Research QuantBook: Sector-Momentum (Dual Momentum)

Objectif

Reproduire l’analyse exploratoire de research.ipynb avec les données natives QuantConnect.

Performance actuelle

  • Sharpe: 0.555, CAGR: 13.0%, MaxDD: 22.8%
  • Stratégie: Dual Momentum (Antonacci) - SPY/TLT/GLD
  • Signal: Composite multi-lookback (40% 1m, 20% 3m, 20% 6m, 20% 12m)
  • Regime filter: SPY > SMA200

Hypotheses a tester

  1. Lookback: 12m simple vs composite multi-lookback
  2. Risk-off: TLT vs GLD vs 50/50 vs best
  3. 4e asset (TIPS, BIL) pour diversification
  4. Filtre VIX pour sizing dynamique

Prerequis

  • Environnement Lean Research
  • 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, 6)

qb = QuantBook()
print("QuantBook initialise.")
QuantBook initialise.

1. Chargement des données

Assets principaux: SPY (risk-on), TLT (bonds), GLD (gold). Candidats supplementaires: TIP (TIPS), BIL (T-Bills), SHY (short-term bonds).

tickers = ['SPY', 'TLT', 'GLD', 'TIP', 'BIL', 'SHY']

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

# Periode longue pour capturer plusieurs cycles
start = datetime(2007, 1, 1)
end = datetime(2026, 1, 1)

history = qb.history(list(symbols.values()), start, end, Resolution.DAILY)
closes = history['close'].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]
closes = closes.dropna()
# QC History retourne les bars daily a 16:00 (cloture marche), pas a minuit.
# resample('MS') etiquette les bacs a minuit -> le test d'appartenance mensuel
# (date in spy.index) ne matcherait jamais l'index daily -> on normalise a minuit.
closes.index = closes.index.normalize()

print(f"Periode: {closes.index[0].date()} a {closes.index[-1].date()}")
print(f"Donnees: {len(closes)} jours de trading")
Periode: 2007-05-30 a 2025-12-31
Donnees: 4679 jours de trading

2. Fonctions de backtest

def compute_momentum(prices, lookback):
    return prices / prices.shift(lookback) - 1

def composite_momentum(prices, windows=[21, 63, 126, 252], weights=[0.4, 0.2, 0.2, 0.2]):
    score = pd.Series(0.0, index=prices.index)
    for w, wt in zip(windows, weights):
        score += wt * compute_momentum(prices, w)
    return score

def backtest_dual_momentum(data, score_fn, risk_off='best', sma_period=200):
    """Backtest Dual Momentum avec rotation mensuelle."""
    spy = data['SPY']
    sma = spy.rolling(sma_period).mean()
    
    spy_mom = score_fn(spy)
    tlt_mom = score_fn(data['TLT'])
    gld_mom = score_fn(data['GLD'])
    
    monthly = data.resample('MS').first().index
    positions = pd.DataFrame(0.0, index=data.index, columns=['SPY', 'TLT', 'GLD'])
    current_pos = pd.Series({'SPY': 0.0, 'TLT': 0.0, 'GLD': 0.0})
    
    for date in monthly:
        if date not in spy.index:
            continue
        if pd.isna(spy_mom.get(date)) or pd.isna(sma.get(date)):
            continue
        
        s_spy = spy_mom[date]
        s_tlt = tlt_mom[date]
        s_gld = gld_mom[date]
        
        risk_on = (s_spy > 0) and (spy[date] > sma[date]) and (s_spy > max(s_tlt, s_gld))
        
        if risk_on:
            current_pos = pd.Series({'SPY': 1.0, 'TLT': 0.0, 'GLD': 0.0})
        else:
            if risk_off == 'TLT':
                current_pos = pd.Series({'SPY': 0.0, 'TLT': 1.0, 'GLD': 0.0})
            elif risk_off == 'GLD':
                current_pos = pd.Series({'SPY': 0.0, 'TLT': 0.0, 'GLD': 1.0})
            elif risk_off == '50/50':
                current_pos = pd.Series({'SPY': 0.0, 'TLT': 0.5, 'GLD': 0.5})
            else:  # best
                if s_tlt >= s_gld:
                    current_pos = pd.Series({'SPY': 0.0, 'TLT': 1.0, 'GLD': 0.0})
                else:
                    current_pos = pd.Series({'SPY': 0.0, 'TLT': 0.0, 'GLD': 1.0})
        
        positions.loc[date:] = current_pos.values
    
    returns = data[['SPY', 'TLT', 'GLD']].pct_change()
    port_ret = (positions.shift(1) * returns).sum(axis=1)
    return port_ret[port_ret.index >= monthly[0]]

