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>
248 lines
10 KiB
Python
248 lines
10 KiB
Python
"""
|
|
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"
|