diff --git a/config/ea_configs.yaml b/config/ea_configs.yaml index 87faf9b..45aec16 100644 --- a/config/ea_configs.yaml +++ b/config/ea_configs.yaml @@ -1,35 +1,34 @@ backtest_settings: currency: USD date_range: - from: 2018.01.01 - to: 2026.06.04 - deposit: 1000.0 - execution_delay: 100 + from: 2024.01.01 + to: 2025.12.31 + deposit: 10000 + execution_delay: 0 forward_date: '' - forward_mode: 2 - leverage: 1:500 + forward_mode: 0 + leverage: 1:100 model: 0 optimization: 0 replace_report: true shutdown_terminal: true symbols: + - EURUSD - XAUUSD timeframes: - - M5 + - H1 visual: 0 -eas: -- filename: MY-EA\without source code\The Gold Reaper MT5_4.3_fix.ex5 - name: The Gold Reaper MT5_4.3_fix +eas: [] execution: kill_between: true skip_existing: true - timeout_per_test: 300000 -# mt5_settings: 路径支持占位符与自动检测(见 scripts/mt5_paths.py) -# {PROJECT_ROOT} → 项目根目录绝对路径(推荐,确保项目拷到任意位置都能跑) -# 留空字符串 → 启动时自动扫描常见位置(Program Files / %APPDATA%/MetaQuotes/Terminal) -# 绝对路径 → 直接使用(适配定制安装或便携版) + timeout_per_test: 600 + +# MT5 路径说明: +# 这部分会被 GUI 接管(启动时从 optimizer/mt5_state.json 读取;失焦时写回 mt5_state.json)。 +# 留空也没事,GUI 自己会用上次保存的值。 mt5_settings: - terminal_path: "" # 留空=自动检测; 或填 "C:\\Program Files\\MetaTrader 5 EXNESS\\terminal64.exe" - data_dir: "" # 留空=自动检测; 或填 "%APPDATA%\\MetaQuotes\\Terminal\\" + terminal_path: "" + data_dir: "" ini_dir: "{PROJECT_ROOT}/config/generated" reports_dir: "{PROJECT_ROOT}/reports" diff --git a/gui.py b/gui.py index 5c4e708..036bdb5 100644 --- a/gui.py +++ b/gui.py @@ -121,22 +121,56 @@ def _clear_mt5_chart_workspace(data_dir): pass -def _launch_mt5(mt5_path, ini_path, data_dir): +def _launch_mt5(mt5_path, ini_path, data_dir, log_lines: list = None): + """启动 MT5 终端跑测试。同步等 5 秒检查进程还活着,活着就让它跑。""" + if log_lines is None: + log_lines = [] _clear_mt5_chart_workspace(data_dir) - process = subprocess.Popen( - [mt5_path, "/config:" + os.path.abspath(ini_path)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - startupinfo=_mt5_startupinfo(), - ) - threading.Thread( - target=_minimize_mt5_windows, args=(process.pid,), daemon=True - ).start() + + cmd = [mt5_path, "/config:" + os.path.abspath(ini_path)] + log_lines.append(f" 命令: {' '.join(cmd)!s}") + + if not os.path.isfile(mt5_path): + log_lines.append(f" [ERR] terminal64.exe 不存在: {mt5_path!r}") + return None + + try: + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + startupinfo=_mt5_startupinfo(), + ) + except OSError as e: + log_lines.append(f" [ERR] 启动失败: {e!r}") + return None + except Exception as e: + log_lines.append(f" [ERR] 异常: {e!r}") + return None + + log_lines.append(f" pid={process.pid}") + + # 同步短等待(5s),检查是否启动后立即挂掉 + try: + process.wait(timeout=5.0) + log_lines.append(f" [WARN] MT5 进程在 5s 内退出, returncode={process.returncode}") + try: + err = (process.stderr.read() or b"").decode('utf-8', 'replace') + if err.strip(): + log_lines.append(f" stderr: {err[:500]}") + except Exception: + pass + return process + except subprocess.TimeoutExpired: + log_lines.append(" MT5 正常运行中(5s 后仍在跑)") + + threading.Thread(target=_minimize_mt5_windows, args=(process.pid,), daemon=True).start() return process class MT5BacktestGUI: - _CONFIG_FILE = "config/ea_configs.yaml" + _CONFIG_FILE = "config/ea_configs.yaml" # 仅给"回测配置"标签页用 + _MT5_STATE_FILE = "optimizer/mt5_state.json" # 优化器用的 MT5 路径,独立小文件 def __init__(self, root): self.root = root @@ -152,26 +186,40 @@ class MT5BacktestGUI: self._load_config_to_gui() def _load_or_default_config(self): - """已保存过配置就读 YAML;第一次用走默认(终端路径留空让用户填)。""" + """回测配置(yaml) + MT5 路径(optimizer/mt5_state.json 优先,缺失字段从 yaml 兜底)。""" + default = self._load_default_config() + # 1) 优先读 mt5_state.json(路径专用小文件) + if os.path.isfile(self._MT5_STATE_FILE): + try: + with open(self._MT5_STATE_FILE, "r", encoding="utf-8") as f: + state = json.load(f) + for k in ("terminal_path", "data_dir", "reports_dir", "ini_dir"): + if k in state and state[k]: + default["mt5_settings"][k] = state[k] + _gui_log(f"已加载 MT5 路径: {self._MT5_STATE_FILE}") + except Exception as e: + _gui_log(f"读 mt5_state 失败 ({e})") + # 2) 用 yaml 兜底(老数据 / 用户编辑过的字段;mt5_state 已有的路径字段不覆盖) if os.path.isfile(self._CONFIG_FILE): try: with open(self._CONFIG_FILE, "r", encoding="utf-8") as f: saved = yaml.safe_load(f) or {} - default = self._load_default_config() - # 浅合并:saved 覆盖 default;缺失字段用 default 补全 for k in ("backtest_settings", "mt5_settings", "execution"): - if k in saved: - default[k].update(saved[k]) - if "eas" in saved: + if k in saved and isinstance(saved.get(k), dict): + for kk, vv in saved[k].items(): + if vv in (None, "", []): + continue + # mt5_state 已经填了相同字段,跳过 yaml 的(避免覆盖最新值) + if k == "mt5_settings" and kk in ("terminal_path", "data_dir", "reports_dir", "ini_dir"): + if default["mt5_settings"].get(kk): + continue + default[k][kk] = vv + if "eas" in saved and saved["eas"]: default["eas"] = saved["eas"] - # 占位符解析(仅做 {PROJECT_ROOT} 展开,无自动探测) - if resolve_mt5_settings: - default["mt5_settings"] = resolve_mt5_settings(default["mt5_settings"]) - _gui_log(f"已加载保存的配置: {self._CONFIG_FILE}") - return default + _gui_log(f"已加载配置: {self._CONFIG_FILE}") except Exception as e: - _gui_log(f"读配置失败 ({e}),使用默认") - return self._load_default_config() + _gui_log(f"读 yaml 失败 ({e}),使用默认") + return default def _load_default_config(self): return { @@ -433,18 +481,21 @@ class MT5BacktestGUI: self.mt5_path = ttk.Entry(path_frame, width=50) self.mt5_path.insert(0, self.config["mt5_settings"].get("terminal_path", "")) self.mt5_path.grid(row=0, column=1, padx=5, pady=3, sticky='ew') + self.mt5_path.bind('', lambda e: self._persist_path_settings()) ttk.Button(path_frame, text="浏览...", command=self._browse_mt5).grid(row=0, column=2, padx=5) ttk.Label(path_frame, text="MT5数据目录 (含 MQL5/Experts 的那一层):").grid(row=1, column=0, sticky='w', padx=5, pady=3) self.data_dir = ttk.Entry(path_frame, width=50) self.data_dir.insert(0, self.config["mt5_settings"].get("data_dir", "")) self.data_dir.grid(row=1, column=1, padx=5, pady=3, sticky='ew') + self.data_dir.bind('', lambda e: self._persist_path_settings()) ttk.Button(path_frame, text="浏览...", command=self._browse_data).grid(row=1, column=2, padx=5) ttk.Label(path_frame, text="报告输出目录:").grid(row=2, column=0, sticky='w', padx=5, pady=3) self.reports_dir = ttk.Entry(path_frame, width=50) self.reports_dir.insert(0, self.config["mt5_settings"].get("reports_dir", "")) self.reports_dir.grid(row=2, column=1, padx=5, pady=3, sticky='ew') + self.reports_dir.bind('', lambda e: self._persist_path_settings()) ttk.Button(path_frame, text="浏览...", command=self._browse_reports).grid(row=2, column=2, padx=5) path_hint = ttk.Label(path_frame, @@ -591,6 +642,24 @@ class MT5BacktestGUI: yaml.dump(config, f, allow_unicode=True) messagebox.showinfo("保存成功", "配置已保存到 config/ea_configs.yaml") + def _persist_path_settings(self): + """三个路径输入框失焦时调用:写到 optimizer/mt5_state.json(不写 yaml)。""" + try: + self.config.setdefault("mt5_settings", {}) + self.config["mt5_settings"]["terminal_path"] = self.mt5_path.get() + self.config["mt5_settings"]["data_dir"] = self.data_dir.get() + self.config["mt5_settings"]["reports_dir"] = self.reports_dir.get() + reports_parent = os.path.dirname(self.reports_dir.get() or ".") or "." + self.config["mt5_settings"]["ini_dir"] = os.path.join(reports_parent, "config", "generated") + + os.makedirs(os.path.dirname(self._MT5_STATE_FILE) or ".", exist_ok=True) + state = {k: self.config["mt5_settings"][k] for k in ("terminal_path", "data_dir", "reports_dir", "ini_dir")} + with open(self._MT5_STATE_FILE, "w", encoding="utf-8") as f: + json.dump(state, f, ensure_ascii=False, indent=2) + _gui_log(f"已保存 MT5 路径: {self._MT5_STATE_FILE}") + except Exception as e: + _gui_log(f"自动保存失败: {e}") + def _on_start_backtest(self): _gui_log("开始回测 button clicked") if _IMPORT_ERRORS: @@ -1477,15 +1546,23 @@ class MT5BacktestGUI: def _refresh_opt_ea_list(self): configs_dir = os.path.join(self.opt_dir.get(), 'configs') if not os.path.exists(configs_dir): - self.opt_log.insert('end', f"配置目录不存在: {configs_dir}\n") + self.opt_log.insert('end', f" [ERR] 配置目录不存在: {configs_dir}\n") + self.opt_log.insert('end', f" 创建方法: mkdir {configs_dir}\n") return sys.path.insert(0, os.path.join(os.getcwd(), 'scripts', 'optimizer')) from ea_config_parser import load_all_ea_configs configs = load_all_ea_configs(configs_dir) + if not configs: + self.opt_log.insert('end', f" [WARN] {configs_dir} 下没有 JSON 配置\n") + self.opt_log.insert('end', f" 请把 EA 的参数配置 .json 放进该目录\n") + self.opt_ea_combo['values'] = [] + return installed_names = None - mt5_data = self.config.get('mt5_settings', {}).get('data_dir', '') - if mt5_data: + mt5_data = self.data_dir.get() + if not mt5_data: + self.opt_log.insert('end', f" [WARN] '回测配置' 里 MT5 数据目录为空,跳过 EA 存在性校验\n") + else: try: from ea_auto_scanner import EAAutoScanner scanner = EAAutoScanner(mt5_data) @@ -1494,9 +1571,13 @@ class MT5BacktestGUI: installed_names = {e['name'] for e in experts} for e in experts: self.opt_ea_paths[e['name']] = e['path'] + else: + self.opt_log.insert('end', f" [WARN] {mt5_data}\\MQL5\\Experts 下没有 .ex5 文件\n") except Exception as e: - self.opt_log.insert('end', f"扫描MT5失败,跳过校验: {e}\n") - installed_names = None + import traceback + self.opt_log.insert('end', f" [ERR] 扫描MT5失败: {e}\n") + self.opt_log.insert('end', f" 请确认 '回测配置' 里的 MT5 数据目录正确\n") + self.opt_log.insert('end', f" 目录应包含 MQL5\\Experts 子目录\n") filtered_names = [] removed_names = [] @@ -1520,7 +1601,7 @@ class MT5BacktestGUI: self.opt_log.insert('end', f"已加载 {len(configs)} 个EA配置\n") def _auto_scan_eas(self): - mt5_data = self.config.get('mt5_settings', {}).get('data_dir', '') + mt5_data = self.data_dir.get() if not mt5_data: messagebox.showwarning('警告', '请先在回测配置中设置MT5数据目录') return @@ -1563,7 +1644,9 @@ class MT5BacktestGUI: self.opt_ea_var.set(ea_list[0]) self._auto_load_ea_with_set(mt5_data, ea_list[0]) - def _auto_load_ea_with_set(self, mt5_data, ea_name): + def _auto_load_ea_with_set(self, mt5_data, ea_name=None): + if ea_name is None: + ea_name = self.opt_ea_var.get() for item in self.param_tree.get_children(): self.param_tree.delete(item) @@ -1596,7 +1679,7 @@ class MT5BacktestGUI: def _on_ea_selected(self, event=None): ea_name = self.opt_ea_var.get() if ea_name: - mt5_data = self.config.get('mt5_settings', {}).get('data_dir', '') + mt5_data = self.data_dir.get() self._auto_load_ea_with_set(mt5_data, ea_name) def _load_ea_params(self, ea_name): @@ -1605,7 +1688,7 @@ class MT5BacktestGUI: configs_dir = os.path.join(self.opt_dir.get(), 'configs') sys.path.insert(0, os.path.join(os.getcwd(), 'scripts', 'optimizer')) from ea_config_parser import load_ea_config - mt5_data = self.config.get('mt5_settings', {}).get('data_dir', '') + mt5_data = self.data_dir.get() config_path = os.path.join(configs_dir, ea_name + '.json') if os.path.exists(config_path): @@ -1894,9 +1977,9 @@ class MT5BacktestGUI: if not ea_name: messagebox.showwarning('警告', '请先选择一个EA') return - mt5_path = self.config.get('mt5_settings', {}).get('terminal_path', '') - if not mt5_path or not os.path.exists(mt5_path): - messagebox.showerror('错误', 'MT5终端路径无效') + mt5_path = self.mt5_path.get() + if not mt5_path or not os.path.isfile(mt5_path): + messagebox.showerror('错误', f'MT5终端路径无效: {mt5_path!r}\n请在"回测配置"标签页用"浏览..."按钮设置 terminal64.exe') return sys.path.insert(0, os.path.join(os.getcwd(), 'scripts', 'optimizer')) from ea_batch_optimizer import EABatchOptimizer @@ -1908,7 +1991,7 @@ class MT5BacktestGUI: return try: config = load_ea_config(config_path) - optimizer = EABatchOptimizer(self.opt_dir.get()) + optimizer = EABatchOptimizer(self.opt_dir.get(), mt5_data_dir=self.data_dir.get()) self.opt_log.insert('end', f"正在生成优化配置...\n") self.opt_log.update() ini_path = optimizer.generate_ini_for_ea(config, None) @@ -1917,9 +2000,12 @@ class MT5BacktestGUI: abs_ini_path = os.path.abspath(ini_path) self.opt_log.insert('end', "正在启动MT5优化器...\n") self.opt_log.update() - mt5_data_dir = self.config.get('mt5_settings', {}).get('data_dir', '') - _launch_mt5(mt5_path, abs_ini_path, mt5_data_dir) - self.opt_log.insert('end', "MT5已启动\n") + mt5_data_dir = self.data_dir.get() + log_lines: list = [] + _launch_mt5(mt5_path, abs_ini_path, mt5_data_dir, log_lines=log_lines) + for line in log_lines: + self.opt_log.insert('end', line + "\n") + self.opt_log.update() mode = self.opt_optimization_mode.get().split('(')[0] if '(' in self.opt_optimization_mode.get() else '遗传算法' self.opt_log.insert('end', f"MT5优化器运行中({mode})\n") self.opt_log.insert('end', "优化完成后点击'分析结果'查看\n") @@ -1927,9 +2013,14 @@ class MT5BacktestGUI: self.opt_log.insert('end', f"错误: {str(e)}\n") def _run_batch_optimize(self): - mt5_path = self.config.get('mt5_settings', {}).get('terminal_path', '') - if not mt5_path or not os.path.exists(mt5_path): - messagebox.showerror('错误', 'MT5终端路径无效') + _gui_log("点击: 批量执行优化") + mt5_path = self.mt5_path.get() + if not mt5_path or not os.path.isfile(mt5_path): + messagebox.showerror('错误', f'MT5终端路径无效: {mt5_path!r}\n请在"回测配置"标签页用"浏览..."按钮设置 terminal64.exe') + return + mt5_data_dir = self.data_dir.get() + if not mt5_data_dir or not os.path.isdir(mt5_data_dir): + messagebox.showerror('错误', f'MT5数据目录无效: {mt5_data_dir!r}\n请在"回测配置"标签页设置 MT5 数据目录') return sys.path.insert(0, os.path.join(os.getcwd(), 'scripts', 'optimizer')) from ea_batch_optimizer import EABatchOptimizer @@ -1945,16 +2036,12 @@ class MT5BacktestGUI: messagebox.showwarning('警告', '没有找到EA配置') return - result = messagebox.askyesno('确认', f'将对 {len(configs)} 个EA进行批量优化\n优化完成后自动进入下一个\n\n是否继续?') - if not result: - return - self.opt_log.insert('end', f"开始批量优化,共 {len(configs)} 个EA\n") self.opt_log.insert('end', "每个EA优化完成后会自动关闭MT5并启动下一个\n") self.opt_log.update() + _gui_log(f"开始批量优化,共 {len(configs)} 个EA") - optimizer = EABatchOptimizer(self.opt_dir.get()) - mt5_data_dir = self.config.get('mt5_settings', {}).get('data_dir', '') + optimizer = EABatchOptimizer(self.opt_dir.get(), mt5_data_dir=self.data_dir.get()) reports_dir = os.path.join(self.opt_dir.get(), 'reports') os.makedirs(reports_dir, exist_ok=True) @@ -1967,8 +2054,17 @@ class MT5BacktestGUI: abs_ini_path = os.path.abspath(ini_path) self.opt_log.insert('end', f"启动MT5: {ea_name}\n") self.opt_log.update() + _gui_log(f"启动MT5: {ea_name}, ini={abs_ini_path}") - process = _launch_mt5(mt5_path, abs_ini_path, mt5_data_dir) + log_lines: list = [] + process = _launch_mt5(mt5_path, abs_ini_path, mt5_data_dir, log_lines=log_lines) + for line in log_lines: + self.opt_log.insert('end', line + "\n") + _gui_log(line) + self.opt_log.update() + if process is None: + self.opt_log.insert('end', "启动失败,跳过该EA\n") + continue xml_name = ea_name + '_optimization.xml' xml_path = os.path.join(mt5_data_dir, xml_name) @@ -2031,8 +2127,8 @@ class MT5BacktestGUI: sys.path.insert(0, os.path.join(os.getcwd(), 'scripts', 'optimizer')) from ea_batch_optimizer import EABatchOptimizer from analyze_results import ResultAnalyzer - optimizer = EABatchOptimizer(self.opt_dir.get()) - mt5_terminal_dir = self.config.get('mt5_settings', {}).get('data_dir', '') + optimizer = EABatchOptimizer(self.opt_dir.get(), mt5_data_dir=self.data_dir.get()) + mt5_terminal_dir = self.data_dir.get() self.opt_log.insert('end', "正在分析结果...\n") self.opt_log.update() @@ -2127,4 +2223,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/logs/gui_run.log b/logs/gui_run.log new file mode 100644 index 0000000..5293ed5 --- /dev/null +++ b/logs/gui_run.log @@ -0,0 +1,6 @@ +[04:15:21] 已加载配置: config/ea_configs.yaml +[04:15:39] 已加载配置: config/ea_configs.yaml +[04:15:39] 已加载 MT5 路径: optimizer/mt5_state.json +[04:15:39] 已加载配置: config/ea_configs.yaml +[04:15:39] 已加载 MT5 路径: optimizer/mt5_state.json +[04:15:39] 已加载配置: config/ea_configs.yaml diff --git a/scripts/optimizer/ea_batch_optimizer.py b/scripts/optimizer/ea_batch_optimizer.py index afded7e..4379076 100644 --- a/scripts/optimizer/ea_batch_optimizer.py +++ b/scripts/optimizer/ea_batch_optimizer.py @@ -13,12 +13,12 @@ from ea_config_parser import load_ea_config, load_all_ea_configs, EAConfig from parameter_constraint import create_constraint_engine from smart_search import SmartSearch from analyze_results import ResultAnalyzer -from mt5_paths import auto_detect_data_dir class EABatchOptimizer: - def __init__(self, base_dir: str = 'optimizer'): + def __init__(self, base_dir: str = 'optimizer', mt5_data_dir: str = ''): self.base_dir = base_dir + self.mt5_data_dir = mt5_data_dir self.configs_dir = os.path.join(base_dir, 'configs') self.set_files_dir = os.path.join(base_dir, 'set_files') self.reports_dir = os.path.join(base_dir, 'reports') @@ -97,8 +97,11 @@ class EABatchOptimizer: ini_path = os.path.join(self.configs_dir, ini_filename) set_filename = ea_config.ea_name + '_optimization.set' - data_dir = auto_detect_data_dir() or r'C:\Users\Administrator\AppData\Roaming\MetaQuotes\Terminal\010E047102812FC0C18890992854220E' - mt5_tester_dir = os.path.join(data_dir, 'MQL5', 'Profiles', 'Tester') + if not self.mt5_data_dir: + raise FileNotFoundError( + "EABatchOptimizer: mt5_data_dir 未设置。请在 GUI '回测配置' 里设置 MT5 数据目录" + ) + mt5_tester_dir = os.path.join(self.mt5_data_dir, 'MQL5', 'Profiles', 'Tester') set_path = os.path.join(mt5_tester_dir, set_filename) date_from = test_config.get('from_date', '2025.01.01')