Research QuantBook: BTC ML Enhanced

Objectif

Analyser la stratégie ML améliorée sur BTCUSDT avec RandomForestClassifier et filtres multi-couches.

Stratégie

  • Modèle: RandomForestClassifier (n_estimators=50, max_depth=4)
  • Features: SMA20, RSI14, EMA(10/20/50/200), ADX14, ATR14, volatilité 20j
  • Filtres:
    • Trend: Prix > EMA200
    • RSI: RSI > 40 (éviter survente)
    • ADX: ADX > 20 (éviter absence de trend)
    • Volatilité: Vol < 60% annuelle
  • Risk Management: Stop-loss -8%, Take-profit +15%
  • Position Sizing: Basé sur la probabilité de hausse (confidence)

Performance de référence

Sharpe ~1.2 (2023-2024) avec filtres optimisés.

Hypothèses à tester

  1. Seuils de confiance: (0.54/0.38), (0.56/0.40), (0.58/0.42)
  2. Seuil volatilité: 50%, 60%, 70%
  3. Stop-loss: 6%, 8%, 10%

Prérequis

  • Environnement Lean Research
  • Données BTCUSDT horaires/journalières
  • Durée estimée: ~15 minutes

Note : Ce notebook utilise une approche simplifiée pour la recherche. Le modèle complet utilise ObjectStore pour la persistance.

# Setup QuantBook
from AlgorithmImports import *
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import warnings
from sklearn.ensemble import RandomForestClassifier
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 BTCUSDT pour la période 2019-2026 (training + backtest).

# BTCUSDT sur Binance
btc = qb.add_crypto("BTCUSDT", Resolution.DAILY, Market.BINANCE)

# Période étendue pour multi-regime
start = datetime(2019, 1, 1)
end = datetime(2026, 1, 1)

history = qb.history(btc.symbol, start, end, Resolution.DAILY)
print(f"Données chargées: {len(history)} lignes")
Données chargées: 2557 lignes

Extraction des séries close, high et low de BTCUSD et affichage des statistiques de prix (initial, final, rendement total) pour BTC-ML.

# Preparer les donnees
if len(history) == 0:
    closes = None
    highs = None
    lows = None
    print("WARNING: No BTCUSDT data returned from QC Cloud.")
    print("Crypto daily data may not be available in this environment.")
    print("All analysis cells will be skipped.")
else:
    # c.935 MultiIndex guard: qb.history single-symbol returns (symbol,time) index -> drop symbol level
    if isinstance(history.index, pd.MultiIndex):
        history = history.reset_index(level=0, drop=True)
    closes = history['close']
    highs = history['high']
    lows = history['low']

    print(f"Periode: {closes.index[0].date()} a {closes.index[-1].date()}")
    print(f"Donnees: {len(closes)} jours")
    print(f"\nStatistiques BTC:")
    print(f"  Prix initial: ${closes.iloc[0]:.2f}")
    print(f"  Prix final: ${closes.iloc[-1]:.2f}")
    print(f"  Return total: {(closes.iloc[-1]/closes.iloc[0] - 1):.1%}")
Periode: 2019-01-02 a 2026-01-01
Donnees: 2557 jours

Statistiques BTC:
  Prix initial: $3797.14
  Prix final: $87648.22
  Return total: 2208.3%

2. Calcul des features ML

