""" 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 BASE_DIR = Path(__file__).parent sys.path.insert(0, str(BASE_DIR)) from flask import Flask, render_template, jsonify, request, send_from_directory, redirect from flask_socketio import SocketIO, emit from optimizer.pipeline import OptimizationPipeline from optimizer.session_config import SessionConfig # ── App setup ───────────────────────────────────────────────────────────────── app = Flask(__name__, template_folder="ui/templates", static_folder="ui/static") app.config["SECRET_KEY"] = "mt5optimizer2024" socketio = SocketIO(app, cors_allowed_origins="*", async_mode="threading") REPORTS_DIR = BASE_DIR / "Reports" REPORTS_DIR.mkdir(exist_ok=True) # Global pipeline instance pipeline: OptimizationPipeline = None pipeline_thread: threading.Thread = None # ── Routes ──────────────────────────────────────────────────────────────────── @app.route("/") 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 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 pipeline, pipeline_thread if pipeline and pipeline.running: return jsonify({"ok": False, "msg": "Optimization already running"}) data = request.get_json(silent=True) or {} 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, ) pipeline.configure(session) pipeline_thread = threading.Thread(target=pipeline.run, daemon=True) pipeline_thread.start() return jsonify({"ok": True, "total_runs": session.total_budget_runs}) @app.route("/api/stop", methods=["POST"]) def stop(): if pipeline: pipeline.stop() return jsonify({"ok": True}) @app.route("/api/history") def history(): """Full run history for chart/table restoration — includes in-progress runs.""" if pipeline is None: return jsonify([]) if hasattr(pipeline, '_completed_runs') and pipeline._completed_runs: # Sort newest first by timestamp (ts field added in _make_run_dict) runs = sorted(pipeline._completed_runs, key=lambda r: r.get("ts", ""), reverse=True) return jsonify(runs) # Fallback: post-phase ranked results results = pipeline.phase1_results + pipeline.phase2_results return jsonify([ { "run_id": r.run_id, "score": round(r.score, 4), "net_profit": round(r.net_profit, 2), "calmar": round(r.calmar, 3), "profit_factor": round(r.profit_factor, 3), "max_drawdown": round(r.max_drawdown, 2), "total_trades": r.total_trades, "win_rate": round(r.win_rate, 1), "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(): import json, re runs = [] if REPORTS_DIR.exists(): for run_dir in REPORTS_DIR.iterdir(): if not run_dir.is_dir(): continue summary = run_dir / "summary.json" if not summary.exists(): continue try: 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) # Defensive defaults so the template never crashes on legacy files. data["run_id"] = data.get("run_id") or run_dir.name data["score"] = data.get("score") or 0 data["score_delta"] = data.get("score_delta") or 0 data["net_profit"] = data.get("net_profit") or 0 data["profit_factor"] = data.get("profit_factor") or 0 data["calmar"] = data.get("calmar") or 0 data["drawdown_pct"] = data.get("drawdown_pct") or 0 data["win_rate"] = data.get("win_rate") or 0 data["total_trades"] = data.get("total_trades") or 0 data["ts"] = data.get("ts") or "" data["phase"] = data.get("phase") or "phase1" # Surface per-card flags the template uses data["has_set"] = bool(list(run_dir.glob("*.set"))) data["has_ai"] = (run_dir / "ai_insight.json").exists() or bool(data.get("has_ai")) runs.append(data) except Exception: pass # Sort by ts desc — newest first (was relying on filesystem sort order before) runs.sort(key=lambda r: r.get("ts", ""), reverse=True) return render_template("reports_index.html", runs=runs[:100]) @app.route("/reports/") def reports_file(filename): return send_from_directory(REPORTS_DIR, filename) @app.route("/api/runs") def runs_list(): import json, re runs = [] if REPORTS_DIR.exists(): for run_dir in REPORTS_DIR.iterdir(): if not run_dir.is_dir(): continue 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) data = json.loads(txt) data["score"] = data.get("score") or 0 data["score_delta"] = data.get("score_delta") or 0 runs.append(data) except Exception: pass # Sort by timestamp field (newest first) runs.sort(key=lambda r: r.get("ts", ""), reverse=True) return jsonify(runs[:50]) @app.route("/api/run/") def run_detail(run_id): """Return full detail for one run: metrics + params + AI insight + .set link.""" import json, re run_dir = REPORTS_DIR / run_id if not run_dir.exists(): return jsonify({"error": "Run not found"}), 404 def read_json(path): try: txt = path.read_text(encoding="utf-8") txt = re.sub(r'\bNaN\b', 'null', txt) txt = re.sub(r'\bInfinity\b', 'null', txt) return json.loads(txt) except Exception: return None summary = read_json(run_dir / "summary.json") or {} params = read_json(run_dir / "parameters.json") or {} ai_insight = read_json(run_dir / "ai_insight.json") # Also check live pipeline for AI insight (current session, not yet on disk) if ai_insight is None and pipeline and hasattr(pipeline, '_run_insights'): ai_insight = pipeline._run_insights.get(run_id) # Detect .set file set_files = list(run_dir.glob("*.set")) set_url = f"/download_set/{run_id}" if set_files else None return jsonify({ **summary, "params": params, "ai_insight": ai_insight, "set_url": set_url, "has_set": bool(set_files), }) @app.route("/api/preflight") def preflight(): """ Run a checklist of "is this run going to work?" probes BEFORE the user clicks Start. Returns {"ok": bool, "checks": [{name, ok, hint}]}. """ import yaml as _yaml import os as _os from pathlib import Path as _P checks = [] # 1. config.yaml exists + readable cfg_path = BASE_DIR / "config.yaml" cfg = {} try: cfg = _yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {} checks.append({"name": "Config file readable", "ok": True, "hint": ""}) except Exception as e: checks.append({"name": "Config file readable", "ok": False, "hint": str(e)}) # 2. Anthropic API key present (env or config) raw_key = (cfg.get("ai", {}) or {}).get("anthropic_api_key", "") or "" if not raw_key or raw_key.startswith("${"): raw_key = _os.environ.get("ANTHROPIC_API_KEY", "") has_key = bool(raw_key) and len(raw_key) >= 30 and raw_key.startswith("sk-") checks.append({ "name": "Anthropic API key configured", "ok": has_key, "hint": "Optional in demo mode. For live AI insights, set ANTHROPIC_API_KEY or paste in Settings → AI Engine.", }) # 3. Demo mode? short-circuit MT5 checks if so demo = _os.environ.get("APEX_DEMO_MODE", "").strip() in ("1", "true", "yes") if demo: checks.append({"name": "Demo mode active (MT5 not required)", "ok": True, "hint": "Synthetic backtest results."}) else: # 4. MT5 terminal exe exists exe = (cfg.get("mt5", {}) or {}).get("terminal_exe", "") exe_ok = bool(exe) and _P(exe).exists() checks.append({ "name": "MT5 terminal found", "ok": exe_ok, "hint": "" if exe_ok else f"Set mt5.terminal_exe in Settings → MetaTrader 5. Looked at: {exe or '(empty)'}", }) # 5. MQL5 Files path writable mql5 = (cfg.get("mt5", {}) or {}).get("mql5_files_path", "") mql5_ok = bool(mql5) and _P(mql5).exists() checks.append({ "name": "MT5 Files folder reachable", "ok": mql5_ok, "hint": "" if mql5_ok else f"Set mt5.mql5_files_path. Looked at: {mql5 or '(empty)'}", }) # 6. At least one EA registered try: from ea.registry import EARegistry reg = EARegistry(str(cfg_path)) ea_count = len(getattr(reg, "_profiles", reg.list() if hasattr(reg, "list") else [])) if ea_count == 0: try: ea_count = len(reg.list()) except Exception: pass checks.append({ "name": "At least one EA registered", "ok": ea_count > 0, "hint": "" if ea_count > 0 else "Register an EA on the Setup page or via /api/ea/register.", }) except Exception as e: checks.append({"name": "EA registry loadable", "ok": False, "hint": str(e)}) # 7. Reports dir writable try: REPORTS_DIR.mkdir(parents=True, exist_ok=True) probe = REPORTS_DIR / ".write_probe" probe.write_text("ok", encoding="utf-8") probe.unlink(missing_ok=True) checks.append({"name": "Reports folder writable", "ok": True, "hint": ""}) except Exception as e: checks.append({"name": "Reports folder writable", "ok": False, "hint": str(e)}) # AI key not strictly required — only blocks if user explicitly enabled AI ai_required = bool((cfg.get("ai", {}) or {}).get("enabled", True)) blocking = [c for c in checks if not c["ok"] and not (c["name"] == "Anthropic API key configured" and not ai_required)] # API key is informational only blocking = [c for c in blocking if c["name"] != "Anthropic API key configured"] return jsonify({ "ok": len(blocking) == 0, "blocking_count": len(blocking), "checks": checks, "demo_mode": demo, }) @app.route("/api/live_activity") def live_activity(): """ Single endpoint the dashboard hits on (re)connect to restore everything that's not already in /api/history: AI thinking feed, parameter changes, validation runs, early-termination state, current phase + mode. """ if not pipeline: return jsonify({ "thinking": [], "param_changes": [], "validation": [], "early_termination": None, "phase": "idle", "phase_mode": None, "running": False, }) # Determine phase mode (autonomous?) for label hints phase_mode = None if getattr(pipeline, "session", None): phase_mode = "autonomous" if getattr(pipeline.session, "autonomous_mode", False) else None return jsonify({ "thinking": getattr(pipeline, "_thinking_log", []) or [], "param_changes": getattr(pipeline, "_param_changes", []) or [], "validation": getattr(pipeline, "_validation_log", []) or [], "early_termination": getattr(pipeline, "_early_term", None), "phase": getattr(pipeline, "_phase", "idle"), "phase_mode": phase_mode, "running": bool(getattr(pipeline, "running", False)), "run_count": getattr(pipeline, "_run_count", 0), "total_runs": getattr(pipeline, "_total_runs", 0), }) @app.route("/api/best_result") def best_result(): """ Return the current best run plus its evolution path — the ordered sequence of AI iterations that led to it (so the user can see how the AI arrived). """ if not pipeline: return jsonify({"error": "No best result yet — optimization has not started."}), 404 best_run = None if getattr(pipeline, "final_result", None): best_run = pipeline.final_result elif getattr(pipeline, "_live_best", None): best_run = pipeline._live_best if best_run is None: return jsonify({"error": "No best result yet — no passing run found so far."}), 404 # Build evolution path: walk through _completed_runs up to (and including) the best evolution = [] for r in getattr(pipeline, "_completed_runs", []): phase = r.get("phase", "") if not (phase.startswith("phase1") or phase.startswith("phase2")): continue ai_insight = r.get("ai_insight") or {} evolution.append({ "run_id": r.get("run_id"), "phase": phase, "ts": r.get("ts"), "score": r.get("score"), "net_profit": r.get("net_profit"), "profit_factor": r.get("profit_factor"), "calmar": r.get("calmar"), "max_drawdown": r.get("max_drawdown"), "passing": r.get("passing"), "changes": ai_insight.get("changes") or [], "analysis": ai_insight.get("analysis") or ai_insight.get("diagnosis") or "", "is_best": r.get("run_id") == best_run.run_id, }) if r.get("run_id") == best_run.run_id: break return jsonify({ "run_id": best_run.run_id, "score": round(best_run.score, 4), "net_profit": round(best_run.net_profit, 2), "profit_factor": round(best_run.profit_factor, 3), "calmar": round(best_run.calmar, 3), # max_drawdown + win_rate are stored as fractions on RankedResult; emit as % "max_drawdown": round(best_run.max_drawdown * 100, 2), "win_rate": round(best_run.win_rate * 100, 1), "total_trades": best_run.total_trades, "passing": bool(best_run.passing), "phase": getattr(best_run, "phase", "phase2_ai"), "params": best_run.params, "evolution": evolution, "set_url": f"/download_set/{best_run.run_id}", }) # ── SocketIO ────────────────────────────────────────────────────────────────── @socketio.on("connect") def on_connect(): if pipeline: emit("status_sync", pipeline.get_status()) @app.route("/api/ai_insight/latest") def ai_insight_latest(): """Return the latest AI insight from the running pipeline.""" if pipeline and hasattr(pipeline, 'get_latest_insight'): insight = pipeline.get_latest_insight() if insight: return jsonify(insight) return jsonify(None) @app.route("/api/ai_insights") def ai_insights_all(): """Return all AI insights from this session.""" if pipeline and hasattr(pipeline, 'get_all_insights'): return jsonify(pipeline.get_all_insights()) return jsonify([]) def _mask_key(k: str) -> str: """Mask an API key so the GET response never exposes the secret.""" if not k: return "" if k.startswith("${") or k in ("YOUR_API_KEY", "sk-ant-..."): return "" # placeholder — return empty so the field shows blank if len(k) <= 12: return "***" return k[:8] + "…" + k[-4:] @app.route("/api/settings", methods=["GET"]) def get_settings(): import yaml import os config_path = BASE_DIR / "config.yaml" try: with open(config_path, encoding="utf-8") as f: cfg = yaml.safe_load(f) or {} ai_cfg = cfg.get("ai", {}) mt5_cfg = cfg.get("mt5", {}) broker_cfg = cfg.get("broker", {}) thresh_cfg = cfg.get("thresholds", {}) # Resolve the active API key — placeholder in config falls back to env var. raw_key = ai_cfg.get("anthropic_api_key", "") if not raw_key or raw_key.startswith("${"): raw_key = os.environ.get("ANTHROPIC_API_KEY", "") return jsonify({ "ai": { "enabled": ai_cfg.get("enabled", True), "anthropic_api_key": _mask_key(raw_key), "anthropic_api_key_set": bool(raw_key), "model": ai_cfg.get("model", "claude-opus-4-7"), "timeout_seconds": ai_cfg.get("timeout_seconds", 30), }, "mt5": { "terminal_exe": mt5_cfg.get("terminal_exe", ""), "appdata_path": mt5_cfg.get("appdata_path", ""), "mql5_files_path": mt5_cfg.get("mql5_files_path", ""), "tester_timeout_seconds": mt5_cfg.get("tester_timeout_seconds", 120), "tester_model": mt5_cfg.get("tester_model", 1), }, "broker": { "timezone_offset_hours": broker_cfg.get("timezone_offset_hours", 3), "deposit": broker_cfg.get("deposit", 10000), "leverage": broker_cfg.get("leverage", 500), }, "thresholds": { "min_trades": thresh_cfg.get("min_trades", 30), "min_profit_factor": thresh_cfg.get("min_profit_factor", 1.2), "min_calmar": thresh_cfg.get("min_calmar", 0.5), "max_oos_degradation": thresh_cfg.get("max_oos_degradation", 0.3), "sensitivity_tolerance": thresh_cfg.get("sensitivity_tolerance", 0.15), }, }) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/settings", methods=["POST"]) def save_settings(): import yaml config_path = BASE_DIR / "config.yaml" data = request.get_json(silent=True) or {} try: with open(config_path, encoding="utf-8") as f: cfg = yaml.safe_load(f) or {} for section in ["ai", "mt5", "broker", "thresholds"]: if section in data and isinstance(data[section], dict): if section not in cfg: cfg[section] = {} # Don't overwrite the real API key with the masked one we sent # the client. We accept a key only if it's empty (clearing) or # looks like a full key (sk-ant-… with no mask markers and at # least 30 chars). Anything ambiguous → preserve the existing. if section == "ai": incoming_key = data["ai"].get("anthropic_api_key", "") if incoming_key: looks_masked = ( "…" in incoming_key or "..." in incoming_key or "***" in incoming_key or len(incoming_key) < 30 ) if looks_masked: data["ai"].pop("anthropic_api_key", None) cfg[section].update(data[section]) with open(config_path, "w", encoding="utf-8") as f: yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True) # Hot-reload into a running pipeline if there is one reload_info = {} if pipeline and pipeline.running: try: reload_info = pipeline.reload_config() or {} except Exception as e: reload_info = {"ok": False, "error": str(e)} note = ( "Settings saved. Hot-reloaded into the running optimization." if reload_info.get("changed") else "Settings saved. Will apply on the next optimization run." ) return jsonify({"ok": True, "note": note, "reload": reload_info}) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 @app.route("/api/ea/register", methods=["POST"]) def ea_register(): """Register a new EA profile.""" from ea.registry import EARegistry, EAProfile data = request.get_json(silent=True) or {} try: reg = EARegistry(str(BASE_DIR / "config.yaml")) profile = EAProfile( name=data["name"], ex5_file=data.get("ex5_file", data["name"]), set_template=data["set_template"], symbol=data.get("symbol", "XAUUSD"), timeframe=data.get("timeframe", "H1"), mode=data.get("mode", "generic"), ) reg.register(profile) return jsonify({"ok": True, "name": profile.name}) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 400 @app.route("/api/ea/list") def ea_list(): """List all registered EAs.""" from ea.registry import EARegistry try: reg = EARegistry(str(BASE_DIR / "config.yaml")) profiles = reg.list_all() return jsonify([{ "name": p.name, "symbol": p.symbol, "timeframe": p.timeframe, "mode": p.mode, "set_template": p.set_template, } for p in profiles]) except Exception as e: return jsonify([]) @app.route("/ai_insights") def ai_insights_page(): """Legacy alias — AI insights now live inline on the dashboard.""" return redirect("/dashboard") @app.route("/api/ea/scan") def ea_scan(): """Scan common MT5 locations for .ex5 and .set files.""" import glob as _glob import os home = Path(os.path.expanduser("~")) appdata = Path(os.environ.get("APPDATA", home / "AppData" / "Roaming")) desktop = home / "Desktop" # Directories to scan for .ex5 files — MetaQuotes terminal data dirs ex5_dirs = [] mq_base = appdata / "MetaQuotes" / "Terminal" if mq_base.exists(): for td in mq_base.iterdir(): if td.is_dir(): ex5_dirs.append(td / "MQL5" / "Experts") ex5_dirs.append(Path("C:/Program Files/MetaTrader 5/MQL5/Experts")) ex5_dirs.append(Path("C:/Program Files (x86)/MetaTrader 5/MQL5/Experts")) # Scan .ex5 files (skip Examples / Advisors / Free Robots subfolders — likely default) SKIP_DIRS = {"Examples", "Advisors", "Free Robots", "Market"} ex5_found = [] seen_names = set() for d in ex5_dirs: if not d.exists(): continue for f in d.rglob("*.ex5"): if any(part in SKIP_DIRS for part in f.parts): continue name = f.stem if name not in seen_names: seen_names.add(name) ex5_found.append({ "name": name, "path": str(f).replace("\\", "/"), "dir": str(f.parent).replace("\\", "/"), }) # Scan .set files — Desktop, Desktop subfolders, MT5 tester agents set_dirs = [desktop] # Desktop subfolders (1 level deep) for item in desktop.iterdir() if desktop.exists() else []: if item.is_dir(): set_dirs.append(item) # MT5 tester agent MQL5/Files tester_base = appdata / "MetaQuotes" / "Tester" if tester_base.exists(): for td in tester_base.rglob("MQL5/Files"): set_dirs.append(td) set_found = [] seen_set = set() for d in set_dirs: if not d.exists(): continue for f in d.glob("*.set"): key = f.name if key not in seen_set: seen_set.add(key) set_found.append({ "name": f.stem, "filename": f.name, "path": str(f).replace("\\", "/"), }) # Build best-match hints: for each ex5, find the most likely .set file def best_set_for(ea_name): ea_lower = ea_name.lower() # exact match first for s in set_found: if s["name"].lower() == ea_lower: return s["path"] # prefix/suffix match for s in set_found: sl = s["name"].lower() if ea_lower in sl or sl in ea_lower: return s["path"] return "" for ea in ex5_found: ea["suggested_set"] = best_set_for(ea["name"]) return jsonify({ "ex5": ex5_found, "set": set_found, }) # ── Launch ──────────────────────────────────────────────────────────────────── def open_browser(): time.sleep(1.5) webbrowser.open("http://localhost:5000") if __name__ == "__main__": print("=" * 60) print(" MT5 Smart EA Optimizer — Starting...") print(" Opening browser at http://localhost:5000") print("=" * 60) threading.Thread(target=open_browser, daemon=True).start() socketio.run(app, host="0.0.0.0", port=5000, debug=False, use_reloader=False, allow_unsafe_werkzeug=True)