Notebook 5.3 — Momentum Backtest (Jegadeesh–Titman)

Sam has been waiting all week for this one. The strategy is the kind a sixteen-year-old day-trader would scribble on a napkin: buy the stocks that went up, short the ones that went down. Jegadeesh and Titman (1993) showed that this almost insultingly simple rule made money, robustly, for decades — and that the risk models of the day could not explain it.1 That is why it matters. If a rule built from nothing but past prices beats the market after risk adjustment, then either there is a risk factor nobody wrote down, or prices are not impounding information the way the weak-form efficient-markets hypothesis claims. The argument is still live thirty years on.

This notebook rebuilds the backtest end to end and, crucially, makes you confront three things the napkin version hides:

The one-sentence result: rank stocks on their past J-month return, hold the winner-minus-loser spread for K months, and you earn a positive average return of order ~1%/month at the classic 6/6 — but the overlapping-holding-period construction induces serial correlation that inflates naive t-stats (fix with Newey–West, exactly as in Week 2), and realistic transaction costs eat a large share of the gross profit.

Two data paths. Path A pulls Ken French’s free momentum portfolios via pandas_datareader (wrapped in try/except). Path B is a deterministic synthetic panel that runs fully offline — we simulate stock returns with a built-in momentum signal so the sort has to work, then do all the inference and cost analysis on it. If Path A fails (no internet, package missing), the notebook silently falls back to Path B and every downstream cell still runs.

import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")  # non-interactive backend: render to buffers, never pop a window
import matplotlib.pyplot as plt

import statsmodels
import statsmodels.api as sm

rng = np.random.default_rng(42)  # one seed for the whole notebook -> reproducible

pd.set_option("display.float_format", lambda v: f"{v:.4f}")
print("pandas", pd.__version__, "| numpy", np.__version__, "| statsmodels", statsmodels.__version__)

1. The J/K sort, in one box

Fix two numbers. The formation (or ranking) period is J months; the holding period is K months. At the end of each month t:

  1. Compute each stock’s cumulative return over the past J months (we rank on months t-J through t-1, skipping the most recent month to dodge short-horizon bid-ask bounce and reversal — a convention more strongly associated with later momentum work than with JT93’s main tables [CHECK]).
  2. Sort all stocks on that past return into ten deciles. The top decile is the winner portfolio W; the bottom decile is the loser portfolio L.
  3. Hold an equal-weighted position in each decile for the next K months.

The headline trade is the zero-cost winner-minus-loser portfolio: long the winners, short the losers, in equal dollar amounts. The legs cancel in cost, so the net investment is (notionally) zero and the return is a pure spread,

r^{\text{WML}}_t \;=\; r^{W}_t - r^{L}_t .

A positive average spread is the momentum profit. We will report a whole J \times K grid of these averages and then zoom in on the classic 6/6.

1.1 The overlapping-holding-period trick — and the catch

Here is the move that makes the method efficient and the statistics subtle. If you held a 6-month portfolio and rebalanced only every six months, you would get one fresh, non-overlapping return every half-year — throwing away five-sixths of your months. Instead, Jegadeesh and Titman form a new winner-minus-loser portfolio every month and hold each for K months. So in any given month you simultaneously hold K overlapping cohorts — the one formed this month, the one formed last month, … back K-1 months — and the reported monthly return is the equal-weighted average across those K cohorts. Every month of data gets used, and the cohort-averaging smooths idiosyncratic noise.

The price you pay is serial correlation in the very return series you then test. Consecutive monthly returns share K-1 of their K component cohorts, so adjacent observations are mechanically correlated even if the true signal were i.i.d. This is exactly the overlapping-observations problem from Week 2 (Ch 2.4). Regress the overlapping series on a constant to test whether its mean beats zero, and the usual i.i.d. standard error is too small: it pretends you have more independent observations than you do, so it inflates the t-statistic. The fix is the Week-2 fix — a heteroskedasticity-and-autocorrelation-consistent (HAC) standard error, Newey–West, with the lag truncation set to at least K-1 to absorb the induced autocorrelation. We will print the naive and HAC t-stats side by side and watch the naive one deflate.

