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.
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:
- Tests for cointegration using the Engle-Granger two-step method
- Estimates the hedge ratio β using OLS regression
- Computes the spread and its statistical properties
- Generates z-score based entry/exit signals
- Backtests the pairs trade with realistic P&L
- Visualises spread dynamics and trade history
!pip install numpy pandas matplotlib seaborn scipy statsmodels --quietimport 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:
- 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 thealpha_hat(intercept) andbeta_hat(hedge ratio) that define the linear relationship between the two assets. - 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. - 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.
- 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 aboveENTRY_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 belowEXIT_Z(indicating the spread has reverted closer to its mean, suggesting closing the position).stop: When the absolute Z-score exceedsSTOP_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()This section visualizes the key components of the pairs trading strategy:
- Asset Prices: Displays the price movements of the two individual assets (Y and X) over time.
- Mean-Reverting Spread: Plots the calculated spread, along with its mean and standard deviation bands, to visually confirm its mean-reverting behavior.
- 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.