Files
PolyWeather/web/services/ops/market_opportunities.py
T
2026-07-04 00:07:24 +08:00

533 lines
19 KiB
Python

from __future__ import annotations
import json
import math
import re
import threading
import time
import unicodedata
from datetime import datetime
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
import requests
from fastapi import Request
from web.scan_terminal_service import build_scan_terminal_payload
GAMMA_API_BASE = "https://gamma-api.polymarket.com"
CLOB_API_BASE = "https://clob.polymarket.com"
_QUOTE_CACHE_TTL_SEC = 60
_EVENT_CACHE_TTL_SEC = 180
_CACHE_LOCK = threading.Lock()
_EVENT_CACHE: Dict[str, Tuple[float, Optional[Dict[str, Any]]]] = {}
_PRICE_CACHE: Dict[str, Tuple[float, Optional[float]]] = {}
def _require_ops(request: Request) -> Dict[str, Any] | None:
from web.services.ops_api import _require_ops as _real
return _real(request)
def _finite_number(value: Any) -> Optional[float]:
if value is None:
return None
try:
number = float(value)
except (TypeError, ValueError):
return None
if not math.isfinite(number):
return None
return number
def _json_list(value: Any) -> List[Any]:
if isinstance(value, list):
return value
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return []
return parsed if isinstance(parsed, list) else []
return []
def _normalize_key(value: Any) -> str:
return re.sub(r"\s+", " ", str(value or "").strip().lower())
def _slugify(value: str) -> str:
normalized = unicodedata.normalize("NFKD", value)
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
ascii_text = ascii_text.lower().replace("&", " and ")
return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", ascii_text)).strip("-")
def _city_slug(row: Mapping[str, Any]) -> str:
city = _normalize_key(row.get("city_display_name") or row.get("display_name") or row.get("city"))
if city in {"new york", "new york city"}:
return "nyc"
return _slugify(city)
def _date_slug(value: Any) -> Optional[str]:
text = str(value or "").strip()
if not text:
return None
try:
parsed = datetime.fromisoformat(text[:10])
except ValueError:
return None
return f"{parsed.strftime('%B').lower()}-{parsed.day}-{parsed.year}"
def _event_slug_for_row(row: Mapping[str, Any]) -> Optional[str]:
date_key = row.get("selected_date") or row.get("local_date")
date_slug = _date_slug(date_key)
city_slug = _city_slug(row)
if not date_slug or not city_slug:
return None
return f"highest-temperature-in-{city_slug}-on-{date_slug}"
def _market_url(slug: str) -> str:
return f"https://polymarket.com/event/{slug}"
def _round_probability(value: float) -> float:
return round(value + 0.0000000001, 4)
def _round_price(value: float) -> float:
return round(value + 0.0000000001, 4)
def _probability_from_bucket(bucket: Mapping[str, Any]) -> Optional[float]:
raw = _finite_number(bucket.get("probability") or bucket.get("model_probability"))
if raw is None:
return None
return raw / 100.0 if raw > 1 else raw
def _distribution_points(row: Mapping[str, Any]) -> List[Tuple[int, float]]:
raw = row.get("distribution_full") or row.get("distribution_preview") or []
if not isinstance(raw, list):
return []
points: List[Tuple[int, float]] = []
for bucket in raw:
if not isinstance(bucket, Mapping):
continue
value = _finite_number(bucket.get("value") or bucket.get("temp") or bucket.get("temperature"))
probability = _probability_from_bucket(bucket)
if value is None or probability is None or probability <= 0:
continue
points.append((int(round(value)), float(probability)))
return points
def parse_market_option_from_question(question: str, unit: str) -> Dict[str, Any]:
text = str(question or "").strip()
unit_text = "°F" if "f" in str(unit or "").lower() else "°C"
between = re.search(
r"between\s+(-?\d+)\s*-\s*(-?\d+)\s*°?\s*([CF])",
text,
flags=re.IGNORECASE,
)
if between:
lower = int(between.group(1))
upper = int(between.group(2))
parsed_unit = f{between.group(3).upper()}"
return {
"label": f"{lower}-{upper}{parsed_unit}",
"lower": min(lower, upper),
"upper": max(lower, upper),
"unit": parsed_unit,
}
below = re.search(r"(-?\d+)\s*°?\s*([CF])\s+or\s+below", text, flags=re.IGNORECASE)
if below:
upper = int(below.group(1))
parsed_unit = f{below.group(2).upper()}"
return {
"label": f"{upper}{parsed_unit} or below",
"lower": None,
"upper": upper,
"unit": parsed_unit,
}
higher = re.search(r"(-?\d+)\s*°?\s*([CF])\s+or\s+higher", text, flags=re.IGNORECASE)
if higher:
lower = int(higher.group(1))
parsed_unit = f{higher.group(2).upper()}"
return {
"label": f"{lower}{parsed_unit} or higher",
"lower": lower,
"upper": None,
"unit": parsed_unit,
}
exact = re.search(r"\bbe\s+(-?\d+)\s*°?\s*([CF])\b", text, flags=re.IGNORECASE)
if exact:
value = int(exact.group(1))
parsed_unit = f{exact.group(2).upper()}"
return {
"label": f"{value}{parsed_unit}",
"lower": value,
"upper": value,
"unit": parsed_unit,
}
value_match = re.search(r"(-?\d+)\s*°?\s*([CF])", text, flags=re.IGNORECASE)
if value_match:
value = int(value_match.group(1))
parsed_unit = f{value_match.group(2).upper()}"
return {
"label": f"{value}{parsed_unit}",
"lower": value,
"upper": value,
"unit": parsed_unit,
}
return {"label": text or "—", "lower": None, "upper": None, "unit": unit_text}
def _bucket_probability(row: Mapping[str, Any], option: Mapping[str, Any]) -> Optional[float]:
points = _distribution_points(row)
if not points:
return None
lower = option.get("lower")
upper = option.get("upper")
lower_number = int(lower) if lower is not None else None
upper_number = int(upper) if upper is not None else None
probability = 0.0
for settled_value, point_probability in points:
if lower_number is not None and settled_value < lower_number:
continue
if upper_number is not None and settled_value > upper_number:
continue
probability += point_probability
return _round_probability(probability)
def _model_stats(row: Mapping[str, Any]) -> Tuple[Optional[float], Optional[float]]:
sources = row.get("model_cluster_sources") or {}
if not isinstance(sources, Mapping):
return None, None
values = sorted(
number
for number in (_finite_number(value) for value in sources.values())
if number is not None
)
if not values:
return None, None
mid = len(values) // 2
median = values[mid] if len(values) % 2 else (values[mid - 1] + values[mid]) / 2
return round(median, 1), round(max(values) - min(values), 1)
def _market_tokens(market: Mapping[str, Any]) -> Dict[str, Optional[str]]:
outcomes = [str(item).strip().lower() for item in _json_list(market.get("outcomes"))]
token_ids = [str(item).strip() for item in _json_list(market.get("clobTokenIds"))]
result = {"yes": None, "no": None}
for index, outcome in enumerate(outcomes):
if index >= len(token_ids) or not token_ids[index]:
continue
if outcome in result:
result[outcome] = token_ids[index]
return result
def _market_hint_prices(market: Mapping[str, Any]) -> Dict[str, Optional[float]]:
outcomes = [str(item).strip().lower() for item in _json_list(market.get("outcomes"))]
prices = _json_list(market.get("outcomePrices"))
result: Dict[str, Optional[float]] = {"yes": None, "no": None}
for index, outcome in enumerate(outcomes):
if index >= len(prices) or outcome not in result:
continue
result[outcome] = _finite_number(prices[index])
return result
def _iter_market_sides(side: str) -> Iterable[str]:
normalized = str(side or "both").strip().lower()
if normalized in {"yes", "no"}:
yield normalized
return
yield "yes"
yield "no"
def build_market_opportunity_rows(
scan_rows: List[Dict[str, Any]],
events_by_city: Mapping[str, Optional[Dict[str, Any]]],
ask_prices_by_token: Mapping[str, Optional[float]],
*,
max_price: float = 0.20,
side: str = "both",
positive_edge_only: bool = True,
min_edge: float = 0.0,
limit: int = 200,
query: str = "",
region: str = "",
) -> List[Dict[str, Any]]:
opportunities: List[Dict[str, Any]] = []
query_text = _normalize_key(query)
region_text = _normalize_key(region)
for scan_row in scan_rows:
city_key = _normalize_key(scan_row.get("city"))
display_name = str(scan_row.get("city_display_name") or scan_row.get("display_name") or scan_row.get("city") or "—")
if query_text and query_text not in _normalize_key(display_name) and query_text not in city_key:
continue
row_region = str(scan_row.get("trading_region_label_zh") or scan_row.get("trading_region_label") or "")
if region_text and region_text not in {"all", ""} and region_text not in _normalize_key(row_region):
continue
event = events_by_city.get(city_key)
if not isinstance(event, Mapping):
continue
market_url = _market_url(str(event.get("slug") or ""))
model_median, model_spread = _model_stats(scan_row)
for market in event.get("markets") or []:
if not isinstance(market, Mapping):
continue
if market.get("active") is False or market.get("closed") is True:
continue
if market.get("enableOrderBook") is False:
continue
option = parse_market_option_from_question(
str(market.get("question") or ""),
str(scan_row.get("temp_symbol") or "°C"),
)
model_probability = _bucket_probability(scan_row, option)
if model_probability is None:
continue
tokens = _market_tokens(market)
for option_side in _iter_market_sides(side):
token_id = tokens.get(option_side)
if not token_id:
continue
ask = ask_prices_by_token.get(token_id)
ask_number = _finite_number(ask)
if ask_number is None or ask_number <= 0 or ask_number >= float(max_price):
continue
target_probability = (
model_probability
if option_side == "yes"
else _round_probability(1.0 - model_probability)
)
edge = _round_probability(target_probability - ask_number)
if positive_edge_only and edge <= float(min_edge):
continue
if not positive_edge_only and edge < float(min_edge):
continue
market_slug = str(market.get("slug") or "")
opportunities.append(
{
"id": f"{city_key}:{market_slug}:{option_side}",
"city": city_key,
"display_name": display_name,
"target_date": scan_row.get("selected_date") or scan_row.get("local_date"),
"bucket_label": option["label"],
"side": option_side,
"ask_price": _round_price(ask_number),
"model_probability": model_probability,
"edge": edge,
"liquidity": _finite_number(market.get("liquidity")),
"volume": _finite_number(market.get("volume")),
"market_url": market_url,
"market_slug": market_slug,
"question": market.get("question"),
"current_max_so_far": _finite_number(scan_row.get("current_max_so_far")),
"deb_prediction": _finite_number(scan_row.get("deb_prediction")),
"model_median": model_median,
"model_spread": model_spread,
"local_time": scan_row.get("local_time"),
"region": row_region,
}
)
opportunities.sort(
key=lambda row: (
float(row.get("edge") or 0),
-float(row.get("ask_price") or 0),
float(row.get("liquidity") or 0),
),
reverse=True,
)
safe_limit = max(1, min(int(limit or 200), 500))
return opportunities[:safe_limit]
class PolymarketQuoteScanner:
def __init__(self, session: Optional[requests.Session] = None) -> None:
self.session = session or requests.Session()
def fetch_event(self, slug: str) -> Optional[Dict[str, Any]]:
now = time.time()
with _CACHE_LOCK:
cached = _EVENT_CACHE.get(slug)
if cached and now - cached[0] < _EVENT_CACHE_TTL_SEC:
return cached[1]
response = self.session.get(
f"{GAMMA_API_BASE}/events",
params={"slug": slug},
timeout=12,
)
response.raise_for_status()
payload = response.json()
event = payload[0] if isinstance(payload, list) and payload else None
with _CACHE_LOCK:
_EVENT_CACHE[slug] = (now, event if isinstance(event, dict) else None)
return event if isinstance(event, dict) else None
def fetch_ask_price(self, token_id: str) -> Optional[float]:
now = time.time()
with _CACHE_LOCK:
cached = _PRICE_CACHE.get(token_id)
if cached and now - cached[0] < _QUOTE_CACHE_TTL_SEC:
return cached[1]
response = self.session.get(
f"{CLOB_API_BASE}/price",
params={"token_id": token_id, "side": "SELL"},
timeout=8,
)
response.raise_for_status()
price = _finite_number((response.json() or {}).get("price"))
with _CACHE_LOCK:
_PRICE_CACHE[token_id] = (now, price)
return price
def _collect_events_and_prices(
rows: List[Dict[str, Any]],
scanner: PolymarketQuoteScanner,
) -> Tuple[Dict[str, Optional[Dict[str, Any]]], Dict[str, Optional[float]], str]:
events_by_city: Dict[str, Optional[Dict[str, Any]]] = {}
prices: Dict[str, Optional[float]] = {}
status = "ready"
for row in rows:
city_key = _normalize_key(row.get("city"))
if not city_key or city_key in events_by_city:
continue
slug = _event_slug_for_row(row)
if not slug:
events_by_city[city_key] = None
continue
try:
event = scanner.fetch_event(slug)
except Exception:
status = "partial"
event = None
events_by_city[city_key] = event
if not isinstance(event, Mapping):
continue
for market in event.get("markets") or []:
if not isinstance(market, Mapping):
continue
hint_prices = _market_hint_prices(market)
for market_side, token_id in _market_tokens(market).items():
if not token_id or token_id in prices:
continue
hint_price = hint_prices.get(market_side)
if hint_price is not None and hint_price > 0.30:
prices[token_id] = hint_price
continue
try:
prices[token_id] = scanner.fetch_ask_price(token_id)
except Exception:
status = "partial"
prices[token_id] = hint_price
return events_by_city, prices, status
def get_ops_market_opportunities(
request: Request,
*,
max_price: float = 0.20,
side: str = "both",
positive_edge_only: bool = True,
min_edge: float = 0.0,
limit: int = 200,
query: str = "",
region: str = "",
) -> Dict[str, Any]:
_require_ops(request)
filters = {
"scan_mode": "tradable",
"min_price": 0.05,
"max_price": 0.95,
"min_edge_pct": 2,
"min_liquidity": 500,
"market_type": "maxtemp",
"time_range": "today",
"limit": 180,
}
scan_payload = build_scan_terminal_payload(filters, force_refresh=False)
scan_rows = scan_payload.get("rows") if isinstance(scan_payload, dict) else []
if not isinstance(scan_rows, list):
scan_rows = []
quote_status = "ready"
try:
events_by_city, ask_prices, quote_status = _collect_events_and_prices(
scan_rows,
PolymarketQuoteScanner(),
)
except Exception as exc:
events_by_city = {}
ask_prices = {}
quote_status = "unavailable"
error = str(exc)
else:
error = None
rows = build_market_opportunity_rows(
scan_rows,
events_by_city,
ask_prices,
max_price=float(max_price),
side=side,
positive_edge_only=positive_edge_only,
min_edge=float(min_edge),
limit=limit,
query=query,
region=region,
)
prices = [float(row["ask_price"]) for row in rows if _finite_number(row.get("ask_price")) is not None]
edges = [float(row["edge"]) for row in rows if _finite_number(row.get("edge")) is not None]
return {
"generated_at": datetime.utcnow().isoformat() + "Z",
"filters": {
"max_price": float(max_price),
"side": side,
"positive_edge_only": bool(positive_edge_only),
"min_edge": float(min_edge),
"limit": int(limit),
"query": query,
"region": region,
},
"summary": {
"opportunity_count": len(rows),
"positive_edge_count": sum(1 for row in rows if float(row.get("edge") or 0) > 0),
"min_price": min(prices) if prices else None,
"max_edge": max(edges) if edges else None,
"quote_status": quote_status,
"scanned_city_count": len(scan_rows),
"matched_event_count": sum(1 for event in events_by_city.values() if isinstance(event, Mapping)),
"error": error,
},
"rows": rows,
}
__all__ = [
"PolymarketQuoteScanner",
"build_market_opportunity_rows",
"get_ops_market_opportunities",
"parse_market_option_from_question",
]