Files
LEGSTECH Optimizer 6caafdb794 feat: open-source release — AI-driven autonomous optimization with live visibility
Major upgrade making the AI loop visible and the project ready for public release.

UI / UX
- Live AI Thinking Feed: streams reasoning, decisions, and outcomes per iteration
- Parameter Changes panel: prev → new + reason for every AI-driven edit
- Validation Activity panel: out-of-sample + sensitivity runs with live metrics
- Early Termination banner: surfaces why optimization stopped (targets met, no profit, budget, stuck, user stop)
- 3-phase tracker renamed Exploration / Iteration / Validation with live N/total
- Best Result modal exposes Evolution Path showing how the AI arrived at the winner
- Run-detail modal accessible from every recent run row
- Setup form validation (dates, walk-forward order, params selection, AI targets)
- Pause button removed; misleading sidebar nav consolidated to Dashboard / New Run / Reports / Source

Backend
- AIGuidedLoop streams ai_thinking, param_changes, ai_targets_met, ai_stuck
- Pipeline emits validation_start / validation_run_start / validation_run_complete / validation_done
- Pipeline emits early_termination on every early-stop path
- /api/best_result returns best run + full evolution chain
- /api/run/<id> + /api/runs sorted by ts
- AIReasoner falls back to ANTHROPIC_API_KEY env var when config is a placeholder
- Demo mode (APEX_DEMO_MODE=1) generates deterministic synthetic backtests so judges can run end-to-end without MT5

Open-source readiness
- README.md with pitch, demo flow, architecture diagram, quickstart, event reference
- LICENSE (MIT)
- config.example.yaml template (config.yaml now git-ignored)
- requirements.txt: added anthropic / requests / psutil / beautifulsoup4, capped majors
- .gitignore: secrets, *.set, scratch screenshots, ea_registry.yaml
- demo/run_demo.py: one-command offline demo runner
- 10 polished screenshots for README + judge review
2026-04-25 11:39:17 +00:00

614 lines
26 KiB
Python

