Lección 3.3.3 · 75 min

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.

Fig. — Volatilidad en evros de un índice de acciones de EE. UU. y de un bono a 10 años según la parte del dólar que se cubre con forwards a un mes (2010-2025)
Lecciones del nivel 3
  1. 3.1 · Futuros en profundidad

    1. 3.1.1 Contratos de futuros principales
    2. 3.1.2 Curva de futuros: base, cost of carry, contango y roll yield
    3. 3.1.3 Gestión del roll, márgenes, SPAN y apalancamiento implícito
    4. 3.1.4 Sistemas de futuros diversificados: el enfoque de Carver
  2. 3.2 · Spreads de futuros

    1. 3.2.1 Spreads de calendario, butterflies y condors
    2. 3.2.2 Spreads intercommodity: crack, spark y crush
    3. 3.2.3 Spreads de tipos y de índices: 2s10s, NOB, NQ/ES y Europa frente a EE. UU.
    4. 3.2.4 Ratios de cobertura: por nominal, por DV01 y por volatilidad
    5. 3.2.5 Datos y backtesting de spreads: cotizados, sintéticos y por patas
    6. 3.2.6 Estacionalidad de spreads: análisis multi-año, patrones y trampas
  3. 3.3 · Forex avanzado

    1. 3.3.1 Microestructura FX: OTC, ECN, last look y sesiones
    2. 3.3.2 Carry y riesgo de crash, momentum y value en divisas; cestas
    3. 3.3.3 Riesgo de divisa en carteras multi-moneda; ejecución vía OANDA, IBKR o MT5
  4. 3.4 · Teoría y valoración de opciones

    1. 3.4.1 Payoffs, paridad put-call y límites de arbitraje
    2. 3.4.2 Árboles binomiales, Black-Scholes-Merton, Black-76 y Monte Carlo
    3. 3.4.3 Griegas: delta, gamma, vega, theta, rho, vanna, volga y charm
    4. 3.4.4 Volatilidad implícita: Newton-Raphson, Brent, py_vollib y QuantLib
    5. 3.4.5 Sonrisa, skew y estructura temporal; superficie de volatilidad con SVI
    6. 3.4.6 Volatilidad realizada frente a implícita; prima de riesgo de volatilidad; VIX y VSTOXX
    7. 3.4.7 Modelos avanzados: Heston, SABR y volatilidad local
  5. 3.5 · Estructuras de opciones en Python

    1. 3.5.1 Estructuras direccionales y verticales: call, put, covered call, collar y spreads
    2. 3.5.2 Estructuras de volatilidad y rango: straddles, strangles, cóndores y mariposas
    3. 3.5.3 Calendarios, diagonales, ratios, backspreads, risk reversals y jade lizard
    4. 3.5.4 Herramienta propia: payoff, griegas agregadas y escenarios
    5. 3.5.5 Gestión: ajustes, roll, cierre anticipado, asignación temprana y pin risk
  6. 3.6 · Estrategias sistemáticas de volatilidad y opciones

    1. 3.6.1 Venta sistemática de prima: put-write, strangles y su riesgo de cola
    2. 3.6.2 Delta hedging, gamma scalping y dispersión
    3. 3.6.3 Estrategias con VIX y VSTOXX: futuros y ETP de volatilidad
    4. 3.6.4 Backtesting de opciones: datos, fills y ajustes por eventos
  7. 3.7 · Cripto derivados y estrategias específicas

    1. 3.7.1 Perpetuos: mecánica del funding, mark price y liquidaciones
    2. 3.7.2 Arbitraje de funding, basis con futuros y arbitraje entre exchanges
    3. 3.7.3 Opciones cripto, datos on-chain y DeFi
    4. 3.7.4 ccxt para operar en varios exchanges
    5. 3.7.5 MiCA: el marco de la UE para criptoactivos y sus proveedores (CASP)

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+rQEV=(1+rlocal) (1+rfx)⟹rQEV=rlocal+rfx+rlocal rfx1 + 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 rfxr_{\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.

IN [01]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)
OUT [01]El índice RVA visto desde el evro
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 XX en USD por evro y FF el forward a un mes:

