Statistical Analysis·Pairs Trading Methods·Intermediate

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.

order-bookpairs-tradingquant-analysistrading-strategies

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:

  1. Parameter optimisation of entry/exit z-score thresholds
  2. Dollar-neutral position sizing (long one leg, short the other)
  3. Full backtesting with transaction costs and slippage
  4. Performance analysis: Sharpe, Sortino, max drawdown
  5. Rolling recalibration of mean/std (expanding vs rolling window)
[1]
!pip install numpy pandas matplotlib seaborn scipy statsmodels --quiet
[2]
import 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.

[14]
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.

[4]
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.

[15]
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.

[16]
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()
cell output

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.

[7]
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.