feat: Implement initial PolyWeather application with interactive map UI, backend API, and Polymarket data client.
This commit is contained in:
@@ -1,274 +1,649 @@
|
||||
"""
|
||||
"""
|
||||
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).
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Market discovery + orderbook snapshot + anomaly detection for weather markets.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
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"
|
||||
CLOB_API = "https://clob.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"],
|
||||
"ankara": ["ankara"],
|
||||
"london": ["london"],
|
||||
"paris": ["paris"],
|
||||
"seoul": ["seoul"],
|
||||
"toronto": ["toronto"],
|
||||
"buenos aires": ["buenos aires"],
|
||||
"wellington": ["wellington"],
|
||||
"new york": ["new york", "nyc", "new york city"],
|
||||
"chicago": ["chicago"],
|
||||
"dallas": ["dallas"],
|
||||
"miami": ["miami"],
|
||||
"atlanta": ["atlanta"],
|
||||
"seattle": ["seattle"],
|
||||
}
|
||||
|
||||
# In-memory cache: {city: {date: data, ...}}
|
||||
CACHE_TTL_MARKETS = 300
|
||||
CACHE_TTL_BOOKS = 20
|
||||
SNAPSHOT_RETENTION_SEC = 48 * 3600
|
||||
|
||||
_market_cache: Dict[str, Any] = {}
|
||||
_cache_ts: float = 0
|
||||
CACHE_TTL = 300 # 5 minutes
|
||||
_market_cache_ts: float = 0.0
|
||||
_book_cache: Dict[str, Dict[str, Any]] = {}
|
||||
_book_cache_ts: Dict[str, float] = {}
|
||||
_prev_snapshots: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _parse_threshold_from_question(question: str) -> Optional[dict]:
|
||||
"""
|
||||
Parse a Polymarket weather question to extract city, threshold, and date.
|
||||
def _build_session(proxy: Optional[str] = None) -> requests.Session:
|
||||
"""Build a requests session with optional explicit proxy."""
|
||||
session = requests.Session()
|
||||
# Disable implicit system/environment proxies for deterministic behavior.
|
||||
session.trust_env = False
|
||||
|
||||
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 proxy:
|
||||
if not proxy.startswith("http"):
|
||||
proxy = f"http://{proxy}"
|
||||
session.proxies = {"http": proxy, "https": proxy}
|
||||
|
||||
return session
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> Optional[float]:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_json_list(v: Any) -> List[Any]:
|
||||
"""Parse value into list. Gamma often returns JSON-encoded strings."""
|
||||
if isinstance(v, list):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
if not s:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(s)
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def _parse_threshold_from_question(question: str) -> Optional[Dict[str, Any]]:
|
||||
"""Extract simple threshold contracts like: exceed 45°F/7°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"}
|
||||
return {
|
||||
"threshold": float(m.group(1)),
|
||||
"unit": m.group(2).upper(),
|
||||
"type": "exceed",
|
||||
}
|
||||
|
||||
# Pattern 2: "Highest temperature in City on Date?" (multi-outcome)
|
||||
m = re.search(r"[Hh]ighest\s+temperature", question)
|
||||
if m:
|
||||
if re.search(r"highest\s+temperature", question, re.IGNORECASE):
|
||||
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
|
||||
def _match_city(text: str) -> Optional[str]:
|
||||
text_l = (text or "").lower()
|
||||
for city, aliases in CITY_KEYWORDS.items():
|
||||
if any(alias in text_l for alias in aliases):
|
||||
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 _parse_date_from_question(text: str) -> Optional[str]:
|
||||
"""Extract date from market question, return YYYY-MM-DD."""
|
||||
m = re.search(r"on\s+(\w+)\s+(\d{1,2})(?:,?\s*(\d{4}))?", text, re.IGNORECASE)
|
||||
if not m:
|
||||
return None
|
||||
|
||||
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(m.group(1).lower())
|
||||
if month is None:
|
||||
return None
|
||||
|
||||
day = int(m.group(2))
|
||||
year = int(m.group(3)) if m.group(3) else datetime.utcnow().year
|
||||
return f"{year:04d}-{month:02d}-{day:02d}"
|
||||
|
||||
|
||||
def _parse_iso_date(dt: Optional[str]) -> Optional[str]:
|
||||
if not dt:
|
||||
return None
|
||||
try:
|
||||
return dt[:10]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _sort_by_volume(markets: List[Dict[str, Any]]) -> None:
|
||||
markets.sort(key=lambda x: _safe_float(x.get("volume")) or 0.0, reverse=True)
|
||||
|
||||
|
||||
def _cleanup_old_snapshots(now_ts: float) -> None:
|
||||
stale = [
|
||||
token for token, rec in _prev_snapshots.items()
|
||||
if now_ts - (_safe_float(rec.get("ts")) or 0.0) > SNAPSHOT_RETENTION_SEC
|
||||
]
|
||||
for token in stale:
|
||||
_prev_snapshots.pop(token, None)
|
||||
|
||||
|
||||
def fetch_weather_markets(
|
||||
proxy: Optional[str] = None, timeout: int = 15
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Fetch all active weather markets from Polymarket.
|
||||
proxy: Optional[str] = None,
|
||||
timeout: int = 15,
|
||||
force_refresh: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Fetch active weather markets and normalize outcome/token metadata."""
|
||||
global _market_cache, _market_cache_ts
|
||||
|
||||
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:
|
||||
now_ts = time.time()
|
||||
if (
|
||||
not force_refresh
|
||||
and _market_cache
|
||||
and now_ts - _market_cache_ts < CACHE_TTL_MARKETS
|
||||
):
|
||||
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}
|
||||
session = _build_session(proxy)
|
||||
|
||||
# Fetch weather-tagged events
|
||||
try:
|
||||
resp = session.get(
|
||||
f"{GAMMA_API}/events",
|
||||
params={
|
||||
"tag": "weather",
|
||||
"active": "true",
|
||||
"closed": "false",
|
||||
"limit": 50,
|
||||
"limit": 200,
|
||||
},
|
||||
timeout=timeout,
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
events = resp.json()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Polymarket fetch_weather_markets failed: {exc}")
|
||||
return _market_cache.get("_all", [])
|
||||
|
||||
all_markets = []
|
||||
for event in events:
|
||||
markets = event.get("markets", [])
|
||||
event_title = event.get("title", "")
|
||||
all_markets: List[Dict[str, Any]] = []
|
||||
|
||||
for mkt in markets:
|
||||
question = mkt.get("question", event_title)
|
||||
city = _match_city(question)
|
||||
if not city:
|
||||
continue
|
||||
for event in events:
|
||||
event_title = event.get("title", "")
|
||||
event_slug = event.get("slug", "")
|
||||
event_end_date = _parse_iso_date(event.get("endDate"))
|
||||
|
||||
date_str = _parse_date_from_question(question)
|
||||
parsed = _parse_threshold_from_question(question)
|
||||
for mkt in event.get("markets", []) or []:
|
||||
question = mkt.get("question") or event_title
|
||||
city = _match_city(question) or _match_city(event_title)
|
||||
if not city:
|
||||
continue
|
||||
|
||||
# Extract outcome prices
|
||||
outcome_prices = mkt.get("outcomePrices", "")
|
||||
outcomes = mkt.get("outcomes", "")
|
||||
yes_price = None
|
||||
no_price = None
|
||||
target_date = (
|
||||
_parse_date_from_question(question)
|
||||
or _parse_date_from_question(event_title)
|
||||
or _parse_iso_date(mkt.get("endDate"))
|
||||
or event_end_date
|
||||
)
|
||||
|
||||
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
|
||||
parsed = _parse_threshold_from_question(question)
|
||||
outcomes = [str(x) for x in _parse_json_list(mkt.get("outcomes"))]
|
||||
outcome_prices = [
|
||||
_safe_float(x) for x in _parse_json_list(mkt.get("outcomePrices"))
|
||||
]
|
||||
token_ids = [str(x) for x in _parse_json_list(mkt.get("clobTokenIds"))]
|
||||
|
||||
market_info = {
|
||||
outcome_rows: List[Dict[str, Any]] = []
|
||||
for idx, name in enumerate(outcomes):
|
||||
outcome_rows.append(
|
||||
{
|
||||
"name": name,
|
||||
"token_id": token_ids[idx] if idx < len(token_ids) else None,
|
||||
"last_price": (
|
||||
outcome_prices[idx] if idx < len(outcome_prices) else None
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
yes_price = None
|
||||
no_price = None
|
||||
for row in outcome_rows:
|
||||
name_l = row["name"].strip().lower()
|
||||
if name_l == "yes":
|
||||
yes_price = row.get("last_price")
|
||||
elif name_l == "no":
|
||||
no_price = row.get("last_price")
|
||||
|
||||
all_markets.append(
|
||||
{
|
||||
"id": mkt.get("id"),
|
||||
"question": question,
|
||||
"city": city,
|
||||
"date": date_str,
|
||||
"date": target_date,
|
||||
"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
|
||||
"contract_type": (
|
||||
parsed.get("type", "unknown") if parsed else "unknown"
|
||||
),
|
||||
"yes_price": yes_price,
|
||||
"no_price": no_price,
|
||||
"volume": mkt.get("volume"),
|
||||
"liquidity": mkt.get("liquidityNum"),
|
||||
"volume": _safe_float(mkt.get("volume")),
|
||||
"liquidity": _safe_float(mkt.get("liquidityNum") or mkt.get("liquidity")),
|
||||
"slug": mkt.get("slug", ""),
|
||||
"url": f"https://polymarket.com/event/{event.get('slug', '')}",
|
||||
"event_slug": event_slug,
|
||||
"url": f"https://polymarket.com/event/{event_slug}" if event_slug else None,
|
||||
"outcomes": outcome_rows,
|
||||
"enable_order_book": bool(mkt.get("enableOrderBook", True)),
|
||||
}
|
||||
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)
|
||||
by_city: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for m in all_markets:
|
||||
by_city.setdefault(m["city"], []).append(m)
|
||||
|
||||
_cache_ts = time.time()
|
||||
logger.info(f"📊 Polymarket: 获取 {len(all_markets)} 个天气合约")
|
||||
return all_markets
|
||||
for city in by_city:
|
||||
_sort_by_volume(by_city[city])
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Polymarket API 请求失败: {e}")
|
||||
return _market_cache.get("_all", [])
|
||||
_sort_by_volume(all_markets)
|
||||
_market_cache = {"_all": all_markets, **by_city}
|
||||
_market_cache_ts = now_ts
|
||||
logger.info(f"Polymarket fetched {len(all_markets)} weather markets")
|
||||
return all_markets
|
||||
|
||||
|
||||
def get_city_markets(city: str, target_date: Optional[str] = None) -> List[Dict]:
|
||||
"""
|
||||
Get Polymarket contracts for a specific city.
|
||||
def get_city_markets(
|
||||
city: str,
|
||||
target_date: Optional[str] = None,
|
||||
proxy: Optional[str] = None,
|
||||
timeout: int = 15,
|
||||
force_refresh: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get city markets, optionally filtered by YYYY-MM-DD target date."""
|
||||
if not _market_cache or force_refresh or (time.time() - _market_cache_ts >= CACHE_TTL_MARKETS):
|
||||
fetch_weather_markets(proxy=proxy, timeout=timeout, force_refresh=force_refresh)
|
||||
|
||||
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, [])
|
||||
rows = list(_market_cache.get(city, []))
|
||||
if target_date:
|
||||
markets = [m for m in markets if m.get("date") == target_date]
|
||||
rows = [m for m in rows 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
|
||||
_sort_by_volume(rows)
|
||||
return rows
|
||||
|
||||
|
||||
def _extract_best_prices(orderbook: Dict[str, Any]) -> Dict[str, Optional[float]]:
|
||||
bids = orderbook.get("bids") or []
|
||||
asks = orderbook.get("asks") or []
|
||||
|
||||
best_bid_price = None
|
||||
best_bid_size = None
|
||||
best_ask_price = None
|
||||
best_ask_size = None
|
||||
|
||||
for level in bids:
|
||||
p = _safe_float(level.get("price"))
|
||||
if p is None:
|
||||
continue
|
||||
s = _safe_float(level.get("size"))
|
||||
if best_bid_price is None or p > best_bid_price:
|
||||
best_bid_price = p
|
||||
best_bid_size = s
|
||||
|
||||
for level in asks:
|
||||
p = _safe_float(level.get("price"))
|
||||
if p is None:
|
||||
continue
|
||||
s = _safe_float(level.get("size"))
|
||||
if best_ask_price is None or p < best_ask_price:
|
||||
best_ask_price = p
|
||||
best_ask_size = s
|
||||
|
||||
spread = None
|
||||
if best_bid_price is not None and best_ask_price is not None:
|
||||
spread = best_ask_price - best_bid_price
|
||||
|
||||
return {
|
||||
"best_bid": best_bid_price,
|
||||
"best_bid_size": best_bid_size,
|
||||
"best_ask": best_ask_price,
|
||||
"best_ask_size": best_ask_size,
|
||||
"spread": spread,
|
||||
"last_trade_price": _safe_float(orderbook.get("last_trade_price")),
|
||||
}
|
||||
|
||||
|
||||
def fetch_order_books(
|
||||
token_ids: List[str],
|
||||
proxy: Optional[str] = None,
|
||||
timeout: int = 12,
|
||||
force_refresh: bool = False,
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""Fetch order books for token IDs (prefer POST /books, fallback GET /book)."""
|
||||
now_ts = time.time()
|
||||
session = _build_session(proxy)
|
||||
|
||||
# Deduplicate while keeping order
|
||||
seen = set()
|
||||
normalized: List[str] = []
|
||||
for token_id in token_ids:
|
||||
tid = str(token_id or "").strip()
|
||||
if not tid or tid in seen:
|
||||
continue
|
||||
seen.add(tid)
|
||||
normalized.append(tid)
|
||||
|
||||
books: Dict[str, Dict[str, Any]] = {}
|
||||
to_fetch: List[str] = []
|
||||
|
||||
for tid in normalized:
|
||||
cached_ok = (
|
||||
(not force_refresh)
|
||||
and (tid in _book_cache)
|
||||
and (now_ts - _book_cache_ts.get(tid, 0) < CACHE_TTL_BOOKS)
|
||||
)
|
||||
if cached_ok:
|
||||
books[tid] = _book_cache[tid]
|
||||
else:
|
||||
to_fetch.append(tid)
|
||||
|
||||
if to_fetch:
|
||||
try:
|
||||
payload = [{"token_id": tid} for tid in to_fetch]
|
||||
resp = session.post(
|
||||
f"{CLOB_API}/books",
|
||||
json=payload,
|
||||
timeout=timeout,
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
rows = resp.json() or []
|
||||
|
||||
for row in rows:
|
||||
tid = str(row.get("asset_id") or row.get("token_id") or "").strip()
|
||||
if not tid:
|
||||
continue
|
||||
books[tid] = row
|
||||
_book_cache[tid] = row
|
||||
_book_cache_ts[tid] = now_ts
|
||||
except Exception as exc:
|
||||
logger.warning(f"Polymarket POST /books failed, fallback to /book: {exc}")
|
||||
|
||||
# Fallback for missing tokens
|
||||
for tid in to_fetch:
|
||||
if tid in books:
|
||||
continue
|
||||
try:
|
||||
resp = session.get(
|
||||
f"{CLOB_API}/book",
|
||||
params={"token_id": tid},
|
||||
timeout=timeout,
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
row = resp.json()
|
||||
books[tid] = row
|
||||
_book_cache[tid] = row
|
||||
_book_cache_ts[tid] = now_ts
|
||||
except Exception as exc:
|
||||
logger.debug(f"Polymarket GET /book failed token={tid}: {exc}")
|
||||
|
||||
return books
|
||||
|
||||
|
||||
def _detect_anomaly_flags(
|
||||
token_id: str,
|
||||
best_bid: Optional[float],
|
||||
best_ask: Optional[float],
|
||||
spread: Optional[float],
|
||||
last_trade_price: Optional[float],
|
||||
best_bid_size: Optional[float],
|
||||
best_ask_size: Optional[float],
|
||||
now_ts: float,
|
||||
) -> List[str]:
|
||||
flags: List[str] = []
|
||||
|
||||
if best_bid is None or best_ask is None:
|
||||
flags.append("one_sided_orderbook")
|
||||
|
||||
if spread is not None and spread >= 0.08:
|
||||
flags.append("wide_spread")
|
||||
|
||||
if (best_bid_size is not None and best_bid_size < 25) or (
|
||||
best_ask_size is not None and best_ask_size < 25
|
||||
):
|
||||
flags.append("thin_liquidity")
|
||||
|
||||
prev = _prev_snapshots.get(token_id)
|
||||
if prev:
|
||||
prev_bid = _safe_float(prev.get("best_bid"))
|
||||
prev_ask = _safe_float(prev.get("best_ask"))
|
||||
prev_trade = _safe_float(prev.get("last_trade_price"))
|
||||
prev_spread = _safe_float(prev.get("spread"))
|
||||
|
||||
if (
|
||||
best_bid is not None
|
||||
and prev_bid is not None
|
||||
and abs(best_bid - prev_bid) >= 0.06
|
||||
):
|
||||
flags.append("bid_price_jump")
|
||||
|
||||
if (
|
||||
best_ask is not None
|
||||
and prev_ask is not None
|
||||
and abs(best_ask - prev_ask) >= 0.06
|
||||
):
|
||||
flags.append("ask_price_jump")
|
||||
|
||||
if (
|
||||
last_trade_price is not None
|
||||
and prev_trade is not None
|
||||
and abs(last_trade_price - prev_trade) >= 0.06
|
||||
):
|
||||
flags.append("last_trade_jump")
|
||||
|
||||
if (
|
||||
spread is not None
|
||||
and prev_spread is not None
|
||||
and spread - prev_spread >= 0.05
|
||||
):
|
||||
flags.append("spread_widening")
|
||||
|
||||
_prev_snapshots[token_id] = {
|
||||
"ts": now_ts,
|
||||
"best_bid": best_bid,
|
||||
"best_ask": best_ask,
|
||||
"spread": spread,
|
||||
"last_trade_price": last_trade_price,
|
||||
}
|
||||
|
||||
return flags
|
||||
|
||||
|
||||
def build_city_market_snapshot(
|
||||
city: str,
|
||||
target_date: Optional[str] = None,
|
||||
proxy: Optional[str] = None,
|
||||
timeout: int = 15,
|
||||
force_refresh: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Build city/date market snapshot with buy/sell prices and anomaly flags.
|
||||
|
||||
buy_price = best ask (what you pay to buy)
|
||||
sell_price = best bid (what you receive when selling)
|
||||
"""
|
||||
now_ts = time.time()
|
||||
_cleanup_old_snapshots(now_ts)
|
||||
|
||||
markets = get_city_markets(
|
||||
city=city,
|
||||
target_date=target_date,
|
||||
proxy=proxy,
|
||||
timeout=timeout,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
|
||||
token_ids: List[str] = []
|
||||
for market in markets:
|
||||
for outcome in market.get("outcomes", []):
|
||||
tid = outcome.get("token_id")
|
||||
if tid:
|
||||
token_ids.append(str(tid))
|
||||
|
||||
books_by_token = fetch_order_books(
|
||||
token_ids,
|
||||
proxy=proxy,
|
||||
timeout=timeout,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
|
||||
snapshot_markets: List[Dict[str, Any]] = []
|
||||
alerts: List[Dict[str, Any]] = []
|
||||
|
||||
for market in markets:
|
||||
market_outcomes: List[Dict[str, Any]] = []
|
||||
market_alerts: List[Dict[str, Any]] = []
|
||||
|
||||
for outcome in market.get("outcomes", []):
|
||||
token_id = outcome.get("token_id")
|
||||
orderbook = books_by_token.get(str(token_id), {}) if token_id else {}
|
||||
top = _extract_best_prices(orderbook)
|
||||
|
||||
buy_price = top["best_ask"]
|
||||
sell_price = top["best_bid"]
|
||||
spread = top["spread"]
|
||||
last_trade_price = top["last_trade_price"]
|
||||
|
||||
flags = _detect_anomaly_flags(
|
||||
token_id=str(token_id or ""),
|
||||
best_bid=top["best_bid"],
|
||||
best_ask=top["best_ask"],
|
||||
spread=spread,
|
||||
last_trade_price=last_trade_price,
|
||||
best_bid_size=top["best_bid_size"],
|
||||
best_ask_size=top["best_ask_size"],
|
||||
now_ts=now_ts,
|
||||
) if token_id else []
|
||||
|
||||
row = {
|
||||
"name": outcome.get("name"),
|
||||
"token_id": token_id,
|
||||
"last_price": outcome.get("last_price"),
|
||||
"buy_price": buy_price,
|
||||
"sell_price": sell_price,
|
||||
"buy_size": top["best_ask_size"],
|
||||
"sell_size": top["best_bid_size"],
|
||||
"spread": spread,
|
||||
"last_trade_price": last_trade_price,
|
||||
"book_timestamp": orderbook.get("timestamp"),
|
||||
"anomaly_flags": flags,
|
||||
}
|
||||
market_outcomes.append(row)
|
||||
|
||||
if flags:
|
||||
market_alert = {
|
||||
"market_id": market.get("id"),
|
||||
"question": market.get("question"),
|
||||
"outcome": outcome.get("name"),
|
||||
"token_id": token_id,
|
||||
"flags": flags,
|
||||
"buy_price": buy_price,
|
||||
"sell_price": sell_price,
|
||||
"spread": spread,
|
||||
"last_trade_price": last_trade_price,
|
||||
}
|
||||
market_alerts.append(market_alert)
|
||||
alerts.append(market_alert)
|
||||
|
||||
snapshot_markets.append(
|
||||
{
|
||||
"id": market.get("id"),
|
||||
"question": market.get("question"),
|
||||
"city": market.get("city"),
|
||||
"date": market.get("date"),
|
||||
"slug": market.get("slug"),
|
||||
"url": market.get("url"),
|
||||
"volume": market.get("volume"),
|
||||
"liquidity": market.get("liquidity"),
|
||||
"outcomes": market_outcomes,
|
||||
"market_alerts": market_alerts,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"city": city,
|
||||
"target_date": target_date,
|
||||
"updated_at": datetime.utcnow().isoformat() + "Z",
|
||||
"summary": {
|
||||
"market_count": len(snapshot_markets),
|
||||
"outcome_count": sum(len(m.get("outcomes", [])) for m in snapshot_markets),
|
||||
"alert_count": len(alerts),
|
||||
},
|
||||
"markets": snapshot_markets,
|
||||
"alerts": alerts,
|
||||
}
|
||||
|
||||
|
||||
def compute_divergence(
|
||||
city_markets: List[Dict],
|
||||
prob_distribution: List[Dict],
|
||||
city_markets: List[Dict[str, Any]],
|
||||
prob_distribution: List[Dict[str, Any]],
|
||||
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 = []
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Compare probability-engine output with Polymarket yes/no pricing."""
|
||||
signals: List[Dict[str, Any]] = []
|
||||
|
||||
for mkt in city_markets:
|
||||
if mkt.get("contract_type") != "exceed" or mkt.get("yes_price") is None:
|
||||
continue
|
||||
|
||||
threshold = mkt.get("threshold")
|
||||
threshold = _safe_float(mkt.get("threshold"))
|
||||
market_prob = _safe_float(mkt.get("yes_price"))
|
||||
mkt_unit = mkt.get("threshold_unit", "F")
|
||||
if threshold is None:
|
||||
if threshold is None or market_prob is None:
|
||||
continue
|
||||
|
||||
# Convert threshold to match our unit
|
||||
# Convert threshold to our unit scale
|
||||
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
|
||||
threshold_v = (threshold - 32) * 5 / 9
|
||||
else:
|
||||
threshold_c = threshold
|
||||
threshold_v = threshold
|
||||
|
||||
# Calculate our probability of exceeding this threshold
|
||||
# Sum probabilities for all values >= threshold (rounded)
|
||||
threshold_wu = round(threshold_c)
|
||||
threshold_wu = round(threshold_v)
|
||||
our_exceed_prob = 0.0
|
||||
for p in prob_distribution:
|
||||
if p.get("value", 0) >= threshold_wu:
|
||||
our_exceed_prob += p.get("probability", 0)
|
||||
if (p.get("value") or 0) >= threshold_wu:
|
||||
our_exceed_prob += _safe_float(p.get("probability")) or 0.0
|
||||
|
||||
market_prob = mkt["yes_price"]
|
||||
divergence = our_exceed_prob - market_prob
|
||||
|
||||
signal = "neutral"
|
||||
@@ -277,16 +652,19 @@ def compute_divergence(
|
||||
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", ""),
|
||||
})
|
||||
signals.append(
|
||||
{
|
||||
"question": mkt.get("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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user