2. Path A — Ken French’s momentum portfolios (free, real data)

Kenneth French publishes, for free, the monthly returns of value-weighted portfolios formed on prior 2–12 month returns (“momentum” portfolios) on his data library. The pandas_datareader 'famafrench' reader pulls them straight into a DataFrame. We wrap the whole thing in try/except: if the package is missing or there is no internet, we set real_data = None and fall back to the synthetic panel in §3. Either way the notebook runs.

This is free, public data — no license, nothing that must stay on GMU infrastructure.

real_data = None
real_label = None
try:
    import pandas_datareader.data as web
    # "10_Portfolios_Prior_12_2" = deciles formed on prior (t-12..t-2) returns, monthly.
    ds = web.DataReader("10_Portfolios_Prior_12_2", "famafrench",
                        start="1994-01-01", end="2023-12-31")
    # ds[0] is the value-weighted monthly average returns table (in percent).
    real_data = ds[0].copy() / 100.0          # convert percent -> decimal
    real_data.index = real_data.index.to_timestamp() if hasattr(real_data.index, "to_timestamp") else real_data.index
    real_label = "Ken French 10 prior-(12,2) momentum portfolios (real, free)"
    print("Path A succeeded:", real_label)
    print(real_data.tail(3))
except Exception as e:
    print(f"Path A unavailable ({type(e).__name__}: {e}).")
    print("-> Falling back to the synthetic panel (Path B). All downstream cells still run.")

3. Path B — a synthetic panel with momentum baked in

To guarantee the notebook runs offline and that the sort has something real to find, we simulate a panel of n_stocks stocks over n_months months whose returns contain a genuine momentum signal. The design is deliberately transparent so you can see why the strategy works:

  • A persistent per-stock “sentiment” state s_{i,t} that follows an AR(1) with positive persistence (\phi = 0.90). Because today’s sentiment is correlated with recent past sentiment, a stock’s recent past return predicts its near-future return — that is momentum at the 3–12 month horizon.
  • A slow mean-reversion (reversal) term: a fraction of the sentiment from K_{\text{rev}} months ago is paid back, so very-long-horizon past winners eventually stop outperforming — the seed of the long-run reversal that De Bondt–Thaler documented.
  • A common, autocorrelated “momentum-factor” return f_t that winners load on positively and losers negatively. Because it is shared across the overlapping cohorts and persistent over time, it is what injects realistic serial correlation into the monthly WML series — giving us the HAC point to make in §5.
  • Plus a market factor and idiosyncratic noise so nothing is degenerate.

None of these knobs is tuned per stock by hand; they are drawn once from rng. The result is a panel where a 6/6 momentum sort earns roughly 1%/month — on purpose.

def simulate_panel(n_stocks=200, n_months=360, seed=42):
    """Deterministic synthetic stock panel with a built-in momentum signal.
    Returns a (n_months x n_stocks) DataFrame of monthly simple returns."""
    g = np.random.default_rng(seed)

    # 1) per-stock AR(1) "sentiment" -> source of medium-horizon momentum
    phi, sent_sd = 0.90, 0.0055
    s = np.zeros((n_months, n_stocks))
    state = g.normal(0.0, sent_sd / np.sqrt(1 - phi**2), n_stocks)   # stationary start
    for t in range(n_months):
        state = phi * state + g.normal(0.0, sent_sd, n_stocks)
        s[t] = state

    # 2) common, persistent momentum-factor return -> serial corr in the WML series
    phi_f, fe_sd = 0.85, 0.008
    f = np.zeros(n_months)
    for t in range(1, n_months):
        f[t] = phi_f * f[t - 1] + g.normal(0.0, fe_sd)
    mom_load = g.uniform(0.5, 1.5, n_stocks)   # heterogeneous loading on the factor

    # 3) market factor + fixed betas + idiosyncratic noise
    mkt = g.normal(0.006, 0.04, n_months)
    betas = g.uniform(0.6, 1.4, n_stocks)
    idio_sd = 0.05
    rev_g, rev_lag = 0.9, 12   # long-horizon reversal: pay back sentiment from a year ago

    ret = np.zeros((n_months, n_stocks))
    for t in range(n_months):
        tilt = np.sign(s[t]) * mom_load           # winners co-move with +f, losers with -f
        r = betas * mkt[t] + s[t] + tilt * f[t]
        if t >= rev_lag:
            r = r - rev_g * s[t - rev_lag]        # reversal term
        ret[t] = r + g.normal(0.0, idio_sd, n_stocks)

    idx = pd.period_range("1994-01", periods=n_months, freq="M")
    cols = [f"S{i:03d}" for i in range(n_stocks)]
    return pd.DataFrame(ret, index=idx, columns=cols)

