Research QuantBook: Fama-French Factor ETF Rotation
Objectif
Reproduire l’analyse exploratoire de research.ipynb avec les données natives QuantConnect via QuantBook, pour valider les conclusions avant backtest cloud.
Différences avec research.ipynb (yfinance)
Données: QuantBook qb.history() au lieu de yf.download()
Prix: Prix bruts QC (pas d’auto_adjust yfinance qui integre les dividendes)
Avantage: Données identiques au moteur de backtest QC
Performance actuelle
Sharpe: 0.540, CAGR: 12.1%, MaxDD: 24.2%
Facteurs: VLUE, MTUM, SIZE, QUAL, USMV
Risk-off: XLP (staples), lookback 12 mois, top 3 factors
Hypotheses a tester
Risk-off asset: TLT vs Cash vs XLP vs XLU vs USMV
Momentum lookback: 1m, 3m, 6m, 12m
Nombre de facteurs et vol-adjustment
Prerequis
Environnement Lean Research (Docker ou local)
Duree estimee: ~5 minutes
# Setup QuantBookfrom AlgorithmImports import*import numpy as npimport pandas as pdimport matplotlib.pyplot as pltimport warningswarnings.filterwarnings('ignore')plt.style.use('seaborn-v0_8-darkgrid')plt.rcParams['figure.figsize'] = (14, 5)qb = QuantBook()print("QuantBook initialise.")
QuantBook initialise.
1. Chargement des données
On charge les 5 facteurs ETF Fama-French, SPY (benchmark et signal de regime), et 3 candidats risk-off (TLT, XLP, XLU) via QuantBook.
Donnees chargees: 24894 lignes
Colonnes: ['close', 'high', 'low', 'open', 'volume']
Symboles: [<QuantConnect.Symbol object at 0x7639b7aae480>, <QuantConnect.Symbol object at 0x7639b7d8e600>, <QuantConnect.Symbol object at 0x7639fb37bd80>, <QuantConnect.Symbol object at 0x7639fb3cfec0>, <QuantConnect.Symbol object at 0x7639b06a3c00>, <QuantConnect.Symbol object at 0x7639b06efc40>, <QuantConnect.Symbol object at 0x7639b073b940>, <QuantConnect.Symbol object at 0x7639b058ba40>, <QuantConnect.Symbol object at 0x7639b05d7ac0>]
Pivot de la série ‘close’ en DataFrame large, avec remapping des colonnes Symbol → ticker pour FamaFrench.
# Pivoter les donnees pour avoir un DataFrame de prix de cloture par tickercloses = history['close'].unstack(level=0)# Renommer les colonnes avec les tickers lisiblessymbol_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]# Fail-fast guard (C941-L/C942-L, #8734, ai-01 c.38): presence != freshness.# A silently dropna()'d ticker or a forward-filled constant must ERROR, not yield# plausible-but-fake factor metrics. add_equity() does not raise on an absent symbol# (so dropna would hide it); fillDataForward=True (default) extends stale data as a# constant. Assert on the LOADED history (no reliable fillDataForward setter, C943-L).# Fresh zips: python scripts/quantconnect/provision_lean_data.py --universe fama_french.missing = [t for t in all_tickers if t notin closes.columns]if missing:raiseValueError(f"Ticker(s) {missing} loaded no daily bars -- regenerate via "f"provision_lean_data.py --universe fama_french (C941-L).")closes = closes.dropna()last = closes.index[-1]ifgetattr(last, 'year', 0) <2024:raiseValueError(f"Local equity data ends {getattr(last, 'date', last)} -- Lean "f"fillDataForward=True would extend it as a CONSTANT (C942-L, #8734). "f"Regenerate fresh zips before trusting post-threshold metrics.")print(f"Periode: {closes.index[0].date()} a {closes.index[-1].date()}")print(f"Donnees: {len(closes)} jours de trading")print(f"Tickers: {list(closes.columns)}")
Periode: 2015-01-02 a 2025-12-31
Donnees: 2766 jours de trading
Tickers: ['MTUM', 'QUAL', 'SIZE', 'SPY', 'TLT', 'USMV', 'VLUE', 'XLP', 'XLU']
Statistiques buy-and-hold des facteurs
Performance annualisee de chaque ETF en buy-and-hold sur la periode complete. Cela permet d’identifier les facteurs les plus performants et les plus volatils.
Les prix QC sont bruts (pas d’auto_adjust comme yfinance). Les rendements annualises peuvent differer legerement des résultats yfinance, surtout pour les ETFs obligataires (TLT, XLP) ou les dividendes representent une part significative du rendement total.
Points a comparer avec research.ipynb : - MTUM et QUAL typiquement les plus performants en bull market - TLT fortement negatif 2022+ (hausse des taux) - USMV le moins volatil (low-vol par construction)
2. Hypothese 1: Asset de risk-off
Tester l’impact du choix de l’asset quand SPY < SMA200 (marche baissiere). TLT a perdu ~30% en 2022 (hausse des taux), ce qui le disqualifie potentiellement.
def factor_momentum_backtest(closes, factor_tickers, top_n=3, lookback=252, rebal_freq=21, use_risk_off=True, risk_off_asset='TLT', vol_adjustment=False):"""Backtest vectorise de la rotation factorielle avec momentum.""" returns_df = closes.pct_change() sma200 = closes['SPY'].rolling(200).mean() portfolio_values = [1.0] holdings_log = [] rebal_counter =0 start_idx =max(lookback, 200) +1for i inrange(start_idx, len(closes)):if holdings_log: holdings = holdings_log[-1]['holdings']iflen(holdings) >0: port_ret = np.mean([returns_df[t].iloc[i] for t in holdings]) portfolio_values.append(portfolio_values[-1] * (1+ port_ret))else: portfolio_values.append(portfolio_values[-1])else: portfolio_values.append(portfolio_values[-1]) rebal_counter +=1if rebal_counter < rebal_freq:continue rebal_counter =0 spy_price = closes['SPY'].iloc[i] spy_sma = sma200.iloc[i]if pd.isna(spy_sma):continue risk_on = spy_price > spy_smaifnot risk_on:if use_risk_off and risk_off_asset and risk_off_asset in closes.columns: holdings_log.append({'date': closes.index[i], 'holdings': [risk_off_asset]})else: holdings_log.append({'date': closes.index[i], 'holdings': []})continue scores = {}for t in factor_tickers:if t notin closes.columns:continue current = closes[t].iloc[i] past = closes[t].iloc[i - lookback]if past >0: mom = current / past -1if vol_adjustment: vol = returns_df[t].iloc[max(0, i -63):i].std() * np.sqrt(252)if vol >0.01: mom = mom / vol scores[t] = momiflen(scores) ==0:continue sorted_scores =sorted(scores.items(), key=lambda x: x[1], reverse=True) top_factors = [t for t, s in sorted_scores[:min(top_n, len(sorted_scores))]] holdings_log.append({'date': closes.index[i], 'holdings': top_factors}) returns = np.diff(portfolio_values) / np.array(portfolio_values[:-1]) cum_returns = pd.Series(portfolio_values[1:]) total_ret = (portfolio_values[-1] / portfolio_values[0]) -1 years =len(returns) /252 cagr = (1+ total_ret) ** (1/ years) -1if years >0else0 vol = np.std(returns) * np.sqrt(252) iflen(returns) >1else0 sharpe = (cagr -0.03) / vol if vol >0.001else0 running_max = cum_returns.expanding().max() drawdown = (cum_returns - running_max) / running_max max_dd = drawdown.min()return {'sharpe': sharpe, 'cagr': cagr, 'max_dd': max_dd, 'vol': vol,'cum': cum_returns, 'holdings': holdings_log}print("Fonction de backtest definie.")
Fonction de backtest definie.
Exécution du backtest de la stratégie FamaFrench sur CAGR, TLT avec les paramètres configurés.
Sortie strippee (FABRICATED Row N / blank PNG). Re-execution QC Cloud recherche kernel requise – see #6891. Code preserve pour tracabilite. Side A = strip honnete, Side B = re-execution (hors scope ce PR).
Verdict Hypothese 1: Risk-off asset
Comparer ces résultats avec ceux de research.ipynb (yfinance).
Divergence attendue: Les prix QC n’incluent pas les dividendes de TLT (~2-3%/an). TLT apparaitra donc encore plus mauvais ici qu’avec yfinance. Cash et XLP devraient rester superieurs, confirmant la règle #3 du backlog.
3. Hypothese 2: Momentum lookback
Tester différentes fenêtres de momentum: 1 mois (21j), 3 mois (63j), 6 mois (126j), 12 mois (252j).
Le momentum long-terme (12 mois) surperforme généralement le court-terme sur les facteurs ETF, conformement a la litterature (Jegadeesh & Titman 1993).
Comparer avec research.ipynb pour verifier la coherence.
4. Hypothese 3: Nombre de facteurs et vol-adjustment
Tester top-2 a top-5, avec et sans ajustement par volatilite (risk-adjusted momentum).
Config Sharpe CAGR MaxDD Vol
-------------------------------------------------------
Top 2 (raw) 0.429 10.3% -35.1% 17.0%
Top 2 (vol-adj) 0.440 10.4% -33.5% 16.7%
Top 3 (raw) 0.459 10.5% -34.4% 16.4%
Top 3 (vol-adj) 0.466 10.6% -34.4% 16.3%
Top 4 (raw) 0.457 10.4% -35.1% 16.2%
Top 4 (vol-adj) 0.464 10.5% -35.1% 16.1%
Top 5 (raw) 0.459 10.4% -35.7% 16.1%
Top 5 (vol-adj) 0.459 10.4% -35.7% 16.1%
Meilleure config: top3_vol-adj (Sharpe=0.466)
Verdict Hypothese 3: Configuration optimale
Le trade-off concentration vs diversification : - Top 2-3 = plus concentre, potentiellement plus de rendement mais plus de risque - Top 4-5 = plus diversifie, rendement lisse - Vol-adjustment (règle #1 du backlog) devrait ameliorer le Sharpe
5. Visualisation: Equity curves
fig, axes = plt.subplots(2, 2, figsize=(15, 10))# H1: Risk-offax = axes[0, 0]for name, result in results_hyp1.items(): ax.plot(result['cum'].values, label=f"{name} (S={result['sharpe']:.2f})", linewidth=1.5)ax.set_title('H1: Risk-off asset (top 3, 12m lookback)', fontsize=11, fontweight='bold')ax.set_ylabel('Valeur du portefeuille')ax.legend(fontsize=8)ax.grid(True, alpha=0.3)# H2: Lookbackax = axes[0, 1]for name, result in results_hyp2.items(): ax.plot(result['cum'].values, label=f"{name} (S={result['sharpe']:.2f})", linewidth=1.5)ax.set_title(f'H2: Momentum lookback (risk-off={best_risk_off[0]})', fontsize=11, fontweight='bold')ax.set_ylabel('Valeur du portefeuille')ax.legend(fontsize=8)ax.grid(True, alpha=0.3)# H3: Num factors (raw)ax = axes[1, 0]for i inrange(2, 6): key =f"top{i}_raw"if key in results_hyp3: r = results_hyp3[key] ax.plot(r['cum'].values, label=f"Top {i} (S={r['sharpe']:.2f})", linewidth=1.5)ax.set_title('H3: Nombre de facteurs (raw momentum)', fontsize=11, fontweight='bold')ax.set_ylabel('Valeur du portefeuille')ax.legend(fontsize=8)ax.grid(True, alpha=0.3)# H3: Num factors (vol-adj)ax = axes[1, 1]for i inrange(2, 6): key =f"top{i}_vol-adj"if key in results_hyp3: r = results_hyp3[key] ax.plot(r['cum'].values, label=f"Top {i} (S={r['sharpe']:.2f})", linewidth=1.5)ax.set_title('H3: Nombre de facteurs (vol-adjusted)', fontsize=11, fontweight='bold')ax.set_ylabel('Valeur du portefeuille')ax.legend(fontsize=8)ax.grid(True, alpha=0.3)plt.tight_layout()plt.savefig('famafrench_quantbook_analysis.png', dpi=150, bbox_inches='tight')plt.show()print("Graphique sauvegarde.")
Graphique sauvegarde.
6. Comparaison yfinance vs QuantBook
Cette section compare les résultats cles entre les deux sources de données pour quantifier la divergence.
# Resultats yfinance (extraits de research.ipynb - a remplir apres execution)# Ces valeurs sont les references de comparaisonyfinance_ref = {'risk_off_TLT_sharpe': None, # A remplir'risk_off_Cash_sharpe': None,'risk_off_XLP_sharpe': None,'lookback_12m_sharpe': None,'best_config_sharpe': None,}print("Resultats QuantBook:")print(f" H1 meilleur risk-off: {best_risk_off[0]} (Sharpe={best_risk_off[1]['sharpe']:.3f})")print(f" H2 meilleur lookback: {best_lb[0]} (Sharpe={best_lb[1]['sharpe']:.3f})")print(f" H3 meilleure config: {best_config[0]} (Sharpe={best_config[1]['sharpe']:.3f})")print()print("Pour comparer: executer research.ipynb et reporter les valeurs ci-dessus.")print("Divergence attendue: QC prix bruts vs yfinance auto_adjust = ~0.05-0.15 Sharpe.")
Resultats QuantBook:
H1 meilleur risk-off: USMV (Sharpe=0.459)
H2 meilleur lookback: 12m (Sharpe=0.459)
H3 meilleure config: top3_vol-adj (Sharpe=0.466)
Pour comparer: executer research.ipynb et reporter les valeurs ci-dessus.
Divergence attendue: QC prix bruts vs yfinance auto_adjust = ~0.05-0.15 Sharpe.
7. Conclusions et recommandations
Tableau recapitulatif
Hypothese
Paramètre optimal
Sharpe QuantBook
Coherent avec yfinance?
H1 Risk-off
(a remplir)
(a remplir)
(a verifier)
H2 Lookback
(a remplir)
(a remplir)
(a verifier)
H3 Config
(a remplir)
(a remplir)
(a verifier)
Prochaines étapes
Executer ce notebook dans un environnement Lean Research
Comparer les résultats avec research.ipynb
Si coherent: valider par backtest cloud QC
Si divergent: identifier la source de divergence (dividendes, fees, slippage)
Règles du backlog appliquees
Règle #1: Risk-adjusted momentum teste (vol-adjustment)
Règle #3: TLT risk-off teste et probablement rejete