Lesson 2.3.4 · 70 min
Carry in currencies, futures and bonds; seasonality and calendar effects
Carry is what a position earns if prices do not move: a currency's interest rate differential, the slope of a future's curve, a bond's coupon and its roll down the curve. This lesson measures it on the level's synthetic data, where the premium is planted with its true value, turns it into portfolios with next-day execution and costs, shows why it crashes all at once and tests calendar effects on series where there are none.
- Module
- 2.3 · Strategy families
- Duration
- 70 min
- Prerequisites
- 1.2.3 · Futures: specifications, margin, roll and contango, 1.2.4 · Forex: pairs, pips, lots, rollover and CFDs, 1.6.3 · Seasonality: day of the week, month, turn of the month and intraday sessions, 2.1.1 · Hypothesis tests, p-values, bootstrap and regression with robust standard errors
- Author
- Jesús Cuesta
- Updated
Level 2 lessons
2.1 · Statistics and econometrics for trading
- 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
- 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
- 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
- 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
- 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
- 2.1.6 Kalman filter for dynamic hedge ratios
- 2.1.7 Regime detection: hidden Markov models and structural breaks
- 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
2.2 · Anatomy of a trading system
2.3 · Strategy families
- 2.3.1 Trend following and momentum
- 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
- 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
- 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
- 2.3.5 Factors (value, quality, low volatility, size) and event strategies
- 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
- 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
2.4 · Backtesting: engines and paradigms
- 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
- 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
- 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
- 2.4.4 backtesting.py and backtrader: two teaching engines and their status
- 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
- 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
- 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
- 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
2.5 · Biases and validation
- 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
- 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
- 2.5.3 Combinatorial purged cross-validation for strategies
- 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
- 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
- 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
- 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
- 2.5.8 “Honest backtest” checklist before moving to paper trading
2.6 · Performance metrics
What carry is
The carry of a position is what it returns if prices do not move. Koijen, Moskowitz, Pedersen and Vrugt (2018) define it this way for any asset and split it into two parts: the expected return if nothing changes (the carry) and the price change, which nobody knows in advance. The definition is useful because carry is known today: it is a signal without estimation, without parameters to optimize and with no possible look-ahead.
Mermaid source
flowchart TB C[Carry] --> FX[Currencies] C --> FU[Futures] C --> BO[Bonds] FX --> D1[Rate differential] FU --> D2[Curve slope] BO --> D3[Coupon and roll-down]
| Asset | Carry | What has to happen to collect it |
|---|---|---|
| Currency (long against the USD) | Currency's rate − USD rate | The exchange rate does not depreciate as much as the differential |
| Future (long) | Curve slope: ln(F₁ / F₂) / (τ₂ − τ₁) | The curve does not change and the contract converges toward spot |
| Bond (long, financed) | Yield − financing rate + roll-down | The yield curve does not move |
Theory says that carry should earn nothing: if a currency pays more interest, the market should expect it to depreciate by the same amount (uncovered interest rate parity), and if a future is in backwardation, spot should fall until it offsets it. What is observed in the literature is that this offset is only partial, and that carry earns a premium with a specific risk: it loses all at once in crises. Brunnermeier, Nagel and Pedersen (2008) document that high-yield currencies have negative skewness, linked to the unwinding of positions in stress episodes.
Currency carry: the interest rate differential
fx2_carry.parquet has ten fictitious currencies against the USD, on business days from 2005 to 2025: the price in USD
of one unit (spot) and the short-term rates of the currency and of the USD, in % a year. Whoever is long a currency
(buys it with borrowed dollars and deposits it) earns its change plus the interest rate differential, with the
money-market 360-day convention:
with the spot, the currency's rate, the USD rate and the calendar days since the previous session (3 on Mondays). The differential that counts is the previous day's: it is the one that was known when the position was opened.
import numpy as np
import pandas as pd
fx = pd.read_parquet("fx2_carry.parquet")
spot = fx.pivot(index="fecha", columns="divisa", values="spot")
dif = (fx.pivot(index="fecha", columns="divisa", values="tipo_divisa")
- fx.pivot(index="fecha", columns="divisa", values="tipo_usd"))
dias = spot.index.to_series().diff().dt.days
d_spot = np.log(spot).diff()
interes = dif.shift(1).div(100).mul(dias / 360, axis=0)
r_fx = d_spot + interes
anual = pd.DataFrame({
"diferencial_pct": dif.mean(),
"interes_pct": 100 * interes.mean() * 260,
"spot_pct": 100 * d_spot.mean() * 260,
"total_pct": 100 * r_fx.mean() * 260,
})
anual.round(2) diferencial_pct interes_pct spot_pct total_pct
divisa
ALV 7.32 7.40 0.43 7.83
BOR 5.10 5.16 -5.45 -0.29
CAS 2.92 2.95 -0.81 2.14
DUN 2.93 2.96 0.35 3.31
EST 1.20 1.21 -0.34 0.88
FOR 0.59 0.60 -2.82 -2.23
GAL -0.41 -0.41 2.09 1.68
HUR -1.06 -1.07 -0.19 -1.26
ISL -0.81 -0.81 2.43 1.61
JAR -1.77 -1.79 2.54 0.75The interes_pct column is the differential collected (somewhat larger than the mean differential because of the
360-day convention) and spot_pct, how much the exchange rate moved. ALV, the one with the highest rate (7.3 points
above the USD), collected 7.40% a year in interest and also appreciated 0.43%: 7.83% a year being long. BOR, the
second, collected 5.16% and lost 5.45% on the spot, almost all of it in a single month (February 2009, below). The
low-yield ones (GAL, ISL, JAR) paid interest and appreciated. The average pattern is that of the known answer,
but currency by currency the noise of the exchange rate, 10% to 15% a year, is much larger than the differential.
The Fama regression (1984) is the classic way to test uncovered parity. Fama used the forward premium, which by covered parity is the interest rate differential; here the differential is used directly: the change in the exchange rate over the next month on today's differential.
Under parity, : the currency depreciates by as much as it pays in excess. If the exchange rate were a random walk, and the carry would be collected in full. In the data, the known answer is outside the crashes, which add losses proportional to the differential. With ten currencies that share the same months, the residuals of the same month are correlated: the standard error has to be clustered by date (lesson 2.1.1).
import statsmodels.api as sm
fin_mes = (spot.index.to_series()
.groupby(spot.index.to_period("M")).max())
s_mes = np.log(spot.loc[fin_mes])
x_mes = dif.loc[fin_mes] / 100 / 12 # differential for the month
y_mes = s_mes.diff().shift(-1) # change over the next month
panel = pd.DataFrame({"y": y_mes.stack(), "x": x_mes.stack()}).dropna()
meses = panel.index.get_level_values("fecha").to_period("M")
fama = sm.OLS(panel["y"], sm.add_constant(panel["x"])).fit(
cov_type="cluster", cov_kwds={"groups": meses.astype(str)})
b, eb = fama.params["x"], fama.bse["x"]
print(f"b = {b:.2f} (standard error {eb:.2f})")
print(f"95% CI: [{b - 1.96 * eb:.2f}, {b + 1.96 * eb:.2f}]")
crash = meses.isin(pd.PeriodIndex(
["2008-09", "2008-10", "2008-11", "2008-12", "2010-05",
"2011-08", "2011-09", "2015-08", "2015-09", "2020-02",
"2020-03"], freq="M") - 1) # x at month end, y from the next one
sin = sm.OLS(panel["y"][~crash], sm.add_constant(panel["x"][~crash]))
sin = sin.fit(cov_type="cluster",
cov_kwds={"groups": meses[~crash].astype(str)})
print(f"without the crash months: b = {sin.params['x']:.2f} "
f"(standard error {sin.bse['x']:.2f})")b = -0.53 (standard error 0.36)
95% CI: [-1.24, 0.18]
without the crash months: b = -0.09 (standard error 0.34)The slope is −0.53, and its 95% interval goes from −1.24 to +0.18: it includes uncovered parity (−1), the random walk (0) and the known answer (−0.3). Without the crash months, the slope rises to −0.09 with a standard error of 0.34: a few months move the estimate by almost half a unit. It is the general lesson of the level applied to a famous regression: the method is correct, but with ten currencies and 251 months it does not have the power to separate the hypotheses that matter. That is why the return of a carry portfolio, which aggregates all the currencies and all the months into a single series, is a more direct measure of the premium.
A carry portfolio with next-day execution
The classic strategy ranks the currencies by differential and buys the high-yield ones against the low-yield ones.
Here, with ten currencies: at the close of the last day of each month they are ranked by differential, the three with
the highest rate are bought and the three with the lowest rate are sold, with a third of the capital in each. The
position enters in the next session (shift(1) on the daily weights) and is held until the next month end. Each
change in weight pays a cost of 3 basis points per side on the notional traded, a reasonable spread for liquid
currencies for an institutional client; for high-yield emerging-market currencies it is usually larger.
puesto = dif.loc[fin_mes].rank(axis=1)
pesos_mes = ((puesto >= 8).astype(float)
- (puesto <= 3).astype(float)) / 3
# signal at the month-end close, position from the next session
pesos = pesos_mes.reindex(spot.index, method="ffill").shift(1)
COSTE = 3 / 1e4 # per side
giro = pesos.diff().abs().sum(axis=1).fillna(pesos.abs().sum(axis=1))
bruto = (pesos * r_fx).sum(axis=1)
neto = (bruto - COSTE * giro)[pesos.abs().sum(axis=1) > 0]
def resumen(r, n=260):
eq = (1 + r).cumprod()
mensual = r.resample("ME").sum()
return pd.Series({
"rent_anual_pct": 100 * r.mean() * n,
"vol_anual_pct": 100 * r.std() * np.sqrt(n),
"sharpe": r.mean() / r.std() * np.sqrt(n),
"asimetria_mensual": mensual.skew(),
"peor_mes_pct": 100 * mensual.min(),
"max_dd_pct": 100 * (eq / eq.cummax() - 1).min(),
})
pd.DataFrame({"bruto": resumen(bruto[neto.index]),
"neto": resumen(neto)}).round(2) bruto neto
rent_anual_pct 3.56 3.50
vol_anual_pct 9.24 9.24
sharpe 0.38 0.38
asimetria_mensual -1.85 -1.85
peor_mes_pct -22.72 -22.72
max_dd_pct -49.61 -49.68Net of costs, the portfolio returns 3.50% a year with a volatility of 9.24%: a Sharpe of 0.38. Costs hardly matter (from 3.56% to 3.50%), because the ranking by rates changes little from one month to the next and very little is traded. What does matter are the other three rows: a monthly skewness of −1.85, a worst month of −22.7% and a maximum drawdown of 49.7%. With a volatility of 9% and normal returns, a drawdown of half the capital would be almost impossible; carry has one because its losses are not normal.
partes = pd.DataFrame({
"interes": (pesos * interes).sum(axis=1),
"divisa": (pesos * d_spot).sum(axis=1),
"costes": -COSTE * giro,
}).loc[neto.index]
(100 * partes.mean() * 260).round(2)interes 6.56
divisa -3.00
costes -0.05
dtype: float64The portfolio collects 6.56% a year of differential and loses 3.00% on the exchange rates: it keeps somewhat more than half of the carry. The known answer (the spot takes 30% of the differential, plus the crashes) predicts something similar. Costs, 0.05 points a year, are irrelevant at this frequency.
Carry crashes
The figure at the top is the curve of the net portfolio, with the generator's five crash periods shaded. The planted mechanism is the one the literature describes: in a flight to quality, high-yield currencies fall and low-yield ones, which act as a safe haven, rise. The portfolio is long the former and short the latter, so it loses on both sides at once.
crashes = {"2008 Q4": ("2008-09-15", "2008-12-31"),
"2010 May": ("2010-05-03", "2010-05-31"),
"2011 Aug-Sep": ("2011-08-01", "2011-09-30"),
"2015 Aug-Sep": ("2015-08-17", "2015-09-30"),
"2020 Feb-Mar": ("2020-02-24", "2020-03-31")}
en_crash = {k: 100 * neto[a:b].sum() for k, (a, b) in crashes.items()}
print(pd.Series(en_crash).round(1).to_string())
print()
mensual = neto.resample("ME").sum()
print((100 * mensual.nsmallest(5)).round(1).to_string())2008 Q4 -23.4
2010 May -7.6
2011 Aug-Sep -8.0
2015 Aug-Sep -4.4
2020 Feb-Mar -15.6
fecha
2009-02-28 -22.7
2020-03-31 -13.2
2008-11-30 -8.6
2022-08-31 -8.3
2010-05-31 -7.6In the last quarter of 2008 the portfolio loses 23.4%, and in February and March 2020 15.6%: two episodes that each
take away several years of differential. The worst month, however, is not in any planted period: February 2009, with
−22.7%, is the 37% fall of BOR in one month, a tail draw from the Student's t innovations of the volatility model. It
is a useful reminder: the tail risk of a portfolio concentrated in a few currencies is not limited to the crashes you
know about, and a backtest with the stress periods removed “because they were exceptional” overestimates what can be
earned.
Mermaid source
flowchart TB E[Market stress] --> V[Risk selling] V --> A[High yield falls] V --> B[Safe havens rise] A --> P[Carry loses] B --> P P --> L[Forced unwind] L --> A
The loop in the diagram is what makes carry crashes abrupt: losses force positions to be reduced, and the selling pushes the same currencies even lower. For a system, the practical consequence is that the Sharpe and the volatility do not describe the risk: you have to look at the skewness, the worst month and the losses in specific episodes, and size the position to survive them (lesson 2.2.2).
Futures carry: the slope of the curve
In a future, the carry is the slope of the curve between the contract held and the next one (lesson 1.2.3). If the
first trades above the second (backwardation), the carry is positive: with a still curve, the contract held rises
toward spot as it approaches expiry. futuros2_carry.parquet brings it computed for the 24 markets:
cont = pd.read_parquet("futuros2_continuos.parquet")
carry = pd.read_parquet("futuros2_carry.parquet")
spec = pd.read_csv("futuros2_especificaciones.csv",
index_col="mercado")
px = cont.pivot(index="fecha", columns="mercado",
values="cierre_ajustado")
c_anual = carry.pivot(index="fecha", columns="mercado",
values="carry_anual")
r_fut = np.log(px).diff()
(100 * c_anual.mean()).groupby(spec["sector"]).agg(
["mean", "min", "max"]).round(1) mean min max
sector
agricolas -3.6 -5.4 -1.4
bonos 1.1 0.3 2.2
divisas 0.6 -1.3 1.9
energia -2.4 -10.3 4.0
metales -2.7 -4.7 -0.6
renta_variable -0.0 -0.1 0.1Mean carry depends on the sector. Bonds have positive carry (1.1% on average), larger the longer the maturity; agriculturals and metals, negative (−3.6% and −2.7%), like the contango from storage and financing of many commodities; energy goes from −10.3% for gas to +4.0% for heavy crude; the indices, almost zero, because their carry in the generator is 2% minus the risk-free rate, which on average is close to 2%. Ranking the 24 markets by carry is partly ranking sectors, and partly something that changes over time: a commodity's carry moves with its price.
In this data carry has two parts, and they do not pay the same. Commodity, currency and bond prices revert toward a reference level (an anchor that grows slowly), as in Schwartz's (1997) one-factor model1Note 1 · Eduardo S. Schwartz, “The Stochastic Behavior of Commodity Prices: Implications for Valuation and Hedging”, The Journal of Finance 52(3), 1997, https://doi.org/10.1111/j.1540-6261.1997.tb02721.x.. When spot is well above the anchor, the curve goes into backwardation because the market already expects it to fall; when it is well below, into contango. That part of the carry, the reversion part, is “fair”: it discounts an expected move in spot and barely predicts the future's return. The other, the exogenous part (storage, financing, hedging pressure), is the one that carries the premium. The generator's known answer, in annual terms, is:
plus the equity premium in the indices and a fall in bonds in 2022. The last term is a trend premium: the market's volatility times the sign of its return over the previous 252 sessions. Only the sum is observed, and the reversion part is correlated with the trend: a market that has been rising for a year is above its anchor, in backwardation and with a positive trend. A regression on carry alone mixes the three things; with trend at the same time, it at least separates the last one.
fin_f = px.index.to_series().groupby(px.index.to_period("M")).max()
r_sig = np.log(px.loc[fin_f]).diff().shift(-1) * 12
sigma = r_fut.std() * np.sqrt(252) # σ of each market
tend = sigma * np.sign(np.log(px).diff(252)).loc[fin_f]
datos_c = pd.DataFrame({"y": r_sig.stack(),
"carry": c_anual.loc[fin_f].stack(),
"tend": tend.stack()}).dropna()
grupos = datos_c.index.get_level_values("fecha").astype(str)
for x in (["carry"], ["carry", "tend"]):
m = sm.OLS(datos_c["y"], sm.add_constant(datos_c[x])).fit(
cov_type="cluster", cov_kwds={"groups": grupos})
print(", ".join(f"{k} {m.params[k]:.2f} (se {m.bse[k]:.2f})"
for k in x))
print(f"n = {len(datos_c)}; carry-trend correlation: "
f"{datos_c['carry'].corr(datos_c['tend']):.2f}")carry 0.27 (se 0.13)
carry 0.17 (se 0.14), tend 0.11 (se 0.08)
n = 7176; carry-trend correlation: 0.49On its own, the carry slope is 0.27 with a standard error of 0.13: neither the 0.5 of the exogenous part nor the 0.1 of the reversion part, but a mix of the two. With trend in the regression, carry drops to 0.17 and trend comes out at 0.11 (truth: 0.15). Part of what the simple regression attributed to carry was momentum, because the two variables have a correlation of 0.49 at month end. The data README gives 0.26, 0.15 and 0.12 with the volatility from its table and another annualization: the same conclusion. The in the cell is each market's whole-sample volatility, a constant scale factor like the one in the table; it is fine for measuring, but for trading you would have to use that of the previous window.
The standard errors are clustered by month because the 24 markets share their surprises, and they are large: with carry and trend together, neither coefficient reaches two standard errors. The 7,176 market-months look like a lot, but they are 299 months of 24 correlated markets. And the regression cannot separate the two parts of carry, because it only sees the sum: the 0.5 planted in the exogenous part is not recovered with a regression on observed carry.
The figure groups the observations into twenty buckets by carry and plots the mean next-month return of each one, with the fitted line and, dashed, the one with slope 0.5 that would be seen if all the carry were exogenous. The relationship is visible, but so is its noise: each point is the mean of about 360 market-months and even so it sits several percentage points away from the line.
A futures carry portfolio with per-tick costs
The portfolio buys the eight markets with the highest carry and sells the eight with the lowest, with a weight inverse
to their 63-session volatility so that each position contributes a similar risk (2.5% a year). It is rebuilt at the
close of each month and enters in the next session. Costs come from the specifications: coste_tipico_ticks ticks of
slippage plus the commission, per contract and side, divided by the contract's notional (price × multiplier). In
addition, each roll (the change of contract) pays two sides on the whole position.
precio = cont.pivot(index="fecha", columns="mercado", values="cierre")
contrato = cont.pivot(index="fecha", columns="mercado",
values="contrato")
coste_pb = ((spec["coste_tipico_ticks"] * spec["valor_tick"]
+ spec["comision_usd"])
/ (precio * spec["multiplicador"]) * 1e4)
coste_pb.median().sort_values().round(1).iloc[[0, 1, 2, -3, -2, -1]]mercado
IDB 0.7
DIA 0.8
BOA 0.9
AGA 10.9
AGB 11.3
MEB 13.2
dtype: float64In the financial markets, one side costs less than one basis point of notional: 0.7 in the IDB index, 0.8 in the
DIA currency and 0.9 in the 2-year bond BOA. In the contracts whose tick and commission weigh more relative to the
notional, the cost is multiplied by more than ten: 10.9 basis points in corn AGA, 11.3 in wheat AGB and 13.2 in
silver MEB. Moreover, a cost measured in ticks depends on the price: if a contract's price halves, its cost in
percentage terms doubles. It is a detail that a backtest with a fixed cost in basis points does not see (lesson 2.4.7).
vol = r_fut.rolling(63).std() * np.sqrt(252)
puesto_f = c_anual.loc[fin_f].rank(axis=1)
n_f = puesto_f.notna().sum(axis=1)
lado = ((puesto_f > n_f.to_numpy()[:, None] - 8).astype(float)
- (puesto_f <= 8).astype(float))
pesos_f = (lado * 0.025 / vol.loc[fin_f]).fillna(0)
pesos_f = pesos_f.reindex(px.index, method="ffill").shift(1)
roll = contrato.ne(contrato.shift(1)) & contrato.shift(1).notna()
coste = (pesos_f.diff().abs() * coste_pb
+ 2 * pesos_f.abs() * coste_pb * roll) / 1e4
bruto_f = (pesos_f * r_fut).sum(axis=1)["2001":]
neto_f = bruto_f - coste.sum(axis=1)["2001":]
pd.DataFrame({"bruto": resumen(bruto_f, 252),
"neto": resumen(neto_f, 252)}).round(2) bruto neto
rent_anual_pct 7.36 5.71
vol_anual_pct 11.64 11.64
sharpe 0.63 0.49
asimetria_mensual 0.01 0.01
peor_mes_pct -12.67 -12.78
max_dd_pct -32.03 -33.71Gross, the portfolio returns 7.36% a year with a Sharpe of 0.63; net, 5.71% and 0.49. Costs, 1.65 points a year, take somewhat more than a fifth of the premium: the monthly rebalance changes few positions, but each roll pays two sides on the whole position, and in the expensive markets of the previous cell that adds up. Unlike currency carry, the monthly skewness is almost zero (0.01): in these futures, the high- and low-carry markets do not all fall at once in the script's crises. The maximum drawdown is 33.7%, and the result is not stable over time:
periodos = [("2001", "2009"), ("2010", "2019"), ("2020", "2025")]
sh = {f"{a}-{b}": resumen(neto_f[a:b], 252)["sharpe"]
for a, b in periodos}
print(pd.Series(sh).round(2).to_dict())
sector = spec["sector"].reindex(px.columns)
peso_22 = pesos_f.loc["2022"].mean().groupby(sector).sum()
res_22 = (pesos_f * r_fut).loc["2022"].sum().groupby(sector).sum()
pd.DataFrame({"peso_medio_2022": peso_22,
"resultado_2022_pct": 100 * res_22}).round(2){'2001-2009': 0.79, '2010-2019': 0.58, '2020-2025': -0.06}
peso_medio_2022 resultado_2022_pct
sector
agricolas -0.08 0.50
bonos 1.35 -23.90
divisas -0.15 2.23
energia 0.03 -4.42
metales -0.21 -1.56
renta_variable 0.06 0.31The net Sharpe is 0.79 in 2001-2009, 0.58 in 2010-2019 and −0.06 in 2020-2025. The last period has a clear culprit: in 2022 the portfolio is long bonds with an average weight of 135% of capital, and bonds lose 23.9 points that year (the generator plants a fall in bonds in 2022, like the one in real markets). Nobody chose that weight: it comes from weighting by inverse volatility. A 2-year bond with a volatility of 2% receives a notional about seventeen times larger than a crude with 35% to contribute the same risk in calm conditions, and when bonds fall together, as in 2022, the risk that was supposed to be spread out was concentrated. The data README measures a somewhat different portfolio (half the markets against the other half, weights of 10% over volatility) and finds the same: a Sharpe of 0.45 over the whole sample and negative in 2020-2025.
The portfolio, moreover, is not a pure bet on carry. With the “fair” reversion inside the signal and a correlation of 0.49 with trend, part of what it earns is disguised momentum, and part of what it pays is reversion that the curve had already discounted. Knowing which premium is being collected matters for combining it with other strategies (lesson 2.3.1).
Bonds: carry and roll-down
In a financed bond, carry has two parts. The first is the yield minus the rate at which the position is financed. The second, the roll-down: with an upward-sloping curve, a 10-year bond is a 9-year bond after one year, which is priced with a lower yield, and its price rises even though the curve has not moved. For a horizon (in years), with a still curve and modified duration :
The level's bond futures do not come with a yield curve, so the calculation is done with an example curve: a 10-year bond with a coupon equal to its yield, valued today and one year from now with the same curve.
plazos = np.array([1, 2, 5, 7, 9, 10, 30])
curva = np.array([3.50, 3.55, 3.80, 3.98, 4.16, 4.25, 4.70]) / 100
r_fin = 0.034 # one-year financing
def precio_bono(cupon, rend, anos):
t = np.arange(1, anos + 1)
return (cupon / (1 + rend) ** t).sum() + 1 / (1 + rend) ** anos
y10 = np.interp(10, plazos, curva)
y9 = np.interp(9, plazos, curva)
hoy = precio_bono(y10, y10, 10) # = 1 (at par)
en_un_ano = precio_bono(y10, y9, 9) # same curve
total = (en_un_ano + y10) / hoy - 1
print(f"coupon {100 * y10:.2f}%, roll-down "
f"{100 * (en_un_ano / hoy - 1):.2f}%, "
f"minus financing {100 * r_fin:.2f}%")
print(f"total one-year carry: {100 * (total - r_fin):.2f}%")coupon 4.25%, roll-down 0.66%, minus financing 3.40%
total one-year carry: 1.51%The bond collects its 4.25% coupon and gains 0.66% in price as it rolls from 10 to 9 years; financed at 3.40%, the total one-year carry is 1.51%. The formula's approximation gives the same: (4.25 − 3.40)% for one year, plus a duration of about 7.4 times the 9 basis points that separate the 10- and 9-year yields.
The figure draws it on the example curve: the bond starts at the 10-year maturity and, with a still curve, ends the year
at the 9-year one, with a lower yield. The steeper the segment it rolls down, the larger the roll-down, and the longer
the duration, the more price each basis point gives. A bond carry strategy chooses maturities with the highest carry
plus roll-down per unit of duration. In bond futures that carry is already in the slope of the futures curve: it is what
the bonos rows of the carry by sector measure.
Seasonality and calendar effects: the control
Lesson 1.6.3 measured the classic calendar effects (day of the week, month, turn of the month) and showed why almost
none holds up. Here they serve as a control: the level 2 generator plants no calendar effect at all in the equity
indices. Anything that shows up is chance or an artifact of the data. The five index futures (IDA to IDE) are
tested, comparing each day with the rest, not with zero:
from scipy import stats
indices = ["IDA", "IDB", "IDC", "IDD", "IDE"]
ri = r_fut[indices].dropna()
dia = ri.index.dayofweek
filas = []
for m in indices:
for d, nombre in enumerate(["Mon", "Tue", "Wed", "Thu", "Fri"]):
t, p = stats.ttest_ind(ri[m][dia == d], ri[m][dia != d],
equal_var=False)
filas.append({"indice": m, "dia": nombre, "t": t, "p": p})
dow = pd.DataFrame(filas)
print("p < 0.05:", (dow["p"] < 0.05).sum(), "of", len(dow),
"| expected by chance:", 0.05 * len(dow))
print(dow.nsmallest(3, "p").round(3).to_string(index=False))
print("mean correlation between indices:",
round(ri.corr().to_numpy()[np.triu_indices(5, 1)].mean(), 2))p < 0.05: 1 of 25 | expected by chance: 1.25
indice dia t p
IDD Thu 2.132 0.033
IDE Thu 1.571 0.116
IDD Mon -1.402 0.161
mean correlation between indices: 0.81One of the 25 tests has p < 0.05 (IDD on Thursdays, p = 0.033), where chance expects 1.25. And the 25 are not
independent: the five indices have a mean correlation of 0.81, so when one comes out “almost significant” on a day, the
others tend to point in the same direction. The effective number of tests is smaller than it seems, and that of false
positives, more variable.
g = ri.groupby(ri.index.to_period("M"))
cambio = (g.cumcount() < 3) | (g.cumcount(ascending=False) == 0)
estres = pd.Series(False, index=ri.index)
for a, b in [("2000-09-01", "2002-10-09"), ("2008-09-02", "2009-03-09"),
("2010-04-26", "2010-07-05"), ("2011-07-25", "2011-10-03"),
("2015-08-17", "2016-02-11"), ("2018-10-01", "2018-12-25"),
("2020-02-20", "2020-03-23"), ("2022-01-04", "2022-10-12"),
("2025-02-20", "2025-04-08")]:
estres[a:b] = True
def tom(r, sel):
a, b = r[cambio & sel], r[~cambio & sel]
p = stats.ttest_ind(a, b, equal_var=False).pvalue
return 1e4 * a.mean(), 1e4 * b.mean(), p
filas = {}
for m in indices:
for nombre, sel in [("all", estres | ~estres),
("calm", ~estres), ("stress", estres)]:
filas[(m, nombre)] = tom(ri[m], sel)
pd.DataFrame(filas, index=["cambio_pb", "resto_pb", "p"]).T.round(3) cambio_pb resto_pb p
IDA all -3.918 4.029 0.061
calm 3.925 8.931 0.192
stress -37.550 -18.249 0.202
IDB all -3.288 4.062 0.146
calm 6.372 7.828 0.757
stress -44.710 -13.054 0.069
IDC all -1.426 3.165 0.337
calm 8.367 8.104 0.954
stress -43.421 -19.280 0.122
IDD all -4.112 4.007 0.079
calm 3.014 9.480 0.126
stress -34.670 -20.870 0.400
IDE all -2.185 3.622 0.208
calm 7.813 7.804 0.998
stress -45.058 -15.388 0.064The turn of the month (the last day and the first three of each month) returns less than the rest in all five indices,
although in none with p < 0.05 (the lowest, IDA, with 0.061). Split by period, the difference almost disappears in
calm conditions (no p falls below 0.12) and concentrates in the stress periods, where turn-of-the-month sessions fall
on average between 35 and 45 basis points, 1.7 to 3.4 times what the other sessions fall. They are a few crisis
sessions with large falls, and several of the script's stress periods start on the first day of a month. Five
differences with the same sign look like a confirmation, but with indices correlated at 0.81 they are almost the same
test repeated five times. A data artifact that looks like a calendar effect: exactly what you need to know how to
recognize.
What carry demands in real trading
- Execution and costs. The signal is known at the close and the position enters in the next session. In currencies, the spread of the high-yield ones widens precisely in the crashes; in futures, low-priced contracts pay a lot per tick, and each roll is a fixed cost the carry has to pay (lesson 2.4.7).
- Real financing. The differential collected is that of the rates at which the broker or the bank pays and charges, not the official one. In a CFD or a retail forex account, the rollover deducts a margin on both rates (lesson 1.2.4) that can eat a good part of the carry.
- Tail risk. The average result hides a distribution with negative skewness. A carry portfolio has to be sized by its worst month, not by its volatility, and it is worth combining it with strategies that win in crises (the trend following of lesson 2.3.1 does so in this data).
- Concentration by sector and by notional. Futures carry ranked without further adjustment is partly a bet on sectors, and with inverse-volatility weights bonds take up the notional: in 2022, the lesson's portfolio had on average 135% of its capital in bonds. Neutralizing by sector (exercise 1) and capping the notional per sector change the portfolio a lot.
Exercises
- Repeat the futures carry portfolio ranking carry within each sector (subtract the sector mean at each date before ranking). Do the Sharpe and the skewness change?
Solution
sector = spec["sector"].reindex(c_anual.columns)
c_rel = c_anual.loc[fin_f] - c_anual.loc[fin_f].T.groupby(
sector).transform("mean").T
puesto_s = c_rel.rank(axis=1)
lado_s = ((puesto_s > n_f.to_numpy()[:, None] - 8).astype(float)
- (puesto_s <= 8).astype(float))
pesos_s = (lado_s * 0.025 / vol.loc[fin_f]).fillna(0)
pesos_s = pesos_s.reindex(px.index, method="ffill").shift(1)
coste_s = (pesos_s.diff().abs() * coste_pb
+ 2 * pesos_s.abs() * coste_pb * roll) / 1e4
neto_s = ((pesos_s * r_fut).sum(axis=1)
- coste_s.sum(axis=1))["2001":]
pd.DataFrame({"global": resumen(neto_f, 252),
"por_sector": resumen(neto_s, 252)}).round(2) global por_sector
rent_anual_pct 5.71 2.33
vol_anual_pct 11.64 8.99
sharpe 0.49 0.26
asimetria_mensual 0.01 0.21
peor_mes_pct -12.78 -7.17
max_dd_pct -33.71 -34.07Sector-neutral, the Sharpe drops from 0.49 to 0.26 and the return, from 5.71% to 2.33% a year. Somewhat more than half of the premium in this data is between sectors: the mean exogenous carries are different (gas in deep contango, bonds with positive carry) and the global portfolio takes advantage of them. The rest is within each sector, where each market's carry moves with its price. Volatility falls (from 11.64% to 8.99%) and so does the worst month (from −12.78% to −7.17%), but not the maximum drawdown (34.07%), and the monthly skewness turns positive (0.21). In real markets, Koijen and coauthors (2018) find the carry premium within each asset class as well.
- In the currency carry portfolio, raise the cost to 10 basis points per side and change the rebalance to weekly (last day of each week). How much Sharpe is lost in each case?
Solution
def cartera_fx(fechas, coste):
pt = dif.loc[fechas].rank(axis=1)
pm = ((pt >= 8).astype(float) - (pt <= 3).astype(float)) / 3
w = pm.reindex(spot.index, method="ffill").shift(1)
giro = w.diff().abs().sum(axis=1)
r = (w * r_fx).sum(axis=1) - coste / 1e4 * giro
return r[w.abs().sum(axis=1) > 0]
fin_sem = (spot.index.to_series()
.groupby(spot.index.to_period("W")).max())
casos = {"monthly, 3 bp": cartera_fx(fin_mes, 3),
"monthly, 10 bp": cartera_fx(fin_mes, 10),
"weekly, 3 bp": cartera_fx(fin_sem, 3),
"weekly, 10 bp": cartera_fx(fin_sem, 10)}
pd.Series({k: resumen(v)["sharpe"] for k, v in casos.items()}).round(2)monthly, 3 bp 0.38
monthly, 10 bp 0.37
weekly, 3 bp 0.35
weekly, 10 bp 0.33
dtype: float64The Sharpe barely moves: from 0.38 to 0.37 with 10 basis points, and to 0.35 or 0.33 with weekly rebalancing. The portfolio changes currencies so rarely that costs do not decide it; weekly rebalancing adds turnover without new information, because rates only change once a month. In a carry strategy, the natural frequency is that of the signal: rebalancing faster than it only pays costs.
- A colleague finds that
IDArises 3% on average in November (t = 2.5) and proposes buying the index only in that month. Explain what is missing before taking it seriously.
Solution
It is one of twelve months tested on one index, and the same test on the other four indices, which have a correlation of 0.8 with it, is not an independent test: the effective number of tests is large and one with t = 2.5 is what you would expect by chance (with twelve tests at 5%, the probability of at least one significant is 46%). A monthly effect is measured with one data point per year: 26 Novembers, of which one or two years of rebound after a crisis are enough to move the mean. What is missing is a prior hypothesis with a mechanism, the multiple-testing correction (lesson 2.1.8) and an out-of-sample test. In this data, moreover, the answer is known: the generator plants no calendar effect at all.
Footnotes
-
Eduardo S. Schwartz, “The Stochastic Behavior of Commodity Prices: Implications for Valuation and Hedging”, The Journal of Finance 52(3), 1997, https://doi.org/10.1111/j.1540-6261.1997.tb02721.x. ↩
Each answer is folded under its question
Self-assessment
In the Fama regression on the data, the change in the exchange rate on the interest rate differential gives a slope of −0.53 with a standard error of 0.36. What do you conclude?
- AThat uncovered interest rate parity holds (slope −1)
- BThat the exchange rate is a random walk (slope 0)
- CThat the regression is too imprecise to separate −1, −0.3 and 0 with 21 years of data; the portfolio return is a more direct measure
- DThat carry does not make money
Show answer
Answer C. That the regression is too imprecise to separate −1, −0.3 and 0 with 21 years of data; the portfolio return is a more direct measure
With a standard error of 0.36, the 95% interval goes from −1.24 to +0.18: it includes parity (−1), the known answer (−0.3) and the random walk (0). The crashes, moreover, push the slope toward more negative values. The Fama regression is the academic benchmark, but with ten currencies and twenty years it decides nothing on its own.
The currency carry portfolio has a monthly skewness of −1.8. What does it mean for whoever trades it?
- AThat it earns little and often and loses a lot all at once: the worst months coincide with flights to quality
- BThat it loses money almost every month
- CThat the Sharpe is overestimated because of a calculation error
- DThat its returns are normal
Show answer
Answer A. That it earns little and often and loses a lot all at once: the worst months coincide with flights to quality
A large negative skewness is the signature of carry: the differential is collected day by day and the high-yield currencies all fall at once when the market seeks safety. In the data, the last quarter of 2008 and February 2009 take away several years of gains.
A future trades at 100 with expiry in 2 months and the next one at 101 with expiry in 5 months. What is its annual carry with the lesson's definition?
- A+4% (backwardation)
- B+1%
- C0%
- D−4% (contango: the long loses if the curve does not change)
Show answer
Answer D. −4% (contango: the long loses if the curve does not change)
c = ln(100/101) / (5/12 − 2/12) = −0.00995 / 0.25 ≈ −4% a year. The curve rises with maturity (contango): if it does not move, the contract held comes to be priced at a shorter maturity, which is cheaper, and the long loses that 4% a year.
The day-of-the-week effect is tested on five stock indices: 25 tests and one comes out with p = 0.04. What do you do?
- ATrade that day
- BNothing: with 25 tests at 5% you expect 1.25 significant by chance, and the five indices also have a correlation of 0.8, so they are not 25 independent tests
- CRepeat the test with more decimal places
- DRemove the bad years and test again
Show answer
Answer B. Nothing: with 25 tests at 5% you expect 1.25 significant by chance, and the five indices also have a correlation of 0.8, so they are not 25 independent tests
A single p = 0.04 among 25 tests is exactly what chance gives. In this data no calendar effect is planted: it is the control. The multiple-testing correction (lesson 2.1.8) leaves it very far from any threshold.
Which part of the return of a 10-year bond is roll-down?
- AThe coupon
- BThe change in central bank rates
- CThe price gain obtained, with an unchanged curve, because the bond ages and comes to be priced at the lower yield of a shorter maturity
- DThe difference between the clean price and the dirty price
Show answer
Answer C. The price gain obtained, with an unchanged curve, because the bond ages and comes to be priced at the lower yield of a shorter maturity
With an upward-sloping curve, a 10-year bond is a 9-year bond one year later. If the curve does not move, its yield falls from the 10-year to the 9-year one and its price rises: that is the roll-down. The coupon minus the financing is carry in the strict sense.
Key points
- Carry is the return of a position if prices do not change: interest rate differential in currencies, curve slope in futures, coupon minus financing plus roll-down in bonds.
- In the data, currency carry earns 70% of the differential (the known answer); the three-against-three portfolio returns a Sharpe of 0.38 net of costs, with a monthly skewness of −1.85 and a maximum drawdown of 50%.
- The slope of futures returns on their carry comes out at 0.27 (standard error 0.13), less than the 0.5 of the exogenous part: a good part of the carry is mean reversion that the curve already discounts; with trend in the regression, it drops to 0.17.
- Carry loses all at once in flights to quality: the risk is in the tails, not in the volatility, and the Sharpe hides it.
- The signal is computed at the close and the position starts in the next session. The futures carry portfolio gives a net Sharpe of 0.49, but negative in 2020-2025: long bonds with inverse-volatility weights, it loses in 2022 when bonds fall.
- The calendar effects in the data are the control: the few significant ones that show up are what chance gives among dozens of correlated tests.
Resources
Documentation
- Koijen, Moskowitz, Pedersen and Vrugt (2018): Carry, Journal of Financial Economics 127(2)
- Fama (1984): Forward and spot exchange rates, Journal of Monetary Economics 14(3)
- Brunnermeier, Nagel and Pedersen (2008): Carry Trades and Currency Crashes, NBER Macroeconomics Annual 23
- statsmodels: OLS with clustered errors (cov_type="cluster")