""" Fast vectorized optimizer for SimpleEMA v2 (crossover + pullback entries). Target: net_profit > 0, trades >= min_trades (default 2000). Usage: python run_optimize.py --trials 5000 --min-trades 2000 """ from __future__ import annotations import argparse import json import random import sys from dataclasses import asdict, dataclass from datetime import datetime from pathlib import Path import MetaTrader5 as mt5 import numpy as np import pandas as pd ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(ROOT / "backtesting" / "MT5")) sys.path.insert(0, str(Path(__file__).resolve().parent)) from cluster_audit.backtest_core import CostModel, load_bars, resolve_symbol # noqa: E402 from indicator_utils import calculate_adx, calculate_atr, calculate_ema # noqa: E402 from run_backtest import pip_size # noqa: E402 TF_MAP = {"M15": mt5.TIMEFRAME_M15, "M5": mt5.TIMEFRAME_M5} @dataclass class Params: fast_ema: int = 8 slow_ema: int = 34 entry_mode: int = 1 min_ema_gap_pips: float = 0.0 cooldown_bars: int = 4 use_atr_stops: bool = True atr_period: int = 14 atr_sl_mult: float = 2.0 atr_tp_mult: float = 4.0 stop_loss_pips: float = 20.0 take_profit_pips: float = 40.0 exit_on_cross: bool = False max_bars_in_trade: int = 96 use_trailing: bool = True trail_atr_mult: float = 1.2 use_adx_filter: bool = True adx_period: int = 14 adx_min: float = 18.0 use_htf_filter: bool = True htf_ema_period: int = 100 session_start: int = 7 session_end: int = 21 max_spread_pips: float = 8.0 lot_size: float = 0.10 initial_balance: float = 10_000.0 @dataclass class SimResult: net_profit: float total_trades: int win_rate: float profit_factor: float max_drawdown_pct: float sharpe: float trades: list[dict] @dataclass class MarketData: df: pd.DataFrame close: np.ndarray open_: np.ndarray high: np.ndarray low: np.ndarray hours: np.ndarray fast: dict[int, np.ndarray] slow: dict[int, np.ndarray] atr: dict[int, np.ndarray] adx: dict[int, np.ndarray] htf: dict[int, np.ndarray] def load_market(df: pd.DataFrame) -> MarketData: close_s = df["close"] h4 = close_s.resample("4h").last().dropna() fast = {p: calculate_ema(close_s, p).to_numpy() for p in range(5, 13)} slow = {p: calculate_ema(close_s, p).to_numpy() for p in range(20, 61, 2)} atr = {p: calculate_atr(df, p).to_numpy() for p in (10, 14, 20)} adx = {p: calculate_adx(df, p).to_numpy() for p in (10, 14, 20)} htf = {p: calculate_ema(h4, p).reindex(df.index, method="ffill").to_numpy() for p in (50, 100, 200)} return MarketData( df=df, close=close_s.to_numpy(), open_=df["open"].to_numpy(), high=df["high"].to_numpy(), low=df["low"].to_numpy(), hours=df.index.hour.to_numpy(), fast=fast, slow=slow, atr=atr, adx=adx, htf=htf, ) def make_signals(md: MarketData, p: Params, pip: float) -> dict[str, np.ndarray]: fast, slow = md.fast[p.fast_ema], md.slow[p.slow_ema] close, high, low = md.close, md.high, md.low f1, f2 = np.roll(fast, 1), np.roll(fast, 2) s1, s2 = np.roll(slow, 1), np.roll(slow, 2) c1, h1, l1 = np.roll(close, 1), np.roll(high, 1), np.roll(low, 1) bull_cross = (f2 <= s2) & (f1 > s1) bear_cross = (f2 >= s2) & (f1 < s1) bull_pb = (f1 > s1) & (l1 <= f1) & (c1 > f1) bear_pb = (f1 < s1) & (h1 >= f1) & (c1 < f1) if p.entry_mode == 0: buy_raw, sell_raw = bull_cross, bear_cross elif p.entry_mode == 2: buy_raw, sell_raw = bull_pb, bear_pb else: buy_raw = bull_cross | bull_pb sell_raw = bear_cross | bear_pb gap_ok = np.abs(f1 - s1) / pip >= p.min_ema_gap_pips sess = (md.hours >= p.session_start) & (md.hours < p.session_end) adx_arr = md.adx[p.adx_period] adx_ok = adx_arr >= p.adx_min if p.use_adx_filter else np.ones(len(close), dtype=bool) htf_arr = md.htf[p.htf_ema_period] if p.use_htf_filter: htf_bull = close > htf_arr htf_bear = close < htf_arr else: htf_bull = htf_bear = np.ones(len(close), dtype=bool) buy_sig = buy_raw & gap_ok & sess & adx_ok & htf_bull sell_sig = sell_raw & gap_ok & sess & adx_ok & htf_bear buy_sig[: p.slow_ema + 3] = False sell_sig[: p.slow_ema + 3] = False return { "open": md.open_, "high": md.high, "low": md.low, "close": md.close, "atr": md.atr[p.atr_period], "bull_cross": bull_cross, "bear_cross": bear_cross, "buy_sig": buy_sig, "sell_sig": sell_sig, } def simulate(md: MarketData, symbol: str, p: Params, costs: CostModel, pip: float, point: float) -> SimResult: sig = make_signals(md, p, pip) opn, high, low, close = sig["open"], sig["high"], sig["low"], sig["close"] atr = sig["atr"] bull_cross, bear_cross = sig["bull_cross"], sig["bear_cross"] buy_sig, sell_sig = sig["buy_sig"], sig["sell_sig"] spread_px = costs.spread_points * point slip = costs.slippage_points * point half = spread_px / 2.0 + slip commission = costs.commission_per_lot * p.lot_size * 2.0 balance = p.initial_balance equity = [balance] trades: list[dict] = [] side = None entry = 0.0 entry_i = 0 trail = 0.0 last_entry_i = -10_000 def calc_profit(entry_px: float, exit_px: float, s: str) -> float: ot = mt5.ORDER_TYPE_BUY if s == "BUY" else mt5.ORDER_TYPE_SELL pr = mt5.order_calc_profit(ot, symbol, p.lot_size, entry_px, exit_px) return float(pr) - commission if pr is not None else -commission warm = max(p.slow_ema + 5, 30) for i in range(warm, len(md.df)): atr1 = float(atr[i - 1]) if not np.isnan(atr[i - 1]) else 0.0 mid = float(opn[i]) if side is not None: bars_held = i - entry_i closed = False if p.max_bars_in_trade > 0 and bars_held >= p.max_bars_in_trade: exit_px = mid - half if side == "BUY" else mid + half profit = calc_profit(entry, exit_px, side) balance += profit trades.append({"side": side, "open_i": entry_i, "close_i": i, "profit": profit, "exit_reason": "max_bars"}) closed = True elif p.exit_on_cross and side == "BUY" and bear_cross[i]: profit = calc_profit(entry, mid - half, side) balance += profit trades.append({"side": side, "open_i": entry_i, "close_i": i, "profit": profit, "exit_reason": "bear_cross"}) closed = True elif p.exit_on_cross and side == "SELL" and bull_cross[i]: profit = calc_profit(entry, mid + half, side) balance += profit trades.append({"side": side, "open_i": entry_i, "close_i": i, "profit": profit, "exit_reason": "bull_cross"}) closed = True elif side == "BUY": if p.use_atr_stops and atr1 > 0: sl_px = entry - atr1 * p.atr_sl_mult tp_px = entry + atr1 * p.atr_tp_mult else: sl_px = entry - p.stop_loss_pips * pip tp_px = entry + p.take_profit_pips * pip eff_sl = sl_px if p.use_trailing and atr1 > 0: td = atr1 * p.trail_atr_mult candidate = high[i] - td if candidate > entry: trail = max(trail, candidate) if trail > 0 else candidate eff_sl = max(sl_px, trail) if low[i] <= eff_sl: reason = "trail" if trail > sl_px and eff_sl > entry else "sl" profit = calc_profit(entry, eff_sl - half, side) balance += profit trades.append({"side": side, "open_i": entry_i, "close_i": i, "profit": profit, "exit_reason": reason}) closed = True elif high[i] >= tp_px: profit = calc_profit(entry, tp_px - half, side) balance += profit trades.append({"side": side, "open_i": entry_i, "close_i": i, "profit": profit, "exit_reason": "tp"}) closed = True elif side == "SELL": if p.use_atr_stops and atr1 > 0: sl_px = entry + atr1 * p.atr_sl_mult tp_px = entry - atr1 * p.atr_tp_mult else: sl_px = entry + p.stop_loss_pips * pip tp_px = entry - p.take_profit_pips * pip eff_sl = sl_px if p.use_trailing and atr1 > 0: td = atr1 * p.trail_atr_mult candidate = low[i] + td if candidate < entry: trail = min(trail, candidate) if trail > 0 else candidate eff_sl = min(sl_px, trail) if high[i] >= eff_sl: reason = "trail" if trail > 0 and trail < sl_px and eff_sl < entry else "sl" profit = calc_profit(entry, eff_sl + half, side) balance += profit trades.append({"side": side, "open_i": entry_i, "close_i": i, "profit": profit, "exit_reason": reason}) closed = True elif low[i] <= tp_px: profit = calc_profit(entry, tp_px + half, side) balance += profit trades.append({"side": side, "open_i": entry_i, "close_i": i, "profit": profit, "exit_reason": "tp"}) closed = True if closed: side = None if side is None: spread_pips = spread_px / pip if pip > 0 else 0 if not (p.max_spread_pips > 0 and spread_pips > p.max_spread_pips) and i - last_entry_i >= p.cooldown_bars: if buy_sig[i]: side, entry, entry_i, trail, last_entry_i = "BUY", mid + half, i, 0.0, i elif sell_sig[i]: side, entry, entry_i, trail, last_entry_i = "SELL", mid - half, i, 0.0, i mark = balance if side == "BUY": mark += calc_profit(entry, float(close[i - 1]), side) + commission elif side == "SELL": mark += calc_profit(entry, float(close[i - 1]), side) + commission equity.append(mark) if side is not None: profit = calc_profit(entry, float(close[-1]), side) balance += profit trades.append({"side": side, "open_i": entry_i, "close_i": len(md.df) - 1, "profit": profit, "exit_reason": "eod"}) eq = pd.Series(equity[: len(md.df)], index=md.df.index[: len(equity)]) net = balance - p.initial_balance wins = [t["profit"] for t in trades if t["profit"] > 0] losses = [t["profit"] for t in trades if t["profit"] <= 0] gp = sum(wins) if wins else 0.0 gl = abs(sum(losses)) if losses else 0.0 pf = gp / gl if gl > 0 else 0.0 wr = 100.0 * len(wins) / len(trades) if trades else 0.0 dd = abs(float(((eq - eq.cummax()) / eq.cummax() * 100).min())) if len(eq) else 0.0 rets = eq.pct_change().dropna() sharpe = float(rets.mean() / rets.std() * np.sqrt(252 * 24 * 4)) if len(rets) > 1 and rets.std() > 0 else 0.0 return SimResult(net, len(trades), wr, pf, dd, sharpe, trades) def sample(rng: random.Random, high_freq: bool = False) -> Params: fast = rng.randint(5, 12) if high_freq: return Params( fast_ema=fast, slow_ema=rng.choice([p for p in range(max(fast + 6, 20), 41, 2)]), entry_mode=rng.choice([1, 1, 1, 2]), min_ema_gap_pips=round(rng.uniform(0, 1.5), 1), cooldown_bars=rng.choice([2, 2, 3, 4]), atr_period=rng.choice([10, 14, 20]), atr_sl_mult=round(rng.uniform(1.8, 3.2), 2), atr_tp_mult=round(rng.uniform(4.0, 9.0), 2), exit_on_cross=False, max_bars_in_trade=rng.choice([64, 96, 128]), use_trailing=False, use_adx_filter=rng.choice([False, False, True]), adx_min=round(rng.uniform(15, 28), 1), use_htf_filter=rng.choice([False, False, True]), htf_ema_period=rng.choice([50, 100, 200]), session_start=rng.choice([0, 6, 7]), session_end=rng.choice([21, 22, 24]), max_spread_pips=rng.choice([8, 10]), ) return Params( fast_ema=fast, slow_ema=rng.choice([p for p in range(max(fast + 8, 20), 61, 2)]), entry_mode=rng.choice([0, 1, 1, 1, 2]), min_ema_gap_pips=round(rng.uniform(0, 3), 1), cooldown_bars=rng.choice([2, 4, 6, 8]), atr_period=rng.choice([10, 14, 20]), atr_sl_mult=round(rng.uniform(1.5, 3.5), 2), atr_tp_mult=round(rng.uniform(3.0, 8.0), 2), exit_on_cross=rng.choice([False, False, True]), max_bars_in_trade=rng.choice([48, 64, 96, 128, 0]), use_trailing=rng.choice([True, True, False]), trail_atr_mult=round(rng.uniform(0.8, 2.0), 2), use_adx_filter=rng.choice([True, False]), adx_min=round(rng.uniform(15, 30), 1), use_htf_filter=rng.choice([True, False]), htf_ema_period=rng.choice([50, 100, 200]), session_start=rng.choice([6, 7, 8]), session_end=rng.choice([20, 21, 22]), max_spread_pips=rng.choice([6, 8, 10]), ) def write_set(p: Params, path: Path) -> None: path.write_text( "\n".join( [ "; SimpleEMA v2 optimized", "Timeframe=16388", f"FastEmaPeriod={p.fast_ema}", f"SlowEmaPeriod={p.slow_ema}", f"EntryMode={p.entry_mode}", f"MinEmaGapPips={p.min_ema_gap_pips}", f"CooldownBars={p.cooldown_bars}", f"UseAtrStops={'true' if p.use_atr_stops else 'false'}", f"AtrPeriod={p.atr_period}", f"AtrSlMult={p.atr_sl_mult}", f"AtrTpMult={p.atr_tp_mult}", f"ExitOnCross={'true' if p.exit_on_cross else 'false'}", f"MaxBarsInTrade={p.max_bars_in_trade}", f"UseTrailing={'true' if p.use_trailing else 'false'}", f"TrailAtrMult={p.trail_atr_mult}", f"UseAdxFilter={'true' if p.use_adx_filter else 'false'}", f"AdxPeriod={p.adx_period}", f"AdxMin={p.adx_min}", f"UseHtfFilter={'true' if p.use_htf_filter else 'false'}", f"HtfEmaPeriod={p.htf_ema_period}", f"SessionStartHour={p.session_start}", f"SessionEndHour={p.session_end}", f"MaxSpreadPips={p.max_spread_pips}", f"LotSize={p.lot_size}", ] ) + "\n", encoding="utf-8", ) def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--symbol", default="EURUSD") ap.add_argument("--timeframe", default="M15") ap.add_argument("--start", default="2020-01-01") ap.add_argument("--end", default="2026-01-01") ap.add_argument("--trials", type=int, default=5000) ap.add_argument("--min-trades", type=int, default=2000) ap.add_argument("--max-trades", type=int, default=3500) ap.add_argument("--profile", choices=["profit", "high-freq", "balanced"], default="balanced", help="profit=max net; high-freq=2k-3.5k trades; balanced=net>0 with most trades") ap.add_argument("--seed", type=int, default=42) args = ap.parse_args() out = Path(__file__).resolve().parent rng = random.Random(args.seed) if not mt5.initialize(): raise SystemExit("MT5 init failed") try: sym = resolve_symbol(args.symbol) df = load_bars(sym, TF_MAP[args.timeframe], datetime.fromisoformat(args.start), datetime.fromisoformat(args.end)) costs = CostModel.for_symbol(sym) pip = pip_size(sym) point = float(mt5.symbol_info(sym).point) print(f"{sym} {args.timeframe} bars={len(df)} trials={args.trials} min_trades={args.min_trades}", flush=True) print("Precomputing market data ...", flush=True) md = load_market(df) best: SimResult | None = None best_p: Params | None = None target: tuple[SimResult, Params] | None = None rows = [] hi_freq: tuple[SimResult, Params] | None = None balanced: tuple[SimResult, Params] | None = None for n in range(1, args.trials + 1): p = sample(rng, high_freq=(args.profile == "high-freq")) r = simulate(md, sym, p, costs, pip, point) rows.append({"trial": n, "net": r.net_profit, "trades": r.total_trades, "pf": r.profit_factor, **asdict(p)}) if best is None or r.net_profit > best.net_profit: best, best_p = r, p if args.min_trades <= r.total_trades <= args.max_trades and r.net_profit > 0 and r.profit_factor >= 1.05: if target is None or r.net_profit > target[0].net_profit: target = (r, p) print( f" HIT {n}: net=${r.net_profit:,.0f} trades={r.total_trades} " f"PF={r.profit_factor:.2f} WR={r.win_rate:.1f}%", flush=True, ) if args.min_trades <= r.total_trades <= args.max_trades: if hi_freq is None or r.net_profit > hi_freq[0].net_profit: hi_freq = (r, p) if r.net_profit > 0 and r.profit_factor >= 1.02: if balanced is None or r.total_trades > balanced[0].total_trades or ( r.total_trades == balanced[0].total_trades and r.net_profit > balanced[0].net_profit ): balanced = (r, p) if n % 1000 == 0: b = balanced or hi_freq or (best, best_p) print( f" ... {n}/{args.trials} profile={args.profile} " f"best_net=${best.net_profit:,.0f} t={best.total_trades} hit={'yes' if target else 'no'}", flush=True, ) pd.DataFrame(rows).sort_values("net", ascending=False).to_csv(out / "optimize_trials.csv", index=False) if args.profile == "profit": final_r, final_p = target if target else (best, best_p) elif args.profile == "high-freq": final_r, final_p = hi_freq if hi_freq else (best, best_p) else: final_r, final_p = balanced if balanced else (target if target else (best, best_p)) assert final_r and final_p with open(out / "best_params.json", "w", encoding="utf-8") as f: json.dump({"target_met": target is not None, "params": asdict(final_p), "metrics": asdict(final_r)}, f, indent=2) write_set(final_p, out / "SimpleEMA_optimized.set") (out / "best_run").mkdir(exist_ok=True) trows = [ { "side": t["side"], "open_time": df.index[t["open_i"]], "close_time": df.index[t["close_i"]], "profit": t["profit"], "exit_reason": t["exit_reason"], } for t in final_r.trades ] pd.DataFrame(trows).to_csv(out / "best_run" / "trades.csv", index=False) print( f"\n{'TARGET MET' if target else 'BEST EFFORT'}: net=${final_r.net_profit:,.2f} " f"trades={final_r.total_trades} PF={final_r.profit_factor:.2f} WR={final_r.win_rate:.1f}% " f"MaxDD={final_r.max_drawdown_pct:.1f}%", flush=True, ) finally: mt5.shutdown() if __name__ == "__main__": main()