feat(news): 6h cache, politics-only, max 5/cycle, 2s sleep between calls
CI/CD / build-and-push (push) Successful in 1m32s

- CACHE_TTL: 4h → 6h (≤36 req/day with ≤9 politics markets)
- GNews only called for is_politics markets (BTC/F&G cover crypto/macro)
- MAX_NEWS_QUERIES_PER_CYCLE=5: BayesianStrategy.reset_cycle() called each
  iteration; counter increments only on actual API call (cache hits free)
- 2s asyncio.sleep in news.py finally block after each real HTTP request
- main.py sorts markets: politics first by end_date ascending, so soonest-
  resolving markets consume the 5-query budget before others

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chemavx
2026-04-14 12:33:26 +00:00
parent d642dbd9cf
commit 33ad86f352
3 changed files with 54 additions and 11 deletions
+11 -4
View File
@@ -1,13 +1,16 @@
""" """
News sentiment client for GNews API. News sentiment client for GNews API.
Free tier: 100 requests/day — we stay well within this by caching each Free tier: 100 requests/day. Budget:
unique query for CACHE_TTL seconds (4 hours). With ~9 political markets - Cache TTL: 6 hours — same query is never repeated within 6 h
refreshed every 4 h that is 9 × 6 = 54 requests/day. - 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). 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. Returns 0.0 on any error or missing API key so the caller degrades gracefully.
""" """
import asyncio
import logging import logging
import os import os
import re import re
@@ -18,7 +21,8 @@ import httpx
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
GNEWS_API = "https://gnews.io/api/v4/search" 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 # Keyword lists for headline sentiment
@@ -127,6 +131,9 @@ class NewsClient:
except Exception as exc: except Exception as exc:
log.warning("GNews network error for %r: %s", query, exc) log.warning("GNews network error for %r: %s", query, exc)
return 0.0 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) log.info("GNews HTTP %d for query %r", resp.status_code, query)
+18 -1
View File
@@ -1,11 +1,12 @@
""" """
Polymarket Trading Bot — Main Entry Point Polymarket Trading Bot — Main Entry Point
# ci-test: 2026-04-13 # ci-test: 2026-04-14
""" """
import asyncio import asyncio
import logging import logging
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timezone
from bot.data.polymarket import PolymarketClient from bot.data.polymarket import PolymarketClient
from bot.data.external import ExternalDataClient from bot.data.external import ExternalDataClient
@@ -42,6 +43,19 @@ async def run_trading_loop(
# 1. Fetch active crypto/finance markets # 1. Fetch active crypto/finance markets
markets = await poly.get_active_markets() markets = await poly.get_active_markets()
log.info("Found %d active markets", len(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: for _m in markets:
log.info(" [market] %s | ends: %s | yes_price: %.3f", log.info(" [market] %s | ends: %s | yes_price: %.3f",
_m.question, _m.end_date, _m.yes_price) _m.question, _m.end_date, _m.yes_price)
@@ -49,6 +63,9 @@ async def run_trading_loop(
# 2. Get external signals # 2. Get external signals
ext_data = await external.get_all_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: for market in markets:
# 3. Estimate true probability # 3. Estimate true probability
signal = await strategy.evaluate(market, ext_data) signal = await strategy.evaluate(market, ext_data)
+21 -2
View File
@@ -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. # which moves a 50% prior to ~18%/82% — strong but not overwhelming.
NEWS_LOGODDS_WEIGHT = 1.5 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 @dataclass
class TradingSignal: class TradingSignal:
@@ -62,6 +66,11 @@ class BayesianStrategy:
def __init__(self, news: Optional[NewsClient] = None) -> None: def __init__(self, news: Optional[NewsClient] = None) -> None:
self._signal_count = 0 self._signal_count = 0
self._news = news # Optional; degrades gracefully when None or key missing 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( async def evaluate(
self, self,
@@ -172,16 +181,26 @@ class BayesianStrategy:
adjustments.append(dom_adj) adjustments.append(dom_adj)
sources.append(f"BTC dom: {ext.btc_dominance:.1f}% (low → alt season)") 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. # 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 # Weight NEWS_LOGODDS_WEIGHT=1.5 means a ±1.0 sentiment score shifts
# log-odds by ±1.5 (e.g. 50% prior → ~82% / ~18%). # log-odds by ±1.5 (e.g. 50% prior → ~82% / ~18%).
news_log_adj = 0.0 news_log_adj = 0.0
if (is_politics or is_tech or is_events) and self._news is not None: 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) sentiment = await self._news.get_sentiment(market.question)
if abs(sentiment) > 0.05: if abs(sentiment) > 0.05:
news_log_adj = sentiment * NEWS_LOGODDS_WEIGHT news_log_adj = sentiment * NEWS_LOGODDS_WEIGHT
sources.append(f"GNews: {sentiment:+.2f}") 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 # Macro/politics/tech/events: cap confidence lower to reflect weaker signal quality
if is_macro or is_politics or is_tech or is_events: if is_macro or is_politics or is_tech or is_events: