Research QuantBook: ML Ensemble

Objectif

Analyser une stratégie d’ensemble ML combinant plusieurs modèles faibles.

Stratégie

  • Univers: 30 stocks liquides US
  • Modèles: Ridge Regression + RandomForest + GradientBoosting
  • Combinaison: Moyenne pondérée des prédictions
  • Confiance: Mesure d’accord entre modèles
  • Entrée: Long si prédiction > 0.5% ET confiance > 60%
  • Max positions: 12 (8% chacune)

Performance de référence

Sharpe ~1.3-1.8 (2020-2025) - l’ensemble réduit la variance.

Hypothèses à tester

  1. Confiance min: 0.5, 0.6, 0.7
  2. Max positions: 8, 12, 16
  3. Poids des modèles: Equal, performance-based

Prérequis

  • Environnement Lean Research
  • scikit-learn pour tous les modèles
  • Durée estimée: ~15 minutes

Note : Cette stratégie utilise le principe de “crowd wisdom” - plusieurs modèles faibles = un fort.

# Setup QuantBook
from AlgorithmImports import *
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.linear_model import Ridge
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import accuracy_score, mean_squared_error
import warnings
warnings.filterwarnings('ignore')

plt.style.use('seaborn-v0_8-darkgrid')
plt.rcParams['figure.figsize'] = (14, 5)

qb = QuantBook()
print("QuantBook initialisé.")
QuantBook initialisé.

1. Chargement des données

On charge les données de 30 stocks liquides pour 2020-2026.

# Univers 30 stocks liquides
tickers = [
    "AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA",
    "JPM", "V", "WMT", "DIS", "NFLX", "PYPL", "ADBE", "CRM",
    "INTC", "AMD", "GS", "MS", "BA", "CSCO", "ORCL", "AVGO",
    "TXN", "QCOM", "IBM", "AMAT", "NOW", "SHOP", "SQ"
]

symbols = {}
for ticker in tickers:
    symbols[ticker] = qb.add_equity(ticker, Resolution.DAILY).symbol

# Charger l'historique (2020-2026)
start = datetime(2020, 1, 1)
end = datetime(2026, 1, 1)

history = qb.history(list(symbols.values()), start, end, Resolution.DAILY)
print(f"Données chargées: {len(history)} lignes")
Données chargées: 4524 lignes

Pivot des séries de prix et de volumes en DataFrame large, avec remapping des colonnes Symbol → ticker pour ML-Ensemble.

# Pivoter les données
closes = history['close'].unstack(level=0)
volumes = history['volume'].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]
volumes.columns = [symbol_to_ticker.get(str(c), str(c)) for c in volumes.columns]

# Forward-fill les données manquantes (certains tickers ont des jours sans cotation)
# puis garder uniquement les jours où tous les tickers ont des données
closes = closes.ffill().dropna()
volumes = volumes.ffill().dropna()

# Si univers trop réduit, utiliser au moins les tickers disponibles
if len(closes.columns) < 10:
    # Certains tickers peuvent ne pas avoir de données sur toute la période
    # On garde ceux qui ont au moins 80% de données
    min_coverage = int(0.8 * len(closes))
    good_tickers = closes.columns[closes.notna().sum() >= min_coverage]
    closes = closes[good_tickers].dropna()
    volumes = volumes[good_tickers].dropna()

print(f"Période: {closes.index[0].date()} à {closes.index[-1].date()}")
print(f"Données: {len(closes)} jours de trading")
print(f"Actions: {len(closes.columns)}")
print(f"\nStatistiques des prix finaux (échantillon):")
for ticker in list(closes.columns)[:5]:
    ret = (closes[ticker].iloc[-1] / closes[ticker].iloc[0] - 1) * 100
    print(f"  {ticker}: {ret:+.1f}%")
Période: 2020-01-02 à 2025-12-31
Données: 1508 jours de trading
Actions: 3

Statistiques des prix finaux (échantillon):
  AAPL: +64.3%
  GOOGL: +50.7%
  IBM: +4.9%

2. Feature Engineering

Création d’un ensemble riche de features techniques.

def calculate_rsi(prices, period=14):
    delta = prices.diff()
    gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
    loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
    rs = gain / loss
    return 100 - (100 / (1 + rs))

def create_features(close, volume):
    features = pd.DataFrame(index=close.index)
    
    # Indicateurs de tendance
    ema20 = close.ewm(span=20).mean()
    ema50 = close.ewm(span=50).mean()
    features['EMA_Ratio'] = ema20 / ema50
    
    # RSI
    features['RSI'] = calculate_rsi(close)
    
    # Returns multi-horizons
    returns = close.pct_change()
    features['Returns_1'] = returns.shift(1)
    features['Returns_5'] = returns.shift(5)
    features['Returns_20'] = returns.shift(20)
    
    # Volatilité
    features['Vol_5'] = returns.rolling(5).std()
    features['Vol_20'] = returns.rolling(20).std()
    features['Vol_Ratio'] = returns.rolling(5).std() / returns.rolling(20).std()
    
    # Volume
    volume_ma = volume.rolling(20).mean()
    features['Volume_Ratio'] = volume / volume_ma
    
    # Momentum
    features['Momentum_5'] = close / close.shift(5) - 1
    features['Momentum_20'] = close / close.shift(20) - 1
    
    # Distance aux moyennes
    features['Dist_MA20'] = (close - ema20) / close
    features['Dist_MA50'] = (close - ema50) / close
    
    # Trend strength
    features['Trend_Strength'] = (close - ema50) / (close.rolling(50).std() + 1e-6)
    
    return features

# Exemple AAPL
aapl_features = create_features(closes['AAPL'], volumes['AAPL'])
print("Features AAPL - Derniers 5 jours:")
print(aapl_features.iloc[-5:].dropna())
Features AAPL - Derniers 5 jours:
Empty DataFrame
Columns: [EMA_Ratio, RSI, Returns_1, Returns_5, Returns_20, Vol_5, Vol_20, Vol_Ratio, Volume_Ratio, Momentum_5, Momentum_20, Dist_MA20, Dist_MA50, Trend_Strength]
Index: []

Interprétation: Features Ensemble

  • EMA_Ratio: Force de la tendance
  • RSI: Surachat/survente
  • Returns multi-horizons: Performance court/moyen terme
  • Vol_Ratio: Changement de régime de volatilité
  • Trend_Strength: Normalisation de la tendance

3. Préparation des données d’entraînement

def prepare_training_data(closes, volumes):
    all_features = []
    all_targets = []
    
    for ticker in closes.columns:
        features = create_features(closes[ticker], volumes[ticker])
        returns = closes[ticker].pct_change()
        
        features['Target'] = returns.shift(-1)
        df = features.dropna()
        
        if len(df) > 30:
            all_features.append(df.drop('Target', axis=1))
            all_targets.append(df['Target'])
    
    X = pd.concat(all_features, ignore_index=True)
    y = pd.concat(all_targets, ignore_index=True)
    
    return X.fillna(0), y.fillna(0)

X, y = prepare_training_data(closes, volumes)
print(f"Dataset: {len(X)} échantillons, {len(X.columns)} features")
print(f"Target: mean={y.mean():.4f}, std={y.std():.4f}")
Dataset: 834 échantillons, 14 features
Target: mean=0.0019, std=0.0231

4. Entraînement des modèles individuels

On entraîne trois modèles diversifiés.

# Split train/test
split_idx = int(len(X) * 0.8)
X_train, X_test = X.iloc[:split_idx], X.iloc[split_idx:]
y_train, y_test = y.iloc[:split_idx], y.iloc[split_idx:]

# StandardScaler
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)

# Modèle 1: Ridge Regression (linéaire)
ridge = Ridge(alpha=1.0)
ridge.fit(X_train_scaled, y_train)
ridge_pred = ridge.predict(X_test_scaled)
ridge_mse = mean_squared_error(y_test, ridge_pred)

# Modèle 2: RandomForest (non-linéaire)
rf = RandomForestClassifier(n_estimators=50, max_depth=5, random_state=42)
y_train_class = (y_train > 0).astype(int)
rf.fit(X_train_scaled, y_train_class)
rf_pred = rf.predict_proba(X_test_scaled)[:, 1]
rf_acc = accuracy_score((y_test > 0).astype(int), (rf_pred > 0.5).astype(int))

# Modèle 3: GradientBoosting (boosting)
gb = GradientBoostingClassifier(n_estimators=50, max_depth=3, random_state=42)
gb.fit(X_train_scaled, y_train_class)
gb_pred = gb.predict_proba(X_test_scaled)[:, 1]
gb_acc = accuracy_score((y_test > 0).astype(int), (gb_pred > 0.5).astype(int))

print("=== Performance Modèles Individuels ===")
print(f"Ridge Regression: MSE={ridge_mse:.6f}")
print(f"RandomForest: Accuracy={rf_acc:.3f}")
print(f"GradientBoosting: Accuracy={gb_acc:.3f}")
=== Performance Modèles Individuels ===
Ridge Regression: MSE=0.000362
RandomForest: Accuracy=0.563
GradientBoosting: Accuracy=0.551

