edgekit

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.

The deal you are making
A pairs trade is short a divergence. Your win rate can be high for years because the spread usually comes back — until the structural link (same sector, same inputs, same index) dissolves, and the spread walks away forever while your z-score screams “buy more”. Everything in this guide is aimed at that one risk: proving the reversion is real and deciding in advance how you will notice when it stops being real.

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.

pairs.py
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 spread

Step 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: tradeable

Two hard gates before any backtest:

CheckPassFail means
verdict"mean-reverting"The spread is a random walk — there is nothing to trade.
half_life~5–40 barsToo 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.8R

The 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 here

Walk-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 coin

Cost 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, survives

Step 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_params on 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.
!The quiet failure is averaging into a broken spread
Every rule in this guide exists to prevent one behaviour: adding to a diverging spread because “it always comes back”. The trade that breaks pairs traders is not the hundredth reversion — it is the one divergence that was a repricing, held without a stop, sized up on the way out. If the z-score hits the stop, the trade was wrong. Take the -1R.

Next#