Files
momentum-system/api/static/dashboard.html
T
momentum devandClaude Fable 5 e2b3c1ceae operator-plane: headless smoke + effective-config panel + ledger + unpin ruff
Step 6: tests/smoke_dashboard_headless.py renders the real page in a headless
browser and fails on any JS console/pageerror (standalone script, not
pytest-collected; CI non-blocking first). admin_api gains GET /api/config/effective
(per-key masked value + source: overrides.env vs compose, missing .env.example
keys flagged as drift) + an admin.html table (values XSS-escaped). dashboard.html
gains the Experiment Ledger panel reading the plan-006 verdicts from the snapshot
(empty-state until the tables exist). Removes the last CI ruff per-file pin
(api/main.py:F821) now that os/hmac are imported; scoped ruff is green unpinned.
Marks plan 007 DONE in plans/README.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:36:00 -04:00

1493 lines
82 KiB
HTML

<!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: #e3e8f2;
--muted: #7884a2;
--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); }
.pnl-cell .label { font: 600 10px var(--mono); text-transform: uppercase; letter-spacing: .7px; color: var(--muted); }
.pnl-cell .value { font: 650 20px var(--mono); margin-top: 3px; letter-spacing: -.3px; font-variant-numeric: tabular-nums; }
#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 10px var(--mono); color: var(--muted); letter-spacing: .3px; text-transform: none; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; font: 600 11px var(--mono); text-transform: uppercase; letter-spacing: .6px; 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); }
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); }
/* 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: #2ecc71; 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: #ff4d4f; text-shadow: 0 0 12px rgba(255,77,79,.5); }
/* armed-trigger proximity radar */
.trig-row { display: flex; align-items: center; gap: 11px; padding: 6px 6px;
border-bottom: 1px solid var(--border); font: 600 12px var(--mono); }
.trig-row:last-child { border-bottom: 0; }
.trig-sym { width: 62px; font-weight: 700; }
.trig-state { width: 52px; font-size: 9px; font-weight: 700; text-transform: uppercase;
letter-spacing: .5px; }
.trig-meta { width: 96px; color: var(--muted); font: 500 10px var(--mono); }
.trig-bar { flex: 1; height: 16px; position: relative; background: #0d1117;
border: 1px solid var(--border); border-radius: 3px; 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: 196px; color: var(--accent); font: 500 10px var(--mono); 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; }
.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: #2ecc71; } .st-fired .trig-fill { background: #2ecc71; }
.st-filled .trig-state { color: #2ecc71; } .st-filled .trig-fill { background: linear-gradient(90deg,#1f8f4e,#2ecc71); }
.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); }
@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" 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" 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>
<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 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 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>
</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>
<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) => {
el.textContent = (v === null || v === undefined || isNaN(+v)) ? "—" : 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 = {};
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, "#e5484d");
const up = closes[closes.length - 1] >= closes[0];
ctx.strokeStyle = up ? "#2fbf71" : "#e5484d";
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 ? "#2fbf71" : "#e5484d";
ctx.fill(); ctx.globalAlpha = 1;
ctx.fillStyle = up ? "#2fbf71" : "#e5484d";
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; stop <b>' + (perShare/entry*100).toFixed(1) + '%</b></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 || []);
if (expanded.has(symbol)) {
try {
const r = await fetch("/api/criteria?symbol=" + encodeURIComponent(symbol));
const host = document.querySelector('[data-crit="' + CSS.escape(symbol) + '"]');
if (host) host.innerHTML = renderCriteriaGrid(await r.json());
} catch (e) {}
}
}
function renderCriteriaGrid(data) {
if (!data.criteria || !data.criteria.length)
return '<div class="empty" style="padding:12px 16px">No criteria evaluated yet.</div>';
const cells = 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 '<div class="criteria-grid">' + 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), "");
expanded.forEach(async (symbol) => {
const host = document.querySelector('[data-crit="' + CSS.escape(symbol) + '"]');
if (host && host.querySelector(".empty")) {
try { host.innerHTML = renderCriteriaGrid(await (await fetch("/api/criteria?symbol=" + encodeURIComponent(symbol))).json()); }
catch (e) {}
}
});
}
// 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>';
}
function renderTriggers(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' : '';
return '<div class="trig-row st-' + esc(t.state) + near + '">'
+ '<div class="trig-sym">' + (t.catalyst ? '📰 ' : '') + esc(t.symbol) + '</div>'
+ '<div class="trig-state">' + esc(t.state) + '</div>'
+ '<div class="trig-meta">' + (t.gap != null ? 'gap ' + t.gap.toFixed(0) + '% ' : '') + (t.rvol != null ? t.rvol.toFixed(1) + 'x' : '') + '</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>';
}).join('');
}
// 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">' + 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 ? '#2ecc71' : '#ff4d4f';
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 ? '#2ecc71' : '#ff4d4f';
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:#ff4d4f;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", "Exit", "R", "P/L", "Time"],
trades.map(t => '<tr><td class="sym">' + esc(t.symbol) + '</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");
badge.textContent = isPaper ? "PAPER · ALPACA" : "LIVE · ALPACA";
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";
// 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"); }
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 ? "#ff4d4f" : 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", "Mode", "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 class="num">' + esc(q.approval_mode || "manual") + '</td>' +
'<td><div class="actions">' + (canAct ?
'<button class="btn-approve" onclick="approve(\'' + esc(q.order_id) + '\')">Approve</button>' +
'<button class="btn-reject" 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 = {};
(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;
}));
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>' + rBar(entry, cur, stop) + '</td>' +
'<td>' + unrealCell(upnl, pct) + '</td>' +
(canCommand ? '<td><button class="btn-exit" data-exit="' + esc(pos.symbol) + '">EXIT</button></td>' : '') +
'</tr>');
}));
$("pos-count").textContent = posRows.length;
fill("positions", table(
["Symbol", "Chart", "Side", "Qty", "Avg", "Last", "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(
["Order", "Symbol", "Side", "Qty", "Price", "Status", "Updated"],
ol.slice(0, 30).map(o => '<tr><td class="num" style="color:var(--muted)">' + esc(String(o.order_id || "").slice(0, 8)) + '</td>' +
'<td class="sym">' + esc(o.symbol) + '</td>' +
'<td><span class="pill ' + (o.side === "sell" ? "short" : "long") + '">' + esc(o.side || "—") + '</span></td>' +
'<td class="num">' + esc(o.quantity != null ? o.quantity : "—") + '</td>' +
'<td class="num">' + fmt(o.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()); }
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"); }
});
startStream();
setTimeout(() => { if (!latest) startPolling(); }, 3000);
</script>
</body>
</html>