---
title: "3.3.3 Riesgo de divisa y ejecución FX: OANDA, IBKR, MT5"
description: "Riesgo de divisa de una cartera multi-moneda, cobertura con forwards y futuros, su coste y sus flujos, y órdenes de divisas por OANDA, IBKR y MT5."
url: "https://pythonparatrading.com/curso-python-trading/nivel-3/riesgo-de-divisa-y-ejecucion-fx/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-3/riesgo-de-divisa-y-ejecucion-fx.png"
---

# 3.3.3 · Riesgo de divisa en carteras multi-moneda; ejecución vía OANDA, IBKR o MT5

> Una cartera en evros con acciones en dólares tiene dos riesgos: el de las acciones y el del dólar. Esta lección los separa, mide cuánto quita una cobertura con forwards a un mes, cuánto cuesta y qué caja exige cada mes, y reparte el riesgo de una cartera con cinco activos en cuatro divisas. Después manda las órdenes: el código real de OANDA v20, Interactive Brokers (`ib_async`) y MetaTrader 5, y un servidor local que imita a OANDA para ejecutar de verdad límites de precio, límites de ritmo y reintentos sin órdenes duplicadas.

- Curso: [Nivel 3: Derivados: futuros, spreads, forex y opciones](https://pythonparatrading.com/curso-python-trading/nivel-3/)
- Módulo: 3.3 · Forex avanzado
- Duración: 75 min
- Requisitos: [1.2.4 · Forex: pares, pips, lotes, rollover y CFD](https://pythonparatrading.com/curso-python-trading/nivel-1/forex-pares-pips-lotes-y-rollover/), [1.3.6 · Brókeres como fuente de datos](https://pythonparatrading.com/curso-python-trading/nivel-1/brokers-como-fuente-de-datos/), [1.4.1 · Descarga por REST y websockets: paginación, límites y reintentos](https://pythonparatrading.com/curso-python-trading/nivel-1/descarga-rest-websockets-y-reintentos/), [3.3.1 · Microestructura FX: OTC, ECN, last look y sesiones](https://pythonparatrading.com/curso-python-trading/nivel-3/microestructura-fx-otc-ecn-y-last-look/), [3.3.2 · Carry y riesgo de crash, momentum y value en divisas; cestas](https://pythonparatrading.com/curso-python-trading/nivel-3/carry-momentum-y-value-en-divisas/)

## Al terminar esta lección

- Descomponer la rentabilidad de un activo extranjero en rentabilidad local, rentabilidad de la divisa y término cruzado, y medir la parte del riesgo que es divisa.
- Cubrir el riesgo de divisa con forwards a un mes: ratio de cobertura, ratio de mínima varianza, coste por el diferencial de tipos y flujos de caja de la renovación.
- Repartir el riesgo de una cartera multi-moneda entre activos y divisas y decidir qué cubrir; calcular la cobertura con futuros y su exposición residual.
- Traducir una exposición en la orden de cada API (unidades con signo en OANDA, cantidad y acción en IBKR, lotes en MT5) y leer sus respuestas y códigos de error.
- Escribir un cliente REST de órdenes con límite de precio, reintentos ante el límite de ritmo y consulta por identificador propio antes de reenviar, y probarlo contra un servidor local.

## Dos riesgos en una posición

Un inversor cuya divisa es el evro (`QEV`, el euro ficticio del curso) compra un índice de acciones de EE. UU. Lo que
gana en evros depende de dos cosas: lo que hace el índice en dólares y lo que hace el dólar frente al evro. Si el
índice sube un 10 % y el dólar cae un 10 %, en evros casi no gana nada:

$$
1 + r_{\text{QEV}} = (1 + r_{\text{local}})\,(1 + r_{\text{fx}})
\quad\Longrightarrow\quad
r_{\text{QEV}} = r_{\text{local}} + r_{\text{fx}} + r_{\text{local}}\,r_{\text{fx}}
$$

con $r_{\text{fx}}$ la rentabilidad del dólar medida en evros. El tercer término, el cruzado, es pequeño en un mes y
crece con el horizonte. La varianza es, aproximadamente, la de las dos partes más dos veces su covarianza: si las dos
fuentes están poco correladas, los riesgos se suman casi enteros.

Los datos: `futuros3_indices_contado.csv` tiene los índices ficticios del módulo 3.1 en su divisa (`RVA` imita al S&P
500, `RVB` al Nasdaq-100, `RVE` al Euro Stoxx 50) con su rentabilidad por dividendo, de 2010 a 2025, y
`fx3_diario.parquet` las doce divisas de la lección 3.3.2. Todo es sintético. Rentabilidad total mensual: la variación
del índice más un doceavo del dividendo.

```python
import numpy as np
import pandas as pd

ix = pd.read_csv("futuros3_indices_contado.csv", parse_dates=["fecha"])
P = ix.pivot(index="fecha", columns="indice", values="contado")
q = ix.pivot(index="fecha", columns="indice",
             values="rentabilidad_dividendo")
fin = P.index.to_series().groupby(P.index.to_period("M")).max()
R = (P.loc[fin].pct_change() + q.loc[fin].shift(1) / 1200).iloc[1:]

fx = pd.read_parquet("fx3_diario.parquet")
def mensual(col):
    t = fx.pivot(index="fecha", columns="divisa", values=col)
    return t.reindex(P.index).ffill().loc[fin]

X = mensual("usd_por_unidad")             # USD por unidad de divisa
r_usd = (X["QEV"].shift(1) / X["QEV"] - 1).iloc[1:]  # USD en QEV
r_qev = (1 + R["RVA"]) * (1 + r_usd) - 1

def vol(x):
    return 100 * x.std() * np.sqrt(12)

pd.Series({"vol_local": vol(R["RVA"]), "vol_dolar": vol(r_usd),
           "correlacion": R["RVA"].corr(r_usd),
           "vol_en_qev": vol(r_qev)}).round(2)
```
```output
vol_local      22.14
vol_dolar      10.15
correlacion     0.07
vol_en_qev     24.90
dtype: float64
```

El índice tiene una volatilidad local del 22,1 % y el dólar, medido en evros, del 10,2 %. Con una correlación de solo
0,07, en evros la posición tiene una volatilidad del 24,9 %: casi tres puntos más. El signo de la correlación importa.
Si el dólar subiera cuando caen las acciones (como hace un refugio), el riesgo de divisa amortiguaría las caídas; aquí
casi no se mueven juntos.

## Cubrir con un forward a un mes

La cobertura clásica es vender a plazo los dólares que se tienen. Cada fin de mes se vende a un mes, al forward, el
valor en dólares de la posición; al mes siguiente se liquida el contrato y se abre otro. Por cada evro invertido, con
$X$ en USD por evro y $F$ el forward a un mes:

$$
\text{resultado de la cobertura} = \frac{X_{t-1}}{F_{t-1}} - \frac{X_{t-1}}{X_t}
$$

el primer término es lo que se recibe por los dólares vendidos a plazo y el segundo lo que valen al contado. Con un
**ratio de cobertura** $h$ entre 0 y 1, la rentabilidad cubierta es la sin cubrir más $h$ veces ese resultado. La
cobertura no es perfecta: se cubre el valor del principio del mes, no lo que la posición gana o pierde durante el mes.

Para comparar, un segundo activo: un **bono del Tesoro a 10 años** aproximado con la curva de `tipos3_curva.csv`
(un bono a la par con cupón semestral igual al rendimiento del mes anterior, revalorizado con el rendimiento de este
mes; una aproximación, no un índice de bonos).

```python
F = mensual("forward_1m")["QEV"]                  # USD por QEV
cobertura = (X["QEV"].shift(1) / F.shift(1)
             - X["QEV"].shift(1) / X["QEV"]).iloc[1:]

curva = pd.read_csv("tipos3_curva.csv", parse_dates=["fecha"])
y = curva.set_index("fecha")["usd_10a"].loc[fin] / 100

def precio_par(y, cupon, anos):
    """Precio de un bono con cupón semestral, nominal 1."""
    d = (1 + y / 2) ** (-2 * anos)
    return cupon / y * (1 - d) + d

bono = (precio_par(y, y.shift(1), 10 - 1 / 12) - 1
        + y.shift(1) / 12).iloc[1:]
activos = {"acciones": R["RVA"], "bono": bono}
filas = {}
for nombre, r_loc in activos.items():
    sin = (1 + r_loc) * (1 + r_usd) - 1
    for h in (0, 0.25, 0.5, 0.75, 1.0):
        filas[(nombre, h)] = vol(sin + h * cobertura)
    c = np.cov(sin, cobertura)
    filas[(nombre, "h_minvar")] = -c[0, 1] / c[1, 1]
    filas[(nombre, "vol_local")] = vol(r_loc)
pd.Series(filas).unstack(0).round(2)
```
```output
           acciones   bono
0             24.90  14.63
0.25          23.80  12.92
0.5           22.93  11.52
0.75          22.32  10.56
1.0           21.98  10.16
h_minvar       1.16   1.04
vol_local     22.14  10.15
```

Es la figura de la cabecera. En las acciones, cubrir el dólar del todo baja la volatilidad del 24,9 % al 22,0 %: algo
por debajo de la local (22,1 %), porque la correlación es ligeramente positiva. En el bono la cobertura importa mucho
más: del 14,6 % al 10,2 %, la volatilidad local. La razón es de proporciones: el dólar tiene una volatilidad del 10 %,
la misma que el bono y menos de la mitad que las acciones. En renta fija extranjera la divisa es casi la mitad del
riesgo; en acciones se diluye.

El **ratio de mínima varianza** (la pendiente de la rentabilidad sin cubrir sobre el resultado de la cobertura) sale
1,16 en las acciones y 1,04 en el bono: algo más de una cobertura completa, por la correlación positiva entre el activo
y el dólar. Con otra muestra podría salir 0,8. Cubrir más del 100 % es apostar contra el dólar; casi todas las
políticas de inversión fijan un ratio (0, 50 % o 100 %) por razones de gobierno, no de estimación.

### Lo que cuesta: el diferencial de tipos

Por la paridad cubierta (lección 3.3.2), el forward lleva dentro el diferencial de tipos. Quien vende dólares a plazo
cuando el dólar paga más interés que el evro recibe menos evros que al contado: paga el diferencial. El coste de cada
mes es $X_t / F_t - 1$, conocido al abrir la cobertura:

```python
coste_mes = (X["QEV"] / F - 1).iloc[:-1]       # conocido al cubrir
por_ano = 100 * coste_mes.groupby(coste_mes.index.year).sum()
# negativo = la cobertura cuesta; positivo = rinde
print(por_ano.round(2).to_frame("carry_cob_pct").T.to_string())
print()
peor = cobertura.idxmin()
print(f"peor liquidación mensual: {100 * cobertura.min():.2f} % "
      f"({peor:%Y-%m})")
doce = cobertura.rolling(12).sum()
print(f"peor suma de 12 meses: {100 * doce.min():.2f} % "
      f"(hasta {doce.idxmin():%Y-%m})")
```
```output
fecha          2010  2011  2012  2013  2014  2015  2016  2017  2018  2019  2020  2021  2022  2023  2024  2025
carry_cob_pct  0.32  0.56  0.18  0.06 -0.01 -0.27 -0.76 -1.37 -2.18 -2.54 -0.63 -0.48 -1.48 -1.59 -1.19 -1.66

peor liquidación mensual: -7.06 % (2021-01)
peor suma de 12 meses: -24.64 % (hasta 2021-11)
```

*Fig. — Resultado anual de cubrir el dólar con forwards a un mes para un inversor en evros: el diferencial de tipos implícito en el forward.* Barras por año de 2010 a 2025. De 2010 a 2013 la cobertura rinde algo (de +0,06 % a +0,56 %); en 2014 es nula y desde 2015 cuesta cada año: −0,27 % en 2015, −1,37 % en 2017, −2,18 % en 2018, −2,54 % en 2019 (destacado), −0,63 % en 2020, −1,48 % en 2022, −1,59 % en 2023 y −1,66 % en 2025.

De 2010 a 2014 los tipos del evro y del dólar eran parecidos y la cobertura casi no costaba (o rendía algo). Desde
2015, con el dólar pagando más, cuesta cada año: un 2,54 % en 2019, un 1,59 % en 2023, un 1,66 % en 2025 (en la tabla, con signo negativo). Frente a la volatilidad del 22 % de las acciones
es poco; para un bono, cuyo rendimiento es de pocos puntos, puede ser una parte grande de lo que rinde. La cobertura también la cambia la base de la paridad cubierta: con la base de −15 pb que tiene el evro en
los datos, conseguir dólares a plazo es algo más caro de lo que dicen los depósitos (lección 3.3.2).

La segunda cifra es la que olvidan los backtests: los **flujos de caja**. Cada mes el forward se liquida. Si el dólar
se aprecia, la cobertura pierde, y esa pérdida hay que pagarla en efectivo ese mes, aunque la ganancia equivalente de
las acciones en dólares no se haya realizado. En el peor mes de los datos la liquidación es del −7,1 % de la cartera,
y en los peores doce meses seguidos (hasta noviembre de 2021), del −24,6 %. Una cartera que no tenga ese efectivo tiene que vender activos para pagar la
cobertura, a menudo en el peor momento.

## Una cartera en cuatro divisas

Un caso más realista: una cartera en evros con acciones de EE. UU. (`RVA` 35 %, `RVB` 15 %), acciones de la zona del
evro (`RVE` 30 %) y liquidez en libras (`QLB`, 10 %) y en yenes (`QYN`, 10 %). La liquidez rinde el depósito a un mes
de su divisa. La exposición a divisas es: dólar 50 %, libra 10 %, yen 10 %; el 30 % restante ya está en evros.

El riesgo se reparte con la descomposición habitual (lección 2.6.1): la contribución de cada fuente a la varianza
total es su peso por su covarianza con la cartera, $c_k = w_k\,\text{cov}(r_k, r_p)$, y las contribuciones suman la
varianza. Aquí las fuentes son las rentabilidades locales de los activos y las de cada divisa frente al evro,
ponderadas por su exposición.

```python
dep = mensual("tipo_deposito_1m")
en_qev = X.div(X["QEV"], axis=0)            # QEV por unidad
fx_qev = (en_qev / en_qev.shift(1) - 1).iloc[1:]
fx_qev["USD"] = r_usd
fuentes = pd.DataFrame({
    "RVA": R["RVA"], "RVB": R["RVB"], "RVE": R["RVE"],
    "dep_QLB": dep["QLB"].shift(1).iloc[1:] / 1200,
    "dep_QYN": dep["QYN"].shift(1).iloc[1:] / 1200,
    "fx_USD": fx_qev["USD"], "fx_QLB": fx_qev["QLB"],
    "fx_QYN": fx_qev["QYN"]}).dropna()
w = pd.Series({"RVA": .35, "RVB": .15, "RVE": .30, "dep_QLB": .10,
               "dep_QYN": .10, "fx_USD": .50, "fx_QLB": .10,
               "fx_QYN": .10})
cartera = fuentes @ w
contrib = w * fuentes.apply(lambda c: c.cov(cartera))
pct = 100 * contrib / cartera.var()
print(f"volatilidad de la cartera: {vol(cartera):.2f} %")
pct.round(1).to_frame("riesgo_pct").T
```
```output
volatilidad de la cartera: 19.71 %
             RVA   RVB   RVE  dep_QLB  dep_QYN  fx_USD  fx_QLB  fx_QYN
riesgo_pct  35.9  19.5  31.8      0.0     -0.0    10.5     1.2     1.1
```

*Fig. — Contribución de cada activo y de cada divisa a la varianza de una cartera en evros con acciones de EE. UU. y de la zona del evro y liquidez en libras y yenes.* Barras horizontales: RVA 35,9 %, RVE 31,8 %, RVB 19,5 %, dólar 10,5 % (destacado), libra 1,2 %, yen 1,1 % y depósitos 0,0 %. Las acciones se llevan el 87 % del riesgo; las divisas, el 13 %, casi todo el dólar.

La cartera tiene una volatilidad del 19,7 % en evros. Las acciones se llevan el 87 % de la varianza: `RVA` el 35,9 %,
`RVE` el 31,8 %, `RVB` el 19,5 %. El dólar aporta un 10,5 % y la libra y el yen, un 1,2 % y un 1,1 %; los depósitos,
nada (su rentabilidad apenas varía). La descomposición es lineal: suma las rentabilidades locales y las de divisa
ponderadas, sin el término cruzado, que en un mes es pequeño. El yen es en el modelo de los datos una divisa refugio,
que se aprecia en las crisis, pero con un 10 % de peso su efecto no compensa su propio ruido: aporta riesgo, aunque
poco. ¿Qué cubrir?

```python
Fu = mensual("forward_1m")
par_usd = fx.groupby("divisa")["par"].first().str.startswith("USD")
Fu.loc[:, par_usd] = 1 / Fu.loc[:, par_usd]     # USD por unidad
f_qev = Fu.div(Fu["QEV"], axis=0)               # QEV por unidad
f_qev["USD"] = 1 / Fu["QEV"]
s_qev = en_qev.copy()
s_qev["USD"] = 1 / X["QEV"]
# vender a plazo 1 QEV de exposición a cada divisa, por QEV
cob = ((f_qev.shift(1) - s_qev) / s_qev.shift(1)).iloc[1:]
cob = cob[["USD", "QLB", "QYN"]].loc[fuentes.index]
expo = {"USD": .50, "QLB": .10, "QYN": .10}
casos = {"sin cubrir": [], "solo USD": ["USD"],
         "USD y QLB": ["USD", "QLB"], "todo": ["USD", "QLB", "QYN"]}
res = {}
for nom, cubre in casos.items():
    r_c = cartera + sum(expo[c] * cob[c] for c in cubre)
    res[nom] = {"vol_pct": vol(r_c), "media_pct": 1200 * r_c.mean(),
                "peor_mes_pct": 100 * r_c.min()}
pd.DataFrame(res).T.round(2)
```
```output
            vol_pct  media_pct  peor_mes_pct
sin cubrir    19.71      11.59        -25.28
solo USD      18.23      10.94        -24.60
USD y QLB     18.16      10.90        -24.81
todo          18.16      10.94        -24.77
```

Cubrir el dólar baja la volatilidad del 19,71 % al 18,23 % y cuesta 0,65 puntos de rentabilidad media al año (de
11,59 % a 10,94 %): es el diferencial de tipos de la sección anterior. Cubrir también la libra la baja a 18,16 %, y
añadir el yen no la cambia. En la rentabilidad, cubrir la libra cuesta 0,04 puntos y cubrir el yen, una divisa de tipo
más bajo que el evro, los devuelve: el inversor cobra el diferencial. El peor mes casi no cambia (de −25,3 % a −24,6 %)
porque lo dominan las acciones. Para esta cartera, la decisión que importa es la del dólar; la de la libra y el yen es
casi indiferente en riesgo. Es una decisión por divisa y con datos, no una regla general: con más peso en renta fija
extranjera, la cobertura importaría mucho más (sección anterior).

## Cubrir con futuros

En lugar de forwards se pueden usar futuros de divisa en un mercado organizado. `DQE`, en los datos, imita al futuro
Euro FX de CME: cotiza en USD por evro, con un multiplicador de 125.000 evros por contrato (las especificaciones del
contrato real, en la lección 3.1.1). Un inversor en evros con activos en dólares está largo de dólar y corto de evro:
para cubrirse **compra** futuros de evro. El número de contratos es la exposición en evros dividida por el nominal en
evros de un contrato:

```python
esp = pd.read_csv("futuros3_especificaciones.csv").set_index("mercado")
curvas = pd.read_parquet("futuros3_curvas.parquet",
                         filters=[("mercado", "==", "DQE")])
venc = pd.read_csv("futuros3_contratos.csv",
                   parse_dates=["vencimiento"])
dia = curvas[curvas["fecha"] == curvas["fecha"].max()].merge(
    venc[["contrato", "vencimiento"]], on="contrato")
front = dia.sort_values("vencimiento").iloc[0]  # el más cercano
mult = esp.loc["DQE", "multiplicador"]

capital_qev = 10_000_000
expo_usd = 0.50 * capital_qev * X["QEV"].iloc[-1]    # en USD
n_exacto = expo_usd / front["liquidacion"] / mult
n = round(n_exacto)
residuo_usd = expo_usd - n * mult * front["liquidacion"]
print(f"{front['contrato']}: {front['liquidacion']:.5f} USD por QEV")
print(f"exposición: {expo_usd:,.0f} USD; contratos: {n_exacto:.2f}"
      f" -> {n}")
print(f"residuo sin cubrir: {residuo_usd:,.0f} USD")
print(f"margen inicial: {n * esp.loc['DQE', 'margen_inicial']:,.0f}"
      " USD")
```
```output
DQEH26: 1.38165 USD por QEV
exposición: 6,884,270 USD; contratos: 39.86 -> 40
residuo sin cubrir: -23,980 USD
margen inicial: 136,000 USD
```

La exposición en dólares, 6.884.270 USD, pide 39,86 contratos de `DQEH26` a 1,38165; con 40 se cubren 23.980 USD de
más: solo se compran contratos enteros, y la exposición cambia cada día con el valor de las acciones. El margen inicial,
136.000 USD, se deposita en dólares. Tres diferencias más con el forward:

- **El margen y las liquidaciones diarias son en dólares** (lección 3.1.3). La cobertura del dólar necesita una cuenta
  en dólares con efectivo para las liquidaciones, que a su vez tiene riesgo de divisa, pequeño.
- **El futuro hay que renovarlo** cada trimestre, con el coste del roll (lección 3.1.2); su base incorpora el mismo
  diferencial de tipos que el forward.
- **La contraparte es la cámara**, no un banco: sin acuerdos bilaterales de crédito, a cambio de márgenes diarios.

Un futuro cubre el **nominal** de una exposición; sus propias ganancias y pérdidas, no. Por eso un futuro sobre un
índice extranjero (un E-mini comprado desde Europa) solo tiene riesgo de divisa en el margen y en el resultado
acumulado, no en todo el nominal: el nominal no se ha pagado.

## De la exposición a la orden: tres APIs

La cobertura y las posiciones en divisas se ejecutan con un bróker. Los tres más usados desde Python por particulares
y profesionales pequeños tienen arquitecturas distintas (lección 1.3.6): OANDA es una API REST directa; IBKR pasa por
TWS o IB Gateway, un programa local que mantiene la sesión; MetaTrader 5 pasa por un terminal instalado en Windows,
que es el que habla con el servidor del bróker.

```mermaid
flowchart LR
  S[Sistema] --> O[REST OANDA v20]
  S --> I[ib_async]
  I --> G[TWS o Gateway]
  S --> M[MetaTrader5]
  M --> T[Terminal MT5]
```

Cada una mide la orden de una forma, y confundirlas es el error más caro y más tonto que se puede cometer:

| API | Cómo se pide una compra de 1 M de EURUSD | Unidad |
|---|---|---|
| OANDA v20 | `"instrument": "EUR_USD"`, `"units": "1000000"` (una venta, `"-1000000"`) | Unidades de la divisa base, con signo |
| IBKR (`ib_async`) | `Forex("EURUSD")`, `MarketOrder("BUY", 1_000_000)` | Cantidad de la divisa base y acción `BUY`/`SELL` |
| MetaTrader 5 | `"symbol": "EURUSD"`, `"volume": 10.0`, `"type": ORDER_TYPE_BUY` | Lotes; el tamaño del lote lo da `symbol_info().trade_contract_size` |

Las tres cosas que hay que saber antes de enviar: qué divisa es la base del par (una exposición en dólares contra
evros es una venta de `EUR_USD`, no una compra de `USD_EUR`, que no existe), el tamaño mínimo y el paso (`volume_min` y
`volume_step` en MT5) y la precisión de la cantidad (OANDA rechaza unidades con más decimales de los permitidos, con
`UNITS_PRECISION_EXCEEDED`). Una función que lo traduce:

```python
def orden_cobertura(expo_usd, precio, tam_lote=100_000, paso=0.01):
    """Cubrir expo_usd (largo en USD) comprando EURUSD: la base es
    el evro, así que la cantidad se expresa en evros."""
    evros = expo_usd / precio
    lotes = round(round(evros / tam_lote / paso) * paso, 2)
    return {
        "oanda": {"instrument": "EUR_USD", "units": str(round(evros))},
        "ibkr": ("BUY", round(evros)),
        "mt5": {"symbol": "EURUSD", "volume": lotes,
                "lotes_en_evros": round(lotes * tam_lote)},
    }

precio = 1.37692                       # ask al enviar la orden
for k, v in orden_cobertura(expo_usd, precio).items():
    print(f"{k:6s} {v}")
```
```output
oanda  {'instrument': 'EUR_USD', 'units': '4999760'}
ibkr   ('BUY', 4999760)
mt5    {'symbol': 'EURUSD', 'volume': 50.0, 'lotes_en_evros': 5000000}
```

Con la misma exposición y un precio de 1,37692 al enviar (el EURUSD hace aquí el papel del QEVUSD del curso), OANDA
pide 4.999.760 unidades, IBKR la misma cantidad y MT5 50,00 lotes, que son 5.000.000 de evros: el redondeo al paso de
0,01 lotes compra 240 evros de más. Un lote de MT5 son 100.000 unidades en casi
todos los brókeres de divisas (el ejemplo de la documentación de `symbol_info` muestra `trade_contract_size=100000.0`,
`volume_min=0.01` y `volume_step=0.01`), pero lo fija el bróker: hay que leerlo, no suponerlo.

## OANDA v20: órdenes por REST

Una orden de mercado en OANDA es un `POST /v3/accounts/{accountID}/orders` con un cuerpo JSON con un campo `order`.
Según la documentación de la v20[^1]:

- `units` positivas compran y negativas venden; `timeInForce` de una orden de mercado solo puede ser `FOK` (todo o
  nada, el valor por defecto) o `IOC`.
- `priceBound` es el peor precio al que el cliente acepta la ejecución. Si el precio de ejecución lo supera, la orden se
  cancela con la razón `BOUNDS_VIOLATION`.
- `clientExtensions` lleva un `id`, una `tag` y un `comment` del cliente. El `id` permite consultar la orden después
  con `GET /v3/accounts/{accountID}/orders/@{id}`.
- La respuesta 201 trae `orderCreateTransaction` y, si la orden se ejecutó, `orderFillTransaction` (con `fullVWAP`,
  `tradeOpened`, `halfSpreadCost`…) o, si se canceló al momento, `orderCancelTransaction` con su `reason`. Los precios y
  las cantidades llegan como texto.

```python skip title="oanda_orden.py"
import json
import os
from urllib.request import Request, urlopen

BASE = "https://api-fxpractice.oanda.com"      # cuenta de práctica
cuenta = os.environ["OANDA_ACCOUNT"]
orden = {"order": {
    "type": "MARKET", "instrument": "EUR_USD", "units": "4999760",
    "timeInForce": "FOK", "positionFill": "DEFAULT",
    "priceBound": "1.37740",                   # peor precio aceptado
    "clientExtensions": {"id": "cob-2025-12-31-usd",
                         "tag": "cobertura"}}}
req = Request(f"{BASE}/v3/accounts/{cuenta}/orders",
              data=json.dumps(orden).encode(), method="POST",
              headers={"Authorization":
                       f"Bearer {os.environ['OANDA_TOKEN']}",
                       "Content-Type": "application/json"})
with urlopen(req, timeout=5) as r:
    respuesta = json.load(r)
fill = respuesta.get("orderFillTransaction")
cancel = respuesta.get("orderCancelTransaction")
```

### Un servidor local que imita a OANDA

Para ejecutar de verdad el cliente, con sus reintentos y sus errores, sin cuenta ni red, la celda siguiente levanta un
servidor HTTP local en un hilo (puerto libre elegido por el sistema) que imita la forma de esas respuestas. No es OANDA:
es un banco de pruebas con tres comportamientos programados. La segunda petición que recibe devuelve un 429 (límite de
ritmo), una orden marcada como `lenta` se registra y se ejecuta, pero su respuesta tarda más que el tiempo de espera del
cliente, y el precio de cada orden sale de una lista fija, para que una de ellas supere su `priceBound`.

```python
import json
import socket
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.error import HTTPError
from urllib.request import Request, urlopen

class Simulador:
    def __init__(self, precios):
        self.precios = list(precios)       # precio de cada ejecución
        self.ordenes = {}                  # id del cliente -> orden
        self.peticiones = 0
        self.lock = threading.Lock()

class Manejador(BaseHTTPRequestHandler):
    def log_message(self, *args):          # sin registro en stderr
        pass

    def _json(self, codigo, cuerpo):
        datos = json.dumps(cuerpo).encode()
        self.send_response(codigo)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(datos)))
        self.end_headers()
        self.wfile.write(datos)

    def do_POST(self):
        sim = self.server.sim
        if self.headers.get("Authorization") != "Bearer prueba":
            return self._json(401, {"errorMessage": "Insufficient "
                                    "authorization"})
        largo = int(self.headers["Content-Length"])
        orden = json.loads(self.rfile.read(largo))["order"]
        with sim.lock:
            sim.peticiones += 1
            if sim.peticiones == 2:
                return self._json(429, {"errorMessage":
                                        "Too many requests"})
            oid = str(100 + len(sim.ordenes))
            precio = sim.precios.pop(0)
            unidades = float(orden["units"])
            limite = float(orden.get("priceBound", "nan"))
            fuera = (precio > limite if unidades > 0
                     else precio < limite)
            ext = orden.get("clientExtensions", {})
            sim.ordenes[ext["id"]] = {
                "id": oid, "state": "CANCELLED" if fuera else "FILLED",
                "clientExtensions": ext}
        cuerpo = {"orderCreateTransaction": {
            "id": oid, "type": "MARKET_ORDER",
            "instrument": orden["instrument"], "units": orden["units"]}}
        if fuera:
            cuerpo["orderCancelTransaction"] = {
                "type": "ORDER_CANCEL", "orderID": oid,
                "reason": "BOUNDS_VIOLATION"}
        else:
            cuerpo["orderFillTransaction"] = {
                "type": "ORDER_FILL", "orderID": oid,
                "units": orden["units"], "fullVWAP": f"{precio:.5f}",
                "tradeOpened": {"tradeID": str(int(oid) + 1),
                                "units": orden["units"],
                                "price": f"{precio:.5f}"}}
        if ext.get("tag") == "lenta":
            time.sleep(0.6)                # respuesta que se pierde
        self._json(201, cuerpo)

    def do_GET(self):
        sim = self.server.sim
        cid = self.path.rsplit("/@", 1)[-1]
        with sim.lock:
            orden = sim.ordenes.get(cid)
        if orden is None:
            return self._json(404, {"errorMessage": "Order not found"})
        self._json(200, {"order": orden})

class Servidor(ThreadingHTTPServer):
    def handle_error(self, request, client_address):
        pass                    # el cliente que ya no espera: sin traza

servidor = Servidor(("127.0.0.1", 0), Manejador)
servidor.sim = Simulador([1.37705, 1.37781, 1.37698, 1.37701])
threading.Thread(target=servidor.serve_forever, daemon=True).start()
BASE = f"http://127.0.0.1:{servidor.server_address[1]}"
print("simulador en marcha:", BASE.startswith("http://127.0.0.1:"))
```
```output
simulador en marcha: True
```

El cliente hace tres cosas que un cliente de órdenes debe hacer siempre: ante un 429, espera un tiempo creciente y
reintenta; si la conexión expira sin respuesta, **no reenvía a ciegas**, sino que consulta la orden por su
identificador propio, porque pudo llegar y ejecutarse; y devuelve la respuesta tal cual, para que el sistema decida
qué hacer con una cancelación.

```python
CABECERAS = {"Authorization": "Bearer prueba",
             "Content-Type": "application/json"}

def consultar(cuenta, cid):
    url = f"{BASE}/v3/accounts/{cuenta}/orders/@{cid}"
    try:
        with urlopen(Request(url, headers=CABECERAS), timeout=1) as r:
            return json.load(r)["order"]
    except HTTPError as e:
        if e.code == 404:
            return None
        raise

def enviar(cuenta, orden, intentos=4, espera=0.05, timeout=0.3):
    url = f"{BASE}/v3/accounts/{cuenta}/orders"
    datos = json.dumps({"order": orden}).encode()
    cid = orden["clientExtensions"]["id"]
    for k in range(intentos):
        req = Request(url, data=datos, method="POST",
                      headers=CABECERAS)
        try:
            with urlopen(req, timeout=timeout) as r:
                return json.load(r)
        except HTTPError as e:
            if e.code != 429:
                raise
            print(f"  {cid}: 429, reintento tras {espera * 2**k:.2f} s")
            time.sleep(espera * 2**k)
        except (TimeoutError, socket.timeout):
            previa = consultar(cuenta, cid)
            print(f"  {cid}: sin respuesta; consulta: "
                  f"{previa['state'] if previa else 'no existe'}")
            if previa is not None:
                return {"consultada": previa}
    raise RuntimeError(f"{cid}: sin respuesta tras {intentos} intentos")

def orden_mercado(cid, unidades, limite, tag="cobertura"):
    return {"type": "MARKET", "instrument": "QEV_USD",
            "units": str(unidades), "timeInForce": "FOK",
            "priceBound": f"{limite:.5f}",
            "clientExtensions": {"id": cid, "tag": tag}}

def resumen_respuesta(resp):
    if "consultada" in resp:
        return f"recuperada por consulta: {resp['consultada']['state']}"
    if "orderFillTransaction" in resp:
        t = resp["orderFillTransaction"]
        return f"ejecutada {t['units']} a {t['fullVWAP']}"
    return "cancelada: " + resp["orderCancelTransaction"]["reason"]

pruebas = [("cob-1", 2_000_000, 1.37740, "cobertura"),
           ("cob-2", 1_000_000, 1.37740, "cobertura"),
           ("cob-3", 1_000_000, 1.37740, "lenta")]
for cid, u, lim, tag in pruebas:
    resp = enviar("001-001-1", orden_mercado(cid, u, lim, tag))
    print(cid, "->", resumen_respuesta(resp))
```
```output
cob-1 -> ejecutada 2000000 a 1.37705
  cob-2: 429, reintento tras 0.05 s
cob-2 -> cancelada: BOUNDS_VIOLATION
  cob-3: sin respuesta; consulta: FILLED
cob-3 -> recuperada por consulta: FILLED
```

La primera orden se ejecuta a 1,37705, dentro de su límite de 1,37740. La segunda se encuentra con el 429, espera
0,05 segundos y reintenta; en ese reintento el precio ha saltado a 1,37781, por encima del límite, y el simulador la
cancela con `BOUNDS_VIOLATION`, como haría OANDA. El cliente no la reenvía por su cuenta: una cancelación por precio
es una decisión del sistema (volver a pedir precio, ampliar el límite o esperar), igual que un rechazo de last look en
la lección 3.3.1. La tercera se ejecuta en el servidor, pero su respuesta tarda más que el tiempo de espera del
cliente; el cliente consulta `@cob-3`, ve que está `FILLED` y no la duplica. Sin esa consulta, el reintento habría
abierto una segunda posición de un millón.

```mermaid
stateDiagram-v2
  [*] --> Enviada
  Enviada --> Ejecutada: 201 con fill
  Enviada --> Cancelada: 201 con cancel
  Enviada --> Espera: 429
  Espera --> Enviada: reintento
  Enviada --> Consulta: sin respuesta
  Consulta --> Ejecutada: existe
  Consulta --> Enviada: no existe
  Ejecutada --> [*]
  Cancelada --> [*]
```

```python
servidor.shutdown()
servidor.server_close()
for cid, o in servidor.sim.ordenes.items():
    print(cid, o["id"], o["state"])
print("peticiones POST recibidas:", servidor.sim.peticiones)
```
```output
cob-1 100 FILLED
cob-2 101 CANCELLED
cob-3 102 FILLED
peticiones POST recibidas: 4
```

Tres órdenes registradas y cuatro peticiones recibidas: la del 429 no creó ninguna orden. Es la contabilidad que un
sistema tiene que poder cuadrar con la del bróker al final del día: órdenes enviadas, aceptadas, canceladas y
ejecutadas, cada una con su identificador.

## Interactive Brokers con `ib_async`

En IBKR la orden la manda TWS o IB Gateway (lección 1.3.6 para la conexión y los puertos). Según la documentación de
`ib_async`[^2], `Forex(pair, exchange='IDEALPRO')` define el par (base y cotizada a partir de `"EURUSD"`),
`MarketOrder(action, totalQuantity)` y `LimitOrder(action, totalQuantity, lmtPrice)` las órdenes, `whatIfOrder`
devuelve el efecto en comisiones y margen sin enviarla, y `placeOrder` devuelve un `Trade` que se actualiza solo con
el estado (`trade.orderStatus.status`), las ejecuciones (`trade.fills`) y un registro; `trade.isDone()` dice si ha
llegado a un estado final (`Filled`, `Cancelled`, `ApiCancelled` o `Inactive`). El campo `orderRef` de la orden es el
identificador propio, que vuelve en las ejecuciones.

```python skip title="ibkr_orden_fx.py"
from ib_async import IB, Forex, LimitOrder     # uv add ib_async

ib = IB()
ib.connect("127.0.0.1", 7497, clientId=7)      # TWS, cuenta de papel
par = Forex("EURUSD")
ib.qualifyContracts(par)

orden = LimitOrder("BUY", 4_999_760, 1.37740)
orden.orderRef = "cob-2025-12-31-usd"          # identificador propio
orden.tif = "IOC"
impacto = ib.whatIfOrder(par, orden)           # margen y comisión
print(impacto.initMarginChange, impacto.commission)

trade = ib.placeOrder(par, orden)
while not trade.isDone():
    ib.sleep(0.2)                              # deja correr el bucle
print(trade.orderStatus.status, trade.orderStatus.filled,
      trade.orderStatus.avgFillPrice)
for f in trade.fills:
    print(f.execution.execId, f.execution.shares, f.execution.price)
ib.disconnect()
```

En IBKR una orden limitada IOC hace el papel del `priceBound` de OANDA: ejecuta lo que pueda hasta el precio límite y
cancela el resto. Si el programa pierde la conexión con TWS después de enviar, al reconectar `ib.openTrades()` y
`ib.reqExecutions()` dicen qué pasó, y el `orderRef` permite reconocer las órdenes propias antes de reenviar nada.

## MetaTrader 5: `order_send` y sus códigos de retorno

En MT5 la orden es un diccionario con la estructura `MqlTradeRequest`, y la respuesta un `OrderSendResult` con un
`retcode`. Según la documentación de MQL5[^3]: `action` es el tipo de operación (`TRADE_ACTION_DEAL` para una orden de
mercado), `volume` va en lotes, `price` es el precio pedido, `deviation` la desviación máxima aceptada respecto a él
**en puntos**, `type_filling` la política de ejecución (`ORDER_FILLING_FOK`, `ORDER_FILLING_IOC` o
`ORDER_FILLING_RETURN`, según lo que admita el símbolo) y `magic` un número que identifica al sistema que envía.

```python skip title="mt5_orden_fx.py"
import MetaTrader5 as mt5                      # solo Windows

if not mt5.initialize():
    raise RuntimeError(mt5.last_error())
info = mt5.symbol_info("EURUSD")
lotes = round(4_999_760 / info.trade_contract_size
              / info.volume_step) * info.volume_step
tick = mt5.symbol_info_tick("EURUSD")
peticion = {
    "action": mt5.TRADE_ACTION_DEAL, "symbol": "EURUSD",
    "volume": lotes, "type": mt5.ORDER_TYPE_BUY, "price": tick.ask,
    "deviation": 5,                            # puntos
    "magic": 20251231, "comment": "cob-usd",
    "type_time": mt5.ORDER_TIME_GTC,
    "type_filling": mt5.ORDER_FILLING_IOC,
}
resultado = mt5.order_send(peticion)
print(resultado.retcode, resultado.deal, resultado.price)
mt5.shutdown()
```

Los códigos de retorno son los que decide el sistema. Una función que los clasifica, con los valores de la tabla de
MQL5, se puede probar sin terminal con resultados simulados:

```python
from collections import namedtuple

RETCODE = {10009: ("DONE", "hecho"),
           10010: ("DONE_PARTIAL", "parcial: enviar el resto"),
           10004: ("REQUOTE", "precio nuevo y reenviar si cabe"),
           10020: ("PRICE_CHANGED", "precio nuevo y reenviar si cabe"),
           10021: ("PRICE_OFF", "sin precios: esperar"),
           10024: ("TOO_MANY_REQUESTS", "esperar y reintentar"),
           10018: ("MARKET_CLOSED", "esperar a la sesión"),
           10019: ("NO_MONEY", "no reintentar: revisar margen"),
           10014: ("INVALID_VOLUME", "no reintentar: revisar lotes"),
           10006: ("REJECT", "no reintentar sin revisar")}

Resultado = namedtuple("Resultado", "retcode volume price")
simulados = [Resultado(10004, 0.0, 0.0),
             Resultado(10009, 50.0, 1.37694),
             Resultado(10014, 0.0, 0.0)]
for res in simulados:
    nombre, accion = RETCODE.get(res.retcode, ("?", "revisar"))
    print(f"{res.retcode} TRADE_RETCODE_{nombre:<13s} -> {accion}")
```
```output
10004 TRADE_RETCODE_REQUOTE       -> precio nuevo y reenviar si cabe
10009 TRADE_RETCODE_DONE          -> hecho
10014 TRADE_RETCODE_INVALID_VOLUME -> no reintentar: revisar lotes
```

La recotización (10004) y el cambio de precio (10020) son la forma que tiene MT5 de decir lo mismo que el last look de
la lección 3.3.1 o el `BOUNDS_VIOLATION` de OANDA: el precio pedido ya no vale. La diferencia entre reintentar o no está
en la tabla: un error de precio o de ritmo se puede reintentar con cuidado; uno de volumen, de margen o un rechazo
genérico, no, porque el reintento dará el mismo error o, peor, otro distinto con la posición a medias.

## Lo que sale mal al ejecutar divisas

- **La base del par.** Cubrir dólares desde el evro es comprar `EUR_USD`. Un signo cambiado dobla la exposición en
  lugar de cubrirla. Conviene comprobar la posición después de cada orden, no solo la respuesta.
- **Lotes y unidades.** 50 lotes de MT5 frente a 4.999.760 unidades de OANDA: un factor de 100.000 entre una y otra.
- **Duplicados.** Toda orden lleva un identificador propio y se consulta antes de reenviar.
- **Rechazos por precio.** Last look, `BOUNDS_VIOLATION`, recotizaciones: una parte de las órdenes no se ejecuta, y son
  las que llegan cuando el precio se mueve.
- **La hora.** El rollover diario a las 17:00 de Nueva York (lección 1.2.4), con horquillas anchas, y el cierre del fin
  de semana, en el que una posición no se puede ajustar. Una cobertura que se renueva a fin de mes debería hacerlo
  lejos de esas horas.
- **Flujos de caja de la cobertura.** El forward o el futuro se liquida en efectivo: hay que tenerlo, o vender activos
  para pagarlo.
- **Límites de ritmo.** OANDA, IBKR y MT5 limitan las peticiones (lección 1.3.6 para las cifras de OANDA e IBKR); un
  bucle de reintentos sin espera creciente acaba bloqueado. El diseño como máquina de estados del research del sitio
  sobre ejecución ayuda a que cada orden esté siempre en un estado conocido.

## Ejercicios

1. Repite la tabla de ratios de cobertura con `RVE` como activo extranjero para un inversor en dólares (es decir,
   mide `RVE` en USD y cubre el evro). ¿Es la cobertura igual de útil que para el inversor en evros con `RVA`?

**Solución.**

```python
r_qev_en_usd = (X["QEV"] / X["QEV"].shift(1) - 1).iloc[1:]
sin_usd = (1 + R["RVE"]) * (1 + r_qev_en_usd) - 1
# vender evros a plazo: recibe F USD por QEV, valen X al contado
cob_usd = ((F.shift(1) - X["QEV"]) / X["QEV"].shift(1)).iloc[1:]
t = {h: vol(sin_usd + h * cob_usd) for h in (0, 0.5, 1.0)}
c = np.cov(sin_usd, cob_usd)
t["h_minvar"] = -c[0, 1] / c[1, 1]
t["vol_local"] = vol(R["RVE"])
pd.Series(t).round(2)
```
```output
0            24.38
0.5          23.25
1.0          23.34
h_minvar      0.71
vol_local    23.19
dtype: float64
```

Para el inversor en dólares, `RVE` tiene una volatilidad local del 23,19 % y sin cubrir del 24,38 %. Cubrir la mitad
la deja en 23,25 % y cubrir del todo, en 23,34 %: un poco **más** que la media cobertura. El ratio de mínima varianza
es 0,71, frente al 1,16 del inversor en evros con `RVA`. El caso no es simétrico: lo que decide es la correlación de
cada índice con la divisa extranjera vista desde cada lado. Un ratio de mínima varianza por debajo de 1 indica que,
en los datos, el evro visto desde el dólar tiende a moverse algo en sentido contrario a `RVE` y amortigua sus
oscilaciones: quitarlo del todo no es lo que más reduce la varianza. Con `RVA` pasa lo contrario (el dólar sube algo
con `RVA`, correlación de 0,07), y el ratio sale por encima de 1. En los dos casos el beneficio de
cubrir es de uno a tres puntos de volatilidad: moderado en acciones.

2. En la cartera de cuatro divisas, calcula la contribución al riesgo cuando se cubren el dólar y la libra (la política
   recomendada). ¿Qué parte del riesgo es ya divisa?

**Solución.**

```python
cubierta = cartera + expo["USD"] * cob["USD"] + expo["QLB"] * cob["QLB"]
f2 = fuentes.assign(cob_USD=cob["USD"], cob_QLB=cob["QLB"])
w2 = pd.concat([w, pd.Series({"cob_USD": .50, "cob_QLB": .10})])
contrib2 = w2 * f2.apply(lambda c: c.cov(cubierta))
pct2 = 100 * contrib2 / cubierta.var()
divisa = pct2[["fx_USD", "fx_QLB", "fx_QYN",
               "cob_USD", "cob_QLB"]].sum()
print(f"volatilidad: {vol(cubierta):.2f} %; divisa: {divisa:.1f} %")
pct2.round(1).to_frame("riesgo_pct").T
```
```output
volatilidad: 18.16 %; divisa: 0.1 %
             RVA   RVB   RVE  dep_QLB  dep_QYN  fx_USD  fx_QLB  fx_QYN  cob_USD  cob_QLB
riesgo_pct  41.5  22.2  36.1      0.0     -0.0     3.7     0.3     0.2     -3.7     -0.3
```

Con el dólar y la libra cubiertos, la volatilidad baja al 18,16 % y cada divisa y su cobertura se compensan (3,7 %
frente a −3,7 % el dólar, 0,3 % frente a −0,3 % la libra): lo que queda de riesgo de divisa es el 0,1 % de la
varianza, el yen sin cubrir más los residuos. El
riesgo de la cartera es ya casi solo el de las acciones: la decisión de cobertura está tomada, y lo que se gestiona a
partir de ahí es la renta variable.

3. En el simulador, el cliente reintenta los 429 con una espera de 0,05 · 2^k segundos. Explica por qué una espera
   creciente es mejor que reintentar enseguida y qué añadirías para que diez procesos que reciben el 429 a la vez no
   vuelvan a chocar en el mismo instante.

**Solución.**

Un 429 dice que el servidor recibe más peticiones de las que admite. Reintentar enseguida añade más peticiones justo
cuando sobran, y prolonga el bloqueo; una espera que se duplica en cada intento (0,05, 0,1, 0,2 s…) reduce la carga
rápido y llega a un ritmo que el servidor acepta. Si diez procesos reciben el 429 a la vez y todos esperan exactamente
lo mismo, vuelven a chocar a la vez: se añade una parte aleatoria a la espera (*jitter*, p. ej. multiplicarla por un
número uniforme entre 0,5 y 1,5) para repartir los reintentos. Y si el servidor devuelve una cabecera que indica
cuándo reintentar, se respeta antes que la fórmula propia. En la lección 1.4.1 está el mismo patrón para descargas de
datos.

[^1]: OANDA, [Order Endpoints](https://developer.oanda.com/rest-live-v20/order-ep/), [Order Definitions](https://developer.oanda.com/rest-live-v20/order-df/) (`MarketOrderRequest`, `LimitOrderRequest`, `OrderState`) y [Transaction Definitions](https://developer.oanda.com/rest-live-v20/transaction-df/) (`OrderFillTransaction`, `OrderCancelTransaction`, `OrderCancelReason`), consultadas el 22/10/2026.
[^2]: [ib_async: API](https://ib-api-reloaded.github.io/ib_async/api.html), versión 2.1.0 en [PyPI](https://pypi.org/project/ib_async/), consultadas el 22/10/2026.
[^3]: MQL5, [order_send](https://www.mql5.com/en/docs/python_metatrader5/mt5ordersend_py), [symbol_info](https://www.mql5.com/en/docs/python_metatrader5/mt5symbolinfo_py), [propiedades de las órdenes](https://www.mql5.com/en/docs/constants/tradingconstants/orderproperties) (`ORDER_FILLING_*`) y [códigos de retorno](https://www.mql5.com/en/docs/constants/errorswarnings/enum_trade_return_codes), consultadas el 22/10/2026.

## Autoevaluación

1. Un inversor en evros tiene un índice de acciones de EE. UU. con una volatilidad local del 22,1 % y el dólar, medido en evros, tiene una del 10,2 %, con una correlación de 0,07 entre los dos. ¿Qué volatilidad tiene la posición sin cubrir?

   - A. 22,1 %: la divisa no añade riesgo
   - B. Unos 24,9 %: las varianzas se suman casi enteras porque la correlación es baja
   - C. 32,3 %: las volatilidades se suman
   - D. 11,9 %: la divisa compensa

   *Respuesta B: Unos 24,9 %: las varianzas se suman casi enteras porque la correlación es baja.* Con correlación ρ, la varianza es σ²_local + σ²_fx + 2ρσ_localσ_fx (más un término cruzado pequeño): √(0,221² + 0,102² + 2·0,07·0,221·0,102) ≈ 0,249. En los datos se mide un 24,9 %. Cubrir del todo la deja en un 22,0 %.

2. En el mismo caso, cubrir el dólar de un bono a 10 años baja su volatilidad del 14,6 % al 10,2 %; cubrir el índice de acciones, del 24,9 % al 22,0 %. ¿Por qué la cobertura importa más en el bono?

   - A. Porque los bonos tienen más riesgo de divisa
   - B. Porque la volatilidad del dólar (10 %) es del mismo orden que la del bono (10 %) y mucho menor que la de las acciones (22 %): en el bono la divisa es casi la mitad del riesgo
   - C. Porque los bonos pagan cupón
   - D. Porque las acciones ya están cubiertas

   *Respuesta B: Porque la volatilidad del dólar (10 %) es del mismo orden que la del bono (10 %) y mucho menor que la de las acciones (22 %): en el bono la divisa es casi la mitad del riesgo.* La parte del riesgo que es divisa depende de cuánto riesgo tiene el activo. En renta fija, la divisa domina; en acciones, se diluye. Por eso muchas carteras cubren casi siempre la renta fija extranjera y deciden caso a caso en las acciones.

3. Desde 2015, cubrir el dólar con forwards le cuesta al inversor en evros entre un 0,3 % y un 2,5 % al año en los datos. ¿De dónde sale ese coste?

   - A. De las comisiones del bróker
   - B. De la horquilla del forward
   - C. Del diferencial de tipos: vender dólares a plazo se hace a un precio que incorpora que el tipo del dólar es mayor que el del evro (paridad cubierta), más la base
   - D. De que el dólar se apreció

   *Respuesta C: Del diferencial de tipos: vender dólares a plazo se hace a un precio que incorpora que el tipo del dólar es mayor que el del evro (paridad cubierta), más la base.* Por la paridad cubierta, el forward incorpora el diferencial de tipos. Quien vende la divisa de tipo alto a plazo recibe menos que el contado: paga el diferencial. En 2010-2014, con tipos parecidos, la cobertura casi no costaba; con el dólar pagando dos o tres puntos más, cuesta eso cada año.

4. Un cliente REST envía una orden de mercado y la conexión expira sin respuesta. ¿Qué debe hacer antes de reenviarla?

   - A. Reenviarla enseguida: si no hay respuesta, no se ejecutó
   - B. Consultar la orden por su identificador propio (en OANDA, `GET /v3/accounts/{id}/orders/@{clientID}`): la orden pudo llegar y ejecutarse aunque la respuesta se perdiera
   - C. Esperar al día siguiente
   - D. Cerrar todas las posiciones

   *Respuesta B: Consultar la orden por su identificador propio (en OANDA, `GET /v3/accounts/{id}/orders/@{clientID}`): la orden pudo llegar y ejecutarse aunque la respuesta se perdiera.* Un tiempo de espera agotado no dice si la orden llegó. Reenviar a ciegas puede duplicar la posición. Por eso cada orden lleva un identificador propio (`clientExtensions.id` en OANDA, `orderRef` en IBKR, `magic` y `comment` en MT5) y el cliente lo consulta antes de reenviar.

5. MetaTrader 5 devuelve `retcode = 10004` a una orden de mercado. ¿Qué significa y qué hace un sistema?

   - A. Orden ejecutada: no hace nada
   - B. Recotización (TRADE_RETCODE_REQUOTE): el precio ya no es válido; pide precio nuevo y decide si reenvía dentro de su desviación máxima
   - C. Fondos insuficientes: reduce el tamaño
   - D. Mercado cerrado: espera a la apertura

   *Respuesta B: Recotización (TRADE_RETCODE_REQUOTE): el precio ya no es válido; pide precio nuevo y decide si reenvía dentro de su desviación máxima.* Según la tabla de códigos de retorno de MQL5, 10004 es TRADE_RETCODE_REQUOTE, 10009 es TRADE_RETCODE_DONE, 10019 es falta de fondos y 10018 mercado cerrado. Una recotización es un rechazo por precio, como el last look de la lección 3.3.1: se reenvía con el precio nuevo solo si sigue dentro del límite.

## Ideas clave

- La rentabilidad en la divisa propia es (1 + r_local)(1 + r_fx) − 1. Para un inversor en evros con un índice de EE. UU., el dólar añade 2,8 puntos de volatilidad (del 22,1 % local al 24,9 %); a un bono a 10 años le añade 4,5 (del 10,2 % al 14,6 %).
- Cubrir con un forward a un mes cambia el riesgo de divisa por el diferencial de tipos: casi gratis en 2010-2014 y entre un 0,3 % y un 2,5 % anual desde 2015 en los datos, y con flujos de caja mensuales que pueden llegar al 7 % de la cartera en un mes.
- En una cartera de cinco activos y cuatro divisas, la divisa es una parte pequeña del riesgo (un 13 % de la varianza, 10,5 puntos del dólar): cubrir el dólar baja la volatilidad del 19,7 % al 18,2 % y cuesta 0,65 puntos de rentabilidad al año; cubrir además la libra y el yen casi no cambia nada.
- Un futuro cubre la divisa con una exposición residual: solo se compran contratos enteros, el margen y las liquidaciones diarias son en la divisa del contrato y el contrato hay que renovarlo.
- Cada API mide la orden a su manera: OANDA en unidades con signo de la divisa base, IBKR en cantidad de la base con acción BUY o SELL, MT5 en lotes con el tamaño de contrato del bróker. Convertir mal es un error de un factor de 100.000.
- Un cliente de órdenes serio pone límite de precio (`priceBound`, `deviation`), reintenta con espera creciente ante el límite de ritmo y, si no hay respuesta, consulta por su identificador propio antes de reenviar.

## Recursos

### Research

- [Máquinas de Estado Finito para Ejecución de Trading Cuantitativo en Python](https://pythonparatrading.com/maquinas-estado-finito-trading-python/) · Research-0029 · 49 min

### Documentación

- [OANDA v20 REST: Order Endpoints (POST /v3/accounts/{accountID}/orders)](https://developer.oanda.com/rest-live-v20/order-ep/) · developer.oanda.com
- [OANDA v20 REST: Transaction Definitions (OrderFillTransaction, OrderCancelReason)](https://developer.oanda.com/rest-live-v20/transaction-df/) · developer.oanda.com
- [ib_async: API (IB.placeOrder, Forex, MarketOrder, LimitOrder, Trade)](https://ib-api-reloaded.github.io/ib_async/api.html) · ib-api-reloaded.github.io
- [MetaTrader 5 para Python: order_send](https://www.mql5.com/en/docs/python_metatrader5/mt5ordersend_py) · mql5.com
- [MQL5: códigos de retorno del servidor de operaciones](https://www.mql5.com/en/docs/constants/errorswarnings/enum_trade_return_codes) · mql5.com

### Datos

- [Datos del nivel 3: índices de contado con dividendos y tipos (CSV)](https://pythonparatrading.com/curso/datos/nivel-3/futuros3_indices_contado.csv) · Descarga · futuros3_indices_contado.csv

Lección anterior: [3.3.2 · Carry y riesgo de crash, momentum y value en divisas; cestas](https://pythonparatrading.com/curso-python-trading/nivel-3/carry-momentum-y-value-en-divisas/)

Lección siguiente: [3.4.1 · Payoffs, paridad put-call y límites de arbitraje](https://pythonparatrading.com/curso-python-trading/nivel-3/payoffs-paridad-put-call-y-limites-de-arbitraje/)