panel = simulate_panel()
print("Synthetic panel:", panel.shape, "(months x stocks)")
print("Mean monthly stock return: %.4f | cross-sectional sd: %.4f"
      % (panel.values.mean(), panel.values.std()))
panel.iloc[:3, :5]

4. The backtest engine: building the J×K grid

The function below implements the overlapping-portfolio construction. For a given (J, K):

  • We rank on the log cumulative past-J-month return (logs add cleanly across months), shifted by skip=1 month so we rank on t-J \dots t-1.
  • For each cohort lag g = 0, 1, \dots, K-1, we re-derive the decile membership the cohort would have been formed with g months ago, then take this month’s equal-weighted winner-minus-loser return for that cohort.
  • The reported monthly WML return is the average across the K cohorts — the overlapping construction.

No chained indexing: every selection uses .where(mask) on an explicit copy of the returns, and we never assign into a slice of a slice.

def wml_series(ret, J, K, n_dec=10, skip=1):
    """Overlapping winner-minus-loser monthly return series for a J/K momentum sort."""
    logret = np.log1p(ret)
    pastJ = logret.rolling(J).sum().shift(skip)        # rank signal known at formation
    cohorts = []
    for g in range(K):                                  # K overlapping cohorts
        signal = pastJ.shift(g)
        ranks = signal.rank(axis=1, pct=True)           # cross-sectional percentile each month
        win = ranks > (n_dec - 1) / n_dec               # top decile
        los = ranks <= 1 / n_dec                        # bottom decile
        w_ret = ret.where(win).mean(axis=1)             # equal-weight winners
        l_ret = ret.where(los).mean(axis=1)             # equal-weight losers
        cohorts.append(w_ret - l_ret)
    wml = pd.concat(cohorts, axis=1).mean(axis=1)       # average across cohorts
    return wml.dropna()

def jk_grid(ret, Js=(3, 6, 9, 12), Ks=(3, 6, 9, 12)):
    """Average monthly WML spread (in %) for every (J, K) cell, plus the raw series."""
    means = pd.DataFrame(index=Js, columns=Ks, dtype=float)
    series = {}
    for J in Js:
        for K in Ks:
            s = wml_series(ret, J, K)
            means.loc[J, K] = s.mean() * 100.0          # percent per month
            series[(J, K)] = s
    means.index.name = "J (formation)"
    means.columns.name = "K (holding)"
    return means, series

grid, series = jk_grid(panel)
print("Average winner-minus-loser spread, %/month (synthetic panel):")
grid.round(3)

Read the grid as a surface, the way a referee reads JT93’s headline table. Three things jump out, and they are exactly the qualitative features of the real paper:

  • The whole region is positive, not one lucky cell. That breadth is the first defense against the data-snooping charge (sixteen cells = sixteen tests; more on that in §7).
  • The classic 6/6 sits around ~1%/month — a big number annualized. We describe it as order-of-magnitude 1%/month; we never quote a textbook decimal from memory.
  • The profit decays as you lengthen K and J, and the far corner (12/12) is already drifting toward zero or negative. That decay is the seed of long-run reversal: hold too long, or rank on too-distant history, and momentum unwinds. We make that explicit next.
