Files
momentum-system/tests/unit/api/test_decisions_endpoint.py
momentum devandClaude Fable 5 dd81dbbda1 operator-plane: live decision trace — gate receipts feed + chart reject markers (plan 014)
Surfaces the entry_gate_evaluated decision receipts (plan 005) that were
write-only until now: a Decision Trace panel (per-gate value->threshold
chips, symbol filter, lazy-load + SSE refresh) and collapsed reject markers
merged into the plan-013 symbol chart's existing markers primitive.

- storage/projections.py: query_gate_receipts (case-insensitive symbol
  filter, newest-first, same _hist_window idiom as query_fills_feed)
- api/main.py: GET /api/decisions (clamped limit<=500, date-scoped);
  DashboardState.decisions() adds a server-computed epoch per row; factored
  chart()'s inline to_epoch into a shared _to_epoch_et helper
- api/static/dashboard.html: Decision Trace panel + refreshChart now also
  fetches /api/decisions and merges collapsed reject markers (same-reason
  bursts within 5min -> one marker) into the single setMarkers call

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 07:36:31 -05:00

164 lines
5.9 KiB
Python

"""Decision Trace feed (plan 014): entry_gate_evaluated receipts -> panel +
chart reject markers.
Fixtures modeled on tests/unit/api/test_chart_endpoint.py (direct
DashboardState/handler-level calls — the urllib HTTP-fixture group in this
directory hits a pre-existing local-sandbox failure unrelated to this
feature)."""
import io
import os
import uuid
from datetime import datetime, timedelta
import psycopg2
import pytest
from api.main import DashboardHandler, DashboardState
from storage.event_schema import EntryGateEvaluatedEvent, EventMode
from storage.event_store import EventStore
from storage.projections import query_gate_receipts
@pytest.fixture(autouse=True)
def _isolated_pg_schema(monkeypatch):
"""Pin every EventStore in this file to one throwaway schema (mirrors
test_chart_endpoint.py) so seeded receipts never touch prod."""
schema = f"mem_{uuid.uuid4().hex[:12]}"
monkeypatch.setenv("MOMENTUM_PG_SCHEMA", schema)
yield
url = os.environ.get("DATABASE_URL")
if url:
conn = psycopg2.connect(url)
conn.autocommit = True
conn.cursor().execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')
conn.close()
class CaptureHandler(DashboardHandler):
"""Drives do_GET without a real socket (same pattern as test_chart_endpoint.py)."""
def __init__(self, state, path):
self.state = state
self.path = path
self.rfile = io.BytesIO(b"")
self.headers = {}
self.captured = []
def _send_json(self, payload, status=200):
self.captured.append(("json", status, payload))
def _get(state, path):
h = CaptureHandler(state, path)
h.do_GET()
return h.captured[-1]
# --------------------------------------------------------------------------
# Step 1: query_gate_receipts
# --------------------------------------------------------------------------
def test_query_gate_receipts_newest_first_symbol_filter_gates_roundtrip():
store = EventStore(":memory:")
try:
t0 = datetime.now() - timedelta(minutes=10)
t1 = datetime.now() - timedelta(minutes=9)
t2 = datetime.now() - timedelta(minutes=8)
store.emit(EntryGateEvaluatedEvent(
timestamp=t0, mode=EventMode.PAPER, message="m",
symbol="AAA", source="fast", passed=True, reject_reason=None,
gates={"session": {"outcome": "pass"}}))
store.emit(EntryGateEvaluatedEvent(
timestamp=t1, mode=EventMode.PAPER, message="m",
symbol="AAA", source="fast", passed=False, reject_reason="rank",
gates={"rank": {"value": 5, "threshold": 0, "outcome": "fail"}}))
store.emit(EntryGateEvaluatedEvent(
timestamp=t2, mode=EventMode.PAPER, message="m",
symbol="BBB", source="auto", passed=True, reject_reason=None,
gates={"regime": {"outcome": "pass"}}))
all_rows = query_gate_receipts(store, limit=100)
assert len(all_rows) == 3
# newest first
timestamps = [r["timestamp"] for r in all_rows]
assert timestamps == sorted(timestamps, reverse=True)
aaa_rows = query_gate_receipts(store, symbol="aaa", limit=100) # case-insensitive
assert len(aaa_rows) == 2
assert all(r["symbol"] == "AAA" for r in aaa_rows)
rank_row = next(r for r in aaa_rows if r["reject_reason"] == "rank")
assert rank_row["gates"]["rank"] == {"value": 5, "threshold": 0, "outcome": "fail"}
finally:
store.close()
# --------------------------------------------------------------------------
# Step 2: /api/decisions route
# --------------------------------------------------------------------------
def test_api_decisions_route_case_insensitive_symbol_and_limit():
store = EventStore("momentum")
try:
store.emit(EntryGateEvaluatedEvent(
timestamp=datetime.now(), mode=EventMode.PAPER, message="m",
symbol="ATAI", source="fast", passed=False, reject_reason="already_active",
gates={"rank": {"value": 5, "threshold": 0, "outcome": "pass"}}))
store.emit(EntryGateEvaluatedEvent(
timestamp=datetime.now(), mode=EventMode.PAPER, message="m",
symbol="OTHER", source="fast", passed=True, reject_reason=None, gates={}))
state = DashboardState("momentum")
kind, status, payload = _get(state, "/api/decisions?symbol=atai&limit=1")
finally:
store.close()
assert kind == "json" and status == 200
assert len(payload["decisions"]) == 1
assert payload["decisions"][0]["symbol"] == "ATAI"
def test_api_decisions_limit_clamped_to_500(monkeypatch):
captured = {}
def fake_decisions(self, symbol=None, limit=100, for_date=None):
captured["limit"] = limit
return []
monkeypatch.setattr(DashboardState, "decisions", fake_decisions)
state = DashboardState("momentum")
kind, status, payload = _get(state, "/api/decisions?limit=9999")
assert kind == "json" and status == 200
assert payload == {"decisions": []}
assert captured["limit"] == 500
def test_api_decisions_epoch_field_matches_naive_et_timestamp():
from zoneinfo import ZoneInfo
store = EventStore("momentum")
try:
ts = datetime(2026, 7, 16, 10, 41, 40)
store.emit(EntryGateEvaluatedEvent(
timestamp=ts, mode=EventMode.PAPER, message="m",
symbol="ATAI", source="fast", passed=True, reject_reason=None, gates={}))
state = DashboardState("momentum")
kind, status, payload = _get(state, "/api/decisions?symbol=ATAI")
finally:
store.close()
assert kind == "json" and status == 200
rows = payload["decisions"]
assert len(rows) == 1
row = rows[0]
assert isinstance(row["epoch"], int)
expected = int(ts.replace(tzinfo=ZoneInfo("America/New_York")).timestamp())
assert row["epoch"] == expected
if __name__ == "__main__": # pragma: no cover
pytest.main([__file__, "-q"])