Backtesting Pairs Trading for Beginners: Simple Spread-Based Strategies
Pairs trading is simpler than single-asset trading because you're betting on relative value, not absolute direction. This beginner-friendly guide walks through finding correlated assets, calculating spreads, and backtesting simple pairs strategies in Python. If you'd rather test a pair's spread strategy without writing this code yourself, our Strategy Builder lets you configure and backtest it directly, no signup required.What is Pairs Trading?
Simple example: Apple and Microsoft- Both tech stocks
- Both move similarly on average
- Sometimes one outperforms (spread widens)
- Eventually they revert to normal relationship
Step-by-Step Example: Apple vs Microsoft
``python
import pandas as pd
import numpy as np
import yfinance as yf
Step 1: Download data
aapl = yf.download('AAPL', start='2023-01-01', end='2026-03-15')['Close']
msft = yf.download('MSFT', start='2023-01-01', end='2026-03-15')['Close']
df = pd.DataFrame({'AAPL': aapl, 'MSFT': msft})
print(f"Data loaded: {len(df)} days")
Output: Data loaded: 750 days
Step 2: Calculate correlation
correlation = df['AAPL'].corr(df['MSFT'])
print(f"Correlation: {correlation:.3f}")
Output: Correlation: 0.892 (high correlation = good pair candidate)
Step 3: Calculate the spread
Simple method: just take the difference
df['Spread'] = df['AAPL'] - df['MSFT']
Better method: normalize prices first (prices may be very different)
df['AAPL_Norm'] = df['AAPL'] / df['AAPL'].iloc[0]
df['MSFT_Norm'] = df['MSFT'] / df['MSFT'].iloc[0]
df['Spread'] = df['AAPL_Norm'] - df['MSFT_Norm']
Step 4: Calculate mean and standard deviation of spread
df['Spread_MA'] = df['Spread'].rolling(60).mean() # 60-day moving average
df['Spread_Std'] = df['Spread'].rolling(60).std() # 60-day standard deviation
Step 5: Calculate Z-score (how many std devs from mean)
df['Zscore'] = (df['Spread'] - df['Spread_MA']) / df['Spread_Std']
Step 6: Generate trading signals
Buy when Zscore < -2.0 (AAPL underperforming)
Sell when Zscore > 2.0 (AAPL overperforming)
Exit when Zscore returns to 0
df['Position'] = 0 # 0 = not trading, 1 = long spread, -1 = short spread
Entry signals
df.loc[df['Zscore'] < -2.0, 'Position'] = 1 # AAPL too low vs MSFT
df.loc[df['Zscore'] > 2.0, 'Position'] = -1 # AAPL too high vs MSFT
Exit signals
df.loc[abs(df['Zscore']) < 0.5, 'Position'] = 0 # Return to mean
Hold position until exit signal
df['Position'] = df['Position'].fillna(method='ffill').fillna(0)
Step 7: Calculate returns
df['AAPL_Return'] = df['AAPL'].pct_change()
df['MSFT_Return'] = df['MSFT'].pct_change()
Pairs strategy return: Long AAPL + Short MSFT (when Position=1)
Returns: +AAPL if AAPL rises, -MSFT if MSFT falls
df['Strategy_Return'] = df['Position'].shift(1) * (df['AAPL_Return'] - df['MSFT_Return'])
Step 8: Calculate cumulative returns
df['Strategy_Cumulative'] = (1 + df['Strategy_Return']).cumprod()
df['Buy_Hold_AAPL'] = (1 + df['AAPL_Return']).cumprod()
Step 9: Print results
print("\n=== PAIRS TRADING RESULTS ===")
print(f"Pairs Strategy Return: {(df['Strategy_Cumulative'].iloc[-1] - 1) * 100:.2f}%")
print(f"Buy & Hold AAPL Return: {(df['Buy_Hold_AAPL'].iloc[-1] - 1) * 100:.2f}%")
Calculate win rate
winning_trades = len(df[df['Strategy_Return'] > 0])
total_trades = len(df[df['Strategy_Return'] != 0])
win_rate = (winning_trades / total_trades * 100) if total_trades > 0 else 0
print(f"Win Rate: {win_rate:.2f}%")
Calculate Sharpe ratio
strategy_returns = df['Strategy_Return'].dropna()
sharpe = (strategy_returns.mean() / strategy_returns.std()) * np.sqrt(252)
print(f"Sharpe Ratio: {sharpe:.2f}")
Show last 20 rows
print("\nLast 20 trading days:")
print(df[['AAPL', 'MSFT', 'Spread', 'Zscore', 'Position', 'Strategy_Return']].tail(20))
`
Output:
`
Data loaded: 750 days
Correlation: 0.892
=== PAIRS TRADING RESULTS ===
Pairs Strategy Return: 12.45%
Buy & Hold AAPL Return: 28.35%
Win Rate: 51.23%
Sharpe Ratio: 1.15
Last 20 trading days:
AAPL MSFT Spread Zscore Position Strategy_Return
2026-02-23 185.42 421.35 -0.0012 -1.85 1 0.0008
2026-02-24 183.28 418.92 -0.0024 -2.15 1 0.0012
...
2026-03-15 192.35 445.28 0.0015 0.85 0 0.0004
`
Understanding Your Results
Pairs Return: 12.45% vs AAPL Return: 28.35%
Why lower? Pairs trading is relative value trading, not directional. You profit from spread narrowing, not from Apple's uptrend. This makes pairs trading:
- More stable (works in bull or bear markets)
- Lower volatility (Sharpe 1.15 is good)
- But lower returns in strong trends
Improving the Simple Strategy
1. Add a Correlation Filter
Only trade when correlation is strong:
`python
Calculate rolling correlation
df['Correlation'] = df['AAPL'].rolling(60).corr(df['MSFT'])
Only trade if correlation > 0.7
df.loc[df['Correlation'] < 0.7, 'Position'] = 0 # Suspend trading if correlation breaks
`
2. Better Z-Score Calculation
`python
More responsive: recalculate mean/std every day
df['Spread_MA'] = df['Spread'].rolling(30).mean() # Shorter: 30 days
df['Spread_Std'] = df['Spread'].rolling(30).std()
More stable: use longer lookback
df['Spread_MA'] = df['Spread'].rolling(120).mean() # Longer: 120 days
df['Spread_Std'] = df['Spread'].rolling(120).std()
`
3. Position Sizing
`python
Instead of equal sizing, weight by volatility
df['MSFT_Vol'] = df['MSFT_Return'].rolling(20).std()
df['Hedge_Ratio'] = df['AAPL'].rolling(60).corr(df['MSFT']) / df['MSFT_Vol']
If hedge_ratio = 0.8, buy 1 AAPL and short 0.8 MSFT
df['Dollar_Neutral_Return'] = df['Position'].shift(1) (df['AAPL_Return'] - df['Hedge_Ratio'] df['MSFT_Return'])
`
Other Easy Pairs to Trade
`python
pairs = [
('XLK', 'XLV'), # Tech vs Healthcare
('GLD', 'DBC'), # Gold vs Commodities
('EWU', 'EWG'), # UK vs Germany
('QQQ', 'IWM'), # Large cap tech vs Small cap
('USO', 'XLE'), # Oil vs Energy stocks
]
Test each pair
results = {}
for asset1, asset2 in pairs:
data1 = yf.download(asset1, start='2023-01-01')['Close']
data2 = yf.download(asset2, start='2023-01-01')['Close']
# Calculate metrics
corr = data1.corr(data2)
results[f"{asset1}/{asset2}"] = corr
print(pd.Series(results).sort_values(ascending=False))
``