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
|
cooldown_reason = EXCLUDED.cooldown_reason
|
||||||
""", poly_market_id, last_status, retry_after, 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 def mark_manifold_audit_used(self, audit_id: str) -> None:
|
||||||
async with self._pool.acquire() as conn:
|
async with self._pool.acquire() as conn:
|
||||||
await conn.execute(
|
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);
|
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
|
||||||
|
);
|
||||||
|
|||||||
+43
@@ -43,6 +43,14 @@ PAPER_BANKROLL = float(os.getenv("PAPER_BANKROLL", "10000"))
|
|||||||
# position per 10 minutes.
|
# position per 10 minutes.
|
||||||
RESOLUTION_CHECK_INTERVAL = 10
|
RESOLUTION_CHECK_INTERVAL = 10
|
||||||
|
|
||||||
|
# Replay R0: persist per-(market, cycle) decision records + the ExternalSignals
|
||||||
|
# snapshot each cycle, so the replay engine can re-run past decisions. The
|
||||||
|
# recorder must never break trading — every write is wrapped in try/except.
|
||||||
|
SIGNAL_RECORDER_ENABLED = os.getenv("SIGNAL_RECORDER_ENABLED", "true").lower() == "true"
|
||||||
|
SIGNALS_RETENTION_DAYS = int(os.getenv("SIGNALS_RETENTION_DAYS", "90"))
|
||||||
|
# Prune the archive roughly once a day at the 60s cycle cadence.
|
||||||
|
SIGNALS_PRUNE_INTERVAL_CYCLES = 1440
|
||||||
|
|
||||||
|
|
||||||
async def check_resolutions(
|
async def check_resolutions(
|
||||||
poly: PolymarketClient,
|
poly: PolymarketClient,
|
||||||
@@ -122,6 +130,16 @@ async def run_trading_loop(
|
|||||||
# 2. Get external signals
|
# 2. Get external signals
|
||||||
ext_data = await external.get_all_signals()
|
ext_data = await external.get_all_signals()
|
||||||
|
|
||||||
|
# 2b. Replay R0: archive this cycle's inputs (ext snapshot + market
|
||||||
|
# metadata). cycle_ts groups all signals rows of this cycle.
|
||||||
|
cycle_ts = datetime.now(timezone.utc)
|
||||||
|
if SIGNAL_RECORDER_ENABLED:
|
||||||
|
try:
|
||||||
|
await db.save_ext_snapshot(cycle_ts, ext_data)
|
||||||
|
await db.upsert_markets(markets)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Signal recorder (inputs) failed: %s", exc)
|
||||||
|
|
||||||
# 3. Build occupied_families from the current open portfolio positions.
|
# 3. Build occupied_families from the current open portfolio positions.
|
||||||
# This prevents re-entering a family where we already hold a position.
|
# This prevents re-entering a family where we already hold a position.
|
||||||
# We also pull from DB to survive pod restarts.
|
# We also pull from DB to survive pod restarts.
|
||||||
@@ -176,6 +194,7 @@ async def run_trading_loop(
|
|||||||
|
|
||||||
reentry_guard_count = 0
|
reentry_guard_count = 0
|
||||||
cycle_trades = 0
|
cycle_trades = 0
|
||||||
|
traded_market_ids: set[str] = set()
|
||||||
for market in markets:
|
for market in markets:
|
||||||
if market.id in inverted_guard:
|
if market.id in inverted_guard:
|
||||||
log.info(
|
log.info(
|
||||||
@@ -183,6 +202,7 @@ async def run_trading_loop(
|
|||||||
market.id, market.question[:60],
|
market.id, market.question[:60],
|
||||||
)
|
)
|
||||||
reentry_guard_count += 1
|
reentry_guard_count += 1
|
||||||
|
strategy.record_skip(market, "reentry_guard")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# evaluate() returns None for all skips — reasons are logged internally
|
# evaluate() returns None for all skips — reasons are logged internally
|
||||||
@@ -214,6 +234,7 @@ async def run_trading_loop(
|
|||||||
# Block this family for the rest of the cycle (Phase 2)
|
# Block this family for the rest of the cycle (Phase 2)
|
||||||
occupied_families.add(signal.family_key)
|
occupied_families.add(signal.family_key)
|
||||||
cycle_trades += 1
|
cycle_trades += 1
|
||||||
|
traded_market_ids.add(market.id)
|
||||||
# Mark manifold audit record as used in this trade
|
# Mark manifold audit record as used in this trade
|
||||||
if signal.mfld_audit_id:
|
if signal.mfld_audit_id:
|
||||||
try:
|
try:
|
||||||
@@ -221,6 +242,28 @@ async def run_trading_loop(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("Failed to mark manifold audit used: %s", exc)
|
log.warning("Failed to mark manifold audit used: %s", exc)
|
||||||
|
|
||||||
|
# 7b. Replay R0: flush this cycle's decision records to the archive.
|
||||||
|
# acted_on marks records whose signal actually became a trade
|
||||||
|
# (evaluate() can emit a signal that risk sizing later rejects).
|
||||||
|
records = strategy.drain_cycle_records()
|
||||||
|
if SIGNAL_RECORDER_ENABLED and records:
|
||||||
|
for rec in records:
|
||||||
|
if rec["market_id"] in traded_market_ids:
|
||||||
|
rec["acted_on"] = True
|
||||||
|
try:
|
||||||
|
await db.save_signal_records(cycle_ts, records)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Signal recorder (records) failed: %s", exc)
|
||||||
|
if cycle_count % SIGNALS_PRUNE_INTERVAL_CYCLES == 1:
|
||||||
|
try:
|
||||||
|
pruned = await db.prune_signal_records(SIGNALS_RETENTION_DAYS)
|
||||||
|
log.info(
|
||||||
|
"Signal archive pruned: %d rows older than %d days removed",
|
||||||
|
pruned, SIGNALS_RETENTION_DAYS,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Signal archive prune failed: %s", exc)
|
||||||
|
|
||||||
# 8. [CYCLE SUMMARY] — one block per cycle, stable format for grep/compare
|
# 8. [CYCLE SUMMARY] — one block per cycle, stable format for grep/compare
|
||||||
stats = strategy.get_cycle_stats()
|
stats = strategy.get_cycle_stats()
|
||||||
legacy_incomplete_count = await db.get_legacy_incomplete_count()
|
legacy_incomplete_count = await db.get_legacy_incomplete_count()
|
||||||
|
|||||||
@@ -361,6 +361,9 @@ class BayesianStrategy:
|
|||||||
self._news_shifts: list[float] = [] # final_prob - prior, signed
|
self._news_shifts: list[float] = [] # final_prob - prior, signed
|
||||||
self._news_guardrail_applied: int = 0
|
self._news_guardrail_applied: int = 0
|
||||||
self._news_changed_decisions: 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:
|
def reset_cycle(self) -> None:
|
||||||
"""Call once at the start of each trading cycle to reset per-cycle counters."""
|
"""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_shifts = []
|
||||||
self._news_guardrail_applied = 0
|
self._news_guardrail_applied = 0
|
||||||
self._news_changed_decisions = 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:
|
def get_cycle_stats(self) -> dict:
|
||||||
"""Return per-cycle counters for the [CYCLE SUMMARY] log block."""
|
"""Return per-cycle counters for the [CYCLE SUMMARY] log block."""
|
||||||
@@ -467,6 +515,7 @@ class BayesianStrategy:
|
|||||||
"SKIP_UNSUPPORTED %-50s | cat=%r",
|
"SKIP_UNSUPPORTED %-50s | cat=%r",
|
||||||
market.question[:50], category,
|
market.question[:50], category,
|
||||||
)
|
)
|
||||||
|
self._record(market, skip_reason="unsupported")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not ext.valid:
|
if not ext.valid:
|
||||||
@@ -474,6 +523,7 @@ class BayesianStrategy:
|
|||||||
"SKIP_NO_SIGNALS %-50s | reason=external data unavailable",
|
"SKIP_NO_SIGNALS %-50s | reason=external data unavailable",
|
||||||
market.question[:50],
|
market.question[:50],
|
||||||
)
|
)
|
||||||
|
self._record(market, skip_reason="no_signals")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ── Phase 1: prior + prior-extreme filter ────────────────────────────
|
# ── Phase 1: prior + prior-extreme filter ────────────────────────────
|
||||||
@@ -485,6 +535,7 @@ class BayesianStrategy:
|
|||||||
"SKIP_PRIOR_EXTREME %-50s | cat=%-12s | prior=%.3f | reason=prior<0.08",
|
"SKIP_PRIOR_EXTREME %-50s | cat=%-12s | prior=%.3f | reason=prior<0.08",
|
||||||
market.question[:50], category, market.yes_price,
|
market.question[:50], category, market.yes_price,
|
||||||
)
|
)
|
||||||
|
self._record(market, skip_reason="prior_extreme", prior_prob=prior)
|
||||||
return None
|
return None
|
||||||
if market.yes_price > 0.92:
|
if market.yes_price > 0.92:
|
||||||
self._skip_prior_extreme += 1
|
self._skip_prior_extreme += 1
|
||||||
@@ -492,6 +543,7 @@ class BayesianStrategy:
|
|||||||
"SKIP_PRIOR_EXTREME %-50s | cat=%-12s | prior=%.3f | reason=prior>0.92",
|
"SKIP_PRIOR_EXTREME %-50s | cat=%-12s | prior=%.3f | reason=prior>0.92",
|
||||||
market.question[:50], category, market.yes_price,
|
market.question[:50], category, market.yes_price,
|
||||||
)
|
)
|
||||||
|
self._record(market, skip_reason="prior_extreme", prior_prob=prior)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ── Phase 2: family deduplication ────────────────────────────────────
|
# ── Phase 2: family deduplication ────────────────────────────────────
|
||||||
@@ -502,6 +554,7 @@ class BayesianStrategy:
|
|||||||
"SKIP_FAMILY %-50s | cat=%-12s | family=%s",
|
"SKIP_FAMILY %-50s | cat=%-12s | family=%s",
|
||||||
market.question[:50], category, family,
|
market.question[:50], category, family,
|
||||||
)
|
)
|
||||||
|
self._record(market, skip_reason="family", prior_prob=prior, family_key=family)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ── Phase 4: regime min-edge ─────────────────────────────────────────
|
# ── Phase 4: regime min-edge ─────────────────────────────────────────
|
||||||
@@ -576,6 +629,10 @@ class BayesianStrategy:
|
|||||||
# highest-value markets reach this block first.
|
# highest-value markets reach this block first.
|
||||||
news_log_adj = 0.0
|
news_log_adj = 0.0
|
||||||
news_sentiment = 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
|
# 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
|
# client is a no-op, so we must not consume (or report) query budget for
|
||||||
# it — see NewsClient.enabled.
|
# it — see NewsClient.enabled.
|
||||||
@@ -588,6 +645,7 @@ class BayesianStrategy:
|
|||||||
news_log_adj = sentiment * NEWS_LOGODDS_WEIGHT
|
news_log_adj = sentiment * NEWS_LOGODDS_WEIGHT
|
||||||
sources.append(f"GNews: {sentiment:+.2f}")
|
sources.append(f"GNews: {sentiment:+.2f}")
|
||||||
else:
|
else:
|
||||||
|
news_budget_skipped = True
|
||||||
log.info(
|
log.info(
|
||||||
"SKIP_GNEWS_PRIORITY %-50s | reason=cycle budget %d reached",
|
"SKIP_GNEWS_PRIORITY %-50s | reason=cycle budget %d reached",
|
||||||
market.question[:50], MAX_NEWS_QUERIES_PER_CYCLE,
|
market.question[:50], MAX_NEWS_QUERIES_PER_CYCLE,
|
||||||
@@ -825,6 +883,38 @@ class BayesianStrategy:
|
|||||||
MAX_NEWS_ONLY_PROB_SHIFT,
|
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:
|
if not can_trade:
|
||||||
# Increment the appropriate edge-net counter
|
# Increment the appropriate edge-net counter
|
||||||
if edge_net <= 0:
|
if edge_net <= 0:
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""
|
||||||
|
Tests for the Replay R0 snapshot recorder (strategy-side record accumulation).
|
||||||
|
|
||||||
|
Every evaluate() call must leave exactly one record in _cycle_records, whatever
|
||||||
|
exit path it takes, so the signals archive is a complete account of each cycle.
|
||||||
|
DB persistence itself (save_signal_records) is exercised in prod; these tests
|
||||||
|
cover the record-building contract the replay engine will rely on:
|
||||||
|
|
||||||
|
- one record per market per evaluate() call, drained per cycle
|
||||||
|
- skip_reason granularity (prior_extreme / family / edge_net / confidence /
|
||||||
|
unsupported / reentry_guard via record_skip)
|
||||||
|
- full input/output fields on records that reached edge computation
|
||||||
|
- news_budget_skipped distinguishes "not asked" from "no news"
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import bot.strategy.bayesian as bayesian
|
||||||
|
from bot.data.external import ExternalSignals
|
||||||
|
from bot.data.polymarket import Market
|
||||||
|
from bot.strategy.bayesian import (
|
||||||
|
MAX_NEWS_QUERIES_PER_CYCLE,
|
||||||
|
BayesianStrategy,
|
||||||
|
)
|
||||||
|
|
||||||
|
from tests.test_news_guardrail import FakeNews, _sentiment_for
|
||||||
|
|
||||||
|
|
||||||
|
def _end_date(days_ahead: int = 20) -> str:
|
||||||
|
dt = datetime.now(timezone.utc) + timedelta(days=days_ahead)
|
||||||
|
return dt.strftime("%Y-%m-%dT00:00:00Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_market(
|
||||||
|
yes_price: float,
|
||||||
|
question: str = "Will John Smith win the election?",
|
||||||
|
category: str = "politics",
|
||||||
|
market_id: str = "mkt-recorder-1",
|
||||||
|
) -> Market:
|
||||||
|
return Market(
|
||||||
|
id=market_id,
|
||||||
|
condition_id="cond-recorder-1",
|
||||||
|
question=question,
|
||||||
|
yes_token_id="yes-tok",
|
||||||
|
no_token_id="no-tok",
|
||||||
|
yes_price=yes_price,
|
||||||
|
no_price=1.0 - yes_price,
|
||||||
|
volume_24h=50_000.0,
|
||||||
|
end_date=_end_date(), # ~20 d → politics regime_min 0.08
|
||||||
|
active=True,
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_signals() -> ExternalSignals:
|
||||||
|
return ExternalSignals(
|
||||||
|
btc_price=100_000.0,
|
||||||
|
btc_change_24h=0.0,
|
||||||
|
eth_price=4_000.0,
|
||||||
|
eth_change_24h=0.0,
|
||||||
|
btc_dominance=50.0,
|
||||||
|
fear_greed_index=50,
|
||||||
|
fear_greed_label="neutral",
|
||||||
|
total_market_cap_change=0.0,
|
||||||
|
valid=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate(strategy: BayesianStrategy, market: Market, families=None) -> None:
|
||||||
|
asyncio.run(strategy.evaluate(market, _make_signals(), families or set()))
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Full-evaluation records: every input/output field the replay needs
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_confidence_skip_record_has_full_fields():
|
||||||
|
"""Politics market whose edge passes but confidence blocks (the known
|
||||||
|
politics ceiling): record must carry the complete decision context."""
|
||||||
|
sentiment = _sentiment_for(0.470, 0.601) # Georgia signature: edge_net 0.091
|
||||||
|
strategy = BayesianStrategy(news=FakeNews(sentiment), manifold=None, db=None)
|
||||||
|
market = _make_market(0.470)
|
||||||
|
_evaluate(strategy, market)
|
||||||
|
|
||||||
|
records = strategy.drain_cycle_records()
|
||||||
|
assert len(records) == 1
|
||||||
|
rec = records[0]
|
||||||
|
assert rec["market_id"] == "mkt-recorder-1"
|
||||||
|
assert rec["skip_reason"] == "confidence"
|
||||||
|
assert rec["category"] == "politics"
|
||||||
|
assert rec["polymarket_price"] == pytest.approx(0.470)
|
||||||
|
assert rec["prior_prob"] == pytest.approx(0.470)
|
||||||
|
assert rec["estimated_prob"] == pytest.approx(0.601, abs=1e-3)
|
||||||
|
assert rec["raw_final_prob"] == pytest.approx(0.601, abs=1e-3)
|
||||||
|
assert rec["edge_net"] == pytest.approx(0.091, abs=1e-3)
|
||||||
|
assert rec["regime_min_edge"] == pytest.approx(0.08)
|
||||||
|
assert rec["passed_net"] is True
|
||||||
|
assert rec["confidence"] == pytest.approx(0.50)
|
||||||
|
assert rec["direction"] == "BUY_YES"
|
||||||
|
assert rec["news_sentiment"] == pytest.approx(sentiment, abs=1e-6)
|
||||||
|
assert rec["feat_news_lo"] != 0.0
|
||||||
|
assert rec["news_budget_skipped"] is False
|
||||||
|
assert rec["guardrail_applied"] is False
|
||||||
|
assert rec["guardrail_changed_decision"] is False
|
||||||
|
assert rec["days_to_resolution"] is not None
|
||||||
|
assert rec["acted_on"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_net_skip_record():
|
||||||
|
"""No news, no edge → skip_reason=edge_net with passed_net False."""
|
||||||
|
strategy = BayesianStrategy(news=None, manifold=None, db=None)
|
||||||
|
market = _make_market(0.50)
|
||||||
|
_evaluate(strategy, market)
|
||||||
|
|
||||||
|
rec = strategy.drain_cycle_records()[0]
|
||||||
|
assert rec["skip_reason"] == "edge_net"
|
||||||
|
assert rec["passed_net"] is False
|
||||||
|
assert rec["estimated_prob"] == pytest.approx(0.50, abs=1e-3)
|
||||||
|
assert rec["feat_news_lo"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_guardrail_fields_recorded_when_clamped():
|
||||||
|
"""Guardrail clamp shows up in the record (applied=True, raw != final)."""
|
||||||
|
strategy = BayesianStrategy(
|
||||||
|
news=FakeNews(_sentiment_for(0.845, 0.431)), manifold=None, db=None
|
||||||
|
)
|
||||||
|
market = _make_market(0.845)
|
||||||
|
_evaluate(strategy, market)
|
||||||
|
|
||||||
|
rec = strategy.drain_cycle_records()[0]
|
||||||
|
assert rec["guardrail_applied"] is True
|
||||||
|
assert rec["raw_final_prob"] == pytest.approx(0.431, abs=1e-3)
|
||||||
|
assert rec["estimated_prob"] == pytest.approx(
|
||||||
|
0.845 - bayesian.MAX_NEWS_ONLY_PROB_SHIFT, abs=1e-3
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Early-skip records: minimal but present
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_prior_extreme_record():
|
||||||
|
strategy = BayesianStrategy(news=None, manifold=None, db=None)
|
||||||
|
_evaluate(strategy, _make_market(0.03))
|
||||||
|
|
||||||
|
rec = strategy.drain_cycle_records()[0]
|
||||||
|
assert rec["skip_reason"] == "prior_extreme"
|
||||||
|
assert rec["polymarket_price"] == pytest.approx(0.03)
|
||||||
|
assert rec["prior_prob"] == pytest.approx(0.05) # clamped prior
|
||||||
|
assert rec["estimated_prob"] is None
|
||||||
|
assert rec["edge_net"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_family_skip_record():
|
||||||
|
strategy = BayesianStrategy(news=None, manifold=None, db=None)
|
||||||
|
market = _make_market(0.50)
|
||||||
|
from bot.data.polymarket import market_family_key
|
||||||
|
_evaluate(strategy, market, families={market_family_key(market)})
|
||||||
|
|
||||||
|
rec = strategy.drain_cycle_records()[0]
|
||||||
|
assert rec["skip_reason"] == "family"
|
||||||
|
assert rec["family_key"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsupported_record():
|
||||||
|
strategy = BayesianStrategy(news=None, manifold=None, db=None)
|
||||||
|
market = _make_market(0.50, question="Will it rain tomorrow?", category="")
|
||||||
|
_evaluate(strategy, market)
|
||||||
|
|
||||||
|
rec = strategy.drain_cycle_records()[0]
|
||||||
|
assert rec["skip_reason"] == "unsupported"
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_skip_external_reason():
|
||||||
|
"""main.py records reentry-guard skips through record_skip()."""
|
||||||
|
strategy = BayesianStrategy(news=None, manifold=None, db=None)
|
||||||
|
strategy.record_skip(_make_market(0.50), "reentry_guard")
|
||||||
|
|
||||||
|
rec = strategy.drain_cycle_records()[0]
|
||||||
|
assert rec["skip_reason"] == "reentry_guard"
|
||||||
|
assert rec["estimated_prob"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Budget flag + cycle lifecycle
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_news_budget_skipped_flag():
|
||||||
|
"""With the cycle budget exhausted, the record must say GNews was never
|
||||||
|
asked — feat_news_lo=0.0 alone would be indistinguishable from no-news."""
|
||||||
|
strategy = BayesianStrategy(news=FakeNews(0.9), manifold=None, db=None)
|
||||||
|
strategy._news_queries_this_cycle = MAX_NEWS_QUERIES_PER_CYCLE
|
||||||
|
_evaluate(strategy, _make_market(0.50))
|
||||||
|
|
||||||
|
rec = strategy.drain_cycle_records()[0]
|
||||||
|
assert rec["news_budget_skipped"] is True
|
||||||
|
assert rec["news_sentiment"] == 0.0
|
||||||
|
assert rec["feat_news_lo"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_drain_empties_and_reset_clears():
|
||||||
|
strategy = BayesianStrategy(news=None, manifold=None, db=None)
|
||||||
|
_evaluate(strategy, _make_market(0.50))
|
||||||
|
assert len(strategy.drain_cycle_records()) == 1
|
||||||
|
assert strategy.drain_cycle_records() == []
|
||||||
|
|
||||||
|
_evaluate(strategy, _make_market(0.50))
|
||||||
|
strategy.reset_cycle()
|
||||||
|
assert strategy.drain_cycle_records() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_record_per_market_accumulates_in_order():
|
||||||
|
strategy = BayesianStrategy(news=None, manifold=None, db=None)
|
||||||
|
_evaluate(strategy, _make_market(0.03, market_id="m1")) # prior_extreme
|
||||||
|
_evaluate(strategy, _make_market(0.50, market_id="m2")) # edge_net
|
||||||
|
_evaluate(strategy, _make_market(0.97, market_id="m3")) # prior_extreme
|
||||||
|
|
||||||
|
records = strategy.drain_cycle_records()
|
||||||
|
assert [r["market_id"] for r in records] == ["m1", "m2", "m3"]
|
||||||
|
assert [r["skip_reason"] for r in records] == [
|
||||||
|
"prior_extreme", "edge_net", "prior_extreme",
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user