Research QuantBook: RiskParity (Inverse-Volatility Weighting)

Objectif

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

Ce notebook vs main.py — divergence assumée (C.4)

Ce quantbook est une baseline pédagogique inverse-vol simplifiée (5 ETF, sans trend filter, sans safe haven). Ses métriques se lisent dans les sorties des cellules de code ci-dessous (hypothèses vol-window / rebalancement / univers) — elles ne sont pas ré-épinglées ici (règle #9434 : une métrique calculée se relit dans la cellule qui la produit, pas en prose d’en-tête). Ces sorties divergent structurellement de celles du backtest LEAN full-fidelity main.py (trend filter + BND safe haven), référencé dans le README et research.ipynb : l’écart, d’environ un ordre de grandeur sur le Sharpe, est précisément le message pédagogique du notebook — il quantifie ce que les optimisations de l’algo complet ajoutent au-dessus de la baseline naïve. Les sorties de ce notebook ne sont donc pas la performance de la stratégie déployée.

Configuration (déterministe)

  • Stratégie: Inverse-vol weighting (Bridgewater-style, sans levier)
  • Univers: SPY, EFA, GLD, DBC, TLT (5 classes d’actifs)
  • Rebal: Mensuel + drift trigger 5%

Hypotheses a tester

  1. Vol lookback window (20d, 40d, 60d, 90d, 120d)
  2. Drift trigger threshold (2%, 3%, 5%, 7%)
  3. Asset universe alternatives (IEF vs TLT, sans DBC)
  4. Correlation-aware weighting

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

5 classes d’actifs: US equities, International, Gold, Commodities, Bonds.

tickers = ['SPY', 'EFA', 'GLD', 'DBC', 'TLT', 'IEF', 'SHY', 'XLP']

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

returns_df = closes.pct_change()
Periode: 2014-06-02 a 2025-12-31
Donnees: 2915 jours de trading

Statistiques par actif

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

for ticker in 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
    print(f"{ticker:<8} {ret:>11.1%} {vol:>11.1%} {sharpe:>7.2f}")
Ticker     Rend. Ann.   Volatilite   Sharpe
------------------------------------------
SPY            11.5%       17.6%    0.49
EFA             0.7%       16.5%   -0.14
GLD            10.9%       14.8%    0.53
DBC            -1.7%       17.0%   -0.28
TLT            -2.2%       14.9%   -0.35
IEF            -0.9%        6.4%   -0.62
SHY            -0.3%        1.5%   -2.18
XLP             4.9%       14.4%    0.13

2. Fonctions de backtest

def inverse_vol_weights(returns_df, assets, vol_window=60):
    """Calcule les poids inverse-volatilite."""
    vols = returns_df[assets].rolling(vol_window).std() * np.sqrt(252)
    inv_vol = 1.0 / vols
    weights = inv_vol.div(inv_vol.sum(axis=1), axis=0)
    return weights

def backtest_risk_parity(closes, assets, vol_window=60, rebal_freq=21,
                          drift_trigger=None):
    """Backtest risk parity avec inverse-vol weighting."""
    returns_df = closes[assets].pct_change()
    target_weights = inverse_vol_weights(returns_df, assets, vol_window)
    
    n = len(returns_df)
    start_idx = vol_window + 1
    
    portfolio_values = [1.0]
    current_weights = target_weights.iloc[start_idx].values
    rebal_counter = 0
    
    for i in range(start_idx, n):
        daily_rets = returns_df.iloc[i].values
        port_ret = np.nansum(current_weights * daily_rets)
        portfolio_values.append(portfolio_values[-1] * (1 + port_ret))
        
        # Update weights with drift
        current_weights = current_weights * (1 + daily_rets)
        total = np.nansum(current_weights)
        if total > 0:
            current_weights = current_weights / total
        
        rebal_counter += 1
        
        # Rebalance decision
        target_w = target_weights.iloc[i].values
        if drift_trigger is not None:
            max_drift = np.nanmax(np.abs(current_weights - target_w))
            if max_drift >= drift_trigger:
                current_weights = target_w
                rebal_counter = 0
        elif rebal_counter >= rebal_freq:
            current_weights = target_w
            rebal_counter = 0
    
    vals = np.array(portfolio_values)
    rets = np.diff(vals) / vals[:-1]
    total_ret = vals[-1] / vals[0] - 1
    years = len(rets) / 252
    cagr = (1 + total_ret) ** (1 / years) - 1 if years > 0 else 0
    vol = np.std(rets) * np.sqrt(252)
    sharpe = (cagr - 0.03) / vol if vol > 0.001 else 0
    cum = pd.Series(vals[1:], index=closes.index[start_idx:])
    max_dd = ((cum - cum.expanding().max()) / cum.expanding().max()).min()
    
    return {'sharpe': sharpe, 'cagr': cagr, 'max_dd': max_dd, 'vol': vol, 'cum': cum}

print("Fonctions definies.")
Fonctions definies.

3. Hypothese 1: Vol lookback window

La fenêtre de calcul de volatilite pour les poids inverse-vol. Règle #9 du backlog: Vol window 60d > 20d.

base_assets = ['SPY', 'EFA', 'GLD', 'DBC', 'TLT']

print(f"{'Vol Window':<15} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8} {'Vol':>8}")
print("-" * 50)

results_vol = {}
for vw in [20, 40, 60, 90, 120]:
    r = backtest_risk_parity(closes, base_assets, vol_window=vw, rebal_freq=21)
    results_vol[f'{vw}d'] = r
    print(f"{f'{vw}d':<15} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%} {r['vol']:>7.1%}")
Vol Window        Sharpe     CAGR    MaxDD      Vol
--------------------------------------------------
20d                0.002    3.0%  -20.7%    8.8%
40d                0.014    3.1%  -20.2%    8.9%
60d                0.037    3.3%  -20.4%    9.0%
90d                0.074    3.7%  -20.0%    9.0%
120d               0.101    3.9%  -19.9%    9.1%

Verdict H1

60d est le standard. 20d trop reactif (bruit), 120d trop lent (retard).

4. Hypothese 2: Drift trigger vs rebal fixe

print(f"{'Rebalancement':<20} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8}")
print("-" * 45)

results_rebal = {}

for name, freq, drift in [
    ('Mensuel (21j)', 21, None),
    ('Trimestriel (63j)', 63, None),
    ('Drift 2%', 252, 0.02),
    ('Drift 3%', 252, 0.03),
    ('Drift 5% (actuel)', 252, 0.05),
    ('Drift 7%', 252, 0.07),
]:
    r = backtest_risk_parity(closes, base_assets, vol_window=60,
                              rebal_freq=freq, drift_trigger=drift)
    results_rebal[name] = r
    print(f"{name:<20} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%}")
Rebalancement          Sharpe     CAGR    MaxDD
---------------------------------------------
Mensuel (21j)           0.037    3.3%  -20.4%
Trimestriel (63j)       0.101    3.9%  -19.9%
Drift 2%                0.034    3.3%  -19.9%
Drift 3%                0.042    3.4%  -19.8%
Drift 5% (actuel)       0.059    3.5%  -19.4%
Drift 7%                0.075    3.7%  -20.0%

Verdict H2

Règle #7 du backlog: Drift rebalancing > SMA overlay pour portfolios statiques. Verifier si drift 5% est optimal ou si 3% est meilleur.

5. Hypothese 3: Composition de l’univers

Tester différentes combinaisons d’actifs. DBC (contango structurel) et TLT (hausse des taux 2022) sont les candidats a remplacer.

universes = {
    'Actuel (5 assets)': ['SPY', 'EFA', 'GLD', 'DBC', 'TLT'],
    'Sans DBC': ['SPY', 'EFA', 'GLD', 'TLT'],
    'IEF au lieu de TLT': ['SPY', 'EFA', 'GLD', 'DBC', 'IEF'],
    'Sans DBC + IEF': ['SPY', 'EFA', 'GLD', 'IEF'],
    'XLP au lieu de DBC': ['SPY', 'EFA', 'GLD', 'XLP', 'TLT'],
    '3 assets simples': ['SPY', 'GLD', 'IEF'],
}

print(f"{'Univers':<25} {'Sharpe':>8} {'CAGR':>8} {'MaxDD':>8}")
print("-" * 50)

results_univ = {}
for name, assets in universes.items():
    avail = [a for a in assets if a in closes.columns]
    r = backtest_risk_parity(closes, avail, vol_window=60, rebal_freq=21)
    results_univ[name] = r
    print(f"{name:<25} {r['sharpe']:>8.3f} {r['cagr']:>7.1%} {r['max_dd']:>7.1%}")
Univers                     Sharpe     CAGR    MaxDD
--------------------------------------------------
Actuel (5 assets)            0.037    3.3%  -20.4%
Sans DBC                     0.168    4.5%  -24.8%
IEF au lieu de TLT          -0.109    2.2%  -17.5%
Sans DBC + IEF               0.010    3.1%  -20.1%
XLP au lieu de DBC           0.185    4.7%  -21.5%
3 assets simples             0.080    3.5%  -18.0%

Verdict H3

Règle #3: TLT risk-off detruit la valeur. DBC = contango structurel. Verifier si des univers simplifies performent mieux.

6. Visualisation

fig, axes = plt.subplots(1, 3, figsize=(18, 6))

# H1: Vol window
ax = axes[0]
for name, r in results_vol.items():
    ax.plot(r['cum'].values, label=f"{name} (S={r['sharpe']:.2f})", linewidth=1.5)
ax.set_title('H1: Vol lookback window', fontweight='bold')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)

# H2: Rebalancement
ax = axes[1]
for name, r in results_rebal.items():
    ax.plot(r['cum'].values, label=f"{name} (S={r['sharpe']:.2f})", linewidth=1.5)
ax.set_title('H2: Rebalancement', fontweight='bold')
ax.legend(fontsize=7)
ax.grid(True, alpha=0.3)

# H3: Univers
ax = axes[2]
for name, r in results_univ.items():
    ax.plot(r['cum'].values, label=f"{name} (S={r['sharpe']:.2f})", linewidth=1.5)
ax.set_title('H3: Asset universe', fontweight='bold')
ax.legend(fontsize=7)
ax.grid(True, alpha=0.3)

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

7. Conclusions

Tableau recapitulatif

Hypothese Résultat QuantBook Coherent avec yfinance?
H1 Vol window (a remplir) (a verifier)
H2 Rebalancement (a remplir) (a verifier)
H3 Univers (a remplir) (a verifier)

Règles du backlog appliquees

  • Règle #3: TLT risk-off detruit la valeur
  • Règle #7: Drift rebalancing > SMA overlay
  • Règle #9: Vol window 60d > 20d
  • Règle #17: Divergence yfinance documentee
Retour au sommet