from dataclasses import dataclass from types import SimpleNamespace import numpy as np import pandas as pd from strategies import REGISTRY from risk.manager import RiskManager from utils.indicators import atr WINDOW = 320 # barres passées données à la stratégie (assez pour EMA200) @dataclass class Pos: type: int # 0 buy, 1 sell (même convention que MT5/RiskManager) price_open: float sl: float tp: float lots: float open_time: object equity_before: float risk_money: float def run_backtest(df, strat_name, params, tf, symbol, spec, risk_cfg, bt, initial=None, start_idx=None): """Simulation barre par barre. Signal à la clôture de la barre i -> exécution à l'ouverture de i+1. Bid = prix du graphique; Ask = bid + spread. Si SL et TP sont touchés dans la même barre, le SL gagne.""" strat = REGISTRY[strat_name](tf, dict(params)) rm = RiskManager(risk_cfg) n = len(df) o, h, l, c = (df[k].to_numpy(float) for k in ("open", "high", "low", "close")) times = pd.DatetimeIndex(df["time"]) a_s = atr(df, 14) atr_arr = a_s.to_numpy() atr_mean = a_s.rolling(100).mean().to_numpy() point = spec["point"] spread = spec["spread_points"] * point slip = bt.get("slippage_points", 0) * point comm = bt.get("commission_per_lot", 0.0) flt = bt.get("apply_filters", True) fcfg = bt.get("_filters", {}) def vpu(price): # USD par 1.0 de variation de prix et par lot return spec["contract_size"] if spec["usd_quote"] else spec["contract_size"] / price balance = float(initial if initial is not None else bt["initial_balance"]) init_bal = balance start = max(start_idx if start_idx is not None else WINDOW, WINDOW) eq_vals, eq_times, trades = [], [], [] pos, pending, dead = None, None, False cur_day, day_start, day_halt, peak = None, balance, False, balance def close_pos(i, exit_price, reason): nonlocal balance, pos mv = (exit_price - pos.price_open) if pos.type == 0 else (pos.price_open - exit_price) pnl = mv * pos.lots * vpu(exit_price) - comm * pos.lots balance += pnl trades.append({"entry_time": pos.open_time, "exit_time": times[i], "side": "buy" if pos.type == 0 else "sell", "lots": pos.lots, "entry": pos.price_open, "exit": exit_price, "pnl": pnl, "r": pnl / pos.risk_money if pos.risk_money else 0.0, "equity_before": pos.equity_before, "reason": reason}) pos = None for i in range(start, n): # 1) exécution du signal en attente à l'ouverture de la barre if pending is not None and pos is None and not dead and not day_halt: buy = pending.side == "buy" entry = o[i] + spread + slip if buy else o[i] - slip info = SimpleNamespace(trade_tick_size=1.0, trade_tick_value=vpu(entry), volume_step=0.01, volume_min=0.01, volume_max=50.0) lots = rm.lot_size(balance, pending.sl_distance, info) if lots > 0: sl = entry - pending.sl_distance if buy else entry + pending.sl_distance tp = entry + pending.tp_distance if buy else entry - pending.tp_distance pos = Pos(0 if buy else 1, entry, sl, tp, lots, times[i], balance, balance * risk_cfg["risk_per_trade_pct"] / 100) pending = None # 2) SL / TP dans la barre if pos is not None: if pos.type == 0: hit_sl, hit_tp = l[i] <= pos.sl, h[i] >= pos.tp if hit_sl: close_pos(i, min(pos.sl, o[i]), "SL") elif hit_tp: close_pos(i, pos.tp, "TP") else: hit_sl, hit_tp = h[i] + spread >= pos.sl, l[i] + spread <= pos.tp if hit_sl: close_pos(i, max(pos.sl, o[i] + spread), "SL") elif hit_tp: close_pos(i, pos.tp, "TP") # 3) equity à la clôture floating = 0.0 if pos is not None: px = c[i] if pos.type == 0 else c[i] + spread mv = (px - pos.price_open) if pos.type == 0 else (pos.price_open - px) floating = mv * pos.lots * vpu(px) equity = balance + floating eq_vals.append(equity); eq_times.append(times[i]) peak = max(peak, equity) # 4) break-even / trailing (même logique que le bot live) if pos is not None and not np.isnan(atr_arr[i]): px = c[i] if pos.type == 0 else c[i] + spread new = rm.new_sl(pos, atr_arr[i], px) if new is not None: pos.sl = new # 5) limites de risque d = times[i].date() if d != cur_day: cur_day, day_start, day_halt = d, equity, False daily_loss = (day_start - equity) / day_start * 100 ref = rm.initial if (rm.prop and rm.initial) else peak dd = (ref - equity) / ref * 100 if pos is not None and (daily_loss >= rm.daily_pct or dd >= rm.dd_pct): close_pos(i, c[i] if pos.type == 0 else c[i] + spread, "RISK") if daily_loss >= rm.daily_pct: day_halt = True if dd >= rm.dd_pct: dead = True # 6) nouveau signal (exécuté à la barre suivante) if pos is None and not day_halt and not dead and i < n - 1: if flt and fcfg: hr = times[i].hour if not any(a <= hr < b for a, b in fcfg.get("sessions_utc", [[0, 24]])): continue rg = fcfg.get("regime", {}) if rg.get("enabled") and not np.isnan(atr_mean[i]) and atr_arr[i] / atr_mean[i] < rg["min_atr_ratio"]: continue pending = strat.on_bar(symbol, df.iloc[i - WINDOW + 1:i + 1]) if pos is not None: close_pos(n - 1, c[-1] if pos.type == 0 else c[-1] + spread, "EOD") eq_vals[-1] = balance tr = pd.DataFrame(trades) if tr.empty: tr = pd.DataFrame(columns=["entry_time", "exit_time", "side", "lots", "entry", "exit", "pnl", "r", "equity_before", "reason"]) return {"trades": tr, "equity": pd.Series(eq_vals, index=pd.DatetimeIndex(eq_times)), "initial": init_bal, "halted": dead}