def compute_features(closes, highs, lows, i, 
                     sma_period=20, rsi_period=14,
                     ema_periods=[10, 20, 50, 200],
                     adx_period=14, atr_period=14,
                     vol_period=20):
    """
    Calcule les features ML pour l'index i.
    
    IMPORTANT: Utilise uniquement les donnees jusqu'a i-1 (walk-forward).
    """
    if i < max(ema_periods) + 2:
        return None
    
    # Features historiques (jusqu'a i-1)
    daily_ret = (closes.iloc[i-1] - closes.iloc[i-2]) / closes.iloc[i-2]
    
    # SMA
    sma_20 = closes.iloc[i-sma_period:i].mean()
    
    # RSI
    deltas = closes.iloc[i-rsi_period:i].diff().dropna()
    gains = deltas.where(deltas > 0, 0).mean()
    losses = (-deltas.where(deltas < 0, 0)).mean()
    rs = gains / max(losses, 1e-10)
    rsi_14 = 100 - (100 / (1 + rs))
    
    # EMAs
    emas = {}
    for period in ema_periods:
        multiplier = 2.0 / (period + 1)
        ema = closes.iloc[i-period:i].iloc[0]
        for val in closes.iloc[i-period:i][1:]:
            ema = (val - ema) * multiplier + ema
        emas[period] = ema
    
    # ADX (simplifie - ATR mean)
    atr_vals = []
    for j in range(max(0, i-adx_period), i):
        tr = max(highs.iloc[j] - lows.iloc[j], 
                 abs(highs.iloc[j] - closes.iloc[j-1]) if j > 0 else 0,
                 abs(lows.iloc[j] - closes.iloc[j-1]) if j > 0 else 0)
        atr_vals.append(tr)
    adx_val = np.mean(atr_vals) if atr_vals else 25.0
    
    # ATR
    atr_val = np.mean(atr_vals) if atr_vals else closes.iloc[i-1] * 0.02
    
    # Volatilite annualisee
    vol_20 = closes.iloc[i-vol_period:i].std() / closes.iloc[i-vol_period:i].mean() * np.sqrt(252) if i >= vol_period else 0
    
    return [sma_20, rsi_14, daily_ret, emas[10], emas[20], emas[50], emas[200], adx_val, atr_val, vol_20]

# Test de la fonction
if closes is None:
    print("BTC data not available — skipping feature computation.")
    print("Run this notebook on QC Cloud with crypto data access.")
else:
    test_features = compute_features(closes, highs, lows, 250)
    if test_features:
        feature_names = ['SMA20', 'RSI14', 'DailyRet', 'EMA10', 'EMA20', 'EMA50', 'EMA200', 'ADX', 'ATR', 'Vol20']
        print("Features a i=250:")
        for name, val in zip(feature_names, test_features):
            print(f"  {name}: {val:.2f}")
Features a i=250:
  SMA20: 10205.01
  RSI14: 55.81
  DailyRet: 0.02
  EMA10: 10194.31
  EMA20: 10315.97
  EMA50: 10422.33
  EMA200: 8395.17
  ADX: 456.88
  ATR: 456.88
  Vol20: 0.63

Interprétation: Features ML

  • SMA20/RSI14: Indicateurs de momentum court-terme
  • EMAs: Tendance multi-périodes (10/20/50/200)
  • ADX: Force du trend (ADX > 25 = trend fort)
  • ATR: Volatilité actuelle
  • Vol20: Volatilité annualisée pour filtre

Toutes les features sont calculées en walk-forward (pas de data leakage).

3. Entraînement du modèle ML

On entraîne un RandomForestClassifier sur la période 2019-2022 (training set).

if closes is None:
    print("BTC data not available — skipping model training.")
    model = None
