import os import sys import yaml from datetime import datetime from itertools import product from typing import Dict, List, Any, Optional sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from mt5_paths import resolve_mt5_settings from symbol_resolver import resolve_symbol class INIGenerator: def __init__(self, source): """ source: YAML 文件路径 str 或 已加载的 config dict(GUI 内存直接传入用)。 """ if isinstance(source, dict): self.config_path = None self.config = source else: self.config_path = source self.config = self._load_config() self.config["mt5_settings"] = resolve_mt5_settings(self.config.get("mt5_settings", {})) self.output_dir = self.config["mt5_settings"]["ini_dir"] os.makedirs(self.output_dir, exist_ok=True) def _load_config(self) -> Dict: with open(self.config_path, 'r', encoding='utf-8') as f: return yaml.safe_load(f) def _scan_experts_dir(self) -> List[str]: data_dir = self.config["mt5_settings"].get("data_dir", "") if not data_dir: mt5_base = os.path.dirname(self.config["mt5_settings"]["terminal_path"]) data_dir = mt5_base experts_dir = os.path.join(data_dir, "MQL5", "Experts") ea_files = [] if not os.path.exists(experts_dir): return [] for root, dirs, files in os.walk(experts_dir): for f in files: if f.endswith('.ex5'): rel_path = os.path.relpath(os.path.join(root, f), experts_dir) ea_files.append(rel_path) return ea_files def _load_set_file(self, set_file_path: str) -> Dict[str, Any]: params = {} if not os.path.exists(set_file_path): return params with open(set_file_path, 'r', encoding='utf-8') as f: for line in f: line = line.strip() if line and not line.startswith(';') and '=' in line: key, value = line.split('=', 1) try: params[key.strip()] = float(value.strip()) except: params[key.strip()] = value.strip() return params def _generate_parameter_combinations(self, parameters: Dict[str, List]) -> List[Dict]: if not parameters: return [{}] keys = list(parameters.keys()) values = list(parameters.values()) combinations = list(product(*values)) return [dict(zip(keys, combo)) for combo in combinations] def _get_timeframe_code(self, timeframe: str) -> str: mapping = { "M1": "M1", "M5": "M5", "M15": "M15", "H1": "H1", "H4": "H4", "D1": "D1", "W1": "W1" } return mapping.get(timeframe, "H1") def _params_hash(self, parameters: Dict) -> str: if not parameters: return "default" sorted_params = sorted(parameters.items()) param_str = "_".join([f"{k}{v}" for k, v in sorted_params]) return str(abs(hash(param_str)))[:8] def _generate_filename(self, ea_name: str, symbol: str, timeframe: str, parameters: Dict) -> str: safe_ea_name = ea_name.replace("\\", "_").replace("/", "_").replace("..", "") for c in '()[]{}|\\/*?:"\'<>': safe_ea_name = safe_ea_name.replace(c, "_") param_hash = self._params_hash(parameters) return f"{safe_ea_name}_{symbol}_{timeframe}_{param_hash}.ini" def _build_ini_content(self, ea_filename: str, symbol: str, timeframe: str, bt_settings: Dict, parameters: Dict, ea_name: str, set_file: Optional[str] = None) -> str: date_from = bt_settings["date_range"]["from"] date_to = bt_settings["date_range"]["to"] report_name = f"{ea_name}_{symbol}_{timeframe}_{self._params_hash(parameters)}" for c in '()[]{}|\\/*?:"\'<>': report_name = report_name.replace(c, "_") safe_report_name = report_name ini_lines = [ "; MT5 Strategy Tester Configuration", f"; Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", "", "[Tester]", f"Expert={ea_filename}", ] if set_file: ini_lines.append(f"ExpertParameters={set_file}") else: ini_lines.append("ExpertParameters=") # MT5 tester delay is controlled by ExecutionMode itself: # 0 = no delay # -1 = random delay # > 0 = fixed delay in milliseconds execution_delay = int(bt_settings.get('execution_delay', 0) or 0) ini_lines.extend([ f"Symbol={symbol}", f"Period={self._get_timeframe_code(timeframe)}", f"Model={bt_settings['model']}", f"ExecutionMode={execution_delay}", f"Optimization={bt_settings['optimization']}", ]) if bt_settings.get("optimization"): ini_lines.append("OptimizationCriterion=6") ini_lines.extend([ f"FromDate={date_from}", f"ToDate={date_to}", f"ForwardMode={bt_settings.get('forward_mode', 0)}", f"ForwardDate={bt_settings.get('forward_date', '')}", f"Report={safe_report_name}", f"ReplaceReport={1 if bt_settings['replace_report'] else 0}", f"ShutdownTerminal={1 if bt_settings['shutdown_terminal'] else 0}", f"Deposit={int(float(bt_settings['deposit']))}", f"Currency={bt_settings['currency']}", f"Leverage={bt_settings['leverage']}", f"Visual={bt_settings['visual']}", "ProfitInPips=0", ]) if parameters: ini_lines.extend(["", "; EA Parameters"]) for param_name, param_value in parameters.items(): ini_lines.append(f"{param_name}={param_value}") return "\n".join(ini_lines) def generate_ini_files(self) -> List[str]: generated_files = [] bt_settings = self.config["backtest_settings"] set_files_dir = self.config["mt5_settings"].get("set_files_dir", "config/sets") ea_configs = self.config.get("eas", []) if not ea_configs: print("No EAs configured, scanning MT5 Experts directory...") ea_files = self._scan_experts_dir() if ea_files: print(f"Found {len(ea_files)} EA files: {ea_files}") for ea_file in ea_files: ea_name = os.path.splitext(ea_file)[0] ea_configs.append({ "name": ea_name, "filename": ea_file, "description": "Auto-scanned EA" }) else: print("No EAs found in Experts directory") return [] for ea in ea_configs: ea_name = ea["name"] ea_filename = ea["filename"] set_file = ea.get("set_file") parameters = ea.get("parameters", {}) if set_file: set_file_path = os.path.join(set_files_dir, set_file) set_params = self._load_set_file(set_file_path) param_combinations = [set_params] if set_params else [{}] else: param_combinations = self._generate_parameter_combinations(parameters) for symbol in bt_settings["symbols"]: resolved_symbol = resolve_symbol(symbol, bt_settings) for timeframe in bt_settings["timeframes"]: for param_combo in param_combinations: ini_content = self._build_ini_content( ea_filename, resolved_symbol, timeframe, bt_settings, param_combo, ea_name, set_file ) ini_filename = self._generate_filename( ea_name, resolved_symbol, timeframe, param_combo ) ini_path = os.path.join(self.output_dir, ini_filename) with open(ini_path, 'w', encoding='utf-8') as f: f.write(ini_content) generated_files.append(ini_path) return generated_files def generate_batch_run_script(self, ini_files: List[str], output_script: str = "run_backtests.bat"): mt5_path = self.config["mt5_settings"]["terminal_path"] lines = [ "@echo off", "echo MT5 Batch Backtest Runner", "echo =======================", "", f'SET "MT5_PATH={mt5_path}"', f'SET "INI_DIR={self.output_dir}"', "", ] for ini_file in ini_files: ini_filename = os.path.basename(ini_file) lines.append(f'echo Running: {ini_filename}') lines.append( f'START "MT5" /WAIT "%MT5_PATH%" /portable /config:"%INI_DIR%\\{ini_filename}"' ) lines.append("if errorlevel 1 echo Failed: " + ini_filename) lines.append("") lines.append("echo All backtests completed!") lines.append("pause") with open(output_script, 'w', encoding='utf-8') as f: f.write("\n".join(lines)) return output_script def main(): config_path = os.path.join(os.path.dirname(__file__), "..", "config", "ea_configs.yaml") generator = INIGenerator(config_path) print("Generating INI files...") ini_files = generator.generate_ini_files() print(f"Generated {len(ini_files)} INI files") for f in ini_files[:5]: print(f" - {os.path.basename(f)}") if len(ini_files) > 5: print(f" ... and {len(ini_files) - 5} more") if ini_files: print("\nGenerating batch run script...") script_path = generator.generate_batch_run_script(ini_files) print(f"Batch script: {script_path}") if __name__ == "__main__": main()