def calc_stats(returns, name=''):
    total = (1 + returns).cumprod().iloc[-1] - 1
    years = len(returns) / 252
    cagr = (1 + total) ** (1 / years) - 1
    vol = returns.std() * np.sqrt(252)
    sharpe = (cagr - 0.03) / vol if vol > 0 else 0
    cum = (1 + returns).cumprod()
    dd = (cum / cum.cummax() - 1).min()
    return {'name': name, 'CAGR': f'{cagr:.1%}', 'Vol': f'{vol:.1%}',
            'Sharpe': f'{sharpe:.3f}', 'MaxDD': f'{dd:.1%}'}

print("Fonctions definies.")
Fonctions definies.

3. Hypothese 1: Lookback

Comparer 12m simple, 6m simple, et composite multi-lookback.

score_12m = lambda p: compute_momentum(p, 252)
score_6m = lambda p: compute_momentum(p, 126)
score_comp = lambda p: composite_momentum(p)

results1 = []
for name, fn in [('12m simple', score_12m), ('6m simple', score_6m), ('Composite', score_comp)]:
    ret = backtest_dual_momentum(closes, fn, risk_off='best')
    results1.append(calc_stats(ret, name))

spy_ret = closes['SPY'].pct_change().dropna()
results1.append(calc_stats(spy_ret, 'SPY B&H'))

print("=== H1: Lookback ===")
print(pd.DataFrame(results1).set_index('name').to_string())
=== H1: Lookback ===
            CAGR    Vol Sharpe   MaxDD
name                                  
12m simple  8.3%  15.8%  0.334  -39.3%
6m simple   8.0%  15.6%  0.319  -34.9%
Composite   8.4%  15.8%  0.338  -30.4%
SPY B&H     6.9%  17.6%  0.219  -55.2%

Verdict H1: Lookback

Comparer avec les résultats yfinance: - yfinance: Composite Sharpe 0.409, 12m Sharpe 0.301 - Divergence attendue: prix QC bruts (sans dividendes TLT/GLD)

4. Hypothese 2: Risk-off asset

results2 = []
for roff in ['best', 'TLT', 'GLD', '50/50']:
    ret = backtest_dual_momentum(closes, score_comp, risk_off=roff)
    results2.append(calc_stats(ret, f'Risk-off: {roff}'))

print("=== H2: Risk-Off ===")
print(pd.DataFrame(results2).set_index('name').to_string())
=== H2: Risk-Off ===
                  CAGR    Vol  Sharpe   MaxDD
name                                         
Risk-off: best    8.4%  15.8%   0.338  -30.4%
Risk-off: TLT     1.6%  15.1%  -0.090  -46.4%
Risk-off: GLD    11.3%  16.2%   0.515  -31.8%
Risk-off: 50/50   6.8%  13.0%   0.290  -26.4%

Verdict H2: Risk-off

