Lesson 3.3.2 · 75 min
Carry and crash risk, momentum and value in currencies; baskets
Lesson 2.3.4 measured currency carry and its crashes. This one goes further: it extracts carry from forwards and separates the covered parity basis, decomposes the premium by portfolios, measures each crash against the planted one, builds momentum and value with the real exchange rate and combines them in a basket net of costs. The data are twelve fictitious currencies with the three effects planted and a control with none.
- Module
- 3.3 · Advanced forex
- Duration
- 75 min
- Prerequisites
- 1.2.4 · Forex: pairs, pips, lots, rollover and CFDs, 2.3.1 · Trend following and momentum, 2.3.4 · Carry in currencies, futures and bonds; seasonality and calendar effects, 2.5.6 · Parameter sensitivity: plateaus versus peaks, robustness across assets and periods, 2.6.1 · CAGR, Sharpe, Sortino, Calmar, Omega, drawdown and Ulcer Index
- 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)
Twelve currencies and what they have planted
Lesson 2.3.4 defined carry, built the classic three-versus-three portfolio with next-day execution, estimated the Fama
regression and looked at the crashes. Here all of that is taken as known and we work with a richer dataset: twelve
fictitious currencies against the USD, on business days from 2006 to 2025, each with its exchange rate, its
one-month forward, its deposit rates and its monthly CPI. Each currency mimics the profile of a real one: the evro
(QEV) the euro, the Nara yen (QYN) the yen, the Brava real (QBR) the Brazilian real. The data are synthetic and
three effects are planted with known parameters:
| Effect | What it does in the data | Planted parameter |
|---|---|---|
| Carry | Long the currency earns 80% of the rate differential (incomplete uncovered parity) | θ = 0.20 |
| Crash | In eight stress episodes, each currency falls k · (its differential − the mean) | k from 0.6 to 3.0 |
| Momentum | Annual drift in the direction of the excess return over 63 sessions | λ = 0.15 volatilities |
| Value | Each currency's own component reverts to zero | Half-life of 3 years |
| Control | sorpresas_macro: a series that does not enter any equation | No effect |
There is also a common dollar factor, a risk factor that lifts high-yield currencies and lowers safe havens (QYN,
QFR) in crises, and a covered parity basis in five currencies. Everything is in
public/curso/datos/nivel-3/README.md and in fx3_verdad.csv. The lesson measures each effect as if it did not know
it and compares.
Mermaid source
flowchart TB D[fx3_diario] --> C[Carry] D --> M[Momentum] I[fx3_ipc_mensual] --> V[Value] D --> V C --> R[Month-end ranks] M --> R V --> R R --> P[3 long, 3 short] P --> E[Scale by vol] E --> K[Net basket]
Carry in the forwards and the covered parity basis
A currency manager does not usually borrow in one currency to deposit in another: they buy forward. The one-month forward already contains the rate differential, by covered interest parity (lesson 1.2.4):
with and in USD per unit of the currency, the currency's rate and . If parity held, the USD rate implied by the forward would be the deposit rate. Since 2008 it does not hold in real currencies: Du, Tepper and Verdelhan (2018) document persistent deviations (the basis) in G10 currencies, which they attribute to arbitraging them consuming bank balance sheet. In the data there is a planted basis in five currencies. It is recovered with the exact formula; the usual log approximation, , leaves several basis points of error in the high-yield ones, where no longer resembles .
import numpy as np
import pandas as pd
import statsmodels.api as sm
fx = pd.read_parquet("fx3_diario.parquet")
def tabla(col):
return fx.pivot(index="fecha", columns="divisa", values=col)
X = tabla("usd_por_unidad")
i_div = tabla("tipo_deposito_1m")
i_usd = fx.groupby("fecha")["tipo_deposito_1m_usd"].first()
dif = i_div.sub(i_usd, axis=0) # % per year
cotiza_usd = fx.groupby("divisa")["par"].first().str.startswith("USD")
F = tabla("forward_1m")
F.loc[:, cotiza_usd] = 1 / F.loc[:, cotiza_usd] # USD per unit
TAU = 30 / 360
i_usd_impl = ((1 + i_div / 100 * TAU) * F / X - 1) / TAU * 100
base_pb = 100 * i_usd_impl.sub(i_usd, axis=0)
base_pb.median().round(1).sort_values().to_frame("base_pb").Tdivisa QYN QEV QFR QLB QSK QBR QCD QAD QNK QMP QZD QZR
base_pb -30.3 -15.4 -9.9 -5.4 -5.4 -0.0 0.0 -0.0 -0.0 -0.0 -0.0 -0.0The five planted bases come out almost exact: −30 bp in QYN, −15 in QEV, −10 in QFR and −5 in QLB and QSK
(the planted figures in calm periods; in stress episodes they widen, and the median ignores them). The rest, zero. A
negative basis means that getting dollars forward with that currency is more expensive than borrowing them: the USD
rate implied by the forward is below the deposit rate, and whoever sells the currency forward to hedge pays that
difference (lesson 3.3.3).
Does the basis matter for carry? For ranking currencies, hardly at all: 30 bp versus differentials of several points. Does it predict anything? In the model it does not enter any return equation: it is a control. The regression of the next month's excess return on the basis, with errors clustered by month (lesson 2.1.1), confirms it:
dias = X.index.to_series().diff().dt.days
interes = dif.shift(1).div(100).mul(dias / 360, axis=0)
d_spot = np.log(X).diff()
r = d_spot + interes # excess return
fin = X.index.to_series().groupby(X.index.to_period("M")).max()
sig_mes = r.groupby(r.index.to_period("M")).sum().shift(-1)
sig_mes.index = fin.values # the month after each month-end
panel = pd.DataFrame({"y": 1200 * sig_mes.stack(), # % per year
"base": base_pb.loc[fin].stack(),
"dif": dif.loc[fin].stack()}).dropna()
grupos = panel.index.get_level_values(0).astype(str)
for xs in (["base"], ["dif", "base"]):
m = sm.OLS(panel["y"], sm.add_constant(panel[xs])).fit(
cov_type="cluster", cov_kwds={"groups": grupos})
print(xs, "t =", m.tvalues[xs].round(2).to_dict())['base'] t = {'base': 0.3}
['dif', 'base'] t = {'dif': 1.75, 'base': -0.39}On its own, the basis has a t of 0.30; with the differential in the regression, −0.39. The differential has a t of 1.75: not even carry, which is planted, is significant currency by currency and month by month. It is the same power problem as the Fama regression of lesson 2.3.4. The useful measure is the portfolio.
The premium, portfolio by portfolio
Lustig, Roussanov and Verdelhan (2011) sort currencies by their rate differential (or their forward discount) into portfolios and document two factors: the dollar (the average of all currencies against the USD) and the currency HML (the high-yield portfolio minus the low-yield one). With twelve currencies you get four portfolios of three. At each month-end they are sorted by differential and held from the next session:
puesto = dif.loc[fin].rank(axis=1)
grupo = ((puesto - 1) // 3 + 1).reindex(X.index, method="ffill")
grupo = grupo.shift(1) # from the next session
filas = {}
for g in range(1, 5):
en_g = grupo == g
filas[f"C{g}"] = {
"dif_medio": dif.where(en_g).mean(axis=1).mean(),
"interes": 260 * 100 * interes.where(en_g).mean(axis=1).mean(),
"spot": 260 * 100 * d_spot.where(en_g).mean(axis=1).mean(),
"total": 260 * 100 * r.where(en_g).mean(axis=1).mean(),
}
pd.DataFrame(filas).T.round(2) dif_medio interes spot total
C1 -1.40 -1.41 1.39 -0.02
C2 -0.29 -0.29 0.35 0.06
C3 1.24 1.26 -0.69 0.57
C4 6.18 6.25 -0.31 5.95This is the header figure. The highest-yield portfolio (C4, 6.18 points over the USD on average) collects 6.25% a
year in interest and loses only 0.31% on the exchange rate: it keeps a 5.95% excess return. The lowest-yield one
(C1) pays 1.41% in interest and recovers it almost entirely on the exchange rate (+1.39%): its total is zero.
Uncovered parity predicts that the exchange rate offsets the differential in all of them; it does in the low-yield
ones and not in the high-yield ones, where the model collects 80% of the differential and also loses the crashes. That
C4 only loses 0.31% a year on the spot includes the crashes: they are lost all at once, but between one and the next
the high-yield currency does not even depreciate as much as parity says.
The difference C4 − C1 is the currency HML, and with twelve currencies in four groups of three it is exactly the
three-versus-three carry portfolio. The average of all of them against the USD is the dollar factor:
dolar = r.mean(axis=1)
hml = (r.where(grupo == 4).mean(axis=1)
- r.where(grupo == 1).mean(axis=1)).dropna()
def sharpe(x, n=260):
return x.mean() / x.std() * np.sqrt(n)
pd.DataFrame({"media_pct": [260 * 100 * dolar.mean(),
260 * 100 * hml.mean()],
"sharpe": [sharpe(dolar), sharpe(hml)]},
index=["dolar", "hml"]).round(2) media_pct sharpe
dolar 1.50 0.20
hml 5.97 0.52The dollar factor returns 1.50% a year with a Sharpe of 0.20: the average of the twelve currencies includes the high-yield ones and collects some carry. The HML, 5.97% with a Sharpe of 0.52. In the literature, the dollar explains most of the common variation of currencies and the HML the difference in average return between portfolios; in the data, by construction, the dollar factor is a bounded AR(1) with no drift and almost all of the premium is in the HML.
Crash risk, episode by episode
The three-versus-three carry portfolio, with the money market convention (360 days) and 260 business days for annualizing:
def cartera(senal, n=3):
"""Long the n with the highest signal and short the n lowest,
signal at month-end, position from the next session."""
s = senal.loc[fin]
k = s.notna().sum(axis=1)
pos = s.rank(axis=1)
w = (pos.gt(k - n, axis=0).astype(float)
- (pos <= n).astype(float)) / n
w = w.where(k >= 2 * n, 0.0, axis=0)
w = w.reindex(X.index, method="ffill").shift(1)
vivo = w.abs().sum(axis=1) > 0
return (w * r).sum(axis=1)[vivo], w[vivo]
def resumen(x, n=260):
m = x.resample("ME").sum()
curva = x.cumsum()
return pd.Series({
"sharpe": x.mean() / x.std() * np.sqrt(n),
"media_pct": 100 * x.mean() * n,
"vol_pct": 100 * x.std() * np.sqrt(n),
"asim_mensual": m.skew(),
"max_dd_pct": 100 * (curva - curva.cummax()).min(),
"peor_mes_pct": 100 * m.min(),
})
carry, w_carry = cartera(dif)
print(resumen(carry).round(2).to_string())
mensual = carry.resample("ME").sum()
print((100 * mensual.nsmallest(4)).round(1).to_string())sharpe 0.52
media_pct 5.97
vol_pct 11.50
asim_mensual -1.49
max_dd_pct -51.80
peor_mes_pct -27.61
fecha
2008-10-31 -27.6
2010-05-31 -8.7
2020-03-31 -8.3
2014-05-31 -8.1A Sharpe of 0.52, 5.97% a year with 11.50% volatility, a monthly skewness of −1.49, a maximum drawdown of 51.8% (in log) and a worst month of −27.6%, October 2008. The data's README gives 0.55 and 6.2% because it annualizes with 252 days and accrues interest with 365: the conventions move the second decimal of the Sharpe, not the conclusion. The other three worst months (May 2010, March 2020, May 2014) are much smaller: carry risk is concentrated in one episode.
Each episode, against the planted one
In each stress episode the generator makes each currency fall in log, spread over the episode. A cross-sectional regression of each currency's fall in the episode on its differential relative to the mean, at the start of the episode, should give a slope of :
tramos = pd.DataFrame({
"desde": ["2008-09-29", "2010-05-03", "2011-08-01", "2013-05-22",
"2015-08-11", "2018-04-16", "2020-02-24", "2024-07-31"],
"hasta": ["2008-10-27", "2010-05-25", "2011-09-23", "2013-06-24",
"2015-08-25", "2018-09-07", "2020-03-23", "2024-08-05"],
"k": [3.0, 0.8, 1.2, 1.0, 0.8, 0.6, 2.5, 0.6]})
filas = []
for t in tramos.itertuples():
antes = X.index[X.index < t.desde][-1]
caida = np.log(X.loc[t.hasta] / X.loc[antes])
x = (dif.loc[antes] - dif.loc[antes].mean()) / 100
fit = sm.OLS(caida, sm.add_constant(x)).fit()
filas.append({"desde": t.desde[:7],
"sesiones": len(X.loc[t.desde:t.hasta]),
"k": t.k, "pendiente": fit.params.iloc[1],
"error": fit.bse.iloc[1],
"carry_pct": 100 * carry[t.desde:t.hasta].sum()})
pd.DataFrame(filas).round(2) desde sesiones k pendiente error carry_pct
0 2008-09 21 3.0 -3.14 0.17 -28.41
1 2010-05 17 0.8 -1.12 0.09 -7.98
2 2011-08 40 1.2 -1.22 0.32 -8.28
3 2013-05 24 1.0 -0.17 0.16 0.64
4 2015-08 11 0.8 -0.81 0.12 -7.33
5 2018-04 105 0.6 2.62 0.96 28.18
6 2020-02 21 2.5 -2.38 0.22 -11.38
7 2024-07 4 0.6 -0.60 0.07 -4.46The two large, short episodes are recovered well: October 2008 (k = 3.0) gives −3.14 with an error of 0.17, and the pandemic (k = 2.5), −2.38 with 0.22. In them the portfolio loses 28.4% and 11.4%. The small episodes are recovered worse or not at all: 2013 (k = 1.0) gives −0.17, and 2018 (k = 0.6) gives +2.62: the portfolio gains 28.2% in a planted stress episode. That episode lasts 105 sessions and the planted fall, 0.6 times a differential of about 5 points, is about 3%; in those five months the high-yield currencies appreciated 20–30% because of the other components of the model (the risk factor, momentum, noise). The general lesson: a small effect spread over a long period is not seen in that period; a large, concentrated effect is. And a reading of real data that says “in episode X carry did not fall, so crash risk is a myth” makes the same mistake.
Global currency volatility
Menkhoff, Sarno, Schmeling and Schrimpf (2012) relate carry returns to innovations in global foreign exchange volatility: high-yield currencies lose when volatility rises unexpectedly, and low-yield ones act as a hedge. Their measure is the average of the absolute daily changes of all currencies. In the data, the volatility of the risk factor doubles in stress episodes, so the relationship should appear:
vol_global = (d_spot.abs().mean(axis=1).resample("ME").mean()
* np.sqrt(260))
ar1 = sm.OLS(vol_global, sm.add_constant(vol_global.shift(1)),
missing="drop").fit()
sorpresa = ar1.resid # what the AR(1) did not expect
j = pd.concat({"carry": 100 * mensual, "sorpresa": 100 * sorpresa},
axis=1).dropna()
m = sm.OLS(j["carry"], sm.add_constant(j["sorpresa"])).fit(
cov_type="HAC", cov_kwds={"maxlags": 3})
print("correlation:", round(j.corr().iloc[0, 1], 2))
print("slope:", round(m.params["sorpresa"], 2),
" t:", round(m.tvalues["sorpresa"], 2))correlation: -0.21
slope: -0.6 t: -1.37The correlation is −0.21, with the sign found in the literature: carry loses in the months when global volatility rises more than expected. But the slope, with errors robust to autocorrelation (HAC), has a t of −1.37: with 240 months, it is not significant. The risk is there, in the data and in the model, but concentrated in a few months; a regression over all months dilutes it.
Scaling by volatility: what it does and what it does not
The usual answer to crash risk is to scale the position by the inverse of its recent volatility (lesson 2.2.2 and the site's volatility targeting research). With a 10% annual target and the volatility of the previous 63 sessions, capped at 2 times:
vol_63 = carry.rolling(63).std().shift(1) * np.sqrt(260)
escala = (0.10 / vol_63).clip(upper=2.0)
carry_esc = (carry * escala).dropna()
pd.DataFrame({"carry": resumen(carry[carry_esc.index]),
"escalado": resumen(carry_esc)}).round(2) carry escalado
sharpe 0.51 0.42
media_pct 5.82 4.41
vol_pct 11.52 10.43
asim_mensual -1.49 -1.53
max_dd_pct -51.80 -45.34
peor_mes_pct -27.61 -23.72Scaling reduces the maximum drawdown from 51.8% to 45.3% and the worst month from 27.6% to 23.7%, but it lowers the Sharpe from 0.51 to 0.42 and leaves the skewness the same (−1.53). It does not protect against the first blow because carry crashes come after calm periods: in September 2008 the volatility of the previous three months was low and the position was at its maximum size. It helps with what comes afterwards, when volatility has already risen. Insurance against the first blow is something else: a size that survives it, or out-of-the-money options on the high-yield currencies, which cost premium (modules 3.4 and 3.5).
Mermaid source
flowchart TB A[Calm: low vol] --> B[Maximum position] B --> C[Crash: first month] C --> D[Vol rises] D --> E[Scaling reduces] E --> F[Smaller loss]
Momentum: cross-sectional and time-series
Currency momentum buys those that have risen most (in excess of the deposit) over the last months and sells those that have risen least. Menkhoff, Sarno, Schmeling and Schrimpf (2012, in the Journal of Financial Economics) document it in currencies with a distinctive feature: it is stronger in currencies with high costs that are hard to arbitrage. In the data it is planted as a drift of λ = 0.15 volatilities a year in the direction of the excess return of the previous 63 sessions. Two versions: cross-sectional (three versus three by rank, like carry) and time-series (each currency long or short according to its own sign, lesson 2.3.1).
exceso_63 = r.rolling(63).sum()
mom, w_mom = cartera(exceso_63)
ts_w = (np.sign(exceso_63.loc[fin]) / 12).reindex(
X.index, method="ffill").shift(1)
tsmom = (ts_w * r).sum(axis=1)[ts_w.abs().sum(axis=1) > 0]
print(pd.DataFrame({"transversal": resumen(mom),
"serial": resumen(tsmom)}).round(2).to_string())
# next month's return in annual volatilities
vol = d_spot.rolling(63).std() * np.sqrt(260)
y = r.rolling(21).sum().shift(-21) * 12 / vol
pan = pd.DataFrame({"y": y.loc[fin].stack(),
"senal": np.sign(exceso_63).loc[fin].stack()}
).dropna()
m = sm.OLS(pan["y"], sm.add_constant(pan["senal"])).fit(
cov_type="cluster",
cov_kwds={"groups": pan.index.get_level_values(0).astype(str)})
print(f"lambda = {m.params['senal']:.3f} "
f"(standard error {m.bse['senal']:.3f}; planted 0.15)") transversal serial
sharpe 0.20 0.16
media_pct 1.69 0.79
vol_pct 8.60 5.05
asim_mensual -0.24 -0.03
max_dd_pct -44.83 -28.49
peor_mes_pct -11.64 -5.77
lambda = 0.060 (standard error 0.109; planted 0.15)The cross-sectional portfolio has a Sharpe of 0.20 and the time-series one, 0.16. Both have a skewness close to zero, without carry's crash profile; but the worst month of cross-sectional momentum is also October 2008 (−11.6%): in a crash the currencies that had been rising were the high-yield ones. The direct estimate of the planted parameter gives λ = 0.060 with a standard error of 0.109: the interval includes the planted 0.15 and zero. With twelve currencies and 240 months, an effect of 0.15 volatilities a year is below what can be measured precisely, even if it is there.
Value: the real exchange rate
The value signal in currencies compares the exchange rate with an equilibrium value. The simplest is purchasing power parity (PPP): in the long run, the real exchange rate, , should revert to its mean. A currency whose real rate has risen a lot over five years is expensive; one that has fallen, cheap. Asness, Moskowitz and Pedersen (2013) use that signal (minus the change in the real rate over five years) in currencies, together with value in other asset classes. Menkhoff, Sarno, Schmeling and Schrimpf (2017) refine the measure by adjusting for productivity and other fundamentals1Note 1 · Menkhoff, Sarno, Schmeling and Schrimpf (2017), Currency Value, Review of Financial Studies 30(2)..
Two look-ahead details. A month's CPI is published with a delay: at the end of month only that of month is known, and that is what is used. And the signal needs five years of history, so value starts in 2011.
ipc = pd.read_csv("fx3_ipc_mensual.csv", parse_dates=["mes"])
ipc = ipc.set_index("mes")
rel = ipc[X.columns].div(ipc["USD"], axis=0).shift(1) # previous month
rel.index = rel.index.to_period("M")
q_real = np.log(X.loc[fin]) + np.log(rel.loc[fin.index].to_numpy())
ventanas = {}
for meses in (36, 48, 60, 72):
senal = -(q_real - q_real.shift(meses)).reindex(X.index)
v, _ = cartera(senal)
ventanas[meses] = resumen(v)[["sharpe", "media_pct",
"max_dd_pct"]]
val, w_val = cartera(-(q_real - q_real.shift(60)).reindex(X.index))
pd.DataFrame(ventanas).T.round(2) sharpe media_pct max_dd_pct
36 0.05 0.40 -35.41
48 0.34 2.99 -28.72
60 0.18 1.66 -30.46
72 0.60 5.19 -27.12With 60 months, the window of Asness, Moskowitz and Pedersen, value has a Sharpe of 0.18. With 48, 0.34; with 36, 0.05; with 72, 0.60. The data's README, with 1,260 sessions (about 58 months) and annualizing with 252 days, gives 0.30. Each window starts on a different date and leaves out different years, and with about fifteen years of signal the standard error of a Sharpe is around . The four figures are compatible with a real but weak premium; choosing the 72-month one because it comes out best would be overfitting (lessons 2.5.4 and 2.5.6).
Does the real rate recover the planted three-year half-life? The planted value is each currency's own component, without the dollar factor; the real rate mixes it with the dollar factor (five-year half-life), the carry drift and inflation differences:
vida = {}
for c in q_real:
z = q_real[c].dropna()
z = z - z.mean()
ar = sm.OLS(z.diff().iloc[1:], z.shift(1).iloc[1:]).fit()
vida[c] = np.log(2) / -ar.params.iloc[0] / 12 # years
pd.Series(vida).round(1).sort_values().to_frame("vida_anos").T QLB QFR QSK QEV QCD QAD QZD QNK QZR QMP QYN QBR
vida_anos 0.9 1.1 1.1 1.2 1.2 1.7 1.8 2.2 2.5 3.2 4.9 5.1Half-lives range from 0.9 years (QLB) to 5.1 (QBR), with a median of 1.75: almost all below the planted three and
with a huge dispersion. The short ones are short because the real rate carries noise that reverts quickly (that of the
CPI, the momentum moves), and the AR(1) mixes the two speeds. The longest are those of currencies with extreme
differentials (QBR very high, QYN very low): the carry drift, which moves the real rate year after year in one
direction, looks like persistence. It is the same problem PPP has in real data, where estimates of the half-life of
real exchange rate deviations are several years and very imprecise: a currency value signal works with a slow
reversion measured with a lot of noise.
The control
sorpresas_macro is an AR(1) series per currency that does not enter any equation. A three-versus-three portfolio
with it as the signal should earn nothing:
sorpresas = tabla("sorpresas_macro")
control, _ = cartera(sorpresas)
resumen(control).round(2).to_frame("control").T sharpe media_pct vol_pct asim_mensual max_dd_pct peor_mes_pct
control 0.1 0.71 6.99 0.45 -28.69 -5.17A Sharpe of 0.10: 0.71% a year with a maximum drawdown of 28.7%. With no planted effect, twenty years of a currency portfolio return something by chance. It is the yardstick for momentum (0.20) and value (0.18): they are above it, but not far. Carry (0.52) does stand apart.
Costs: turnover decides
The three signals change at very different rates. Carry depends on central bank rates, which move little; 63-session
momentum changes ranking almost every month. With a cost per side of 3 bp of notional in G10 currencies and 10 bp in
emerging ones (QMP, QBR, QZR), a reasonable assumption for an institutional client that will have to be checked
against your own prices (lessons 2.4.7 and 3.3.1):
coste_lado = pd.Series(3e-4, index=X.columns)
coste_lado[["QMP", "QBR", "QZR"]] = 10e-4
brutas = {"carry": (carry, w_carry), "momentum": (mom, w_mom),
"value": (val, w_val)}
netas, filas = {}, {}
for nom, (bruta, w) in brutas.items():
giro = w.diff().abs().fillna(w.abs())
coste = (giro * coste_lado).sum(axis=1)
netas[nom] = bruta - coste
anos = len(w) / 260
filas[nom] = {"giro_anual": giro.sum(axis=1).sum() / anos,
"coste_pct": 100 * 260 * coste.mean(),
"sharpe_bruto": resumen(bruta)["sharpe"],
"sharpe_neto": resumen(netas[nom])["sharpe"]}
pd.DataFrame(filas).T.round(2) giro_anual coste_pct sharpe_bruto sharpe_neto
carry 1.60 0.06 0.52 0.51
momentum 20.38 1.02 0.20 0.08
value 5.69 0.26 0.18 0.15Carry turns over 1.6 times a year and pays 0.06% a year: its Sharpe goes from 0.52 to 0.51. Value, 5.7 times and 0.26% (from 0.18 to 0.15). Momentum turns over 20 times a year and pays 1.02% a year, more than half of its gross return: its Sharpe drops from 0.20 to 0.08, below the control. A momentum premium that looks small but real in a backtest without costs comes to nothing with reasonable costs; in emerging currencies, with wider bid-ask spreads, worse.
A basket of carry, momentum and value
The three premia have little to do with each other:
N = pd.DataFrame(netas).dropna()
N.resample("ME").sum().corr().round(2) carry momentum value
carry 1.00 0.12 0.02
momentum 0.12 1.00 -0.23
value 0.02 -0.23 1.00A correlation of 0.12 between carry and momentum, 0.02 between carry and value, and −0.23 between momentum and value. The negative correlation between value and momentum is the one Asness, Moskowitz and Pedersen (2013) document across all asset classes: a currency that has risen a lot (high momentum) has become more expensive (low value). The basket combines them with the same risk: each strategy is scaled to 10% volatility with its standard deviation over the previous 252 sessions (not that of the whole sample, which would be look-ahead bias) and the basket is the average of the three.
vol_ant = N.rolling(252, min_periods=126).std().shift(1)
escaladas = (N * 0.10 / (vol_ant * np.sqrt(260))).dropna()
cesta = escaladas.mean(axis=1)
tabla_cesta = pd.DataFrame({c: resumen(escaladas[c])
for c in escaladas})
tabla_cesta["cesta"] = resumen(cesta)
print("from", cesta.index[0].date())
tabla_cesta.round(2)from 2011-08-24
carry momentum value cesta
sharpe 0.82 0.32 0.31 0.91
media_pct 8.38 3.24 3.16 4.93
vol_pct 10.16 10.25 10.30 5.39
asim_mensual 0.28 0.09 -0.09 -0.02
max_dd_pct -24.67 -35.12 -23.63 -12.31
peor_mes_pct -8.79 -10.30 -6.98 -4.78Since August 2011 (when all three have at least half a year of history to estimate their volatility), the basket has a Sharpe of 0.91 with 5.39% volatility and a maximum drawdown of 12.3%. Scaled carry alone, over the same period, 0.82 and a 24.7% drawdown; momentum, 0.32; value, 0.31. The basket barely improves the Sharpe of carry, because since 2011 carry has been the best of the three and has not had an October 2008; where it shows is in the maximum drawdown, which is halved, and in the skewness, which becomes almost zero.
periodos = [("2011", "2015"), ("2016", "2020"), ("2021", "2025")]
pd.DataFrame({f"{a}-{b}": {**{c: resumen(escaladas[c][a:b])["sharpe"]
for c in escaladas},
"cesta": resumen(cesta[a:b])["sharpe"]}
for a, b in periodos}).round(2) 2011-2015 2016-2020 2021-2025
carry 1.12 0.18 1.23
momentum 0.04 0.31 0.55
value 0.77 0.19 0.03
cesta 0.95 0.53 1.28No strategy wins in every subperiod. Carry has a Sharpe of 0.18 in 2016-2020 and 1.23 in 2021-2025; value, 0.77 in 2011-2015 and 0.03 in 2021-2025; momentum, 0.04 at the beginning and 0.55 at the end. The basket has the most stable Sharpe: 0.95, 0.53 and 1.28. That stability, more than a higher average Sharpe, is what you buy by diversifying across premia that do not depend on the same thing.
What a currency basket demands in live trading
- Size by the worst episode, not by volatility. Carry has a volatility of 11% and a drawdown of 52%. The basket reduces the second, but does not eliminate it.
- Per-currency costs. Momentum dies from costs; in emerging currencies the bid-ask spread is several times that of the G10, and in crashes it widens further (lesson 3.3.1).
- Windows and rebalancing dates. Value changes from 0.05 to 0.60 with the window. Any parameter that cannot be justified before looking at the result is a degree of freedom that inflates the backtest (lesson 2.5.4).
- Look-ahead bias. CPI with publication lag, signal at the month-end close and position from the next session, volatility weights with past data. All three mistakes show up in published backtests.
- Implementation. Positions are taken with currency forwards or swaps, not with deposits: carry is collected in the forward points, basis included. Lesson 3.3.3 covers execution and hedging the currency risk of a portfolio.
Exercises
- Build a basket of only net carry and momentum, which can be measured from 2006, with the same past-volatility scaling. Compare with scaled carry alone: Sharpe, maximum drawdown and result in October 2008.
Solution
dos = pd.DataFrame({k: netas[k] for k in ("carry", "momentum")})
dos = dos.dropna()
v2 = dos.rolling(252, min_periods=126).std().shift(1)
esc2 = (dos * 0.10 / (v2 * np.sqrt(260))).dropna()
cesta2 = esc2.mean(axis=1)
oct08 = {c: 100 * esc2[c]["2008-10"].sum() for c in esc2}
oct08["cesta"] = 100 * cesta2["2008-10"].sum()
t = pd.DataFrame({"carry": resumen(esc2["carry"]),
"cesta": resumen(cesta2)}).T
t["oct_2008_pct"] = [oct08["carry"], oct08["cesta"]]
t.round(2) sharpe media_pct vol_pct asim_mensual max_dd_pct peor_mes_pct oct_2008_pct
carry 0.53 5.48 10.32 -1.93 -46.06 -27.05 -27.05
cesta 0.37 3.01 8.06 -2.08 -43.39 -20.84 -20.84The two-strategy basket starts in September 2006 (half a year of history for the volatility) and includes October 2008. Its Sharpe is worse than that of carry alone (0.37 versus 0.53): momentum net of costs barely returns anything and takes up half of the risk. In October 2008 it loses 20.8% versus carry's 27.1%, because only half is in carry, but momentum also loses that month: in a crash, the currencies that had been rising are the high-yield ones. The maximum drawdown barely changes (−43.4% versus −46.1%) and the skewness gets worse (−2.08). Diversifying with a weak premium that is correlated in the worst month does not protect: the 12% drawdown of the three-strategy basket, measured from 2011, says nothing about 2008.
- The dollar factor mixes the carry premium with USD exposure. Build a carry portfolio “against the dollar”: long the three with the highest differential, no shorts. Compare its correlation with the dollar factor and with the three-versus-three portfolio.
Solution
puesto_d = dif.loc[fin].rank(axis=1)
w_d = ((puesto_d > 9).astype(float) / 3).reindex(
X.index, method="ffill").shift(1)
carry_usd = (w_d * r).sum(axis=1)[w_d.sum(axis=1) > 0]
comp = pd.DataFrame({"carry_usd": carry_usd, "carry_3x3": carry,
"dolar": dolar}).dropna()
print(comp.resample("ME").sum().corr().round(2).to_string())
resumen(carry_usd).round(2).to_frame("carry_usd").T carry_usd carry_3x3 dolar
carry_usd 1.00 0.77 0.80
carry_3x3 0.77 1.00 0.28
dolar 0.80 0.28 1.00
sharpe media_pct vol_pct asim_mensual max_dd_pct peor_mes_pct
carry_usd 0.51 5.95 11.59 0.12 -53.44 -14.65The portfolio long only the high-yield currencies has a monthly correlation of 0.80 with the dollar factor and 0.77 with the three-versus-three carry, which in turn has only 0.28 with the dollar. Its return is almost the same (5.95% a year, Sharpe of 0.51) and its maximum drawdown somewhat larger (53.4%), but its worst month is −14.7%, not −27.6%, and its skewness is positive (+0.12). The difference is in the shorts: in a crash, the low-yield currencies are safe havens that rise, and the three-versus-three portfolio loses on both sides; the long one against the USD only on one, in exchange for carrying dollar risk all the time. That is why studies separate the dollar factor from the HML: they are two different risks with two different tail profiles.
- A colleague proposes the 72-month window for value because it gives a Sharpe of 0.60. Which three checks would you ask for before accepting it?
Solution
First, that the window be justified before looking at the result: the planted three-year half-life or the five-year convention of the literature, not the Sharpe. Second, that the result be stable around the chosen window (66 and 78 months should give something similar) and across subperiods; a figure that jumps from 0.34 to 0.18 and to 0.60 in neighboring windows is noise. Third, that it be corrected for the number of windows tried: the deflated Sharpe ratio of lesson 2.5.4 with four trials and about fifteen years of data leaves 0.60 far from conclusive. And a fourth, a specific one: the 72-month window starts in 2012, a year after the 60-month one, and it is not the same sample.
Footnotes
-
Menkhoff, Sarno, Schmeling and Schrimpf (2017), Currency Value, Review of Financial Studies 30(2). ↩
Each answer is folded under its question
Self-assessment
In the data, the covered parity basis of the QYN is −30 bp. What does it mean for a carry strategy that uses forward points as its signal?
- AThat the QYN carry is 30 bp better than deposits say
- BThat there is a risk-free arbitrage of 30 bp a year
- CThat the differential implied by the forward departs from that of deposits by 30 bp; it ranks the currencies almost the same and, in the data, the basis predicts nothing (t = −0.39 with the differential in the regression)
- DThat the forward is computed wrong
Show answer
Answer C. That the differential implied by the forward departs from that of deposits by 30 bp; it ranks the currencies almost the same and, in the data, the basis predicts nothing (t = −0.39 with the differential in the regression)
The basis is the difference between the USD rate implied by the forward and the deposit rate. It has existed since 2008 in real currencies (Du, Tepper and Verdelhan, 2018) because arbitraging it consumes bank balance sheet. For carry it barely changes the ranking of currencies, and in this data it is a control: it does not enter any return equation.
The three-versus-three carry portfolio has a Sharpe of 0.52 since 2006; scaled by volatility and net of costs, 0.82 since August 2011. What mainly explains the difference?
- AThat the short period does not include the October 2008 crash, the worst month (−27.6%): the carry Sharpe depends on whether or not the sample has a large crash
- BThat the strategy improved over time
- CThat costs went down
- DA calculation error
Show answer
Answer A. That the short period does not include the October 2008 crash, the worst month (−27.6%): the carry Sharpe depends on whether or not the sample has a large crash
Carry earns steadily and gives it back in a few months. A sample without its worst episode gives a positive skewness (+0.28) and a much higher Sharpe. That is why carry is always presented with its worst episode, not just with its Sharpe.
The measured slope of the 2018 stress episode is +2.62, with k = 0.6 planted (high-yield currencies should fall). What is happening?
- AThe generator has a bug
- BThe episode lasts 105 sessions and the planted fall, about 0.6 × 5 points ≈ 3%, is buried under 20–30% moves of the high-yield currencies from other sources; the short and large episodes (2008, 2020) are recovered well
- CThat in 2018 carry had no risk
- DThat 2018 should be removed from the sample
Show answer
Answer B. The episode lasts 105 sessions and the planted fall, about 0.6 × 5 points ≈ 3%, is buried under 20–30% moves of the high-yield currencies from other sources; the short and large episodes (2008, 2020) are recovered well
A small effect spread over five months has a very low signal-to-noise ratio. In 2008 (k = 3.0) −3.14 is measured with an error of 0.17, and in 2020 (k = 2.5), −2.38. What is not seen in one episode does not stop existing: it is seen by aggregating many.
Scaling the carry portfolio by its volatility over the previous 63 days reduces the maximum drawdown from 51.8% to 45.3%, but the worst month only goes from −27.6% to −23.7%. Why does it not protect more?
- ABecause the scaling uses future volatility
- BBecause the scaling increases costs
- CBecause volatility does not change in crises
- DBecause carry crashes come after calm periods: past volatility was low and the position was at its maximum size when the fall began
Show answer
Answer D. Because carry crashes come after calm periods: past volatility was low and the position was at its maximum size when the fall began
Scaling reacts to volatility that has already happened. It helps in long crises, in which volatility rises and the position is reduced in time for the rest, but not with the first blow. Insurance against the first blow is another instrument (options, modules 3.4 and 3.5) or a size that survives it.
The value signal with a 36-month window gives a Sharpe of 0.05; with 48, 0.34; with 60, 0.18; with 72, 0.60. What do you do?
- AUse 72 months
- BDiscard value
- CUse last year's window
- DAverage or set the window for prior reasons (the planted half-life is 3 years) and treat the dispersion as the uncertainty of the method, not as a parameter to optimize
Show answer
Answer D. Average or set the window for prior reasons (the planted half-life is 3 years) and treat the dispersion as the uncertainty of the method, not as a parameter to optimize
With 12 currencies and about 15 years of signal, the Sharpe of a particular window has a standard error of around 0.25. Choosing the best of four is overfitting (lessons 2.5.4 and 2.5.6). The dispersion across windows is the useful information: the value in this data is real but weak.
Key points
- Carry can be read from forwards: the implied differential matches that of deposits except for the covered parity basis, which in the data is recovered exactly (−30 bp in the QYN, −15 in the QEV) and does not predict returns.
- Sorted by differential, the portfolios earn almost everything through interest: the highest-yield one collects 6.25% a year and loses 0.31% on the exchange rate. Its difference with the lowest-yield one is the currency HML: the 3-versus-3 carry.
- Carry loses suddenly in stress episodes: −28.4% in October 2008, with a measured slope of −3.14 versus the planted k = 3.0. Small, long episodes cannot be distinguished from noise.
- Momentum and value are weaker and more fragile premia: cross-sectional momentum (Sharpe 0.20 gross) is left with almost nothing after costs (0.08) because of its turnover of 20 times a year, and value changes from 0.05 to 0.60 depending on the window.
- The basket of the three with past-volatility weights and costs has a Sharpe of 0.91 and a maximum drawdown of 12% since 2011, versus 0.82 and 25% for carry alone, scaled the same way, over the same period: its advantage lies in the drawdowns and in each factor's bad subperiods, not in the Sharpe.
- The data are synthetic and the effects are planted: the figures show the methods and their traps, they do not prove that these strategies work in real markets; that is what the cited literature is for.
Resources
Research
Documentation
- Lustig, Roussanov and Verdelhan (2011): Common Risk Factors in Currency Markets, Review of Financial Studies 24(11)
- Menkhoff, Sarno, Schmeling and Schrimpf (2012): Carry Trades and Global Foreign Exchange Volatility, Journal of Finance 67(2)
- Menkhoff, Sarno, Schmeling and Schrimpf (2012): Currency momentum strategies, Journal of Financial Economics 106(3)
- Asness, Moskowitz and Pedersen (2013): Value and Momentum Everywhere, Journal of Finance 68(3)
- Du, Tepper and Verdelhan (2018): Deviations from Covered Interest Rate Parity, Journal of Finance 73(3)