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.
- Módulo
- 3.3 · Forex avanzado
- Duración
- 75 min
- Requisitos
- 1.2.4 · Forex: pares, pips, lotes, rollover y CFD, 1.3.6 · Brókeres como fuente de datos, 1.4.1 · Descarga por REST y websockets: paginación, límites y reintentos, 3.3.1 · Microestructura FX: OTC, ECN, last look y sesiones, 3.3.2 · Carry y riesgo de crash, momentum y value en divisas; cestas
Lecciones del nivel 3
3.1 · Futuros en profundidad
3.2 · Spreads de futuros
- 3.2.1 Spreads de calendario, butterflies y condors
- 3.2.2 Spreads intercommodity: crack, spark y crush
- 3.2.3 Spreads de tipos y de índices: 2s10s, NOB, NQ/ES y Europa frente a EE. UU.
- 3.2.4 Ratios de cobertura: por nominal, por DV01 y por volatilidad
- 3.2.5 Datos y backtesting de spreads: cotizados, sintéticos y por patas
- 3.2.6 Estacionalidad de spreads: análisis multi-año, patrones y trampas
3.3 · Forex avanzado
3.4 · Teoría y valoración de opciones
- 3.4.1 Payoffs, paridad put-call y límites de arbitraje
- 3.4.2 Árboles binomiales, Black-Scholes-Merton, Black-76 y Monte Carlo
- 3.4.3 Griegas: delta, gamma, vega, theta, rho, vanna, volga y charm
- 3.4.4 Volatilidad implícita: Newton-Raphson, Brent, py_vollib y QuantLib
- 3.4.5 Sonrisa, skew y estructura temporal; superficie de volatilidad con SVI
- 3.4.6 Volatilidad realizada frente a implícita; prima de riesgo de volatilidad; VIX y VSTOXX
- 3.4.7 Modelos avanzados: Heston, SABR y volatilidad local
3.5 · Estructuras de opciones en Python
- 3.5.1 Estructuras direccionales y verticales: call, put, covered call, collar y spreads
- 3.5.2 Estructuras de volatilidad y rango: straddles, strangles, cóndores y mariposas
- 3.5.3 Calendarios, diagonales, ratios, backspreads, risk reversals y jade lizard
- 3.5.4 Herramienta propia: payoff, griegas agregadas y escenarios
- 3.5.5 Gestión: ajustes, roll, cierre anticipado, asignación temprana y pin risk
3.6 · Estrategias sistemáticas de volatilidad y opciones
3.7 · Cripto derivados y estrategias específicas
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:
con 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.
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)vol_local 22.14
vol_dolar 10.15
correlacion 0.07
vol_en_qev 24.90
dtype: float64El í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 en USD por evro y el forward a un mes:
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 entre 0 y 1, la rentabilidad cubierta es la sin cubrir más 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).
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) 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.15Es 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 , conocido al abrir la cobertura:
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})")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)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, , 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.
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").Tvolatilidad 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.1La 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?
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) 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.77Cubrir 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:
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")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 USDLa 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.
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:
| 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:
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}")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.:
unitspositivas compran y negativas venden;timeInForcede una orden de mercado solo puede serFOK(todo o nada, el valor por defecto) oIOC.priceBoundes 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ónBOUNDS_VIOLATION.clientExtensionslleva unid, unatagy uncommentdel cliente. Elidpermite consultar la orden después conGET /v3/accounts/{accountID}/orders/@{id}.- La respuesta 201 trae
orderCreateTransactiony, si la orden se ejecutó,orderFillTransaction(confullVWAP,tradeOpened,halfSpreadCost…) o, si se canceló al momento,orderCancelTransactioncon sureason. Los precios y las cantidades llegan como texto.
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.
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:"))simulador en marcha: TrueEl 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.
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))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: FILLEDLa 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.
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 --> [*]
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)cob-1 100 FILLED
cob-2 101 CANCELLED
cob-3 102 FILLED
peticiones POST recibidas: 4Tres ó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.
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.
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:
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}")10004 TRADE_RETCODE_REQUOTE -> precio nuevo y reenviar si cabe
10009 TRADE_RETCODE_DONE -> hecho
10014 TRADE_RETCODE_INVALID_VOLUME -> no reintentar: revisar lotesLa 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
- Repite la tabla de ratios de cobertura con
RVEcomo activo extranjero para un inversor en dólares (es decir, mideRVEen USD y cubre el evro). ¿Es la cobertura igual de útil que para el inversor en evros conRVA?
Solución
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)0 24.38
0.5 23.25
1.0 23.34
h_minvar 0.71
vol_local 23.19
dtype: float64Para 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.
- 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
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").Tvolatilidad: 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.3Con 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.
- 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
-
OANDA, Order Endpoints, Order Definitions (
MarketOrderRequest,LimitOrderRequest,OrderState) y Transaction Definitions (OrderFillTransaction,OrderCancelTransaction,OrderCancelReason), consultadas el 22/10/2026. ↩ -
ib_async: API, versión 2.1.0 en PyPI, consultadas el 22/10/2026. ↩
-
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
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 %.
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.
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.
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 perdieraUn 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.iden OANDA,orderRefen IBKR,magicycommenten MT5) y el cliente lo consulta antes de reenviar.MetaTrader 5 devuelve
retcode = 10004a 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
Documentación
- OANDA v20 REST: Order Endpoints (POST /v3/accounts/{accountID}/orders)
- OANDA v20 REST: Transaction Definitions (OrderFillTransaction, OrderCancelReason)
- ib_async: API (IB.placeOrder, Forex, MarketOrder, LimitOrder, Trade)
- MetaTrader 5 para Python: order_send
- MQL5: códigos de retorno del servidor de operaciones