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>
This commit is contained in:
momentum devandClaude Fable 5 committed 2026-07-17 07:36:31 -05:00
1 parent 968bcadc94
commit dd81dbbda1
4 files changed
+334 -7

No files matched your search

+40 -5
View File
@@ -9,6 +9,7 @@ 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
GET /api/chart?symbol=XYZ[&hours=24] 24h candles + fill markers + entry/stop/target levels
GET /api/decisions[?symbol=&limit=&date=] entry_gate_evaluated decision receipts (plan 014)
GET /static/vendor/... vendored JS libs (e.g. Lightweight Charts)
POST /api/trading/approvals/approve {"order_id": ..., "approved_by"?: ...}
POST /api/trading/approvals/reject {"order_id": ..., "reason"?: ...}
@@ -47,6 +48,7 @@ from storage.projections import (
query_catalyst_feed,
query_daily_performance,
query_fills_feed,
query_gate_receipts,
query_armed_triggers,
query_equity_curve,
query_order_lifecycle_snapshot,
@@ -89,6 +91,19 @@ def _eastern_session_date():
return datetime.now(ZoneInfo("America/New_York")).date()
def _to_epoch_et(ts) -> int:
"""Naive US/Eastern timestamp (datetime or ISO string) -> UTC epoch seconds.
Event timestamps (and ``minute_bars``) are stored naive-ET; both /api/chart
(plan 013) and /api/decisions (plan 014) hand the browser epoch seconds so
Lightweight Charts — and the JS merging the two marker sets — never does
timezone math."""
from zoneinfo import ZoneInfo
dt = ts if isinstance(ts, datetime) else datetime.fromisoformat(str(ts))
return int(dt.replace(tzinfo=ZoneInfo("America/New_York")).timestamp())
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."""
@@ -384,11 +399,7 @@ class DashboardState:
cutoff_et = now_et - timedelta(hours=hours)
cutoff_naive = cutoff_et.replace(tzinfo=None) # naive ET, matches storage
cutoff_epoch = int(cutoff_et.timestamp())
def to_epoch(naive_et_ts) -> int:
dt = naive_et_ts if isinstance(naive_et_ts, datetime) \
else datetime.fromisoformat(str(naive_et_ts))
return int(dt.replace(tzinfo=et).timestamp())
to_epoch = _to_epoch_et
with self._lock:
try:
@@ -464,6 +475,23 @@ class DashboardState:
return {"symbol": symbol, "bars": bars, "markers": markers, "levels": levels}
def decisions(self, symbol: str | None = None, limit: int = 100,
for_date: str | None = None) -> list[dict]:
"""Live decision-trace feed (plan 014): entry_gate_evaluated receipts
with a server-computed ``epoch`` per row (naive-ET storage; the chart
overlay and the panel never do timezone math client-side)."""
with self._lock:
store = self.open_store()
try:
rows = query_gate_receipts(store, symbol=symbol, limit=limit,
for_date=for_date)
finally:
store.close()
for row in rows:
ts = row.get("timestamp")
row["epoch"] = _to_epoch_et(ts) if ts else None
return rows
def _with_service(self, fn):
if self.execution_service is None:
return None
@@ -666,6 +694,13 @@ class DashboardHandler(BaseHTTPRequestHandler):
symbol = (q.get("symbol") or [""])[0]
hours = int((q.get("hours") or ["24"])[0])
self._send_json(self.state.chart(symbol, hours))
elif route == "/api/decisions":
q = self._query()
symbol = (q.get("symbol") or [""])[0] or None
limit = min(int((q.get("limit") or ["100"])[0]), 500)
d = (q.get("date") or [""])[0] or None
self._send_json({"decisions":
self.state.decisions(symbol=symbol, limit=limit, for_date=d)})
elif route == "/api/attribution":
# per-component stage ratings (research/attribution.py); computed on
# demand — a handful of indexed event queries, fine per click
+102 -2
View File
@@ -238,6 +238,13 @@
.crit.fail { color: #c9a98a; }
.crit.skip { color: var(--muted); }
/* Decision Trace panel (plan 014) — per-gate chips + filterable receipt rows */
.dec-row { display: flex; align-items: center; gap: 8px; padding: 7px 14px; border-bottom: 1px solid #151a26; font: 500 12px var(--mono); flex-wrap: wrap; }
.dec-row:last-child { border-bottom: none; }
.gate-chip { display: inline-block; font: 600 10px var(--mono); padding: 2px 7px; border-radius: 4px; margin: 1px 2px; }
.gate-chip.pass { background: #12362a; color: var(--green); }
.gate-chip.fail { background: #38221a; color: var(--red); }
/* risk preview chip */
.risk { font: 500 11px var(--mono); color: var(--muted); }
.risk b { color: var(--text); font-weight: 600; }
@@ -559,6 +566,12 @@
<summary><h2>Component Attribution <span class="hint">per-stage ratings: discovery &rarr; signals (early vs late) &rarr; gates &rarr; execution &rarr; P&amp;L &middot; loads on open</span></h2></summary>
<div id="attrib"><span class="empty">Open to load the last 7 days.</span></div>
</details>
<details class="panel full" id="p-decisions">
<summary><h2>Decision Trace <span class="hint">why each entry was taken or refused &middot; value &rarr; threshold per gate &middot; receipts kept 14 days</span></h2></summary>
<div class="inject"><input id="dec-filter" placeholder="filter symbol..." maxlength="6" autocomplete="off"></div>
<div id="decisions"><span class="empty">Open to load recent decisions.</span></div>
</details>
</main>
<script>
(function(){
@@ -711,16 +724,38 @@ function applyChartLevels(levels) {
add(levels.target, "#2fbf71", "target");
}
// Collapse a burst of same-reason rejected decisions into one marker per
// reason-change (plan 014 Step 4) — ~1,300 receipts/day would otherwise smear
// the chart. `decisions`: [{epoch, reject_reason}]; any input order. Emits
// one marker at the START of each burst, then suppresses repeats of the same
// reason for 5 minutes (300s) before allowing a new one.
function collapseDecisionMarkers(decisions) {
const sorted = (decisions || []).slice().sort((a, b) => a.epoch - b.epoch);
const lastEpochByReason = {};
const out = [];
sorted.forEach(d => {
const prev = lastEpochByReason[d.reject_reason];
lastEpochByReason[d.reject_reason] = d.epoch;
if (prev != null && d.epoch - prev <= 300) return; // within 5min of the last one -> collapsed
out.push({ epoch: d.epoch, reject_reason: d.reject_reason });
});
return out;
}
async function refreshChart(symbol) {
chartState.lastFetch = Date.now();
try {
const r = await fetch("/api/chart?symbol=" + encodeURIComponent(symbol) + "&hours=24");
const [r, rd] = await Promise.all([
fetch("/api/chart?symbol=" + encodeURIComponent(symbol) + "&hours=24"),
fetch("/api/decisions?symbol=" + encodeURIComponent(symbol) + "&limit=200"),
]);
if (!r.ok) return;
const data = await r.json();
const decData = rd.ok ? await rd.json() : { decisions: [] };
if (chartState.symbol !== symbol || !chartState.series) return; // closed/switched mid-fetch
chartState.series.setData((data.bars || []).map(b =>
({ time: b.time, open: b.open, high: b.high, low: b.low, close: b.close })));
const markers = (data.markers || []).slice().sort((a, b) => a.time - b.time).map(m => {
const fillMarkers = (data.markers || []).map(m => {
const sell = m.side === "sell";
const unfilled = m.kind === "order_submitted";
return {
@@ -731,6 +766,19 @@ async function refreshChart(symbol) {
text: (sell ? "S " : "B ") + (m.quantity != null ? m.quantity : ""),
};
});
// rejected receipts only (a pass needs no marker — the fill arrow already
// marks the outcome); collapsed so a rejection burst reads as one mark.
const rejected = (decData.decisions || [])
.filter(d => !d.passed && d.reject_reason && d.epoch != null)
.map(d => ({ epoch: d.epoch, reject_reason: d.reject_reason }));
const declineMarkers = collapseDecisionMarkers(rejected).map(d => ({
time: d.epoch,
position: "aboveBar",
shape: "circle",
color: "#9c6b6b", // muted red-gray, distinct from the sell-arrow red
text: String(d.reject_reason).slice(0, 12),
}));
const markers = fillMarkers.concat(declineMarkers).sort((a, b) => a.time - b.time);
if (chartState.markersApi) chartState.markersApi.setMarkers(markers);
applyChartLevels(data.levels || {});
} catch (e) { /* leave the last good render on screen */ }
@@ -749,6 +797,57 @@ document.addEventListener("click", (e) => {
});
$("chart-close").onclick = closeChart;
// Decision Trace panel (plan 014) — lazy-load on first open, refetch on SSE
// `snapshot` while open (imitates p-attrib's lazy-open pattern; ≥5s throttle
// like the chart modal so a snapshot flood doesn't hammer the endpoint).
let lastDecFetch = 0;
function gateChip(name, g) {
g = g || {};
const ok = g.outcome === "pass";
const vt = (g.value != null && g.threshold != null)
? (esc(g.value) + "&rarr;" + esc(g.threshold) + " ") : "";
return '<span class="gate-chip ' + (ok ? "pass" : "fail") + '">' + esc(name) + " " + vt + (ok ? "✓" : "✗") + "</span>";
}
function renderDecisions(data) {
const rows = data.decisions || [];
if (!rows.length) { fill("decisions", null, "No decision receipts in the live window."); return; }
fill("decisions", rows.map(r => {
const chips = Object.keys(r.gates || {}).map(k => gateChip(k, r.gates[k])).join("");
const verdict = r.passed ? '<span class="pill long">PASS</span>'
: '<span class="pill short">' + esc(r.reject_reason || "reject") + "</span>";
return '<div class="dec-row" title="' + esc(JSON.stringify(r)) + '">' +
'<span class="feed-time">' + timeOf(r.timestamp) + "</span>" +
'<span class="sym" style="cursor:pointer" onclick="openChart(\'' + esc(r.symbol) + "')\">" + esc(r.symbol || "") + "</span>" +
'<span class="pill ' + (r.source === "fast" ? "watching" : "discovered") + '">' + esc(r.source || "") + "</span>" +
verdict + chips + "</div>";
}).join(""));
}
function loadDecisions(force) {
const dp = $("p-decisions");
if (!dp || !dp.open) return;
const now = Date.now();
if (!force && now - lastDecFetch < 5000) return;
lastDecFetch = now;
const sym = ($("dec-filter") || {}).value || "";
fetch("/api/decisions?limit=100" + (sym ? "&symbol=" + encodeURIComponent(sym) : ""))
.then(r => r.json()).then(renderDecisions).catch(() => {});
}
(() => {
const dp = $("p-decisions");
if (!dp) return;
dp.addEventListener("toggle", () => {
if (!dp.open) return;
$("decisions").innerHTML = '<span class="empty">Loading…</span>';
loadDecisions(true);
});
let filterTimer;
const fi = $("dec-filter");
if (fi) fi.addEventListener("input", () => {
clearTimeout(filterTimer);
filterTimer = setTimeout(() => loadDecisions(true), 300);
});
})();
let toastTimer;
function toast(msg, kind) {
const t = $("toast");
@@ -1566,6 +1665,7 @@ function startStream() {
render(JSON.parse(ev.data)); hideLoader(); setConn(true, "live · " + new Date().toLocaleTimeString());
// chart modal rides the same signal — no standalone poller (≥5s throttle, full replace).
if (chartState.symbol && Date.now() - chartState.lastFetch >= 5000) refreshChart(chartState.symbol);
loadDecisions(); // no-op unless the panel is open; internally ≥5s throttled
}
catch (e) { console.error("render failed:", e); setConn(false, "render error (see console)"); }
});
+29
View File
@@ -429,6 +429,35 @@ def query_fills_feed(store, limit: int = 50, for_date: str | None = None,
return rows[:limit]
def query_gate_receipts(store, symbol: str | None = None, limit: int = 100,
for_date: str | None = None) -> list[dict]:
"""Latest ``entry_gate_evaluated`` decision receipts (plan 014), newest
first — the per-gate pass/reject breakdown behind every entry evaluation.
LIVE LENS, not an archive: the nightly housekeeping in
research/premarket_eval.py purges these events after 14 days.
``symbol`` matches case-insensitively when given (mirrors query_fills_feed's
in-Python filter idiom rather than store.query_events' exact-match one)."""
since, until = _hist_window(for_date)
sym_upper = symbol.upper() if symbol else None
rows: list[dict] = []
for event in store.query_events(event_type="entry_gate_evaluated", since=since,
until=until, limit=None):
payload = json.loads(event.get("payload_json", "{}"))
if sym_upper is not None and str(payload.get("symbol") or "").upper() != sym_upper:
continue
rows.append({
"timestamp": event.get("timestamp"),
"symbol": payload.get("symbol"),
"source": payload.get("source"),
"passed": payload.get("passed"),
"reject_reason": payload.get("reject_reason"),
"gates": payload.get("gates") or {},
})
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:
+163
View File
@@ -0,0 +1,163 @@
"""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"])