/** * 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, '&') .replace(//g, '>') .replace(/"/g, '"'); } 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, phase: 'idle', // current pipeline phase, lowercase autonomous: false, // whether the current run uses the AI loop aiKeySet: false, // whether ANTHROPIC_API_KEY is present 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 => '
  • ' + escapeHtml(p) + '
  • ').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 => '
  • ' + escapeHtml(r) + '
  • ').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) => ` #${state.runs - i} ${fmtMoney(r.profit)} ${r.pf.toFixed(2)} ${r.maxDD.toFixed(1)}% ${r.verdictLabel} ${r.run_id ? `` : ''} `).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 ` ${escapeHtml(key)} ${escapeHtml(String(display))} ${impactLabel} `; }).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(); refreshAIWaitingState(); } else { stopElapsedTimer(); } } /** * Update the AI Summary panel's waiting message + action link based on * whether (a) a run is active and (b) the API key is configured. * Called whenever phase/state/aiKeySet changes. */ function refreshAIWaitingState() { const waiting = el('ai-waiting'); const txt = el('ai-waiting-text'); const action = el('ai-waiting-action'); if (!waiting || !txt) return; // Don't override if AI content has already loaded const content = el('ai-content'); if (content && content.style.display !== 'none') return; if (state.isRunning && !state.aiKeySet) { txt.textContent = 'AI insights are off — no Anthropic API key set.'; if (action) action.style.display = ''; } else if (state.isRunning && state.aiKeySet) { txt.textContent = 'Optimization running — first AI analysis fires after the next backtest.'; if (action) action.style.display = 'none'; } else { txt.textContent = 'Waiting for optimization to start...'; if (action) action.style.display = 'none'; } } /* ══════════════════════════════════════════════════════════════════════ 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 = '' + nowStr() + '' + escapeHtml(String(msg)) + ''; 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 = `
    ${fmtMoney(profit)}
    Net Profit
    ${calmar.toFixed(2)}
    Calmar
    ${winRate.toFixed(1)}%
    Win Rate
    ${maxDD.toFixed(1)}%
    Max DD
    `; }); safe('verdict-actions', e => { e.innerHTML = ` ${d.set_file_url ? '⬇ Download .set File' : ''} 📋 View Report ⚡ New Run `; }); 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); state.phase = (d.phase || '').toLowerCase(); if (d.mode === 'autonomous') state.autonomous = true; setPhaseActive(phaseNum, { total: d.total, mode: d.mode }); // Re-render empty-state cards so they pick up the phase-aware message renderParamChanges(); 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 = '
    Waiting for the optimizer to start…
    The AI\'s reasoning will stream here in real time.
    '; return; } box.innerHTML = thinkingFeedState.entries.map(e => { const kind = e.kind || 'info'; const iter = e.iteration ? `iter ${e.iteration}` : ''; const phase = e.phase ? `${escapeHtml(e.phase)}` : ''; const time = e.time ? `${escapeHtml(e.time)}` : ''; const cursor = e.streaming ? '' : ''; return `
    ${_markerFor(kind)}
    ${escapeHtml(e.msg)}${cursor}
    ${time}${iter}${phase}
    `; }).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) { // Tailor the empty-state message to the current phase + mode const phase = (state.phase || '').toLowerCase(); let msg, sub; if (phase === 'phase1') { msg = 'Exploration phase'; sub = 'Parameter changes appear once Phase 2 (AI iteration) starts.'; } else if (phase.startsWith('phase2')) { msg = state.autonomous ? 'Waiting for first AI iteration…' : 'Random-neighbor refinement'; sub = state.autonomous ? 'Claude will choose the next parameter set after the first run finishes.' : 'Enable Autonomous AI mode in setup to see AI-driven parameter changes.'; } else if (phase.startsWith('phase3')) { msg = 'Validation phase'; sub = 'Phase 2 complete. See validation panel for OOS + sensitivity results.'; } else { msg = 'No iterations yet'; sub = 'AI-driven parameter edits appear per iteration during Phase 2.'; } box.innerHTML = `
    ${escapeHtml(msg)}
    ${escapeHtml(sub)}
    `; return; } // Newest first const items = [...paramChangesState.iterations].reverse(); box.innerHTML = items.map(it => { const changes = (it.changes || []).map(c => `
    ${escapeHtml(c.param || '')}: ${escapeHtml(_formatVal(c.from))} ${escapeHtml(_formatVal(c.to))} ${c.reason ? `
    ${escapeHtml(c.reason)}
    ` : ''}
    `).join(''); const conf = (typeof it.confidence === 'number') ? `conf ${(it.confidence * 100).toFixed(0)}%` : ''; return `
    iter ${it.iteration} ${escapeHtml(conf)}
    ${changes || '
    (no param changes — random escape)
    '}
    `; }).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 = '
    Validation runs after optimization.
    Out-of-sample + sensitivity tests.
    '; return; } const planned = validationState.planned || validationState.runs.length; const done = validationState.complete; const pct = planned > 0 ? Math.round(done / planned * 100) : 0; const progress = `
    ${done}/${planned}
    ${pct}%
    `; 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' ? '' : (r.passing ? '' : r.passing === false ? '' : ''); const metrics = (r.status === 'complete') ? `
    Profit
    ${fmtMoney(r.net_profit)}
    PF
    ${Number(r.profit_factor || 0).toFixed(2)}
    Calmar
    ${Number(r.calmar || 0).toFixed(2)}
    DD
    ${Number(r.max_drawdown || 0).toFixed(1)}%
    ` : ''; return `
    ${statusBadge} ${escapeHtml(r.label || r.kind || 'Run')} ${escapeHtml(r.kind || '')}
    ${r.description ? `
    ${escapeHtml(r.description)}
    ` : ''} ${metrics}
    `; }).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]) => `${escapeHtml(k)}: ${escapeHtml(String(v))}`) .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, settingsResp] = await Promise.all([ fetch('/api/history'), fetch('/api/ai_insight/latest'), fetch('/api/live_activity'), fetch('/api/settings'), ]); // Note whether the API key is set so the AI panel can show a useful empty state try { const s = await settingsResp.json(); state.aiKeySet = !!(s && s.ai && s.ai.anthropic_api_key_set); } catch (_) {} refreshAIWaitingState(); 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 = 'Loading…'; }); safe('rd-ai-section', e => { e.style.display = 'none'; }); safe('rd-footer', e => { e.innerHTML = ''; }); // 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 => `
    ${escapeHtml(m.label)}
    ${escapeHtml(String(m.val))}
    ` ).join(''); }); // Parameters const params = d.params || {}; const paramEntries = Object.entries(params); safe('rd-params-tbody', e => { if (!paramEntries.length) { e.innerHTML = 'No parameters available'; return; } e.innerHTML = paramEntries.map(([key, val]) => { const display = typeof val === 'number' ? val.toFixed(val % 1 !== 0 ? 4 : 0) : val; return `${escapeHtml(key)}${escapeHtml(String(display))}`; }).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 = '
    '; const analysisText = ai.analysis || ai.diagnosis || ai.headline || ''; if (analysisText) { html += '
    ' + escapeHtml(analysisText) + '
    '; } const changes = ai.changes || ai.suggestions || []; if (changes.length) { html += '
    Suggested Changes
    '; html += '
    '; 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 += `
    ${escapeHtml(p)} ${escapeHtml(String(v))}
    `; } else if (typeof c === 'string') { html += `
    ${escapeHtml(c)}
    `; } }); html += '
    '; } const conf = ai.confidence; if (conf !== undefined) { const confStr = typeof conf === 'number' ? (conf * 100).toFixed(0) + '%' : String(conf); html += `
    Confidence: ${escapeHtml(confStr)}
    `; } 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 += `
    Targets: ${escapeHtml(flags)}
    `; } html += '
    '; e.innerHTML = html; }); } // Footer safe('rd-footer', e => { let btns = ''; if (d.set_url) { btns += `⬇ Download .set`; } const reportHref = '/reports/' + encodeURIComponent(d.run_id || run_id) + '/summary.html'; btns += `📋 View Report`; btns += ''; 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 = '—'; }); safe('rd-ai-section', e => { e.style.display = 'none'; }); safe('rd-footer', e => { e.innerHTML = ''; }); 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 = '
    Evolution Path
    No evolution data available for this run.
    '; 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 = `
    Evolution Path — ${evo.length} steps to this best result · drag the slider to replay
    score
    `; // Wire scrubber const slider = document.getElementById('evo-slider'); const stepNum = document.getElementById('evo-step-num'); const stepId = document.getElementById('evo-step-id'); const stepPh = document.getElementById('evo-step-phase'); const stepBst = document.getElementById('evo-step-best'); const stepScr = document.getElementById('evo-step-score'); const stepMtr = document.getElementById('evo-step-metrics'); const stepChg = document.getElementById('evo-step-changes'); const stepAna = document.getElementById('evo-step-analysis'); const prevBtn = document.getElementById('evo-prev-btn'); const nextBtn = document.getElementById('evo-next-btn'); const playBtn = document.getElementById('evo-play-btn'); function paintStep(i) { const r = evo[i]; if (!r) return; stepNum.textContent = `Step ${i + 1} / ${evo.length}`; stepId.textContent = r.run_id || ''; stepPh.textContent = (r.phase || '').replace(/_/g, ' '); stepBst.innerHTML = r.is_best ? ' BEST' : ''; stepScr.textContent = Number(r.score || 0).toFixed(3); const cells = [ { l: 'PF', v: Number(r.profit_factor || 0).toFixed(2), c: 'var(--yellow)' }, { l: 'Calmar', v: Number(r.calmar || 0).toFixed(2), c: 'var(--teal)' }, { l: 'DD', v: Number(r.max_drawdown || 0).toFixed(1) + '%', c: 'var(--red)' }, { l: 'Profit', v: fmtMoney(r.net_profit || 0), c: r.net_profit >= 0 ? 'var(--green)' : 'var(--red)' }, ]; stepMtr.innerHTML = cells.map(c => `
    ${c.l}
    ${escapeHtml(c.v)}
    `).join(''); const changes = r.changes || []; if (changes.length) { stepChg.innerHTML = 'Δ ' + changes.slice(0, 4).map(c => { const p = c.param || c.parameter || '?'; const v = c.value !== undefined ? c.value : (c.to !== undefined ? c.to : '?'); return `${escapeHtml(p)}=${escapeHtml(String(v))}`; }).join(', '); } else { stepChg.innerHTML = '— no parameter changes (LHS seed)'; } stepAna.textContent = r.analysis || ''; slider.value = i; } slider.addEventListener('input', () => paintStep(parseInt(slider.value, 10))); prevBtn.addEventListener('click', () => paintStep(Math.max(0, parseInt(slider.value, 10) - 1))); nextBtn.addEventListener('click', () => paintStep(Math.min(evo.length - 1, parseInt(slider.value, 10) + 1))); // Play: auto-step ~700ms intervals let playTimer = null; playBtn.addEventListener('click', () => { if (playTimer) { clearInterval(playTimer); playTimer = null; playBtn.textContent = '▶ Play'; return; } playBtn.textContent = '⏸ Pause'; let pos = parseInt(slider.value, 10); if (pos >= evo.length - 1) pos = 0; playTimer = setInterval(() => { pos++; if (pos >= evo.length) { clearInterval(playTimer); playTimer = null; playBtn.textContent = '▶ Play'; return; } paintStep(pos); }, 700); }); paintStep(bestIdx); // start showing the best step } catch (err) { addLog('Failed to open best result: ' + err.message, 'error'); } } /* ══════════════════════════════════════════════════════════════════════ INIT ══════════════════════════════════════════════════════════════════════ */ (function init() { initSparklines(); restoreHistory(); startPolling(); addLog('APEX Dashboard initialized.', 'success'); })();