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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user