Files
polymarket-bot/bot/strategy/bayesian.py
T
chemavx 4dadd3c2c4
CI/CD / build-and-push (push) Successful in 1m28s
feat: add GNews sentiment signal for politics/tech/events markets
bot/data/news.py (new):
- NewsClient with in-memory cache (TTL=4h) to stay within 100 req/day limit
- _build_query(): strips dates, punctuation and stopwords from market question
- _score_headlines(): keyword-based pos/neg vote per article, averaged ∈ [-1, +1]
- Degrades to 0.0 on missing key, 403 quota, or network error

bot/strategy/bayesian.py:
- BayesianStrategy(news=NewsClient) — optional, backwards compatible
- Signal 4: GNews sentiment applied as direct log-odds shift (weight=1.5)
  so a ±1.0 sentiment score moves a 50% prior to 82%/18%
- +0.10 confidence boost when news signal is present
- NEWS_LOGODDS_WEIGHT constant documented at module level

bot/main.py:
- Instantiate NewsClient, pass to BayesianStrategy, close in finally block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 08:24:11 +00:00

265 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Bayesian Market Making Strategy.
Core idea:
1. Compute a prior probability for a market outcome using external data
2. Compare with Polymarket's current price
3. If divergence > threshold + confidence is high enough → generate signal
For crypto markets: if BTC is up 5% and fear/greed is 75 (greed),
a market asking "Will BTC be above $X?" should be priced higher than
Polymarket might reflect in a slow-moving order book.
"""
import logging
import math
from dataclasses import dataclass
from typing import Optional
from bot.data.polymarket import Market
from bot.data.external import ExternalSignals
from bot.data.news import NewsClient
log = logging.getLogger(__name__)
# Minimum edge required to place a trade.
# With an informed prior (poly price), 10% means our signals strongly disagree
# with the market — much higher bar than before, but necessary to avoid noise.
MIN_EDGE = 0.10 # 10% edge minimum
MIN_CONFIDENCE = 0.55 # Minimum confidence in our estimate
# Log-odds weight applied to the GNews sentiment score (range ±1.0).
# A weight of 1.5 means a fully negative/positive signal shifts log-odds by ±1.5,
# which moves a 50% prior to ~18%/82% — strong but not overwhelming.
NEWS_LOGODDS_WEIGHT = 1.5
@dataclass
class TradingSignal:
market_id: str
question: str
polymarket_price: float # Current market price for YES (0-1)
estimated_prob: float # Our Bayesian estimate (0-1)
edge: float # estimated_prob - polymarket_price
confidence: float # How confident we are (0-1)
direction: str # "BUY_YES" | "BUY_NO"
reasoning: str # Human-readable explanation for logging
sources: list[str] # Data sources used
class BayesianStrategy:
"""
Estimates true probability using external signals and Bayesian updating.
Prior: Polymarket's current YES price (market consensus — not 0.5)
Likelihood updates from:
- BTC/ETH price momentum
- Fear & Greed index
- Market cap trend / BTC dominance
We only bet when our signals move the estimate far enough from the prior
to justify the fee + slippage cost (MIN_EDGE).
"""
def __init__(self, news: Optional[NewsClient] = None) -> None:
self._signal_count = 0
self._news = news # Optional; degrades gracefully when None or key missing
async def evaluate(
self,
market: Market,
ext: ExternalSignals,
) -> Optional[TradingSignal]:
"""
Evaluate a market and return a signal if edge exists.
Returns None if no actionable opportunity.
"""
question_lower = market.question.lower()
category = market.category # set by PolymarketClient
# Classify what kind of market this is
is_price_above = any(w in question_lower for w in ["above", "over", "exceed", "higher", "atleast", "reach"])
is_price_below = any(w in question_lower for w in ["below", "under", "less than", "lower", "drop"])
is_btc = "btc" in question_lower or "bitcoin" in question_lower
is_eth = "eth" in question_lower or "ethereum" in question_lower
is_sol = "sol" in question_lower or "solana" in question_lower
is_xrp = "xrp" in question_lower or "ripple" in question_lower
is_doge = "doge" in question_lower or "dogecoin" in question_lower
is_altcoin = is_sol or is_xrp or is_doge or any(
w in question_lower for w in ["ltc", "litecoin", "bnb", "ada", "cardano", "avax", "avalanche"]
)
is_general_crypto = any(
w in question_lower for w in ["crypto", "market cap", "total market", "altcoin", "defi"]
)
is_macro = any(
w in question_lower for w in ["nasdaq", "s&p", "sp500", "inflation", "fed rate", "interest rate", "tariff"]
)
is_politics = category == "politics"
is_tech = category == "tech"
is_events = category == "events"
is_any_supported = (
is_btc or is_eth or is_altcoin or is_general_crypto or is_macro
or is_politics or is_tech or is_events
)
if not is_any_supported:
log.info(
"SKIP %-50s | reason=unsupported category=%r",
market.question[:50], category,
)
return None
if not ext.valid:
log.info(
"SKIP %-50s | reason=no external signals",
market.question[:50],
)
return None # Can't reason without external data
# --- Bayesian probability estimation ---
# Prior = Polymarket consensus price, clamped away from extremes.
# The market already aggregates information from many traders;
# our signals update from that informed baseline, not from 0.5.
prior = max(0.05, min(0.95, market.yes_price))
sources: list[str] = [f"Prior=poly({prior:.3f})"]
adjustments: list[float] = []
# Signal 1: Price momentum (asset-specific or total market cap as proxy)
# For politics/tech/events use BTC as a broad 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:
# BTC as risk-sentiment proxy for non-crypto categories
momentum = ext.btc_change_24h
asset_label = "BTC(sentiment)"
else:
# Altcoins and general crypto: use total market cap change as proxy
momentum = ext.total_market_cap_change
asset_label = "total mktcap"
if abs(momentum) > 2:
momentum_adj = math.tanh(momentum / 20) * 0.15 # Max ±15%
# For non-directional markets (politics/events/tech), momentum is weaker signal
if is_politics or is_tech or is_events:
momentum_adj *= 0.5
adjustments.append(momentum_adj if is_price_above else -momentum_adj)
sources.append(f"{asset_label} 24h: {momentum:+.1f}%")
# 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)")
adjustments.append(fg_adj if is_price_above else -fg_adj)
# Signal 3: BTC dominance — hurts altcoins when high
if (is_eth or is_altcoin or is_general_crypto) and ext.btc_dominance > 55:
dom_adj = -0.03 if is_price_above else 0.03
adjustments.append(dom_adj)
sources.append(f"BTC dom: {ext.btc_dominance:.1f}% (high → alt pressure)")
elif (is_eth or is_altcoin or is_general_crypto) and ext.btc_dominance < 45:
dom_adj = 0.03 if is_price_above else -0.03
adjustments.append(dom_adj)
sources.append(f"BTC dom: {ext.btc_dominance:.1f}% (low → alt season)")
# Signal 4: GNews sentiment (politics / tech / events only)
# 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}")
# Macro/politics/tech/events: cap confidence lower to reflect weaker signal quality
if is_macro or is_politics or is_tech or is_events:
confidence_cap = 0.65
else:
confidence_cap = 0.90
# Compute posterior using log-odds updating.
# total_adj (BTC/F&G/dominance) is amplified ×2 because those are weak proxies.
# news_log_adj is applied at face value — it IS a direct log-odds signal.
log_odds_prior = math.log(prior / (1 - prior))
total_adj = sum(adjustments)
estimated_prob = _sigmoid(log_odds_prior + total_adj * 2 + news_log_adj)
estimated_prob = max(0.05, min(0.95, estimated_prob))
# Compute edge
edge = estimated_prob - market.yes_price
direction = "BUY_YES" if edge > 0 else "BUY_NO"
abs_edge = abs(edge)
# Confidence based on signal agreement
agreement = sum(1 for a in adjustments if (a > 0) == (total_adj > 0))
confidence = min(confidence_cap, 0.4 + (agreement / max(len(adjustments), 1)) * 0.5)
# News signal available → boost confidence by 0.10 (news corroborates macro signals)
if news_log_adj != 0.0:
confidence = min(confidence_cap, confidence + 0.10)
# Log evaluation result for every market
action = "TRADE" if (abs_edge >= MIN_EDGE and confidence >= MIN_CONFIDENCE) else "SKIP"
skip_reason = ""
if action == "SKIP":
reasons = []
if abs_edge < MIN_EDGE:
reasons.append(f"edge={abs_edge:.3f}<{MIN_EDGE}")
if confidence < MIN_CONFIDENCE:
reasons.append(f"conf={confidence:.2f}<{MIN_CONFIDENCE}")
skip_reason = " | reason=" + ",".join(reasons)
log.info(
"%-5s %-50s | cat=%-12s | prior=%.3f | est=%.3f | edge=%+.3f | conf=%.2f | dir=%-8s | signals=%s%s",
action,
market.question[:50],
category,
prior,
estimated_prob,
edge,
confidence,
direction,
", ".join(sources[1:]) or "none",
skip_reason,
)
# Filter: only trade if edge and confidence thresholds met
if abs_edge < MIN_EDGE or confidence < MIN_CONFIDENCE:
return None
reasoning = (
f"Prior=poly({prior:.3f}) → estimate={estimated_prob:.3f} | "
f"Poly price={market.yes_price:.3f} | "
f"Edge={edge:+.3f} | "
f"Direction={direction} | "
f"Signals: {', '.join(sources[1:])}" # skip the prior label already shown
)
self._signal_count += 1
return TradingSignal(
market_id=market.id,
question=market.question,
polymarket_price=market.yes_price,
estimated_prob=estimated_prob,
edge=abs_edge,
confidence=confidence,
direction=direction,
reasoning=reasoning,
sources=sources,
)
def _sigmoid(x: float) -> float:
return 1 / (1 + math.exp(-x))