Research QuantBook: PairsTrading (Statistical Arbitrage)
Objectif pedagogique
Ce notebook est un outil d’apprentissage pour comprendre pourquoi le pairs trading est difficile en pratique. La stratégie actuelle a un Sharpe de -0.361, ce qui en fait un excellent cas d’étude des pieges de l’arbitrage statistique.
Signal: Z-score du log-spread, entry |z| > 2.0, exit z = 0
Ce que ce notebook enseigne
Pourquoi la cointegration est instable sur longue periode
Comment estimer un hedge ratio optimal (OLS vs constant)
Sensibilite des seuils z-score sur la performance
Pourquoi la diversification des paires est essentielle
Les limites structurelles du pairs trading ETF
Prerequis
Environnement Lean Research
Duree estimee: ~10 minutes
# Setup QuantBookfrom AlgorithmImports import*import numpy as npimport pandas as pdimport matplotlib.pyplot as pltfrom scipy import statsimport warningswarnings.filterwarnings('ignore')plt.style.use('seaborn-v0_8-darkgrid')plt.rcParams['figure.figsize'] = (14, 6)qb = QuantBook()print("QuantBook initialise.")
QuantBook initialise.
1. Chargement des données
Paires actuelles: XLF/XLK (secteurs), GLD/GDX (gold), EWA/EWC (pays). Candidats supplementaires pour explorer d’autres paires potentielles.
tickers = ['XLF', 'XLK', 'GLD', 'GDX', 'EWA', 'EWC','SPY', 'IWM', 'XLE', 'XOP', 'EWJ', 'EWZ', 'SLV', 'TLT', 'IEF']symbols = {}for ticker in tickers: symbols[ticker] = qb.add_equity(ticker, Resolution.DAILY).symbolstart = datetime(2010, 1, 1)end = datetime(2026, 1, 1)history = qb.history(list(symbols.values()), start, end, Resolution.DAILY)closes = history['close'].unstack(level=0)symbol_to_ticker = {str(v): k for k, v in symbols.items()}closes.columns = [symbol_to_ticker.get(str(c), str(c)) for c in closes.columns]closes = closes.dropna()print(f"Periode: {closes.index[0].date()} a {closes.index[-1].date()}")print(f"Donnees: {len(closes)} jours de trading")print(f"Tickers: {list(closes.columns)}")
Periode: 2010-01-04 a 2025-12-31
Donnees: 4024 jours de trading
Tickers: ['EWA', 'EWC', 'EWJ', 'EWZ', 'GDX', 'GLD', 'IEF', 'IWM', 'SLV', 'SPY', 'TLT', 'XLE', 'XLF', 'XLK', 'XOP']
2. Concept: Cointegration et spread
Le pairs trading repose sur la cointegration: deux actifs qui divergent temporairement mais revertent vers un equilibre de long terme. Le test d’Engle-Granger verifie cette propriete en testant la stationnarite du residu de regression OLS.
Piege fondamental: La cointegration peut etre valide sur une sous-periode et invalide sur une autre. Sur 2010-2026, les changements structurels (tech bull, COVID, hausse des taux) brisent les relations historiques.
def engle_granger_test(y, x):"""Test de cointegration Engle-Granger simplifie."""# OLS regression: y = alpha + beta * x + residual slope, intercept, r_value, p_value, std_err = stats.linregress(x, y) residuals = y - (intercept + slope * x)# ADF test approximation: check if residuals are stationary# Simple version: test if residuals have mean-reverting properties n =len(residuals) delta_r = np.diff(residuals) r_lag = residuals[:-1]# ADF regression: delta_r = phi * r_lag + error slope_adf, intercept_adf, _, _, se_adf = stats.linregress(r_lag, delta_r) t_stat = slope_adf / se_adf if se_adf >0else0# Critical values for Engle-Granger (approx): -3.34 (5%), -2.93 (10%)return {'t_stat': t_stat, 'beta': slope, 'r_squared': r_value**2,'coint_5pct': t_stat <-3.34, 'coint_10pct': t_stat <-2.93}# Tester les 3 paires actuelles + alternativespairs_to_test = [ ('XLF', 'XLK', 'Financials/Tech'), ('GLD', 'GDX', 'Gold/Miners'), ('EWA', 'EWC', 'Australia/Canada'), ('SPY', 'IWM', 'Large/Small cap'), ('XLE', 'XOP', 'Energy ETF/E&P'), ('GLD', 'SLV', 'Gold/Silver'), ('TLT', 'IEF', 'Long/Mid bonds'),]print(f"{'Paire':<20}{'t-stat':>8}{'Beta':>8}{'R2':>6}{'Coint 5%':>10}{'Coint 10%':>10}")print("-"*65)for t1, t2, name in pairs_to_test:if t1 notin closes.columns or t2 notin closes.columns:continue result = engle_granger_test(closes[t1].values, closes[t2].values)print(f"{name:<20}{result['t_stat']:>8.2f}{result['beta']:>8.3f}{result['r_squared']:>5.2f} "f"{'OUI'if result['coint_5pct'] else'NON':>10}{'OUI'if result['coint_10pct'] else'NON':>10}")
Paire t-stat Beta R2 Coint 5% Coint 10%
-----------------------------------------------------------------
Financials/Tech -2.95 0.311 0.92 NON OUI
Gold/Miners 0.28 2.279 0.36 NON NON
Australia/Canada -4.32 0.261 0.43 OUI OUI
Large/Small cap -1.86 2.780 0.90 NON NON
Energy ETF/E&P -1.54 0.074 0.36 NON NON
Gold/Silver -1.65 5.196 0.54 NON NON
Long/Mid bonds -3.86 2.506 0.96 OUI OUI
Interpretation: Cointegration
Résultat attendu: la plupart des paires ne sont PAS cointegrees sur toute la periode. C’est la raison fondamentale du Sharpe negatif. Le spread diverge structurellement au lieu de reverter.
Lecon cle: Tester la cointegration sur la periode entiere ne suffit pas. Il faut aussi verifier la stabilite temporelle.
3. Stabilite temporelle de la cointegration
Tester la cointegration sur des fenêtres glissantes de 252 jours (1 an) pour identifier les periodes de validite et de rupture.
window =252# 1 anprint("=== Stabilite de la cointegration (fenetre 1 an) ===")print(f"{'Paire':<20}{'% coint (5%)':>15}{'% coint (10%)':>15}{'Periodes stables':>18}")print("-"*70)for t1, t2, name in pairs_to_test:if t1 notin closes.columns or t2 notin closes.columns:print(f"{name:<20}{'N/A':>15}{'N/A':>15}{'Non disponible':>18}")continue n_windows =0 n_coint_5 =0 n_coint_10 =0for i inrange(window, len(closes), 21): # Check every month y_win = closes[t1].iloc[i-window:i].values x_win = closes[t2].iloc[i-window:i].values# Verifier que les donnees ne sont pas identiques (evite ValueError linregress)iflen(np.unique(x_win)) <=1orlen(np.unique(y_win)) <=1:continue result = engle_granger_test(y_win, x_win) n_windows +=1if result['coint_5pct']: n_coint_5 +=1if result['coint_10pct']: n_coint_10 +=1 pct_5 = n_coint_5 / n_windows if n_windows >0else0 pct_10 = n_coint_10 / n_windows if n_windows >0else0 stability ='Stable'if pct_10 >0.6else'Instable'if pct_10 >0.3else'Fragile'print(f"{name:<20}{pct_5:>14.0%}{pct_10:>14.0%}{stability:>18}")
=== Stabilite de la cointegration (fenetre 1 an) ===
Paire % coint (5%) % coint (10%) Periodes stables
----------------------------------------------------------------------
Financials/Tech 11% 20% Fragile
Gold/Miners 12% 24% Fragile
Australia/Canada 32% 40% Instable
Large/Small cap 3% 11% Fragile
Energy ETF/E&P 16% 27% Fragile
Gold/Silver 7% 14% Fragile
Long/Mid bonds 11% 24% Fragile
Interpretation: Stabilite
Les paires “Stables” (cointegrees >60% du temps) sont les meilleures candidates. Les paires “Fragiles” ne devraient pas etre tradees - leur cointegration est un artefact statistique sur certaines sous-periodes.
Règle #16 du backlog: OLS hedge ratio ne sauve pas les paires si le problème fondamental est la qualite des paires elles-mêmes.
4. Hedge ratio: constant vs OLS roulant
Le hedge ratio determine combien de l’actif B vendre pour chaque unite de A achetee. Un ratio constant (beta=1, log-ratio) est simple mais ignore la derive. Un ratio OLS roulant s’adapte mais peut etre bruite.
def compute_spreads(closes, t1, t2, window=60):"""Calcule le spread avec ratio constant et OLS roulant."""# Log-ratio (beta=1) log_spread = np.log(closes[t1]) - np.log(closes[t2])# OLS rolling hedge ratio ols_spread = pd.Series(index=closes.index, dtype=float) ols_beta = pd.Series(index=closes.index, dtype=float)for i inrange(window, len(closes)): y = closes[t1].iloc[i-window:i].values x = closes[t2].iloc[i-window:i].values slope, intercept, _, _, _ = stats.linregress(x, y) ols_beta.iloc[i] = slope ols_spread.iloc[i] = closes[t1].iloc[i] - slope * closes[t2].iloc[i] - interceptreturn log_spread, ols_spread, ols_beta# Comparer pour les 3 paires actuellesfor t1, t2, name in [('XLF', 'XLK', 'XLF/XLK'), ('GLD', 'GDX', 'GLD/GDX'), ('EWA', 'EWC', 'EWA/EWC')]:if t1 notin closes.columns or t2 notin closes.columns:continue log_sp, ols_sp, ols_b = compute_spreads(closes, t1, t2)print(f"\n{name}:")print(f" Log-spread std: {log_sp.std():.4f}")print(f" OLS spread std: {ols_sp.dropna().std():.4f}")print(f" Beta range: [{ols_b.dropna().min():.3f}, {ols_b.dropna().max():.3f}]")print(f" Beta stable: {'Oui'if ols_b.dropna().std() <0.5else'Non'} (std={ols_b.dropna().std():.3f})")
Si le beta varie enormement (range large, std > 0.5), la relation entre les actifs n’est pas stable. Un hedge ratio roulant s’adapte mais ne corrige pas le problème fondamental: les actifs ne sont pas cointegres de maniere robuste.
5. Backtest: sensibilite des seuils z-score
Le z-score mesure combien le spread est eloigne de sa moyenne. Entry: |z| > seuil (spread aberrant), Exit: |z| < seuil (retour a la moyenne).
Stop: |z| > seuil (rupture de cointegration).
def backtest_pair(closes, t1, t2, entry_z=2.0, exit_z=0.0, stop_z=4.0, z_window=60, weight=0.15):"""Backtest une paire avec z-score entry/exit/stop.""" log_spread = np.log(closes[t1]) - np.log(closes[t2]) z_mean = log_spread.rolling(z_window).mean() z_std = log_spread.rolling(z_window).std() z_score = (log_spread - z_mean) / z_std ret1 = closes[t1].pct_change() ret2 = closes[t2].pct_change() position =0# -1 = short spread, +1 = long spread, 0 = flat port_ret = pd.Series(0.0, index=closes.index) n_trades =0for i inrange(z_window +1, len(closes)): z = z_score.iloc[i]if pd.isna(z):continue# Exit/stop conditionsif position !=0:ifabs(z) > stop_z: # Stop: cointegration breakdown position =0 n_trades +=1elif position ==1and z < exit_z: # Mean reversion achieved position =0 n_trades +=1elif position ==-1and z >-exit_z: position =0 n_trades +=1# Entry conditionsif position ==0:if z > entry_z: # Spread too high, short it position =-1elif z <-entry_z: # Spread too low, long it position =1# PnL: long spread = long t1 + short t2if position ==1: port_ret.iloc[i] = weight * (ret1.iloc[i] - ret2.iloc[i])elif position ==-1: port_ret.iloc[i] = weight * (ret2.iloc[i] - ret1.iloc[i]) total = (1+ port_ret).cumprod().iloc[-1] -1 years =len(port_ret) /252 cagr = (1+ total) ** (1/ years) -1if years >0else0 vol = port_ret.std() * np.sqrt(252) sharpe = (cagr -0.03) / vol if vol >0.001else0return {'sharpe': sharpe, 'cagr': cagr, 'vol': vol, 'n_trades': n_trades}# Test de sensibilite sur les 3 pairesfor t1, t2, name in [('XLF', 'XLK', 'XLF/XLK'), ('GLD', 'GDX', 'GLD/GDX'), ('EWA', 'EWC', 'EWA/EWC')]:if t1 notin closes.columns or t2 notin closes.columns:continueprint(f"\n=== {name} ===")print(f"{'Entry/Exit/Stop':<20}{'Sharpe':>8}{'CAGR':>8}{'Trades':>8}")print("-"*45)for entry in [1.5, 2.0, 2.5]:for exit_z in [0.0, 0.5]: r = backtest_pair(closes, t1, t2, entry_z=entry, exit_z=exit_z)print(f"{f'{entry}/{exit_z}/4.0':<20}{r['sharpe']:>8.3f}{r['cagr']:>7.1%}{r['n_trades']:>7}")
Si aucune combinaison de seuils ne produit un Sharpe positif, le problème est la paire elle-même, pas les paramètres. C’est le cas de XLF/XLK qui a diverge structurellement (tech bull 2016-2024).
Lecon cle: Optimiser les seuils sur une paire non-cointegree = overfitting.
6. Portfolio de paires
Combiner plusieurs paires reduit la variance idiosyncratique. Mais si toutes les paires sous-performent, la diversification n’aide pas.
# Backtest portfolio de pairespair_configs = [ ('XLF', 'XLK', 'XLF/XLK'), ('GLD', 'GDX', 'GLD/GDX'), ('EWA', 'EWC', 'EWA/EWC'), ('SPY', 'IWM', 'SPY/IWM'), ('XLE', 'XOP', 'XLE/XOP'), ('GLD', 'SLV', 'GLD/SLV'), ('TLT', 'IEF', 'TLT/IEF'),]print(f"{'Paire':<15}{'Sharpe':>8}{'CAGR':>8}{'Trades':>8}")print("-"*40)pair_sharpes = []for t1, t2, name in pair_configs:if t1 notin closes.columns or t2 notin closes.columns:continue r = backtest_pair(closes, t1, t2) pair_sharpes.append((name, r['sharpe']))print(f"{name:<15}{r['sharpe']:>8.3f}{r['cagr']:>7.1%}{r['n_trades']:>7}")print(f"\nMeilleure paire: {max(pair_sharpes, key=lambda x: x[1])[0]}")print(f"Sharpe moyen: {np.mean([s for _, s in pair_sharpes]):.3f}")
Cointegration instable: Les relations changent avec les regimes (taux, inflation)
ETFs = paniers: La composition des ETFs change, alterant la relation
Frais implicites: Le spread bid-ask erode les petits profits de mean-reversion
Frequence insuffisante: Peu de trades = haute variance des résultats
Quand le pairs trading fonctionne mieux
Actions individuelles dans le même secteur (même facteurs de risque)
Periodes stables sans changements structurels
Exécution rapide (intraday/haute frequence > daily)
Grand univers de paires pour la diversification
Classification: Exploratoire/Pedagogique
Avec un Sharpe de -0.361, cette stratégie est classee Exploratoire. Sa valeur reside dans l’apprentissage de la cointegration, du z-score, et des limites de l’arbitrage statistique.
8. Conclusions
Tableau recapitulatif
Test
Résultat
Lecon
Cointegration globale
Faible
Instable sur 15 ans
Stabilite temporelle
Variable
Ruptures 2016, 2020, 2022
OLS vs log-ratio
Marginal
Ne sauve pas les mauvaises paires
Seuils z-score
Non significatif
Paramètre < qualite des paires
Diversification paires
Aide un peu
Mais alpha moyen reste negatif
Règles du backlog appliquees
Règle #16: OLS hedge ratio ne sauve pas le pairs trading