Lesson 3.3.3 · 75 min
Currency risk in multi-currency portfolios; execution via OANDA, IBKR or MT5
A portfolio in evros holding stocks in dollars has two risks: the stocks and the dollar. This lesson separates them, measures how much a one-month forward hedge removes, how much it costs and how much cash it demands each month, and breaks down the risk of a portfolio with five assets in four currencies. Then it sends the orders: the real code for OANDA v20, Interactive Brokers (ib_async) and MetaTrader 5, and a local server that mimics OANDA to actually run price bounds, rate limits and retries without duplicate orders.
- Module
- 3.3 · Advanced forex
- Duration
- 75 min
- Prerequisites
- 1.2.4 · Forex: pairs, pips, lots, rollover and CFDs, 1.3.6 · Brokers as market data sources, 1.4.1 · Downloading over REST and websockets: pagination, rate limits and retries, 3.3.1 · FX microstructure: OTC, ECN, last look and sessions, 3.3.2 · Carry and crash risk, momentum and value in currencies; baskets
- Author
- Jesús Cuesta
- Updated
Level 3 lessons
3.1 · Futures in depth
3.2 · Futures spreads
- 3.2.1 Calendar spreads, butterflies and condors
- 3.2.2 Intercommodity spreads: crack, spark and crush
- 3.2.3 Rate and index spreads: 2s10s, NOB, NQ/ES and Europe versus the U.S.
- 3.2.4 Hedge ratios: by notional, by DV01 and by volatility
- 3.2.5 Spread data and backtesting: listed, synthetic and legged
- 3.2.6 Seasonality of spreads: multi-year analysis, patterns and traps
3.3 · Advanced forex
3.4 · Option theory and pricing
- 3.4.1 Payoffs, put-call parity and arbitrage bounds
- 3.4.2 Binomial trees, Black-Scholes-Merton, Black-76 and Monte Carlo
- 3.4.3 Greeks: delta, gamma, vega, theta, rho, vanna, volga and charm
- 3.4.4 Implied volatility: Newton-Raphson, Brent, py_vollib and QuantLib
- 3.4.5 Smile, skew and term structure; volatility surface with SVI
- 3.4.6 Realized vs implied volatility; volatility risk premium; VIX and VSTOXX
- 3.4.7 Advanced models: Heston, SABR and local volatility
3.5 · Option structures in Python
- 3.5.1 Directional and vertical structures: call, put, covered call, collar and spreads
- 3.5.2 Volatility and range structures: straddles, strangles, condors and butterflies
- 3.5.3 Calendars, diagonals, ratios, backspreads, risk reversals and jade lizard
- 3.5.4 A custom tool: payoff, aggregate Greeks and scenarios
- 3.5.5 Management: adjustments, rolls, early closes, early assignment and pin risk
3.6 · Systematic volatility and options strategies
3.7 · Crypto derivatives and specific strategies
- 3.7.1 Perpetuals: funding mechanics, mark price and liquidations
- 3.7.2 Funding arbitrage, basis with futures and cross-exchange arbitrage
- 3.7.3 Crypto options, on-chain data and DeFi
- 3.7.4 ccxt for trading on multiple exchanges
- 3.7.5 MiCA: the EU framework for crypto-assets and their service providers (CASPs)
Two risks in one position
An investor whose currency is the evro (QEV, the course's fictitious euro) buys a US stock index. What they earn in
evros depends on two things: what the index does in dollars and what the dollar does against the evro. If the index
rises 10% and the dollar falls 10%, in evros they earn almost nothing:
with the return of the dollar measured in evros. The third term, the cross term, is small over a month and grows with the horizon. The variance is, approximately, that of the two parts plus twice their covariance: if the two sources are weakly correlated, the risks add up almost in full.
The data: futuros3_indices_contado.csv has the fictitious indices of module 3.1 in their currency (RVA mimics the
S&P 500, RVB the Nasdaq-100, RVE the Euro Stoxx 50) with their dividend yield, from 2010 to 2025, and
fx3_diario.parquet the twelve currencies of lesson 3.3.2. Everything is synthetic. Monthly total return: the change
in the index plus one twelfth of the dividend.
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 per unit of currency
r_usd = (X["QEV"].shift(1) / X["QEV"] - 1).iloc[1:] # USD in 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: float64The index has a local volatility of 22.1% and the dollar, measured in evros, of 10.2%. With a correlation of only 0.07, in evros the position has a volatility of 24.9%: almost three points more. The sign of the correlation matters. If the dollar rose when stocks fall (as a safe haven does), the currency risk would cushion the falls; here they barely move together.
Hedging with a one-month forward
The classic hedge is to sell forward the dollars one holds. At each month end, the dollar value of the position is sold one month forward, at the forward rate; the next month the contract is settled and another one is opened. For each evro invested, with in USD per evro and the one-month forward:
the first term is what is received for the dollars sold forward and the second what they are worth at spot. With a hedge ratio between 0 and 1, the hedged return is the unhedged one plus times that result. The hedge is not perfect: it hedges the value at the start of the month, not what the position gains or loses during the month.
For comparison, a second asset: a 10-year Treasury bond approximated with the curve in tipos3_curva.csv (a par
bond with a semiannual coupon equal to the previous month's yield, repriced with this month's yield; an approximation,
not a bond index).
F = mensual("forward_1m")["QEV"] # USD per 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):
"""Price of a bond with a semiannual coupon, face value 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.15This is the header figure. For the stocks, fully hedging the dollar lowers the volatility from 24.9% to 22.0%: slightly below the local one (22.1%), because the correlation is slightly positive. For the bond the hedge matters much more: from 14.6% to 10.2%, the local volatility. The reason is one of proportions: the dollar has a volatility of 10%, the same as the bond and less than half that of the stocks. In foreign fixed income the currency is almost half the risk; in stocks it is diluted.
The minimum-variance ratio (the slope of the unhedged return on the hedge result) comes out at 1.16 for the stocks and 1.04 for the bond: somewhat more than a full hedge, because of the positive correlation between the asset and the dollar. With another sample it could come out at 0.8. Hedging more than 100% is betting against the dollar; almost all investment policies set a ratio (0, 50% or 100%) for governance reasons, not estimation ones.
What it costs: the interest rate differential
Through covered parity (lesson 3.3.2), the forward carries the interest rate differential inside it. Whoever sells dollars forward when the dollar pays more interest than the evro receives fewer evros than at spot: they pay the differential. The cost of each month is , known when the hedge is opened:
coste_mes = (X["QEV"] / F - 1).iloc[:-1] # known when hedging
por_ano = 100 * coste_mes.groupby(coste_mes.index.year).sum()
# negative = the hedge costs; positive = it earns
print(por_ano.round(2).to_frame("carry_cob_pct").T.to_string())
print()
peor = cobertura.idxmin()
print(f"worst monthly settlement: {100 * cobertura.min():.2f} % "
f"({peor:%Y-%m})")
doce = cobertura.rolling(12).sum()
print(f"worst 12-month sum: {100 * doce.min():.2f} % "
f"(through {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
worst monthly settlement: -7.06 % (2021-01)
worst 12-month sum: -24.64 % (through 2021-11)From 2010 to 2014 evro and dollar rates were similar and the hedge cost almost nothing (or earned a little). Since 2015, with the dollar paying more, it costs every year: 2.54% in 2019, 1.59% in 2023, 1.66% in 2025 (in the table, with a negative sign). Against the 22% volatility of the stocks it is little; for a bond, whose yield is a few points, it can be a large part of what it earns. The hedge is also changed by the covered parity basis: with the −15 bp basis that the evro has in the data, getting dollars forward is somewhat more expensive than the deposits say (lesson 3.3.2).
The second figure is the one backtests forget: the cash flows. Every month the forward is settled. If the dollar appreciates, the hedge loses, and that loss has to be paid in cash that month, even though the equivalent gain on the stocks in dollars has not been realized. In the worst month of the data the settlement is −7.1% of the portfolio, and in the worst twelve consecutive months (through November 2021), −24.6%. A portfolio that does not have that cash has to sell assets to pay for the hedge, often at the worst moment.
A portfolio in four currencies
A more realistic case: a portfolio in evros with US stocks (RVA 35%, RVB 15%), evro-area stocks (RVE 30%) and
cash in pounds (QLB, 10%) and yen (QYN, 10%). The cash earns the one-month deposit rate of its currency. The
currency exposure is: dollar 50%, pound 10%, yen 10%; the remaining 30% is already in evros.
The risk is broken down with the usual decomposition (lesson 2.6.1): the contribution of each source to the total variance is its weight times its covariance with the portfolio, , and the contributions add up to the variance. Here the sources are the local returns of the assets and those of each currency against the evro, weighted by their exposure.
dep = mensual("tipo_deposito_1m")
en_qev = X.div(X["QEV"], axis=0) # QEV per unit
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"portfolio volatility: {vol(cartera):.2f} %")
pct.round(1).to_frame("riesgo_pct").Tportfolio volatility: 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.1The portfolio has a volatility of 19.7% in evros. The stocks take 87% of the variance: RVA 35.9%, RVE 31.8%,
RVB 19.5%. The dollar contributes 10.5% and the pound and the yen, 1.2% and 1.1%; the deposits, nothing (their return
barely varies). The decomposition is linear: it adds up the local returns and the weighted currency returns, without
the cross term, which over a month is small. In the data model the yen is a safe-haven currency that appreciates in
crises, but with a 10% weight its effect does not offset its own noise: it adds risk, if only a little. What to hedge?
Fu = mensual("forward_1m")
par_usd = fx.groupby("divisa")["par"].first().str.startswith("USD")
Fu.loc[:, par_usd] = 1 / Fu.loc[:, par_usd] # USD per unit
f_qev = Fu.div(Fu["QEV"], axis=0) # QEV per unit
f_qev["USD"] = 1 / Fu["QEV"]
s_qev = en_qev.copy()
s_qev["USD"] = 1 / X["QEV"]
# sell forward 1 QEV of exposure to each currency, per 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 = {"unhedged": [], "USD only": ["USD"],
"USD and QLB": ["USD", "QLB"], "all": ["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
unhedged 19.71 11.59 -25.28
USD only 18.23 10.94 -24.60
USD and QLB 18.16 10.90 -24.81
all 18.16 10.94 -24.77Hedging the dollar lowers the volatility from 19.71% to 18.23% and costs 0.65 points of average return a year (from 11.59% to 10.94%): it is the interest rate differential of the previous section. Also hedging the pound lowers it to 18.16%, and adding the yen does not change it. On return, hedging the pound costs 0.04 points and hedging the yen, a currency with a lower rate than the evro, gives them back: the investor collects the differential. The worst month barely changes (from −25.3% to −24.6%) because the stocks dominate it. For this portfolio, the decision that matters is the dollar one; the pound and yen one is almost indifferent in risk. It is a decision per currency and with data, not a general rule: with more weight in foreign fixed income, the hedge would matter much more (previous section).
Hedging with futures
Instead of forwards, currency futures on an exchange can be used. DQE, in the data, mimics the CME Euro FX future: it
is quoted in USD per evro, with a multiplier of 125,000 evros per contract (the specifications of the real contract, in
lesson 3.1.1). An evro investor with assets in dollars is long the dollar and short the evro: to hedge, they buy
evro futures. The number of contracts is the exposure in evros divided by the notional in evros of one contract:
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] # the nearest one
mult = esp.loc["DQE", "multiplicador"]
capital_qev = 10_000_000
expo_usd = 0.50 * capital_qev * X["QEV"].iloc[-1] # in 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 per QEV")
print(f"exposure: {expo_usd:,.0f} USD; contracts: {n_exacto:.2f}"
f" -> {n}")
print(f"unhedged residual: {residuo_usd:,.0f} USD")
print(f"initial margin: {n * esp.loc['DQE', 'margen_inicial']:,.0f}"
" USD")DQEH26: 1.38165 USD per QEV
exposure: 6,884,270 USD; contracts: 39.86 -> 40
unhedged residual: -23,980 USD
initial margin: 136,000 USDThe dollar exposure, 6,884,270 USD, calls for 39.86 contracts of DQEH26 at 1.38165; with 40, 23,980 USD too many are
hedged: only whole contracts can be bought, and the exposure changes every day with the value of the stocks. The
initial margin, 136,000 USD, is posted in dollars. Three more differences from the forward:
- The margin and the daily settlements are in dollars (lesson 3.1.3). Hedging the dollar needs a dollar account with cash for the settlements, which in turn has a small currency risk.
- The future has to be rolled every quarter, with the cost of the roll (lesson 3.1.2); its basis reflects the same interest rate differential as the forward.
- The counterparty is the clearing house, not a bank: no bilateral credit agreements, in exchange for daily margins.
A future hedges the notional of an exposure; its own gains and losses, it does not. That is why a future on a foreign index (an E-mini bought from Europe) only has currency risk on the margin and on the accumulated result, not on the whole notional: the notional has not been paid.
From exposure to order: three APIs
The hedge and currency positions are executed through a broker. The three most used from Python by individuals and small professionals have different architectures (lesson 1.3.6): OANDA is a direct REST API; IBKR goes through TWS or IB Gateway, a local program that keeps the session; MetaTrader 5 goes through a terminal installed on Windows, which is what talks to the broker's server.
Mermaid source
flowchart LR S[System] --> O[REST OANDA v20] S --> I[ib_async] I --> G[TWS or Gateway] S --> M[MetaTrader5] M --> T[MT5 terminal]
Each one measures the order differently, and mixing them up is the most expensive and silliest mistake one can make:
| API | How to request a purchase of 1 M EURUSD | Unit |
|---|---|---|
| OANDA v20 | "instrument": "EUR_USD", "units": "1000000" (a sale, "-1000000") | Units of the base currency, signed |
IBKR (ib_async) | Forex("EURUSD"), MarketOrder("BUY", 1_000_000) | Quantity of the base currency and BUY/SELL action |
| MetaTrader 5 | "symbol": "EURUSD", "volume": 10.0, "type": ORDER_TYPE_BUY | Lots; the lot size is given by symbol_info().trade_contract_size |
The three things to know before sending: which currency is the base of the pair (a dollar exposure against evros is a
sale of EUR_USD, not a purchase of USD_EUR, which does not exist), the minimum size and the step (volume_min and
volume_step in MT5) and the precision of the quantity (OANDA rejects units with more decimals than allowed, with
UNITS_PRECISION_EXCEEDED). A function that translates it:
def orden_cobertura(expo_usd, precio, tam_lote=100_000, paso=0.01):
"""Hedge expo_usd (long USD) by buying EURUSD: the base is
the evro, so the quantity is expressed in 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 when sending the order
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}With the same exposure and a price of 1.37692 when sending (EURUSD plays here the role of the course's QEVUSD), OANDA
asks for 4,999,760 units, IBKR the same quantity and MT5 50.00 lots, which are 5,000,000 evros: rounding to the 0.01-lot
step buys 240 evros too many. An MT5 lot is 100,000 units at almost
all currency brokers (the example in the symbol_info documentation shows trade_contract_size=100000.0,
volume_min=0.01 and volume_step=0.01), but the broker sets it: it has to be read, not assumed.
OANDA v20: orders over REST
A market order in OANDA is a POST /v3/accounts/{accountID}/orders with a JSON body with an order field. According
to the v20 documentation1Note 1 · OANDA, Order Endpoints, Order Definitions (MarketOrderRequest, LimitOrderRequest, OrderState) and Transaction Definitions (OrderFillTransaction, OrderCancelTransaction, OrderCancelReason), accessed on October 22, 2026.:
- Positive
unitsbuy and negative ones sell; thetimeInForceof a market order can only beFOK(fill or kill, the default) orIOC. priceBoundis the worst price at which the client accepts the fill. If the fill price exceeds it, the order is cancelled with the reasonBOUNDS_VIOLATION.clientExtensionscarries anid, atagand acommentfrom the client. Theidmakes it possible to look up the order later withGET /v3/accounts/{accountID}/orders/@{id}.- The 201 response brings
orderCreateTransactionand, if the order was filled,orderFillTransaction(withfullVWAP,tradeOpened,halfSpreadCost…) or, if it was cancelled immediately,orderCancelTransactionwith itsreason. Prices and quantities arrive as text.
import json
import os
from urllib.request import Request, urlopen
BASE = "https://api-fxpractice.oanda.com" # practice account
cuenta = os.environ["OANDA_ACCOUNT"]
orden = {"order": {
"type": "MARKET", "instrument": "EUR_USD", "units": "4999760",
"timeInForce": "FOK", "positionFill": "DEFAULT",
"priceBound": "1.37740", # worst accepted price
"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")A local server that mimics OANDA
To actually run the client, with its retries and its errors, without an account or network, the next cell starts a
local HTTP server in a thread (free port chosen by the system) that mimics the shape of those responses. It is not
OANDA: it is a test bench with three programmed behaviors. The second request it receives returns a 429 (rate limit),
an order tagged as lenta is recorded and filled, but its response takes longer than the client's timeout, and the
price of each order comes from a fixed list, so that one of them exceeds its 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) # price of each fill
self.ordenes = {} # client id -> order
self.peticiones = 0
self.lock = threading.Lock()
class Manejador(BaseHTTPRequestHandler):
def log_message(self, *args): # no logging to 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) # response that gets lost
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 # the client that no longer waits: no trace
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("simulator running:", BASE.startswith("http://127.0.0.1:"))simulator running: TrueThe client does three things an order client should always do: on a 429, it waits an increasing amount of time and retries; if the connection times out with no response, it does not resend blindly, but looks up the order by its client ID, because it may have arrived and been filled; and it returns the response as is, so that the system decides what to do with a cancellation.
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, retry after {espera * 2**k:.2f} s")
time.sleep(espera * 2**k)
except (TimeoutError, socket.timeout):
previa = consultar(cuenta, cid)
print(f" {cid}: no response; lookup: "
f"{previa['state'] if previa else 'does not exist'}")
if previa is not None:
return {"consultada": previa}
raise RuntimeError(f"{cid}: no response after {intentos} attempts")
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"recovered by lookup: {resp['consultada']['state']}"
if "orderFillTransaction" in resp:
t = resp["orderFillTransaction"]
return f"filled {t['units']} at {t['fullVWAP']}"
return "cancelled: " + 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 -> filled 2000000 at 1.37705
cob-2: 429, retry after 0.05 s
cob-2 -> cancelled: BOUNDS_VIOLATION
cob-3: no response; lookup: FILLED
cob-3 -> recovered by lookup: FILLEDThe first order is filled at 1.37705, within its bound of 1.37740. The second runs into the 429, waits 0.05 seconds and
retries; on that retry the price has jumped to 1.37781, above the bound, and the simulator cancels it with
BOUNDS_VIOLATION, as OANDA would. The client does not resend it on its own: a cancellation on price is a decision for
the system (ask for a price again, widen the bound or wait), just like a last-look rejection in lesson 3.3.1. The third
is filled on the server, but its response takes longer than the client's timeout; the client looks up @cob-3, sees
that it is FILLED and does not duplicate it. Without that lookup, the retry would have opened a second position of
one million.
Mermaid source
stateDiagram-v2 [*] --> Sent Sent --> Filled: 201 with fill Sent --> Cancelled: 201 with cancel Sent --> Waiting: 429 Waiting --> Sent: retry Sent --> Lookup: no response Lookup --> Filled: exists Lookup --> Sent: does not exist Filled --> [*] Cancelled --> [*]
servidor.shutdown()
servidor.server_close()
for cid, o in servidor.sim.ordenes.items():
print(cid, o["id"], o["state"])
print("POST requests received:", servidor.sim.peticiones)cob-1 100 FILLED
cob-2 101 CANCELLED
cob-3 102 FILLED
POST requests received: 4Three orders recorded and four requests received: the 429 one did not create any order. This is the bookkeeping a system has to be able to reconcile with the broker's at the end of the day: orders sent, accepted, cancelled and filled, each with its identifier.
Interactive Brokers with ib_async
In IBKR the order is sent by TWS or IB Gateway (lesson 1.3.6 for the connection and the ports). According to the
ib_async documentation2Note 2 · ib_async: API, version 2.1.0 on PyPI, accessed on October 22, 2026., Forex(pair, exchange='IDEALPRO') defines the pair (base and quote from "EURUSD"),
MarketOrder(action, totalQuantity) and LimitOrder(action, totalQuantity, lmtPrice) the orders, whatIfOrder
returns the effect on commissions and margin without sending it, and placeOrder returns a Trade that updates itself
with the status (trade.orderStatus.status), the fills (trade.fills) and a log; trade.isDone() says whether it has
reached a final state (Filled, Cancelled, ApiCancelled or Inactive). The order's orderRef field is the client
ID, which comes back in the fills.
from ib_async import IB, Forex, LimitOrder # uv add ib_async
ib = IB()
ib.connect("127.0.0.1", 7497, clientId=7) # TWS, paper account
par = Forex("EURUSD")
ib.qualifyContracts(par)
orden = LimitOrder("BUY", 4_999_760, 1.37740)
orden.orderRef = "cob-2025-12-31-usd" # client ID
orden.tif = "IOC"
impacto = ib.whatIfOrder(par, orden) # margin and commission
print(impacto.initMarginChange, impacto.commission)
trade = ib.placeOrder(par, orden)
while not trade.isDone():
ib.sleep(0.2) # let the loop run
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()In IBKR an IOC limit order plays the role of OANDA's priceBound: it fills what it can up to the limit price and
cancels the rest. If the program loses the connection to TWS after sending, on reconnecting ib.openTrades() and
ib.reqExecutions() say what happened, and the orderRef makes it possible to recognize one's own orders before
resending anything.
MetaTrader 5: order_send and its return codes
In MT5 the order is a dictionary with the MqlTradeRequest structure, and the response an OrderSendResult with a
retcode. According to the MQL5 documentation3Note 3 · MQL5, order_send, symbol_info, order properties (ORDER_FILLING_*) and return codes, accessed on October 22, 2026.: action is the type of operation (TRADE_ACTION_DEAL for a market
order), volume is in lots, price is the requested price, deviation the maximum accepted deviation from it in
points, type_filling the fill policy (ORDER_FILLING_FOK, ORDER_FILLING_IOC or ORDER_FILLING_RETURN, depending
on what the symbol allows) and magic a number that identifies the sending system.
import MetaTrader5 as mt5 # Windows only
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, # points
"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()The return codes are what the system decides on. A function that classifies them, with the values from the MQL5 table, can be tested without a terminal on simulated results:
from collections import namedtuple
RETCODE = {10009: ("DONE", "done"),
10010: ("DONE_PARTIAL", "partial: send the rest"),
10004: ("REQUOTE", "new price and resend if it fits"),
10020: ("PRICE_CHANGED", "new price and resend if it fits"),
10021: ("PRICE_OFF", "no prices: wait"),
10024: ("TOO_MANY_REQUESTS", "wait and retry"),
10018: ("MARKET_CLOSED", "wait for the session"),
10019: ("NO_MONEY", "do not retry: check margin"),
10014: ("INVALID_VOLUME", "do not retry: check lots"),
10006: ("REJECT", "do not retry without checking")}
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, ("?", "check"))
print(f"{res.retcode} TRADE_RETCODE_{nombre:<13s} -> {accion}")10004 TRADE_RETCODE_REQUOTE -> new price and resend if it fits
10009 TRADE_RETCODE_DONE -> done
10014 TRADE_RETCODE_INVALID_VOLUME -> do not retry: check lotsThe requote (10004) and the price change (10020) are MT5's way of saying the same as the last look of lesson 3.3.1 or
OANDA's BOUNDS_VIOLATION: the requested price is no longer good. The difference between retrying or not is in the
table: a price or rate error can be retried with care; a volume or margin error or a generic rejection cannot, because
the retry will give the same error or, worse, a different one with the position half done.
What goes wrong when executing currencies
- The base of the pair. Hedging dollars from the evro is buying
EUR_USD. A flipped sign doubles the exposure instead of hedging it. It is worth checking the position after each order, not just the response. - Lots and units. 50 MT5 lots versus 4,999,760 OANDA units: a factor of 100,000 between one and the other.
- Duplicates. Every order carries a client ID and is looked up before resending.
- Rejections on price. Last look,
BOUNDS_VIOLATION, requotes: some of the orders are not filled, and they are the ones that arrive when the price moves. - The time. The daily rollover at 17:00 New York time (lesson 1.2.4), with wide spreads, and the weekend close, during which a position cannot be adjusted. A hedge rolled at month end should do it away from those hours.
- Cash flows from the hedge. The forward or the future is settled in cash: one has to have it, or sell assets to pay for it.
- Rate limits. OANDA, IBKR and MT5 limit requests (lesson 1.3.6 for the OANDA and IBKR figures); a retry loop without increasing backoff ends up blocked. The state machine design from the site's research on execution helps keep every order always in a known state.
Exercises
- Repeat the hedge ratio table with
RVEas the foreign asset for a dollar investor (that is, measureRVEin USD and hedge the evro). Is the hedge as useful as for the evro investor withRVA?
Solution
r_qev_en_usd = (X["QEV"] / X["QEV"].shift(1) - 1).iloc[1:]
sin_usd = (1 + R["RVE"]) * (1 + r_qev_en_usd) - 1
# sell evros forward: receives F USD per QEV, worth X at spot
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: float64For the dollar investor, RVE has a local volatility of 23.19% and an unhedged one of 24.38%. Hedging half leaves it at
23.25% and hedging fully, at 23.34%: slightly more than the half hedge. The minimum-variance ratio is 0.71, against
the 1.16 of the evro investor with RVA. The case is not symmetric: what decides is the correlation of each index with
the foreign currency seen from each side. A minimum-variance ratio below 1 indicates that, in the data, the evro seen
from the dollar tends to move somewhat in the opposite direction to RVE and dampens its swings: removing it fully is
not what reduces the variance the most. With RVA the opposite happens (the dollar rises somewhat with RVA,
correlation of 0.07), and the ratio comes out above 1. In both cases the benefit of hedging is one to three points of
volatility: moderate in stocks.
- In the four-currency portfolio, compute the risk contribution when the dollar and the pound are hedged (the recommended policy). What share of the risk is still currency?
Solution
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"volatility: {vol(cubierta):.2f} %; currency: {divisa:.1f} %")
pct2.round(1).to_frame("riesgo_pct").Tvolatility: 18.16 %; currency: 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.3With the dollar and the pound hedged, the volatility drops to 18.16% and each currency and its hedge offset each other (3.7% against −3.7% for the dollar, 0.3% against −0.3% for the pound): what is left of currency risk is 0.1% of the variance, the unhedged yen plus the residuals. The portfolio's risk is now almost only that of the stocks: the hedging decision is made, and what is managed from there on is the equity.
- In the simulator, the client retries the 429s with a wait of 0.05 · 2^k seconds. Explain why an increasing wait is better than retrying right away and what you would add so that ten processes receiving the 429 at the same time do not collide again at the same instant.
Solution
A 429 says that the server is receiving more requests than it accepts. Retrying right away adds more requests exactly when there are too many, and prolongs the block; a wait that doubles on each attempt (0.05, 0.1, 0.2 s…) cuts the load quickly and reaches a rate the server accepts. If ten processes receive the 429 at the same time and all wait exactly the same, they collide again at the same time: a random part is added to the wait (jitter, e.g. multiplying it by a uniform number between 0.5 and 1.5) to spread out the retries. And if the server returns a header that indicates when to retry, it is respected over one's own formula. Lesson 1.4.1 has the same pattern for data downloads.
Footnotes
-
OANDA, Order Endpoints, Order Definitions (
MarketOrderRequest,LimitOrderRequest,OrderState) and Transaction Definitions (OrderFillTransaction,OrderCancelTransaction,OrderCancelReason), accessed on October 22, 2026. ↩ -
ib_async: API, version 2.1.0 on PyPI, accessed on October 22, 2026. ↩
-
MQL5, order_send, symbol_info, order properties (
ORDER_FILLING_*) and return codes, accessed on October 22, 2026. ↩
Each answer is folded under its question
Self-assessment
An investor in evros holds a US stock index with a local volatility of 22.1%, and the dollar, measured in evros, has one of 10.2%, with a correlation of 0.07 between the two. What is the volatility of the unhedged position?
- A22.1%: the currency adds no risk
- B32.3%: the volatilities add up
- CAbout 24.9%: the variances add up almost in full because the correlation is low
- D11.9%: the currency offsets
Show answer
Answer C. About 24.9%: the variances add up almost in full because the correlation is low
With correlation ρ, the variance is σ²_local + σ²_fx + 2ρσ_localσ_fx (plus a small cross term): √(0.221² + 0.102² + 2·0.07·0.221·0.102) ≈ 0.249. The data measures 24.9%. Hedging fully leaves it at 22.0%.
In the same case, hedging the dollar of a 10-year bond lowers its volatility from 14.6% to 10.2%; hedging the stock index, from 24.9% to 22.0%. Why does the hedge matter more for the bond?
- ABecause the volatility of the dollar (10%) is of the same order as that of the bond (10%) and much lower than that of the stocks (22%): in the bond the currency is almost half the risk
- BBecause bonds have more currency risk
- CBecause bonds pay a coupon
- DBecause the stocks are already hedged
Show answer
Answer A. Because the volatility of the dollar (10%) is of the same order as that of the bond (10%) and much lower than that of the stocks (22%): in the bond the currency is almost half the risk
The share of the risk that is currency depends on how much risk the asset has. In fixed income, the currency dominates; in stocks, it is diluted. That is why many portfolios almost always hedge foreign fixed income and decide case by case for stocks.
Since 2015, hedging the dollar with forwards costs the evro investor between 0.3% and 2.5% a year in the data. Where does that cost come from?
- AFrom the broker's commissions
- BFrom the forward's bid-ask spread
- CFrom the dollar having appreciated
- DFrom the interest rate differential: selling dollars forward is done at a price that reflects the dollar rate being higher than the evro rate (covered parity), plus the basis
Show answer
Answer D. From the interest rate differential: selling dollars forward is done at a price that reflects the dollar rate being higher than the evro rate (covered parity), plus the basis
Through covered parity, the forward reflects the interest rate differential. Whoever sells the high-rate currency forward receives less than spot: they pay the differential. In 2010-2014, with similar rates, the hedge cost almost nothing; with the dollar paying two or three points more, it costs that every year.
A REST client sends a market order and the connection times out with no response. What should it do before resending it?
- AResend it right away: if there is no response, it was not executed
- BLook up the order by its client ID (in OANDA,
GET /v3/accounts/{id}/orders/@{clientID}): the order may have arrived and been filled even though the response was lost - CWait until the next day
- DClose all positions
Show answer
Answer B. Look up the order by its client ID (in OANDA,
GET /v3/accounts/{id}/orders/@{clientID}): the order may have arrived and been filled even though the response was lostA timeout does not say whether the order arrived. Resending blindly can double the position. That is why every order carries a client ID (
clientExtensions.idin OANDA,orderRefin IBKR,magicandcommentin MT5) and the client looks it up before resending.MetaTrader 5 returns
retcode = 10004to a market order. What does it mean and what does a system do?- ARequote (TRADE_RETCODE_REQUOTE): the price is no longer valid; it asks for a new price and decides whether to resend within its maximum deviation
- BOrder filled: it does nothing
- CInsufficient funds: it reduces the size
- DMarket closed: it waits for the open
Show answer
Answer A. Requote (TRADE_RETCODE_REQUOTE): the price is no longer valid; it asks for a new price and decides whether to resend within its maximum deviation
According to the MQL5 return code table, 10004 is TRADE_RETCODE_REQUOTE, 10009 is TRADE_RETCODE_DONE, 10019 is not enough money and 10018 market closed. A requote is a rejection on price, like the last look of lesson 3.3.1: it is resent with the new price only if it is still within the bound.
Key points
- The return in one's own currency is (1 + r_local)(1 + r_fx) − 1. For an evro investor with a US index, the dollar adds 2.8 points of volatility (from 22.1% local to 24.9%); to a 10-year bond it adds 4.5 (from 10.2% to 14.6%).
- Hedging with a one-month forward trades currency risk for the interest rate differential: almost free in 2010-2014 and between 0.3% and 2.5% a year since 2015 in the data, with monthly cash flows that can reach 7% of the portfolio in one month.
- In a portfolio of five assets and four currencies, the currency is a small part of the risk (13% of the variance, 10.5 points from the dollar): hedging the dollar lowers the volatility from 19.7% to 18.2% and costs 0.65 points of return a year; also hedging the pound and the yen changes almost nothing.
- A future hedges the currency with a residual exposure: only whole contracts can be bought, the margin and daily settlements are in the contract's currency and the contract has to be rolled.
- Each API measures the order its own way: OANDA in signed units of the base currency, IBKR in quantity of the base with a BUY or SELL action, MT5 in lots with the broker's contract size. Converting wrong is an error by a factor of 100,000.
- A serious order client sets a price bound (
priceBound,deviation), retries with increasing backoff on the rate limit and, if there is no response, looks up its client ID before resending.