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