else:
    # Preparer les donnees d'entrainement (2019-2022)
    train_end_idx = closes.index.get_loc(datetime(2022, 12, 31)) if datetime(2022, 12, 31) in closes.index else len(closes) // 2

    X_train, y_train = [], []
    feature_names = ['SMA20', 'RSI14', 'DailyRet', 'EMA10', 'EMA20', 'EMA50', 'EMA200', 'ADX', 'ATR', 'Vol20']

    for i in range(250, min(train_end_idx, len(closes))):
        features = compute_features(closes, highs, lows, i)
        if features is None:
            continue
        # Label: prix monte de i-1 vers i
        label = 1 if closes.iloc[i] > closes.iloc[i-1] else 0
        X_train.append(features)
        y_train.append(label)

    X_train = np.array(X_train)
    y_train = np.array(y_train)

    print(f"Dataset d'entrainement: {len(X_train)} samples")
    print(f"  Features shape: {X_train.shape}")
    print(f"  Labels distribution: {np.bincount(y_train)}")

    # Entrainer le modele
    model = RandomForestClassifier(
        n_estimators=50,
        max_depth=4,
        min_samples_leaf=10,
        random_state=42
    )
    model.fit(X_train, y_train)

    train_accuracy = model.score(X_train, y_train)
    print(f"\nModele entraine.")
    print(f"  Train accuracy: {train_accuracy:.2%}")
    print(f"  Feature importances:")
    for name, imp in sorted(zip(feature_names, model.feature_importances_), key=lambda x: -x[1]):
        print(f"    {name}: {imp:.3f}")
Dataset d'entrainement: 1209 samples
  Features shape: (1209, 10)
  Labels distribution: [611 598]

Modele entraine.
  Train accuracy: 65.34%
  Feature importances:
    DailyRet: 0.183
    RSI14: 0.146
    EMA10: 0.109
    EMA200: 0.096
    EMA20: 0.093
    EMA50: 0.089
    Vol20: 0.081
    SMA20: 0.078
    ADX: 0.069
    ATR: 0.056

4. Backtest ML Strategy

Simulation de la stratégie avec: - Seuils de confiance pour entry/exit - Filtres (trend, RSI, ADX, volatilité) - Stop-loss et take-profit

def backtest_ml_btc(closes, highs, lows, model,
                     confidence_long=0.56,
                     confidence_exit=0.40,
                     vol_threshold=0.60,
                     stop_loss=0.08,
                     take_profit=0.15,
                     start_idx=None):
    """
    Backtest BTC ML strategy (OOS-only).

    start_idx defaults to train_end_idx (the model's train/OOS boundary,
    set in the data-prep cell) so the backtest evaluates ONLY the
    out-of-sample window and is never contaminated by training data.
    """
    # OOS start: align with the model's train_end_idx (fallback 250)
    if start_idx is None:
        start_idx = train_end_idx if 'train_end_idx' in globals() else 250
    start_idx = max(start_idx, 250)  # keep features warmup

    portfolio_values = [1.0]
    invested = False
    entry_price = None

    warmup = start_idx  # alias for index alignment below
    
    # Stats
    trades = 0
    stop_losses = 0
    take_profits = 0
    signal_exits = 0
    
    for i in range(warmup, len(closes)):
        current_price = closes.iloc[i]
        
        # Calcul features
        features = compute_features(closes, highs, lows, i)
        if features is None:
            portfolio_values.append(portfolio_values[-1])
            continue
        
        # Extraire indicateurs pour filtres
        sma_20, rsi_14, daily_ret, ema_10, ema_20, ema_50, ema_200, adx_val, atr_val, vol_20 = features
        
        # Prediction
        proba = model.predict_proba([features])[0]
        confidence_up = proba[1]
        
        port_return = 0.0
        
        if invested and entry_price is not None:
            pnl_pct = (current_price - entry_price) / entry_price
            
            # Check stop-loss
            if pnl_pct <= -stop_loss:
                invested = False
                entry_price = None
                stop_losses += 1
                port_return = pnl_pct
            # Check take-profit
            elif pnl_pct >= take_profit:
                invested = False
                entry_price = None
                take_profits += 1
                port_return = pnl_pct
            # Check signal exit
            elif confidence_up < confidence_exit:
                invested = False
                entry_price = None
                signal_exits += 1
                port_return = pnl_pct
            else:
                # Hold - mark-to-market
                port_return = daily_ret * (current_price / closes.iloc[i-1])
        
        # Entry signal
        elif not invested:
            # FILTRE VOLATILITE
            if vol_20 > vol_threshold:
                portfolio_values.append(portfolio_values[-1])
                continue
            
            # FILTRE TREND: Prix > EMA200
            if current_price < ema_200:
                portfolio_values.append(portfolio_values[-1])
                continue
            
            # FILTRE RSI: RSI > 40 (éviter survente)
            if rsi_14 < 40:
                portfolio_values.append(portfolio_values[-1])
                continue
            
            # FILTRE ADX: ADX > 20 (éviter absence de trend)
            if adx_val < 20:
                portfolio_values.append(portfolio_values[-1])
                continue
            
            # Signal d'entrée
            if confidence_up > confidence_long:
                invested = True
                entry_price = current_price
                trades += 1
        
        portfolio_values.append(portfolio_values[-1] * (1 + port_return))
    
    # Métriques
    returns = np.diff(portfolio_values) / np.array(portfolio_values[:-1])
    cum_returns = pd.Series(portfolio_values[1:], index=closes.index[warmup:])
    
    total_ret = (portfolio_values[-1] / portfolio_values[0]) - 1
    years = len(returns) / 365
    cagr = (1 + total_ret) ** (1 / years) - 1 if years > 0 else 0
    vol = np.std(returns) * np.sqrt(365) 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,
        'trades': trades,
        'stop_losses': stop_losses,
        'take_profits': take_profits,
        'signal_exits': signal_exits
    }

