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 @@
+
+
+
Risk—
+
Positions—
+
Gross—
+
Day vs limit—
+
Data—
+
+
@@ -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,
+ }