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

  1. Risk-off asset: TLT vs Cash vs XLP vs XLU vs USMV
  2. Momentum lookback: 1m, 3m, 6m, 12m
  3. Nombre de facteurs et vol-adjustment

Prerequis

  • Environnement Lean Research (Docker ou local)
  • Duree estimee: ~5 minutes
# Setup QuantBook
from AlgorithmImports import *
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.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.

# Ajouter les symboles
factor_tickers = ['VLUE', 'MTUM', 'SIZE', 'QUAL', 'USMV']
risk_off_tickers = ['TLT', 'XLP', 'XLU']
all_tickers = factor_tickers + risk_off_tickers + ['SPY']

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

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

history = qb.history(list(symbols.values()), start, end, Resolution.DAILY)
print(f"Donnees chargees: {len(history)} lignes")
print(f"Colonnes: {list(history.columns)}")
print(f"Symboles: {history.index.get_level_values(0).unique().tolist()}")
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 ticker
closes = history['close'].unstack(level=0)

# Renommer les colonnes avec les tickers lisibles
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]

# 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 not in closes.columns]
if missing:
    raise ValueError(
        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]
if getattr(last, 'year', 0) < 2024:
    raise ValueError(
        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.

returns_df = closes.pct_change()

print(f"{'Ticker':<8} {'Rend. Ann.':>12} {'Volatilite':>12} {'Sharpe':>8}")
print("-" * 42)

factor_stats = {}
for ticker in all_tickers:
    if ticker not in closes.columns:
        continue
    ret = (closes[ticker].iloc[-1] / closes[ticker].iloc[0]) ** (252 / len(closes)) - 1
    vol = returns_df[ticker].std() * np.sqrt(252)
    sharpe = (ret - 0.03) / vol if vol > 0 else 0
    factor_stats[ticker] = {'ret': ret, 'vol': vol, 'sharpe': sharpe}
    print(f"{ticker:<8} {ret:>11.1%} {vol:>11.1%} {sharpe:>7.2f}")

print(f"\nNote: Prix bruts QC (sans ajustement dividendes yfinance).")
Ticker     Rend. Ann.   Volatilite   Sharpe
------------------------------------------
VLUE            7.0%       19.4%    0.20
MTUM           12.6%       20.3%    0.47
SIZE            8.6%       18.4%    0.30
QUAL           11.2%       18.0%    0.45
USMV            8.0%       14.5%    0.34
TLT            -3.4%       15.1%   -0.42
XLP             4.4%       14.6%    0.10
XLU             5.5%       19.0%    0.13
SPY            12.8%       17.8%    0.55

Note: Prix bruts QC (sans ajustement dividendes yfinance).

Interpretation: Statistiques buy-and-hold

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) + 1
    
    for i in range(start_idx, len(closes)):
        if holdings_log:
            holdings = holdings_log[-1]['holdings']
            if len(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 += 1
        if 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_sma
        
        if not 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 not in closes.columns:
                continue
            current = closes[t].iloc[i]
            past = closes[t].iloc[i - lookback]
            if past > 0:
                mom = current / past - 1
                if 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] = mom
        
        if len(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) - 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 {'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.

# Test Hypothese 1: Risk-off asset
print(f"{'Risk-off Asset':<15} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8} {'Vol':>8}")
print("-" * 50)

results_hyp1 = {}
n, lb = 3, 252

for name, asset in [('TLT', 'TLT'), ('Cash', None), ('XLP', 'XLP'),
                     ('XLU', 'XLU'), ('USMV', 'USMV')]:
    r = factor_momentum_backtest(closes, factor_tickers, top_n=n, lookback=lb,
                                 use_risk_off=True, risk_off_asset=asset)
    results_hyp1[name] = r
    print(f"{name:<15} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%} {r['vol']:>7.1%}")

best_risk_off = max(results_hyp1.items(), key=lambda x: x[1]['sharpe'])
print(f"\nMeilleur risk-off: {best_risk_off[0]} (Sharpe={best_risk_off[1]['sharpe']:.3f})")
Risk-off Asset    Sharpe     CAGR    MaxDD      Vol
--------------------------------------------------
TLT                0.172    5.6%  -38.6%   15.3%
Cash               0.402    8.1%  -22.4%   12.6%
XLP                0.390    9.2%  -28.6%   15.9%
XLU                0.323    8.9%  -39.5%   18.2%
USMV               0.459   10.5%  -34.4%   16.4%

Meilleur risk-off: USMV (Sharpe=0.459)

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).

best_risk_off_asset = best_risk_off[0] if best_risk_off[0] != 'Cash' else None
n = 3

print(f"Risk-off asset: {best_risk_off[0]}")
print()
print(f"{'Lookback (jours)':<18} {'Lookback (mois)':<15} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8}")
print("-" * 60)

results_hyp2 = {}
for lb, lb_name in [(21, '1m'), (63, '3m'), (126, '6m'), (252, '12m')]:
    r = factor_momentum_backtest(closes, factor_tickers, top_n=n, lookback=lb,
                                 use_risk_off=True, risk_off_asset=best_risk_off_asset)
    results_hyp2[lb_name] = r
    print(f"{lb:<18} {lb_name:<15} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%}")

best_lb = max(results_hyp2.items(), key=lambda x: x[1]['sharpe'])
print(f"\nMeilleur lookback: {best_lb[0]} (Sharpe={best_lb[1]['sharpe']:.3f})")
Risk-off asset: USMV

Lookback (jours)   Lookback (mois)   Sharpe     CAGR    MaxDD
------------------------------------------------------------
21                 1m                 0.399    9.6%  -35.5%
63                 3m                 0.446   10.3%  -33.9%
126                6m                 0.406    9.8%  -37.4%
252                12m                0.459   10.5%  -34.4%

Meilleur lookback: 12m (Sharpe=0.459)

Verdict Hypothese 2: Lookback optimal

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).

best_lb_days = {'1m': 21, '3m': 63, '6m': 126, '12m': 252}[best_lb[0]]

print(f"{'Config':<20} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8} {'Vol':>8}")
print("-" * 55)

results_hyp3 = {}
for num_factors in [2, 3, 4, 5]:
    for vol_adj, vol_label in [(False, 'raw'), (True, 'vol-adj')]:
        r = factor_momentum_backtest(closes, factor_tickers, top_n=num_factors,
                                     lookback=best_lb_days,
                                     use_risk_off=True, risk_off_asset=best_risk_off_asset,
                                     vol_adjustment=vol_adj)
        key = f"top{num_factors}_{vol_label}"
        results_hyp3[key] = r
        print(f"{f'Top {num_factors} ({vol_label})':<20} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%} {r['vol']:>7.1%}")

best_config = max(results_hyp3.items(), key=lambda x: x[1]['sharpe'])
print(f"\nMeilleure config: {best_config[0]} (Sharpe={best_config[1]['sharpe']:.3f})")
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-off
ax = 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: Lookback
ax = 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 in range(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 in range(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 comparaison
yfinance_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

  1. Executer ce notebook dans un environnement Lean Research
  2. Comparer les résultats avec research.ipynb
  3. Si coherent: valider par backtest cloud QC
  4. 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
  • Règle #5: Poids egaux utilises
  • Règle #17: yfinance auto_adjust divergence documentee
Retour au sommet