Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
919fe1617a | ||
|
|
117d2b33b2 | ||
|
|
7f84bc3ec7 | ||
|
|
9e21ecac21 | ||
|
|
a3ec69d2be | ||
|
|
af0d1fbc59 | ||
|
|
54fc8fa11a |
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -1,7 +1,7 @@
|
||||
# Core
|
||||
asyncpg==0.29.0
|
||||
httpx==0.27.0
|
||||
fastapi==0.137.1
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.29.0
|
||||
pydantic==2.7.0
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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("Trump–Putin 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
|
||||
@@ -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