feat(replay): R0 snapshot recorder — archive per-cycle decisions into signals
The signals and markets tables existed since Phase 2/5 but never had a writer; the replay engine (phase plan line 2.1) needs a per-(market, cycle) archive of what the strategy saw and decided. This wires them up: - signals: one row per evaluated market per cycle, now carrying INPUTS (news_sentiment, feat_*_lo, volume_24h, days_to_resolution) plus the existing outputs (probs, edges, gates, skip_reason). skip_reason is granular: unsupported/no_signals/prior_extreme/family/edge_net/ confidence/reentry_guard. news_budget_skipped distinguishes "GNews not asked" (5-query budget) from "no news". - ext_snapshots: one row per cycle with the ExternalSignals snapshot; signals rows join on cycle_ts. - markets: metadata upserted each cycle (replay rebuilds Market from it). - Retention: prune > SIGNALS_RETENTION_DAYS (default 90) once a day. - SIGNAL_RECORDER_ENABLED (default true) gates all DB writes; every write is try/except — the recorder can never break trading. Strategy changes are purely additive (record accumulation at each exit path of evaluate()); no weights, thresholds, gates or sizing touched, per the freeze in the current phase plan. Tests: 10 new deterministic tests (85 total passing). Schema migration dry-run validated against prod postgres inside a rolled-back transaction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
117d2b33b2
commit
919fe1617a
@@ -650,6 +650,103 @@ class Database:
|
||||
cooldown_reason = EXCLUDED.cooldown_reason
|
||||
""", poly_market_id, last_status, retry_after, cooldown_reason)
|
||||
|
||||
# ── Replay R0: snapshot recorder ─────────────────────────────────────────
|
||||
|
||||
async def save_ext_snapshot(self, cycle_ts, ext) -> None:
|
||||
"""Persist the ExternalSignals snapshot for one cycle (Replay R0)."""
|
||||
async with self._pool.acquire() as conn:
|
||||
await conn.execute("""
|
||||
INSERT INTO ext_snapshots (
|
||||
cycle_ts, btc_price, btc_change_24h, eth_price, eth_change_24h,
|
||||
btc_dominance, fear_greed_index, fear_greed_label,
|
||||
total_market_cap_change, valid
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
ON CONFLICT (cycle_ts) DO NOTHING
|
||||
""",
|
||||
cycle_ts, ext.btc_price, ext.btc_change_24h,
|
||||
ext.eth_price, ext.eth_change_24h, ext.btc_dominance,
|
||||
ext.fear_greed_index, ext.fear_greed_label,
|
||||
ext.total_market_cap_change, ext.valid,
|
||||
)
|
||||
|
||||
async def upsert_markets(self, markets: list) -> None:
|
||||
"""Refresh market metadata (Replay R0) — replay rebuilds Market from here."""
|
||||
rows = [
|
||||
(m.id, m.condition_id, m.question, m.category, m.end_date, m.active)
|
||||
for m in markets
|
||||
]
|
||||
async with self._pool.acquire() as conn:
|
||||
await conn.executemany("""
|
||||
INSERT INTO markets (id, condition_id, question, category, end_date, active, last_seen)
|
||||
VALUES ($1,$2,$3,$4,$5,$6, now())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
condition_id = EXCLUDED.condition_id,
|
||||
question = EXCLUDED.question,
|
||||
category = EXCLUDED.category,
|
||||
end_date = EXCLUDED.end_date,
|
||||
active = EXCLUDED.active,
|
||||
last_seen = now()
|
||||
""", rows)
|
||||
|
||||
async def save_signal_records(self, cycle_ts, records: list[dict]) -> None:
|
||||
"""Batch-insert one cycle's decision records into signals (Replay R0)."""
|
||||
if not records:
|
||||
return
|
||||
rows = [
|
||||
(
|
||||
r["market_id"], cycle_ts, cycle_ts,
|
||||
r["polymarket_price"], r["category"], r["volume_24h"],
|
||||
r["skip_reason"], r["family_key"],
|
||||
r["prior_prob"], r["estimated_prob"], r["raw_final_prob"],
|
||||
r["edge_gross"], r["edge_net"], r["regime_min_edge"],
|
||||
r["days_to_resolution"], r["confidence"], r["direction"],
|
||||
r["passed_gross"], r["passed_net"],
|
||||
r["news_sentiment"], r["news_budget_skipped"],
|
||||
r["guardrail_applied"], r["guardrail_changed_decision"],
|
||||
r["feat_fg_lo"], r["feat_mom_lo"], r["feat_news_lo"],
|
||||
r["feat_mfld_lo"], r["feat_btc_dom_lo"],
|
||||
r["edge_gross"], # legacy `edge` column mirrors edge_gross
|
||||
r["acted_on"],
|
||||
)
|
||||
for r in records
|
||||
]
|
||||
async with self._pool.acquire() as conn:
|
||||
await conn.executemany("""
|
||||
INSERT INTO signals (
|
||||
market_id, timestamp, cycle_ts,
|
||||
polymarket_price, category, volume_24h,
|
||||
skip_reason, family_key,
|
||||
prior_prob, estimated_prob, raw_final_prob,
|
||||
edge_gross, edge_net, regime_min_edge,
|
||||
days_to_resolution, confidence, direction,
|
||||
passed_gross, passed_net,
|
||||
news_sentiment, news_budget_skipped,
|
||||
guardrail_applied, guardrail_changed_decision,
|
||||
feat_fg_lo, feat_mom_lo, feat_news_lo,
|
||||
feat_mfld_lo, feat_btc_dom_lo,
|
||||
edge, acted_on
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,
|
||||
$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30
|
||||
)
|
||||
""", rows)
|
||||
|
||||
async def prune_signal_records(self, retention_days: int) -> int:
|
||||
"""Delete archive rows older than retention_days; returns rows deleted."""
|
||||
async with self._pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"DELETE FROM signals WHERE timestamp < now() - ($1 || ' days')::interval",
|
||||
str(retention_days),
|
||||
)
|
||||
await conn.execute(
|
||||
"DELETE FROM ext_snapshots WHERE cycle_ts < now() - ($1 || ' days')::interval",
|
||||
str(retention_days),
|
||||
)
|
||||
try:
|
||||
return int(result.split()[-1])
|
||||
except (ValueError, IndexError):
|
||||
return 0
|
||||
|
||||
async def mark_manifold_audit_used(self, audit_id: str) -> None:
|
||||
async with self._pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
|
||||
@@ -318,3 +318,55 @@ CREATE TABLE IF NOT EXISTS manifold_eval_cooldown (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mfld_cooldown_retry ON manifold_eval_cooldown(retry_after);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Replay R0: snapshot recorder — the archive the replay engine reads from
|
||||
--
|
||||
-- The signals table (Phase 2/5 schema) never had a writer; R0 makes it the
|
||||
-- per-(market, cycle) decision archive. One row per evaluated market per
|
||||
-- cycle, carrying both the INPUTS the strategy saw (external signals, news
|
||||
-- sentiment, per-feature log-odds) and the OUTPUTS it produced (probs, edges,
|
||||
-- gates, skip_reason). A replay run rebuilds Market/ExternalSignals from
|
||||
-- these rows plus ext_snapshots and re-executes evaluate() deterministically.
|
||||
--
|
||||
-- cycle_ts groups all rows of one trading cycle and joins them to their
|
||||
-- ext_snapshots row (same timestamp; no FK to keep writes independent).
|
||||
-- days_to_resolution is persisted so replay does not depend on wall-clock.
|
||||
-- news_budget_skipped distinguishes "GNews had nothing" from "GNews was not
|
||||
-- asked this cycle" (5-query budget) — without it politics replay would treat
|
||||
-- budget starvation as absence of news.
|
||||
-- Retention: rows older than SIGNALS_RETENTION_DAYS (default 90) are pruned.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
ALTER TABLE signals ADD COLUMN IF NOT EXISTS cycle_ts TIMESTAMPTZ;
|
||||
ALTER TABLE signals ADD COLUMN IF NOT EXISTS category TEXT;
|
||||
ALTER TABLE signals ADD COLUMN IF NOT EXISTS prior_prob DOUBLE PRECISION;
|
||||
ALTER TABLE signals ADD COLUMN IF NOT EXISTS raw_final_prob DOUBLE PRECISION;
|
||||
ALTER TABLE signals ADD COLUMN IF NOT EXISTS days_to_resolution INTEGER;
|
||||
ALTER TABLE signals ADD COLUMN IF NOT EXISTS volume_24h DOUBLE PRECISION;
|
||||
ALTER TABLE signals ADD COLUMN IF NOT EXISTS news_sentiment DOUBLE PRECISION;
|
||||
ALTER TABLE signals ADD COLUMN IF NOT EXISTS news_budget_skipped BOOLEAN;
|
||||
ALTER TABLE signals ADD COLUMN IF NOT EXISTS guardrail_applied BOOLEAN;
|
||||
ALTER TABLE signals ADD COLUMN IF NOT EXISTS guardrail_changed_decision BOOLEAN;
|
||||
ALTER TABLE signals ADD COLUMN IF NOT EXISTS feat_fg_lo DOUBLE PRECISION;
|
||||
ALTER TABLE signals ADD COLUMN IF NOT EXISTS feat_mom_lo DOUBLE PRECISION;
|
||||
ALTER TABLE signals ADD COLUMN IF NOT EXISTS feat_news_lo DOUBLE PRECISION;
|
||||
ALTER TABLE signals ADD COLUMN IF NOT EXISTS feat_mfld_lo DOUBLE PRECISION;
|
||||
ALTER TABLE signals ADD COLUMN IF NOT EXISTS feat_btc_dom_lo DOUBLE PRECISION;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_signals_cycle ON signals(cycle_ts);
|
||||
|
||||
-- One row per trading cycle: the ExternalSignals snapshot every market in
|
||||
-- that cycle was evaluated against. Written once per cycle before the
|
||||
-- evaluation loop; signals rows join on cycle_ts.
|
||||
CREATE TABLE IF NOT EXISTS ext_snapshots (
|
||||
cycle_ts TIMESTAMPTZ PRIMARY KEY,
|
||||
btc_price DOUBLE PRECISION,
|
||||
btc_change_24h DOUBLE PRECISION,
|
||||
eth_price DOUBLE PRECISION,
|
||||
eth_change_24h DOUBLE PRECISION,
|
||||
btc_dominance DOUBLE PRECISION,
|
||||
fear_greed_index INTEGER,
|
||||
fear_greed_label TEXT,
|
||||
total_market_cap_change DOUBLE PRECISION,
|
||||
valid BOOLEAN
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user