LIVE rendered ONLY from the EventSource snapshot handler — and that handler
swallowed render/parse errors (catch {}). If EventSource didn't establish in the
user's browser (proxy, extension, connection cap), LIVE stayed blank while the
date selector — a plain fetch — worked fine. Exactly the reported symptom.
- startStream() now does an immediate pollOnce() (the same fetch path the date
selector uses) so LIVE shows data even if SSE never connects; SSE then layers
on for low-latency pushes, with onerror -> interval polling as before.
- Surface render errors (console + conn status) instead of hiding them.
- Serve the dashboard HTML with no-store/no-cache so pushed fixes actually reach
the browser (a stale cached shell was part of why "I see nothing" persisted).
Verified headless: with EventSource forcibly disabled, LIVE still populates
(10 armed rows, pnl, risk) via the polling fallback — stamp shows "polling ·".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
857 lines
46 KiB
HTML
857 lines
46 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; }
|
|
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; }
|
|
.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);
|
|
}
|
|
.count { font: 600 11px var(--mono); background: var(--border); color: var(--text); border-radius: 10px; padding: 1px 8px; }
|
|
.hint { margin-left: auto; font: 500 10px var(--mono); color: var(--muted); letter-spacing: .3px; text-transform: none; }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th { text-align: left; font: 600 11px var(--mono); text-transform: uppercase; letter-spacing: .6px; color: var(--muted); padding: 8px 14px; border-bottom: 1px solid var(--border); }
|
|
td { padding: 8px 14px; border-bottom: 1px solid #151a26; font-size: 13px; vertical-align: middle; }
|
|
tr:last-child td { border-bottom: none; }
|
|
.num { font-family: var(--mono); }
|
|
.sym { font: 600 13px var(--mono); color: #e9edf5; }
|
|
.empty { color: var(--muted); padding: 18px 14px; font-size: 13px; }
|
|
.pill { display: inline-block; font: 600 10px var(--mono); text-transform: uppercase; letter-spacing: .6px; padding: 3px 9px; border-radius: 5px; border: 1px solid transparent; }
|
|
.pill.ready { background: #103225; color: var(--green); border-color: #1d5a40; }
|
|
.pill.watching { background: #13283f; color: var(--accent); border-color: #244a74; }
|
|
.pill.blocked { background: #34211a; color: var(--amber); border-color: #5c3d1c; }
|
|
.pill.late { background: #28223040; color: var(--purple); border-color: #3d3450; }
|
|
.pill.discovered { background: #1a2130; color: var(--muted); border-color: #28324a; }
|
|
.pill.long { background: #103225; color: var(--green); border-color: #1d5a40; }
|
|
.pill.short { background: #361719; color: #ff8589; border-color: #5e2327; }
|
|
|
|
button { font: 600 12px -apple-system, "Segoe UI", sans-serif; border: 1px solid transparent; border-radius: 6px; padding: 6px 13px; cursor: pointer; transition: filter .12s, transform .1s, box-shadow .12s; }
|
|
button:hover { filter: brightness(1.16); transform: translateY(-1px); }
|
|
button:active { transform: translateY(0); }
|
|
button:disabled { opacity: .4; cursor: default; transform: none; }
|
|
.btn-approve { background: linear-gradient(180deg, #1a5238, #134029); color: #7defb4; border-color: #25714d; }
|
|
.btn-approve:hover { box-shadow: 0 4px 14px -6px rgba(52,209,126,.5); }
|
|
.btn-reject { background: linear-gradient(180deg, #45222540, #34191b); color: #ff9296; border-color: #6e2226; }
|
|
.btn-exit { background: linear-gradient(180deg, #43301440, #33260f); color: #ffc456; border-color: #6b4e1f; }
|
|
.actions { display: flex; gap: 6px; }
|
|
/* subtle load-in */
|
|
main > .panel { animation: rise .45s cubic-bezier(.2,.7,.3,1) both; }
|
|
@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
|
|
|
/* sparkline canvas */
|
|
.spark { display: block; width: 132px; height: 38px; }
|
|
.spark-wrap { position: relative; }
|
|
.spark-meta { font: 500 9px var(--mono); color: var(--muted); margin-top: 1px; }
|
|
|
|
/* expandable criteria row */
|
|
.expandable { cursor: pointer; }
|
|
.expandable:hover { background: #131826; }
|
|
.caret { display: inline-block; width: 10px; color: var(--muted); transition: transform .15s; }
|
|
.caret.open { transform: rotate(90deg); }
|
|
.criteria-cell { padding: 0 !important; background: #0d111a; }
|
|
.criteria-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; padding: 12px 16px; }
|
|
.crit { display: flex; align-items: center; gap: 7px; font: 500 12px var(--mono); }
|
|
.crit .mark { width: 15px; height: 15px; border-radius: 3px; display: grid; place-items: center; font-size: 10px; font-weight: 700; flex-shrink: 0; }
|
|
.crit.pass .mark { background: #12362a; color: var(--green); }
|
|
.crit.fail .mark { background: #38221a; color: var(--amber); }
|
|
.crit.skip .mark { background: #1c2230; color: var(--muted); }
|
|
.crit.fail { color: #c9a98a; }
|
|
.crit.skip { color: var(--muted); }
|
|
|
|
/* risk preview chip */
|
|
.risk { font: 500 11px var(--mono); color: var(--muted); }
|
|
.risk b { color: var(--text); font-weight: 600; }
|
|
/* naked-position alarm banner + portfolio risk strip */
|
|
.naked-banner { display: none; background: #b00020; color: #fff;
|
|
font: 700 13px var(--mono); padding: 9px 14px; text-align: center; letter-spacing: .4px; }
|
|
.naked-banner.show { display: block; animation: nakedpulse 1.1s ease-in-out infinite; }
|
|
@keyframes nakedpulse { 50% { background: #d32f3f; } }
|
|
.risk-strip { display: flex; gap: 20px; padding: 7px 14px; align-items: center;
|
|
border-bottom: 1px solid var(--border); font: 500 11px var(--mono); }
|
|
.risk-strip .label { color: var(--muted); text-transform: uppercase; font-size: 10px;
|
|
letter-spacing: .6px; margin-right: 5px; }
|
|
.risk-strip b { color: var(--text); font-weight: 650; }
|
|
.rdot { font-weight: 700; }
|
|
.rdot.ok { color: #2ecc71; text-shadow: 0 0 12px rgba(46,204,113,.45); }
|
|
.rdot.warn { color: #f1c40f; text-shadow: 0 0 12px rgba(241,196,15,.45); }
|
|
.rdot.critical { color: #ff4d4f; text-shadow: 0 0 12px rgba(255,77,79,.5); }
|
|
/* armed-trigger proximity radar */
|
|
.trig-row { display: flex; align-items: center; gap: 11px; padding: 6px 6px;
|
|
border-bottom: 1px solid var(--border); font: 600 12px var(--mono); }
|
|
.trig-row:last-child { border-bottom: 0; }
|
|
.trig-sym { width: 62px; font-weight: 700; }
|
|
.trig-state { width: 52px; font-size: 9px; font-weight: 700; text-transform: uppercase;
|
|
letter-spacing: .5px; }
|
|
.trig-meta { width: 96px; color: var(--muted); font: 500 10px var(--mono); }
|
|
.trig-bar { flex: 1; height: 16px; position: relative; background: #0d1117;
|
|
border: 1px solid var(--border); border-radius: 3px; overflow: hidden; min-width: 120px; }
|
|
.trig-fill { position: absolute; left: 0; top: 0; bottom: 0; transition: width .35s ease; }
|
|
.trig-trigline { position: absolute; top: -1px; bottom: -1px; width: 2px; background: #e6edf3;
|
|
box-shadow: 0 0 4px rgba(230,237,243,.6); }
|
|
.trig-dist { width: 70px; text-align: right; font-weight: 700; }
|
|
.trig-cat { width: 150px; color: var(--accent); font: 500 10px var(--mono); overflow: hidden;
|
|
text-overflow: ellipsis; white-space: nowrap; }
|
|
.st-armed .trig-state { color: #58a6ff; } .st-armed .trig-fill { background: linear-gradient(90deg,#1f6feb,#58a6ff); }
|
|
.st-fired .trig-state { color: #2ecc71; } .st-fired .trig-fill { background: #2ecc71; }
|
|
.st-filled .trig-state { color: #2ecc71; } .st-filled .trig-fill { background: linear-gradient(90deg,#1f8f4e,#2ecc71); }
|
|
.st-weak .trig-state { color: #7d8590; } .st-weak .trig-fill { background: #39414d; }
|
|
.st-waiting .trig-state { color: #7d8590; } .st-waiting .trig-fill { background: #30363d; }
|
|
.st-armed.near .trig-bar { box-shadow: 0 0 0 1px #f1c40f55; }
|
|
/* intraday equity sparkline */
|
|
.pnl-spark { flex: 1.5 1 200px; }
|
|
.eqspark { margin-top: 2px; } .eqspark svg { display: block; }
|
|
/* per-position R / risk bar */
|
|
.rbar { position: relative; display: inline-block; width: 72px; height: 13px;
|
|
background: #0d1117; border: 1px solid var(--border); border-radius: 2px;
|
|
vertical-align: middle; overflow: hidden; }
|
|
.rtxt { font: 700 11px var(--mono); margin-left: 7px; vertical-align: middle; }
|
|
|
|
/* activity feed */
|
|
.feed-row { display: flex; align-items: center; gap: 10px; padding: 7px 14px; border-bottom: 1px solid #151a26; font: 500 12px var(--mono); }
|
|
.feed-row:last-child { border-bottom: none; }
|
|
.feed-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
|
.feed-time { color: var(--muted); font-size: 11px; min-width: 64px; }
|
|
|
|
.toast { position: fixed; bottom: 20px; right: 20px; background: var(--panel-2); border: 1px solid var(--border); border-left: 3px solid var(--accent); padding: 11px 16px; border-radius: 6px; font-size: 13px; max-width: 380px; opacity: 0; transform: translateY(8px); transition: all .25s; pointer-events: none; z-index: 50; }
|
|
.toast.show { opacity: 1; transform: none; }
|
|
.toast.err { border-left-color: var(--red); }
|
|
.toast.ok { border-left-color: var(--green); }
|
|
.acct-row { display: flex; gap: 26px; padding: 12px 14px; flex-wrap: wrap; }
|
|
.metric { min-width: 120px; }
|
|
.metric .label { font: 600 10px var(--mono); text-transform: uppercase; letter-spacing: .7px; color: var(--muted); }
|
|
.metric .value { font: 600 17px var(--mono); margin-top: 2px; }
|
|
|
|
.inject { display: flex; gap: 6px; padding: 10px 14px; border-bottom: 1px solid var(--border); }
|
|
.inject input { flex: 1; background: #0d111a; border: 1px solid var(--border); border-radius: 5px; color: var(--text); padding: 6px 10px; font: 600 12px var(--mono); text-transform: uppercase; }
|
|
.inject input:focus { outline: none; border-color: var(--accent); }
|
|
.inject button { background: #14283f; color: var(--accent); border-color: #2a4a78; }
|
|
|
|
/* keyboard help overlay */
|
|
.kbd-help { position: fixed; inset: 0; background: rgba(6,8,12,.82); display: none; place-items: center; z-index: 60; }
|
|
.kbd-help.show { display: grid; }
|
|
.kbd-card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 24px 28px; max-width: 420px; }
|
|
.kbd-card h3 { font-size: 14px; margin-bottom: 14px; letter-spacing: .5px; }
|
|
.kbd-card .row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 13px; color: var(--muted); }
|
|
kbd { background: var(--panel-2); border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px; padding: 2px 7px; font: 600 12px var(--mono); color: var(--text); }
|
|
@media (max-width: 1040px) { .panel { grid-column: span 12; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>MOMENTUM <span>/ control room</span></h1>
|
|
<span id="mode-badge" class="badge paper">PAPER</span>
|
|
<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>
|
|
<div class="conn"><div id="conn-dot" class="dot"></div><span id="stamp" class="stamp">connecting...</span></div>
|
|
</header>
|
|
|
|
<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">Positions</span><b id="risk-pos">—</b></div>
|
|
<div><span class="label">Gross</span><b id="risk-gross">—</b></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>
|
|
<section class="panel full">
|
|
<h2>Armed Triggers <span id="trig-count" class="count">0</span><span class="hint">live proximity to the opening-range breakout · closest to fire on top</span></h2>
|
|
<div id="armed-triggers"><span class="empty">No armed gappers — waiting for the open / first bars.</span></div>
|
|
</section>
|
|
|
|
<section class="panel full">
|
|
<h2>Account <span id="acct-count" class="count">0</span></h2>
|
|
<div id="accounts" class="acct-row"><span class="empty">No account snapshot yet — waiting for broker sync.</span></div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<h2>Approval Queue <span id="aq-count" class="count">0</span><span class="hint">A approve top · R reject top</span></h2>
|
|
<div id="approval-queue"></div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<h2>Ready Signals <span id="rs-count" class="count">0</span></h2>
|
|
<div id="ready-signals"></div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<h2>Open Positions <span id="pos-count" class="count">0</span></h2>
|
|
<div id="positions"></div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<h2>Watch States <span id="ws-count" class="count">0</span><span class="hint">click a row for criteria</span></h2>
|
|
<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>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<h2>Activity Feed <span id="feed-count" class="count">0</span></h2>
|
|
<div id="feed"></div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<h2>Order Lifecycle <span id="ol-count" class="count">0</span></h2>
|
|
<div id="order-lifecycle"></div>
|
|
</section>
|
|
</main>
|
|
|
|
<div id="toast" class="toast"></div>
|
|
|
|
<div id="kbd-help" class="kbd-help">
|
|
<div class="kbd-card">
|
|
<h3>Keyboard shortcuts</h3>
|
|
<div class="row"><span>Approve top of queue</span><kbd>A</kbd></div>
|
|
<div class="row"><span>Reject top of queue</span><kbd>R</kbd></div>
|
|
<div class="row"><span>Toggle alert sound</span><kbd>S</kbd></div>
|
|
<div class="row"><span>Focus add-symbol box</span><kbd>/</kbd></div>
|
|
<div class="row"><span>Show / hide this help</span><kbd>?</kbd></div>
|
|
<div class="row"><span>Close help</span><kbd>Esc</kbd></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const $ = (id) => document.getElementById(id);
|
|
const fmt = (v, dp = 2) => (v === null || v === undefined || v === "" || isNaN(+v)) ? "—" : (+v).toFixed(dp);
|
|
const money = (v) => (v === null || v === undefined || isNaN(+v)) ? "—" : (v < 0 ? "-$" : "$") + Math.abs(+v).toLocaleString("en-US", {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
|
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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 = "";
|
|
const expanded = new Set();
|
|
const barsCache = {};
|
|
|
|
let toastTimer;
|
|
function toast(msg, kind) {
|
|
const t = $("toast");
|
|
t.textContent = msg;
|
|
t.className = "toast show" + (kind ? " " + kind : "");
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => t.classList.remove("show"), 4000);
|
|
}
|
|
|
|
let audioCtx;
|
|
function beep() {
|
|
if (!soundOn) return;
|
|
try {
|
|
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
|
|
[880, 1320].forEach((freq, i) => {
|
|
const osc = audioCtx.createOscillator();
|
|
const gain = audioCtx.createGain();
|
|
osc.type = "sine"; osc.frequency.value = freq;
|
|
const t0 = audioCtx.currentTime + i * 0.12;
|
|
gain.gain.setValueAtTime(0.0001, t0);
|
|
gain.gain.exponentialRampToValueAtTime(0.25, t0 + 0.02);
|
|
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.18);
|
|
osc.connect(gain); gain.connect(audioCtx.destination);
|
|
osc.start(t0); osc.stop(t0 + 0.2);
|
|
});
|
|
} catch (e) {}
|
|
}
|
|
|
|
async function post(path, body) {
|
|
try {
|
|
const r = await fetch(path, { method: "POST", headers: {"Content-Type": "application/json"}, 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"); }
|
|
}
|
|
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"});
|
|
const exitPos = (sym) => { if (confirm("Submit market exit for " + sym + "?")) post("/api/trading/exit-order", {symbol: sym}); };
|
|
|
|
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> · stop <b>' + (perShare/entry*100).toFixed(1) + '%</b></span>';
|
|
}
|
|
|
|
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) {
|
|
$("ws-count").textContent = ws.length;
|
|
if (!ws.length) { fill("watch-states", null, "Watcher has not evaluated any symbols yet."); return; }
|
|
const rows = ws.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 };
|
|
function renderTriggers(tw) {
|
|
const arr = (tw && tw.triggers) || [];
|
|
$("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">' + (t.catalyst ? esc(t.catalyst) : '') + '</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 render(d) {
|
|
latest = d;
|
|
populateDates(d.available_dates);
|
|
|
|
const live = d.execution_mode === "schwab_live";
|
|
const badge = $("mode-badge");
|
|
badge.textContent = live ? "LIVE · SCHWAB" : "PAPER · ALPACA";
|
|
badge.className = "badge " + (live ? "live" : "paper");
|
|
|
|
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-pos").textContent = (rk.open_positions != null ? rk.open_positions : "—") + " / " + (rk.max_concurrent != null ? rk.max_concurrent : "—");
|
|
$("risk-gross").textContent = rk.gross_pct != null ? (rk.gross_pct * 100).toFixed(0) + "% / " + (rk.max_gross_pct * 100).toFixed(0) + "%" : "—";
|
|
$("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);
|
|
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>' +
|
|
'<div class="metric"><div class="label">Buying Power</div><div class="value">' + money(a.buying_power) + '</div></div>' +
|
|
'<div class="metric"><div class="label">Synced</div><div class="value" style="font-size:13px">' + timeOf(a.timestamp) + '</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", "Mode", "Actions"],
|
|
aq.map(q => {
|
|
const r = q.execution_request || {};
|
|
return '<tr><td class="sym">' + esc(q.symbol) + '</td>' +
|
|
'<td><span class="pill ' + (r.side === "sell" ? "short" : "long") + '">' + esc(r.side || "buy") + '</span></td>' +
|
|
'<td>' + riskChip(r) + '</td>' +
|
|
'<td class="num">' + esc(q.approval_mode || "manual") + '</td>' +
|
|
'<td><div class="actions">' +
|
|
'<button class="btn-approve" onclick="approve(\'' + esc(q.order_id) + '\')">Approve</button>' +
|
|
'<button class="btn-reject" onclick="reject(\'' + esc(q.order_id) + '\')">Reject</button>' +
|
|
'</div></td></tr>';
|
|
})
|
|
), "No pending approvals.");
|
|
|
|
const rs = d.ready_signals || [];
|
|
$("rs-count").textContent = rs.length;
|
|
fill("ready-signals", table(
|
|
["Symbol", "Chart", "Setup", "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>' + esc(s.signal_type || "—") + '</td>' +
|
|
'<td class="num">' + fmt(s.entry_price) + '</td>' +
|
|
'<td class="num">' + fmt(s.stop_loss_price) + '</td>' +
|
|
'<td class="num">' + fmt(s.quality_score) + '</td>' +
|
|
'<td class="num">' + timeOf(s.timestamp) + '</td></tr>')
|
|
), "No ready signals this session.");
|
|
|
|
// map each symbol -> its resting protective stop (for the R bar + spark stop line)
|
|
const stopBy = {};
|
|
(d.orders || []).forEach(snap => (snap.orders || []).forEach(o => {
|
|
if (o.side === "sell" && (o.type === "stop" || o.type === "stop_limit") && o.stop_price != null && !(o.symbol in stopBy))
|
|
stopBy[o.symbol] = +o.stop_price;
|
|
}));
|
|
const posRows = [];
|
|
(d.positions || []).forEach(snap => (snap.positions || []).forEach(pos => {
|
|
const qty = +(pos.quantity != null ? pos.quantity : pos.qty != null ? pos.qty : 0);
|
|
const entry = pos.avg_entry_price != null ? +pos.avg_entry_price : (pos.average_price != null ? +pos.average_price : null);
|
|
const cur = pos.current_price != null ? +pos.current_price : (pos.market_price != null ? +pos.market_price : null);
|
|
const stop = stopBy[pos.symbol] != null ? stopBy[pos.symbol] : null;
|
|
const upnl = (entry != null && cur != null) ? (cur - entry) * Math.abs(qty) : (pos.unrealized_pl || pos.unrealized_pnl);
|
|
posRows.push('<tr><td class="sym">' + esc(pos.symbol) + (stop == null ? ' <span class="rtxt neg" title="no protective stop">⚠</span>' : '') + '</td>' +
|
|
'<td class="spark-wrap"><canvas class="spark" data-spark="' + esc(pos.symbol) + '" data-entry="' + (entry != null ? entry : "") + '" data-stop="' + (stop != null ? stop : "") + '"></canvas></td>' +
|
|
'<td><span class="pill ' + (qty < 0 ? "short" : "long") + '">' + (qty < 0 ? "short" : "long") + '</span></td>' +
|
|
'<td class="num">' + esc(Math.abs(qty)) + '</td>' +
|
|
'<td class="num">' + fmt(entry) + '</td>' +
|
|
'<td class="num">' + fmt(cur) + '</td>' +
|
|
'<td>' + rBar(entry, cur, stop) + '</td>' +
|
|
'<td class="num ' + (+(upnl || 0) >= 0 ? "pos" : "neg") + '">' + money(upnl) + '</td>' +
|
|
'<td><button class="btn-exit" onclick="exitPos(\'' + esc(pos.symbol) + '\')">Exit</button></td></tr>');
|
|
}));
|
|
$("pos-count").textContent = posRows.length;
|
|
fill("positions", table(["Symbol", "Chart", "Side", "Qty", "Avg", "Last", "R / risk", "P/L", ""], posRows), "No open positions.");
|
|
|
|
renderWatch(d.watch_states || []);
|
|
|
|
const fills = d.fills || [];
|
|
$("feed-count").textContent = fills.length;
|
|
fill("feed", fills.length ? fills.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.");
|
|
|
|
const ol = d.order_lifecycle || [];
|
|
$("ol-count").textContent = ol.length;
|
|
fill("order-lifecycle", table(
|
|
["Order", "Symbol", "Side", "Qty", "Price", "Status", "Updated"],
|
|
ol.slice(0, 30).map(o => '<tr><td class="num" style="color:var(--muted)">' + esc(String(o.order_id || "").slice(0, 8)) + '</td>' +
|
|
'<td class="sym">' + esc(o.symbol) + '</td>' +
|
|
'<td><span class="pill ' + (o.side === "sell" ? "short" : "long") + '">' + esc(o.side || "—") + '</span></td>' +
|
|
'<td class="num">' + esc(o.quantity != null ? o.quantity : "—") + '</td>' +
|
|
'<td class="num">' + fmt(o.price) + '</td>' +
|
|
'<td class="num">' + esc(o.status || "—") + '</td>' +
|
|
'<td class="num">' + timeOf(o.updated_at) + '</td></tr>')
|
|
), "No orders yet.");
|
|
|
|
document.querySelectorAll("canvas[data-spark]").forEach(canvas => {
|
|
const sym = canvas.getAttribute("data-spark");
|
|
const entry = parseFloat(canvas.getAttribute("data-entry"));
|
|
const stop = parseFloat(canvas.getAttribute("data-stop"));
|
|
loadBars(sym, isNaN(entry) ? null : entry, isNaN(stop) ? null : stop, canvas);
|
|
});
|
|
|
|
const key = rs.length ? rs[0].symbol + "|" + rs[0].timestamp : "";
|
|
if (key && key !== lastSignalKey && lastSignalKey !== "") {
|
|
beep();
|
|
toast("New ready signal: " + rs[0].symbol + " (" + (rs[0].signal_type || "setup") + ")", "ok");
|
|
}
|
|
lastSignalKey = key;
|
|
}
|
|
|
|
let es = null, pollTimer = null, viewDate = null;
|
|
|
|
// populate the date picker from the snapshot's available_dates (LIVE + past days)
|
|
function populateDates(dates) {
|
|
const sel = document.getElementById("date-select"); if (!sel) return;
|
|
if (sel.options.length === ((dates || []).length + 1)) return; // already built
|
|
sel.innerHTML = '<option value="">● LIVE</option>' +
|
|
(dates || []).map(d => '<option value="' + d + '">' + d + '</option>').join('');
|
|
sel.value = viewDate || '';
|
|
}
|
|
// switch between live and a historical session
|
|
function setView(date) {
|
|
viewDate = date || null;
|
|
const sel = document.getElementById("date-select");
|
|
if (sel) sel.classList.toggle("histor", !!viewDate);
|
|
if (es) { es.close(); es = null; }
|
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
|
if (viewDate) loadHistorical(); else startStream(); // live resumes the stream
|
|
}
|
|
async function loadHistorical() {
|
|
setConn(true, "loading " + viewDate + " ...");
|
|
try {
|
|
const r = await fetch("/api/snapshots?date=" + encodeURIComponent(viewDate));
|
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
render(await r.json());
|
|
setConn(true, "VIEWING " + viewDate + " (historical)");
|
|
} catch (e) { setConn(false, "load failed — " + e.message); }
|
|
}
|
|
function setConn(ok, msg) {
|
|
$("conn-dot").className = "dot " + (ok ? "live" : "err");
|
|
$("stamp").textContent = msg;
|
|
}
|
|
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)); setConn(true, "live · " + new Date().toLocaleTimeString()); }
|
|
catch (e) { console.error("render failed:", e); setConn(false, "render error (see console)"); }
|
|
});
|
|
es.onerror = () => { setConn(false, "stream lost — polling"); if (es) es.close(); es = null; startPolling(); };
|
|
} catch (e) { startPolling(); }
|
|
}
|
|
async function pollOnce() {
|
|
try {
|
|
const r = await fetch("/api/snapshots");
|
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
render(await r.json());
|
|
setConn(true, "polling · " + new Date().toLocaleTimeString());
|
|
} catch (e) { 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);
|
|
$("kbd-toggle").onclick = () => $("kbd-help").classList.toggle("show");
|
|
$("kbd-help").onclick = (e) => { if (e.target.id === "kbd-help") $("kbd-help").classList.remove("show"); };
|
|
|
|
function doInject() {
|
|
const v = $("inject-input").value.trim().toUpperCase();
|
|
if (v) { post("/api/watch/add", {symbol: v}); $("inject-input").value = ""; }
|
|
}
|
|
$("inject-btn").onclick = doInject;
|
|
$("inject-input").addEventListener("keydown", (e) => { if (e.key === "Enter") doInject(); e.stopPropagation(); });
|
|
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.target.tagName === "INPUT") return;
|
|
const aq = latest && latest.approval_queue || [];
|
|
if (e.key === "a" || e.key === "A") { if (aq[0]) approve(aq[0].order_id); }
|
|
else if (e.key === "r" || e.key === "R") { if (aq[0]) reject(aq[0].order_id); }
|
|
else if (e.key === "s" || e.key === "S") { $("sound-toggle").click(); }
|
|
else if (e.key === "/") { e.preventDefault(); $("inject-input").focus(); }
|
|
else if (e.key === "?") { $("kbd-help").classList.toggle("show"); }
|
|
else if (e.key === "Escape") { $("kbd-help").classList.remove("show"); }
|
|
});
|
|
|
|
startStream();
|
|
setTimeout(() => { if (!latest) startPolling(); }, 3000);
|
|
</script>
|
|
</body>
|
|
</html>
|