print("Fonction de backtest définie.")
Fonction de backtest définie.

5. Test des seuils de confiance

if closes is None or model is None:
    print("BTC data or model not available — skipping confidence threshold analysis.")
else:
    # Test differents seuils de confiance
    confidence_configs = [
        ((0.54, 0.38), "Conf54/38"),
        ((0.56, 0.40), "Conf56/40"),
        ((0.58, 0.42), "Conf58/42"),
    ]

    print(f"{'Config':<12} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8} {'Trades':>8}")
    print("-" * 52)

    conf_results = {}
    for (long, exit), name in confidence_configs:
        r = backtest_ml_btc(closes, highs, lows, model, confidence_long=long, confidence_exit=exit)
        conf_results[name] = r
        print(f"{name:<12} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%} {r['trades']:>8}")

    best_conf = max(conf_results.items(), key=lambda x: x[1]['sharpe'])
    print(f"\nMeilleure config: {best_conf[0]} (Sharpe={best_conf[1]['sharpe']:.3f})")
Config         Sharpe     CAGR    MaxDD   Trades
----------------------------------------------------
Conf54/38       0.476   11.9%  -19.1%        3
Conf56/40       0.542    9.2%   -8.3%        1
Conf58/42       0.000    0.0%    0.0%        0

Meilleure config: Conf56/40 (Sharpe=0.542)

6. Test du seuil de volatilité

if closes is None or model is None:
    print("BTC data or model not available — skipping volatility threshold analysis.")
else:
    # Test différents seuils de volatilité
    vol_thresholds = [0.50, 0.60, 0.70]

    print(f"{'Seuil Vol':<12} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8} {'Trades':>8}")
    print("-" * 48)

    vol_results = {}
    for threshold in vol_thresholds:
        r = backtest_ml_btc(closes, highs, lows, model, vol_threshold=threshold)
        vol_results[f"{threshold*100:.0f}%"] = r
        print(f"{threshold*100:.0f}%{'':<9} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%} {r['trades']:>8}")

    best_vol = max(vol_results.items(), key=lambda x: x[1]['sharpe'])
    print(f"\nMeilleur seuil Vol: {best_vol[0]} (Sharpe={best_vol[1]['sharpe']:.3f})")
Seuil Vol      Sharpe     CAGR    MaxDD   Trades
------------------------------------------------
50%             0.000    0.0%    0.0%        0
60%             0.542    9.2%   -8.3%        1
70%             0.542    9.2%   -8.3%        1

Meilleur seuil Vol: 60% (Sharpe=0.542)

7. Test du Stop-Loss

if closes is None or model is None:
    print("BTC data or model not available — skipping stop-loss analysis.")
