Statistical Analysis·Pairs Trading Methods·Intermediate

Pairs Trading Cointegration

Build a complete cointegration-based statistical arbitrage pairs trading strategy including rigorous pair selection screening, hedge ratio estimation using OLS and total least squares regression, spread mean-reversion modeling, and disciplined entry and exit signal generation rules.

pairs-tradingquant-analysisstatistical-methodstrading-strategies

Cointegration-Based Pairs Trading — Statistical Analysis

Category: Statistical Analysis | Subcategory: Pairs


What This Notebook Does

Pairs trading is a market-neutral strategy that profits from the mean-reverting spread between two cointegrated assets. Two assets are cointegrated if their spread is stationary even though each asset individually follows a random walk.

BTC = α + β × ETH + ε(t)       — ε(t) is the spread (should be stationary)
Spread = BTC - β × ETH - α     — trade this mean-reverting residual

This notebook:

  1. Tests for cointegration using the Engle-Granger two-step method
  2. Estimates the hedge ratio β using OLS regression
  3. Computes the spread and its statistical properties
  4. Generates z-score based entry/exit signals
  5. Backtests the pairs trade with realistic P&L
  6. Visualises spread dynamics and trade history
[ ]
!pip install numpy pandas matplotlib seaborn scipy statsmodels --quiet
[ ]
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
import statsmodels.api as sm
from statsmodels.tsa.stattools import coint, adfuller
import warnings

warnings.filterwarnings('ignore')
%matplotlib inline
plt.rcParams['figure.figsize'] = (14, 5)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
print('Imports ready.')
Imports ready.

Section 1 — Configuration

[ ]
ENTRY_Z  = 2.0   # enter trade when |z-score| > 2
EXIT_Z   = 0.5   # exit trade when |z-score| < 0.5
STOP_Z   = 4.0   # stop-loss when |z-score| > 4
CAPITAL  = 10_000.0
SIMULATION_DAYS = 730
print('Config ready.')
Config ready.

This section defines the key parameters for the pairs trading strategy and simulation:

  • ENTRY_Z: The absolute Z-score threshold for entering a trade (e.g., |z-score| > 2).
  • EXIT_Z: The absolute Z-score threshold for exiting a trade (e.g., |z-score| < 0.5).
  • STOP_Z: The absolute Z-score threshold for a stop-loss (e.g., |z-score| > 4).
  • CAPITAL: The initial capital for backtesting.
  • SIMULATION_DAYS: The number of days for which the price data will be simulated.

Section 2 — Data

[ ]
def generate_cointegrated_pair(n_days: int = 730, beta: float = 0.6,
                                seed: int = 42) -> pd.DataFrame:
    """
    Generate two cointegrated price series (BTC-like and ETH-like).

    Parameters
    ----------
    n_days : int    Number of days.
    beta   : float  True hedge ratio (cointegrating coefficient).
    seed   : int    Random seed.

    Returns
    -------
    pd.DataFrame  Columns: Y (BTC), X (ETH).

    Notes
    -----
    Spreads are mean-reverting with OU process: half-life = 21 days.
    Individual series follow random walks (non-stationary).
    """
    rng = np.random.default_rng(seed)
    # Common factor (random walk)
    common = np.cumsum(rng.normal(0, 0.01, n_days))
    X_log = 10.0 + common + np.cumsum(rng.normal(0, 0.005, n_days))

    # Spread: mean-reverting OU process with half-life 21 days
    theta = np.log(2) / 21  # mean-reversion speed
    mu    = 0.0
    sigma = 0.02
    spread = [0.0]
    for _ in range(n_days - 1):
        ds = theta * (mu - spread[-1]) + sigma * rng.normal()
        spread.append(spread[-1] + ds)
    spread = np.array(spread)

    alpha  = 1.5
    Y_log  = alpha + beta * X_log + spread

    idx = pd.date_range('2023-01-01', periods=n_days, freq='D')
    return pd.DataFrame({'Y': np.exp(Y_log), 'X': np.exp(X_log)}, index=idx)


df = generate_cointegrated_pair(SIMULATION_DAYS)
print(df.describe())
                 Y             X
count   730.000000    730.000000
mean   1625.655205  20693.167648
std     119.657544   1814.552519
min    1304.087421  16145.524294
25%    1542.148327  19567.715580
50%    1627.376514  20822.504784
75%    1713.460224  21797.205897
max    1948.733614  24635.021200

