diff --git a/bot/data/news.py b/bot/data/news.py index 5c5b23e..23d8e18 100644 --- a/bot/data/news.py +++ b/bot/data/news.py @@ -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. diff --git a/bot/strategy/bayesian.py b/bot/strategy/bayesian.py index a20d08b..87518b8 100644 --- a/bot/strategy/bayesian.py +++ b/bot/strategy/bayesian.py @@ -503,7 +503,10 @@ 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: + # 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) diff --git a/tests/test_news_query.py b/tests/test_news_query.py new file mode 100644 index 0000000..6f46dc2 --- /dev/null +++ b/tests/test_news_query.py @@ -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