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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9e21ecac21
commit
7f84bc3ec7
@@ -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"
|
||||
Reference in New Issue
Block a user