diff --git a/api/main.py b/api/main.py index 0da1efd..cb56ebf 100644 --- a/api/main.py +++ b/api/main.py @@ -36,6 +36,7 @@ from storage.projections import ( query_account_summary_snapshot, query_approval_queue, query_fills_feed, + query_armed_triggers, query_order_lifecycle_snapshot, query_ready_signals_snapshot, query_risk_state, @@ -87,6 +88,7 @@ class DashboardState: "has_execution": self.execution_service is not None, "pnl": query_session_pnl(store), "risk": query_risk_state(store), + "triggers": query_armed_triggers(store), "approval_queue": query_approval_queue(store), "ready_signals": query_ready_signals_snapshot(store), "watch_states": query_watch_states_snapshot(store), diff --git a/api/static/dashboard.html b/api/static/dashboard.html index a65b58d..3659338 100644 --- a/api/static/dashboard.html +++ b/api/static/dashboard.html @@ -146,6 +146,28 @@ .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; } + /* 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; } /* activity feed */ .feed-row { display: flex; align-items: center; gap: 10px; padding: 7px 14px; border-bottom: 1px solid #151a26; font: 500 12px var(--mono); } @@ -206,6 +228,11 @@
+
+

Armed Triggers 0live proximity to the opening-range breakout · closest to fire on top

+
No armed gappers — waiting for the open / first bars.
+
+

Account 0

No account snapshot yet — waiting for broker sync.
@@ -436,6 +463,44 @@ function renderWatch(ws) { }); } +// 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 = 'No armed gappers — waiting for the open / first bars.'; 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 '
' + + '
' + (t.catalyst ? '📰 ' : '') + esc(t.symbol) + '
' + + '
' + esc(t.state) + '
' + + '
' + (t.gap != null ? 'gap ' + t.gap.toFixed(0) + '% ' : '') + (t.rvol != null ? t.rvol.toFixed(1) + 'x' : '') + '
' + + '
' + + (hasBar ? '
' : '') + + '
' + + '
' + distTxt + '
' + + '
' + (t.catalyst ? esc(t.catalyst) : '') + '
' + + '
'; + }).join(''); +} + function render(d) { latest = d; @@ -474,6 +539,8 @@ function render(d) { // 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); + const accts = d.accounts || []; $("acct-count").textContent = accts.length; if (accts.length) { diff --git a/storage/projections.py b/storage/projections.py index 941368a..c5135cb 100644 --- a/storage/projections.py +++ b/storage/projections.py @@ -661,3 +661,22 @@ def query_risk_state(store) -> dict: "data_age_s": round(age, 1) if age is not None else None, "unprotected": unprotected, } + + +def query_armed_triggers(store) -> dict: + """The live armed-trigger book — the heart of the ORB strategy: which gappers + are loaded and how close each is to firing its opening-range breakout. The + fast trigger thread emits this every few seconds as a module_tick(triggers); + the dashboard renders it as a proximity radar. Bounded to the last 15 min so + it's a cheap range scan, not a full module_tick load.""" + from datetime import datetime, timedelta + since = (datetime.now() - timedelta(minutes=15)).isoformat() + events = store.query_events(event_type="module_tick", since=since, limit=None) + for event in reversed(events): # ASC order -> newest first + p = json.loads(event.get("payload_json", "{}")) + if p.get("module") == "triggers": + m = p.get("metrics") or {} + return {"triggers": m.get("triggers") or [], + "armed": m.get("armed", 0), + "timestamp": event.get("timestamp")} + return {"triggers": [], "armed": 0, "timestamp": None}