Mean Reversion and Statistical Arbitrage › Pairs Trading: The Classic Implementation

Michael BrenndoerferDecember 27, 202515 min read
Quantitative FinanceAll chapters
Part 11 of 15Quantitative Trading Strategies
Chapter 60Mean Reversion and Statistical Arbitrage › Pairs Trading: The Classic Implementation
Reading section 4 of 12 · Pairs Trading: The Classic Implementation · 15 minSection 4 of 12 · 15 min

Book contents

Section 4 of 12 · 15 min read

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.

Cointegration

Two time series XtX_t and YtY_t are cointegrated if both are integrated of order 1 (non-stationary with a unit root) but there exists a linear combination Yt−βXtY_t - \beta X_t that is stationary. The coefficient β\beta is called the cointegrating coefficient or hedge ratio.

Out[8]:
Visualization
Line chart of two correlated random walks diverging over time.
Two highly correlated random walks (rho=0.8) that drift apart indefinitely. Correlation alone does not prevent permanent divergence.
Line chart of two cointegrated price series tracking each other long-term.
Two cointegrated series sharing a common stochastic trend. Despite short-term deviations, they remain anchored to each other long-term.
Line chart of the non-stationary spread between correlated series drifting without bound.
Spread of the correlated-only series is non-stationary and unsuitable for mean reversion trading.
Line chart of the stationary mean-reverting spread between cointegrated series.
Spread of the cointegrated series is stationary with mean reversion, confirming a tradeable equilibrium relationship.

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:

Yt=α+βXt+εtY_t = \alpha + \beta X_t + \varepsilon_t

where:

  • YtY_t: price of the first asset
  • XtX_t: price of the second asset
  • α\alpha: intercept
  • β\beta: hedge ratio (cointegrating coefficient)
  • εt\varepsilon_t: residual spread

The regression coefficient β\beta tells us how many units of asset XX we need to hold to hedge one unit of asset YY. If β=1.5\beta = 1.5, for example, then for every share of YY we buy, we should sell 1.5 shares of XX to construct a market-neutral spread.

Step 2: Test the residuals ε^t=Yt−α^−β^Xt\hat{\varepsilon}_t = Y_t - \hat{\alpha} - \hat{\beta}X_t 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 β^\hat{\beta} is the hedge ratio for constructing the tradeable spread.

In[9]:
Code
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
)
In[10]:
Code
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)
Out[11]:
Console
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.

Out[12]:
Visualization
Line chart of two cointegrated stock price series tracking a common trend.
Stock A and Stock B sharing a common long-term trend. Despite short-term fluctuations, the two series remain anchored to each other.
Line chart of the stationary spread between the two stocks with mean and 2-sigma bands.
Mean-reverting spread constructed as Stock B minus β times Stock A, oscillating within ±2σ bands around a constant mean.

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.

zt=St−SˉσSz_t = \frac{S_t - \bar{S}}{\sigma_S}

where:

  • ztz_t: normalized z-score at time tt
  • StS_t: current value of the spread
  • Sˉ\bar{S}: historical mean of the spread
  • σS\sigma_S: 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 zt<−2z_t < -2
  • Enter short spread (short Stock B, long Stock A) when zt>2z_t > 2
  • Exit position when ztz_t crosses back to zero or a small threshold like ±0.5\pm 0.5
  • Stop loss if ztz_t exceeds ±3\pm 3 or ±4\pm 4

These thresholds balance the tradeoff between signal quality and trading frequency. Higher entry thresholds (like ±2.5\pm 2.5) generate fewer but higher-conviction signals, while lower thresholds (like ±1.5\pm 1.5) trade more frequently with smaller expected profits per trade.

In[13]:
Code
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)
Out[14]:
Console
Pairs Trading Backtest Results
========================================
Total Return: 14.58%
Annualized Sharpe Ratio: 7.03
Number of Trades: 38
Win Rate: 70.1%
Out[15]:
Visualization
Line chart of spread z-score with horizontal dashed lines marking entry and exit thresholds.
Spread z-score time series with entry thresholds at ±2.0 (dashed) and exit thresholds at ±0.5 (dotted). Trades are triggered when the z-score crosses the entry bands.
Dual-axis chart showing long and short position shading alongside cumulative strategy returns.
Cumulative return (purple line) alongside long (green) and short (red) position shading. The strategy consistently captures mean reversion over the backtest period.

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.

Out[16]:
Visualization
Histogram showing z-score distribution with vertical lines at entry thresholds.
Distribution of spread z-scores with trading thresholds marked at ±2.0. The histogram displays a bell-shaped concentration near zero where no trading occurs, with tails extending into the actionable zones (red/green regions), confirming the statistical rarity of trade signals.

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.