Pivot de la série ‘close’ en DataFrame large, avec remapping des colonnes Symbol → ticker pour Framework_Composite_FamaFrenchAllWeather.
# 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")print(f"Tickers: {list(closes.columns)}")
Periode: 2010-01-04 a 2025-12-31
Donnees: 4024 jours de trading
Tickers: ['SPY']
2. Implementation des signaux Alpha
def compute_risk_adjusted_momentum(closes, tickers, lookback=252, skip_days=21, vol_window=63):"""Calcule le momentum risk-adjuste pour les factor ETFs.""" scores = pd.DataFrame(index=closes.index, columns=tickers)for ticker in tickers:if ticker notin closes.columns:continue prices = closes[ticker] scores[ticker] = np.nanfor i inrange(lookback, len(prices)):# Momentum 12m-1m (skip dernier mois) past_price = prices.iloc[i - lookback] skip_idx =max(i - skip_days, lookback) skip_price = prices.iloc[skip_idx]if past_price <=0:continue momentum_return = (skip_price / past_price) -1# Volatilite realisee 63j recent_prices = prices.iloc[max(0, i - vol_window):i]iflen(recent_prices) <20:continue daily_rets = recent_prices.pct_change().dropna() realized_vol = daily_rets.std() * np.sqrt(252)if realized_vol <=0.001:continue scores[ticker].iloc[i] = momentum_return / realized_volreturn scoresdef compute_famafrench_signals(closes, tickers, scores_df, use_sma200=False):"""Genere les signaux FamaFrench (mensuels).""" signals = pd.DataFrame(index=closes.index, columns=tickers) sma200 = closes['SPY'].rolling(200).mean() if'SPY'in closes.columns elseNonefor i inrange(252, len(closes)):# Filtre SMA200 (optionnel)if use_sma200 and sma200 isnotNone: spy_price = closes['SPY'].iloc[i] spy_sma = sma200.iloc[i]if pd.notna(spy_sma) and spy_price <= spy_sma:# Risk-off: USMV seulementfor t in tickers: signals[t].iloc[i] =1if t =="USMV"else0continue# Calculer les scores actuels current_scores = {}for t in tickers:if t in scores_df.columns: score = scores_df[t].iloc[i]if pd.notna(score): current_scores[t] = scoreiflen(current_scores) <2:continue# Selectionner tous les facteurs avec momentum positif positive_factors = [t for t, s in current_scores.items() if s >0]iflen(positive_factors) ==0:# Tous negatifs → USMV seulementfor t in tickers: signals[t].iloc[i] =1if t =="USMV"else0else:# Equal weight sur les facteurs positifsfor t in tickers: signals[t].iloc[i] =1if t in positive_factors else0return signals# Calculer les scores de momentumff_scores = compute_risk_adjusted_momentum(closes, ff_tickers)# Signaux SANS SMA200 (design recommande)ff_signals_no_sma = compute_famafrench_signals(closes, ff_tickers, ff_scores, use_sma200=False)# Signaux AVEC SMA200 (pour comparaison)ff_signals_with_sma = compute_famafrench_signals(closes, ff_tickers, ff_scores, use_sma200=True)print("Scores risk-adjusted momentum (derniers 5 jours):")print(ff_scores.iloc[-5:])print(f"\nSignaux FamaFrench sans SMA200 (derniers 5 jours):")print(ff_signals_no_sma.iloc[-5:])
Scores risk-adjusted momentum (derniers 5 jours):
VLUE MTUM SIZE QUAL USMV
time
2025-12-24 13:00:00 NaN NaN NaN NaN NaN
2025-12-26 16:00:00 NaN NaN NaN NaN NaN
2025-12-29 16:00:00 NaN NaN NaN NaN NaN
2025-12-30 16:00:00 NaN NaN NaN NaN NaN
2025-12-31 16:00:00 NaN NaN NaN NaN NaN
Signaux FamaFrench sans SMA200 (derniers 5 jours):
VLUE MTUM SIZE QUAL USMV
time
2025-12-24 13:00:00 NaN NaN NaN NaN NaN
2025-12-26 16:00:00 NaN NaN NaN NaN NaN
2025-12-29 16:00:00 NaN NaN NaN NaN NaN
2025-12-30 16:00:00 NaN NaN NaN NaN NaN
2025-12-31 16:00:00 NaN NaN NaN NaN NaN
Interpretation: Momentum risk-adjuste
Le score risk-adjuste = (rendement 12m-1m) / (volatilite 63j).
Score positif: Le facteur surperforme avec une volatilite raisonnable
Score negatif: Sous-performance ou volatilite excessive
Les facteurs selectionnes varient mensuellement, creant une rotation dynamique.
3. Backtest du Composite
Fonction de backtest combinant FamaFrench + AllWeather avec allocation variable.
def backtest_composite(closes, ff_signals, ff_alloc=0.20, aw_alloc=0.80, rebal_freq=21):""" Backtest du composite FamaFrench + AllWeather. Args: ff_alloc: Allocation FamaFrench (ex: 0.20 = 20%) aw_alloc: Allocation AllWeather (ex: 0.80 = 80%) rebal_freq: Frequence rebalance (21 = mensuel ~) """ returns_df = closes.pct_change() portfolio_values = [1.0] warmup =252 counter =0# AllWeather holdings (statiques) aw_weights = {"SPY": 0.30, "IEF": 0.30, "GLD": 0.30, "XLP": 0.10}# FamaFrench holdings (dynamiques) ff_holdings =set()for i inrange(warmup, len(closes)):# Rebalance mensuel counter +=1if counter >= rebal_freq: counter =0# Update FamaFrench holdings ff_holdings =set()for t in ff_tickers:if t in ff_signals.columns and ff_signals[t].iloc[i] ==1: ff_holdings.add(t)# Calcul du return port_return =0.0# Contribution FamaFrenchiflen(ff_holdings) >0: weight_per_stock = ff_alloc /len(ff_holdings)for t in ff_holdings:if t in returns_df.columns: port_return += weight_per_stock * returns_df[t].iloc[i]# Contribution AllWeatherfor t, w in aw_weights.items():if t in returns_df.columns: port_return += aw_alloc * w * returns_df[t].iloc[i] portfolio_values.append(portfolio_values[-1] * (1+ port_return))# Metriques returns = np.diff(portfolio_values) / np.array(portfolio_values[:-1]) cum_returns = pd.Series(portfolio_values[1:], index=closes.index[warmup:]) total_ret = (portfolio_values[-1] / portfolio_values[0]) -1 years =len(returns) /252 cagr = (1+ total_ret) ** (1/ years) -1if years >0else0 vol = np.std(returns) * np.sqrt(252) sharpe = (cagr -0.03) / vol if vol >0.001else0 running_max = cum_returns.expanding().max() drawdown = (cum_returns - running_max) / running_max max_dd = drawdown.min()return {'cum': cum_returns,'sharpe': sharpe,'cagr': cagr,'max_dd': max_dd,'vol': vol,'final_value': portfolio_values[-1] }print("Fonction de backtest composite definie.")
Fonction de backtest composite definie.
4. Test des allocations
On teste différentes allocations FF/AW de 10/90 a 50/50.