Research QuantBook: DualMomentum (Antonacci)

Objectif

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

Performance actuelle

  • Sharpe: 0.350, CAGR: 9.2%, MaxDD: 33.6%
  • Stratégie: Relative + Absolute Momentum (Gary Antonacci)
  • Univers: SPY (US), EFA (Intl), BND (Bonds refuge)
  • Signal: 12m return, rotation mensuelle, 100% concentration

Hypotheses a tester

  1. Lookback period (6m, 9m, 12m, 18m)
  2. Absolute momentum threshold (-5% a +5%)
  3. Universe expansion (ajouter EEM, VNQ)
  4. Refuge alternatives (BND, IEF, SHY, GLD)
  5. Signal frequency (mensuel vs bi-mensuel)

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

Univers Antonacci: SPY (US equities), EFA (International), BND (US Aggregate Bonds). Candidats alternatifs: EEM (Emerging), IEF (7-10Y Treasuries), SHY (1-3Y), GLD (Gold), VNQ (REITs).

tickers = ['SPY', 'EFA', 'BND', 'EEM', 'IEF', 'SHY', 'GLD', 'VNQ']

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

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

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

2. Fonctions de backtest

def backtest_dual_momentum(closes, risky_assets, refuge, lookback=252,
                           abs_threshold=0.0, rebal_freq='MS'):
    """Backtest Dual Momentum: relative + absolute.
    
    - Choisit le meilleur actif risque sur lookback period
    - Si son rendement > abs_threshold, investit 100% dedans
    - Sinon, investit 100% dans le refuge
    """
    all_assets = risky_assets + [refuge]
    returns_df = closes[all_assets].pct_change()
    
    # Momentum scores
    mom = {}
    for asset in all_assets:
        mom[asset] = closes[asset] / closes[asset].shift(lookback) - 1
    
    # first ACTUAL trading day of each month (month-start labels are not in
    # closes.index -> would skip every rebalance -> 0% returns; fix #6891)
    monthly = pd.Series(closes.index, index=closes.index).resample(rebal_freq).first().dropna().values
    positions = pd.Series(index=closes.index, dtype=str)
    current_pos = refuge
    
    for date in monthly:
        if date not in closes.index:
            continue
        if any(pd.isna(mom[a].get(date, np.nan)) for a in risky_assets):
            continue
        
        # Relative momentum: best risky asset
        risky_scores = {a: mom[a][date] for a in risky_assets}
        best_risky = max(risky_scores, key=risky_scores.get)
        
        # Absolute momentum: is it positive enough?
        if risky_scores[best_risky] > abs_threshold:
            current_pos = best_risky
        else:
            current_pos = refuge
        
        positions.loc[date:] = current_pos
    
    # Calculer les rendements
    port_ret = pd.Series(0.0, index=closes.index)
    for asset in all_assets:
        mask = positions == asset
        port_ret[mask] = returns_df[asset][mask]
    
    port_ret = port_ret[port_ret.index >= monthly[0]]
    return port_ret

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.

Source des donnees et reproductibilite. Les 8 ETF (SPY/EFA/BND/EEM/IEF/SHY/GLD/VNQ) sont servis localement au format LEAN daily via DefaultDataProvider : donnees OHLCV journalieres converties depuis yfinance (source gratuite, sans cle API). Cette voie evite le Security Master payant – ApiDataProvider (telechargement cloud) exige un abonnement map/factor que l’organisation n’a pas, tandis que DefaultDataProvider lit directement le cache local. Les cours sont bruts (non ajustes des dividendes) : bénin pour un signal de momentum sur prix, documente ici en toute transparence.

