Objectif: Utiliser les machines a vecteurs de support (SVM) pour la classification de direction des prix.
Stratégie
Hyperplan optimal: Trouver la frontiere de decision maximisant la marge
Kernel RBF: Capturer les relations non-lineaires dans les données
Standardisation: Features normalisees pour performance optimale
Probabilites: Platt scaling pour estimer la confiance
Margin analysis: Utiliser la distance a l’hyperplan comme confiance
Prerequis
scikit-learn: pip install scikit-learn
Comprehension des hyperplans et kernels
Duree estimee: 50 minutes
# Initialisation QuantBookfrom AlgorithmImports import*qb = QuantBook()# Periode d'analyseqb.SetStartDate(2020, 1, 1)qb.SetEndDate(2024, 12, 31)print(f"Periode: {qb.StartDate} a {qb.EndDate}")
Periode: 2020-01-01 00:00:00 a 2024-12-31 23:59:59.999999
1. Chargement des Données
# Ajouter les ETFstickers = ['SPY', 'QQQ', 'IWM', 'TLT', 'GLD']symbols = {}for ticker in tickers: symbols[ticker] = qb.AddEquity(ticker, Resolution.DAILY).Symbol# Recuperer les donnees.# NB: qb.History(lookback) retourne une fenetre ancoree a qb.Time, qui dans un QuantBook# frais vaut StartDate -- donc le lookback 5y finit vers StartDate (2020), pas EndDate.# La fenetre effective (imprimee ci-dessous) reflete ce que le notebook calcule reellement,# pas la periode announcee en cell[1]. Ne PAS re-fenetrer sur 2020-2024 pour l'instant :# les donnees equity locales s'arretent au 2021-03-31 (#8734) et Lean les prolongerait en# constante (fillDataForward=True) -> artefact de ligne plate. La fenetre reculee est, par# accident, la raison pour laquelle ce notebook a de vraies donnees. (C951-L, #8772.)history = qb.History(list(symbols.values()), 365*5, Resolution.Daily)closes = history['close'].unstack(level=0)volumes = history['volume'].unstack(level=0)highs = history['high'].unstack(level=0)lows = history['low'].unstack(level=0)# Fail-fast guard (C941-L/C942-L, #8734, ai-01 c.38) : presence != fraicheur.# (1) Un ticker silencieusement droppe doit ERREUR (add_equity accepte les symboles# absents ; dropna les retire sans avertir -> KeyError plus tard, attribue a tort a un bug)._missing = [t for t in tickers ifstr(symbols[t]) notin [str(col) for col in closes.columns]]if _missing or closes.shape[1] <len(tickers):raiseValueError(f"Ticker(s) absent(s) de l'historique (attendu {len(tickers)}, obtenu "f"{closes.shape[1]}). Regenerer les donnees via provision_lean_data.py (C941-L).")# (2) Detecteur de forward-fill (C942-L) : un zip tronque prolonge par Lean# fillDataForward=True (defaut) produit une queue PLATE (std==0 sur les dernieres# barres), pas du vrai prix. Window-agnostic (ne false-fire pas sur l'ancrage StartDate)._flat = [str(col) for col in closes.columns if closes[col].tail(60).std(ddof=0) ==0]if _flat:raiseValueError(f"Queue plate (fillDataForward constant) sur {_flat} -- zip tronque "f"prolonge en constante (C942-L, #8734). Regenerer les zips frais via "f"provision_lean_data.py avant de faire confiance aux metriques.")print(f"Donnees: {closes.shape[0]} jours, {closes.shape[1]} actifs")print(f"Fenetre effective: {closes.index[0].date()} a {closes.index[-1].date()} "f"(calculee, non announcee -- qb.Time ancres a StartDate, cf commentaire ci-dessus)")
Donnees: 1825 jours, 5 actifs
Fenetre effective: 2012-09-28 a 2019-12-31 (calculee, non announcee -- qb.Time ancres a StartDate, cf commentaire ci-dessus)
Fenetre effective vs annoncee - honnetete documentaire (#8772).
La cellule d’initialisation annonce Periode: 2020-01-01 a 2024-12-31, mais le DataFrame charge commence au 2012-09-28 et compte 1825 barres (verifiable en tete de la sortie de la cellule precedente, et via Features shape: (1825, 65) plus bas) - soit une fenetre reelle 2012-09-28 -> fin 2019 (debut verifie sur l’index committe ; fin deduite du nombre de barres, identique au pattern imprime par ML-DeepLearning en #8770).
Pourquoi : qb.History(N, Resolution.Daily) recule depuis qb.Time, qui dans un QuantBook frais vaut StartDate (2020-01-01). Un lookback de 365*5 barres demande donc les 5 ans qui precedent 2020, et non les 5 ans declares apres 2020. Les metriques de ce notebook (precision de direction, etc.) sont calculees sur 2012-2019 - une fenetre qui n’inclut ni le COVID ni le drawdown 2022, les deux regimes qu’on s’attendrait precisement a voir.
A ne pas faire : re-fenetrer explicitement sur 2020-2024 tant que l’issue #8734 n’est pas resolue - les donnees equity locales s’arretent au 2021-03-31 et Lean les prolonge en constante via fillDataForward=True, ce qui produirait ~3 ans de plat (l’artefact a l’origine des 100 % de direction accuracy en #8719). La fenetre reculee est, par accident, la raison pour laquelle ce notebook a des donnees reelles.
Reste a faire (garde par #6891, creds QC Cloud requises) : une re-execution via QC Cloud ajoutera la ligne Fenetre effective: imprimee par le code, sur le modele de ML-DeepLearning (#8770). En attendant, cette divulgation documentaire assure l’honnetete interimaire.
import matplotlib.pyplot as plt# Distance a l'hyperplan (decision function)decision_values = svm_model.decision_function(X_test_scaled)# Visualiser la distribution des margesplt.figure(figsize=(12, 5))plt.subplot(1, 2, 1)plt.hist(decision_values, bins=50, edgecolor='black')plt.axvline(x=0, color='red', linestyle='--', label='Hyperplan')plt.xlabel('Decision Value')plt.ylabel('Count')plt.title('Distribution des Marges')plt.legend()plt.subplot(1, 2, 2)# Marge vs probabiliteproba_class1 = y_proba[:, 1]plt.scatter(decision_values, proba_class1, alpha=0.5)plt.xlabel('Decision Value (Marge)')plt.ylabel('Probabilite classe 1')plt.title('Marge vs Probabilite (Platt Scaling)')plt.tight_layout()plt.show()print(f"Marge moyenne: {decision_values.mean():.4f}")print(f"Marge std: {decision_values.std():.4f}")
Walk-Forward Accuracy: 53.44%
High Confidence Accuracy (|margin| > 0.5): 53.61%
High Confidence Samples: 929/1512
9. Backtest avec Confidence Weighting
def backtest_svm_strategy(closes, features, train_period=252, rebalance_freq=5):"""Backtest strategie SVM avec position sizing base sur la marge.""" portfolio_value =100000 positions = {}# Only use tickers that actually have feature columns in the DataFrame available_tickers = []for t in tickers: cols = [c for c in features.columns if t in c]iflen(cols) >0: available_tickers.append(t)print(f"Available tickers for backtest: {available_tickers}")for i inrange(train_period, len(closes) -1, rebalance_freq): current_date = closes.index[i]# Entrainer sur SPY features only (consistent with scaler dimensions) spy_cols = [c for c in features.columns if'SPY'in c]iflen(spy_cols) ==0:continue train_features = features[spy_cols].iloc[i-train_period:i] spy_returns = closes['SPY'].pct_change().iloc[i-train_period:i] target = (spy_returns.shift(-1) >0).astype(int) train_data = train_features.copy() train_data['target'] = target train_data = train_data.dropna()iflen(train_data) <50:continue X_train = train_data.drop('target', axis=1) y_train = train_data['target'] scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) model = SVC(C=1.0, kernel='rbf', probability=True, random_state=42) model.fit(X_train_scaled, y_train)# Prevoir pour chaque ETF (using each ticker's own features) predictions = {}for ticker in available_tickers: ticker_cols = [c for c in features.columns if ticker in c]iflen(ticker_cols) ==0:continue ticker_features = features[ticker_cols]iflen(ticker_features) < i +1:continue latest = ticker_features.iloc[i:i+1]if latest.shape[1] ==0:continue# Projeter les features du ticker sur l'espace du modele (fit sur SPY) :# memes features dans le meme ordre, noms alignes sur ceux du fitiflist(latest.columns) !=list(X_train.columns): latest = latest.rename(columns=dict(zip(latest.columns, X_train.columns))) latest_scaled = scaler.transform(latest) proba = model.predict_proba(latest_scaled)[0] margin = model.decision_function(latest_scaled)[0] predictions[ticker] = {'prob': proba[1],'margin': margin }# Liquiderfor t, qty in positions.items():if t in closes.columns: portfolio_value += qty * closes[t].iloc[i] positions = {}# Nouvelles positions avec confidence weighting sorted_preds =sorted(predictions.items(), key=lambda x: x[1]['prob'], reverse=True) count =0for ticker, pred in sorted_preds: prob = pred['prob'] margin = pred['margin']if prob >0.55and count <2and ticker in closes.columns:# Position sizing base sur la marge confidence =min(abs(margin) /2, 1) position_size = portfolio_value *0.40* (0.5+0.5* confidence) price = closes[ticker].iloc[i] positions[ticker] = position_size / price portfolio_value -= position_size count +=1# Valeur finale final_value = portfolio_valuefor t, qty in positions.items():if t in closes.columns: final_value += qty * closes[t].iloc[-1]return {'initial': 100000,'final': final_value,'return': (final_value -100000) /100000 }# Executerresults = backtest_svm_strategy(closes, features)print(f"\nBacktest Results:")print(f"Valeur initiale: ${results['initial']:,.2f}")print(f"Valeur finale: ${results['final']:,.2f}")print(f"Rendement total: {results['return']:.2%}")
Available tickers for backtest: ['SPY', 'QQQ', 'IWM', 'TLT', 'GLD']
Backtest Results:
Valeur initiale: $100,000.00
Valeur finale: $139,031.20
Rendement total: 39.03%
10. Avantages et Limites de SVM
Avantages
Efficace en haute dimension: Performe bien avec beaucoup de features
Kernel trick: Capture les relations non-lineaires
Marge maximale: Robuste aux outliers
Interpretabilite partielle: Decision values indiquent la confiance
Limites
Sensibilite a l’echelle: Standardisation obligatoire
Choix du kernel: Pas toujours evident
Lenteur: O(n^2) a O(n^3) pour l’entrainement
Paramètres C et gamma: Sensibles, necessitent tuning