Files
Apex_AI_MT5_EA_Optimizer/ui/templates/reports_index.html
T
LEGSTECH Optimizer a584f46891 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
2026-04-25 12:53:27 +00:00

586 lines
28 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Reports — APEX MT5 Optimizer</title>
<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>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #080d1a; --card: #0f1629; --border: rgba(255,255,255,0.08);
--text: #e2e8f0; --muted: #64748b; --accent: #4f46e5; --accent2: #7c6dfa;
--green: #10b981; --red: #ef4444; --yellow: #f59e0b; --teal: #00d4aa;
}
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; font-size: 13px; padding: 28px 32px 80px; min-height: 100vh; }
a { color: var(--accent2); }
header { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 18px; gap: 24px; flex-wrap: wrap; }
h1 { font-size: 22px; font-weight: 800; letter-spacing: -0.02em; }
.sub { color: var(--muted); font-size: 12px; margin-top: 4px; }
.back-link { color: var(--accent2); text-decoration: none; font-size: 12px; font-weight: 500; }
.back-link:hover { text-decoration: underline; }
/* Toolbar: search + phase filter */
.toolbar {
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; }
/* 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>
<header>
<div>
<h1>📁 Optimization Reports</h1>
<div class="sub">{{ runs|length }} run(s) on disk · click "View Params" or "Download .set" on any card</div>
</div>
<a class="back-link" href="/dashboard">&larr; Back to Dashboard</a>
</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 %}
<div class="empty">No reports yet.<br>Start the optimizer to generate your first run.</div>
{% else %}
<div class="cards" id="cards">
{% for r in runs %}
<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="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>
<div class="run-score">{{ "%.4f"|format(r.score) }}</div>
</div>
<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 class="metrics">
<div class="m">
<div class="m-label">Net Profit</div>
<div class="m-val {% if r.net_profit >= 0 %}green{% else %}red{% endif %}">${{ "%.0f"|format(r.net_profit) }}</div>
</div>
<div class="m">
<div class="m-label">PF</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 class="m">
<div class="m-label">Trades</div>
<div class="m-val">{{ r.total_trades }}</div>
</div>
</div>
<div class="run-ts">{{ r.ts[:19].replace("T"," ") }} UTC</div>
<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 %}
</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">
<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 = { '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#039;' };
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(); 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>