feat: Smart Autonomous 3-Phase Optimizer — complete redesign

PROBLEM: Old system repeated identical params, all scores flat at 0.2500,
user had zero control over symbol/TF/dates. Not smart, not dynamic.

NEW ARCHITECTURE:
  optimizer/                        (NEW package)
  ├── __init__.py
  ├── session_config.py             User choices (EA, symbol, TF, dates, budget, objective)
  ├── lhs_sampler.py                Latin Hypercube Sampling — diverse exploration
  ├── result_ranker.py              Relative scoring (best in session=1.0, worst=0.0)
  ├── budget.py                     Time budget tracker
  └── pipeline.py                   3-phase orchestrator

  Phase 1 — Broad Discovery (LHS, 20-26 runs):
    Samples FULL parameter space, not just defaults±tiny step
    LHS guarantees coverage: all 17 optimizable LEGSTECH params explored
    Relative ranking: profitable configs float top, losers score 0

  Phase 2 — Refinement (9 runs):
    Neighbor search around top 3 configs at ±20% range (not ±0.5 step)
    Keeps best of Phase1 vs Phase2 — never regresses

  Phase 3 — Validation (5 runs):
    OOS backtest on unseen data period
    Sensitivity test: nudge params ±20%, detect fragility
    Verdict: RECOMMENDED / RISKY / NOT_RELIABLE

  Output: Clean downloadable .set file via /download_set/<run_id>

UI REDESIGN:
  ui/templates/landing.html         New / homepage (was old dashboard)
  ui/templates/setup.html           New /setup — EA, symbol, TF, dates, budget, objective
  ui/templates/dashboard.html       Updated /dashboard with:
    - 5-step phase indicator
    - Real progress bar per run
    - Phase 1 results table (top 5 after phase1)
    - Verdict banner with download button
    - No-profitable-config warning
  ui/static/js/dashboard.js         Handles 8 new pipeline SocketIO events

  app.py                            New routes: /, /setup, /dashboard
                                    /api/start accepts full SessionConfig JSON
                                    /download_set/<id> serves optimized .set

  ea/registry.py                    +list_all() for setup page dropdown
  ui/templates/reports_index.html   Back to Dashboard → /dashboard (was /)
  ui/static/css/style.css           +dot-warn, dot-done, profit-pos/neg, aliases

FIXES:
  Score no longer flat 0.2500 (was: absolute thresholds on losing EA)
  User now controls: symbol, timeframe, dates, budget, objective
  Parameters now span full range (was: tiny step from defaults)
  Verdict is actionable: RECOMMENDED / RISKY / NOT_RELIABLE with reason

TESTED:
  8/8 pre-flight checks pass
  Browser test: landing ✓, setup form ✓, /dashboard ✓,
                phase indicator active ✓, /reports ✓, back link ✓
