Série Algo Trading : De 100K au Million — Partie 9 sur 12

Régime Adaptatif & Machine Learning

Détection de régime par Hidden Markov Models, signaux alpha via Gradient Boosting, Reinforcement Learning, prévision de volatilité GARCH, NLP financier et pipeline ML de production. L'intelligence artificielle au service du quant systématique.

Machine Learning Deep Learning Régime Detection Reinforcement Learning NLP Finance Overfitting
Algo Trading — De 100K au Million9/12
Régimes HMM ML Alpha Overfitting RL Adaptatif GARCH NLP Pipeline Takeaways
Pourquoi les stratégies statiques échouent

Le cimetière des backtests parfaits

Vous avez développé une stratégie momentum qui génère un Sharpe de 2.5 en backtest sur 2019-2021. Vous la déployez en production début 2022. Six mois plus tard, votre drawdown atteint -35%. Que s'est-il passé ?

Le marché a changé de régime. La stratégie qui fonctionnait parfaitement en bull market (taux bas, liquidité abondante, momentum fort) s'est effondrée en bear market (hausse des taux, resserrement quantitatif, rotation violente). Ce n'est pas un bug — c'est le problème fondamental de toute approche statique.

73%
Stratégies statiques qui sous-performent OOS
2-3 ans
Durée moyenne d'un régime
4
Régimes de marché distincts
+40%
Gain CAGR avec adaptation

Les 4 régimes de marché

Toute la littérature académique converge vers quatre macro-régimes qui gouvernent les marchés financiers. Chaque régime modifie radicalement les caractéristiques statistiques des rendements :

Risk-On (Bull)

VIX < 18, courbe des taux positive, spreads crédit serrés. Momentum et growth dominent. CAGR typique : +15-25%.

Risk-Off (Bear)

VIX > 28, spreads crédit écartés, fuite vers la qualité. Value et défensifs surperforment. DD typique : -20-40%.

Transition

VIX 18-28, signaux mixtes, rotation sectorielle rapide. Volatilité des corrélations élevée. Le plus dangereux.

Recovery

Sortie de bear market, VIX en baisse, breadth en expansion. Small caps et cycliques explosent. Fenêtre étroite.

Les indicateurs de détection

Détecter le régime actuel requiert un faisceau d'indicateurs convergents. Aucun indicateur seul n'est suffisant — c'est leur combinaison qui génère un signal fiable :

IndicateurRisk-OnRisk-OffTransitionRecovery
VIX (niveau)< 16> 2818-2828 → 18 (baisse)
VIX Term StructureContango fortBackwardationFlatContango naissant
Yield Curve (10Y-2Y)> +50bpsInverséeFlat ± 25bpsRe-pentification
Credit Spreads (HY-IG)< 300bps> 500bps300-500bps> 500 → 300
Momentum Breadth> 70% au-dessus SMA200< 30%30-70%< 30 → 50%+
Put/Call Ratio< 0.7 (complaisance)> 1.2 (panique)0.7-1.0> 1.0 → 0.8
Fed Funds RateStable ou en baisseEn hausse rapidePic/PlateauPremières baisses
ISM Manufacturing> 55 (expansion)< 48 (contraction)48-52< 48 → 50+

Impact du régime sur les stratégies

Chaque famille de stratégie possède un régime d'or où elle excelle et un régime mortel où elle se fait détruire. Ignorer cela, c'est jouer à la roulette russe :

StratégieRisk-OnRisk-OffTransitionRecovery
Momentum cross-sectionnelExcellent (+)Catastrophique (crash)MoyenExcellent (+)
Mean ReversionFaibleBonExcellent (+)Faible
Value/QualitySous-performeDéfensif (ok)BonExcellent (+)
Trend FollowingTrès bonTrès bonWhipsawsMoyen
Carry/YieldExcellent (+)CatastrophiqueMoyenBon
Vol SellingExcellent (+)CataclysmiqueRisquéBon
Defensive/Min-VolSous-performeExcellent (+)BonSous-performe

Pourquoi l'adaptativité est non-négociable

