Mean Reversion and Statistical Arbitrage › Pairs Trading: The Classic Implementation
Quantitative FinanceAll chapters Mean Reversion and Statistical Arbitrage › Pairs Trading: The Classic ImplementationOne section · Sections in this chapter4 / 12 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Pairs trading is the simplest and most intuitive application of mean reversion. The strategy involves identifying two securities with prices that move together, then trading the spread when it diverges from its historical norm. The elegance of pairs trading lies in its relative simplicity: rather than predicting absolute price movements, you only need to predict that a divergence will correct.
Finding Suitable Pairs
Good pairs share common economic drivers. The logic is that securities exposed to the same fundamental forces should have prices that co-move over time. When they temporarily diverge, one or both must be mispriced, creating an opportunity for profit when the mispricing corrects. Classic examples include:
- Coca-Cola and PepsiCo (competing soft drink companies)
- Exxon Mobil and Chevron (major oil producers)
- Goldman Sachs and Morgan Stanley (investment banks)
- Different share classes of the same company
- ETFs tracking similar indices
The key requirement is not that the prices be correlated, but that they be cointegrated. This distinction is critical and often misunderstood. Correlation measures how similarly two series move on a percentage basis over short periods. Two highly correlated series can still drift apart indefinitely, making them unsuitable for mean reversion trading. Cointegration, by contrast, captures a much stronger condition: the existence of a stable long-run equilibrium relationship that prevents permanent divergence.
Two series are cointegrated if some linear combination of them is stationary, even though each series individually may be non-stationary. In practical terms, this means you can construct a "spread" from the two prices that mean-reverts, even though neither price individually mean-reverts. This is the foundation of pairs trading.
Two time series and are cointegrated if both are integrated of order 1 (non-stationary with a unit root) but there exists a linear combination that is stationary. The coefficient is called the cointegrating coefficient or hedge ratio.




The Engle-Granger Two-Step Method
The Engle-Granger procedure tests for cointegration and estimates the hedge ratio. The method is elegant in its simplicity: it turns the problem of testing for cointegration into a standard unit root test on residuals from a regression.
Step 1: Regress one price series on the other:
where:
- : price of the first asset
- : price of the second asset
- : intercept
- : hedge ratio (cointegrating coefficient)
- : residual spread
The regression coefficient tells us how many units of asset we need to hold to hedge one unit of asset . If , for example, then for every share of we buy, we should sell 1.5 shares of to construct a market-neutral spread.
Step 2: Test the residuals for stationarity using the ADF test.
The residuals represent the spread after adjusting for the hedge ratio. If these residuals are stationary, it means the spread cannot drift arbitrarily far from zero; it must revert. This confirms that the series are cointegrated and that is the hedge ratio for constructing the tradeable spread.
def generate_cointegrated_pair(
n=500, beta=1.5, mu_spread=0, kappa=0.3, sigma_spread=1
):
"""Generate a pair of cointegrated price series."""
# Generate a random walk for the first series
x = np.cumsum(np.random.randn(n)) + 100
# Generate a stationary spread (OU process)
spread = np.zeros(n)
spread[0] = mu_spread
for t in range(1, n):
spread[t] = (
spread[t - 1]
+ kappa * (mu_spread - spread[t - 1])
+ sigma_spread * np.random.randn()
)
# Second series is cointegrated with first
y = beta * x + spread + 50 # Adding constant for different price levels
return x, y, spread
# Generate cointegrated pair
np.random.seed(123)
true_beta = 1.2
stock_a, stock_b, true_spread = generate_cointegrated_pair(
n=500, beta=true_beta
)from statsmodels.regression.linear_model import OLS
from statsmodels.tools import add_constant
from statsmodels.tsa.stattools import adfuller
def engle_granger_test(y, x):
"""Perform Engle-Granger cointegration test."""
# Step 1: Regress Y on X
X_const = add_constant(x)
model = OLS(y, X_const).fit()
alpha = model.params[0]
beta = model.params[1]
residuals = model.resid
# Step 2: ADF test on residuals
adf_result = adfuller(residuals, maxlag=10, autolag="AIC")
return {
"alpha": alpha,
"beta": beta,
"residuals": residuals,
"adf_statistic": adf_result[0],
"adf_pvalue": adf_result[1],
"critical_values": adf_result[4],
"r_squared": model.rsquared,
}
coint_test = engle_granger_test(stock_b, stock_a)Engle-Granger Cointegration Test Results ================================================== Estimated hedge ratio (β): 1.2231 Estimated intercept (α): 47.7082 R-squared of regression: 0.9887 ADF test on residuals: Test Statistic: -9.1315 p-value: 0.0000 Critical Values: 1%: -3.444, 5%: -2.867 True hedge ratio: 1.20
The estimated hedge ratio of approximately 1.20 closely matches the true cointegrating coefficient. The ADF test strongly rejects the null of a unit root in the spread, confirming cointegration.


