Pairs trading through the gauntlet
Pairs trading is the cleanest edge story in the book — two related instruments, a spread that mean-reverts, a z-score to trade it — and also the one with the sharpest failure mode: cointegration is a statistical relationship, not a law, and it breaks. This guide takes a real pair from raw data to a tear sheet, running every claim through the validation gauntlet on the way.
Step 0 — two related instruments#
The relationship must exist for a reason you can say out loud — same business, same commodity, share classes of one firm. Scanning thousands of pairs for the best p-value manufactures spurious cointegration by brute force (see why backtests lie). Here: Coca-Cola and PepsiCo, daily bars via ek.data.fetch — or load your own CSVs with ek.data.load_bars.
import numpy as np
import pandas as pd
import edgekit as ek
from edgekit.core import bootstrap_rng
ko = ek.data.fetch("stooq", "ko.us", interval="d", start="2015-01-01")
pep = ek.data.fetch("stooq", "pep.us", interval="d", start="2015-01-01")
idx = ko.index.intersection(pep.index) # trade only shared sessions
ko, pep = ko.loc[idx], pep.loc[idx]
y = np.log(ko["close"].to_numpy(float)) # log prices: additive hedge,
x = np.log(pep["close"].to_numpy(float)) # returns-proportional spreadStep 1 — is the spread actually mean-reverting?#
A fixed OLS hedge ratio assumes the relationship never drifts; a decade of data says it does. kalman_hedge regresses y on x with a random-walk beta, and its spread output is the residual you would trade. Then make the spread prove itself twice: the fused stationarity_report verdict, and an Ornstein-Uhlenbeck half-life short enough to trade.
kh = ek.timeseries.kalman_hedge(y, x, delta=1e-4)
spread = pd.Series(kh["spread"], index=idx).iloc[100:] # drop the filter warm-up
rep = ek.timeseries.stationarity_report(spread.to_numpy())
print(rep["verdict"], f"adf {rep['adf_stat']:.2f} hurst {rep['hurst']:.2f}")
# mean-reverting adf -4.31 hurst 0.31 <- what you need to see
ou = ek.timeseries.ou_params(spread.to_numpy())
print(f"half-life {ou['half_life']:.1f} bars")
# half-life 11.4 bars <- ~2 trading weeks: tradeableTwo hard gates before any backtest:
| Check | Pass | Fail means |
|---|---|---|
verdict | "mean-reverting" | The spread is a random walk — there is nothing to trade. |
half_life | ~5–40 bars | Too short: you are trading noise inside the spread. Too long: capital sits in a diverged position for months and the stop does the exiting. |
Step 2 — backtest with PairsCoint#
PairsCoint wraps the whole loop — Kalman hedge, rolling z-score of the spread over lookback bars, entries beyond entry_z, exits inside exit_z, a hard stop at stop_z — and returns the canonical trade frame in R-multiples, where 1R is the z-distance from entry to stop.
pair = ek.strategy.PairsCoint(entry_z=2.0, exit_z=0.5, stop_z=4.0, lookback=100)
trades = pair.backtest(ko, pep)
stats = ek.trade_stats(trades.r.to_numpy(), dates=trades.date)
real = float(trades.r.sum())
print(f"{stats['n']} trades PF {stats['pf']:.2f} EV {stats['ev_r']:+.3f}R total {real:+.1f}R")
# 142 trades PF 1.52 EV +0.118R total +16.8RThe defaults are deliberate: entry_z=2.0 waits for a two-sigma divergence (about 1 entry per 2–3 half-lives), exit_z=0.5 banks most of the reversion without paying to chase the last half sigma, and stop_z=4.0 is the structural tripwire — a spread four sigmas out is more likely broken than stretched.
Step 3 — the gauntlet#
Permutation test. The null that matters: does the strategy profit specifically from the co-movement of these two series?So the null world shuffles one leg’s returns with permute_returns— same marginal distribution, cointegration destroyed — and re-runs the identical strategy. If shuffled worlds match the real total R, your “reversion” was never about the pair.
pep_lr = np.diff(np.log(pep["close"].to_numpy(float)))
def null_stat(rng: np.random.Generator) -> float:
fake_close = pep["close"].iloc[0] * np.exp(np.concatenate(
[[0.0], np.cumsum(ek.validation.permute_returns(pep_lr, rng))]))
fake_pep = pep.assign(close=fake_close) # same marginal, link destroyed
t = pair.backtest(ko, fake_pep)
return float(t.r.sum()) if len(t) else 0.0
p = ek.validation.mcpt(real, null_stat, n=200, rng=bootstrap_rng())
print(f"permutation p = {p:.4f}") # want < 0.01; p = 0.0050 hereWalk-forward. Reversion edges are regime creatures — one merger or one input-cost shock can carry a whole backtest. Six sequential blocks, each must stand alone.
daily_r = trades.set_index("date").r.groupby(lambda t: t.normalize()).sum()
wf = ek.validation.walk_forward(daily_r.to_numpy(), k=6, refit=False)
print(f"{wf['n_positive']} / {wf['k']} blocks positive")
# 5 / 6 positive with one small negative block is normal; 3 / 6 is a coinCost stress. Pairs pay double costs — two legs per entry, two per exit — so a thin per-leg edge dies quickly under stress. PF must stay above 1 at 2x and 3x.
from edgekit.costs import cost_stress
def run(cost):
t = pair.backtest(ko, pep, cost=cost)
return ek.trade_stats(t.r.to_numpy())
for mult, s in cost_stress(run).items():
print(f" {mult:.0f}x -> PF {s['pf']:.2f} EV {s['ev_r']:+.3f}R")
# 1x -> PF 1.52 EV +0.118R
# 2x -> PF 1.31 EV +0.072R
# 3x -> PF 1.13 EV +0.031R <- degrades gracefully, survivesStep 4 — size it#
Spread volatility is not constant — it doubles in a vol spike, exactly when correlations misbehave. vol_target scales the daily-R stream toward its own historical vol level, capped at 1.5x leverage, with every scale fixed from information through the prior bar.
sized = ek.sizing.vol_target(daily_r, cap=1.5, win=60)
print(f"raw sharpe {ek.trade_stats(daily_r.to_numpy())['sharpe']:.2f} "
f"sized {ek.trade_stats(sized.to_numpy())['sharpe']:.2f}")Step 5 — the tear sheet#
ek.report.tear_sheet(trades, account=500.0, # dollars per R
title="KO/PEP pairs — gauntlet survivor",
out="ko_pep_tearsheet.html")Read it looking for the pairs signature: modest win-size, high win-rate, and the loss distribution’s left edge pinned near -1R by the stop_z. A fat lump of losses beyond-1R means gaps are jumping your stop — your live costs will be worse than the model’s.
How pairs die#
This pair passed. That is a statement about the sample, not a promise — and pairs fail differently from most strategies: not by fading, but by breaking. The cointegration that held for years ends in a week when one company is acquired, changes business mix, or the sector re-rates one leg. The backtest cannot warn you, because the break is by definition out of sample. What you can do:
- Keep the stop structural.
stop_z=4.0is not a risk knob to widen when it stings — it is the definition of “this relationship is no longer the one I tested”. - Watch the half-life, not just the P&L. Re-fit
ou_paramson a trailing window; a half-life drifting from 11 bars to 40 is the spread forgetting how to come home — usually before the equity curve shows it. - Put the stream on a kill switch.
ek.monitor.KillSwitch(max_dd=0.10).check(daily_r)after every session, with thresholds frozen before go-live — see live monitoring.
Next#
- Cointegration & pairs — the theory: why correlation is not cointegration, and how the Kalman hedge works.
- Live monitoring — CUSUM, PSR and the kill switch for the surviving stream.
- Proving an edge — the full gauntlet on a strategy that fails it.
- API · strategy —
PairsCointparameters and the trade-frame contract.