Activate slot
Choose a strategy to deploy as paper trading.
Paper-first: live trading requires promote_to_live.sh with wallet credentials.
Cancel
Deploy as Paper
Overview
Equity, exposure, and sleeve activity across the operating framework
HALTED
Refresh
/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
function viewRisk() {
const rules = state.haltRules?.rules || [];
const r = state.risk || {};
// Drawdown panel: prefer portfolio_state's equity (live wallet) over the
// RiskManager-side reflection — the latter is empty until the engine has
// run a full reconcile cycle, while portfolio_state is always fresh.
const port = state.portfolio || {};
const currentEq = port.equity_usd || r.current_equity || 0;
const peakEq = port.peak_equity || r.peak_equity || currentEq;
const ddPct = port.drawdown_pct != null ? port.drawdown_pct : (r.drawdown_pct || 0);
return `
Kill Switch
${r.kill_switch_active ? 'HALTED' : 'ACTIVE'}
${r.kill_switch_active ? `
Reason: ${escapeHtml(r.kill_switch_reason || 'unknown')}
Until: ${escapeHtml(r.kill_switch_until || '—')}
Reset Kill Switch
` : '
No halt active. Trading engine processing signals.
'}
Drawdown
Current
${FMT_USD(currentEq)}
DD
${FMT_PCT(-Math.abs(ddPct))}
Safe
Scale-down @10%
Halt @25%
Audit-Validated Halt Rules
${state.haltRules?.summary?.breached || 0} of ${state.haltRules?.summary?.total || 0} breached
Rule
Description
Current
Threshold
Samples
Status
${rules.map(r => `
${r.name}
${r.description}
${r.current == null ? '—' : (r.unit === 'pct' ? FMT_PCT(r.current) : r.current)}
${r.unit === 'pct' ? r.threshold + '%' : r.threshold + (r.unit !== 'months' ? '' : ' months')}
${r.samples == null ? '—' : r.samples}
${r.breached ? badge('BREACHED', 'danger') : badge('OK', 'good')}
`).join('')}
`;
}
// Strategy tab — renders EXCLUSIVELY from /api/strategy/info (2026-06-10).
// That endpoint merges infra/configs/experiment_registry.yaml (label,
// description, mode) with the slot's deployed exp_{slot}_strategy_params.yaml
// (sleeves, weights, sizing, exits, router). No strategy facts live in this
// file: updating a slot's yaml or the registry updates the UI with no code
// change. Only runtime liveness (heartbeats from /api/strategy/sleeves) and
// the regime chip are merged in — state, not strategy.
function viewStrategy() {
const si = state.strategyInfo;
if (!si) {
return `
Strategy info unavailable — /api/strategy/info did not respond.
The deployed config lives in infra/configs/exp_<slot>_strategy_params.yaml .
`;
}
const meta = si.meta || {};
const sleeves = si.sleeves || {};
const cfg = si.config || {};
const hbList = state.sleeves?.sleeves || [];
// Runtime heartbeat enrichment: config sleeve keys (v23, liq_cascade,
// xsect_mr, …) matched by prefix to service ids (v23, liq, xsect).
const hbFor = (name) => hbList.find(h => name === h.id || name.startsWith(h.id));
const fv = (v) => {
if (v === true) return 'on';
if (v === false) return 'off';
if (typeof v === 'number') return Number.isInteger(v) ? v.toLocaleString() : String(+v.toFixed(4));
return v == null ? '—' : String(v);
};
const fvHtml = (v) =>
v === true ? 'on '
: v === false ? 'off '
: `${fv(v)} `;
const paramChips = (obj) => Object.entries(obj || {})
.map(([k, v]) => `${k} ${fvHtml(v)} `)
.join(' · ');
const SECTION_LABELS = { sizing: 'Sizing', exits: 'Exits', risk: 'Risk', cusum: 'CUSUM', adx: 'ADX', allw_router: 'Regime Router' };
const sectionCard = (key, obj) => {
const rows = Object.entries(obj || {}).map(([k, v]) => {
if (v && typeof v === 'object' && !Array.isArray(v)) {
return `${k}
${paramChips(v)} `;
}
return `${k}
${fv(v)} `;
}).join('');
return `
${SECTION_LABELS[key] || key}
`;
};
const sleeveRows = Object.entries(sleeves)
.sort((a, b) => (b[1].enabled - a[1].enabled) || ((b[1].weight ?? 0) - (a[1].weight ?? 0)))
.map(([name, s]) => {
const hb = hbFor(name);
const live = hb
? badge(hb.alive ? 'LIVE' : 'OFFLINE', hb.alive ? 'good' : 'danger')
: '— ';
const polls = hb && (hb.poll_count != null || hb.signal_count != null)
? `${hb.poll_count ?? '—'} / ${hb.signal_count ?? '—'}` : '—';
return `
${name}
${badge(s.enabled ? 'ENABLED' : 'OFF', s.enabled ? 'good' : 'muted')}
${s.weight != null ? FMT_PCT(s.weight * 100) : '—'}
${paramChips(s.params) || '— '}
${polls}
${live}
`;
}).join('');
const modeTone = meta.mode === 'live' ? 'good' : 'warn';
const updated = si.config_updated ? new Date(si.config_updated).toLocaleString() : '—';
return `
${meta.label || `Slot ${(si.slot || '').toUpperCase()}`}
slot ${(si.slot || '').toUpperCase()}
${meta.mode ? badge(meta.mode.toUpperCase(), modeTone) : ''}
${meta.enabled === false ? badge('DISABLED', 'danger') : ''}
${meta.description ? `
${meta.description}
` : ''}
Exchange
${(meta.exchange || '—').toUpperCase()}
Allocated capital
${FMT_USD(meta.capital_usd)}
Config source
${si.config_file || '—'}
Sleeves
Regime: ${state.sleeves?.current_regime?.toUpperCase() || '—'}
Sleeve State
Weight
Parameters
Polls / Signals
Service
${sleeveRows || 'No sleeves configured for this slot. '}
Deployed Parameters
${si.config_file || ''}
${Object.keys(cfg).length
? `
${Object.entries(cfg).map(([k, v]) => sectionCard(k, v)).join('')}
`
: '
No parameter sections found in the slot config.
'}
`;
}
// ─── Analysis view ──────────────────────────────────────────────────────
// Daily PnL bar chart + by-sleeve & by-symbol contribution tables +
// returns distribution histogram. Backed by /api/analytics/* endpoints
// (30-60s Redis cache).
// Fallback universe for the Per-Symbol Chart picker: a hand-kept copy of
// config/pairs.yaml minus BTCUSDT (which is carried for correlation / HMM
// regime context, not signal generation — see TradingEngine.trading_symbols).
// It is only a FALLBACK because it is not authoritative: the wide-carry sleeves
// pick candidates from the whole venue funding trail, so a slot can hold a
// position on a symbol that was never in pairs.yaml. There is no endpoint that
// returns a slot's universe; until there is, union the default with whatever
// the slot is actually trading so every real position is chartable.
const SYMCHART_DEFAULT_SYMBOLS = ['DOGEUSDT','ETHUSDT','AXSUSDT','RSRUSDT','ENJUSDT','AAVEUSDT','CRVUSDT','ADAUSDT','ALGOUSDT','XRPUSDT','ETCUSDT','FILUSDT','LTCUSDT'];
// NOTE: the returned list is no longer literals-only — it now carries
// server-supplied symbols from state.positions / state.trades. EVERY sink
// that consumes it must therefore encode: escapeHtml() in the
// template below, encodeURIComponent() in loadSymbolChart()'s query string.
function symchartSymbols() {
const seen = new Set(SYMCHART_DEFAULT_SYMBOLS);
const extra = [];
const add = (s) => {
if (!s || seen.has(s)) return;
seen.add(s); extra.push(s);
};
(state.positions || []).forEach(p => add(p.internal_symbol || p.symbol));
(state.trades || []).forEach(t => add(t.symbol));
// Keep the current selection selectable even if it has since gone quiet.
add(state.symchartSymbol);
return SYMCHART_DEFAULT_SYMBOLS.concat(extra.sort());
}
function viewAnalysisPlaceholder() {
const days = state.analysisDays || 30;
return `
Live vs Backtest · ${days}d
Bootstrap 10k-sample Sharpe CI · backtest reference: v22_FINAL
Daily Realized PnL · ${days}d
${[7, 14, 30, 60, 90].map(d => `
${d}d
`).join('')}
Exchange Fee Impact
across all closed trades currently loaded
Daily Returns Distribution · 90d
Per-Symbol Chart
${symchartSymbols().map(s => `
${escapeHtml(s)}
`).join('')}
${['1h','4h','1d'].map(tf => `
${tf}
`).join('')}
Loading…
▲ long entry
▼ short entry
✕ close
signal (fired)
signal (filtered)
`;
}
async function setSymchartTf(tf) {
state.symchartTf = tf;
await loadSymbolChart();
}
async function loadSymbolChart() {
const symSel = document.getElementById('symchart-symbol');
if (symSel) state.symchartSymbol = symSel.value;
const sym = state.symchartSymbol || 'DOGEUSDT';
const tf = state.symchartTf || '1h';
// Hours: scale by TF so the chart shows ~ 200 candles
const hoursByTf = { '5m': 24, '1h': 168, '4h': 168 * 4, '1d': 24 * 90 };
const hours = hoursByTf[tf] || 168;
// sym comes from symchartSymbols(), which is fed by state.positions /
// state.trades — encode it rather than splicing a raw venue string into
// the query.
const data = await api(`/api/analytics/symbol-chart?symbol=${encodeURIComponent(sym)}&timeframe=${tf}&hours=${hours}`).catch(() => null);
const summaryEl = document.getElementById('symchart-summary');
if (!data) {
if (summaryEl) summaryEl.textContent = 'No data available for this symbol/timeframe.';
return;
}
if (summaryEl) {
summaryEl.innerHTML = `
${data.candles?.length || 0} candles ·
${data.trades?.length || 0} fills ·
${data.signals?.length || 0} signals
· Re-rendered every 15s
`;
}
drawSymbolChart(data);
}
function drawSymbolChart(data) {
const ctx = document.getElementById('symchart-chart');
if (!ctx) return;
if (state.charts.symchart) {
try { state.charts.symchart.destroy(); } catch (_) {}
}
const candles = data.candles || [];
if (candles.length === 0) {
return;
}
const labels = candles.map(c => c.ts);
const closes = candles.map(c => c.close);
// Build scatter overlays. Chart.js Time axis would be cleaner but
// requires the adapter; here we map event ts -> nearest candle index.
const tsToIdx = (ts) => {
if (!ts) return -1;
// Binary search candles for ts
const t = new Date(ts).getTime();
let lo = 0, hi = candles.length - 1, best = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const mt = new Date(candles[mid].ts).getTime();
if (mt <= t) { best = mid; lo = mid + 1; } else { hi = mid - 1; }
}
return best;
};
// All markers (entries, closes, signals) snap their Y value to the
// candle's close price at the marker's timestamp — so every marker
// visually sits ON the price curve rather than floating below or
// above it. The actual fill price (which can differ from the 1h
// candle close due to intra-bar moves, slippage, or fills mapped to
// an earlier candle than the timestamp suggests) is preserved as
// `price` in the marker payload so the tooltip can still surface it.
const tradeOpens = [];
const tradeCloses = [];
for (const t of (data.trades || [])) {
const idx = tsToIdx(t.ts);
if (idx < 0) continue;
const point = { x: candles[idx].ts, y: candles[idx].close, price: t.price };
if (t.kind === 'open') {
tradeOpens.push({ ...point, side: t.side, sleeve: t.sleeve });
} else {
tradeCloses.push({ ...point, reason: t.exit_reason, pnl: t.pnl_usd });
}
}
const signalsFired = [];
const signalsFiltered = [];
for (const s of (data.signals || [])) {
const idx = tsToIdx(s.ts);
if (idx < 0) continue;
const point = { x: candles[idx].ts, y: candles[idx].close };
if (s.status === 'fired') signalsFired.push({ ...point, sleeve: s.sleeve, dir: s.direction });
else signalsFiltered.push({ ...point, sleeve: s.sleeve, reason: s.reason });
}
state.charts.symchart = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{
label: 'Close',
data: closes,
borderColor: '#60a5fa',
backgroundColor: 'rgba(96,165,250,0.08)',
borderWidth: 1.2,
tension: 0.15,
pointRadius: 0,
fill: true,
order: 10,
},
{
// Long entries — upward triangle, green.
type: 'scatter',
label: '▲ Long entry',
data: tradeOpens.filter(p => (p.side || '').toLowerCase() === 'buy'),
backgroundColor: '#34d399',
borderColor: '#065f46',
pointStyle: 'triangle',
pointRotation: 0,
pointRadius: 8,
pointHoverRadius: 11,
borderWidth: 1.5,
order: 1,
},
{
// Short entries — downward triangle, red.
type: 'scatter',
label: '▼ Short entry',
data: tradeOpens.filter(p => (p.side || '').toLowerCase() === 'sell'),
backgroundColor: '#f87171',
borderColor: '#7f1d1d',
pointStyle: 'triangle',
pointRotation: 180,
pointRadius: 8,
pointHoverRadius: 11,
borderWidth: 1.5,
order: 1,
},
{
// Close marker — crosshair X, neutral.
type: 'scatter',
label: '✕ Close',
data: tradeCloses,
backgroundColor: '#cbd5e1',
borderColor: '#475569',
pointStyle: 'crossRot',
pointRadius: 7,
pointHoverRadius: 10,
borderWidth: 2,
order: 2,
},
{
type: 'scatter',
label: 'Signal (fired)',
data: signalsFired,
backgroundColor: '#fbbf24',
borderColor: '#d97706',
pointRadius: 3,
pointHoverRadius: 6,
order: 3,
},
{
type: 'scatter',
label: 'Signal (filtered)',
data: signalsFiltered,
backgroundColor: '#475569',
borderColor: '#334155',
pointRadius: 3,
pointHoverRadius: 6,
order: 4,
},
],
},
options: {
maintainAspectRatio: false,
interaction: { mode: 'nearest', intersect: true },
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => {
const ds = ctx.dataset.label;
const v = ctx.parsed.y;
if (ds === 'Close') return `Close $${v.toFixed(4)}`;
const raw = ctx.raw || {};
// For trade markers, show the ACTUAL fill price (raw.price)
// rather than the candle close where the marker is plotted.
// raw.price may differ from raw.y because we snap markers
// to candles[idx].close so they visually sit on the curve.
const fill = raw.price != null ? raw.price : v;
if (ds.endsWith('Long entry') || ds.endsWith('Short entry')) {
return `${ds} @ $${fill.toFixed(4)} (${raw.sleeve || '?'})`;
}
if (ds.endsWith('Close')) return `Close @ $${fill.toFixed(4)} (${raw.reason || '?'}, pnl ${raw.pnl != null ? '$' + raw.pnl.toFixed(2) : '—'})`;
if (ds.startsWith('Signal')) {
return `${ds} ${raw.sleeve || ''} ${raw.dir ? (raw.dir > 0 ? '↑' : '↓') : ''} ${raw.reason || ''}`.trim();
}
return `${ds}: ${v}`;
},
},
},
},
scales: {
x: {
grid: { display: false },
ticks: { color: '#64748b', maxRotation: 0, autoSkip: true, maxTicksLimit: 10, callback: function (v) { const lbl = this.getLabelForValue(v); return lbl ? lbl.substring(5, 16).replace('T', ' ') : ''; } },
},
y: {
grid: { color: 'rgba(100,116,139,0.15)' },
ticks: { color: '#64748b' },
},
},
},
});
}
async function setAnalysisDays(d) {
state.analysisDays = d;
// Re-render placeholder (tabs reflect new state) then reload
document.getElementById('page-content').innerHTML = viewAnalysisPlaceholder();
afterRender();
await loadAnalysis();
}
// ─── Live vs Backtest rendering ───────────────────────────────────────
// Renders the headline "did we measure edge yet" card. Consumes the
// /api/analytics/live-vs-backtest response shape (see scripts/
// live_vs_backtest_report.py:build_report).
function renderLiveVsBacktest(data) {
const root = document.getElementById('lvb-content');
if (!root) return;
if (!data || data.error) {
root.innerHTML = `No comparison available: ${escapeHtml(data?.error || 'no data')}
`;
return;
}
const realized = data.realized || {};
const backtest = data.backtest || {};
const edge = data.edge || {};
const confirmed = !!edge.confirmed;
const lo = Number(edge.sharpe_ci_lower || 0);
const hi = Number(edge.sharpe_ci_upper || 0);
const te = Number(edge.tracking_error_sharpe || 0);
const severity = edge.divergence_severity || 'unknown';
const sevColor = severity === 'within_band' ? '#34d399' : (severity === 'moderate_drift' ? '#fbbf24' : '#f87171');
// Headline traffic light
const badge = confirmed
? `
EDGE CONFIRMED
`
: `
measuring edge…
`;
// Metric row helper
const row = (label, live, bt, fmt) => {
const fmtV = (v) => {
if (v == null) return '—';
if (fmt === 'pct') return (Number(v) >= 0 ? '+' : '') + Number(v).toFixed(2) + '%';
if (fmt === 'ratio') return Number(v).toFixed(3);
return Number(v).toLocaleString(undefined, {maximumFractionDigits: 2, minimumFractionDigits: 2});
};
const dlt = (live != null && bt != null) ? Number(live) - Number(bt) : null;
const dltStr = dlt == null ? '—' : (fmt === 'pct' ? (dlt >= 0 ? '+' : '') + dlt.toFixed(2) + '%' : dlt.toFixed(3));
const dltColor = dlt == null ? '#94a3b8' : (dlt >= 0 ? '#34d399' : '#f87171');
return `
${label}
${fmtV(live)}
${fmtV(bt)}
${dltStr}
`;
};
root.innerHTML = `
${badge}
Sharpe 95% CI: [${lo.toFixed(2)}, ${hi.toFixed(2)}]
· divergence: ${severity.replace(/_/g, ' ')}
${te !== 0 ? ` (${te > 0 ? '+' : ''}${te.toFixed(2)} CI-widths)` : ''}
Metric
Live (realized)
Backtest
Δ
${row('Trades', realized.n_trades, backtest.n_trades, 'num')}
${row('Return %', realized.return_pct, backtest.return_pct, 'pct')}
${row('Sharpe (ann)', realized.sharpe_ann, backtest.sharpe_ann, 'ratio')}
${row('Sortino (ann)', realized.sortino_ann, backtest.sortino_ann, 'ratio')}
${row('Max DD %', realized.max_dd_pct, backtest.max_dd_pct, 'pct')}
${row('Win rate', realized.win_rate != null ? realized.win_rate * 100 : null, null, 'pct')}
${row('Profit factor', realized.profit_factor, null, 'ratio')}
Window: ${data.window?.start?.substring(0, 10) || '?'} → ${data.window?.end?.substring(0, 10) || '?'}
· ${realized.n_equity_snapshots || 0} equity samples
· ${(realized.n_trades || 0)} closed trades
· backtest src: ${escapeHtml((backtest.source || '').split('/').pop() || '?')}
`;
}
// Aggregates fee-impact metrics over the closed trades currently in
// scope (state.trades, scoped to the N-day window via timestamp). Then
// renders a 4-tile summary plus a per-sleeve breakdown row.
//
// Reads only the rows the FIFO walker tagged with status='closed' —
// stream-event fallback rows are excluded (same convention the equity
// chart uses to avoid double-counting; see the chart's marker loop).
function renderFeeImpact(allTrades, days, truncated) {
const root = document.getElementById('fees-impact-body');
if (!root) return;
const cutoffMs = Date.now() - (Number(days) || 30) * 24 * 3600 * 1000;
const scoped = (allTrades || []).filter(t =>
t.status === 'closed' &&
(t.ts ? new Date(t.ts).getTime() >= cutoffMs : true)
);
if (!scoped.length) {
root.innerHTML = 'No closed trades in this window.
';
return;
}
let gross = 0, fees = 0, entryFees = 0, exitFees = 0;
const bySleeve = new Map();
// Per-exchange aggregation. Tracks total notional too so we can show
// the EFFECTIVE realised bps per exchange — useful for spotting
// maker-rebate situations or miscoded fees that don't match the
// configured taker rate.
const byExchange = new Map();
for (const t of scoped) {
const p = Number(t.pnl_usd) || 0;
const f = Number(t.fee_usd) || 0;
const ef = Number(t.entry_fee_usd) || 0;
const xf = Number(t.exit_fee_usd) || 0;
gross += p;
fees += f;
entryFees += ef;
exitFees += xf;
const slv = t.sleeve || 'unknown';
const sb = bySleeve.get(slv) || { sleeve: slv, gross: 0, fees: 0, trades: 0 };
sb.gross += p; sb.fees += f; sb.trades += 1;
bySleeve.set(slv, sb);
const exch = (t.exchange || 'unknown').toLowerCase();
// Notional approximation: |qty| × entry_price. The FIFO row already
// carries `quantity` and `entry_price`. We need this for both the
// entry and exit legs combined to compute realised round-trip bps.
const qty = Math.abs(Number(t.quantity) || 0);
const entryNotional = qty * (Number(t.entry_price) || 0);
const exitNotional = qty * (Number(t.exit_price) || 0);
const roundTripNotional = entryNotional + exitNotional;
const eb = byExchange.get(exch) || {
exchange: exch, gross: 0, fees: 0, trades: 0, notional: 0, taker_bps_cfg: null,
};
eb.gross += p; eb.fees += f; eb.trades += 1; eb.notional += roundTripNotional;
// Carry the configured taker_bps so we can compare against the
// realised effective bps.
if (eb.taker_bps_cfg == null && t.taker_bps != null) eb.taker_bps_cfg = t.taker_bps;
byExchange.set(exch, eb);
}
const net = gross - fees;
const avgFee = fees / scoped.length;
// Drag = fees as % of |gross|. When gross is ~0 the % blows up; cap
// at a sentinel "—" and let the user read the raw $ numbers instead.
const dragPct = Math.abs(gross) > 1e-6 ? (fees / Math.abs(gross)) * 100 : null;
const c = (x) => x >= 0 ? '#34d399' : '#f87171';
const tile = (label, value, sub, color) => `
${label}
${value}
${sub ? `
${sub}
` : ''}
`;
const sleeveRows = [...bySleeve.values()]
.sort((a, b) => b.fees - a.fees)
.map(b => `
${b.sleeve}
${FMT_USD(b.gross)}
${FMT_USD(b.fees)}
${FMT_USD(b.gross - b.fees)}
${b.trades}
`).join('');
// By-Exchange rows. Effective bps = fees / round_trip_notional × 10000.
// We compare against the configured taker_bps so an unexpected delta
// (e.g. realised 18bps on Kraken's 24bps because some fills were
// makers) is visible at a glance.
const exchRows = [...byExchange.values()]
.sort((a, b) => b.fees - a.fees)
.map(b => {
const effBps = b.notional > 0 ? (b.fees / b.notional) * 10000 : null;
const cfgBps = b.taker_bps_cfg;
const bpsCell = effBps != null
? `${effBps.toFixed(1)} bps ${
cfgBps != null
? `(cfg ${cfgBps.toFixed(1)}) `
: ''
}`
: '—';
const exName = b.exchange.toUpperCase();
return `
${badge(exName, b.exchange === 'kraken' ? 'info' : (b.exchange === 'binance' ? 'warn' : 'muted'))}
${FMT_USD(b.gross)}
${FMT_USD(b.fees)}
${FMT_USD(b.gross - b.fees)}
${b.trades}
${bpsCell}
`;
}).join('');
// The /api/trades row cap can bite before the day window does. When it has,
// every figure below is a floor over the most recent closes — never present
// it as the window's total.
const capNote = truncated
? `
Trade list hit its row cap — older closes in this ${Number(days) || 30}-day window are missing. Figures below are a lower bound.
`
: '';
root.innerHTML = `
${capNote}
${tile('Total Fees', FMT_USD(fees), `${scoped.length} closed trades${truncated ? ' (capped)' : ''}`, '#fbbf24')}
${tile('Avg Fee / Trade', FMT_USD(avgFee), `entry ${FMT_USD(entryFees / scoped.length)} + exit ${FMT_USD(exitFees / scoped.length)}`, '#fbbf24')}
${tile('Gross PnL', FMT_USD(gross), 'before exchange fees', c(gross))}
${tile('Net PnL', FMT_USD(net), dragPct != null ? `fees = ${dragPct.toFixed(1)}% of |gross|` : '', c(net))}
By Sleeve
Sleeve
Gross
Fees
Net
Trades
${sleeveRows}
By Exchange
Exchange
Gross
Fees
Net
Trades
Eff bps
${exchRows}
`;
// Hydrate the row-cap warning's icon — nothing else re-runs lucide on
// this card after loadAnalysis().
if (truncated && typeof lucide !== 'undefined') lucide.createIcons();
}
async function loadAnalysis() {
const days = state.analysisDays || 30;
// The Fee Impact card needs its OWN /api/trades page, scoped to the
// Analysis window. state.trades is capped by refreshAll's ladder, which is
// keyed off the OVERVIEW equity timeframe — with that tab on '1h' the card
// aggregated the latest 100 closes and reported them as a 90-day figure,
// understating fee drag by an order of magnitude while every neighbouring
// panel was server-computed over the real window. Same hours→limit ladder
// as refreshAll, keyed off `days`.
const feeHours = (Number(days) || 30) * 24;
const feeLimit = tradesLimitForHours(feeHours);
// …but only when refreshAll's own page is not ALREADY a superset. Both
// calls hit the same endpoint through the same ladder, and render() re-runs
// loadAnalysis() on every 30s tick, so an Analysis tab left open on the
// same bucket as the Overview equity timeframe paid a second FIFO walk on
// the trader VM every 30s for rows it already had in state.trades. A
// non-empty state.trades capped at >= feeLimit contains the newest
// feeLimit closes, which is exactly what the extra fetch would return.
const overviewLimit = tradesLimitForHours(tfToHours(state.equityTf || '7d'));
const reuseTrades = overviewLimit >= feeLimit
&& Array.isArray(state.trades) && state.trades.length > 0;
// Fire all endpoints in parallel — independent caches
const [pnl, bySleeve, bySymbol, dist, lvb, feeTr] = await Promise.all([
api('/api/analytics/daily-pnl?days=' + days).catch(() => null),
api('/api/analytics/by-sleeve?days=' + days).catch(() => null),
api('/api/analytics/by-symbol?days=' + days).catch(() => null),
api('/api/analytics/returns-distribution?days=90').catch(() => null),
api('/api/analytics/live-vs-backtest?days=' + days).catch(() => null),
reuseTrades
? Promise.resolve({ trades: state.trades, _limit: overviewLimit })
: api(`/api/trades?limit=${feeLimit}`).catch(() => null),
]);
// ── Live vs Backtest card (renders first because it's the headline)
renderLiveVsBacktest(lvb);
// ── Exchange Fee Impact card — client-side aggregation of the window's
// closed trades; each row carries fee_usd / entry_fee_usd / exit_fee_usd
// from the FIFO walker in /api/trades. A full page back from the server
// (or the state.trades fallback when the fetch failed) means rows older
// than the cap are missing, so the totals are a floor, not the window's
// true figure — say so on the card rather than quoting a silent undercount.
const feeRows = feeTr ? (feeTr.trades || []) : (state.trades || []);
// Compare against the cap the rows actually came back under — when they were
// reused from refreshAll's page that cap is the OVERVIEW limit, not ours.
const feeCap = (feeTr && feeTr._limit) || feeLimit;
const feeTruncated = feeTr ? feeRows.length >= feeCap : true;
renderFeeImpact(feeRows, days, feeTruncated);
// ── Daily PnL chart + totals
if (pnl) {
const t = pnl.totals || {};
const wr = (t.win_rate || 0) * 100;
const totalsEl = document.getElementById('analysis-totals');
if (totalsEl) {
const pnlColor = (t.pnl_usd || 0) >= 0 ? '#34d399' : '#f87171';
totalsEl.innerHTML = `
Total ${FMT_USD(t.pnl_usd || 0)} ${
t.pnl_pct != null
? ` (${t.pnl_pct >= 0 ? '+' : ''}${t.pnl_pct.toFixed(2)}% )`
: ''
}
across ${t.trades || 0} trades
(WR ${wr.toFixed(1)}% ,
${t.wins || 0}W /
${t.losses || 0}L )
`;
}
drawAnalysisPnlChart(pnl.series || []);
}
// ── By Sleeve table
if (bySleeve) {
const el = document.getElementById('analysis-sleeve-table');
if (el) el.innerHTML = renderAnalyticsTable(bySleeve.by_sleeve || [], 'sleeve');
}
// ── By Symbol table
if (bySymbol) {
const el = document.getElementById('analysis-symbol-table');
if (el) el.innerHTML = renderAnalyticsTable(bySymbol.by_symbol || [], 'symbol');
}
// ── Returns distribution
if (dist) {
const statsEl = document.getElementById('analysis-dist-stats');
if (statsEl) {
const s = dist.stats || {};
statsEl.innerHTML = `
Samples: ${dist.samples || 0} ·
Mean: ${(s.mean_pct || 0).toFixed(3)}% ·
Std: ${(s.std_pct || 0).toFixed(3)}% ·
Sharpe (ann): ${(s.sharpe_annualized || 0).toFixed(2)} ·
Range: ${(s.min_pct || 0).toFixed(2)}% / ${(s.max_pct || 0).toFixed(2)}%
`;
}
drawAnalysisDistChart(dist.histogram || []);
}
// Per-symbol chart (fires its own fetch + render)
await loadSymbolChart();
afterRender();
}
function renderAnalyticsTable(rows, keyField) {
if (!rows || rows.length === 0) {
return 'No trades yet.
';
}
const head = `
${keyField === 'sleeve' ? 'Sleeve' : 'Symbol'}
PnL
Trades
WR
`;
const body = rows.map(r => {
const k = r[keyField] || '?';
const pnl = r.pnl_usd || 0;
const color = pnl >= 0 ? '#34d399' : '#f87171';
const wr = (r.win_rate || 0) * 100;
return `
${k}
${FMT_USD(pnl)}
${r.trades || 0}
${wr.toFixed(1)}%
`;
}).join('');
return ``;
}
function drawAnalysisPnlChart(series) {
const ctx = document.getElementById('analysis-pnl-chart');
if (!ctx) return;
if (state.charts.analysisPnl) {
try { state.charts.analysisPnl.destroy(); } catch (_) {}
}
const labels = series.map(b => b.date.substring(5)); // MM-DD
const data = series.map(b => b.pnl_usd);
const colors = series.map(b => (b.pnl_usd || 0) >= 0 ? 'rgba(52,211,153,0.7)' : 'rgba(248,113,113,0.7)');
state.charts.analysisPnl = new Chart(ctx, {
type: 'bar',
data: { labels, datasets: [{ label: 'Daily PnL ($)', data, backgroundColor: colors, borderRadius: 3 }] },
options: {
maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => `$${ctx.parsed.y.toFixed(2)}` } } },
scales: {
x: { grid: { display: false }, ticks: { color: '#64748b', maxRotation: 0, autoSkip: true, maxTicksLimit: 12 } },
y: { grid: { color: 'rgba(100,116,139,0.15)' }, ticks: { color: '#64748b', callback: v => '$' + v } },
},
},
});
}
function drawAnalysisDistChart(histogram) {
const ctx = document.getElementById('analysis-dist-chart');
if (!ctx) return;
if (state.charts.analysisDist) {
try { state.charts.analysisDist.destroy(); } catch (_) {}
}
// Range labels like "1 to 2%" — server now emits whole-percent bounds.
const labels = histogram.map(b => {
const lo = Math.round(b.lo), hi = Math.round(b.hi);
return `${lo} to ${hi}%`;
});
const counts = histogram.map(b => b.count);
// Bucket color: green if entirely positive, red if entirely negative,
// gray for the bucket straddling 0%.
const colors = histogram.map(b => {
if (b.hi <= 0) return 'rgba(248,113,113,0.7)';
if (b.lo >= 0) return 'rgba(52,211,153,0.7)';
return 'rgba(148,163,184,0.6)';
});
state.charts.analysisDist = new Chart(ctx, {
type: 'bar',
data: { labels, datasets: [{ label: 'Days', data: counts, backgroundColor: colors, borderRadius: 3 }] },
options: {
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: (items) => `Return ${items[0].label}`,
label: (ctx) => `${ctx.parsed.y} day${ctx.parsed.y === 1 ? '' : 's'}`,
},
},
},
scales: {
x: { grid: { display: false }, ticks: { color: '#64748b' } },
y: { grid: { color: 'rgba(100,116,139,0.15)' }, ticks: { color: '#64748b', precision: 0 } },
},
},
});
}
// Logs view uses cursor-paginated lazy loading: render a scroll
// container with a sentinel at the bottom, an IntersectionObserver
// triggers the next page fetch when the sentinel scrolls into view.
// State.logsView keeps the running list + cursor.
function viewLogs() {
return ``;
}
const LOGS_PAGE_SIZE = 200;
function _logsRowHtml(e) {
return `
${(e.ts || '').substring(0, 19)}
${badge(e.event, eventColor(e.event))}
${eventDetail(e)}
`;
}
async function _fetchLogsPage(before) {
const params = new URLSearchParams();
params.set('limit', String(LOGS_PAGE_SIZE));
if (before) params.set('before', before);
try {
return await api('/api/logs?' + params.toString());
} catch (e) {
console.error('logs fetch failed', e);
return null;
}
}
async function _loadNextLogsPage() {
if (!state.logsView) state.logsView = { rows: [], oldest: null, exhausted: false, loading: false };
const lv = state.logsView;
if (lv.loading || lv.exhausted) return;
lv.loading = true;
const sentinel = document.getElementById('logs-sentinel');
if (sentinel) sentinel.textContent = 'Loading…';
const page = await _fetchLogsPage(lv.oldest);
lv.loading = false;
if (!page) {
if (sentinel) sentinel.innerHTML = 'Retry ';
return;
}
const events = page.events || [];
if (events.length === 0) {
lv.exhausted = true;
if (sentinel) sentinel.textContent = lv.rows.length ? '— end of log —' : 'No events.';
return;
}
lv.rows.push(...events);
lv.oldest = page.oldest_ts || events[events.length - 1].ts;
if (!page.has_more) lv.exhausted = true;
// Append rows to the table
const body = document.getElementById('logs-body');
if (body) {
body.insertAdjacentHTML('beforeend', events.map(_logsRowHtml).join(''));
}
const count = document.getElementById('logs-count');
if (count) count.textContent = `${lv.rows.length} entries loaded${lv.exhausted ? ' (all)' : ''}`;
if (lv.exhausted && sentinel) sentinel.textContent = '— end of log —';
if (typeof lucide !== 'undefined') lucide.createIcons();
}
function reloadLogs() {
// Reset state, clear DOM, kick off first page.
state.logsView = { rows: [], oldest: null, exhausted: false, loading: false };
const body = document.getElementById('logs-body');
if (body) body.innerHTML = '';
const sentinel = document.getElementById('logs-sentinel');
if (sentinel) sentinel.textContent = 'Loading…';
_loadNextLogsPage();
}
function _initLogsLazyLoader() {
// Repaint-safe. render() re-runs this branch on every 30s refreshAll tick
// while the Logs tab is open, and the Logs view has no [data-user-dirty]
// control for pageHoldsUserInput() to latch onto — so resetting logsView
// here threw the operator back to the newest page every 30 seconds,
// mid-investigation. Only start a fresh cursor when nothing is loaded;
// setView() clears logsView on a real nav-in, and Reload does it on demand.
const prev = state.logsView;
const resume = !!(prev && prev.rows && prev.rows.length);
if (!resume) state.logsView = { rows: [], oldest: null, exhausted: false, loading: false };
const scroll = document.getElementById('logs-scroll');
const sentinel = document.getElementById('logs-sentinel');
if (!scroll || !sentinel) return;
// Remember the scroll offset across repaints — the container node is
// rebuilt by innerHTML, so it has to be stashed on state as it changes.
scroll.addEventListener('scroll', () => {
if (state.logsView) state.logsView.scrollTop = scroll.scrollTop;
});
// Cleanup previous observer
if (state._logsObserver) {
try { state._logsObserver.disconnect(); } catch (_) {}
}
state._logsObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) _loadNextLogsPage();
}
}, { root: scroll, rootMargin: '120px', threshold: 0.01 });
state._logsObserver.observe(sentinel);
if (resume) {
// Re-paint the pages already loaded into the fresh tbody and restore the
// scroll offset. `loading` is deliberately left alone: an in-flight page
// fetch appends to this same tbody when it resolves, and clearing the
// flag here would let a second concurrent fetch duplicate those rows.
const lv = state.logsView;
const body = document.getElementById('logs-body');
if (body) body.innerHTML = lv.rows.map(_logsRowHtml).join('');
const count = document.getElementById('logs-count');
if (count) count.textContent = `${lv.rows.length} entries loaded${lv.exhausted ? ' (all)' : ''}`;
if (lv.exhausted) sentinel.textContent = '— end of log —';
if (lv.scrollTop) scroll.scrollTop = lv.scrollTop;
if (typeof lucide !== 'undefined') lucide.createIcons();
return;
}
// Prime the first page
_loadNextLogsPage();
}
// Tone MUST be one of the five classes defined in the stylesheet:
// good | warn | danger | info | muted. An unknown tone (e.g. 'bad',
// 'success') silently renders the uncoloured .badge base rule — a dead
// sleeve's OFFLINE pill then reads as a routine grey label instead of red.
function badge(text, tone) { return `${text} `; }
// ONE sleeve table for the whole SPA — badge tone, activity-feed pill and
// overview doughnut all read from here. There used to be three separate
// maps ~600 lines apart, and only the doughnut knew about wide_carry: a
// slot E/F carry position rendered a grey "unknown" pill in Positions,
// Trades and Signals while the doughnut on the same screen drew it green
// and labelled "Funding Carry". Adding a sleeve to
// config/sleeve_registry.yaml means adding exactly one row here.
// tone — MUST be one of the five badge tones (see badge() above)
// tag — activity-feed pill classes
// hex — doughnut slice colour
// Keys that are not registry sleeves (liq, xsect, funding) are legacy
// aliases still present in old rows; adopted/unattributed/unknown are UI
// buckets for positions the engine could not attribute.
const SLEEVE_META = {
v23: { label: 'V2.3 Trend', tone: 'info', tag: 'bg-blue-900/60 text-blue-300', hex: '#1E90FF' },
liq_cascade: { label: 'Liq Cascade (retired)', tone: 'good', tag: 'bg-emerald-900/60 text-emerald-300', hex: '#94A3B8' },
liq: { label: 'Liq Cascade', tone: 'good', tag: 'bg-emerald-900/60 text-emerald-300', hex: '#94A3B8' },
xsect_mr: { label: 'XSect MR', tone: 'warn', tag: 'bg-amber-900/60 text-amber-300', hex: '#FBBF24' },
xsect: { label: 'XSect', tone: 'warn', tag: 'bg-amber-900/60 text-amber-300', hex: '#FBBF24' },
wide_carry: { label: 'Funding Carry', tone: 'good', tag: 'bg-emerald-900/60 text-emerald-300', hex: '#34D399' },
funding: { label: 'Funding Carry', tone: 'good', tag: 'bg-emerald-900/60 text-emerald-300', hex: '#34D399' },
xsec_momentum: { label: 'XSec Momentum', tone: 'info', tag: 'bg-blue-900/60 text-blue-300', hex: '#38BDF8' },
wide_xsect: { label: 'Wide XSect', tone: 'warn', tag: 'bg-amber-900/60 text-amber-300', hex: '#F472B6' },
adopted: { label: 'Adopted', tone: 'muted', tag: 'bg-slate-700/60 text-slate-300', hex: '#A78BFA' },
unattributed: { label: 'Unattributed', tone: 'muted', tag: 'bg-slate-700/60 text-slate-300', hex: '#64748B' },
unknown: { label: 'unknown', tone: 'muted', tag: 'bg-slate-700/60 text-slate-300', hex: '#64748B' },
};
function sleeveColor(s) {
// v23 → blue, liq → green, xsect → amber. Match drawSleevePie() colors.
return (SLEEVE_META[s] || {}).tone || 'muted';
}
function signalColor(e) {
if (e === 'signal_fired' || e === 'EMITTED') return 'good';
if (e === 'exit_triggered' || e === 'trade_closed') return 'info';
if (e === 'signal_filtered' || e === 'trade_rejected') return 'muted';
if (e === 'trade_opened') return 'good';
return 'warn';
}
function eventColor(e) {
if (!e) return 'muted';
if (e.includes('error') || e.includes('fail')) return 'danger';
if (e.includes('triggered') || e.includes('halt')) return 'warn';
if (e.includes('fired') || e.includes('opened') || e.includes('connected')) return 'good';
return 'info';
}
function eventLabel(e) {
return ({
signal_emitted: 'Signal emitted',
signal_fired: 'Signal fired · entry placed',
signal_filtered: 'Signal filtered',
exit_triggered: 'Exit triggered',
cusum_breach: 'CUSUM breach',
trade_opened: 'Trade opened',
trade_closed: 'Trade closed',
position_opened: 'Position opened',
position_adopted: 'Position adopted (from exchange)',
position_closed_manual: 'Manual close',
external_close_detected: 'External close detected',
kill_switch_triggered: 'Kill switch triggered',
kill_switch_reset_manual: 'Kill switch reset (manual)',
trading_halted: 'Trading halted',
trading_resumed: 'Trading resumed',
heartbeat_started: 'Engine started',
heartbeat_recovered: 'Heartbeat recovered',
state_mismatch: 'State mismatch with exchange',
reconcile_failed: 'Reconcile failed',
// New event types (added 2026-05-13)
dd_threshold_crossed: 'Drawdown threshold crossed',
regime_switched: 'Regime switched',
wallet_balance_changed: 'Wallet balance changed',
datafeed_degraded: 'Data feed degraded',
datafeed_recovered: 'Data feed recovered',
sleeve_state: 'Sleeve state',
capacity_warning: 'Capacity warning',
}[e] || e || 'unknown');
}
// Color-tone hint for the small left dot. Keeps the feed legible at a glance:
// green = good outcome, red = bad outcome / halt, amber = filtered / warning,
// slate = routine info.
function eventTone(e) {
if (['kill_switch_triggered', 'trading_halted', 'reconcile_failed',
'state_mismatch', 'datafeed_degraded'].includes(e)) return 'rose';
if (['signal_filtered', 'dd_threshold_crossed', 'regime_switched',
'capacity_warning'].includes(e)) return 'amber';
if (['signal_fired', 'trade_opened', 'position_opened', 'trading_resumed',
'kill_switch_reset_manual', 'heartbeat_recovered', 'datafeed_recovered',
'sleeve_state'].includes(e)) return 'emerald';
return 'slate';
}
// Money/percent formatting for the feed comes from the shared FMT_*
// helpers (top of this script). The feed used to carry its own
// _fmtUsd/_fmtPct: same numbers, different output (no thousands
// separator, and a negative PnL lost its minus sign entirely).
const _dirText = (d) => (d === 1 || d === '1' || d === 'long') ? 'LONG'
: (d === -1 || d === '-1' || d === 'short') ? 'SHORT' : '';
const _sleeveTag = (s) => {
if (!s) return '';
// Pill classes come from SLEEVE_META so the feed can never disagree with
// the Positions/Trades badges or the overview doughnut.
const cls = (SLEEVE_META[s] || {}).tag || 'bg-slate-700/60 text-slate-300';
return `${s} `;
};
// Event-aware detail formatter. For each event type we extract the fields
// that actually matter and format them in a human-readable way. Falls back
// to the generic key=value dump for events we don't have a template for.
function eventDetail(e) {
const sym = e.symbol;
const sl = e.sleeve;
const dir = _dirText(e.direction);
const size = e.size_usd != null ? +e.size_usd : (e.notional_usd != null ? +e.notional_usd : null);
switch (e.event) {
case 'signal_emitted':
case 'signal_fired':
return [
_sleeveTag(sl),
sym,
dir ? `${dir} ` : '',
size != null ? `$${size.toFixed(2)} ` : '',
].filter(Boolean).join(' · ');
case 'signal_filtered':
return [
_sleeveTag(sl),
sym,
e.reason ? `reason: ${e.reason} ` : '',
].filter(Boolean).join(' · ');
case 'position_opened':
case 'position_adopted':
return [
_sleeveTag(sl),
sym,
dir,
e.entry_price != null ? `entry $${(+e.entry_price).toFixed(4)} ` : '',
size != null ? `$${size.toFixed(2)} ` : '',
].filter(Boolean).join(' · ');
case 'trade_opened':
// Gross trade amount is the missing-but-important field: the
// fill_price alone says nothing about exposure. Show USD notional
// first (the institutional view), then qty, then fill price.
const filled = e.filled_amount != null ? +e.filled_amount : null;
return [
_sleeveTag(sl),
sym,
e.side ? `${e.side.toUpperCase()} ` : '',
size != null ? `$${size.toFixed(2)} ` : '',
filled != null ? `${filled.toFixed(4)} units ` : '',
e.fill_price != null ? `@ $${(+e.fill_price).toFixed(4)} ` : '',
e.slippage_bps != null ? `slip ${(+e.slippage_bps).toFixed(1)}bp ` : '',
].filter(Boolean).join(' · ');
case 'exit_triggered':
return [
sym,
e.exit_reason ? `${e.exit_reason} ` : '',
e.price != null ? `@ $${(+e.price).toFixed(4)} ` : '',
].filter(Boolean).join(' · ');
case 'trade_closed':
case 'external_close_detected':
const pnl = e.pnl != null ? +e.pnl : (e.pnl_usd != null ? +e.pnl_usd : null);
const pnlPct = e.pnl_pct != null ? +e.pnl_pct : null;
const pnlColor = pnl == null ? '' : (pnl >= 0 ? 'text-emerald-300' : 'text-rose-300');
return [
sym,
e.exit_reason ? `${e.exit_reason} ` : '',
pnl != null ? `${FMT_USD_SIGNED(pnl)} ` : '',
pnlPct != null ? `${FMT_PCT(pnlPct)} ` : '',
e.hours_held != null ? `${(+e.hours_held).toFixed(1)}h held` : '',
].filter(Boolean).join(' · ');
case 'kill_switch_triggered':
case 'trading_halted':
return [
e.reason ? `${e.reason} ` : '',
e.halt_hours != null ? `halt ${e.halt_hours}h` : '',
e.halt_until ? `until ${e.halt_until.substring(11, 16)} UTC` : '',
].filter(Boolean).join(' · ');
case 'trading_resumed':
case 'kill_switch_reset_manual':
return e.actor ? `by ${e.actor}` : '';
case 'state_mismatch':
return [sym, `bot=${e.bot_size}`, `exchange=${e.exchange_size}`].filter(Boolean).join(' · ');
case 'heartbeat_started':
return e.target_vol ? `vol target ${e.target_vol} · lev ${e.max_lev}` : '';
// New event types (added 2026-05-13) ────────────────────────────────
case 'dd_threshold_crossed': {
const from = e.from_zone || '?';
const to = e.to_zone || '?';
const arrowCls = (to === 'halt') ? 'text-rose-300'
: (to === 'safe') ? 'text-emerald-300'
: 'text-amber-300';
return [
`${from} → ${to} `,
e.dd_pct != null ? `DD ${e.dd_pct}% ` : '',
e.equity_usd != null ? `equity $${(+e.equity_usd).toFixed(2)} ` : '',
].filter(Boolean).join(' · ');
}
case 'regime_switched': {
const from = e.from_regime || '?';
const to = e.to_regime || '?';
const cls = (to === 'bear') ? 'text-rose-300' : 'text-emerald-300';
const w = e.weights ? `weights ${e.weights.v23 || 0}/${e.weights.liq || 0}/${e.weights.xsect || 0}` : '';
return [
`${from} → ${to} `,
w,
e.cumret_30d_pct != null ? `30d ${e.cumret_30d_pct}% ` : '',
].filter(Boolean).join(' · ');
}
case 'wallet_balance_changed': {
const delta = e.delta_usd != null ? +e.delta_usd : null;
const dirLabel = e.direction === 'deposit' ? 'DEPOSIT' : 'WITHDRAWAL';
const dirCls = e.direction === 'deposit' ? 'text-emerald-300' : 'text-rose-300';
return [
`${dirLabel} `,
delta != null ? `${FMT_USD_SIGNED(delta)} ` : '',
e.new_balance_usd != null ? `→ $${(+e.new_balance_usd).toFixed(2)} ` : '',
].filter(Boolean).join(' · ');
}
case 'datafeed_degraded':
return [
`${e.source || 'unknown'} `,
e.consecutive_failures != null ? `${e.consecutive_failures} consecutive failures` : '',
e.last_reason ? `${e.last_reason} ` : '',
].filter(Boolean).join(' · ');
case 'datafeed_recovered':
return [
`${e.source || 'unknown'} `,
e.failed_for_n_calls != null ? `recovered after ${e.failed_for_n_calls} failed calls` : '',
].filter(Boolean).join(' · ');
case 'sleeve_state': {
const stateCls = e.state === 'active' ? 'text-emerald-300'
: e.state === 'disabled' ? 'text-rose-300'
: 'text-slate-400';
const extras = [];
if (e.internal_leverage && e.internal_leverage > 1) extras.push(`lev ${e.internal_leverage}x`);
if (e.max_leverage) extras.push(`max lev ${e.max_leverage}`);
if (e.entry_z) extras.push(`z=${e.entry_z}`);
if (e.universe_size) extras.push(`${e.universe_size} symbols`);
return [
_sleeveTag(e.sleeve),
`${(e.state || '').toUpperCase()} `,
...extras,
].filter(Boolean).join(' · ');
}
case 'capacity_warning': {
const bandCls = e.band === 'hard' ? 'text-rose-300'
: e.band === 'soft' ? 'text-amber-300'
: 'text-amber-300/80';
const bandLabel = ({approaching: '≥80%', soft: '≥100%', hard: '≥200%'})[e.band] || e.band;
const exposureK = e.exposure_usd != null ? `$${((+e.exposure_usd) / 1000).toFixed(0)}k exposure` : '';
return [
_sleeveTag(e.sleeve),
`${bandLabel} of soft cap `,
exposureK,
].filter(Boolean).join(' · ');
}
default:
// Fallback for events we don't have a template for. Keep the old
// generic format so nothing disappears.
const ignore = new Set(['ts', 'event', 'logger', 'level', 'timestamp']);
return Object.entries(e).filter(([k]) => !ignore.has(k))
.map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : v}`)
.slice(0, 4).join(' · ');
}
}
function tfToHours(tf) {
// 'all' uses a generous bound — equity-history endpoint caps to whatever
// exists in portfolio_history.jsonl anyway.
return ({ '1h': 1, '6h': 6, '24h': 24, '7d': 168, '30d': 720, 'all': 24 * 365 * 5 }[tf] || 168);
}
// Trades limit scales with the equity-curve timeframe so the markers cover the
// entire visible window. 'all' tf -> 5000 trades (plenty of headroom even for
// 1y+ live; FIFO cache makes this cheap). ONE table: refreshAll() and
// setEquityTf() must ask for the same trade set, otherwise the KPI tile sums a
// different set of closed trades than the equity chart marks.
function tradesLimitForHours(equityHours) {
return equityHours <= 24 ? 100
: equityHours <= 168 ? 500
: equityHours <= 720 ? 2000 : 5000;
}
// Format the X-axis label depending on timeframe span. For 24h show HH:MM,
// for multi-day show MMM-DD HH:MM.
function fmtTfLabel(ts, tf) {
if (!ts) return '';
// Intraday ranges show HH:MM only (same day, day prefix would be noise)
if (tf === '1h' || tf === '6h' || tf === '24h') return ts.substring(11, 16);
// For 7d/30d/all: "MM-DD HH:MM" so day boundaries are visible
return ts.substring(5, 10) + ' ' + ts.substring(11, 16);
}
async function setEquityTf(tf) {
state.equityTf = tf;
try { localStorage.setItem('equityTf', tf); } catch (_) {}
// Re-fetch trades scaled to the new TF window so all entries within
// the visible equity range get marked. Previously we only had ~50
// most-recent trades cached, so picking "30d" or "all" only marked
// the last handful — older entries vanished off the curve.
const equityHours = tfToHours(tf);
const tradesLimit = tradesLimitForHours(equityHours);
// /api/realized_window has to move with the tf too: viewOverview only
// trusts a payload whose window_hours equals the selected tf (`haveWindow`),
// so without this the headline realized-PnL KPI blanks to "— / loading…"
// next to a fully drawn curve until the next 30s refreshAll tick.
const [tr, rw] = await Promise.allSettled([
api(`/api/trades?limit=${tradesLimit}`),
api('/api/realized_window?hours=' + equityHours),
]);
// Keep the stale value on failure — a blank tile is better than a wrong one,
// and the window check above already rejects a mismatched payload.
if (tr.status === 'fulfilled' && tr.value && tr.value.trades) state.trades = tr.value.trades;
if (rw.status === 'fulfilled' && rw.value) state.realizedWindow = rw.value;
// Re-render Overview so the TF-tab highlight + KPI cards reflect
// the new selection.
if (state.view === 'overview') {
const c = document.getElementById('page-content');
if (c) c.innerHTML = viewOverview();
if (typeof afterRender === 'function') afterRender();
if (typeof drawSleevePie === 'function') drawSleevePie();
}
await drawEquityChart();
}
async function drawEquityChart() {
const tf = state.equityTf || '7d';
const hours = tfToHours(tf);
const ctx = document.getElementById('equity-chart');
const empty = document.getElementById('equity-empty');
if (!ctx) return; // viewOverview not currently mounted
// Read from the cache that refreshAll() populates so this is synchronous
// and the chart appears in the same animation frame as the rest of the
// overview rebuild. Fall back to a one-off fetch only if the cache is
// empty (first render after page load) or the user switched timeframes
// (cached window doesn't match the requested one).
let series = state.equityHistory;
if (!Array.isArray(series) || state.equityHistoryHours !== hours) {
try {
const data = await api('/api/portfolio/equity-history?hours=' + hours);
series = data.series || [];
state.equityHistory = series;
state.equityHistoryHours = hours;
} catch (e) {
console.error('drawEquityChart fetch', e);
series = [];
}
}
// Capital flows from cache (populated by refreshAll's parallel
// fetch). Same caching strategy as equityHistory above — without it,
// every periodic refresh awaited a fresh network round-trip while
// the canvas had already been destroyed by viewOverview's innerHTML
// rebuild, leaving the chart blank for 100-300ms.
let flows = state.equityFlows;
if (!Array.isArray(flows) || state.equityFlowsHours !== hours) {
flows = [];
}
try {
if (!Array.isArray(state.equityFlows) || state.equityFlowsHours !== hours) {
// Cache miss (first render or timeframe change) — one-off fetch.
const flowData = await api('/api/portfolio/capital-flows?hours=' + hours);
flows = flowData.flows || [];
state.equityFlows = flows;
state.equityFlowsHours = hours;
}
} catch (e) {
console.warn('drawEquityChart capital-flows fetch failed', e);
}
if (state.charts.equity) { try { state.charts.equity.destroy(); } catch (_) {} state.charts.equity = null; }
if (!series.length) {
if (empty) empty.style.display = 'flex';
return;
}
if (empty) empty.style.display = 'none';
// Sample down to ~200 points if we have many — Chart.js gets sluggish with
// 5000+ points and the visual fidelity is identical at this scale.
const MAX_POINTS = 240;
let plot = series;
if (series.length > MAX_POINTS) {
const step = Math.ceil(series.length / MAX_POINTS);
plot = series.filter((_, i) => i % step === 0);
if (plot[plot.length - 1] !== series[series.length - 1]) plot.push(series[series.length - 1]);
}
// ── Bucket time index ──────────────────────────────────────────────
// Every bucket timestamp is constant for the whole draw, so parse them
// ONCE. Before this, each capital flow AND each trade/position marker
// re-parsed all <=240 bucket ts strings with new Date() to find its
// nearest bucket — on the 'all' timeframe (5000 trades) that is ~1.2M
// date-string parses per draw, redone on every 30s Overview repaint.
const plotTimes = plot.map(p => new Date(p.ts).getTime());
// The equity series arrives time-ordered (winStart/winEnd below already
// assume it), which lets indexAt() binary-search instead of scanning.
// Verify it once rather than trusting it: on an out-of-order series a
// binary search would silently pick the wrong bucket, so fall back to
// the original linear scan in that case.
let plotAscending = true;
for (let i = 1; i < plotTimes.length; i++) {
if (!(plotTimes[i] >= plotTimes[i - 1])) { plotAscending = false; break; }
}
// Nearest plotted bucket for an epoch-ms timestamp; -1 when there is
// none or the timestamp is unparseable (NaN would otherwise compare
// false against everything and land the marker on bucket 0).
const indexAt = (t) => {
if (!plotTimes.length || !Number.isFinite(t)) return -1;
if (plotAscending) {
let lo = 0, hi = plotTimes.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (plotTimes[mid] < t) lo = mid + 1; else hi = mid;
}
// lo is the first bucket >= t; its left neighbour can be closer.
// Ties go to the earlier bucket, matching the old first-wins scan.
let idx = lo;
if (lo > 0 && Math.abs(plotTimes[lo - 1] - t) <= Math.abs(plotTimes[lo] - t)) idx = lo - 1;
// Buckets with an identical timestamp: the linear scan this replaced
// kept the FIRST of them, so rewind over the run to return the same
// index it did (runs are 1-2 long, so this stays O(1) in practice).
while (idx > 0 && plotTimes[idx - 1] === plotTimes[idx]) idx--;
return idx;
}
let bestIdx = -1, bestDist = Infinity;
for (let i = 0; i < plotTimes.length; i++) {
const d = Math.abs(plotTimes[i] - t);
if (d < bestDist) { bestDist = d; bestIdx = i; }
}
return bestIdx;
};
// ── Annotation markers (vertical lines at flow times) ────────────
// Find the chart x-position of each flow timestamp by matching to
// the nearest plotted bucket. Render as vertical dashed lines with
// arrow labels showing amount + kind.
const flowAnnotations = {};
flows.forEach((f, idx) => {
const bestIdx = indexAt(new Date(f.ts).getTime());
if (bestIdx < 0) return;
const amt = f.amount_usd || 0;
const isInflow = amt > 0;
const color = isInflow ? '#10B981' : '#EF4444'; // emerald in / rose out
const arrow = isInflow ? '↑' : '↓';
const sign = isInflow ? '+' : '−';
flowAnnotations['flow_' + idx] = {
type: 'line',
xMin: bestIdx,
xMax: bestIdx,
borderColor: color,
borderWidth: 1.5,
borderDash: [4, 4],
label: {
content: `${arrow} ${sign}$${Math.abs(amt).toLocaleString(undefined, {maximumFractionDigits: 0})}`,
display: true,
position: 'start',
backgroundColor: color,
color: '#FFFFFF',
font: { size: 10, weight: 'bold' },
padding: 3,
yAdjust: 4,
},
};
});
// ── Trade-entry markers ────────────────────────────────────────────
// Show where positions were opened, color-coded by side. This makes
// it visually obvious which equity moves correspond to which trades.
// Data sources:
// - state.trades: closed trades with entry_ts / side from /api/trades
// - state.positions: currently-open positions with opened_at + side
// For each entry, find the nearest equity bucket index and the equity
// at that time (so the marker sits on the curve itself).
const winStart = plotTimes[0];
const winEnd = plotTimes[plotTimes.length - 1];
const entryTs = (t) => {
const v = t.entry_ts || t.opened_at || t.ts;
return v ? new Date(v).getTime() : null;
};
// indexAt() is defined once above, over the precomputed plotTimes.
// POSITIONING FIX (2026-05-18): scatter datasets that share a
// category x-axis with a line dataset must use the LABEL STRING as
// their x value — not the numeric index. With an integer x, Chart.js
// coerces it through its parser and the marker drifts (sometimes by
// one or several buckets, depending on auto-skip and label format).
// Using the same labels[i] string the line uses guarantees alignment.
const labelFor = (i) => fmtTfLabel(plot[i].ts, tf);
const buyMarkers = [], sellMarkers = [];
// STRIP-PLOT LAYOUT (2026-05-19): entry markers are rendered on a
// dedicated hidden y-axis "yMarkers" with a fixed [0,1] range. Long
// markers sit at Y_LONG_STRIP, short markers at Y_SHORT_STRIP. The
// equity curve uses the main y-axis (auto-scale on the actual equity
// range). Previously markers used the equity axis with y = equity at
// the trade's bucket — when many trades clustered in a narrow time
// band AND equity micro-fluctuated by a few dollars, each marker
// snapped to its own slightly-different equity value and the cluster
// rendered as a vertical beadstring instead of a clean band. The
// strip plot decouples the two so the equity line stays unobstructed
// and the marker band cleanly shows when we traded.
const Y_LONG_STRIP = 0.18;
const Y_SHORT_STRIP = 0.08;
//
// /api/trades returns two kinds of rows:
// (a) FIFO walker output — one row per (close-fill × matched open-lot)
// chunk, status='closed', with both entry_ts (the open lot's ts)
// and ts (the close ts).
// (b) Redis events-stream fallback — one row per trade_closed event,
// status undefined, only ts (close ts), no entry_ts.
//
// (b) is a safety net for when (a) misses a close (e.g. silent
// _log_trade failure). When the trades table is healthy (a) and (b)
// describe the SAME logical entries, which is what we want.
//
// For chart markers we use ONLY (a) — one marker per real ENTRY, where
// multiple FIFO chunks for the same entry share an entry_ts and dedupe
// cleanly. Including (b) too would double-count every healthy trade
// because the stream event's only timestamp is the close ts, so its
// dedupe key never collides with the corresponding FIFO chunk's
// entry-ts-based key. (Bug surfaced 2026-05-19 on slot B: chart
// claimed 20 long / 66 short while the actual unique entries were
// ~14 long / 28 short.)
const seenEntry = new Set();
for (const t of (state.trades || [])) {
// Skip rows that didn't come from the FIFO walker — they're
// stream-event fallbacks that re-describe the same entries.
if (t.status !== 'closed') continue;
const ts = entryTs(t);
if (!ts || ts < winStart || ts > winEnd) continue;
const key = (t.signal_id || '') + '@' + ts;
if (seenEntry.has(key)) continue;
seenEntry.add(key);
const idx = indexAt(ts);
if (idx < 0) continue;
const side = (t.side || '').toLowerCase();
const isLong = side === 'long' || side === 'buy';
const marker = { x: labelFor(idx), y: isLong ? Y_LONG_STRIP : Y_SHORT_STRIP, symbol: (t.symbol || '').replace('USDT',''), sleeve: t.sleeve, pnl: t.pnl_usd, side, kind: 'closed', ts: t.entry_ts || t.ts };
(isLong ? buyMarkers : sellMarkers).push(marker);
}
// Currently-open positions
for (const p of (state.positions || [])) {
const ts = entryTs({ opened_at: p.opened_at });
if (!ts || ts < winStart || ts > winEnd) continue;
const idx = indexAt(ts);
if (idx < 0) continue;
const side = (p.side || '').toLowerCase();
const isLong = side === 'long' || side === 'buy';
const marker = { x: labelFor(idx), y: isLong ? Y_LONG_STRIP : Y_SHORT_STRIP, symbol: (p.internal_symbol || p.symbol || '').replace('USDT',''), sleeve: p.sleeve, pnl: p.unrealized_pnl, side, kind: 'open', ts: p.opened_at };
(isLong ? buyMarkers : sellMarkers).push(marker);
}
// Wallet lifecycle markers — the audit-visible moments capital was
// attached or detached. Without these, a detach reads on the chart as an
// unexplained regime change. Amber diamonds on the marker strip; the
// events already flow through /api/logs (push_event wallet_slot_changed).
const walletMarkers = [];
for (const ev of (state.logs || [])) {
if ((ev.event || '') !== 'wallet_slot_changed') continue;
const ts = entryTs({ opened_at: ev.ts });
if (!ts || ts < winStart || ts > winEnd) continue;
const idx = indexAt(ts);
if (idx < 0) continue;
walletMarkers.push({
x: labelFor(idx),
y: (Y_LONG_STRIP + Y_SHORT_STRIP) / 2,
symbol: ev.label || 'wallet',
sleeve: `${ev.from_slot || 'pool'} → ${ev.to_slot || 'pool'}`,
pnl: null, side: 'wallet', kind: 'wallet', ts: ev.ts,
});
}
try {
const datasets = [{
label: 'Equity',
data: plot.map(s => s.equity),
borderColor: '#1E90FF',
backgroundColor: 'rgba(30, 144, 255, 0.12)',
fill: true, tension: 0.25, pointRadius: 0, borderWidth: 2,
}];
// Trade-entry overlays (only if we have any). Both scatter datasets
// bind to the hidden `yMarkers` axis so their constant y values
// (Y_LONG_STRIP / Y_SHORT_STRIP) map into the bottom strip without
// distorting the equity-curve's autoscale on the main `y` axis.
if (buyMarkers.length > 0) {
datasets.push({
type: 'scatter',
label: `Long entries (${buyMarkers.length})`,
data: buyMarkers,
yAxisID: 'yMarkers',
backgroundColor: '#34d399',
borderColor: '#10b981',
pointRadius: 3,
pointHoverRadius: 6,
showLine: false,
});
}
if (walletMarkers.length > 0) {
datasets.push({
type: 'scatter',
label: `Wallet events (${walletMarkers.length})`,
data: walletMarkers,
yAxisID: 'yMarkers',
backgroundColor: '#f59e0b',
borderColor: '#d97706',
pointStyle: 'rectRot',
pointRadius: 5,
pointHoverRadius: 8,
showLine: false,
});
}
if (sellMarkers.length > 0) {
datasets.push({
type: 'scatter',
label: `Short entries (${sellMarkers.length})`,
data: sellMarkers,
yAxisID: 'yMarkers',
backgroundColor: '#f87171',
borderColor: '#dc2626',
pointRadius: 3,
pointHoverRadius: 6,
showLine: false,
});
}
// Annotation plugin (chartjs-plugin-annotation) — only used when
// there are flows. If the plugin isn't loaded, we silently skip
// the markers; the chart still renders without them.
// Show legend whenever we have at least one trade-marker overlay.
const hasOverlay = buyMarkers.length > 0 || sellMarkers.length > 0;
const plugins = {
legend: { display: hasOverlay, position: 'top', labels: { color: '#9CA3AF', font: { size: 10 }, boxWidth: 10, padding: 8 } },
tooltip: {
callbacks: {
label: (item) => {
const ds = item.dataset.label || '';
if (ds.startsWith('Long entries') || ds.startsWith('Short entries') || ds.startsWith('Wallet events')) {
const m = item.raw || {};
const pnl = (m.pnl != null) ? ` PnL ${m.pnl >= 0 ? '+' : ''}$${m.pnl.toFixed(2)}` : '';
const kind = m.kind === 'open' ? ' (open)' : '';
return `${m.symbol || '?'} ${m.side || ''}${kind} [${m.sleeve || '?'}]${pnl}`;
}
return ds + ': $' + Number(item.parsed.y).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
},
},
},
};
if (Object.keys(flowAnnotations).length > 0 && typeof Chart !== 'undefined' && Chart.registry && Chart.registry.plugins.get('annotation')) {
plugins.annotation = { annotations: flowAnnotations };
}
state.charts.equity = new Chart(ctx, {
type: 'line',
data: {
labels: plot.map(s => fmtTfLabel(s.ts, tf)),
datasets: datasets,
},
options: {
responsive: true, maintainAspectRatio: false,
// Disable the default 1s create-time animation. On every
// refreshAll the canvas DOM is rebuilt by viewOverview's
// innerHTML and the chart is recreated; the animation made
// the chart "load in" from zero on each refresh, which read
// as a blank flash. With animation off the chart paints in
// a single frame at full values.
animation: false,
plugins: plugins,
scales: {
x: { grid: { color: '#151A20' }, ticks: { color: '#707A8A', font: { size: 10 }, maxTicksLimit: 8, autoSkip: true } },
y: { grid: { color: '#151A20' }, ticks: { color: '#707A8A', font: { size: 10 }, callback: v => '$' + Number(v).toLocaleString() } },
// Hidden axis for the entry-marker strip plot. Fixed [0,1]
// range so Y_LONG_STRIP / Y_SHORT_STRIP map to predictable
// pixel positions at the bottom of the chart, independent of
// whatever the equity curve's actual price range is.
yMarkers: { display: false, min: 0, max: 1 },
},
},
});
} catch (e) { console.error('drawEquityChart render', e); }
}
function drawSleevePie() {
// REWRITTEN 2026-06-11: the old chart rendered the bear_regime_gate
// DESIGN weights (10/60/30) — hardcoded fiction that kept showing the
// retired liq cascade sleeve as 60% of the book, and never reflected
// how capital actually deploys (v23 vol-targets off full equity).
// Now: REAL exposure — open positions grouped by sleeve, value-weighted.
const ctx = document.getElementById('sleeve-pie');
if (!ctx) return;
const bySleeve = {};
for (const p of state.positions || []) {
const key = p.sleeve || 'unattributed';
bySleeve[key] = (bySleeve[key] || 0) + Math.abs(Number(p.notional) || 0);
}
const entries = Object.entries(bySleeve).filter(([, v]) => v > 0)
.sort((a, b) => b[1] - a[1]);
if (!entries.length) return;
if (state.charts.pie) state.charts.pie.destroy();
// Labels and slice colours come from SLEEVE_META (defined next to
// sleeveColor) — same table that drives the sleeve badges and feed pills.
const fallback = ['#1E90FF', '#34D399', '#FBBF24', '#A78BFA', '#F87171'];
state.charts.pie = new Chart(ctx, {
type: 'doughnut',
data: { labels: entries.map(([k]) => (SLEEVE_META[k] || {}).label || k), datasets: [{
data: entries.map(([, v]) => v),
backgroundColor: entries.map(([k], i) => (SLEEVE_META[k] || {}).hex || fallback[i % fallback.length]),
borderColor: '#0A1B2E', borderWidth: 2,
}] },
options: { responsive: true, maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { color: '#C0C4CC', font: { size: 11 }, padding: 12 } },
tooltip: { callbacks: { label: (c) => `${c.label}: ${FMT_USD(c.parsed)}` } },
} },
});
}
// Closes are delegated to the slot's engine over the shared-redis command bus
// (the shared dashboard has no exchange creds), so they're async: the endpoint
// waits ~8s for the engine ack, else returns "pending". `symbol` is the
// engine's internal key (XRPUSDT), sent in the BODY — never the URL path,
// because ccxt symbols contain '/' which 405s against the static mount.
// Outcome dialogs on the close / kill-switch money paths go through
// confirmModal, never native alert(): Chrome's "prevent this page from
// creating additional dialogs" checkbox turns alert() into a silent no-op,
// so an "engine could not close" result would vanish and the operator would
// read the still-open row as a pending close. Same reasoning as confirmModal
// itself (see its comment) — a money path must always have a visible failure
// surface.
async function noticeModal(title, message, danger) {
await confirmModal({
title: title,
bodyHtml: `${escapeHtml(String(message))}
`,
confirmLabel: 'Close',
danger: !!danger,
});
}
function _closeResultMsg(r, label) {
if (r && r.status === 'done') return `${label} closed.`;
if (r && r.status === 'pending') return `${label}: close requested — the engine will action it within a few seconds.`;
if (r && r.status === 'error') return `${label}: engine could not close — ${(r.result && r.result.error) || 'unknown error'}`;
return `${label}: close requested.`;
}
async function closePosition(symbol) {
if (!(await confirmModal({
title: `Close ${symbol}?`,
bodyHtml: 'Market-closes this position at the venue.',
confirmLabel: 'Close position',
danger: true
}))) return;
try {
const r = await api('/api/positions/close', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ symbol, confirm: true }) });
const failed = !r || r.status === 'error';
await noticeModal(failed ? `Close ${symbol} failed` : `Close ${symbol}`, _closeResultMsg(r, symbol), failed);
refreshAll(true);
} catch (e) { await noticeModal(`Close ${symbol} failed`, e.message, true); }
}
async function closeAllPositions() {
if (!(await confirmModal({
title: `Close ALL ${state.positions.length} positions?`,
bodyHtml: 'Market-closes every open position at the venue.',
confirmLabel: 'Close all',
danger: true
}))) return;
try {
const r = await api('/api/positions/close-all', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ confirm: true }) });
const failed = !r || r.status === 'error';
await noticeModal(failed ? 'Close all failed' : 'Close all positions', _closeResultMsg(r, 'All positions'), failed);
refreshAll(true);
} catch (e) { await noticeModal('Close all failed', e.message, true); }
}
async function resetKillSwitch() {
if (!(await confirmModal({
title: 'Reset kill switch?',
bodyHtml: 'Trading resumes immediately for this slot.',
confirmLabel: 'Reset — resume trading',
danger: true
}))) return;
try { await api('/api/risk/reset-kill-switch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ confirm: true }) }); refreshAll(true); }
catch (e) { await noticeModal('Kill-switch reset failed', e.message, true); }
}
// The slot the live socket should stream — the same rule api() applies to
// /api/* calls: name the slot only when a NON-local one is selected, null
// otherwise. The two rules must agree or the socket and the panels show
// different slots. Server side (ws_live in app.py) a scoped viewer's single
// grant fills in for a missing param; anything else ungranted is closed 1008.
function wsExperimentParam() {
const exp = window.currentExperiment;
return (exp && exp !== window._localExperimentId) ? exp : null;
}
function connectWS() {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const exp = wsExperimentParam();
const url = `${proto}://${window.location.host}/ws/live`
+ (exp ? '?experiment=' + encodeURIComponent(exp) : '');
const ws = new WebSocket(url);
ws._experiment = exp; // what this socket was dialled for; reconnectWS() compares against it
window._liveWs = ws;
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === 'portfolio_state') {
// SCOPE GUARD: every push names the slot it is for (null = the
// dashboard's own slot, the ?experiment= convention). Accept it only
// when that is the slot the page is showing. A socket dialled before
// a slot switch keeps pushing the OLD slot until reconnectWS()
// replaces it, and 2026-05-17's "I see slot A's data while scoped in
// slot B" was exactly such a push overwriting the remote slot's
// panels. Compared as effective slots so null and the local id match.
const local = window._localExperimentId;
const want = wsExperimentParam() || local;
const got = msg.experiment || local;
if (want !== got) return;
state.portfolio = msg.data;
if (state.view === 'overview') updateChrome();
}
} catch (e) {}
};
ws.onclose = (ev) => {
// reconnectWS() closed this socket on purpose and has already dialled
// its replacement — scheduling another dial here would leave two
// sockets pushing.
if (ws._superseded) return;
// 1008 (policy violation) is the server's /ws/live gate refusing this
// principal, or this slot — see ws_live() in app.py, which accepts and
// then closes 1008. That verdict does not change until the operator
// signs in again or picks another slot (reconnectWS re-dials then), so
// re-dialling every 3s just pins one socket handshake per client per 3s
// on the single-worker dashboard forever. Stop; a reload, a fresh login
// or a slot switch starts a new dial.
if (ev && ev.code === 1008) {
window._wsPolicyClosed = true;
return;
}
// Retry only if nothing replaced this socket in the meantime: a slot
// switch during the 3s wait already dialled, and a second dial here
// would double the pushes (and the server-side portfolio_state loops).
setTimeout(() => { if (window._liveWs === ws) connectWS(); }, 3000);
};
}
function reconnectWS() {
// The socket is dialled for ONE slot (the ?experiment= it sent): the
// server holds that slot for the life of its loop, exactly as the HTTP
// middleware holds it per request, so a slot switch needs a new dial.
// No-op while the current socket already serves the wanted slot —
// compared as effective slots, because the boot dial happens before the
// registry has said which slot is local. Nothing to replace if nothing
// was dialled yet (the boot sequence below dials unconditionally).
const cur = window._liveWs;
if (!cur) return;
const local = window._localExperimentId;
if ((cur._experiment || local) === (wsExperimentParam() || local)) return;
// A 1008 latched on the OLD slot; the new one may well be granted.
window._wsPolicyClosed = false;
cur._superseded = true;
try { cur.close(); } catch (_) {}
connectWS();
}
lucide.createIcons();
// Boot order: load the experiment registry FIRST (so api() knows which
// slot to query), then do the initial data refresh. The slot selector
// renders a skeleton in the meantime — no blank flash.
(async () => {
await loadExperimentRegistry();
await refreshAll();
await updateSlotMetrics();
})();
// Single identity fetch drives BOTH nav gates (per-item nav_views and the
// staff-only MLOPS group) — see applyRoleNav. Fail-open on any error: the
// server-side tiers are the actual enforcement.
applyRoleNav();
// Periodic refresh: full data every 30s, slot metrics every 60s
setInterval(refreshAll, 30000);
setInterval(updateSlotMetrics, 60000);
connectWS();
// Keyboard navigation for the slot selector: arrow keys cycle through
// enabled slots when focus is inside #slot-selector.
document.addEventListener('keydown', (e) => {
const inSlotSel = e.target && e.target.closest && e.target.closest('#slot-selector');
if (!inSlotSel) return;
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const enabled = (window._experimentSlots || []).filter(s => s.enabled);
if (enabled.length < 2) return;
const cur = enabled.findIndex(s => s.id === window.currentExperiment);
const dir = e.key === 'ArrowDown' ? 1 : -1;
const next = enabled[(cur + dir + enabled.length) % enabled.length];
setExperiment(next.id);
});