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.

Performance actuelle

  • Sharpe: -0.361, CAGR: 0.9%, MaxDD: 15.1%
  • Alpha: -0.016 (negatif = sous-performance systématique)
  • Paires: XLF/XLK, GLD/GDX, EWA/EWC
  • Signal: Z-score du log-spread, entry |z| > 2.0, exit z = 0

Ce que ce notebook enseigne

  1. Pourquoi la cointegration est instable sur longue periode
  2. Comment estimer un hedge ratio optimal (OLS vs constant)
  3. Sensibilite des seuils z-score sur la performance
  4. Pourquoi la diversification des paires est essentielle
  5. Les limites structurelles du pairs trading ETF

Prerequis

  • Environnement Lean Research
  • Duree estimee: ~10 minutes
# Setup QuantBook
from AlgorithmImports import *
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats
import warnings
warnings.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).symbol

start = 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 > 0 else 0
    
    # 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 + alternatives
pairs_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 not in closes.columns or t2 not in 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 an

print("=== 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 not in closes.columns or t2 not in 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 = 0
    
    for i in range(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)
        if len(np.unique(x_win)) <= 1 or len(np.unique(y_win)) <= 1:
            continue
        
        result = engle_granger_test(y_win, x_win)
        n_windows += 1
        if result['coint_5pct']: n_coint_5 += 1
        if result['coint_10pct']: n_coint_10 += 1
    
    pct_5 = n_coint_5 / n_windows if n_windows > 0 else 0
    pct_10 = n_coint_10 / n_windows if n_windows > 0 else 0
    stability = 'Stable' if pct_10 > 0.6 else 'Instable' if pct_10 > 0.3 else '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 in range(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] - intercept
    
    return log_spread, ols_spread, ols_beta

# Comparer pour les 3 paires actuelles
for t1, t2, name in [('XLF', 'XLK', 'XLF/XLK'), ('GLD', 'GDX', 'GLD/GDX'), ('EWA', 'EWC', 'EWA/EWC')]:
    if t1 not in closes.columns or t2 not in 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.5 else 'Non'} (std={ols_b.dropna().std():.3f})")

XLF/XLK:
  Log-spread std: 0.3533
  OLS spread std: 0.9371
  Beta range: [-1.225, 2.504]
  Beta stable: Oui (std=0.482)

GLD/GDX:
  Log-spread std: 0.3048
  OLS spread std: 3.3519
  Beta range: [-0.493, 7.072]
  Beta stable: Non (std=0.924)

EWA/EWC:
  Log-spread std: 0.1304
  OLS spread std: 0.5887
  Beta range: [-1.259, 2.739]
  Beta stable: Oui (std=0.393)

Interpretation: Hedge ratio

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 = 0
    
    for i in range(z_window + 1, len(closes)):
        z = z_score.iloc[i]
        if pd.isna(z):
            continue
        
        # Exit/stop conditions
        if position != 0:
            if abs(z) > stop_z:  # Stop: cointegration breakdown
                position = 0
                n_trades += 1
            elif position == 1 and z < exit_z:  # Mean reversion achieved
                position = 0
                n_trades += 1
            elif position == -1 and z > -exit_z:
                position = 0
                n_trades += 1
        
        # Entry conditions
        if position == 0:
            if z > entry_z:  # Spread too high, short it
                position = -1
            elif z < -entry_z:  # Spread too low, long it
                position = 1
        
        # PnL: long spread = long t1 + short t2
        if 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) - 1 if years > 0 else 0
    vol = port_ret.std() * np.sqrt(252)
    sharpe = (cagr - 0.03) / vol if vol > 0.001 else 0
    
    return {'sharpe': sharpe, 'cagr': cagr, 'vol': vol, 'n_trades': n_trades}

# Test de sensibilite sur les 3 paires
for t1, t2, name in [('XLF', 'XLK', 'XLF/XLK'), ('GLD', 'GDX', 'GLD/GDX'), ('EWA', 'EWC', 'EWA/EWC')]:
    if t1 not in closes.columns or t2 not in closes.columns:
        continue
    print(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}")

=== XLF/XLK ===
Entry/Exit/Stop        Sharpe     CAGR   Trades
---------------------------------------------
1.5/0.0/4.0            -5.324   -5.3%    1242
1.5/0.5/4.0            -5.324   -5.3%    1242
2.0/0.0/4.0            -5.204   -3.2%     478
2.0/0.5/4.0            -5.204   -3.2%     478
2.5/0.0/4.0            -5.415   -1.9%     164
2.5/0.5/4.0            -5.415   -1.9%     164

=== GLD/GDX ===
Entry/Exit/Stop        Sharpe     CAGR   Trades
---------------------------------------------
1.5/0.0/4.0            -4.330   -8.3%    1213
1.5/0.5/4.0            -4.330   -8.3%    1213
2.0/0.0/4.0            -4.008   -5.2%     474
2.0/0.5/4.0            -4.008   -5.2%     474
2.5/0.0/4.0            -3.904   -2.6%     152
2.5/0.5/4.0            -3.904   -2.6%     152

=== EWA/EWC ===
Entry/Exit/Stop        Sharpe     CAGR   Trades
---------------------------------------------
1.5/0.0/4.0            -5.474   -4.0%    1221
1.5/0.5/4.0            -5.711   -4.1%    1221
2.0/0.0/4.0            -4.990   -2.6%     545
2.0/0.5/4.0            -5.714   -2.7%     550
2.5/0.0/4.0            -4.843   -1.4%     197
2.5/0.5/4.0            -5.721   -1.4%     197

Interpretation: Sensibilite des seuils

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 paires
pair_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 not in closes.columns or t2 not in 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}")
Paire             Sharpe     CAGR   Trades
----------------------------------------
XLF/XLK           -5.204   -3.2%     478
GLD/GDX           -4.008   -5.2%     474
EWA/EWC           -4.990   -2.6%     545
SPY/IWM           -7.493   -2.0%     489
XLE/XOP           -4.478   -3.4%     497
GLD/SLV           -4.637   -4.5%     552
TLT/IEF           -7.450   -2.3%     545

Meilleure paire: GLD/GDX
Sharpe moyen: -5.466

7. Pourquoi le pairs trading ETF est structurellement difficile

Problemes fondamentaux (2010-2026)

  1. Divergence sectorielle: XLF/XLK diverge structurellement (tech bull massif)
  2. Cointegration instable: Les relations changent avec les regimes (taux, inflation)
  3. ETFs = paniers: La composition des ETFs change, alterant la relation
  4. Frais implicites: Le spread bid-ask erode les petits profits de mean-reversion
  5. 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
  • Règle #42: Reclassification pedagogique (exploratoire)
Retour au sommet