Twenty-day momentum, skipping the most recent day

Do the last month's winners keep winning? A study whose most significant in-sample number turned out to be an artefact of overlapping targets, and which reported nothing rather than something.

Updated 21 Sept 20264 code blocks verifiedcase-studymomentumoverlappingcross-sectionalIChold-out

The reversal study found that yesterday’s losers bounce. The natural next question is whether the effect changes sign at a longer lookback: do stocks that have risen over the past month keep rising? This study asks that under a fixed protocol and finds nothing it is willing to report, which is the result.

1. The question as I read it

  • Cross-sectional, one signal per stock per day.
  • Signal. The sum of daily returns over the twenty trading days ending yesterday. Today is skipped, so that the signal does not contain the one-day reversal effect in disguise.
  • Targets. Tomorrow’s return, the next five days’ return, and the next twenty days’ return, because a monthly signal might act at a monthly horizon.
  • Score. Daily rank IC, mean and t, with the overlap correction for the multi-day targets.
  • Baseline. Zero.

2. Setup

import pandas as pd, numpy as np

df = pd.read_csv("all_stocks_5yr.csv")
df["date"] = pd.to_datetime(df["date"])
df = df.rename(columns={"Name": "ticker"}).sort_values(["ticker", "date"]).reset_index(drop=True)
per = df.groupby("ticker").size()
df = df[df["ticker"].isin(per[per >= 1000].index)].copy()
g = df.groupby("ticker")
df["ret"] = g["close"].pct_change()
df["fwd1"] = g["ret"].shift(-1)
df["fwd5"] = g["ret"].transform(lambda x: x.shift(-5).rolling(5).sum())
df["fwd20"] = g["ret"].transform(lambda x: x.shift(-20).rolling(20).sum())
df["mom20"] = g["ret"].transform(lambda x: x.shift(1).rolling(20).sum())        # days t-20 .. t-1: skips today
df["mom20_incl"] = g["ret"].transform(lambda x: x.rolling(20).sum())            # days t-19 .. t: includes today

cut, embargo = pd.Timestamp("2016-06-30"), pd.Timedelta(days=7)
train = df[df["date"] <= cut]
test = df[df["date"] > cut + embargo]

def daily_ic(d, sig, tgt):
    return d.groupby("date")[[sig, tgt]].apply(lambda x: x[sig].corr(x[tgt], method="spearman")).dropna()

def report(ic, label, h=1):
    m, s, n = ic.mean(), ic.std(), len(ic)
    t = m / (s / np.sqrt(n))
    print("%-30s mean IC %+.4f   t = %+.2f   overlap-adjusted t = %+.2f   hit = %.2f   days = %d" % (label, m, t, t / np.sqrt(h), (ic > 0).mean(), n))

3. Results

for h in (1, 5, 20):
    tgt = "fwd%d" % h
    for name, d in (("build", train), ("check", test)):
        report(daily_ic(d.dropna(subset=["mom20", tgt]), "mom20", tgt), "mom20 -> %s, %s" % (tgt, name), h)
mom20 -> fwd1, build           mean IC -0.0086   t = -1.56   overlap-adjusted t = -1.56   hit = 0.47   days = 834
mom20 -> fwd1, check           mean IC -0.0077   t = -0.95   overlap-adjusted t = -0.95   hit = 0.50   days = 399
mom20 -> fwd5, build           mean IC -0.0091   t = -1.70   overlap-adjusted t = -0.76   hit = 0.50   days = 834
mom20 -> fwd5, check           mean IC -0.0119   t = -1.48   overlap-adjusted t = -0.66   hit = 0.48   days = 395
mom20 -> fwd20, build          mean IC -0.0152   t = -3.09   overlap-adjusted t = -0.69   hit = 0.46   days = 834
mom20 -> fwd20, check          mean IC -0.0061   t = -0.79   overlap-adjusted t = -0.18   hit = 0.52   days = 380

Every IC is negative: over this period, last month’s winners did slightly worse, not better. But look at the sizes. The only number that would have passed a naive threshold is the twenty-day target on the build period, t = −3.1, and that is computed across 834 daily looks at a target that overlaps 19 days with the next one. After the 20\sqrt{20} correction it is −0.7. On the check period the same line is −0.2.

Had the protocol said “score against the twenty-day return” and had the overlap been ignored, this study would have reported a significant negative momentum effect on the build period and been wrong about it by the check period. The protocol’s requirement to correct for overlap, and the hold-out, are what prevented that.

4. Did skipping today matter?

for name, d in (("build", train), ("check", test)):
    report(daily_ic(d.dropna(subset=["mom20_incl", "fwd1"]), "mom20_incl", "fwd1"), "mom20 incl. today -> fwd1, " + name)
mom20 incl. today -> fwd1, build mean IC -0.0085   t = -1.53   overlap-adjusted t = -1.53   hit = 0.47   days = 835
mom20 incl. today -> fwd1, check mean IC -0.0073   t = -0.89   overlap-adjusted t = -0.89   hit = 0.50   days = 399

Barely. One day out of twenty is a small share of the signal, and the one-day reversal effect is itself small. Skipping the most recent day is still the right construction, because it makes the two studies answer different questions.

5. The picture

d = test.dropna(subset=["mom20", "fwd20"])
q = d.groupby("date")["mom20"].transform(lambda x: pd.qcut(x.rank(method="first"), 10, labels=False))
print("next-20-day return by mom20 decile, check (bps):", (d.groupby(q)["fwd20"].mean() * 1e4).round(1).tolist())
next-20-day return by mom20 decile, check (bps): [118.1, 131.7, 142.5, 133.6, 128.5, 118.7, 118.6, 108.7, 132.8, 140.7]

No tilt in either direction. The bars are flat with noise on top, which is what an IC of −0.006 looks like.

6. What to say

Over one month, with today skipped, past returns have a small negative rank correlation with future returns at every horizon tested, none of it distinguishable from zero on the hold-out once overlapping targets are accounted for. The one in-sample number that looked significant was an overlap artefact. Nothing to report.

Two caveats belong next to it. The documented momentum effect in equities uses a much longer lookback, of the order of a year with the most recent month skipped; at a one-month lookback the literature’s expectation is, if anything, mild reversal, which is the sign found here. And five years is a short sample for a monthly effect: with 20-day targets there are only about sixty non-overlapping looks in the whole file.

What I would do with another day

  1. Extend the lookback to 250 trading days, skipping the last 20, which is the construction the momentum literature actually uses.
  2. Score against non-overlapping monthly returns, so the t-statistic needs no correction and the sample size is honest on its face.
  3. Split by volatility: momentum effects are documented to behave differently in calm and turbulent markets.