""" mt5/runner.py Robust MT5 Strategy Tester runner with: 1. MT5 process control (kill existing, launch fresh) 2. Pre-run environment validation 3. Historical data readiness wait 4. Actionable error messages 5. Auto-retry on failure (1 retry) 6. Report detection in MT5 native reports folder """ from __future__ import annotations import shutil import subprocess import time import psutil from pathlib import Path from typing import Optional, TYPE_CHECKING import yaml from loguru import logger from data.models import RunResult if TYPE_CHECKING: from ea.registry import EAProfile # ── Custom Exceptions ───────────────────────────────────────────────────────── class MT5TimeoutError(RuntimeError): pass class MT5ValidationError(RuntimeError): pass # ── MT5Runner ───────────────────────────────────────────────────────────────── class MT5Runner: POLL_INTERVAL_S = 5 # seconds between report checks PROCESS_SETTLE_S = 2 # seconds to wait after process exit MT5_INIT_WAIT_S = 8 # seconds after launch before testing begins KILL_WAIT_S = 3 # seconds after killing MT5 before launching fresh MAX_RETRIES = 1 # retry once on failure MT5_EXE_NAME = "terminal64.exe" def __init__(self, config_path: str | Path = "config.yaml"): with open(config_path) as f: cfg = yaml.safe_load(f) self.cfg = cfg self.terminal_exe = Path(cfg["mt5"]["terminal_exe"]) self.timeout_s = cfg["mt5"]["tester_timeout_seconds"] self.appdata_path = Path(cfg["mt5"]["appdata_path"]) # MT5 writes reports to the ROOT of the terminal data folder # (not a 'reports' subfolder) — confirmed by log inspection self.mt5_reports_dir = self.appdata_path self.mql5_files_dir = Path(cfg["mt5"].get( "mql5_files_path", str(self.appdata_path / "MQL5" / "Files") )) self.data_wait_s = cfg["mt5"].get("data_readiness_wait_seconds", 10) # ── Public entry point ──────────────────────────────────────────────────── def run( self, run_id: str, ini_path: Path, report_dir: Path, log_csv_search_dir: Optional[Path] = None, profile: Optional["EAProfile"] = None, ) -> RunResult: """ Full execution pipeline with retry. profile: EAProfile used for validation + TradeLog lookup. Falls back to config['ea'] if not provided (legacy mode). """ report_dir.mkdir(parents=True, exist_ok=True) report_stem = f"Optimizer_{run_id}" for attempt in range(1, self.MAX_RETRIES + 2): is_retry = attempt > 1 if is_retry: logger.warning(f"[{run_id}] Retry attempt {attempt}...") try: # ── Step 1: Kill any running MT5 ───────────────────────── self._kill_mt5(run_id) # ── Step 1b: Clear stale reports from previous runs ────── self._clear_stale_reports(run_id) # ── Step 2: Pre-run validation ─────────────────────────── self._validate(run_id, profile=profile) # ── Step 3: Launch fresh MT5 ───────────────────────────── proc = self._launch_mt5(run_id, ini_path) # ── Step 4: Wait for MT5 to initialize + data ──────────── logger.info(f"[{run_id}] Waiting {self.MT5_INIT_WAIT_S}s for MT5 to initialize...") time.sleep(self.MT5_INIT_WAIT_S) # ── Step 5: Wait for report + TradeLog ─────────────────── result = self._wait_for_report(run_id, proc, report_dir, report_stem) # ── Step 6: Find TradeLogger CSV ───────────────────────── result.trade_log_csv = self._find_trade_log( run_id, log_csv_search_dir, profile=profile ) logger.success(f"[{run_id}] Run complete. Report: {result.report_xml}") return result except MT5ValidationError as e: logger.error(f"[{run_id}] Validation error: {e}") return RunResult(run_id=run_id, success=False, error_message=str(e)) except Exception as e: logger.warning(f"[{run_id}] Attempt {attempt} failed: {e}") if attempt > self.MAX_RETRIES: msg = self._diagnose_failure(str(e)) logger.error(f"[{run_id}] All retries exhausted. {msg}") return RunResult(run_id=run_id, success=False, error_message=msg) logger.info(f"[{run_id}] Will retry after {self.KILL_WAIT_S}s...") time.sleep(self.KILL_WAIT_S) # ── Step 1: Kill existing MT5 ───────────────────────────────────────────── def _kill_mt5(self, run_id: str) -> None: """Find and terminate any running MT5 processes.""" killed = 0 for proc in psutil.process_iter(["pid", "name", "exe"]): try: if proc.info["name"] and self.MT5_EXE_NAME.lower() in proc.info["name"].lower(): logger.info(f"[{run_id}] Closing existing MT5 (PID {proc.pid})...") proc.terminate() try: proc.wait(timeout=5) except psutil.TimeoutExpired: proc.kill() killed += 1 except (psutil.NoSuchProcess, psutil.AccessDenied): pass if killed > 0: logger.info(f"[{run_id}] Closed {killed} MT5 instance(s). Waiting {self.KILL_WAIT_S}s...") time.sleep(self.KILL_WAIT_S) else: logger.debug(f"[{run_id}] No existing MT5 process found.") # ── Step 2: Pre-run validation ──────────────────────────────────────────── def _validate(self, run_id: str, profile: Optional["EAProfile"] = None) -> None: """Validate all required files and directories exist before launch.""" errors = [] if not self.terminal_exe.exists(): errors.append(f"MT5 terminal not found: {self.terminal_exe}") # EA file: use profile if provided, else fall back to config['ea'] ea_name = profile.ex5_file if profile else self.cfg.get("ea", {}).get("file", "") symbol = profile.symbol if profile else self.cfg.get("ea", {}).get("symbol", "") ea_candidates = [ self.appdata_path / "MQL5" / "Experts" / f"{ea_name}.ex5", self.appdata_path / "MQL5" / "Experts" / ea_name, ] if not any(p.exists() for p in ea_candidates): errors.append( f"EA file not found: {ea_name}.ex5 — " f"ensure you compiled the EA in MetaEditor before running." ) if not self.appdata_path.exists(): errors.append(f"MT5 appdata folder not found: {self.appdata_path}") if not symbol: errors.append("Symbol not configured (check EAProfile or config.yaml)") if errors: for e in errors: logger.error(f"[{run_id}] Validation: {e}") raise MT5ValidationError( "Pre-run validation failed:\n" + "\n".join(f" • {e}" for e in errors) ) logger.debug(f"[{run_id}] Pre-run validation passed.") # ── Step 3: Launch MT5 ─────────────────────────────────────────────────── def _launch_mt5(self, run_id: str, ini_path: Path) -> subprocess.Popen: """Launch a fresh MT5 instance with the given INI config.""" cmd = [str(self.terminal_exe), f"/config:{ini_path}"] logger.info(f"[{run_id}] Launching MT5: {' '.join(cmd)}") proc = subprocess.Popen( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) logger.debug(f"[{run_id}] MT5 PID: {proc.pid}") return proc # ── Step 5: Wait for report ─────────────────────────────────────────────── def _wait_for_report( self, run_id: str, proc: subprocess.Popen, report_dir: Path, report_stem: str, ) -> RunResult: """ Poll both our local dir and MT5's native reports folder. MT5 writes the report to \\reports\\ using the name from INI Report= field. """ elapsed = 0 search_dirs = [report_dir, self.mt5_reports_dir] while elapsed < self.timeout_s: # Check for report in all locations for search in search_dirs: if not search.exists(): continue result = self._find_report_files(search, report_stem) if result: xml_path, html_path = result time.sleep(self.PROCESS_SETTLE_S) # Archive to our local report dir if xml_path: report_dir.mkdir(parents=True, exist_ok=True) dest = report_dir / Path(xml_path).name if not dest.exists(): shutil.copy2(xml_path, dest) logger.info(f"[{run_id}] Report found in {search} after {elapsed}s") return RunResult( run_id=run_id, report_xml=xml_path, report_html=html_path, success=True, ) # Check if process already exited ret = proc.poll() if ret is not None: time.sleep(self.PROCESS_SETTLE_S) # Final check after process exit for search in search_dirs: if not search.exists(): continue result = self._find_report_files(search, report_stem) if result: xml_path, html_path = result return RunResult( run_id=run_id, report_xml=xml_path, report_html=html_path, success=True, ) # Process exited but no report — diagnose if ret != 0: raise RuntimeError( f"MT5 exited with error code {ret}. " f"Possible causes: invalid INI parameters, EA not compiled, " f"or missing historical data." ) else: raise RuntimeError( "MT5 exited without generating a report. " "Possible causes: Symbol data not downloaded, " "invalid date range, or EA failed to initialize." ) time.sleep(self.POLL_INTERVAL_S) elapsed += self.POLL_INTERVAL_S if elapsed % 30 == 0: logger.info(f"[{run_id}] Still waiting for report... {elapsed}/{self.timeout_s}s") raise MT5TimeoutError( f"No report after {self.timeout_s}s. " f"MT5 may be stuck or the test is taking too long. " f"Consider reducing the test date range or using OHLC M1 model." ) def _clear_stale_reports(self, run_id: str) -> None: """Remove stale Optimizer_* reports from MT5 appdata root to avoid false-positive detection.""" try: import glob # Only delete files NOT matching the current run_id for pattern in ["*.htm", "*.xml", "*.html"]: for f in self.mt5_reports_dir.glob(f"Optimizer_*{pattern[-3:]}"): if run_id not in f.name: try: f.unlink() logger.debug(f"[{run_id}] Cleared stale report: {f.name}") except Exception: pass except Exception as e: logger.debug(f"[{run_id}] Could not clear stale reports: {e}") def _find_report_files( self, search_dir: Path, report_stem: str ) -> Optional[tuple[Optional[str], Optional[str]]]: """Search for report XML/HTML files by stem prefix.""" xml_list = sorted( list(search_dir.glob(f"{report_stem}*.xml")) + list(search_dir.glob(f"{report_stem}*.XML")), key=lambda p: p.stat().st_mtime, reverse=True ) htm_list = sorted( list(search_dir.glob(f"{report_stem}*.htm")) + list(search_dir.glob(f"{report_stem}*.html")) + list(search_dir.glob(f"{report_stem}*.HTM")), key=lambda p: p.stat().st_mtime, reverse=True ) if xml_list or htm_list: return ( str(xml_list[0]) if xml_list else None, str(htm_list[0]) if htm_list else None, ) return None # ── TradeLogger CSV lookup ──────────────────────────────────────────────── def _find_trade_log( self, run_id: str, search_dir: Optional[Path], profile: Optional["EAProfile"] = None, ) -> Optional[Path]: """Find the TradeLogger CSV written by the EA during the backtest.""" # Use profile if available, else fall back to config['ea'] ea_cfg = self.cfg.get("ea", {}) ea_name = profile.ex5_file if profile else ea_cfg.get("file", "") symbol = profile.symbol if profile else ea_cfg.get("symbol", "") if not ea_name or not symbol: return None candidates = [ self.mql5_files_dir / f"{ea_name}_{symbol}_TradeLog.csv", ] if search_dir: candidates.append(search_dir / f"{ea_name}_{symbol}_TradeLog.csv") for path in candidates: if path.exists(): logger.debug(f"[{run_id}] TradeLog found: {path}") return path logger.debug(f"[{run_id}] TradeLog CSV not found (fallback to report-only mode).") return None # ── Error diagnosis ─────────────────────────────────────────────────────── def _diagnose_failure(self, error_msg: str) -> str: """Convert technical errors to actionable user-facing messages.""" msg = error_msg.lower() if "exit" in msg and "cleanly" in msg: return ( "MT5 started but did not produce a report.\n" "✦ Check that XAUUSD historical data is downloaded in MT5\n" "✦ Ensure the date range (2022-2023) has data available\n" "✦ Verify the EA compiled successfully in MetaEditor" ) if "timeout" in msg: return ( f"MT5 tester timed out after {self.timeout_s}s.\n" "✦ Try a shorter date range in config.yaml\n" "✦ Switch tester_model to 4 (OHLC M1) for faster runs" ) if "validation" in msg or "not found" in msg: return error_msg return ( f"MT5 run failed: {error_msg}\n" "✦ Ensure MT5 is fully closed before starting the optimizer\n" "✦ Check config.yaml paths are correct" )