feat: Implement initial PolyWeather web map API with comprehensive weather data collection, analysis, and Polymarket integration.
This commit is contained in:
@@ -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
|
||||
+30
@@ -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
|
||||
|
||||
|
||||
@@ -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 = '<div class="market-no-data">暂无相关天气合约</div>';
|
||||
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 += `
|
||||
<div class="market-card">
|
||||
<div class="market-question">
|
||||
<a href="${mkt.url}" target="_blank" style="color:var(--text-primary);text-decoration:none;">${mkt.question} 🔗</a>
|
||||
</div>
|
||||
<div class="market-prices">
|
||||
<span class="market-price yes">${probText}</span><span class="market-price-label">Yes</span>
|
||||
<span style="margin: 0 4px; color:var(--text-muted)">|</span>
|
||||
<span class="market-price no">${noText}</span><span class="market-price-label">No</span>
|
||||
</div>
|
||||
<div class="market-volume">
|
||||
交易量: $${mkt.volume ? Math.round(mkt.volume).toLocaleString() : 0}
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 += `
|
||||
<div class="market-divergence ${divClass.replace("slight_", "")}">
|
||||
<div><b>${divIcon} 你的模型:</b> ${Math.round(divSignal.our_prob * 100)}%</div>
|
||||
<div style="margin-top:2px;"><b>🆚 市场定价:</b> ${Math.round(divSignal.market_prob * 100)}%</div>
|
||||
<div style="margin-top:6px;font-weight:600;">💡 信号: ${divText}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderRisk(data) {
|
||||
const container = document.getElementById("riskInfo");
|
||||
const risk = data.risk || {};
|
||||
|
||||
@@ -131,6 +131,14 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Polymarket Odds ── -->
|
||||
<section class="market-section">
|
||||
<h3>📈 市场赔率 <span style="font-size:11px;color:var(--text-muted);font-weight:400;">Polymarket</span></h3>
|
||||
<div id="marketOdds" class="market-odds">
|
||||
<div style="color:var(--text-muted);font-size:13px;">点击城市后加载...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Risk Profile ── -->
|
||||
<section class="risk-section">
|
||||
<h3>⚠️ 数据偏差风险</h3>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user