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.

Fig. — Three trend rules on 24 synthetic futures with the planted premium, net of costs, against a permuted-returns control (2001-2025)
Level 2 lessons
  1. 2.1 · Statistics and econometrics for trading

    1. 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
    2. 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
    3. 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
    4. 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
    5. 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
    6. 2.1.6 Kalman filter for dynamic hedge ratios
    7. 2.1.7 Regime detection: hidden Markov models and structural breaks
    8. 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
  2. 2.2 · Anatomy of a trading system

    1. 2.2.1 Edge hypotheses: risk, behavior, structure and information
    2. 2.2.2 Universe, signal, filter, entry, exit, sizing and risk management
    3. 2.2.3 Frequencies: intraday, swing and position trading, and the capacity of a strategy
    4. 2.2.4 Research journal and experiment tracking against data snooping
  3. 2.3 · Strategy families

    1. 2.3.1 Trend following and momentum
    2. 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
    3. 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
    4. 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
    5. 2.3.5 Factors (value, quality, low volatility, size) and event strategies
    6. 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
    7. 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
  4. 2.4 · Backtesting: engines and paradigms

    1. 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
    2. 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
    3. 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
    4. 2.4.4 backtesting.py and backtrader: two teaching engines and their status
    5. 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
    6. 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
    7. 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
    8. 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
  5. 2.5 · Biases and validation

    1. 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
    2. 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
    3. 2.5.3 Combinatorial purged cross-validation for strategies
    4. 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
    5. 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
    6. 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
    7. 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
    8. 2.5.8 “Honest backtest” checklist before moving to paper trading
  6. 2.6 · Performance metrics

    1. 2.6.1 CAGR, Sharpe, Sortino, Calmar, Omega, drawdown and Ulcer Index
    2. 2.6.2 Win rate, payoff, expectancy, alpha, beta, information ratio, turnover and capacity
    3. 2.6.3 Reports with QuantStats or pyfolio-reloaded and custom tearsheets

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.
Past pricesAgainst own pastAgainst the othersSign per assetCross-sectional rankNet long or shortNeutral long-short
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.

IN [01]python
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())
OUT [01]Continuous futures and rolls
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:

cside=nticks⋅tickF+commissionF⋅multiplierc_{\text{side}} = \frac{n_{\text{ticks}} \cdot \text{tick}}{F} + \frac{\text{commission}}{F \cdot \text{multiplier}}
IN [02]python
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")
OUT [02]Cost per side in basis points of notional
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 bp

The 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:

wi,t=sign⁡(Pi,tPi,t−252−1)⋅40%σ^i,tw_{i,t} = \operatorname{sign}\left(\frac{P_{i,t}}{P_{i,t-252}} - 1\right) \cdot \frac{40\%}{\hat\sigma_{i,t}}

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).

Close tSignal and sizeOrder close t+1Earns from t+1
Mermaid source
flowchart LR
  C[Close t] --> S[Signal and size]
  S --> E[Order close t+1]
  E --> G[Earns from t+1]
IN [03]python
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)
OUT [03]A vectorized engine for any signal
sharpe_bruto    0.946
sharpe_neto     0.772
vol_anual       0.105
coste_anual     0.018
max_dd         -0.212
dtype: float64

The 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.

IN [04]python
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}")
OUT [04]Market by market
       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.05

Market 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,

SRportfolio≈SRmarketN1+(N−1)ρ=0.29241+23⋅0.05≈0.97SR_{\text{portfolio}} \approx SR_{\text{market}} \sqrt{\frac{N}{1 + (N-1)\rho}} = 0.29 \sqrt{\frac{24}{1 + 23 \cdot 0.05}} \approx 0.97

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:

IN [05]python
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}")
OUT [05]Control: permuted returns
demeaned=False  gross +0.06 ± 0.17   net -0.21 ± 0.18
demeaned=True   gross -0.12 ± 0.22   net -0.39 ± 0.22

Both 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 n sessions; 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 on N, the 20-session exponential average of the true range: each unit was sized so that a move of N cost 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.

breaks 55 highbreaks 55 lowloses 20 lowexceeds 20 highbreaks 55 lowbreaks 55 highFLATLONGSHORT
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.

IN [06]python
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%}")
OUT [06]50/200 crossover and 55/20 Donchian breakout
              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.

IN [07]python
netos = pd.DataFrame({"tsmom": b_ts - c_ts, "cruce": b_cr - c_cr,
                      "donchian": b_dn - c_dn})
netos.loc["2001":].corr().round(2)
OUT [07]How similar are they?
          tsmom  cruce  donchian
tsmom      1.00   0.71      0.33
cruce      0.71   1.00      0.30
donchian   0.33   0.30      1.00

The 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.

IN [08]python
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))
OUT [08]Quarterly returns against the IDA index
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.086
Fig. — No smile: quarterly return of the trend portfolio on 24 futures against the IDA index, 2001-2025

It 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:

IN [09]python
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())
OUT [09]Trend in the stress periods
            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.743

In 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:

  1. Point-in-time universe: each month only the stocks that traded that month enter (lesson 1.4.4).
  2. 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.
  3. 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.
IN [10]python
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())
OUT [10]12-1 signal and next-month return, with delistings
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 t=IC‾/(sIC/T)t = \overline{IC} / (s_{IC} / \sqrt{T}) say whether there is a relationship. There are two versions:

IN [11]python
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)
OUT [11]Rank IC versus linear IC
        ic_medio      t
rangos     0.027  2.808
lineal     0.041  4.295

The 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.

IN [12]python
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}")
OUT [12]Decile portfolios
                  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.46
Fig. — Mean annual return by decile of 12-1 momentum and of a placebo (ESG score) in 120 synthetic stocks, 2007-2025

Three 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·z a 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.

IN [13]python
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")
OUT [13]Turnover and cost of the WML portfolio
monthly turnover: 113% of capital
gross 16.14%  cost 0.49%  net 15.65% a year

The 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.

IN [14]python
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())
OUT [14]WML in the months after the bottoms
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.083

The 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).

IN [15]python
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}")
OUT [15]Top three sectors minus bottom three
long-short: 3.98% a year, t = 1.34
rank IC: 0.024, t = 1.02

The 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

  1. 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
IN [16]python
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)
OUT [16]Signal horizon
              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.018

All 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.

  1. The Turtle rules also had a faster “System 1”: entry at 20 sessions and exit at 10. Test it with the donchian function and compare it with the 55/20 one in net Sharpe, cost and correlation.
Solution
IN [17]python
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))
OUT [17]Donchian 20/10 versus 55/20
              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.76

System 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.

  1. 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

  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. ↩

  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. ↩ ↩2 ↩3

  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. ↩

  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. ↩ ↩2

  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. ↩

Each answer is folded under its question

Self-assessment

  1. 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”.

  2. 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.

  3. 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).

  4. 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.

  5. 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

Data