diff --git a/api/main.py b/api/main.py index df421d2..a29b602 100644 --- a/api/main.py +++ b/api/main.py @@ -53,6 +53,7 @@ from storage.projections import ( query_fills_feed, query_gate_receipts, query_armed_triggers, + query_loop_session, query_equity_curve, query_order_lifecycle_snapshot, query_ready_signals_snapshot, diff --git a/api/static/dashboard.html b/api/static/dashboard.html index c66484a..ffef6c4 100644 --- a/api/static/dashboard.html +++ b/api/static/dashboard.html @@ -484,6 +484,7 @@
⏸ ENTRIES PAUSED — no new positions (exits & management still run). Click PAUSE ENTRIES to resume.
+
Risk—
Day vs limit—
@@ -1181,7 +1182,7 @@ function renderTriggers(tw, catMap) { 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' : '') + '
' + + '
' + (t.gap != null ? 'gap ' + t.gap.toFixed(0) + '% ' : '') + (t.rvol != null ? t.rvol.toFixed(1) + 'x' : '') + (t.rvol_tod != null ? ' \u00B7 tod ' + (t.rvol_tod >= 100 ? t.rvol_tod.toFixed(0) : t.rvol_tod.toFixed(1)) + 'x' : '') + '
' + '
entry ' + fmt(trig) + ' stop ' + fmt(stop) + ' now ' + fmt(price) + '
' + '
' + (hasBar ? '
' : '') @@ -1457,6 +1458,15 @@ function render(d) { nb.textContent = "⚠ UNPROTECTED: " + rk.unprotected.join(", ") + " — held long with no / short protective stop"; nb.classList.add("show"); } else { nb.classList.remove("show"); } + // day-2 stale-session alarm: the loop booted a PRIOR day and never restarted — + // its memory-only daily flags block every entry with zero events (2026-07-21). + const ls = d.loop_session || {}, sb = $("stale-banner"); + if (sb) { + if (ls.stale && !d.view_date) { + sb.textContent = "\u26A0 LOOP SESSION FROM " + ls.session_date + " STILL RUNNING \u2014 daily state is stale (post-EOD boot survived midnight); new entries are silently blocked. Restart the loop container."; + sb.classList.add("show"); + } else { sb.classList.remove("show"); } + } const rsEl = $("risk-status"); rsEl.textContent = (rk.status || "—").toUpperCase(); rsEl.className = "rdot " + (rk.status || ""); diff --git a/storage/projections.py b/storage/projections.py index 59a05cb..ac64e4d 100644 --- a/storage/projections.py +++ b/storage/projections.py @@ -880,6 +880,32 @@ def query_armed_triggers(store) -> dict: return {"triggers": [], "armed": 0, "timestamp": None} +def query_loop_session(store) -> dict: + """The live loop's CURRENT session identity — and whether it is STALE (booted a + prior day and never restarted). The loop's daily flags (_session_closed, entry + caps, benches) are memory-only: a session whose embedded date != today means a + post-EOD boot survived midnight and entries are silently blocked (hit live + 2026-07-21: signals READY + book armed, zero orders all morning). Session ids + look like paper-YYYY-MM-DD-hash; the date is parsed from the id, so this is one + bounded range scan with no per-session lookup.""" + from datetime import datetime, timedelta + since = (datetime.now() - timedelta(minutes=15)).isoformat() + events = store.query_events(since=since, limit=None) + sid = None + for e in reversed(events): + cid = str(e.get("correlation_id") or "") + if cid.startswith("paper-"): + sid = cid + break + if not sid: + return {"session_id": None, "session_date": None, "stale": False} + parts = sid.split("-") # paper-YYYY-MM-DD-hash + sess_date = "-".join(parts[1:4]) if len(parts) >= 5 else None + today = datetime.now().strftime("%Y-%m-%d") + return {"session_id": sid, "session_date": sess_date, + "stale": bool(sess_date and sess_date != today)} + + def query_equity_curve(store, points: int = 200, for_date: str | None = None) -> dict: """Total-equity series for an intraday P&L-shape sparkline, plus the prior- session-close baseline (last_equity). Live = last 10h (the trading day); a diff --git a/tests/unit/storage/test_interactive_projections.py b/tests/unit/storage/test_interactive_projections.py index 4890b9f..d0f2ac6 100644 --- a/tests/unit/storage/test_interactive_projections.py +++ b/tests/unit/storage/test_interactive_projections.py @@ -282,3 +282,28 @@ def test_daily_performance_curve_trading_days_only(): assert days[1]["day_pnl"] == -200.0 and days[1]["cum_pnl"] == -100.0 assert days[1]["trades"] == 2 and days[1]["win_rate"] == 0.5 store.close() + + +def test_loop_session_stale_detection(store): + """A paper session whose embedded date != today must flag stale — the day-2 + silent entry-block signature (loop booted post-EOD, survived midnight).""" + from storage.event_schema import ModuleTickEvent + from storage.projections import query_loop_session + + def _tick(cid): + store.emit(ModuleTickEvent( + timestamp=datetime.now(), mode=EventMode.PAPER, + correlation_id=cid, message="tick", + module="triggers", stage="snapshot", duration_ms=0.0, + input_count=0, output_count=0, metrics={}, errors=[])) + + _tick("paper-2020-01-02-abc123") + s = query_loop_session(store) + assert s["session_id"] == "paper-2020-01-02-abc123" + assert s["session_date"] == "2020-01-02" + assert s["stale"] is True + + today = datetime.now().strftime("%Y-%m-%d") + _tick(f"paper-{today}-def456") + s = query_loop_session(store) + assert s["session_date"] == today and s["stale"] is False