Objectif: Simuler les signaux alpha et la construction de portefeuille pour iterer rapidement sur les paramètres (allocations, risk management) avant backtest cloud.
Données: yfinance (exécution locale). Les prix peuvent differer legerement de QC (ajustements), mais suffisant pour explorer les paramètres.
0. Chargement des données
import pandas as pdimport numpy as npqb = QuantBook()trend_tickers = ["AAPL", "MSFT", "GOOGL", "AMZN", "NVDA","JPM", "V", "MA", "UNH", "JNJ","XOM", "CVX", "HD", "PG", "KO"]aw_tickers = ["SPY", "IEF", "GLD", "XLP"]aw_target = {"SPY": 0.30, "IEF": 0.30, "GLD": 0.30, "XLP": 0.10}all_tickers =list(set(trend_tickers + aw_tickers))symbols = {}for t in all_tickers: symbols[t] = qb.add_equity(t, Resolution.DAILY).symbolhistory = qb.history(list(symbols.values()), datetime(2014, 1, 1), datetime(2026, 3, 1), Resolution.DAILY)print(f"History: {history.shape}")# Check which tickers actually have dataifnot history.empty: available_symbols = history.index.get_level_values(0).unique() symbol_to_ticker = {str(v): k for k, v in symbols.items()} available_tickers = [symbol_to_ticker.get(str(s), str(s)) for s in available_symbols]print(f"Available tickers in data: {sorted(available_tickers)}")# Filter to only available tickers trend_tickers = [t for t in trend_tickers if t in available_tickers] aw_tickers = [t for t in aw_tickers if t in available_tickers] aw_target = {k: v for k, v in aw_target.items() if k in aw_tickers}# Re-normalize aw_target weights if some are missing total_aw =sum(aw_target.values())if total_aw >0and total_aw !=1.0: aw_target = {k: v / total_aw for k, v in aw_target.items()}print(f"Trend tickers available: {len(trend_tickers)}/{len(trend_tickers)} -> {trend_tickers}")print(f"AW tickers available: {aw_tickers}")print(f"AW target (renormalized): {aw_target}")
History: (9171, 5)
Available tickers in data: ['AAPL', 'GOOGL', 'SPY']
Trend tickers available: 2/2 -> ['AAPL', 'GOOGL']
AW tickers available: ['SPY']
AW target (renormalized): {'SPY': 1.0}
Pivot de la série ‘close’ en DataFrame large, avec remapping des colonnes Symbol → ticker pour Framework_Composite_TrendWeather.
# Build close price matrixcloses = history["close"].unstack(level=0)sym_to_ticker = {str(v): k for k, v in symbols.items()}closes.columns = [sym_to_ticker.get(str(c), str(c)) for c in closes.columns]# Keep only available tickersall_available = [t for t in (trend_tickers + aw_tickers) if t in closes.columns]closes = closes[all_available]closes = closes.sort_index().dropna(how="all")# Daily returnsreturns = closes.pct_change().fillna(0)print(f"Closes: {closes.shape[0]} days x {closes.shape[1]} tickers")print(f"Period: {closes.index[0].date()} to {closes.index[-1].date()}")print(f"Columns: {list(closes.columns)}")
Closes: 3057 days x 3 tickers
Period: 2014-01-02 to 2026-02-27
Columns: ['AAPL', 'GOOGL', 'SPY']
1. Signaux TrendStocks Alpha
Per stock: Price > SMA200 AND EMA20 > EMA50 -> bullish (1), sinon 0. Rebalancement hebdomadaire (lundi).
# Indicateurs techniquesiflen(trend_tickers) >0: available_trend = [t for t in trend_tickers if t in closes.columns] sma200 = closes[available_trend].rolling(200).mean() ema20 = closes[available_trend].ewm(span=20, adjust=False).mean() ema50 = closes[available_trend].ewm(span=50, adjust=False).mean()# Signal quotidien trend_signal = ((closes[available_trend] > sma200) & (ema20 > ema50)).astype(int)# Resample hebdomadaire (lundi) puis forward-fill trend_weekly = trend_signal.resample("W-MON").last().reindex(closes.index, method="ffill")# Trim to backtest start (after 200-day warmup) start ="2015-01-01" trend_weekly = trend_weekly.loc[start:] n_bullish = trend_weekly.sum(axis=1)print(f"Stocks bullish: mean={n_bullish.mean():.1f}, min={n_bullish.min()}, max={n_bullish.max()}")print(f"Pct all cash (0 bullish): {(n_bullish ==0).mean():.1%}")print(f"Pct fully invested ({len(available_trend)}): {(n_bullish ==len(available_trend)).mean():.1%}") trend_tickers = available_trend # Update to only availableelse: trend_signal = pd.DataFrame(index=closes.index) trend_weekly = pd.DataFrame(index=closes.index)print("WARNING: No trend tickers available in Docker data.")
On combine les poids des deux stratégies avec des slices de capital configurables.
def build_composite_weights(trend_weekly, closes, trend_tickers, aw_tickers, aw_target, trend_slice=0.50, aw_slice=0.50, start="2015-01-01"):"""Build daily weight matrix for the composite portfolio.""" idx = closes.loc[start:].index all_t =list(set(trend_tickers + aw_tickers)) weights = pd.DataFrame(0.0, index=idx, columns=all_t)# TrendStocks: equal weight among bullish stocks tw = trend_weekly.reindex(idx, method="ffill")for date in idx: bullish_mask = tw.loc[date] n = bullish_mask.sum()if n >0:for t in trend_tickers:if bullish_mask.get(t, 0) ==1: weights.loc[date, t] += trend_slice / n# AllWeather: static weightsfor t in aw_tickers: weights[t] += aw_target[t] * aw_slicereturn weightsweights = build_composite_weights(trend_weekly, closes, trend_tickers, aw_tickers, aw_target)print(f"Total weight: mean={weights.sum(axis=1).mean():.3f}, "f"min={weights.sum(axis=1).min():.3f}, max={weights.sum(axis=1).max():.3f}")print(f"\nCash moyen: {1- weights.sum(axis=1).mean():.1%}")
Total weight: mean=0.767, min=0.500, max=1.000
Cash moyen: 23.3%
3. Simulation de performance
Backtest simplifie: portefeuille rebalance quotidiennement selon les poids. Pas de couts de transaction dans cette simulation (le backtest cloud les inclut).
Modifier les paramètres dans les sections 4 et 5 pour explorer: - Allocation optimale TrendStocks/AllWeather - Niveau de trailing stop optimal - Impact de l’ajout de MaxDD per security
Une fois les meilleurs paramètres identifies, les reporter dans le backtest cloud.
8. Itération 2: Rebalancing frequency + fees estimation
Gap analysis: fees + slippage explain part of the gap. Explore: - Bi-weekly rebalancing (reduce trades) - Monthly rebalancing - Fee-adjusted simulation (5bps per trade) - Allocation 40/60 vs 50/50 vs 60/40
# Rebalancing frequency impactdef build_weights_rebal(trend_signal, closes, trend_tickers, aw_tickers, aw_target, trend_slice=0.50, aw_slice=0.50, freq="W-MON", start="2015-01-01"):"""Build weights with configurable rebalancing frequency.""" tw = trend_signal.resample(freq).last().reindex(closes.index, method="ffill") tw = tw.loc[start:] idx = closes.loc[start:].index all_t =list(set(trend_tickers + aw_tickers)) weights = pd.DataFrame(0.0, index=idx, columns=all_t) tw_aligned = tw.reindex(idx, method="ffill")for date in idx: bullish_mask = tw_aligned.loc[date] n = bullish_mask.sum()if n >0:for t in trend_tickers:if bullish_mask.get(t, 0) ==1: weights.loc[date, t] += trend_slice / nfor t in aw_tickers: weights[t] += aw_target[t] * aw_slicereturn weightsdef simulate_with_fees(weights, returns, fee_bps=5, start="2015-01-01"):"""Simulate with transaction cost estimate.""" r = returns.loc[start:] w = weights.reindex(r.index, method="ffill").fillna(0) common = w.columns.intersection(r.columns) w = w[common] r = r[common]# Estimate turnover as sum of absolute weight changes turnover = w.diff().abs().sum(axis=1).fillna(0) fee_drag = turnover * fee_bps /10000 port_ret = (w * r).sum(axis=1) - fee_drag equity = (1+ port_ret).cumprod() *100000return port_ret, equity, turnover# Compare rebalancing frequenciesfreq_results = []for freq, label in [("W-MON", "Weekly"), ("2W-MON", "Bi-weekly"), ("MS", "Monthly")]: w = build_weights_rebal(trend_signal, closes, trend_tickers, aw_tickers, aw_target, freq=freq)# Without fees r_nf, eq_nf = simulate_portfolio(w, returns) m_nf = calc_metrics(r_nf, eq_nf, f"{label} (no fees)")# With fees (5bps) r_f, eq_f, to = simulate_with_fees(w, returns, fee_bps=5) m_f = calc_metrics(r_f, eq_f, f"{label} (5bps)") m_f["Turnover"] =f"{to.sum():.0f} trades-eq" freq_results.append(m_nf) freq_results.append(m_f)print(pd.DataFrame(freq_results).to_string(index=False))
Strategy CAGR Vol Sharpe MaxDD Turnover
Weekly (no fees) 10.93% 13.06% 0.684 -23.53% NaN
Weekly (5bps) 10.80% 13.06% 0.674 -23.55% 26 trades-eq
Bi-weekly (no fees) 10.01% 12.80% 0.626 -23.53% NaN
Bi-weekly (5bps) 9.92% 12.80% 0.619 -23.55% 19 trades-eq
Monthly (no fees) 16.13% 13.21% 1.070 -23.93% NaN
Monthly (5bps) 16.06% 13.21% 1.064 -23.94% 15 trades-eq
Balayage fin des allocations tendance/all-weather avec prise en compte des frais de transaction dans Framework_Composite_TrendWeather.
# Fine-grained allocation grid with feesalloc_fee_results = []for trend_pct in [30, 40, 50, 60, 70]: aw_pct =100- trend_pctfor freq, flabel in [("W-MON", "W"), ("2W-MON", "2W"), ("MS", "M")]: w = build_weights_rebal(trend_signal, closes, trend_tickers, aw_tickers, aw_target, trend_slice=trend_pct/100, aw_slice=aw_pct/100, freq=freq) r_f, eq_f, to = simulate_with_fees(w, returns, fee_bps=5) m = calc_metrics(r_f, eq_f, f"T{trend_pct}/{flabel}") m["Turnover"] =f"{to.mean()*252:.1%}" alloc_fee_results.append(m)print(pd.DataFrame(alloc_fee_results).to_string(index=False))
Comparer les résultats ci-dessus pour identifier: - La frequence de rebalancement optimale (hebdo vs bi-hebdo vs mensuel) - L allocation optimale avec frais inclus - Le delta entre simulation avec frais et le backtest cloud v1.1