fix: live-activity persistence on refresh + reports page rebuild
Refresh-during-run no longer wipes the dashboard - pipeline now keeps in-memory rolling logs of ai_thinking, param_changes, validation events, and the current early-termination state. Logs reset at the start of each new run (capped: 200 thinking / 50 param-change records / 40 validation events) - new GET /api/live_activity returns those logs + current phase + running state in one shot — the dashboard hits it on page load - restoreHistory() in dashboard.js now replays each event into addThinking / addParamChanges / validationRunStart-Complete-Done / showEarlyTermination, and re-applies the active phase via setPhaseActive. Reload F5 mid-run no longer shows a fresh empty dashboard - addThinking() preserves the original ts on replay (was using nowStr() so every replayed entry got the refresh time) Reports page (/reports) rebuilt - previous template crashed with 500 on legacy summary.json files that pre-date the win_rate / drawdown_pct fields. Server now backfills sane defaults for every metric the template touches - runs now sorted by ts (was filesystem-iterdir order) - new template: search bar + filter chips (All / Exploration / AI Iteration / Validation / AI insight / Has .set), phase tags, AI/.set badges, three action buttons per card (View Params / Download .set / Full Report) - per-card detail modal shows metrics grid + full parameters table + AI reasoning + Download .set button — the missing "click to see params + download" path the user reported - has_set detected per-run by globbing run_dir/*.set; AI insight tag shown when ai_insight.json exists on disk
This commit is contained in:
@@ -186,21 +186,38 @@ def download_set(run_id):
|
|||||||
def reports_index():
|
def reports_index():
|
||||||
import json, re
|
import json, re
|
||||||
runs = []
|
runs = []
|
||||||
for run_dir in sorted(REPORTS_DIR.iterdir(), reverse=True) if REPORTS_DIR.exists() else []:
|
if REPORTS_DIR.exists():
|
||||||
if not run_dir.is_dir():
|
for run_dir in REPORTS_DIR.iterdir():
|
||||||
continue
|
if not run_dir.is_dir():
|
||||||
summary = run_dir / "summary.json"
|
continue
|
||||||
if summary.exists():
|
summary = run_dir / "summary.json"
|
||||||
|
if not summary.exists():
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
txt = summary.read_text(encoding="utf-8")
|
txt = summary.read_text(encoding="utf-8")
|
||||||
txt = re.sub(r'\bNaN\b', 'null', txt)
|
txt = re.sub(r'\bNaN\b', 'null', txt)
|
||||||
txt = re.sub(r'\bInfinity\b', 'null', txt)
|
txt = re.sub(r'\bInfinity\b', 'null', txt)
|
||||||
data = json.loads(txt)
|
data = json.loads(txt)
|
||||||
data["score"] = data.get("score") or 0
|
# Defensive defaults so the template never crashes on legacy files.
|
||||||
data["score_delta"] = data.get("score_delta") or 0
|
data["run_id"] = data.get("run_id") or run_dir.name
|
||||||
|
data["score"] = data.get("score") or 0
|
||||||
|
data["score_delta"] = data.get("score_delta") or 0
|
||||||
|
data["net_profit"] = data.get("net_profit") or 0
|
||||||
|
data["profit_factor"] = data.get("profit_factor") or 0
|
||||||
|
data["calmar"] = data.get("calmar") or 0
|
||||||
|
data["drawdown_pct"] = data.get("drawdown_pct") or 0
|
||||||
|
data["win_rate"] = data.get("win_rate") or 0
|
||||||
|
data["total_trades"] = data.get("total_trades") or 0
|
||||||
|
data["ts"] = data.get("ts") or ""
|
||||||
|
data["phase"] = data.get("phase") or "phase1"
|
||||||
|
# Surface per-card flags the template uses
|
||||||
|
data["has_set"] = bool(list(run_dir.glob("*.set")))
|
||||||
|
data["has_ai"] = (run_dir / "ai_insight.json").exists() or bool(data.get("has_ai"))
|
||||||
runs.append(data)
|
runs.append(data)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# Sort by ts desc — newest first (was relying on filesystem sort order before)
|
||||||
|
runs.sort(key=lambda r: r.get("ts", ""), reverse=True)
|
||||||
return render_template("reports_index.html", runs=runs[:100])
|
return render_template("reports_index.html", runs=runs[:100])
|
||||||
|
|
||||||
|
|
||||||
@@ -273,6 +290,39 @@ def run_detail(run_id):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/live_activity")
|
||||||
|
def live_activity():
|
||||||
|
"""
|
||||||
|
Single endpoint the dashboard hits on (re)connect to restore everything
|
||||||
|
that's not already in /api/history: AI thinking feed, parameter changes,
|
||||||
|
validation runs, early-termination state, current phase + mode.
|
||||||
|
"""
|
||||||
|
if not pipeline:
|
||||||
|
return jsonify({
|
||||||
|
"thinking": [], "param_changes": [], "validation": [],
|
||||||
|
"early_termination": None,
|
||||||
|
"phase": "idle", "phase_mode": None,
|
||||||
|
"running": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Determine phase mode (autonomous?) for label hints
|
||||||
|
phase_mode = None
|
||||||
|
if getattr(pipeline, "session", None):
|
||||||
|
phase_mode = "autonomous" if getattr(pipeline.session, "autonomous_mode", False) else None
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"thinking": getattr(pipeline, "_thinking_log", []) or [],
|
||||||
|
"param_changes": getattr(pipeline, "_param_changes", []) or [],
|
||||||
|
"validation": getattr(pipeline, "_validation_log", []) or [],
|
||||||
|
"early_termination": getattr(pipeline, "_early_term", None),
|
||||||
|
"phase": getattr(pipeline, "_phase", "idle"),
|
||||||
|
"phase_mode": phase_mode,
|
||||||
|
"running": bool(getattr(pipeline, "running", False)),
|
||||||
|
"run_count": getattr(pipeline, "_run_count", 0),
|
||||||
|
"total_runs": getattr(pipeline, "_total_runs", 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/best_result")
|
@app.route("/api/best_result")
|
||||||
def best_result():
|
def best_result():
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -96,6 +96,13 @@ class OptimizationPipeline:
|
|||||||
# All completed runs (for history restoration)
|
# All completed runs (for history restoration)
|
||||||
self._completed_runs: list[dict] = []
|
self._completed_runs: list[dict] = []
|
||||||
|
|
||||||
|
# Live activity logs — persisted in-memory so a dashboard refresh during
|
||||||
|
# a run can replay them via /api/thinking, /api/param_changes, /api/validation.
|
||||||
|
self._thinking_log: list[dict] = []
|
||||||
|
self._param_changes: list[dict] = []
|
||||||
|
self._validation_log: list[dict] = []
|
||||||
|
self._early_term: Optional[dict] = None
|
||||||
|
|
||||||
# ── Public API ────────────────────────────────────────────────────────────
|
# ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def configure(self, session: SessionConfig) -> None:
|
def configure(self, session: SessionConfig) -> None:
|
||||||
@@ -187,6 +194,11 @@ class OptimizationPipeline:
|
|||||||
self._ai_insights.clear()
|
self._ai_insights.clear()
|
||||||
self._run_findings.clear()
|
self._run_findings.clear()
|
||||||
self._run_insights.clear()
|
self._run_insights.clear()
|
||||||
|
# Reset live-activity logs for the new run
|
||||||
|
self._thinking_log = []
|
||||||
|
self._param_changes = []
|
||||||
|
self._validation_log = []
|
||||||
|
self._early_term = None
|
||||||
|
|
||||||
budget.start()
|
budget.start()
|
||||||
|
|
||||||
@@ -1013,6 +1025,10 @@ class OptimizationPipeline:
|
|||||||
}
|
}
|
||||||
if meta:
|
if meta:
|
||||||
payload["meta"] = meta
|
payload["meta"] = meta
|
||||||
|
# Persist for dashboard refresh — keep last 200 entries
|
||||||
|
self._thinking_log.append(payload)
|
||||||
|
if len(self._thinking_log) > 200:
|
||||||
|
self._thinking_log = self._thinking_log[-200:]
|
||||||
self._emit("ai_thinking", payload)
|
self._emit("ai_thinking", payload)
|
||||||
|
|
||||||
def _emit_early_termination(self, reason_code: str, message: str, details: dict = None) -> None:
|
def _emit_early_termination(self, reason_code: str, message: str, details: dict = None) -> None:
|
||||||
@@ -1033,9 +1049,23 @@ class OptimizationPipeline:
|
|||||||
}
|
}
|
||||||
if details:
|
if details:
|
||||||
payload["details"] = details
|
payload["details"] = details
|
||||||
|
self._early_term = payload # remember for refresh
|
||||||
self._emit("early_termination", payload)
|
self._emit("early_termination", payload)
|
||||||
|
|
||||||
def _emit(self, event: str, data: dict = {}) -> None:
|
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.
|
||||||
|
try:
|
||||||
|
if event == "param_changes":
|
||||||
|
self._param_changes.append(data)
|
||||||
|
if len(self._param_changes) > 50:
|
||||||
|
self._param_changes = self._param_changes[-50:]
|
||||||
|
elif event in ("validation_start", "validation_run_start", "validation_run_complete", "validation_done"):
|
||||||
|
self._validation_log.append({"event": event, **data})
|
||||||
|
if len(self._validation_log) > 40:
|
||||||
|
self._validation_log = self._validation_log[-40:]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
self.socketio.emit(event, data)
|
self.socketio.emit(event, data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1043,12 +1043,22 @@ function renderThinkingFeed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addThinking(d) {
|
function addThinking(d) {
|
||||||
|
// For replayed events, use the original ts from the server. For live ones, use now.
|
||||||
|
let timeLabel = nowStr();
|
||||||
|
if (d.ts) {
|
||||||
|
try {
|
||||||
|
const dt = new Date(d.ts);
|
||||||
|
if (!isNaN(dt)) {
|
||||||
|
timeLabel = dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
const entry = {
|
const entry = {
|
||||||
msg: d.msg || '',
|
msg: d.msg || '',
|
||||||
kind: d.kind || 'info',
|
kind: d.kind || 'info',
|
||||||
iteration: d.iteration,
|
iteration: d.iteration,
|
||||||
phase: d.phase,
|
phase: d.phase,
|
||||||
time: nowStr(),
|
time: timeLabel,
|
||||||
};
|
};
|
||||||
thinkingFeedState.entries.push(entry);
|
thinkingFeedState.entries.push(entry);
|
||||||
if (thinkingFeedState.entries.length > MAX_THINKING_ENTRIES) {
|
if (thinkingFeedState.entries.length > MAX_THINKING_ENTRIES) {
|
||||||
@@ -1324,9 +1334,10 @@ safe('btn-stop', stopBtn => {
|
|||||||
|
|
||||||
async function restoreHistory() {
|
async function restoreHistory() {
|
||||||
try {
|
try {
|
||||||
const [histResp, aiResp] = await Promise.all([
|
const [histResp, aiResp, liveResp] = await Promise.all([
|
||||||
fetch('/api/history'),
|
fetch('/api/history'),
|
||||||
fetch('/api/ai_insight/latest'),
|
fetch('/api/ai_insight/latest'),
|
||||||
|
fetch('/api/live_activity'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const runs = await histResp.json();
|
const runs = await histResp.json();
|
||||||
@@ -1366,6 +1377,60 @@ async function restoreHistory() {
|
|||||||
updateAIPanel(insight);
|
updateAIPanel(insight);
|
||||||
addLog('AI insight restored (run: ' + (insight.run_id || '?') + ').', 'info');
|
addLog('AI insight restored (run: ' + (insight.run_id || '?') + ').', 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Restore Live Intelligence (thinking feed, param changes, validation, banner) ──
|
||||||
|
// This is what makes a refresh-mid-run feel like nothing happened.
|
||||||
|
const live = await liveResp.json();
|
||||||
|
if (live) {
|
||||||
|
// Thinking feed
|
||||||
|
const thinking = Array.isArray(live.thinking) ? live.thinking : [];
|
||||||
|
thinking.forEach(t => addThinking(t));
|
||||||
|
|
||||||
|
// Param changes
|
||||||
|
const pcs = Array.isArray(live.param_changes) ? live.param_changes : [];
|
||||||
|
pcs.forEach(pc => addParamChanges(pc));
|
||||||
|
|
||||||
|
// Validation panel
|
||||||
|
const val = Array.isArray(live.validation) ? live.validation : [];
|
||||||
|
val.forEach(v => {
|
||||||
|
if (v.event === 'validation_start') {
|
||||||
|
validationState.runs = [];
|
||||||
|
validationState.planned = v.planned_runs || 0;
|
||||||
|
validationState.complete = 0;
|
||||||
|
validationState.active = true;
|
||||||
|
} else if (v.event === 'validation_run_start') {
|
||||||
|
validationRunStart(v);
|
||||||
|
} else if (v.event === 'validation_run_complete') {
|
||||||
|
validationRunComplete(v);
|
||||||
|
} else if (v.event === 'validation_done') {
|
||||||
|
validationDone(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (val.length) renderValidation();
|
||||||
|
|
||||||
|
// Early-termination banner
|
||||||
|
if (live.early_termination) {
|
||||||
|
showEarlyTermination(live.early_termination);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase tracker — restore active phase even if no events have arrived yet
|
||||||
|
const phaseMapLocal = { phase1: 1, phase2: 2, phase2_ai: 2, phase3: 3, phase3_oos: 3, phase3_sens: 3 };
|
||||||
|
const ph = phaseMapLocal[live.phase] || (live.running ? 1 : 0);
|
||||||
|
if (ph) {
|
||||||
|
setPhaseActive(ph, { mode: live.phase_mode });
|
||||||
|
}
|
||||||
|
if (live.running) {
|
||||||
|
setRunningState(true, live.phase || 'Optimizing...');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thinking.length || pcs.length || val.length) {
|
||||||
|
addLog(
|
||||||
|
`Restored live activity: ${thinking.length} thinking, `
|
||||||
|
+ `${pcs.length} param-change iterations, ${val.length} validation events.`,
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addLog('History restore: ' + e.message, 'warn');
|
addLog('History restore: ' + e.message, 'warn');
|
||||||
}
|
}
|
||||||
|
|||||||
+368
-52
@@ -2,92 +2,408 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Reports — MT5 Optimizer</title>
|
<title>Reports — APEX MT5 Optimizer</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body { background: #0a0d14; color: #e2e8f8; font-family: 'Inter', sans-serif; font-size: 13px; padding: 32px; }
|
:root {
|
||||||
header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 32px; }
|
--bg: #080d1a; --card: #0f1629; --border: rgba(255,255,255,0.08);
|
||||||
h1 { font-size: 22px; font-weight: 800; }
|
--text: #e2e8f0; --muted: #64748b; --accent: #4f46e5; --accent2: #7c6dfa;
|
||||||
.sub { color: #6b7fa3; font-size: 12px; margin-top: 4px; }
|
--green: #10b981; --red: #ef4444; --yellow: #f59e0b; --teal: #00d4aa;
|
||||||
.back-link { color: #4f8ef7; text-decoration: none; font-size: 12px; }
|
|
||||||
.back-link:hover { text-decoration: underline; }
|
|
||||||
.empty { color: #3d4f70; text-align: center; padding: 80px 0; font-size: 16px; }
|
|
||||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
|
|
||||||
.run-card {
|
|
||||||
background: #111621; border: 1px solid #1e2740; border-radius: 12px; padding: 18px 20px;
|
|
||||||
transition: border-color 0.2s, transform 0.2s;
|
|
||||||
text-decoration: none; color: inherit; display: block;
|
|
||||||
}
|
}
|
||||||
.run-card:hover { border-color: #4f8ef7; transform: translateY(-2px); }
|
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; font-size: 13px; padding: 28px 32px 80px; min-height: 100vh; }
|
||||||
.run-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
|
a { color: var(--accent2); }
|
||||||
.run-id { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: #6b7fa3; }
|
header { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 18px; gap: 24px; flex-wrap: wrap; }
|
||||||
.run-score { font-size: 28px; font-weight: 800; font-family: 'JetBrains Mono', monospace; color: #22d3a5; }
|
h1 { font-size: 22px; font-weight: 800; letter-spacing: -0.02em; }
|
||||||
.run-phase { font-size: 10px; padding: 2px 8px; border-radius: 4px; background: rgba(79,142,247,0.15); color: #4f8ef7; font-weight: 700; text-transform: uppercase; }
|
.sub { color: var(--muted); font-size: 12px; margin-top: 4px; }
|
||||||
.metrics-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 10px; }
|
.back-link { color: var(--accent2); text-decoration: none; font-size: 12px; font-weight: 500; }
|
||||||
.m { text-align: center; background: #161c2a; border-radius: 6px; padding: 6px 8px; }
|
.back-link:hover { text-decoration: underline; }
|
||||||
.m-label { font-size: 9px; color: #6b7fa3; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
||||||
.m-val { font-size: 14px; font-weight: 700; font-family: 'JetBrains Mono', monospace; margin-top: 2px; }
|
/* Toolbar: search + phase filter */
|
||||||
.delta-up { color: #22d3a5; } .delta-dn { color: #f44; }
|
.toolbar {
|
||||||
.run-ts { font-size: 10px; color: #3d4f70; margin-top: 10px; font-family: 'JetBrains Mono', monospace; }
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||||
|
padding: 10px 14px; background: var(--card); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.search {
|
||||||
|
flex: 1; min-width: 220px;
|
||||||
|
background: rgba(255,255,255,0.04); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; padding: 7px 11px; color: var(--text); font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.search:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.filter-chip {
|
||||||
|
padding: 5px 11px; border-radius: 6px; background: rgba(255,255,255,0.04);
|
||||||
|
border: 1px solid var(--border); color: var(--muted); font-size: 12px;
|
||||||
|
cursor: pointer; user-select: none; font-weight: 500;
|
||||||
|
}
|
||||||
|
.filter-chip:hover { color: var(--text); }
|
||||||
|
.filter-chip.active { background: rgba(79,70,229,0.18); color: var(--accent2); border-color: rgba(79,70,229,0.4); }
|
||||||
|
.toolbar-stats { font-size: 11px; color: var(--muted); margin-left: auto; font-family: 'JetBrains Mono', monospace; }
|
||||||
|
|
||||||
|
.empty { color: var(--muted); text-align: center; padding: 80px 0; font-size: 15px; }
|
||||||
|
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: 14px; }
|
||||||
|
.run-card {
|
||||||
|
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
|
||||||
|
padding: 16px 18px; transition: border-color 0.15s, transform 0.15s;
|
||||||
|
display: flex; flex-direction: column; gap: 10px; position: relative;
|
||||||
|
}
|
||||||
|
.run-card:hover { border-color: rgba(79,70,229,0.4); transform: translateY(-1px); }
|
||||||
|
.run-card.hidden { display: none; }
|
||||||
|
|
||||||
|
.run-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; }
|
||||||
|
.run-id { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--muted); word-break: break-all; }
|
||||||
|
.run-score { font-size: 24px; font-weight: 800; font-family: 'JetBrains Mono', monospace; color: var(--teal); line-height: 1; margin-top: 2px; }
|
||||||
|
.run-tags { display: flex; gap: 4px; flex-wrap: wrap; align-items: flex-start; }
|
||||||
|
.tag {
|
||||||
|
font-size: 9px; padding: 2px 7px; border-radius: 4px; font-weight: 700;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tag.phase-phase1 { background: rgba(124,109,250,0.15); color: var(--accent2); }
|
||||||
|
.tag.phase-phase2 { background: rgba(0,212,170,0.15); color: var(--teal); }
|
||||||
|
.tag.phase-phase2_ai { background: rgba(0,212,170,0.15); color: var(--teal); }
|
||||||
|
.tag.phase-phase3 { background: rgba(245,158,11,0.15); color: var(--yellow); }
|
||||||
|
.tag.phase-phase3_oos { background: rgba(245,158,11,0.15); color: var(--yellow); }
|
||||||
|
.tag.phase-phase3_sens { background: rgba(245,158,11,0.15); color: var(--yellow); }
|
||||||
|
.tag.ai { background: rgba(124,109,250,0.18); color: var(--accent2); }
|
||||||
|
.tag.set { background: rgba(16,185,129,0.15); color: var(--green); }
|
||||||
|
.tag.no-set { background: rgba(100,116,139,0.15); color: var(--muted); }
|
||||||
|
|
||||||
|
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
|
||||||
|
.m { background: rgba(255,255,255,0.03); border-radius: 6px; padding: 6px 8px; }
|
||||||
|
.m-label { font-size: 9px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.m-val { font-size: 13px; font-weight: 700; font-family: 'JetBrains Mono', monospace; margin-top: 2px; }
|
||||||
|
.green { color: var(--green); } .red { color: var(--red); } .gold { color: var(--yellow); } .teal { color: var(--teal); }
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex; gap: 6px; margin-top: auto; padding-top: 8px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
flex: 1; text-align: center; padding: 6px 8px; border-radius: 6px;
|
||||||
|
font-size: 11px; font-weight: 600; cursor: pointer; text-decoration: none;
|
||||||
|
border: 1px solid transparent; font-family: inherit; transition: all 0.15s;
|
||||||
|
background: rgba(255,255,255,0.04); color: var(--text); border-color: var(--border);
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 4px;
|
||||||
|
}
|
||||||
|
.btn:hover { background: rgba(255,255,255,0.08); }
|
||||||
|
.btn.primary { background: rgba(79,70,229,0.15); color: var(--accent2); border-color: rgba(79,70,229,0.32); }
|
||||||
|
.btn.primary:hover { background: rgba(79,70,229,0.28); }
|
||||||
|
.btn.success { background: rgba(16,185,129,0.12); color: var(--green); border-color: rgba(16,185,129,0.3); }
|
||||||
|
.btn.success:hover { background: rgba(16,185,129,0.22); }
|
||||||
|
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.run-ts { font-size: 10px; color: var(--muted); font-family: 'JetBrains Mono', monospace; }
|
||||||
|
|
||||||
|
/* ── Modal ──────────────────────────────────────────────────── */
|
||||||
|
.overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.78);
|
||||||
|
display: none; align-items: center; justify-content: center;
|
||||||
|
z-index: 200; backdrop-filter: blur(5px); padding: 24px;
|
||||||
|
}
|
||||||
|
.overlay.open { display: flex; }
|
||||||
|
.modal {
|
||||||
|
background: var(--card); border: 1px solid var(--border); border-radius: 16px;
|
||||||
|
width: 720px; max-width: 95vw; max-height: 88vh; display: flex; flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: pop 0.25s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||||
|
}
|
||||||
|
@keyframes pop { from { opacity: 0; transform: scale(0.94); } to { opacity: 1; transform: scale(1); } }
|
||||||
|
.modal-header { display: flex; justify-content: space-between; align-items: flex-start; padding: 18px 22px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||||||
|
.modal-title { font-family: 'JetBrains Mono', monospace; font-size: 15px; font-weight: 700; word-break: break-all; }
|
||||||
|
.modal-meta { font-size: 11px; color: var(--muted); margin-top: 4px; }
|
||||||
|
.close-btn {
|
||||||
|
width: 28px; height: 28px; border-radius: 50%; background: rgba(255,255,255,0.06);
|
||||||
|
border: 1px solid var(--border); color: var(--muted); cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.close-btn:hover { background: rgba(255,255,255,0.12); color: var(--text); }
|
||||||
|
.modal-body { padding: 18px 22px; overflow-y: auto; flex: 1; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.modal-section-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); padding-bottom: 6px; border-bottom: 1px solid var(--border); }
|
||||||
|
.params-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
.params-table th { text-align: left; padding: 6px 8px; color: var(--muted); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; border-bottom: 1px solid var(--border); }
|
||||||
|
.params-table td { padding: 5px 8px; border-bottom: 1px solid rgba(255,255,255,0.03); font-family: 'JetBrains Mono', monospace; font-size: 11px; }
|
||||||
|
.params-table .pn { color: var(--text); }
|
||||||
|
.params-table .pv { color: var(--teal); font-weight: 600; text-align: right; }
|
||||||
|
.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; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<h1>📁 Optimization Reports</h1>
|
<h1>📁 Optimization Reports</h1>
|
||||||
<div class="sub">{{ runs|length }} run(s) recorded · Click any card to open the full report</div>
|
<div class="sub">{{ runs|length }} run(s) on disk · click "View Params" or "Download .set" on any card</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="back-link" href="/dashboard">← Back to Dashboard</a>
|
<a class="back-link" href="/dashboard">← Back to Dashboard</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Toolbar: search + phase filters -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<input type="text" class="search" id="search" placeholder="Filter by run id, phase, or score…">
|
||||||
|
<div class="filter-chip active" data-phase="all">All</div>
|
||||||
|
<div class="filter-chip" data-phase="phase1">Exploration</div>
|
||||||
|
<div class="filter-chip" data-phase="phase2">AI Iteration</div>
|
||||||
|
<div class="filter-chip" data-phase="phase3">Validation</div>
|
||||||
|
<div class="filter-chip" data-phase="ai">AI insight</div>
|
||||||
|
<div class="filter-chip" data-phase="set">Has .set</div>
|
||||||
|
<div class="toolbar-stats" id="visible-count">{{ runs|length }} visible</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if not runs %}
|
{% if not runs %}
|
||||||
<div class="empty">No reports yet.<br>Start the optimizer to generate your first run report.</div>
|
<div class="empty">No reports yet.<br>Start the optimizer to generate your first run.</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="cards">
|
<div class="cards" id="cards">
|
||||||
{% for r in runs %}
|
{% for r in runs %}
|
||||||
<a class="run-card" href="/reports/{{ r.run_id }}/summary.html" target="_blank">
|
<div class="run-card"
|
||||||
|
data-run-id="{{ r.run_id }}"
|
||||||
|
data-phase="{{ r.phase }}"
|
||||||
|
data-has-ai="{{ r.has_ai|lower }}"
|
||||||
|
data-has-set="{{ r.has_set|lower }}"
|
||||||
|
data-search="{{ (r.run_id ~ ' ' ~ r.phase)|lower }}">
|
||||||
<div class="run-header">
|
<div class="run-header">
|
||||||
<div>
|
<div style="min-width:0;flex:1;">
|
||||||
<div class="run-id">{{ r.run_id }}</div>
|
<div class="run-id">{{ r.run_id }}</div>
|
||||||
<div class="run-score">{{ "%.4f"|format(r.score) }}</div>
|
<div class="run-score">{{ "%.4f"|format(r.score) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="run-phase">{{ r.phase }}</span>
|
<div class="run-tags">
|
||||||
|
<span class="tag phase-{{ r.phase }}">{{ r.phase|replace('phase1','Explore')|replace('phase2_ai','AI iter')|replace('phase2','Iter')|replace('phase3_oos','OOS')|replace('phase3_sens','Sens')|replace('phase3','Validate') }}</span>
|
||||||
|
{% if r.has_ai %}<span class="tag ai" title="AI reasoning available">AI</span>{% endif %}
|
||||||
|
{% if r.has_set %}<span class="tag set" title=".set file ready">.set</span>{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metrics-row">
|
<div class="metrics">
|
||||||
<div class="m">
|
<div class="m">
|
||||||
<div class="m-label">Net Profit</div>
|
<div class="m-label">Net Profit</div>
|
||||||
<div class="m-val {% if r.net_profit >= 0 %}delta-up{% else %}delta-dn{% endif %}">
|
<div class="m-val {% if r.net_profit >= 0 %}green{% else %}red{% endif %}">${{ "%.0f"|format(r.net_profit) }}</div>
|
||||||
${{ "%.0f"|format(r.net_profit) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="m">
|
|
||||||
<div class="m-label">Calmar</div>
|
|
||||||
<div class="m-val">{{ "%.2f"|format(r.calmar) }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="m">
|
|
||||||
<div class="m-label">Drawdown</div>
|
|
||||||
<div class="m-val delta-dn">{{ "%.1f"|format(r.drawdown_pct) }}%</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="m">
|
<div class="m">
|
||||||
<div class="m-label">PF</div>
|
<div class="m-label">PF</div>
|
||||||
<div class="m-val">{{ "%.2f"|format(r.profit_factor) }}</div>
|
<div class="m-val gold">{{ "%.2f"|format(r.profit_factor) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="m">
|
||||||
|
<div class="m-label">Calmar</div>
|
||||||
|
<div class="m-val teal">{{ "%.2f"|format(r.calmar) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="m">
|
||||||
|
<div class="m-label">Max DD</div>
|
||||||
|
<div class="m-val red">{{ "%.1f"|format(r.drawdown_pct) }}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="m">
|
||||||
|
<div class="m-label">Win %</div>
|
||||||
|
<div class="m-val">{{ "%.1f"|format(r.win_rate) }}%</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="m">
|
<div class="m">
|
||||||
<div class="m-label">Trades</div>
|
<div class="m-label">Trades</div>
|
||||||
<div class="m-val">{{ r.total_trades }}</div>
|
<div class="m-val">{{ r.total_trades }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="m">
|
|
||||||
<div class="m-label">Score Δ</div>
|
|
||||||
<div class="m-val {% if r.score_delta >= 0 %}delta-up{% else %}delta-dn{% endif %}">
|
|
||||||
{{ "%+.4f"|format(r.score_delta) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="run-ts">{{ r.ts[:19].replace("T"," ") }} UTC</div>
|
<div class="run-ts">{{ r.ts[:19].replace("T"," ") }} UTC</div>
|
||||||
</a>
|
<div class="actions">
|
||||||
|
<button class="btn primary" onclick="openDetail('{{ r.run_id }}')">View Params</button>
|
||||||
|
{% if r.has_set %}
|
||||||
|
<a class="btn success" href="/download_set/{{ r.run_id }}" download>⬇ .set</a>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn" disabled title="No .set file generated for this run">No .set</button>
|
||||||
|
{% endif %}
|
||||||
|
<a class="btn" href="/reports/{{ r.run_id }}/summary.html" target="_blank">Full Report ↗</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- ── Detail Modal (params + AI reasoning + .set) ─────────────── -->
|
||||||
|
<div class="overlay" id="overlay" onclick="if(event.target===this)closeDetail()">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<div class="modal-title" id="md-run-id">—</div>
|
||||||
|
<div class="modal-meta" id="md-meta"></div>
|
||||||
|
</div>
|
||||||
|
<button class="close-btn" onclick="closeDetail()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div>
|
||||||
|
<div class="modal-section-title">Metrics</div>
|
||||||
|
<div class="metrics" id="md-metrics"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="modal-section-title">Parameters</div>
|
||||||
|
<div style="max-height:280px;overflow-y:auto;border:1px solid var(--border);border-radius:6px;">
|
||||||
|
<table class="params-table"><thead><tr><th>Parameter</th><th style="text-align:right">Value</th></tr></thead><tbody id="md-params"></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="md-ai-wrap" style="display:none;">
|
||||||
|
<div class="modal-section-title">AI Reasoning</div>
|
||||||
|
<div class="ai-box" id="md-ai"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" id="md-footer"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function escHtml(s) {
|
||||||
|
const map = { '&':'&', '<':'<', '>':'>', '"':'"', "'":''' };
|
||||||
|
return String(s == null ? '' : s).replace(/[&<>"']/g, c => map[c]);
|
||||||
|
}
|
||||||
|
function fmtMoney(v) {
|
||||||
|
const n = parseFloat(v) || 0;
|
||||||
|
const sign = n < 0 ? '-' : '';
|
||||||
|
const abs = Math.abs(n);
|
||||||
|
return sign + '$' + abs.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filtering ────────────────────────────────────────────────
|
||||||
|
const cards = () => Array.from(document.querySelectorAll('.run-card'));
|
||||||
|
let activeFilter = 'all';
|
||||||
|
let activeSearch = '';
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
let visible = 0;
|
||||||
|
cards().forEach(c => {
|
||||||
|
const phase = c.dataset.phase || '';
|
||||||
|
const hasAi = c.dataset.hasAi === 'true';
|
||||||
|
const hasSet = c.dataset.hasSet === 'true';
|
||||||
|
const text = c.dataset.search || '';
|
||||||
|
let phaseOk = true;
|
||||||
|
if (activeFilter === 'phase1') phaseOk = phase === 'phase1';
|
||||||
|
else if (activeFilter === 'phase2') phaseOk = phase.startsWith('phase2');
|
||||||
|
else if (activeFilter === 'phase3') phaseOk = phase.startsWith('phase3');
|
||||||
|
else if (activeFilter === 'ai') phaseOk = hasAi;
|
||||||
|
else if (activeFilter === 'set') phaseOk = hasSet;
|
||||||
|
const searchOk = !activeSearch || text.includes(activeSearch);
|
||||||
|
const show = phaseOk && searchOk;
|
||||||
|
c.classList.toggle('hidden', !show);
|
||||||
|
if (show) visible++;
|
||||||
|
});
|
||||||
|
const el = document.getElementById('visible-count');
|
||||||
|
if (el) el.textContent = visible + ' visible';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.filter-chip').forEach(chip => {
|
||||||
|
chip.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.filter-chip').forEach(c => c.classList.remove('active'));
|
||||||
|
chip.classList.add('active');
|
||||||
|
activeFilter = chip.dataset.phase;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('search').addEventListener('input', e => {
|
||||||
|
activeSearch = (e.target.value || '').toLowerCase().trim();
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Detail modal ────────────────────────────────────────────
|
||||||
|
async function openDetail(runId) {
|
||||||
|
document.getElementById('overlay').classList.add('open');
|
||||||
|
document.getElementById('md-run-id').textContent = runId;
|
||||||
|
document.getElementById('md-meta').textContent = 'Loading…';
|
||||||
|
document.getElementById('md-metrics').innerHTML = '';
|
||||||
|
document.getElementById('md-params').innerHTML = '<tr><td colspan="2" style="color:var(--muted);text-align:center;padding:18px;">Loading…</td></tr>';
|
||||||
|
document.getElementById('md-ai-wrap').style.display = 'none';
|
||||||
|
document.getElementById('md-footer').innerHTML = '<button class="btn" onclick="closeDetail()">Close</button>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/run/' + encodeURIComponent(runId));
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
const d = await r.json();
|
||||||
|
|
||||||
|
document.getElementById('md-run-id').textContent = d.run_id || runId;
|
||||||
|
const ts = d.ts ? new Date(d.ts).toLocaleString() : '—';
|
||||||
|
const phase = (d.phase || '—').replace(/_/g, ' ');
|
||||||
|
document.getElementById('md-meta').textContent = phase + ' · ' + ts;
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
const profit = parseFloat(d.net_profit || 0);
|
||||||
|
const pf = parseFloat(d.profit_factor || 0);
|
||||||
|
const dd = parseFloat(d.drawdown_pct || d.max_drawdown || 0);
|
||||||
|
const cal = parseFloat(d.calmar || 0);
|
||||||
|
const wr = parseFloat(d.win_rate || 0);
|
||||||
|
const tr = parseInt(d.total_trades || 0, 10);
|
||||||
|
const sc = parseFloat(d.score || 0);
|
||||||
|
const cells = [
|
||||||
|
['Net Profit', fmtMoney(profit), profit >= 0 ? 'green' : 'red'],
|
||||||
|
['Profit Factor', pf.toFixed(2), 'gold'],
|
||||||
|
['Calmar', cal.toFixed(2), 'teal'],
|
||||||
|
['Max DD', dd.toFixed(1) + '%', 'red'],
|
||||||
|
['Win Rate', wr.toFixed(1) + '%', ''],
|
||||||
|
['Trades', tr.toLocaleString(), ''],
|
||||||
|
];
|
||||||
|
document.getElementById('md-metrics').innerHTML = cells.map(([l,v,cl]) => `
|
||||||
|
<div class="m">
|
||||||
|
<div class="m-label">${escHtml(l)}</div>
|
||||||
|
<div class="m-val ${cl}">${escHtml(v)}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Params
|
||||||
|
const params = d.params || {};
|
||||||
|
const entries = Object.entries(params);
|
||||||
|
const tbody = document.getElementById('md-params');
|
||||||
|
if (!entries.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="2" style="color:var(--muted);text-align:center;padding:18px;">No parameters recorded</td></tr>';
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = entries.map(([k, v]) => {
|
||||||
|
const display = typeof v === 'number'
|
||||||
|
? (v % 1 !== 0 ? v.toFixed(4) : v)
|
||||||
|
: v;
|
||||||
|
return `<tr><td class="pn">${escHtml(k)}</td><td class="pv">${escHtml(display)}</td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI Reasoning
|
||||||
|
const ai = d.ai_insight;
|
||||||
|
if (ai) {
|
||||||
|
const wrap = document.getElementById('md-ai-wrap');
|
||||||
|
wrap.style.display = '';
|
||||||
|
const text = ai.headline || ai.diagnosis || ai.analysis || '';
|
||||||
|
let html = '';
|
||||||
|
if (text) html += '<div class="lbl">Diagnosis</div><div>' + escHtml(text) + '</div>';
|
||||||
|
const sugg = ai.suggestions || ai.changes || [];
|
||||||
|
if (sugg.length) {
|
||||||
|
html += '<div class="lbl" style="margin-top:10px;">Suggestions</div>';
|
||||||
|
html += '<ul style="padding-left:18px;margin:0;">';
|
||||||
|
sugg.slice(0, 5).forEach(s => {
|
||||||
|
if (typeof s === 'string') {
|
||||||
|
html += '<li>' + escHtml(s) + '</li>';
|
||||||
|
} else if (s && typeof s === 'object') {
|
||||||
|
const p = s.param || s.parameter || '';
|
||||||
|
const r = s.reason || '';
|
||||||
|
const v = s.value !== undefined ? s.value : (s.to !== undefined ? s.to : '');
|
||||||
|
html += '<li>' + (p ? '<code style="color:var(--teal)">' + escHtml(p) + '</code>' : '')
|
||||||
|
+ (v !== '' ? ' → <strong>' + escHtml(String(v)) + '</strong>' : '')
|
||||||
|
+ (r ? ' — ' + escHtml(r) : '') + '</li>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
html += '</ul>';
|
||||||
|
}
|
||||||
|
document.getElementById('md-ai').innerHTML = html || '<em style="color:var(--muted);">No reasoning recorded.</em>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer actions
|
||||||
|
let btns = '';
|
||||||
|
if (d.set_url || d.has_set) {
|
||||||
|
const url = d.set_url || ('/download_set/' + encodeURIComponent(d.run_id || runId));
|
||||||
|
btns += '<a class="btn success" href="' + url + '" download>⬇ Download .set</a>';
|
||||||
|
}
|
||||||
|
btns += '<a class="btn" href="/reports/' + encodeURIComponent(d.run_id || runId) + '/summary.html" target="_blank">Full Report ↗</a>';
|
||||||
|
btns += '<button class="btn" onclick="closeDetail()">Close</button>';
|
||||||
|
document.getElementById('md-footer').innerHTML = btns;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('md-meta').textContent = 'Error: ' + err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeDetail() {
|
||||||
|
document.getElementById('overlay').classList.remove('open');
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') closeDetail();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user