fig, axes = plt.subplots(1, 2, figsize=(11, 4.2))

# (a) the J x K grid as a heatmap
ax = axes[0]
im = ax.imshow(grid.values.astype(float), cmap="RdYlGn", aspect="auto")
ax.set_xticks(range(len(grid.columns))); ax.set_xticklabels(grid.columns)
ax.set_yticks(range(len(grid.index)));   ax.set_yticklabels(grid.index)
ax.set_xlabel("K (holding months)"); ax.set_ylabel("J (formation months)")
ax.set_title("WML spread (%/month) across the J x K grid")
for i in range(grid.shape[0]):
    for j in range(grid.shape[1]):
        ax.text(j, i, f"{grid.values[i, j]:.2f}", ha="center", va="center", fontsize=9)
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)

# (b) long-horizon: rank on J months, hold 1 month -> momentum fades toward reversal
ax = axes[1]
logret = np.log1p(panel)
Js_long = [3, 6, 9, 12, 18, 24, 36, 48, 60]
spreads = []
for J in Js_long:
    sig = logret.rolling(J).sum().shift(1)
    ranks = sig.rank(axis=1, pct=True)
    sp = (panel.where(ranks > 0.9).mean(axis=1)
          - panel.where(ranks <= 0.1).mean(axis=1)).dropna().mean() * 100
    spreads.append(sp)
ax.plot(Js_long, spreads, "o-", color="#3b6ea5")
ax.axhline(0, color="0.5", lw=0.8)
ax.axvspan(3, 12, color="#9ecae1", alpha=0.3, label="medium horizon (momentum)")
ax.set_xlabel("formation horizon J (months)")
ax.set_ylabel("top-bottom decile spread (%/month)")
ax.set_title("Momentum fades as the horizon lengthens")
ax.legend(fontsize=8)
fig.tight_layout()
fig.savefig("nb53_grid_horizon.png", dpi=110)
print("saved nb53_grid_horizon.png")
print("long-horizon spreads (%/mo):", [round(x, 2) for x in spreads])

5. Inference: naive vs. Newey–West on the 6/6 series

Now the Week-2 payoff. We take the 6/6 monthly WML series and test whether its mean beats zero two ways: regress it on a constant with (a) the classical i.i.d. standard error and (b) the HAC / Newey–West standard error with lag L = K - 1 = 5. The point estimate — the mean spread — is identical both ways. Only the standard error, and therefore the t-statistic, changes.

Before the regression, look at the autocorrelation of the series. If the overlapping construction is doing what §1.1 said, the first few lags should be positive: adjacent months share five of their six cohorts.

wml66 = series[(6, 6)].copy()
print(f"6/6 series: {len(wml66)} monthly observations")
print(f"mean spread = {wml66.mean()*100:.4f} %/month")
print("autocorrelation at lags 1..6:")
for L in range(1, 7):
    print(f"  rho(L={L}) = {wml66.autocorr(L):+.3f}")
y = wml66.values
X = np.ones((len(y), 1))                       # regress on a constant => tests the mean

fit_iid = sm.OLS(y, X).fit()                   # classical i.i.d. SE
fit_hac = sm.OLS(y, X).fit(cov_type="HAC", cov_kwds={"maxlags": 5})  # Newey-West, L=K-1=5

infer = pd.DataFrame({
    "estimate (%/mo)": [fit_iid.params[0] * 100,         fit_hac.params[0] * 100],
    "std error (%/mo)": [fit_iid.bse[0] * 100,           fit_hac.bse[0] * 100],
    "t-stat":           [fit_iid.tvalues[0],             fit_hac.tvalues[0]],
}, index=["Classical (i.i.d.)", "Newey-West HAC(5)"])
infer.round(4)

