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:
chemavx
2026-07-02 08:52:07 +00:00
co-authored by Claude Fable 5
parent 117d2b33b2
commit 919fe1617a
5 changed files with 506 additions and 0 deletions
+97
View File
@@ -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(
+52
View File
@@ -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
);