resultado de la cobertura=Xt−1Ft−1−Xt−1Xt\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 hh entre 0 y 1, la rentabilidad cubierta es la sin cubrir más hh 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).

IN [02]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)
OUT [02]Volatilidad según el ratio de cobertura: acciones y bono
           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 Xt/Ft−1X_t / F_t - 1, conocido al abrir la cobertura:

IN [03]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})")
OUT [03]Coste anual de la cobertura y flujos de caja
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

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, ck=wk cov(rk,rp)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.

IN [04]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
OUT [04]Contribución al riesgo de activos y divisas
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

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?

IN [05]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)
OUT [05]Qué pasa al cubrir cada divisa
            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:

IN [06]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")
OUT [06]Contratos de DQE para cubrir el dólar de una cartera
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.

SistemaREST OANDA v20ib_asyncTWS o GatewayMetaTrader5Terminal MT5
Fuente 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:

APICómo se pide una compra de 1 M de EURUSDUnidad
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_BUYLotes; 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:

IN [07]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}")
OUT [07]Una exposición traducida a cada API
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 v201Nota 1 · OANDA, Order Endpoints, Order Definitions (MarketOrderRequest, LimitOrderRequest, OrderState) y Transaction Definitions (OrderFillTransaction, OrderCancelTransaction, OrderCancelReason), consultadas el 22/10/2026.:

  • 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.
IN [08]oanda_orden.pypython
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.

IN [09]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:"))
OUT [09]Servidor local con la forma de las respuestas de OANDA v20
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.

IN [10]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))
OUT [10]Cliente con reintentos y consulta por identificador propio
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.

201 con fill201 con cancel429reintentosin respuestaexisteno existeEnviadaEjecutadaCanceladaEsperaConsulta
Fuente 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 --> [*]
IN [11]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)
OUT [11]Lo que queda registrado en el servidor
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_async2Nota 2 · ib_async: API, versión 2.1.0 en PyPI, consultadas el 22/10/2026., 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.

IN [12]ibkr_orden_fx.pypython
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 MQL53Nota 3 · MQL5, order_send, symbol_info, propiedades de las órdenes (ORDER_FILLING_*) y códigos de retorno, consultadas el 22/10/2026.: 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.

IN [13]mt5_orden_fx.pypython
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:

IN [14]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}")
OUT [14]Qué hacer con cada código de retorno de MT5
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
IN [15]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)
OUT [15]Un inversor en dólares con acciones de la zona del evro
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.

  1. 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
IN [16]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
OUT [16]Contribuciones con el dólar y la libra cubiertos
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.

  1. 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.

Footnotes

  1. OANDA, Order Endpoints, Order Definitions (MarketOrderRequest, LimitOrderRequest, OrderState) y Transaction Definitions (OrderFillTransaction, OrderCancelTransaction, OrderCancelReason), consultadas el 22/10/2026. ↩

  2. ib_async: API, versión 2.1.0 en PyPI, consultadas el 22/10/2026. ↩

  3. MQL5, order_send, symbol_info, propiedades de las órdenes (ORDER_FILLING_*) y códigos de retorno, consultadas el 22/10/2026. ↩

Respuesta plegada en cada pregunta

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?

    • A22,1 %: la divisa no añade riesgo
    • BUnos 24,9 %: las varianzas se suman casi enteras porque la correlación es baja
    • C32,3 %: las volatilidades se suman
    • D11,9 %: la divisa compensa
    Ver respuesta

    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?

    • APorque los bonos tienen más riesgo de divisa
    • BPorque 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
    • CPorque los bonos pagan cupón
    • DPorque las acciones ya están cubiertas
    Ver respuesta

    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?

    • ADe las comisiones del bróker
    • BDe la horquilla del forward
    • CDel 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
    • DDe que el dólar se apreció
    Ver respuesta

    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?

    • AReenviarla enseguida: si no hay respuesta, no se ejecutó
    • BConsultar 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
    • CEsperar al día siguiente
    • DCerrar todas las posiciones
    Ver respuesta

    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?

    • AOrden ejecutada: no hace nada
    • BRecotizació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
    • CFondos insuficientes: reduce el tamaño
    • DMercado cerrado: espera a la apertura
    Ver respuesta

    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

Datos