else:
    # Test différents stop-loss
    sl_values = [0.06, 0.08, 0.10]

    print(f"{'Stop-Loss':<12} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8} {'SL Hits':>10}")
    print("-" * 54)

    sl_results = {}
    for sl in sl_values:
        r = backtest_ml_btc(closes, highs, lows, model, stop_loss=sl)
        sl_results[f"{sl*100:.0f}%"] = r
        print(f"{sl*100:.0f}%{'':<9} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%} {r['stop_losses']:>10}")

    best_sl = max(sl_results.items(), key=lambda x: x[1]['sharpe'])
    print(f"\nMeilleur Stop-Loss: {best_sl[0]} (Sharpe={best_sl[1]['sharpe']:.3f})")
Stop-Loss      Sharpe     CAGR    MaxDD    SL Hits
------------------------------------------------------
6%            -1.220   -3.5%  -10.7%          1
8%             0.542    9.2%   -8.3%          0
10%             0.542    9.2%   -8.3%          0

Meilleur Stop-Loss: 8% (Sharpe=0.542)

8. Comparaison avec BTC B&H

if closes is None or model is None:
    print("BTC data or model not available — skipping B&H comparison.")
else:
    # ML Strategy avec paramètres optimaux
    ml_result = backtest_ml_btc(closes, highs, lows, model)

    # BTC B&H (same OOS window as the strategy: from train_end_idx)
    bh_start = train_end_idx if 'train_end_idx' in globals() else 250
    btc_values = closes.iloc[bh_start:] / closes.iloc[bh_start]

    # Métriques BTC
    btc_ret = btc_values.pct_change().dropna()
    btc_cagr = (btc_values.iloc[-1] ** (365/len(btc_values))) - 1
    btc_vol = btc_ret.std() * np.sqrt(365)
    btc_sharpe = (btc_cagr - 0.03) / btc_vol
    btc_dd = (btc_values / btc_values.cummax() - 1).min()

    print("=== Comparaison vs BTC B&H ===")
    print(f"{'Stratégie':<20} {'CAGR':>10} {'Sharpe':>10} {'MaxDD':>10}")
    print("-" * 53)
    print(f"{'BTC ML Enhanced':<20} {ml_result['cagr']:>9.1%} {ml_result['sharpe']:>10.3f} {ml_result['max_dd']:>9.1%}")
    print(f"{'BTC B&H':<20} {btc_cagr:>9.1%} {btc_sharpe:>10.3f} {btc_dd:>9.1%}")

    print(f"\n=== Statistiques Trading ===")
    print(f"Trades totaux: {ml_result['trades']}")
    print(f"Stop-Loss hits: {ml_result['stop_losses']}")
    print(f"Take-Profit hits: {ml_result['take_profits']}")
    print(f"Signal exits: {ml_result['signal_exits']}")
=== Comparaison vs BTC B&H ===
Stratégie                  CAGR     Sharpe      MaxDD
-----------------------------------------------------
BTC ML Enhanced           9.2%      0.542     -8.3%
BTC B&H                  73.8%      1.530    -32.0%

=== Statistiques Trading ===
Trades totaux: 1
Stop-Loss hits: 0
Take-Profit hits: 1
Signal exits: 0

Interprétation : modèle ML tabulaire sous-performe le Buy & Hold sur la fenêtre OOS (résultat honnête)

Contexte d’exécution. Ce quantbook a été ré-exécuté via le moteur de recherche QC (image Docker quantconnect/research:latest, vrai QuantBook()) avec de vraies données BTCUSDT Binance journalières (2557 jours, 2019-01-02 → 2026-01-01, $3797 → $87648, +2208 %). Sorties C.2 réelles, aucune fabrication (Stop & Repair).