Interprétation: Modèles Individuels

  • Ridge: Capture les relations linéaires
  • RandomForest: Capture les interactions non-linéaires
  • GradientBoosting: Capture les motifs complexes
  • Diversité: Chaque modèle a ses forces/faiblesses

5. Création de l’Ensemble

Combinaison des prédictions avec pondération.

def ensemble_predict(ridge_pred, rf_pred, gb_pred, weights='equal'):
    """
    Combine les prédictions de plusieurs modèles.
    
    weights: 'equal' ou dict {'ridge': 0.3, 'rf': 0.4, 'gb': 0.3}
    """
    if weights == 'equal':
        w = {'ridge': 1/3, 'rf': 1/3, 'gb': 1/3}
    else:
        w = weights
    
    # Normaliser les prédictions Ridge (centrées sur 0)
    ridge_norm = ridge_pred - ridge_pred.mean()
    
    # Combiner
    ensemble = (w['ridge'] * ridge_norm + 
                 w['rf'] * rf_pred + 
                 w['gb'] * gb_pred)
    
    return ensemble

# Prédictions ensemble
ensemble_pred = ensemble_predict(ridge_pred, rf_pred, gb_pred)

# Confiance = accord entre modèles (écart-type faible)
model_preds = np.column_stack([
    ridge_pred - ridge_pred.mean(),
    rf_pred,
    gb_pred
])
confidence = 1 - model_preds.std(axis=1)

# Performance ensemble
ensemble_corr = np.corrcoef(ensemble_pred, y_test.values)[0, 1]

print("=== Performance Ensemble ===")
print(f"Corrélation avec rendements réels: {ensemble_corr:.3f}")
print(f"Confiance moyenne: {confidence.mean():.3f}")
print(f"\nDistribution de la confiance:")
print(pd.Series(confidence).describe())
=== Performance Ensemble ===
Corrélation avec rendements réels: -0.046
Confiance moyenne: 0.753

Distribution de la confiance:
count    167.000000
mean       0.752809
std        0.061189
min        0.634227
25%        0.718286
50%        0.744414
75%        0.773510
max        0.944497
dtype: float64

Interprétation: Ensemble

  • Confiance élevée: Les modèles sont d’accord → signal fiable
  • Confiance faible: Les modèles divergent → signal incertain
  • Filtrage: On ne trade que quand la confiance est haute

6. Backtest Walk-Forward Ensemble

