Closes the audit's remaining web-UI gaps. New query_risk_state projection bundles the conditions that silently cost money this session: concentration vs the caps (open/max, gross %), day P&L vs the daily-loss limit, broker-sync data freshness, and any UNPROTECTED (naked) long. Wired into /api/snapshots. dashboard.html: a pulsing red UNPROTECTED banner the instant a held long lacks stop coverage (would have shown the moment APWC/NOWL went naked), plus a risk strip — ok/warn/critical status dot, positions n/max, gross%/cap, day%/limit, and a freshness chip (amber >30s, red >90s). Verified end-to-end: the API returns the risk block (status=critical on today's 3 unprotected overnight positions) and the page renders it. Tests: projections suite passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
638 lines
32 KiB
HTML
638 lines
32 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: #0b0e14;
|
|
--panel: #11151f;
|
|
--panel-2: #161b28;
|
|
--border: #1f2636;
|
|
--text: #d7dce6;
|
|
--muted: #76809a;
|
|
--accent: #4f9cf9;
|
|
--green: #2fbf71;
|
|
--red: #e5484d;
|
|
--amber: #f5a524;
|
|
--purple: #b89ad6;
|
|
--mono: "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font: 14px/1.5 -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
min-height: 100vh;
|
|
}
|
|
header {
|
|
display: flex; align-items: center; gap: 14px;
|
|
padding: 12px 22px;
|
|
border-bottom: 1px solid var(--border);
|
|
background: linear-gradient(180deg, #10141e, #0b0e14);
|
|
position: sticky; top: 0; z-index: 20;
|
|
}
|
|
header h1 { font-size: 16px; font-weight: 650; letter-spacing: .4px; }
|
|
header h1 span { color: var(--accent); }
|
|
.badge {
|
|
font: 600 11px/1 var(--mono); letter-spacing: .8px; text-transform: uppercase;
|
|
padding: 5px 10px; border-radius: 4px; 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; }
|
|
|
|
/* P&L strip */
|
|
.pnl-strip {
|
|
display: flex; gap: 0; flex-wrap: wrap;
|
|
border-bottom: 1px solid var(--border);
|
|
background: var(--panel);
|
|
}
|
|
.pnl-cell {
|
|
flex: 1; min-width: 120px;
|
|
padding: 10px 18px;
|
|
border-right: 1px solid var(--border);
|
|
}
|
|
.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; }
|
|
.pos { color: var(--green); } .neg { color: var(--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: var(--panel);
|
|
border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
|
|
}
|
|
.panel.full { grid-column: span 12; }
|
|
.panel h2 {
|
|
font-size: 12px; font-weight: 650; letter-spacing: 1px; text-transform: uppercase;
|
|
color: var(--muted); padding: 11px 14px; border-bottom: 1px solid var(--border);
|
|
background: var(--panel-2); display: flex; align-items: center; gap: 8px;
|
|
}
|
|
.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 8px; border-radius: 3px; }
|
|
.pill.ready { background: #12362a; color: var(--green); }
|
|
.pill.watching { background: #14283f; color: var(--accent); }
|
|
.pill.blocked { background: #38221a; color: var(--amber); }
|
|
.pill.late { background: #2a2330; color: var(--purple); }
|
|
.pill.discovered { background: #1c2230; color: var(--muted); }
|
|
.pill.long { background: #12362a; color: var(--green); }
|
|
.pill.short { background: #3a191c; color: #ff8589; }
|
|
|
|
button { font: 600 12px -apple-system, "Segoe UI", sans-serif; border: 1px solid transparent; border-radius: 5px; padding: 6px 12px; cursor: pointer; transition: filter .12s; }
|
|
button:hover { filter: brightness(1.18); }
|
|
button:disabled { opacity: .45; cursor: default; }
|
|
.btn-approve { background: #15452f; color: #6ee7a8; border-color: #1f6b48; }
|
|
.btn-reject { background: #3c1d20; color: #ff9296; border-color: #6e2226; }
|
|
.btn-exit { background: #3a2a12; color: #ffc456; border-color: #6b4e1f; }
|
|
.actions { display: flex; gap: 6px; }
|
|
|
|
/* 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; } .rdot.warn { color: #f1c40f; } .rdot.critical { color: #ff4d4f; }
|
|
|
|
/* 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>
|
|
<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>
|
|
|
|
<main>
|
|
<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) {}
|
|
}
|
|
});
|
|
}
|
|
|
|
function render(d) {
|
|
latest = d;
|
|
|
|
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 rs = $("risk-status");
|
|
rs.textContent = (rk.status || "—").toUpperCase();
|
|
rs.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" : "—";
|
|
fr.style.color = (rk.data_age_s == null) ? "" : (rk.data_age_s > 90 ? "#ff4d4f" : rk.data_age_s > 30 ? "#f1c40f" : "");
|
|
|
|
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.");
|
|
|
|
const posRows = [];
|
|
(d.positions || []).forEach(snap => (snap.positions || []).forEach(pos => {
|
|
const qty = +(pos.quantity != null ? pos.quantity : pos.qty != null ? pos.qty : 0);
|
|
posRows.push('<tr><td class="sym">' + esc(pos.symbol) + '</td>' +
|
|
'<td class="spark-wrap"><canvas class="spark" data-spark="' + esc(pos.symbol) + '" data-entry="' + (pos.avg_entry_price != null ? pos.avg_entry_price : pos.average_price != null ? pos.average_price : "") + '" data-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(pos.avg_entry_price != null ? pos.avg_entry_price : pos.average_price) + '</td>' +
|
|
'<td class="num">' + fmt(pos.current_price != null ? pos.current_price : pos.market_price) + '</td>' +
|
|
'<td class="num ' + (+(pos.unrealized_pl || 0) >= 0 ? "pos" : "neg") + '">' + money(pos.unrealized_pl) + '</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", "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;
|
|
function setConn(ok, msg) {
|
|
$("conn-dot").className = "dot " + (ok ? "live" : "err");
|
|
$("stamp").textContent = msg;
|
|
}
|
|
function startStream() {
|
|
try {
|
|
es = new EventSource("/api/stream");
|
|
es.addEventListener("snapshot", (ev) => {
|
|
try { render(JSON.parse(ev.data)); setConn(true, "live · " + new Date().toLocaleTimeString()); }
|
|
catch (e) {}
|
|
});
|
|
es.onerror = () => { setConn(false, "stream lost — polling"); 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();
|
|
};
|
|
$("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>
|