Files
momentum-system/storage/projections.py
Claude 618950ce9c Add AI trade analysis (advisory): armed/weak verdict, post-mortems, EOD note
Extends the local-LLM layer from news to TRADES, reusing the proven enrichment
pattern (structured JSON, Postgres cache, off-hot-path scheduler, graceful
degradation). Advisory only — never gates a trade.

strategy/evaluation/trade_analysis.py: four analyses keyed by analysis_type —
  * armed      — does a loaded setup still make sense to pursue? (pursue/avoid/
                 monitor + confidence + concerns), weighing numbers + catalyst
  * weak       — why a too-soft setup failed and whether a thesis remains
  * postmortem — closed-trade lesson (uses r_multiple / exit_reason)
  * eod        — once-a-day session narrative
Schema-constrained decoding (ANALYSIS_SCHEMA), tolerant _parse, and a
context_hash so an unchanged board isn't re-sent to the GPU. run_* helpers
upsert into ai_trade_analysis_cache; trade_analysis_map / query_trade_analysis
read it back. NEVER raise.

Wiring:
- ai_trade_analysis_cache table (storage/pg_schema.sql)
- OllamaConfig.trade_analysis_{enabled,interval_seconds,batch_limit} + env
- run_live_paper step_analyze on the Scheduler, gated by ollama.enabled AND
  TRADE_ANALYSIS_ENABLED (ships OFF); reads the in-memory trigger book + day P&L
- query_trade_analysis projection; api snapshots() attaches the verdict to armed
  triggers + approval rows and exposes trade_analysis
- dashboard: per-name AI verdict chip (pursue=green/avoid=red/monitor=amber) +
  an "AI session note" panel
- .env.example documents the toggles

Tests: parse/enum/clamp, prompt builders, persistence + context_hash dedupe,
closed-trade + EOD paths, config from_env, and the dashboard projection. 230 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5HTMkaadGDnAPtXe2hWwg
2026-06-29 22:00:03 +00:00

837 lines
33 KiB
Python