Sur la période 2000-2025, un portefeuille momentum statique a généré un CAGR de ~12% avec un max drawdown de -55% (2008-2009). Le même portefeuille avec un overlay de détection de régime qui réduit l'exposition en Risk-Off a généré un CAGR de ~18% avec un max drawdown de -22%. Même rendement annualisé supérieur, mais surtout un ratio rendement/risque multiplié par 3. La différence : survivre aux crashs permet de composer à plein régime pendant les phases haussières.

Le graphique ci-dessus illustre les transitions de régime depuis 2018. Observez comment les phases Risk-Off (rouges) correspondent systématiquement aux drawdowns majeurs du S&P 500. La phase de Transition (jaune) précédant chaque Risk-Off est la fenêtre critique pour réduire l'exposition.

Hidden Markov Models pour la détection de régime

Les états cachés du marché

Les Hidden Markov Models (HMM) sont l'outil de référence académique pour la détection de régime de marché. Le principe est élégant : le marché se trouve à chaque instant dans un état caché (le régime) que nous ne pouvons pas observer directement, mais qui génère des observations (rendements, volatilité, volume) dont les propriétés statistiques diffèrent selon l'état.

L'analogie du casino

Imaginez un casino avec deux dés truqués : un dé "favorable" (moyenne +0.5% par jour) et un dé "défavorable" (moyenne -0.2% par jour). Le croupier alterne entre les deux dés de manière aléatoire, mais vous ne voyez pas quel dé est utilisé — vous ne voyez que les résultats. Le HMM tente de deviner, à partir de la séquence des résultats observés, quel dé était utilisé à chaque instant et quelles sont les probabilités de transition entre les dés. Appliqué aux marchés : les "dés" sont les régimes, les "résultats" sont les rendements quotidiens.

Les composantes d'un HMM

Un HMM est défini par cinq éléments fondamentaux. Maîtrisez chacun d'eux pour comprendre ce que le modèle fait réellement :

N

Nombre d'états

2 (bull/bear), 3 (bull/bear/sideways) ou 4 états. 3 est le sweet spot en pratique.

A

Matrice de transition

Probabilités de passer d'un état à l'autre. P(bull→bear) ~ 2-5% par jour.

B

Distributions d'émission

Distribution des observations dans chaque état. Souvent gaussienne (moyenne, variance).

π

Distribution initiale

Probabilité d'être dans chaque état au temps t=0. Souvent uniforme ou estimée.

Problèmes fondamentaux du HMM
Évaluation : P(observations | modèle) → Forward-Backward
Décodage : arg max P(états | observations) → Viterbi
Apprentissage : arg max P(observations | θ) → Baum-Welch (EM)

Implémentation : HMM à 3 états avec hmmlearn

Voici l'implémentation complète d'un détecteur de régime HMM à 3 états. Ce code est production-ready et peut être branché directement sur votre pipeline de données :

