feat: 6 user-facing upgrades + realistic demo metrics + animated hero
Demo + assets
- Synthetic backtests now occasionally fail (regime failures, OOS degradation)
so verdicts span RECOMMENDED / RISKY / NOT_RELIABLE realistically. Phase 1
has ~35% failure rate, Phase 2 AI loop ~10%, OOS has 30% chance of severe
degradation — matches what real markets look like
- screenshots/dashboard.png + best_result_modal.png regenerated against the
current UI; screenshots/apex_demo.gif (6-frame autonomous-run timelapse)
embedded in the README
FEATURE 1 — Live AI token streaming
- AIReasoner._call_claude() now streams via SSE when a callback is
registered. Each text delta forwards to the dashboard as
`ai_thinking_chunk` events
- The Live AI Thinking Feed renders a single growing bubble with a blinking
cursor while text streams in, finalising on `end`. Looks and feels like
watching the AI type
FEATURE 2 — Pre-flight check on /setup
- New /api/preflight endpoint runs 5–7 probes: config readable, API key
set, MT5 paths exist (skipped in demo), EA registered, reports folder
writable. Returns {ok, blocking_count, checks[]}
- Setup page renders a colour-coded checklist on load and refocus.
Replaces "click Start, wait 5s, see generic error"
FEATURE 3 — Hot-reload settings into the running pipeline
- pipeline.reload_config() applies AI model / timeout / API-key swaps to
the live reasoner mid-run. Threshold changes surface for next run
- /api/settings POST detects a running pipeline and calls reload_config(),
returning the changed keys plus a "hot-reloaded into the running
optimization" note
FEATURE 4 — Replay scrubber on Best Result
- Evolution path now renders as an interactive scrubber: range slider +
prev/next/play buttons. Each step shows the run ID, phase, score, full
metrics grid, parameter changes for that step, and the AI's analysis
text — auto-plays at 700ms/step
FEATURE 5 — Compare runs on /reports
- Each card has a checkbox; selecting 2–4 reveals a floating Compare bar.
Compare modal renders a side-by-side table with metric winners
highlighted (Calmar / PF / profit favour higher; DD favours lower)
and a parameter-diff section showing changed values
FEATURE 6 — Discord / Slack / generic webhook on completion
- New `notifications.webhook_url` + `webhook_style` config keys
- Auto-detects Discord vs Slack from the URL host. Posts a one-line
summary on `optimization_complete`: verdict + best run + PF/Calmar/DD/
profit/trades/elapsed
This commit is contained in:
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+58
-14
@@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+159
-6
@@ -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.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 428 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 198 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 230 KiB |
+158
-32
@@ -1030,10 +1030,11 @@ function renderThinkingFeed() {
|
||||
const iter = e.iteration ? `<span class="tf-chip">iter ${e.iteration}</span>` : '';
|
||||
const phase = e.phase ? `<span class="tf-chip">${escapeHtml(e.phase)}</span>` : '';
|
||||
const time = e.time ? `<span>${escapeHtml(e.time)}</span>` : '';
|
||||
return `<div class="tf-entry ${kind}">
|
||||
const cursor = e.streaming ? '<span class="tf-cursor">▌</span>' : '';
|
||||
return `<div class="tf-entry ${kind}${e.streaming ? ' streaming' : ''}">
|
||||
<div class="tf-marker">${_markerFor(kind)}</div>
|
||||
<div class="tf-body">
|
||||
<div class="tf-msg">${escapeHtml(e.msg)}</div>
|
||||
<div class="tf-msg">${escapeHtml(e.msg)}${cursor}</div>
|
||||
<div class="tf-meta">${time}${iter}${phase}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -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 = '<div class="rd-section-title">Evolution Path</div><div style="font-size:0.76rem;color:var(--muted);padding:0.5rem 0;">No evolution data available for this run.</div>';
|
||||
return;
|
||||
}
|
||||
const items = evo.map((r, i) => {
|
||||
const tag = r.is_best ? '<span style="background:rgba(16,185,129,0.18);color:var(--green);padding:0.08rem 0.4rem;border-radius:4px;font-size:0.58rem;font-weight:700;margin-left:0.4rem">BEST</span>' : '';
|
||||
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 ? `<div style="font-size:0.68rem;color:#94a3b8;margin-top:0.25rem;line-height:1.45">${escapeHtml(r.analysis).slice(0, 220)}${r.analysis.length > 220 ? '…' : ''}</div>` : '';
|
||||
return `<div style="border-left:2px solid ${r.is_best ? 'var(--green)' : 'rgba(124,109,250,0.4)'};padding:0.5rem 0.65rem;margin-bottom:0.4rem;background:rgba(255,255,255,0.02);border-radius:0 6px 6px 0">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.72rem">
|
||||
<span style="font-family:'JetBrains Mono',monospace;color:var(--muted)">#${i + 1}</span>
|
||||
<span style="font-family:'JetBrains Mono',monospace;color:var(--accent2)">${escapeHtml(r.run_id)}</span>
|
||||
<span style="color:var(--muted);font-size:0.6rem;text-transform:uppercase;letter-spacing:0.04em">${escapeHtml(phaseLbl)}</span>
|
||||
${tag}
|
||||
<span style="margin-left:auto;font-size:0.65rem;font-family:'JetBrains Mono',monospace;color:var(--muted)">score ${score}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.6rem;font-size:0.65rem;color:var(--muted);margin-top:0.2rem;font-family:'JetBrains Mono',monospace">
|
||||
<span>PF ${pf}</span><span>Calmar ${calmar}</span><span>DD ${dd}%</span>
|
||||
</div>
|
||||
${changeList ? `<div style="font-size:0.66rem;color:var(--teal);font-family:'JetBrains Mono',monospace;margin-top:0.25rem">Δ ${escapeHtml(changeList)}</div>` : ''}
|
||||
${analysis}
|
||||
</div>`;
|
||||
}).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 = `
|
||||
<div class="rd-section-title">Evolution Path <span style="font-weight:500;color:var(--muted);letter-spacing:0.02em;text-transform:none;font-size:0.65rem">— ${evo.length} steps to this best result</span></div>
|
||||
<div style="max-height:260px;overflow-y:auto;padding:0.25rem 0;">${items}</div>
|
||||
<div class="rd-section-title">
|
||||
Evolution Path
|
||||
<span style="font-weight:500;color:var(--muted);letter-spacing:0.02em;text-transform:none;font-size:0.65rem">— ${evo.length} steps to this best result · drag the slider to replay</span>
|
||||
</div>
|
||||
|
||||
<div id="evo-scrubber-wrap" style="background:rgba(255,255,255,0.02);border:1px solid var(--border);border-radius:10px;padding:0.85rem 1rem;margin-bottom:0.5rem;">
|
||||
|
||||
<!-- Step indicator + slider -->
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.6rem;">
|
||||
<button id="evo-prev-btn" style="background:rgba(255,255,255,0.05);border:1px solid var(--border);color:var(--text);font-family:inherit;font-size:0.85rem;width:28px;height:28px;border-radius:6px;cursor:pointer;flex-shrink:0;">‹</button>
|
||||
<input type="range" id="evo-slider" min="0" max="${evo.length - 1}" value="${bestIdx}"
|
||||
style="flex:1;accent-color:var(--accent2);cursor:pointer;">
|
||||
<button id="evo-next-btn" style="background:rgba(255,255,255,0.05);border:1px solid var(--border);color:var(--text);font-family:inherit;font-size:0.85rem;width:28px;height:28px;border-radius:6px;cursor:pointer;flex-shrink:0;">›</button>
|
||||
<button id="evo-play-btn" style="background:rgba(79,70,229,0.15);border:1px solid rgba(79,70,229,0.4);color:var(--accent2);font-family:inherit;font-size:0.7rem;padding:4px 10px;border-radius:6px;cursor:pointer;flex-shrink:0;font-weight:600;">▶ Play</button>
|
||||
</div>
|
||||
|
||||
<!-- Step header -->
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.72rem;flex-wrap:wrap;margin-bottom:0.5rem;">
|
||||
<span id="evo-step-num" style="font-family:'JetBrains Mono',monospace;color:var(--muted);font-weight:600;"></span>
|
||||
<span id="evo-step-id" style="font-family:'JetBrains Mono',monospace;color:var(--accent2);"></span>
|
||||
<span id="evo-step-phase" style="color:var(--muted);font-size:0.6rem;text-transform:uppercase;letter-spacing:0.06em;"></span>
|
||||
<span id="evo-step-best"></span>
|
||||
<span style="margin-left:auto;font-size:0.65rem;font-family:'JetBrains Mono',monospace;color:var(--muted);">score <span id="evo-step-score" style="color:var(--text);font-weight:600;"></span></span>
|
||||
</div>
|
||||
|
||||
<!-- Metrics for this step -->
|
||||
<div id="evo-step-metrics" style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.4rem;margin-bottom:0.55rem;"></div>
|
||||
|
||||
<!-- Changes -->
|
||||
<div id="evo-step-changes" style="font-size:0.7rem;font-family:'JetBrains Mono',monospace;color:var(--teal);line-height:1.5;"></div>
|
||||
|
||||
<!-- AI analysis -->
|
||||
<div id="evo-step-analysis" style="font-size:0.72rem;color:#94a3b8;margin-top:0.45rem;line-height:1.55;"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 ? ' <span style="background:rgba(16,185,129,0.18);color:var(--green);padding:0.06rem 0.4rem;border-radius:4px;font-size:0.55rem;font-weight:700;letter-spacing:0.04em;">BEST</span>' : '';
|
||||
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 => `<div style="background:rgba(255,255,255,0.03);padding:5px 8px;border-radius:5px;"><div style="font-size:0.55rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;">${c.l}</div><div style="font-size:0.85rem;font-weight:700;font-family:'JetBrains Mono',monospace;color:${c.c};">${escapeHtml(c.v)}</div></div>`).join('');
|
||||
|
||||
const changes = r.changes || [];
|
||||
if (changes.length) {
|
||||
stepChg.innerHTML = '<strong style="color:var(--muted);font-weight:500;text-transform:uppercase;letter-spacing:0.06em;font-size:0.6rem;">Δ</strong> ' + 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 = '<em style="color:var(--muted);">— no parameter changes (LHS seed)</em>';
|
||||
}
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -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 }}">
|
||||
<div class="cmp-checkbox" onclick="toggleCompare(this, '{{ r.run_id }}')" title="Select for compare">✓</div>
|
||||
<div class="run-header">
|
||||
<div style="min-width:0;flex:1;">
|
||||
<div class="run-id">{{ r.run_id }}</div>
|
||||
@@ -215,6 +255,28 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── Floating compare bar ──────────────────────────────────── -->
|
||||
<div class="cmp-bar" id="cmp-bar">
|
||||
<span class="cmp-bar-text"><strong id="cmp-count">0</strong> selected for compare</span>
|
||||
<button class="btn primary" onclick="openCompare()">Compare</button>
|
||||
<button class="btn" onclick="clearCompare()">Clear</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Compare modal ─────────────────────────────────────────── -->
|
||||
<div class="overlay" id="cmp-overlay" onclick="if(event.target===this)closeCompare()">
|
||||
<div class="modal" style="width:920px;max-width:96vw;">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<div class="modal-title">Compare Runs</div>
|
||||
<div class="modal-meta" id="cmp-meta">side-by-side metrics + parameter diff</div>
|
||||
</div>
|
||||
<button class="close-btn" onclick="closeCompare()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" id="cmp-body"></div>
|
||||
<div class="modal-footer"><button class="btn" onclick="closeCompare()">Close</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Detail Modal (params + AI reasoning + .set) ─────────────── -->
|
||||
<div class="overlay" id="overlay" onclick="if(event.target===this)closeDetail()">
|
||||
<div class="modal">
|
||||
@@ -402,8 +464,122 @@ function closeDetail() {
|
||||
document.getElementById('overlay').classList.remove('open');
|
||||
}
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closeDetail();
|
||||
if (e.key === 'Escape') { closeDetail(); closeCompare(); }
|
||||
});
|
||||
|
||||
// ── Compare runs ────────────────────────────────────────────
|
||||
const compareSet = new Set();
|
||||
const COMPARE_MAX = 4;
|
||||
|
||||
function toggleCompare(box, runId) {
|
||||
if (compareSet.has(runId)) {
|
||||
compareSet.delete(runId);
|
||||
box.classList.remove('on');
|
||||
} else {
|
||||
if (compareSet.size >= COMPARE_MAX) {
|
||||
alert('Compare up to ' + COMPARE_MAX + ' runs at once.');
|
||||
return;
|
||||
}
|
||||
compareSet.add(runId);
|
||||
box.classList.add('on');
|
||||
}
|
||||
const bar = document.getElementById('cmp-bar');
|
||||
const count = compareSet.size;
|
||||
document.getElementById('cmp-count').textContent = count;
|
||||
bar.classList.toggle('open', count >= 2);
|
||||
}
|
||||
|
||||
function clearCompare() {
|
||||
compareSet.clear();
|
||||
document.querySelectorAll('.cmp-checkbox.on').forEach(c => c.classList.remove('on'));
|
||||
document.getElementById('cmp-bar').classList.remove('open');
|
||||
document.getElementById('cmp-count').textContent = '0';
|
||||
}
|
||||
|
||||
async function openCompare() {
|
||||
if (compareSet.size < 2) return;
|
||||
const ids = Array.from(compareSet);
|
||||
const overlay = document.getElementById('cmp-overlay');
|
||||
const body = document.getElementById('cmp-body');
|
||||
overlay.classList.add('open');
|
||||
body.innerHTML = '<div style="text-align:center;padding:24px;color:var(--muted);">Loading…</div>';
|
||||
document.getElementById('cmp-meta').textContent = ids.length + ' runs · side-by-side metrics + parameter diff';
|
||||
|
||||
try {
|
||||
const detail = await Promise.all(ids.map(id => fetch('/api/run/' + encodeURIComponent(id)).then(r => r.json())));
|
||||
const metrics = [
|
||||
['score', 'Score', v => Number(v || 0).toFixed(4), 'higher'],
|
||||
['net_profit', 'Net Profit', v => '$' + Number(v || 0).toLocaleString(undefined, {maximumFractionDigits: 0}), 'higher'],
|
||||
['profit_factor', 'Profit Factor', v => Number(v || 0).toFixed(2), 'higher'],
|
||||
['calmar', 'Calmar', v => Number(v || 0).toFixed(2), 'higher'],
|
||||
['drawdown_pct', 'Max Drawdown', v => Number(v || 0).toFixed(1) + '%', 'lower'],
|
||||
['win_rate', 'Win Rate', v => Number(v || 0).toFixed(1) + '%', 'higher'],
|
||||
['total_trades', 'Trades', v => Number(v || 0).toLocaleString(), 'higher'],
|
||||
];
|
||||
|
||||
// Build header row
|
||||
let html = '<table class="cmp-table">';
|
||||
html += '<thead><tr><th>Metric</th>';
|
||||
detail.forEach(d => {
|
||||
const phase = (d.phase || '—').replace(/_/g, ' ');
|
||||
html += `<th style="text-align:right;">
|
||||
<div style="color:var(--accent2);font-family:'JetBrains Mono',monospace;font-size:10px;">${escHtml(d.run_id || '?')}</div>
|
||||
<div style="color:var(--muted);font-size:9px;font-weight:500;">${escHtml(phase)}</div>
|
||||
</th>`;
|
||||
});
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
// Metric rows: highlight winner per row
|
||||
for (const [key, label, fmt, dir] of metrics) {
|
||||
const vals = detail.map(d => {
|
||||
const raw = d[key];
|
||||
return raw === undefined || raw === null ? null : parseFloat(raw);
|
||||
});
|
||||
const winners = (() => {
|
||||
const valid = vals.filter(v => v !== null && !isNaN(v));
|
||||
if (!valid.length) return [];
|
||||
const target = dir === 'higher' ? Math.max(...valid) : Math.min(...valid);
|
||||
return vals.map(v => v === target);
|
||||
})();
|
||||
html += `<tr><td class="metric-name">${escHtml(label)}</td>`;
|
||||
detail.forEach((d, i) => {
|
||||
const cls = vals[i] === null ? '' : (winners[i] ? 'winner' : 'loser');
|
||||
html += `<td class="metric-val ${cls}">${escHtml(d[key] === undefined || d[key] === null ? '—' : fmt(d[key]))}</td>`;
|
||||
});
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
// Parameter diff section
|
||||
html += '<tr class="cmp-row-header"><td colspan="' + (detail.length + 1) + '">Parameters · differences highlighted</td></tr>';
|
||||
const allParams = new Set();
|
||||
detail.forEach(d => Object.keys(d.params || {}).forEach(p => allParams.add(p)));
|
||||
const paramList = Array.from(allParams).sort();
|
||||
paramList.forEach(p => {
|
||||
const vals = detail.map(d => (d.params || {})[p]);
|
||||
const allEqual = vals.every(v => JSON.stringify(v) === JSON.stringify(vals[0]));
|
||||
const cls = allEqual ? '' : 'changed';
|
||||
html += `<tr class="${cls}"><td class="metric-name">${escHtml(p)}</td>`;
|
||||
vals.forEach(v => {
|
||||
const display = v === undefined || v === null
|
||||
? '—'
|
||||
: (typeof v === 'number'
|
||||
? (v % 1 !== 0 ? v.toFixed(4) : String(v))
|
||||
: String(v));
|
||||
html += `<td class="metric-val">${escHtml(display)}</td>`;
|
||||
});
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
|
||||
body.innerHTML = html;
|
||||
} catch (err) {
|
||||
body.innerHTML = '<div style="color:var(--red);padding:16px;">Compare failed: ' + escHtml(err.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function closeCompare() {
|
||||
document.getElementById('cmp-overlay').classList.remove('open');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -223,6 +223,28 @@
|
||||
<form id="setup-form" novalidate>
|
||||
|
||||
<!-- EA Identity -->
|
||||
<!-- ── Pre-flight check ─────────────────────────────────────── -->
|
||||
<div class="section" id="preflight-section" style="margin-bottom:14px;">
|
||||
<div class="section-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9 12l2 2 4-4"/></svg>
|
||||
Pre-flight check
|
||||
<span id="preflight-status" style="margin-left:auto;font-size:0.78rem;color:#64748b;font-weight:500;">running…</span>
|
||||
</div>
|
||||
<div id="preflight-list" style="display:flex;flex-direction:column;gap:6px;font-size:0.82rem;"></div>
|
||||
</div>
|
||||
<style>
|
||||
.pf-row { display:flex; align-items:flex-start; gap:8px; padding:6px 10px; border-radius:6px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.05); }
|
||||
.pf-row.ok { border-color: rgba(16,185,129,0.3); }
|
||||
.pf-row.warn { border-color: rgba(245,158,11,0.3); background: rgba(245,158,11,0.05); }
|
||||
.pf-row.err { border-color: rgba(239,68,68,0.3); background: rgba(239,68,68,0.05); }
|
||||
.pf-mark { width:14px; flex-shrink:0; font-weight:700; }
|
||||
.pf-row.ok .pf-mark { color:#10b981; }
|
||||
.pf-row.warn .pf-mark { color:#f59e0b; }
|
||||
.pf-row.err .pf-mark { color:#ef4444; }
|
||||
.pf-name { font-weight:600; color:#e2e8f0; }
|
||||
.pf-hint { display:block; font-size:0.72rem; color:#94a3b8; margin-top:2px; line-height:1.4; }
|
||||
</style>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M9 12l2 2 4-4"/></svg>
|
||||
@@ -735,6 +757,39 @@
|
||||
loadEAParams(this.value);
|
||||
});
|
||||
|
||||
// ── Pre-flight check ──────────────────────────────────────────
|
||||
async function runPreflight() {
|
||||
const list = document.getElementById('preflight-list');
|
||||
const status = document.getElementById('preflight-status');
|
||||
if (!list) return;
|
||||
try {
|
||||
const r = await fetch('/api/preflight');
|
||||
const d = await r.json();
|
||||
const blocking = d.blocking_count || 0;
|
||||
status.textContent = blocking === 0 ? (d.demo_mode ? 'demo mode · ready' : 'all clear · ready') : `${blocking} issue${blocking > 1 ? 's' : ''} to fix`;
|
||||
status.style.color = blocking === 0 ? '#10b981' : '#ef4444';
|
||||
list.innerHTML = (d.checks || []).map(c => {
|
||||
let cls = 'ok', mark = '✓';
|
||||
if (!c.ok) {
|
||||
// API key is informational, not blocking
|
||||
if (c.name && c.name.toLowerCase().includes('api key')) { cls = 'warn'; mark = '!'; }
|
||||
else { cls = 'err'; mark = '✗'; }
|
||||
}
|
||||
return `<div class="pf-row ${cls}">
|
||||
<div class="pf-mark">${mark}</div>
|
||||
<div><div class="pf-name">${c.name || ''}</div>${c.hint ? `<span class="pf-hint">${c.hint}</span>` : ''}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
status.textContent = 'preflight failed';
|
||||
status.style.color = '#ef4444';
|
||||
list.innerHTML = `<div class="pf-row err"><div class="pf-mark">✗</div><div><div class="pf-name">Could not reach /api/preflight</div><span class="pf-hint">${e.message}</span></div></div>`;
|
||||
}
|
||||
}
|
||||
runPreflight();
|
||||
// Re-run preflight when user adds an EA so the "EA registered" check refreshes
|
||||
window.addEventListener('focus', runPreflight);
|
||||
|
||||
function toggleAutonomous(btn) {
|
||||
const isOn = btn.style.background === 'rgb(79, 70, 229)' || btn.classList.contains('on');
|
||||
if (isOn) {
|
||||
|
||||
Reference in New Issue
Block a user