feat(replay): R0 snapshot recorder — archivo por ciclo en signals #15
@@ -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(
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
+43
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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