Files
LEGSTECH Optimizer d5a8973c05 feat(step1): Universal EA Schema Layer — replaces param_manifest.yaml
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
2026-04-13 19:55:16 +00:00

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)