Files
LEGSTECH Optimizer 47f6012eb2 feat: Smart Autonomous 3-Phase Optimizer — complete redesign
PROBLEM: Old system repeated identical params, all scores flat at 0.2500,
user had zero control over symbol/TF/dates. Not smart, not dynamic.

NEW ARCHITECTURE:
  optimizer/                        (NEW package)
  ├── __init__.py
  ├── session_config.py             User choices (EA, symbol, TF, dates, budget, objective)
  ├── lhs_sampler.py                Latin Hypercube Sampling — diverse exploration
  ├── result_ranker.py              Relative scoring (best in session=1.0, worst=0.0)
  ├── budget.py                     Time budget tracker
  └── pipeline.py                   3-phase orchestrator

  Phase 1 — Broad Discovery (LHS, 20-26 runs):
    Samples FULL parameter space, not just defaults±tiny step
    LHS guarantees coverage: all 17 optimizable LEGSTECH params explored
    Relative ranking: profitable configs float top, losers score 0

  Phase 2 — Refinement (9 runs):
    Neighbor search around top 3 configs at ±20% range (not ±0.5 step)
    Keeps best of Phase1 vs Phase2 — never regresses

  Phase 3 — Validation (5 runs):
    OOS backtest on unseen data period
    Sensitivity test: nudge params ±20%, detect fragility
    Verdict: RECOMMENDED / RISKY / NOT_RELIABLE

  Output: Clean downloadable .set file via /download_set/<run_id>

UI REDESIGN:
  ui/templates/landing.html         New / homepage (was old dashboard)
  ui/templates/setup.html           New /setup — EA, symbol, TF, dates, budget, objective
  ui/templates/dashboard.html       Updated /dashboard with:
    - 5-step phase indicator
    - Real progress bar per run
    - Phase 1 results table (top 5 after phase1)
    - Verdict banner with download button
    - No-profitable-config warning
  ui/static/js/dashboard.js         Handles 8 new pipeline SocketIO events

  app.py                            New routes: /, /setup, /dashboard
                                    /api/start accepts full SessionConfig JSON
                                    /download_set/<id> serves optimized .set

  ea/registry.py                    +list_all() for setup page dropdown
  ui/templates/reports_index.html   Back to Dashboard → /dashboard (was /)
  ui/static/css/style.css           +dot-warn, dot-done, profit-pos/neg, aliases

FIXES:
  Score no longer flat 0.2500 (was: absolute thresholds on losing EA)
  User now controls: symbol, timeframe, dates, budget, objective
  Parameters now span full range (was: tiny step from defaults)
  Verdict is actionable: RECOMMENDED / RISKY / NOT_RELIABLE with reason

TESTED:
  8/8 pre-flight checks pass
  Browser test: landing ✓, setup form ✓, /dashboard ✓,
                phase indicator active ✓, /reports ✓, back link ✓
2026-04-13 21:14:47 +00:00

213 lines
8.4 KiB
Python

"""
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