Cointegration & pairs
A single price is a random walk — non-stationary, no mean to trade against. But two prices can be individually non-stationary and yet tethered to each other, so that a particular combination of them is stationary. That combination is a spread, the tether is called cointegration, and the whole of market-neutral pairs trading is built on it: you cannot forecast where either leg goes, but you can bet the gap between them snaps back. This chapter is the mechanics — spotting a real relationship versus a fake one, estimating the hedge ratio, testing the spread, and modelling its mean-reversion.
Spurious regression vs cointegration#
Regress one random walk on an unrelated second random walk and you will very often get a large and a t-statistic that screams significance. This is the spurious regression trap (Granger and Newbold, 1974): two independent I(1) series drift, and over any finite sample their drifts happen to line up enough to fool ordinary least squares. The regression is statistically meaningless — its residual is itself a random walk, so nothing pins the two series together.
Cointegration is the real thing that spurious regression imitates. Two series and are cointegrated if each is individually I(1) (a random walk in levels) but some linear combination is I(0) (stationary):
The economic picture is an equilibrium with a rubber band. Both prices wander freely, but whenever the spread stretches away from its mean, arbitrage-like forces pull it back. The distinction from spurious regression is entirely in the residual: for a spurious pair the residual is a random walk (ADF fails to reject); for a cointegrated pair the residual is stationary (ADF rejects). That test on the residual is the whole game.

