diff --git a/src/data_collection/polymarket_client.py b/src/data_collection/polymarket_client.py new file mode 100644 index 00000000..80259b72 --- /dev/null +++ b/src/data_collection/polymarket_client.py @@ -0,0 +1,292 @@ +""" +Polymarket Weather Market Client +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Fetches real-time odds from Polymarket's Gamma API for weather contracts. +Used by the web dashboard only (not the Telegram bot). +""" + +import re +import time +import logging +import requests +from typing import Dict, List, Optional, Any +from datetime import datetime, timedelta + +logger = logging.getLogger(__name__) + +GAMMA_API = "https://gamma-api.polymarket.com" + +# Map our city names → Polymarket contract keywords +CITY_KEYWORDS = { + "ankara": ["ankara", "Ankara"], + "london": ["london", "London"], + "paris": ["paris", "Paris"], + "seoul": ["seoul", "Seoul"], + "toronto": ["toronto", "Toronto"], + "buenos aires": ["buenos aires", "Buenos Aires"], + "wellington": ["wellington", "Wellington"], + "new york": ["new york", "New York", "NYC"], + "chicago": ["chicago", "Chicago"], + "dallas": ["dallas", "Dallas"], + "miami": ["miami", "Miami"], + "atlanta": ["atlanta", "Atlanta"], + "seattle": ["seattle", "Seattle"], +} + +# In-memory cache: {city: {date: data, ...}} +_market_cache: Dict[str, Any] = {} +_cache_ts: float = 0 +CACHE_TTL = 300 # 5 minutes + + +def _parse_threshold_from_question(question: str) -> Optional[dict]: + """ + Parse a Polymarket weather question to extract city, threshold, and date. + + Examples: + "Will the high temperature in Ankara exceed 8°C on March 5?" + "Highest temperature in London on March 4?" + "Will the high in New York City exceed 45°F on March 5, 2026?" + """ + # Pattern 1: "exceed X°F/°C" + m = re.search( + r"exceed\s+([\d.]+)\s*°\s*([FC])", question, re.IGNORECASE + ) + if m: + value = float(m.group(1)) + unit = m.group(2).upper() + return {"threshold": value, "unit": unit, "type": "exceed"} + + # Pattern 2: "Highest temperature in City on Date?" (multi-outcome) + m = re.search(r"[Hh]ighest\s+temperature", question) + if m: + return {"type": "range"} + + return None + + +def _match_city(question: str) -> Optional[str]: + """Match a Polymarket question to one of our tracked cities.""" + q_lower = question.lower() + for city, keywords in CITY_KEYWORDS.items(): + for kw in keywords: + if kw.lower() in q_lower: + return city + return None + + +def _parse_date_from_question(question: str) -> Optional[str]: + """Extract date from question, return as YYYY-MM-DD.""" + # "on March 5, 2026" or "on March 5" + m = re.search( + r"on\s+(\w+)\s+(\d{1,2})(?:,?\s*(\d{4}))?", question, re.IGNORECASE + ) + if m: + month_str, day_str, year_str = m.group(1), m.group(2), m.group(3) + month_map = { + "january": 1, "february": 2, "march": 3, "april": 4, + "may": 5, "june": 6, "july": 7, "august": 8, + "september": 9, "october": 10, "november": 11, "december": 12, + } + month = month_map.get(month_str.lower()) + if month: + year = int(year_str) if year_str else datetime.now().year + return f"{year}-{month:02d}-{int(day_str):02d}" + return None + + +def fetch_weather_markets( + proxy: Optional[str] = None, timeout: int = 15 +) -> List[Dict]: + """ + Fetch all active weather markets from Polymarket. + + Returns a list of dicts, each representing a market with: + - question, city, date, odds, volume, etc. + """ + global _market_cache, _cache_ts + + if time.time() - _cache_ts < CACHE_TTL and _market_cache: + return _market_cache.get("_all", []) + + try: + session = requests.Session() + if proxy: + if not proxy.startswith("http"): + proxy = f"http://{proxy}" + session.proxies = {"http": proxy, "https": proxy} + + # Fetch weather-tagged events + resp = session.get( + f"{GAMMA_API}/events", + params={ + "tag": "weather", + "active": "true", + "closed": "false", + "limit": 50, + }, + timeout=timeout, + headers={"Accept": "application/json"}, + ) + resp.raise_for_status() + events = resp.json() + + all_markets = [] + for event in events: + markets = event.get("markets", []) + event_title = event.get("title", "") + + for mkt in markets: + question = mkt.get("question", event_title) + city = _match_city(question) + if not city: + continue + + date_str = _parse_date_from_question(question) + parsed = _parse_threshold_from_question(question) + + # Extract outcome prices + outcome_prices = mkt.get("outcomePrices", "") + outcomes = mkt.get("outcomes", "") + yes_price = None + no_price = None + + try: + if isinstance(outcome_prices, str) and outcome_prices: + import json + prices = json.loads(outcome_prices) + if len(prices) >= 2: + yes_price = float(prices[0]) + no_price = float(prices[1]) + elif isinstance(outcome_prices, list) and len(outcome_prices) >= 2: + yes_price = float(outcome_prices[0]) + no_price = float(outcome_prices[1]) + except Exception: + pass + + market_info = { + "id": mkt.get("id"), + "question": question, + "city": city, + "date": date_str, + "threshold": parsed.get("threshold") if parsed else None, + "threshold_unit": parsed.get("unit") if parsed else None, + "contract_type": parsed.get("type", "unknown") if parsed else "unknown", + "yes_price": yes_price, # 0.00-1.00 = market probability + "no_price": no_price, + "volume": mkt.get("volume"), + "liquidity": mkt.get("liquidityNum"), + "slug": mkt.get("slug", ""), + "url": f"https://polymarket.com/event/{event.get('slug', '')}", + } + all_markets.append(market_info) + + # Organize by city + _market_cache = {"_all": all_markets} + for m in all_markets: + c = m["city"] + if c not in _market_cache: + _market_cache[c] = [] + _market_cache[c].append(m) + + _cache_ts = time.time() + logger.info(f"📊 Polymarket: 获取 {len(all_markets)} 个天气合约") + return all_markets + + except Exception as e: + logger.warning(f"Polymarket API 请求失败: {e}") + return _market_cache.get("_all", []) + + +def get_city_markets(city: str, target_date: Optional[str] = None) -> List[Dict]: + """ + Get Polymarket contracts for a specific city. + + Args: + city: City name (lowercase) + target_date: Optional date filter (YYYY-MM-DD) + + Returns: + List of market dicts for this city, sorted by volume desc. + """ + # Ensure markets are fetched + if not _market_cache or time.time() - _cache_ts >= CACHE_TTL: + fetch_weather_markets() + + markets = _market_cache.get(city, []) + if target_date: + markets = [m for m in markets if m.get("date") == target_date] + + # Sort by volume (descending) + markets.sort(key=lambda m: float(m.get("volume") or 0), reverse=True) + return markets + + +def compute_divergence( + city_markets: List[Dict], + prob_distribution: List[Dict], + temp_symbol: str = "°C", + use_fahrenheit: bool = False, +) -> List[Dict]: + """ + Compare our probability engine output with Polymarket odds. + + Args: + city_markets: Markets from get_city_markets() + prob_distribution: Our engine's [{value, probability}, ...] + temp_symbol: "°C" or "°F" + use_fahrenheit: Whether our data is in Fahrenheit + + Returns: + List of divergence signals: + [{threshold, our_prob, market_prob, divergence, signal}, ...] + """ + signals = [] + + for mkt in city_markets: + if mkt.get("contract_type") != "exceed" or mkt.get("yes_price") is None: + continue + + threshold = mkt.get("threshold") + mkt_unit = mkt.get("threshold_unit", "F") + if threshold is None: + continue + + # Convert threshold to match our unit + if mkt_unit == "F" and not use_fahrenheit: + threshold_c = (threshold - 32) * 5 / 9 + elif mkt_unit == "C" and use_fahrenheit: + threshold_c = threshold # keep as-is, our data is F + else: + threshold_c = threshold + + # Calculate our probability of exceeding this threshold + # Sum probabilities for all values >= threshold (rounded) + threshold_wu = round(threshold_c) + our_exceed_prob = 0.0 + for p in prob_distribution: + if p.get("value", 0) >= threshold_wu: + our_exceed_prob += p.get("probability", 0) + + market_prob = mkt["yes_price"] + divergence = our_exceed_prob - market_prob + + signal = "neutral" + if abs(divergence) > 0.10: + signal = "underpriced" if divergence > 0 else "overpriced" + elif abs(divergence) > 0.05: + signal = "slight_under" if divergence > 0 else "slight_over" + + signals.append({ + "question": mkt["question"], + "threshold": threshold, + "threshold_unit": mkt_unit, + "our_prob": round(our_exceed_prob, 3), + "market_prob": round(market_prob, 3), + "divergence": round(divergence, 3), + "signal": signal, + "volume": mkt.get("volume"), + "url": mkt.get("url", ""), + }) + + return signals diff --git a/web/app.py b/web/app.py index d62b5ef0..a8a78609 100644 --- a/web/app.py +++ b/web/app.py @@ -26,6 +26,11 @@ from src.utils.config_loader import load_config from src.data_collection.weather_sources import WeatherDataCollector from src.data_collection.city_risk_profiles import CITY_RISK_PROFILES from src.analysis.deb_algorithm import calculate_dynamic_weights, get_deb_accuracy +from src.data_collection.polymarket_client import ( + fetch_weather_markets, + get_city_markets, + compute_divergence, +) # ────────────────────────────────────────────────────────── # Setup @@ -453,6 +458,31 @@ def _analyze(city: str) -> Dict[str, Any]: "updated_at": datetime.now(timezone.utc).isoformat(), } + # ── 16. Polymarket odds (web-only, non-blocking) ── + try: + proxy = _config.get("proxy") + fetch_weather_markets(proxy=proxy, timeout=10) + city_mkts = get_city_markets(city, target_date=local_date_str) + if city_mkts: + result["polymarket"] = { + "markets": [ + { + "question": m["question"], + "yes_price": m["yes_price"], + "volume": m.get("volume"), + "threshold": m.get("threshold"), + "threshold_unit": m.get("threshold_unit"), + "url": m.get("url", ""), + } + for m in city_mkts[:5] + ], + "divergence": compute_divergence( + city_mkts, probabilities, sym, use_fahrenheit=info.get("f", False) + ), + } + except Exception as e: + logger.debug(f"Polymarket data skipped for {city}: {e}") + _cache[city] = {"t": _time.time(), "d": result} return result diff --git a/web/static/app.js b/web/static/app.js index aab878b6..f635c347 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -326,6 +326,8 @@ function renderPanel(data) { renderForecast(data); // AI renderAI(data); + // Market Odds + renderMarket(data); // Risk renderRisk(data); } @@ -824,6 +826,75 @@ function renderAI(data) { container.innerHTML = text; } +function renderMarket(data) { + const container = document.getElementById("marketOdds"); + if ( + !data.polymarket || + !data.polymarket.markets || + data.polymarket.markets.length === 0 + ) { + container.innerHTML = '
暂无相关天气合约
'; + return; + } + + const { markets, divergence } = data.polymarket; + let html = ""; + + markets.forEach((mkt) => { + // Find matching divergence signal + const divSignal = divergence.find((d) => d.question === mkt.question); + + let probText = + mkt.yes_price != null ? `${Math.round(mkt.yes_price * 100)}¢` : "N/A"; + let noText = + mkt.yes_price != null + ? `${Math.round((1 - mkt.yes_price) * 100)}¢` + : "N/A"; + + html += ` +
+
+ ${mkt.question} 🔗 +
+
+ ${probText}Yes + | + ${noText}No +
+
+ 交易量: $${mkt.volume ? Math.round(mkt.volume).toLocaleString() : 0} +
+ `; + + if (divSignal) { + let divClass = divSignal.signal; + let divIcon = "⚪"; + let divText = "市场定价合理"; + let diffPct = (Math.abs(divSignal.divergence) * 100).toFixed(1); + + if (divClass === "underpriced" || divClass === "slight_under") { + divIcon = "🟢"; + divText = `低估 (偏离 +${diffPct}%)`; + } else if (divClass === "overpriced" || divClass === "slight_over") { + divIcon = "🔴"; + divText = `高估 (偏离 -${diffPct}%)`; + } + + html += ` +
+
${divIcon} 你的模型: ${Math.round(divSignal.our_prob * 100)}%
+
🆚 市场定价: ${Math.round(divSignal.market_prob * 100)}%
+
💡 信号: ${divText}
+
+ `; + } + + html += `
`; + }); + + container.innerHTML = html; +} + function renderRisk(data) { const container = document.getElementById("riskInfo"); const risk = data.risk || {}; diff --git a/web/static/index.html b/web/static/index.html index 0aaaf5b6..3c35fca0 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -131,6 +131,14 @@ + +
+

📈 市场赔率 Polymarket

+
+
点击城市后加载...
+
+
+

⚠️ 数据偏差风险

diff --git a/web/static/style.css b/web/static/style.css index a79470ac..85658a0e 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -804,6 +804,95 @@ body { font-style: italic; } +/* ── Market Odds Section ── */ +.market-odds { + display: flex; + flex-direction: column; + gap: 10px; +} + +.market-card { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border-subtle); + border-radius: 10px; + padding: 12px 14px; + transition: var(--transition); +} + +.market-card:hover { + border-color: var(--border-glass); + background: rgba(255, 255, 255, 0.05); +} + +.market-question { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 8px; + line-height: 1.4; +} + +.market-prices { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 6px; +} + +.market-price { + font-size: 20px; + font-weight: 700; + font-family: "Inter", sans-serif; +} + +.market-price.yes { + color: var(--accent-green); +} +.market-price.no { + color: #f87171; +} +.market-price-label { + font-size: 11px; + color: var(--text-muted); + margin-left: 2px; +} + +.market-volume { + font-size: 11px; + color: var(--text-muted); +} + +.market-divergence { + margin-top: 10px; + padding: 10px 12px; + border-radius: 8px; + font-size: 12px; + line-height: 1.6; +} + +.market-divergence.underpriced { + background: rgba(52, 211, 153, 0.08); + border: 1px solid rgba(52, 211, 153, 0.2); + color: var(--accent-green); +} + +.market-divergence.overpriced { + background: rgba(248, 113, 113, 0.08); + border: 1px solid rgba(248, 113, 113, 0.2); + color: #f87171; +} + +.market-divergence.neutral { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border-subtle); + color: var(--text-muted); +} + +.market-no-data { + font-size: 13px; + color: var(--text-muted); + padding: 8px 0; +} + /* ── Risk Section ── */ .risk-info { font-size: 12px;