This commit is contained in:
LEGSTECH Optimizer
2026-04-13 21:14:47 +00:00
parent c4b5fe0ad0
commit 47f6012eb2
14 changed files with 2675 additions and 364 deletions
+8
View File
@@ -71,8 +71,16 @@ body {
.dot-idle { background: var(--text-muted); }
.dot-running { background: var(--green); box-shadow: 0 0 8px var(--green); animation: pulse 1.5s infinite; }
.dot-paused { background: var(--yellow); }
.dot-warn { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); }
.dot-done { background: var(--green); }
.dot-error { background: var(--red); }
/* Alias for dashboard pipeline inline styles */
:root { --card-bg: var(--bg-card); --border-color: var(--border); }
.profit-pos { color: var(--green); }
.profit-neg { color: var(--red); }
.log-warning { color: var(--yellow); }
@keyframes pulse {
0%,100% { opacity: 1; } 50% { opacity: 0.4; }
}
+389 -310
View File
@@ -1,387 +1,466 @@
/* dashboard.js — Real-time MT5 Optimizer Dashboard */
/**
* dashboard.js — Smart Optimizer Live Dashboard
* Handles all SocketIO events from the 3-phase pipeline.
*/
// ── Socket.IO connection ───────────────────────────────────────────────────
const socket = io();
let startTime = null;
let timerInterval = null;
let findingsCount = 0;
let candidatesCount = 0;
let currentParams = {};
// ── Chart setup ────────────────────────────────────────────────────────────
const ctx = document.getElementById('scoreChart').getContext('2d');
const scoreChart = new Chart(ctx, {
// ── Chart Setup ───────────────────────────────────────────────────────────────
const ctx = document.getElementById('scoreChart').getContext('2d');
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [
{
label: 'Composite Score',
label: 'Session Score',
data: [],
borderColor: '#22d3a5',
backgroundColor: 'rgba(34,211,165,0.08)',
fill: true,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: '#22d3a5',
borderColor: '#00d4aa',
backgroundColor: 'rgba(0,212,170,0.08)',
borderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
pointBackgroundColor: ctx => {
const v = ctx.raw;
return (v !== null && v > 0) ? '#00d4aa' : '#ef4444';
},
tension: 0.35,
fill: true,
},
{
label: 'Calmar Ratio',
data: [],
borderColor: '#4f8ef7',
backgroundColor: 'rgba(79,142,247,0.05)',
fill: false,
tension: 0.4,
pointRadius: 3,
borderColor: '#7c6dfa',
borderWidth: 1.5,
borderDash: [4, 2],
},
pointRadius: 2,
borderDash: [4, 3],
tension: 0.35,
fill: false,
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 500 },
animation: { duration: 400 },
plugins: {
legend: {
labels: { color: '#6b7fa3', font: { size: 11, family: 'Inter' } }
labels: { color: '#94a3b8', font: { family: 'Inter', size: 11 }, boxWidth: 12 }
},
tooltip: {
backgroundColor: '#111621',
borderColor: '#2a3a6e',
backgroundColor: '#141b27',
borderColor: 'rgba(255,255,255,0.1)',
borderWidth: 1,
titleColor: '#e2e8f8',
bodyColor: '#6b7fa3',
titleColor: '#e2e8f0',
bodyColor: '#94a3b8',
callbacks: {
label: ctx => {
if (ctx.dataset.label === 'Session Score') return ` Score: ${(ctx.raw || 0).toFixed(4)}`;
return ` Calmar: ${(ctx.raw || 0).toFixed(3)}`;
}
}
}
},
scales: {
x: {
ticks: { color: '#6b7fa3', font: { size: 10 } },
grid: { color: 'rgba(30,39,64,0.6)' },
},
y: {
ticks: { color: '#6b7fa3', font: { size: 10 } },
grid: { color: 'rgba(30,39,64,0.6)' },
min: 0, max: 1,
}
x: { ticks: { color: '#475569', font: { size: 10 }, maxTicksLimit: 12 }, grid: { color: 'rgba(255,255,255,0.04)' } },
y: { min: 0, max: 1, ticks: { color: '#475569', font: { size: 10 } }, grid: { color: 'rgba(255,255,255,0.04)' } }
}
}
});
// ── Controls ───────────────────────────────────────────────────────────────
// ── State ─────────────────────────────────────────────────────────────────────
let startTs = null;
let elapsedTimer = null;
let findingsCount = 0;
let runCount = 0;
let bestScore = 0;
let candCount = 0;
let totalRuns = 0;
function startOptimizer() {
fetch('/api/start', { method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ auto: true })
});
startTime = Date.now();
timerInterval = setInterval(updateTimer, 1000);
setRunning(true);
addLog('info', 'Starting optimizer...');
}
// ── SocketIO ──────────────────────────────────────────────────────────────────
const socket = io();
function pauseOptimizer() {
fetch('/api/pause', { method: 'POST' }).then(r => r.json()).then(d => {
const btn = document.getElementById('btn-pause');
btn.textContent = d.paused ? '▶ Resume' : '⏸ Pause';
setDot(d.paused ? 'paused' : 'running', d.paused ? 'Paused' : 'Running...');
});
}
socket.on('connect', () => addLog('info', '🔗 Connected to optimizer server'));
socket.on('disconnect', () => addLog('warning', '⚡ Disconnected from server'));
// Status sync on connect
socket.on('status_sync', (s) => {
setStatus(s.state, s.phase);
if (s.ea_name && s.symbol && s.timeframe) {
document.getElementById('session-label').textContent =
`${s.ea_name} · ${s.symbol} · ${s.timeframe}`;
}
totalRuns = s.total_runs || 0;
updateRunCount(s.run_count || 0);
if (s.state === 'running') {
document.getElementById('progress-wrap').style.display = '';
document.getElementById('btn-stop').classList.remove('hidden');
}
});
socket.on('status_change', (d) => {
setStatus(d.state, d.phase);
if (d.state === 'running') {
startTs = Date.now();
startElapsedTimer();
document.getElementById('progress-wrap').style.display = '';
document.getElementById('btn-stop').classList.remove('hidden');
}
if (d.state === 'idle' || d.state === 'stopping') {
document.getElementById('btn-stop').classList.add('hidden');
}
});
// Each run completes
socket.on('run_complete', (d) => {
updateRunCount((d.run_number != null) ? null : runCount + 1, d);
// Update metrics
const profit = d.net_profit || 0;
el('m-profit').textContent = `$${profit.toFixed(0)}`;
el('m-profit').className = 'metric-val ' + (profit >= 0 ? 'profit-pos' : 'profit-neg');
el('m-calmar').textContent = (d.calmar || 0).toFixed(3);
el('m-pf').textContent = d.profit_factor != null ? (d.profit_factor).toFixed(2) : '—';
el('m-dd').textContent = d.max_drawdown != null ? `${d.max_drawdown.toFixed(1)}%` : '—';
el('m-wr').textContent = d.win_rate != null ? `${d.win_rate.toFixed(1)}%` : '—';
el('m-trades').textContent = d.total_trades != null ? d.total_trades : '—';
el('current-run-id').textContent = d.run_id || '—';
// Session score (01)
const score = d.score || 0;
const scoreStr = score.toFixed(4);
el('m-score').textContent = d.passing ? scoreStr : 'failing';
el('score-bar-fill').style.width = `${Math.round(score * 100)}%`;
// Update best score header
if (d.passing && score > bestScore) {
bestScore = score;
el('hdr-score').textContent = scoreStr;
}
// Add to chart
const label = d.run_id ? d.run_id.slice(-6) : `${runCount}`;
chart.data.labels.push(label);
chart.data.datasets[0].data.push(d.passing ? score : 0);
// Calmar normalized to 0-1 range (cap at 3)
const calmarNorm = Math.min(Math.max((d.calmar || 0) / 3.0, 0), 1);
chart.data.datasets[1].data.push(d.passing ? calmarNorm : 0);
chart.update('none');
el('chart-badge').textContent = `${chart.data.labels.length} runs`;
// Progress bar
if (d.progress_pct != null) {
el('progress-bar-fill').style.width = `${d.progress_pct}%`;
}
if (d.phase) updatePhaseLabel(d.phase, d.run_number, d.total);
if (d.budget_summary) el('progress-budget').textContent = d.budget_summary;
// Add to best configs panel if profitable
if (d.passing) {
addCandidate(d);
}
});
// Phase start
socket.on('phase_start', (d) => {
const labels = { phase1: 'Phase 1: Broad Discovery', phase2: 'Phase 2: Refinement', phase3: 'Phase 3: Validation' };
el('progress-phase').textContent = labels[d.phase] || d.phase;
el('progress-count').textContent = `0 / ${d.total || '?'}`;
setPhaseActive(d.phase);
if (d.phase === 'phase2') {
markPhaseDone('phase1');
el('phase1-results-card').style.display = '';
}
if (d.phase === 'phase3') {
markPhaseDone('phase2');
}
});
// Phase 1 complete — show results table
socket.on('phase1_complete', (d) => {
el('phase1-results-card').style.display = '';
el('phase1-badge').textContent = `${d.n_passing}/${d.total_tested} profitable`;
renderPhase1Table(d.top_results || []);
addLog('info', `━━ Phase 1 done: ${d.n_passing}/${d.total_tested} profitable configs found. Starting refinement...`);
});
// Phase 2 complete
socket.on('phase2_complete', (d) => {
addLog('success', `✅ Phase 2 done. Best: ${d.best_run_id} · Profit: $${d.best_profit} · Calmar: ${d.best_calmar}`);
});
// No profitable configuration found
socket.on('no_profitable_config', (d) => {
el('no-profit-banner').style.display = '';
el('no-profit-msg').textContent = d.msg || 'No profitable configuration found.';
el('progress-wrap').style.display = 'none';
addLog('error', '❌ ' + (d.msg || 'No profitable config found'));
});
// Optimization complete — show verdict
socket.on('optimization_complete', (d) => {
markPhaseDone('phase3');
markPhaseDone('done');
setStatus('idle', 'done');
stopElapsedTimer();
el('progress-wrap').style.display = 'none';
el('btn-stop').classList.add('hidden');
renderVerdict(d);
addLog(d.verdict === 'RECOMMENDED' ? 'success' : 'warning',
`🏁 Optimization complete! ${d.verdict} · `+
`Profit: $${d.net_profit} · Calmar: ${d.calmar} · Runs: ${d.total_runs} · ${d.elapsed_min}min`
);
});
// Log
socket.on('log', (d) => addLog(d.level, d.msg));
// Error
socket.on('error', (d) => addLog('error', '❌ ' + (d.msg || 'Unknown error')));
// ── Actions ───────────────────────────────────────────────────────────────────
function stopOptimizer() {
fetch('/api/stop', { method: 'POST' });
clearInterval(timerInterval);
setRunning(false);
setDot('idle', 'Stopped');
addLog('warn', 'Optimizer stopped by user.');
fetch('/api/stop', { method: 'POST' }).catch(() => {});
addLog('warning', '⏹ Stop requested...');
}
function clearLog() {
document.getElementById('log-feed').innerHTML = '';
el('log-feed').innerHTML = '';
}
function setRunning(on) {
document.getElementById('btn-start').classList.toggle('hidden', on);
document.getElementById('btn-pause').classList.toggle('hidden', !on);
document.getElementById('btn-stop').classList.toggle('hidden', !on);
if (on) setDot('running', 'Running...');
}
// ── Timer ──────────────────────────────────────────────────────────────────
// ── Rendering helpers ─────────────────────────────────────────────────────────
function updateTimer() {
if (!startTime) return;
const s = Math.floor((Date.now() - startTime) / 1000);
const m = Math.floor(s / 60);
const ss = String(s % 60).padStart(2, '0');
document.getElementById('hdr-elapsed').textContent = `${m}:${ss}`;
}
// ── State dot ──────────────────────────────────────────────────────────────
function setDot(state, label) {
const dot = document.getElementById('state-dot');
dot.className = `dot dot-${state}`;
document.getElementById('state-label').textContent = label;
}
// ── Phase tracker ──────────────────────────────────────────────────────────
const PHASES = ['baseline', 'analyze', 'explore', 'validate', 'oos'];
function setPhase(phase) {
const idx = PHASES.indexOf(phase);
PHASES.forEach((p, i) => {
const el = document.getElementById(`phase-${p}`);
if (!el) return;
el.classList.remove('active', 'done');
if (i < idx) el.classList.add('done');
else if (i === idx) el.classList.add('active');
});
}
// ── Metrics update ─────────────────────────────────────────────────────────
function updateMetrics(d) {
const set = (id, val) => {
const el = document.getElementById(id);
if (el) el.textContent = val;
};
set('m-profit', d.net_profit !== undefined ? `$${d.net_profit.toLocaleString()}` : '—');
set('m-calmar', d.calmar !== undefined ? d.calmar.toFixed(3) : '—');
set('m-pf', d.profit_factor !== undefined ? d.profit_factor.toFixed(3) : '—');
set('m-dd', d.drawdown_pct !== undefined ? `${d.drawdown_pct}%` : '—');
set('m-wr', d.win_rate !== undefined ? `${d.win_rate.toFixed(1)}%` : '—');
set('m-trades', d.total_trades ?? '—');
set('m-mfe', d.mfe_capture !== undefined ? `${d.mfe_capture.toFixed(1)}%` : '—');
set('m-rev', d.reversal_rate !== undefined ? `${d.reversal_rate.toFixed(1)}%` : '—');
set('m-score', d.score !== undefined ? d.score.toFixed(4) : '—');
set('current-run-id', d.run_id || '—');
if (d.score !== undefined) {
const fill = document.getElementById('score-bar-fill');
if (fill) fill.style.width = `${Math.min(100, d.score * 100)}%`;
set('hdr-score', d.score.toFixed(4));
}
// Color profit
const pEl = document.getElementById('m-profit');
if (pEl && d.net_profit !== undefined) {
pEl.style.color = d.net_profit >= 0 ? 'var(--green)' : 'var(--red)';
}
}
// ── Findings ───────────────────────────────────────────────────────────────
function addFinding(f) {
findingsCount++;
const feed = document.getElementById('findings-feed');
const empty = feed.querySelector('.finding-empty');
if (empty) empty.remove();
const sevClass = { high: 'sev-high', medium: 'sev-medium', low: 'sev-low' }[f.severity] || 'sev-low';
const row = document.createElement('div');
row.className = 'finding-row';
row.innerHTML = `
<span class="finding-sev ${sevClass}">${f.severity}</span>
<span class="finding-text">${escHtml(f.description)}</span>
<span class="finding-conf">${(f.confidence * 100).toFixed(0)}%</span>
`;
feed.insertBefore(row, feed.firstChild);
// Keep max 20 findings visible
while (feed.children.length > 20) feed.removeChild(feed.lastChild);
document.getElementById('findings-count').textContent = `${findingsCount} findings`;
}
// ── Hypotheses / Params ────────────────────────────────────────────────────
function showHypotheses(items) {
if (!items || !items.length) return;
const h = items[0]; // show first (highest priority)
document.getElementById('hyp-desc').textContent = h.desc || '';
document.getElementById('hyp-badge').textContent = `${items.length} hypothesis(es)`;
const tbody = document.getElementById('params-tbody');
function renderPhase1Table(results) {
const tbody = el('phase1-tbody');
tbody.innerHTML = '';
Object.entries(h.delta || {}).forEach(([param, newVal]) => {
const oldVal = currentParams[param];
results.forEach((r, i) => {
const tr = document.createElement('tr');
if (i === 0) tr.className = 'rank-1';
const profitClass = r.net_profit >= 0 ? 'profit-pos' : 'profit-neg';
const badge = r.passing
? `<span class="badge-pass">✓ pass</span>`
: `<span class="badge-fail">fail</span>`;
tr.innerHTML = `
<td class="param-changed">${escHtml(param)}</td>
<td class="param-old">${oldVal !== undefined ? oldVal : '—'}</td>
<td class="param-new">${newVal}</td>
<td>#${r.rank || i+1} ${i === 0 ? '🥇' : ''}</td>
<td class="${profitClass}">$${(r.net_profit||0).toFixed(0)}</td>
<td>${(r.calmar||0).toFixed(3)}</td>
<td>${(r.profit_factor||0).toFixed(2)}</td>
<td>${(r.win_rate||0).toFixed(1)}%</td>
<td>${(r.max_drawdown||0).toFixed(1)}%</td>
<td>${r.total_trades||0}</td>
<td>${(r.score||0).toFixed(4)}</td>
`;
tbody.appendChild(tr);
});
}
// ── Log ────────────────────────────────────────────────────────────────────
function renderVerdict(d) {
const banner = el('verdict-banner');
const icons = { RECOMMENDED: '✅', RISKY: '⚠️', NOT_RELIABLE: '❌' };
const labels = {
RECOMMENDED: 'Recommended',
RISKY: 'Risky',
NOT_RELIABLE: 'Not Reliable',
};
const subs = {
RECOMMENDED: 'Consistent returns with controlled drawdown. Ready to deploy.',
RISKY: 'Profitable in-sample but shows signs of fragility or OOS degradation. Use with caution.',
NOT_RELIABLE: 'Results are inconsistent or not profitable enough. Consider different settings.',
};
function addLog(level, msg) {
const feed = document.getElementById('log-feed');
const line = document.createElement('div');
line.className = `log-line log-${level}`;
const ts = new Date().toLocaleTimeString('en-GB', { hour12: false });
line.textContent = `[${ts}] ${msg}`;
feed.insertBefore(line, feed.firstChild);
while (feed.children.length > 200) feed.removeChild(feed.lastChild);
const profitClass = d.net_profit >= 0 ? 'profit-pos' : 'profit-neg';
const oos = d.oos_profit != null
? `<div class="verdict-metric"><div class="verdict-metric-val ${d.oos_profit>=0?'profit-pos':'profit-neg'}" >$${d.oos_profit.toFixed(0)}</div><div class="verdict-metric-lbl">OOS Profit</div></div>`
: '';
banner.className = `verdict-banner ${d.verdict}`;
banner.innerHTML = `
<div class="verdict-header verdict-${d.verdict}">
<div class="verdict-icon">${icons[d.verdict] || '?'}</div>
<div>
<div class="verdict-title">${labels[d.verdict] || d.verdict}</div>
<div class="verdict-sub">${subs[d.verdict] || ''}</div>
</div>
</div>
<div class="verdict-metrics">
<div class="verdict-metric">
<div class="verdict-metric-val ${profitClass}">$${(d.net_profit||0).toFixed(0)}</div>
<div class="verdict-metric-lbl">Net Profit</div>
</div>
<div class="verdict-metric">
<div class="verdict-metric-val">${(d.calmar||0).toFixed(2)}</div>
<div class="verdict-metric-lbl">Calmar</div>
</div>
<div class="verdict-metric">
<div class="verdict-metric-val">${(d.win_rate||0).toFixed(1)}%</div>
<div class="verdict-metric-lbl">Win Rate</div>
</div>
<div class="verdict-metric">
<div class="verdict-metric-val">${(d.max_drawdown||0).toFixed(1)}%</div>
<div class="verdict-metric-lbl">Max DD</div>
</div>
${oos}
</div>
<div class="verdict-actions">
${d.set_file_url ? `<a href="${d.set_file_url}" class="btn-download" download>⬇ Download .set File</a>` : ''}
<a href="/reports" class="btn-new-run">📁 View Full Report</a>
<a href="/setup" class="btn-new-run">⚡ New Optimization</a>
</div>
`;
banner.style.display = '';
}
// ── Candidates ─────────────────────────────────────────────────────────────
function addCandidate(d) {
candidatesCount++;
const list = document.getElementById('candidates-list');
const list = el('candidates-list');
const empty = list.querySelector('.cand-empty');
if (empty) empty.remove();
const deltaClass = d.delta >= 0 ? 'delta-up' : 'delta-dn';
const deltaSign = d.delta >= 0 ? '+' : '';
candCount++;
el('cand-count').textContent = `${candCount} config${candCount !== 1 ? 's' : ''}`;
const chip = document.createElement('div');
chip.className = 'candidate-chip';
chip.innerHTML = `
<div class="cand-score">${d.score.toFixed(4)}</div>
<div class="cand-delta ${deltaClass}">${deltaSign}${d.delta.toFixed(4)} vs prev</div>
<div class="cand-id">ID: ${d.candidate_id}</div>
// Keep only top 10
if (list.children.length >= 10) {
list.removeChild(list.lastChild);
}
const card = document.createElement('div');
card.className = 'cand-card';
const profitClass = (d.net_profit||0) >= 0 ? 'profit-pos' : 'profit-neg';
card.innerHTML = `
<div class="cand-header">
<span class="cand-rank">${d.phase?.toUpperCase() || 'RUN'} · Score ${(d.score||0).toFixed(4)}</span>
<span class="cand-run-id">${d.run_id}</span>
</div>
<div class="cand-score-row">
<div class="cand-metric">
<div class="cand-metric-lbl">Profit</div>
<div class="cand-metric-val ${profitClass}">$${(d.net_profit||0).toFixed(0)}</div>
</div>
<div class="cand-metric">
<div class="cand-metric-lbl">Calmar</div>
<div class="cand-metric-val">${(d.calmar||0).toFixed(3)}</div>
</div>
<div class="cand-metric">
<div class="cand-metric-lbl">Win%</div>
<div class="cand-metric-val">${(d.win_rate||0).toFixed(1)}%</div>
</div>
<div class="cand-metric">
<div class="cand-metric-lbl">DD%</div>
<div class="cand-metric-val">${(d.max_drawdown||0).toFixed(1)}%</div>
</div>
<div class="cand-metric">
<div class="cand-metric-lbl">Trades</div>
<div class="cand-metric-val">${d.total_trades||0}</div>
</div>
</div>
`;
list.insertBefore(chip, list.firstChild);
document.getElementById('cand-count').textContent = `${candidatesCount} candidates`;
list.insertBefore(card, list.firstChild);
}
// ── Chart update ───────────────────────────────────────────────────────────
function pushChartPoint(d) {
const labels = scoreChart.data.labels;
// Label: 'Baseline' for first point, 'Iter N · run_id' for hypothesis runs
const lbl = d.run_id
? (d.run_id.startsWith('baseline') ? 'Baseline' : `It${d.iteration}·${d.run_id.split('_').pop()}`)
: `Iter ${d.iteration}`;
labels.push(lbl);
scoreChart.data.datasets[0].data.push(d.score);
// Normalize calmar: clamp -0.5..2.0 → 0..1 for display
const calmarNorm = Math.max(0, Math.min(1, (d.calmar + 0.5) / 2.5));
scoreChart.data.datasets[1].data.push(calmarNorm);
if (labels.length > 50) {
labels.shift();
scoreChart.data.datasets.forEach(ds => ds.data.shift());
}
scoreChart.update('none');
document.getElementById('chart-badge').textContent = `Score: ${d.score.toFixed(4)}`;
function addLog(level, msg) {
const feed = el('log-feed');
const div = document.createElement('div');
div.className = `log-line log-${level}`;
const ts = new Date().toLocaleTimeString('en-GB', { hour12: false });
div.textContent = `[${ts}] ${msg}`;
feed.appendChild(div);
feed.scrollTop = feed.scrollHeight;
// Max 200 lines
while (feed.children.length > 200) feed.removeChild(feed.firstChild);
}
// ── Utility ────────────────────────────────────────────────────────────────
// ── Phase indicator helpers ───────────────────────────────────────────────────
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
function setPhaseActive(phase) {
const map = { setup: 'phase-setup', phase1: 'phase-phase1', phase2: 'phase-phase2', phase3: 'phase-phase3' };
const id = map[phase];
if (!id) return;
document.querySelectorAll('.phase-step').forEach(s => s.classList.remove('active'));
const step = el(id);
if (step) step.classList.add('active');
}
// ── Socket events ──────────────────────────────────────────────────────────
function markPhaseDone(phase) {
const map = { phase1: 'phase-phase1', phase2: 'phase-phase2', phase3: 'phase-phase3', done: 'phase-done' };
const step = el(map[phase]);
if (step) { step.classList.remove('active'); step.classList.add('done'); }
}
socket.on('connect', () => addLog('info', 'Connected to optimizer.'));
// ── Status helpers ────────────────────────────────────────────────────────────
socket.on('status_sync', d => {
document.getElementById('hdr-iter').textContent = d.iteration;
if (d.state === 'running') { setRunning(true); setDot('running', 'Running...'); }
if (d.best_score) document.getElementById('hdr-score').textContent = d.best_score.toFixed(4);
});
function setStatus(state, phase) {
const dot = el('state-dot');
const label = el('state-label');
socket.on('status_change', d => {
if (d.state === 'running') setDot('running', 'Running...');
if (d.state === 'paused') setDot('paused', 'Paused');
if (d.state === 'idle') setDot('idle', 'Ready');
if (d.phase) setPhase(d.phase);
});
const states = {
running: { cls: 'dot-running', label: phase ? `${phase.replace('phase','Phase ')}` : 'Running' },
stopping: { cls: 'dot-warn', label: 'Stopping...' },
idle: { cls: 'dot-idle', label: 'Ready' },
done: { cls: 'dot-done', label: 'Complete' },
};
const s = states[state] || states.idle;
dot.className = `dot ${s.cls}`;
label.textContent = s.label;
}
socket.on('iteration_start', d => {
document.getElementById('hdr-iter').textContent = d.iteration;
addLog('info', `━━ Starting Iteration ${d.iteration} ━━`);
setPhase('analyze');
});
function updateRunCount(count, d) {
if (count !== null) runCount = count;
else if (d) runCount++;
el('hdr-iter').textContent = totalRuns > 0 ? `${runCount}/${totalRuns}` : runCount;
}
socket.on('run_started', d => {
setPhase(d.phase === 'baseline' ? 'baseline' : 'explore');
document.getElementById('current-run-id').textContent = d.run_id;
addLog('info', `▶ MT5 started: ${d.run_id} [${d.period}]`);
currentParams = d.params || {};
});
socket.on('run_complete', d => {
updateMetrics(d);
const sign = d.score > 0 ? '\u2713' : '\u2022';
addLog(d.score > 0.3 ? 'success' : 'info',
`${sign} ${d.run_id}: Score=${d.score} | Calmar=${d.calmar} | PF=${d.profit_factor} | DD=${d.drawdown_pct}%`
);
// Push baseline run to chart immediately (hypothesis runs pushed via score_update)
if (d.run_id && d.run_id.startsWith('baseline')) {
pushChartPoint({ iteration: 0, run_id: d.run_id, score: d.score, calmar: d.calmar });
function updatePhaseLabel(phase, runNum, total) {
const labels = { phase1: 'Phase 1: Broad Search', phase2: 'Phase 2: Refinement', phase3: 'Phase 3: Validation', phase3_oos: 'Phase 3: OOS Test', phase3_sens: 'Phase 3: Sensitivity' };
el('progress-phase').textContent = labels[phase] || phase;
if (runNum != null && total != null) {
el('progress-count').textContent = `${runNum} / ${total}`;
el('progress-bar-fill').style.width = `${Math.round(runNum / total * 100)}%`;
}
});
}
socket.on('run_failed', d => {
addLog('error', `✗ Run failed: ${d.run_id}${d.error}`);
});
// ── Elapsed timer ─────────────────────────────────────────────────────────────
socket.on('finding', f => addFinding(f));
function startElapsedTimer() {
if (elapsedTimer) clearInterval(elapsedTimer);
if (!startTs) startTs = Date.now();
elapsedTimer = setInterval(() => {
const secs = Math.floor((Date.now() - startTs) / 1000);
const mm = Math.floor(secs / 60).toString().padStart(2, '0');
const ss = (secs % 60).toString().padStart(2, '0');
el('hdr-elapsed').textContent = `${mm}:${ss}`;
}, 1000);
}
socket.on('hypotheses', d => {
setPhase('explore');
showHypotheses(d.items);
addLog('info', `💡 ${d.items.length} hypothesis(es) proposed`);
});
function stopElapsedTimer() {
if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; }
}
socket.on('hypothesis_testing', d => {
addLog('info', `🧪 Testing H${d.idx}: ${d.desc.substring(0, 60)}...`);
});
// ── Utility ───────────────────────────────────────────────────────────────────
socket.on('score_update', d => {
pushChartPoint(d);
// Update best score header only when a candidate is actually promoted
if (d.promoted) {
document.getElementById('hdr-score').textContent = d.score.toFixed(4);
function el(id) { return document.getElementById(id); }
// Init: mark Setup phase as active
setPhaseActive('setup');
// Check if run already in progress
fetch('/api/status').then(r => r.json()).then(s => {
if (s.state === 'running') {
setStatus(s.state, s.phase);
startTs = Date.now() - (s.elapsed_s || 0) * 1000;
startElapsedTimer();
totalRuns = s.total_runs || 0;
updateRunCount(s.run_count || 0, null);
document.getElementById('progress-wrap').style.display = '';
document.getElementById('btn-stop').classList.remove('hidden');
setPhaseActive(s.phase);
if (s.ea_name) {
document.getElementById('session-label').textContent =
`${s.ea_name} · ${s.symbol} · ${s.timeframe}`;
}
}
});
socket.on('candidate_promoted', d => {
addCandidate(d);
addLog('success', `🏆 Candidate promoted! Score: ${d.score}`);
});
socket.on('optimization_complete', d => {
clearInterval(timerInterval);
setRunning(false);
setDot('idle', 'Complete');
addLog('success', `🏁 Done! ${d.iterations} iterations, ${d.candidates} candidate(s), best score: ${d.best_score}`);
addLog('info', '📁 Reports saved to MT5_Optimizer\\Reports\\');
});
socket.on('error', d => {
addLog('error', `Error: ${d.msg}`);
setDot('error', 'Error');
});
socket.on('log', d => addLog(d.level, d.msg));
// ── Init: restore chart from history ──────────────────────────────────────
fetch('/api/history').then(r => r.json()).then(history => {
history.forEach(d => pushChartPoint(d));
});
fetch('/api/status').then(r => r.json()).then(d => {
document.getElementById('hdr-iter').textContent = d.iteration;
if (d.best_score) document.getElementById('hdr-score').textContent = d.best_score.toFixed(4);
if (d.state === 'running') {
setRunning(true);
setDot('running', 'Running...');
startTime = Date.now() - d.elapsed_s * 1000;
timerInterval = setInterval(updateTimer, 1000);
}
});
}).catch(() => {});