Research QuantBook: Framework Composite Momentum + RegimeSwitching
Objectif
Analyser la combinaison de deux stratégies complementaires: - SectorMomentum: Dual momentum SPY/IEF/GLD avec lookbacks multiples (1/3/6/12 mois) - RegimeSwitching: Momentum en bull, mean-reversion en bear/sideways
Hypotheses a tester
Allocation optimale: Ratio Trend/RegimeSwitching (55/45, 60/40, 65/35)
Detection de regime: SMA50/SMA200 vs SMA200 seul
Complementarite: Momentum fort + defensif intelligent en regimes faibles
Pivot de la série ‘close’ en DataFrame large, avec remapping des colonnes Symbol → ticker pour Framework_Composite_MomentumRegime.
# Pivoter les donneescloses = 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: 2011-03-23 a 2025-12-31
Donnees: 3717 jours de trading
2. Implementation des signaux Alpha
def compute_sector_momentum_signals(closes, tickers, lookbacks=[21, 63, 126, 252], weights=[0.4, 0.2, 0.2, 0.2]):"""Signaux SectorMomentum: composite momentum multi-lookbacks.""" signals = pd.DataFrame(index=closes.index, columns=tickers) scores = pd.DataFrame(index=closes.index, columns=tickers)for ticker in tickers:if ticker notin closes.columns:continue prices = closes[ticker]for i inrange(max(lookbacks), len(prices)): composite_score =0.0 valid =Truefor lb, w inzip(lookbacks, weights): past_price = prices.iloc[i - lb]if past_price <=0: valid =Falsebreak momentum = (prices.iloc[i] / past_price) -1 composite_score += w * momentumif valid: scores[ticker].iloc[i] = composite_score# SMA200 filter sma200 = closes['SPY'].rolling(200).mean() if'SPY'in closes.columns elseNonefor i inrange(max(lookbacks), len(closes)): current_scores = {}for t in tickers:if t in scores.columns: score = scores[t].iloc[i]if pd.notna(score): current_scores[t] = scoreifnot current_scores:continue best =max(current_scores, key=current_scores.get)# SPY only if positive momentum AND above SMA200if best =="SPY":if sma200 isnotNone: spy_price = closes['SPY'].iloc[i] spy_sma = sma200.iloc[i]if pd.notna(spy_sma) and (spy_price <= spy_sma or current_scores[best] <=0):# Fallback to defensive defensive = {k: v for k, v in current_scores.items() if k in ["IEF", "GLD"]}if defensive: best =max(defensive, key=defensive.get)for t in tickers: signals[t].iloc[i] =1if t == best else0return signalsdef compute_rsi(prices, period=14):"""Calcule RSI.""" delta = prices.diff() gain = (delta.where(delta >0, 0)).rolling(window=period).mean() loss = (-delta.where(delta <0, 0)).rolling(window=period).mean() rs = gain / loss rsi =100- (100/ (1+ rs))return rsidef detect_regime(closes):"""Detecte le regime: bull, bear, sideways."""if'SPY'notin closes.columns:return pd.Series(index=closes.index, data='unknown') prices = closes['SPY'] sma50 = prices.rolling(50).mean() sma200 = prices.rolling(200).mean() regimes = []for i inrange(len(closes)):if pd.isna(sma50.iloc[i]) or pd.isna(sma200.iloc[i]): regimes.append('unknown')continue price = prices.iloc[i] s50 = sma50.iloc[i] s200 = sma200.iloc[i]if price > s200 and s50 > s200: regimes.append('bull')elif price < s200 and s50 < s200: regimes.append('bear')else: regimes.append('sideways')return pd.Series(regimes, index=closes.index)def compute_regime_switching_signals(closes, tickers, regimes):"""Signaux RegimeSwitching: momentum en bull, mean-reversion en bear/sideways.""" signals = pd.DataFrame(index=closes.index, columns=tickers)# RSI pour mean-reversion rsi_values = {}for t in ["SPY", "QQQ"]:if t in closes.columns: rsi_values[t] = compute_rsi(closes[t])for i inrange(252, len(closes)): regime = regimes.iloc[i]if regime =='bull':# Momentum: SPY 70%, QQQ 30%for t in tickers:if t =="SPY": signals[t].iloc[i] =0.7elif t =="QQQ": signals[t].iloc[i] =0.3else: signals[t].iloc[i] =0elif regime =='bear':# Defensive: GLD 50%, IEF 50%for t in tickers:if t =="GLD": signals[t].iloc[i] =0.5elif t =="IEF": signals[t].iloc[i] =0.5else: signals[t].iloc[i] =0else: # sideways# Check RSI oversold oversold = []for t in ["SPY", "QQQ"]:if t in rsi_values and i <len(rsi_values[t]): rsi = rsi_values[t].iloc[i]if pd.notna(rsi) and rsi <30: oversold.append(t)if oversold:# Mean-reversion: oversold 30%, GLD 35%, IEF 35%for t in tickers:if t in oversold: signals[t].iloc[i] =0.30/len(oversold)elif t =="GLD": signals[t].iloc[i] =0.35elif t =="IEF": signals[t].iloc[i] =0.35else: signals[t].iloc[i] =0else:# Reduced equity: SPY 30%, GLD 35%, IEF 35%for t in tickers:if t =="SPY": signals[t].iloc[i] =0.30elif t =="GLD": signals[t].iloc[i] =0.35elif t =="IEF": signals[t].iloc[i] =0.35else: signals[t].iloc[i] =0return signals# Generer les signauxmom_tickers = ["SPY", "IEF", "GLD"]regime_tickers = ["SPY", "QQQ", "IEF", "GLD"]mom_signals = compute_sector_momentum_signals(closes, mom_tickers)regimes = detect_regime(closes)regime_signals = compute_regime_switching_signals(closes, regime_tickers, regimes)print("Signaux SectorMomentum (derniers 5 jours):")print(mom_signals.iloc[-5:])print(f"\nRegimes (derniers 5 jours):")print(regimes.iloc[-5:])
# Distribution des regimesregime_counts = regimes.value_counts()regime_pct = regimes.value_counts(normalize=True) *100print("Distribution des regimes (2010-2026):")print(f"{'Regime':<12}{'Jours':>10}{'Pourcentage':>12}")print("-"*35)for regime in ['bull', 'bear', 'sideways']: count = regime_counts.get(regime, 0) pct = regime_pct.get(regime, 0)print(f"{regime:<12}{count:>10}{pct:>11.1f}%")print(f"\nInterpretation: Le passe decompose le temps en regimes distincts.\n""La strategy RegimeSwitching adapte son comportement a chaque regime.")
Distribution des regimes (2010-2026):
Regime Jours Pourcentage
-----------------------------------
bull 2156 58.0%
bear 150 4.0%
sideways 1212 32.6%
Interpretation: Le passe decompose le temps en regimes distincts.
La strategy RegimeSwitching adapte son comportement a chaque regime.
6. Visualisation des equity curves
fig, axes = plt.subplots(1, 2, figsize=(16, 5))# Gauche: Comparaison des allocationsax = axes[0]for name, r in results.items(): ax.plot(r['cum'].values, label=f"{name} (S={r['sharpe']:.2f})", linewidth=1.5)ax.set_title('Allocation Trend/RegimeSwitching', fontsize=12, fontweight='bold')ax.set_ylabel('Valeur du portefeuille')ax.legend(fontsize=9)ax.grid(True, alpha=0.3)# Droite: SPY vs Compositeax = axes[1]# Buy and hold SPYspy_values = closes['SPY'].iloc[252:] / closes['SPY'].iloc[252]ax.plot(spy_values.values, label=f"SPY B&H", linewidth=1.5, alpha=0.7)ax.plot(best_alloc[1]['cum'].values, label=f"Composite {best_alloc[0]} (S={best_alloc[1]['sharpe']:.2f})", linewidth=1.5)ax.set_title(f'Composite vs SPY', fontsize=12, fontweight='bold')ax.set_ylabel('Valeur du portefeuille (normalisee)')ax.legend(fontsize=9)ax.grid(True, alpha=0.3)plt.tight_layout()plt.savefig('composite_momentum_regime.png', dpi=150, bbox_inches='tight')plt.show()print("Graphique sauvegarde.")
Graphique sauvegarde.
7. Conclusions et recommandations
Resume
Metrique
SectorMomentum
RegimeSwitching
Composite optimal
Sharpe
(a remplir)
(a remplir)
(a remplir)
CAGR
(a remplir)
(a remplir)
(a remplir)
Max DD
(a remplir)
(a remplir)
(a remplir)
Allocation recommandee
Allocation: [a remplir]
Design pattern valide
Complementarite: Momentum fort (SectorMomentum) + Adaptatif (RegimeSwitching)
Gestion du risque: SectorMomentum filtre SMA200, RegimeSwitching adapte au marche
Synergie: Les deux stratégies utilisent SPY/IEF/GLD = allocation additive sur conviction forte
Prochaines étapes
Deployer sur QC cloud avec l’allocation optimale
Backtester sur différentes periodes pour valider la robustesse