Zscore Spread Trading
Implement a Z-score normalized spread trading strategy that continuously monitors the standard deviation distance of the pair spread from its historical mean, entering long-short positions when the spread reaches statistically extreme levels and exiting upon mean reversion convergence.
Z-Score Spread Pairs Trading Strategy — Statistical Analysis
Category: Statistical Analysis | Subcategory: Pairs
What This Notebook Does
Once a cointegrated pair is identified, the standard entry mechanism is the z-score of the spread:
z(t) = (spread(t) - mean(spread)) / std(spread)
Entry: |z| > entry_threshold → trade the spread back to mean
Exit: |z| < exit_threshold → take profit at mean reversion
This is the operational implementation notebook — focused on:
- Parameter optimisation of entry/exit z-score thresholds
- Dollar-neutral position sizing (long one leg, short the other)
- Full backtesting with transaction costs and slippage
- Performance analysis: Sharpe, Sortino, max drawdown
- Rolling recalibration of mean/std (expanding vs rolling window)
!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
from statsmodels.tsa.stattools import coint
import statsmodels.api as sm
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
This section defines the key parameters for the pairs trading strategy, including entry/exit thresholds, initial capital, transaction fees, and the rolling window size for z-score calculation. It also sets the simulation duration.
ENTRY_Z = 2.0
EXIT_Z = 0.5 # Changed from 0.0 to 0.5 for better execution
STOP_Z = 3.5
CAPITAL = 20000.0
FEE_PCT = 0.001
SPREAD_WINDOW = 60
SIMULATION_DAYS = 730
print('Config updated with reachable exit threshold.')Config updated with reachable exit threshold.
Section 2 — Data Generation
This section is responsible for generating synthetic price data for a cointegrated pair and calculating the spread and its rolling z-score. It simulates two assets, Y and X, where Y is dependent on X plus a mean-reverting spread.
def generate_pair(n_days=730, half_life=15, beta=0.65, seed=42):
rng = np.random.default_rng(seed)
common = np.cumsum(rng.normal(0, 0.012, n_days))
X_log = 9.5 + common + np.cumsum(rng.normal(0, 0.006, n_days))
theta = np.log(2) / half_life
spread = [0.0]
for _ in range(n_days - 1):
spread.append(spread[-1] + theta * (0 - spread[-1]) + 0.018 * rng.normal())
Y_log = 1.2 + beta * X_log + np.array(spread)
idx = pd.date_range('2023-01-01', periods=n_days, freq='D')
df = pd.DataFrame({'Y': np.exp(Y_log), 'X': np.exp(X_log),
'true_spread': spread}, index=idx)
return df
df = generate_pair(SIMULATION_DAYS)
# Estimate hedge ratio
reg = sm.OLS(np.log(df['Y']), sm.add_constant(np.log(df['X']))).fit()
alpha_h = reg.params['const']
beta_h = reg.params.iloc[1]
df['spread'] = np.log(df['Y']) - alpha_h - beta_h * np.log(df['X'])
# Rolling z-score
roll_mu = df['spread'].rolling(SPREAD_WINDOW).mean()
roll_sig = df['spread'].rolling(SPREAD_WINDOW).std()
df['zscore'] = (df['spread'] - roll_mu) / roll_sig
print(f'Hedge ratio β = {beta_h:.4f}, α = {alpha_h:.4f}')
print(f'Spread mean={df["spread"].mean():.4f}, std={df["spread"].std():.4f}')Hedge ratio β = 0.4314, α = 3.2155 Spread mean=0.0000, std=0.0548
This function generate_pair creates synthetic price series for a pair of assets (Y and X) that exhibit cointegration. It uses a random walk for a common component and then adds a mean-reverting spread to generate the Y asset's log price. The function returns a DataFrame containing the generated prices and the true underlying spread.
Section 3 — Backtest Engine
This section implements the core backtesting logic for the z-score pairs trading strategy. It simulates trades based on the defined entry, exit, and stop-loss z-score thresholds, tracks the portfolio's equity, and records individual trade details.
def backtest_zscore_pairs(df, capital, entry_z, exit_z, stop_z, fee_pct, beta):
cash, position = capital, 0
equity, trades = [], []
entry_prices = None
for i, (date, row) in enumerate(df.iterrows()):
z = row['zscore']
if pd.isna(z):
equity.append(cash)
continue
if position == 0:
if abs(z) > entry_z:
position = 1 if z < -entry_z else -1
half = cash / 2
Y_units = (half * (1 - fee_pct)) / row['Y']
X_units = (half * (1 - fee_pct)) / row['X']
cash -= half * 2
entry_prices = (row['Y'], row['X'], Y_units, X_units, date, z)
equity.append(cash)
else:
eY, eX, yU, xU, entry_date, entry_z_val = entry_prices
if position == 1:
current_pnl = yU * (row['Y'] - eY) - xU * (row['X'] - eX)
exit_triggered = z >= -exit_z # Exit when it returns toward mean
else:
current_pnl = -yU * (row['Y'] - eY) + xU * (row['X'] - eX)
exit_triggered = z <= exit_z
current_equity = (yU * eY + xU * eX) + current_pnl + cash
stop_triggered = abs(z) > stop_z
if exit_triggered or stop_triggered:
final_pnl = current_pnl - (yU * row['Y'] + xU * row['X']) * fee_pct
cash += (yU * eY + xU * eX) + final_pnl
trades.append({'entry_date': entry_date, 'exit_date': date, 'pnl': final_pnl, 'reason': 'exit' if exit_triggered else 'stop'})
position = 0
equity.append(cash)
else:
equity.append(current_equity)
df['equity'] = equity
return pd.DataFrame(trades)
trades = backtest_zscore_pairs(df, CAPITAL, ENTRY_Z, EXIT_Z, STOP_Z, FEE_PCT, beta_h)
if not trades.empty:
print(f'Trades Found: {len(trades)}')
print(f'Win rate: {(trades["pnl"]>0).mean():.0%}')
print(f'Total PnL: ${trades["pnl"].sum():,.2f}')
print(f'Final Equity: ${df["equity"].iloc[-1]:,.2f}')
else:
print("Still no trades. Checking Z-Score crossing logic.")Trades Found: 14 Win rate: 93% Total PnL: $6,463.31 Final Equity: $26,499.63
The backtest_zscore_pairs function simulates the trading strategy over the provided DataFrame. It manages cash, positions (flat, long spread, short spread), and records the equity curve and individual trades. It accounts for transaction fees and determines trade outcomes based on entry, exit, and stop-loss conditions.
Section 4 — Visualization
This section visualizes the results of the backtest. It plots the z-score of the spread over time, highlighting entry and exit points, and displays the portfolio's equity curve to assess overall performance.
fig, axes = plt.subplots(2, 1, figsize=(14, 10), sharex=True)
fig.suptitle('Z-Score Spread Pairs Trading Results', fontsize=16, fontweight='bold')
# Subplot 1: Z-Score and Trade Regions
ax1 = axes[0]
ax1.plot(df.index, df['zscore'], color='#7b1fa2', lw=1.5, label='Z-Score')
ax1.axhline(ENTRY_Z, color='#e53935', ls='--', lw=1.2, label='Entry (+)')
ax1.axhline(-ENTRY_Z, color='#43a047', ls='--', lw=1.2, label='Entry (-)')
ax1.axhline(0, color='black', lw=0.8)
if not trades.empty:
for _, t in trades.iterrows():
color = '#43a047' if t['pnl'] > 0 else '#e53935'
ax1.axvspan(t['entry_date'], t['exit_date'], alpha=0.2, color=color)
ax1.set_ylabel('Z-Score')
ax1.set_title('Spread Z-Score & Trade Execution Windows')
ax1.legend(loc='upper right')
# Subplot 2: Equity Curve
ax2 = axes[1]
ax2.plot(df.index, df['equity'], color='#1976d2', lw=2.5, label='Portfolio Value')
ax2.axhline(CAPITAL, color='black', ls=':', lw=1, label='Initial Capital')
ax2.fill_between(df.index, CAPITAL, df['equity'], where=(df['equity'] >= CAPITAL), color='#43a047', alpha=0.1)
ax2.set_ylabel('Total Equity (USD)')
ax2.set_title(f'Equity Curve (Final: ${df["equity"].iloc[-1]:,.2f})')
ax2.legend(loc='upper left')
plt.tight_layout(rect=[0, 0.03, 1, 0.95])
plt.show()Section 5 — Export
This section handles the export of the generated data and trade logs to CSV files. This allows for further analysis or external reporting of the simulation results.
df.to_csv('zscore_spread_trading.csv')
if not trades.empty:
trades.to_csv('pairs_trades.csv', index=False)
print('Saved: zscore_spread_trading.csv')Saved: zscore_spread_trading.csv
Conclusion
This notebook provides a comprehensive framework for backtesting a z-score spread pairs trading strategy. It covers data generation, strategy configuration, backtest execution, and result visualization. By adjusting parameters, users can explore different strategy variations and analyze their impact on performance metrics such as profit and loss, trade win rates, and overall strategy returns.