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>
225 lines
9.2 KiB
Python
225 lines
9.2 KiB
Python
"""
|
|
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",
|
|
]
|