diff --git a/app.py b/app.py index 04d168b..67a19b6 100644 --- a/app.py +++ b/app.py @@ -1,18 +1,22 @@ """ -app.py — MT5 EA Optimizer Web App -Double-click to launch. Browser opens automatically at http://localhost:5000 +app.py — MT5 Smart EA Optimizer Web App +Routes: + / → Landing page + /setup → Configure new optimization session + /dashboard → Live optimization dashboard + /reports → Past runs browser """ import sys, os, threading, webbrowser, time from pathlib import Path -# ── Make sure imports resolve from project root ─────────────────────────────── BASE_DIR = Path(__file__).parent sys.path.insert(0, str(BASE_DIR)) -from flask import Flask, render_template, jsonify, request, send_from_directory +from flask import Flask, render_template, jsonify, request, send_from_directory, redirect from flask_socketio import SocketIO, emit -from optimizer_loop import OptimizerLoop +from optimizer.pipeline import OptimizationPipeline +from optimizer.session_config import SessionConfig # ── App setup ───────────────────────────────────────────────────────────────── app = Flask(__name__, @@ -24,76 +28,151 @@ socketio = SocketIO(app, cors_allowed_origins="*", async_mode="threading") REPORTS_DIR = BASE_DIR / "Reports" REPORTS_DIR.mkdir(exist_ok=True) -# Global optimizer instance -optimizer: OptimizerLoop = None -optimizer_thread: threading.Thread = None +# Global pipeline instance +pipeline: OptimizationPipeline = None +pipeline_thread: threading.Thread = None # ── Routes ──────────────────────────────────────────────────────────────────── @app.route("/") -def index(): - return render_template("index.html") +def landing(): + return render_template("landing.html") +@app.route("/setup") +def setup(): + """Setup page: EA selector, dates, budget, objective.""" + import yaml + from ea.registry import EARegistry + try: + reg = EARegistry(str(BASE_DIR / "config.yaml")) + eas = reg.list_all() + default_ea = eas[0].name if eas else "LEGSTECH_EA_V2" + + # Load param list for the first EA (or selected) + ea_name = request.args.get("ea", default_ea) + profile = reg.get(ea_name) + schema = reg.get_schema(profile, apply_optimize_selection=False) + params = [p for p in schema.all_params() if p.type != "fixed"] + except Exception as e: + eas = [] + default_ea = "LEGSTECH_EA_V2" + params = [] + + return render_template("setup.html", + registered_eas=eas, + default_ea=default_ea, + params=params) + + +@app.route("/dashboard") +def dashboard(): + return render_template("dashboard.html") + + +# Keep old / redirect for muscle memory +@app.route("/index") +def old_index(): + return redirect("/dashboard") + + +# ── API ─────────────────────────────────────────────────────────────────────── + @app.route("/api/status") def status(): - if optimizer is None: - return jsonify({"state": "idle", "iteration": 0, "best_score": 0}) - return jsonify(optimizer.get_status()) + if pipeline is None: + return jsonify({"state": "idle", "run_count": 0, "total_runs": 0, + "best_score": 0, "phase": "idle"}) + return jsonify(pipeline.get_status()) @app.route("/api/start", methods=["POST"]) def start(): - global optimizer, optimizer_thread - if optimizer and optimizer.running: - return jsonify({"ok": False, "msg": "Already running"}) - + global pipeline, pipeline_thread + + if pipeline and pipeline.running: + return jsonify({"ok": False, "msg": "Optimization already running"}) + data = request.get_json(silent=True) or {} - optimizer = OptimizerLoop( + + try: + session = SessionConfig.from_dict(data) + session.derive_samples() + except Exception as e: + return jsonify({"ok": False, "msg": f"Invalid config: {e}"}) + + pipeline = OptimizationPipeline( config_path=str(BASE_DIR / "config.yaml"), socketio=socketio, reports_dir=REPORTS_DIR, - auto_mode=data.get("auto", True), ) - optimizer_thread = threading.Thread(target=optimizer.run, daemon=True) - optimizer_thread.start() - return jsonify({"ok": True}) + pipeline.configure(session) + pipeline_thread = threading.Thread(target=pipeline.run, daemon=True) + pipeline_thread.start() -@app.route("/api/pause", methods=["POST"]) -def pause(): - if optimizer: - optimizer.toggle_pause() - return jsonify({"ok": True, "paused": optimizer.paused}) - return jsonify({"ok": False}) + return jsonify({"ok": True, "total_runs": session.total_budget_runs}) @app.route("/api/stop", methods=["POST"]) def stop(): - if optimizer: - optimizer.stop() - return jsonify({"ok": True}) - - -@app.route("/api/skip", methods=["POST"]) -def skip(): - if optimizer: - optimizer.skip_hypothesis() + if pipeline: + pipeline.stop() return jsonify({"ok": True}) @app.route("/api/history") def history(): - if optimizer is None: + """Score history for chart — built from pipeline results.""" + if pipeline is None: return jsonify([]) - return jsonify(optimizer.score_history) + results = pipeline.phase1_results + pipeline.phase2_results + return jsonify([ + { + "run_id": r.run_id, + "score": round(r.score, 4), + "calmar": round(r.calmar, 3), + "passing": r.passing, + "phase": r.phase, + } + for r in results + ]) +@app.route("/api/ea_params") +def ea_params(): + """Return param list for a given EA (used by setup page AJAX).""" + ea_name = request.args.get("ea", "") + try: + from ea.registry import EARegistry + reg = EARegistry(str(BASE_DIR / "config.yaml")) + profile = reg.get(ea_name) + schema = reg.get_schema(profile, apply_optimize_selection=False) + return jsonify([ + {"name": p.name, "type": p.type, + "range": p.range_label, "optimize": p.optimize} + for p in schema.all_params() if p.type != "fixed" + ]) + except Exception as e: + return jsonify({"error": str(e)}), 400 + + +@app.route("/download_set/") +def download_set(run_id): + """Serve the optimized .set file for download.""" + run_dir = REPORTS_DIR / run_id + set_files = list(run_dir.glob("*.set")) if run_dir.exists() else [] + if not set_files: + return "No .set file found", 404 + return send_from_directory(run_dir, set_files[0].name, as_attachment=True) + + +# ── Reports routes (unchanged) ──────────────────────────────────────────────── + @app.route("/reports") @app.route("/reports/") def reports_index(): - """Reports browser page — fixes the 404 on the Reports button.""" import json, re runs = [] for run_dir in sorted(REPORTS_DIR.iterdir(), reverse=True) if REPORTS_DIR.exists() else []: @@ -102,12 +181,10 @@ def reports_index(): summary = run_dir / "summary.json" if summary.exists(): try: - txt = summary.read_text(encoding="utf-8") - # Fix legacy NaN values (invalid JSON) written before the fix - txt = re.sub(r'\bNaN\b', 'null', txt) - txt = re.sub(r'\bInfinity\b', 'null', txt) + txt = summary.read_text(encoding="utf-8") + txt = re.sub(r'\bNaN\b', 'null', txt) + txt = re.sub(r'\bInfinity\b', 'null', txt) data = json.loads(txt) - # Replace None scores with 0 for display data["score"] = data.get("score") or 0 data["score_delta"] = data.get("score_delta") or 0 runs.append(data) @@ -118,7 +195,6 @@ def reports_index(): @app.route("/reports/") def reports_file(filename): - """Serve individual report files (HTML, CSV, JSON).""" return send_from_directory(REPORTS_DIR, filename) @@ -133,9 +209,9 @@ def runs_list(): summary = run_dir / "summary.json" if summary.exists(): try: - txt = summary.read_text(encoding="utf-8") - txt = re.sub(r'\bNaN\b', 'null', txt) - txt = re.sub(r'\bInfinity\b', 'null', txt) + txt = summary.read_text(encoding="utf-8") + txt = re.sub(r'\bNaN\b', 'null', txt) + txt = re.sub(r'\bInfinity\b', 'null', txt) data = json.loads(txt) data["score"] = data.get("score") or 0 data["score_delta"] = data.get("score_delta") or 0 @@ -145,12 +221,12 @@ def runs_list(): return jsonify(runs[:50]) -# ── SocketIO events ─────────────────────────────────────────────────────────── +# ── SocketIO ────────────────────────────────────────────────────────────────── @socketio.on("connect") def on_connect(): - if optimizer: - emit("status_sync", optimizer.get_status()) + if pipeline: + emit("status_sync", pipeline.get_status()) # ── Launch ──────────────────────────────────────────────────────────────────── @@ -162,7 +238,7 @@ def open_browser(): if __name__ == "__main__": print("=" * 60) - print(" MT5 EA Optimizer — Starting...") + print(" MT5 Smart EA Optimizer — Starting...") print(" Opening browser at http://localhost:5000") print("=" * 60) threading.Thread(target=open_browser, daemon=True).start() diff --git a/ea/registry.py b/ea/registry.py index 2d40d98..e681922 100644 --- a/ea/registry.py +++ b/ea/registry.py @@ -166,6 +166,11 @@ class EARegistry: 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: diff --git a/optimizer/__init__.py b/optimizer/__init__.py new file mode 100644 index 0000000..514f95f --- /dev/null +++ b/optimizer/__init__.py @@ -0,0 +1 @@ +"""optimizer/ — Smart 3-Phase EA Optimization Pipeline""" diff --git a/optimizer/budget.py b/optimizer/budget.py new file mode 100644 index 0000000..49841b4 --- /dev/null +++ b/optimizer/budget.py @@ -0,0 +1,76 @@ +""" +optimizer/budget.py +Time budget manager — tracks elapsed time and estimates remaining runs. +Updates its average-run-time estimate after every completed run. +""" +from __future__ import annotations + +import time +from loguru import logger + + +class BudgetManager: + """ + Tracks the time budget for an optimization session. + + Usage: + budget = BudgetManager(budget_minutes=60) + budget.start() + ...after each run: + budget.record_run(elapsed_seconds) + if budget.is_exhausted(): + break + remaining = budget.estimated_runs_remaining() + """ + + def __init__(self, budget_minutes: int, initial_seconds_per_run: float = 75.0): + self.budget_seconds = budget_minutes * 60 + self.avg_seconds_per_run = initial_seconds_per_run + self._start_ts: float = None + self._run_times: list[float] = [] + + def start(self) -> None: + self._start_ts = time.time() + logger.info(f"BudgetManager: started. Budget = {self.budget_seconds/60:.0f} min") + + @property + def elapsed_seconds(self) -> float: + if self._start_ts is None: + return 0.0 + return time.time() - self._start_ts + + @property + def remaining_seconds(self) -> float: + return max(0.0, self.budget_seconds - self.elapsed_seconds) + + @property + def elapsed_pct(self) -> float: + return min(100.0, self.elapsed_seconds / self.budget_seconds * 100) + + def record_run(self, seconds: float) -> None: + """Call after each run completes. Updates rolling average.""" + self._run_times.append(seconds) + # Rolling average (last 5 runs for responsiveness) + recent = self._run_times[-5:] + self.avg_seconds_per_run = sum(recent) / len(recent) + + def estimated_runs_remaining(self) -> int: + """How many more runs fit in the remaining budget.""" + if self.avg_seconds_per_run <= 0: + return 0 + return max(0, int(self.remaining_seconds / self.avg_seconds_per_run)) + + def is_exhausted(self) -> bool: + """True when less than 1 average run's time remains.""" + return self.remaining_seconds < self.avg_seconds_per_run + + def can_fit(self, n_runs: int) -> bool: + """True if n_runs more runs fit in remaining budget.""" + return self.remaining_seconds >= n_runs * self.avg_seconds_per_run + + def summary(self) -> str: + return ( + f"Budget: {self.elapsed_seconds/60:.1f}/{self.budget_seconds/60:.0f} min used. " + f"Avg run: {self.avg_seconds_per_run:.0f}s. " + f"Est. remaining: {self.estimated_runs_remaining()} runs." + ) diff --git a/optimizer/lhs_sampler.py b/optimizer/lhs_sampler.py new file mode 100644 index 0000000..412597b --- /dev/null +++ b/optimizer/lhs_sampler.py @@ -0,0 +1,186 @@ +""" +optimizer/lhs_sampler.py +Latin Hypercube Sampling — generates diverse parameter sets that +span the FULL optimization range with minimum samples. + +Why LHS instead of random: + With 20 samples and 8 parameters: + - Pure random: clusters near the center, misses extremes + - LHS: divides each parameter into 20 equal bands, samples exactly + one value per band — GUARANTEED coverage of the full space. + + Result: 20 LHS samples cover the space better than 200 random samples. +""" +from __future__ import annotations + +import random +import math +from typing import Any + +from loguru import logger + +from ea.schema import ParameterSchema, ParameterDef + + +class LatinHypercubeSampler: + """ + Generates n_samples diverse parameter dicts from a ParameterSchema. + + Usage: + sampler = LatinHypercubeSampler(seed=42) + samples = sampler.sample(schema, n_samples=20) + # → list of 20 dicts, each covering different regions + + Each sample is a complete param dict (all params, optimizable + fixed). + Fixed params always take their automation-safe default value. + """ + + def __init__(self, seed: int = None): + self.rng = random.Random(seed) + + def sample(self, schema: ParameterSchema, n_samples: int) -> list[dict[str, Any]]: + """ + Generate n_samples diverse parameter sets. + Returns list of complete param dicts (ready for IniBuilder). + """ + opts = schema.optimizable() + if not opts: + logger.warning("LHSampler: no optimizable params — returning n_samples copies of defaults") + return [schema.defaults() for _ in range(n_samples)] + + logger.info( + f"LHSampler: generating {n_samples} samples over " + f"{len(opts)} optimizable params: {[p.name for p in opts]}" + ) + + # Build LHS columns: one per optimizable param + # Each column is a shuffled list of n_samples values — each from a different band + columns: dict[str, list[Any]] = {} + for param in opts: + columns[param.name] = self._lhs_column(param, n_samples) + + # Assemble rows: combine column values + base = schema.defaults() + samples = [] + for i in range(n_samples): + row = dict(base) # start with all defaults (includes fixed params) + for param in opts: + row[param.name] = columns[param.name][i] + samples.append(row) + + return samples + + # ── Internal ───────────────────────────────────────────────────────────── + + def _lhs_column(self, param: ParameterDef, n: int) -> list[Any]: + """ + Generate n values for one parameter using Latin Hypercube spacing. + Each value comes from a different equally-sized band of the range. + The list is shuffled so rows don't correlate across parameters. + """ + if param.type == "bool": + # Alternate True/False, shuffled + vals = [True if i < n // 2 else False for i in range(n)] + # Ensure roughly 50/50 + if n % 2 == 1: + vals.append(self.rng.choice([True, False])) + vals = vals[:n] + self.rng.shuffle(vals) + return vals + + if param.type == "enum": + # Cycle through enum values with shuffled repetition + enum_vals = param.enum_values + vals = [] + while len(vals) < n: + cycle = list(enum_vals) + self.rng.shuffle(cycle) + vals.extend(cycle) + self.rng.shuffle(vals) + return vals[:n] + + if param.type in ("int", "float"): + return self._lhs_continuous(param, n) + + # fixed: return default repeated + return [param.default] * n + + def _lhs_continuous(self, param: ParameterDef, n: int) -> list[Any]: + """LHS for continuous (float) or discrete (int) parameters.""" + lo = float(param.min) + hi = float(param.max) + step = float(param.step) if param.step else (hi - lo) / n + + band_size = (hi - lo) / n + + values = [] + for i in range(n): + band_lo = lo + i * band_size + band_hi = band_lo + band_size + + # Random point within the band + raw = self.rng.uniform(band_lo, band_hi) + + # Snap to valid step grid + if step > 0: + steps_from_lo = round((raw - lo) / step) + snapped = lo + steps_from_lo * step + # Clamp to [lo, hi] + snapped = max(lo, min(hi, snapped)) + else: + snapped = raw + + values.append(param.clamp(snapped)) + + # Shuffle so the ordering isn't correlated with other params + self.rng.shuffle(values) + return values + + def sample_neighbors( + self, + base_params: dict[str, Any], + schema: ParameterSchema, + n_neighbors: int, + step_pct: float = 0.20, + ) -> list[dict[str, Any]]: + """ + Generate n_neighbors variants of base_params by nudging + the most impactful optimizable params by ±step_pct of their range. + + Used in Phase 2 refinement. + + step_pct=0.20 means ±20% of (max - min). Much larger + than the old ±0.5 step — actually explores the space. + """ + opts = schema.optimizable() + if not opts: + return [dict(base_params)] + + neighbors = [] + for _ in range(n_neighbors): + candidate = dict(base_params) + + # Perturb 2–4 random optimizable params + n_perturb = min(len(opts), self.rng.randint(2, 4)) + to_perturb = self.rng.sample(opts, n_perturb) + + for param in to_perturb: + current = candidate[param.name] + if param.type == "bool": + # 50% chance to flip + if self.rng.random() < 0.5: + candidate[param.name] = not current + continue + if param.type == "enum": + candidate[param.name] = self.rng.choice(param.enum_values) + continue + + # float / int: nudge by ±step_pct of range + span = float(param.max) - float(param.min) + delta = span * step_pct * self.rng.choice([-1, 1]) + raw = float(current) + delta + candidate[param.name] = param.clamp(raw) + + neighbors.append(candidate) + + return neighbors diff --git a/optimizer/pipeline.py b/optimizer/pipeline.py new file mode 100644 index 0000000..ce74c3f --- /dev/null +++ b/optimizer/pipeline.py @@ -0,0 +1,539 @@ +""" +optimizer/pipeline.py +The Smart 3-Phase Optimization Pipeline. + +Replaces optimizer_loop.py as the core orchestration engine. +Receives a SessionConfig → runs Phase 1, 2, 3 → emits SocketIO events. + +Architecture: + Phase 1: Broad Discovery (LHS samples, 20–30 runs) + Phase 2: Deep Refinement (neighbor search around top 3) + Phase 3: Validation (OOS backtest + sensitivity) + Decision: RECOMMENDED / RISKY / NOT_RELIABLE + .set file download +""" +from __future__ import annotations + +import time +import uuid +import threading +from datetime import datetime +from pathlib import Path +from typing import Any, Optional, Callable + +import yaml +from loguru import logger + +from ea.registry import EARegistry, EAProfile +from ea.schema import ParameterSchema +from mt5.ini_builder import IniBuilder +from mt5.runner import MT5Runner +from mt5.report_parser import ReportParser +from data.models import Run +from data.store import DataStore +from reports.writer import ReportWriter + +from optimizer.session_config import SessionConfig +from optimizer.lhs_sampler import LatinHypercubeSampler +from optimizer.result_ranker import ResultRanker, RankedResult +from optimizer.budget import BudgetManager + +import pandas as pd + +BASE_DIR = Path(__file__).parent.parent +RUNS_DIR = BASE_DIR / "runs" +DB_PATH = BASE_DIR / "optimizer.db" + + +class OptimizationPipeline: + """ + 3-phase autonomous optimization pipeline. Run in a background thread. + + Usage: + pipeline = OptimizationPipeline("config.yaml", socketio, reports_dir) + pipeline.configure(session_config) + thread = threading.Thread(target=pipeline.run, daemon=True) + thread.start() + """ + + def __init__(self, config_path: str, socketio, reports_dir: Path): + self.config_path = config_path + self.socketio = socketio + self.reports_dir = reports_dir + + with open(config_path) as f: + self.cfg = yaml.safe_load(f) + + # Runtime state + self.session: Optional[SessionConfig] = None + self.running = False + self._stop_flag = False + self._phase = "idle" + self._run_count = 0 + self._total_runs = 0 + self.best_result: Optional[RankedResult] = None + self.phase1_results: list[RankedResult] = [] + self.phase2_results: list[RankedResult] = [] + self.final_result: Optional[RankedResult] = None + self.verdict: Optional[str] = None + self.best_set_path: Optional[Path] = None + self.run_start_ts: Optional[float] = None + + # ── Public API ──────────────────────────────────────────────────────────── + + def configure(self, session: SessionConfig) -> None: + self.session = session + + def stop(self) -> None: + self._stop_flag = True + self._emit("status_change", {"state": "stopping"}) + + 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 "idle", + "phase": self._phase, + "run_count": self._run_count, + "total_runs": self._total_runs, + "best_score": round(self.best_result.score, 4) if self.best_result else 0.0, + "verdict": self.verdict, + "elapsed_s": elapsed, + "ea_name": self.session.ea_name if self.session else "", + "symbol": self.session.symbol if self.session else "", + "timeframe": self.session.timeframe if self.session else "", + } + + # ── Main entry point ────────────────────────────────────────────────────── + + def run(self) -> None: + self.running = True + self._stop_flag = False + self.run_start_ts = time.time() + + try: + self._run_pipeline() + except Exception as e: + logger.exception(f"Pipeline crashed: {e}") + self._emit("error", {"msg": f"Pipeline error: {e}"}) + finally: + self.running = False + self._phase = "idle" + self._emit("status_change", {"state": "idle"}) + + def _run_pipeline(self) -> None: + cfg = self.session + self._total_runs = cfg.total_budget_runs + + self._emit("status_change", {"state": "running", "phase": "setup"}) + self._log("info", f"🚀 Smart Optimizer started — {cfg.ea_name} | {cfg.symbol} | {cfg.timeframe}") + self._log("info", f"📋 Budget: {cfg.budget_minutes} min | Samples: {cfg.phase1_samples} Phase1 + {cfg.phase2_samples} Phase2 + {cfg.phase3_samples} Phase3") + + # ── Build components ───────────────────────────────────────────────── + reg = EARegistry(self.config_path) + profile = reg.get(cfg.ea_name) + schema = reg.get_schema(profile) + + # Apply user's param selection if specified + if cfg.selected_params: + for p in schema.parameters.values(): + if p.type != "fixed": + p.optimize = p.name in cfg.selected_params + + builder = IniBuilder(self.config_path, schema=schema) + runner = MT5Runner(self.config_path) + parser = ReportParser() + store = DataStore(DB_PATH, RUNS_DIR) + writer = ReportWriter(self.reports_dir) + sampler = LatinHypercubeSampler(seed=int(time.time())) + ranker = ResultRanker(weights=cfg.scoring_weights) + budget = BudgetManager(cfg.budget_minutes) + + budget.start() + + # ── Phase 1: Broad Discovery ───────────────────────────────────────── + if self._stop_flag: + return + + self._phase = "phase1" + self._emit("phase_start", {"phase": "phase1", "total": cfg.phase1_samples}) + self._log("info", f"━━ Phase 1: Broad Discovery ({cfg.phase1_samples} configurations) ━━") + + samples = sampler.sample(schema, cfg.phase1_samples) + phase1_raw: list[RankedResult] = [] + + for i, params in enumerate(samples): + if self._stop_flag: + break + + run_id = f"p1_{i+1:02d}_{datetime.utcnow().strftime('%H%M%S')}" + self._log("info", f"[{i+1}/{cfg.phase1_samples}] Testing configuration {i+1}...") + + t0 = time.time() + result = self._execute_run( + run_id, params, cfg.train_start, cfg.train_end, + "phase1", builder, runner, parser, store, writer, ranker, profile + ) + budget.record_run(time.time() - t0) + phase1_raw.append(result) + self._run_count += 1 + + # Emit progress after each run + self._emit("run_complete", { + "run_id": run_id, + "phase": "phase1", + "run_number": i + 1, + "total": cfg.phase1_samples, + "net_profit": round(result.net_profit, 2), + "calmar": round(result.calmar, 3), + "profit_factor": round(result.profit_factor, 3), + "win_rate": round(result.win_rate, 1), + "max_drawdown": round(result.max_drawdown, 2), + "total_trades": result.total_trades, + "passing": result.passing, + "progress_pct": round((i + 1) / cfg.phase1_samples * 100), + "budget_summary": budget.summary(), + }) + + if budget.is_exhausted(): + self._log("warning", "⏱ Time budget reached during Phase 1") + break + + # Rank Phase 1 results + self.phase1_results = ranker.rank(phase1_raw) + top5 = ranker.top_n(self.phase1_results, 5) + + n_passing = sum(1 for r in self.phase1_results if r.passing) + self._log("info" if n_passing > 0 else "warning", + f"Phase 1 complete: {n_passing}/{len(self.phase1_results)} profitable configurations found" + ) + + # Emit Phase 1 summary for checkpoint UI + self._emit("phase1_complete", { + "total_tested": len(self.phase1_results), + "n_passing": n_passing, + "top_results": [self._result_to_dict(r) for r in top5], + }) + + if not top5: + self._emit("no_profitable_config", { + "msg": ( + "Phase 1 found no profitable configuration. " + "Suggestions: try a different date range, check EA settings, " + "or try a different timeframe." + ) + }) + self._log("error", "❌ No profitable configuration found in Phase 1. Stopping.") + return + + if self._stop_flag: + return + + # ── Phase 2: Deep Refinement ───────────────────────────────────────── + if not budget.can_fit(3): + self._log("warning", "⏱ Not enough budget for Phase 2 — using Phase 1 winner directly") + self.final_result = top5[0] + else: + self._phase = "phase2" + self._emit("phase_start", {"phase": "phase2", "total": cfg.phase2_samples}) + self._log("info", f"━━ Phase 2: Deep Refinement (refining top {min(3, len(top5))} configs) ━━") + + top3 = top5[:3] + phase2_raw = [] + neighbors_per = cfg.phase2_samples // max(1, len(top3)) + + for rank_i, base_result in enumerate(top3): + if self._stop_flag: + break + + self._log("info", f" Refining config #{rank_i+1}: {base_result.run_id}") + neighbors = sampler.sample_neighbors( + base_result.params, schema, + n_neighbors=neighbors_per, step_pct=0.20 + ) + + for j, params in enumerate(neighbors): + if self._stop_flag or budget.is_exhausted(): + break + + run_id = f"p2_{rank_i+1}_{j+1:02d}_{datetime.utcnow().strftime('%H%M%S')}" + t0 = time.time() + result = self._execute_run( + run_id, params, cfg.train_start, cfg.train_end, + "phase2", builder, runner, parser, store, writer, ranker, profile + ) + budget.record_run(time.time() - t0) + phase2_raw.append(result) + self._run_count += 1 + + self._emit("run_complete", { + "run_id": run_id, + "phase": "phase2", + "net_profit": round(result.net_profit, 2), + "calmar": round(result.calmar, 3), + "passing": result.passing, + "progress_pct": round(self._run_count / self._total_runs * 100), + }) + + # Best from Phase 1 + Phase 2 combined + all_results = list(self.phase1_results) + ranker.rank(phase2_raw) + all_ranked = ranker.rank( + [r for r in all_results if r.passing] + or list(self.phase1_results) # fallback to Phase 1 if P2 all fail + ) + self.phase2_results = ranker.rank(phase2_raw) + self.final_result = all_ranked[0] if all_ranked else top5[0] + + self._emit("phase2_complete", { + "best_run_id": self.final_result.run_id, + "best_score": round(self.final_result.score, 4), + "best_profit": round(self.final_result.net_profit, 2), + "best_calmar": round(self.final_result.calmar, 3), + }) + self._log("info", + f"Phase 2 complete. Best config: {self.final_result.run_id} " + f"(profit=${self.final_result.net_profit:.0f}, calmar={self.final_result.calmar:.2f})" + ) + + if self._stop_flag: + return + + # ── Phase 3: Validation ─────────────────────────────────────────────── + if not budget.can_fit(2): + self._log("warning", "⏱ Not enough budget for Phase 3 validation — skipping OOS test") + self.verdict = "RISKY" + oos_result = None + else: + self._phase = "phase3" + self._emit("phase_start", {"phase": "phase3", "total": cfg.phase3_samples}) + self._log("info", "━━ Phase 3: Validation (out-of-sample + sensitivity) ━━") + + # OOS test + oos_id = f"oos_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}" + self._log("info", f" OOS test: {cfg.val_start} → {cfg.val_end}") + t0 = time.time() + oos_result = self._execute_run( + oos_id, self.final_result.params, + cfg.val_start, cfg.val_end, + "phase3_oos", builder, runner, parser, store, writer, ranker, profile + ) + budget.record_run(time.time() - t0) + self._run_count += 1 + + self._emit("run_complete", { + "run_id": oos_id, + "phase": "phase3_oos", + "net_profit": round(oos_result.net_profit, 2), + "calmar": round(oos_result.calmar, 3), + "passing": oos_result.passing, + }) + + # Sensitivity test (2 runs: nudge top param up and down) + sens_results = [] + opts = schema.optimizable() + if opts and budget.can_fit(2): + top_param = opts[0] # first optimizable param + for direction in [1, -1]: + if budget.is_exhausted() or self._stop_flag: + break + nudged = dict(self.final_result.params) + current = float(nudged.get(top_param.name, top_param.default)) + span = float(top_param.max) - float(top_param.min) + nudged[top_param.name] = top_param.clamp(current + direction * span * 0.20) + + sens_id = f"sens_{direction}_{datetime.utcnow().strftime('%H%M%S')}" + t0 = time.time() + sr = self._execute_run( + sens_id, nudged, cfg.train_start, cfg.train_end, + "phase3_sens", builder, runner, parser, store, writer, ranker, profile + ) + budget.record_run(time.time() - t0) + sens_results.append(sr) + self._run_count += 1 + + # Determine verdict + self.verdict = self._determine_verdict(self.final_result, oos_result, sens_results) + + # ── Generate .set output ───────────────────────────────────────────── + self.best_set_path = self._write_set_file(self.final_result, schema, cfg) + + # ── Final emit ─────────────────────────────────────────────────────── + self._emit("optimization_complete", { + "verdict": self.verdict, + "best_run_id": self.final_result.run_id, + "net_profit": round(self.final_result.net_profit, 2), + "calmar": round(self.final_result.calmar, 3), + "profit_factor": round(self.final_result.profit_factor, 3), + "win_rate": round(self.final_result.win_rate, 1), + "max_drawdown": round(self.final_result.max_drawdown, 2), + "total_trades": self.final_result.total_trades, + "oos_profit": round(oos_result.net_profit, 2) if oos_result else None, + "oos_calmar": round(oos_result.calmar, 3) if oos_result else None, + "set_file_url": f"/download_set/{self.final_result.run_id}" if self.best_set_path else None, + "total_runs": self._run_count, + "elapsed_min": round((time.time() - self.run_start_ts) / 60, 1), + }) + + verdict_icon = {"RECOMMENDED": "✅", "RISKY": "⚠️", "NOT_RELIABLE": "❌"}.get(self.verdict, "?") + self._log("success" if self.verdict == "RECOMMENDED" else "warning", + f"{verdict_icon} VERDICT: {self.verdict} | " + f"Profit: ${self.final_result.net_profit:.0f} | " + f"Calmar: {self.final_result.calmar:.2f}" + ) + + # ── Single run executor ─────────────────────────────────────────────────── + + def _execute_run( + self, run_id, params, period_start, period_end, phase, + builder, runner, parser, store, writer, ranker, profile + ) -> RankedResult: + """Execute one MT5 backtest and return a RankedResult.""" + run_dir = RUNS_DIR / run_id + run_dir.mkdir(parents=True, exist_ok=True) + + try: + 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=profile.ex5_file, + ea_symbol=profile.symbol, + ea_timeframe=profile.timeframe, + ) + + run = Run( + run_id=run_id, + ea_name=profile.name, symbol=profile.symbol, + timeframe=profile.timeframe, + period_start=period_start, period_end=period_end, + params=params, phase=phase, + tester_model=self.cfg["mt5"]["tester_model"], + ini_snapshot=ini_path.read_text(), + ) + store.save_run(run) + + result = runner.run( + run_id, ini_path, run_dir / "report", + log_csv_search_dir=Path(self.cfg["mt5"]["mql5_files_path"]), + profile=profile, + ) + + if not result.success: + return ranker.make_result(run_id, params, phase, None, + error=result.error_message) + + metrics, _ = parser.parse(result.report_xml, result.report_html) + if metrics is None: + return ranker.make_result(run_id, params, phase, None, + error="parse_failed") + + metrics.run_id = run_id + writer.write(run_id, metrics, pd.DataFrame(), [], params) + + return ranker.make_result(run_id, params, phase, metrics) + + except Exception as e: + logger.warning(f"[{run_id}] Run error: {e}") + return ranker.make_result(run_id, params, phase, None, error=str(e)) + + # ── Verdict logic ───────────────────────────────────────────────────────── + + def _determine_verdict( + self, + best: RankedResult, + oos: Optional[RankedResult], + sens: list[RankedResult], + ) -> str: + """ + RECOMMENDED: IS profitable + OOS profitable + not fragile + RISKY: IS profitable but OOS weak OR fragile + NOT_RELIABLE: IS marginal or OOS loss + """ + if best.calmar < 0.1 or best.net_profit <= 0: + return "NOT_RELIABLE" + + oos_ok = False + if oos and oos.net_profit > 0: + # OOS degradation: acceptable if OOS calmar ≥ 50% of IS calmar + oos_ratio = oos.calmar / max(best.calmar, 0.001) + oos_ok = oos_ratio >= 0.50 + elif oos is None: + oos_ok = True # No OOS test — can't penalize + + # Sensitivity: fragile if any nudge drops Calmar by >50% + fragile = any( + s.passing and s.calmar < best.calmar * 0.50 + for s in sens + ) if sens else False + + if oos_ok and not fragile and best.calmar >= 0.30: + return "RECOMMENDED" + if oos_ok or (not fragile and best.calmar >= 0.20): + return "RISKY" + return "NOT_RELIABLE" + + # ── .set file output ────────────────────────────────────────────────────── + + def _write_set_file( + self, result: RankedResult, schema: ParameterSchema, cfg: SessionConfig + ) -> Optional[Path]: + """Write a clean .set file for MT5 import.""" + try: + out_dir = self.reports_dir / result.run_id + out_dir.mkdir(parents=True, exist_ok=True) + out_path = out_dir / f"{cfg.ea_name}_optimized_{cfg.symbol}_{cfg.timeframe}.set" + + header = ( + f"Optimized by MT5 Smart Optimizer\n" + f"EA: {cfg.ea_name} | Symbol: {cfg.symbol} | TF: {cfg.timeframe}\n" + f"Training: {cfg.train_start} – {cfg.train_end}\n" + f"Verdict: {self.verdict}\n" + f"Net Profit: ${result.net_profit:.2f} | Calmar: {result.calmar:.2f} | " + f"Win Rate: {result.win_rate:.1f}%\n" + f"Generated: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}" + ) + + content = schema.to_set_file(result.params, header_comment=header) + out_path.write_text(content, encoding="utf-8") + logger.info(f"Optimized .set file written: {out_path}") + return out_path + except Exception as e: + logger.warning(f"Could not write .set file: {e}") + return None + + # ── Helpers ─────────────────────────────────────────────────────────────── + + def _result_to_dict(self, r: RankedResult) -> dict: + return { + "run_id": r.run_id, + "rank": r.rank, + "score": round(r.score, 4), + "net_profit": round(r.net_profit, 2), + "calmar": round(r.calmar, 3), + "profit_factor": round(r.profit_factor, 3), + "win_rate": round(r.win_rate, 1), + "max_drawdown": round(r.max_drawdown, 2), + "total_trades": r.total_trades, + "passing": r.passing, + "params_summary": self._params_summary(r.params), + } + + @staticmethod + def _params_summary(params: dict) -> str: + """Show a few key params for display.""" + keys = ["InpRiskPercent", "InpRRRatio", "InpMaxDailyLossPct", + "InpTrailStartPips", "InpMinScore", "InpSessionStart", "InpSessionEnd"] + parts = [] + for k in keys: + if k in params: + short = k.replace("Inp", "") + parts.append(f"{short}={params[k]}") + return " | ".join(parts[:4]) + + def _log(self, level: str, msg: str) -> None: + getattr(logger, level, logger.info)(msg) + self._emit("log", {"level": level, "msg": msg}) + + def _emit(self, event: str, data: dict = {}) -> None: + try: + self.socketio.emit(event, data) + except Exception as e: + logger.debug(f"Emit error ({event}): {e}") diff --git a/optimizer/result_ranker.py b/optimizer/result_ranker.py new file mode 100644 index 0000000..4c70f0c --- /dev/null +++ b/optimizer/result_ranker.py @@ -0,0 +1,178 @@ +""" +optimizer/result_ranker.py +Relative scoring and ranking of backtest results within a session. + +KEY DESIGN: Scores are relative to the current session — the best run +in the session = 1.0, worst passing = 0.0. This eliminates the flat +0.2500 problem from absolute thresholds. + +Failing runs (unprofitable or < 30 trades) always score 0.0 and +float to the bottom of the ranking. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Optional + +from loguru import logger + +MIN_TRADES = 30 # Runs with fewer trades have no statistical meaning + + +@dataclass +class RankedResult: + """One backtest run's result after scoring and ranking.""" + run_id: str + params: dict[str, Any] + phase: str # "phase1" | "phase2" | "phase3_oos" | "phase3_sens" + + # Raw metrics from ReportParser + net_profit: float = 0.0 + calmar: float = 0.0 + profit_factor: float = 0.0 + win_rate: float = 0.0 + max_drawdown: float = 0.0 + total_trades: int = 0 + + # Computed by ranker + raw_score: float = 0.0 # weighted before normalization + score: float = 0.0 # normalized 0–1 within session + passing: bool = False # True if profitable + enough trades + rank: int = 0 # 1 = best + + error: Optional[str] = None + + +class ResultRanker: + """ + Ranks a list of RankedResult objects using relative scoring. + + Usage: + ranker = ResultRanker(weights={"calmar": 0.5, "profit_factor": 0.3, "win_rate": 0.2}) + ranker.rank(results) # modifies in-place: sets .raw_score, .score, .passing, .rank + """ + + def __init__(self, weights: dict[str, float] = None): + self.weights = weights or { + "calmar": 0.50, + "profit_factor": 0.30, + "win_rate": 0.20, + } + + def rank(self, results: list[RankedResult]) -> list[RankedResult]: + """ + Score, classify (passing/failing), and rank all results. + Returns the same list sorted by rank (best first). + Modifies results in-place. + """ + # Step 1: Classify each result + for r in results: + r.passing = self._is_passing(r) + r.raw_score = self._raw_score(r) if r.passing else 0.0 + + # Step 2: Normalize scores within passing runs + passing = [r for r in results if r.passing] + failing = [r for r in results if not r.passing] + + if passing: + max_raw = max(r.raw_score for r in passing) + min_raw = min(r.raw_score for r in passing) + span = max_raw - min_raw + + for r in passing: + if span > 1e-9: + r.score = (r.raw_score - min_raw) / span + else: + r.score = 1.0 # all passing runs scored identically + + for r in failing: + r.score = 0.0 + + # Step 3: Sort (passing by score desc, failing at bottom) + passing.sort(key=lambda r: r.score, reverse=True) + failing.sort(key=lambda r: r.raw_score, reverse=True) + ranked = passing + failing + + for i, r in enumerate(ranked): + r.rank = i + 1 + + n_pass = len(passing) + n_fail = len(failing) + logger.info( + f"ResultRanker: {len(results)} runs — " + f"{n_pass} passing, {n_fail} failing. " + f"Best score: {passing[0].score:.3f} ({passing[0].run_id})" + if passing else + f"ResultRanker: {len(results)} runs — 0 passing (EA found no profitable config)" + ) + + return ranked + + def top_n(self, results: list[RankedResult], n: int) -> list[RankedResult]: + """Return top n passing results.""" + return [r for r in results if r.passing][:n] + + # ── Internal ───────────────────────────────────────────────────────────── + + def _is_passing(self, r: RankedResult) -> bool: + """A result passes if it's profitable AND has enough trades.""" + if r.error: + return False + if r.total_trades < MIN_TRADES: + return False + if r.net_profit <= 0: + return False + return True + + def _raw_score(self, r: RankedResult) -> float: + """Weighted composite score (before normalization).""" + w = self.weights + + # Calmar: cap at 5.0 to prevent wild outliers dominating + calmar_capped = min(max(r.calmar, 0.0), 5.0) / 5.0 + + # Profit factor: cap at 3.0 + pf_capped = min(max(r.profit_factor, 0.0), 3.0) / 3.0 + + # Win rate: 0–1 already + wr = max(0.0, min(1.0, r.win_rate / 100.0 if r.win_rate > 1 else r.win_rate)) + + score = ( + w.get("calmar", 0.5) * calmar_capped + + w.get("profit_factor", 0.3) * pf_capped + + w.get("win_rate", 0.2) * wr + ) + + # Optional net_profit boost (for max_profit objective) + if "net_profit" in w and w["net_profit"] > 0: + # Normalize profit to ~$10k scale + profit_norm = min(max(r.net_profit / 10000.0, 0.0), 1.0) + score += w["net_profit"] * profit_norm + + return score + + def make_result( + self, + run_id: str, + params: dict, + phase: str, + metrics, # RunMetrics from report_parser — or None on failure + error: str = None, + ) -> RankedResult: + """Convenience constructor from RunMetrics.""" + if metrics is None or error: + return RankedResult( + run_id=run_id, params=params, phase=phase, + error=error or "run_failed", + ) + return RankedResult( + run_id = run_id, + params = params, + phase = phase, + net_profit = getattr(metrics, "net_profit", 0.0) or 0.0, + calmar = getattr(metrics, "calmar_ratio", 0.0) or 0.0, + profit_factor = getattr(metrics, "profit_factor", 0.0) or 0.0, + win_rate = getattr(metrics, "win_rate", 0.0) or 0.0, + max_drawdown = getattr(metrics, "max_drawdown_pct", 0.0) or 0.0, + total_trades = getattr(metrics, "total_trades", 0) or 0, + ) diff --git a/optimizer/session_config.py b/optimizer/session_config.py new file mode 100644 index 0000000..d67e914 --- /dev/null +++ b/optimizer/session_config.py @@ -0,0 +1,116 @@ +""" +optimizer/session_config.py +Holds all user choices for one optimization session. +Passed from the /setup form → /api/start → pipeline. +""" +from __future__ import annotations +from dataclasses import dataclass, field, asdict +from typing import Literal, Optional + + +ObjectiveType = Literal["balanced", "max_profit", "min_drawdown"] +BudgetMinutes = Literal[30, 60, 120] + + +@dataclass +class SessionConfig: + """Everything the user configures on the /setup page.""" + + # EA identity + ea_name: str = "LEGSTECH_EA_V2" + symbol: str = "XAUUSD" + timeframe: str = "H1" + + # Training period (in-sample) + train_start: str = "2022.01.01" + train_end: str = "2023.12.31" + + # Validation period (out-of-sample) + val_start: str = "2024.01.01" + val_end: str = "2024.06.30" + + # Optimization objective + objective: ObjectiveType = "balanced" + + # Time budget + budget_minutes: int = 60 # 30 | 60 | 120 + + # Advanced: which params the user wants to optimize + # Empty list means "use profile's default optimize_params" + selected_params: list[str] = field(default_factory=list) + + # Phase 1 sample count (derived from budget, not user-set directly) + phase1_samples: int = 20 + + # ── Derived helpers ─────────────────────────────────────────────────────── + + def derive_samples(self, seconds_per_run: float = 75.0) -> None: + """ + Automatically set phase1_samples based on time budget. + Reserves ~40% of budget for Phase 2 + Phase 3. + """ + total_seconds = self.budget_minutes * 60 + phase1_budget = total_seconds * 0.55 # 55% for broad search + n = int(phase1_budget / seconds_per_run) + self.phase1_samples = max(10, min(n, 50)) # clamp 10–50 + + @property + def phase2_samples(self) -> int: + """Refinement runs: top 3 configs × 3 neighbors each = 9.""" + return 9 + + @property + def phase3_samples(self) -> int: + """Validation: 2 OOS runs + 3 sensitivity = 5.""" + return 5 + + @property + def total_budget_runs(self) -> int: + return self.phase1_samples + self.phase2_samples + self.phase3_samples + + # ── Scoring weights based on objective ─────────────────────────────────── + + @property + def scoring_weights(self) -> dict: + if self.objective == "max_profit": + return {"calmar": 0.3, "profit_factor": 0.3, "win_rate": 0.2, "net_profit": 0.2} + if self.objective == "min_drawdown": + return {"calmar": 0.6, "profit_factor": 0.25, "win_rate": 0.15, "net_profit": 0.0} + # balanced (default) + return {"calmar": 0.5, "profit_factor": 0.3, "win_rate": 0.2, "net_profit": 0.0} + + # ── Serialization ───────────────────────────────────────────────────────── + + def to_dict(self) -> dict: + return asdict(self) + + @classmethod + def from_dict(cls, d: dict) -> "SessionConfig": + known = {k: v for k, v in d.items() if k in cls.__dataclass_fields__} + return cls(**known) + + @classmethod + def from_form(cls, form: dict) -> "SessionConfig": + """ + Parse raw form POST data (all values are strings). + Handles type coercion, validation, and defaults. + """ + def s(key, default=""): return str(form.get(key, default)).strip() + def i(key, default=0): + try: return int(form.get(key, default)) + except (ValueError, TypeError): return default + + cfg = cls( + ea_name = s("ea_name", "LEGSTECH_EA_V2"), + symbol = s("symbol", "XAUUSD").upper(), + timeframe = s("timeframe", "H1").upper(), + train_start = s("train_start", "2022.01.01").replace("-", "."), + train_end = s("train_end", "2023.12.31").replace("-", "."), + val_start = s("val_start", "2024.01.01").replace("-", "."), + val_end = s("val_end", "2024.06.30").replace("-", "."), + objective = s("objective", "balanced"), + budget_minutes = i("budget_minutes", 60), + selected_params = form.get("selected_params", []), + ) + cfg.derive_samples() + return cfg diff --git a/ui/static/css/style.css b/ui/static/css/style.css index 07caa66..8ac67ef 100644 --- a/ui/static/css/style.css +++ b/ui/static/css/style.css @@ -71,8 +71,16 @@ body { .dot-idle { background: var(--text-muted); } .dot-running { background: var(--green); box-shadow: 0 0 8px var(--green); animation: pulse 1.5s infinite; } .dot-paused { background: var(--yellow); } +.dot-warn { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); } +.dot-done { background: var(--green); } .dot-error { background: var(--red); } +/* Alias for dashboard pipeline inline styles */ +:root { --card-bg: var(--bg-card); --border-color: var(--border); } +.profit-pos { color: var(--green); } +.profit-neg { color: var(--red); } +.log-warning { color: var(--yellow); } + @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } } diff --git a/ui/static/js/dashboard.js b/ui/static/js/dashboard.js index 65b8cbb..7b0666d 100644 --- a/ui/static/js/dashboard.js +++ b/ui/static/js/dashboard.js @@ -1,387 +1,466 @@ -/* dashboard.js — Real-time MT5 Optimizer Dashboard */ +/** + * dashboard.js — Smart Optimizer Live Dashboard + * Handles all SocketIO events from the 3-phase pipeline. + */ -// ── Socket.IO connection ─────────────────────────────────────────────────── -const socket = io(); -let startTime = null; -let timerInterval = null; -let findingsCount = 0; -let candidatesCount = 0; -let currentParams = {}; - -// ── Chart setup ──────────────────────────────────────────────────────────── -const ctx = document.getElementById('scoreChart').getContext('2d'); -const scoreChart = new Chart(ctx, { +// ── Chart Setup ─────────────────────────────────────────────────────────────── +const ctx = document.getElementById('scoreChart').getContext('2d'); +const chart = new Chart(ctx, { type: 'line', data: { labels: [], datasets: [ { - label: 'Composite Score', + label: 'Session Score', data: [], - borderColor: '#22d3a5', - backgroundColor: 'rgba(34,211,165,0.08)', - fill: true, - tension: 0.4, - pointRadius: 4, - pointBackgroundColor: '#22d3a5', + borderColor: '#00d4aa', + backgroundColor: 'rgba(0,212,170,0.08)', borderWidth: 2, + pointRadius: 4, + pointHoverRadius: 6, + pointBackgroundColor: ctx => { + const v = ctx.raw; + return (v !== null && v > 0) ? '#00d4aa' : '#ef4444'; + }, + tension: 0.35, + fill: true, }, { label: 'Calmar Ratio', data: [], - borderColor: '#4f8ef7', - backgroundColor: 'rgba(79,142,247,0.05)', - fill: false, - tension: 0.4, - pointRadius: 3, + borderColor: '#7c6dfa', borderWidth: 1.5, - borderDash: [4, 2], - }, + pointRadius: 2, + borderDash: [4, 3], + tension: 0.35, + fill: false, + } ] }, options: { responsive: true, maintainAspectRatio: false, - animation: { duration: 500 }, + animation: { duration: 400 }, plugins: { legend: { - labels: { color: '#6b7fa3', font: { size: 11, family: 'Inter' } } + labels: { color: '#94a3b8', font: { family: 'Inter', size: 11 }, boxWidth: 12 } }, tooltip: { - backgroundColor: '#111621', - borderColor: '#2a3a6e', + backgroundColor: '#141b27', + borderColor: 'rgba(255,255,255,0.1)', borderWidth: 1, - titleColor: '#e2e8f8', - bodyColor: '#6b7fa3', + titleColor: '#e2e8f0', + bodyColor: '#94a3b8', + callbacks: { + label: ctx => { + if (ctx.dataset.label === 'Session Score') return ` Score: ${(ctx.raw || 0).toFixed(4)}`; + return ` Calmar: ${(ctx.raw || 0).toFixed(3)}`; + } + } } }, scales: { - x: { - ticks: { color: '#6b7fa3', font: { size: 10 } }, - grid: { color: 'rgba(30,39,64,0.6)' }, - }, - y: { - ticks: { color: '#6b7fa3', font: { size: 10 } }, - grid: { color: 'rgba(30,39,64,0.6)' }, - min: 0, max: 1, - } + x: { ticks: { color: '#475569', font: { size: 10 }, maxTicksLimit: 12 }, grid: { color: 'rgba(255,255,255,0.04)' } }, + y: { min: 0, max: 1, ticks: { color: '#475569', font: { size: 10 } }, grid: { color: 'rgba(255,255,255,0.04)' } } } } }); -// ── Controls ─────────────────────────────────────────────────────────────── +// ── State ───────────────────────────────────────────────────────────────────── +let startTs = null; +let elapsedTimer = null; +let findingsCount = 0; +let runCount = 0; +let bestScore = 0; +let candCount = 0; +let totalRuns = 0; -function startOptimizer() { - fetch('/api/start', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ auto: true }) - }); - startTime = Date.now(); - timerInterval = setInterval(updateTimer, 1000); - setRunning(true); - addLog('info', 'Starting optimizer...'); -} +// ── SocketIO ────────────────────────────────────────────────────────────────── +const socket = io(); -function pauseOptimizer() { - fetch('/api/pause', { method: 'POST' }).then(r => r.json()).then(d => { - const btn = document.getElementById('btn-pause'); - btn.textContent = d.paused ? '▶ Resume' : '⏸ Pause'; - setDot(d.paused ? 'paused' : 'running', d.paused ? 'Paused' : 'Running...'); - }); -} +socket.on('connect', () => addLog('info', '🔗 Connected to optimizer server')); +socket.on('disconnect', () => addLog('warning', '⚡ Disconnected from server')); + +// Status sync on connect +socket.on('status_sync', (s) => { + setStatus(s.state, s.phase); + if (s.ea_name && s.symbol && s.timeframe) { + document.getElementById('session-label').textContent = + `${s.ea_name} · ${s.symbol} · ${s.timeframe}`; + } + totalRuns = s.total_runs || 0; + updateRunCount(s.run_count || 0); + if (s.state === 'running') { + document.getElementById('progress-wrap').style.display = ''; + document.getElementById('btn-stop').classList.remove('hidden'); + } +}); + +socket.on('status_change', (d) => { + setStatus(d.state, d.phase); + if (d.state === 'running') { + startTs = Date.now(); + startElapsedTimer(); + document.getElementById('progress-wrap').style.display = ''; + document.getElementById('btn-stop').classList.remove('hidden'); + } + if (d.state === 'idle' || d.state === 'stopping') { + document.getElementById('btn-stop').classList.add('hidden'); + } +}); + +// Each run completes +socket.on('run_complete', (d) => { + updateRunCount((d.run_number != null) ? null : runCount + 1, d); + + // Update metrics + const profit = d.net_profit || 0; + el('m-profit').textContent = `$${profit.toFixed(0)}`; + el('m-profit').className = 'metric-val ' + (profit >= 0 ? 'profit-pos' : 'profit-neg'); + el('m-calmar').textContent = (d.calmar || 0).toFixed(3); + el('m-pf').textContent = d.profit_factor != null ? (d.profit_factor).toFixed(2) : '—'; + el('m-dd').textContent = d.max_drawdown != null ? `${d.max_drawdown.toFixed(1)}%` : '—'; + el('m-wr').textContent = d.win_rate != null ? `${d.win_rate.toFixed(1)}%` : '—'; + el('m-trades').textContent = d.total_trades != null ? d.total_trades : '—'; + el('current-run-id').textContent = d.run_id || '—'; + + // Session score (0–1) + const score = d.score || 0; + const scoreStr = score.toFixed(4); + el('m-score').textContent = d.passing ? scoreStr : 'failing'; + el('score-bar-fill').style.width = `${Math.round(score * 100)}%`; + + // Update best score header + if (d.passing && score > bestScore) { + bestScore = score; + el('hdr-score').textContent = scoreStr; + } + + // Add to chart + const label = d.run_id ? d.run_id.slice(-6) : `${runCount}`; + chart.data.labels.push(label); + chart.data.datasets[0].data.push(d.passing ? score : 0); + + // Calmar normalized to 0-1 range (cap at 3) + const calmarNorm = Math.min(Math.max((d.calmar || 0) / 3.0, 0), 1); + chart.data.datasets[1].data.push(d.passing ? calmarNorm : 0); + + chart.update('none'); + el('chart-badge').textContent = `${chart.data.labels.length} runs`; + + // Progress bar + if (d.progress_pct != null) { + el('progress-bar-fill').style.width = `${d.progress_pct}%`; + } + if (d.phase) updatePhaseLabel(d.phase, d.run_number, d.total); + if (d.budget_summary) el('progress-budget').textContent = d.budget_summary; + + // Add to best configs panel if profitable + if (d.passing) { + addCandidate(d); + } +}); + +// Phase start +socket.on('phase_start', (d) => { + const labels = { phase1: 'Phase 1: Broad Discovery', phase2: 'Phase 2: Refinement', phase3: 'Phase 3: Validation' }; + el('progress-phase').textContent = labels[d.phase] || d.phase; + el('progress-count').textContent = `0 / ${d.total || '?'}`; + setPhaseActive(d.phase); + if (d.phase === 'phase2') { + markPhaseDone('phase1'); + el('phase1-results-card').style.display = ''; + } + if (d.phase === 'phase3') { + markPhaseDone('phase2'); + } +}); + +// Phase 1 complete — show results table +socket.on('phase1_complete', (d) => { + el('phase1-results-card').style.display = ''; + el('phase1-badge').textContent = `${d.n_passing}/${d.total_tested} profitable`; + renderPhase1Table(d.top_results || []); + addLog('info', `━━ Phase 1 done: ${d.n_passing}/${d.total_tested} profitable configs found. Starting refinement...`); +}); + +// Phase 2 complete +socket.on('phase2_complete', (d) => { + addLog('success', `✅ Phase 2 done. Best: ${d.best_run_id} · Profit: $${d.best_profit} · Calmar: ${d.best_calmar}`); +}); + +// No profitable configuration found +socket.on('no_profitable_config', (d) => { + el('no-profit-banner').style.display = ''; + el('no-profit-msg').textContent = d.msg || 'No profitable configuration found.'; + el('progress-wrap').style.display = 'none'; + addLog('error', '❌ ' + (d.msg || 'No profitable config found')); +}); + +// Optimization complete — show verdict +socket.on('optimization_complete', (d) => { + markPhaseDone('phase3'); + markPhaseDone('done'); + setStatus('idle', 'done'); + stopElapsedTimer(); + + el('progress-wrap').style.display = 'none'; + el('btn-stop').classList.add('hidden'); + + renderVerdict(d); + addLog(d.verdict === 'RECOMMENDED' ? 'success' : 'warning', + `🏁 Optimization complete! ${d.verdict} · `+ + `Profit: $${d.net_profit} · Calmar: ${d.calmar} · Runs: ${d.total_runs} · ${d.elapsed_min}min` + ); +}); + +// Log +socket.on('log', (d) => addLog(d.level, d.msg)); + +// Error +socket.on('error', (d) => addLog('error', '❌ ' + (d.msg || 'Unknown error'))); + + +// ── Actions ─────────────────────────────────────────────────────────────────── function stopOptimizer() { - fetch('/api/stop', { method: 'POST' }); - clearInterval(timerInterval); - setRunning(false); - setDot('idle', 'Stopped'); - addLog('warn', 'Optimizer stopped by user.'); + fetch('/api/stop', { method: 'POST' }).catch(() => {}); + addLog('warning', '⏹ Stop requested...'); } function clearLog() { - document.getElementById('log-feed').innerHTML = ''; + el('log-feed').innerHTML = ''; } -function setRunning(on) { - document.getElementById('btn-start').classList.toggle('hidden', on); - document.getElementById('btn-pause').classList.toggle('hidden', !on); - document.getElementById('btn-stop').classList.toggle('hidden', !on); - if (on) setDot('running', 'Running...'); -} -// ── Timer ────────────────────────────────────────────────────────────────── +// ── Rendering helpers ───────────────────────────────────────────────────────── -function updateTimer() { - if (!startTime) return; - const s = Math.floor((Date.now() - startTime) / 1000); - const m = Math.floor(s / 60); - const ss = String(s % 60).padStart(2, '0'); - document.getElementById('hdr-elapsed').textContent = `${m}:${ss}`; -} - -// ── State dot ────────────────────────────────────────────────────────────── - -function setDot(state, label) { - const dot = document.getElementById('state-dot'); - dot.className = `dot dot-${state}`; - document.getElementById('state-label').textContent = label; -} - -// ── Phase tracker ────────────────────────────────────────────────────────── - -const PHASES = ['baseline', 'analyze', 'explore', 'validate', 'oos']; - -function setPhase(phase) { - const idx = PHASES.indexOf(phase); - PHASES.forEach((p, i) => { - const el = document.getElementById(`phase-${p}`); - if (!el) return; - el.classList.remove('active', 'done'); - if (i < idx) el.classList.add('done'); - else if (i === idx) el.classList.add('active'); - }); -} - -// ── Metrics update ───────────────────────────────────────────────────────── - -function updateMetrics(d) { - const set = (id, val) => { - const el = document.getElementById(id); - if (el) el.textContent = val; - }; - set('m-profit', d.net_profit !== undefined ? `$${d.net_profit.toLocaleString()}` : '—'); - set('m-calmar', d.calmar !== undefined ? d.calmar.toFixed(3) : '—'); - set('m-pf', d.profit_factor !== undefined ? d.profit_factor.toFixed(3) : '—'); - set('m-dd', d.drawdown_pct !== undefined ? `${d.drawdown_pct}%` : '—'); - set('m-wr', d.win_rate !== undefined ? `${d.win_rate.toFixed(1)}%` : '—'); - set('m-trades', d.total_trades ?? '—'); - set('m-mfe', d.mfe_capture !== undefined ? `${d.mfe_capture.toFixed(1)}%` : '—'); - set('m-rev', d.reversal_rate !== undefined ? `${d.reversal_rate.toFixed(1)}%` : '—'); - set('m-score', d.score !== undefined ? d.score.toFixed(4) : '—'); - set('current-run-id', d.run_id || '—'); - - if (d.score !== undefined) { - const fill = document.getElementById('score-bar-fill'); - if (fill) fill.style.width = `${Math.min(100, d.score * 100)}%`; - set('hdr-score', d.score.toFixed(4)); - } - - // Color profit - const pEl = document.getElementById('m-profit'); - if (pEl && d.net_profit !== undefined) { - pEl.style.color = d.net_profit >= 0 ? 'var(--green)' : 'var(--red)'; - } -} - -// ── Findings ─────────────────────────────────────────────────────────────── - -function addFinding(f) { - findingsCount++; - const feed = document.getElementById('findings-feed'); - const empty = feed.querySelector('.finding-empty'); - if (empty) empty.remove(); - - const sevClass = { high: 'sev-high', medium: 'sev-medium', low: 'sev-low' }[f.severity] || 'sev-low'; - - const row = document.createElement('div'); - row.className = 'finding-row'; - row.innerHTML = ` - ${f.severity} - ${escHtml(f.description)} - ${(f.confidence * 100).toFixed(0)}% - `; - feed.insertBefore(row, feed.firstChild); - - // Keep max 20 findings visible - while (feed.children.length > 20) feed.removeChild(feed.lastChild); - - document.getElementById('findings-count').textContent = `${findingsCount} findings`; -} - -// ── Hypotheses / Params ──────────────────────────────────────────────────── - -function showHypotheses(items) { - if (!items || !items.length) return; - const h = items[0]; // show first (highest priority) - document.getElementById('hyp-desc').textContent = h.desc || ''; - document.getElementById('hyp-badge').textContent = `${items.length} hypothesis(es)`; - - const tbody = document.getElementById('params-tbody'); +function renderPhase1Table(results) { + const tbody = el('phase1-tbody'); tbody.innerHTML = ''; - Object.entries(h.delta || {}).forEach(([param, newVal]) => { - const oldVal = currentParams[param]; + results.forEach((r, i) => { const tr = document.createElement('tr'); + if (i === 0) tr.className = 'rank-1'; + const profitClass = r.net_profit >= 0 ? 'profit-pos' : 'profit-neg'; + const badge = r.passing + ? `✓ pass` + : `fail`; tr.innerHTML = ` - ${escHtml(param)} - ${oldVal !== undefined ? oldVal : '—'} - ${newVal} + #${r.rank || i+1} ${i === 0 ? '🥇' : ''} + $${(r.net_profit||0).toFixed(0)} + ${(r.calmar||0).toFixed(3)} + ${(r.profit_factor||0).toFixed(2)} + ${(r.win_rate||0).toFixed(1)}% + ${(r.max_drawdown||0).toFixed(1)}% + ${r.total_trades||0} + ${(r.score||0).toFixed(4)} `; tbody.appendChild(tr); }); } -// ── Log ──────────────────────────────────────────────────────────────────── +function renderVerdict(d) { + const banner = el('verdict-banner'); + const icons = { RECOMMENDED: '✅', RISKY: '⚠️', NOT_RELIABLE: '❌' }; + const labels = { + RECOMMENDED: 'Recommended', + RISKY: 'Risky', + NOT_RELIABLE: 'Not Reliable', + }; + const subs = { + RECOMMENDED: 'Consistent returns with controlled drawdown. Ready to deploy.', + RISKY: 'Profitable in-sample but shows signs of fragility or OOS degradation. Use with caution.', + NOT_RELIABLE: 'Results are inconsistent or not profitable enough. Consider different settings.', + }; -function addLog(level, msg) { - const feed = document.getElementById('log-feed'); - const line = document.createElement('div'); - line.className = `log-line log-${level}`; - const ts = new Date().toLocaleTimeString('en-GB', { hour12: false }); - line.textContent = `[${ts}] ${msg}`; - feed.insertBefore(line, feed.firstChild); - while (feed.children.length > 200) feed.removeChild(feed.lastChild); + const profitClass = d.net_profit >= 0 ? 'profit-pos' : 'profit-neg'; + const oos = d.oos_profit != null + ? `
$${d.oos_profit.toFixed(0)}
OOS Profit
` + : ''; + + banner.className = `verdict-banner ${d.verdict}`; + banner.innerHTML = ` +
+
${icons[d.verdict] || '?'}
+
+
${labels[d.verdict] || d.verdict}
+
${subs[d.verdict] || ''}
+
+
+
+
+
$${(d.net_profit||0).toFixed(0)}
+
Net Profit
+
+
+
${(d.calmar||0).toFixed(2)}
+
Calmar
+
+
+
${(d.win_rate||0).toFixed(1)}%
+
Win Rate
+
+
+
${(d.max_drawdown||0).toFixed(1)}%
+
Max DD
+
+ ${oos} +
+ + `; + banner.style.display = ''; } -// ── Candidates ───────────────────────────────────────────────────────────── - function addCandidate(d) { - candidatesCount++; - const list = document.getElementById('candidates-list'); + const list = el('candidates-list'); const empty = list.querySelector('.cand-empty'); if (empty) empty.remove(); - const deltaClass = d.delta >= 0 ? 'delta-up' : 'delta-dn'; - const deltaSign = d.delta >= 0 ? '+' : ''; + candCount++; + el('cand-count').textContent = `${candCount} config${candCount !== 1 ? 's' : ''}`; - const chip = document.createElement('div'); - chip.className = 'candidate-chip'; - chip.innerHTML = ` -
${d.score.toFixed(4)}
-
${deltaSign}${d.delta.toFixed(4)} vs prev
-
ID: ${d.candidate_id}
+ // Keep only top 10 + if (list.children.length >= 10) { + list.removeChild(list.lastChild); + } + + const card = document.createElement('div'); + card.className = 'cand-card'; + const profitClass = (d.net_profit||0) >= 0 ? 'profit-pos' : 'profit-neg'; + card.innerHTML = ` +
+ ${d.phase?.toUpperCase() || 'RUN'} · Score ${(d.score||0).toFixed(4)} + ${d.run_id} +
+
+
+
Profit
+
$${(d.net_profit||0).toFixed(0)}
+
+
+
Calmar
+
${(d.calmar||0).toFixed(3)}
+
+
+
Win%
+
${(d.win_rate||0).toFixed(1)}%
+
+
+
DD%
+
${(d.max_drawdown||0).toFixed(1)}%
+
+
+
Trades
+
${d.total_trades||0}
+
+
`; - list.insertBefore(chip, list.firstChild); - document.getElementById('cand-count').textContent = `${candidatesCount} candidates`; + list.insertBefore(card, list.firstChild); } -// ── Chart update ─────────────────────────────────────────────────────────── - -function pushChartPoint(d) { - const labels = scoreChart.data.labels; - // Label: 'Baseline' for first point, 'Iter N · run_id' for hypothesis runs - const lbl = d.run_id - ? (d.run_id.startsWith('baseline') ? 'Baseline' : `It${d.iteration}·${d.run_id.split('_').pop()}`) - : `Iter ${d.iteration}`; - labels.push(lbl); - scoreChart.data.datasets[0].data.push(d.score); - // Normalize calmar: clamp -0.5..2.0 → 0..1 for display - const calmarNorm = Math.max(0, Math.min(1, (d.calmar + 0.5) / 2.5)); - scoreChart.data.datasets[1].data.push(calmarNorm); - if (labels.length > 50) { - labels.shift(); - scoreChart.data.datasets.forEach(ds => ds.data.shift()); - } - scoreChart.update('none'); - document.getElementById('chart-badge').textContent = `Score: ${d.score.toFixed(4)}`; +function addLog(level, msg) { + const feed = el('log-feed'); + const div = document.createElement('div'); + div.className = `log-line log-${level}`; + const ts = new Date().toLocaleTimeString('en-GB', { hour12: false }); + div.textContent = `[${ts}] ${msg}`; + feed.appendChild(div); + feed.scrollTop = feed.scrollHeight; + // Max 200 lines + while (feed.children.length > 200) feed.removeChild(feed.firstChild); } -// ── Utility ──────────────────────────────────────────────────────────────── +// ── Phase indicator helpers ─────────────────────────────────────────────────── -function escHtml(str) { - return String(str) - .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +function setPhaseActive(phase) { + const map = { setup: 'phase-setup', phase1: 'phase-phase1', phase2: 'phase-phase2', phase3: 'phase-phase3' }; + const id = map[phase]; + if (!id) return; + document.querySelectorAll('.phase-step').forEach(s => s.classList.remove('active')); + const step = el(id); + if (step) step.classList.add('active'); } -// ── Socket events ────────────────────────────────────────────────────────── +function markPhaseDone(phase) { + const map = { phase1: 'phase-phase1', phase2: 'phase-phase2', phase3: 'phase-phase3', done: 'phase-done' }; + const step = el(map[phase]); + if (step) { step.classList.remove('active'); step.classList.add('done'); } +} -socket.on('connect', () => addLog('info', 'Connected to optimizer.')); +// ── Status helpers ──────────────────────────────────────────────────────────── -socket.on('status_sync', d => { - document.getElementById('hdr-iter').textContent = d.iteration; - if (d.state === 'running') { setRunning(true); setDot('running', 'Running...'); } - if (d.best_score) document.getElementById('hdr-score').textContent = d.best_score.toFixed(4); -}); +function setStatus(state, phase) { + const dot = el('state-dot'); + const label = el('state-label'); -socket.on('status_change', d => { - if (d.state === 'running') setDot('running', 'Running...'); - if (d.state === 'paused') setDot('paused', 'Paused'); - if (d.state === 'idle') setDot('idle', 'Ready'); - if (d.phase) setPhase(d.phase); -}); + const states = { + running: { cls: 'dot-running', label: phase ? `${phase.replace('phase','Phase ')}` : 'Running' }, + stopping: { cls: 'dot-warn', label: 'Stopping...' }, + idle: { cls: 'dot-idle', label: 'Ready' }, + done: { cls: 'dot-done', label: 'Complete' }, + }; + const s = states[state] || states.idle; + dot.className = `dot ${s.cls}`; + label.textContent = s.label; +} -socket.on('iteration_start', d => { - document.getElementById('hdr-iter').textContent = d.iteration; - addLog('info', `━━ Starting Iteration ${d.iteration} ━━`); - setPhase('analyze'); -}); +function updateRunCount(count, d) { + if (count !== null) runCount = count; + else if (d) runCount++; + el('hdr-iter').textContent = totalRuns > 0 ? `${runCount}/${totalRuns}` : runCount; +} -socket.on('run_started', d => { - setPhase(d.phase === 'baseline' ? 'baseline' : 'explore'); - document.getElementById('current-run-id').textContent = d.run_id; - addLog('info', `▶ MT5 started: ${d.run_id} [${d.period}]`); - currentParams = d.params || {}; -}); - -socket.on('run_complete', d => { - updateMetrics(d); - const sign = d.score > 0 ? '\u2713' : '\u2022'; - addLog(d.score > 0.3 ? 'success' : 'info', - `${sign} ${d.run_id}: Score=${d.score} | Calmar=${d.calmar} | PF=${d.profit_factor} | DD=${d.drawdown_pct}%` - ); - // Push baseline run to chart immediately (hypothesis runs pushed via score_update) - if (d.run_id && d.run_id.startsWith('baseline')) { - pushChartPoint({ iteration: 0, run_id: d.run_id, score: d.score, calmar: d.calmar }); +function updatePhaseLabel(phase, runNum, total) { + const labels = { phase1: 'Phase 1: Broad Search', phase2: 'Phase 2: Refinement', phase3: 'Phase 3: Validation', phase3_oos: 'Phase 3: OOS Test', phase3_sens: 'Phase 3: Sensitivity' }; + el('progress-phase').textContent = labels[phase] || phase; + if (runNum != null && total != null) { + el('progress-count').textContent = `${runNum} / ${total}`; + el('progress-bar-fill').style.width = `${Math.round(runNum / total * 100)}%`; } -}); +} -socket.on('run_failed', d => { - addLog('error', `✗ Run failed: ${d.run_id} — ${d.error}`); -}); +// ── Elapsed timer ───────────────────────────────────────────────────────────── -socket.on('finding', f => addFinding(f)); +function startElapsedTimer() { + if (elapsedTimer) clearInterval(elapsedTimer); + if (!startTs) startTs = Date.now(); + elapsedTimer = setInterval(() => { + const secs = Math.floor((Date.now() - startTs) / 1000); + const mm = Math.floor(secs / 60).toString().padStart(2, '0'); + const ss = (secs % 60).toString().padStart(2, '0'); + el('hdr-elapsed').textContent = `${mm}:${ss}`; + }, 1000); +} -socket.on('hypotheses', d => { - setPhase('explore'); - showHypotheses(d.items); - addLog('info', `💡 ${d.items.length} hypothesis(es) proposed`); -}); +function stopElapsedTimer() { + if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; } +} -socket.on('hypothesis_testing', d => { - addLog('info', `🧪 Testing H${d.idx}: ${d.desc.substring(0, 60)}...`); -}); +// ── Utility ─────────────────────────────────────────────────────────────────── -socket.on('score_update', d => { - pushChartPoint(d); - // Update best score header only when a candidate is actually promoted - if (d.promoted) { - document.getElementById('hdr-score').textContent = d.score.toFixed(4); +function el(id) { return document.getElementById(id); } + +// Init: mark Setup phase as active +setPhaseActive('setup'); + +// Check if run already in progress +fetch('/api/status').then(r => r.json()).then(s => { + if (s.state === 'running') { + setStatus(s.state, s.phase); + startTs = Date.now() - (s.elapsed_s || 0) * 1000; + startElapsedTimer(); + totalRuns = s.total_runs || 0; + updateRunCount(s.run_count || 0, null); + document.getElementById('progress-wrap').style.display = ''; + document.getElementById('btn-stop').classList.remove('hidden'); + setPhaseActive(s.phase); + if (s.ea_name) { + document.getElementById('session-label').textContent = + `${s.ea_name} · ${s.symbol} · ${s.timeframe}`; + } } -}); - -socket.on('candidate_promoted', d => { - addCandidate(d); - addLog('success', `🏆 Candidate promoted! Score: ${d.score}`); -}); - -socket.on('optimization_complete', d => { - clearInterval(timerInterval); - setRunning(false); - setDot('idle', 'Complete'); - addLog('success', `🏁 Done! ${d.iterations} iterations, ${d.candidates} candidate(s), best score: ${d.best_score}`); - addLog('info', '📁 Reports saved to MT5_Optimizer\\Reports\\'); -}); - -socket.on('error', d => { - addLog('error', `Error: ${d.msg}`); - setDot('error', 'Error'); -}); - -socket.on('log', d => addLog(d.level, d.msg)); - -// ── Init: restore chart from history ────────────────────────────────────── - -fetch('/api/history').then(r => r.json()).then(history => { - history.forEach(d => pushChartPoint(d)); -}); - -fetch('/api/status').then(r => r.json()).then(d => { - document.getElementById('hdr-iter').textContent = d.iteration; - if (d.best_score) document.getElementById('hdr-score').textContent = d.best_score.toFixed(4); - if (d.state === 'running') { - setRunning(true); - setDot('running', 'Running...'); - startTime = Date.now() - d.elapsed_s * 1000; - timerInterval = setInterval(updateTimer, 1000); - } -}); +}).catch(() => {}); diff --git a/ui/templates/dashboard.html b/ui/templates/dashboard.html new file mode 100644 index 0000000..475cf53 --- /dev/null +++ b/ui/templates/dashboard.html @@ -0,0 +1,315 @@ + + + + + + MT5 Smart Optimizer — Live Dashboard + + + + + + + + +
+
+ +
+
+
+ + Ready +
+
+ Runs + 0 +
+
+ Best Score + +
+
+ Elapsed + 0:00 +
+
+
+ 🏠 Home + 📁 Reports + + ⚡ New Run +
+
+ + +
+ + +
+ + +
+ + +
+

⚠️ No Profitable Configuration Found

+

Phase 1 found no profitable configuration. Try a different timeframe, date range, or check EA settings.

+ +
+ + + + + +
+
+ 📈 Score History + Waiting... +
+
+ +
+
+ + +
+
+ 🎯 Latest Run Metrics + +
+
+
Net Profit
+
Calmar Ratio
+
Profit Factor
+
Max Drawdown
+
Win Rate
+
Total Trades
+
+
+
+ Session Score + +
+
+
+
+
+
+ +
+ + +
+ + +
+
+
+
1
+
Setup
+
+
+
+
2
+
Broad Search
+
+
+
+
3
+
Refine
+
+
+
+
4
+
Validate
+
+
+
+
+
Done
+
+
+
+ + + + + +
+
+ 📋 Live Log + +
+
+ +
+
+ + +
+
+ ⭐ Best Configs Found + 0 configs +
+
+
Profitable configurations will appear here as the optimizer runs...
+
+
+ +
+
+ + + + + + + diff --git a/ui/templates/landing.html b/ui/templates/landing.html new file mode 100644 index 0000000..03fd693 --- /dev/null +++ b/ui/templates/landing.html @@ -0,0 +1,267 @@ + + + + + +MT5 Smart Optimizer + + + + + +
+
+ +
+ + + +
+
+ Ready +
+ +

Find the Best Settings
for Any MT5 EA

+ +

+ The Smart Optimizer tests dozens of configurations across your full parameter space, + validates the winner on unseen data, and delivers a ready-to-use .set file — in under an hour. +

+ + + +
+
+
3
+
Phases
+
+
+
20–50
+
Config Tests
+
+
+
<1hr
+
Typical Time
+
+
+
OOS
+
Validated
+
+
+
+ + + + diff --git a/ui/templates/reports_index.html b/ui/templates/reports_index.html index 4b255ce..55f7e8f 100644 --- a/ui/templates/reports_index.html +++ b/ui/templates/reports_index.html @@ -38,7 +38,7 @@

📁 Optimization Reports

{{ runs|length }} run(s) recorded · Click any card to open the full report
- ← Back to Dashboard + ← Back to Dashboard {% if not runs %} diff --git a/ui/templates/setup.html b/ui/templates/setup.html new file mode 100644 index 0000000..ea864a1 --- /dev/null +++ b/ui/templates/setup.html @@ -0,0 +1,465 @@ + + + + + +New Optimization — MT5 Smart Optimizer + + + + + +
+ + +
+ + + Back + +
+
⚡ New Optimization
+
Configure and launch a smart EA optimization session
+
+
+ +
+ + +
+
+ + EA Configuration +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + Date Ranges +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ Tip: Training period is where the optimizer searches for settings. + Validation is unseen data — the optimizer never touches it until Phase 3. + Keep validation at least 6 months. +
+
+ + +
+
+ + Time Budget +
+
+ + + +
+ +
+ + +
+
+ + Optimize For +
+
+
+
⚖️
+
Balanced
+
Good profit + Low drawdown
+
+
+
📈
+
Max Profit
+
Highest return, any risk
+
+
+
🛡️
+
Low Risk
+
Minimum drawdown first
+
+
+ +
+ + +
+
+ + Advanced: Select Parameters to Optimize + (optional) +
+
+

+ Leave all checked to let the optimizer decide. Uncheck parameters you want to keep fixed. +

+
+ {% for p in params %} + + {% endfor %} +
+
+
+ + +
+ +
+ Estimated: ~35 tests in ~1 hour +
+
+ +
+
+ + + +