Adds a read-only /backtest page showing how the bot WOULD have traded past sessions under the CURRENTLY configured live settings: theoretical ORB fires from setup_events, replayed through the shared exit engine (simulate_exit + ExitConfig.from_env(), same adverse-fill accounting the exit-experiment harness uses), screened through the shared population gate and (optionally) the live regime gate. No new simulation math — replay.replay_window() is pure assembly of existing pieces (reconstruct_fires_from_setups, _fire_in_population, _load_regime_favorable, simulate_exit); api/main.py adds the /backtest and /api/backtest routes plus a dedicated-lock 10-min cache so a slow replay never blocks the live dashboard's SSE stream. Also extends /api/chart with an optional date= anchor so the trade drill-in chart modal can reach sessions older than the existing 72h-from-now window, and adds a one-line BACKTEST nav link to the live dashboard header. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1753 lines
96 KiB
HTML
1753 lines
96 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); }
|
|
|
|
/* 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; }
|
|
.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: #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); }
|
|
|
|
/* 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&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>
|
|
<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">⏸ ENTRIES PAUSED — no new positions (exits & 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&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 · 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 · 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 & how it ruled · 🟢 bullish · 🔻 bearish · 🔴 dilutive · 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&L bars + cumulative equity curve · 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 · 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 → signals (early vs late) → gates → execution → P&L · 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 · value → threshold per gate · 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 {'&':'&','<':'<','>':'>'}[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&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]) + '×' + 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) · 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 => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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 = {};
|
|
|
|
// 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: "#2fbf71", downColor: "#e5484d", borderVisible: false,
|
|
wickUpColor: "#2fbf71", wickDownColor: "#e5484d",
|
|
});
|
|
const markersApi = LightweightCharts.createSeriesMarkers(series, []);
|
|
chartState = { symbol, chart, series, markersApi, 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, "#e5484d", "stop");
|
|
add(levels.target, "#2fbf71", "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
|
|
chartState.series.setData((data.bars || []).map(b =>
|
|
({ time: b.time, open: b.open, high: b.high, low: b.low, close: b.close })));
|
|
const fillMarkers = (data.markers || []).map(m => {
|
|
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 ? "#e5484d" : "#2fbf71"),
|
|
text: (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) + "→" + 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, "#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> · ' + qty + 'sh · notional <b>' + money(notional) + '</b> · entry <b>' + money(entry) + '</b> · 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 || []);
|
|
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) + '% · ' + data.passed_count + '/' + data.total_count + ' · ' + 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 : '?') + ' → 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 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) + ' · ' + 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" 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 = {};
|
|
// 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" 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>
|