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>
164 lines
5.9 KiB
Python
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"])
|