263 lines
10 KiB
Python
263 lines
10 KiB
Python
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()
|