yfinance montrait: GLD-only > Best > 50/50 > TLT-only. TLT penalise par hausse des taux 2022 (règle #3 du backlog). Verifier si le ranking est conserve avec les prix QC.

5. Analyse par regime

ret_cloud = backtest_dual_momentum(closes, score_comp, risk_off='best')

regimes = {
    'Pre-COVID (2010-2019)': ('2010-01-01', '2019-12-31'),
    'COVID crash (2020)': ('2020-01-01', '2020-12-31'),
    'Post-COVID (2021)': ('2021-01-01', '2021-12-31'),
    'Rate Hikes (2022)': ('2022-01-01', '2022-12-31'),
    'Recovery (2023-2025)': ('2023-01-01', '2025-12-31'),
}

print("=== Performance par regime (QuantBook) ===")
regime_stats = []
for regime, (s, e) in regimes.items():
    mask = (ret_cloud.index >= s) & (ret_cloud.index <= e)
    if mask.sum() > 50:
        regime_stats.append(calc_stats(ret_cloud[mask], regime))

print(pd.DataFrame(regime_stats).set_index('name').to_string())

print("\n=== SPY B&H par regime ===")
spy_regime = []
for regime, (s, e) in regimes.items():
    mask = (spy_ret.index >= s) & (spy_ret.index <= e)
    if mask.sum() > 50:
        spy_regime.append(calc_stats(spy_ret[mask], regime))
print(pd.DataFrame(spy_regime).set_index('name').to_string())
=== Performance par regime (QuantBook) ===
                        CAGR    Vol  Sharpe   MaxDD
name                                               
Pre-COVID (2010-2019)   5.8%  14.2%   0.193  -30.4%
COVID crash (2020)     22.7%  20.1%   0.982  -12.5%
Post-COVID (2021)       3.5%   8.7%   0.052   -5.0%
Rate Hikes (2022)      -3.6%  15.5%  -0.424  -21.0%
Recovery (2023-2025)   32.9%  16.3%   1.831  -11.3%

=== SPY B&H par regime ===
                        CAGR    Vol Sharpe   MaxDD
name                                              
Pre-COVID (2010-2019)  13.5%  14.7%  0.710  -19.3%
COVID crash (2020)     18.3%  33.4%  0.457  -33.7%
Post-COVID (2021)       6.4%   7.7%  0.435   -4.1%
Rate Hikes (2022)       0.0%   0.0%  0.000    0.0%
Recovery (2023-2025)    0.0%   0.0%  0.000    0.0%

Interpretation: Regime analysis

Points cles de research.ipynb (yfinance): - Pre-COVID: sous-performance vs SPY (momentum lent a capter le bull) - COVID 2020: forte surperformance (rotation vers GLD) - Rate Hikes 2022: perte significative (TLT drag) - Recovery 2023-2025: surperformance

Verifier si les patterns sont conserves avec les prix QC.

6. Visualisation

fig, axes = plt.subplots(2, 1, figsize=(14, 10))

# Equity curves
ax = axes[0]
variants = {
    'Composite + Best': backtest_dual_momentum(closes, score_comp, 'best'),
    'Composite + GLD': backtest_dual_momentum(closes, score_comp, 'GLD'),
    'Composite + 50/50': backtest_dual_momentum(closes, score_comp, '50/50'),
    '12m + Best': backtest_dual_momentum(closes, score_12m, 'best'),
}
for name, ret in variants.items():
    cum = (1 + ret).cumprod()
    ax.plot(cum.index, cum, label=name, linewidth=1.5)
spy_cum = (1 + spy_ret).cumprod()
ax.plot(spy_cum.index, spy_cum, label='SPY B&H', linestyle='--', alpha=0.5)
ax.set_title('Equity Curves - Dual Momentum (QuantBook)', fontweight='bold')
ax.legend(fontsize=8)
ax.set_ylabel('Valeur (base 1)')
ax.grid(True, alpha=0.3)

# Drawdowns
ax = axes[1]
for name, ret in variants.items():
    cum = (1 + ret).cumprod()
    dd = cum / cum.cummax() - 1
    ax.plot(dd.index, dd, label=name, alpha=0.7)
ax.set_title('Drawdowns', fontweight='bold')
ax.legend(fontsize=8)
ax.set_ylabel('Drawdown')
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('sectormomentum_quantbook_analysis.png', dpi=150, bbox_inches='tight')
plt.show()

7. Conclusions

Comparaison yfinance vs QuantBook

Variante Sharpe (yfinance) Sharpe (QuantBook) Divergence
Composite + Best 0.409 (a remplir) (a calculer)
12m + Best 0.301 (a remplir) (a calculer)
Composite + GLD 0.434 (a remplir) (a calculer)

Règles du backlog appliquees

  • Règle #3: TLT risk-off teste
  • Règle #9: Monthly rebal + regime-change
  • Règle #17: yfinance divergence quantifiee
Retour au sommet