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:
1 parent
968bcadc94
commit
dd81dbbda1
4 files changed
+334
-7
No files matched your search
+40
-5
@@ -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
@@ -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 → signals (early vs late) → gates → execution → P&L · 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 · value → threshold per gate · 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) + "→" + 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)"); }
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
Reference in new issue
Block a user