The hedge ratio: static vs dynamic#
The spread only makes sense once you have a hedge ratio — the number of units of that neutralise one unit of . There are two ways to get it, and the choice matters.
Static: rolling OLS#
The Engle-Granger first step is an ordinary regression of one leg on the other, whose slope is the hedge ratio. A single full-sample OLS bakes in look-ahead (it uses the whole history to price today), so in practice you run it on a trailing window and let evolve. edgekit's rolling_ols_hedge returns the rolling intercept, slope, and residual in one call:
import edgekit as ek
# y, x are aligned price series (e.g. two related instruments' closes)
alpha, beta, resid = ek.indicators.rolling_ols_hedge(y, x, win=90)
# alpha[t], beta[t] use only the trailing 90 bars up to t -> causal, no look-ahead
# resid[t] = y[t] - (alpha[t] + beta[t]*x[t]) is the spreadRolling OLS is transparent and cheap, but it has a lag: the window must fill with new data before adapts, and a short window is jumpy while a long one is stale.
Dynamic: the Kalman filter#
The Kalman filter treats the hedge ratio as a hidden state that drifts continuously, updating it every bar from the latest observation rather than re-fitting a window. It models as a random walk and as a noisy linear readout of it:
Two knobs govern its behaviour. sets how fast the hedge ratio is allowed to wander (the state-transition variance) — larger means a more adaptive, more nervous ; is the observation-noise variance. The filter's one-step prediction error is the spread, delivered causally with no window lag:
k = ek.timeseries.kalman_hedge(y, x, delta=1e-4, r=1.0)
# k == {"beta": ndarray, # the dynamic hedge ratio, updated every bar
# "alpha": ndarray, # the dynamic intercept
# "spread": ndarray} # y - (alpha + beta*x), the tradeable series
spread = k["spread"]
hedge_now = k["beta"][-1] # units of x to short per unit of y, right now
Engle-Granger: the two-step test#
The Engle-Granger procedure is the standard way to certify a pair, and it is exactly two steps:
- Step 1 — estimate the spread. Regress on to get , then form (via
rolling_ols_hedgeorkalman_hedgeabove). - Step 2 — test the residual for a unit root. Run the Augmented Dickey-Fuller test on the spread. If the spread is stationary (ADF rejects the unit root), the pair is cointegrated.
Recall from the time-series chapter that adf_stat returns the raw t-statistic, not a p-value — compare it to the critical values (below roughly is 5% significance, is 1%). The more negative, the more convincingly the spread reverts.
# Step 1: spread from the dynamic hedge (or use the rolling-OLS resid)
spread = ek.timeseries.kalman_hedge(y, x, delta=1e-4, r=1.0)["spread"]
# Step 2: ADF on the spread. More negative = more clearly mean-reverting.
t = ek.timeseries.adf_stat(spread, lags=1)
if t < -2.86:
print(f"spread stationary at ~5% (adf t = {t:.2f}) -> candidate cointegrated pair")
else:
print(f"cannot reject unit root (adf t = {t:.2f}) -> likely spurious, do not trade")adf_stat on the Coke-Pepsi spread over five years and it returns — comfortably past the 1% critical value of , so the spread convincingly reverts: a real candidate. Now run it on a spurious pair — say Coke against an unrelated oil driller that merely happened to trend together during a bull market — and you get , nowhere near : the residual is itself a random walk, no tether, do not trade it. Same code, same threshold, opposite decisions — and note that because you fitted the hedge ratio, a marginal should still be read as a fail, not a pass.The Ornstein-Uhlenbeck model of the spread#
A stationary spread is tradeable, but you want more than a yes/no — you want to know how fast it reverts and where it reverts to. The continuous-time model of exactly this is the Ornstein-Uhlenbeck process, the mean-reverting cousin of Brownian motion:
Three parameters carry all the meaning. is the equilibrium level the spread is pulled toward. is the speed of that pull — larger means faster reversion. is the size of the random shocks fighting the pull. edgekit fits it by OLS on the AR(1) increments and hands back the derived half-life — the expected number of bars to close half the gap to equilibrium:
ou = ek.timeseries.ou_params(spread)
# ou == {"kappa": reversion speed,
# "theta": equilibrium level,
# "sigma": per-bar shock std,
# "half_life": ln(2)/kappa bars}
# a convenience half-life is also on indicators:
hl = ek.indicators.half_life(spread)
print(f"kappa={ou['kappa']:.4f} theta={ou['theta']:.4f} half-life={ou['half_life']:.1f} bars")The half-life is the most operationally useful number in the whole chapter: it is your holding horizon. A half-life of 5 bars says positions close in days and the pair can be traded actively; a half-life of 200 bars says you would hold for the better part of a year, tying up capital and exposing you to the relationship breaking before it reverts. A negative or huge means no usable reversion — skip it.
ou_params on the Coke-Pepsi spread returns , giving a half-life of trading days. Read operationally: when the spread gaps out, expect it to close half the gap in about two weeks — so this is a swing pair, not a day trade, and a position that has sat open for ~30 days (three half-lives) without reverting is a red flag that the tether may have snapped. Contrast a pair with (half-life ≈ 230 days): you would tie up capital for the better part of a year per round-trip and take only a handful of independent bets — statistically too thin to trust however pretty the backtest. The half-life sets your holding horizon, your time-stop, and your realistic trade count all at once.Trading the z-score#
With a stationary spread and a sane half-life, the trade rule writes itself: standardise the spread into a z-score and fade the extremes back toward .
where and are a trailing mean and standard deviation of the spread (or the OU and the stationary std). The canonical rule: short the spread (sell , buy of ) when , long the spread when , and flatten as crosses back through 0. Because both legs are held, the position is dollar-neutral and — if the cointegration holds — largely immune to the direction of the underlying market. You can build this as a BaseStrategy subclass that trades a synthetic spread instrument, sizing the two legs by the current hedge ratio.
# z-score of the causal spread against a trailing window (past data only)
import numpy as np
win = 60
s = np.asarray(spread, float)
mu = np.array([s[max(0, t - win):t].mean() for t in range(1, len(s) + 1)])
sd = np.array([s[max(0, t - win):t].std() for t in range(1, len(s) + 1)])
z = (s - mu) / np.where(sd == 0, np.nan, sd)
# entry/exit rule inside a BaseStrategy on the synthetic spread:
# z > +2 -> short spread (sell y, buy beta*x)
# z < -2 -> long spread (buy y, sell beta*x)
# |z| < 0.5 -> flat ; also time-stop after ~3x the OU half-lifeThe risks that actually kill pairs trades#
- Cointegration breaks. The relationship is an empirical regularity, not a law. A merger, a balance-sheet shock, an index reconstitution, or a regime change can sever the tether permanently — and the spread you were fading simply trends away and never comes back. This is the dominant risk.
- It was never real. A spurious pair that cleared a too-lenient ADF threshold in one window. The defence is the gauntlet: permutation-test the spread, split it out-of-sample, and confirm the ADF stat holds on data the hedge ratio was never fitted to.
- Costs on two legs. Every round-trip pays spread and commission twice, and reversion edges are thin. A pair that is profitable gross can be dead net — always run
ek.costs.cost_stressat multiples of your assumed cost. - Too few trades. A long half-life yields a handful of round-trips per year, so a great-looking backtest can rest on a statistically meaningless number of independent bets. Count trades, not just returns.
Next: Regime detection — cointegration holds in some regimes and breaks in others, so the natural companion question is how to detect which regime you are in. We fit a two-state hidden Markov model to label calm-vs-volatile (or trending-vs-choppy) regimes and gate a strategy accordingly.

