Features: Z-score, demi-vie, régime de volatilité, momentum
Entrée: |Z| > 2 + prédiction ML positive
Sortie: |Z| < 0.5
Performance de référence
Sharpe ~1.2-1.8 (2020-2025) - pairs trading avec filtrage ML.
Hypothèses à tester
Seuil Z-score: 1.5, 2.0, 2.5
Demi-vie max: 30, 60, 90 jours
ML threshold: 0.3, 0.5, 0.7
Prérequis
Environnement Lean Research
statsmodels pour cointégration
scikit-learn pour ML
Durée estimée: ~15 minutes
Note : Cette stratégie combine l’analyse statistique (cointégration) avec le ML pour optimiser le timing d’entrée.
# Setup QuantBookfrom AlgorithmImports import*import numpy as npimport pandas as pdimport matplotlib.pyplot as pltfrom sklearn.ensemble import RandomForestClassifierfrom sklearn.preprocessing import StandardScalerfrom sklearn.metrics import accuracy_score, classification_reportimport statsmodels.api as smfrom statsmodels.tsa.stattools import adfullerimport warningswarnings.filterwarnings('ignore')plt.style.use('seaborn-v0_8-darkgrid')plt.rcParams['figure.figsize'] = (14, 5)qb = QuantBook()qb.SetStartDate(2020, 1, 1)qb.SetEndDate(2025, 12, 31)print(f"QuantBook initialise. Periode: {qb.StartDate} a {qb.EndDate}")
QuantBook initialise. Periode: 2020-01-01 00:00:00 a 2025-12-31 23:59:59.999999
1. Chargement des données
On charge les données d’ETF sectoriels pour la période 2020-2026.
# Paires d'ETF disponibles dans l'environnement Lean# Les ETF sectoriels (XLE, XLP, XLV...) ne sont pas disponibles en Docker research.# On utilise les ETF broad-market qui sont disponibles.all_tickers = ["SPY", "QQQ", "IWM", "EFA", "AGG"]symbols = {}for ticker in all_tickers: symbols[ticker] = qb.AddEquity(ticker, Resolution.DAILY).Symbol# Charger l'historique (5 ans de donnees quotidiennes)history = qb.History(list(symbols.values()), 365*5, Resolution.Daily)print(f"Donnees chargees: {len(history)} lignes")# Verifier quels tickers ont des donneesiflen(history) >0: available = history.index.get_level_values(0).unique() avail_tickers = [t for t in all_tickers ifany(str(s) == t or t instr(s) for s in available)]print(f"Tickers disponibles: {avail_tickers}")else: avail_tickers = []print("ATTENTION: Aucune donnee chargee. L'environnement peut ne pas avoir de donnees pour ces tickers.")# Paires a tester parmi les tickers disponiblesetf_pairs = []for i inrange(len(avail_tickers)):for j inrange(i+1, len(avail_tickers)): etf_pairs.append((avail_tickers[i], avail_tickers[j]))print(f"Paires a tester: {etf_pairs}")
Pivot de la série ‘close’ en DataFrame large, avec remapping des colonnes Symbol → ticker pour ML-EnhancedPairs.
# Pivoter les donneesiflen(history) >0and'close'in history.columns: closes = history['close'].unstack(level=0)# Remapper les colonnes Symbol Lean -> ticker strings symbol_to_ticker = {}for ticker, sym in symbols.items(): symbol_to_ticker[str(sym)] = ticker closes.columns = [symbol_to_ticker.get(str(c), str(c)) for c in closes.columns]# Garder uniquement les colonnes avec assez de donnees closes = closes.ffill().dropna(axis=1, thresh=len(closes)*0.5).dropna()print(f"Periode: {closes.index[0].date()} a {closes.index[-1].date()}")print(f"Donnees: {len(closes)} jours de trading")print(f"ETFs: {list(closes.columns)}")print("\nStatistiques des prix finaux:")for ticker in closes.columns: ret = (closes[ticker].iloc[-1] / closes[ticker].iloc[0] -1) *100print(f" {ticker}: {ret:+.1f}%")else: closes = pd.DataFrame()print("Aucune donnee de prix disponible.")print(f"Colonnes history: {list(history.columns) iflen(history) >0else'vide'}")
Periode: 2012-09-28 a 2019-12-31
Donnees: 1825 jours de trading
ETFs: ['IWM', 'QQQ', 'SPY']
Statistiques des prix finaux:
IWM: +120.4%
QQQ: +235.6%
SPY: +158.6%
Fenetre reellement calculee : 2012-09-28 a 2019-12-31
Les deux lignes Periode: de ce notebook ne parlent pas de la meme chose.
Ou
Ce qui est imprime
Ce que c’est
cellule 1
Periode: 2020-01-01 a 2025-12-31
la periode declaree au QuantBook (SetStartDate / SetEndDate)
cellule ci-dessus
Periode: 2012-09-28 a 2019-12-31
la fenetre effectivement chargee, et donc celle sur laquelle tout ce qui suit est calcule
Le libelle identique fait croire a une contradiction ; il n’y en a pas, seulement une homonymie. Toutes les statistiques en aval (cointegration, spreads, z-scores, performances des paires) portent sur la seconde : 2012-2019, 1825 jours de trading.
Pourquoi les deux different.qb.History(N, Resolution.Daily) recule depuis qb.Time, pas depuis EndDate. Dans un QuantBook frais, qb.Time vaut StartDate — ici 2020-01-01. Le 365*5 de la cellule 3 demande donc les cinq annees qui precedent 2020, pas les cinq annees declarees apres elle. Le mecanisme a ete nomme par po-2024 sur #8770 et generalise par #8772.
Ce que ca change pour la lecture. La fenetre 2012-2019 exclut le COVID et le drawdown 2022 — les deux regimes qu’on s’attend justement a voir tester une strategie de paires. Les resultats restent valides sur leur propre fenetre ; ils ne disent rien de 2020-2025.
Pourquoi on ne re-fenetre pas maintenant. Passer des dates explicites (qb.History(symbols, datetime(2020,1,1), datetime(2024,12,31), ...)) ferait rejoindre le calcul a l’annonce — et casserait les donnees. Les donnees equity locales s’arretent au 2021-03-31 (#8734) et Lean les prolonge en constante via fillDataForward=True : demander 2020-2024 aujourd’hui rendrait ~3 ans de plat parfait, l’artefact meme qui a produit les 100 % de direction accuracy de #8719. La fenetre reculee est, par accident, la raison pour laquelle ce notebook a de vraies barres. Le re-fenetrage est une PR distincte, gardee par #8734/#8724.
Ce qui reste a faire ici. Renommer la seconde ligne en Fenetre effective: dans la source, sur le modele de ML-DeepLearning (#8770), demande une re-execution — donc un QuantBook, donc QC Cloud : gate #6891 (RECOVERABLE-USER-HAND). Cette cellule est la divulgation interimaire, pas le correctif final.
2. Test de Cointégration
Le test d’Engle-Granger vérifie si deux séries ont une relation à long terme.
def engle_granger_coint(y1, y2, significance=0.05):""" Test de cointegration Engle-Granger. Retourne: (is_cointegrated, hedge_ratio, half_life, spread) """ df = pd.DataFrame({'y': y1, 'x': y2}).dropna()iflen(df) <30:returnFalse, None, None, None# OLS pour le hedge ratio x = sm.add_constant(df['x']) result = sm.OLS(df['y'], x).fit() hedge_ratio = result.params['x']# Calcul du spread spread = df['y'] - hedge_ratio * df['x']# Test ADF sur le spread adf_result = adfuller(spread.dropna()) p_value = adf_result[1] is_cointegrated = p_value < significance# Demi-vie df_spread = pd.DataFrame({'spread': spread}).dropna() df_spread['spread_lag'] = df_spread['spread'].shift(1) df_spread = df_spread.dropna()iflen(df_spread) <10: half_life =999else:from sklearn.linear_model import LinearRegression model = LinearRegression() model.fit(df_spread[['spread_lag']], df_spread['spread']) lambda_coef = model.coef_[0]if lambda_coef >=1: half_life =999else: half_life =max(0, -np.log(2) / np.log(lambda_coef))return is_cointegrated, hedge_ratio, half_life, spread# Tester toutes les paires (uniquement les tickers disponibles)iflen(closes.columns) >0: available_tickers =set(closes.columns)print("=== Test de Cointegration ===")print(f"{'Paire':<15}{'Cointegre?':<12}{'Hedge Ratio':>12}{'Demi-vie':>10}")print("-"*52) cointegrated_pairs = []for ticker1, ticker2 in etf_pairs:if ticker1 notin available_tickers or ticker2 notin available_tickers:print(f"{ticker1}/{ticker2:<10}{'DONNEES MANQUANTES':<12}")continue is_coint, hr, hl, spread = engle_granger_coint(closes[ticker1], closes[ticker2]) status ="OUI"if is_coint else"NON" hr_str =f"{hr:.3f}"if hr else"N/A" hl_str =f"{hl:.1f}j"if hl else"N/A"print(f"{ticker1}/{ticker2:<10}{status:<12}{hr_str:>12}{hl_str:>10}")if is_coint and hl and5< hl <60: cointegrated_pairs.append((ticker1, ticker2, hr, hl, spread))print(f"\nPaires cointegrees avec demi-vie valide: {len(cointegrated_pairs)}")else: cointegrated_pairs = []print("Pas de donnees disponibles pour tester la cointegration.")
=== Test de Cointegration ===
Paire Cointegre? Hedge Ratio Demi-vie
----------------------------------------------------
SPY/QQQ OUI 1.226 42.8j
SPY/IWM NON 1.964 147.5j
QQQ/IWM NON 1.584 117.6j
Paires cointegrees avec demi-vie valide: 1
Interprétation: Cointégration
Cointégré: Les deux prix ont une relation stable à long terme
Hedge Ratio: Combien de l’asset 2 pour couvrir l’asset 1
Demi-vie: Temps de retour à la moyenne (court = meilleur)
Spread: Écart normalisé à mean-revert
3. Calcul du Z-Score
Le Z-score indique combien d’écarts-types le spread est de sa moyenne.
def calculate_zscore(spread, window=20):"""Calcule le Z-score du spread.""" mean = spread.rolling(window).mean() std = spread.rolling(window).std()return (spread - mean) / std# Exemple: première paire cointégréeif cointegrated_pairs: ticker1, ticker2, hr, hl, spread = cointegrated_pairs[0] zscore = calculate_zscore(spread)print(f"Exemple: Paire {ticker1}/{ticker2}")print(f"Hedge Ratio: {hr:.3f}")print(f"Demi-vie: {hl:.1f} jours")print(f"\nZ-score - 10 derniers jours:")print(zscore.iloc[-10:])