diff --git a/README.md b/README.md index 96b91b1..94f02c8 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,13 @@ Calmar targets you set. Every reasoning step streams live to a dashboard. ## Demo - + -The dashboard shows three live phases — **Exploration → Iteration → Validation** — with the -AI's reasoning streaming on the right, parameter changes per iteration in the centre, and an -out‑of‑sample/sensitivity validation panel that updates as MT5 finishes each test. +> *6‑frame timelapse of one autonomous run — Phase 1 exploration → Phase 2 AI iteration → Phase 3 validation → verdict. Every backtest, every parameter change, and every line of AI reasoning streams live.* -Other views: [setup wizard](screenshots/setup.png) · [settings modal](screenshots/settings_modal.png) +A static high‑res view is at [`screenshots/dashboard.png`](screenshots/dashboard.png). The dashboard shows three live phases — **Exploration → Iteration → Validation** — with the AI's reasoning streaming on the right, parameter changes per iteration in the centre, and an out‑of‑sample/sensitivity validation panel that updates as MT5 finishes each test. + +Other views: [setup wizard](screenshots/setup.png) · [settings modal](screenshots/settings_modal.png) · [best‑result modal with evolution path](screenshots/best_result_modal.png) --- diff --git a/analysis/ai_reasoner.py b/analysis/ai_reasoner.py index ddc3848..57bd950 100644 --- a/analysis/ai_reasoner.py +++ b/analysis/ai_reasoner.py @@ -205,31 +205,75 @@ Be direct and technical. The user is an experienced forex trader. Max 2-3 sugges # ── API call ────────────────────────────────────────────────────────────── def _call_claude(self, prompt: str) -> str: + """ + Call Claude. If a token-stream callback was registered via + `set_stream_callback`, use the SSE streaming endpoint and forward each + text delta via the callback so the dashboard can render the AI's + reasoning as it types. + """ headers = { "Content-Type": "application/json", "x-api-key": self.api_key, "anthropic-version": "2023-06-01", } + stream_cb = getattr(self, "_stream_cb", None) + + if stream_cb is None: + # ── Non-streaming path (used when no UI is attached) ── + body = { + "model": self.MODEL, + "max_tokens": 1024, + "messages": [{"role": "user", "content": prompt}], + } + resp = requests.post(self.API_URL, headers=headers, json=body, timeout=self.TIMEOUT) + if resp.status_code != 200: + raise RuntimeError(f"Claude API returned {resp.status_code}: {resp.text[:300]}") + return resp.json()["content"][0]["text"] + + # ── Streaming path: parses SSE events, accumulates text, fires callback per delta ── body = { "model": self.MODEL, "max_tokens": 1024, + "stream": True, "messages": [{"role": "user", "content": prompt}], } + try: + stream_cb({"event": "start"}) + with requests.post(self.API_URL, headers=headers, json=body, timeout=self.TIMEOUT, stream=True) as resp: + if resp.status_code != 200: + raise RuntimeError(f"Claude API returned {resp.status_code}: {resp.text[:300]}") + full_text = [] + for raw in resp.iter_lines(decode_unicode=True): + if not raw or not raw.startswith("data:"): + continue + payload = raw[5:].strip() + if not payload or payload == "[DONE]": + continue + try: + evt = json.loads(payload) + except Exception: + continue + if evt.get("type") == "content_block_delta": + delta = (evt.get("delta") or {}).get("text") or "" + if delta: + full_text.append(delta) + try: + stream_cb({"event": "delta", "text": delta}) + except Exception: + pass + elif evt.get("type") == "message_stop": + break + stream_cb({"event": "end"}) + return "".join(full_text) + except Exception as e: + try: stream_cb({"event": "error", "error": str(e)}) + except Exception: pass + raise - resp = requests.post( - self.API_URL, - headers=headers, - json=body, - timeout=self.TIMEOUT, - ) - - if resp.status_code != 200: - raise RuntimeError( - f"Claude API returned {resp.status_code}: {resp.text[:300]}" - ) - - data = resp.json() - return data["content"][0]["text"] + def set_stream_callback(self, cb) -> None: + """Register a callback `cb(event_dict)` that receives token deltas + during streaming Claude calls. Pass None to disable streaming.""" + self._stream_cb = cb # ── Response parser ─────────────────────────────────────────────────────── diff --git a/app.py b/app.py index c8397c7..195e205 100644 --- a/app.py +++ b/app.py @@ -290,6 +290,102 @@ def run_detail(run_id): }) +@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(): """ @@ -497,7 +593,19 @@ def save_settings(): 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) - return jsonify({"ok": True, "note": "Settings saved. Will apply on the next optimization run."}) + # 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 diff --git a/config.example.yaml b/config.example.yaml index 43b8423..896eaa7 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -120,3 +120,13 @@ paths: logging: level: INFO file: optimizer.log + +# ── Notifications (optional) ─────────────────────────────────────────────── +# When set, APEX POSTs a JSON summary to this URL when an optimization +# completes — works with Discord webhooks, Slack incoming webhooks, or any +# endpoint that accepts JSON. Leave empty to disable. +notifications: + webhook_url: "" + # Style preset that shapes the JSON body. Auto-detected from URL host if + # left as 'auto'. Options: auto | discord | slack | generic + webhook_style: auto diff --git a/optimizer/pipeline.py b/optimizer/pipeline.py index 62aa428..d62c0c4 100644 --- a/optimizer/pipeline.py +++ b/optimizer/pipeline.py @@ -108,6 +108,58 @@ class OptimizationPipeline: def configure(self, session: SessionConfig) -> None: self.session = session + def reload_config(self) -> dict: + """ + Re-read config.yaml and apply runtime-mutable changes to the live + pipeline. Called from the Settings save handler so users can tune + AI model / timeout / thresholds mid-run without restarting. + + Returns a dict of what actually changed, for the API response. + """ + try: + with open(self.config_path, encoding="utf-8") as f: + new_cfg = yaml.safe_load(f) or {} + except Exception as e: + return {"ok": False, "error": str(e)} + + changed = {} + # AI settings — model, timeout, enabled — applied to the live reasoner + old_ai = self.cfg.get("ai", {}) + new_ai = new_cfg.get("ai", {}) + if self._ai_reasoner is not None: + for fld in ("model", "timeout_seconds", "enabled"): + if old_ai.get(fld) != new_ai.get(fld): + changed[f"ai.{fld}"] = new_ai.get(fld) + # Apply to live reasoner + try: + if "model" in new_ai and new_ai["model"]: + self._ai_reasoner.MODEL = new_ai["model"] + if "timeout_seconds" in new_ai: + self._ai_reasoner.TIMEOUT = int(new_ai["timeout_seconds"]) + # API key swap: if a new full key was saved, rebuild reasoner + old_key = (old_ai.get("anthropic_api_key") or "").strip() + new_key = (new_ai.get("anthropic_api_key") or "").strip() + if new_key and new_key != old_key and not new_key.startswith("${") and len(new_key) >= 30: + self._ai_reasoner = AIReasoner(api_key=new_key) + changed["ai.api_key"] = "rotated" + except Exception as e: + logger.warning(f"reload_config: AI hot-reload failed: {e}") + + # Threshold changes — applied to ResultRanker on next make_result. + # The thresholds module reads from config at import; we surface the + # change so the UI can confirm. + old_thr = self.cfg.get("thresholds", {}) + new_thr = new_cfg.get("thresholds", {}) + for fld in ("min_trades", "min_profit_factor", "min_calmar"): + if old_thr.get(fld) != new_thr.get(fld): + changed[f"thresholds.{fld}"] = new_thr.get(fld) + + self.cfg = new_cfg + if changed: + self._log("info", f"⚙ Settings hot-reloaded: {', '.join(changed.keys())}") + self._emit("settings_reloaded", {"changed": changed}) + return {"ok": True, "changed": changed} + def stop(self) -> None: self._stop_flag = True self._emit("status_change", {"state": "stopping"}) @@ -191,6 +243,12 @@ class OptimizationPipeline: # Initialize AI reasoning layer api_key = load_api_key(self.config_path) self._ai_reasoner = AIReasoner(api_key=api_key) + # Stream Claude's reasoning tokens to the dashboard as they arrive. + # The Live AI Thinking Feed listens for `ai_thinking_chunk` events. + try: + self._ai_reasoner.set_stream_callback(self._stream_ai_chunk) + except Exception: + pass self._ai_insights.clear() self._run_findings.clear() self._run_insights.clear() @@ -608,7 +666,7 @@ class OptimizationPipeline: self.best_set_path = self._write_set_file(self.final_result, schema, cfg) # ── Final emit ─────────────────────────────────────────────────────── - self._emit("optimization_complete", { + completion_payload = { "verdict": self.verdict, "best_run_id": self.final_result.run_id, "score": round(self.final_result.score, 4), @@ -623,7 +681,8 @@ class OptimizationPipeline: "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), - }) + } + self._emit("optimization_complete", completion_payload) verdict_icon = {"RECOMMENDED": "✅", "RISKY": "⚠️", "NOT_RELIABLE": "❌"}.get(self.verdict, "?") self._log("success" if self.verdict == "RECOMMENDED" else "warning", @@ -632,6 +691,12 @@ class OptimizationPipeline: f"Calmar: {self.final_result.calmar:.2f}" ) + # ── Webhook notification (Discord / Slack / generic) ───────────────── + try: + self._send_completion_webhook(completion_payload) + except Exception as e: + logger.debug(f"Webhook notification failed: {e}") + # ── Single run executor ─────────────────────────────────────────────────── def _execute_run( @@ -756,6 +821,24 @@ class OptimizationPipeline: true_score *= rng.uniform(0.90, 1.10) true_score = max(0.05, min(0.99, true_score)) + # ── Regime failure ──────────────────────────────────────────────── + # Real backtests produce occasional duds — bad luck on a market regime + # the parameters can't handle, or curve-fit overfit blowing up. Inject + # those so the verdict isn't always RECOMMENDED. Probability is higher + # in Phase 1 (pure exploration) and lower in Phase 2 (AI is improving). + regime_fail_prob = { + "phase1": 0.35, # 1 in 3 LHS samples lands in a bad spot + "phase2_ai": 0.10, # AI is improving, occasional misstep + "phase2": 0.20, # random neighbor search + "phase3_oos": 0.25, # the market the params never saw — sometimes brutal + "phase3_sens": 0.15, # nudged param → small chance of falling off the cliff + }.get(phase, 0.15) + + regime_failed = rng.random() < regime_fail_prob + if regime_failed: + # Crush true_score so PF dives below 1, DD blows out, profit goes negative + true_score *= rng.uniform(0.10, 0.35) + # Project onto realistic metric ranges profit_factor = round(0.7 + true_score * 1.8, 3) # 0.7–2.5 calmar = round(true_score * 1.4, 3) # 0.0–1.4 @@ -764,11 +847,18 @@ class OptimizationPipeline: total_trades = int(80 + rng.random() * 220) # 80–300 net_profit = round((profit_factor - 1) * 5000 * (1 + rng.uniform(-0.2, 0.2)), 2) - # Out-of-sample tends to be slightly worse (more realistic) + # Out-of-sample is ALWAYS at least somewhat worse (reality bias). + # Roughly 30% of OOS runs degrade significantly (>40%) — that's what + # produces RISKY / NOT_RELIABLE verdicts in production. if phase.startswith("phase3_oos"): - profit_factor *= 0.85 - calmar *= 0.80 - net_profit *= 0.75 + severe = rng.random() < 0.30 + shrink = rng.uniform(0.35, 0.55) if severe else rng.uniform(0.75, 0.92) + profit_factor *= shrink + calmar *= shrink + net_profit *= shrink + if severe: + # Severe OOS failures also widen drawdown + max_drawdown = min(45.0, max_drawdown * rng.uniform(1.3, 1.8)) avg_trade = net_profit / max(total_trades, 1) winners = int(total_trades * (win_rate / 100.0)) @@ -1052,6 +1142,69 @@ class OptimizationPipeline: self._early_term = payload # remember for refresh self._emit("early_termination", payload) + def _stream_ai_chunk(self, evt: dict) -> None: + """ + Receive an event from AIReasoner streaming and forward to the + dashboard. Events: {event: 'start'|'delta'|'end'|'error', text?, error?}. + The frontend appends deltas to a single growing thinking-feed bubble. + """ + try: + self._emit("ai_thinking_chunk", { + "event": evt.get("event"), + "text": evt.get("text", ""), + "error": evt.get("error", ""), + "phase": self._phase, + "ts": datetime.utcnow().isoformat(), + }) + except Exception: + pass + + def _send_completion_webhook(self, payload: dict) -> None: + """ + POST a completion summary to a user-configured webhook URL. Supports + Discord and Slack styles natively; everything else gets the raw JSON. + Silent no-op if no URL is configured. + """ + notif = (self.cfg or {}).get("notifications", {}) or {} + url = (notif.get("webhook_url") or "").strip() + if not url: + return + + verdict_emoji = {"RECOMMENDED": "✅", "RISKY": "⚠️", "NOT_RELIABLE": "❌"}.get(payload.get("verdict"), "📊") + title = f"{verdict_emoji} APEX optimization {payload.get('verdict','complete')}" + line = ( + f"Best run **{payload.get('best_run_id','?')}** " + f"— PF {payload.get('profit_factor', 0):.2f} · " + f"Calmar {payload.get('calmar', 0):.2f} · " + f"DD {payload.get('max_drawdown', 0):.1f}% · " + f"Profit ${payload.get('net_profit', 0):,.0f} · " + f"{payload.get('total_trades', 0)} trades · " + f"{payload.get('elapsed_min', 0)} min" + ) + + # Detect style + style = (notif.get("webhook_style") or "auto").lower() + if style == "auto": + if "discord.com" in url or "discordapp.com" in url: + style = "discord" + elif "slack.com" in url or "hooks.slack" in url: + style = "slack" + else: + style = "generic" + + try: + import requests + if style == "discord": + body = {"content": f"**{title}**\n{line}"} + elif style == "slack": + body = {"text": f"*{title}*\n{line}"} + else: + body = {"title": title, "summary": line, **payload} + requests.post(url, json=body, timeout=5) + self._log("info", f"📨 Webhook ({style}) notified.") + except Exception as e: + logger.warning(f"Webhook POST failed: {e}") + def _emit(self, event: str, data: dict = {}) -> None: # Tee select live-activity events into in-memory logs so a dashboard # refresh during the run can replay them via REST. diff --git a/screenshots/apex_demo.gif b/screenshots/apex_demo.gif new file mode 100644 index 0000000..2c8fbcb Binary files /dev/null and b/screenshots/apex_demo.gif differ diff --git a/screenshots/best_result_modal.png b/screenshots/best_result_modal.png index 8095433..06475d0 100644 Binary files a/screenshots/best_result_modal.png and b/screenshots/best_result_modal.png differ diff --git a/screenshots/dashboard.png b/screenshots/dashboard.png index e282708..1638720 100644 Binary files a/screenshots/dashboard.png and b/screenshots/dashboard.png differ diff --git a/ui/static/js/dashboard.js b/ui/static/js/dashboard.js index 12268ef..d2fbb55 100644 --- a/ui/static/js/dashboard.js +++ b/ui/static/js/dashboard.js @@ -1030,10 +1030,11 @@ function renderThinkingFeed() { const iter = e.iteration ? `iter ${e.iteration}` : ''; const phase = e.phase ? `${escapeHtml(e.phase)}` : ''; const time = e.time ? `${escapeHtml(e.time)}` : ''; - return `