Read it the way the referee in Ch 5.3 demands. The estimate column is identical — the mean spread does not care which standard error you compute. But the HAC standard error is markedly larger than the naive one, because Newey–West counts the positive autocorrelation you just printed and concludes you have fewer independent observations than the i.i.d. formula assumed. The t-statistic deflates accordingly.

Here both t-stats are large enough that the strategy survives — a t that falls from, say, 12 to 8 is still overwhelming. But that is the lesson, not a loophole: a marginal result (t \approx 2.2 naive) could easily cross below the 2.0 line once you respect the overlap. The first thing to recompute in any momentum replication is the Newey–West t-stat with lag \ge K-1. The naive number on overlapping data is not wrong by a rounding error; it is systematically optimistic.

# A picture of the gross 6/6 strategy: cumulative profit of $1 of long and $1 of short.
fig, ax = plt.subplots(figsize=(8, 4))
cum = (1 + wml66).cumprod()
ax.plot(cum.index.to_timestamp(), cum.values, color="#2b8a3e", lw=1.6)
ax.set_title("Gross cumulative value of the 6/6 winner-minus-loser strategy (synthetic)")
ax.set_xlabel("date"); ax.set_ylabel("growth of a $1 zero-cost spread")
ax.axhline(1.0, color="0.6", lw=0.8)
fig.tight_layout()
fig.savefig("nb53_cumprofit.png", dpi=110)
print("saved nb53_cumprofit.png | terminal gross multiple: %.2fx" % cum.iloc[-1])

6. The transaction-cost critique (the PS 5.3 core)

This is the first and biggest vulnerability the chapter flags. Momentum is a high-turnover strategy: every month you re-rank and trade a fresh slice, and the losers you short are disproportionately small, illiquid, low-priced names with wide bid-ask spreads — the most expensive stocks to trade. A gross spread of ~1%/month can be eaten alive.

To make the critique honest we measure turnover from the actual portfolio weights rather than assume it. Each month our held book is the cohort-averaged set of long (+) and short (−) decile weights. Turnover is how much of that weight vector changes from one month to the next:

\text{turnover}_t \;=\; \sum_i \bigl| w_{i,t} - w_{i,t-1} \bigr| ,

the round-trip notional traded. We then charge a per-round-trip cost c (in basis points) on that notional, with an extra premium on the short leg to proxy the borrow fee on hard-to-short losers.

def held_weights(ret, J=6, K=6, n_dec=10, skip=1):
    """Cohort-averaged net weights each month: + in winners, - in losers.
    Also returns the long-leg and short-leg weight matrices separately."""
    logret = np.log1p(ret)
    pastJ = logret.rolling(J).sum().shift(skip)
    cols = ret.columns
    Wlong = pd.DataFrame(0.0, index=ret.index, columns=cols)
    Wshort = pd.DataFrame(0.0, index=ret.index, columns=cols)
    for g in range(K):
        ranks = pastJ.shift(g).rank(axis=1, pct=True)
        win = (ranks > (n_dec - 1) / n_dec).astype(float)
        los = (ranks <= 1 / n_dec).astype(float)
        nlong = win.sum(axis=1).replace(0, np.nan)
        nshort = los.sum(axis=1).replace(0, np.nan)
        Wlong = Wlong + win.div(nlong, axis=0).fillna(0.0) / K
        Wshort = Wshort + los.div(nshort, axis=0).fillna(0.0) / K
    valid = pastJ.dropna(how="all").index
    return Wlong.loc[valid].dropna(how="all"), Wshort.loc[valid].dropna(how="all")

Wlong, Wshort = held_weights(panel)
turn_long = Wlong.diff().abs().sum(axis=1).dropna()     # round-trip notional, long leg
turn_short = Wshort.diff().abs().sum(axis=1).dropna()   # round-trip notional, short leg
turn_total = (turn_long + turn_short)