import json
"""Projections for Milestone 2 read models.
Aggregated views for querying sessions, symbols, approvals, and positions.
"""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
def _evt_window(for_date: str | None = None) -> tuple[str | None, str | None]:
"""(since, until) event-time bounds for a dashboard view.
LIVE (None / 'live' / 'today'): the last 45 min — enough to surface the
LATEST snapshot of every account/symbol while turning a full-history scan
into a tiny range read (the dashboard projections only need the latest, but
used to load the ENTIRE history — 112s on watch_states, 78s on orders).
A 'YYYY-MM-DD': that whole local calendar day, so the date picker can review
a past session's end-of-day P&L / trades / activity.
"""
from datetime import datetime, timedelta
if for_date and str(for_date).lower() not in ("live", "today", ""):
try:
d = datetime.fromisoformat(str(for_date)).date()
start = datetime(d.year, d.month, d.day)
return (start.isoformat(), (start + timedelta(days=1)).isoformat())
except (TypeError, ValueError):
pass
return ((datetime.now() - timedelta(minutes=45)).isoformat(), None)
def _state_until(for_date: str | None = None) -> str | None:
"""Upper time bound for a 'latest state' read: end-of-day for a historical
date, None (= now) for live."""
from datetime import datetime, timedelta
if for_date and str(for_date).lower() not in ("live", "today", ""):
try:
d = datetime.fromisoformat(str(for_date)).date()
return (datetime(d.year, d.month, d.day) + timedelta(days=1)).isoformat()
except (TypeError, ValueError):
pass
return None
def _hist_window(for_date: str | None = None) -> tuple[str | None, str | None]:
"""(since, until) for DAY-SCOPED reads (fills, closed trades). Live = UNBOUNDED
(these event types are tiny — fills/closed are rare), so an idle loop or a
test's fixed-date fixtures still show. A 'YYYY-MM-DD' = that whole day."""
from datetime import datetime, timedelta
if for_date and str(for_date).lower() not in ("live", "today", ""):
try:
d = datetime.fromisoformat(str(for_date)).date()
start = datetime(d.year, d.month, d.day)
return (start.isoformat(), (start + timedelta(days=1)).isoformat())
except (TypeError, ValueError):
pass
return (None, None)
def _latest_event_payload(store, event_type: str, until: str | None = None):
"""The single most-recent event payload of a type (optionally before
`until`) via a DESC-LIMIT-1 index seek — O(1) vs loading the whole history.
Used by the latest-state snapshots (account orders/positions carry big
payloads; loading every one just to take the newest was 78s)."""
safe_type = "".join(c for c in str(event_type) if c.isalnum() or c == "_")
cond = f"event_type = '{safe_type}'"
if until:
safe_until = str(until).replace("'", "")
cond += f" AND timestamp < '{safe_until}'"
try:
rows = store.con.execute(
f"SELECT payload_json, timestamp FROM events WHERE {cond} "
# created_at DESC tie-breaks events sharing a timestamp (e.g. two
# account snapshots emitted in the same second) so we return the
# truly newest by insertion order, not an arbitrary one.
f"ORDER BY timestamp DESC, created_at DESC LIMIT 1"
).fetchall()
return rows[0] if rows else None
except Exception: # noqa: BLE001
return None
def query_session_summary(store) -> list[dict]:
"""Query all session summaries from events."""
events = store.query_events(event_type="session_summary")
summaries = []
for event in events:
payload = json.loads(event.get("payload_json", "{}"))
if "session_id" in payload:
summaries.append(
{
"session_id": payload["session_id"],
"event_id": event["id"],
"timestamp": event["timestamp"],
}
)
return summaries
def query_symbol_status(store, symbol: str | None = None) -> list[dict]:
"""Query current status for symbols."""
events = store.query_events(
event_type="symbol_state_changed",
symbol=symbol,
)
return [
{
"event_id": event["id"],
"symbol": json.loads(event.get("payload_json", "{}")).get("symbol"),
"previous_state": json.loads(event.get("payload_json", "{}")).get(
"previous_state"
),
"new_state": json.loads(event.get("payload_json", "{}")).get("new_state"),
"timestamp": event["timestamp"],
}
for event in events
]
def query_approval_queue(store) -> list[dict]:
requested = store.query_events(event_type="order_approval_requested", limit=None)
approved = store.query_events(event_type="order_approved", limit=None)
rejected = store.query_events(event_type="order_rejected", limit=None)
submitted = store.query_events(event_type="order_submitted", limit=None)
cancelled = store.query_events(event_type="order_cancelled", limit=None)
resolved_ids = {
str(json.loads(event.get("payload_json", "{}")).get("order_id"))
for event in approved + rejected + submitted + cancelled
if json.loads(event.get("payload_json", "{}")).get("order_id") is not None
}
queue = []
for event in requested:
payload = json.loads(event.get("payload_json", "{}"))
order_id = payload.get("order_id")
if order_id is None or str(order_id) in resolved_ids:
continue
queue.append(
{
"event_id": event["id"],
"order_id": str(order_id),
"symbol": payload.get("symbol"),
"requested_by": payload.get("requested_by"),
"approval_mode": payload.get("approval_mode"),
"execution_mode": payload.get("execution_mode"),
"execution_request": payload.get("execution_request", {}),
"timestamp": event["timestamp"],
}
)
return queue
def query_positions_view(store) -> list[dict]:
"""Query all positions (opened/closed events)."""
opened = store.query_events(event_type="position_opened")
closed = store.query_events(event_type="position_closed")
position_events = opened + closed
positions = []
for event in position_events:
payload = json.loads(event.get("payload_json", "{}"))
positions.append(
{
"event_id": event["id"],
"position_id": payload.get("position_id"),
"symbol": payload.get("symbol"),
"event_type": event["event_type"],
"entry_price": payload.get("entry_price"),
"quantity": payload.get("quantity"),
"stop_loss_price": payload.get("stop_loss_price"),
"exit_price": payload.get("exit_price")
if event["event_type"] == "position_closed"
else None,
"exit_reason": payload.get("exit_reason")
if event["event_type"] == "position_closed"
else None,
"realized_pnl": payload.get("realized_pnl")
if event["event_type"] == "position_closed"
else None,
"timestamp": event["timestamp"],
}
)
return positions
def query_event_timeline(store, session_id: str | None = None) -> list[dict]:
"""Query full event timeline for a session."""
return store.query_events(session_id=session_id, limit=None)
def query_settings_snapshot(store) -> dict:
"""Query current settings snapshot."""
events = store.query_events(event_type="settings_snapshot")
if not events:
return {}
latest_event = events[-1]
return json.loads(latest_event.get("payload_json", "{}"))
def query_ready_signals_snapshot(store, session_id: str | None = None) -> list[dict]:
"""Latest signal_ready per symbol, newest first. Feeds the dashboard."""
events = store.query_events(
event_type="signal_ready", session_id=session_id, limit=None
)
latest_by_symbol: dict[str, dict] = {}
for event in events:
payload = json.loads(event.get("payload_json", "{}"))
symbol = payload.get("symbol")
if not symbol:
continue
signal_data = payload.get("signal_data") or {}
latest_by_symbol[str(symbol)] = {
"symbol": str(symbol),
"signal_type": payload.get("signal_type"),
"confidence": payload.get("confidence"),
"entry_price": signal_data.get("entry_price"),
"stop_loss_price": signal_data.get("stop_loss_price"),
"quality_score": signal_data.get("quality_score"),
"timestamp": event.get("timestamp"),
}
return sorted(
latest_by_symbol.values(),
key=lambda row: str(row.get("timestamp") or ""),
reverse=True,
)
def query_watch_states_snapshot(store, session_id: str | None = None,
for_date: str | None = None) -> list[dict]:
since, until = _evt_window(for_date)
# symbol_state_changed is tiny (~2k all-time) and we want the current state
# of every watched symbol, so load it unbounded; only criteria_evaluated
# (the ~120k-row monster that made this projection 112s) needs the window.
state_events = store.query_events(
event_type="symbol_state_changed",
session_id=session_id, limit=None,
)
criteria_events = store.query_events(
event_type="criteria_evaluated",
session_id=session_id, since=since, until=until,
limit=None,
)
latest_by_symbol: dict[str, dict] = {}
def _blank(symbol_key: str) -> dict:
return {
"symbol": symbol_key,
"state": None,
"previous_state": None,
"state_reason": None,
"last_transition_at": None,
"last_score": None,
"last_criteria_at": None,
}
for event in criteria_events:
payload = json.loads(event.get("payload_json", "{}"))
symbol = payload.get("symbol")
if not symbol:
continue
symbol_key = str(symbol)
entry = latest_by_symbol.setdefault(symbol_key, _blank(symbol_key))
entry["last_score"] = payload.get("success_score_pct")
entry["last_criteria_at"] = event.get("timestamp")
for event in state_events:
payload = json.loads(event.get("payload_json", "{}"))
symbol = payload.get("symbol")
if not symbol:
continue
symbol_key = str(symbol)
entry = latest_by_symbol.setdefault(symbol_key, _blank(symbol_key))
entry["state"] = payload.get("new_state")
entry["previous_state"] = payload.get("previous_state")
entry["state_reason"] = payload.get("state_reason")
entry["last_transition_at"] = event.get("timestamp")
return sorted(latest_by_symbol.values(), key=lambda row: row["symbol"])
def query_account_summary_snapshot(store, broker_name: str | None = None,
for_date: str | None = None) -> list[dict]:
row = _latest_event_payload(store, "account_summary_updated", _state_until(for_date))
if not row:
return []
payload = json.loads(row[0] or "{}")
broker = str(payload.get("broker_name") or "")
account_id = str(payload.get("account_id") or "")
if not broker or not account_id or (broker_name is not None and broker != broker_name):
return []
return [{
"broker_name": broker, "account_id": account_id,
"account_desc": payload.get("account_desc"),
"total_equity": payload.get("total_equity"),
"cash_balance": payload.get("cash_balance"),
"buying_power": payload.get("buying_power"),
"net_liquidating_value": payload.get("net_liquidating_value"),
"timestamp": row[1],
}]
def query_account_positions_snapshot(
store, broker_name: str | None = None, for_date: str | None = None
) -> list[dict]:
row = _latest_event_payload(store, "account_positions_updated", _state_until(for_date))
if not row:
return []
payload = json.loads(row[0] or "{}")
broker = str(payload.get("broker_name") or "")
account_id = str(payload.get("account_id") or "")
if not broker or not account_id or (broker_name is not None and broker != broker_name):
return []
return [{"broker_name": broker, "account_id": account_id,
"positions": payload.get("positions", []), "timestamp": row[1]}]
def query_account_orders_snapshot(store, broker_name: str | None = None,
for_date: str | None = None) -> list[dict]:
# O(1) latest event — these payloads are huge (every order incl. legs), so
# loading the full history to take the newest was the 78s offender.
row = _latest_event_payload(store, "account_orders_updated", _state_until(for_date))
if not row:
return []
payload = json.loads(row[0] or "{}")
broker = str(payload.get("broker_name") or "")
account_id = str(payload.get("account_id") or "")
if not broker or not account_id or (broker_name is not None and broker != broker_name):
return []
return [{"broker_name": broker, "account_id": account_id,
"orders": payload.get("orders", []), "timestamp": row[1]}]
def query_order_lifecycle_snapshot(store) -> list[dict]:
event_types = [
"order_submitted",
"order_approval_requested",
"order_approved",
"order_rejected",
"order_filled",
"order_cancelled",
]
latest_by_order: dict[str, dict] = {}
for event_type in event_types:
events = store.query_events(event_type=event_type, limit=None)
for event in events:
payload = json.loads(event.get("payload_json", "{}"))
order_id = payload.get("order_id")
if not order_id:
continue
entry = latest_by_order.setdefault(
str(order_id),
{
"order_id": str(order_id),
"symbol": payload.get("symbol"),
"status": None,
"submitted_at": None,
"updated_at": None,
"side": None,
"quantity": None,
"price": None,
"fill_price": None,
"fill_quantity": None,
"approval_mode": None,
"rejection_reason": None,
},
)
if event_type == "order_submitted":
entry["status"] = "submitted"
entry["submitted_at"] = event.get("timestamp")
entry["side"] = payload.get("side")
entry["quantity"] = payload.get("quantity")
entry["price"] = payload.get("price")
elif event_type == "order_approval_requested":
entry["status"] = "approval_requested"
entry["approval_mode"] = payload.get("approval_mode")
elif event_type == "order_approved":
entry["status"] = "approved"
elif event_type == "order_rejected":
entry["status"] = "rejected"
entry["rejection_reason"] = payload.get("rejection_reason")
elif event_type == "order_filled":
entry["status"] = "filled"
entry["fill_price"] = payload.get("fill_price")
entry["fill_quantity"] = payload.get("fill_quantity")
elif event_type == "order_cancelled":
entry["status"] = "cancelled"
entry["rejection_reason"] = payload.get("cancel_reason")
entry["updated_at"] = event.get("timestamp")
return sorted(latest_by_order.values(), key=lambda row: row["order_id"])
# ---------------------------------------------------------------------------
# Interactive dashboard projections (criteria detail, session P&L, fills feed)
# ---------------------------------------------------------------------------
# Human-readable labels for the nine setup criteria, in evaluation order.
CRITERIA_LABELS = {
"sufficient_data": "Sufficient data",
"gap": "Gap %",
"relative_volume": "Relative volume",
"impulse": "Impulse leg",
"pullback": "Pullback depth",
"pullback_volume": "Pullback volume",
"vwap": "Holding VWAP",
"candle_quality": "Candle quality",
"breakout": "Breakout trigger",
}
def query_symbol_criteria(store, symbol: str, session_id: str | None = None) -> dict:
"""Latest per-criterion pass/fail breakdown for one symbol.
Powers the click-to-expand criteria panel: returns every criterion with a
passed flag and label so a 'blocked' symbol becomes legible at a glance.
"""
events = store.query_events(
event_type="criteria_evaluated",
symbol=symbol,
session_id=session_id,
limit=None,
)
if not events:
return {"symbol": symbol, "criteria": [], "score": None, "evaluated_at": None}
latest = events[-1]
payload = json.loads(latest.get("payload_json", "{}"))
results = payload.get("criteria_results") or {}
passed = set(results.get("passed") or [])
failed = set(results.get("failed") or [])
criteria = []
for key, label in CRITERIA_LABELS.items():
if key in passed:
state = True
elif key in failed:
state = False
else:
state = None # not evaluated this pass
criteria.append({"key": key, "label": label, "passed": state})
return {
"symbol": symbol,
"criteria": criteria,
"passed_count": payload.get("passed_criteria"),
"total_count": payload.get("total_criteria"),
"score": payload.get("success_score_pct"),
"status": payload.get("status"),
"evaluated_at": latest.get("timestamp"),
}
def query_fills_feed(store, limit: int = 50, for_date: str | None = None) -> list[dict]:
"""Chronological fills + submissions for the activity feed (newest first)."""
since, until = _hist_window(for_date)
rows: list[dict] = []
for event_type in ("order_filled", "order_submitted"):
for event in store.query_events(event_type=event_type, since=since,
until=until, limit=None):
payload = json.loads(event.get("payload_json", "{}"))
rows.append(
{
"kind": event_type,
"order_id": payload.get("order_id"),
"symbol": payload.get("symbol"),
"side": payload.get("side"),
"quantity": payload.get("fill_quantity") or payload.get("quantity"),
"price": payload.get("fill_price") or payload.get("price"),
"timestamp": event.get("timestamp"),
}
)
rows.sort(key=lambda r: str(r.get("timestamp") or ""), reverse=True)
return rows[:limit]
def _r_multiple(entry, stop, exit_price, side: str) -> float | None:
"""Realized R-multiple given entry/stop/exit. None if stop distance is 0."""
try:
entry = float(entry)
stop = float(stop)
exit_price = float(exit_price)
except (TypeError, ValueError):
return None
risk = abs(entry - stop)
if risk <= 0:
return None
direction = 1.0 if side != "sell" else -1.0
return round((exit_price - entry) * direction / risk, 2)
def query_session_pnl(store, session_id: str | None = None,
for_date: str | None = None) -> dict:
"""Day P&L + trade stats. Realized comes from the broker equity delta (the
closed-event reconstruction reads $0); unrealized from the latest positions
snapshot. ``for_date`` scopes it to a past session for the date picker."""
since, until = _hist_window(for_date)
closed = store.query_events(event_type="position_closed",
since=since, until=until, limit=None)
realized = 0.0
wins = 0
losses = 0
r_multiples: list[float] = []
trades: list[dict] = []
for event in closed:
payload = json.loads(event.get("payload_json", "{}"))
pnl = payload.get("realized_pnl")
if pnl is None:
continue
pnl = float(pnl)
realized += pnl
if pnl > 0:
wins += 1
elif pnl < 0:
losses += 1
r = _r_multiple(
payload.get("entry_price"),
payload.get("stop_loss_price"),
payload.get("exit_price"),
payload.get("side") or "buy",
)
if r is not None:
r_multiples.append(r)
trades.append(
{
"symbol": payload.get("symbol"),
"realized_pnl": round(pnl, 2),
"r_multiple": r,
"exit_reason": payload.get("exit_reason"),
"timestamp": event.get("timestamp"),
}
)
# unrealized from the latest positions snapshot (any broker)
unrealized = 0.0
open_positions = 0
snapshots = query_account_positions_snapshot(store, for_date=for_date)
if snapshots:
for position in snapshots[-1].get("positions") or []:
open_positions += 1
try:
unrealized += float(position.get("unrealized_pl") or 0.0)
except (TypeError, ValueError):
pass
closed_count = wins + losses
win_rate = round(wins / closed_count, 3) if closed_count else None
avg_r = round(sum(r_multiples) / len(r_multiples), 2) if r_multiples else None
# BROKER-AUTHORITATIVE day P&L. The closed-event `realized` above is $0
# whenever position_closed events aren't emitted (they currently aren't), so
# the strip read $0 on a real -$2,322 day — the operator flew blind. Prefer
# the broker equity delta: day P&L = latest equity - prior-session close
# (last_equity); matched (realized) = day P&L - current unrealized.
broker_day = None
_ar = _latest_event_payload(store, "account_summary_updated", _state_until(for_date))
if _ar:
sp = json.loads(_ar[0] or "{}") # latest equity snapshot (O(1))
try:
eq = float(sp.get("total_equity") or 0.0)
le = float(sp.get("last_equity") or 0.0)
if eq and le:
broker_day = eq - le
except (TypeError, ValueError):
pass
if broker_day is not None:
realized_out = round(broker_day - unrealized, 2) # broker-authoritative matched
total_out = round(broker_day, 2)
pnl_source = "broker"
else:
realized_out = round(realized, 2)
total_out = round(realized + unrealized, 2)
pnl_source = "closed_events"
return {
"realized_pnl": realized_out,
"unrealized_pnl": round(unrealized, 2),
"total_pnl": total_out,
"broker_day_pnl": round(broker_day, 2) if broker_day is not None else None,
"pnl_source": pnl_source,
"wins": wins,
"losses": losses,
"closed_trades": closed_count,
"open_positions": open_positions,
"win_rate": win_rate,
"avg_r_multiple": avg_r,
"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, for_date: str | None = None) -> 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
_ar = _latest_event_payload(store, "account_summary_updated", _state_until(for_date))
ts_latest = None
if _ar:
sp = json.loads(_ar[0] or "{}")
ts_latest = _ar[1]
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, for_date=for_date)
positions = (psnap[-1].get("positions") if psnap else []) or []
osnap = query_account_orders_snapshot(store, for_date=for_date)
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:
# event timestamps are stored NAIVE LOCAL (datetime.now()), so
# compare against naive-local now — comparing to UTC would be off
# by the whole timezone offset (the chip read ~4h on fresh data).
age = (datetime.now() - t).total_seconds()
else:
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,
}
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}
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
YYYY-MM-DD = that whole day, for the date picker."""
from datetime import datetime, timedelta
if for_date and str(for_date).lower() not in ("live", "today", ""):
since, until = _evt_window(for_date)
else:
since, until = (datetime.now() - timedelta(hours=10)).isoformat(), None
events = store.query_events(event_type="account_summary_updated",
since=since, until=until, limit=None)
series: list[float] = []
baseline = None
for e in events:
p = json.loads(e.get("payload_json", "{}"))
try:
eq = float(p.get("total_equity") or 0.0)
if eq:
series.append(round(eq, 2))
if baseline is None:
le = float(p.get("last_equity") or 0.0)
if le:
baseline = round(le, 2)
except (TypeError, ValueError):
pass
if len(series) > points: # keep the most recent N
series = series[-points:]
return {"equity": series, "baseline": baseline if baseline else (series[0] if series else None)}
def query_catalyst_advisory(store, lookback_hours: int = 8) -> dict:
"""Local-LLM news/catalyst read for the dashboard advisory column.
{ticker: {catalyst_type, sentiment, conviction, is_dilutive, rationale,
headline}} for tickers enriched within the window (latest per symbol). Reads
the news_catalyst_cache table directly (same Postgres datastore) — never
raises, returns {} on any fault so the dashboard degrades cleanly."""
from datetime import datetime, timedelta
cutoff = (datetime.utcnow() - timedelta(hours=lookback_hours))
try:
rows = store.con.execute(
"SELECT symbol, catalyst_type, sentiment, conviction, is_dilutive, "
"rationale, headline FROM news_catalyst_cache "
"WHERE symbol <> '' AND enriched_at >= ? ORDER BY enriched_at DESC",
[cutoff],
).fetchall()
except Exception: # noqa: BLE001 — table may not exist / Ollama never ran
return {}
out: dict[str, dict] = {}
for sym, ctype, sentiment, conviction, is_dilutive, rationale, headline in rows:
key = (sym or "").upper()
if key and key not in out: # newest first -> first seen is latest
out[key] = {
"catalyst_type": ctype,
"sentiment": round(float(sentiment), 3) if sentiment is not None else None,
"conviction": round(float(conviction), 3) if conviction is not None else None,
"is_dilutive": bool(is_dilutive),
"rationale": rationale,
"headline": headline,
}
return out
def query_trade_analysis(store, for_date: str | None = None) -> dict:
"""Local-LLM AI trade analysis for the dashboard.
{analysis_type: {symbol: {decision, confidence, summary, concerns}}} for a
session — 'armed'/'weak'/'postmortem' keyed by symbol, 'eod' under ''. Reads
ai_trade_analysis_cache directly; never raises, {} on any fault (table absent
/ analysis never ran) so the dashboard degrades cleanly."""
try:
if for_date and str(for_date).lower() not in ("live", "today", ""):
sess = for_date
else:
row = store.con.execute(
"SELECT MAX(session_date) FROM ai_trade_analysis_cache").fetchone()
sess = row[0] if row else None
if sess is None:
return {}
rows = store.con.execute(
"SELECT analysis_type, symbol, decision, confidence, summary, concerns "
"FROM ai_trade_analysis_cache WHERE session_date = ? "
"ORDER BY analyzed_at DESC",
[sess],
).fetchall()
except Exception: # noqa: BLE001
return {}
out: dict[str, dict] = {}
for atype, sym, decision, confidence, summary, concerns in rows:
bucket = out.setdefault(atype, {})
key = (sym or "").upper()
if key in bucket: # newest first -> keep latest per symbol
continue
try:
concerns_list = json.loads(concerns) if concerns else []
except (TypeError, ValueError):
concerns_list = []
bucket[key] = {
"decision": decision,
"confidence": round(float(confidence), 3) if confidence is not None else None,
"summary": summary,
"concerns": concerns_list,
}
return out