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 -![Dashboard](screenshots/dashboard.png) +![APEX in action](screenshots/apex_demo.gif) -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 `
+ const cursor = e.streaming ? '' : ''; + return `
${_markerFor(kind)}
-
${escapeHtml(e.msg)}
+
${escapeHtml(e.msg)}${cursor}
${time}${iter}${phase}
`; @@ -1257,6 +1258,52 @@ socket.on('ai_thinking', (d) => { addThinking(d); }); +/* ── Live AI token streaming ───────────────────────────────────── + Claude streams the reasoning text token-by-token via SSE; the + pipeline forwards each chunk as `ai_thinking_chunk`. We render + it as a single growing bubble that finalises on `end`. */ +let _streamingEntry = null; // index into thinkingFeedState.entries while open +socket.on('ai_thinking_chunk', (d) => { + const evt = d.event; + if (evt === 'start') { + // Open a new entry. Render-loop will paint as deltas arrive. + thinkingFeedState.entries.push({ + msg: '', + kind: 'reasoning', + streaming: true, + time: nowStr(), + phase: d.phase, + }); + if (thinkingFeedState.entries.length > MAX_THINKING_ENTRIES) { + thinkingFeedState.entries.shift(); + } + _streamingEntry = thinkingFeedState.entries.length - 1; + safe('tf-badge', e => { e.textContent = 'Streaming'; e.className = 'badge live'; }); + renderThinkingFeed(); + } else if (evt === 'delta' && _streamingEntry != null) { + const e = thinkingFeedState.entries[_streamingEntry]; + if (e) { + e.msg += d.text || ''; + renderThinkingFeed(); + } + } else if (evt === 'end' && _streamingEntry != null) { + const e = thinkingFeedState.entries[_streamingEntry]; + if (e) { + e.streaming = false; + renderThinkingFeed(); + } + _streamingEntry = null; + safe('tf-badge', e => { e.textContent = 'Live'; e.className = 'badge live'; }); + } else if (evt === 'error') { + if (_streamingEntry != null) { + const e = thinkingFeedState.entries[_streamingEntry]; + if (e) { e.kind = 'warning'; e.streaming = false; e.msg += ' [stream error: ' + (d.error || '?') + ']'; } + } + _streamingEntry = null; + renderThinkingFeed(); + } +}); + socket.on('param_changes', (d) => { addParamChanges(d); }); @@ -1769,38 +1816,117 @@ async function openBestResult() { evoSection.innerHTML = '
Evolution Path
No evolution data available for this run.
'; return; } - const items = evo.map((r, i) => { - const tag = r.is_best ? 'BEST' : ''; - const phaseLbl = (r.phase || '').replace(/_/g, ' '); - const pf = Number(r.profit_factor || 0).toFixed(2); - const calmar = Number(r.calmar || 0).toFixed(2); - const dd = Number(r.max_drawdown || 0).toFixed(1); - const score = Number(r.score || 0).toFixed(3); - const changeList = (r.changes || []).slice(0, 3).map(c => { - const p = c.param || c.parameter || '?'; - const v = c.value !== undefined ? c.value : (c.to !== undefined ? c.to : '?'); - return `${escapeHtml(p)}=${escapeHtml(String(v))}`; - }).join(', '); - const analysis = r.analysis ? `
${escapeHtml(r.analysis).slice(0, 220)}${r.analysis.length > 220 ? '…' : ''}
` : ''; - return `
-
- #${i + 1} - ${escapeHtml(r.run_id)} - ${escapeHtml(phaseLbl)} - ${tag} - score ${score} -
-
- PF ${pf}Calmar ${calmar}DD ${dd}% -
- ${changeList ? `
Δ ${escapeHtml(changeList)}
` : ''} - ${analysis} -
`; - }).join(''); + // ── Replay scrubber: slider that walks through evolution one step at a time ── + const bestIdx = Math.max(0, evo.findIndex(r => r.is_best)); evoSection.innerHTML = ` -
Evolution Path — ${evo.length} steps to this best result
-
${items}
+
+ Evolution Path + — ${evo.length} steps to this best result · drag the slider to replay +
+ +
+ + +
+ + + + +
+ + +
+ + + + + score +
+ + +
+ + +
+ + +
+
`; + + // Wire scrubber + const slider = document.getElementById('evo-slider'); + const stepNum = document.getElementById('evo-step-num'); + const stepId = document.getElementById('evo-step-id'); + const stepPh = document.getElementById('evo-step-phase'); + const stepBst = document.getElementById('evo-step-best'); + const stepScr = document.getElementById('evo-step-score'); + const stepMtr = document.getElementById('evo-step-metrics'); + const stepChg = document.getElementById('evo-step-changes'); + const stepAna = document.getElementById('evo-step-analysis'); + const prevBtn = document.getElementById('evo-prev-btn'); + const nextBtn = document.getElementById('evo-next-btn'); + const playBtn = document.getElementById('evo-play-btn'); + + function paintStep(i) { + const r = evo[i]; + if (!r) return; + stepNum.textContent = `Step ${i + 1} / ${evo.length}`; + stepId.textContent = r.run_id || ''; + stepPh.textContent = (r.phase || '').replace(/_/g, ' '); + stepBst.innerHTML = r.is_best ? ' BEST' : ''; + stepScr.textContent = Number(r.score || 0).toFixed(3); + + const cells = [ + { l: 'PF', v: Number(r.profit_factor || 0).toFixed(2), c: 'var(--yellow)' }, + { l: 'Calmar', v: Number(r.calmar || 0).toFixed(2), c: 'var(--teal)' }, + { l: 'DD', v: Number(r.max_drawdown || 0).toFixed(1) + '%', c: 'var(--red)' }, + { l: 'Profit', v: fmtMoney(r.net_profit || 0), c: r.net_profit >= 0 ? 'var(--green)' : 'var(--red)' }, + ]; + stepMtr.innerHTML = cells.map(c => `
${c.l}
${escapeHtml(c.v)}
`).join(''); + + const changes = r.changes || []; + if (changes.length) { + stepChg.innerHTML = 'Δ ' + changes.slice(0, 4).map(c => { + const p = c.param || c.parameter || '?'; + const v = c.value !== undefined ? c.value : (c.to !== undefined ? c.to : '?'); + return `${escapeHtml(p)}=${escapeHtml(String(v))}`; + }).join(', '); + } else { + stepChg.innerHTML = '— no parameter changes (LHS seed)'; + } + stepAna.textContent = r.analysis || ''; + slider.value = i; + } + + slider.addEventListener('input', () => paintStep(parseInt(slider.value, 10))); + prevBtn.addEventListener('click', () => paintStep(Math.max(0, parseInt(slider.value, 10) - 1))); + nextBtn.addEventListener('click', () => paintStep(Math.min(evo.length - 1, parseInt(slider.value, 10) + 1))); + + // Play: auto-step ~700ms intervals + let playTimer = null; + playBtn.addEventListener('click', () => { + if (playTimer) { + clearInterval(playTimer); playTimer = null; + playBtn.textContent = '▶ Play'; + return; + } + playBtn.textContent = '⏸ Pause'; + let pos = parseInt(slider.value, 10); + if (pos >= evo.length - 1) pos = 0; + playTimer = setInterval(() => { + pos++; + if (pos >= evo.length) { + clearInterval(playTimer); playTimer = null; + playBtn.textContent = '▶ Play'; + return; + } + paintStep(pos); + }, 700); + }); + + paintStep(bestIdx); // start showing the best step } catch (err) { addLog('Failed to open best result: ' + err.message, 'error'); } diff --git a/ui/templates/dashboard.html b/ui/templates/dashboard.html index 1a6cf01..a547221 100644 --- a/ui/templates/dashboard.html +++ b/ui/templates/dashboard.html @@ -1432,6 +1432,14 @@ gap: 0.5rem; font-family: 'JetBrains Mono', monospace; } + .tf-cursor { + display: inline-block; + margin-left: 1px; + color: var(--accent2); + font-weight: 700; + animation: tf-blink 1s steps(2) infinite; + } + @keyframes tf-blink { 50% { opacity: 0; } } .tf-chip { display: inline-block; padding: 0.05rem 0.35rem; diff --git a/ui/templates/reports_index.html b/ui/templates/reports_index.html index 8aa7eaf..376a9b3 100644 --- a/ui/templates/reports_index.html +++ b/ui/templates/reports_index.html @@ -129,6 +129,45 @@ .ai-box { background: rgba(79,70,229,0.07); border: 1px solid rgba(79,70,229,0.22); border-radius: 8px; padding: 12px 14px; font-size: 12px; line-height: 1.6; } .ai-box .lbl { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent2); margin-bottom: 5px; } .modal-footer { padding: 14px 22px; border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end; flex-shrink: 0; flex-wrap: wrap; } + + /* Compare-runs UI */ + .cmp-checkbox { + position: absolute; top: 10px; right: 10px; + width: 20px; height: 20px; + border-radius: 5px; border: 1.5px solid var(--border); + background: rgba(0,0,0,0.4); cursor: pointer; + display: flex; align-items: center; justify-content: center; + font-size: 12px; font-weight: 700; color: transparent; + transition: all 0.15s; + } + .cmp-checkbox:hover { border-color: var(--accent2); } + .cmp-checkbox.on { background: var(--accent2); border-color: var(--accent2); color: white; } + + .cmp-bar { + position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); + background: var(--card); border: 1px solid rgba(79,70,229,0.4); + border-radius: 999px; padding: 8px 16px; + display: none; align-items: center; gap: 14px; + z-index: 150; box-shadow: 0 6px 24px rgba(0,0,0,0.4); + animation: pop 0.25s cubic-bezier(0.34,1.56,0.64,1) both; + } + .cmp-bar.open { display: flex; } + .cmp-bar-text { font-size: 13px; font-weight: 600; } + .cmp-bar-text strong { color: var(--accent2); } + .cmp-bar .btn { padding: 6px 14px; font-size: 12px; } + + /* Compare modal contents */ + .cmp-table { width: 100%; border-collapse: collapse; font-size: 12px; } + .cmp-table th, .cmp-table td { padding: 7px 10px; border-bottom: 1px solid rgba(255,255,255,0.04); text-align: left; } + .cmp-table th { color: var(--muted); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; } + .cmp-table td.metric-name { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text); } + .cmp-table td.metric-val { font-family: 'JetBrains Mono', monospace; font-weight: 600; text-align: right; } + .cmp-table .winner { background: rgba(16,185,129,0.08); } + .cmp-table .winner .metric-val { color: var(--green); } + .cmp-table .loser .metric-val { color: var(--muted); } + .cmp-table .changed { background: rgba(245,158,11,0.06); } + .cmp-row-header { background: rgba(255,255,255,0.03); } + .cmp-row-header td { font-weight: 700; color: var(--text); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; } @@ -163,6 +202,7 @@ data-has-ai="{{ r.has_ai|lower }}" data-has-set="{{ r.has_set|lower }}" data-search="{{ (r.run_id ~ ' ' ~ r.phase)|lower }}"> +
{{ r.run_id }}
@@ -215,6 +255,28 @@
{% endif %} + +
+ 0 selected for compare + + +
+ + +
+ +
+