print("Average round-trip notional traded per month:")
print(f"  long leg  : {turn_long.mean():.4f}")
print(f"  short leg : {turn_short.mean():.4f}")
print(f"  total     : {turn_total.mean():.4f}  (book gross exposure = 2.0)")
print(f"  -> about {turn_total.mean()/2*100:.1f}% of the book turns over each month")

With turnover measured, the net monthly return is the gross spread minus the per-month trading bill. We align the gross series and the turnover series on their common dates, then sweep the round-trip cost c from 0 up. The short leg carries an extra short_premium basis points to reflect borrow costs on illiquid losers — the leg the chapter warns is the one most likely to kill the strategy.

def net_series(gross, turn_long, turn_short, c_bps, short_premium_bps=40.0):
    """Gross WML minus trading costs; per-round-trip cost c_bps on notional traded,
    plus an extra short_premium_bps on the short leg."""
    c = c_bps / 1e4
    sp = short_premium_bps / 1e4
    cost = c * turn_long + (c + sp) * turn_short
    idx = gross.index.intersection(cost.index)
    return (gross.loc[idx] - cost.loc[idx]).dropna()

gross66 = series[(6, 6)].copy()

rows = []
for c_bps in [0, 10, 25, 50, 75, 100, 150, 200]:
    net = net_series(gross66, turn_long, turn_short, c_bps)
    rows.append({"round-trip cost (bps)": c_bps,
                 "net mean (%/mo)": net.mean() * 100,
                 "net t (HAC)": sm.OLS(net.values, np.ones((len(net), 1)))
                                  .fit(cov_type="HAC", cov_kwds={"maxlags": 5}).tvalues[0]})
cost_table = pd.DataFrame(rows).set_index("round-trip cost (bps)")
print("Net 6/6 momentum profit as round-trip costs rise (incl. 40bps short premium):")
cost_table.round(3)
# Break-even cost: the round-trip c (bps) at which the net mean spread hits zero.
# net_mean(c) = gross_mean - c*mean(turn_long) - (c+sp)*mean(turn_short) = 0
# => c* = (gross_mean - sp*mean(turn_short)) / (mean(turn_long) + mean(turn_short))
idx = gross66.index.intersection(turn_total.index)
gross_mean = gross66.loc[idx].mean()
tl, ts = turn_long.loc[idx].mean(), turn_short.loc[idx].mean()
sp = 40.0 / 1e4
c_star = (gross_mean - sp * ts) / (tl + ts)
print(f"Gross 6/6 mean spread : {gross_mean*100:.4f} %/month")
print(f"Break-even round-trip cost (incl. short premium): {c_star*1e4:.1f} bps")
print(f"Break-even WITHOUT a short premium              : {gross_mean/(tl+ts)*1e4:.1f} bps")
fig, ax = plt.subplots(figsize=(8, 4.4))
cs = np.linspace(0, 250, 60)
net_means = [net_series(gross66, turn_long, turn_short, c).mean() * 100 for c in cs]
ax.plot(cs, net_means, color="#b5651d", lw=1.8, label="net mean spread")
ax.axhline(0, color="0.4", lw=1.0)
ax.axvline(c_star * 1e4, color="#cc2222", ls="--", lw=1.2,
           label=f"break-even = {c_star*1e4:.0f} bps")
ax.axhline(gross_mean * 100, color="#2b8a3e", ls=":", lw=1.2, label="gross (cost-free)")
ax.set_xlabel("round-trip transaction cost (bps, with 40bps short premium)")
ax.set_ylabel("net 6/6 momentum profit (%/month)")
ax.set_title("How much momentum survives trading costs")
ax.legend(fontsize=8)
fig.tight_layout()
fig.savefig("nb53_breakeven.png", dpi=110)
print("saved nb53_breakeven.png")

