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
+90
View File
@@ -361,6 +361,9 @@ class BayesianStrategy:
self._news_shifts: list[float] = [] # final_prob - prior, signed
self._news_guardrail_applied: int = 0
self._news_changed_decisions: int = 0
# Replay R0: per-(market, cycle) decision records, drained by main.py
# into the signals table after each evaluation loop.
self._cycle_records: list[dict] = []
def reset_cycle(self) -> None:
"""Call once at the start of each trading cycle to reset per-cycle counters."""
@@ -375,6 +378,51 @@ class BayesianStrategy:
self._news_shifts = []
self._news_guardrail_applied = 0
self._news_changed_decisions = 0
self._cycle_records = []
def record_skip(self, market: Market, skip_reason: str) -> None:
"""Record a skip decided OUTSIDE evaluate() (e.g. reentry_guard in main)."""
self._record(market, skip_reason=skip_reason)
def drain_cycle_records(self) -> list[dict]:
"""Return and clear this cycle's decision records (Replay R0)."""
records, self._cycle_records = self._cycle_records, []
return records
def _record(self, market: Market, skip_reason: Optional[str], **fields) -> None:
"""Append one decision record. Early skips leave most fields None —
the archive still shows the market existed and why it went no further."""
rec = {
"market_id": market.id,
"polymarket_price": market.yes_price,
"category": market.category,
"volume_24h": market.volume_24h,
"skip_reason": skip_reason,
"family_key": None,
"prior_prob": None,
"estimated_prob": None,
"raw_final_prob": None,
"edge_gross": None,
"edge_net": None,
"regime_min_edge": None,
"days_to_resolution": None,
"confidence": None,
"direction": None,
"passed_gross": None,
"passed_net": None,
"news_sentiment": None,
"news_budget_skipped": None,
"guardrail_applied": None,
"guardrail_changed_decision": None,
"feat_fg_lo": None,
"feat_mom_lo": None,
"feat_news_lo": None,
"feat_mfld_lo": None,
"feat_btc_dom_lo": None,
"acted_on": False,
}
rec.update(fields)
self._cycle_records.append(rec)
def get_cycle_stats(self) -> dict:
"""Return per-cycle counters for the [CYCLE SUMMARY] log block."""
@@ -467,6 +515,7 @@ class BayesianStrategy:
"SKIP_UNSUPPORTED %-50s | cat=%r",
market.question[:50], category,
)
self._record(market, skip_reason="unsupported")
return None
if not ext.valid:
@@ -474,6 +523,7 @@ class BayesianStrategy:
"SKIP_NO_SIGNALS %-50s | reason=external data unavailable",
market.question[:50],
)
self._record(market, skip_reason="no_signals")
return None
# ── Phase 1: prior + prior-extreme filter ────────────────────────────
@@ -485,6 +535,7 @@ class BayesianStrategy:
"SKIP_PRIOR_EXTREME %-50s | cat=%-12s | prior=%.3f | reason=prior<0.08",
market.question[:50], category, market.yes_price,
)
self._record(market, skip_reason="prior_extreme", prior_prob=prior)
return None
if market.yes_price > 0.92:
self._skip_prior_extreme += 1
@@ -492,6 +543,7 @@ class BayesianStrategy:
"SKIP_PRIOR_EXTREME %-50s | cat=%-12s | prior=%.3f | reason=prior>0.92",
market.question[:50], category, market.yes_price,
)
self._record(market, skip_reason="prior_extreme", prior_prob=prior)
return None
# ── Phase 2: family deduplication ────────────────────────────────────
@@ -502,6 +554,7 @@ class BayesianStrategy:
"SKIP_FAMILY %-50s | cat=%-12s | family=%s",
market.question[:50], category, family,
)
self._record(market, skip_reason="family", prior_prob=prior, family_key=family)
return None
# ── Phase 4: regime min-edge ─────────────────────────────────────────
@@ -576,6 +629,10 @@ class BayesianStrategy:
# highest-value markets reach this block first.
news_log_adj = 0.0
news_sentiment = 0.0
# Replay R0: True when GNews was never consulted for this market this
# cycle (budget exhausted) — a replay must not read feat_news_lo=0.0 as
# "there was no news".
news_budget_skipped = False
# self._news.enabled gates the whole block: with no GNews API key the
# client is a no-op, so we must not consume (or report) query budget for
# it — see NewsClient.enabled.
@@ -588,6 +645,7 @@ class BayesianStrategy:
news_log_adj = sentiment * NEWS_LOGODDS_WEIGHT
sources.append(f"GNews: {sentiment:+.2f}")
else:
news_budget_skipped = True
log.info(
"SKIP_GNEWS_PRIORITY %-50s | reason=cycle budget %d reached",
market.question[:50], MAX_NEWS_QUERIES_PER_CYCLE,
@@ -825,6 +883,38 @@ class BayesianStrategy:
MAX_NEWS_ONLY_PROB_SHIFT,
)
# Replay R0: full decision record — same fields for skip and trade paths.
# skip_reason granularity: "edge_net" when the edge gate failed,
# "confidence" when only the confidence gate blocked the trade.
self._record(
market,
skip_reason=(
None if can_trade
else ("edge_net" if not passed_net else "confidence")
),
family_key=family,
prior_prob=prior,
estimated_prob=estimated_prob,
raw_final_prob=raw_final_prob,
edge_gross=edge_gross,
edge_net=edge_net,
regime_min_edge=regime_min,
days_to_resolution=days,
confidence=confidence,
direction=direction,
passed_gross=passed_gross,
passed_net=passed_net,
news_sentiment=news_sentiment,
news_budget_skipped=news_budget_skipped,
guardrail_applied=news_guardrail_applied,
guardrail_changed_decision=guardrail_changed_trade_decision,
feat_fg_lo=feat_fg_lo,
feat_mom_lo=feat_mom_lo,
feat_news_lo=feat_news_lo,
feat_mfld_lo=feat_mfld_lo,
feat_btc_dom_lo=feat_btc_dom_lo,
)
if not can_trade:
# Increment the appropriate edge-net counter
if edge_net <= 0: