diff --git a/api/main.py b/api/main.py index 0f0237f..0da1efd 100644 --- a/api/main.py +++ b/api/main.py @@ -38,6 +38,7 @@ from storage.projections import ( query_fills_feed, query_order_lifecycle_snapshot, query_ready_signals_snapshot, + query_risk_state, query_session_pnl, query_session_summary, query_symbol_criteria, @@ -85,6 +86,7 @@ class DashboardState: "execution_mode": self.execution_mode, "has_execution": self.execution_service is not None, "pnl": query_session_pnl(store), + "risk": query_risk_state(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 62f9b1c..412bb90 100644 --- a/api/static/dashboard.html +++ b/api/static/dashboard.html @@ -134,6 +134,18 @@ /* 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); } @@ -175,6 +187,15 @@
connecting...
+
+
+
Risk—
+
Positions—
+
Gross—
+
Day vs limit—
+
Data—
+
+
Total P&L
—
Realized
—
@@ -434,6 +455,24 @@ function render(d) { $("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) { diff --git a/storage/projections.py b/storage/projections.py index 6c5fd82..9079314 100644 --- a/storage/projections.py +++ b/storage/projections.py @@ -556,3 +556,104 @@ def query_session_pnl(store, session_id: str | None = None) -> dict: "expectancy_r": avg_r, "trades": list(reversed(trades))[:20], } + + +_PROT_ACTIVE = {"held", "new", "accepted", "pending_new", + "accepted_for_bidding", "partially_filled"} + + +def query_risk_state(store) -> dict: + """Portfolio risk/health for the dashboard gauge — the conditions that + silently cost money this session: concentration vs the caps, day P&L vs the + daily-loss limit, data freshness, and any UNPROTECTED (naked) long. Returns a + status (ok / warn / critical) plus the underlying numbers.""" + import os + from datetime import datetime, timezone + + equity = None + day_pnl = None + summ = store.query_events(event_type="account_summary_updated", limit=None) + ts_latest = None + if summ: + sp = json.loads(summ[-1].get("payload_json", "{}")) + ts_latest = summ[-1].get("timestamp") + try: + equity = float(sp.get("total_equity") or 0.0) or None + le = float(sp.get("last_equity") or 0.0) + if equity and le: + day_pnl = equity - le + except (TypeError, ValueError): + pass + + psnap = query_account_positions_snapshot(store) + positions = (psnap[-1].get("positions") if psnap else []) or [] + osnap = query_account_orders_snapshot(store) + orders = (osnap[-1].get("orders") if osnap else []) or [] + + sell_cover: dict = {} + for o in orders: + if (o.get("side") == "sell" and o.get("status") in _PROT_ACTIVE + and o.get("type") in ("stop", "stop_limit", "limit", "trailing_stop")): + q = float(o.get("quantity") or 0) - float(o.get("filled_quantity") or 0) + sell_cover[o.get("symbol")] = sell_cover.get(o.get("symbol"), 0.0) + max(q, 0.0) + + open_count = 0 + gross = 0.0 + unprotected: list[str] = [] + for p in positions: + try: + qty = float(p.get("quantity") if p.get("quantity") is not None + else p.get("qty") or 0) + except (TypeError, ValueError): + qty = 0.0 + if qty <= 0: + continue + open_count += 1 + try: + gross += abs(float(p.get("market_value") or 0.0)) + except (TypeError, ValueError): + pass + if sell_cover.get(p.get("symbol"), 0.0) < qty - 1e-6: + unprotected.append(p.get("symbol")) + + gross_pct = (gross / equity) if equity else None + day_pnl_pct = (day_pnl / equity) if (equity and day_pnl is not None) else None + + age = None # data freshness from the latest broker-sync timestamp + if ts_latest: + try: + t = (datetime.fromisoformat(ts_latest.replace("Z", "+00:00")) + if isinstance(ts_latest, str) else ts_latest) + if t.tzinfo is None: + t = t.replace(tzinfo=timezone.utc) + age = (datetime.now(timezone.utc) - t).total_seconds() + except Exception: # noqa: BLE001 + pass + + max_conc = int(os.environ.get("TRADING_MAX_CONCURRENT_POSITIONS", "6")) + max_gross = float(os.environ.get("TRADING_MAX_GROSS_NOTIONAL_PCT", "0.60")) + daily_loss = float(os.environ.get("TRADING_MAX_DAILY_LOSS_PCT", "0.03")) + + status = "ok" + if unprotected: + status = "critical" + elif day_pnl_pct is not None and day_pnl_pct <= -daily_loss * 0.8: + status = "critical" + elif (open_count >= max_conc + or (gross_pct is not None and gross_pct >= max_gross * 0.9) + or (day_pnl_pct is not None and day_pnl_pct <= -daily_loss * 0.5) + or (age is not None and age > 90)): + status = "warn" + + return { + "status": status, + "open_positions": open_count, + "max_concurrent": max_conc, + "gross_pct": round(gross_pct, 3) if gross_pct is not None else None, + "max_gross_pct": max_gross, + "day_pnl": round(day_pnl, 2) if day_pnl is not None else None, + "day_pnl_pct": round(day_pnl_pct, 4) if day_pnl_pct is not None else None, + "daily_loss_limit_pct": daily_loss, + "data_age_s": round(age, 1) if age is not None else None, + "unprotected": unprotected, + }