Python — regime_hmm.py import numpy as np import pandas as pd from hmmlearn.hmm import GaussianHMM from sklearn.preprocessing import StandardScaler import warnings warnings.filterwarnings('ignore') class RegimeDetector: """ Détecteur de régime basé sur un HMM gaussien à 3 états. États : 0=Bull (Risk-On), 1=Bear (Risk-Off), 2=Sideways (Transition) Features utilisées : - Rendements quotidiens (momentum court terme) - Volatilité réalisée 21j (risque) - Volume relatif (activité/liquidité) - Corrélation actions/obligations 21j (risk appetite) """ def __init__(self, n_states=3, lookback=252, n_iter=200, random_state=42): self.n_states = n_states self.lookback = lookback self.n_iter = n_iter self.random_state = random_state self.model = None self.scaler = StandardScaler() self.state_mapping = {} # Mappe les états HMM aux régimes def _prepare_features(self, prices_df): """ Calcule les features multi-dimensionnelles à partir d'un DataFrame avec colonnes: SPY, TLT, volume_spy """ features = pd.DataFrame(index=prices_df.index) # 1. Rendements log quotidiens features['returns'] = np.log( prices_df['SPY'] / prices_df['SPY'].shift(1) ) # 2. Volatilité réalisée 21 jours (annualisée) features['realized_vol'] = ( features['returns'] .rolling(21) .std() * np.sqrt(252) ) # 3. Volume relatif (vs moyenne 50j) features['rel_volume'] = ( prices_df['volume_spy'] / prices_df['volume_spy'].rolling(50).mean() ) # 4. Corrélation glissante SPY/TLT (21j) features['spy_tlt_corr'] = ( prices_df['SPY'].pct_change() .rolling(21) .corr(prices_df['TLT'].pct_change()) ) # 5. Momentum 20 jours features['momentum_20d'] = ( prices_df['SPY'].pct_change(20) ) return features.dropna() def _identify_states(self, features, states): """ Mappe les états HMM numériques (0,1,2) aux régimes sémantiques (Bull, Bear, Sideways) en se basant sur la moyenne des rendements dans chaque état. """ state_returns = {} for s in range(self.n_states): mask = states == s state_returns[s] = features['returns'][mask].mean() # Trier par rendement moyen : le plus élevé = Bull sorted_states = sorted( state_returns.keys(), key=lambda x: state_returns[x] ) self.state_mapping = { sorted_states[0]: 'Bear', # Rendement le plus bas sorted_states[1]: 'Sideways', # Rendement intermédiaire sorted_states[2]: 'Bull', # Rendement le plus élevé } return self.state_mapping def fit(self, prices_df): """Entraîne le HMM sur les données historiques.""" features = self._prepare_features(prices_df) # Standardiser les features feature_cols = [ 'returns', 'realized_vol', 'rel_volume', 'spy_tlt_corr', 'momentum_20d' ] X = self.scaler.fit_transform(features[feature_cols]) # Entraîner le HMM avec EM (Baum-Welch) self.model = GaussianHMM( n_components=self.n_states, covariance_type='full', # Covariance complète n_iter=self.n_iter, random_state=self.random_state, tol=1e-4, verbose=False, init_params='stmc', # Init auto ) self.model.fit(X) # Décoder les états les plus probables (Viterbi) states = self.model.predict(X) # Identifier les régimes self._identify_states(features, states) # Log des caractéristiques par état for state_id, regime in self.state_mapping.items(): mask = states == state_id avg_ret = features['returns'][mask].mean() * 252 avg_vol = features['realized_vol'][mask].mean() pct = mask.sum() / len(mask) * 100 print(f" {regime:8s}: ret={avg_ret:+.1f}%, " f"vol={avg_vol:.1f}%, freq={pct:.0f}%") return self def predict_regime(self, prices_df): """ Prédit le régime actuel + probabilités de chaque état. Retourne (régime, probabilités_dict) """ features = self._prepare_features(prices_df) feature_cols = [ 'returns', 'realized_vol', 'rel_volume', 'spy_tlt_corr', 'momentum_20d' ] X = self.scaler.transform(features[feature_cols]) # Probabilités filtrées (Forward algorithm) log_prob, posteriors = self.model.score_samples(X) # Dernier point = régime actuel current_probs = posteriors[-1] current_state = np.argmax(current_probs) current_regime = self.state_mapping[current_state] probs_dict = { self.state_mapping[i]: round(p, 4) for i, p in enumerate(current_probs) } return current_regime, probs_dict def get_transition_matrix(self): """Retourne la matrice de transition lisible.""" if self.model is None: raise ValueError("Modèle non entraîné") labels = [self.state_mapping[i] for i in range(self.n_states)] return pd.DataFrame( self.model.transmat_, index=labels, columns=labels ).round(4) # --- Utilisation --- # detector = RegimeDetector(n_states=3) # detector.fit(prices_df) # DataFrame avec SPY, TLT, volume_spy # regime, probs = detector.predict_regime(prices_df) # print(f"Régime actuel: {regime}") # print(f"Probabilités: {probs}") # print(detector.get_transition_matrix())

Résultats typiques d'un HMM à 3 états

Après entraînement sur 10 ans de données S&P 500 (2014-2024), le HMM identifie trois régimes clairement distincts :

