diff --git a/bot/data/news.py b/bot/data/news.py index bafa39f..8eb1914 100644 --- a/bot/data/news.py +++ b/bot/data/news.py @@ -1,13 +1,16 @@ """ News sentiment client for GNews API. -Free tier: 100 requests/day — we stay well within this by caching each -unique query for CACHE_TTL seconds (4 hours). With ~9 political markets -refreshed every 4 h that is 9 × 6 = 54 requests/day. +Free tier: 100 requests/day. Budget: +- Cache TTL: 6 hours — same query is never repeated within 6 h +- Max 5 queries per trading cycle (politics markets only) +- 2-second sleep between actual API calls to avoid burst 429s +With ≤9 politics markets and 6 h cache → ≤9 requests per 6 h = ≤36/day. Score returned: -1.0 (very negative headlines) → +1.0 (very positive). Returns 0.0 on any error or missing API key so the caller degrades gracefully. """ +import asyncio import logging import os import re @@ -18,7 +21,8 @@ import httpx log = logging.getLogger(__name__) GNEWS_API = "https://gnews.io/api/v4/search" -CACHE_TTL = 4 * 3600 # seconds — fits 100 req/day free tier +CACHE_TTL = 6 * 3600 # seconds — ≤9 politics markets × 4 cycles/day = ≤36 req/day +_INTER_REQUEST_SLEEP = 2 # seconds between consecutive real API calls # --------------------------------------------------------------------------- # Keyword lists for headline sentiment @@ -127,6 +131,9 @@ class NewsClient: except Exception as exc: log.warning("GNews network error for %r: %s", query, exc) return 0.0 + finally: + # Always sleep after a real network attempt to avoid burst 429s + await asyncio.sleep(_INTER_REQUEST_SLEEP) log.info("GNews HTTP %d for query %r", resp.status_code, query) diff --git a/bot/main.py b/bot/main.py index 701e7fb..52e250d 100644 --- a/bot/main.py +++ b/bot/main.py @@ -1,11 +1,12 @@ """ Polymarket Trading Bot — Main Entry Point -# ci-test: 2026-04-13 +# ci-test: 2026-04-14 """ import asyncio import logging import os from contextlib import asynccontextmanager +from datetime import datetime, timezone from bot.data.polymarket import PolymarketClient from bot.data.external import ExternalDataClient @@ -42,6 +43,19 @@ async def run_trading_loop( # 1. Fetch active crypto/finance markets markets = await poly.get_active_markets() log.info("Found %d active markets", len(markets)) + + # Sort: politics markets first (soonest-resolving → highest GNews priority), + # then all others. This ensures the 5-query-per-cycle cap hits the most + # time-sensitive political markets before the budget runs out. + def _sort_key(m): + is_pol = m.category == "politics" + try: + dt = datetime.fromisoformat(m.end_date.replace("Z", "+00:00")) + except Exception: + dt = datetime(9999, 12, 31, tzinfo=timezone.utc) + return (0 if is_pol else 1, dt) + + markets = sorted(markets, key=_sort_key) for _m in markets: log.info(" [market] %s | ends: %s | yes_price: %.3f", _m.question, _m.end_date, _m.yes_price) @@ -49,6 +63,9 @@ async def run_trading_loop( # 2. Get external signals ext_data = await external.get_all_signals() + # Reset per-cycle GNews counter so the limit applies fresh each cycle + strategy.reset_cycle() + for market in markets: # 3. Estimate true probability signal = await strategy.evaluate(market, ext_data) diff --git a/bot/strategy/bayesian.py b/bot/strategy/bayesian.py index 56f9cd6..eeacb27 100644 --- a/bot/strategy/bayesian.py +++ b/bot/strategy/bayesian.py @@ -32,6 +32,10 @@ MIN_CONFIDENCE = 0.55 # Minimum confidence in our estimate # which moves a 50% prior to ~18%/82% — strong but not overwhelming. NEWS_LOGODDS_WEIGHT = 1.5 +# 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 + @dataclass class TradingSignal: @@ -62,6 +66,11 @@ class BayesianStrategy: def __init__(self, news: Optional[NewsClient] = None) -> None: self._signal_count = 0 self._news = news # Optional; degrades gracefully when None or key missing + self._news_queries_this_cycle = 0 + + def reset_cycle(self) -> None: + """Call once at the start of each trading cycle to reset the per-cycle GNews counter.""" + self._news_queries_this_cycle = 0 async def evaluate( self, @@ -172,16 +181,26 @@ class BayesianStrategy: adjustments.append(dom_adj) sources.append(f"BTC dom: {ext.btc_dominance:.1f}% (low → alt season)") - # Signal 4: GNews sentiment (politics / tech / events only) + # Signal 4: GNews sentiment — politics markets only. + # BTC/F&G already cover crypto and macro; GNews budget is too tight to + # waste on tech/events. Cap at MAX_NEWS_QUERIES_PER_CYCLE per cycle so + # we prioritise the soonest-resolving markets (caller sorts by end_date). # Applied as a direct log-odds shift — stronger signal than macro proxies. # Weight NEWS_LOGODDS_WEIGHT=1.5 means a ±1.0 sentiment score shifts # log-odds by ±1.5 (e.g. 50% prior → ~82% / ~18%). news_log_adj = 0.0 - if (is_politics or is_tech or is_events) and self._news is not None: - sentiment = await self._news.get_sentiment(market.question) - if abs(sentiment) > 0.05: - news_log_adj = sentiment * NEWS_LOGODDS_WEIGHT - sources.append(f"GNews: {sentiment:+.2f}") + if is_politics and self._news is not None: + 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_log_adj = sentiment * NEWS_LOGODDS_WEIGHT + sources.append(f"GNews: {sentiment:+.2f}") + else: + log.debug( + "GNews cycle limit (%d) reached — skipping news for %r", + MAX_NEWS_QUERIES_PER_CYCLE, market.question[:50], + ) # Macro/politics/tech/events: cap confidence lower to reflect weaker signal quality if is_macro or is_politics or is_tech or is_events: