Files
Apex_AI_MT5_EA_Optimizer/ui/static/js/dashboard.js
T
LEGSTECH Optimizer 6caafdb794 feat: open-source release — AI-driven autonomous optimization with live visibility
Major upgrade making the AI loop visible and the project ready for public release.

UI / UX
- Live AI Thinking Feed: streams reasoning, decisions, and outcomes per iteration
- Parameter Changes panel: prev → new + reason for every AI-driven edit
- Validation Activity panel: out-of-sample + sensitivity runs with live metrics
- Early Termination banner: surfaces why optimization stopped (targets met, no profit, budget, stuck, user stop)
- 3-phase tracker renamed Exploration / Iteration / Validation with live N/total
- Best Result modal exposes Evolution Path showing how the AI arrived at the winner
- Run-detail modal accessible from every recent run row
- Setup form validation (dates, walk-forward order, params selection, AI targets)
- Pause button removed; misleading sidebar nav consolidated to Dashboard / New Run / Reports / Source

Backend
- AIGuidedLoop streams ai_thinking, param_changes, ai_targets_met, ai_stuck
- Pipeline emits validation_start / validation_run_start / validation_run_complete / validation_done
- Pipeline emits early_termination on every early-stop path
- /api/best_result returns best run + full evolution chain
- /api/run/<id> + /api/runs sorted by ts
- AIReasoner falls back to ANTHROPIC_API_KEY env var when config is a placeholder
- Demo mode (APEX_DEMO_MODE=1) generates deterministic synthetic backtests so judges can run end-to-end without MT5

Open-source readiness
- README.md with pitch, demo flow, architecture diagram, quickstart, event reference
- LICENSE (MIT)
- config.example.yaml template (config.yaml now git-ignored)
- requirements.txt: added anthropic / requests / psutil / beautifulsoup4, capped majors
- .gitignore: secrets, *.set, scratch screenshots, ea_registry.yaml
- demo/run_demo.py: one-command offline demo runner
- 10 polished screenshots for README + judge review
2026-04-25 11:39:17 +00:00

1754 lines
75 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>` : '';
return `<div class="tf-entry ${kind}">
<div class="tf-marker">${_markerFor(kind)}</div>
<div class="tf-body">
<div class="tf-msg">${escapeHtml(e.msg)}</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) {
const entry = {
msg: d.msg || '',
kind: d.kind || 'info',
iteration: d.iteration,
phase: d.phase,
time: nowStr(),
};
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);
});
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] = await Promise.all([
fetch('/api/history'),
fetch('/api/ai_insight/latest'),
]);
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');
}
} 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;
}
const items = evo.map((r, i) => {
const tag = r.is_best ? '<span style="background:rgba(16,185,129,0.18);color:var(--green);padding:0.08rem 0.4rem;border-radius:4px;font-size:0.58rem;font-weight:700;margin-left:0.4rem">BEST</span>' : '';
const phaseLbl = (r.phase || '').replace(/_/g, ' ');
const pf = Number(r.profit_factor || 0).toFixed(2);
const calmar = Number(r.calmar || 0).toFixed(2);
const dd = Number(r.max_drawdown || 0).toFixed(1);
const score = Number(r.score || 0).toFixed(3);
const changeList = (r.changes || []).slice(0, 3).map(c => {
const p = c.param || c.parameter || '?';
const v = c.value !== undefined ? c.value : (c.to !== undefined ? c.to : '?');
return `${escapeHtml(p)}=${escapeHtml(String(v))}`;
}).join(', ');
const analysis = r.analysis ? `<div style="font-size:0.68rem;color:#94a3b8;margin-top:0.25rem;line-height:1.45">${escapeHtml(r.analysis).slice(0, 220)}${r.analysis.length > 220 ? '…' : ''}</div>` : '';
return `<div style="border-left:2px solid ${r.is_best ? 'var(--green)' : 'rgba(124,109,250,0.4)'};padding:0.5rem 0.65rem;margin-bottom:0.4rem;background:rgba(255,255,255,0.02);border-radius:0 6px 6px 0">
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.72rem">
<span style="font-family:'JetBrains Mono',monospace;color:var(--muted)">#${i + 1}</span>
<span style="font-family:'JetBrains Mono',monospace;color:var(--accent2)">${escapeHtml(r.run_id)}</span>
<span style="color:var(--muted);font-size:0.6rem;text-transform:uppercase;letter-spacing:0.04em">${escapeHtml(phaseLbl)}</span>
${tag}
<span style="margin-left:auto;font-size:0.65rem;font-family:'JetBrains Mono',monospace;color:var(--muted)">score ${score}</span>
</div>
<div style="display:flex;gap:0.6rem;font-size:0.65rem;color:var(--muted);margin-top:0.2rem;font-family:'JetBrains Mono',monospace">
<span>PF ${pf}</span><span>Calmar ${calmar}</span><span>DD ${dd}%</span>
</div>
${changeList ? `<div style="font-size:0.66rem;color:var(--teal);font-family:'JetBrains Mono',monospace;margin-top:0.25rem">Δ ${escapeHtml(changeList)}</div>` : ''}
${analysis}
</div>`;
}).join('');
evoSection.innerHTML = `
<div class="rd-section-title">Evolution Path <span style="font-weight:500;color:var(--muted);letter-spacing:0.02em;text-transform:none;font-size:0.65rem">— ${evo.length} steps to this best result</span></div>
<div style="max-height:260px;overflow-y:auto;padding:0.25rem 0;">${items}</div>
`;
} catch (err) {
addLog('Failed to open best result: ' + err.message, 'error');
}
}
/* ══════════════════════════════════════════════════════════════════════
INIT
══════════════════════════════════════════════════════════════════════ */
(function init() {
initSparklines();
restoreHistory();
startPolling();
addLog('APEX Dashboard initialized.', 'success');
})();