973 lines
46 KiB
Python
973 lines
46 KiB
Python
# -*- 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.62–0.70 : WR=64.1% → destructeur de PF (20% du volume, quasi toutes les pertes nettes)
|
||
# bucket 0.70–0.75 : WR=80.2%
|
||
# bucket 0.75–0.80 : WR=81.7%
|
||
# bucket 0.80–0.90 : WR=90.9%
|
||
# Seuil 0.72 → exclut la zone 64%, garde uniquement les signals ≥80% WR.
|
||
# Volume -20%, PF estimé +40–50%.
|
||
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()
|