""" RSIReversalAsianEURUSD — bar backtest mirroring main.mq5 inputs. Outputs in this folder: backtest_report.json, trades.csv, report.png, equity_curve.png, drawdown.png, monthly_returns.png, pnl_distribution.png, exit_reasons.png Usage: python run_backtest.py python run_backtest.py --start 2021-01-01 --end 2026-01-01 """ from __future__ import annotations import argparse import json import sys from dataclasses import asdict, dataclass from datetime import datetime from pathlib import Path import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt 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")) from cluster_audit.backtest_core import ( # noqa: E402 BacktestReport, CostModel, load_bars, resolve_symbol, run_single_position, ) from indicator_utils import calculate_rsi # noqa: E402 STRATEGY_ID = "RSIReversalAsianEURUSD" @dataclass class StrategyParams: rsi_period: int = 28 overbought_level: float = 60 oversold_level: float = 8 rsi_exit_level: float = 55 close_outside_session: bool = False use_rsi_exit: bool = True max_duration_hours: int = 270 max_spread_points: int = 1000 lot_size: float = 0.1 asian_session_start: int = 0 asian_session_end: int = 8 initial_balance: float = 10_000.0 def to_dict(self) -> dict: return asdict(self) def make_params(balance: float) -> StrategyParams: return StrategyParams(initial_balance=balance) def save_reports(report: BacktestReport, out_dir: Path) -> None: rows = [ { "side": t.side, "open_time": t.open_time, "close_time": t.close_time, "open_price": t.open_price, "close_price": t.close_price, "volume": t.volume, "profit": t.profit, "bars_held": t.bars_held, "exit_reason": t.exit_reason, } for t in report.trades_list ] pd.DataFrame(rows).to_csv(out_dir / "trades.csv", index=False) with open(out_dir / "backtest_report.json", "w", encoding="utf-8") as f: json.dump(report.to_dict(), f, indent=2, ensure_ascii=False) bal0 = report.params.get("initial_balance", 10_000.0) if report.equity_curve is not None and len(report.equity_curve) > 1: eq_s = report.equity_curve eq_times = eq_s.index equity = eq_s.values elif report.trades_list: df = pd.DataFrame(rows) df["close_time"] = pd.to_datetime(df["close_time"]) df = df.sort_values("close_time") eq_times = df["close_time"] equity = bal0 + df["profit"].cumsum().values else: fig, ax = plt.subplots(figsize=(10, 4)) ax.text(0.5, 0.5, "No trades in backtest window", ha="center", va="center", fontsize=14) ax.axis("off") fig.savefig(out_dir / "report.png", dpi=200, bbox_inches="tight") plt.close(fig) return dd = (equity - np.maximum.accumulate(equity)) / np.maximum.accumulate(equity) * 100 fig = plt.figure(figsize=(14, 10)) gs = fig.add_gridspec(3, 2, height_ratios=[2, 1.2, 1.2]) ax1 = fig.add_subplot(gs[0, :]) ax1.plot(eq_times, equity, lw=1.8) ax1.axhline(bal0, color="gray", ls="--") ax1.set_title("Equity Curve") ax1.grid(alpha=0.3) ax2 = fig.add_subplot(gs[1, 0]) ax2.fill_between(eq_times, dd, 0, color="#d62728", alpha=0.35) ax2.set_title("Drawdown %") ax2.grid(alpha=0.3) if report.trades_list: df = pd.DataFrame(rows) df["close_time"] = pd.to_datetime(df["close_time"]) df["month"] = df["close_time"].dt.to_period("M") monthly = df.groupby("month")["profit"].sum() ax3 = fig.add_subplot(gs[1, 1]) ax3.bar(range(len(monthly)), monthly.values, color=["#2ca02c" if v >= 0 else "#d62728" for v in monthly]) ax3.set_title("Monthly PnL") ax3.axhline(0, color="black", lw=0.6) ax4 = fig.add_subplot(gs[2, 0]) ax4.hist(df["profit"], bins=30, color="#9467bd", alpha=0.85) ax4.axvline(0, color="black") ax4.set_title("Trade PnL Distribution") ax5 = fig.add_subplot(gs[2, 1]) rc = df["exit_reason"].value_counts() ax5.bar(rc.index.astype(str), rc.values, color="#ff7f0e") ax5.set_title("Exit Reasons") fig.suptitle( f"{STRATEGY_ID} — Net ${report.net_profit:,.2f} | Trades {report.total_trades} | " f"WR {report.win_rate:.1f}% | PF {report.profit_factor:.2f} | MaxDD {report.max_drawdown_pct:.2f}%", fontsize=11, ) fig.tight_layout(rect=[0, 0, 1, 0.96]) fig.savefig(out_dir / "report.png", dpi=200, bbox_inches="tight") plt.close(fig) plt.figure(figsize=(12, 5)) plt.plot(eq_times, equity, lw=2) plt.title("Equity Curve") plt.grid(alpha=0.3) plt.tight_layout() plt.savefig(out_dir / "equity_curve.png", dpi=200, bbox_inches="tight") plt.close() plt.figure(figsize=(12, 5)) plt.fill_between(eq_times, dd, 0, color="red", alpha=0.3) plt.plot(eq_times, dd, color="darkred") plt.title("Drawdown %") plt.grid(alpha=0.3) plt.tight_layout() plt.savefig(out_dir / "drawdown.png", dpi=200, bbox_inches="tight") plt.close() if report.trades_list: df = pd.DataFrame(rows) df["close_time"] = pd.to_datetime(df["close_time"]) df["month"] = df["close_time"].dt.to_period("M") monthly = df.groupby("month")["profit"].sum() plt.figure(figsize=(12, 5)) plt.bar(range(len(monthly)), monthly.values, color=["green" if v >= 0 else "red" for v in monthly], alpha=0.75) plt.title("Monthly PnL") plt.axhline(0, color="black") plt.grid(alpha=0.3, axis="y") plt.tight_layout() plt.savefig(out_dir / "monthly_returns.png", dpi=200, bbox_inches="tight") plt.close() plt.figure(figsize=(10, 5)) plt.hist(df["profit"], bins=40, color="#6a5acd", alpha=0.85) plt.axvline(0, color="black") plt.title("Per-Trade PnL Distribution") plt.tight_layout() plt.savefig(out_dir / "pnl_distribution.png", dpi=200, bbox_inches="tight") plt.close() if report.exit_reason_breakdown: labels = list(report.exit_reason_breakdown.keys()) counts = [report.exit_reason_breakdown[k]["count"] for k in labels] plt.figure(figsize=(8, 5)) plt.bar(labels, counts, color="#e377c2") plt.title("Exit Reason Counts") plt.tight_layout() plt.savefig(out_dir / "exit_reasons.png", dpi=200, bbox_inches="tight") plt.close() def run_backtest(df: pd.DataFrame, symbol: str, params: StrategyParams, costs: CostModel, period_label: str) -> BacktestReport: info = mt5.symbol_info(symbol) point = float(info.point) if info else 0.00001 rsi = calculate_rsi(df["close"], params.rsi_period).to_numpy() p = params.to_dict() session_close_done = False def in_session(ts) -> bool: return params.asian_session_start <= ts.hour < params.asian_session_end def on_bar(i, st, open_pos, close): nonlocal session_close_done if i < params.rsi_period + 2 or np.isnan(rsi[i - 1]) or np.isnan(rsi[i - 2]): return ts = df.index[i] prev, cur = float(rsi[i - 2]), float(rsi[i - 1]) mid = float(df["open"].iloc[i]) if not in_session(ts): if st.side and params.close_outside_session and not session_close_done: close(i, mid, "session") session_close_done = True return session_close_done = False if st.side: hours_held = (ts - pd.Timestamp(st.entry_time)).total_seconds() / 3600.0 if hours_held > params.max_duration_hours: close(i, mid, "timeout") return if params.use_rsi_exit: el = params.rsi_exit_level if st.side == "BUY" and prev < el <= cur: close(i, mid, "rsi_exit") return if st.side == "SELL" and prev > el >= cur: close(i, mid, "rsi_exit") return return if costs.spread_points > params.max_spread_points: return if prev < params.overbought_level <= cur: open_pos(i, "SELL", mid) elif prev > params.oversold_level >= cur: open_pos(i, "BUY", mid) return run_single_position( df, symbol, point, costs, params.lot_size, STRATEGY_ID, "M15", period_label, p, params.initial_balance, on_bar, bar_seconds=900, ) def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description=f"{STRATEGY_ID} Python backtest") p.add_argument("--symbol", default="EURUSD") p.add_argument("--start", default="2021-01-01") p.add_argument("--end", default="2026-01-01") p.add_argument("--balance", type=float, default=10_000.0) return p.parse_args() def main() -> None: args = parse_args() out_dir = Path(__file__).resolve().parent params = make_params(args.balance) if not mt5.initialize(): raise SystemExit("MetaTrader5 initialize() failed") try: symbol = resolve_symbol(args.symbol) start = datetime.fromisoformat(args.start) end = datetime.fromisoformat(args.end) period_label = f"{args.start}_{args.end}" print(f"Loading {symbol} M15 bars ...") df = load_bars(symbol, mt5.TIMEFRAME_M15, start, end) costs = CostModel.for_symbol(symbol) report = run_backtest(df, symbol, params, costs, period_label) save_reports(report, out_dir) print(f"Net: ${report.net_profit:,.2f} | Trades: {report.total_trades} | WR: {report.win_rate:.1f}% | PF: {report.profit_factor:.2f}") print(f"Saved to {out_dir}") finally: mt5.shutdown() if __name__ == "__main__": main()