import xgboost as xgb
def backtest_xgboost_strategy(closes, volumes, highs, lows,
train_period=252, rebalance_freq=5, refit_every=25):
"""Backtest la strategie XGBoost (archi cross-sectionnelle, #8759).
Refit periodique (tous les 'refit_every' rebalances ~ trimestriel) plutot qu'a
chaque rebalance : borne le runtime (Prong-B, voir C955b-L) et reste realiste
(on ne re-entraine pas un modele de production tous les 5 jours). Le modele est
re-entraine sur une fenetre glissante de 'train_period' barres.
"""
portfolio_value = 100000
positions = {}
model = None
scaler = None
rebalance_count = 0
# Forward-fill les prix : le frame 'closes' large (unstack de 14 tickers) contient
# des NaN la ou un ticker manquait une barre a une date donnee (jours boursiers
# differents, actions corporate). Une seule NaN sur une position detenue -> NaN
# dans portfolio_value qui se propage pour toujours. Forward-fill = mark-to-market
# standard entre rebalances (honnete, pas de fabrication de rendement).
# Forward-fill + back-fill les prix : le frame 'closes' large (unstack de 14 tickers)
# contient des NaN la ou un ticker manquait une barre a une date donnee (jours boursiers
# differents, actions corporate). ffill couvre les trous interieurs, bfill les NaN de tete
# (un ticker commence plus tard -> pas de valeur anterieure a propager). Une seule NaN sur
# une position detenue -> portfolio_value NaN qui se propage pour toujours. Mark-to-market
# standard entre rebalances (honnete, pas de fabrication de rendement).
closes = closes.ffill().bfill().dropna(how='all')
volumes = volumes.reindex(closes.index).ffill().bfill()
highs = highs.reindex(closes.index).ffill().bfill()
lows = lows.reindex(closes.index).ffill().bfill()
for i in range(train_period, len(closes) - 50, rebalance_freq):
# Refit periodique (ou premier passage)
if model is None or rebalance_count % refit_every == 0:
train_features = calculate_xgb_features(
closes.iloc[i-train_period:i],
volumes.iloc[i-train_period:i],
highs.iloc[i-train_period:i],
lows.iloc[i-train_period:i]
)
X_train, y_train = prepare_training_data(
train_features, closes.iloc[i-train_period:i]
)
if X_train is None or len(X_train) < 50:
rebalance_count += 1
continue
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
model = xgb.XGBRegressor(
n_estimators=100,
max_depth=5,
learning_rate=0.05,
random_state=42,
verbosity=0,
tree_method="hist",
n_jobs=1,
)
model.fit(X_train_scaled, y_train)
# Predire pour chaque ticker (schema 24 features generiques, #8759)
predictions = {}
for ticker in tickers:
recent_features = calculate_xgb_features(
closes[[ticker]].iloc[i-50:i],
volumes[[ticker]].iloc[i-50:i],
highs[[ticker]].iloc[i-50:i],
lows[[ticker]].iloc[i-50:i]
)
if len(recent_features) == 0:
continue
# Strip le prefixe -> schema generique 24 features attendu par le modele
generic_map = {c: c[len(ticker) + 1:] for c in recent_features.columns
if c.startswith(f'{ticker}_')}
if not generic_map:
continue
X_pred = recent_features.iloc[-1:][list(generic_map.keys())].rename(columns=generic_map)
# Nettoyer inf/nan avant predict : calculate_xgb_features finit par .fillna(0) qui
# ne retire PAS inf (bb_position -> inf sur fenetre plate, C942-L/C949-L : inf != nan).
# inf -> scaler.transform -> nan -> predict -> nan -> portfolio_value corrompu.
X_pred = X_pred.replace([np.inf, -np.inf], np.nan).fillna(0)
X_pred_scaled = scaler.transform(X_pred)
pred = model.predict(X_pred_scaled)[0]
if np.isfinite(pred):
predictions[ticker] = pred
# Vendre positions
for ticker, qty in positions.items():
portfolio_value += qty * closes[ticker].iloc[i]
positions = {}
# Acheter top 5
sorted_preds = sorted(predictions.items(), key=lambda x: x[1], reverse=True)
position_size = portfolio_value / 5
count = 0
for ticker, pred in sorted_preds:
if pred > 0.002 and count < 5:
buy_price = closes[ticker].iloc[i]
qty = position_size / buy_price
positions[ticker] = qty
portfolio_value -= qty * buy_price
count += 1
rebalance_count += 1
# Valeur finale
final_value = portfolio_value
for ticker, qty in positions.items():
final_value += qty * closes[ticker].iloc[-1]
return {
'initial': 100000,
'final': final_value,
'return': (final_value - 100000) / 100000
}
if closes is None:
print("Data not available - skipping backtest.")
bt_results = None
else:
# Executer backtest (archi cross-sectionnelle, #8759 -- plus de graceful skip).
bt_results = backtest_xgboost_strategy(closes, volumes, highs, lows)
print(f"\nBacktest Results:")
if bt_results is not None:
print(f"Return: {bt_results['return']:.2%}")
# Baseline buy-and-hold equipondere sur la MEME fenetre / le MEME univers
# (recette ai-01 c.45, #8774 CHANGES_REQUESTED). Sans ce comparateur, un
# rendement isole laisse croire que le modele "marche" alors que le R2
# walk-forward est NEGATIF (-0.097 < prediction de la moyenne) : le
# rendement vient de la structure long-only top-5 + du beta du marche tech
# 2020-2024, pas du signal du modele. Le comparateur rend cela lisible.
bh = (closes.ffill().iloc[-1] / closes.ffill().iloc[0] - 1).mean()
print(f"Buy & hold (equipondere, meme univers/fenetre) : {bh:.2%}")
print(f"Ecart strategie - buy&hold : {bt_results['return'] - bh:+.2%}")