Files
Apex_AI_MT5_EA_Optimizer/ui/static/js/dashboard.js
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

1945 lines
84 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* dashboard.js — APEX AI-Powered EA Optimizer
* Handles all SocketIO events, chart rendering, and UI state.
*/
'use strict';
/* ══════════════════════════════════════════════════════════════════════
UTILITIES
══════════════════════════════════════════════════════════════════════ */
function el(id) { return document.getElementById(id); }
function fmtMoney(v) {
const n = parseFloat(v) || 0;
const abs = Math.abs(n);
let s;
if (abs >= 1000000) s = (n / 1000000).toFixed(2) + 'M';
else if (abs >= 1000) s = (n / 1000).toFixed(1) + 'k';
else s = n.toFixed(2);
return (n >= 0 ? '$' : '-$') + (n < 0 ? s.replace('-', '') : s);
}
function fmtPct(v, decimals) {
return (parseFloat(v) || 0).toFixed(decimals != null ? decimals : 2) + '%';
}
function fmtTime(secs) {
const s = Math.floor(secs);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return h + 'h ' + String(m).padStart(2, '0') + 'm';
return String(m).padStart(2, '0') + ':' + String(sec).padStart(2, '0');
}
function nowStr() {
return new Date().toLocaleTimeString('en-US', { hour12: false });
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function safe(id, fn) {
const e = el(id);
if (e) fn(e);
}
/* ══════════════════════════════════════════════════════════════════════
GLOBAL STATE
══════════════════════════════════════════════════════════════════════ */
const state = {
runs: 0,
isRunning: false,
startTime: null,
elapsedTimer: null,
logCount: 0,
bestProfit: null,
sparkData: { profit: [], dd: [], pf: [], trades: [], runs: [] },
};
/* ══════════════════════════════════════════════════════════════════════
CHART.JS DEFAULTS
══════════════════════════════════════════════════════════════════════ */
Chart.defaults.color = '#64748b';
Chart.defaults.font.family = "'Inter', -apple-system, sans-serif";
Chart.defaults.font.size = 11;
/* ── Equity Curve ─────────────────────────────────────────────────── */
const equityChart = new Chart(el('equityChart').getContext('2d'), {
type: 'line',
data: {
labels: [],
datasets: [
{
label: 'Equity',
data: [],
borderColor: '#4f46e5',
backgroundColor: (ctx) => {
const { ctx: c, chartArea } = ctx.chart;
if (!chartArea) return 'transparent';
const g = c.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
g.addColorStop(0, 'rgba(79,70,229,0.22)');
g.addColorStop(1, 'rgba(79,70,229,0)');
return g;
},
borderWidth: 2, pointRadius: 0, pointHoverRadius: 4,
pointHoverBackgroundColor: '#4f46e5', tension: 0.4, fill: true,
},
{
label: 'Balance',
data: [],
borderColor: '#00d4aa',
backgroundColor: 'transparent',
borderWidth: 1.5,
borderDash: [5, 4],
pointRadius: 0, pointHoverRadius: 4,
pointHoverBackgroundColor: '#00d4aa', tension: 0.4, fill: false,
},
],
},
options: {
responsive: true, maintainAspectRatio: false,
animation: { duration: 400 },
interaction: { intersect: false, mode: 'index' },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#0f1629', borderColor: 'rgba(255,255,255,0.1)', borderWidth: 1,
padding: 10, titleColor: '#94a3b8', bodyColor: '#e2e8f0',
callbacks: { label: (ctx) => ' ' + ctx.dataset.label + ': $' + ctx.parsed.y.toFixed(2) },
},
},
scales: {
x: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { maxTicksLimit: 8, maxRotation: 0, color: '#475569', font: { size: 10 } }, border: { display: false } },
y: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { maxTicksLimit: 6, color: '#475569', font: { size: 10 }, callback: (v) => '$' + v.toFixed(0) }, border: { display: false } },
},
},
});
/* ── Drawdown Chart ───────────────────────────────────────────────── */
const drawdownChart = new Chart(el('drawdownChart').getContext('2d'), {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Drawdown',
data: [],
borderColor: '#ef4444',
backgroundColor: (ctx) => {
const { ctx: c, chartArea } = ctx.chart;
if (!chartArea) return 'transparent';
const g = c.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
g.addColorStop(0, 'rgba(239,68,68,0.35)');
g.addColorStop(1, 'rgba(239,68,68,0.02)');
return g;
},
borderWidth: 1.5, pointRadius: 0, pointHoverRadius: 3, tension: 0.4, fill: true,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
animation: { duration: 400 },
interaction: { intersect: false, mode: 'index' },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#0f1629', borderColor: 'rgba(255,255,255,0.1)', borderWidth: 1,
padding: 8, titleColor: '#94a3b8', bodyColor: '#ef4444',
callbacks: { label: (ctx) => ' Drawdown: ' + ctx.parsed.y.toFixed(2) + '%' },
},
},
scales: {
x: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { maxTicksLimit: 8, maxRotation: 0, color: '#475569', font: { size: 10 } }, border: { display: false } },
y: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { maxTicksLimit: 4, color: '#475569', font: { size: 10 }, callback: (v) => v.toFixed(1) + '%' }, border: { display: false } },
},
},
});
/* ── Sparklines ───────────────────────────────────────────────────── */
const sparklineCharts = {};
function createSparkline(canvasId, color, fillColor) {
const canvas = el(canvasId);
if (!canvas) return null;
return new Chart(canvas.getContext('2d'), {
type: 'line',
data: { labels: [], datasets: [{ data: [], borderColor: color, backgroundColor: fillColor || 'transparent', borderWidth: 1.5, pointRadius: 0, tension: 0.4, fill: !!fillColor }] },
options: { responsive: false, animation: { duration: 250 }, plugins: { legend: { display: false }, tooltip: { enabled: false } }, scales: { x: { display: false }, y: { display: false } } },
});
}
function initSparklines() {
sparklineCharts.profit = createSparkline('spark-profit', '#10b981', 'rgba(16,185,129,0.12)');
sparklineCharts.dd = createSparkline('spark-dd', '#ef4444', 'rgba(239,68,68,0.12)');
sparklineCharts.pf = createSparkline('spark-pf', '#f59e0b', 'rgba(245,158,11,0.10)');
sparklineCharts.trades = createSparkline('spark-trades', '#00d4aa', 'rgba(0,212,170,0.10)');
sparklineCharts.runs = createSparkline('spark-runs', '#7c6dfa', 'rgba(124,109,250,0.10)');
}
function pushSparkData(key, value) {
const arr = state.sparkData[key];
arr.push(value);
if (arr.length > 20) arr.shift();
const c = sparklineCharts[key];
if (!c) return;
c.data.labels = arr.map((_, i) => i);
c.data.datasets[0].data = arr;
c.update('none');
}
/* ══════════════════════════════════════════════════════════════════════
METRIC CARDS
══════════════════════════════════════════════════════════════════════ */
function updateMetricCards(d) {
const profit = parseFloat(d.net_profit || d.best_net_profit || 0);
const maxDD = parseFloat(d.max_drawdown || d.best_max_drawdown || d.max_dd || 0);
const pf = parseFloat(d.profit_factor || d.best_pf || d.pf || 0);
const trades = parseInt(d.total_trades || d.best_total_trades || d.trades || 0, 10);
// Net Profit
safe('m-net-profit', e => { e.textContent = fmtMoney(profit); });
if (state.bestProfit === null) state.bestProfit = profit;
const delta = state.bestProfit !== 0 ? ((profit - state.bestProfit) / Math.abs(state.bestProfit) * 100) : 0;
safe('m-net-profit-delta-val', e => { e.textContent = (delta >= 0 ? '+' : '') + delta.toFixed(1) + '%'; });
const arrow = el('m-net-profit-delta') && el('m-net-profit-delta').querySelector('span:first-child');
if (arrow) { arrow.textContent = delta >= 0 ? '↑' : '↓'; arrow.className = delta >= 0 ? 'delta-up' : 'delta-down'; }
pushSparkData('profit', profit);
// Max Drawdown
safe('m-max-dd', e => { e.textContent = fmtPct(maxDD); });
safe('max-dd-badge', e => { e.textContent = 'Max DD: ' + fmtPct(maxDD, 1); });
pushSparkData('dd', maxDD);
// Profit Factor
safe('m-pf', e => { e.textContent = pf.toFixed(2); });
safe('m-pf-quality', e => {
if (pf >= 2) { e.textContent = 'Excellent'; e.style.color = 'var(--green)'; }
else if (pf >= 1.5){ e.textContent = 'Good'; e.style.color = 'var(--teal)'; }
else if (pf >= 1) { e.textContent = 'Marginal'; e.style.color = 'var(--yellow)'; }
else { e.textContent = 'Poor'; e.style.color = 'var(--red)'; }
});
pushSparkData('pf', pf);
// Trades
safe('m-trades', e => { e.textContent = trades.toLocaleString(); });
pushSparkData('trades', trades);
}
function updateRunsCounter() {
state.runs++;
safe('m-runs', e => { e.textContent = state.runs.toString(); });
pushSparkData('runs', state.runs);
}
/* ══════════════════════════════════════════════════════════════════════
AI GAUGE
══════════════════════════════════════════════════════════════════════ */
const CIRCUMFERENCE = 163.4; // 2π × 26
function updateAIGauge(pct, label) {
const color = { high: '#10b981', medium: '#f59e0b', low: '#ef4444' }[label] || '#00d4aa';
safe('ai-gauge-arc', e => {
e.style.strokeDashoffset = (CIRCUMFERENCE * (1 - pct / 100)).toString();
e.style.stroke = color;
});
safe('ai-gauge-pct', e => { e.textContent = pct + '%'; });
safe('ai-conf-label', e => {
e.textContent = label ? label.charAt(0).toUpperCase() + label.slice(1) : '—';
e.style.color = color;
});
}
/* ══════════════════════════════════════════════════════════════════════
HEADER SCORE
══════════════════════════════════════════════════════════════════════ */
function updateHeaderScore(score, delta) {
const s = parseFloat(score);
if (isNaN(s)) return;
safe('hdr-score', e => { e.textContent = s.toFixed(3); });
if (delta != null) {
const d2 = parseFloat(delta);
if (!isNaN(d2)) {
const sign = d2 >= 0 ? '+' : '';
safe('hdr-score-delta', e => {
e.textContent = sign + d2.toFixed(3) + ' improvement';
e.style.color = d2 >= 0 ? 'var(--green)' : 'var(--red)';
});
}
} else {
safe('hdr-score-delta', e => { e.textContent = 'Latest result'; e.style.color = ''; });
}
}
/* ══════════════════════════════════════════════════════════════════════
AI PANEL — shared update function
══════════════════════════════════════════════════════════════════════ */
function updateAIPanel(d) {
if (!d) return;
safe('ai-waiting', e => { e.style.display = 'none'; });
safe('ai-content', e => { e.style.display = ''; });
// Preview (always visible) — headline + diagnosis
if (d.headline) {
safe('ai-headline', e => { e.textContent = d.headline; e.style.display = ''; });
}
if (d.diagnosis) {
safe('ai-diagnosis-text', e => { e.textContent = d.diagnosis; });
}
// Details section — populate but keep collapsed until user clicks "View Details"
const patterns = d.patterns || d.strengths || [];
if (patterns.length) {
safe('ai-strengths-section', e => { e.style.display = ''; });
safe('ai-strengths-list', e => {
e.innerHTML = patterns.slice(0, 4).map(p => '<li>' + escapeHtml(p) + '</li>').join('');
});
}
const suggestions = d.suggestions || [];
if (suggestions.length) {
const s = suggestions[0];
let txt = '';
if (typeof s === 'string') {
txt = s;
} else {
txt = s.reason || '';
if (s.param || s.parameter) {
const param = s.param || s.parameter;
txt += ' (' + param + ': ' + (s.from !== undefined ? s.from : '') + ' → ' + (s.to !== undefined ? s.to : '') + ')';
}
}
safe('ai-suggestion-text', e => { e.textContent = txt; });
safe('ai-suggestion-box', e => { e.style.display = ''; });
}
const risks = d.risk_flags || d.risks || [];
if (risks.length) {
safe('ai-risks-section', e => { e.style.display = ''; });
safe('ai-risks-list', e => {
e.innerHTML = risks.slice(0, 3).map(r => '<li>' + escapeHtml(r) + '</li>').join('');
});
}
// Confidence (always shown at bottom)
const conf = (typeof d.confidence === 'string' ? d.confidence : 'medium').toLowerCase();
safe('ai-panel-conf-badge', e => {
e.textContent = conf;
e.className = 'ai-conf-badge ' + conf;
});
const pctMap = { high: 87, medium: 62, low: 38 };
const pct = typeof d.confidence_pct === 'number' ? d.confidence_pct : (pctMap[conf] || 62);
updateAIGauge(pct, conf);
if (d.run_id) safe('ai-run-ref', e => { e.textContent = 'Run: ' + d.run_id; });
safe('ai-badge', e => { e.style.display = ''; });
}
/* ══════════════════════════════════════════════════════════════════════
RECENT RUNS TABLE
══════════════════════════════════════════════════════════════════════ */
const MAX_RECENT_RUNS = 8;
const recentRuns = [];
function addRecentRun(d) {
const profit = parseFloat(d.net_profit || 0);
const pf = parseFloat(d.profit_factor || d.pf || 0);
const maxDD = parseFloat(d.max_drawdown || d.max_dd || 0);
const run_id = d.run_id || null;
// Derive verdict: use explicit field if present, otherwise infer from passing
let verdict = (d.verdict || '').toUpperCase().replace(/ /g, '_');
if (!verdict) {
verdict = d.passing === false ? 'NOT_RELIABLE' : (d.passing === true ? 'PASSING' : 'PENDING');
}
const verdictClass = {
RECOMMENDED: 'verdict-recommended',
PASSING: 'verdict-recommended',
RISKY: 'verdict-risky',
PENDING: 'verdict-risky',
NOT_RELIABLE: 'verdict-not-reliable',
}[verdict] || 'verdict-risky';
const verdictLabel = {
RECOMMENDED: 'Recommended',
PASSING: 'Passing',
RISKY: 'Risky',
PENDING: 'Pending',
NOT_RELIABLE: 'Not Reliable',
}[verdict] || verdict.replace(/_/g, ' ');
recentRuns.unshift({ profit, pf, maxDD, verdictClass, verdictLabel, run_id });
if (recentRuns.length > MAX_RECENT_RUNS) recentRuns.pop();
const tbody = el('recent-runs-tbody');
if (!tbody) return;
tbody.innerHTML = recentRuns.map((r, i) => `
<tr>
<td class="run-num">#${state.runs - i}</td>
<td class="${r.profit >= 0 ? 'profit-pos' : 'profit-neg'}">${fmtMoney(r.profit)}</td>
<td style="color:var(--yellow);font-weight:600;">${r.pf.toFixed(2)}</td>
<td style="color:var(--red);">${r.maxDD.toFixed(1)}%</td>
<td><span class="${r.verdictClass}">${r.verdictLabel}</span></td>
<td>${r.run_id ? `<button class="detail-btn" onclick="openRunDetail('${escapeHtml(r.run_id)}')">Details</button>` : ''}</td>
</tr>
`).join('');
}
/* ══════════════════════════════════════════════════════════════════════
PARAMETERS TABLE
══════════════════════════════════════════════════════════════════════ */
function updateParamsTable(params) {
if (!params || typeof params !== 'object') return;
const tbody = el('params-tbody');
if (!tbody) return;
const entries = Object.entries(params);
if (!entries.length) return;
tbody.innerHTML = entries.slice(0, 8).map(([key, val], i) => {
const impactClass = i < 2 ? 'impact-high' : i < 5 ? 'impact-medium' : 'impact-low';
const impactLabel = i < 2 ? 'High' : i < 5 ? 'Medium' : 'Low';
const display = typeof val === 'number' ? val.toFixed(val % 1 !== 0 ? 4 : 0) : val;
return `
<tr>
<td class="param-name">${escapeHtml(key)}</td>
<td class="param-val">${escapeHtml(String(display))}</td>
<td><span class="${impactClass}">${impactLabel}</span></td>
</tr>
`;
}).join('');
}
/* ══════════════════════════════════════════════════════════════════════
CHART UPDATES
══════════════════════════════════════════════════════════════════════ */
function pushEquityPoint(equity, balance, label) {
const MAX = 80;
const lbl = label || nowStr();
equityChart.data.labels.push(lbl);
equityChart.data.datasets[0].data.push(parseFloat(equity) || 0);
equityChart.data.datasets[1].data.push(parseFloat(balance) || 0);
if (equityChart.data.labels.length > MAX) {
equityChart.data.labels.shift();
equityChart.data.datasets.forEach(ds => ds.data.shift());
}
equityChart.update('none');
}
function pushDDPoint(dd, label) {
const MAX = 80;
const lbl = label || nowStr();
drawdownChart.data.labels.push(lbl);
drawdownChart.data.datasets[0].data.push(parseFloat(dd) || 0);
if (drawdownChart.data.labels.length > MAX) {
drawdownChart.data.labels.shift();
drawdownChart.data.datasets[0].data.shift();
}
drawdownChart.update('none');
}
function populateChartsFromEquityCurve(curve) {
if (!Array.isArray(curve) || !curve.length) return;
equityChart.data.labels = [];
equityChart.data.datasets[0].data = [];
equityChart.data.datasets[1].data = [];
drawdownChart.data.labels = [];
drawdownChart.data.datasets[0].data = [];
const step = Math.max(1, Math.floor(curve.length / 80));
curve.forEach((pt, i) => {
if (i % step !== 0 && i !== curve.length - 1) return;
const lbl = pt.date || pt.time || String(i + 1);
equityChart.data.labels.push(lbl);
equityChart.data.datasets[0].data.push(parseFloat(pt.equity || pt.Equity || 0));
equityChart.data.datasets[1].data.push(parseFloat(pt.balance || pt.Balance || 0));
drawdownChart.data.labels.push(lbl);
drawdownChart.data.datasets[0].data.push(parseFloat(pt.drawdown || pt.Drawdown || 0));
});
equityChart.update();
drawdownChart.update();
}
/* ══════════════════════════════════════════════════════════════════════
PROGRESS
══════════════════════════════════════════════════════════════════════ */
function updateProgress(d) {
// Use progress_pct if available, fall back to run_number/total
let pct = null;
if (d.progress_pct !== undefined) pct = d.progress_pct;
else if (d.run_number !== undefined && d.total) pct = Math.round(d.run_number / d.total * 100);
if (pct !== null) {
const p = Math.min(100, pct);
safe('opt-progress-fill', e => { e.style.width = p + '%'; });
safe('hdr-prog-fill', e => { e.style.width = p + '%'; });
safe('hdr-pct', e => { e.textContent = p + '% Complete'; });
}
if (d.run_number !== undefined && d.total !== undefined) {
safe('opt-gen', e => { e.textContent = 'Run ' + d.run_number + ' / ' + d.total; });
safe('hdr-gen-cur', e => { e.textContent = d.run_number; });
safe('hdr-gen-total', e => { e.textContent = d.total; });
safe('opt-remaining', e => {
if (!state.startTime || !d.run_number) return;
const elapsed = (Date.now() - state.startTime) / 1000;
const rem = (elapsed / d.run_number) * (d.total - d.run_number);
e.textContent = fmtTime(rem);
});
}
}
/* ══════════════════════════════════════════════════════════════════════
ELAPSED TIMER
══════════════════════════════════════════════════════════════════════ */
function startElapsedTimer() {
if (!state.startTime) state.startTime = Date.now();
stopElapsedTimer();
state.elapsedTimer = setInterval(() => {
const secs = Math.floor((Date.now() - state.startTime) / 1000);
safe('opt-elapsed', e => { e.textContent = fmtTime(secs); });
}, 1000);
}
function stopElapsedTimer() {
if (state.elapsedTimer) { clearInterval(state.elapsedTimer); state.elapsedTimer = null; }
}
/* ══════════════════════════════════════════════════════════════════════
RUNNING STATE TOGGLE
══════════════════════════════════════════════════════════════════════ */
function setRunningState(isRunning, label) {
state.isRunning = isRunning;
safe('run-indicator', e => { e.classList.toggle('active', isRunning); });
safe('btn-stop', e => { e.classList.toggle('active', isRunning); });
// (btn-pause removed — pipeline doesn't support pause yet)
safe('hdr-dot', e => { e.classList.toggle('running', isRunning); });
safe('hdr-status', e => { e.textContent = isRunning ? (label || 'Optimizing') : 'System Idle'; });
safe('hdr-status-sub', e => { e.textContent = isRunning ? 'Optimization in progress...' : 'Ready to optimize'; });
if (isRunning) {
if (label) safe('run-indicator-label', e => { e.textContent = label; });
startElapsedTimer();
} else {
stopElapsedTimer();
}
}
/* ══════════════════════════════════════════════════════════════════════
PHASE STEPS
══════════════════════════════════════════════════════════════════════ */
const phaseMap = {
phase1: 1, phase_1: 1, broad: 1, scan: 1,
phase2: 2, phase_2: 2, deep: 2, refine: 2,
phase3: 3, phase_3: 3, validate: 3, validation: 3, phase3_oos: 3, phase3_sens: 3,
};
function setPhaseActive(phaseNum, meta) {
for (let i = 1; i <= 3; i++) {
const step = el('phase-step-' + i);
if (!step) continue;
step.classList.remove('active', 'done');
if (i < phaseNum) step.classList.add('done');
if (i === phaseNum) step.classList.add('active');
}
const labels = { 1: 'Exploration', 2: 'Iteration', 3: 'Validation' };
safe('opt-phase', e => { e.textContent = labels[phaseNum] || 'Phase ' + phaseNum; });
if (meta && typeof meta.total === 'number') {
const mode = meta.mode === 'autonomous' ? ' · AI' : '';
safe('phase-sub-' + phaseNum, e => {
e.textContent = `0/${meta.total}${mode}`;
});
}
}
function updatePhaseSubProgress(phaseNum, current, total) {
safe('phase-sub-' + phaseNum, e => {
e.textContent = `${current}/${total}`;
});
}
/* ══════════════════════════════════════════════════════════════════════
LIVE LOG
══════════════════════════════════════════════════════════════════════ */
const MAX_LOG_LINES = 200;
function addLog(msg, level) {
const lvl = level || 'info';
state.logCount++;
safe('log-count', e => { e.textContent = state.logCount; });
const feed = el('log-feed');
if (!feed) return;
const line = document.createElement('div');
line.className = 'log-line ' + lvl;
line.innerHTML = '<span class="log-time">' + nowStr() + '</span><span class="log-msg">' + escapeHtml(String(msg)) + '</span>';
feed.appendChild(line);
while (feed.children.length > MAX_LOG_LINES) feed.removeChild(feed.firstChild);
feed.scrollTop = feed.scrollHeight;
}
function toggleLog() {
const body = el('log-body');
const icon = el('log-toggle-icon');
if (body) body.classList.toggle('open');
if (icon) icon.classList.toggle('open');
}
/* ══════════════════════════════════════════════════════════════════════
MT5 STATUS
══════════════════════════════════════════════════════════════════════ */
function updateMT5Status(statusStr, label) {
const dot = el('mt5-dot');
if (dot) dot.classList.toggle('disconnected', statusStr === 'disconnected');
safe('mt5-state', e => { e.textContent = label || statusStr; });
}
/* ══════════════════════════════════════════════════════════════════════
VERDICT OVERLAY
══════════════════════════════════════════════════════════════════════ */
function showVerdict(d) {
const icons = { RECOMMENDED: '✅', RISKY: '⚠️', NOT_RELIABLE: '❌' };
const colors = { RECOMMENDED: '#10b981', RISKY: '#f59e0b', NOT_RELIABLE: '#ef4444' };
const subs = {
RECOMMENDED: 'Consistent returns with controlled drawdown. Ready to deploy.',
RISKY: 'Profitable in-sample but shows fragility. Deploy with caution.',
NOT_RELIABLE: 'Results are inconsistent. Try different settings or date ranges.',
};
const verdict = (d.verdict || 'RISKY').toUpperCase().replace(/ /g, '_');
safe('verdict-icon', e => { e.textContent = icons[verdict] || '?'; });
safe('verdict-title', e => { e.textContent = verdict.replace(/_/g, ' '); e.style.color = colors[verdict] || '#e2e8f0'; });
safe('verdict-subtitle', e => { e.textContent = subs[verdict] || ''; });
const profit = parseFloat(d.net_profit || 0);
const calmar = parseFloat(d.calmar || 0);
const winRate = parseFloat(d.win_rate || 0);
const maxDD = parseFloat(d.max_drawdown || d.max_dd || 0);
safe('verdict-metrics', e => {
e.innerHTML = `
<div class="vm"><div class="vm-val" style="color:#10b981">${fmtMoney(profit)}</div><div class="vm-lbl">Net Profit</div></div>
<div class="vm"><div class="vm-val">${calmar.toFixed(2)}</div><div class="vm-lbl">Calmar</div></div>
<div class="vm"><div class="vm-val">${winRate.toFixed(1)}%</div><div class="vm-lbl">Win Rate</div></div>
<div class="vm"><div class="vm-val" style="color:#ef4444">${maxDD.toFixed(1)}%</div><div class="vm-lbl">Max DD</div></div>
`;
});
safe('verdict-actions', e => {
e.innerHTML = `
${d.set_file_url ? '<a href="' + d.set_file_url + '" class="verdict-btn primary" download>⬇ Download .set File</a>' : ''}
<a href="/reports" class="verdict-btn secondary">📋 View Report</a>
<a href="/setup" class="verdict-btn secondary">⚡ New Run</a>
<button class="verdict-btn ghost" onclick="document.getElementById('verdict-overlay').style.display='none'">✕ Close</button>
`;
});
safe('verdict-overlay', e => { e.style.display = 'flex'; });
stopElapsedTimer();
setRunningState(false);
}
/* ══════════════════════════════════════════════════════════════════════
STATUS POLLING FALLBACK
Polls /api/status every 5 seconds when SocketIO is connected but
pipeline state is unknown (e.g., user opens dashboard mid-run).
══════════════════════════════════════════════════════════════════════ */
let pollInterval = null;
function startPolling() {
if (pollInterval) return;
pollInterval = setInterval(async () => {
try {
const resp = await fetch('/api/status');
const d = await resp.json();
syncFromStatus(d);
} catch (_) {}
}, 5000);
}
function stopPolling() {
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
}
function syncFromStatus(d) {
const isRunning = d.state === 'running';
if (isRunning && !state.isRunning) {
setRunningState(true, d.phase || 'Optimizing...');
const ph = phaseMap[d.phase] || 1;
setPhaseActive(ph);
} else if (!isRunning && state.isRunning) {
setRunningState(false);
}
if (d.run_count) {
state.runs = d.run_count;
safe('m-runs', e => { e.textContent = state.runs.toString(); });
}
// Populate metric cards from flat status fields
if (d.best_net_profit != null || d.best_max_drawdown != null) {
updateMetricCards({
net_profit: d.best_net_profit,
max_drawdown: d.best_max_drawdown,
profit_factor: d.best_pf,
total_trades: d.best_total_trades,
});
}
// Restore header score
if (d.best_score != null) updateHeaderScore(d.best_score);
// Restore AI panel
if (d.latest_insight) {
updateAIPanel(d.latest_insight);
addLog('AI insight restored from server (run: ' + (d.latest_insight.run_id || '?') + ')', 'info');
}
if (d.elapsed_s) {
// Approximate startTime from elapsed
if (!state.startTime) state.startTime = Date.now() - d.elapsed_s * 1000;
}
}
/* ══════════════════════════════════════════════════════════════════════
SOCKET.IO
══════════════════════════════════════════════════════════════════════ */
const socket = io({ transports: ['websocket', 'polling'] });
if (typeof window !== 'undefined') window.socket = socket;
socket.on('connect', () => {
addLog('Connected to APEX server.', 'success');
updateMT5Status('connected', 'Connected');
// Also poll once to sync state immediately
fetch('/api/status').then(r => r.json()).then(syncFromStatus).catch(() => {});
});
socket.on('disconnect', (reason) => {
addLog('Disconnected: ' + reason, 'warn');
updateMT5Status('disconnected', 'Disconnected');
});
socket.on('connect_error', (err) => {
addLog('Connection error: ' + err.message, 'error');
updateMT5Status('disconnected', 'Error');
});
/* ── status_sync ── full state on reconnect ───────────────────────── */
socket.on('status_sync', (d) => {
addLog('Status sync received.', 'info');
syncFromStatus(d);
});
/* ── status_change ────────────────────────────────────────────────── */
socket.on('status_change', (d) => {
// Backend emits d.state NOT d.status
const running = d.state === 'running' || d.status === 'running';
const label = d.label || d.phase || (running ? 'Optimizing...' : 'Idle');
setRunningState(running, label);
addLog('Status: ' + (d.state || d.status || (running ? 'running' : 'idle')), 'info');
// Reset live-intelligence panels when a fresh run starts
if (running && (d.state === 'running' || d.status === 'running') && d.phase === 'setup') {
thinkingFeedState.entries = [];
paramChangesState.iterations = [];
validationState.runs = [];
validationState.planned = 0;
validationState.complete = 0;
validationState.active = false;
renderThinkingFeed();
renderParamChanges();
renderValidation();
safe('tf-badge', e => { e.textContent = 'Live'; e.className = 'badge live'; });
safe('pc-badge', e => { e.textContent = '—'; e.className = 'badge'; });
safe('val-badge', e => { e.textContent = 'Pending'; e.className = 'badge'; });
dismissEarlyTerm();
}
});
/* ── run_complete ─────────────────────────────────────────────────── */
socket.on('run_complete', (d) => {
updateRunsCounter();
// Only update metric cards if we have meaningful data
if (d.net_profit != null) updateMetricCards(d);
// Charts: use equity curve if available, otherwise push single point
if (d.equity_curve) {
populateChartsFromEquityCurve(d.equity_curve);
} else if (d.net_profit != null) {
pushEquityPoint(d.net_profit, d.balance || d.net_profit, 'Run ' + state.runs);
pushDDPoint(d.max_drawdown || 0, 'Run ' + state.runs);
}
if (d.params) updateParamsTable(d.params);
if (d.score != null) updateHeaderScore(d.score);
addRecentRun(d);
updateProgress(d);
// Update per-phase sub-progress label under phase dot
const phase = (d.phase || '').toLowerCase();
if (phase === 'phase1' && d.run_number && d.total) {
updatePhaseSubProgress(1, d.run_number, d.total);
} else if (phase.startsWith('phase2') && d.progress_pct != null) {
// Rough count: we don't always have iteration here; UI updates via ai_iteration_* handler
} else if (phase.startsWith('phase3')) {
// Updated via validation_run_complete handler
}
const msg = 'Run ' + state.runs + ' (' + (d.phase || '?') + ') '
+ (d.net_profit != null ? '— Net Profit: ' + fmtMoney(d.net_profit) : '')
+ (d.profit_factor != null ? ', PF: ' + parseFloat(d.profit_factor).toFixed(2) : '')
+ (d.max_drawdown != null ? ', Max DD: ' + fmtPct(d.max_drawdown) : '');
addLog(msg, d.passing === false ? 'warn' : 'success');
});
/* ── phase_start ──────────────────────────────────────────────────── */
socket.on('phase_start', (d) => {
const phaseNum = phaseMap[d.phase] || parseInt(d.phase_num || d.phase || '1', 10);
setPhaseActive(phaseNum, { total: d.total, mode: d.mode });
const label = d.label
|| ({ 1: 'Exploration', 2: (d.mode === 'autonomous' ? 'AI Iteration' : 'Iteration'), 3: 'Validation' }[phaseNum])
|| ('Phase ' + phaseNum);
addLog('Phase started: ' + label, 'info');
safe('run-indicator-label', e => { e.textContent = label + '...'; });
setRunningState(true, label);
});
/* ── phase1_complete ──────────────────────────────────────────────── */
socket.on('phase1_complete', (d) => {
addLog('Phase 1 complete — ' + (d.n_passing || 0) + '/' + (d.total_tested || '?') + ' configs passed.', 'success');
setPhaseActive(2);
// Backend emits top_results (NOT top_configs)
const topResults = d.top_results || d.top_configs || [];
if (topResults.length) {
if (topResults[0] && topResults[0].params) updateParamsTable(topResults[0].params);
topResults.forEach(cfg => addRecentRun(cfg));
}
});
/* ── phase2_complete ──────────────────────────────────────────────── */
socket.on('phase2_complete', (d) => {
addLog('Phase 2 complete — best: ' + (d.best_run_id || '?') + ', profit=$' + (d.best_profit || 0), 'success');
setPhaseActive(3);
// Phase2 complete has flat fields: best_profit, best_calmar
if (d.best_profit != null) {
updateMetricCards({ net_profit: d.best_profit, calmar: d.best_calmar });
}
});
/* ── optimization_complete ────────────────────────────────────────── */
socket.on('optimization_complete', (d) => {
addLog('✅ Optimization complete! Verdict: ' + (d.verdict || '?'), 'success');
for (let i = 1; i <= 3; i++) {
const step = el('phase-step-' + i);
if (step) { step.classList.remove('active'); step.classList.add('done'); }
}
safe('opt-progress-fill', e => { e.style.width = '100%'; });
safe('hdr-prog-fill', e => { e.style.width = '100%'; });
safe('hdr-pct', e => { e.textContent = '100% Complete'; });
safe('opt-phase', e => { e.textContent = 'Complete'; });
if (d.score != null) updateHeaderScore(d.score);
showVerdict(d);
});
/* ── ai_insight ───────────────────────────────────────────────────── */
socket.on('ai_insight', (d) => {
updateAIPanel(d);
addLog('AI Insight received (confidence: ' + (d.confidence || '?') + ')', 'info');
});
/* ── log ──────────────────────────────────────────────────────────── */
socket.on('log', (d) => {
const msg = typeof d === 'string' ? d : (d.msg || d.message || JSON.stringify(d));
const level = typeof d === 'object' ? (d.level || 'info') : 'info';
addLog(msg, level);
});
/* ── no_profitable_config ─────────────────────────────────────────── */
socket.on('no_profitable_config', (d) => {
safe('no-profit-banner', e => {
e.classList.add('active');
// Backend emits d.msg (not d.message)
const text = (d && (d.msg || d.message)) || 'No profitable configuration found.';
const msgEl = el('no-profit-msg');
if (msgEl) msgEl.textContent = ' ' + text;
});
setRunningState(false);
addLog('No profitable configuration found.', 'warn');
});
/* ── error ────────────────────────────────────────────────────────── */
socket.on('error', (d) => {
const msg = (d && d.msg) || 'Unknown server error';
addLog('Server error: ' + msg, 'error');
setRunningState(false);
});
/* ── Autonomous AI Loop events ─────────────────────────────────────── */
socket.on('ai_iteration_start', (d) => {
const iter = d.iteration || '?';
const max = d.max_iterations || '?';
safe('hdr-status', e => { e.textContent = 'AI Loop'; });
safe('hdr-status-sub', e => { e.textContent = `Iteration ${iter} / ${max}`; });
let msg = `🤖 AI Iteration ${iter}/${max}`;
if (d.is_stuck_escape) {
msg += ' [escape mode]';
} else if (d.changes && d.changes.length) {
const changes = d.changes.map(c => `${c.param}=${c.value}`).join(', ');
msg += ` — trying: ${changes}`;
}
addLog(msg, 'info');
if (d.analysis) addLog(` AI reasoning: ${d.analysis}`, 'info');
});
socket.on('ai_iteration_complete', (d) => {
const iter = d.iteration || '?';
const max = d.max_iterations || 0;
const pf = parseFloat(d.profit_factor || 0).toFixed(2);
const calmar = parseFloat(d.calmar || 0).toFixed(2);
const dd = parseFloat(d.max_drawdown || 0).toFixed(1);
const status = d.passing ? '✅' : '❌';
addLog(
` ${status} iter=${iter} | PF=${pf} | Calmar=${calmar} | DD=${dd}% | `
+ `confidence=${(d.confidence || 0).toFixed(2)}`,
d.passing ? 'success' : 'warn'
);
if (typeof iter === 'number' && max) updatePhaseSubProgress(2, iter, max);
if (d.best_score != null) updateHeaderScore(d.best_score);
if (d.goal_status) {
const gs = d.goal_status;
const flags = [
gs.profit_factor_met ? '✓ PF' : '✗ PF',
gs.drawdown_ok ? '✓ DD' : '✗ DD',
gs.calmar_met ? '✓ Calmar' : '✗ Calmar',
].join(' ');
addLog(` Targets: ${flags}`, 'info');
}
if (d.targets_met) addLog('🎯 All targets met! Stopping AI loop early.', 'success');
});
socket.on('ai_targets_met', (d) => {
addLog(
`🎯 Targets achieved after ${d.iteration} iterations — `
+ `PF=${parseFloat(d.profit_factor || 0).toFixed(2)}, `
+ `Calmar=${parseFloat(d.calmar || 0).toFixed(2)}, `
+ `DD=${parseFloat(d.max_drawdown || 0).toFixed(1)}%`,
'success'
);
});
socket.on('ai_stuck', (d) => {
addLog(`⚠ AI loop stuck at iteration ${d.iteration} — applying random escape`, 'warn');
});
/* ══════════════════════════════════════════════════════════════════════
LIVE INTELLIGENCE — AI Thinking Feed / Param Changes / Validation
══════════════════════════════════════════════════════════════════════ */
const MAX_THINKING_ENTRIES = 150;
const MAX_PARAM_CHANGE_ITERATIONS = 20;
const thinkingFeedState = { entries: [], active: false };
const paramChangesState = { iterations: [] };
const validationState = { runs: [], planned: 0, complete: 0, active: false };
function _formatVal(v) {
if (v === null || v === undefined) return '—';
if (typeof v === 'number') {
if (Number.isInteger(v)) return String(v);
return Number(v).toFixed(Math.abs(v) < 10 ? 4 : 2);
}
return String(v);
}
function _markerFor(kind) {
return {
info: '•',
reasoning: '✦',
decision: '→',
success: '✓',
warning: '⚠',
hypothesis: '?',
}[kind] || '•';
}
function renderThinkingFeed() {
const box = el('thinking-feed');
if (!box) return;
if (!thinkingFeedState.entries.length) {
box.innerHTML = '<div class="live-intel-empty">Waiting for the optimizer to start…<br><span style="font-size:0.68rem">The AI\'s reasoning will stream here in real time.</span></div>';
return;
}
box.innerHTML = thinkingFeedState.entries.map(e => {
const kind = e.kind || 'info';
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>` : '';
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)}${cursor}</div>
<div class="tf-meta">${time}${iter}${phase}</div>
</div>
</div>`;
}).join('');
// Auto-scroll to bottom so latest entry is visible
box.scrollTop = box.scrollHeight;
}
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 = {
msg: d.msg || '',
kind: d.kind || 'info',
iteration: d.iteration,
phase: d.phase,
time: timeLabel,
};
thinkingFeedState.entries.push(entry);
if (thinkingFeedState.entries.length > MAX_THINKING_ENTRIES) {
thinkingFeedState.entries.shift();
}
thinkingFeedState.active = true;
safe('tf-badge', e => { e.textContent = 'Live'; e.className = 'badge live'; });
renderThinkingFeed();
}
function renderParamChanges() {
const box = el('param-changes-list');
if (!box) return;
if (!paramChangesState.iterations.length) {
box.innerHTML = '<div class="live-intel-empty">No iterations yet.<br><span style="font-size:0.68rem">AI-driven parameter edits appear per iteration.</span></div>';
return;
}
// Newest first
const items = [...paramChangesState.iterations].reverse();
box.innerHTML = items.map(it => {
const changes = (it.changes || []).map(c => `
<div class="pc-change">
<span class="pc-param">${escapeHtml(c.param || '')}</span>:
<span class="pc-oldval">${escapeHtml(_formatVal(c.from))}</span>
<span class="pc-arrow">→</span>
<span class="pc-newval">${escapeHtml(_formatVal(c.to))}</span>
${c.reason ? `<div class="pc-reason">${escapeHtml(c.reason)}</div>` : ''}
</div>
`).join('');
const conf = (typeof it.confidence === 'number')
? `conf ${(it.confidence * 100).toFixed(0)}%`
: '';
return `<div class="pc-iteration">
<div class="pc-iter-header">
<span class="pc-iter-label">iter ${it.iteration}</span>
<span class="pc-iter-conf">${escapeHtml(conf)}</span>
</div>
${changes || '<div class="pc-change" style="color:var(--muted);font-style:italic">(no param changes — random escape)</div>'}
</div>`;
}).join('');
}
function addParamChanges(d) {
paramChangesState.iterations.push({
iteration: d.iteration,
changes: d.changes || d.change_records || [],
analysis: d.analysis || '',
confidence: d.confidence,
});
if (paramChangesState.iterations.length > MAX_PARAM_CHANGE_ITERATIONS) {
paramChangesState.iterations.shift();
}
safe('pc-badge', e => { e.textContent = `iter ${d.iteration}`; e.className = 'badge live'; });
renderParamChanges();
}
function renderValidation() {
const box = el('validation-panel');
if (!box) return;
if (!validationState.active && !validationState.runs.length) {
box.innerHTML = '<div class="live-intel-empty">Validation runs after optimization.<br><span style="font-size:0.68rem">Out-of-sample + sensitivity tests.</span></div>';
return;
}
const planned = validationState.planned || validationState.runs.length;
const done = validationState.complete;
const pct = planned > 0 ? Math.round(done / planned * 100) : 0;
const progress = `
<div class="val-progress-row">
<span>${done}/${planned}</span>
<div class="val-progress-bar"><div class="val-progress-fill" style="width:${pct}%"></div></div>
<span>${pct}%</span>
</div>`;
const runsHtml = validationState.runs.map(r => {
let clsExtra = r.status === 'running' ? 'active' : (r.passing === true ? 'pass' : (r.passing === false ? 'fail' : ''));
const statusBadge = r.status === 'running'
? '<span class="val-spinner"></span>'
: (r.passing ? '<span style="color:var(--green)">✓</span>'
: r.passing === false ? '<span style="color:var(--red)">✗</span>' : '');
const metrics = (r.status === 'complete') ? `
<div class="val-run-metrics">
<div class="val-metric"><div class="val-metric-lbl">Profit</div><div class="val-metric-val" style="color:${r.net_profit >= 0 ? 'var(--green)' : 'var(--red)'}">${fmtMoney(r.net_profit)}</div></div>
<div class="val-metric"><div class="val-metric-lbl">PF</div><div class="val-metric-val" style="color:var(--yellow)">${Number(r.profit_factor || 0).toFixed(2)}</div></div>
<div class="val-metric"><div class="val-metric-lbl">Calmar</div><div class="val-metric-val" style="color:var(--teal)">${Number(r.calmar || 0).toFixed(2)}</div></div>
<div class="val-metric"><div class="val-metric-lbl">DD</div><div class="val-metric-val" style="color:var(--red)">${Number(r.max_drawdown || 0).toFixed(1)}%</div></div>
</div>` : '';
return `<div class="val-run ${clsExtra}">
<div class="val-run-title">
${statusBadge}
<span>${escapeHtml(r.label || r.kind || 'Run')}</span>
<span class="val-run-kind ${escapeHtml(r.kind || '')}">${escapeHtml(r.kind || '')}</span>
</div>
${r.description ? `<div class="val-run-desc">${escapeHtml(r.description)}</div>` : ''}
${metrics}
</div>`;
}).join('');
box.innerHTML = progress + runsHtml;
}
function validationRunStart(d) {
validationState.active = true;
validationState.planned = d.total || validationState.planned;
// Replace any existing run with same run_id
validationState.runs = validationState.runs.filter(r => r.run_id !== d.run_id);
validationState.runs.push({
run_id: d.run_id,
kind: d.kind,
label: d.label,
description: d.description,
index: d.index,
status: 'running',
});
safe('val-badge', e => { e.textContent = `Running ${d.index}/${d.total || validationState.planned}`; e.className = 'badge live'; });
renderValidation();
}
function validationRunComplete(d) {
const idx = validationState.runs.findIndex(r => r.run_id === d.run_id);
const base = idx >= 0 ? validationState.runs[idx] : { run_id: d.run_id, kind: d.kind, label: d.label };
const merged = {
...base,
status: 'complete',
net_profit: d.net_profit,
profit_factor: d.profit_factor,
calmar: d.calmar,
max_drawdown: d.max_drawdown,
total_trades: d.total_trades,
passing: d.passing,
};
if (idx >= 0) validationState.runs[idx] = merged;
else validationState.runs.push(merged);
validationState.complete++;
renderValidation();
}
function validationDone(d) {
validationState.active = false;
const label = ({
RECOMMENDED: 'Passed',
RISKY: 'Mixed',
NOT_RELIABLE: 'Failed',
})[d.verdict] || 'Done';
const cls = d.verdict === 'RECOMMENDED' ? 'live' : '';
safe('val-badge', e => { e.textContent = label; e.className = `badge ${cls}`; });
renderValidation();
}
function showEarlyTermination(d) {
const banner = el('early-term-banner');
if (!banner) return;
const reason = d.reason || 'unknown';
const cls = {
targets_met: 'success',
no_profit: 'critical',
budget_exhausted: 'warning',
stuck_escape: 'warning',
user_stop: 'warning',
}[reason] || 'warning';
banner.className = `early-term-banner ${cls} active`;
const icon = {
targets_met: '🎯',
no_profit: '⚠',
budget_exhausted: '⏱',
stuck_escape: '↻',
user_stop: '⏹',
}[reason] || '⚠';
safe('early-term-icon', e => { e.textContent = icon; });
const heading = {
targets_met: 'Targets Met — Optimization Complete',
no_profit: 'Optimization Stopped Early',
budget_exhausted: 'Time Budget Exhausted',
stuck_escape: 'Optimizer Stuck',
user_stop: 'Stopped by User',
}[reason] || 'Optimization Event';
safe('early-term-heading', e => { e.textContent = heading; });
safe('early-term-msg', e => { e.textContent = d.message || ''; });
// Render details as chips
const det = d.details || {};
const chips = Object.entries(det)
.filter(([k, v]) => typeof v !== 'object' && v !== null && v !== undefined && v !== '')
.map(([k, v]) => `<span>${escapeHtml(k)}: <strong style="color:var(--text)">${escapeHtml(String(v))}</strong></span>`)
.join('');
safe('early-term-details', e => { e.innerHTML = chips; });
}
function dismissEarlyTerm() {
safe('early-term-banner', e => { e.classList.remove('active'); });
}
/* ── Socket wiring for new events ─────────────────────────────────── */
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);
});
socket.on('validation_start', (d) => {
// Reset the panel for a fresh validation phase
validationState.runs = [];
validationState.planned = d.planned_runs || 0;
validationState.complete = 0;
validationState.active = true;
safe('val-badge', e => { e.textContent = `0/${d.planned_runs}`; e.className = 'badge live'; });
renderValidation();
addLog(`Validation: planning ${d.planned_runs} test runs`, 'info');
});
socket.on('validation_run_start', (d) => {
validationRunStart(d);
});
socket.on('validation_run_complete', (d) => {
validationRunComplete(d);
});
socket.on('validation_done', (d) => {
validationDone(d);
});
socket.on('early_termination', (d) => {
showEarlyTermination(d);
addLog(`⚠ ${d.message}`, d.reason === 'targets_met' ? 'success' : 'warn');
});
/* ── Also capture change_records from ai_iteration_start as a fallback ── */
socket.on('ai_iteration_start', (d) => {
// (existing handler runs first — this just ensures param-changes panel is populated
// even if the dedicated param_changes event is missed for any reason)
if (d.change_records && d.change_records.length) {
const already = paramChangesState.iterations.find(i => i.iteration === d.iteration);
if (!already) {
addParamChanges({
iteration: d.iteration,
changes: d.change_records,
analysis: d.analysis,
confidence: d.confidence,
});
}
}
});
/* ══════════════════════════════════════════════════════════════════════
STOP BUTTON — uses HTTP POST, not socket emit
══════════════════════════════════════════════════════════════════════ */
safe('btn-stop', stopBtn => {
stopBtn.addEventListener('click', async () => {
addLog('Stopping optimization...', 'warn');
stopBtn.classList.remove('active');
try {
await fetch('/api/stop', { method: 'POST' });
setRunningState(false);
addLog('Stop signal sent.', 'warn');
} catch (e) {
addLog('Stop request failed: ' + e.message, 'error');
}
});
});
/* Pause button removed — see dashboard.html. Pipeline only supports Stop. */
/* ══════════════════════════════════════════════════════════════════════
HISTORY RESTORATION
Loads past runs from /api/history and /api/ai_insight/latest so
reopening the dashboard shows progressive data, not a blank slate.
══════════════════════════════════════════════════════════════════════ */
async function restoreHistory() {
try {
const [histResp, aiResp, liveResp] = await Promise.all([
fetch('/api/history'),
fetch('/api/ai_insight/latest'),
fetch('/api/live_activity'),
]);
const runs = await histResp.json();
if (Array.isArray(runs) && runs.length) {
// Populate recent runs table (last 8, newest first)
const sorted = runs.slice().reverse();
state.runs = runs.length;
safe('m-runs', e => { e.textContent = state.runs; });
sorted.slice(0, 8).forEach(r => addRecentRun(r));
// Push equity sparkline from net profits
runs.forEach(r => {
if (r.net_profit != null) {
pushEquityPoint(r.net_profit, r.net_profit, r.run_id || '');
pushDDPoint(r.max_drawdown || 0, r.run_id || '');
pushSparkData('profit', r.net_profit);
pushSparkData('dd', r.max_drawdown || 0);
pushSparkData('pf', r.profit_factor || 0);
pushSparkData('trades', r.total_trades || 0);
pushSparkData('runs', state.runs);
}
});
// Show best metrics
const passing = runs.filter(r => r.passing);
if (passing.length) {
const best = passing.reduce((a, b) => (a.net_profit > b.net_profit ? a : b));
updateMetricCards(best);
addLog('Restored ' + runs.length + ' past runs from history.', 'info');
}
}
// Restore AI panel if insight exists
const insight = await aiResp.json();
if (insight) {
updateAIPanel(insight);
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) {
addLog('History restore: ' + e.message, 'warn');
}
}
/* ══════════════════════════════════════════════════════════════════════
SETTINGS MODAL
══════════════════════════════════════════════════════════════════════ */
function openSettings() {
safe('settings-overlay', e => { e.style.display = 'flex'; });
loadSettings();
}
function closeSettings() {
safe('settings-overlay', e => { e.style.display = 'none'; });
}
function switchSettingsTab(tab) {
document.querySelectorAll('.settings-tab').forEach(t => {
t.classList.toggle('active', t.dataset.tab === tab);
});
document.querySelectorAll('.settings-panel').forEach(p => {
p.classList.toggle('active', p.id === 'stab-' + tab);
});
}
function toggleSettingsBool(btn) {
btn.classList.toggle('on');
}
function setBoolToggle(id, value) {
const btn = el(id);
if (!btn) return;
btn.classList.toggle('on', !!value);
}
function getBoolToggle(id) {
const btn = el(id);
return btn ? btn.classList.contains('on') : false;
}
function setVal(id, value) {
const e = el(id);
if (e) e.value = value != null ? value : '';
}
function getVal(id) {
const e = el(id);
return e ? e.value : '';
}
async function loadSettings() {
try {
const resp = await fetch('/api/settings');
const cfg = await resp.json();
// AI tab
setBoolToggle('s-ai-enabled', cfg.ai && cfg.ai.enabled !== false);
setVal('s-ai-key', (cfg.ai && cfg.ai.anthropic_api_key) || '');
setVal('s-ai-model', (cfg.ai && cfg.ai.model) || 'claude-opus-4-7');
setVal('s-ai-timeout', (cfg.ai && cfg.ai.timeout_seconds) || 30);
// MT5 tab
setVal('s-mt5-exe', (cfg.mt5 && cfg.mt5.terminal_exe) || '');
setVal('s-mt5-appdata', (cfg.mt5 && cfg.mt5.appdata_path) || '');
setVal('s-mt5-mql5', (cfg.mt5 && cfg.mt5.mql5_files_path) || '');
setVal('s-mt5-timeout', (cfg.mt5 && cfg.mt5.tester_timeout_seconds) || 120);
setVal('s-mt5-model', (cfg.mt5 && cfg.mt5.tester_model) || 1);
// Broker tab
setVal('s-broker-tz', (cfg.broker && cfg.broker.timezone_offset_hours) != null ? cfg.broker.timezone_offset_hours : 3);
setVal('s-broker-deposit', (cfg.broker && cfg.broker.deposit) || 10000);
setVal('s-broker-leverage', (cfg.broker && cfg.broker.leverage) || 500);
// Thresholds tab
setVal('s-thr-trades', (cfg.thresholds && cfg.thresholds.min_trades) || 30);
setVal('s-thr-pf', (cfg.thresholds && cfg.thresholds.min_profit_factor) || 1.2);
setVal('s-thr-calmar', (cfg.thresholds && cfg.thresholds.min_calmar) || 0.5);
setVal('s-thr-oos', (cfg.thresholds && cfg.thresholds.max_oos_degradation) || 0.3);
setVal('s-thr-sens', (cfg.thresholds && cfg.thresholds.sensitivity_tolerance) || 0.15);
} catch (e) {
showSettingsMsg('Failed to load settings: ' + e.message, false);
}
}
async function saveSettings() {
const btn = el('settings-save-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
const payload = {
ai: {
enabled: getBoolToggle('s-ai-enabled'),
anthropic_api_key: getVal('s-ai-key').trim(),
model: getVal('s-ai-model'),
timeout_seconds: parseFloat(getVal('s-ai-timeout')) || 30,
},
mt5: {
terminal_exe: getVal('s-mt5-exe').trim(),
appdata_path: getVal('s-mt5-appdata').trim(),
mql5_files_path: getVal('s-mt5-mql5').trim(),
tester_timeout_seconds: parseInt(getVal('s-mt5-timeout'), 10) || 120,
tester_model: parseInt(getVal('s-mt5-model'), 10) || 1,
},
broker: {
timezone_offset_hours: parseFloat(getVal('s-broker-tz')) || 3,
deposit: parseFloat(getVal('s-broker-deposit')) || 10000,
leverage: parseInt(getVal('s-broker-leverage'), 10) || 500,
},
thresholds: {
min_trades: parseInt(getVal('s-thr-trades'), 10) || 30,
min_profit_factor: parseFloat(getVal('s-thr-pf')) || 1.2,
min_calmar: parseFloat(getVal('s-thr-calmar')) || 0.5,
max_oos_degradation: parseFloat(getVal('s-thr-oos')) || 0.3,
sensitivity_tolerance: parseFloat(getVal('s-thr-sens')) || 0.15,
},
};
try {
const resp = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await resp.json();
if (data.ok) {
showSettingsMsg('Settings saved.', true);
addLog('Settings saved successfully.', 'success');
setTimeout(closeSettings, 1200);
} else {
showSettingsMsg('Error: ' + (data.error || 'Unknown error'), false);
}
} catch (e) {
showSettingsMsg('Save failed: ' + e.message, false);
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Save Settings'; }
}
}
function showSettingsMsg(msg, ok) {
safe('settings-save-msg', e => {
e.textContent = msg;
e.className = 'settings-save-msg ' + (ok ? 'ok' : 'err');
e.style.opacity = '1';
setTimeout(() => { e.style.opacity = '0'; }, 3000);
});
}
/* ══════════════════════════════════════════════════════════════════════
AI PANEL TOGGLE
══════════════════════════════════════════════════════════════════════ */
function toggleAIDetails() {
const details = el('ai-details');
if (!details) return;
const isOpen = details.classList.contains('open');
details.classList.toggle('open', !isOpen);
const label = isOpen ? 'View Details →' : '▲ Collapse';
safe('ai-expand-btn', e => { e.textContent = label; });
safe('ai-view-details', e => { e.textContent = label; });
}
/* ══════════════════════════════════════════════════════════════════════
RUN DETAIL MODAL
══════════════════════════════════════════════════════════════════════ */
async function openRunDetail(run_id) {
if (!run_id) return;
// Show modal immediately with loading state
safe('run-detail-overlay', e => { e.style.display = 'flex'; });
safe('rd-run-id', e => { e.textContent = run_id; });
safe('rd-meta', e => { e.textContent = 'Loading…'; });
safe('rd-metrics', e => { e.innerHTML = ''; });
safe('rd-params-tbody', e => { e.innerHTML = '<tr><td colspan="2" class="empty-state">Loading…</td></tr>'; });
safe('rd-ai-section', e => { e.style.display = 'none'; });
safe('rd-footer', e => { e.innerHTML = '<button class="verdict-btn ghost" onclick="closeRunDetail()">Close</button>'; });
// Clear any lingering evolution section from a previous openBestResult call
const existingEvo = document.getElementById('rd-evolution-section');
if (existingEvo) existingEvo.remove();
try {
const resp = await fetch('/api/run/' + encodeURIComponent(run_id));
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const d = await resp.json();
// Header
safe('rd-run-id', e => { e.textContent = d.run_id || run_id; });
const phase = (d.phase || '—').replace(/_/g, ' ');
const ts = d.ts ? new Date(d.ts).toLocaleString() : '—';
safe('rd-meta', e => { e.textContent = 'Phase: ' + phase + ' · ' + ts; });
// Metrics grid
const profit = parseFloat(d.net_profit || 0);
const pf = parseFloat(d.profit_factor || 0);
const calmar = parseFloat(d.calmar || 0);
const dd = parseFloat(d.drawdown_pct || d.max_drawdown || 0);
const wr = parseFloat(d.win_rate || 0);
const trades = parseInt(d.total_trades || 0, 10);
const score = parseFloat(d.score || 0);
const metrics = [
{ label: 'Net Profit', val: fmtMoney(profit), color: profit >= 0 ? 'var(--green)' : 'var(--red)' },
{ label: 'Profit Factor', val: pf.toFixed(2), color: pf >= 1.5 ? 'var(--green)' : pf >= 1 ? 'var(--yellow)' : 'var(--red)' },
{ label: 'Calmar Ratio', val: calmar.toFixed(2), color: 'var(--teal)' },
{ label: 'Max Drawdown', val: dd.toFixed(1) + '%', color: 'var(--red)' },
{ label: 'Win Rate', val: wr.toFixed(1) + '%', color: 'var(--text)' },
{ label: 'Total Trades', val: trades.toLocaleString(), color: 'var(--text)' },
{ label: 'Score', val: score.toFixed(4), color: 'var(--accent2)' },
{ label: 'Phase', val: phase, color: 'var(--muted)' },
];
safe('rd-metrics', e => {
e.innerHTML = metrics.map(m =>
`<div class="rd-metric">
<div class="rd-metric-label">${escapeHtml(m.label)}</div>
<div class="rd-metric-val" style="color:${m.color}">${escapeHtml(String(m.val))}</div>
</div>`
).join('');
});
// Parameters
const params = d.params || {};
const paramEntries = Object.entries(params);
safe('rd-params-tbody', e => {
if (!paramEntries.length) {
e.innerHTML = '<tr><td colspan="2" class="empty-state">No parameters available</td></tr>';
return;
}
e.innerHTML = paramEntries.map(([key, val]) => {
const display = typeof val === 'number' ? val.toFixed(val % 1 !== 0 ? 4 : 0) : val;
return `<tr><td class="param-name">${escapeHtml(key)}</td><td class="param-val">${escapeHtml(String(display))}</td></tr>`;
}).join('');
});
// AI Reasoning
const ai = d.ai_insight;
if (ai) {
safe('rd-ai-section', e => { e.style.display = ''; });
safe('rd-ai-content', e => {
let html = '<div class="rd-ai-box">';
const analysisText = ai.analysis || ai.diagnosis || ai.headline || '';
if (analysisText) {
html += '<div class="rd-ai-analysis">' + escapeHtml(analysisText) + '</div>';
}
const changes = ai.changes || ai.suggestions || [];
if (changes.length) {
html += '<div class="ai-sub-title" style="margin-bottom:0.4rem;">Suggested Changes</div>';
html += '<div class="rd-ai-changes">';
changes.forEach(c => {
if (typeof c === 'object' && (c.param || c.parameter)) {
const p = c.param || c.parameter;
const v = c.value !== undefined ? c.value : (c.to !== undefined ? c.to : '?');
html += `<div class="rd-ai-change">
<span class="rd-ai-change-param">${escapeHtml(p)}</span>
<span class="rd-ai-change-arrow">→</span>
<span class="rd-ai-change-val">${escapeHtml(String(v))}</span>
</div>`;
} else if (typeof c === 'string') {
html += `<div class="rd-ai-change"><span style="color:var(--text)">${escapeHtml(c)}</span></div>`;
}
});
html += '</div>';
}
const conf = ai.confidence;
if (conf !== undefined) {
const confStr = typeof conf === 'number' ? (conf * 100).toFixed(0) + '%' : String(conf);
html += `<div style="margin-top:0.65rem;font-size:0.7rem;color:var(--muted)">Confidence: <strong style="color:var(--text)">${escapeHtml(confStr)}</strong></div>`;
}
const gs = ai.goal_status;
if (gs) {
const flags = [
gs.profit_factor_met ? '✓ PF' : '✗ PF',
gs.drawdown_ok ? '✓ DD' : '✗ DD',
gs.calmar_met ? '✓ Calmar' : '✗ Calmar',
].join(' · ');
html += `<div style="margin-top:0.4rem;font-size:0.7rem;color:var(--muted)">Targets: ${escapeHtml(flags)}</div>`;
}
html += '</div>';
e.innerHTML = html;
});
}
// Footer
safe('rd-footer', e => {
let btns = '';
if (d.set_url) {
btns += `<a href="${escapeHtml(d.set_url)}" class="verdict-btn primary" download>⬇ Download .set</a>`;
}
const reportHref = '/reports/' + encodeURIComponent(d.run_id || run_id) + '/summary.html';
btns += `<a href="${reportHref}" class="verdict-btn secondary" target="_blank">📋 View Report</a>`;
btns += '<button class="verdict-btn ghost" onclick="closeRunDetail()">Close</button>';
e.innerHTML = btns;
});
} catch (err) {
safe('rd-meta', e => { e.textContent = 'Error: ' + err.message; });
}
}
function closeRunDetail() {
safe('run-detail-overlay', e => { e.style.display = 'none'; });
}
/* ══════════════════════════════════════════════════════════════════════
BEST RESULT — shows full params, AI reasoning, AND evolution path
══════════════════════════════════════════════════════════════════════ */
async function openBestResult() {
try {
const resp = await fetch('/api/best_result');
if (!resp.ok) {
// No best yet — open a toast-like state in the run detail modal
safe('run-detail-overlay', e => { e.style.display = 'flex'; });
safe('rd-run-id', e => { e.textContent = 'Best Result'; });
safe('rd-meta', e => { e.textContent = 'No best result yet — optimization must complete at least one passing run.'; });
safe('rd-metrics', e => { e.innerHTML = ''; });
safe('rd-params-tbody', e => { e.innerHTML = '<tr><td colspan="2" class="empty-state">—</td></tr>'; });
safe('rd-ai-section', e => { e.style.display = 'none'; });
safe('rd-footer', e => { e.innerHTML = '<button class="verdict-btn ghost" onclick="closeRunDetail()">Close</button>'; });
return;
}
const d = await resp.json();
// Delegate to the existing openRunDetail, then append evolution path
await openRunDetail(d.run_id);
// Append evolution path as an extra section inside the modal body
const body = document.querySelector('.run-detail-body');
if (!body) return;
let evoSection = document.getElementById('rd-evolution-section');
if (!evoSection) {
evoSection = document.createElement('div');
evoSection.id = 'rd-evolution-section';
body.appendChild(evoSection);
}
const evo = d.evolution || [];
if (!evo.length) {
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;
}
// ── 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 · 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');
}
}
/* ══════════════════════════════════════════════════════════════════════
INIT
══════════════════════════════════════════════════════════════════════ */
(function init() {
initSparklines();
restoreHistory();
startPolling();
addLog('APEX Dashboard initialized.', 'success');
})();