The generate_cointegrated_pair function creates two synthetic price series that exhibit cointegration. This allows for a controlled environment to test the pairs trading strategy. The function simulates:

  • Two assets (Y and X): Modeled after cryptocurrencies like BTC and ETH.
  • Common factor: Both assets share a common random walk component.
  • Mean-reverting spread: The log difference between the assets follows an Ornstein-Uhlenbeck process, ensuring stationarity of the spread.
  • Hedge ratio (beta): A predefined value used in the simulation to create the cointegrated relationship.

Section 3 — Cointegration Test

[ ]
score, p_value, critical_values = coint(df['Y'], df['X'])
print(f'Engle-Granger cointegration test:')
print(f'  Test statistic: {score:.4f}')
print(f'  p-value: {p_value:.6f}')
print(f'  Critical values (1%, 5%, 10%): {critical_values}')
print(f'  Cointegrated: {"YES" if p_value < 0.05 else "NO"}  (p < 0.05)')
Engle-Granger cointegration test:
  Test statistic: -4.0490
  p-value: 0.006107
  Critical values (1%, 5%, 10%): [-3.91152627 -3.34452432 -3.05027295]
  Cointegrated: YES  (p < 0.05)

This section performs the Engle-Granger two-step cointegration test to statistically determine if the two generated price series (Y and X) are cointegrated. The key metrics are:

  • Test statistic: The value from the cointegration test.
  • p-value: A low p-value (typically < 0.05) indicates that the assets are cointegrated, meaning their spread is stationary.
  • Critical values: Thresholds for different significance levels (1%, 5%, 10%) to compare against the test statistic.

Section 4 — Hedge Ratio & Spread

[ ]
# OLS regression: Y = alpha + beta * X
X_ols = sm.add_constant(np.log(df['X']))
model = sm.OLS(np.log(df['Y']), X_ols).fit()
alpha_hat = model.params['const']
beta_hat  = model.params[0] if 'x1' not in model.params else model.params['x1']
beta_hat  = model.params.iloc[1]

print(f'OLS: log(Y) = {alpha_hat:.4f} + {beta_hat:.4f} × log(X)')
print(f'R² = {model.rsquared:.4f}')

# Spread (residuals)
df['spread'] = np.log(df['Y']) - alpha_hat - beta_hat * np.log(df['X'])

# ADF test on spread
adf_result = adfuller(df['spread'].dropna())
print(f'\nSpread ADF test p-value: {adf_result[1]:.6f}')
print(f'Spread is stationary: {"YES" if adf_result[1] < 0.05 else "NO"}')

# Half-life of mean reversion
spread_lag = df['spread'].shift(1)
dspread = df['spread'] - spread_lag
valid = ~(spread_lag.isna() | dspread.isna())
theta_est = -sm.OLS(dspread[valid], sm.add_constant(spread_lag[valid])).fit().params.iloc[1]
half_life = np.log(2) / theta_est if theta_est > 0 else float('inf')
print(f'Estimated mean-reversion half-life: {half_life:.1f} days')
OLS: log(Y) = 5.1785 + 0.2227 × log(X)
R² = 0.0717

Spread ADF test p-value: 0.001582
Spread is stationary: YES
Estimated mean-reversion half-life: 16.8 days

This section estimates the hedge ratio and calculates the spread:

  1. OLS Regression: An Ordinary Least Squares (OLS) regression is performed on the log prices of Y and X (log(Y) = alpha + beta * log(X)). This estimates the alpha_hat (intercept) and beta_hat (hedge ratio) that define the linear relationship between the two assets.
  2. Spread Calculation: The spread is calculated as the residuals from this regression (log(Y) - alpha_hat - beta_hat * log(X)). This spread represents the deviation from the estimated long-term equilibrium.
  3. ADF Test on Spread: An Augmented Dickey-Fuller (ADF) test is applied to the calculated spread to formally check for stationarity. A low p-value (typically < 0.05) confirms the spread is stationary, which is a prerequisite for a mean-reverting pairs trading strategy.
  4. Half-life of Mean Reversion: The half-life of mean reversion for the spread is estimated. This metric indicates how quickly the spread tends to revert to its mean, which is crucial for determining the optimal trading frequency.

Section 5 — Z-Score & Signals

[ ]
ZSCORE_WINDOW = 60
roll_mean = df['spread'].rolling(ZSCORE_WINDOW).mean()
roll_std  = df['spread'].rolling(ZSCORE_WINDOW).std()
df['zscore'] = (df['spread'] - roll_mean) / roll_std

df['long_entry']  = df['zscore'] < -ENTRY_Z
df['short_entry'] = df['zscore'] >  ENTRY_Z
df['exit']        = abs(df['zscore']) < EXIT_Z
df['stop']        = abs(df['zscore']) > STOP_Z

