← Back to Research Notes

Statistical Arbitrage: From Cointegration Theory to Pairs Trading

Aug 2026
StatisticsArbitrageCointegration

Correlation isn't enough

Two assets can be highly correlated in returns and still drift arbitrarily far apart in price over time — correlation says nothing about whether the spread between them is stationary. Cointegration does: if P1P_1 and P2P_2 are both non-stationary but some linear combination

εt=P1,tβP2,t\varepsilon_t = P_{1,t} - \beta P_{2,t}

is stationary, the pair is cointegrated, and εt\varepsilon_t reverts to a mean — which is the entire premise a pairs trade is built on.

From spread to signal

Once β\beta is estimated (Engle-Granger's two-step OLS, or Johansen's procedure for testing multiple series at once), the spread is converted into a z-score against its own rolling mean and standard deviation, and trades trigger at threshold crossings:

z_t = (spread_t - rolling_mean) / rolling_std
enter short spread when z_t > +2
enter long spread  when z_t < -2
exit                when z_t crosses 0

Walk-forward, not a single fit

Fitting β\beta once on a long history and trading it forever is the easy mistake to make — it treats a relationship that drifts over time as if it were a fixed constant. The Pairs Trading Backtest Dashboard instead refits on a rolling, not expanding, 252-day formation window every ~21 trading days:

W_FORMATION = 252  # trading days: rolling formation window used to fit beta + cointegration tests
W_REFIT = 21        # trading days: refit cadence (~monthly)
Z_WINDOW = 20        # trading days: rolling window for the spread's z-score
Z_STOP = 4.0          # hard stop-loss |z| magnitude — not user-configurable

At each scheduled refit, β\beta and the Engle-Granger test are both re-estimated on the prior 252 days and used going forward until the next refit. Critically, entriesAllowed is a property of that specific window, not of the pair in general:

if i == next_refit_idx:
    if position is None:
        formation = log_prices.iloc[i - W_FORMATION : i]
        current_beta = cointegration.hedge_ratio(formation["p1"], formation["p2"])
        eg = cointegration.engle_granger_test(formation["p1"], formation["p2"])
        entries_allowed = eg["isCointegrated"]
    next_refit_idx += W_REFIT

If a pair's cointegrating relationship breaks down partway through the backtest, entries_allowed simply flips to False for that block and stays there until a later formation window tests significant again — the strategy stops opening new trades on a relationship it can no longer justify, rather than continuing to trade a stale β\beta from a year ago. One deliberate wrinkle: if a refit falls due while a position is already open, it's deferred until that position closes naturally, rather than yanking the spread definition out from under a live trade.

The rolling window also has to stay strictly causal. The z-score at day tt uses only the 20 days up to and including tt, computed under whichever β\beta is currently active — and the position that z-score implies is applied to day t+1t+1's return, a one-day lag that mirrors how a real order would actually fill the day after a signal fires, not the same day it's observed.

Two cointegration tests, one gate

The dashboard runs both Engle-Granger and Johansen, but they don't carry equal weight. Engle-Granger is the actual gate — a two-step OLS-then-ADF test via statsmodels.tsa.stattools.coint() — and its p-value is what entries_allowed above checks against a 0.05 significance level:

statistic, pvalue, crit_values = coint(p1.values, p2.values, trend="c", autolag="aic")
is_cointegrated = bool(pvalue < SIGNIFICANCE_LEVEL)

Johansen's trace test runs alongside — same two-series system, gated on its own 95% critical value — but it's reported as corroborating evidence, never used to derive the traded hedge ratio or to override Engle-Granger's decision. That asymmetry is deliberate rather than an oversight: Johansen's real strength is testing cointegration rank across many series at once, which a two-asset pair doesn't need, but it's still a useful second opinion precisely because it's a differently-specified test — if Engle-Granger says a pair is cointegrated and Johansen disagrees, that disagreement is exactly the kind of signal worth surfacing to a user rather than quietly discarding.

Building a parameter search, then pulling it down

An earlier version of this dashboard also shipped a Sharpe-maximizing parameter search: sweep lookback period against entry/exit z-thresholds, rank every candidate by in-sample Sharpe only, and report out-of-sample Sharpe separately so a combination that looked great in-sample but fell apart out-of-sample would get flagged with a warning instead of quietly winning. The in-sample/out-of-sample split itself wasn't the problem — that safeguard is standard practice, and skipping it is exactly how a strategy ends up curve-fit to noise.

The problem was scope. Under Vercel's 60-second serverless budget, a workable grid topped out around three dozen candidates — a handful of lookback periods crossed with a handful of z-threshold pairs. That's not enough combinations to say anything statistically meaningful about which region of the parameter space is actually robust; it's just enough to pick a plausible-looking winner and dress it up with a validation number that looks more rigorous than the sample size behind it earns. A feature that looks like it's protecting against overfitting while still resting on a search too coarse to trust isn't more honest than not having the feature at all — it's arguably less honest, because the IS/OOS split lends it unearned credibility. So it came out. The dashboard now exposes lookback period, entry/exit z-thresholds, and slippage directly, and leaves parameter selection to the user rather than to a grid search too small to justify claiming an answer.

Try it live

Live app — if it doesn't load, open it directly ↗

Enter any two tickers, pick a lookback from 1 to 20 years, and run the backtest. A genuinely cointegrated pair (Visa and Mastercard, the dashboard's default, are a reasonable place to start) will clear the Engle-Granger gate and produce a full trade log and equity curve; an uncointegrated pair will show the gate failing, with the backtest correctly declining to run rather than pretending a spread that isn't mean-reverting is tradeable.

Source is on GitHub.