diff --git a/bot/data/db.py b/bot/data/db.py index b622634..fa04fd1 100644 --- a/bot/data/db.py +++ b/bot/data/db.py @@ -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( diff --git a/bot/data/schema.sql b/bot/data/schema.sql index 7b8af74..f6d55d6 100644 --- a/bot/data/schema.sql +++ b/bot/data/schema.sql @@ -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 +); diff --git a/bot/main.py b/bot/main.py index b79e1d7..0c5fe3f 100644 --- a/bot/main.py +++ b/bot/main.py @@ -43,6 +43,14 @@ PAPER_BANKROLL = float(os.getenv("PAPER_BANKROLL", "10000")) # position per 10 minutes. 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( poly: PolymarketClient, @@ -122,6 +130,16 @@ async def run_trading_loop( # 2. Get external 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. # This prevents re-entering a family where we already hold a position. # We also pull from DB to survive pod restarts. @@ -176,6 +194,7 @@ async def run_trading_loop( reentry_guard_count = 0 cycle_trades = 0 + traded_market_ids: set[str] = set() for market in markets: if market.id in inverted_guard: log.info( @@ -183,6 +202,7 @@ async def run_trading_loop( market.id, market.question[:60], ) reentry_guard_count += 1 + strategy.record_skip(market, "reentry_guard") continue # 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) occupied_families.add(signal.family_key) cycle_trades += 1 + traded_market_ids.add(market.id) # Mark manifold audit record as used in this trade if signal.mfld_audit_id: try: @@ -221,6 +242,28 @@ async def run_trading_loop( except Exception as 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 stats = strategy.get_cycle_stats() legacy_incomplete_count = await db.get_legacy_incomplete_count() diff --git a/bot/strategy/bayesian.py b/bot/strategy/bayesian.py index 5dd2ed2..ebc03e1 100644 --- a/bot/strategy/bayesian.py +++ b/bot/strategy/bayesian.py @@ -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: diff --git a/tests/test_signal_recorder.py b/tests/test_signal_recorder.py new file mode 100644 index 0000000..a1e3f82 --- /dev/null +++ b/tests/test_signal_recorder.py @@ -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", + ]