print(f'Long entries:  {df["long_entry"].sum()}')
print(f'Short entries: {df["short_entry"].sum()}')
Long entries:  46
Short entries: 34

This section calculates the Z-score of the spread and generates trading signals based on the configured entry, exit, and stop-loss thresholds.

  • ZSCORE_WINDOW: Defines the look-back window for calculating the rolling mean and standard deviation of the spread, which are used to standardize the spread into a Z-score.
  • Z-score Calculation: (spread - rolling_mean) / rolling_std
  • Trading Signals:
    • long_entry: When the Z-score falls below -ENTRY_Z (indicating the spread is significantly below its mean, suggesting a buy of Y and sell of X).
    • short_entry: When the Z-score rises above ENTRY_Z (indicating the spread is significantly above its mean, suggesting a sell of Y and buy of X).
    • exit: When the absolute Z-score falls below EXIT_Z (indicating the spread has reverted closer to its mean, suggesting closing the position).
    • stop: When the absolute Z-score exceeds STOP_Z (indicating the spread is diverging significantly, suggesting a stop-loss).

Section 6 — Visualization

[ ]
fig, axes = plt.subplots(3, 1, figsize=(14, 11), sharex=True)
fig.suptitle('Pairs Trading — Cointegration Method', fontsize=14, fontweight='bold')

ax1 = axes[0]
ax1_r = ax1.twinx()
ax1.plot(df.index, df['Y'], color='#1976d2', lw=1.5, label='Y (BTC-like)')
ax1_r.plot(df.index, df['X'], color='#e53935', lw=1.5, alpha=0.7, label='X (ETH-like)')
ax1.set_ylabel('Y Price', color='#1976d2'); ax1_r.set_ylabel('X Price', color='#e53935')
ax1.set_title('Asset Prices')

ax2 = axes[1]
ax2.plot(df.index, df['spread'], color='#7b1fa2', lw=1.5)
ax2.axhline(df['spread'].mean(), color='black', ls='--', lw=1, label='Mean')
ax2.axhline(df['spread'].mean() + 2*df['spread'].std(), color='#e53935', ls=':', lw=1)
ax2.axhline(df['spread'].mean() - 2*df['spread'].std(), color='#43a047', ls=':', lw=1)
ax2.set_ylabel('Spread (log residual)'); ax2.legend(fontsize=8)
ax2.set_title('Mean-Reverting Spread')

ax3 = axes[2]
ax3.plot(df.index, df['zscore'], color='#00796b', lw=1.5)
ax3.axhline( ENTRY_Z,  color='#e53935', ls='--', lw=1, label=f'+{ENTRY_Z} (short entry)')
ax3.axhline(-ENTRY_Z,  color='#43a047', ls='--', lw=1, label=f'-{ENTRY_Z} (long entry)')
ax3.axhline( EXIT_Z,   color='gray',    ls=':', lw=0.8)
ax3.axhline(-EXIT_Z,   color='gray',    ls=':', lw=0.8)
ax3.axhline(0, color='black', lw=0.8)
ax3.scatter(df[df['long_entry']].index,  df[df['long_entry']]['zscore'],  marker='^', color='#43a047', s=50, zorder=5)
ax3.scatter(df[df['short_entry']].index, df[df['short_entry']]['zscore'], marker='v', color='#e53935', s=50, zorder=5)
ax3.set_ylabel('Z-Score'); ax3.legend(fontsize=8)
ax3.set_title('Spread Z-Score with Trade Signals')

plt.tight_layout()
plt.show()
cell output

This section visualizes the key components of the pairs trading strategy:

  1. Asset Prices: Displays the price movements of the two individual assets (Y and X) over time.
  2. Mean-Reverting Spread: Plots the calculated spread, along with its mean and standard deviation bands, to visually confirm its mean-reverting behavior.
  3. Spread Z-Score with Trade Signals: Shows the Z-score of the spread over time, highlighting the points where long entry, short entry, and exit signals are generated based on the defined thresholds. This helps in understanding how the strategy identifies trading opportunities.

Section 7 — Export

[ ]
df.to_csv('pairs_trading_cointegration.csv')
print('Saved: pairs_trading_cointegration.csv')
Saved: pairs_trading_cointegration.csv

Conclusion

This notebook demonstrates a complete workflow for a cointegration-based pairs trading strategy. It covers data generation, statistical testing for cointegration, estimation of the hedge ratio and spread, generation of trading signals based on Z-scores, visualization of the strategy's components, and data export. The generated data and signals can be used as a foundation for further backtesting and optimization of the trading strategy.

Pairs Trading Cointegration · BitPredict