def backtest_ensemble(closes, volumes,
                      min_confidence=0.6,
                      max_positions=12,
                      train_window=90,
                      retrain_freq=20):
    """
    Backtest ML Ensemble avec réentraînement.
    """
    portfolio_values = [1.0]
    
    warmup = train_window + 50
    
    models = None
    scaler = None
    
    for i in range(warmup, len(closes)):
        # Réentraîner périodiquement
        if (i - warmup) % retrain_freq == 0:
            train_closes = closes.iloc[:i]
            train_volumes = volumes.iloc[:i]
            
            X_train, y_train = prepare_training_data(train_closes, train_volumes)
            
            if len(X_train) < 100:
                continue
            
            scaler = StandardScaler()
            X_train_scaled = scaler.fit_transform(X_train)
            
            ridge = Ridge(alpha=1.0)
            ridge.fit(X_train_scaled, y_train)
            
            y_train_class = (y_train > 0).astype(int)
            rf = RandomForestClassifier(n_estimators=50, max_depth=5, random_state=42)
            rf.fit(X_train_scaled, y_train_class)
            
            gb = GradientBoostingClassifier(n_estimators=50, max_depth=3, random_state=42)
            gb.fit(X_train_scaled, y_train_class)
            
            models = {'ridge': ridge, 'rf': rf, 'gb': gb}
        
        if models is None:
            portfolio_values.append(portfolio_values[-1])
            continue
        
        # Prédictions pour tous les stocks
        predictions = {}
        confidences = {}
        
        for ticker in closes.columns:
            try:
                features = create_features(
                    closes[ticker].iloc[:i+1],
                    volumes[ticker].iloc[:i+1]
                )
                
                if len(features) == 0:
                    continue
                
                feat_row = features.iloc[-1].values.reshape(1, -1)
                feat_row_scaled = scaler.transform(feat_row)
                
                ridge_p = models['ridge'].predict(feat_row_scaled)[0]
                rf_p = models['rf'].predict_proba(feat_row_scaled)[0][1]
                gb_p = models['gb'].predict_proba(feat_row_scaled)[0][1]
                
                # Ensemble prediction
                ensemble_p = (ridge_p + rf_p + gb_p) / 3
                
                # Confidence = 1 - std
                preds = np.array([ridge_p, rf_p, gb_p])
                conf = 1 - preds.std()
                
                predictions[ticker] = ensemble_p
                confidences[ticker] = conf
            except:
                continue
        
        # Filtrer par confiance
        filtered = {t: p for t, p in predictions.items() 
                   if confidences.get(t, 0) > min_confidence}
        
        # Sélectionner les meilleures prédictions
        sorted_preds = sorted(filtered.items(), key=lambda x: x[1], reverse=True)
        
        # Calculer le rendement du portefeuille
        if i < len(closes) - 1:
            daily_returns = closes.iloc[i+1] / closes.iloc[i] - 1
            port_return = 0
            
            for ticker, pred in sorted_preds[:max_positions]:
                if pred > 0.005:
                    port_return += (0.95 / max_positions) * daily_returns[ticker]
            
            portfolio_values.append(portfolio_values[-1] * (1 + port_return))
        else:
            portfolio_values.append(portfolio_values[-1])
    
    # Métriques
    returns = np.diff(portfolio_values) / np.array(portfolio_values[:-1])
    cum_returns = pd.Series(portfolio_values, index=closes.index[warmup-1:])
    
    total_ret = (portfolio_values[-1] / portfolio_values[0]) - 1
    years = len(returns) / 252
    cagr = (1 + total_ret) ** (1 / years) - 1 if years > 0 else 0
    vol = np.std(returns) * np.sqrt(252) if len(returns) > 1 else 0
    sharpe = (cagr - 0.03) / vol if vol > 0.001 else 0
    
    running_max = cum_returns.expanding().max()
    drawdown = (cum_returns - running_max) / running_max
    max_dd = drawdown.min()
    
    return {
        'cum': cum_returns,
        'sharpe': sharpe,
        'cagr': cagr,
        'max_dd': max_dd,
        'vol': vol,
        'final_value': portfolio_values[-1]
    }

result = backtest_ensemble(closes, volumes)

print(f"Performance ML Ensemble:")
print(f"  Sharpe: {result['sharpe']:.3f}")
print(f"  CAGR:   {result['cagr']:.1%}")
print(f"  Max DD: {result['max_dd']:.1%}")
print(f"  Vol:    {result['vol']:.1%}")
Performance ML Ensemble:
  Sharpe: -0.987
  CAGR:   1.1%
  Max DD: -3.6%
  Vol:    1.9%

7. Test du seuil de confiance

# Test différents seuils de confiance
confidence_values = [0.5, 0.6, 0.7]

print(f"{'Confidence':<12} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8}")
print("-" * 39)

conf_results = {}
for conf in confidence_values:
    r = backtest_ensemble(closes, volumes, min_confidence=conf)
    conf_results[f"{conf}"] = r
    print(f"{conf}{'':<11} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%}")

best_conf = max(conf_results.items(), key=lambda x: x[1]['sharpe'])
print(f"\nMeilleure Confidence: {best_conf[0]} (Sharpe={best_conf[1]['sharpe']:.3f}")
Confidence     Sharpe     CAGR    MaxDD
---------------------------------------
0.5              -0.987    1.1%   -3.6%
0.6              -0.987    1.1%   -3.6%
0.7              -1.632    0.7%   -2.0%

