Files
momentum-system/api/static/dashboard.html
T
momentum devandClaude Fable 5 72a9ecbd0b fix: reasoning panel flicker + stuck loading on live ticks
Every SSE snapshot re-render rebuilt the expanded panel's HTML back to
'loading…' and re-fetched — panels flickered per tick and never looked
settled. Rendered panels now cache per symbol: re-renders repaint from
cache instantly, background refresh at most once/min, single-flight
guard against fetch races. Watch panel and trigger panel share the
cache; toggleCriteria's duplicate direct fetch removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 20:36:31 -05:00

1980 lines
110 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Momentum — Trading Control Room</title>
<style>
:root {
--bg: #080a10;
--panel: #111622;
--panel-2: #161c2a;
--panel-hi: #1c2435;
--border: #232c41;
--border-soft: #18202f;
--text: #e6ebf5;
--muted: #93a0bd;
--sans: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
--accent: #5ba3ff;
--accent-dim: #2a4a78;
--green: #34d17e;
--red: #ff5a60;
--amber: #f7b13b;
--purple: #c2a6e2;
--glow-green: rgba(52,209,126,.5);
--glow-red: rgba(255,90,96,.45);
--shadow: 0 8px 24px -12px rgba(0,0,0,.7), 0 1px 0 0 rgba(255,255,255,.035) inset;
--mono: "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
/* first-paint loading overlay — the snapshot does real Postgres work, so a
blank page reads as broken; this shows it's actively fetching. */
.loader {
position: fixed; inset: 0; z-index: 9999; display: flex;
align-items: center; justify-content: center;
background:
radial-gradient(1100px 560px at 82% -12%, rgba(30,58,110,.30), transparent 60%),
radial-gradient(820px 480px at -5% -5%, rgba(60,40,120,.18), transparent 55%),
#080a10;
transition: opacity .45s ease; }
.loader.hide { opacity: 0; pointer-events: none; }
.loader-card { text-align: center; }
.loader-title { font: 700 18px/1 var(--mono); letter-spacing: 1.5px;
color: var(--text); text-transform: uppercase; }
.loader-title span { color: var(--accent); font-weight: 600; }
.loader-spin { width: 34px; height: 34px; margin: 22px auto 18px;
border: 3px solid var(--border); border-top-color: var(--accent);
border-radius: 50%; animation: loader-spin .8s linear infinite; }
.loader.err .loader-spin { border-top-color: var(--red); animation-play-state: paused; }
@keyframes loader-spin { to { transform: rotate(360deg); } }
.loader-msg { font: 600 12px/1.4 var(--mono); color: var(--text); letter-spacing: .3px; }
.loader.err .loader-msg { color: var(--red); }
.loader-sub { margin-top: 6px; font: 500 11px/1.4 var(--mono); color: var(--muted); }
@media (prefers-reduced-motion: reduce) { .loader-spin { animation: none; } }
body {
background:
radial-gradient(1100px 560px at 82% -12%, rgba(30,58,110,.28), transparent 60%),
radial-gradient(820px 480px at -5% -5%, rgba(60,40,120,.16), transparent 55%),
var(--bg);
background-attachment: fixed;
color: var(--text);
font: 14px/1.5 -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar { width: 11px; height: 11px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #283246; border-radius: 7px; border: 3px solid transparent; background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background: #38456180; background-clip: padding-box; }
::selection { background: rgba(91,163,255,.28); }
/* thin live accent line along the very top edge */
body::before {
content: ""; position: fixed; top: 0; left: 0; right: 0; height: 2px; z-index: 40;
background: linear-gradient(90deg, #5ba3ff, #34d17e, #5ba3ff, #c2a6e2, #5ba3ff);
background-size: 280% 100%; opacity: .55;
animation: shimmer 14s linear infinite;
}
@keyframes shimmer { to { background-position: 280% 0; } }
header {
display: flex; align-items: center; gap: 14px;
padding: 13px 24px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, rgba(20,26,40,.82), rgba(8,10,16,.72));
backdrop-filter: blur(14px) saturate(150%);
-webkit-backdrop-filter: blur(14px) saturate(150%);
position: sticky; top: 0; z-index: 20;
box-shadow: 0 1px 0 rgba(255,255,255,.04), 0 10px 30px -20px #000;
}
header h1 { font-size: 16px; font-weight: 700; letter-spacing: .6px; }
header h1 span { color: var(--accent); font-weight: 600; }
.badge {
font: 600 11px/1 var(--mono); letter-spacing: .8px; text-transform: uppercase;
padding: 5px 11px; border-radius: 6px; border: 1px solid;
}
.badge.paper { color: var(--accent); border-color: #2a4a78; background: #12243d; }
.badge.live { color: #ff8589; border-color: #6e2226; background: #341417; }
/* operator kill switch (plan 007) — ghost buttons; solid fill = armed/active */
.op-btn { font: 700 11px var(--mono); letter-spacing: .6px; border-radius: 4px;
padding: 5px 10px; cursor: pointer; background: transparent; border: 1px solid; }
.op-flat { color: var(--red); border-color: var(--red); }
.op-flat.armed { color: var(--text); background: var(--red); }
.op-pause { color: var(--amber); border-color: var(--amber); }
.op-pause.on { color: var(--bg); background: var(--amber); }
#op-token { width: 104px; background: var(--panel-2); border: 1px solid var(--border);
border-radius: 4px; color: var(--text); padding: 5px 8px; font: 600 11px var(--mono); }
#op-token:focus { outline: none; border-color: var(--accent); }
.pause-banner { display: none; padding: 7px 24px; text-align: center;
background: var(--panel-2); color: var(--amber); border-bottom: 1px solid var(--amber);
font: 700 12px var(--mono); letter-spacing: .5px; }
.pause-banner.show { display: block; }
.btn-exit { background: transparent; color: var(--red); border: 1px solid var(--red);
border-radius: 4px; padding: 3px 8px; font: 600 11px var(--mono); cursor: pointer; }
.btn-exit.armed { color: var(--text); background: var(--red); }
/* live-mode (broker truth): the top shimmer line turns solid red */
body.live-mode::before { background: var(--red); opacity: .6; animation: none; }
.spacer { flex: 1; }
.stamp { color: var(--muted); font: 12px var(--mono); }
.conn { display: flex; align-items: center; gap: 7px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); transition: background .3s; }
.dot.live { background: var(--green); box-shadow: 0 0 8px rgba(47,191,113,.6); animation: pulse 2s infinite; }
.dot.err { background: var(--red); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .45; } }
.hdr-btn {
font: 600 11px var(--mono); letter-spacing: .5px;
background: var(--panel-2); color: var(--muted);
border: 1px solid var(--border); border-radius: 4px;
padding: 5px 9px; cursor: pointer;
}
.hdr-btn.on { color: var(--accent); border-color: #2a4a78; }
.hdr-sel { margin-left: 10px; background: var(--panel, #0d1117); color: var(--text);
border: 1px solid var(--border); border-radius: 5px; padding: 4px 8px;
font: 600 11px var(--mono); cursor: pointer; }
.hdr-sel.histor { color: #f1c40f; border-color: #6b5a16; }
/* P&L strip */
.pnl-strip {
display: flex; gap: 0; flex-wrap: wrap;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, rgba(20,26,40,.6), rgba(13,17,26,.4));
}
.pnl-cell {
flex: 1; min-width: 120px;
padding: 11px 20px;
border-right: 1px solid var(--border-soft);
transition: background .2s ease;
}
.pnl-cell:hover { background: rgba(91,163,255,.04); }
/* labels in the UI sans, values in mono — two textures so label/value pairs
parse at a glance instead of dissolving into one mono soup */
.pnl-cell .label { font: 600 11px var(--sans); text-transform: uppercase; letter-spacing: .5px; color: var(--muted); }
.pnl-cell .value { font: 650 22px var(--mono); margin-top: 4px; letter-spacing: -.3px; font-variant-numeric: tabular-nums; }
/* signature: TOTAL P&L is the one instrument that dominates, and its cell
carries a state wash (green/red) readable from across the room */
.pnl-cell:first-child .value { font-size: 34px; }
.pnl-cell:first-child:has(.value.pos) { background: linear-gradient(180deg, rgba(52,209,126,.10), transparent 75%); box-shadow: inset 3px 0 0 var(--green); }
.pnl-cell:first-child:has(.value.neg) { background: linear-gradient(180deg, rgba(255,90,96,.10), transparent 75%); box-shadow: inset 3px 0 0 var(--red); }
.funds-avail { display: block; font: 500 11px var(--sans); color: var(--muted); margin-top: 2px; letter-spacing: .2px; }
#pnl-total { font-size: 27px; font-weight: 760; }
.pos { color: var(--green); text-shadow: 0 0 20px var(--glow-green); }
.neg { color: var(--red); text-shadow: 0 0 20px var(--glow-red); }
main {
display: grid; grid-template-columns: repeat(12, 1fr);
gap: 14px; padding: 16px 22px 56px; max-width: 1600px; margin: 0 auto;
}
.panel {
grid-column: span 6;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--border); border-radius: 12px; overflow: hidden;
box-shadow: var(--shadow);
transition: border-color .2s ease, box-shadow .2s ease;
}
.panel:hover { border-color: #2e3a55; box-shadow: 0 12px 30px -12px rgba(0,0,0,.75), 0 1px 0 0 rgba(255,255,255,.04) inset; }
.panel.full { grid-column: span 12; }
.panel h2 {
font-size: 12px; font-weight: 700; letter-spacing: 1.1px; text-transform: uppercase;
color: #9aa6c4; padding: 12px 16px; border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, var(--panel-hi), rgba(22,28,42,.5));
display: flex; align-items: center; gap: 9px;
}
.panel h2::before {
content: ""; width: 3px; height: 13px; border-radius: 2px;
background: linear-gradient(180deg, var(--accent), #2a6fd6); flex-shrink: 0;
box-shadow: 0 0 8px rgba(91,163,255,.5);
}
/* collapsible panels: <details class="panel"><summary><h2>..</h2></summary>..</details> */
details.panel > summary { list-style: none; cursor: pointer; outline: none; }
details.panel > summary::-webkit-details-marker { display: none; }
details.panel > summary > h2 { margin: 0; }
details.panel > summary > h2::after {
content: "▾"; margin-left: 12px; color: var(--muted); font-size: 11px;
flex-shrink: 0; transition: color .15s ease;
}
details.panel:not([open]) > summary > h2::after { content: "▸"; }
details.panel:not([open]) > summary > h2 { border-bottom: none; }
details.panel > summary:hover > h2 { color: #c3cde6; }
details.panel > summary:hover > h2::after { color: var(--accent); }
details.panel[open] > summary > h2 { background: linear-gradient(180deg, var(--panel-hi), rgba(22,28,42,.5)); }
#panels-toggle { margin-left: 4px; }
.count { font: 600 11px var(--mono); background: var(--border); color: var(--text); border-radius: 10px; padding: 1px 8px; }
.hint { margin-left: auto; font: 500 11px var(--sans); color: var(--muted); letter-spacing: .2px; text-transform: none; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; font: 600 11px var(--sans); text-transform: uppercase; letter-spacing: .5px; color: var(--muted); padding: 8px 14px; border-bottom: 1px solid var(--border); }
td { padding: 8px 14px; border-bottom: 1px solid #151a26; font-size: 13px; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
.num { font-family: var(--mono); }
.sym { font: 600 13px var(--mono); color: #e9edf5; }
.empty { color: var(--muted); padding: 18px 14px; font-size: 13px; }
.pill { display: inline-block; font: 600 10px var(--mono); text-transform: uppercase; letter-spacing: .6px; padding: 3px 9px; border-radius: 5px; border: 1px solid transparent; }
.pill.ready { background: #103225; color: var(--green); border-color: #1d5a40; }
.pill.watching { background: #13283f; color: var(--accent); border-color: #244a74; }
.pill.blocked { background: #34211a; color: var(--amber); border-color: #5c3d1c; }
.pill.late { background: #28223040; color: var(--purple); border-color: #3d3450; }
.pill.discovered { background: #1a2130; color: var(--muted); border-color: #28324a; }
.pill.long { background: #103225; color: var(--green); border-color: #1d5a40; }
.pill.short { background: #361719; color: #ff8589; border-color: #5e2327; }
button { font: 600 12px -apple-system, "Segoe UI", sans-serif; border: 1px solid transparent; border-radius: 6px; padding: 6px 13px; cursor: pointer; transition: filter .12s, transform .1s, box-shadow .12s; }
button:hover { filter: brightness(1.16); transform: translateY(-1px); }
button:active { transform: translateY(0); }
/* accessibility (UI-REVIEW fix 2): keyboard operators must SEE focus,
esp. on money-path controls (approve/reject/exit/flatten). */
button:focus-visible, .btn-exit:focus-visible, .btn-approve:focus-visible,
.btn-reject:focus-visible, input:focus-visible, summary:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px; }
button:disabled { opacity: .4; cursor: default; transform: none; }
.btn-approve { background: linear-gradient(180deg, #1a5238, #134029); color: #7defb4; border-color: #25714d; }
.btn-approve:hover { box-shadow: 0 4px 14px -6px rgba(52,209,126,.5); }
.btn-reject { background: linear-gradient(180deg, #45222540, #34191b); color: #ff9296; border-color: #6e2226; }
.btn-exit { background: linear-gradient(180deg, #43301440, #33260f); color: #ffc456; border-color: #6b4e1f; }
.actions { display: flex; gap: 6px; }
/* subtle load-in */
main > .panel { animation: rise .45s cubic-bezier(.2,.7,.3,1) both; }
@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
/* sparkline canvas */
.spark { display: block; width: 132px; height: 38px; }
.spark-wrap { position: relative; }
.spark-meta { font: 500 9px var(--mono); color: var(--muted); margin-top: 1px; }
/* expandable criteria row */
.expandable { cursor: pointer; }
.expandable:hover { background: #131826; }
.caret { display: inline-block; width: 10px; color: var(--muted); transition: transform .15s; }
.caret.open { transform: rotate(90deg); }
.criteria-cell { padding: 0 !important; background: #0d111a; }
.criteria-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; padding: 12px 16px; }
.crit { display: flex; align-items: center; gap: 7px; font: 500 12px var(--mono); }
.crit .mark { width: 15px; height: 15px; border-radius: 3px; display: grid; place-items: center; font-size: 10px; font-weight: 700; flex-shrink: 0; }
.crit.pass .mark { background: #12362a; color: var(--green); }
.crit.fail .mark { background: #38221a; color: var(--amber); }
.crit.skip .mark { background: #1c2230; color: var(--muted); }
.crit.fail { color: #c9a98a; }
.crit.skip { color: var(--muted); }
/* Decision Trace panel (plan 014) — per-gate chips + filterable receipt rows */
.dec-row { display: flex; align-items: center; gap: 8px; padding: 7px 14px; border-bottom: 1px solid #151a26; font: 500 12px var(--mono); flex-wrap: wrap; }
.dec-row:last-child { border-bottom: none; }
.gate-chip { display: inline-block; font: 600 10px var(--mono); padding: 2px 7px; border-radius: 4px; margin: 1px 2px; }
/* reasoning-over-time panel (criteria drill-down) */
.rsn-block { padding: 10px 16px 2px; }
.rsn-title { font: 600 11px var(--sans); text-transform: uppercase; letter-spacing: .4px;
color: var(--muted); margin-bottom: 4px; }
.rsn-title b { font: 700 12px var(--mono); }
.rsn-thr { font: 500 10px var(--mono); color: var(--muted); text-transform: none; margin-left: 6px; }
.rsn-svg { display: block; width: 100%; background: rgba(13,17,23,.55);
border: 1px solid var(--border-soft); border-radius: 4px; }
.rsn-line { fill: none; stroke-width: 1.6; vector-effect: non-scaling-stroke; }
.rsn-line.ok { stroke: var(--green); } .rsn-line.bad { stroke: var(--amber); }
.rsn-gate { stroke: var(--muted); stroke-dasharray: 4 4; stroke-width: 1; vector-effect: non-scaling-stroke; }
.lanes { display: flex; gap: 8px; align-items: flex-start; }
.lane-labels { flex: none; width: 108px; }
.lane-label { font: 500 10px var(--sans); color: var(--muted); overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; }
.lanes-svg { flex: 1; }
.lane-on { fill: rgba(52,209,126,.75); } .lane-off { fill: rgba(255,90,96,.28); }
.criteria-grid.with-why { display: block; }
.criteria-grid.with-why .crit { display: flex; gap: 8px; align-items: baseline; }
.crit-name { font: 600 11px var(--mono); min-width: 118px; }
.crit-why { font: 500 11px var(--sans); color: var(--muted); overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
.gate-chip.pass { background: #12362a; color: var(--green); }
.gate-chip.fail { background: #38221a; color: var(--red); }
/* risk preview chip */
.risk { font: 500 11px var(--mono); color: var(--muted); }
.risk b { color: var(--text); font-weight: 600; }
/* naked-position alarm banner + portfolio risk strip */
.naked-banner { display: none; background: #b00020; color: #fff;
font: 700 13px var(--mono); padding: 9px 14px; text-align: center; letter-spacing: .4px; }
.naked-banner.show { display: block; animation: nakedpulse 1.1s ease-in-out infinite; }
@keyframes nakedpulse { 50% { background: #d32f3f; } }
.risk-strip { display: flex; gap: 20px; padding: 7px 14px; align-items: center;
border-bottom: 1px solid var(--border); font: 500 11px var(--mono); }
.risk-strip .label { color: var(--muted); text-transform: uppercase; font-size: 10px;
letter-spacing: .6px; margin-right: 5px; }
.risk-strip b { color: var(--text); font-weight: 650; }
.rdot { font-weight: 700; }
.rdot.ok { color: #34d17e; text-shadow: 0 0 12px rgba(46,204,113,.45); }
.rdot.warn { color: #f1c40f; text-shadow: 0 0 12px rgba(241,196,15,.45); }
.rdot.critical { color: #ff5a60; text-shadow: 0 0 12px rgba(255,77,79,.5); }
/* armed-trigger proximity radar */
.trig-row { display: flex; align-items: center; gap: 12px; padding: 9px 8px;
border-bottom: 1px solid var(--border); font: 600 12px var(--mono); }
.trig-row:last-child { border-bottom: 0; }
.trig-row:nth-child(even) { background: rgba(255,255,255,.016); }
.trig-row { cursor: pointer; }
.trig-row:hover { background: rgba(91,163,255,.05); }
.trig-row.open { background: rgba(91,163,255,.07); border-bottom: 0; }
.trig-detail { border-bottom: 1px solid var(--border-soft);
background: rgba(13,17,23,.4); padding-bottom: 6px; }
.trig-sym { width: 68px; font-weight: 700; font-size: 15px; letter-spacing: .2px; }
.trig-state { width: 56px; font-size: 9px; font-weight: 700; text-transform: uppercase;
letter-spacing: .5px; text-align: center; padding: 3px 0; border-radius: 4px;
background: var(--panel-hi); }
.trig-state[data-state="armed"], .trig-state.armed { background: rgba(91,163,255,.16); color: var(--accent); }
/* single-line meta with ellipsis — the 3-line "gap 836% 3277.8x tod" crumb wrap
was the worst readability offender in the panel */
.trig-meta { width: 148px; color: var(--muted); font: 500 11px var(--mono);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.trig-levels { width: 218px; color: var(--muted); font: 500 11px var(--mono);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.trig-levels b { color: var(--text); font-weight: 700; }
.trig-bar { flex: 1; height: 10px; position: relative; background: #0d1117;
border: 1px solid var(--border-soft); border-radius: 5px; overflow: hidden; min-width: 120px; }
.trig-fill { position: absolute; left: 0; top: 0; bottom: 0; transition: width .35s ease; }
.trig-trigline { position: absolute; top: -1px; bottom: -1px; width: 2px; background: #e6edf3;
box-shadow: 0 0 4px rgba(230,237,243,.6); }
.trig-dist { width: 70px; text-align: right; font-weight: 700; }
.trig-cat { width: 216px; color: var(--accent); font: 500 11px var(--sans); overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
.trig-adv { font: 600 10px var(--mono); color: #c9d3e6; }
.trig-cat-raw { color: var(--muted); font: 500 10px var(--mono); }
/* catalyst engine status chip in the Armed Triggers header */
.cat-engine { margin-left: 10px; font: 600 10px var(--mono); color: var(--muted);
text-transform: none; letter-spacing: .2px; }
.cat-engine b { color: var(--text); font-weight: 700; }
.cat-engine b.on { color: var(--green); } .cat-engine b.off { color: var(--muted); }
.cat-engine .neg { color: var(--red); }
.cat-engine .cat-off { color: var(--muted); }
/* Catalyst Engine feed — one row per LLM read */
.cf-row { display: flex; align-items: baseline; gap: 10px; padding: 5px 0;
border-bottom: 1px solid var(--border); font-size: 11px; }
.cf-row:last-child { border-bottom: 0; }
.cf-row.cf-veto { background: rgba(248,81,73,.06); }
.cf-time { width: 44px; color: var(--muted); font: 500 10px var(--mono); flex: none; }
.cf-sym { width: 52px; font-weight: 700; flex: none; }
.cf-read { width: 188px; font: 600 10px var(--mono); color: #c9d3e6; flex: none;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cf-nums { width: 40px; text-align: right; flex: none; color: var(--muted); font: 500 10px var(--mono); }
.cf-text { flex: 1; min-width: 0; }
.cf-head { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* one-click trace-back to the source article (NN/g: citations visible and
styled distinctly, next to the claim) */
.cf-link { color: inherit; text-decoration: underline dotted; text-decoration-color: var(--accent); text-underline-offset: 3px; }
.cf-link:hover { color: var(--accent); }
.cf-why { color: var(--muted); font-style: italic; font-size: 10px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
/* clickable engine chip + its filter indicator */
.cat-engine.clickable { cursor: pointer; }
.cat-engine.clickable:hover { color: var(--text); }
.cat-filter { color: var(--accent); font-weight: 700; }
/* ready-signals catalyst cell */
.rs-cat { font: 600 10px var(--mono); color: #c9d3e6; max-width: 190px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rs-cat .muted { color: var(--muted); }
/* open-positions unrealized P&L cell: $ + % + colored magnitude bar */
.pl-cell { min-width: 96px; }
.pl-top { font: 600 12px var(--mono); white-space: nowrap; }
.pl-cell.pos .pl-top { color: var(--green); } .pl-cell.neg .pl-top { color: var(--red); }
.pl-pct { font-size: 10px; opacity: .8; }
.pl-bar { margin-top: 3px; height: 4px; background: #0d1117; border-radius: 2px; overflow: hidden; }
.pl-bar-fill { height: 100%; transition: width .35s ease; }
/* all-time scorecard + per-day trades */
.metric .value.pos { color: var(--green); } .metric .value.neg { color: var(--red); }
.metric .value .sub { font: 500 10px var(--mono); color: var(--muted); margin-left: 2px; }
.exit-tag { font: 600 9px var(--mono); text-transform: uppercase; letter-spacing: .4px;
padding: 2px 6px; border-radius: 3px; white-space: nowrap; }
.exit-tag.ex-stop { color: #ff8d8f; background: rgba(248,81,73,.12); }
.exit-tag.ex-tp { color: #5fd39a; background: rgba(52,209,126,.12); }
.exit-tag.ex-mkt { color: var(--muted); background: rgba(120,132,162,.12); }
#trades-net b.pos { color: var(--green); } #trades-net b.neg { color: var(--red); }
/* daily performance waterfall / equity curve */
.daily-svg { display: block; max-width: 100%; }
.daily-col { cursor: pointer; }
.daily-hit { fill: transparent; }
.daily-col:hover .daily-hit { fill: rgba(91,163,255,.07); }
.daily-col.daily-sel .daily-hit { fill: rgba(91,163,255,.12); }
.daily-lbl { fill: var(--muted); font: 500 9px var(--mono); text-anchor: middle; }
.daily-lbl.daily-sel { fill: var(--accent); font-weight: 700; }
.st-armed .trig-state { color: #58a6ff; } .st-armed .trig-fill { background: linear-gradient(90deg,#1f6feb,#58a6ff); }
.st-fired .trig-state { color: #34d17e; } .st-fired .trig-fill { background: #34d17e; }
.st-filled .trig-state { color: #34d17e; } .st-filled .trig-fill { background: linear-gradient(90deg,#1f8f4e,#34d17e); }
.st-weak .trig-state { color: #7d8590; } .st-weak .trig-fill { background: #39414d; }
.st-waiting .trig-state { color: #7d8590; } .st-waiting .trig-fill { background: #30363d; }
.st-armed.near .trig-bar { box-shadow: 0 0 0 1px #f1c40f55; }
/* intraday equity sparkline */
.pnl-spark { flex: 1.5 1 200px; }
.eqspark { margin-top: 2px; } .eqspark svg { display: block; }
/* per-position R / risk bar */
.rbar { position: relative; display: inline-block; width: 72px; height: 13px;
background: #0d1117; border: 1px solid var(--border); border-radius: 2px;
vertical-align: middle; overflow: hidden; }
.rtxt { font: 700 11px var(--mono); margin-left: 7px; vertical-align: middle; }
/* activity feed */
.feed-row { display: flex; align-items: center; gap: 10px; padding: 7px 14px; border-bottom: 1px solid #151a26; font: 500 12px var(--mono); }
.feed-row:last-child { border-bottom: none; }
.feed-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.feed-time { color: var(--muted); font-size: 11px; min-width: 64px; }
.toast { position: fixed; bottom: 20px; right: 20px; background: var(--panel-2); border: 1px solid var(--border); border-left: 3px solid var(--accent); padding: 11px 16px; border-radius: 6px; font-size: 13px; max-width: 380px; opacity: 0; transform: translateY(8px); transition: all .25s; pointer-events: none; z-index: 50; }
.toast.show { opacity: 1; transform: none; }
.toast.err { border-left-color: var(--red); }
.toast.ok { border-left-color: var(--green); }
.acct-row { display: flex; gap: 26px; padding: 12px 14px; flex-wrap: wrap; }
.metric { min-width: 120px; }
.metric .label { font: 600 10px var(--mono); text-transform: uppercase; letter-spacing: .7px; color: var(--muted); }
.metric .value { font: 600 17px var(--mono); margin-top: 2px; }
.inject { display: flex; gap: 6px; padding: 10px 14px; border-bottom: 1px solid var(--border); }
.inject input { flex: 1; background: #0d111a; border: 1px solid var(--border); border-radius: 5px; color: var(--text); padding: 6px 10px; font: 600 12px var(--mono); text-transform: uppercase; }
.inject input:focus { outline: none; border-color: var(--accent); }
.inject button { background: #14283f; color: var(--accent); border-color: #2a4a78; }
/* keyboard help overlay */
.kbd-help { position: fixed; inset: 0; background: rgba(6,8,12,.82); display: none; place-items: center; z-index: 60; }
.kbd-help.show { display: grid; }
.kbd-card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 24px 28px; max-width: 420px; }
.kbd-card h3 { font-size: 14px; margin-bottom: 14px; letter-spacing: .5px; }
.kbd-card .row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 13px; color: var(--muted); }
kbd { background: var(--panel-2); border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px; padding: 2px 7px; font: 600 12px var(--mono); color: var(--text); }
/* symbol drill-in chart modal (plan 013) */
.chart-modal { position: fixed; inset: 0; background: rgba(6,8,12,.82); display: none; place-items: center; z-index: 70; }
.chart-modal.show { display: grid; }
.chart-card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
width: min(920px, 94vw); height: min(560px, 88vh); display: flex; flex-direction: column; overflow: hidden; }
.chart-hdr { display: flex; align-items: center; gap: 10px; padding: 10px 16px;
border-bottom: 1px solid var(--border); flex-shrink: 0; }
.chart-hdr .sym { font: 700 15px var(--mono); letter-spacing: .3px; }
.chart-hdr .close { margin-left: auto; background: transparent; border: 1px solid var(--border);
color: var(--muted); border-radius: 4px; padding: 4px 10px; cursor: pointer; font: 600 11px var(--mono); }
.chart-hdr .close:hover { color: var(--text); border-color: var(--accent); }
#chart-container { flex: 1; min-height: 0; }
.chart-ftr { padding: 6px 16px; border-top: 1px solid var(--border-soft); font: 500 10px var(--mono);
color: var(--muted); flex-shrink: 0; }
.chart-ftr a { color: var(--accent); text-decoration: none; }
.chart-ftr a:hover { text-decoration: underline; }
td.sym { cursor: pointer; }
td.sym:hover { color: var(--accent); }
@media (max-width: 1040px) { .panel { grid-column: span 12; } }
/* ---- phones / small screens ---- */
@media (max-width: 640px) {
header { flex-wrap: wrap; padding: 10px 14px; gap: 8px 10px; }
header h1 { font-size: 14px; }
#sound-toggle, #kbd-toggle, .spacer { display: none; } /* desktop-only conveniences */
.hdr-sel { margin-left: 0; }
.conn { margin-left: auto; }
.stamp { font-size: 11px; }
.risk-strip { flex-wrap: wrap; gap: 6px 16px; padding: 8px 14px; }
/* P&L: 2-up grid so cells don't crush; day curve gets its own full row */
.pnl-strip { display: grid; grid-template-columns: 1fr 1fr; }
.pnl-cell { border-right: 1px solid var(--border-soft);
border-bottom: 1px solid var(--border-soft); padding: 9px 14px; }
.pnl-cell .value { font-size: 18px; }
#pnl-total { font-size: 22px; }
.pnl-cell.pnl-spark { grid-column: 1 / -1; }
main { padding: 10px 10px 44px; gap: 10px; }
.panel { border-radius: 10px; }
.panel h2 { padding: 10px 13px; font-size: 11px; }
.panel h2 .hint { display: none; } /* secondary helper text — save width */
/* wide tables scroll horizontally instead of crushing to multi-line cells */
.panel > div { overflow-x: auto; -webkit-overflow-scrolling: touch; }
th, td { white-space: nowrap; padding: 7px 11px; }
/* armed-trigger radar: drop the wide meta/catalyst cols, keep sym+bar+dist */
.trig-meta, .trig-cat { display: none; }
.trig-sym { width: 46px; }
.trig-state { width: 40px; }
.trig-bar { min-width: 70px; }
.trig-dist { width: 54px; }
.acct-row { gap: 12px 16px; }
.feed-time { min-width: 54px; }
.toast { left: 14px; right: 14px; bottom: 14px; max-width: none; }
/* catalyst feed: let the headline+why wrap instead of truncating; drop the
narrow conviction column to save width */
.cf-row { flex-wrap: wrap; gap: 4px 8px; }
.cf-read { width: auto; flex: 1 1 auto; min-width: 0; }
.cf-nums { display: none; }
.cf-text { flex: 1 1 100%; }
.cf-head, .cf-why { white-space: normal; }
.cat-engine { display: block; margin: 4px 0 0; width: 100%; }
/* fat-finger-friendly tap targets */
button, .btn-exit, .btn-approve, .btn-reject, .inject button { min-height: 38px; padding: 8px 14px; }
.hdr-sel, #date-select { min-height: 36px; }
}
</style>
</head>
<body>
<div id="loader" class="loader">
<div class="loader-card">
<div class="loader-title">MOMENTUM <span>/ control room</span></div>
<div class="loader-spin"></div>
<div id="loader-msg" class="loader-msg">connecting to the control room…</div>
<div id="loader-sub" class="loader-sub">pulling the live snapshot — P&amp;L, triggers, catalysts</div>
</div>
</div>
<header>
<h1>MOMENTUM <span>/ control room</span></h1>
<span id="mode-badge" class="badge paper">PAPER</span>
<button id="flatten-all" class="op-btn op-flat" aria-label="Flatten all positions and cancel resting entries" style="display:none" title="Cancel resting entries + market-close every position (blocks re-entry for the session)">FLATTEN ALL</button>
<button id="pause-entries" class="op-btn op-pause" aria-label="Pause or resume new entries" style="display:none" title="Pause / resume new entries on both entry paths (exits + management still run)">PAUSE ENTRIES</button>
<input id="op-token" type="password" placeholder="op token" style="display:none" title="DASHBOARD_TOKEN — required to send operator commands" autocomplete="off">
<select id="date-select" class="hdr-sel" title="View LIVE or review a past session"><option value="">● LIVE</option></select>
<div class="spacer"></div>
<button id="sound-toggle" class="hdr-btn" title="Toggle signal alert sound">SOUND OFF</button>
<button id="kbd-toggle" class="hdr-btn" title="Keyboard shortcuts (?)">KEYS</button>
<button id="panels-toggle" class="hdr-btn" title="Expand or collapse every panel">EXPAND ALL</button>
<a href="/backtest" class="hdr-btn" style="text-decoration:none;display:inline-flex;align-items:center" title="Theoretical replay: how the bot would have traded past sessions under current settings">BACKTEST</a>
<div class="conn"><div id="conn-dot" class="dot"></div><span id="stamp" class="stamp">connecting...</span></div>
</header>
<div id="pause-banner" class="pause-banner">&#9208; ENTRIES PAUSED — no new positions (exits &amp; management still run). Click PAUSE ENTRIES to resume.</div>
<div id="naked-banner" class="naked-banner"></div>
<div id="stale-banner" class="naked-banner" role="alert"></div>
<div class="risk-strip" id="risk-strip">
<div><span class="label">Risk</span><span id="risk-status" class="rdot">—</span></div>
<div><span class="label">Day vs limit</span><b id="risk-day">—</b></div>
<div><span class="label">Data</span><b id="risk-fresh">—</b></div>
</div>
<div class="pnl-strip" id="pnl-strip">
<div class="pnl-cell"><div class="label">Total P&amp;L</div><div class="value num" id="pnl-total">—</div></div>
<div class="pnl-cell"><div class="label">Realized</div><div class="value num" id="pnl-realized">—</div></div>
<div class="pnl-cell"><div class="label">Unrealized</div><div class="value num" id="pnl-unrealized">—</div></div>
<div class="pnl-cell"><div class="label">Win Rate</div><div class="value num" id="pnl-winrate">—</div></div>
<div class="pnl-cell"><div class="label">Avg R</div><div class="value num" id="pnl-avgr">—</div></div>
<div class="pnl-cell"><div class="label">Open / Closed</div><div class="value num" id="pnl-counts">—</div></div>
<div class="pnl-cell"><div class="label">Equity</div><div class="value num" id="pnl-funds">—</div></div>
<div class="pnl-cell pnl-spark"><div class="label">Day Curve</div><div id="equity-spark" class="eqspark">—</div></div>
</div>
<main>
<details class="panel full" id="p-armed" open>
<summary><h2>Armed Triggers <span id="trig-count" class="count">0</span><span id="cat-engine" class="cat-engine"></span><span class="hint">live proximity to the opening-range breakout &middot; closest to fire on top</span></h2></summary>
<div id="armed-triggers"><span class="empty">No armed gappers — waiting for the open / first bars.</span></div>
</details>
<details class="panel full" id="p-positions" open>
<summary><h2>Open Positions <span id="pos-count" class="count">0</span></h2></summary>
<div id="positions"></div>
</details>
<details class="panel" id="p-approval" open>
<summary><h2>Approval Queue <span id="aq-count" class="count">0</span><span class="hint">A approve top &middot; R reject top</span></h2></summary>
<div id="approval-queue"></div>
</details>
<details class="panel" id="p-ready" open>
<summary><h2>Ready Signals <span id="rs-count" class="count">0</span></h2></summary>
<div id="ready-signals"></div>
</details>
<details class="panel" id="p-account">
<summary><h2>Account <span id="acct-count" class="count">0</span></h2></summary>
<div id="accounts" class="acct-row"><span class="empty">No account snapshot yet — waiting for broker sync.</span></div>
</details>
<details class="panel full" id="p-catalyst">
<summary><h2>Catalyst Engine <span id="cat-feed-count" class="count">0</span><span class="hint">what the local LLM read &amp; how it ruled &middot; 🟢 bullish &middot; 🔻 bearish &middot; 🔴 dilutive &middot; hover a row for the model's reasoning</span></h2></summary>
<div id="catalyst-feed"><span class="empty">No catalyst reads yet — the news enrichment loop hasn't classified any headlines.</span></div>
</details>
<details class="panel" id="p-trades">
<summary><h2>Trades <span id="trades-count" class="count">0</span><span id="trades-net" class="hint"></span></h2></summary>
<div id="trades"></div>
</details>
<details class="panel" id="p-watch">
<summary><h2>Watch States <span id="ws-count" class="count">0</span><span class="hint">click a row for criteria</span></h2></summary>
<div class="inject">
<input id="inject-input" placeholder="add symbol..." maxlength="6" autocomplete="off">
<button id="inject-btn">Watch</button>
</div>
<div id="watch-states"></div>
</details>
<details class="panel full" id="p-daily">
<summary><h2>Daily Performance <span id="daily-count" class="count">0</span><span class="hint">per-day P&amp;L bars + cumulative equity curve &middot; click a day to open it</span></h2></summary>
<div id="daily-curve"><span class="empty">No trading days recorded yet.</span></div>
</details>
<details class="panel full" id="p-alltime">
<summary><h2>All-Time Score <span class="hint">cumulative across every closed round-trip — bot's record to date</span></h2></summary>
<div id="alltime" class="acct-row"><span class="empty">No closed trades recorded yet.</span></div>
</details>
<details class="panel" id="p-experiments">
<summary><h2>Experiment Ledger <span id="exp-count" class="count">0</span><span class="hint">pre-registered counterfactuals &middot; latest verdict per experiment</span></h2></summary>
<div id="experiments"><span class="empty">No experiments registered — plan 006 fills this.</span></div>
</details>
<details class="panel" id="p-feed">
<summary><h2>Activity Feed <span id="feed-count" class="count">0</span></h2></summary>
<div id="feed"></div>
</details>
<details class="panel" id="p-orders">
<summary><h2>Order Lifecycle <span id="ol-count" class="count">0</span></h2></summary>
<div id="order-lifecycle"></div>
</details>
<details class="panel full" id="p-attrib">
<summary><h2>Component Attribution <span class="hint">per-stage ratings: discovery &rarr; signals (early vs late) &rarr; gates &rarr; execution &rarr; P&amp;L &middot; loads on open</span></h2></summary>
<div id="attrib"><span class="empty">Open to load the last 7 days.</span></div>
</details>
<details class="panel full" id="p-decisions">
<summary><h2>Decision Trace <span class="hint">why each entry was taken or refused &middot; value &rarr; threshold per gate &middot; receipts kept 14 days</span></h2></summary>
<div class="inject"><input id="dec-filter" placeholder="filter symbol..." maxlength="6" autocomplete="off"></div>
<div id="decisions"><span class="empty">Open to load recent decisions.</span></div>
</details>
</main>
<script>
(function(){
// Component Attribution — lazy-load on first open (it queries the event store)
var ap = document.getElementById('p-attrib'), loadedAttrib = false;
function esc(s){ return String(s == null ? '' : s).replace(/[&<>]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;'}[c]; }); }
function renderAttrib(a){
var days = Object.keys(a.days || {}).sort().reverse();
if (!days.length) { document.getElementById('attrib').innerHTML = '<span class="empty">No events in window.</span>'; return; }
var h = '<table><tr><th>day</th><th>disc</th><th>ready</th><th>early%</th><th>gate cuts</th><th>subm</th><th>fill%</th><th>trades</th><th>win%</th><th>P&amp;L</th></tr>';
days.forEach(function(d){
var st = a.days[d], c = st.counts || {}, r = st.rating || {}, oc = st.outcomes || {};
var gates = Object.entries(st.gates || {}).sort(function(x, y){ return y[1] - x[1]; })
.map(function(kv){ return esc(kv[0]) + '&times;' + kv[1]; }).join(', ');
var pnl = (r.pnl == null) ? '-' : ('$' + (r.pnl >= 0 ? '+' : '') + r.pnl.toFixed(0));
var cls = (r.pnl == null) ? '' : (r.pnl >= 0 ? ' style="color:#2f9e44"' : ' style="color:#e03131"');
h += '<tr title="' + gates + '"><td>' + d + '</td><td>' + (c.symbol_discovered || 0) + '</td><td>' + (c.signal_ready || 0) +
'</td><td>' + (r.signals_early_pct == null ? '-' : r.signals_early_pct + '%') +
'</td><td>' + Object.values(st.gates || {}).reduce(function(s, v){ return s + v; }, 0) +
'</td><td>' + (c.order_submitted || 0) +
'</td><td>' + (r.fill_efficiency_pct == null ? '-' : r.fill_efficiency_pct + '%') +
'</td><td>' + (oc.trades || 0) + '</td><td>' + (r.win_rate_pct == null ? '-' : r.win_rate_pct + '%') +
'</td><td' + cls + '>' + pnl + '</td></tr>';
});
h += '</table><div class="hint" style="margin-top:6px">early% = ready signals inside the ' + a.confirm_minute + 'm confirmation window (the only +EV ones) &middot; hover a row for its gate histogram</div>';
document.getElementById('attrib').innerHTML = h;
}
if (ap) ap.addEventListener('toggle', function(){
if (!ap.open || loadedAttrib) return;
loadedAttrib = true;
document.getElementById('attrib').innerHTML = '<span class="empty">Loading…</span>';
fetch('/api/attribution?days=7').then(function(r){ return r.json(); }).then(renderAttrib)
.catch(function(e){ document.getElementById('attrib').innerHTML = '<span class="empty">failed: ' + esc(e) + '</span>'; loadedAttrib = false; });
});
})();
(function(){
// Collapsible panels — remember each panel's open/closed state across refreshes,
// and an expand/collapse-all toggle. Pure UI; the live app JS keys off the inner ids.
var KEY = 'momentum.panels.v2', saved = {};
try { saved = JSON.parse(localStorage.getItem(KEY) || '{}'); } catch (e) {}
var panels = document.querySelectorAll('details.panel[id]');
panels.forEach(function(d){
if (d.id in saved) d.open = !!saved[d.id];
d.addEventListener('toggle', function(){
saved[d.id] = d.open;
try { localStorage.setItem(KEY, JSON.stringify(saved)); } catch (e) {}
});
});
var btn = document.getElementById('panels-toggle');
if (btn) btn.addEventListener('click', function(){
var all = document.querySelectorAll('details.panel');
var anyClosed = [].some.call(all, function(d){ return !d.open; });
all.forEach(function(d){ d.open = anyClosed; });
btn.textContent = anyClosed ? 'COLLAPSE ALL' : 'EXPAND ALL';
});
})();
</script>
<div id="toast" class="toast"></div>
<div id="kbd-help" class="kbd-help">
<div class="kbd-card">
<h3>Keyboard shortcuts</h3>
<div class="row"><span>Approve top of queue</span><kbd>A</kbd></div>
<div class="row"><span>Reject top of queue</span><kbd>R</kbd></div>
<div class="row"><span>Toggle alert sound</span><kbd>S</kbd></div>
<div class="row"><span>Focus add-symbol box</span><kbd>/</kbd></div>
<div class="row"><span>Show / hide this help</span><kbd>?</kbd></div>
<div class="row"><span>Close help</span><kbd>Esc</kbd></div>
</div>
</div>
<!-- Charting by TradingView Lightweight Charts, https://www.tradingview.com/ (Apache-2.0, see /static/vendor/NOTICE-lightweight-charts.txt) -->
<div id="chart-modal" class="chart-modal">
<div class="chart-card">
<div class="chart-hdr">
<span class="sym" id="chart-sym"></span>
<button class="close" id="chart-close">CLOSE (Esc)</button>
</div>
<div id="chart-container"></div>
<div class="chart-ftr"><a href="https://www.tradingview.com/" target="_blank" rel="noopener">Charts by TradingView</a></div>
</div>
</div>
<script src="/static/vendor/lightweight-charts.standalone.production.js" defer></script>
<script>
const $ = (id) => document.getElementById(id);
const fmt = (v, dp = 2) => (v === null || v === undefined || v === "" || isNaN(+v)) ? "—" : (+v).toFixed(dp);
const money = (v) => (v === null || v === undefined || isNaN(+v)) ? "—" : (v < 0 ? "-$" : "$") + Math.abs(+v).toLocaleString("en-US", {minimumFractionDigits: 2, maximumFractionDigits: 2});
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const timeOf = (ts) => { try { return new Date(ts).toLocaleTimeString(); } catch { return "—"; } };
const signed = (el, v) => {
// explicit "+" so gain/loss never rides on color alone (red-green CVD ~8% of
// males; the minus already prints, the plus must too)
el.textContent = (v === null || v === undefined || isNaN(+v)) ? "—"
: (+v > 0 ? "+" : "") + money(v);
el.className = "value num " + (+v > 0 ? "pos" : +v < 0 ? "neg" : "");
};
let latest = null;
let soundOn = false;
let lastSignalKey = "";
let catVetoOnly = false; // engine-chip filter: show only dilutive/veto reads
let latestFeed = []; // cached so the filter toggles without a refetch
let latestEngine = null;
const expanded = new Set();
const barsCache = {};
// symbol drill-in chart modal (plan 013) — one instance reused across opens;
// full-replace refresh off the SSE snapshot signal, no standalone poller.
let chartState = { symbol: null, chart: null, series: null, markersApi: null, lastFetch: 0, priceLines: [] };
function closeChart() {
$("chart-modal").classList.remove("show");
if (chartState.chart) chartState.chart.remove(); // release the canvas (+ its markers primitive)
chartState = { symbol: null, chart: null, series: null, markersApi: null, lastFetch: 0, priceLines: [] };
}
function openChart(symbol) {
if (!window.LightweightCharts) return; // vendored lib not loaded yet — fail quiet
if (chartState.chart) closeChart();
$("chart-sym").textContent = symbol;
$("chart-modal").classList.add("show");
const container = $("chart-container");
container.innerHTML = "";
const chart = LightweightCharts.createChart(container, {
autoSize: true,
layout: { background: { color: "#111622" }, textColor: "#e3e8f2" },
grid: { vertLines: { color: "#18202f" }, horzLines: { color: "#18202f" } },
timeScale: { timeVisible: true, secondsVisible: false },
});
const series = chart.addSeries(LightweightCharts.CandlestickSeries, {
upColor: "#34d17e", downColor: "#ff5a60", borderVisible: false,
wickUpColor: "#34d17e", wickDownColor: "#ff5a60",
});
// Bollinger Bands (20-period, 2σ) — momentum context: price riding the upper
// band = strong, a break back inside = fading. Three faint lines on the price
// scale, non-intrusive.
const bbLine = () => chart.addSeries(LightweightCharts.LineSeries,
{ color: "#4f9cf9", lineWidth: 1, lineStyle: 2, priceLineVisible: false, lastValueVisible: false });
const bbUpper = bbLine(), bbMid = bbLine(), bbLower = bbLine();
bbMid.applyOptions({ color: "#3a4a66" });
// Volume histogram on its own bottom overlay (20% height), coloured by candle.
const volSeries = chart.addSeries(LightweightCharts.HistogramSeries,
{ priceFormat: { type: "volume" }, priceScaleId: "vol" });
chart.priceScale("vol").applyOptions({ scaleMargins: { top: 0.8, bottom: 0 } });
const markersApi = LightweightCharts.createSeriesMarkers(series, []);
chartState = { symbol, chart, series, markersApi, volSeries,
bbUpper, bbMid, bbLower, lastFetch: 0, priceLines: [] };
refreshChart(symbol);
}
function applyChartLevels(levels) {
const s = chartState.series;
if (!s) return;
(chartState.priceLines || []).forEach(pl => s.removePriceLine(pl));
chartState.priceLines = [];
const add = (price, color, title) => {
if (price == null) return;
chartState.priceLines.push(s.createPriceLine({ price, color, title, lineWidth: 1, lineStyle: 2 }));
};
add(levels.avg_entry, "#4f9cf9", "entry");
add(levels.entry_limit, "#4f9cf9", "entry limit");
add(levels.stop, "#ff5a60", "stop");
add(levels.target, "#34d17e", "target");
}
// Collapse a burst of same-reason rejected decisions into one marker per
// reason-change (plan 014 Step 4) — ~1,300 receipts/day would otherwise smear
// the chart. `decisions`: [{epoch, reject_reason}]; any input order. Emits
// one marker at the START of each burst, then suppresses repeats of the same
// reason for 5 minutes (300s) before allowing a new one.
function collapseDecisionMarkers(decisions) {
const sorted = (decisions || []).slice().sort((a, b) => a.epoch - b.epoch);
const lastEpochByReason = {};
const out = [];
sorted.forEach(d => {
const prev = lastEpochByReason[d.reject_reason];
lastEpochByReason[d.reject_reason] = d.epoch;
if (prev != null && d.epoch - prev <= 300) return; // within 5min of the last one -> collapsed
out.push({ epoch: d.epoch, reject_reason: d.reject_reason });
});
return out;
}
async function refreshChart(symbol) {
chartState.lastFetch = Date.now();
try {
const [r, rd] = await Promise.all([
fetch("/api/chart?symbol=" + encodeURIComponent(symbol) + "&hours=24"),
fetch("/api/decisions?symbol=" + encodeURIComponent(symbol) + "&limit=200"),
]);
if (!r.ok) return;
const data = await r.json();
const decData = rd.ok ? await rd.json() : { decisions: [] };
if (chartState.symbol !== symbol || !chartState.series) return; // closed/switched mid-fetch
const bars = data.bars || [];
chartState.series.setData(bars.map(b =>
({ time: b.time, open: b.open, high: b.high, low: b.low, close: b.close })));
// volume histogram — coloured by candle direction
if (chartState.volSeries) chartState.volSeries.setData(bars.map(b =>
({ time: b.time, value: b.volume || 0, color: (b.close >= b.open ? "rgba(52,209,126,.4)" : "rgba(255,90,96,.4)") })));
// Bollinger Bands (20-period SMA ± 2σ on close)
if (chartState.bbMid) {
const N = 20, up = [], mid = [], lo = [];
for (let i = N - 1; i < bars.length; i++) {
let s = 0; for (let j = i - N + 1; j <= i; j++) s += bars[j].close;
const m = s / N;
let v = 0; for (let j = i - N + 1; j <= i; j++) v += (bars[j].close - m) ** 2;
const sd = Math.sqrt(v / N), t = bars[i].time;
mid.push({ time: t, value: m }); up.push({ time: t, value: m + 2 * sd }); lo.push({ time: t, value: m - 2 * sd });
}
chartState.bbUpper.setData(up); chartState.bbMid.setData(mid); chartState.bbLower.setData(lo);
}
const fillMarkers = (data.markers || []).map(m => {
if (m.kind === "exit") { // trade close — the EXIT marker
const win = (+m.pnl || 0) >= 0;
return {
time: m.time, position: "aboveBar", shape: "square",
color: win ? "#34d17e" : "#ff5a60",
text: "EXIT " + String(m.exit_reason || "").replace(/_/g, " ") + (m.pnl != null ? " " + money(m.pnl) : ""),
};
}
const sell = m.side === "sell";
const unfilled = m.kind === "order_submitted";
return {
time: m.time,
position: sell ? "aboveBar" : "belowBar",
shape: sell ? "arrowDown" : "arrowUp",
color: unfilled ? "#7884a2" : (sell ? "#ff5a60" : "#34d17e"),
text: (unfilled ? "expected " : "") + (sell ? "S " : "B ") + (m.quantity != null ? m.quantity : ""),
};
});
// rejected receipts only (a pass needs no marker — the fill arrow already
// marks the outcome); collapsed so a rejection burst reads as one mark.
const rejected = (decData.decisions || [])
.filter(d => !d.passed && d.reject_reason && d.epoch != null)
.map(d => ({ epoch: d.epoch, reject_reason: d.reject_reason }));
const declineMarkers = collapseDecisionMarkers(rejected).map(d => ({
time: d.epoch,
position: "aboveBar",
shape: "circle",
color: "#9c6b6b", // muted red-gray, distinct from the sell-arrow red
text: String(d.reject_reason).slice(0, 12),
}));
const markers = fillMarkers.concat(declineMarkers).sort((a, b) => a.time - b.time);
if (chartState.markersApi) chartState.markersApi.setMarkers(markers);
applyChartLevels(data.levels || {});
} catch (e) { /* leave the last good render on screen */ }
}
// any td.sym click (positions, ready signals, watch, order lifecycle, feed)
// opens the chart — ONE delegated listener since rows re-render every SSE tick.
document.addEventListener("click", (e) => {
const cell = e.target.closest("td.sym");
if (cell) {
const sym = (cell.textContent || "").trim().split(/\s+/)[0];
if (sym) openChart(sym);
return;
}
if (e.target.id === "chart-modal") closeChart();
});
$("chart-close").onclick = closeChart;
// Decision Trace panel (plan 014) — lazy-load on first open, refetch on SSE
// `snapshot` while open (imitates p-attrib's lazy-open pattern; ≥5s throttle
// like the chart modal so a snapshot flood doesn't hammer the endpoint).
let lastDecFetch = 0;
function gateChip(name, g) {
g = g || {};
const ok = g.outcome === "pass";
const vt = (g.value != null && g.threshold != null)
? (esc(g.value) + "&rarr;" + esc(g.threshold) + " ") : "";
return '<span class="gate-chip ' + (ok ? "pass" : "fail") + '">' + esc(name) + " " + vt + (ok ? "✓" : "✗") + "</span>";
}
function renderDecisions(data) {
const rows = data.decisions || [];
if (!rows.length) { fill("decisions", null, "No decision receipts in the live window."); return; }
fill("decisions", rows.map(r => {
const chips = Object.keys(r.gates || {}).map(k => gateChip(k, r.gates[k])).join("");
const verdict = r.passed ? '<span class="pill long">PASS</span>'
: '<span class="pill short">' + esc(r.reject_reason || "reject") + "</span>";
return '<div class="dec-row" title="' + esc(JSON.stringify(r)) + '">' +
'<span class="feed-time">' + timeOf(r.timestamp) + "</span>" +
'<span class="sym" style="cursor:pointer" onclick="openChart(\'' + esc(r.symbol) + "')\">" + esc(r.symbol || "") + "</span>" +
'<span class="pill ' + (r.source === "fast" ? "watching" : "discovered") + '">' + esc(r.source || "") + "</span>" +
verdict + chips + "</div>";
}).join(""));
}
function loadDecisions(force) {
const dp = $("p-decisions");
if (!dp || !dp.open) return;
const now = Date.now();
if (!force && now - lastDecFetch < 5000) return;
lastDecFetch = now;
const sym = ($("dec-filter") || {}).value || "";
fetch("/api/decisions?limit=100" + (sym ? "&symbol=" + encodeURIComponent(sym) : ""))
.then(r => r.json()).then(renderDecisions).catch(() => {});
}
(() => {
const dp = $("p-decisions");
if (!dp) return;
dp.addEventListener("toggle", () => {
if (!dp.open) return;
$("decisions").innerHTML = '<span class="empty">Loading…</span>';
loadDecisions(true);
});
let filterTimer;
const fi = $("dec-filter");
if (fi) fi.addEventListener("input", () => {
clearTimeout(filterTimer);
filterTimer = setTimeout(() => loadDecisions(true), 300);
});
})();
let toastTimer;
function toast(msg, kind) {
const t = $("toast");
t.textContent = msg;
t.className = "toast show" + (kind ? " " + kind : "");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove("show"), 4000);
}
let audioCtx;
function beep() {
if (!soundOn) return;
try {
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
[880, 1320].forEach((freq, i) => {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = "sine"; osc.frequency.value = freq;
const t0 = audioCtx.currentTime + i * 0.12;
gain.gain.setValueAtTime(0.0001, t0);
gain.gain.exponentialRampToValueAtTime(0.25, t0 + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.18);
osc.connect(gain); gain.connect(audioCtx.destination);
osc.start(t0); osc.stop(t0 + 0.2);
});
} catch (e) {}
}
// the operator token (localStorage) is sent as X-Dashboard-Token on every POST —
// harmless when the server has no token set, and required once it does.
const opToken = () => localStorage.getItem("opTok") || "";
function _postHeaders() {
const h = {"Content-Type": "application/json"};
const tok = opToken();
if (tok) h["X-Dashboard-Token"] = tok;
return h;
}
async function post(path, body) {
try {
const r = await fetch(path, { method: "POST", headers: _postHeaders(), body: JSON.stringify(body) });
const data = await r.json().catch(() => ({}));
if (!r.ok || data.ok === false) toast(data.error || ("failed (" + r.status + ")"), "err");
else toast(data.status ? ("OK — " + data.status) : (data.symbol ? "Added " + data.symbol : "Done"), "ok");
} catch (e) { toast("network error: " + e.message, "err"); }
}
// operator command bus: dedicated so the toast names the exact action.
async function cmd(command, symbol, okMsg) {
try {
const body = symbol ? { command, symbol } : { command };
const r = await fetch("/api/command", { method: "POST", headers: _postHeaders(), body: JSON.stringify(body) });
const data = await r.json().catch(() => ({}));
if (!r.ok || data.ok === false) { toast(data.error || ("command failed (" + r.status + ")"), "err"); return false; }
toast(okMsg || "Command sent — loop executes within ~5s.", "ok");
return true;
} catch (e) { toast("network error: " + e.message, "err"); return false; }
}
// two-step arm/confirm for destructive actions (NO keyboard shortcut). First
// click arms (solid, 5s countdown auto-disarm); a second click within 5s fires.
function _disarm(btn) {
if (btn.dataset.timer) { clearInterval(+btn.dataset.timer); btn.dataset.timer = ""; }
btn.dataset.armed = "";
btn.classList.remove("armed");
if (btn.dataset.idleLabel != null) btn.textContent = btn.dataset.idleLabel;
}
function armButton(btn, labelFn, onConfirm) {
if (btn.dataset.armed === "1") { _disarm(btn); onConfirm(); return; }
btn.dataset.armed = "1";
btn.dataset.idleLabel = btn.textContent;
btn.classList.add("armed");
let n = 5;
btn.textContent = labelFn(n);
btn.dataset.timer = setInterval(() => {
n -= 1;
if (n <= 0) _disarm(btn); else btn.textContent = labelFn(n);
}, 1000);
}
const approve = (id) => post("/api/trading/approvals/approve", {order_id: id, approved_by: "dashboard"});
const reject = (id) => post("/api/trading/approvals/reject", {order_id: id, reason: "dashboard_reject"});
function drawSpark(canvas, bars, entry, stop) {
const dpr = window.devicePixelRatio || 1;
const W = 132, H = 38;
canvas.width = W * dpr; canvas.height = H * dpr;
const ctx = canvas.getContext("2d");
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
if (!bars || bars.length < 2) {
ctx.fillStyle = "#76809a"; ctx.font = "9px monospace";
ctx.fillText("no bars", 6, 22); return;
}
const closes = bars.map(b => b.c);
const levels = [entry, stop].filter(v => v != null && !isNaN(v));
const lo = Math.min.apply(null, closes.concat(levels));
const hi = Math.max.apply(null, closes.concat(levels));
const pad = (hi - lo) * 0.12 || 0.1;
const min = lo - pad, max = hi + pad;
const x = (i) => 2 + i * (W - 4) / (closes.length - 1);
const y = (v) => H - 3 - (v - min) / (max - min) * (H - 6);
const refLine = (v, color) => {
if (v == null || isNaN(v)) return;
ctx.strokeStyle = color; ctx.globalAlpha = 0.5; ctx.lineWidth = 1;
ctx.setLineDash([3, 3]); ctx.beginPath();
ctx.moveTo(2, y(v)); ctx.lineTo(W - 2, y(v)); ctx.stroke();
ctx.setLineDash([]); ctx.globalAlpha = 1;
};
refLine(entry, "#4f9cf9");
refLine(stop, "#ff5a60");
const up = closes[closes.length - 1] >= closes[0];
ctx.strokeStyle = up ? "#34d17e" : "#ff5a60";
ctx.lineWidth = 1.5; ctx.beginPath();
closes.forEach((c, i) => i ? ctx.lineTo(x(i), y(c)) : ctx.moveTo(x(i), y(c)));
ctx.stroke();
ctx.lineTo(x(closes.length - 1), H); ctx.lineTo(x(0), H); ctx.closePath();
ctx.globalAlpha = 0.10; ctx.fillStyle = up ? "#34d17e" : "#ff5a60";
ctx.fill(); ctx.globalAlpha = 1;
ctx.fillStyle = up ? "#34d17e" : "#ff5a60";
ctx.beginPath(); ctx.arc(x(closes.length - 1), y(closes[closes.length - 1]), 2, 0, 7); ctx.fill();
}
async function loadBars(symbol, entry, stop, canvas) {
const cached = barsCache[symbol];
if (cached && (Date.now() - cached.fetchedAt < 8000)) { drawSpark(canvas, cached.bars, entry, stop); return; }
try {
const r = await fetch("/api/bars?symbol=" + encodeURIComponent(symbol) + "&minutes=40");
const data = await r.json();
barsCache[symbol] = { bars: data.bars || [], fetchedAt: Date.now() };
drawSpark(canvas, data.bars || [], entry, stop);
} catch (e) { drawSpark(canvas, [], entry, stop); }
}
function riskChip(req) {
const qty = +req.quantity || 0, entry = +req.entry_price || 0, stop = +req.stop_loss_price || 0;
if (!qty || !entry || !stop) return "";
const perShare = Math.abs(entry - stop);
const dollarRisk = perShare * qty;
const notional = entry * qty;
return '<span class="risk">risk <b>' + money(dollarRisk) + '</b> &middot; ' + qty + 'sh &middot; notional <b>' + money(notional) + '</b> &middot; entry <b>' + money(entry) + '</b> &middot; stop <b>' + money(stop) + '</b> (' + (perShare/entry*100).toFixed(1) + '%)</span>';
}
// Local-LLM catalyst advisory cell for the approval queue. adv = {catalyst_type,
// sentiment, conviction, is_dilutive, rationale}. Empty when not enriched.
function catalystCell(adv) {
if (!adv || !adv.catalyst_type) return '<span class="muted">—</span>';
const s = adv.sentiment;
const tone = s == null ? "" : (s > 0.15 ? "long" : (s < -0.15 ? "short" : ""));
const type = String(adv.catalyst_type).replace(/_/g, " ");
const dil = adv.is_dilutive ? ' <span class="pill short" title="confirmed dilution — Phase 2 veto candidate">⚠ DILUTIVE</span>' : '';
const chip = '<span class="pill ' + tone + '" title="' + esc(adv.rationale || "") + '">📰 ' + esc(type)
+ (s != null ? ' ' + (s > 0 ? '+' : '') + Number(s).toFixed(2) : '') + '</span>';
return chip + dil;
}
// Experiment ledger (plan 006 tables via the snapshot). Verdict pill colors come
// from :root tokens; bars-vs-observed shown inline (the pre-registered pass bar
// beside the observed metrics).
function renderExperiments(rows) {
rows = rows || [];
$("exp-count").textContent = rows.length;
if (!rows.length) { fill("experiments", null, "No experiments registered — plan 006 fills this."); return; }
const vcolor = { PASS: "var(--green)", FAIL: "var(--red)", INCONCLUSIVE: "var(--muted)",
RUNNING: "var(--accent)", KILLED: "var(--amber)" };
const kv = (o) => Object.entries(o || {}).map(e => esc(e[0]) + "=" + esc(e[1])).join(", ") || "—";
const trs = rows.map(e => {
const v = String(e.verdict || e.status || "RUNNING").toUpperCase();
const col = vcolor[v] || "var(--muted)";
return '<tr title="' + esc(e.hypothesis || "") + '">' +
'<td class="sym">' + esc(e.experiment) + '</td>' +
'<td class="num">' + esc(e.n != null ? e.n : "—") + '</td>' +
'<td class="num" style="max-width:230px;overflow:hidden;text-overflow:ellipsis">' + kv(e.bars) + '</td>' +
'<td class="num" style="max-width:230px;overflow:hidden;text-overflow:ellipsis">' + kv(e.metrics) + '</td>' +
'<td><span class="pill" style="color:' + col + ';border-color:' + col + '">' + esc(v) + '</span></td></tr>';
});
fill("experiments", table(["Experiment", "n", "Pre-registered bar", "Observed", "Verdict"], trs), "");
}
function table(headers, rows) {
if (!rows.length) return null;
return "<table><thead><tr>" + headers.map(h => "<th>" + h + "</th>").join("") + "</tr></thead><tbody>" + rows.join("") + "</tbody></table>";
}
function fill(id, html, emptyMsg) { $(id).innerHTML = html || '<div class="empty">' + emptyMsg + "</div>"; }
async function toggleCriteria(symbol) {
if (expanded.has(symbol)) expanded.delete(symbol); else expanded.add(symbol);
if (latest) renderWatch(latest.watch_states || []); // fill happens via the shared cache
}
// --- reasoning-over-time drawings (plain SVG, no lib) -----------------------
function svgLine(hist, key, opts) {
// one metric over the day as a polyline, with an optional threshold rule
const pts = hist.map((h, i) => [i, h[key]]).filter(p => p[1] != null && isFinite(+p[1]));
if (pts.length < 2) return "";
const W = 720, H = opts.h || 56, PAD = 4;
let lo = opts.min != null ? opts.min : Math.min(...pts.map(p => +p[1]));
let hi = opts.max != null ? opts.max : Math.max(...pts.map(p => +p[1]));
if (opts.thresh != null) { lo = Math.min(lo, opts.thresh); hi = Math.max(hi, opts.thresh); }
if (hi - lo < 1e-9) hi = lo + 1;
const x = i => PAD + i * (W - 2 * PAD) / Math.max(1, hist.length - 1);
const y = v => H - PAD - (v - lo) * (H - 2 * PAD) / (hi - lo);
const line = pts.map(p => x(p[0]).toFixed(1) + "," + y(+p[1]).toFixed(1)).join(" ");
const last = +pts[pts.length - 1][1];
const ok = opts.thresh == null || (opts.above ? last >= opts.thresh : last <= opts.thresh);
let out = '<div class="rsn-block"><div class="rsn-title">' + esc(opts.label)
+ ' <b class="' + (ok ? "pos" : "neg") + '">' + last.toFixed(opts.dp != null ? opts.dp : 1) + esc(opts.unit || "") + '</b>'
+ (opts.thresh != null ? ' <span class="rsn-thr">gate ' + opts.thresh + esc(opts.unit || "") + '</span>' : "")
+ '</div><svg viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none" class="rsn-svg" style="height:' + H + 'px">';
if (opts.thresh != null)
out += '<line x1="0" x2="' + W + '" y1="' + y(opts.thresh).toFixed(1) + '" y2="' + y(opts.thresh).toFixed(1) + '" class="rsn-gate"/>';
out += '<polyline points="' + line + '" class="rsn-line ' + (ok ? "ok" : "bad") + '"/></svg></div>';
return out;
}
function svgLanes(hist, criteria) {
// one row per criterion, one cell per sample: green pass / dim fail —
// the variance of each logical step across the session at a glance
if (!hist.length || !criteria.length) return "";
const cols = hist.length, W = 720, ROW = 13, GAP = 2;
const cw = W / cols;
const H = criteria.length * (ROW + GAP);
let rects = "";
criteria.forEach((c, r) => {
hist.forEach((h, i) => {
const on = (h.passed || []).includes(c.key);
const off = (h.failed || []).includes(c.key);
if (!on && !off) return; // not evaluated that pass -> gap stays dark
rects += '<rect x="' + (i * cw).toFixed(2) + '" y="' + (r * (ROW + GAP))
+ '" width="' + Math.max(cw - 0.4, 0.8).toFixed(2) + '" height="' + ROW
+ '" class="' + (on ? "lane-on" : "lane-off") + '"/>';
});
});
const labels = criteria.map(c =>
'<div class="lane-label" style="height:' + (ROW + GAP) + 'px">' + esc(c.label) + '</div>').join("");
return '<div class="rsn-block"><div class="rsn-title">criteria over the session'
+ ' <span class="rsn-thr">' + esc(hist[0].t) + ' → ' + esc(hist[hist.length - 1].t) + '</span></div>'
+ '<div class="lanes"><div class="lane-labels">' + labels + '</div>'
+ '<svg viewBox="0 0 720 ' + H + '" preserveAspectRatio="none" class="rsn-svg lanes-svg" style="height:' + H + 'px">' + rects + '</svg></div></div>';
}
function renderCriteriaGrid(data) {
if (!data.criteria || !data.criteria.length)
return '<div class="empty" style="padding:12px 16px">No criteria evaluated yet.</div>';
const hist = data.history || [];
let viz = "";
if (hist.length > 1) {
viz += svgLine(hist, "score", {label: "setup score", unit: "%", thresh: 60, above: true, min: 0, max: 100, dp: 0, h: 56});
viz += svgLine(hist, "rvol", {label: "relative volume", unit: "x", thresh: 2.5, above: true, min: 0, dp: 1, h: 44});
viz += svgLanes(hist, data.criteria);
}
// per-criterion explanation in words (latest pass) — reason next to each flag
const why = (data.detail || []).map(d =>
'<div class="crit ' + (d.passed ? "pass" : "fail") + '"><span class="mark">' + (d.passed ? "✓" : "✕") + '</span>'
+ '<span class="crit-name">' + esc(d.name || "") + '</span><span class="crit-why">' + esc(d.reason || "") + '</span></div>').join("");
const cells = why || data.criteria.map(c => {
const cls = c.passed === true ? "pass" : c.passed === false ? "fail" : "skip";
const mark = c.passed === true ? "✓" : c.passed === false ? "✕" : "·";
return '<div class="crit ' + cls + '"><span class="mark">' + mark + '</span>' + esc(c.label) + '</div>';
}).join("");
const score = data.score != null ? 'score ' + (+data.score).toFixed(0) + '% &middot; ' + data.passed_count + '/' + data.total_count + ' &middot; ' + esc(data.status||"") : "";
return viz + '<div class="criteria-grid' + (why ? " with-why" : "") + '">' + cells + '</div><div class="spark-meta" style="padding:0 16px 10px">' + score + '</div>';
}
function renderWatch(ws) {
// hide the score-gate 'blocked' rows (usually 100s of them) — show what's actually
// watching/ready first; if everything is blocked, still show some so it's not blank.
const relevant = ws.filter(w => w.state !== "blocked");
const shown = (relevant.length ? relevant : ws).slice(0, 25);
$("ws-count").textContent = shown.length < ws.length ? shown.length + " / " + ws.length : ws.length;
if (!ws.length) { fill("watch-states", null, "Watcher has not evaluated any symbols yet."); return; }
const rows = shown.map(w => {
const isOpen = expanded.has(w.symbol);
const main = '<tr class="expandable" onclick="toggleCriteria(\'' + esc(w.symbol) + '\')">' +
'<td><span class="caret ' + (isOpen ? "open" : "") + '">▶</span> <span class="sym">' + esc(w.symbol) + '</span></td>' +
'<td><span class="pill ' + esc(w.state || "discovered") + '">' + esc(w.state || "—") + '</span></td>' +
'<td class="num">' + fmt(w.last_score, 0) + (w.last_score != null ? "%" : "") + '</td>' +
'<td style="color:var(--muted)">' + esc(w.state_reason || "") + '</td>' +
'<td class="num">' + timeOf(w.last_transition_at || w.last_criteria_at) + '</td></tr>';
const detail = isOpen
? '<tr><td class="criteria-cell" colspan="5"><div data-crit="' + esc(w.symbol) + '"><div class="empty" style="padding:12px 16px">loading...</div></div></td></tr>'
: "";
return main + detail;
});
fill("watch-states", table(["Symbol", "State", "Score", "Reason", "Updated"], rows), "");
// same cache as the trigger panel: instant repaint on re-render, no flicker
expanded.forEach((symbol) => {
const sel = '[data-crit="' + CSS.escape(symbol) + '"]';
const host = document.querySelector(sel);
if (!host) return;
const c = trigCritCache.get(symbol);
if (c) host.innerHTML = c.html;
const stale = !c || (Date.now() - c.at > 60000);
if (!stale || trigCritInflight.has(symbol)) return;
trigCritInflight.add(symbol);
fetch("/api/criteria?symbol=" + encodeURIComponent(symbol))
.then(r => r.json())
.then(d => {
const html = renderCriteriaGrid(d);
trigCritCache.set(symbol, {html: html, at: Date.now()});
const el2 = document.querySelector(sel);
if (el2) el2.innerHTML = html;
})
.catch(() => {})
.finally(() => trigCritInflight.delete(symbol));
});
}
// Armed-trigger proximity radar — for each loaded gapper, a bar showing where the
// LIVE price sits between the stop (left) and the breakout trigger (the marker).
// The fill grows toward the marker as price nears the break; crossing it = fired.
const TRIG_ORDER = { fired: 0, filled: 1, armed: 2, weak: 3, waiting: 4 };
// catalyst read for one armed symbol: colored dot + type + veto/dilutive badge.
// VETO floor is conviction >= 0.6 (matches the execution dilution veto).
function trigCatalyst(adv, rawHeadline) {
if (!adv || !adv.catalyst_type || adv.catalyst_type === 'none') {
return rawHeadline ? '<span class="trig-cat-raw" title="' + esc(rawHeadline) + '">' + esc(rawHeadline) + '</span>' : '';
}
const s = +adv.sentiment || 0, conv = +adv.conviction || 0;
const typ = String(adv.catalyst_type).replace(/_/g, ' ');
let dot, badge = '';
if (adv.is_dilutive) {
dot = '🔴';
badge = conv >= 0.6
? ' <span class="pill short" title="confirmed dilution, conv ' + conv.toFixed(2) + ' — entry VETOED">⚠ VETO</span>'
: ' <span class="pill blocked" title="dilution flagged, conv ' + conv.toFixed(2) + ' (below 0.60 veto floor)">dilut?</span>';
} else { dot = s > 0.2 ? '🟢' : s < -0.2 ? '🔻' : '⚪'; }
const tip = typ + ' · sentiment ' + (s >= 0 ? '+' : '') + s.toFixed(2) + ' · conviction ' + conv.toFixed(2)
+ (adv.rationale ? ' — ' + adv.rationale : '');
return '<span class="trig-adv" title="' + esc(tip) + '">' + dot + ' ' + esc(typ) + badge + '</span>';
}
// click a trigger row -> same reasoning timeline as the watch panel, right
// where the day's relevant names live
const trigExpanded = new Set();
let lastTrigArgs = null;
async function toggleTrigReasoning(symbol) {
if (trigExpanded.has(symbol)) trigExpanded.delete(symbol); else trigExpanded.add(symbol);
if (lastTrigArgs) renderTriggers(lastTrigArgs[0], lastTrigArgs[1]);
}
// per-symbol cache so snapshot re-renders repaint instantly instead of
// flashing "loading…" and re-fetching every tick; background refresh <= 1/min
const trigCritCache = new Map(); // sym -> {html, at}
const trigCritInflight = new Set();
function fillTrigReasoning() {
trigExpanded.forEach((symbol) => {
const sel = '[data-trig-crit="' + CSS.escape(symbol) + '"]';
const hostEl = document.querySelector(sel);
if (!hostEl) return;
const c = trigCritCache.get(symbol);
if (c) hostEl.innerHTML = c.html; // instant repaint, no flicker
const stale = !c || (Date.now() - c.at > 60000);
if (!stale || trigCritInflight.has(symbol)) return;
trigCritInflight.add(symbol);
fetch("/api/criteria?symbol=" + encodeURIComponent(symbol))
.then(r => r.json())
.then(d => {
const html = renderCriteriaGrid(d);
trigCritCache.set(symbol, {html: html, at: Date.now()});
const el2 = document.querySelector(sel);
if (el2) el2.innerHTML = html;
})
.catch(() => {})
.finally(() => trigCritInflight.delete(symbol));
});
}
function renderTriggers(tw, catMap) {
lastTrigArgs = [tw, catMap];
const arr = (tw && tw.triggers) || [];
const cat = catMap || {};
$("trig-count").textContent = tw && tw.armed != null ? tw.armed : 0;
const host = $("armed-triggers");
if (!arr.length) { host.innerHTML = '<span class="empty">No armed gappers — waiting for the open / first bars.</span>'; return; }
const rows = arr.slice().sort((a, b) =>
(TRIG_ORDER[a.state] ?? 9) - (TRIG_ORDER[b.state] ?? 9) || ((b.dist ?? -9) - (a.dist ?? -9)));
host.innerHTML = rows.map(t => {
const trig = t.trigger, stop = t.stop, price = t.price;
const hasBar = trig != null && stop != null && price != null && trig > stop;
let fill = 0, trigPos = 100;
if (hasBar) {
const lo = stop, hi = trig + (trig - stop) * 0.30; // headroom so 'fired' shows past the marker
fill = Math.max(0, Math.min(100, (price - lo) / (hi - lo) * 100));
trigPos = Math.max(0, Math.min(100, (trig - lo) / (hi - lo) * 100));
}
const dist = t.dist != null ? t.dist * 100 : null;
const fired = t.state === 'fired' || t.state === 'filled';
const distTxt = fired ? '▲' : (dist != null ? (dist >= 0 ? '▲ ' : '') + dist.toFixed(1) + '%' : '—');
const distCls = dist == null ? '' : (dist >= 0 ? 'pos' : dist > -1 ? 'warn' : 'neg');
const near = (t.state === 'armed' && dist != null && dist > -1.0) ? ' near' : '';
const isOpen = trigExpanded.has(t.symbol);
return '<div class="trig-row st-' + esc(t.state) + near + (isOpen ? ' open' : '')
+ '" onclick="toggleTrigReasoning(\'' + esc(t.symbol) + '\')" title="click for the reasoning timeline">'
+ '<div class="trig-sym"><span class="caret ' + (isOpen ? "open" : "") + '">▶</span> ' + (t.catalyst ? '📰 ' : '') + esc(t.symbol) + '</div>'
+ '<div class="trig-state ' + (String(t.state).toLowerCase() === "armed" ? "armed" : "") + '">' + esc(t.state) + '</div>'
+ '<div class="trig-meta">' + (t.gap != null ? 'gap ' + t.gap.toFixed(0) + '% ' : '') + (t.rvol != null ? t.rvol.toFixed(1) + 'x' : '') + (t.rvol_tod != null ? ' \u00B7 tod ' + (t.rvol_tod >= 100 ? t.rvol_tod.toFixed(0) : t.rvol_tod.toFixed(1)) + 'x' : '') + '</div>'
+ '<div class="trig-levels">entry <b>' + fmt(trig) + '</b> stop <b>' + fmt(stop) + '</b> now ' + fmt(price) + '</div>'
+ '<div class="trig-bar" title="' + (price != null ? '$' + price : '?') + ' &rarr; trigger $' + (trig != null ? trig : '?') + ' (stop $' + (stop != null ? stop : '?') + ')">'
+ (hasBar ? '<div class="trig-fill" style="width:' + fill.toFixed(1) + '%"></div><div class="trig-trigline" style="left:' + trigPos.toFixed(1) + '%"></div>' : '')
+ '</div>'
+ '<div class="trig-dist ' + distCls + '">' + distTxt + '</div>'
+ '<div class="trig-cat">' + trigCatalyst(cat[(t.symbol || '').toUpperCase()] || cat[t.symbol], t.catalyst) + '</div>'
+ '</div>'
+ (isOpen ? '<div class="trig-detail" data-trig-crit="' + esc(t.symbol)
+ '"><div class="empty" style="padding:12px 16px">loading…</div></div>' : '');
}).join('');
fillTrigReasoning();
}
// catalyst engine status chip (shows the LLM news engine is live + its activity)
function renderCatalystEngine(e) {
latestEngine = e;
const el = $("cat-engine"); if (!el) return;
el.onclick = null; el.classList.remove("clickable");
if (!e || !e.enabled) {
el.innerHTML = '<span class="cat-off">🧠 catalyst engine off</span>';
el.title = 'Local-LLM news enrichment disabled (OLLAMA_ENABLED / NEWS_ENRICH_ENABLED)';
return;
}
const veto = e.dilution_veto ? '<b class="on">veto</b>' : '<b class="off">veto&nbsp;off</b>';
const score = e.score_blend ? ' · <b class="on">score</b>' : '';
el.innerHTML = '🧠 <b>' + esc(e.model || 'llm') + '</b> · ' + (e.advisories || 0) + ' read'
+ (e.dilutive ? ' · <span class="neg">' + e.dilutive + ' dilutive</span>' : '')
+ ' · ' + veto + score
+ (e.vetoes_today ? ' · <span class="neg">⚠ ' + e.vetoes_today + ' vetoed</span>' : '')
+ (catVetoOnly ? ' · <span class="cat-filter">▼ dilutive only ✕</span>' : '');
el.title = (catVetoOnly ? 'Showing DILUTIVE reads only — click to show all. '
: 'Click to filter the feed to dilutive/veto reads. ')
+ 'Catalyst engine LIVE — ' + (e.advisories || 0) + ' headlines classified by '
+ (e.model || 'the LLM') + ', ' + (e.dilutive || 0) + ' flagged dilutive, '
+ (e.vetoes_today || 0) + ' entries vetoed today.';
el.classList.add("clickable");
el.onclick = () => { catVetoOnly = !catVetoOnly; renderCatalystEngine(latestEngine); renderCatalystFeed(latestFeed); };
}
// enrichment timestamps are stored UTC; show them in ET like the rest of the board
function fmtET(s) {
if (!s) return '';
try {
const d = new Date(String(s).replace(' ', 'T').replace(/\.\d+$/, '') + 'Z');
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZone: 'America/New_York', hour12: false });
} catch (e) { return String(s).slice(11, 16); }
}
// Catalyst Engine feed — the raw stream of what the LLM read and how it ruled,
// with the model's own rationale, so the operator can judge the determinations.
function renderCatalystFeed(feed) {
latestFeed = feed || latestFeed || [];
const all = latestFeed;
const arr = catVetoOnly ? all.filter(r => r.is_dilutive) : all;
const host = $("catalyst-feed");
const cnt = $("cat-feed-count"); if (cnt) cnt.textContent = catVetoOnly ? (arr.length + " / " + all.length) : all.length;
if (!arr.length) {
host.innerHTML = '<span class="empty">' + (catVetoOnly
? 'No dilutive/veto reads in the window — click the chip again to show all.'
: 'No catalyst reads yet — the news enrichment loop hasn\'t classified any headlines.') + '</span>';
return;
}
host.innerHTML = arr.slice(0, 20).map(r => {
const s = r.sentiment == null ? 0 : +r.sentiment, conv = r.conviction == null ? 0 : +r.conviction;
const typ = String(r.catalyst_type || 'none').replace(/_/g, ' ');
let dot, badge = '';
if (r.is_dilutive) {
dot = '🔴';
badge = r.vetoed
? ' <span class="pill short" title="confirmed dilution — entry VETOED">⚠ VETO</span>'
: ' <span class="pill blocked" title="dilution flagged, below the 0.60 veto floor">dilut?</span>';
} else { dot = s > 0.2 ? '🟢' : s < -0.2 ? '🔻' : '⚪'; }
const sym = r.symbol ? esc(r.symbol) : '·';
const sentTxt = (s >= 0 ? '+' : '') + s.toFixed(2);
return '<div class="cf-row' + (r.vetoed ? ' cf-veto' : '') + '">'
+ '<div class="cf-time">' + fmtET(r.enriched_at) + '</div>'
+ '<div class="cf-sym">' + sym + '</div>'
+ '<div class="cf-read">' + dot + ' ' + esc(typ) + badge + '</div>'
+ '<div class="cf-nums" title="sentiment ' + sentTxt + ' · conviction ' + conv.toFixed(2) + '">'
+ '<span class="cf-conv">' + conv.toFixed(2) + '</span></div>'
+ '<div class="cf-text">'
+ '<div class="cf-head">' + (r.url
? '<a class="cf-link" href="' + esc(r.url) + '" target="_blank" rel="noopener">' + esc(r.headline || '') + ' ↗</a>'
: esc(r.headline || '')) + '</div>'
+ (r.rationale ? '<div class="cf-why">“' + esc(r.rationale) + '”</div>' : '')
+ '</div>'
+ '</div>';
}).join('');
}
// intraday equity sparkline — the day's P&L shape vs the prior close (dashed)
function renderEquity(ec) {
const host = $("equity-spark"); if (!host) return;
const eq = (ec && ec.equity) || []; const base = ec && ec.baseline;
if (eq.length < 2) { host.innerHTML = '<span class="muted" style="font:600 11px var(--mono)">—</span>'; return; }
const W = 190, H = 34, pad = 2;
const lo = Math.min(...eq, base != null ? base : Infinity);
const hi = Math.max(...eq, base != null ? base : -Infinity);
const rng = (hi - lo) || 1;
const X = i => (pad + i * (W - 2 * pad) / (eq.length - 1));
const Y = v => (pad + (H - 2 * pad) * (1 - (v - lo) / rng));
const pts = eq.map((v, i) => X(i).toFixed(1) + ',' + Y(v).toFixed(1)).join(' ');
const up = eq[eq.length - 1] >= (base != null ? base : eq[0]);
const col = up ? '#34d17e' : '#ff5a60';
let baseLine = '';
if (base != null) { const by = Y(base).toFixed(1); baseLine = '<line x1="0" y1="' + by + '" x2="' + W + '" y2="' + by + '" stroke="#7d8590" stroke-width="1" stroke-dasharray="3 3" opacity=".55"/>'; }
const area = 'M' + X(0).toFixed(1) + ',' + (H - pad) + ' L' + eq.map((v, i) => X(i).toFixed(1) + ',' + Y(v).toFixed(1)).join(' L') + ' L' + X(eq.length - 1).toFixed(1) + ',' + (H - pad) + ' Z';
host.innerHTML = '<svg width="' + W + '" height="' + H + '" viewBox="0 0 ' + W + ' ' + H + '">'
+ '<path d="' + area + '" fill="' + col + '" opacity=".12"/>' + baseLine
+ '<polyline points="' + pts + '" fill="none" stroke="' + col + '" stroke-width="1.5"/></svg>';
}
// per-position R bar — current open profit/loss in R units; -1R = the stop.
// breakeven (0R) is the white tick; the stop (-1R) is the red tick; fill shows
// where the position sits (green right = in profit, red left = toward the stop).
function rBar(entry, cur, stop) {
if (entry == null || cur == null) return '<span class="muted">—</span>';
let r = (stop != null && entry > stop) ? (cur - entry) / (entry - stop) : null;
const W = 72, LOR = -1.5, HIR = 3.0; // visible R range
const xR = rr => W * (Math.max(LOR, Math.min(HIR, rr)) - LOR) / (HIR - LOR);
let inner = '';
if (r != null) {
const z = xR(0), c = xR(r), col = r >= 0 ? '#34d17e' : '#ff5a60';
inner += '<div style="position:absolute;top:0;bottom:0;left:' + Math.min(z, c).toFixed(1) + 'px;width:' + Math.abs(c - z).toFixed(1) + 'px;background:' + col + ';opacity:.6"></div>';
inner += '<div style="position:absolute;top:-1px;bottom:-1px;left:' + xR(-1).toFixed(1) + 'px;width:1px;background:#ff5a60;opacity:.6"></div>'; // stop
inner += '<div style="position:absolute;top:-1px;bottom:-1px;left:' + z.toFixed(1) + 'px;width:1px;background:#e6edf3;opacity:.7"></div>'; // breakeven
}
const txt = r != null ? (r >= 0 ? '+' : '') + r.toFixed(2) + 'R'
: ((cur - entry) / entry >= 0 ? '+' : '') + ((cur - entry) / entry * 100).toFixed(1) + '%';
const sgn = (r != null ? r : (cur - entry)) >= 0 ? 'pos' : 'neg';
return '<div class="rbar">' + inner + '</div><span class="rtxt ' + sgn + '">' + txt + '</span>';
}
function exitCls(reason) {
reason = String(reason || "");
if (reason.includes("stop")) return "ex-stop";
if (reason.includes("profit") || reason.includes("target")) return "ex-tp";
return "ex-mkt";
}
// All-Time scorecard — the bot's cumulative record across every closed round-trip
function renderAllTime(a) {
const host = $("alltime"); if (!host) return;
if (!a || !a.trades) { host.innerHTML = '<span class="empty">No closed trades recorded yet.</span>'; return; }
const m = (label, val, cls) => '<div class="metric"><div class="label">' + label
+ '</div><div class="value ' + (cls || "") + '">' + val + '</div></div>';
const wr = a.win_rate != null ? (a.win_rate * 100).toFixed(0) + '%' : '—';
const avgr = a.avg_r_multiple != null ? (a.avg_r_multiple >= 0 ? '+' : '') + a.avg_r_multiple + 'R' : '—';
const day = d => d ? d.date.slice(5) + ' ' + money(d.pnl) : '—';
host.innerHTML =
m('Total Realized', money(a.total_realized), a.total_realized >= 0 ? 'pos' : 'neg') +
m('Win Rate', wr + ' <span class="sub">' + a.wins + 'W / ' + a.losses + 'L</span>') +
m('Trades', a.trades) +
m('Avg R', avgr, a.avg_r_multiple == null ? '' : (a.avg_r_multiple >= 0 ? 'pos' : 'neg')) +
m('Trading Days', a.trading_days) +
m('Best Day', day(a.best_day), a.best_day && a.best_day.pnl >= 0 ? 'pos' : 'neg') +
m('Worst Day', day(a.worst_day), 'neg');
}
// Day-over-day equity curve: a waterfall — the blue line is cumulative P&L
// (the equity curve), each bar is that day's step (green up / red down). One
// shared scale; click a day to open it. Highlights the currently-viewed day.
function renderDaily(days) {
const host = $("daily-curve"); const cnt = $("daily-count");
if (cnt) cnt.textContent = (days || []).length;
if (!days || !days.length) { host.innerHTML = '<span class="empty">No trading days recorded yet.</span>'; return; }
const n = days.length;
const W = Math.max(host.clientWidth || 680, n * 48);
const H = 184, padT = 16, padB = 30, padX = 12, plotH = H - padT - padB;
const cums = days.map(d => +d.cum_pnl || 0);
const lo = Math.min(0, ...cums), hi = Math.max(0, ...cums), rng = (hi - lo) || 1;
const Y = v => padT + plotH * (1 - (v - lo) / rng);
const slot = (W - 2 * padX) / n, cx = i => padX + slot * (i + 0.5);
const barW = Math.min(slot * 0.5, 34);
const z = Y(0).toFixed(1);
let bars = '', labels = '', dots = '', prev = 0;
days.forEach((d, i) => {
const cur = cums[i], up = (+d.day_pnl || 0) >= 0, col = up ? '#34d17e' : '#ff5a60';
const y1 = Y(prev), y2 = Y(cur), top = Math.min(y1, y2), h = Math.max(1, Math.abs(y1 - y2));
const x = (cx(i) - barW / 2).toFixed(1);
const sel = viewDate === d.date ? ' daily-sel' : '';
const wr = d.win_rate != null ? (d.win_rate * 100).toFixed(0) + '% win' : 'no trades';
const tip = d.date + ' · day ' + (d.day_pnl >= 0 ? '+' : '') + d.day_pnl
+ ' · ' + d.trades + ' trades · ' + wr + ' · cum ' + cur.toFixed(0);
bars += '<g class="daily-col' + sel + '" onclick="setView(\'' + d.date + '\')"><title>' + esc(tip) + '</title>'
+ '<rect class="daily-hit" x="' + (padX + slot * i).toFixed(1) + '" y="' + padT + '" width="' + slot.toFixed(1) + '" height="' + plotH + '"/>'
+ '<rect x="' + x + '" y="' + top.toFixed(1) + '" width="' + barW.toFixed(1) + '" height="' + h.toFixed(1) + '" rx="2" fill="' + col + '" opacity=".5"/>'
+ '</g>';
labels += '<text class="daily-lbl' + sel + '" x="' + cx(i).toFixed(1) + '" y="' + (H - 10) + '">' + d.date.slice(5) + '</text>';
dots += '<circle cx="' + cx(i).toFixed(1) + '" cy="' + y2.toFixed(1) + '" r="2.8" fill="#5ba3ff"/>';
prev = cur;
});
const line = days.map((d, i) => cx(i).toFixed(1) + ',' + Y(cums[i]).toFixed(1)).join(' ');
host.innerHTML = '<svg width="' + W + '" height="' + H + '" viewBox="0 0 ' + W + ' ' + H + '" class="daily-svg">'
+ '<line x1="' + padX + '" y1="' + z + '" x2="' + (W - padX) + '" y2="' + z + '" stroke="#30363d" stroke-width="1" stroke-dasharray="3 3"/>'
+ bars
+ '<polyline points="' + line + '" fill="none" stroke="#5ba3ff" stroke-width="1.6"/>'
+ dots + labels + '</svg>';
}
// Per-day Trades — the individual round-trips that closed on the viewed day
function renderTrades(pnl) {
const trades = (pnl && pnl.trades) || [];
$("trades-count").textContent = trades.length;
const net = trades.reduce((s, t) => s + (+t.realized_pnl || 0), 0);
const wins = trades.filter(t => +t.realized_pnl > 0).length;
const netEl = $("trades-net");
if (netEl) netEl.innerHTML = trades.length
? 'net <b class="' + (net >= 0 ? 'pos' : 'neg') + '">' + money(net) + '</b> · ' + wins + '/' + trades.length + ' win'
: '';
fill("trades", table(
["Symbol", "Entry", "Exit $", "Exit", "R", "P/L", "Time"],
trades.map(t => '<tr><td class="sym">' + esc(t.symbol) + '</td>' +
'<td class="num">' + fmt(t.entry_price) + '</td>' +
'<td class="num">' + fmt(t.exit_price) + '</td>' +
'<td><span class="exit-tag ' + exitCls(t.exit_reason) + '">' + esc(String(t.exit_reason || "").replace(/_/g, " ")) + '</span></td>' +
'<td class="num ' + (t.r_multiple == null ? '' : (t.r_multiple >= 0 ? 'pos' : 'neg')) + '">' + (t.r_multiple == null ? '—' : (t.r_multiple >= 0 ? '+' : '') + t.r_multiple + 'R') + '</td>' +
'<td class="num ' + (+t.realized_pnl >= 0 ? 'pos' : 'neg') + '">' + money(t.realized_pnl) + '</td>' +
'<td class="num">' + timeOf(t.timestamp) + '</td></tr>')
), "No closed trades on this day.");
}
// unrealized P&L as $ + % with a colored magnitude bar (bar maxes out at ±10%)
function unrealCell(upnl, pct) {
const v = +upnl;
const cls = isNaN(v) ? "" : v >= 0 ? "pos" : "neg";
const hasPct = pct != null && !isNaN(+pct);
const pctTxt = hasPct ? '<span class="pl-pct">' + (+pct >= 0 ? '+' : '') + (+pct * 100).toFixed(1) + '%</span>' : '';
const w = hasPct ? Math.min(100, Math.abs(+pct) * 1000) : 0; // 10% -> full bar
const col = v >= 0 ? 'var(--green)' : 'var(--red)';
const bar = '<div class="pl-bar"><div class="pl-bar-fill" style="width:' + w.toFixed(0) + '%;background:' + col + '"></div></div>';
return '<div class="pl-cell ' + cls + '"><div class="pl-top">' + money(upnl) + ' ' + pctTxt + '</div>' + bar + '</div>';
}
function render(d) {
latest = d;
populateDates(d.available_dates);
// LIVE badge derives from BROKER TRUTH (the base-URL host), not the
// execution_mode literal — the old schwab_live keying read PAPER while the
// go-live flow swapped only ALPACA_BASE_URL/keys (plan 007 step 4).
const broker = d.broker || {};
const isPaper = broker.is_paper !== false; // default to paper when unknown
const badge = $("mode-badge");
const brokerName = broker.name || "ALPACA";
badge.textContent = (isPaper ? "PAPER · " : "LIVE · ") + brokerName;
badge.className = "badge " + (isPaper ? "paper" : "live");
const acct0 = (d.accounts || [])[0] || {};
badge.title = (broker.base_url_host || "") + (acct0.account_id ? " · " + acct0.account_id : "");
document.body.classList.toggle("live-mode", !isPaper);
// action-control gating: the standalone dashboard has no execution service, so
// it can only act via the token-gated command bus. Kill-switch controls need
// the bus; approve/reject/inject need either.
const canCommand = !!d.command_bus;
const canAct = !!(d.has_execution || d.command_bus);
["flatten-all", "pause-entries", "op-token"].forEach(id => {
const el = $(id); if (el) el.style.display = canCommand ? "" : "none";
});
const injectEl = document.querySelector(".inject");
if (injectEl) injectEl.style.display = canAct ? "" : "none";
const p = d.pnl || {};
signed($("pnl-total"), p.total_pnl);
signed($("pnl-realized"), p.realized_pnl);
signed($("pnl-unrealized"), p.unrealized_pnl);
$("pnl-winrate").textContent = p.win_rate != null ? (p.win_rate * 100).toFixed(0) + "%" : "—";
$("pnl-winrate").className = "value num";
$("pnl-avgr").textContent = p.avg_r_multiple != null ? p.avg_r_multiple.toFixed(2) + "R" : "—";
$("pnl-avgr").className = "value num " + (p.avg_r_multiple > 0 ? "pos" : p.avg_r_multiple < 0 ? "neg" : "");
$("pnl-counts").textContent = (p.open_positions != null ? p.open_positions : 0) + " / " + (p.closed_trades != null ? p.closed_trades : 0);
$("pnl-counts").className = "value num";
// equity + settled/available funds (cash account: buys need settled cash,
// so available can lag equity by days after a deposit or sale)
if (acct0.account_id) {
const eq = acct0.total_equity != null ? acct0.total_equity : acct0.net_liquidating_value;
const avail = acct0.buying_power;
$("pnl-funds").innerHTML = money(eq)
+ '<span class="funds-avail">avail ' + (avail != null ? money(avail) : "—") + '</span>';
} else {
$("pnl-funds").textContent = "—";
}
// portfolio risk strip + naked-position alarm — the conditions that silently
// cost money this session, now visible.
const rk = d.risk || {};
const nb = $("naked-banner");
if (rk.unprotected && rk.unprotected.length) {
nb.textContent = "⚠ UNPROTECTED: " + rk.unprotected.join(", ") + " — held long with no / short protective stop";
nb.classList.add("show");
} else { nb.classList.remove("show"); }
// day-2 stale-session alarm: the loop booted a PRIOR day and never restarted —
// its memory-only daily flags block every entry with zero events (2026-07-21).
const ls = d.loop_session || {}, sb = $("stale-banner");
if (sb) {
if (ls.stale && !d.view_date) {
sb.textContent = "\u26A0 LOOP SESSION FROM " + ls.session_date + " STILL RUNNING \u2014 daily state is stale (post-EOD boot survived midnight); new entries are silently blocked. Restart the loop container.";
sb.classList.add("show");
} else { sb.classList.remove("show"); }
}
const rsEl = $("risk-status");
rsEl.textContent = (rk.status || "—").toUpperCase();
rsEl.className = "rdot " + (rk.status || "");
$("risk-day").textContent = rk.day_pnl_pct != null ? (rk.day_pnl_pct * 100).toFixed(1) + "% / -" + (rk.daily_loss_limit_pct * 100).toFixed(0) + "%" : "—";
const fr = $("risk-fresh");
fr.textContent = rk.data_age_s != null ? rk.data_age_s.toFixed(0) + "s" : "—";
// broker sync runs ~every 60s, so >90s = a missed sync (amber), >180s = stale (red)
fr.style.color = (rk.data_age_s == null) ? "" : (rk.data_age_s > 180 ? "#ff5a60" : rk.data_age_s > 90 ? "#f1c40f" : "");
renderTriggers(d.triggers, d.catalyst);
renderCatalystEngine(d.catalyst_engine);
renderCatalystFeed(d.catalyst_feed);
renderAllTime(d.alltime);
renderExperiments(d.experiments);
renderDaily(d.daily);
renderTrades(d.pnl);
renderEquity(d.equity_curve);
const accts = d.accounts || [];
$("acct-count").textContent = accts.length;
if (accts.length) {
$("accounts").innerHTML = accts.map(a =>
'<div class="metric"><div class="label">' + esc(a.broker_name) + ' &middot; ' + esc(a.account_id) + '</div><div class="value">' + money(a.total_equity != null ? a.total_equity : a.net_liquidating_value) + '</div></div>' +
'<div class="metric"><div class="label">Cash</div><div class="value">' + money(a.cash_balance) + '</div></div>'
).join("");
} else {
$("accounts").innerHTML = '<span class="empty">No account snapshot yet — waiting for broker sync.</span>';
}
const aq = d.approval_queue || [];
$("aq-count").textContent = aq.length;
fill("approval-queue", table(
["Symbol", "Side", "Risk preview", "Catalyst", "Actions"],
aq.map(q => {
const r = q.execution_request || {};
return '<tr><td class="sym">' + esc(q.symbol) + '</td>' +
'<td><span class="pill ' + (r.side === "sell" ? "short" : "long") + '">' + esc(r.side || "buy") + '</span></td>' +
'<td>' + riskChip(r) + '</td>' +
'<td>' + catalystCell(q.catalyst) + '</td>' +
'<td><div class="actions">' + (canAct ?
'<button class="btn-approve" aria-label="Approve entry for ' + esc(q.symbol) + '" onclick="approve(\'' + esc(q.order_id) + '\')">Approve</button>' +
'<button class="btn-reject" aria-label="Reject entry for ' + esc(q.symbol) + '" onclick="reject(\'' + esc(q.order_id) + '\')">Reject</button>'
: '<span class="muted">read-only</span>') +
'</div></td></tr>';
})
), "No pending approvals.");
// top 15 by quality only — rendering all (often 80+) sprays a chart fetch per row and
// buries the best setups; "Setup" is dropped (≈every row is opening_range_break).
const rsAll = d.ready_signals || [];
const rs = rsAll.slice().sort((a, b) => (+b.quality_score || 0) - (+a.quality_score || 0)).slice(0, 15);
const catMap = d.catalyst || {};
$("rs-count").textContent = rsAll.length > 15 ? "15 / " + rsAll.length : rsAll.length;
fill("ready-signals", table(
["Symbol", "Chart", "Catalyst", "Entry", "Stop", "Qual", "Time"],
rs.map(s => '<tr><td class="sym">' + esc(s.symbol) + '</td>' +
'<td class="spark-wrap"><canvas class="spark" data-spark="' + esc(s.symbol) + '" data-entry="' + (s.entry_price != null ? s.entry_price : "") + '" data-stop="' + (s.stop_loss_price != null ? s.stop_loss_price : "") + '"></canvas></td>' +
'<td class="rs-cat">' + (trigCatalyst(catMap[(s.symbol || "").toUpperCase()] || catMap[s.symbol], null) || '<span class="muted">—</span>') + '</td>' +
'<td class="num">' + fmt(s.entry_price) + '</td>' +
'<td class="num">' + fmt(s.stop_loss_price) + '</td>' +
'<td class="num">' + fmt(s.quality_score) + '</td>' +
'<td class="num">' + timeOf(s.timestamp) + '</td></tr>')
), "No ready signals this session.");
// map each symbol -> its resting protective stop (for the R bar + spark stop line)
const stopBy = {};
// map each symbol -> its resting take-profit limit / not-yet-filled entry limit
// (working legs only — a filled or cancelled leg is not a live level). Kept
// top-level in render(d): plan 013's symbol chart reuses these as level-lines.
// sync.py fetches status="all", so stopBy above can pick up a CANCELLED stop
// leg — fine for its existing consumers (R-bar/spark), but the new Positions
// Stop column must only ever show a live level. stopWorkingBy is that
// working-filtered variant (plan 013 will want it too).
const targetBy = {}, entryLimitBy = {}, stopWorkingBy = {};
const WORKING = { new: 1, accepted: 1, pending_new: 1, partially_filled: 1 };
(d.orders || []).forEach(snap => (snap.orders || []).forEach(o => {
if (o.side === "sell" && (o.type === "stop" || o.type === "stop_limit") && o.stop_price != null && !(o.symbol in stopBy))
stopBy[o.symbol] = +o.stop_price;
if (!WORKING[o.status]) return;
if (o.side === "sell" && o.type === "limit" && o.limit_price != null && !(o.symbol in targetBy))
targetBy[o.symbol] = +o.limit_price;
if (o.side === "buy" && o.type === "limit" && o.limit_price != null && !(o.symbol in entryLimitBy))
entryLimitBy[o.symbol] = +o.limit_price;
if (o.side === "sell" && (o.type === "stop" || o.type === "stop_limit") && o.stop_price != null && !(o.symbol in stopWorkingBy))
stopWorkingBy[o.symbol] = +o.stop_price;
}));
// Order Lifecycle events never captured limit/stop/type (see plan 012 Step 3
// revision) — join client-side to the broker snapshot via client_order_id,
// which is the internal order_id we send Alpaca (execution.py:116, truncated
// to 48 chars; uuids are 36 chars so it never actually truncates).
const brokerByClientId = {};
(d.orders || []).forEach(snap => (snap.orders || []).forEach(o => {
if (o.client_order_id && !(o.client_order_id in brokerByClientId))
brokerByClientId[o.client_order_id] = {
type: o.type,
limit: o.limit_price != null ? +o.limit_price : null,
stop: o.stop_price != null ? +o.stop_price : null,
fill: o.filled_avg_price != null ? +o.filled_avg_price : null,
};
}));
const posRows = [];
(d.positions || []).forEach(snap => (snap.positions || []).forEach(pos => {
const qty = +(pos.quantity != null ? pos.quantity : pos.qty != null ? pos.qty : 0);
const entry = pos.avg_entry_price != null ? +pos.avg_entry_price : (pos.average_price != null ? +pos.average_price : null);
const cur = pos.current_price != null ? +pos.current_price : (pos.market_price != null ? +pos.market_price : null);
const stop = stopBy[pos.symbol] != null ? stopBy[pos.symbol] : null;
const upnl = (entry != null && cur != null) ? (cur - entry) * Math.abs(qty) : (pos.unrealized_pl || pos.unrealized_pnl);
const pct = pos.unrealized_pnl_pct != null ? +pos.unrealized_pnl_pct
: (entry && cur != null ? (cur - entry) / entry : null);
posRows.push('<tr><td class="sym">' + esc(pos.symbol) + (stop == null ? ' <span class="rtxt neg" title="no protective stop">⚠</span>' : '') + '</td>' +
'<td class="spark-wrap"><canvas class="spark" data-spark="' + esc(pos.symbol) + '" data-entry="' + (entry != null ? entry : "") + '" data-stop="' + (stop != null ? stop : "") + '"></canvas></td>' +
'<td><span class="pill ' + (qty < 0 ? "short" : "long") + '">' + (qty < 0 ? "short" : "long") + '</span></td>' +
'<td class="num">' + esc(Math.abs(qty)) + '</td>' +
'<td class="num">' + fmt(entry) + '</td>' +
'<td class="num">' + fmt(cur) + '</td>' +
'<td class="num">' + fmt(stopWorkingBy[pos.symbol]) + '</td>' +
'<td class="num">' + fmt(targetBy[pos.symbol]) + '</td>' +
'<td>' + rBar(entry, cur, stop) + '</td>' +
'<td>' + unrealCell(upnl, pct) + '</td>' +
(canCommand ? '<td><button class="btn-exit" aria-label="Exit position " data-exit="' + esc(pos.symbol) + '">EXIT</button></td>' : '') +
'</tr>');
}));
$("pos-count").textContent = posRows.length;
fill("positions", table(
["Symbol", "Chart", "Side", "Qty", "Avg", "Last", "Stop", "Target", "R / risk", "P/L"].concat(canCommand ? ["Exit"] : []),
posRows), "No open positions.");
if (canCommand) document.querySelectorAll("button[data-exit]").forEach(btn => {
const sym = btn.getAttribute("data-exit");
btn.onclick = () => armButton(btn, s => "CONFIRM EXIT " + sym + " — " + s + "s",
() => cmd("flatten_symbol", sym, "Exit " + sym + " sent — loop executes within ~5s."));
});
renderWatch(d.watch_states || []);
const fills = d.fills || [];
$("feed-count").textContent = fills.length;
fill("feed", fills.length ? fills.slice(0, 20).map(f => {
const color = f.kind === "order_filled" ? "var(--green)" : "var(--accent)";
const verb = f.kind === "order_filled" ? "FILL" : "SENT";
return '<div class="feed-row"><span class="feed-dot" style="background:' + color + '"></span>' +
'<span class="feed-time">' + timeOf(f.timestamp) + '</span>' +
'<span style="color:' + color + ';font-weight:600">' + verb + '</span>' +
'<span class="sym">' + esc(f.symbol || "") + '</span>' +
'<span class="pill ' + (f.side === "sell" ? "short" : "long") + '">' + esc(f.side || "") + '</span>' +
'<span>' + esc(f.quantity != null ? f.quantity : "") + ' @ ' + fmt(f.price) + '</span></div>';
}).join("") : null, "No fills yet this session.");
// show only the ACTIONABLE orders — the raw count (often 1000s incl. rejected/cancelled)
// reads as an alarm and the noise buries live orders.
const olAll = d.order_lifecycle || [];
const ol = olAll.filter(o => o.status !== "rejected" && o.status !== "cancelled");
$("ol-count").textContent = olAll.length > ol.length ? ol.length + " / " + olAll.length : ol.length;
fill("order-lifecycle", table(
["Symbol", "Side", "Type", "Qty", "Limit", "Stop", "Fill", "Status", "Updated"],
ol.slice(0, 30).map(o => {
const b = brokerByClientId[o.order_id] || {};
return '<tr><td class="sym" title="' + esc(o.order_id || "") + '">' + esc(o.symbol) + '</td>' +
'<td><span class="pill ' + (o.side === "sell" ? "short" : "long") + '">' + esc(o.side || "—") + '</span></td>' +
'<td class="num">' + esc(b.type || "—") + '</td>' +
'<td class="num">' + esc(o.quantity != null ? o.quantity : "—") + '</td>' +
'<td class="num">' + fmt(b.limit) + '</td>' +
'<td class="num">' + fmt(b.stop) + '</td>' +
'<td class="num">' + fmt(b.fill != null ? b.fill : o.fill_price) + '</td>' +
'<td class="num">' + esc(o.status || "—") + '</td>' +
'<td class="num">' + timeOf(o.updated_at) + '</td></tr>';
})
), "No orders yet.");
document.querySelectorAll("canvas[data-spark]").forEach(canvas => {
const sym = canvas.getAttribute("data-spark");
const entry = parseFloat(canvas.getAttribute("data-entry"));
const stop = parseFloat(canvas.getAttribute("data-stop"));
loadBars(sym, isNaN(entry) ? null : entry, isNaN(stop) ? null : stop, canvas);
});
const key = rs.length ? rs[0].symbol + "|" + rs[0].timestamp : "";
if (key && key !== lastSignalKey && lastSignalKey !== "") {
beep();
toast("New ready signal: " + rs[0].symbol + " (" + (rs[0].signal_type || "setup") + ")", "ok");
}
lastSignalKey = key;
}
let es = null, pollTimer = null, viewDate = null;
// populate the date picker from the snapshot's available_dates (LIVE + past days)
function populateDates(dates) {
const sel = document.getElementById("date-select"); if (!sel) return;
if (sel.options.length === ((dates || []).length + 1)) return; // already built
sel.innerHTML = '<option value="">● LIVE</option>' +
(dates || []).map(d => '<option value="' + d + '">' + d + '</option>').join('');
sel.value = viewDate || '';
}
// switch between live and a historical session
function setView(date) {
viewDate = date || null;
const sel = document.getElementById("date-select");
if (sel) sel.classList.toggle("histor", !!viewDate);
if (es) { es.close(); es = null; }
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
if (viewDate) loadHistorical(); else startStream(); // live resumes the stream
}
async function loadHistorical() {
setConn(true, "loading " + viewDate + " ...");
try {
const r = await fetch("/api/snapshots?date=" + encodeURIComponent(viewDate));
if (!r.ok) throw new Error("HTTP " + r.status);
render(await r.json());
hideLoader();
setConn(true, "VIEWING " + viewDate + " (historical)");
} catch (e) { loaderError(e.message); setConn(false, "load failed — " + e.message); }
}
function setConn(ok, msg) {
$("conn-dot").className = "dot " + (ok ? "live" : "err");
$("stamp").textContent = msg;
}
// First-paint loading overlay control. Dismissed the moment real data renders;
// shows an error (and stops the spinner) if the first fetch fails so it never
// hangs silently.
let loaderDone = false;
function hideLoader() {
if (loaderDone) return;
loaderDone = true;
const el = $("loader"); if (!el) return;
el.classList.add("hide");
setTimeout(() => { el.style.display = "none"; }, 500);
}
function loaderError(msg) {
if (loaderDone) return; // already showing live data — ignore
const el = $("loader"); if (!el) return;
el.classList.add("err");
const m = $("loader-msg"), s = $("loader-sub");
if (m) m.textContent = "couldn't reach the control room";
if (s) s.textContent = (msg || "connection failed") + " — retrying…";
}
// watchdog: if the first snapshot is slow, reassure rather than look frozen
setTimeout(() => {
if (loaderDone) return;
const s = $("loader-sub");
if (s && !$("loader").classList.contains("err")) s.textContent = "taking a moment — still fetching the snapshot…";
}, 5000);
function startStream() {
// Render immediately from a plain fetch (the same reliable path the date
// selector uses) so LIVE shows data even if EventSource never connects in
// this browser/proxy. SSE then layers on for low-latency pushes.
pollOnce();
if (typeof EventSource === "undefined") { startPolling(); return; }
try {
es = new EventSource("/api/stream");
es.addEventListener("snapshot", (ev) => {
try {
render(JSON.parse(ev.data)); hideLoader(); setConn(true, "live · " + new Date().toLocaleTimeString());
// chart modal rides the same signal — no standalone poller (≥5s throttle, full replace).
if (chartState.symbol && Date.now() - chartState.lastFetch >= 5000) refreshChart(chartState.symbol);
loadDecisions(); // no-op unless the panel is open; internally ≥5s throttled
}
catch (e) { console.error("render failed:", e); setConn(false, "render error (see console)"); }
});
es.onerror = () => { setConn(false, "stream lost — polling"); if (es) es.close(); es = null; startPolling(); };
} catch (e) { startPolling(); }
}
async function pollOnce() {
try {
const r = await fetch("/api/snapshots");
if (!r.ok) throw new Error("HTTP " + r.status);
render(await r.json());
hideLoader();
setConn(true, "polling · " + new Date().toLocaleTimeString());
} catch (e) { loaderError(e.message); setConn(false, "disconnected — " + e.message); }
}
function startPolling() {
if (pollTimer) return;
pollOnce();
pollTimer = setInterval(pollOnce, 4000);
}
$("sound-toggle").onclick = () => {
soundOn = !soundOn;
$("sound-toggle").textContent = soundOn ? "SOUND ON" : "SOUND OFF";
$("sound-toggle").className = "hdr-btn" + (soundOn ? " on" : "");
if (soundOn) beep();
};
$("date-select").onchange = (e) => setView(e.target.value);
// --- operator kill switch wiring ---
let entriesPaused = false;
function applyPauseUI() {
const b = $("pause-entries");
if (b) { b.classList.toggle("on", entriesPaused); b.textContent = entriesPaused ? "RESUME ENTRIES" : "PAUSE ENTRIES"; }
const banner = $("pause-banner");
if (banner) banner.classList.toggle("show", entriesPaused);
}
const flattenBtn = $("flatten-all");
if (flattenBtn) flattenBtn.onclick = () => {
const n = ((latest && latest.positions) || []).reduce((a, s) => a + ((s.positions || []).length), 0);
armButton(flattenBtn, s => "CONFIRM FLATTEN (" + n + " position" + (n === 1 ? "" : "s") + ") — " + s + "s",
() => cmd("flatten_all", null, "Flatten command sent — loop executes within ~5s."));
};
const pauseBtn = $("pause-entries");
if (pauseBtn) pauseBtn.onclick = async () => {
const next = !entriesPaused;
const ok = await cmd(next ? "pause_entries" : "resume_entries", null,
next ? "Entries paused — no new positions." : "Entries resumed.");
if (ok) { entriesPaused = next; applyPauseUI(); }
};
const opTokEl = $("op-token");
if (opTokEl) {
opTokEl.value = opToken();
opTokEl.addEventListener("input", () => localStorage.setItem("opTok", opTokEl.value));
opTokEl.addEventListener("keydown", (e) => e.stopPropagation()); // don't trip global A/R shortcuts
}
$("kbd-toggle").onclick = () => $("kbd-help").classList.toggle("show");
$("kbd-help").onclick = (e) => { if (e.target.id === "kbd-help") $("kbd-help").classList.remove("show"); };
function doInject() {
const v = $("inject-input").value.trim().toUpperCase();
if (v) { post("/api/watch/add", {symbol: v}); $("inject-input").value = ""; }
}
$("inject-btn").onclick = doInject;
$("inject-input").addEventListener("keydown", (e) => { if (e.key === "Enter") doInject(); e.stopPropagation(); });
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT") return;
const aq = latest && latest.approval_queue || [];
if (e.key === "a" || e.key === "A") { if (aq[0]) approve(aq[0].order_id); }
else if (e.key === "r" || e.key === "R") { if (aq[0]) reject(aq[0].order_id); }
else if (e.key === "s" || e.key === "S") { $("sound-toggle").click(); }
else if (e.key === "/") { e.preventDefault(); $("inject-input").focus(); }
else if (e.key === "?") { $("kbd-help").classList.toggle("show"); }
else if (e.key === "Escape") { $("kbd-help").classList.remove("show"); closeChart(); }
});
startStream();
setTimeout(() => { if (!latest) startPolling(); }, 3000);
</script>
</body>
</html>