Note de debug (#6891). La re-execution honnete a revele un bug d’alignement mensuel latent dans backtest_dual_momentum (les debuts de mois renvoyes par resample('MS') ne sont pas dans l’index des jours de bourse -> chaque rebalancement etait saute -> rendements systematiquement nuls). Le bug, masque jusque-la par les sorties fabriquees, est corrige dans la cellule precedente (premier jour de bourse reel de chaque mois). Voir #6891, #7265.

3. Hypothese 1: Lookback period

12 mois est le lookback canonique d’Antonacci. Tester la robustesse.

results_lb = []
for lb_months, lb_days in [(6, 126), (9, 189), (12, 252), (18, 378)]:
    ret = backtest_dual_momentum(closes, ['SPY', 'EFA'], 'BND', lookback=lb_days)
    results_lb.append(calc_stats(ret, f'{lb_months}m ({lb_days}d)'))

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

print("=== H1: Lookback ===")
print(pd.DataFrame(results_lb).set_index('name').to_string())
=== H1: Lookback ===
            CAGR    Vol Sharpe   MaxDD
name                                  
6m (126d)   4.8%  14.5%  0.121  -35.9%
9m (189d)   5.5%  14.6%  0.170  -34.1%
12m (252d)  6.2%  14.9%  0.213  -34.1%
18m (378d)  7.6%  15.3%  0.303  -34.1%
SPY B&H     8.6%  19.9%  0.284  -56.5%

Verdict H1

Le lookback 12m est standard dans la litterature. Le COVID crash (Mars 2020) expose la latence du signal mensuel quel que soit le lookback.

4. Hypothese 2: Absolute momentum threshold

Seuil de basculement risk-on/risk-off. Actuellement 0% (proxy T-bills).

results_thresh = []
for thresh in [-0.05, -0.02, 0.0, 0.02, 0.05]:
    ret = backtest_dual_momentum(closes, ['SPY', 'EFA'], 'BND', abs_threshold=thresh)
    results_thresh.append(calc_stats(ret, f'Seuil {thresh:+.0%}'))

print("=== H2: Absolute momentum threshold ===")
print(pd.DataFrame(results_thresh).set_index('name').to_string())
=== H2: Absolute momentum threshold ===
           CAGR    Vol Sharpe   MaxDD
name                                 
Seuil -5%  7.0%  15.9%  0.253  -34.1%
Seuil -2%  6.6%  15.1%  0.239  -34.1%
Seuil +0%  6.2%  14.9%  0.213  -34.1%
Seuil +2%  4.6%  14.7%  0.108  -37.6%
Seuil +5%  4.6%  14.3%  0.114  -34.8%

Verdict H2

Un seuil positif (+2%, +5%) augmente le temps en refuge (plus conservateur). Un seuil negatif (-2%, -5%) maintient l’exposition plus longtemps.

5. Hypothese 3: Refuge alternatives

BND vs IEF vs SHY vs GLD comme asset de refuge. Règle #3 du backlog: TLT risk-off detruit la valeur 2015-2026.

results_refuge = []
for refuge in ['BND', 'IEF', 'SHY', 'GLD']:
    ret = backtest_dual_momentum(closes, ['SPY', 'EFA'], refuge)
    results_refuge.append(calc_stats(ret, f'Refuge: {refuge}'))

print("=== H3: Refuge alternatives ===")
print(pd.DataFrame(results_refuge).set_index('name').to_string())
=== H3: Refuge alternatives ===
             CAGR    Vol Sharpe   MaxDD
name                                   
Refuge: BND  6.2%  14.9%  0.213  -34.1%
Refuge: IEF  6.1%  15.1%  0.204  -34.1%
Refuge: SHY  5.8%  14.6%  0.192  -34.1%
Refuge: GLD  9.1%  17.5%  0.348  -34.1%

Verdict H3

research.ipynb (yfinance) montrait IEF/SHY legerement meilleur que BND, mais le QC cloud a rejete ce changement (Sharpe 0.350->0.324 avec SHY). Verifier avec les prix QC bruts.

6. Hypothese 4: Univers elargi

Ajouter EEM (Emerging Markets) et VNQ (REITs) a l’univers risque.

results_univ = []
universes = {
    'SPY+EFA (actuel)': ['SPY', 'EFA'],
    'SPY+EFA+EEM': ['SPY', 'EFA', 'EEM'],
    'SPY+EFA+VNQ': ['SPY', 'EFA', 'VNQ'],
    'SPY+EFA+EEM+VNQ': ['SPY', 'EFA', 'EEM', 'VNQ'],
}

for name, risky in universes.items():
    ret = backtest_dual_momentum(closes, risky, 'BND')
    results_univ.append(calc_stats(ret, name))

print("=== H4: Univers elargi ===")
print(pd.DataFrame(results_univ).set_index('name').to_string())
=== H4: Univers elargi ===
                  CAGR    Vol Sharpe   MaxDD
name                                        
SPY+EFA (actuel)  6.2%  14.9%  0.213  -34.1%
SPY+EFA+EEM       5.3%  16.4%  0.143  -34.8%
SPY+EFA+VNQ       8.2%  16.0%  0.322  -34.1%
SPY+EFA+EEM+VNQ   6.7%  17.0%  0.219  -35.4%

Verdict H4

EEM et VNQ ajoutent de la diversification mais peuvent diluer le signal. Verifier si le gain en diversification compense la rotation accrue.

7. Analyse par regime

ret_base = backtest_dual_momentum(closes, ['SPY', 'EFA'], 'BND')

regimes = {
    'GFC (2008-2009)': ('2008-01-01', '2009-12-31'),
    'Bull (2010-2019)': ('2010-01-01', '2019-12-31'),
    'COVID (2020)': ('2020-01-01', '2020-12-31'),
    'Rate Hikes (2022)': ('2022-01-01', '2022-12-31'),
    'Recovery (2023-2025)': ('2023-01-01', '2025-12-31'),
}

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

print(pd.DataFrame(regime_stats).set_index('name').to_string())
=== Performance par regime ===
                        CAGR    Vol  Sharpe   MaxDD
name                                               
GFC (2008-2009)         3.4%   9.3%   0.048   -9.8%
Bull (2010-2019)        6.3%  14.0%   0.237  -25.3%
COVID (2020)            0.8%  30.9%  -0.070  -34.1%
Rate Hikes (2022)     -18.3%  14.5%  -1.468  -21.5%
Recovery (2023-2025)   14.3%  15.1%   0.748  -19.0%

8. Visualisation

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

# H1: Lookback
ax = axes[0, 0]
for lb_m, lb_d in [(6, 126), (9, 189), (12, 252), (18, 378)]:
    ret = backtest_dual_momentum(closes, ['SPY', 'EFA'], 'BND', lookback=lb_d)
    cum = (1 + ret).cumprod()
    ax.plot(cum.index, cum, label=f'{lb_m}m', linewidth=1.5)
ax.set_title('H1: Lookback period', fontweight='bold')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)

