Lesson 2.3.1 · 75 min
Trend following and momentum
Two families with the same name: buying what rises against its own history (trend) or against the others (cross-sectional momentum). This lesson builds both with costs and next-session execution, on 24 futures and 120 synthetic stocks with the premium planted, and compares them with controls that have none.
- Module
- 2.3 · Strategy families
- Duration
- 75 min
- Prerequisites
- 1.4.6 · Continuous futures series: roll rules, difference adjustment and ratio adjustment, 1.4.4 · Universes without survivorship bias: delistings, mergers and symbol changes, 1.6.1 · Returns, volatility, rolling correlations and drawdown
- 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
Two ideas with the same name
“Momentum” is used for two different strategies that share an intuition: what has gone up tends to keep going up for a while. The difference lies in what it is compared against.
- Time series momentum (TSMOM, or simply trend): each asset is compared with its own past. If the crude oil future has risen over the last twelve months, you buy; if it has fallen, you sell. Each market is decided separately and the portfolio can be long or short everything at once. It is what CTAs and managed futures funds do.
- Cross-sectional momentum: assets are compared with each other. Each month the stocks are ranked by their past return, the best are bought and the worst are sold. The portfolio is neutral by construction: it wins if the winners beat the losers, even if the whole market falls.
Mermaid source
flowchart TB P[Past prices] --> TS[Against own past] P --> XS[Against the others] TS --> S1[Sign per asset] XS --> S2[Cross-sectional rank] S1 --> R1[Net long or short] S2 --> R2[Neutral long-short]
Both have broad academic evidence in real data. Jegadeesh and Titman documented in 1993 that buying winners and selling losers of the previous 3 to 12 months gave significant positive returns in US stocks, and that part of it was undone over the following two years1Note 1 · N. Jegadeesh and S. Titman, “Returns to Buying Winners and Selling Losers: Implications for Stock Market Efficiency”, Journal of Finance 48(1), 1993, 65-91. DOI 10.1111/j.1540-6261.1993.tb04702.x.. Moskowitz, Ooi and Pedersen found in 2012 time series momentum in each of the 58 liquid futures they studied (indices, currencies, commodities and bonds): persistence of 1 to 12 months that partly reverses at longer horizons, and a diversified portfolio that performs best in extreme markets2Note 2 · T. J. Moskowitz, Y. H. Ooi and L. H. Pedersen, “Time series momentum”, Journal of Financial Economics 104(2), 2012, 228-250. DOI 10.1016/j.jfineco.2011.11.003. Scaling at 40% and EWMA estimator with a center of mass of 60 days: sections 2.4 and 4.1; the smile, figure 4.. This lesson does not repeat those figures: it builds the strategies with the method of those papers and tests them on data where the answer is known.
Futures: returns, rolls and costs
The 24 markets in futuros2_continuos.parquet cover indices, bonds, energy, metals, agriculturals and currencies from
2000 to 2025. The cierre_ajustado column is the continuous series with backward proportional adjustment at each roll:
its percentage change is the return of holding the future (lesson 1.4.6). The contrato column says which expiry is
held each day, and with it you detect the rolls, which cost money.
import numpy as np
import pandas as pd
fut = pd.read_parquet("futuros2_continuos.parquet")
spec = pd.read_csv("futuros2_especificaciones.csv")
spec = spec.set_index("mercado")
ancho = lambda col: fut.pivot(index="fecha", columns="mercado",
values=col)
px, cierre, contrato = (ancho("cierre_ajustado"), ancho("cierre"),
ancho("contrato"))
r = px.pct_change()
roll = (contrato != contrato.shift()).astype(float)
roll.iloc[0] = 0.0
print(px.shape[1], "markets,", len(px), "sessions,",
px.index[0].date(), "to", px.index[-1].date())
rolls_ano = (roll.sum() / 26).round().astype(int)
print("rolls per year:", rolls_ano.value_counts().sort_index().to_dict())24 markets, 6539 sessions, 2000-01-03 to 2025-12-31
rolls per year: {4: 14, 5: 5, 6: 1, 7: 1, 12: 3}Four rolls a year in the financials (quarterly expiries), twelve in energy (monthly) and between four and seven in metals and agriculturals. Each roll is two trades: closing the expiring contract and opening the next one.
The cost of a trade comes from the specifications: a typical slippage in ticks per side plus the commission per
contract. To express it as a fraction of the notional you have to divide by the contract value,
close × multiplier:
c_lado = (spec["coste_tipico_ticks"] * spec["tick"] / cierre
+ spec["comision_usd"] / (cierre * spec["multiplicador"]))
pb = (1e4 * c_lado.median()).sort_values()
print(pb.round(1).iloc[[0, 1, 2, 12, 21, 22, 23]].to_string())
print(f"median of the 24 markets: {pb.median():.1f} bp")mercado
IDB 0.7
DIA 0.8
BOA 0.9
BOE 3.1
AGA 10.9
AGB 11.3
MEB 13.2
median of the 24 markets: 2.7 bpThe output shows the three cheapest, the market in the middle and the three most expensive. The median of the 24 is 2.7 basis points per side, but the extremes are almost twenty times apart for a reason worth remembering with real data: the tick is fixed in price. In the IDB index, a 0.25-point tick on a median price of about 5,000 weighs half a basis point; in silver (MEB), a 0.005 USD tick on about 7.9 USD weighs more than 6, and the specifications assume two ticks of slippage per side: 13.2 bp. The relevant cost is not “one tick”, but one tick divided by the price, and it changes over time: wheat (AGB) moves in this data between 1.86 and 11.58 USD, so the same tick costs it six times more in percentage terms at its low than at its peak.
Time series momentum with volatility scaling
The Moskowitz, Ooi and Pedersen rule has two pieces. The signal is the sign of the return over the last twelve months. The sizing gives each market the same ex ante risk: the position is a volatility target (40% a year in the paper) divided by the volatility estimated with an exponential average of squared daily returns, with a center of mass of 60 sessions and data up to the previous day2:
Without that scaling, natural gas (50% annual volatility in this data) would weigh twenty-five times more than the 2-year bond (2%). With it, the average of the 24 markets is a portfolio in which each one contributes a similar risk.
Execution is the same as in the whole level: the signal is computed with the close of session t and traded at the
close of t+1; the position gains or loses from there. Costs are charged on each change of position and on the whole
position at each roll (two sides).
Mermaid source
flowchart LR C[Close t] --> S[Signal and size] S --> E[Order close t+1] E --> G[Earns from t+1]
def backtest(senal, r, objetivo=0.40):
"""Signal in {-1, 0, 1} decided at the close of t; traded at t+1."""
vol = r.ewm(com=60, min_periods=60).std() * np.sqrt(252)
w = (senal * objetivo / vol).clip(-4, 4)
pos = w.shift(1).fillna(0.0) # held from the close of t+1
bruto = (pos.shift(1) * r).fillna(0.0)
giro = (pos - pos.shift()).abs()
coste = (giro * c_lado + pos.abs() * roll * 2 * c_lado).fillna(0.0)
return bruto.mean(axis=1), coste.mean(axis=1), pos
def sharpe(x):
return x.mean() / x.std() * np.sqrt(252)
def resumen(bruto, coste):
neto = bruto - coste
return pd.Series({
"sharpe_bruto": sharpe(bruto), "sharpe_neto": sharpe(neto),
"vol_anual": neto.std() * np.sqrt(252),
"coste_anual": coste.mean() * 252,
"max_dd": ((1 + neto).cumprod()
/ (1 + neto).cumprod().cummax() - 1).min()})
tsmom = np.sign(px / px.shift(252) - 1)
b_ts, c_ts, pos_ts = backtest(tsmom, r)
resumen(b_ts, c_ts).round(3)sharpe_bruto 0.946
sharpe_neto 0.772
vol_anual 0.105
coste_anual 0.018
max_dd -0.212
dtype: float64The 24-market portfolio has a gross Sharpe of 0.95 and a net one of 0.77, with a volatility of 10.5% a year (close to
the 12% the paper gets with the same 40% target per market) and a maximum drawdown of 21%. Costs, 1.8 points a year,
take almost a fifth of the Sharpe: volatility scaling changes the position a little every day, and each roll pays two
sides on the whole position. The data README gives, with another weighting (10% over the 63-session volatility) and
without costs, a Sharpe of 0.98 for the same signal: the engine recovers what the generator put in. The clip(-4, 4)
caps a market's leverage at four times its notional when its estimated volatility collapses. The figure at the top
shows how 1 USD grows with this rule and with the other two in the lesson, against the control in the next section.
vol = r.ewm(com=60, min_periods=60).std() * np.sqrt(252)
pos_m = (tsmom * 0.40 / vol).clip(-4, 4).shift(1).fillna(0.0)
bruto_m = (pos_m.shift(1) * r).fillna(0.0)
coste_m = ((pos_m - pos_m.shift()).abs() * c_lado
+ pos_m.abs() * roll * 2 * c_lado).fillna(0.0)
por_mercado = pd.DataFrame({
"bruto": bruto_m.apply(sharpe),
"neto": (bruto_m - coste_m).apply(sharpe)})
print(por_mercado.describe().loc[["mean", "50%", "min", "max"]]
.round(2).T.to_string())
print("markets with net Sharpe > 0:",
(por_mercado["neto"] > 0).sum(), "of 24")
rho = (bruto_m.corr().to_numpy()[np.triu_indices(24, 1)]).mean()
print(f"mean correlation between markets: {rho:.2f}") mean 50% min max
bruto 0.29 0.26 -0.13 0.79
neto 0.24 0.24 -0.25 0.71
markets with net Sharpe > 0: 21 of 24
mean correlation between markets: 0.05Market by market, the mean gross Sharpe is 0.29 and the median 0.26: above the planted 0.15, because the rule also captures the trends in the script (the long crises in the indices, the fall in bonds in 2022) and the drift of the indices. The range goes from −0.13 to 0.79, and 21 of the 24 markets win net: with 26 years of data, the standard error of an annual Sharpe is ≈ 1/√26 ≈ 0.20, so the dispersion between markets is almost all noise. What turns a small, noisy edge into a portfolio with a Sharpe of 0.95 is diversification: with a mean correlation of 0.05 between the results of the markets,
It is the raison d'être of CTAs: no individual trend is reliable, but many weakly correlated ones do add up.
A control you have to know how to read
How much of that Sharpe is real trend? The natural control is to repeat the strategy on permuted returns: the same daily returns of each market in a random order, without any memory. If the rule only exploited trend, it would give zero. There are two ways to permute, and in general they do not say the same thing:
rng = np.random.default_rng(2028)
def permuta(r, quitar_media):
out = r.copy()
for m in r:
v = r[m].to_numpy().copy()
ok = ~np.isnan(v)
x = v[ok] - (v[ok].mean() if quitar_media else 0.0)
v[ok] = rng.permutation(x)
out[m] = v
return out
for quitar_media in (False, True):
res = []
for _ in range(10):
rp = permuta(r, quitar_media)
pp = (1 + rp.fillna(0)).cumprod()
b, c, _ = backtest(np.sign(pp / pp.shift(252) - 1), rp)
res.append([sharpe(b), sharpe(b - c)])
m, s = np.mean(res, axis=0), np.std(res, axis=0)
print(f"demeaned={quitar_media!s:5} gross {m[0]:+.2f} "
f"± {s[0]:.2f} net {m[1]:+.2f} ± {s[1]:.2f}")demeaned=False gross +0.06 ± 0.17 net -0.21 ± 0.18
demeaned=True gross -0.12 ± 0.22 net -0.39 ± 0.22Both permutations give a gross Sharpe compatible with zero: +0.06 ± 0.17 without subtracting the mean and −0.12 ± 0.22 subtracting it, over ten permutations each. Net, −0.21 and −0.39: what it costs to trade without an edge. Against those controls, the real rule's 0.95 is more than four standard deviations away: it is trend, not noise.
The difference between the two versions is the one you have to know how to read. Permuting preserves each market's mean. In a market that rises on average, the twelve-month return is positive most of the time and the rule is almost always long: it collects the drift, not a trend. That is why the control without subtracting the mean comes out above the other (here, by about 0.2 of Sharpe, which with ten permutations does not separate from noise either). In this data that part is small because there is almost no drift to collect: bonds have no term premium, commodities and currencies revert to an anchor through the carry, and only the five indices carry the equity premium. In real markets, with decades of rising bonds and equity premium, the drift can be a large part of what a trend rule that is long on average earns. Both sources are legitimate, but they are two different bets and it is worth knowing how much each one weighs.
Moving-average crossovers and Donchian breakouts: the same idea, another horizon
The classic CTA rules are variations of the same idea with other filters:
- Moving-average crossover: long if the 50-session average is above the 200-session one; short if it is below (lesson 1.7.1 for the averages).
- Donchian breakout: long when the price exceeds the high of the previous
nsessions; short when it breaks the low. The original Turtle rules, published by one of the traders in the group, used two systems based on Richard Donchian's channel: System 1, with a 20-session breakout and an exit on breaking the opposite 10-session extreme, and System 2, with 55 and 203Note 3 · “The Original Turtle Trading Rules”, published at tradingblox.com/originalturtles: N and units on pages 13-14, entry systems on page 18 and exits on page 26. The Turtles traded the breakout as soon as it happened during the session; here it is traded at the next close, which is more conservative.. Sizing depended onN, the 20-session exponential average of the true range: each unit was sized so that a move ofNcost 1% of the account. It is another way of scaling by volatility.
The breakout is a state machine: flat, long or short, with transitions that depend on the channel.
Mermaid source
stateDiagram-v2 [*] --> FLAT FLAT --> LONG: breaks 55 high FLAT --> SHORT: breaks 55 low LONG --> FLAT: loses 20 low SHORT --> FLAT: exceeds 20 high LONG --> SHORT: breaks 55 low SHORT --> LONG: breaks 55 high
With close data there are no session highs and lows, so the channel is computed with closes and the sizing uses the same EWMA volatility as the 12-month rule: that way the three rules differ only in the signal.
cruce = np.sign(px.rolling(50).mean() - px.rolling(200).mean())
def donchian(p, entrada=55, salida=20):
alto = p.rolling(entrada).max().shift(1).to_numpy()
bajo = p.rolling(entrada).min().shift(1).to_numpy()
s_alto = p.rolling(salida).max().shift(1).to_numpy()
s_bajo = p.rolling(salida).min().shift(1).to_numpy()
a = p.to_numpy()
out = np.zeros(a.shape)
for j in range(a.shape[1]):
est = 0
for i in range(a.shape[0]):
if np.isnan(alto[i, j]):
continue
x = a[i, j]
if x > alto[i, j]:
est = 1
elif x < bajo[i, j]:
est = -1
elif est == 1 and x < s_bajo[i, j]:
est = 0
elif est == -1 and x > s_alto[i, j]:
est = 0
out[i, j] = est
return pd.DataFrame(out, index=p.index, columns=p.columns)
ruptura = donchian(px)
b_cr, c_cr, _ = backtest(cruce, r)
b_dn, c_dn, _ = backtest(ruptura, r)
tabla = pd.DataFrame({"tsmom_12m": resumen(b_ts, c_ts),
"cruce_50_200": resumen(b_cr, c_cr),
"donchian_55_20": resumen(b_dn, c_dn)})
print(tabla.round(3).to_string())
print("time in the market of the breakout:",
f"{(ruptura != 0).to_numpy().mean():.0%}") tsmom_12m cruce_50_200 donchian_55_20
sharpe_bruto 0.946 0.819 0.647
sharpe_neto 0.772 0.701 0.491
vol_anual 0.105 0.106 0.091
coste_anual 0.018 0.012 0.014
max_dd -0.212 -0.214 -0.311
time in the market of the breakout: 63%All three rules win with the same planted premium, and the order follows the horizon. The 12-month rule, which looks at exactly what the generator rewards (the sign of the previous 252 sessions), has the best net Sharpe (0.77); the 50/200 crossover, which looks at trends of several months, comes close (0.70, with the same 21% maximum drawdown); the Donchian breakout is the worst (0.49 net, 31% drawdown). The breakout is only in the market 63% of the time, because the 20-session exit leaves it flat after any pullback, and it reacts to moves of two or three months that here are mostly noise. With a single dataset and three rules, the difference between 0.70 and 0.77 is within the error of a 26-year Sharpe (≈ 0.2); the one from 0.49 to 0.77 starts not to be, and it has a reason: the planted effect is one year long.
netos = pd.DataFrame({"tsmom": b_ts - c_ts, "cruce": b_cr - c_cr,
"donchian": b_dn - c_dn})
netos.loc["2001":].corr().round(2) tsmom cruce donchian
tsmom 1.00 0.71 0.33
cruce 0.71 1.00 0.30
donchian 0.33 0.30 1.00The 12-month rule and the 50/200 crossover have a correlation of 0.71: both look at trends of months. The 55-session breakout is faster and only correlates 0.33 and 0.30 with the other two. In practice, CTAs combine several horizons for exactly that reason: they diversify across trend speeds as well as across markets.
Is there a trend smile?
One property that makes trend valuable in a portfolio is how it behaves when equities move a lot. Moskowitz, Ooi and Pedersen plot the quarterly returns of their diversified portfolio against those of the S&P 500 and find a smile shape: it does better in extreme quarters, up and down2. It is not a law of the rule, but a consequence of what the crises in their sample were like: long, and preceded by declines that had already put the rule short. Here you can check whether it shows up in data where the planted effect does not depend on the state of equities.
trim = lambda x: (1 + x).resample("QE").prod() - 1
q = pd.DataFrame({"indice": trim(r["IDA"].fillna(0)),
"tendencia": trim(b_ts - c_ts)}).loc["2001":]
a2, a1, a0 = np.polyfit(q["indice"], q["tendencia"], 2)
print(f"fit: {a0:+.3f} {a1:+.3f}·x {a2:+.3f}·x²")
extremos = q["indice"].abs() > q["indice"].abs().quantile(0.8)
print(q.groupby(extremos)["tendencia"].mean().rename(
{False: "central 80%", True: "extreme 20%"}).round(4))
print(q.nsmallest(4, "indice").round(3))fit: +0.021 +0.078·x -0.094·x²
indice
central 80% 0.0236
extreme 20% 0.0131
Name: tendencia, dtype: float64
indice tendencia
fecha
2020-03-31 -0.510 -0.078
2001-09-30 -0.275 0.178
2008-12-31 -0.268 0.062
2011-09-30 -0.209 -0.086It does not show up. The x² term of the fit is −0.094, slightly negative (an almost flat and somewhat concave parabola, not a smile), and the slope, +0.078: the trend portfolio rises a little with equities, because it is long the indices most of the time. In the central 80% of the index's quarters, trend gains 2.4% on average; in the 20% most extreme quarters, 1.3%. Of the index's four worst quarters, two were good for trend (the third quarter of 2001, −27.5% for the index and +17.8% for trend, and the fourth of 2008, −26.8% and +6.2%) and two bad (the first of 2020, −51.0% and −7.8%, and the third of 2011, −20.9% and −8.6%).
The difference between some crises and others is easier to see with the stress periods in the data script (those in the README), from when the signal exists (January 2001), looking at the position in the IDA index at the start of each period:
tramos = [("2001-01-03", "2002-10-09"), ("2008-09-02", "2009-03-09"),
("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")]
neto = b_ts - c_ts
filas = []
for a, b in tramos:
filas.append({"desde": a, "sesiones": len(r.loc[a:b]),
"IDA": (1 + r["IDA"].loc[a:b]).prod() - 1,
"tendencia": (1 + neto.loc[a:b]).prod() - 1,
"pos_IDA_ini": pos_ts["IDA"].loc[a:].iloc[0]})
print(pd.DataFrame(filas).set_index("desde").round(3).to_string()) sesiones IDA tendencia pos_IDA_ini
desde
2001-01-03 442 -0.499 0.001 1.559
2008-09-02 130 -0.406 0.140 -2.693
2011-07-25 50 -0.213 -0.098 2.731
2015-08-17 124 -0.071 -0.138 3.689
2018-10-01 59 -0.115 -0.088 4.000
2020-02-20 23 -0.400 -0.082 2.939
2022-01-04 195 -0.038 0.072 2.307
2025-02-20 34 -0.144 -0.049 2.743In six of the eight periods the rule started long the index (pos_IDA_ini positive), because they came after a
bullish period. The fast declines of 23 to 59 sessions (2011, 2018, 2020 and 2025) catch it long and do not give it
time to turn: it loses between 4.9% and 9.8% in each one. In 2015-2016 it loses 13.8% in a choppy period in which the
index only falls 7.1%. The 2001-2002 period, the longest (442 sessions and −49.9% for the index), it ends flat
(+0.1%): it turns short and wins the third quarter of 2001, but gives almost everything back in the rebounds. The two
periods it wins are the ones the script makes long or preceded by declines: 2008-2009 (+14.0%), in which it was already
short the index at the start, and 2022 (+7.2% with the index at −3.8%), where the gain comes from bonds, which fall for
months.
That is the useful reading of the smile. A twelve-month rule protects in crises that arrive slowly or that last, and
loses in sharp declines after a bull market; the premium planted here (0.15·σ·sign) does not depend on the state of
equities, so the shape of the chart depends only on what the crises in the sample are like. In the Moskowitz, Ooi and
Pedersen data, the first kind predominated. It is not insurance against the next crash.
12-1 cross-sectional momentum in stocks
The universo2 universe has 120 fictitious stocks from 2006 to 2025, with IPOs, four bankruptcies and six
acquisitions or mergers. The classic signal is the 12-1 return: from the close twelve months ago to the one a month
ago, skipping the last month (which in real data has a short-term reversal effect of its own; lesson 2.3.2).
Three decisions avoid the usual biases:
- Point-in-time universe: each month only the stocks that traded that month enter (lesson 1.4.4).
- Next-session execution: the signal is computed with the close of the last day of the month and the portfolio is formed at the close of the first session of the following month. The return runs from that session to the first one of the following month.
- Delisting return: if a stock stops trading during the month, its return includes the delisting return (−100% in a bankruptcy). Without it, bankruptcies disappear just before their worst day.
aj = pd.read_parquet("universo2_ajustados.parquet")
P = aj.pivot(index="fecha", columns="ticker",
values="cierre_ajustado")
ev = pd.read_csv("universo2_eventos.csv", parse_dates=["fecha"])
desl = ev.dropna(subset=["rentabilidad_deslistado"])
ses = P.index
mes = ses.to_period("M")
fin = ses[np.r_[mes[1:] != mes[:-1], True]][:-1] # last day
ent = ses[ses.searchsorted(fin, side="right")] # next session
Pf = P.loc[fin]
mom = Pf.shift(1) / Pf.shift(12) - 1 # 12-1
Pe = P.loc[ent].set_axis(fin)
fwd = Pe.shift(-1) / Pe - 1 # from ent to ent
for _, d in desl.iterrows(): # month in which it stops trading
k = ent.searchsorted(d["fecha"]) - 1
p0 = Pe.iloc[k][d["ticker"]]
p_ult = P[d["ticker"]].dropna().iloc[-1]
fwd.iloc[k, fwd.columns.get_loc(d["ticker"])] = (
p_ult / p0 * (1 + d["rentabilidad_deslistado"]) - 1)
mom, fwd = mom.loc["2007":], fwd.loc["2007":]
validas = mom.notna() & fwd.notna()
n_mes = validas.sum(axis=1).iloc[:-1] # the last month has no future
print(len(n_mes), "portfolio months; stocks per month:",
n_mes.min(), "to", n_mes.max())
print(fwd.loc[:, ["QXQT", "QXKV"]].min().round(3).to_dict())226 portfolio months; stocks per month: 106 to 110
{'QXQT': -1.0, 'QXKV': -1.0}On the order of a hundred stocks per month (from 106 to 110: IPOs enter when they have twelve months of history and delistings leave). The first two bankruptcies have a return of −100% in their last month: the data point that would be missing if only prices were used.
The universo2_caracteristicas.parquet file brings those returns already computed: each stock that stops trading has
a last row without a price, with a rentabilidad_mes that chains the month-to-date return with the delisting return.
Here they are built by hand from the events to see the mechanism and because the portfolio runs from the first session
of one month to that of the next, not from month end to month end. When that file is read further down for the ESG
placebo and the bid-ask spread, those rows change nothing: they fall in the month in which the stock no longer has a
next return to predict.
The standard measure of a cross-sectional signal is the information coefficient (IC): the correlation, each month, between the signal and the next return across the whole cross-section. The mean of the monthly ICs and its statistic say whether there is a relationship. There are two versions:
from scipy import stats
def ic_mensual(senal):
filas = []
for d in senal.index[:-1]:
v = senal.loc[d].notna() & fwd.loc[d].notna()
if v.sum() < 50:
continue
x, y = senal.loc[d][v], fwd.loc[d][v]
filas.append((d, stats.spearmanr(x, y).statistic,
np.corrcoef(x, y)[0, 1]))
return pd.DataFrame(filas, columns=["fecha", "rangos", "lineal"]
).set_index("fecha")
ic = ic_mensual(mom)
t = ic.mean() / ic.std() * np.sqrt(len(ic))
pd.DataFrame({"ic_medio": ic.mean(), "t": t}).round(3) ic_medio t
rangos 0.027 2.808
lineal 0.041 4.295The rank IC (Spearman) is 0.027 with t = 2.8: what the generator predicts (≈ 0.03). The linear IC (Pearson) is 0.041 with t = 4.3: it looks like a signal 50% stronger and much more reliable. It is not. The linear correlation weights each observation by its distance from the mean, and four bankruptcies, guided over 320 sessions to a last price of between 0.30 and 1.50 USD and ending at −100%, are the most extreme observations in the panel in both variables at once: very negative momentum and very negative next return. A few rows move the correlation. The rank IC only uses the order and sees them as no more than “the worst” of their month.
Deciles, bankruptcies and a placebo
The usual picture of momentum is ten portfolios: each month the stocks are ranked by their signal, split into deciles and the mean return of each one is measured. The “winners minus losers” portfolio (WML) buys decile 10 and sells decile 1.
def deciles(senal, rent):
out = {}
for d in senal.index[:-1]:
v = senal.loc[d].notna() & rent.loc[d].notna()
if v.sum() < 50:
continue
g = pd.qcut(senal.loc[d][v].rank(method="first"), 10,
labels=range(1, 11))
out[d] = rent.loc[d][v].groupby(g, observed=True).mean()
return pd.DataFrame(out).T
D = deciles(mom, fwd)
quiebras = ["QXQT", "QXKV", "QXZT", "QXYX"]
D_sq = deciles(mom, fwd.drop(columns=quiebras))
car = pd.read_parquet("universo2_caracteristicas.parquet")
esg = car.pivot(index="fecha", columns="ticker",
values="puntuacion_esg").reindex(mom.index)
D_esg = deciles(esg.where(mom.notna()), fwd)
anual = pd.DataFrame({"momentum": 12 * D.mean(),
"sin_quiebras": 12 * D_sq.mean(),
"placebo_esg": 12 * D_esg.mean()})
anual.loc["10-1"] = anual.loc[10] - anual.loc[1]
print(anual.round(3).T.to_string())
wml = D[10] - D[1]
w_esg = D_esg[10] - D_esg[1]
for nombre, x in (("WML momentum", wml), ("10-1 placebo", w_esg)):
print(f"{nombre}: t = {x.mean() / x.std() * np.sqrt(len(x)):.2f}")
ic_esg = ic_mensual(esg.where(mom.notna()))["rangos"]
print(f"rank IC of the placebo: {ic_esg.mean():.3f}, t = "
f"{ic_esg.mean() / ic_esg.std() * np.sqrt(len(ic_esg)):.2f}") 1 2 3 4 5 6 7 8 9 10 10-1
momentum -0.046 0.049 0.097 0.101 0.096 0.089 0.110 0.094 0.075 0.114 0.160
sin_quiebras 0.013 0.045 0.103 0.101 0.098 0.096 0.106 0.094 0.076 0.112 0.098
placebo_esg 0.056 0.085 0.101 0.063 0.060 0.045 0.083 0.077 0.085 0.119 0.063
WML momentum: t = 4.25
10-1 placebo: t = 2.18
rank IC of the placebo: 0.010, t = 1.46Three readings of the table and the figure:
- The bankruptcies make half the spread. With them, winners minus losers gives 16.0% a year (t = 4.25); without
the four stocks that went bankrupt, 9.8%. Decile 1 goes from −4.6% to +1.3% a year. What was planted (
0.0025·za month with z of ±1.75 in the extreme deciles) gives ≈ 10% a year: the spread without bankruptcies is the one that comes close to the truth. - The middle is not ordered. Deciles 2 to 9 do not rise like a staircase (decile 9 returns less than decile 3): with a dozen stocks per decile and idiosyncratic volatilities of 20-30%, the 19-year mean of each decile has an error of several points.
- The placebo also has a spread. The ESG score, which has no effect at all, gives 6.3 points a year between its top and bottom deciles, with a t of 2.18 that would pass a test at 5%. Its rank IC, with the whole cross-section, is 0.010 with t = 1.46: not significant. The spread of two extreme portfolios is a picture, not a test (lesson 2.1.8 for the problem of testing many variants).
Costs and crashes
A decile portfolio is rebuilt every month. The cost of each change is, at the very least, half the bid-ask spread:
the horquilla_pb column of the characteristics gives the average quoted spread of each stock and month.
hs = car.pivot(index="fecha", columns="ticker",
values="horquilla_pb").reindex(mom.index) / 2 / 1e4
def pesos(senal):
W = {}
for d in senal.index[:-1]:
v = senal.loc[d].dropna()
g = pd.qcut(v.rank(method="first"), 10, labels=False)
w = pd.Series(0.0, index=senal.columns)
w[g.index[g == 9]] = 1 / (g == 9).sum()
w[g.index[g == 0]] = -1 / (g == 0).sum()
W[d] = w
return pd.DataFrame(W).T
W = pesos(mom)
bruto = (W * fwd.reindex(W.index)).sum(axis=1)
giro = (W - W.shift().fillna(0.0)).abs()
coste = (giro * hs.reindex(W.index)).sum(axis=1)
print(f"monthly turnover: {giro.sum(axis=1).mean():.0%} of capital")
print(f"gross {12 * bruto.mean():.2%} cost {12 * coste.mean():.2%}"
f" net {12 * (bruto - coste).mean():.2%} a year")monthly turnover: 113% of capital
gross 16.14% cost 0.49% net 15.65% a yearThe portfolio changes more than half of its positions every month (a turnover of 113% on a gross capital of 200%, 100% long and 100% short), but the stocks in the universe are large and the half spread, of a few basis points, costs 0.5% a year. Monthly momentum in large stocks is not killed by spread costs; it is made more expensive by impact in large portfolios and by borrowing on the short side, which for stocks about to go bankrupt is expensive or impossible (lessons 2.4.7 and 2.3.5).
The risk of cross-sectional momentum is not in the costs, but in the crashes. Daniel and Moskowitz show that momentum suffers infrequent and persistent losing streaks that are partly predictable: they arrive in “panic” states, after market declines and with high volatility, and tend to coincide with rebounds4Note 4 · K. Daniel and T. J. Moskowitz, “Momentum crashes”, Journal of Financial Economics 122(2), 2016, 221-247. DOI 10.1016/j.jfineco.2015.12.002.. The mechanism is simple: after a sharp decline, the losers are the stocks with the highest beta that have been hit hardest; when the market rebounds, they are the ones that rise most, and the portfolio is short them. The generator of this data plants it in the three months following the 2009 and 2020 bottoms.
meses_mes = wml.copy()
meses_mes.index = (wml.index.to_period("M") + 1).strftime("%Y-%m")
crash = meses_mes.loc[["2009-04", "2009-05", "2009-06",
"2020-04", "2020-05", "2020-06"]]
print(crash.round(3).to_string())
print("worst months of the whole sample:")
print(meses_mes.nsmallest(5).round(3).to_string())2009-04 -0.073
2009-05 0.035
2009-06 -0.161
2020-04 -0.046
2020-05 -0.032
2020-06 -0.032
worst months of the whole sample:
2009-06 -0.161
2022-02 -0.123
2014-09 -0.090
2008-06 -0.087
2023-03 -0.083The quarter after the 2009 bottom adds up to −20% (−7.3%, +3.5% and −16.1%), and that of 2020, −11%. What was planted (a coefficient of −0.012 a month in those six months, with z of ±1.75) gives ≈ −4.2% a month, −12.6% per quarter; noise makes 2009 come out worse and 2020 somewhat better. June 2009 is the worst month of the whole sample. The other four worst (February 2022, September 2014, June 2008 and March 2023) are not planted: they are the noise of a 24-stock portfolio. For a real system, the consequence is about size: a strategy with a 10% annual mean that can lose 20% in a quarter needs a volatility target and capital that can withstand it. Daniel and Moskowitz propose scaling momentum by its forecast volatility, the same idea as in the futures4.
Sector rotation: ten assets are few
Sector rotation is cross-sectional momentum across sectors: each month, buy the sectors with the best 12-1 return and sell the worst. Moskowitz and Grinblatt found in 1999 that industry momentum explained a good part of individual stock momentum in the US5Note 5 · T. J. Moskowitz and M. Grinblatt, “Do Industries Explain Momentum?”, Journal of Finance 54(4), 1999, 1249-1290. DOI 10.1111/0022-1082.00146.. In this universe there is no planted sector premium: sectors inherit the premium of their stocks and have some stretches of drift (financials in 2008, energy in 2014-2016 and 2020, technology in 2022).
sector = pd.read_csv("universo2_sectores.csv").set_index("ticker")
R = P.pct_change(fill_method=None)
R_sec = R.T.groupby(sector["sector"]).mean().T # equal-weighted
S = (1 + R_sec.fillna(0.0)).cumprod()
Sf = S.loc[fin]
s_mom = (Sf.shift(1) / Sf.shift(12) - 1).loc["2007":]
Se = S.loc[ent].set_axis(fin)
s_fwd = (Se.shift(-1) / Se - 1).loc[s_mom.index]
rk = s_mom.rank(axis=1)
ls = (s_fwd[rk >= 8].mean(axis=1)
- s_fwd[rk <= 3].mean(axis=1)).dropna()
ic_s = pd.Series([stats.spearmanr(s_mom.loc[d], s_fwd.loc[d]).statistic
for d in ls.index])
print(f"long-short: {12 * ls.mean():.2%} a year, t = "
f"{ls.mean() / ls.std() * np.sqrt(len(ls)):.2f}")
print(f"rank IC: {ic_s.mean():.3f}, t = "
f"{ic_s.mean() / ic_s.std() * np.sqrt(len(ic_s)):.2f}")long-short: 3.98% a year, t = 1.34
rank IC: 0.024, t = 1.02The rotation earns 4.0% a year with t = 1.34, and its rank IC is 0.024 with t = 1.02. Both figures are compatible with a small premium and also with zero. The problem is the amount of information: an IC across ten sectors has an enormous monthly standard error (with N = 10, a rank correlation by chance has a standard deviation of ≈ 1/√9 ≈ 0.33), and 226 months are not enough to separate 0.02 from zero. For a strategy with so few assets, the evidence has to come from somewhere else: from decades of data, from several markets or from a strong economic reason.
What this changes in your backtests
- Ask for the right control. A long-short rule on assets with drift earns something just by being on the side of the mean. The demeaned control separates that part from the trend.
- The cost is per tick, not per contract. A market whose price has fallen a lot can be expensive to trade even though its tick has not changed.
- Ranks before levels. In the cross-section, the rank IC and its statistic are more robust than the linear IC or the spread of two deciles, which a few extreme observations move.
- Delistings included. Without the delisting return the loser decile looks better than it was; with it, you have to check how much of the result depends on a handful of bankruptcies.
- The risk of momentum is in the tail. A positive mean coexists with months of −10% or worse just when the market rebounds.
Exercises
- Repeat the time series rule with horizons of 1, 3 and 12 months (21, 63 and 252 sessions). Which one comes closest to the planted effect, which depends on the previous 252 sessions, and what happens to costs when the horizon is shortened?
Solution
filas = {}
for n in (21, 63, 252):
b, c, _ = backtest(np.sign(px / px.shift(n) - 1), r)
filas[f"{n} sessions"] = resumen(b, c)
pd.DataFrame(filas).loc[["sharpe_bruto", "sharpe_neto",
"coste_anual"]].round(3) 21 sessions 63 sessions 252 sessions
sharpe_bruto 0.414 0.607 0.946
sharpe_neto -0.029 0.312 0.772
coste_anual 0.049 0.032 0.018All three horizons win gross because a one-year trend is also visible, with more noise, in stretches of one and three months, but the 252-session one is the one that captures the planted premium (0.95 gross, against 0.61 and 0.41). As the horizon shortens the signal changes much more often: the annual cost goes from 1.8% to 4.9% and the one-month rule loses everything net (−0.03). A short horizon needs a much larger edge to pay for its turnover.
- The Turtle rules also had a faster “System 1”: entry at 20 sessions and exit at 10. Test it with the
donchianfunction and compare it with the 55/20 one in net Sharpe, cost and correlation.
Solution
b_d1, c_d1, _ = backtest(donchian(px, 20, 10), r)
print(pd.DataFrame({"20_10": resumen(b_d1, c_d1),
"55_20": resumen(b_dn, c_dn)}).round(3).to_string())
print("correlation of the net returns:",
round(np.corrcoef(b_d1 - c_d1, b_dn - c_dn)[0, 1], 2)) 20_10 55_20
sharpe_bruto 0.484 0.647
sharpe_neto 0.212 0.491
vol_anual 0.096 0.091
coste_anual 0.026 0.014
max_dd -0.483 -0.311
correlation of the net returns: 0.76System 1 is faster and trades more: it costs almost twice as much (2.6% against 1.4% a year), its net Sharpe drops to 0.21 and its maximum drawdown rises to 48%. The two versions are very similar (correlation of 0.76), so combining them diversifies little. In this data the trend premium is at the one-year horizon, and a 20-session breakout reacts to moves that are mostly noise.
- A colleague shows you a cross-sectional momentum backtest on real stocks with a decile spread of 15% a year, without delisting returns and with the universe of the index's current members. List what you would ask for before believing it.
Solution
First, the point-in-time universe: with the current members, the companies that went up survive, and survivorship bias (lesson 1.4.4) changes both extremes. Next, the delisting returns: without them the loser decile loses its worst months and the spread changes, in either direction depending on how the last quote is treated. Third, the rank IC with its statistic, not just two deciles: in this lesson, a placebo gives a 6-point spread with a t of 2.2, even though its rank IC is not significant (t = 1.5). Fourth, the result without the five or ten most extreme observations. Fifth, execution: signal at the month-end close and portfolio formed at the following close, with half the bid-ask spread and, on the short side, the borrowing cost (lesson 2.4.7). And finally, the crash months after market bottoms: how much it loses and whether the position size would survive them.
Footnotes
-
N. Jegadeesh and S. Titman, “Returns to Buying Winners and Selling Losers: Implications for Stock Market Efficiency”, Journal of Finance 48(1), 1993, 65-91. DOI 10.1111/j.1540-6261.1993.tb04702.x. ↩
-
T. J. Moskowitz, Y. H. Ooi and L. H. Pedersen, “Time series momentum”, Journal of Financial Economics 104(2), 2012, 228-250. DOI 10.1016/j.jfineco.2011.11.003. Scaling at 40% and EWMA estimator with a center of mass of 60 days: sections 2.4 and 4.1; the smile, figure 4. ↩ ↩2 ↩3
-
“The Original Turtle Trading Rules”, published at tradingblox.com/originalturtles: N and units on pages 13-14, entry systems on page 18 and exits on page 26. The Turtles traded the breakout as soon as it happened during the session; here it is traded at the next close, which is more conservative. ↩
-
K. Daniel and T. J. Moskowitz, “Momentum crashes”, Journal of Financial Economics 122(2), 2016, 221-247. DOI 10.1016/j.jfineco.2015.12.002. ↩ ↩2
-
T. J. Moskowitz and M. Grinblatt, “Do Industries Explain Momentum?”, Journal of Finance 54(4), 1999, 1249-1290. DOI 10.1111/0022-1082.00146. ↩
Each answer is folded under its question
Self-assessment
To control a
sign(12-month return)rule, why is it worth subtracting each market's mean before permuting its returns?- ABecause without subtracting it the permutation is done wrong
- BBecause costs are negative
- CBecause volatility scaling creates return
- DBecause the permutation preserves each market's mean drift: if a market rises on average, its 12-month return is usually positive and the rule is almost always long, collecting that drift without any trend
Show answer
Answer D. Because the permutation preserves each market's mean drift: if a market rises on average, its 12-month return is usually positive and the rule is almost always long, collecting that drift without any trend
Permuting destroys the time order but not the mean. In this data the drift is small (bonds without a term premium, commodities and currencies that revert to the anchor), and both versions give a gross Sharpe compatible with zero: +0.06 ± 0.17 without subtracting it and −0.12 ± 0.22 subtracting it. In markets with a lot of drift, what the rule earns by being on the side of the mean can be a large part, and that is not “momentum”.
What does the volatility scaling of Moskowitz, Ooi and Pedersen do?
- AIt trades only when volatility is low
- BIt scales the signal by the strength of the trend
- CIt sets the position in each market as target/σ estimated with data up to the previous day, so that each market contributes a similar risk
- DIt reduces commissions
Show answer
Answer C. It sets the position in each market as target/σ estimated with data up to the previous day, so that each market contributes a similar risk
Without scaling, natural gas (50% volatility) would dominate a 2-year bond (2%). With a position of 40%/σ each market has the same ex ante risk, and the average portfolio of many weakly correlated markets ends up at around 10.5% a year.
In the stock universe, the linear IC of 12-1 momentum is 0.041 and the rank IC 0.027. Which better describes the planted premium, and why?
- AThe linear one, because it uses more information
- BThe rank one: the linear one is inflated by four bankruptcies with a −100% return that were in the loser decile; a few extreme observations move a Pearson correlation
- CNeither: the IC measures nothing
- DThe linear one, because ranks lose the sign
Show answer
Answer B. The rank one: the linear one is inflated by four bankruptcies with a −100% return that were in the loser decile; a few extreme observations move a Pearson correlation
The generator's premium gives an expected rank IC of ≈ 0.03, which is what the rank IC measures. The four guided bankruptcies fall in the loser decile and their −100% weighs a great deal in a linear correlation and in the decile spread (16.0% a year with them, 9.8% without them).
A decile portfolio on the ESG score (a placebo with no effect at all) gives a spread of 6.3 points a year between the top and bottom deciles. Which conclusion is correct?
- AThat a spread between two portfolios of 12 stocks is very noisy: you have to look at the IC of the whole cross-section and its t (0.010 with t = 1.46 here); the decile spread reaches t = 2.18 by chance
- BThat ESG has a 6% annual premium
- CThat there is an error in the data
- DThat the placebo works like momentum
Show answer
Answer A. That a spread between two portfolios of 12 stocks is very noisy: you have to look at the IC of the whole cross-section and its t (0.010 with t = 1.46 here); the decile spread reaches t = 2.18 by chance
The extreme decile has a dozen stocks with idiosyncratic volatilities of 20-30%: its 19-year mean has an error of several points. The decile spread is a good picture, but the test is the IC and its statistic.
When does cross-sectional momentum lose the most, according to the evidence and to the generator of this data?
- AIn sharp market declines
- BIn rebounds after a market bottom: the losers, with higher beta, rise more than the winners
- CIn sideways markets
- DIt never loses in a month
Show answer
Answer B. In rebounds after a market bottom: the losers, with higher beta, rise more than the winners
Daniel and Moskowitz document crashes in panic states, after declines and with high volatility, which coincide with market rebounds. Here it is planted in the three months after the 2009 and 2020 bottoms: the winners-minus-losers portfolio loses 20% in the 2009 quarter.
Key points
- Trend (time series) compares an asset with its own past and can be short everything; cross-sectional momentum compares assets with each other and is neutral by construction.
- The 12-month rule with volatility scaling recovers the planted premium in the 24 futures (gross Sharpe 0.95, net 0.77), more than four standard deviations above a permutation control; the demeaned control also separates out what would amount to collecting each market's drift.
- Moving-average crossover, Donchian and 12-month momentum are the same idea at different horizons: correlations of 0.3 to 0.7 between them.
- The “smile” against equities depends on the crises in the sample: it does not show up here; the 12-month rule wins in long declines or those preceded by declines (2008-2009) and loses in sharp ones after a bull market (2020).
- In stocks, rank IC (0.027, t = 2.8) instead of linear IC or decile spread, which are inflated by a few bankruptcies and the noise of small portfolios; a placebo gives a 6-point spread (t = 2.2) by chance.
- Cross-sectional momentum suffers crashes in the rebounds after bottoms, and a rotation across ten sectors does not have enough data to separate a premium from zero.
Resources
Documentation
- Moskowitz, Ooi and Pedersen (2012): “Time series momentum”, Journal of Financial Economics 104(2), 228-250
- Jegadeesh and Titman (1993): “Returns to Buying Winners and Selling Losers”, Journal of Finance 48(1), 65-91
- Daniel and Moskowitz: “Momentum Crashes” (NBER w20439; Journal of Financial Economics 122(2), 2016)
- Moskowitz and Grinblatt (1999): “Do Industries Explain Momentum?”, Journal of Finance 54(4), 1249-1290
- The Original Turtle Trading Rules (PDF; systems 1 and 2, N and units)