Files
momentum-system/api/main.py
T
momentum devandClaude Fable 5 e7c34f298f operator-plane: guarded command bus (flatten / pause kill switch)
Adds OperatorCommandEvent + a closed command vocabulary (flatten_all,
flatten_symbol, pause_entries, resume_entries). POST /api/command emits it
(token-required: 503 when DASHBOARD_TOKEN unset — commands never run
unauthenticated); the live loop's new 5s "command" step consumes them via
OperatorCommandConsumer with three replay-safety layers (in-session nonce set,
persisted operator_command_done reload, 10-min staleness window).

flatten_all routes through close_session DIRECTLY (never step_eod_flatten) so it
cannot trip plan 003's daily-restart exit. Pause is checked at the top of BOTH
request_approvals_for_ready_signals AND submit_breakout_now (missing either
leaks pause on that path). qabot gains an authorized-chat-only /flatten slash
command (prose "flatten" still refuses via _ACTION_WORDS). Snapshot exposes
broker truth + command_bus + a defensive experiment ledger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:15:00 -04:00

656 lines
28 KiB
Python

"""Dashboard API (Milestone 5) — stdlib ThreadingHTTPServer, no FastAPI.
Endpoints
---------
GET / dashboard HTML
GET /api/snapshots everything the dashboard needs (one shot)
GET /api/stream Server-Sent Events: pushes snapshots on change
GET /api/approval-queue pending approvals only
GET /api/criteria?symbol=XYZ per-criterion pass/fail breakdown
GET /api/bars?symbol=XYZ[&minutes=60] recent 1-min bars for a sparkline
POST /api/trading/approvals/approve {"order_id": ..., "approved_by"?: ...}
POST /api/trading/approvals/reject {"order_id": ..., "reason"?: ...}
POST /api/trading/exit-order {"symbol": ...}
POST /api/watch/add {"symbol": ...} (manual watchlist inject)
POST /api/command {"command": ..., "symbol"?: ...} (operator kill switch; requires DASHBOARD_TOKEN)
The server holds an EventStore for reads; trade actions route through a
TradingExecutionService when one is attached (the orchestrator does this).
Without one, action endpoints return 503 instead of crashing, so the
dashboard still works read-only.
"""
from __future__ import annotations
import hmac
import json
import os
import threading
import time
import uuid
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from storage.event_schema import EventMode, OperatorCommandEvent, OPERATOR_COMMANDS
from storage.event_store import EventStore
from storage.projections import (
query_account_orders_snapshot,
query_account_positions_snapshot,
query_account_summary_snapshot,
query_alltime_score,
query_approval_queue,
query_catalyst_advisory,
query_catalyst_feed,
query_daily_performance,
query_fills_feed,
query_armed_triggers,
query_equity_curve,
query_order_lifecycle_snapshot,
query_ready_signals_snapshot,
query_risk_state,
query_session_pnl,
query_session_summary,
query_symbol_criteria,
query_watch_states_snapshot,
)
STATIC_DIR = Path(__file__).parent / "static"
def _command_bus_enabled() -> bool:
"""The operator command bus is live only when a token guards it AND it is
explicitly enabled — so a stray COMMAND_BUS_ENABLED never opens an
unauthenticated kill switch."""
return bool(os.environ.get("DASHBOARD_TOKEN")) and (
os.environ.get("COMMAND_BUS_ENABLED", "").strip().lower()
in {"1", "true", "yes", "on"}
)
def _broker_block() -> dict:
"""Broker truth for the LIVE badge, derived from the base URL the services
already have (not the execution_mode literal, which lied). Alpaca's paper host
is ``paper-api.alpaca.markets``; anything else is live money."""
from urllib.parse import urlparse as _urlparse
url = os.environ.get("ALPACA_BASE_URL") or "https://paper-api.alpaca.markets"
host = _urlparse(url).hostname or url
return {"base_url_host": host, "is_paper": host.startswith("paper-api")}
def _eastern_session_date():
"""Today's date in US/Eastern — matches the ingestion pipeline's flags."""
from zoneinfo import ZoneInfo
return datetime.now(ZoneInfo("America/New_York")).date()
def _available_dates(store) -> list[str]:
"""Distinct session dates with account data — populates the date picker so
you can switch from LIVE to review a past day."""
try:
rows = store.con.execute(
"SELECT DISTINCT timestamp::date AS d FROM events "
"WHERE event_type = 'account_summary_updated' "
"ORDER BY d DESC LIMIT 90"
).fetchall()
return [str(r[0]) for r in rows]
except Exception: # noqa: BLE001
return []
def _catalyst_engine(store, catalyst: dict) -> dict:
"""Catalyst-engine status for the dashboard: config (read from the same .env)
plus live activity (advisory + dilution counts, and how many entries the
dilution veto has blocked today)."""
try:
from config import OllamaConfig
cfg = OllamaConfig.from_env()
on = bool(cfg.enabled and cfg.enrichment_enabled)
model = cfg.model
score_blend = bool(cfg.catalyst_score_enabled)
veto = bool(cfg.dilution_veto_enabled)
except Exception: # noqa: BLE001
on, model, score_blend, veto = False, "", False, False
adv = catalyst or {}
dilutive = sum(1 for a in adv.values() if isinstance(a, dict) and a.get("is_dilutive"))
vetoes = 0
try:
row = store.con.execute(
"SELECT count(*) FROM events WHERE event_type='risk_rule_triggered' "
"AND timestamp::date = CURRENT_DATE "
"AND payload_json LIKE '%catalyst_dilution_veto%'"
).fetchone()
vetoes = int(row[0]) if row and row[0] else 0
except Exception: # noqa: BLE001
vetoes = 0
return {"enabled": on, "model": model, "score_blend": score_blend,
"dilution_veto": veto, "advisories": len(adv),
"dilutive": dilutive, "vetoes_today": vetoes}
class _ChangeSignal:
"""One process-wide event-change signal shared by ALL SSE clients.
Before this, every SSE client ran ``COUNT(*)`` on a FRESH EventStore
connection every ~1s (re-applying the 473-line schema unless
``PG_SKIP_SCHEMA=1``). Now a single long-lived connection polls
``max(timestamp)`` (an index-tail seek) at most once per second GLOBALLY,
behind a lock — N clients cost one query/sec, not N. ``reader`` is injectable
so tests can count underlying queries without a DB.
"""
def __init__(self, db_path: str | None = None, *, reader=None, min_interval: float = 1.0):
self._db_path = db_path
self._reader = reader
self._min_interval = float(min_interval)
self._lock = threading.Lock()
self._store = None
self._stamp = None
self._checked_at = 0.0
self.query_count = 0 # test hook: number of underlying reads performed
def _read(self):
self.query_count += 1
if self._reader is not None:
return self._reader()
if self._store is None:
self._store = EventStore(self._db_path) # long-lived; schema skipped via PG_SKIP_SCHEMA
return self._store.max_timestamp()
def current(self):
"""Latest change token (max event timestamp), refreshed at most once per
``min_interval`` seconds across all callers."""
now = time.monotonic()
with self._lock:
if self._checked_at == 0.0 or (now - self._checked_at) >= self._min_interval:
try:
self._stamp = self._read()
except Exception: # noqa: BLE001 — a transient read must not kill the stream
pass
self._checked_at = now
return self._stamp
def _maybe_json(v):
"""JSONB columns come back from psycopg2 as str OR already-parsed dict; make it
a dict either way so the ledger never double-encodes."""
if isinstance(v, str):
try:
return json.loads(v)
except (ValueError, TypeError):
return {}
return v or {}
def _experiment_ledger(store) -> list[dict]:
"""Latest verdict per experiment for the ledger panel (plan 006 tables).
Returns [] on ANY error — the experiments/experiment_verdicts tables live in a
separate schema file applied by the registry, so on a bare/test database they
simply don't exist and the panel shows its empty state."""
try:
rows = store.con.execute(
"SELECT v.experiment_id, v.as_of, v.n, v.verdict, v.metrics, "
" e.hypothesis, e.bars, e.status "
"FROM (SELECT DISTINCT ON (experiment_id) experiment_id, as_of, n, verdict, metrics "
" FROM experiment_verdicts ORDER BY experiment_id, as_of DESC) v "
"LEFT JOIN experiments e ON e.id = v.experiment_id "
"ORDER BY v.as_of DESC"
).fetchall()
except Exception: # noqa: BLE001 — tables absent on a bare DB
return []
out = []
for r in rows:
out.append({
"experiment": r[0], "as_of": str(r[1]), "n": r[2], "verdict": r[3],
"metrics": _maybe_json(r[4]), "hypothesis": r[5],
"bars": _maybe_json(r[6]), "status": r[7],
})
return out
class DashboardState:
"""Shared state: event store path + optional live execution service."""
def __init__(
self,
event_db_path: str,
execution_service=None,
execution_mode: str = "alpaca_paper",
research_con=None,
watch_inject=None,
) -> None:
self.event_db_path = event_db_path
self.execution_service = execution_service
self.execution_mode = execution_mode
# optional research DB connection + callback for charts / manual inject
self.research_con = research_con
self.watch_inject = watch_inject
self._lock = threading.Lock()
# lazily-built, process-wide SSE change signal (shared by all clients)
self._change_signal: _ChangeSignal | None = None
def change_stamp(self):
"""SSE change token from the shared once-per-second signal (see _ChangeSignal)."""
if self._change_signal is None:
with self._lock:
if self._change_signal is None:
self._change_signal = _ChangeSignal(self.event_db_path)
return self._change_signal.current()
def open_store(self) -> EventStore:
return EventStore(self.event_db_path)
def snapshots(self, for_date: str | None = None) -> dict:
# for_date=None/'live' -> the live view (latest snapshots, fast windowed
# query); a 'YYYY-MM-DD' -> that past session's end-of-day review.
is_live = not for_date or str(for_date).lower() in ("live", "today")
with self._lock:
store = self.open_store()
try:
# Local-LLM catalyst advisory (live only). {} when enrichment has
# never run, so the dashboard simply shows no catalyst column.
catalyst = query_catalyst_advisory(store) if is_live else {}
approval_queue = query_approval_queue(store) if is_live else []
for row in approval_queue: # attach advisory for the human approver
adv = catalyst.get(str(row.get("symbol") or "").upper())
if adv:
row["catalyst"] = adv
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"execution_mode": self.execution_mode,
"has_execution": self.execution_service is not None,
# broker truth (LIVE badge) + operator command-bus availability
"broker": _broker_block(),
"command_bus": _command_bus_enabled(),
# experiment ledger (plan 006 tables) — [] until they exist
"experiments": _experiment_ledger(store) if is_live else [],
"view_date": None if is_live else for_date,
"available_dates": _available_dates(store),
"pnl": query_session_pnl(store, for_date=for_date),
# all-time scorecard (every closed round-trip, not date-scoped)
"alltime": query_alltime_score(store),
# one point per market day for the day-over-day comparison curve
"daily": query_daily_performance(store),
"risk": query_risk_state(store, for_date=for_date),
"triggers": query_armed_triggers(store) if is_live else {"triggers": [], "armed": 0},
"equity_curve": query_equity_curve(store, for_date=for_date),
"catalyst": catalyst,
"catalyst_engine": _catalyst_engine(store, catalyst) if is_live else {},
# The raw stream of what the LLM read + decided (newest first)
# so the operator can see the WHY, not just the engine totals.
"catalyst_feed": query_catalyst_feed(store) if is_live else [],
"approval_queue": approval_queue,
"ready_signals": query_ready_signals_snapshot(store) if is_live else [],
# watch_states is a LIVE concept (and the heaviest projection
# — ~99k criteria rows for a whole past day); skip it for a
# historical review, which is about that day's P&L/trades.
"watch_states": query_watch_states_snapshot(store) if is_live else [],
"accounts": query_account_summary_snapshot(store, for_date=for_date),
"positions": query_account_positions_snapshot(store, for_date=for_date),
"orders": query_account_orders_snapshot(store, for_date=for_date),
"order_lifecycle": query_order_lifecycle_snapshot(store),
"fills": query_fills_feed(store, limit=30, for_date=for_date),
"sessions": query_session_summary(store),
}
finally:
store.close()
def event_count(self) -> int:
with self._lock:
store = self.open_store()
try:
return store.count_events()
finally:
store.close()
def approval_queue(self) -> list[dict]:
with self._lock:
store = self.open_store()
try:
return query_approval_queue(store)
finally:
store.close()
def criteria(self, symbol: str) -> dict:
with self._lock:
store = self.open_store()
try:
return query_symbol_criteria(store, symbol)
finally:
store.close()
def bars(self, symbol: str, minutes: int = 60) -> dict:
"""Recent 1-min bars for a sparkline, from the research DB if present.
Uses the current US/Eastern session date (matching the ingestion
pipeline). If today has no bars yet (pre-market, weekend, or replay of
an older session), falls back to the most recent session that does.
"""
if self.research_con is None:
return {"symbol": symbol, "bars": [], "reason": "no research db attached"}
from research.query import query_minute_bars
with self._lock:
try:
session_date = _eastern_session_date()
df = query_minute_bars(self.research_con, symbol, session_date)
if df is None or df.empty:
# fall back to the latest session with bars for this symbol
row = self.research_con.execute(
"SELECT MAX(session_date) FROM minute_bars WHERE symbol = ?",
[symbol],
).fetchone()
if row and row[0] is not None:
df = query_minute_bars(self.research_con, symbol, row[0])
except Exception as exc: # noqa: BLE001
return {"symbol": symbol, "bars": [], "reason": str(exc)}
if df is None or df.empty:
return {"symbol": symbol, "bars": []}
tail = df.tail(minutes)
bars = [
{
"t": str(row["timestamp"]),
"c": float(row["close"]),
"h": float(row["high"]),
"l": float(row["low"]),
"v": int(row["volume"]),
"vwap": float(row["vwap"]) if row.get("vwap") is not None else None,
}
for _, row in tail.iterrows()
]
return {"symbol": symbol, "bars": bars}
def _with_service(self, fn):
if self.execution_service is None:
return None
with self._lock:
return fn(self.execution_service)
def approve(self, order_id: str, approved_by: str, notes: str | None) -> dict | None:
return self._with_service(
lambda svc: svc.approve_order(order_id, approved_by=approved_by, notes=notes)
)
def reject(self, order_id: str, rejected_by: str, reason: str) -> dict | None:
return self._with_service(
lambda svc: svc.reject_order(order_id, rejected_by=rejected_by, reason=reason)
)
def exit_position(self, symbol: str) -> dict | None:
return self._with_service(lambda svc: svc.submit_exit_order(symbol))
def emit_command(self, command: str, symbol: str | None = None,
requested_by: str = "dashboard") -> dict:
"""Emit a guarded operator command event (the loop consumes + executes it).
Works even when execution_service is None — the standalone dashboard is a
thin emitter; the loop process is the single execution authority. Callers
gate on DASHBOARD_TOKEN before reaching here (commands never run
unauthenticated)."""
nonce = uuid.uuid4().hex
with self._lock:
store = self.open_store()
try:
store.emit(OperatorCommandEvent(
timestamp=datetime.now(), mode=EventMode.PAPER,
message=f"operator command {command}"
+ (f" {symbol}" if symbol else ""),
command=command, symbol=(symbol or None),
requested_by=requested_by, nonce=nonce))
finally:
store.close()
return {"ok": True, "command": command, "symbol": symbol, "nonce": nonce}
def add_watch(self, symbol: str) -> dict | None:
"""Manually inject a symbol onto the watchlist (if supported)."""
if not self.watch_inject or not symbol:
return None
try:
self.watch_inject(symbol.upper())
return {"ok": True, "symbol": symbol.upper()}
except Exception as exc: # noqa: BLE001
return {"ok": False, "error": str(exc)}
def _json_default(obj):
if isinstance(obj, datetime):
return obj.isoformat()
return str(obj)
def _dump(payload) -> bytes:
return json.dumps(payload, default=_json_default).encode()
class DashboardHandler(BaseHTTPRequestHandler):
state: DashboardState # injected via make_handler
protocol_version = "HTTP/1.1"
# -- plumbing -----------------------------------------------------------
def log_message(self, fmt, *args): # noqa: A003 - quiet by default
pass
def _send_json(self, payload, status: int = 200) -> None:
body = _dump(payload)
try:
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
# the client hung up while we were rendering (slow query + browser
# timeout) — nothing to send it; don't double-traceback the log
pass
def _send_html(self, path: Path) -> None:
if not path.exists():
self._send_json({"error": "dashboard.html not found"}, 404)
return
body = path.read_bytes()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
# never cache the dashboard shell — a stale cached page was serving old
# JS/CSS, so pushed fixes (and live-vs-cached behaviour) didn't reach the
# browser. The page is tiny and the data is fetched separately anyway.
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
self.send_header("Pragma", "no-cache")
self.end_headers()
self.wfile.write(body)
def _read_body(self) -> dict:
length = int(self.headers.get("Content-Length") or 0)
if not length:
return {}
try:
return json.loads(self.rfile.read(length).decode() or "{}")
except json.JSONDecodeError:
return {}
def _query(self) -> dict:
return parse_qs(urlparse(self.path).query)
# -- SSE ----------------------------------------------------------------
def _stream(self) -> None:
"""Server-Sent Events: push a fresh snapshot whenever events change.
Falls back gracefully — a heartbeat every ~15s keeps proxies open even
when nothing is happening, and the loop exits when the client drops or
the server signals shutdown (so the DB handle is released cleanly).
"""
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "keep-alive")
self.end_headers()
shutdown = getattr(type(self), "shutdown_event", None)
_sentinel = object()
last_stamp = _sentinel
last_heartbeat = 0.0
try:
while shutdown is None or not shutdown.is_set():
stamp = self.state.change_stamp()
now = time.monotonic()
if stamp != last_stamp: # last_stamp starts as a unique sentinel
last_stamp = stamp
payload = _dump(self.state.snapshots()).decode()
self.wfile.write(f"event: snapshot\ndata: {payload}\n\n".encode())
self.wfile.flush()
last_heartbeat = now
elif now - last_heartbeat > 15:
self.wfile.write(b": heartbeat\n\n")
self.wfile.flush()
last_heartbeat = now
# sleep in short slices so shutdown is responsive
for _ in range(10):
if shutdown is not None and shutdown.is_set():
break
time.sleep(0.1)
except (BrokenPipeError, ConnectionResetError, OSError):
return # client disconnected
# -- routes ---------------------------------------------------------------
def do_GET(self): # noqa: N802
try:
route = urlparse(self.path).path
if route in ("/", "/index.html", "/dashboard"):
self._send_html(STATIC_DIR / "dashboard.html")
elif route == "/api/stream":
self._stream()
elif route == "/api/snapshots":
d = (self._query().get("date") or [""])[0]
self._send_json(self.state.snapshots(for_date=d or None))
elif route == "/api/approval-queue":
self._send_json({"approval_queue": self.state.approval_queue()})
elif route == "/api/criteria":
symbol = (self._query().get("symbol") or [""])[0]
self._send_json(self.state.criteria(symbol))
elif route == "/api/bars":
q = self._query()
symbol = (q.get("symbol") or [""])[0]
minutes = int((q.get("minutes") or ["60"])[0])
self._send_json(self.state.bars(symbol, minutes))
elif route == "/api/attribution":
# per-component stage ratings (research/attribution.py); computed on
# demand — a handful of indexed event queries, fine per click
from research.attribution import compute_attribution
days = int((self._query().get("days") or ["7"])[0])
self._send_json(compute_attribution(days=days))
elif route == "/api/health":
self._send_json({"ok": True, "mode": self.state.execution_mode})
else:
self._send_json({"error": "not found"}, 404)
except Exception as exc: # noqa: BLE001
self._send_json({"error": str(exc)}, 500)
def do_POST(self): # noqa: N802
# optional shared-secret gate on trade actions (approve/reject/exit/watch).
# Off by default (autonomous stack auto-approves); set DASHBOARD_TOKEN to
# require X-Dashboard-Token on every POST. CORS wildcard was removed so a
# cross-origin LAN page can't drive these either way.
_tok = os.environ.get("DASHBOARD_TOKEN")
if _tok and not hmac.compare_digest(self.headers.get("X-Dashboard-Token", ""), _tok):
return self._send_json({"error": "bad or missing X-Dashboard-Token"}, 401)
try:
route = urlparse(self.path).path
body = self._read_body()
if route == "/api/trading/approvals/approve":
result = self.state.approve(
str(body.get("order_id") or ""),
approved_by=str(body.get("approved_by") or "dashboard"),
notes=body.get("notes"),
)
elif route == "/api/trading/approvals/reject":
result = self.state.reject(
str(body.get("order_id") or ""),
rejected_by=str(body.get("rejected_by") or "dashboard"),
reason=str(body.get("reason") or "manual"),
)
elif route == "/api/trading/exit-order":
result = self.state.exit_position(str(body.get("symbol") or ""))
elif route == "/api/watch/add":
result = self.state.add_watch(str(body.get("symbol") or ""))
elif route == "/api/command":
# The command bus is token-gated by construction: without
# DASHBOARD_TOKEN a command could run unauthenticated, so refuse
# outright (the top-of-do_POST gate only bites WHEN a token is set).
if not os.environ.get("DASHBOARD_TOKEN"):
self._send_json(
{"ok": False, "error": "command bus disabled: DASHBOARD_TOKEN not set"},
503)
return
command = str(body.get("command") or "")
if command not in OPERATOR_COMMANDS:
self._send_json(
{"ok": False, "error": f"unknown command {command!r}"}, 400)
return
symbol = body.get("symbol")
symbol = str(symbol).upper() if symbol else None
if command == "flatten_symbol" and not symbol:
self._send_json(
{"ok": False, "error": "flatten_symbol requires a symbol"}, 400)
return
self._send_json(
self.state.emit_command(command, symbol=symbol), 200)
return
else:
self._send_json({"error": "not found"}, 404)
return
if result is None:
self._send_json(
{"ok": False, "error": "not available in read-only mode"}, 503
)
else:
self._send_json(result, 200 if result.get("ok") else 400)
except Exception as exc: # noqa: BLE001
self._send_json({"error": str(exc)}, 500)
def do_OPTIONS(self): # noqa: N802
self.send_response(204)
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def make_handler(state: DashboardState, shutdown_event=None):
return type(
"BoundDashboardHandler",
(DashboardHandler,),
{"state": state, "shutdown_event": shutdown_event},
)
def create_server(
state: DashboardState, host: str = "127.0.0.1", port: int = 8010
) -> ThreadingHTTPServer:
shutdown_event = threading.Event()
server = ThreadingHTTPServer((host, port), make_handler(state, shutdown_event))
# let SSE loops notice shutdown and release their DB handles cleanly
server._sse_shutdown = shutdown_event # type: ignore[attr-defined]
_orig_shutdown = server.shutdown
def _shutdown():
shutdown_event.set()
_orig_shutdown()
server.shutdown = _shutdown # type: ignore[method-assign]
return server
def serve_in_background(
state: DashboardState, host: str = "127.0.0.1", port: int = 8010
) -> tuple[ThreadingHTTPServer, threading.Thread]:
server = create_server(state, host, port)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, thread