Compare commits

..
Author SHA1 Message Date
chemavxandClaude Fable 5 919fe1617a 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>
2026-07-02 08:52:07 +00:00
chemavx 117d2b33b2 Merge pull request 'feat(strategy): GNews guardrail — clamp news-only shifts to prior±0.25' (#14) from feat/news-guardrail into main
CI/CD / build-and-push (push) Successful in 8s
2026-07-02 07:17:34 +00:00
chemavxandClaude Fable 5 7f84bc3ec7 feat(strategy): GNews guardrail — clamp news-only shifts to prior±0.25
Post-mortem NVIDIA 631181: one uncorroborated high-weight signal (legacy
Manifold 0.13 at weight 0.6) flipped a 0.845 market to 0.431 and lost.
With Manifold observational-only and macro signals gated behind
is_non_price, GNews (weight 1.5) is the only live signal able to move
politics markets 20-30 pp against the order-book consensus.  This adds a
catastrophic fuse, not a fine calibration:

- apply_news_guardrail(): when |news_lo| >= NEWS_MATERIAL_LOGODDS_THRESHOLD
  (0.10) and every other signal (fg, mom, btc_dom, mfld) is below it,
  clamp the posterior to prior ± MAX_NEWS_ONLY_PROB_SHIFT (0.25).  Any
  corroborating material signal disables the clamp.  Config via env
  (NEWS_GUARDRAIL_ENABLED=true by default).
- edge_gross/edge_net computed from the clamped posterior; raw_final_prob
  preserved in reasoning (persisted via trades.reasoning — no schema
  migration) and in the NEWS_MATERIAL log line.
- guardrail_changed_trade_decision: raw edge crossed the regime gate but
  the clamped edge no longer does (fuse prevented a trade).  Note: with
  the default 0.25 band the clamped edge_net is 0.21, above every regime
  minimum, so the flag only fires with a tighter configured band.
- Observability gated on materiality: NEWS_MATERIAL per-market line and a
  compact NEWS SUMMARY cycle line, only when with_news > 0 — no flood
  from the ~145 news-less markets per cycle.
- 9 deterministic tests (extreme clamp, in-band passthrough, corroboration,
  inclusive threshold, disabled, changed_decision).

No changes to NEWS_LOGODDS_WEIGHT, Manifold flags, edge thresholds,
sizing, payout, resolution, or historical trades.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 20:26:02 +00:00
chemavx 9e21ecac21 Merge pull request 'fix(security): stop httpx from logging GNEWS_API_KEY in plaintext' (#13) from fix/redact-gnews-token-logs into main
CI/CD / build-and-push (push) Successful in 8s
2026-06-26 15:15:44 +00:00
chemavxandClaude Opus 4.8 a3ec69d2be fix(security): stop httpx from logging GNEWS_API_KEY in plaintext
httpx logs every request URL at INFO level, and the GNews search URL
carries the API key as a `?token=` query param, so GNEWS_API_KEY was
written in plaintext into the pod logs on every news query. Raise the
httpx/httpcore loggers to WARNING so request URLs never reach INFO.

The bot's own GNews log lines only print the sanitised keyword query
(NewsClient._build_query), never the token, so they are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:13:32 +00:00
chemavx af0d1fbc59 Merge pull request 'fix(news): strip GNews operator dashes and stop phantom query-budget counting' (#12) from fix/gnews-minor into main
CI/CD / build-and-push (push) Successful in 24s
2026-06-26 08:09:04 +00:00
chemavxandClaude Opus 4.8 54fc8fa11a fix(news): strip GNews operator dashes and stop phantom query-budget counting
Two minor faults found during the GNews capture/prioritisation diagnostic:

1. Hyphens/dashes reached the GNews query verbatim. '-' is GNews's exclusion
   operator, so a token like "El-Sayed" returned HTTP 400 and wasted a query.
   _PUNCT_RE now strips '-', en dash and em dash to spaces.

2. The per-cycle GNews budget counter incremented in evaluate() before
   get_sentiment() checked the API key, so with no key configured the
   [CYCLE SUMMARY] reported a phantom "gnews_queries_used: 5/5" with zero real
   requests. Added NewsClient.enabled and gated the GNews block on it; with no
   key the counter stays 0/5 and no spurious SKIP_GNEWS_PRIORITY is logged.
   No behaviour change when a key is present.

Prioritisation itself was confirmed correct and is left untouched: politics
markets are sorted by gnews_priority DESC and prior-extreme markets return
before the budget is consumed, so no query is ever spent on a market that
cannot trade.

Tests: tests/test_news_query.py (4 new); full suite 66 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 08:07:05 +00:00
9 changed files with 1027 additions and 15 deletions
+97
View File
@@ -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(
+17 -1
View File
@@ -51,7 +51,11 @@ _DATE_RE = re.compile(
r"|\bQ[1-4]\b",
flags=re.IGNORECASE,
)
_PUNCT_RE = re.compile(r"[?!\"'.,;:()\[\]{}]")
# Hyphens/dashes are GNews query operators (a leading '-' means "exclude the
# next term"), so a token like "El-Sayed" makes the API return HTTP 400. Strip
# them to spaces along with the rest of the punctuation so the query stays a
# plain keyword list. = en dash, — = em dash.
_PUNCT_RE = re.compile(r"[?!\"'.,;:()\[\]{}\-–—]")
class NewsClient:
@@ -79,6 +83,18 @@ class NewsClient:
# Public API
# ------------------------------------------------------------------
@property
def enabled(self) -> bool:
"""True only when a GNews API key is configured.
When False, get_sentiment() is a no-op that returns 0.0 without any
network call, so callers must skip GNews entirely — including the
per-cycle query budget accounting — instead of "spending" a query that
never reaches the API (which inflated gnews_queries_used to a phantom
5/5 while the key was missing).
"""
return bool(self._api_key)
async def get_sentiment(self, question: str) -> float:
"""
Return a sentiment score ∈ [-1.0, +1.0] for the market question.
+52
View File
@@ -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
);
+63
View File
@@ -27,6 +27,12 @@ logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
# httpx logs every request URL at INFO, and the GNews URL carries the API key as
# a `?token=` query param — that would leak GNEWS_API_KEY in plaintext into the
# pod logs. Raise httpx/httpcore to WARNING so request URLs never reach INFO.
# The bot's own GNews log lines only print the sanitised query, not the token.
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
log = logging.getLogger("bot.main")
PAPER_MODE = os.getenv("PAPER_MODE", "true").lower() == "true"
@@ -37,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,
@@ -116,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.
@@ -170,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(
@@ -177,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
@@ -208,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:
@@ -215,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()
@@ -271,6 +320,20 @@ async def run_trading_loop(
manifold_summary,
)
# NEWS SUMMARY — one compact line, only on cycles where at least
# one market had a material GNews contribution (never an empty
# section on news-less cycles).
if stats["news_with_material"] > 0:
log.info(
"NEWS SUMMARY | with_news=%d | avg_shift=%+.2f | "
"max_shift=%+.2f | guardrail_applied=%d | changed_decisions=%d",
stats["news_with_material"],
stats["news_avg_shift"],
stats["news_max_shift"],
stats["news_guardrail_applied"],
stats["news_changed_decisions"],
)
# 9. Update daily metrics
await metrics.update_daily_summary()
+249 -13
View File
@@ -84,6 +84,27 @@ def _env_bool(name: str, default: bool) -> bool:
MANIFOLD_SIGNAL_ENABLED = _env_bool("MANIFOLD_SIGNAL_ENABLED", False)
MANIFOLD_AUDIT_ENABLED = _env_bool("MANIFOLD_AUDIT_ENABLED", True)
# ── GNews guardrail (catastrophic fuse) ────────────────────────────────────────
# Post-mortem NVIDIA 631181: a single strong signal (legacy Manifold 0.13 at
# weight 0.6) flipped a 0.845 market to 0.431 and lost. With Manifold now
# observational-only and macro signals gated behind is_non_price, GNews
# (weight 1.5) is the only live signal that can move politics markets 20-30 pp
# against the order-book consensus. This is NOT a fine calibration — it is a
# fuse against the extreme case: one uncorroborated signal violently inverting
# the market.
#
# NEWS_GUARDRAIL_ENABLED: master switch for the fuse.
# MAX_NEWS_ONLY_PROB_SHIFT: when GNews is the ONLY material signal, the final
# probability is clamped to prior ± this value. 0.25 still allows a 25 pp
# move (edge_net 0.21 after costs) — trades still happen, sizing is bounded.
# NEWS_MATERIAL_LOGODDS_THRESHOLD: a signal counts as *material* iff its
# |log-odds contribution| >= this value. Below it, a signal is noise and
# does NOT count as corroboration. If ANY other signal (fg, momentum,
# btc_dom, manifold) is material, the fuse does not apply.
NEWS_GUARDRAIL_ENABLED = _env_bool("NEWS_GUARDRAIL_ENABLED", True)
MAX_NEWS_ONLY_PROB_SHIFT = float(os.getenv("MAX_NEWS_ONLY_PROB_SHIFT", "0.25"))
NEWS_MATERIAL_LOGODDS_THRESHOLD = float(os.getenv("NEWS_MATERIAL_LOGODDS_THRESHOLD", "0.10"))
# GNews free tier: 100 req/day. We limit to 5 queries per trading cycle
# (politics markets only) and rely on 6 h cache to stay within budget.
MAX_NEWS_QUERIES_PER_CYCLE = 5
@@ -179,6 +200,42 @@ def has_token(text: str, token: str) -> bool:
# Phase 3 — GNews priority scoring
# ─────────────────────────────────────────────────────────────────────────────
def apply_news_guardrail(
prior: float,
raw_final_prob: float,
feat_news_lo: float,
other_feats_lo: tuple[float, ...],
) -> tuple[float, bool]:
"""
GNews guardrail (catastrophic fuse).
Clamp raw_final_prob to prior ± MAX_NEWS_ONLY_PROB_SHIFT when ALL hold:
1. NEWS_GUARDRAIL_ENABLED
2. |feat_news_lo| >= NEWS_MATERIAL_LOGODDS_THRESHOLD (news is material)
3. every other signal's |log-odds contribution| is below the threshold
(GNews is the ONLY material signal no corroboration)
Returns (final_prob, guardrail_applied). guardrail_applied is True only
when the clamp actually changed the value; a raw_final_prob already inside
the band passes through untouched with applied=False.
Module globals are read at call time so tests can monkeypatch them.
"""
if not NEWS_GUARDRAIL_ENABLED:
return raw_final_prob, False
if abs(feat_news_lo) < NEWS_MATERIAL_LOGODDS_THRESHOLD:
return raw_final_prob, False
if any(abs(v) >= NEWS_MATERIAL_LOGODDS_THRESHOLD for v in other_feats_lo):
return raw_final_prob, False # corroborated — fuse does not apply
clamped = min(
max(raw_final_prob, prior - MAX_NEWS_ONLY_PROB_SHIFT),
prior + MAX_NEWS_ONLY_PROB_SHIFT,
)
if clamped == raw_final_prob:
return raw_final_prob, False
return clamped, True
def gnews_priority(market: Market, news: "NewsClient") -> float:
"""
Score a market for GNews query priority (higher = more valuable to query).
@@ -300,6 +357,13 @@ class BayesianStrategy:
# (edge_gross, edge_net, regime_min) for every market that reached the
# edge computation stage (passed prior-extreme, family, unsupported filters)
self._evaluated_edges: list[tuple[float, float, float]] = []
# GNews guardrail observability — only markets with material news
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."""
@@ -311,6 +375,54 @@ class BayesianStrategy:
self._manifold_fetched = 0
self._manifold_on_trade = 0
self._evaluated_edges = []
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."""
@@ -330,6 +442,14 @@ class BayesianStrategy:
"gross_gt_004": sum(1 for g in all_gross if g > 0.04),
"manifold_matches_accepted": self._manifold_on_trade,
"manifold_matches_rejected": self._manifold_fetched - self._manifold_on_trade,
# GNews guardrail — markets with |news_lo| >= NEWS_MATERIAL_LOGODDS_THRESHOLD
"news_with_material": len(self._news_shifts),
"news_avg_shift": (sum(self._news_shifts) / len(self._news_shifts))
if self._news_shifts else 0.0,
"news_max_shift": max(self._news_shifts, key=abs)
if self._news_shifts else 0.0,
"news_guardrail_applied": self._news_guardrail_applied,
"news_changed_decisions": self._news_changed_decisions,
}
async def evaluate(
@@ -395,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:
@@ -402,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 ────────────────────────────
@@ -413,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
@@ -420,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 ────────────────────────────────────
@@ -430,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 ─────────────────────────────────────────
@@ -503,14 +628,24 @@ class BayesianStrategy:
# Phase 3: caller has pre-sorted markets by gnews_priority() so the
# highest-value markets reach this block first.
news_log_adj = 0.0
if is_politics and self._news is not None:
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.
if is_politics and self._news is not None and self._news.enabled:
if self._news_queries_this_cycle < MAX_NEWS_QUERIES_PER_CYCLE:
self._news_queries_this_cycle += 1
sentiment = await self._news.get_sentiment(market.question)
if abs(sentiment) > 0.05:
news_sentiment = sentiment
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,
@@ -649,8 +784,31 @@ class BayesianStrategy:
# Posterior via log-odds updating
log_odds_prior = math.log(prior / (1 - prior))
total_adj = sum(adjustments)
estimated_prob = _sigmoid(log_odds_prior + total_adj * 2 + news_log_adj + manifold_log_adj)
estimated_prob = max(0.05, min(0.95, estimated_prob))
# raw_final_prob: posterior BEFORE the news guardrail.
raw_final_prob = _sigmoid(log_odds_prior + total_adj * 2 + news_log_adj + manifold_log_adj)
raw_final_prob = max(0.05, min(0.95, raw_final_prob))
# Per-feature log-odds contributions (Phase 6) — computed here (not
# after the edge gate) because the guardrail below needs them to decide
# signal materiality.
# fg / mom / btc_dom: probability-delta × 2 → log-odds.
# news / mfld: already log-odds (LOGODDS_WEIGHT already applied).
feat_fg_lo = _fg_contribution * 2
feat_mom_lo = _momentum_contribution * 2
feat_news_lo = news_log_adj
feat_mfld_lo = manifold_log_adj
feat_btc_dom_lo = _btc_dom_contribution * 2
# ── GNews guardrail (catastrophic fuse) ──────────────────────────────
# When GNews is the ONLY material signal, clamp the posterior to
# prior ± MAX_NEWS_ONLY_PROB_SHIFT. estimated_prob (post-guardrail) is
# what edge/trading uses; raw_final_prob is kept for observability.
estimated_prob, news_guardrail_applied = apply_news_guardrail(
prior,
raw_final_prob,
feat_news_lo,
(feat_fg_lo, feat_mom_lo, feat_btc_dom_lo, feat_mfld_lo),
)
# ── Phase 1: edge_gross and edge_net ─────────────────────────────────
raw_edge = estimated_prob - market.yes_price
@@ -672,15 +830,6 @@ class BayesianStrategy:
if manifold_log_adj != 0.0:
confidence = min(confidence_cap, confidence + 0.08)
# Per-feature log-odds contributions (Phase 6).
# fg / mom / btc_dom: probability-delta × 2 → log-odds.
# news / mfld: already log-odds (LOGODDS_WEIGHT already applied).
feat_fg_lo = _fg_contribution * 2
feat_mom_lo = _momentum_contribution * 2
feat_news_lo = news_log_adj
feat_mfld_lo = manifold_log_adj
feat_btc_dom_lo = _btc_dom_contribution * 2
feat_str = (
f"fg_lo={feat_fg_lo:+.4f} mom_lo={feat_mom_lo:+.4f} "
f"news_lo={feat_news_lo:+.4f} mfld_lo={feat_mfld_lo:+.4f} "
@@ -692,6 +841,80 @@ class BayesianStrategy:
passed_net = edge_net >= regime_min
can_trade = passed_net and confidence >= MIN_CONFIDENCE
# ── Guardrail decision impact ────────────────────────────────────────
# True when the un-clamped posterior's edge crossed the regime gate but
# the clamped one no longer does — i.e. the fuse PREVENTED a trade.
# Confidence is invariant under the clamp (it depends only on signal
# agreement), so the edge gate is the only component that can flip.
guardrail_changed_trade_decision = False
if news_guardrail_applied:
raw_edge_net = abs(raw_final_prob - market.yes_price) - TOTAL_COST_RATE
guardrail_changed_trade_decision = (
raw_edge_net >= regime_min and edge_net < regime_min
)
# ── Guardrail observability — ONLY markets with material news ───────
# Gated on materiality so the ~145 markets/cycle without news don't
# flood the logs. posterior_before_news = everything except GNews.
news_is_material = abs(feat_news_lo) >= NEWS_MATERIAL_LOGODDS_THRESHOLD
if news_is_material:
posterior_before_news = max(0.05, min(0.95, _sigmoid(
log_odds_prior + total_adj * 2 + manifold_log_adj
)))
self._news_shifts.append(estimated_prob - prior)
if news_guardrail_applied:
self._news_guardrail_applied += 1
if guardrail_changed_trade_decision:
self._news_changed_decisions += 1
log.info(
"NEWS_MATERIAL %-50s | cat=%-12s | family=%-28s | "
"prior=%.3f | before_news=%.3f | raw=%.3f | final=%.3f | "
"sent=%+.2f | news_lo=%+.4f | "
"edge_before_news=%.3f | edge_after_raw=%.3f | edge_after_guardrail=%.3f | "
"guardrail=%s | changed_decision=%s | max_shift=%.2f",
market.question[:50], category, family,
prior, posterior_before_news, raw_final_prob, estimated_prob,
news_sentiment, feat_news_lo,
abs(posterior_before_news - market.yes_price),
abs(raw_final_prob - market.yes_price),
edge_gross,
"applied" if news_guardrail_applied else "none",
str(guardrail_changed_trade_decision).lower(),
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:
@@ -720,8 +943,21 @@ class BayesianStrategy:
)
return None
# When GNews participated, expose raw vs final and the guardrail verdict
# (Task 4 of the guardrail spec); otherwise keep the legacy format.
if news_log_adj != 0.0:
prob_part = (
f"Prior=poly({prior:.3f}) → raw={raw_final_prob:.3f} "
f"→ final={estimated_prob:.3f} | "
f"GNews sent={news_sentiment:+.2f} | "
f"guardrail={'applied' if news_guardrail_applied else 'none'} | "
f"changed_decision={str(guardrail_changed_trade_decision).lower()} | "
f"max_shift={MAX_NEWS_ONLY_PROB_SHIFT:.2f} | "
)
else:
prob_part = f"Prior=poly({prior:.3f}) → estimate={estimated_prob:.3f} | "
reasoning = (
f"Prior=poly({prior:.3f}) → estimate={estimated_prob:.3f} | "
prob_part +
f"Poly price={market.yes_price:.3f} | "
f"edge_gross={edge_gross:+.3f} | edge_net={edge_net:+.3f} | "
f"regime_min={regime_min:.2f} | days={days} | "
+1 -1
View File
@@ -1,7 +1,7 @@
# Core
asyncpg==0.29.0
httpx==0.27.0
fastapi==0.137.2
fastapi==0.111.0
uvicorn[standard]==0.29.0
pydantic==2.7.0
+247
View File
@@ -0,0 +1,247 @@
"""
Tests for the GNews guardrail (catastrophic fuse).
Post-mortem NVIDIA 631181: one uncorroborated signal at high weight flipped a
0.845 market to 0.431. With Manifold observational-only and macro signals
gated behind is_non_price, GNews is the only live signal able to move politics
markets 20-30 pp against the order-book consensus. The fuse clamps the
posterior to prior ± MAX_NEWS_ONLY_PROB_SHIFT when GNews is the ONLY material
signal (|log-odds| >= NEWS_MATERIAL_LOGODDS_THRESHOLD); any other material
signal counts as corroboration and disables the clamp.
Politics markets have no macro adjustments, so full-path tests exercise the
"GNews only" branch naturally; the corroboration branch is tested through the
pure helper apply_news_guardrail().
evaluate() emits a NEWS_MATERIAL log line for every market whose news
contribution is material (trade or skip); tests parse it via caplog.
"""
import asyncio
import logging
import math
import re
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 (
NEWS_LOGODDS_WEIGHT,
BayesianStrategy,
apply_news_guardrail,
)
NEWS_MATERIAL_RE = re.compile(
r"NEWS_MATERIAL.*raw=(\d+\.\d+) \| final=(\d+\.\d+).*"
r"guardrail=(applied|none) \| changed_decision=(true|false)"
)
def _logodds(p: float) -> float:
return math.log(p / (1 - p))
def _sentiment_for(prior: float, target_raw: float) -> float:
"""Sentiment that moves `prior` to exactly `target_raw` via GNews alone."""
return (_logodds(target_raw) - _logodds(prior)) / NEWS_LOGODDS_WEIGHT
class FakeNews:
"""Deterministic NewsClient stub returning a fixed sentiment."""
enabled = True
def __init__(self, sentiment: float) -> None:
self._sentiment = sentiment
async def get_sentiment(self, question: str) -> float:
return self._sentiment
def get_freshness(self, question: str) -> float:
return 1.0
def _make_market(yes_price: float) -> Market:
return Market(
id="mkt-guardrail-1",
condition_id="cond-guardrail-1",
question="Will John Smith win the election?",
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="2026-07-15T00:00:00Z", # politics <30 d → regime_min 0.08
active=True,
category="politics",
)
def _make_signals() -> ExternalSignals:
# Neutral macro environment; irrelevant for politics (gated) but explicit.
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(yes_price: float, sentiment: float, caplog) -> tuple[
BayesianStrategy, tuple[float, float, str, str]
]:
"""Run evaluate() on a politics market and parse the NEWS_MATERIAL line."""
strategy = BayesianStrategy(news=FakeNews(sentiment), manifold=None, db=None)
market = _make_market(yes_price)
with caplog.at_level(logging.INFO, logger="bot.strategy.bayesian"):
asyncio.run(strategy.evaluate(market, _make_signals(), occupied_families=set()))
for record in caplog.records:
m = NEWS_MATERIAL_RE.search(record.getMessage())
if m:
return strategy, (
float(m.group(1)), float(m.group(2)), m.group(3), m.group(4)
)
pytest.fail(
"No NEWS_MATERIAL log line found; got: "
f"{[r.getMessage() for r in caplog.records]}"
)
# ─────────────────────────────────────────────────────────────────────────────
# Test 1 — extreme uncorroborated shift: clamp to prior - MAX_NEWS_ONLY_PROB_SHIFT
# ─────────────────────────────────────────────────────────────────────────────
def test_extreme_news_only_shift_is_clamped(caplog):
"""prior=0.845, raw 0.431 (NVIDIA signature) → final clamped to 0.595."""
strategy, (raw, final, guardrail, _) = _evaluate(
yes_price=0.845, sentiment=_sentiment_for(0.845, 0.431), caplog=caplog
)
assert raw == pytest.approx(0.431, abs=1e-3)
assert guardrail == "applied"
assert final >= 0.595
assert final == pytest.approx(0.845 - bayesian.MAX_NEWS_ONLY_PROB_SHIFT, abs=1e-3)
assert strategy.get_cycle_stats()["news_guardrail_applied"] == 1
assert strategy.get_cycle_stats()["news_with_material"] == 1
# ─────────────────────────────────────────────────────────────────────────────
# Test 2 — moderate shift inside the band: passes through untouched
# ─────────────────────────────────────────────────────────────────────────────
def test_moderate_news_shift_inside_band_not_clamped(caplog):
"""prior=0.50, raw 0.62 → within ±0.25 band → final=0.62, no clamp."""
strategy, (raw, final, guardrail, _) = _evaluate(
yes_price=0.50, sentiment=_sentiment_for(0.50, 0.62), caplog=caplog
)
assert raw == pytest.approx(0.62, abs=1e-3)
assert final == pytest.approx(0.62, abs=1e-3)
assert guardrail == "none"
assert strategy.get_cycle_stats()["news_guardrail_applied"] == 0
# Still counted as a material-news market for the NEWS SUMMARY.
assert strategy.get_cycle_stats()["news_with_material"] == 1
# ─────────────────────────────────────────────────────────────────────────────
# Test 3 — corroboration: any other material signal disables the fuse
# ─────────────────────────────────────────────────────────────────────────────
def test_corroborated_news_not_clamped():
"""GNews material + another signal >= threshold → raw passes without clamp."""
news_lo = _logodds(0.20) - _logodds(0.50) # ≈ -1.386, clearly material
final, applied = apply_news_guardrail(
prior=0.50,
raw_final_prob=0.20,
feat_news_lo=news_lo,
other_feats_lo=(0.0, 0.15, 0.0, 0.0), # one corroborating signal
)
assert final == 0.20
assert applied is False
def test_corroboration_threshold_is_inclusive():
"""|other| == threshold exactly counts as corroboration (>=, not >)."""
final, applied = apply_news_guardrail(
prior=0.50,
raw_final_prob=0.20,
feat_news_lo=-1.386,
other_feats_lo=(bayesian.NEWS_MATERIAL_LOGODDS_THRESHOLD, 0.0, 0.0, 0.0),
)
assert final == 0.20
assert applied is False
def test_uncorroborated_helper_clamps():
"""Same shift with only noise elsewhere → clamped to prior - 0.25."""
final, applied = apply_news_guardrail(
prior=0.50,
raw_final_prob=0.20,
feat_news_lo=-1.386,
other_feats_lo=(0.05, -0.09, 0.0, 0.0), # all below threshold → noise
)
assert final == pytest.approx(0.25)
assert applied is True
def test_sub_material_news_never_clamped():
"""|news_lo| below threshold → fuse not armed, whatever the shift."""
final, applied = apply_news_guardrail(
prior=0.50,
raw_final_prob=0.10,
feat_news_lo=0.09,
other_feats_lo=(0.0, 0.0, 0.0, 0.0),
)
assert final == 0.10
assert applied is False
def test_guardrail_disabled_passthrough(monkeypatch):
monkeypatch.setattr(bayesian, "NEWS_GUARDRAIL_ENABLED", False)
final, applied = apply_news_guardrail(
prior=0.845,
raw_final_prob=0.431,
feat_news_lo=-1.974,
other_feats_lo=(0.0, 0.0, 0.0, 0.0),
)
assert final == 0.431
assert applied is False
# ─────────────────────────────────────────────────────────────────────────────
# Test 4 — changed_decision: the clamp moves the edge from tradeable to not
# ─────────────────────────────────────────────────────────────────────────────
def test_guardrail_changed_trade_decision(monkeypatch, caplog):
"""
With max_shift=0.10 the clamped edge (0.10 gross, 0.06 net) falls below the
politics <30 d regime gate (0.08) while the raw edge (0.414 gross, 0.374
net) crossed it the fuse prevented the trade changed_decision=true.
(With the default 0.25 the clamped edge_net is 0.21, above every regime
minimum, so the flag can only fire with a tighter configured band.)
"""
monkeypatch.setattr(bayesian, "MAX_NEWS_ONLY_PROB_SHIFT", 0.10)
strategy, (raw, final, guardrail, changed) = _evaluate(
yes_price=0.845, sentiment=_sentiment_for(0.845, 0.431), caplog=caplog
)
assert raw == pytest.approx(0.431, abs=1e-3)
assert final == pytest.approx(0.745, abs=1e-3)
assert guardrail == "applied"
assert changed == "true"
stats = strategy.get_cycle_stats()
assert stats["news_changed_decisions"] == 1
assert stats["news_guardrail_applied"] == 1
def test_default_band_does_not_change_decision(caplog):
"""Default 0.25 band: clamp binds but edge_net 0.21 still crosses the gate."""
_, (_, _, guardrail, changed) = _evaluate(
yes_price=0.845, sentiment=_sentiment_for(0.845, 0.431), caplog=caplog
)
assert guardrail == "applied"
assert changed == "false"
+77
View File
@@ -0,0 +1,77 @@
"""Tests for the GNews layer minor fixes.
Two faults found during the GNews capture/prioritisation diagnostic:
1. Hyphens/dashes in a market question reached the GNews query verbatim and,
because '-' is GNews's exclusion operator, produced HTTP 400
(e.g. "Abdul El-Sayed Michigan Democratic Primary").
2. The per-cycle GNews budget counter incremented in evaluate() *before*
get_sentiment() checked the API key, so with no key configured the
[CYCLE SUMMARY] reported a phantom "gnews_queries_used: 5/5" even though
zero real requests left the process.
"""
import asyncio
from bot.data.news import NewsClient
from bot.data.external import ExternalSignals
from bot.data.polymarket import Market
from bot.strategy.bayesian import BayesianStrategy
# ── Fix 1: query sanitisation ────────────────────────────────────────────────
def test_build_query_strips_hyphen_that_breaks_gnews():
q = NewsClient._build_query(
"Will Abdul El-Sayed win the 2026 Michigan Democratic Primary?"
)
assert "-" not in q # the exclusion operator must be gone
assert "El-Sayed" not in q
assert "Sayed" in q # the meaningful token survives as its own word
def test_build_query_strips_unicode_dashes():
q = NewsClient._build_query("TrumpPutin summit — final outcome")
assert "" not in q and "" not in q
assert "Trump" in q and "Putin" in q
# ── Fix 2: enabled property + budget accounting ──────────────────────────────
def test_enabled_reflects_api_key(monkeypatch):
monkeypatch.delenv("GNEWS_API_KEY", raising=False)
assert NewsClient().enabled is False
monkeypatch.setenv("GNEWS_API_KEY", "deadbeefdeadbeefdeadbeefdeadbeef")
assert NewsClient().enabled is True
def _politics_market() -> Market:
return Market(
id="m1", condition_id="c1",
question="Will candidate X win the 2026 governor election?",
yes_token_id="y", no_token_id="n",
yes_price=0.50, no_price=0.50, volume_24h=10_000.0,
end_date="2026-07-15T00:00:00Z", active=True, category="politics",
)
def _signals() -> ExternalSignals:
return ExternalSignals(
btc_price=1.0, btc_change_24h=0.0, eth_price=1.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 test_disabled_news_consumes_no_gnews_budget(monkeypatch):
"""Regression: no API key → gnews_queries_used stays 0 (was a phantom 1+)."""
monkeypatch.delenv("GNEWS_API_KEY", raising=False)
news = NewsClient()
assert news.enabled is False
strategy = BayesianStrategy(news=news, manifold=None, db=None)
strategy.reset_cycle()
asyncio.run(
strategy.evaluate(_politics_market(), _signals(), occupied_families=set())
)
assert strategy.get_cycle_stats()["gnews_queries_used"] == 0
+224
View File
@@ -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",
]