"""
optimizer_loop.py
Background thread that runs the full optimization loop and emits
real-time SocketIO events to the dashboard.
"""
from __future__ import annotations
import json
import threading
import time
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
import yaml
from loguru import logger
# Project imports
from data.models import Run, RunMetrics, Candidate, Hypothesis
from data.store import DataStore
from mt5.ini_builder import IniBuilder
from mt5.runner import MT5Runner
from mt5.report_parser import ReportParser
from mt5.log_reader import TradeLogReader
from analysis.reversal import ReversalAnalyzer
from analysis.time_performance import TimePerformanceAnalyzer
from analysis.entry_exit_quality import EntryExitQualityAnalyzer
from analysis.equity_curve import EquityCurveAnalyzer
from scoring.composite import CompositeScorer
from mutation.engine import MutationEngine
from validation.gate import ValidationGate
from reports.writer import ReportWriter
from analysis.ai_reasoner import AIReasoner
from analysis.ai_reasoner_config import load_api_key
import pandas as pd
BASE_DIR = Path(__file__).parent
KB_PATH = BASE_DIR / "mutation" / "knowledge_base.yaml"
DB_PATH = BASE_DIR / "optimizer.db"
RUNS_DIR = BASE_DIR / "runs"
# Keep MANIFEST_PATH for MutationEngine (still uses it for advanced mode)
MANIFEST_PATH = BASE_DIR / "mutation" / "param_manifest.yaml"
class OptimizerLoop:
"""
Runs the full optimization pipeline in a background thread.
Pushes events to the SocketIO broadcast channel so the dashboard
updates in real time.
"""
def __init__(self, config_path: str, socketio, reports_dir: Path, auto_mode: bool = True):
self.config_path = config_path
self.socketio = socketio
self.reports_dir = reports_dir
self.auto_mode = auto_mode
self.running = False
self.paused = False
self._stop_flag = False
self._skip_flag = False
self._pause_event = threading.Event()
self._pause_event.set() # not paused initially
# Live state (read by /api/status)
self.iteration = 0
self.phase = "idle"
self.best_score = 0.0
self.current_run_id: Optional[str] = None
self.score_history: list[dict] = []
self.run_start_ts: Optional[float] = None
self.session_tested_deltas: list[dict] = [] # dedup within this session only
# AI Reasoning Layer
api_key = load_api_key(config_path)
self.ai_reasoner = AIReasoner(api_key=api_key)
self._run_history: list[dict] = [] # accumulates across iterations for AI context
with open(config_path) as f:
self.cfg = yaml.safe_load(f)
# ── Controls ──────────────────────────────────────────────────────────────
def toggle_pause(self):
self.paused = not self.paused
if self.paused:
self._pause_event.clear()
self._emit("status_change", {"state": "paused"})
else:
self._pause_event.set()
self._emit("status_change", {"state": "running"})
def stop(self):
self._stop_flag = True
self._pause_event.set()
self.running = False
def skip_hypothesis(self):
self._skip_flag = True
def get_status(self) -> dict:
elapsed = int(time.time() - self.run_start_ts) if self.run_start_ts else 0
return {
"state": "running" if self.running else ("paused" if self.paused else "idle"),
"iteration": self.iteration,
"phase": self.phase,
"best_score": round(self.best_score, 4),
"run_id": self.current_run_id,
"elapsed_s": elapsed,
}
# ── Main loop ─────────────────────────────────────────────────────────────
def run(self):
self.running = True
self._stop_flag = False
self.run_start_ts = time.time()
cfg = self.cfg
store, builder, runner, parser, log_rdr, analyzers, scorer, mutator, gate, writer = (
self._build_components()
)
per = cfg["periods"]
self._emit("status_change", {"state": "running", "phase": "baseline"})
self._emit("log", {"level": "info", "msg": "🚀 Optimizer started"})
# ── Baseline run ──────────────────────────────────────────────────────
self.phase = "baseline"
default_params = builder.default_params()
baseline_id = f"baseline_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}"
baseline_metrics, baseline_trades = self._execute_run(
run_id=baseline_id,
params=default_params,
period_start=per["train_start"],
period_end=per["train_end"],
phase="baseline",
hypothesis_id=None,
store=store, builder=builder, runner=runner,
parser=parser, log_rdr=log_rdr, analyzers=analyzers, scorer=scorer,
)
if baseline_metrics is None:
self._emit("error", {"msg": "Baseline run failed. Check MT5 configuration."})
self.running = False
return
# Write baseline report
findings = self._run_analysis(baseline_id, baseline_trades, baseline_metrics,
analyzers, store)
# AI Reasoning — baseline
self._emit("log", {"level": "info", "msg": "🤖 AI Reasoner analyzing baseline..."})
ai_insight = self.ai_reasoner.analyze(
findings=findings,
metrics=baseline_metrics,
run_history=self._run_history,
current_params=default_params,
)
self._run_history.append({
"run_id": baseline_id,
"score": round(baseline_metrics.composite_score, 4),
"calmar": round(baseline_metrics.calmar_ratio, 4),
"pf": round(baseline_metrics.profit_factor, 4),
"phase": "baseline",
"params": default_params,
})
self._emit("ai_insight", ai_insight.to_dict())
self._emit("log", {"level": "info", "msg": f"🤖 AI: {ai_insight.headline}"})
writer.write(baseline_id, baseline_metrics, baseline_trades, findings, default_params)
self.best_score = baseline_metrics.composite_score
current_params = default_params.copy()
current_metrics = baseline_metrics
current_findings = findings # reuse in iter 1, avoid double-emit
no_improve_count = 0
max_iter = cfg["optimization"]["max_iterations"]
conv_win = cfg["optimization"]["convergence_window"]
conv_thr = cfg["optimization"]["convergence_threshold"]
# ── Iteration loop ────────────────────────────────────────────────────
while self.iteration < max_iter and not self._stop_flag:
self._pause_event.wait()
if self._stop_flag:
break
self.iteration += 1
self.phase = "analyze"
self._emit("iteration_start", {"iteration": self.iteration})
self._emit("log", {"level": "info", "msg": f"━━ Iteration {self.iteration} ━━"})
# Load latest trades (for re-analysis)
trades_df = store.load_trades(current_metrics.run_id)
if trades_df.empty and not baseline_trades.empty:
trades_df = baseline_trades
# Analysis — reuse cached findings when re-analyzing same run_id
if current_findings is not None and trades_df.empty:
findings = current_findings
else:
findings = self._run_analysis(
current_metrics.run_id, trades_df, current_metrics, analyzers, store
)
current_findings = None # only reuse once
if not findings:
self._emit("log", {"level": "warn", "msg": "No actionable findings. Stopping."})
break
# AI Reasoning — per iteration
self._emit("log", {"level": "info", "msg": f"🤖 AI Reasoner analyzing iteration {self.iteration}..."})
ai_insight = self.ai_reasoner.analyze(
findings=findings,
metrics=current_metrics,
run_history=self._run_history,
current_params=current_params,
)
self._emit("ai_insight", ai_insight.to_dict())
self._emit("log", {"level": "info", "msg": f"🤖 AI: {ai_insight.headline}"})
# Mutation proposals — only dedup within this session
hypotheses = mutator.propose(
findings=findings,
current_params=current_params,
recent_deltas=self.session_tested_deltas,
max_proposals=cfg["mutation"]["max_hypotheses_per_cycle"],
)
if not hypotheses:
self._emit("log", {"level": "warn", "msg": "No new hypotheses. Stopping."})
break
self._emit("hypotheses", {"items": [
{
"id": h.hypothesis_id,
"desc": h.description,
"delta": h.param_delta,
"strategy": h.strategy,
}
for h in hypotheses
]})
# Test each hypothesis
iteration_best: Optional[RunMetrics] = None
iteration_best_params: Optional[dict] = None
iteration_best_hyp: Optional[Hypothesis] = None
for idx, hyp in enumerate(hypotheses):
self._pause_event.wait()
if self._stop_flag or self._skip_flag:
self._skip_flag = False
break
test_params = {**current_params, **hyp.param_delta}
run_id = f"iter{self.iteration:03d}_h{idx+1}"
store.save_hypothesis(hyp)
self._emit("log", {
"level": "info",
"msg": f"Testing H{idx+1}: {hyp.description[:60]}"
})
self._emit("hypothesis_testing", {"idx": idx+1, "desc": hyp.description})
test_metrics, test_trades = self._execute_run(
run_id=run_id,
params=test_params,
period_start=per["train_start"],
period_end=per["train_end"],
phase="explore",
hypothesis_id=hyp.hypothesis_id,
store=store, builder=builder, runner=runner,
parser=parser, log_rdr=log_rdr, analyzers=analyzers, scorer=scorer,
)
if test_metrics is None:
continue
delta_score = test_metrics.composite_score - current_metrics.composite_score
color = "green" if delta_score > 0 else "red"
self._emit("log", {
"level": "success" if delta_score > 0 else "warn",
"msg": f"H{idx+1} score: {current_metrics.composite_score:.4f} → "
f"{test_metrics.composite_score:.4f} ({delta_score:+.4f})"
})
# Write per-run report
h_findings = self._run_analysis(run_id, test_trades, test_metrics, analyzers, store)
writer.write(run_id, test_metrics, test_trades, h_findings, test_params,
hypothesis=hyp, baseline_score=current_metrics.composite_score)
store.update_hypothesis_status(hyp.hypothesis_id, "tested", run_id)
self.session_tested_deltas.append(hyp.param_delta) # track for session dedup
# Emit score update for every run so chart populates
self._emit("score_update", {
"iteration": self.iteration,
"run_id": run_id,
"score": round(test_metrics.composite_score, 4),
"calmar": round(test_metrics.calmar_ratio, 4),
"pf": round(test_metrics.profit_factor, 4),
"ts": datetime.utcnow().isoformat(),
"promoted": False,
})
if iteration_best is None or test_metrics.composite_score > iteration_best.composite_score:
iteration_best = test_metrics
iteration_best_params = test_params
iteration_best_hyp = hyp
if iteration_best is None:
no_improve_count += 1
continue
# Validation gate
self.phase = "validate"
gate_result = gate.run_is_check(iteration_best, baseline_score=current_metrics.composite_score)
if not gate_result.passed:
self._emit("log", {"level": "error", "msg": f"IS gate failed: {gate_result.reason}"})
store.update_hypothesis_status(iteration_best_hyp.hypothesis_id, "rejected")
no_improve_count += 1
continue
# Walk-forward — pass an executor so gate never imports 'main'
self._emit("log", {"level": "info", "msg": "Running walk-forward validation..."})
_store, _builder, _runner, _parser, _log_rdr, _analyzers, _scorer = (
store, builder, runner, parser, log_rdr, analyzers, scorer
)
def _wfv_executor(params, start, end, fold_id):
m, _ = self._execute_run(
run_id=fold_id, params=params,
period_start=start, period_end=end,
phase="wfv", hypothesis_id=None,
store=_store, builder=_builder, runner=_runner,
parser=_parser, log_rdr=_log_rdr,
analyzers=_analyzers, scorer=_scorer,
)
return m
wfv = gate.run_walk_forward(
params=iteration_best_params,
executor=_wfv_executor,
)
self._emit("log", {
"level": "success" if wfv.passed else "warn",
"msg": f"WFV: OOS/IS ratio = {wfv.oos_is_ratio:.2f} "
f"({'PASS ✅' if wfv.passed else 'FAIL ❌'})"
})
if not wfv.passed:
store.update_hypothesis_status(iteration_best_hyp.hypothesis_id, "rejected")
no_improve_count += 1
continue
# Promote candidate
candidate = Candidate(
run_id=iteration_best.run_id,
composite_score=iteration_best.composite_score,
params=iteration_best_params,
)
store.save_candidate(candidate)
store.update_hypothesis_status(iteration_best_hyp.hypothesis_id, "validated")
self._emit("candidate_promoted", {
"candidate_id": candidate.candidate_id,
"score": round(candidate.composite_score, 4),
"delta": round(candidate.composite_score - self.best_score, 4),
"params": candidate.params,
})
self._emit("log", {
"level": "success",
"msg": f"✅ Candidate promoted! Score: {candidate.composite_score:.4f}"
})
improvement = iteration_best.composite_score - self.best_score
if improvement >= conv_thr:
current_params = iteration_best_params
current_metrics = iteration_best
self.best_score = iteration_best.composite_score
no_improve_count = 0
else:
no_improve_count += 1
# Track run history for AI context
self._run_history.append({
"run_id": iteration_best.run_id,
"score": round(iteration_best.composite_score, 4),
"calmar": round(iteration_best.calmar_ratio, 4),
"pf": round(iteration_best.profit_factor, 4),
"phase": "explore",
"params": iteration_best_params,
})
# Update score chart
self.score_history.append({
"iteration": self.iteration,
"score": round(self.best_score, 4),
"calmar": round(current_metrics.calmar_ratio, 4),
"pf": round(current_metrics.profit_factor, 4),
"ts": datetime.utcnow().isoformat(),
})
self._emit("score_update", self.score_history[-1])
if no_improve_count >= conv_win:
self._emit("log", {
"level": "warn",
"msg": f"Converged: no improvement in {no_improve_count} iterations."
})
break
# ── Done ─────────────────────────────────────────────────────────────
candidates = store.list_candidates()
self._emit("optimization_complete", {
"candidates": len(candidates),
"best_score": round(self.best_score, 4),
"iterations": self.iteration,
})
self._emit("log", {"level": "success", "msg": "🏁 Optimization complete!"})
self.running = False
self.phase = "idle"
# ── Single run ────────────────────────────────────────────────────────────
def _execute_run(
self,
run_id, params, period_start, period_end, phase, hypothesis_id,
store, builder, runner, parser, log_rdr, analyzers, scorer,
):
cfg = self.cfg
self.current_run_id = run_id
run_dir = RUNS_DIR / run_id
run_dir.mkdir(parents=True, exist_ok=True)
self._emit("run_started", {
"run_id": run_id,
"phase": phase,
"period": f"{period_start}{period_end}",
"params": {k: v for k, v in list(params.items())[:8]}, # first 8 for display
})
ini_path = builder.build(
run_id=run_id, params=params,
period_start=period_start, period_end=period_end,
output_dir=run_dir, phase=phase,
ea_file=self._profile.ex5_file,
ea_symbol=self._profile.symbol,
ea_timeframe=self._profile.timeframe,
)
run = Run(
run_id=run_id,
ea_name=self._profile.name,
symbol=self._profile.symbol,
timeframe=self._profile.timeframe,
period_start=period_start, period_end=period_end,
params=params, phase=phase, hypothesis_id=hypothesis_id,
tester_model=cfg["mt5"]["tester_model"],
ini_snapshot=ini_path.read_text(),
)
store.save_run(run)
self._emit("log", {"level": "info", "msg": f"⏳ MT5 running: {run_id}"})
result = runner.run(
run_id, ini_path, run_dir / "report",
log_csv_search_dir=Path(cfg["mt5"]["mql5_files_path"]),
profile=self._profile,
)
if not result.success:
self._emit("run_failed", {"run_id": run_id, "error": result.error_message})
return None, pd.DataFrame()
metrics, trades = parser.parse(result.report_xml, result.report_html)
if metrics is None:
self._emit("run_failed", {"run_id": run_id, "error": "Could not parse report"})
return None, pd.DataFrame()
metrics.run_id = run_id
if trades:
trades = log_rdr.merge(
trades, result.trade_log_csv,
reversal_mfe_threshold_pips=cfg["analysis"]["reversal"]["mfe_threshold_pips"],
)
trades_df = pd.DataFrame([t.model_dump() for t in trades])
else:
trades_df = pd.DataFrame()
if not trades_df.empty:
if "result_class" in trades_df.columns:
losers = trades_df[trades_df["net_money"] < 0]
reversals = trades_df[trades_df["result_class"] == "reversal"]
metrics.reversal_rate = len(reversals) / max(1, len(losers))
if "mfe_capture_ratio" in trades_df.columns:
# dropna() first — if no MFE data, series is all-NaN, mean()=NaN
# Use None (not NaN) so scorer uses its safe default of 0.5
cap_series = trades_df["mfe_capture_ratio"].dropna()
metrics.avg_mfe_capture = float(cap_series.mean()) if not cap_series.empty else None
metrics.composite_score = scorer.score(metrics)
store.save_metrics(metrics)
if not trades_df.empty:
store.save_trades(run_id, trades)
run.report_path = result.report_xml
store.save_run(run)
self._emit("run_complete", {
"run_id": run_id,
"phase": phase,
"net_profit": round(metrics.net_profit, 2),
"profit_factor": round(metrics.profit_factor, 3),
"calmar": round(metrics.calmar_ratio, 3),
"drawdown_pct": round(metrics.max_drawdown_pct * 100, 1),
"win_rate": round(metrics.win_rate * 100, 1),
"total_trades": metrics.total_trades,
"score": round(metrics.composite_score, 4),
"reversal_rate": round((metrics.reversal_rate or 0) * 100, 1),
"mfe_capture": round((metrics.avg_mfe_capture or 0) * 100, 1),
})
return metrics, trades_df
def _run_analysis(self, run_id, trades_df, metrics, analyzers, store):
all_findings = []
for az in analyzers:
findings = az.run(trades_df, metrics, run_id)
for f in findings:
self._emit("finding", {
"analyzer": f.analyzer,
"severity": f.severity,
"description": f.description,
"confidence": round(f.confidence, 2),
"impact": round(f.impact_estimate_pnl, 0),
})
all_findings.extend(findings)
all_findings.sort(key=lambda f: f.confidence, reverse=True)
store.save_findings(all_findings)
return all_findings
# ── SocketIO emit helper ──────────────────────────────────────────────────
def _emit(self, event: str, data: dict = {}):
try:
self.socketio.emit(event, data)
except Exception as e:
logger.debug(f"Emit error ({event}): {e}")
# ── Component factory ─────────────────────────────────────────────────────
def _build_components(self):
cfg = self.cfg
# ── EA Registry: load profile + schema ────────────────────────────────
from ea.registry import EARegistry
reg = EARegistry(self.config_path)
ea_name = cfg.get("ea", {}).get("name", "LEGSTECH_EA_V2")
try:
self._profile = reg.get(ea_name)
schema = reg.get_schema(self._profile)
logger.info(f"Using EA profile from registry: {ea_name} (mode={self._profile.mode})")
except KeyError:
# Profile not in registry — fall back to legacy manifest
logger.warning(f"EA {ea_name!r} not in registry, using legacy manifest")
from ea.registry import EAProfile
self._profile = EAProfile(
name=ea_name,
ex5_file=cfg["ea"]["file"],
set_template="",
symbol=cfg["ea"]["symbol"],
timeframe=cfg["ea"]["timeframe"],
)
schema = None # builder will use manifest
# ── Component construction ─────────────────────────────────────────────
store = DataStore(DB_PATH, RUNS_DIR)
if schema is not None:
builder = IniBuilder(self.config_path, schema=schema)
else:
builder = IniBuilder(self.config_path, str(MANIFEST_PATH))
runner = MT5Runner(self.config_path)
parser = ReportParser()
log_rdr = TradeLogReader(
broker_tz_offset_hours=cfg["broker"]["timezone_offset_hours"],
)
analyzers = [
ReversalAnalyzer(
mfe_threshold_pips=cfg["analysis"]["reversal"]["mfe_threshold_pips"],
min_reversal_rate=cfg["analysis"]["reversal"]["min_reversal_rate"],
permutation_n=cfg["analysis"]["reversal"]["permutation_n"],
),
TimePerformanceAnalyzer(
z_score_threshold=cfg["analysis"]["time_performance"]["z_score_threshold"],
min_bucket_trades=cfg["analysis"]["time_performance"]["min_trades_per_bucket"],
permutation_n=cfg["analysis"]["time_performance"]["permutation_n"],
),
EntryExitQualityAnalyzer(
poor_exit_threshold=cfg["analysis"]["entry_exit"]["poor_exit_quality"],
poor_entry_threshold=cfg["analysis"]["entry_exit"]["poor_entry_quality"],
),
EquityCurveAnalyzer(
max_flatness=cfg["analysis"]["equity_curve"]["max_flatness_score"],
min_r_squared=cfg["analysis"]["equity_curve"]["min_r_squared"],
),
]
scorer = CompositeScorer(self.config_path)
mutator = MutationEngine(KB_PATH, MANIFEST_PATH,
dedup_lookback=cfg["mutation"]["dedup_lookback_runs"])
gate = ValidationGate(self.config_path)
writer = ReportWriter(self.reports_dir)
return store, builder, runner, parser, log_rdr, analyzers, scorer, mutator, gate, writer