Quant Memo

Build It Yourself

← All guides

Build a Short-Term Mean-Reversion Strategy

Implement a cross-sectional short-term reversal strategy on equities, rank by 5-day return, short the winners and buy the losers, market-neutral, daily, then confront the elephant: reversal's edge is real gross but is eaten alive by turnover costs.

Momentum's mirror image lives at short horizons. Over 3–12 months stocks trend (see Momentum); over a few days they reverse, last week's biggest winners tend to underperform next week, and the losers bounce. This is short-term reversal, and unlike most anomalies it has a clean economic story: someone has to absorb the order flow when a stock gets slammed by a forced seller, and they demand to be paid for it. Buy the losers, sell the winners, collect the liquidity premium.

The catch, and the entire point of this guide, is that the signal decays in days, so you rebalance daily and your turnover is enormous. Reversal is the canonical strategy where a beautiful gross edge is devoured by costs. We'll build the cross-sectional version, market-neutral, on the backtest skeleton you already know, and watch the alpha evaporate line by line.

The universe and data

Reversal is a breadth game: you want many names so the idiosyncratic bets diversify. We'll simulate a 40-stock panel with a common market factor plus mean-reverting idiosyncratic noise (swap the synthetic block for yf.download(tickers)["Close"]):

import numpy as np
import pandas as pd

rng = np.random.default_rng(7)
n_days, n_assets = 800, 40
dates = pd.bdate_range("2020-01-01", periods=n_days)
tickers = [f"A{i:02d}" for i in range(n_assets)]

# --- real data: prices = yf.download(tickers)["Close"] ---
mkt = rng.normal(0.0002, 0.010, n_days)               # common factor
rets = np.zeros((n_days, n_assets))
idio_prev = np.zeros(n_assets)
for t in range(n_days):
    shock = rng.normal(0, 0.015, n_assets)
    idio = shock - 0.12 * idio_prev                   # negative autocorr => reversal
    rets[t] = mkt[t] + idio
    idio_prev = idio
prices = pd.DataFrame(100 * np.exp(np.cumsum(rets, axis=0)),
                      index=dates, columns=tickers)

The -0.12 * idio_prev term bakes a real reversal effect into the idiosyncratic return only, the market factor still trends. That mirrors reality: reversal is a bet on relative moves within the cross-section, not on the index. A real panel won't hand you an effect this clean, so treat the gross numbers below as an upper bound.

The signal: recent relative return

Compute each stock's trailing 5-day return, then subtract the cross-sectional mean so the signal is purely relative, who moved more than their peers:

lookback = 5
past = prices.pct_change(lookback)
signal = past.sub(past.mean(axis=1), axis=0)          # relative to the pack

Demeaning is what makes this cross-sectional. A stock up 3% on a day the whole market rose 3% has a signal near zero: it did nothing special. Only relative winners and losers get a position, which automatically hedges out the market move.

The weights: contrarian and market-neutral

The classic Lo–MacKinlay contrarian portfolio weights each name by the negative of its demeaned return, so you short the relative winners and buy the relative losers:

wi,t  =  (ri,t(k)rˉt(k))/ ⁣jrj,t(k)rˉt(k)w_{i,t} \;=\; -\bigl(r^{(k)}_{i,t} - \bar r^{(k)}_t\bigr)\Big/\!\sum_j \bigl|\,r^{(k)}_{j,t} - \bar r^{(k)}_t\,\bigr|

The denominator normalizes gross exposure to 1 (0.5 long, 0.5 short). Because the numerator is demeaned, the weights sum to zero, the book is dollar-neutral by construction:

raw = -signal
weights = raw.div(raw.abs().sum(axis=1), axis=0)      # dollar-neutral, gross = 1

row = weights.dropna().iloc[100]
print(round(row.sum(), 6), round(row.abs().sum(), 6))  # 0.0  1.0

A weight vector that sums to 0.0 with absolute value 1.0 is exactly what you want: no net market exposure, one dollar of gross risk. If your longs and shorts don't net to zero you've accidentally taken a directional bet, and your "market-neutral" strategy will live or die on the index.

No lookahead, then returns

signal at day t uses the close of day t, so you can only hold it from t+1. Same shift(1) discipline as every honest backtest:

w = weights.shift(1)
asset_ret = prices.pct_change().fillna(0)
gross = (w * asset_ret).sum(axis=1)

