""" ea/registry.py EA Profile storage and retrieval. Profiles are stored in ea_registry.yaml (path from config.yaml paths.ea_registry). The registry is the single source of truth for which EAs are registered and how to find their .set files. """ from __future__ import annotations from dataclasses import dataclass, field, asdict from datetime import datetime, timezone from pathlib import Path from typing import Optional import yaml from loguru import logger from ea.schema import ParameterSchema from ea.set_parser import SetParser # ── EAProfile ───────────────────────────────────────────────────────────────── @dataclass class EAProfile: """Configuration for one EA registered in the optimizer.""" name: str # Display name, e.g. "LEGSTECH_EA_V2" ex5_file: str # MT5 Experts file name (without .ex5 extension) set_template: str # Absolute path string to template .set file symbol: str # e.g. "XAUUSD" timeframe: str # e.g. "H1" mode: str = "generic" # "generic" | "advanced" registered_at: str = field( default_factory=lambda: datetime.now(timezone.utc).isoformat() ) # Params the user has chosen to optimize (param names → True/False). # Empty dict means: use default_optimize logic in SetParser. optimize_params: dict[str, bool] = field(default_factory=dict) # Automation overrides: param values that must be used during backtesting, # regardless of what the .set template says. # Example: {"InpShowPanel": 0, "InpTesterMode": 1} automation_overrides: dict[str, object] = field(default_factory=dict) def __post_init__(self): assert self.mode in ("generic", "advanced"), \ f"EAProfile.mode must be 'generic' or 'advanced', got {self.mode!r}" assert self.timeframe in ( "M1","M5","M15","M30","H1","H4","D1","W1","MN" ), f"Invalid timeframe: {self.timeframe}" @property def set_template_path(self) -> Path: return Path(self.set_template) def to_dict(self) -> dict: return asdict(self) @classmethod def from_dict(cls, d: dict) -> "EAProfile": return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__}) # ── EARegistry ──────────────────────────────────────────────────────────────── class EARegistry: """ Manages registered EA profiles, persisted in ea_registry.yaml. Usage: reg = EARegistry("config.yaml") profile = reg.get("LEGSTECH_EA_V2") schema = reg.get_schema(profile) """ def __init__(self, config_path: str | Path = "config.yaml"): config_path = Path(config_path) with open(config_path) as f: cfg = yaml.safe_load(f) registry_rel = cfg.get("paths", {}).get("ea_registry", "ea_registry.yaml") self._registry_path = config_path.parent / registry_rel self._parser = SetParser() self._profiles: dict[str, EAProfile] = {} self._load() # ── Public API ──────────────────────────────────────────────────────────── def register(self, profile: EAProfile) -> None: """Add or update an EA profile.""" self._profiles[profile.name] = profile self._save() logger.info(f"EARegistry: registered {profile.name!r} (mode={profile.mode})") def get(self, name: str) -> EAProfile: """Get a registered EA profile by name. Raises KeyError if not found.""" if name not in self._profiles: available = list(self._profiles.keys()) raise KeyError( f"EA {name!r} not registered. Available: {available}" ) return self._profiles[name] def list_all(self) -> list[EAProfile]: """Return all registered profiles.""" return list(self._profiles.values()) def remove(self, name: str) -> None: """Unregister an EA.""" self._profiles.pop(name, None) self._save() logger.info(f"EARegistry: removed {name!r}") def exists(self, name: str) -> bool: return name in self._profiles def get_schema( self, profile: EAProfile, apply_optimize_selection: bool = True, ) -> ParameterSchema: """ Parse the EA's .set file and return a ParameterSchema. Applies automation_overrides to ensure headless-safe defaults. Applies optimize_params selection if present. """ if not profile.set_template_path.exists(): raise FileNotFoundError( f"Set template not found: {profile.set_template}\n" f"Please update the path in EA Registry for {profile.name!r}." ) schema = self._parser.parse( path=profile.set_template_path, ea_name=profile.name, default_optimize=False, ) # Apply automation overrides: force specific param values # (e.g. InpShowPanel=0 so no GUI renders during headless backtests) for pname, value in profile.automation_overrides.items(): if pname in schema.parameters: schema.parameters[pname].default = value schema.parameters[pname].type = "fixed" schema.parameters[pname].optimize = False if apply_optimize_selection and profile.optimize_params: for pname, should_opt in profile.optimize_params.items(): if pname in schema.parameters: p = schema.parameters[pname] if p.type != "fixed": p.optimize = should_opt elif not profile.optimize_params: # No selection yet — mark all non-fixed as optimizable by default for p in schema.parameters.values(): if p.type != "fixed": p.optimize = True return schema def update_optimize_params(self, name: str, optimize_params: dict[str, bool]) -> None: """Update which parameters to optimize for a registered EA.""" profile = self.get(name) profile.optimize_params = optimize_params self._save() def list_all(self) -> list: """Return all registered EAProfile objects (for UI dropdowns).""" return list(self._profiles.values()) # ── Internal ───────────────────────────────────────────────────────────── def _load(self) -> None: """Load profiles from YAML. Creates the file if it doesn't exist.""" if not self._registry_path.exists(): logger.info(f"EARegistry: creating new registry at {self._registry_path}") self._registry_path.write_text("profiles: []\n", encoding="utf-8") return with open(self._registry_path, encoding="utf-8") as f: data = yaml.safe_load(f) or {} profiles_raw = data.get("profiles", []) for item in profiles_raw: try: p = EAProfile.from_dict(item) self._profiles[p.name] = p except Exception as e: logger.warning(f"EARegistry: skipped malformed profile {item}: {e}") logger.info(f"EARegistry: loaded {len(self._profiles)} profile(s) from {self._registry_path.name}") def _save(self) -> None: """Persist all profiles to YAML.""" data = {"profiles": [p.to_dict() for p in self._profiles.values()]} with open(self._registry_path, "w", encoding="utf-8") as f: yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False) logger.debug(f"EARegistry: saved {len(self._profiles)} profile(s)") def verify_integrity(self) -> list[str]: """ Check that all registered EAs have accessible .set files. Returns a list of error messages (empty = all OK). """ errors = [] for name, profile in self._profiles.items(): if not profile.set_template_path.exists(): errors.append(f"{name}: .set file missing at {profile.set_template}") return errors