d5a8973c05
NEW FILES: ea/__init__.py - EA module package ea/schema.py - ParameterDef + ParameterSchema (replaces YAML manifest) ea/set_parser.py - Parse ANY MT5 .set file → ParameterSchema ea/registry.py - EAProfile + EARegistry (ea_registry.yaml) ea_registry.yaml - LEGSTECH_EA_V2 pre-registered with automation overrides MODIFIED: mt5/ini_builder.py - Accepts ParameterSchema OR legacy manifest_path mt5/runner.py - Accepts optional EAProfile for EA-agnostic validation config.yaml - Removed hardcoded ea: block; added ea_registry path KEY DESIGN: - .set file IS the manifest: value|min|max|step auto-detects float/int/bool/enum/fixed - automation_overrides in EAProfile forces headless-safe values (InpShowPanel=0) - Phase A optimization INI: Optimization=2 (genetic), ranges from schema - Phase B single backtest: Optimization=0 (unchanged behavior) - LEGSTECH advanced mode: fully backwards compatible via legacy manifest fallback - runner.py still works for legacy callers (no EAProfile needed) TESTED: - 49/49 LEGSTECH params parsed correctly - InpShowPanel=0 automation override applied - INI output correct for both Phase A (Optimization=2) and Phase B (Optimization=0) - All imports pass
214 lines
7.5 KiB
Python
214 lines
7.5 KiB
Python
"""
|
|
mt5/ini_builder.py
|
|
Generates the MT5 strategy tester .ini file from a ParameterSchema + config.
|
|
|
|
Accepts either:
|
|
- ParameterSchema object (new universal path)
|
|
- manifest_path YAML string (legacy path, still supported for transition)
|
|
"""
|
|
from __future__ import annotations
|
|
import configparser
|
|
from pathlib import Path
|
|
from typing import Any, Optional, TYPE_CHECKING
|
|
|
|
import yaml
|
|
from loguru import logger
|
|
|
|
if TYPE_CHECKING:
|
|
from ea.schema import ParameterSchema
|
|
|
|
|
|
# MT5 period string names (used in [Tester] Period= field)
|
|
TIMEFRAME_NAMES = {
|
|
"M1": "M1", "M5": "M5", "M15": "M15", "M30": "M30",
|
|
"H1": "H1", "H4": "H4", "D1": "D1", "W1": "W1", "MN": "MN1",
|
|
}
|
|
|
|
# MT5 tester model codes
|
|
MODEL_CODES = {
|
|
"every_tick": 0,
|
|
"every_tick_real": 1,
|
|
"ohlc_m1": 4,
|
|
}
|
|
|
|
|
|
class IniBuilder:
|
|
"""
|
|
Builds MT5 strategy tester .ini files.
|
|
|
|
New usage (universal, recommended):
|
|
builder = IniBuilder(config_path="config.yaml", schema=my_schema)
|
|
|
|
Legacy usage (LEGSTECH transition):
|
|
builder = IniBuilder(config_path="config.yaml", manifest_path="mutation/param_manifest.yaml")
|
|
|
|
The EA identity (ex5_file, symbol, timeframe) comes from the EAProfile
|
|
passed to build(), or falls back to config["ea"] for backwards compat.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
config_path: str | Path,
|
|
manifest_path: Optional[str | Path] = None,
|
|
schema: Optional["ParameterSchema"] = None,
|
|
):
|
|
with open(config_path) as f:
|
|
self.cfg = yaml.safe_load(f)
|
|
|
|
self._schema = schema
|
|
|
|
# Legacy manifest support
|
|
self._manifest: dict = {}
|
|
if manifest_path is not None and schema is None:
|
|
with open(manifest_path) as f:
|
|
self._manifest = yaml.safe_load(f)["parameters"]
|
|
|
|
def set_schema(self, schema: "ParameterSchema") -> None:
|
|
"""Update the schema (useful when EA changes mid-session)."""
|
|
self._schema = schema
|
|
self._manifest = {}
|
|
|
|
# ── Public ────────────────────────────────────────────────────────────────
|
|
|
|
def build(
|
|
self,
|
|
run_id: str,
|
|
params: dict[str, Any],
|
|
period_start: str,
|
|
period_end: str,
|
|
output_dir: Path,
|
|
phase: str = "explore",
|
|
ea_file: Optional[str] = None,
|
|
ea_symbol: Optional[str] = None,
|
|
ea_timeframe: Optional[str] = None,
|
|
optimize_mode: bool = False,
|
|
) -> Path:
|
|
"""
|
|
Write <run_id>.ini to output_dir and return its path.
|
|
|
|
ea_file, ea_symbol, ea_timeframe: override config["ea"] values.
|
|
Pass these from EAProfile when using the universal path.
|
|
|
|
optimize_mode=True: write Phase A optimization INI (Optimization=2).
|
|
optimize_mode=False: write Phase B single backtest INI (Optimization=0).
|
|
"""
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
ini_path = output_dir / f"{run_id}.ini"
|
|
report_dir = output_dir / "report"
|
|
report_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
full_params = self._merge_with_defaults(params)
|
|
ini_text = self._render(
|
|
run_id, full_params, period_start, period_end,
|
|
report_dir, phase,
|
|
ea_file=ea_file, ea_symbol=ea_symbol, ea_timeframe=ea_timeframe,
|
|
optimize_mode=optimize_mode,
|
|
)
|
|
|
|
ini_path.write_text(ini_text, encoding="utf-8")
|
|
logger.debug(f"INI written: {ini_path}")
|
|
return ini_path
|
|
|
|
def default_params(self) -> dict[str, Any]:
|
|
"""Return all EA parameters at their default values."""
|
|
if self._schema is not None:
|
|
return self._schema.defaults()
|
|
return self._merge_with_defaults({})
|
|
|
|
# ── Internal ──────────────────────────────────────────────────────────────
|
|
|
|
def _merge_with_defaults(self, overrides: dict[str, Any]) -> dict[str, Any]:
|
|
"""Merge caller-supplied overrides with defaults."""
|
|
if self._schema is not None:
|
|
return self._schema.with_overrides(overrides)
|
|
# Legacy manifest path
|
|
result: dict[str, Any] = {}
|
|
manifest = self._manifest
|
|
for name, spec in manifest.items():
|
|
result[name] = overrides.get(name, spec.get("default", 0))
|
|
return result
|
|
|
|
def _format_value(self, name: str, value: Any) -> str:
|
|
"""Format a parameter value for the [TesterInputs] section."""
|
|
# Schema path
|
|
if self._schema is not None:
|
|
p = self._schema.get(name)
|
|
if p is not None:
|
|
return self._schema._fmt(p, value)
|
|
return str(value)
|
|
# Legacy manifest path
|
|
spec = self._manifest.get(name, {})
|
|
ptype = spec.get("type", "float")
|
|
if ptype == "bool":
|
|
return "true" if value else "false"
|
|
if ptype == "fixed":
|
|
return str(value)
|
|
if ptype in ("int", "enum"):
|
|
return str(int(value))
|
|
if ptype == "float":
|
|
step = spec.get("step", 0.1)
|
|
decimals = len(str(step).split(".")[-1]) if "." in str(step) else 0
|
|
return f"{float(value):.{decimals}f}"
|
|
return str(value)
|
|
|
|
def _render(
|
|
self,
|
|
run_id: str,
|
|
params: dict[str, Any],
|
|
period_start: str,
|
|
period_end: str,
|
|
report_dir: Path,
|
|
phase: str,
|
|
ea_file: Optional[str] = None,
|
|
ea_symbol: Optional[str] = None,
|
|
ea_timeframe: Optional[str] = None,
|
|
optimize_mode: bool = False,
|
|
) -> str:
|
|
"""Render final INI content as a string."""
|
|
mt5_cfg = self.cfg["mt5"]
|
|
broker_cfg = self.cfg["broker"]
|
|
|
|
# EA identity: prefer explicit args, fall back to config["ea"] for legacy
|
|
ea_cfg = self.cfg.get("ea", {})
|
|
_ea_file = ea_file or ea_cfg.get("file", "EA")
|
|
_symbol = ea_symbol or ea_cfg.get("symbol", "XAUUSD")
|
|
_tf_str = ea_timeframe or ea_cfg.get("timeframe", "H1")
|
|
tf_name = TIMEFRAME_NAMES.get(_tf_str.upper(), "H1")
|
|
|
|
model_code = mt5_cfg.get("tester_model", 4)
|
|
report_name = f"Optimizer_{run_id}"
|
|
opt_value = 2 if optimize_mode else 0 # 2=genetic, 0=single backtest
|
|
|
|
lines = [
|
|
f"; MT5 Optimizer INI — run_id={run_id} phase={phase}",
|
|
f"",
|
|
f"[Tester]",
|
|
f"Expert={_ea_file}",
|
|
f"Symbol={_symbol}",
|
|
f"Period={tf_name}",
|
|
f"Optimization={opt_value}",
|
|
f"Model={model_code}",
|
|
f"FromDate={period_start}",
|
|
f"ToDate={period_end}",
|
|
f"ForwardMode=0",
|
|
f"Report={report_name}",
|
|
f"ReplaceReport=1",
|
|
f"ShutdownTerminal={mt5_cfg.get('shutdown_terminal', 1)}",
|
|
f"Deposit={broker_cfg['deposit']}",
|
|
f"Currency={broker_cfg['currency']}",
|
|
f"Leverage={broker_cfg['leverage']}",
|
|
f"",
|
|
f"[TesterInputs]",
|
|
]
|
|
|
|
# Use schema's INI renderer if available
|
|
if self._schema is not None:
|
|
lines.append(self._schema.to_ini_inputs(params, optimize_mode=optimize_mode))
|
|
else:
|
|
for name, value in params.items():
|
|
formatted = self._format_value(name, value)
|
|
lines.append(f"{name}={formatted}")
|
|
|
|
lines.append("") # trailing newline
|
|
return "\n".join(lines)
|