The elephant: turnover

Here is where reversal is different from slow momentum. The signal is a 5-day return that reshuffles every single day, so your target weights churn constantly. Turnover is the total absolute change in weights across all names:

turn = w.diff().abs().sum(axis=1)
print(round(turn.mean(), 3))                           # 0.686

An average daily turnover of 0.686 means you replace roughly 69% of a gross-1 book every day, north of 170× a year. Compare that to the ~0.16 monthly turnover of the dual-momentum book. This is the structural reason reversal is a costs problem first and an alpha problem second.

Now charge for it. Costs are paid on trades, not holdings, so we multiply turnover by a per-unit cost and sweep it:

def sharpe(r, ppy=252):
    r = r.dropna()
    return np.nan if r.std() == 0 else (r.mean()/r.std())*np.sqrt(ppy)

for bps in [0, 5, 10, 20]:
    net = gross - turn.fillna(0) * bps/10000
    eq = (1 + net.fillna(0)).cumprod()
    print(f"{bps:2d} bps -> Sharpe {sharpe(net):.2f} | final {eq.iloc[-1]:.3f}")

Gross vs net: where the edge goes

Cost per unit tradedNet SharpeFinal equity
0 bps (gross)4.421.90
5 bps2.541.44
10 bps0.661.10
20 bps−3.100.63

The gross Sharpe of 4.42 looks like free money, and in a frictionless world it would be. But the strategy earns only about 8 bps of gross return per day against 0.69 units of turnover, so its breakeven cost is 8.06/0.68611.758.06 / 0.686 \approx 11.75 bps per unit traded. Cross that line and you lose money. A retail trader paying a realistic spread-plus-impact of 10–20 bps on small names is already past it. The signal is real; the business is not.

This is the whole lesson. Reversal separates two skills that momentum lets you conflate: finding an edge and keeping it after costs. Gross backtests of high-turnover strategies are almost meaningless, always report the cost sweep, and always find the breakeven.

Why reversal exists, and August 2007

The edge is compensation for liquidity provision. When a mutual fund dumps a basket to meet redemptions, it pushes those prices down below fair value; the contrarian who buys is paid the overreaction premium as prices snap back. That is a genuine service, and genuinely risky.

How risky became clear in the quant quake of August 2007 (Khandani & Lo). Reversal and other equity stat-arb books were so crowded that when one large fund began liquidating, the shared positions moved against everyone at once. Delevering to cut risk meant selling the same names, which pushed prices further, which forced more delevering, a mechanical spiral. Market-neutral funds that had never had a down month lost 20–30% in three days, and much of it snapped back by month-end. The lesson practitioners took away: a market-neutral book is not a risk-free book. Your beta is zero; your crowding exposure is not.

Where this breaks, and what to test next

  • Costs are the strategy. Everything hinges on your cost assumption. Model per-name spreads and impact that grows with size, a flat bps is generous. If it doesn't survive realistic costs, it doesn't exist.
  • Microstructure lookahead. Trading on the same close that defined the signal is optimistic even with shift(1); the bid–ask bounce alone can fake reversal profits (Lo–MacKinlay). Add an execution lag and trade at the next open.
  • Capacity and crowding. Reversal has tiny capacity, impact scales with size and the trade is crowded, as 2007 proved. What clears at $1m is uninvestable at $100m.
  • Where the alpha really is. Most reversal profit concentrates in the smallest, least liquid, hardest-to-short names, exactly where costs are worst. Re-run on large caps only and watch the gross edge shrink toward the cost line.

The machinery, though, is completely general: a demeaned cross-sectional signal, dollar-neutral weights that sum to zero, shift-clean timing, and turnover-based costs with a breakeven. That is the skeleton of every stat-arb book. Compare your build against the Mean-Reversion on the Russell and Statistical-Arbitrage Pairs entries, then carry the turnover discipline straight into the volatility-targeting overlay.

The theory behind it

Related strategies

Further reading

  • Khandani & Lo, What Happened to the Quants in August 2007? (2007)
  • Lehmann, Fads, Martingales, and Market Efficiency (QJE, 1990)
  • Lo & MacKinlay, When Are Contrarian Profits Due to Stock Market Overreaction? (1990)
  • Grinold & Kahn, Active Portfolio Management (2000)

Code is for education, not investment advice. Test everything on paper before risking capital.