Files

973 lines
46 KiB
Python
Raw Permalink Normal View History

2026-06-25 14:00:20 +03:00
# -*- coding: utf-8 -*-
"""
AHAD QUANT — Backtester CORRIGÉ v10-fixed (Forex Edition)
=============================================
Corrections v10-fixed vs v10 :
FIX A — PROFIT FACTOR mal calculé
AVANT : pf = avg_win / avg_loss → donne le ratio moyen gain/perte, PAS le PF
APRÈS : pf = sum(gains) / sum(|pertes|) → vrai Profit Factor standard
Impact : v10 affichait 1.15 au lieu du vrai ~9.38 (identique à v11)
FIX B — SHARPE biaisé par le compound sizing
AVANT : calculé sur les P&L absolus en $ → les trades tardifs (~$213)
pèsent 7x plus que les trades précoces (~$30), std() gonflé
APRÈS : calculé sur les rendements % (pnl / position_val) → chaque trade
est comparable indépendamment de la taille du compte
Impact : Sharpe de 13.5 → valeur réaliste selon win rate réel
FIX C — DEAD CODE supprimé (ligne eq = [...])
AVANT : eq = [balance + sum(pnl[:k]) - sum(pnl[:k]) for k in ...]
calcule balance + X - X = balance pour tout k, variable jamais utilisée
APRÈS : ligne supprimée
FIX D — DRAWDOWN calculé sur l'equity curve RÉELLE
AVANT : P&L appliqués séquentiellement trade par trade → ignore les pertes
simultanées (jusqu'à MAX_POSITIONS positions ouvertes en même temps)
APRÈS : equity_curve produite par run_backtest_corrected() transmise à
print_results() et utilisée directement pour le calcul du DD
FIX E — MARGE utilisée calculée sur les montants RÉELS
AVANT : already_used_margin = n_positions × balance_actuel × RISK_PER_TRADE
→ approximation fausse (les positions ont été ouvertes à des
niveaux de balance différents)
APRÈS : open_positions stocke (expiration, marge_réelle) → la marge
déjà engagée est la somme exacte des marges à l'ouverture
FIX F — TP_PCT_OVERRIDE synchronisé avec config.py (v32-fixed)
AVANT : TP_PCT_OVERRIDE = 0.022 (2.2%) alors que config.TAKE_PROFIT_PCT = 0.0075 (0.75%)
→ backtest 3× plus optimiste que le bot en production
APRÈS : TP_PCT_OVERRIDE = 0.0 → utilise toujours config.TAKE_PROFIT_PCT
Résultats backtest maintenant fidèles au comportement réel du bot.
Nouveauté v10 vs v9 (inchangé) :
Compound sizing activé, plafond $10,000/trade
Héritage v9 (inchangé) :
OOS strict 30%, TP 2.2%, MIN_CONFIDENCE 0.72, exclusion coins récents
Usage:
python backtest.py
"""
import json, os, pickle, time, sys
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from collections import defaultdict
from datetime import datetime, timezone
import numpy as np
sys.path.insert(0, os.path.dirname(__file__))
import config
from features import build_features, FEATURE_NAMES
import ensemble_core as _ens_core
# ── RL — MÊME filtre qu'en live (unified_brain.py), pour que le backtest
# teste le système RÉEL (ML+RL), pas juste le ML seul. Import optionnel :
# si stable-baselines3/gymnasium ne sont pas installés, le backtest reste
# utilisable en mode ML seul (comme avant), avec un avertissement explicite.
_RL_READY = False
try:
from rl_agent import get_rl_agent, PositionState
_RL_READY = True
except ImportError:
PositionState = None # type: ignore[assignment,misc]
# ─── Paramètres ──────────────────────────────────────────────────────────────
INITIAL_BALANCE = 100.0 # Forex: compte standard $100
LEVERAGE = config.LEVERAGE # synchro config.py / .env
RISK_PER_TRADE = config.RISK_PER_TRADE
STOP_LOSS_PCT = config.STOP_LOSS_PCT # synchro config.py / .env
TAKE_PROFIT_PCT = config.TAKE_PROFIT_PCT
MIN_CONFIDENCE = config.MIN_CONFIDENCE
MAX_POSITIONS = config.MAX_POSITIONS # synchro config.py / .env
FEE_RATE = config.FEE_RATE
# ── Slippage réaliste (FIX BUG #5) ──────────────────────────────────────────
# 1.5 pips sur les majors Forex (EUR/USD, GBP/USD) — valeur broker OANDA réaliste.
# Appliqué à l'entrée ET à la sortie (total : 3 pips par trade).
# Pour les JPY pairs (USD/JPY), 1 pip = 0.01 donc ajuster manuellement si besoin.
SLIPPAGE_PIPS = float(os.getenv("BACKTEST_SLIPPAGE_PIPS", "1.5"))
PIP_SIZE = 0.0001 # taille d'1 pip pour paires non-JPY
WARMUP = 50
# HISTORIQUE MINIMUM : paires avec moins de bougies que ce seuil sont exclues.
# FOREX yfinance H1 : marchés fermés ~65h/semaine → ~17 000 bougies = 2.7 ans
# de données réelles. Le seuil crypto (20 000) excluait TOUTES les paires Forex.
# 500 bougies H1 ≈ 3 semaines — minimum statistique pour un OOS strict 15%.
MIN_HISTORY_CANDLES = 500
# OUT-OF-SAMPLE : fraction des données réservée au backtest.
# Les (1 - OOS_RATIO) premières bougies ont servi à l'entraînement → on ne
# les touche pas. Doit correspondre à 1 - TRAIN_RATIO - VAL_RATIO de train.py
# (0.70 + 0.15 = 0.85 → OOS = 0.15 au minimum ; on prend 0.30 pour la marge).
OOS_RATIO = 0.15 # TEST set uniquement — 100% OOS propre
# FULL HISTORY MODE : si True, le backtest tourne sur TOUTES les bougies
# disponibles (pas seulement les 30% OOS finaux). Utile pour maximiser le
# nombre de trades simulés et avoir une vision complète de l'historique.
# ⚠️ Attention : inclut les bougies in-sample sur lesquelles le modèle a
# été entraîné → les métriques seront optimistes vs. un vrai OOS test.
USE_FULL_HISTORY = False # OOS strict 30% — seule mesure fiable du vrai edge live
# COMPOUND SIZING : si True, la taille de position est recalculée à chaque
# trade sur le solde courant (balance × RISK_PER_TRADE × LEVERAGE) au lieu
# d'être fixée à INITIAL_BALANCE × RISK_PER_TRADE × LEVERAGE.
# → L'effet de compounding exponentiel est activé : les gains s'accumulent.
# ⚠️ En live, bien que très profitable sur backtest, le compounding amplifie
# aussi les pertes. S'assurer que RISK_PER_TRADE est conservateur (≤ 3%).
COMPOUND_SIZING = config.COMPOUND_ENABLED # COMPOUND_ENABLED — intérêts composés activés : position grandit avec le solde
# PLAFOND DE POSITION : limite la taille max par trade en mode compound.
# Sans ce plafond, à $1M de solde une position = $1M × 3% × 10x = $300K
# → croissance exponentielle irréaliste (impossible en vrai sur un exchange).
# $10,000 = taille max raisonnable pour un compte retail en futures.
# Augmente ce plafond uniquement si tu trades avec un vrai gros capital.
MAX_POSITION_VALUE = config.CAPITAL_CAP_PER_TRADE # $ max par trade — CAPITAL_CAP_PER_TRADE (compound plafonné)
# OVERRIDE DU TAKE PROFIT : si > 0, remplace TAKE_PROFIT_PCT de config.py.
# ⚠️ BUG CORRIGÉ : l'ancienne valeur 0.022 (2.2%) était 3× supérieure à la valeur
# de production config.py (0.75%) → les résultats backtest ne correspondaient pas
# au comportement réel du bot.
# Valeur 0 = utilise TAKE_PROFIT_PCT de config.py / .env → cohérence garantie.
TP_PCT_OVERRIDE = 0.0 # 0 = utilise config.TAKE_PROFIT_PCT (synchronisé avec la prod)
# OVERRIDE DE LA CONFIANCE MINIMALE : si > 0, remplace MIN_CONFIDENCE de config.py/.env.
# Analyse OOS sur 500 trades :
# bucket 0.620.70 : WR=64.1% → destructeur de PF (20% du volume, quasi toutes les pertes nettes)
# bucket 0.700.75 : WR=80.2%
# bucket 0.750.80 : WR=81.7%
# bucket 0.800.90 : WR=90.9%
# Seuil 0.72 → exclut la zone 64%, garde uniquement les signals ≥80% WR.
# Volume -20%, PF estimé +4050%.
MIN_CONFIDENCE_OVERRIDE = 0.72 # 0 = utilise MIN_CONFIDENCE du .env / config.py
# POSITION SIZING FIXE : taille calculée sur le capital initial, pas sur le
# solde courant. Évite l'explosion par compounding géométrique en backtest.
# En live le bot peut utiliser un sizing dynamique, mais pour mesurer l'edge
# du modèle on veut une taille constante.
FIXED_POSITION_VAL = INITIAL_BALANCE * RISK_PER_TRADE * LEVERAGE
# Appliquer l'override TP si activé
_TP = TP_PCT_OVERRIDE if TP_PCT_OVERRIDE > 0 else TAKE_PROFIT_PCT
TAKE_PROFIT_PCT = _TP # shadowed pour le reste du fichier
# Appliquer l'override MIN_CONFIDENCE si activé
_MC = MIN_CONFIDENCE_OVERRIDE if MIN_CONFIDENCE_OVERRIDE > 0 else MIN_CONFIDENCE
MIN_CONFIDENCE = _MC # shadowed pour le reste du fichier
# Nouvelle limite : jamais plus de 50 % du solde en marge simultanément
MAX_MARGIN_USAGE = config.MAX_MARGIN_USAGE # fraction du solde (sans levier) — 20% max simultané
# TIMEOUT : si True, les trades non résolus (ni TP ni SL) sont clôturés au
# close de la bougie MAX_HOLD_CANDLES. Si False, les positions restent ouvertes
# indéfiniment (non recommandé en live).
TIMEOUT_ENABLED = config.TIMEOUT_ENABLED # fermeture forcée après MAX_HOLD_CANDLES bougies
MAX_HOLD_CANDLES = config.MAX_HOLD_CANDLES
# Risk manager simulé
CIRCUIT_BREAKER_LOSSES = config.CIRCUIT_BREAKER_LOSSES # 3
CIRCUIT_BREAKER_CANDLES = config.CIRCUIT_BREAKER_COOLDOWN // 3600 # dérivé de CIRCUIT_BREAKER_COOLDOWN (en secondes → bougies 1h)
MAX_DAILY_LOSS_PCT = config.MAX_DAILY_LOSS_PCT # 0.05 = 5 %
# ─── Chargement modèle ───────────────────────────────────────────────────────
def load_model():
if os.path.exists(config.ENSEMBLE_MODEL_PATH):
with open(config.ENSEMBLE_MODEL_PATH, "rb") as f:
return pickle.load(f)
with open(config.MODEL_PATH, "rb") as f:
raw = pickle.load(f)
m = raw["model"] if isinstance(raw, dict) else raw
return {"lgbm": m, "xgb": None, "rf": None, "meta": None, "scaler": None}
def load_candles(coin):
path = os.path.join(config.DATA_DIR, f"{coin}_1h.json")
if not os.path.exists(path):
return []
with open(path) as f:
return json.load(f)
# ─── Étape 1 : Pré-calcul vectorisé de TOUTES les features ──────────────────
def precompute_all_features(coins_data, btc_aligned):
"""
Appelle build_features() une seule fois par coin sur l'intégralité
des données → renvoie dict coin → np.ndarray (N, 62).
"""
features = {}
total = len(coins_data)
t0 = time.perf_counter()
for idx, (coin, candles) in enumerate(coins_data.items(), 1):
t1 = time.perf_counter()
window = [
{"o": c["o"], "h": c["h"], "l": c["l"], "c": c["c"], "v": c["v"]}
for c in candles
]
btc_closes = np.array([c["c"] for c in btc_aligned[-len(candles):]])
X = build_features(window, btc_closes=btc_closes)
features[coin] = np.nan_to_num(X, nan=0.0, posinf=0.0, neginf=0.0)
elapsed = time.perf_counter() - t1
remaining = elapsed * (total - idx)
print(
f" [{idx:2d}/{total}] {coin:8s}{X.shape[0]:,} bougies, {elapsed:.1f}s"
f" | reste ~{remaining:.0f}s"
)
total_time = time.perf_counter() - t0
print(f"\n Pré-calcul terminé en {total_time:.1f}s ({total_time/60:.1f} min)\n")
return features
# ─── Étape 2 : Batch prediction pour tout le tableau ────────────────────────
def batch_predict_all(model_data, features_dict):
"""
Prédit la probabilité haussière pour TOUS les candles d'un coup par coin.
Renvoie dict coin → np.ndarray (N,) de probabilités [0, 1].
Délègue à ensemble_core.predict_ensemble_batch() — la MÊME implémentation
que celle utilisée en live (unified_brain.py) et pendant l'entraînement
RL (rl_env.py). Corrige le point #1 du diagnostic (2ᵉ occurrence) :
l'ancienne version ici ne gérait JAMAIS les modèles DL (TFT/TGRU) — un
ensemble entraîné avec has_dl=True aurait donc été validé en backtest
avec un stacking à 3 modèles, puis exécuté en live avec un stacking à 5,
deux distributions de probabilités différentes pour le "même" modèle.
"""
probs = {}
for coin, X in features_dict.items():
result = _ens_core.predict_ensemble_batch(model_data, X)
probs[coin] = result[:, 0] # colonne 0 = proba_long
return probs
# ─── Étape 3 : Backtest corrigé ──────────────────────────────────────────────
def run_backtest_corrected(probs_dict, coins_data, min_len, features_dict=None):
"""
Simule le bot sur l'historique avec les 3 corrections + risk manager simulé.
Logique temporelle correcte :
- Bougie i se FERME → features[i] connues → probs[i] calculé
- On ENTRE au prix closes[i] (= clôture de la bougie i)
- TP/SL résolu sur les bougies i+1, i+2, ..., i+MAX_HOLD_CANDLES
`features_dict` (optionnel) : si fourni ET config.USE_RL_AGENT=true ET
stable-baselines3 est installé, le signal ML de chaque bougie est filtré
par le MÊME agent RL qu'en live (rl_agent.RLAgent.filter_signal()) —
boost si accord, override si RL très confiant et en désaccord, fallback
sinon. C'est ce qui rend ce backtest fidèle au système réel : ML+RL
comme un seul système, jamais le ML testé seul puis le RL en aveugle.
"""
rl_agent = None
if features_dict is not None and _RL_READY and getattr(config, "USE_RL_AGENT", False):
try:
rl_agent = get_rl_agent()
if not rl_agent.is_ready():
print(" ⚠️ RL activé (USE_RL_AGENT=true) mais agent non chargé "
"(rl_agent.zip absent ?) — backtest en ML seul.")
rl_agent = None
else:
print(" ✅ Filtre RL actif pour ce backtest (ML+RL unifiés, comme en live).")
except Exception as e:
print(f" ⚠️ RL activé mais erreur de chargement ({e}) — backtest en ML seul.")
rl_agent = None
elif getattr(config, "USE_RL_AGENT", False) and not _RL_READY:
print(" ⚠️ USE_RL_AGENT=true mais stable-baselines3/gymnasium non installés "
"— backtest en ML seul (pip install -r requirements.txt pour corriger).")
balance = INITIAL_BALANCE
trades = []
equity_curve = [balance]
peak_balance = balance
coins = list(coins_data.keys())
closes_arr = {c: np.array([x["c"] for x in coins_data[c]]) for c in coins}
highs_arr = {c: np.array([x["h"] for x in coins_data[c]]) for c in coins}
lows_arr = {c: np.array([x["l"] for x in coins_data[c]]) for c in coins}
# BUG 2 FIX — tracker les positions actives
# open_positions[coin] = (bougie_expiration, margin_réelle_bloquée)
open_positions: dict[str, tuple[int, float]] = {}
# Risk manager simulé
consecutive_losses = 0
circuit_breaker_until = 0 # indice de bougie
daily_pnl = 0.0
current_day = -1 # jour julien de la bougie courante
total_steps = min_len - WARMUP
progress_interval = max(1, total_steps // 20)
for i in range(WARMUP, min_len):
step = i - WARMUP
if step % progress_interval == 0:
pct = step / total_steps * 100
print(
f" [{pct:5.1f}%] bougie {i:,}/{min_len:,}"
f" balance ${balance:,.2f} trades {len(trades)}"
f" positions_ouvertes {len([c for c, v in open_positions.items() if v[0] > i])}"
)
# ── Nettoyer les positions expirées ──────────────────────────────────
open_positions = {c: v for c, v in open_positions.items() if v[0] > i}
# ── Jour calendaire simulé (chaque bougie = 1h) ──────────────────────
day_index = i // 24
if day_index != current_day:
current_day = day_index
daily_pnl = 0.0 # reset daily P&L
# ── Circuit breaker simulé ───────────────────────────────────────────
if i < circuit_breaker_until:
continue
# ── Daily loss limit ─────────────────────────────────────────────────
if balance > 0 and (daily_pnl / balance) <= -MAX_DAILY_LOSS_PCT:
continue
# BUG 3 FIX — compter les positions actuellement ouvertes
currently_open = len(open_positions)
available_slots = MAX_POSITIONS - currently_open
if available_slots <= 0:
continue
# BUG 3 FIX — calculer la marge déjà engagée
# FIX — on utilise la marge RÉELLE de chaque position ouverte (stockée à l'ouverture)
# plutôt qu'une approximation basée sur le balance actuel.
already_used_margin = sum(v[1] for v in open_positions.values())
available_margin = (balance if COMPOUND_SIZING else INITIAL_BALANCE) * MAX_MARGIN_USAGE - already_used_margin
if available_margin <= 0:
continue
# ── Générer les signaux pour cette bougie ────────────────────────────
candle_signals = []
for coin in coins:
# Skip si position déjà ouverte sur ce coin
if coin in open_positions:
continue
prob = probs_dict[coin][i]
if prob > MIN_CONFIDENCE:
signal = "long"
confidence = prob
elif prob < (1 - MIN_CONFIDENCE):
signal = "short"
confidence = 1 - prob
else:
continue
# ── Filtre RL — EXACTEMENT la même règle qu'en live
# (unified_brain.UnifiedBrain.decide()) : uniquement sur signal
# ML non-neutre, jamais de re-calibration silencieuse ici. ──
if rl_agent is not None:
try:
last_feat = features_dict[coin][i]
pos_state = PositionState(in_position=False, direction=0, balance_ratio=1.0)
rl_signal, rl_confidence, _rl_action = rl_agent.filter_signal(
ml_signal=signal, ml_confidence=confidence,
features=last_feat, position_state=pos_state,
)
signal, confidence = rl_signal, rl_confidence
if signal == "neutral":
continue
except Exception:
pass # fallback silencieux sur le signal ML, comme en live
# BUG 1 FIX — entry au close de la bougie i (prix connu à la clôture)
entry = closes_arr[coin][i]
if entry <= 0:
continue
# FIX BUG #5 — Slippage réaliste à l'entrée (1.5 pips)
slip = SLIPPAGE_PIPS * PIP_SIZE
if signal == "long":
entry = entry * (1 + slip) # achat : prix monte légèrement
else:
entry = entry * (1 - slip) # vente : prix descend légèrement
if signal == "long":
sl = entry * (1 - STOP_LOSS_PCT)
tp = entry * (1 + TAKE_PROFIT_PCT)
else:
sl = entry * (1 + STOP_LOSS_PCT)
tp = entry * (1 - TAKE_PROFIT_PCT)
# BUG 1 FIX — future commence à i+1 (bougie SUIVANTE, pas la même)
fut_start = i + 1
fut_end = min(i + 1 + MAX_HOLD_CANDLES, min_len)
if fut_start >= min_len:
continue # plus de données pour résoudre ce trade
future_h = highs_arr[coin][fut_start:fut_end]
future_l = lows_arr[coin][fut_start:fut_end]
future_c = closes_arr[coin][fut_start:fut_end]
if signal == "long":
sl_hit = np.where(future_l <= sl)[0]
tp_hit = np.where(future_h >= tp)[0]
else:
sl_hit = np.where(future_h >= sl)[0]
tp_hit = np.where(future_l <= tp)[0]
sl_idx = sl_hit[0] if len(sl_hit) > 0 else 999
tp_idx = tp_hit[0] if len(tp_hit) > 0 else 999
if sl_idx == tp_idx == 999:
if not TIMEOUT_ENABLED:
continue # position ignorée si timeout désactivé
result = "timeout"
exit_price = future_c[-1] if len(future_c) > 0 else entry
hold_dur = len(future_c)
elif tp_idx <= sl_idx:
result = "tp"
exit_price = tp
hold_dur = tp_idx + 1
else:
result = "sl"
exit_price = sl
hold_dur = sl_idx + 1
# FIX BUG #5 — Slippage réaliste à la sortie (1.5 pips)
if signal == "long":
exit_price = exit_price * (1 - slip) # vente : prix descend
else:
exit_price = exit_price * (1 + slip) # rachat : prix monte
if signal == "long":
pnl_pct = (exit_price - entry) / entry
else:
pnl_pct = (entry - exit_price) / entry
# COMPOUND_SIZING : taille recalculée sur le solde courant, plafonnée
if COMPOUND_SIZING:
position_val = min(
max(balance * RISK_PER_TRADE * LEVERAGE, 0.0),
MAX_POSITION_VALUE
)
else:
position_val = FIXED_POSITION_VAL
fee = position_val * FEE_RATE * 2
net_pnl = position_val * pnl_pct - fee
candle_signals.append({
"coin": coin,
"signal": signal,
"confidence": float(confidence),
"result": result,
"entry": float(entry),
"exit": float(exit_price),
"pnl": float(net_pnl),
"position_val": float(position_val),
"hold_candles": int(hold_dur),
"candle_idx": i,
})
# ── Sélectionner les meilleurs signaux dans la limite des slots ───────
candle_signals.sort(key=lambda x: x["confidence"], reverse=True)
margin_used_this_step = 0.0
for t in candle_signals:
if len(open_positions) >= MAX_POSITIONS:
break
margin_needed_check = (balance if COMPOUND_SIZING else INITIAL_BALANCE) * RISK_PER_TRADE
if margin_used_this_step + margin_needed_check > available_margin:
break
# Enregistrer la position comme ouverte jusqu'à sa bougie de clôture
# FIX — on stocke (expiration, margin_réelle) pour un calcul de marge exact
margin_this_trade = (balance if COMPOUND_SIZING else INITIAL_BALANCE) * RISK_PER_TRADE
close_at = i + 1 + t["hold_candles"]
open_positions[t["coin"]] = (close_at, margin_this_trade)
margin_used_this_step += margin_this_trade
# Appliquer le P&L
balance = max(0.0, balance + t["pnl"])
trades.append(t)
equity_curve.append(balance)
peak_balance = max(peak_balance, balance)
# ── Risk manager simulé ──────────────────────────────────────────
daily_pnl += t["pnl"]
if t["pnl"] < 0:
consecutive_losses += 1
if consecutive_losses >= CIRCUIT_BREAKER_LOSSES:
circuit_breaker_until = i + CIRCUIT_BREAKER_CANDLES
consecutive_losses = 0
pass # circuit breaker silencieux (trop verbeux en console)
else:
consecutive_losses = 0
if balance == 0.0:
print(f" 💀 Balance = 0 à la bougie {i} — simulation arrêtée")
return trades, equity_curve, (rl_agent is not None)
return trades, equity_curve, (rl_agent is not None)
# ─── Affichage des résultats ──────────────────────────────────────────────────
def print_results(trades, balance, equity_curve=None):
print("\n" + "=" * 62)
print(" RÉSULTATS DU BACKTEST (v10-fixed — OOS STRICT + COMPOUND + TP 2.2% + CONF 0.72)")
print("=" * 62)
if not trades:
print(" Aucun trade généré.")
return
wins = [t for t in trades if t["pnl"] > 0]
loss = [t for t in trades if t["pnl"] <= 0]
tps = [t for t in trades if t["result"] == "tp"]
sls = [t for t in trades if t["result"] == "sl"]
tmos = [t for t in trades if t["result"] == "timeout"]
longs = [t for t in trades if t["signal"] == "long"]
shts = [t for t in trades if t["signal"] == "short"]
total_pnl = balance - INITIAL_BALANCE
pnl_pct = total_pnl / INITIAL_BALANCE * 100
win_rate = len(wins) / len(trades) * 100 if trades else 0
# Drawdown max — calculé sur l'equity curve RÉELLE du backtest
# FIX — l'ancienne méthode appliquait les trades séquentiellement alors que
# plusieurs positions sont ouvertes simultanément : le vrai drawdown peut
# être plus élevé. On utilise l'equity_curve produite par run_backtest_corrected.
eq_for_dd = equity_curve if equity_curve and len(equity_curve) > 1 else None
if eq_for_dd:
peak = eq_for_dd[0]
max_dd = 0.0
for v in eq_for_dd:
peak = max(peak, v)
dd = (peak - v) / peak * 100 if peak > 0 else 0
max_dd = max(max_dd, dd)
else:
# Fallback séquentiel si equity_curve non fournie
peak = INITIAL_BALANCE
max_dd = 0.0
running = INITIAL_BALANCE
for t in trades:
running += t["pnl"]
peak = max(peak, running)
dd = (peak - running) / peak * 100 if peak > 0 else 0
max_dd = max(max_dd, dd)
avg_win = np.mean([t["pnl"] for t in wins]) if wins else 0
avg_loss = np.mean([t["pnl"] for t in loss]) if loss else 0
# FIX — vrai Profit Factor = somme des gains / somme des pertes
# (l'ancienne formule avg_win/avg_loss donnait le ratio moyen, pas le PF)
gross_profit = sum(t["pnl"] for t in wins)
gross_loss = abs(sum(t["pnl"] for t in loss))
pf = gross_profit / gross_loss if gross_loss > 0 else float("inf")
best = max(trades, key=lambda x: x["pnl"])
worst = min(trades, key=lambda x: x["pnl"])
avg_hold = np.mean([t["hold_candles"] for t in trades]) if trades else 0
# Sharpe (calculé sur les rendements % par trade, pas sur le P&L absolu)
# FIX — le P&L absolu est biaisé par le compound : les trades tardifs valent
# 1000x plus qu'au départ, ce qui gonfle artificiellement le Sharpe.
# On normalise chaque trade par la taille de position pour obtenir
# un rendement % comparable quelle que soit la taille du compte.
# Annualisation : ~24 trades/jour × 252 jours = 6048 périodes/an.
ret_series = np.array([
t["pnl"] / max(t.get("position_val", FIXED_POSITION_VAL), 1e-9)
for t in trades
])
sharpe = (ret_series.mean() / ret_series.std() * np.sqrt(6048)) \
if ret_series.std() > 0 else 0
# Top/flop coins
coin_pnl: dict[str, float] = {}
for t in trades:
coin_pnl[t["coin"]] = coin_pnl.get(t["coin"], 0) + t["pnl"]
top_coins = sorted(coin_pnl.items(), key=lambda x: x[1], reverse=True)
flop_coins = sorted(coin_pnl.items(), key=lambda x: x[1])
print(f"\n 💰 Balance initiale : ${INITIAL_BALANCE:>12,.2f}")
print(f" 💰 Balance finale : ${balance:>12,.2f}")
print(f" 📈 PnL total : ${total_pnl:>+12,.2f} ({pnl_pct:+.1f}%)")
print(f" 📉 Drawdown max : {max_dd:.1f}%")
print(f" 📐 Sharpe (approx.) : {sharpe:.2f}")
print(f" ⚖️ Profit Factor : {pf:.2f}")
# Compound sizing info
if COMPOUND_SIZING:
cap_balance = MAX_POSITION_VALUE / (RISK_PER_TRADE * LEVERAGE)
print(f" 🔄 Compound : ON — plafond atteint à ~${cap_balance:,.0f} de solde")
print(f"\n 📊 Trades total : {len(trades):,}")
print(f" ✅ Gagnants : {len(wins):,} ({win_rate:.1f}%)")
print(f" ❌ Perdants : {len(loss):,} ({100-win_rate:.1f}%)")
print(f" 🎯 TP atteints : {len(tps):,}")
print(f" 🛑 SL atteints : {len(sls):,}")
print(f" ⏱️ Timeouts : {len(tmos):,}")
print(f" 📗 Longs / 📕 Shorts : {len(longs):,} / {len(shts):,}")
print(f" ⌛ Durée moy. trade : {avg_hold:.1f} bougies")
print(f"\n 💵 Gain moyen : ${avg_win:>+10,.2f}")
print(f" 💵 Perte moyenne : ${avg_loss:>+10,.2f}")
print(f" 🏆 Meilleur trade : ${best['pnl']:>+10,.2f} ({best['coin']} {best['signal'].upper()})")
print(f" 💀 Pire trade : ${worst['pnl']:>+10,.2f} ({worst['coin']} {worst['signal'].upper()})")
print(f"\n 🏅 Top 5 coins:")
max_coin_pnl = abs(top_coins[0][1]) if top_coins else 1
for coin, pnl in top_coins[:5]:
bar = "█" * int(max(0, pnl) / max(1, max_coin_pnl) * 20)
print(f" {coin:8s} ${pnl:>+10,.2f} {bar}")
print(f"\n 💀 Flop 5 coins:")
for coin, pnl in flop_coins[:5]:
bar = "█" * int(max(0, -pnl) / max(1, max_coin_pnl) * 20)
print(f" {coin:8s} ${pnl:>+10,.2f} {bar}")
print("\n" + "=" * 62)
# ── Diagnostic ──────────────────────────────────────────────────────────
print("\n DIAGNOSTIC :")
if pnl_pct > 50:
print(" ✅ PnL > +50% — Fort sur historique, vérifie en paper")
elif pnl_pct > 20:
print(" ✅ PnL > +20% — Excellent")
elif pnl_pct > 5:
print(" 🟡 PnL +5% à +20% — Acceptable, passe en paper trading")
elif pnl_pct > 0:
print(" 🟠 PnL faiblement + — Augmente MIN_CONFIDENCE ou réduis levier")
else:
print(" ❌ PnL négatif — Ré-entraîne avec OPTUNA_TRIALS=50 dans train.py")
if max_dd < 15:
print(" ✅ DD < 15% — Très sûr pour 5x levier")
elif max_dd < 25:
print(" 🟡 DD 15-25% — Acceptable, surveille en paper trading")
else:
print(" ❌ DD > 25% — Réduis RISK_PER_TRADE ou LEVERAGE dans .env")
if win_rate > 52:
print(" ✅ Win rate > 52% — Excellent avec R:R 1:2")
elif win_rate > 45:
print(" 🟡 Win rate 45-52% — OK, le R:R compense")
else:
print(" ❌ Win rate < 45% — Augmente MIN_CONFIDENCE à 0.72+")
if sharpe > 1.5:
print(" ✅ Sharpe > 1.5 — Très bon rapport risque/rendement")
elif sharpe > 1.0:
print(" 🟡 Sharpe 1.0-1.5 — Correct")
else:
print(" 🟠 Sharpe < 1.0 — Stratégie volatile, réduis le levier")
print("=" * 62)
# ── Equity curve ASCII dans le terminal ──────────────────────────────────
W = 62
print("\n" + "=" * W)
print(" COURBE D\'ÉQUITÉ (terminal)")
print("=" * W)
# Rééchantillonner l'equity curve sur 50 points max
# Reconstruire l'equity curve proprement
# FIX — suppression du dead code : l'ancienne ligne calculait balance + X - X = balance
running = INITIAL_BALANCE
eq_vals = [running]
for t in trades:
running = max(0.0, running + t["pnl"])
eq_vals.append(running)
n_pts = min(50, len(eq_vals))
step = max(1, len(eq_vals) // n_pts)
sample = eq_vals[::step]
if eq_vals[-1] not in sample:
sample.append(eq_vals[-1])
eq_min = min(sample)
eq_max = max(sample)
rows = 8
print()
for row in range(rows, -1, -1):
thresh = eq_min + (eq_max - eq_min) * row / rows
if row == rows:
label = f"${eq_max:>10,.0f} │"
elif row == 0:
label = f"${eq_min:>10,.0f} │"
else:
label = f"{'':>11} │"
line = ""
for v in sample:
norm = (v - eq_min) / max(eq_max - eq_min, 1)
filled = norm * rows >= row
line += "█" if filled else " "
print(f" {label}{line}")
print(f" {'':>11}{'─'*len(sample)}")
print(f" {'':>12} début{'':>{max(0,len(sample)-12)}}fin")
# ── Distribution des trades par résultat ─────────────────────────────────
print("\n" + "=" * W)
print(" DISTRIBUTION DES TRADES")
print("=" * W)
total_t = len(trades)
tp_pct_t = len(tps) / total_t * 100 if total_t else 0
sl_pct_t = len(sls) / total_t * 100 if total_t else 0
tmo_pct_t = len(tmos) / total_t * 100 if total_t else 0
bar_w = 30
def pct_bar(pct, char="█"):
n = int(pct / 100 * bar_w)
return char * n + "░" * (bar_w - n)
print(f" 🎯 TP {pct_bar(tp_pct_t)} {len(tps):>6,} ({tp_pct_t:5.1f}%)")
print(f" 🛑 SL {pct_bar(sl_pct_t)} {len(sls):>6,} ({sl_pct_t:5.1f}%)")
print(f" ⏱ Timeout {pct_bar(tmo_pct_t)} {len(tmos):>6,} ({tmo_pct_t:5.1f}%)")
# ── PnL par coin (barres horizontales) ───────────────────────────────────
print("\n" + "=" * W)
print(" PnL PAR COIN (tous les coins)")
print("=" * W)
all_coins = sorted(coin_pnl.items(), key=lambda x: x[1], reverse=True)
max_abs = max(abs(p) for _, p in all_coins) if all_coins else 1
for coin, pnl in all_coins:
bar_len = int(abs(pnl) / max_abs * 20)
sign = "+" if pnl >= 0 else "-"
bar = ("█" if pnl >= 0 else "░") * bar_len
print(f" {coin:6s} {sign}${abs(pnl):>10,.0f} {bar}")
print("\n" + "=" * W + "\n")
# ─── Sauvegarde des résultats ─────────────────────────────────────────────────
def save_results(trades, balance, equity_curve, run_timestamp=None, rl_filter_active=False):
"""
Sauvegarde les résultats du backtest dans backtest_results.json.
⚠️ ÉCRASE TOUJOURS le fichier précédent — un seul fichier de résultats,
mis à jour à chaque nouveau backtest (pas d'accumulation).
Format identique à l'ancien backtest_results.json pour la compatibilité
avec dashboard.py / web_ui.py.
"""
if not trades:
print(" [SAVE] Aucun trade — fichier non écrasé.")
return
wins = [t for t in trades if t["pnl"] > 0]
loss = [t for t in trades if t["pnl"] <= 0]
tps = [t for t in trades if t["result"] == "tp"]
sls = [t for t in trades if t["result"] == "sl"]
tmos = [t for t in trades if t["result"] == "timeout"]
total_pnl = balance - INITIAL_BALANCE
pnl_pct = total_pnl / INITIAL_BALANCE * 100
win_rate = len(wins) / len(trades) * 100 if trades else 0
# Drawdown sur equity curve réelle
eq = equity_curve if equity_curve and len(equity_curve) > 1 else None
if eq:
peak, max_dd = eq[0], 0.0
for v in eq:
peak = max(peak, v)
dd = (peak - v) / peak * 100 if peak > 0 else 0
max_dd = max(max_dd, dd)
else:
peak, max_dd, running = INITIAL_BALANCE, 0.0, INITIAL_BALANCE
for t in trades:
running += t["pnl"]
peak = max(peak, running)
dd = (peak - running) / peak * 100 if peak > 0 else 0
max_dd = max(max_dd, dd)
gross_profit = sum(t["pnl"] for t in wins)
gross_loss = abs(sum(t["pnl"] for t in loss))
pf = gross_profit / gross_loss if gross_loss > 0 else float("inf")
ret_series = np.array([
t["pnl"] / max(t.get("position_val", FIXED_POSITION_VAL), 1e-9)
for t in trades
])
sharpe = (ret_series.mean() / ret_series.std() * np.sqrt(6048)) \
if ret_series.std() > 0 else 0
avg_hold = float(np.mean([t["hold_candles"] for t in trades])) if trades else 0
coin_pnl: dict[str, float] = {}
for t in trades:
coin_pnl[t["coin"]] = coin_pnl.get(t["coin"], 0) + t["pnl"]
result = {
# ── Metadata ──────────────────────────────────────────────────────
"backtest_version": "v10-fixed",
"run_timestamp": run_timestamp or datetime.now(timezone.utc).isoformat(),
"rl_filter_active": bool(rl_filter_active),
"system": "ML+RL unifié" if rl_filter_active else "ML seul (RL indisponible)",
"corrections": [
"profit_factor_fixed",
"sharpe_pct_based",
"dead_code_removed",
"drawdown_real_equity",
"margin_real_values",
],
# ── Paramètres utilisés ───────────────────────────────────────────
"min_history_candles": MIN_HISTORY_CANDLES,
"oos_ratio": OOS_RATIO,
"use_full_history": USE_FULL_HISTORY,
"compound_sizing": COMPOUND_SIZING,
"tp_pct_used": TAKE_PROFIT_PCT,
"sl_pct_used": STOP_LOSS_PCT,
"min_confidence_used": MIN_CONFIDENCE,
"max_position_value": MAX_POSITION_VALUE,
"fixed_position_val": FIXED_POSITION_VAL,
# ── Métriques principales ─────────────────────────────────────────
"balance_initial": INITIAL_BALANCE,
"balance_final": float(balance),
"total_pnl": float(total_pnl),
"pnl_pct": float(pnl_pct),
"win_rate": float(win_rate),
"max_drawdown": float(max_dd),
"sharpe": float(sharpe),
"profit_factor": float(pf),
# ── Compteurs trades ──────────────────────────────────────────────
"total_trades": len(trades),
"tp_count": len(tps),
"sl_count": len(sls),
"timeout_count": len(tmos),
"avg_hold_candles": float(avg_hold),
# ── Config active ─────────────────────────────────────────────────
"config": {
"leverage": LEVERAGE,
"risk_per_trade": RISK_PER_TRADE,
"stop_loss_pct": STOP_LOSS_PCT,
"take_profit_pct": TAKE_PROFIT_PCT,
"min_confidence": MIN_CONFIDENCE,
"max_positions": MAX_POSITIONS,
"max_margin_usage": MAX_MARGIN_USAGE,
},
# ── PnL par coin ──────────────────────────────────────────────────
"coin_pnl": {k: float(v) for k, v in coin_pnl.items()},
# ── 500 derniers trades pour le dashboard ─────────────────────────
"trades_last_500": trades[-500:],
# ── Equity curve rééchantillonnée sur 500 points max ──────────────
"equity_curve_sampled": (
equity_curve[:: max(1, len(equity_curve) // 500)]
if equity_curve else []
),
}
out_path = os.path.join(os.path.dirname(__file__), "backtest_results.json")
# Écriture atomique : on écrit d'abord dans un fichier temporaire,
# puis on le renomme → évite un fichier corrompu en cas d'interruption.
tmp_path = out_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(result, f, indent=2, ensure_ascii=False)
os.replace(tmp_path, out_path) # atomique sur Linux/Windows
size_kb = os.path.getsize(out_path) / 1024
print(f"\n 💾 Résultats sauvegardés → backtest_results.json ({size_kb:.0f} KB)")
print(f" Run : {result['run_timestamp']}")
print(f" {len(trades):,} trades | balance ${balance:,.2f} | PnL {pnl_pct:+.1f}%")
# ─── Main ─────────────────────────────────────────────────────────────────────
def main():
t_global = time.perf_counter()
print("=" * 62)
print(" AHAD QUANT — Backtester v10-fixed — OOS STRICT + COMPOUND + R:R 1:1.47")
print(" (OOS strict 30% + compound sizing + TP 2.2% + confiance 0.72)")
print("=" * 62)
print(
f"\n Config : levier {LEVERAGE}x | SL {STOP_LOSS_PCT*100:.1f}%"
f" | TP {TAKE_PROFIT_PCT*100:.1f}% | confiance > {MIN_CONFIDENCE*100:.0f}%"
f" | margin_max {MAX_MARGIN_USAGE*100:.0f}%"
f" | compound {'ON 🚀' if COMPOUND_SIZING else 'OFF'}"
f" | cap ${MAX_POSITION_VALUE:,.0f}/trade"
)
# ── Chargement ────────────────────────────────────────────────────────────
print("\n[1/4] Chargement des données...")
# Paire de référence pour la corrélation (EURUSD = paire dominante Forex)
_ref_pair = "EURUSD"
btc_candles = load_candles(_ref_pair)
if not btc_candles:
print(f" [ERREUR] {_ref_pair}_1h.json introuvable. Lance download_data.py d'abord.")
return
coins_data: dict[str, list] = {}
excluded = []
for coin in config.COINS:
c = load_candles(coin)
if len(c) >= MIN_HISTORY_CANDLES:
coins_data[coin] = c
else:
excluded.append(f"{coin}({len(c)}b)")
if excluded:
print(f" ⚠️ Paires exclues (historique insuffisant < {MIN_HISTORY_CANDLES:,} bougies) :")
print(f" {', '.join(excluded)}")
if not coins_data:
print(" [ERREUR] Aucune donnée trouvée dans data/. Lance download_data.py (Forex).")
return
# Aligner toutes les séries sur la même longueur
min_len = min(len(c) for c in coins_data.values())
min_len = min(min_len, len(btc_candles))
for coin in coins_data:
coins_data[coin] = coins_data[coin][-min_len:]
btc_aligned = btc_candles[-min_len:]
# ── Découpage Out-Of-Sample (ou historique complet) ─────────────────────
if USE_FULL_HISTORY:
# MODE FULL HISTORY : toutes les bougies disponibles sont utilisées.
# Aucune donnée n'est exclue — le backtest maximise le nombre de trades.
# ⚠️ Les bougies in-sample (vues à l'entraînement) sont incluses :
# les métriques sont donc optimistes par rapport au vrai live.
oos_start = 0
print(f" {len(coins_data)} coins | {min_len:,} bougies total "
f"| MODE FULL HISTORY (toutes les bougies) "
f"| OOS strict désactivé")
else:
# MODE OOS STRICT : on ne garde QUE les dernières OOS_RATIO bougies.
# train.py : TRAIN=70%, VAL=15%, TEST=15% → OOS_RATIO=0.30 englobe
# entièrement le set de test + une marge de sécurité supplémentaire.
oos_start = int(min_len * (1 - OOS_RATIO))
for coin in coins_data:
coins_data[coin] = coins_data[coin][oos_start:]
btc_aligned = btc_aligned[oos_start:]
min_len = min(len(c) for c in coins_data.values())
n_total_candles = min_len + oos_start
print(f" {len(coins_data)} coins | {n_total_candles:,} bougies total "
f"| OOS : {min_len:,} bougies ({OOS_RATIO*100:.0f}% finaux) "
f"| In-sample ignoré : {oos_start:,} bougies")
# ── Pré-calcul features ───────────────────────────────────────────────────
print(f"\n[2/4] Pré-calcul des features (1 appel / coin)...")
print(f" Estimation : ~{len(coins_data) * 17:.0f}s ({len(coins_data) * 17/60:.1f} min)\n")
features_dict = precompute_all_features(coins_data, btc_aligned)
# ── Batch prediction ──────────────────────────────────────────────────────
print("[3/4] Prédiction batch (tout le tableau d'un coup)...")
t_pred = time.perf_counter()
model_data = load_model()
probs_dict = batch_predict_all(model_data, features_dict)
print(f" Terminé en {time.perf_counter()-t_pred:.1f}s\n")
# ── Backtest ──────────────────────────────────────────────────────────────
print(f"[4/4] Simulation backtest corrigée ({min_len - WARMUP:,} steps)...\n")
trades, equity_curve, rl_filter_active = run_backtest_corrected(
probs_dict, coins_data, min_len, features_dict=features_dict
)
# ── Résultats ─────────────────────────────────────────────────────────────
final_balance = equity_curve[-1] if equity_curve else INITIAL_BALANCE
run_ts = datetime.now(timezone.utc).isoformat()
print_results(trades, final_balance, equity_curve)
# ── Sauvegarde — écrase toujours le backtest précédent ────────────────
save_results(trades, final_balance, equity_curve, run_timestamp=run_ts, rl_filter_active=rl_filter_active)
total_time = time.perf_counter() - t_global
print(f" Temps total : {total_time:.0f}s ({total_time/60:.1f} min)")
if __name__ == "__main__":
main()