47f6012eb2
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 ✓
213 lines
8.4 KiB
Python
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
|