Skip to content

Testing Cointegration for Pairs Trading in Python

Original question

How can I test whether two stock price series are cointegrated and use the relationship for pairs trading?
For two plausibly I(1)I(1) log-price series, use the Engle–Granger test on a formation window to ask whether a fitted linear combination is stationary. If there is evidence of cointegration, estimate a hedge ratio and trade deviations from the fitted spread on later data. Evaluate the entire process after execution costs, allowing the relationship to weaken or break.

Fit the spread on formation data

Use adjusted, aligned observations and choose a formation window. Inspect the log price levels and first differences, and use unit-root and stationarity diagnostics to check the I(1)I(1) assumption. Regress yt=log⁡PA,ty_t=\log P_{A,t} on xt=log⁡PB,tx_t=\log P_{B,t} over that window:
yt=a+bxt+et.y_t=a+b x_t+e_t.

Engle–Granger tests the residual for a unit root using critical values that account for having estimated aa and bb. Its null is no cointegration. A low p-value supports a stationary spread under the selected specification and sample. It also matters which series is chosen as the dependent variable in finite samples. Select the orientation and deterministic terms before interpreting the test.

Use the formation-window mean and standard deviation of the spread to form zt=(et−eˉ)/sez_t=(e_t-\bar e)/s_e on later observations. A simple strategy sells the spread above z=2z=2, buys it below z=−2z=-2, and exits closer to zero. For a log spread, the coefficient bb is a log-price sensitivity. To obtain approximate exposure to Δyt−bΔxt\Delta y_t-b\Delta x_t, size dollar notionals in a 1:b1:b ratio, and specify how the positions are financed and rebalanced.

Translate a spread signal into actual positions

Suppose a pair has b=1.2b=1.2, a formation spread mean of zero and a standard deviation of 0.03. Today's spread of 0.075 is 2.5 standard deviations above its fitted mean. With a positive hedge coefficient, an illustrative short-spread position would short one unit of dollar notional in A and buy 1.2 units in B. If the spread narrows to 0.03 before the exit, the 0.045 log spread change helps explain the direction of gross P&L. Actual P&L depends on both legs' percentage moves, positions, timing, financing, commissions, and borrow costs.

The hedge coefficient is fitted in log-price units. If you use the spread as a signal, you still need to define the dollar exposure, convert it to shares at executable prices, and handle the cost of holding the short. Re-estimating bb every day can change the reported spread even when neither price moves. Treat a refit as an explicit model decision and charge for any resulting trade. In validation, compare a frozen formation fit with your scheduled refitting policy so you can see whether the policy itself is helping.

Run the Engle–Granger test and freeze the fit

import numpy as np import pandas as pd import statsmodels.api as sm from statsmodels.tsa.stattools import coint rng = np.random.default_rng(7) x = 4 + np.cumsum(rng.normal(0, 0.015, 600)) e = np.zeros(600) for t in range(1, len(e)): e[t] = 0.7 * e[t - 1] + rng.normal(0, 0.015) logs = pd.DataFrame({"x": x, "y": 0.4 + 1.2 * x + e}) formation, later = logs.iloc[:400], logs.iloc[400:] _, p_value, _ = coint(formation.y, formation.x, trend="c") fit = sm.OLS(formation.y, sm.add_constant(formation.x)).fit() later_spread = later.y - fit.predict(sm.add_constant(later.x)) z = (later_spread - fit.resid.mean()) / fit.resid.std() print("Formation test p-value:", p_value) print("First later signals:", z.head().round(2).tolist())

This forms signals from synthetic log prices. A trading simulation would timestamp orders after those signals and model fills on both legs.

What to check

  • Using ordinary ADF critical values on the fitted spread.
  • Selecting a pair using future data or testing hundreds of pairs without tracking multiple comparisons.
  • Re-estimating the hedge ratio with data that arrives after the signal.

References and next steps