diff --git a/bot/strategy/bayesian.py b/bot/strategy/bayesian.py index 832120e..c4698c3 100644 --- a/bot/strategy/bayesian.py +++ b/bot/strategy/bayesian.py @@ -419,42 +419,48 @@ class BayesianStrategy: sources: list[str] = [f"Prior=poly({prior:.3f})"] adjustments: list[float] = [] - # Signal 1: price momentum (asset-specific or BTC as sentiment proxy) - if is_btc: - momentum = ext.btc_change_24h - asset_label = "BTC" - elif is_eth: - momentum = ext.eth_change_24h - asset_label = "ETH" - elif is_politics or is_tech or is_events: - momentum = ext.btc_change_24h - asset_label = "BTC(sentiment)" - else: - momentum = ext.total_market_cap_change - asset_label = "total mktcap" + # Momentum and Fear & Greed only make sense for price markets, where + # is_price_above gives the adjustment a meaningful sign. For + # politics/tech/events there is no above/below notion — is_price_above + # defaults to False (or flips on accidental wording like "reach"), so + # applying these signals just injected sign noise. Skip them entirely; + # their contributions stay 0.0 → feat_mom_lo / feat_fg_lo = 0.0. + is_non_price = is_politics or is_tech or is_events + # Signal 1: price momentum (asset-specific; price markets only) _momentum_contribution = 0.0 - if abs(momentum) > 2: - momentum_adj = math.tanh(momentum / 20) * 0.15 - if is_politics or is_tech or is_events: - momentum_adj *= 0.5 - _momentum_contribution = momentum_adj if is_price_above else -momentum_adj - adjustments.append(_momentum_contribution) - sources.append(f"{asset_label} 24h: {momentum:+.1f}%") + if not is_non_price: + if is_btc: + momentum = ext.btc_change_24h + asset_label = "BTC" + elif is_eth: + momentum = ext.eth_change_24h + asset_label = "ETH" + else: + momentum = ext.total_market_cap_change + asset_label = "total mktcap" - # Signal 2: Fear & Greed - fg = ext.fear_greed_index - if fg > 70: - fg_adj = 0.06 - sources.append(f"Fear&Greed: {fg} (greed)") - elif fg < 30: - fg_adj = -0.06 - sources.append(f"Fear&Greed: {fg} (fear)") - else: - fg_adj = (fg - 50) / 50 * 0.04 - sources.append(f"Fear&Greed: {fg} (neutral)") - _fg_contribution = fg_adj if is_price_above else -fg_adj - adjustments.append(_fg_contribution) + if abs(momentum) > 2: + momentum_adj = math.tanh(momentum / 20) * 0.15 + _momentum_contribution = momentum_adj if is_price_above else -momentum_adj + adjustments.append(_momentum_contribution) + sources.append(f"{asset_label} 24h: {momentum:+.1f}%") + + # Signal 2: Fear & Greed (price markets only) + _fg_contribution = 0.0 + if not is_non_price: + fg = ext.fear_greed_index + if fg > 70: + fg_adj = 0.06 + sources.append(f"Fear&Greed: {fg} (greed)") + elif fg < 30: + fg_adj = -0.06 + sources.append(f"Fear&Greed: {fg} (fear)") + else: + fg_adj = (fg - 50) / 50 * 0.04 + sources.append(f"Fear&Greed: {fg} (neutral)") + _fg_contribution = fg_adj if is_price_above else -fg_adj + adjustments.append(_fg_contribution) # Signal 3: BTC dominance — hurts altcoins when high _btc_dom_contribution = 0.0 diff --git a/tests/test_bayesian_macro_signals.py b/tests/test_bayesian_macro_signals.py new file mode 100644 index 0000000..fe029f1 --- /dev/null +++ b/tests/test_bayesian_macro_signals.py @@ -0,0 +1,123 @@ +""" +Tests for FASE 3 — macro signals (momentum, Fear & Greed) must not apply to +non-price markets (politics / tech / events). + +Regression: for "Will X win the election?"-style questions, is_price_above is +False, so positive BTC momentum and high Fear & Greed were sign-flipped into +evidence AGAINST the YES outcome. The fix skips both signals entirely for +politics/tech/events, leaving their contributions (and feat_mom_lo / +feat_fg_lo) at 0.0. + +evaluate_market only returns a TradingSignal on the TRADE path; on skips it +returns None but always emits a structured log line containing the per-feature +log-odds (fg_lo=… mom_lo=…). The tests parse that line via caplog. +""" +import asyncio +import logging +import math +import re + +import pytest + +from bot.data.external import ExternalSignals +from bot.data.polymarket import Market +from bot.strategy.bayesian import BayesianStrategy + +FEAT_RE = re.compile(r"fg_lo=([+-]\d+\.\d+) mom_lo=([+-]\d+\.\d+)") + + +def _make_market(question: str, category: str) -> Market: + return Market( + id="mkt-test-1", + condition_id="cond-test-1", + question=question, + yes_token_id="yes-tok", + no_token_id="no-tok", + yes_price=0.50, + no_price=0.50, + volume_24h=50_000.0, + end_date="2026-07-15T00:00:00Z", + active=True, + category=category, + ) + + +def _make_signals() -> ExternalSignals: + # Strong bullish macro environment: BTC +10%, extreme greed. + return ExternalSignals( + btc_price=100_000.0, + btc_change_24h=10.0, + eth_price=4_000.0, + eth_change_24h=8.0, + btc_dominance=50.0, + fear_greed_index=80, + fear_greed_label="greed", + total_market_cap_change=5.0, + valid=True, + ) + + +def _evaluate_and_parse_feats(question: str, category: str, caplog) -> tuple[float, float]: + """Run BayesianStrategy.evaluate and return (feat_fg_lo, feat_mom_lo) from the audit log.""" + strategy = BayesianStrategy(news=None, manifold=None, db=None) + market = _make_market(question, category) + 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 = FEAT_RE.search(record.getMessage()) + if m: + return float(m.group(1)), float(m.group(2)) + pytest.fail( + "No SKIP_EDGE_NET/TRADE log line with feature contributions found; " + f"got: {[r.getMessage() for r in caplog.records]}" + ) + + +def test_politics_market_ignores_momentum_and_fear_greed(caplog): + """Political market with BTC +10% and F&G=80 → both contributions 0.0.""" + feat_fg_lo, feat_mom_lo = _evaluate_and_parse_feats( + "Will John Smith win the election?", "politics", caplog + ) + assert feat_mom_lo == 0.0 + assert feat_fg_lo == 0.0 + # The signal sources must not mention momentum or Fear & Greed either. + full_log = "\n".join(r.getMessage() for r in caplog.records) + assert "Fear&Greed" not in full_log + assert "24h" not in full_log + + +def test_tech_and_events_markets_ignore_macro_signals(caplog): + for category in ("tech", "events"): + caplog.clear() + feat_fg_lo, feat_mom_lo = _evaluate_and_parse_feats( + "Will the product launch happen this quarter?", category, caplog + ) + assert feat_mom_lo == 0.0, f"momentum applied to {category} market" + assert feat_fg_lo == 0.0, f"Fear&Greed applied to {category} market" + + +def test_btc_market_keeps_momentum_and_fear_greed(caplog): + """BTC price market with BTC +10% and F&G=80 → current behavior preserved.""" + feat_fg_lo, feat_mom_lo = _evaluate_and_parse_feats( + "Will Bitcoin be above $150,000 on July 1?", "crypto/finance", caplog + ) + assert feat_mom_lo > 0 + assert feat_fg_lo > 0 + # Exact values: is_price_above=True ("above"), so contributions are positive. + # momentum: tanh(10/20) * 0.15, ×2 → log-odds. F&G>70: +0.06, ×2 → log-odds. + assert feat_mom_lo == pytest.approx(math.tanh(10 / 20) * 0.15 * 2, abs=1e-4) + assert feat_fg_lo == pytest.approx(0.06 * 2, abs=1e-4) + full_log = "\n".join(r.getMessage() for r in caplog.records) + assert "Fear&Greed: 80 (greed)" in full_log + assert "BTC 24h: +10.0%" in full_log + + +def test_btc_below_market_sign_flip_preserved(caplog): + """'below' market: bullish macro lowers YES probability (sign flip intact).""" + feat_fg_lo, feat_mom_lo = _evaluate_and_parse_feats( + "Will Bitcoin drop below $50,000 by August?", "crypto/finance", caplog + ) + assert feat_mom_lo < 0 + assert feat_fg_lo < 0