Trading Rules and Signal Generation
With a cointegrated pair identified, the trading rules follow naturally from the z-score of the spread. The z-score normalizes the spread by its historical mean and standard deviation, transforming it into a standardized measure of how extreme the current deviation is. This normalization serves two purposes: it makes the signal comparable across different pairs with different spread scales, and it provides a natural interpretation in terms of statistical rarity.
where:
- : normalized z-score at time
- : current value of the spread
- : historical mean of the spread
- : historical standard deviation of the spread
The z-score tells us how many standard deviations the current spread is from its historical mean. A z-score of -2 indicates the spread is two standard deviations below average, an unusually low value that historically has tended to increase. A z-score of +2 indicates an unusually high spread that historically has tended to decrease.
Common entry and exit thresholds are:
- Enter long spread (long Stock B, short Stock A) when
- Enter short spread (short Stock B, long Stock A) when
- Exit position when crosses back to zero or a small threshold like
- Stop loss if exceeds or
These thresholds balance the tradeoff between signal quality and trading frequency. Higher entry thresholds (like ) generate fewer but higher-conviction signals, while lower thresholds (like ) trade more frequently with smaller expected profits per trade.
from statsmodels.regression.linear_model import OLS
from statsmodels.tools import add_constant
def pairs_trading_backtest(
stock_a, stock_b, lookback=60, entry_z=2.0, exit_z=0.5, stop_z=4.0
):
"""Backtest a pairs trading strategy with rolling z-score."""
n = len(stock_a)
# Calculate rolling hedge ratio using rolling regression
beta_series = np.full(n, np.nan)
spread = np.full(n, np.nan)
z_score = np.full(n, np.nan)
for t in range(lookback, n):
window_a = stock_a[t - lookback : t]
window_b = stock_b[t - lookback : t]
# Rolling regression to get hedge ratio
X_const = add_constant(window_a)
model = OLS(window_b, X_const).fit()
beta_series[t] = model.params[1]
# Calculate spread
spread[t] = stock_b[t] - model.params[0] - model.params[1] * stock_a[t]
# Rolling z-score
spread_window = (
stock_b[t - lookback : t]
- model.params[0]
- model.params[1] * stock_a[t - lookback : t]
)
z_score[t] = (spread[t] - np.mean(spread_window)) / np.std(
spread_window
)
# Generate trading signals
position = np.zeros(n)
for t in range(lookback + 1, n):
if position[t - 1] == 0: # Not in position
if z_score[t] < -entry_z:
position[t] = 1 # Long spread
elif z_score[t] > entry_z:
position[t] = -1 # Short spread
else:
position[t] = 0
else: # In position
# Check for exit or stop
if position[t - 1] == 1: # Long spread position
if z_score[t] > -exit_z or z_score[t] < -stop_z:
position[t] = 0 # Exit
else:
position[t] = 1 # Hold
else: # Short spread position
if z_score[t] < exit_z or z_score[t] > stop_z:
position[t] = 0 # Exit
else:
position[t] = -1 # Hold
# Calculate daily P&L and normalize by gross notional. Dividing by the
# spread itself would explode whenever the residual crosses zero.
strategy_returns = np.zeros(n)
for t in range(lookback + 1, n):
if np.isfinite(beta_series[t - 1]):
spread_pnl = (stock_b[t] - stock_b[t - 1]) - beta_series[t - 1] * (
stock_a[t] - stock_a[t - 1]
)
gross_notional = abs(stock_b[t - 1]) + abs(
beta_series[t - 1] * stock_a[t - 1]
)
strategy_returns[t] = position[t - 1] * spread_pnl / gross_notional
return {
"z_score": z_score,
"spread": spread,
"position": position,
"strategy_returns": strategy_returns,
"beta_series": beta_series,
}
backtest = pairs_trading_backtest(stock_a, stock_b, lookback=60)Pairs Trading Backtest Results ======================================== Total Return: 14.58% Annualized Sharpe Ratio: 7.03 Number of Trades: 38 Win Rate: 70.1%


The idealized backtest produced a Sharpe ratio of 7.03 and a 70.1% win rate. Those unusually strong results reflect the deliberately mean-reverting synthetic data and the absence of trading costs, so they illustrate the mechanics rather than a realistic performance forecast.

Practical Considerations for Pairs Trading
Several practical issues arise when implementing pairs trading:
Rolling vs. expanding windows: We used a 60-day rolling window to estimate the hedge ratio and z-score. This adapts to changing relationships but can introduce noise. Longer windows are more stable but slower to adapt.
Transaction costs: Pairs trading involves frequent rebalancing and trades both legs simultaneously. With 4 legs per round-trip (enter long, enter short, exit long, exit short), transaction costs accumulate quickly.
Margin requirements: Short selling requires margin, and the margin requirement may change based on position size and market conditions.
Execution risk: Both legs must be executed simultaneously to capture the spread. Any delay or partial fill creates unwanted directional exposure.