# H2: Threshold
ax = axes[0, 1]
for thresh in [-0.05, 0.0, 0.05]:
    ret = backtest_dual_momentum(closes, ['SPY', 'EFA'], 'BND', abs_threshold=thresh)
    cum = (1 + ret).cumprod()
    ax.plot(cum.index, cum, label=f'Seuil {thresh:+.0%}', linewidth=1.5)
ax.set_title('H2: Abs. momentum threshold', fontweight='bold')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)

# H3: Refuge
ax = axes[1, 0]
for refuge in ['BND', 'IEF', 'SHY', 'GLD']:
    ret = backtest_dual_momentum(closes, ['SPY', 'EFA'], refuge)
    cum = (1 + ret).cumprod()
    ax.plot(cum.index, cum, label=f'Refuge: {refuge}', linewidth=1.5)
ax.set_title('H3: Refuge alternatives', fontweight='bold')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)

# H4: Universe
ax = axes[1, 1]
for name, risky in universes.items():
    ret = backtest_dual_momentum(closes, risky, 'BND')
    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('H4: Univers elargi', fontweight='bold')
ax.legend(fontsize=7)
ax.grid(True, alpha=0.3)

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

Lecture des 4 panneaux. Chaque panneau trace la richesse cumulee (1+r).cumprod() de la strategie Dual Momentum sur 2007-2025. H1 (lookback) : le rendement croit avec la fenetre (6m -> 18m), signe d’un momentum plus stable sur periode longue. H2 (seuil absolu) : un seuil plus permissif (-5%) maintient une exposition au marche plus longtemps -> rendement legerement superieur. H3 (refuge) : GLD offre le meilleur couple rendement/MaxDD sur la periode (or 2007-2025), BND/IEF/SHY proches. H4 (univers) : ajouter VNQ ameliore le rendement (8.2%), EEM le penalise. En pointille contre SPY B&H (pointille) : la strategie reduit le drawdown max (~-34% vs ~-56%) au prix d’un rendement legerement inferieur – l’arbitrage attendu d’une strategie de refuge dynamique.

9. Conclusions

Tableau recapitulatif

Hypothese Résultat QuantBook Coherent avec yfinance?
H1 Lookback (a remplir) (a verifier)
H2 Threshold (a remplir) (a verifier)
H3 Refuge (a remplir) (a verifier)
H4 Univers (a remplir) (a verifier)

Règles du backlog appliquees

  • Règle #3: TLT risk-off detruit la valeur
  • Règle #10: Monthly rebal confirme
  • Règle #17: Divergence yfinance documentee (BND sans coupons)
Retour au sommet