CaractéristiqueBull (Risk-On)Sideways (Transition)Bear (Risk-Off)
Rendement annualisé+22.4%+3.8%-28.6%
Volatilité annualisée11.2%16.5%31.8%
Sharpe Ratio2.000.23-0.90
Durée médiane145 jours42 jours28 jours
Fréquence58%28%14%
P(rester dans l'état)98.2%95.1%93.7%
Corrélation SPY/TLT-0.35-0.15+0.42

Insight clé : La corrélation SPY/TLT est un des indicateurs les plus puissants. En Risk-On, les actions et obligations sont décorrélées (diversification fonctionne). En Risk-Off, elles deviennent positivement corrélées — les deux baissent ensemble (2022). Ce changement de corrélation est un signal précurseur de transition de régime.

Limites et pièges du HMM

Les 5 pièges des HMM en finance

  • Hindsight bias : L'algorithme de Viterbi utilise toute la séquence pour décoder — en temps réel, utilisez le Forward algorithm (filtrage) qui n'utilise que le passé.
  • Nombre d'états : BIC/AIC peuvent aider à choisir N, mais en pratique N=3 est robuste. N>4 overfit systématiquement.
  • Label switching : À chaque ré-entraînement, l'état 0 peut devenir l'état 2. Toujours re-mapper les états par rendement moyen.
  • Stationnarité : Le HMM suppose des paramètres stationnaires. Ré-entraînez tous les 6-12 mois avec une fenêtre glissante.
  • Faux signaux en Transition : Le HMM oscille entre Bull et Bear en période de Transition. Appliquez un filtre de persistance (rester N jours dans un état avant de le confirmer).

Filtre de persistance et lissage

En production, les probabilités brutes du HMM oscillent trop. Voici comment les lisser et ajouter un filtre de persistance pour éviter les whipsaws :

Python — regime_filter.py class RegimeFilter: """ Filtre de persistance pour les signaux de régime HMM. Évite les oscillations en imposant une durée minimale dans un état avant de confirmer la transition. """ def __init__(self, min_days=5, ema_span=10, threshold=0.6): self.min_days = min_days # Jours minimum dans un état self.ema_span = ema_span # EMA pour lisser les probas self.threshold = threshold # Seuil de probabilité self.current_regime = None self.days_in_regime = 0 self.pending_regime = None self.pending_days = 0 def smooth_probabilities(self, probs_series): """Lisse les probabilités avec une EMA.""" smoothed = {} for regime in probs_series.columns: smoothed[regime] = ( probs_series[regime] .ewm(span=self.ema_span, adjust=False) .mean() ) return pd.DataFrame(smoothed) def update(self, regime_probs): """ Met à jour le régime filtré. regime_probs: dict {'Bull': 0.7, 'Sideways': 0.2, 'Bear': 0.1} Returns: régime confirmé (str) """ # Régime brut (probabilité max) raw_regime = max(regime_probs, key=regime_probs.get) raw_prob = regime_probs[raw_regime] # Initialisation if self.current_regime is None: self.current_regime = raw_regime self.days_in_regime = 1 return self.current_regime # Même régime que l'actuel if raw_regime == self.current_regime: self.days_in_regime += 1 self.pending_regime = None self.pending_days = 0 return self.current_regime # Nouveau régime détecté if raw_prob >= self.threshold: if self.pending_regime == raw_regime: self.pending_days += 1 else: self.pending_regime = raw_regime self.pending_days = 1 # Confirmer après min_days consécutifs if self.pending_days >= self.min_days: self.current_regime = raw_regime self.days_in_regime = self.pending_days self.pending_regime = None self.pending_days = 0 else: self.pending_regime = None self.pending_days = 0 return self.current_regime

Le filtre de persistance réduit le nombre de transitions de régime de 40-60 par an (HMM brut) à 8-12 par an (filtré), éliminant 80% des faux signaux tout en conservant 95% de la valeur ajoutée de la détection.

Machine Learning pour le signal alpha

Du facteur linéaire au modèle non-linéaire

Dans les parties précédentes, nous avons construit des signaux alpha basés sur des combinaisons linéaires de facteurs (momentum, value, quality). Le Machine Learning permet d'aller plus loin en capturant des interactions non-linéaires entre facteurs que le cerveau humain ne peut pas identifier.

Mais attention : le ML en finance n'est pas comme le ML en computer vision ou en NLP. Le ratio signal/bruit est infiniment plus faible (IC de 0.02-0.05 vs. 0.99 en classification d'images). Cela change radicalement l'approche.