The one-paragraph verdict. At zero cost the synthetic 6/6 strategy earns about 1%/month. Because the strategy churns roughly a quarter to a third of its book every month, each basis point of round-trip cost bites hard, and the short leg — carrying the extra borrow premium — does more than its share of the damage. The strategy stays net-positive up to a break-even cost in the low hundreds of basis points; below that it survives, above it the gross anomaly evaporates. In the real world the losers you short are exactly the small, illiquid, low-priced names where round-trip costs and borrow fees are highest, which is why the honest verdict from later literature is contested: net profitability shrinks substantially and, by some accounts of the short leg in small caps, can vanish. A gross spread is not yet an efficiency violation. That is the whole point of PS 5.3: turn “anomaly” into “tradable or not,” and name which leg kills it.

7. If Path A loaded: a quick real-data echo

If pandas_datareader reached Ken French’s server, real_data holds the ten momentum deciles. Their column ordering runs from the lowest prior-return decile (Lo PRIOR) to the highest (Hi PRIOR). The winner-minus-loser proxy is simply the last column minus the first. The cell below runs only if Path A succeeded; otherwise it prints a note and the synthetic results above stand as the full lesson. (We do not depend on this cell for any downstream output — the notebook’s verified path is fully offline.)

if real_data is not None:
    cols = list(real_data.columns)
    wml_real = (real_data[cols[-1]] - real_data[cols[0]]).dropna()   # Hi minus Lo decile
    yr = wml_real.values
    fr = sm.OLS(yr, np.ones((len(yr), 1))).fit(cov_type="HAC", cov_kwds={"maxlags": 5})
    print(real_label)
    print(f"  months           : {len(wml_real)}")
    print(f"  mean WML spread  : {wml_real.mean()*100:.4f} %/month")
    print(f"  Newey-West t(5)  : {fr.tvalues[0]:.2f}")
    print(f"  ann. mean        : {wml_real.mean()*12*100:.2f} %/year")
    print("Note: real momentum includes the 2009 'momentum crash' window, so the spread "
          "and its t-stat are typically smaller and noisier than the synthetic panel.")
else:
    print("Path A did not load (offline or package missing).")
    print("The synthetic panel in sections 3-6 is the complete, self-contained lesson.")

8. What to carry forward

  • The sort works and is broad. A 6/6 winner-minus-loser spread of order ~1%/month shows up across a wide region of the J \times K grid — not one starred cell. Breadth, ex-ante prediction, and out-of-sample survival are the real answers to the data-snooping charge (Ch 1.5; FDR in Ch 8.2).
  • The overlap is not free. Forming a new portfolio every month and averaging K cohorts uses all your data but induces serial correlation. Test the mean with Newey–West, lag \ge K-1, never the naive i.i.d. t. The estimate is unchanged; the honest t is smaller. This is the Week-2 lesson, made tradable.
  • Costs are the referee’s first question. Measure turnover from the actual weights, charge the short leg extra, and report the break-even cost. Gross profit is a hypothesis; net profit is the claim.
  • Interpretation stays open. Even net-positive momentum does not tell you why: behavioral under/overreaction versus a priced crash-risk factor (the 2009 crash). JT93 predates the evidence on both sides; a careful reader labels the mechanism as unresolved.

Your Turn

  1. Crank the cost dial. In §6, raise short_premium_bps from 40 to 120 (hard-to-borrow losers) and re-run the break-even cell. How far does the break-even cost fall? Does the strategy still survive at a realistic 50 bps round-trip?
  2. Kill the momentum. In simulate_panel, set phi = 0.0 (sentiment has no memory) and re-run §4. The grid should collapse toward zero — proof the profit comes from the persistence you built in, not from the sort mechanics.
  3. Lengthen the rope. Add K = 18, 24, 36 to jk_grid and watch the far cells turn negative. Where does momentum hand off to long-run reversal?
  4. Break the inference. Shrink the panel to n_months = 90 and find a (J, K) cell whose naive t clears 2.0 but whose Newey–West t does not. That cell is the cautionary tale of §5 in miniature.

  1. Jegadeesh, N., & Titman, S. (1993). Returns to Buying Winners and Selling Losers: Implications for Stock Market Efficiency. Journal of Finance, 48(1), 65–91.↩︎