Files
2026-06-25 14:00:20 +03:00

973 lines
46 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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()