0.02-0.05
IC typique des meilleurs modèles ML
50-100
Features alpha candidates
+3-8%
CAGR additionnel vs linéaire
Mensuel
Rebalancement optimal ML

Gradient Boosting (XGBoost / LightGBM) — Le champion

Pour les données tabulaires financières, le Gradient Boosting (XGBoost, LightGBM) domine systématiquement. Ce n'est pas un hasard : les données financières sont structurées, hétérogènes (mix de features continues et catégorielles), et le gradient boosting excelle précisément dans ce contexte.

Pourquoi pas le Deep Learning ?

Les réseaux de neurones profonds brillent quand les données sont massives, homogènes et à très haute dimension (images, texte, audio). En finance quantitative, les données sont relativement petites (quelques milliers d'observations mensuelles), hétérogènes et extrêmement bruitées. Le Deep Learning overfit presque systématiquement dans ce régime de données. Les arbres de gradient boosting sont supérieurs car ils gèrent naturellement les interactions non-linéaires, sont robustes aux outliers, et nécessitent beaucoup moins de données pour converger. Exception notable : les LSTM pour le time-series forecasting et les Transformers pour le NLP financier.

Python — ml_alpha_signal.py import numpy as np import pandas as pd import lightgbm as lgb from sklearn.model_selection import TimeSeriesSplit import optuna import shap class AlphaMLModel: """ Modèle de signal alpha basé sur LightGBM. Target : quintile de rendement du mois suivant (1-5) Features : 50 alpha factors (momentum, value, quality, sentiment, technique) Validation : Purged K-Fold pour éviter le data leakage Tuning : Optuna (Bayesian Optimization) """ def __init__(self, n_splits=5, purge_days=21, embargo_days=5): self.n_splits = n_splits self.purge_days = purge_days # Jours purgés entre train/val self.embargo_days = embargo_days self.model = None self.feature_names = None self.best_params = None def _purged_kfold(self, X, n_splits, purge_days, embargo_days): """ Purged K-Fold Cross-Validation. Élimine les observations proches de la frontière train/test pour éviter le data leakage temporel. """ n = len(X) fold_size = n // n_splits for i in range(n_splits): test_start = i * fold_size test_end = min((i + 1) * fold_size, n) # Purge : supprimer les obs proches du test set train_end = max(0, test_start - purge_days) test_start_adj = min(n, test_end + embargo_days) train_idx = list(range(0, train_end)) if test_start_adj < n: train_idx += list(range(test_start_adj, n)) test_idx = list(range(test_start, test_end)) if len(train_idx) > 0 and len(test_idx) > 0: yield np.array(train_idx), np.array(test_idx) def _create_target(self, returns_df, horizon=21): """ Crée le target : quintile de rendement forward. 1 = worst performers, 5 = best performers """ fwd_returns = returns_df.shift(-horizon) # Quintiles cross-sectionnels par date quintiles = fwd_returns.apply( lambda x: pd.qcut( x.rank(method='first'), 5, labels=[1,2,3,4,5] ), axis=1 ) return quintiles def _objective(self, trial, X, y): """Objectif Optuna pour le tuning bayésien.""" params = { 'objective': 'multiclass', 'num_class': 5, 'metric': 'multi_logloss', 'verbosity': -1, 'n_estimators': trial.suggest_int( 'n_estimators', 100, 1000 ), 'max_depth': trial.suggest_int( 'max_depth', 3, 8 ), 'learning_rate': trial.suggest_float( 'learning_rate', 0.01, 0.1, log=True ), 'num_leaves': trial.suggest_int( 'num_leaves', 8, 64 ), 'min_child_samples': trial.suggest_int( 'min_child_samples', 20, 100 ), 'subsample': trial.suggest_float( 'subsample', 0.6, 1.0 ), 'colsample_bytree': trial.suggest_float( 'colsample_bytree', 0.5, 0.9 ), 'reg_alpha': trial.suggest_float( 'reg_alpha', 1e-3, 10.0, log=True ), 'reg_lambda': trial.suggest_float( 'reg_lambda', 1e-3, 10.0, log=True ), } scores = [] for train_idx, val_idx in self._purged_kfold( X, self.n_splits, self.purge_days, self.embargo_days ): model = lgb.LGBMClassifier(**params) model.fit( X.iloc[train_idx], y.iloc[train_idx], eval_set=[(X.iloc[val_idx], y.iloc[val_idx])], callbacks=[lgb.early_stopping(50, verbose=False)] ) # IC = Spearman correlation entre prédiction et réalisation pred_proba = model.predict_proba(X.iloc[val_idx]) # Score attendu = somme pondérée des quintiles expected_quintile = np.sum( pred_proba * np.array([1,2,3,4,5]), axis=1 ) from scipy.stats import spearmanr ic, _ = spearmanr(expected_quintile, y.iloc[val_idx]) scores.append(ic) return np.mean(scores) def tune_and_fit(self, X, y, n_trials=100): """ Optimise les hyperparamètres via Optuna puis entraîne le modèle final. """ self.feature_names = X.columns.tolist() # Bayesian Optimization study = optuna.create_study( direction='maximize', sampler=optuna.samplers.TPESampler(seed=42) ) study.optimize( lambda trial: self._objective(trial, X, y), n_trials=n_trials, show_progress_bar=True ) self.best_params = study.best_params self.best_params['objective'] = 'multiclass' self.best_params['num_class'] = 5 self.best_params['verbosity'] = -1 print(f"Meilleur IC moyen: {study.best_value:.4f}") print(f"Meilleurs paramètres: {self.best_params}") # Entraîner le modèle final self.model = lgb.LGBMClassifier(**self.best_params) self.model.fit(X, y) return self def predict_alpha(self, X): """ Retourne un score alpha continu (1-5) pour chaque stock. Score élevé = alpha attendu positif. """ pred_proba = self.model.predict_proba(X) alpha_score = np.sum( pred_proba * np.array([1,2,3,4,5]), axis=1 ) return alpha_score def explain(self, X, top_n=20): """ SHAP feature importance — comprendre ce que le modèle a appris. """ explainer = shap.TreeExplainer(self.model) shap_values = explainer.shap_values(X) # Importance moyenne absolue par feature importance = np.mean( [np.abs(sv).mean(axis=0) for sv in shap_values], axis=0 ) feat_imp = pd.DataFrame({ 'feature': self.feature_names, 'importance': importance }).sort_values('importance', ascending=False) return feat_imp.head(top_n)

Random Forest — Plus robuste, moins performant

Le Random Forest est souvent le premier choix pour un débutant en ML finance, et pour cause : il est presque impossible à faire overfitter grâce au bagging et au subsampling de features. En contrepartie, il capture moins bien les interactions complexes que le gradient boosting.

Python — random_forest_alpha.py from sklearn.ensemble import RandomForestClassifier class RFAlphaModel: """Random Forest pour alpha signal — plus robuste.""" def __init__(self): self.model = RandomForestClassifier( n_estimators=500, # Beaucoup d'arbres = stabilité max_depth=6, # Peu profond = moins d'overfitting min_samples_leaf=50, # Feuilles larges max_features='sqrt', # Subsampling features classique n_jobs=-1, # Parallélisation random_state=42, class_weight='balanced' # Gérer le déséquilibre ) def fit_predict(self, X_train, y_train, X_test): self.model.fit(X_train, y_train) proba = self.model.predict_proba(X_test) # Score alpha = espérance du quintile alpha = np.sum( proba * np.arange(1, 6), axis=1 ) return alpha def feature_importance(self): imp = pd.DataFrame({ 'feature': self.model.feature_names_in_, 'importance': self.model.feature_importances_ }) return imp.sort_values('importance', ascending=False)

Ridge / Lasso — Linéaire mais puissant

Ne sous-estimez jamais les modèles linéaires. En finance, la Ridge Regression (L2) et le Lasso (L1) restent parmi les meilleurs modèles quand le nombre de features est élevé par rapport au nombre d'observations. Le Lasso a l'avantage de la sélection automatique de features (coefficients mis à zéro).

Python — linear_alpha.py from sklearn.linear_model import RidgeCV, LassoCV from sklearn.preprocessing import StandardScaler class LinearAlphaModel: """Modèle linéaire régularisé pour alpha signal.""" def __init__(self, method='ridge'): self.scaler = StandardScaler() if method == 'ridge': self.model = RidgeCV( alphas=np.logspace(-3, 3, 20), cv=5 ) else: self.model = LassoCV( alphas=np.logspace(-4, 1, 30), cv=5, max_iter=10000 ) def fit_predict(self, X_train, y_train, X_test): # Standardiser (crucial pour régularisation) X_tr = self.scaler.fit_transform(X_train) X_te = self.scaler.transform(X_test) self.model.fit(X_tr, y_train) alpha = self.model.predict(X_te) return alpha def selected_features(self): """Features sélectionnées (Lasso: coeff != 0).""" coefs = pd.Series( self.model.coef_, index=self.feature_names ) return coefs[coefs != 0].sort_values( key=lambda x: x.abs(), ascending=False )

Ensemble — La sagesse des foules

En production, les meilleurs fonds quantitatifs ne s'appuient jamais sur un seul modèle. L'ensemble de modèles réduit la variance des prédictions et est plus robuste aux changements de régime. Voici l'approche :

Python — ensemble_alpha.py class EnsembleAlpha: """ Ensemble de modèles alpha : moyenne pondérée. Poids basés sur la performance récente (IC glissant). """ def __init__(self, models, ic_window=6): """ models: dict {'lgbm': LGBMModel, 'rf': RFModel, 'ridge': RidgeModel} ic_window: nombre de mois pour calculer IC glissant """ self.models = models self.ic_window = ic_window self.weights = {k: 1.0/len(models) for k in models} self.ic_history = {k: [] for k in models} def predict(self, X): """Prédiction ensemble pondérée.""" predictions = {} for name, model in self.models.items(): predictions[name] = model.predict_alpha(X) # Moyenne pondérée par IC récent ensemble = np.zeros(len(X)) total_weight = sum(self.weights.values()) for name, pred in predictions.items(): w = self.weights[name] / total_weight ensemble += w * pred return ensemble def update_weights(self, X, y_actual): """ Met à jour les poids basés sur la performance récente de chaque modèle. """ from scipy.stats import spearmanr for name, model in self.models.items(): pred = model.predict_alpha(X) ic, _ = spearmanr(pred, y_actual) self.ic_history[name].append(ic) # IC moyen glissant (pondéré récent) recent = self.ic_history[name][-self.ic_window:] # Pondération exponentielle decay = np.array([0.8**i for i in range( len(recent)-1, -1, -1 )]) avg_ic = np.average(recent, weights=decay) # Poids = max(IC, 0) pour exclure les modèles négatifs self.weights[name] = max(avg_ic, 0.01) print("Poids mis à jour:") total = sum(self.weights.values()) for name, w in self.weights.items(): print(f" {name}: {w/total:.1%}")

L'ensemble typique en production combine 3-5 modèles avec des horizons et des familles de facteurs différents. La diversification des modèles réduit le drawdown de 15-25% par rapport au meilleur modèle individuel.

ModèleIC moyenStabilité ICTemps entraînementRisque overfittingInterprétabilité
LightGBM0.045MoyenneRapide (30s)MoyenSHAP disponible
XGBoost0.043MoyenneModéré (2min)MoyenSHAP disponible
Random Forest0.038HauteModéré (1min)FaibleFeature importance
Ridge Regression0.032Très hauteInstantanéTrès faibleCoefficients
Lasso0.030Très hauteInstantanéTrès faibleFeature selection
Ensemble (3 modèles)0.052HauteSommeFaibleMoyenne pondérée
Algo Trading — De 100K au Million9/12