Fenêtre out-of-sample (correctif c.940). Le modèle est entraîné sur 2019-2022 (train_end_idx = 2022-12-31). Le backtest évalue uniquement la fenêtre OOS 2023-2026 : la stratégie et la ligne de référence Buy & Hold démarrent toutes deux à train_end_idx, alignées sur la même fenêtre, pour exclure toute contamination in-sample. (Version antérieure : le backtest bouclait sur la série entière 2019-2026 incluant le train window — corrigé suite à un audit coord.)

Résultat (fenêtre OOS 2023-2026). Le classifieur tabulaire (10 features SMA/RSI/EMA/ADX/ATR/Vol, 1209 samples d’entraînement, 65.3 % d’accuracy en train) utilisé comme signal de trading avec seuils de confiance élevés (Conf56/40) produit 1 seul trade sur la fenêtre OOS (un take-profit). Il sous-performe massivement le Buy & Hold :

Stratégie CAGR Sharpe MaxDD
BTC ML Enhanced 9.2 % 0.542 −8.3 %
BTC B&H (2023-2026) 73.8 % 1.530 −32.0 %

Pourquoi le signal se déclenche presque jamais. Les seuils de confiance stricts (Conf56/40) combinés aux quatre filtres de risque (volatilité, prix > EMA200, RSI > 40, ADX > 20) excluent la quasi-totalité des jours OOS. Sur la phase haussière 2023-2026 du BTC (où le B&H fait +73.8 % CAGR), ne trader qu’une seule fois fait rater l’essentiel de la dynamique. La précision de direction à 65 % en entraînement ne se traduit pas en edge de trading une fois la rareté des signaux prise en compte.

Leçon pédagogique (limite honnête). Ce n’est pas un bug : un classifieur tabulaire sur features techniques journalières, à seuils de confiance élevés, ne bat pas le Buy & Hold sur un actif en bull market soutenu. Le seul avantage est la réduction de drawdown (−8.3 % vs −32.0 % sur la fenêtre OOS), au prix d’un rendement divisé par ~8. Verdict : NO BEATS — résultat négatif documenté comme livrable, conformément à la barre honnête des rungs du curriculum (cf. ML-Training-Pipeline/scripts/results/).

9. Visualisation des résultats

if closes is None or model is None:
    print("BTC data or model not available — skipping visualization.")
else:
    fig, axes = plt.subplots(1, 2, figsize=(16, 5))

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

    # Droite: Volatility threshold comparison
    ax = axes[1]
    for name, r in vol_results.items():
        ax.plot(r['cum'].values, label=f"Vol {name} (S={r['sharpe']:.2f})", linewidth=1.5)
    ax.plot(btc_values.values, label='BTC B&H', linestyle='--', alpha=0.5)
    ax.set_title('Filtre Volatilité', 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('btc_ml_analysis.png', dpi=150, bbox_inches='tight')
    plt.show()
    print("Graphique sauvegardé.")

Graphique sauvegardé.

10. Conclusions et recommandations

Résumé

Métrique Meilleure config
Confiance (à remplir)
Seuil Vol (à remplir)
Stop-Loss (à remplir)
Sharpe (à remplir)
CAGR (à remplir)

Verdict

Si Sharpe > 1.0: Déployer avec les paramètres optimaux

Points forts BTC ML Enhanced

  • Multi-filtres: Trend + RSI + ADX + Volatilité
  • Position sizing: Basé sur la probabilité de hausse
  • Risk management: Stop-loss + take-profit stricts
  • Adaptatif: Le modèle ML capture des patterns non-linéaires

Limitations

  • Surapprentissage potentiel: Le modèle peut overfitter sur le training set
  • Dépendance aux features: Les indicateurs techniques peuvent perdre en efficacité
  • Complexité: Nécessite retraining périodique (tous les 60 jours)

Prochaines étapes

  1. Déployer sur QC cloud avec les paramètres optimaux
  2. Tester d’autres modèles (XGBoost, LSTM)
  3. Optimiser les features avec feature importance analysis
  4. Tester sur d’autres crypto (ETH, LTC)
Retour au sommet