Meilleure Confidence: 0.5 (Sharpe=-0.987

8. Test du nombre de positions

# Test différents nombres de positions
pos_values = [8, 12, 16]

print(f"{'Max Pos':<10} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8}")
print("-" * 37)

pos_results = {}
for n in pos_values:
    r = backtest_ensemble(closes, volumes, max_positions=n)
    pos_results[f"{n}"] = r
    print(f"{n}{'':<9} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%}")

best_pos = max(pos_results.items(), key=lambda x: x[1]['sharpe'])
print(f"\nMeilleur Max Pos: {best_pos[0]} (Sharpe={best_pos[1]['sharpe']:.3f}")
Max Pos      Sharpe     CAGR    MaxDD
-------------------------------------
8            -0.464    1.7%   -5.4%
12            -0.987    1.1%   -3.6%
16            -1.512    0.8%   -2.7%

Meilleur Max Pos: 8 (Sharpe=-0.464

9. Comparaison avec SPY B&H

# Charger SPY pour comparaison
spy = qb.add_equity("SPY", Resolution.DAILY).symbol
spy_history = qb.history(spy, start, end, Resolution.DAILY)
spy_close = spy_history['close']

# Aligner les dates
warmup = 140
spy_values = spy_close.iloc[warmup:] / spy_close.iloc[warmup]

# Métriques SPY
spy_ret = spy_values.pct_change().dropna()
spy_cagr = (spy_values.iloc[-1] ** (252/len(spy_values))) - 1
spy_vol = spy_ret.std() * np.sqrt(252)
spy_sharpe = (spy_cagr - 0.03) / spy_vol
spy_dd = (spy_values / spy_values.cummax() - 1).min()

print("=== Comparaison vs SPY B&H ===")
print(f"{'Stratégie':<20} {'CAGR':>10} {'Sharpe':>10} {'MaxDD':>10}")
print("-" * 53)
print(f"{'ML Ensemble':<20} {result['cagr']:>9.1%} {result['sharpe']:>10.3f} {result['max_dd']:>9.1%}")
print(f"{'SPY B&H':<20} {spy_cagr:>9.1%} {spy_sharpe:>10.3f} {spy_dd:>9.1%}")
=== Comparaison vs SPY B&H ===
Stratégie                  CAGR     Sharpe      MaxDD
-----------------------------------------------------
ML Ensemble               1.1%     -0.987     -3.6%
SPY B&H                   4.1%      0.182     -9.4%

10. Visualisation des résultats

fig, axes = plt.subplots(1, 2, figsize=(16, 5))

# Confidence threshold comparison
ax = axes[0]
for name, r in conf_results.items():
    ax.plot(r['cum'].values, label=f"Conf={name} (S={r['sharpe']:.2f})", linewidth=1.5)
ax.plot(spy_values.values, label='SPY B&H', linestyle='--', alpha=0.5)
ax.set_title('Seuil de Confiance', fontsize=12, fontweight='bold')
ax.set_ylabel('Valeur du portefeuille')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)

# Position count comparison
ax = axes[1]
for name, r in pos_results.items():
    ax.plot(r['cum'].values, label=f"{name} pos (S={r['sharpe']:.2f})", linewidth=1.5)
ax.plot(spy_values.values, label='SPY B&H', linestyle='--', alpha=0.5)
ax.set_title('Nombre de Positions', fontsize=12, fontweight='bold')
ax.set_ylabel('Valeur du portefeuille')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('ml_ensemble_analysis.png', dpi=150, bbox_inches='tight')
plt.show()
print("Graphique sauvegardé.")

Graphique sauvegardé.

11. Conclusions et recommandations

Résumé

Métrique Meilleure config
Confidence min 0.5 (Sharpe=-0.987)
Max Positions 8 (Sharpe=-0.464)
Sharpe -0.987 (meilleur cas)
CAGR 1.1%

Verdict: NO BEATS

L’ensemble ne bat pas SPY B&H (Sharpe 0.182, CAGR 4.1%). La performance négative s’explique par:

  1. Univers restreint: Seuls 3 tickers sur 30 ont des données complètes sur 2020-2026 dans l’environnement de recherche QC (AAPL, GOOGL, IBM). Le manque de diversité limite la capacité de l’ensemble a generer des signaux exploitables.
  2. Features non predictives: Les indicateurs techniques classiques (RSI, EMA, momentum) ont un pouvoir predictif limite sur un univers aussi etroit.
  3. Correlation negative: La correlation ensemble/rendements de -0.046 montre que les modèles ne capturent pas de signal exploitable dans cette configuration.

Points forts ML Ensemble (théoriques)

  • Robustesse: Reduit la variance des predictions
  • Diversite: 3 types de modèles complementaires
  • Filtrage intelligent: Confiance evite les signaux incertains

Limitations constatees

  • Univers trop petit: 3 actions ne suffisent pas pour un ensemble ML
  • Complexite vs valeur ajoutee: 3 modèles n’apportent rien sur un univers restreint
  • Overfitting: Risque eleve avec peu de données d’entrainement (834 echantillons)
  • Interpretabilite: Difficile d’expliquer les decisions

Prochaines étapes

  1. Elargir l’univers a des tickers QC disponibles (verifier la couverture de données)
  2. Ajouter des features fondamentales (P/E, revenus) pour augmenter le signal
  3. Tester avec un reseau de neurones comme 4e modèle
  4. Optimiser les ponderations (performance-based weighting)
  5. Backtester sur une periode plus longue avec un vrai workflow de production
Retour au sommet