Build a pairs trading strategy with cointegration testing (Engle-Granger, ADF), hedge-ratio estimation, z-score signals, half-life exits, and a walk-forward backtest in Python.
Meta Description: Build a pairs trading strategy with cointegration testing (Engle-Granger, ADF), hedge-ratio estimation, z-score signals, and a walk-forward backtest in Python. Includes half-life exits and the failure modes that kill most pairs.
Pairs trading is a mean-reversion strategy on the spread between two cointegrated assets: you go long the underperformer and short the outperformer when the spread deviates, and exit when it reverts. Cointegration not correlation is the statistical foundation, because correlation breaks exactly when you need it while a cointegrated spread is stationary by construction.
This article is part of the quantitative finance knowledge cluster. For the single-asset OU formulation see OU mean reversion; for validation see walk-forward optimisation and purged CV.
Two price series are cointegrated if some linear combination is stationary even though each leg is non-stationary (unit root). Correlation measures co-movement over a window and drifts; cointegration is a long-run equilibrium property testable with the Engle-Granger two-step procedure: regress on , then run an Augmented Dickey-Fuller (ADF) test on the residuals. Reject the unit-root null (p < 0.05) and the spread is tradable mean-reversion fuel; fail to reject and you have correlated drift, not a pair.
import numpy as np
import pandas as pd
import statsmodels.api as sm
from statsmodels.tsa.stattools import adfuller
def engle_granger(price_a: pd.Series, price_b: pd.Series) -> dict:
"""Step 1: OLS hedge ratio. Step 2: ADF on residuals."""
X = sm.add_constant(price_b.values)
ols = sm.OLS(price_a.values, X).fit()
beta = float(ols.params[1])
spread = price_a.values - beta * price_b.values
adf_stat, p_value, *_ = adfuller(spread, autolag="AIC")[:2]
return {"beta": beta, "spread": spread,
"adf_stat": float(adf_stat), "p_value": float(p_value),
"cointegrated": bool(p_value < 0.05)}
A common mistake is testing cointegration on the full history and trading on the same window. Split first: estimate and the ADF on a formation period (e.g., 12 months), then freeze and trade the following 6 months. Re-estimate on a roll, never on the trading window.
The hedge ratio is the OLS slope of on over the formation window. The spread is dollar-neutral by construction at formation: a $1 move in the spread corresponds to the hedged portfolio, not to either leg alone. Signals come from the spread z-score with estimated on formation data only never on the trading window, or the thresholds already know the future.
Enter when (long the cheap leg, short the rich leg), exit when . The asymmetry between a wide entry and a tight exit is deliberate: it harvests the full reversion while cutting time spent in flat noise. Position size should be inversely proportional to so each pair contributes comparable risk.
The spread's half-life , with from the AR(1) regression , tells you how long reversion takes. If exceeds ~20 trading days the pair is too slow for a statistical-arbitrage book and behaves like a buy-and-hold tilt. Cap holding at : a spread that has not reverted in three half-lives has likely decoupled (takeover, regime break, constituent change), and hope is not a risk model.
def spread_half_life(spread: np.ndarray) -> float:
"""Half-life of mean reversion via AR(1) OLS. Returns inf if no reversion."""
lag = spread[:-1]
delta = np.diff(spread)
X = sm.add_constant(lag)
beta = sm.OLS(delta, X).fit().params[1]
theta = -beta
return float(np.log(2) / theta) if theta > 1e-8 else float("inf")
def pair_signals(spread: np.ndarray, mu: float, sigma: float,
z_entry: float = 2.0, z_exit: float = 0.5) -> np.ndarray:
"""Integer positions {-1, 0, 1} on the spread. +1 = long spread."""
z = (spread - mu) / sigma if sigma > 1e-12 else np.zeros_like(spread)
pos, holding = np.zeros(len(spread), dtype=int), 0
for i in range(len(spread)):
if holding == 0:
if z[i] < -z_entry: pos[i], holding = 1, 1
elif z[i] > z_entry: pos[i], holding = -1, -1
else:
if abs(z[i]) < z_exit: pos[i], holding = 0, 0
else: pos[i] = holding
return pos
A spread chart that reverts is not a backtest. The backtest must simulate both legs with borrow costs on the short, realistic fills (next-open after the signal bar, never same-close), and a portfolio constraint (e.g., max 10 concurrent pairs, equal risk per pair). Report net-of-cost Sharpe with a stationary block bootstrap confidence interval, and run the whole pipeline walk-forward: formation → frozen → 6-month trade → roll.
The three failure modes that kill most pairs: (1) decoupling cointegration estimated on history breaks (test rolling 60-day ADF p-values; abandon the pair after 3 consecutive months above 0.10); (2) crowding famous pairs (KO/PEP class) decay post-publication, consistent with McLean & Pontiff (2016); (3) cost blindness daily rebalancing of a 2%-edge spread with 2 bps per leg per day erases the edge, so gate every pair on net Sharpe, never gross.
Take two large-cap consumer staples names over 2022–2024 daily closes (illustrative numbers). Formation year 2022: OLS of on gives with ADF p-value 0.021 cointegrated, trade approved. Formation spread volatility \sigma_S = \1.40\mu_S \approx $0$. Half-life from the AR(1) fit: 9 trading days fast enough for a stat-arb book, with a 27-day maximum hold.
Trading H1 2023 frozen at : the spread hits in March (A sells off on earnings while B holds flat) → long spread (long A, short B notional). It reverts to eleven days later for +1.9% on deployed spread notional, +1.6% after 2 bps per leg plus borrow. Two more round-trips in H1 print +1.1% and −0.4% (the loser: a holding that hit the 27-day cap during a sector rotation exactly the decoupling case). H1 net: +2.3% on the pair sleeve with 4.1% volatility, gross Sharpe ≈ 1.1, net ≈ 0.8.
Attribution matters more than the total: of the +2.3%, +2.9% came from reversion captures and −0.6% from the capped decoupling the stop did its job. Rolling 60-day ADF p-values stayed below 0.08 throughout H1; had they printed above 0.10 for three straight months, the protocol kills the pair regardless of P&L. Re-formation in July 2023 re-estimated (drift is normal; jumps are not a jump beyond ±20% triggers a fundamentals review before re-approval).
A single pair is a concentrated coin-flip on one equilibrium. Production pairs books hold 20–100 pairs across sectors with three overlays: a market-neutrality check (net beta ≈ 0 daily), a concentration cap (no pair > 5% of risk), and a kill switch (portfolio-level stop at −3 ATR-equivalents of daily P&L). Validate the book not just each pair with CPCV and the deflated Sharpe, because selecting the best 20 of 500 tested pairs is exactly the multiple-testing problem those tools exist for.
Upgrade to unlock all institutional-grade algorithms, derivative pricing engines, factor backtesting frameworks, and live QuantLab execution.
Explore Membership Access
Pre-order · £490Quantitative analytics platform
Waitlist closed — signal locked. Pre-order TQH TERMINAL now at £490 with launch-day delivery.