feat: Add weather alert engine, Polymarket data reader, and Telegram notification utility.

This commit is contained in:
2569718930@qq.com
2026-03-11 01:56:22 +08:00
parent 79b4708b7e
commit d6434bf174
3 changed files with 327 additions and 12 deletions
+235 -10
View File
@@ -7,7 +7,9 @@ from __future__ import annotations
import math import math
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Tuple
from src.analysis.settlement_rounding import wu_round
def _sf(v: Any) -> Optional[float]: def _sf(v: Any) -> Optional[float]:
@@ -471,6 +473,130 @@ def _bucket_label(bucket: Any) -> Optional[str]:
return None return None
def _to_celsius(temp: Optional[float], temp_symbol: str) -> Optional[float]:
if temp is None:
return None
if "F" in (temp_symbol or "").upper():
return (temp - 32.0) * 5.0 / 9.0
return temp
def _extract_open_meteo_today_high_c(city_weather: Dict[str, Any]) -> Optional[float]:
forecast = city_weather.get("forecast") or {}
om_today = _sf(forecast.get("today_high"))
if om_today is None:
om = city_weather.get("open-meteo") or {}
daily = om.get("daily") or {}
series = daily.get("temperature_2m_max") or []
if isinstance(series, list) and series:
om_today = _sf(series[0])
if om_today is None:
return None
temp_symbol = str(city_weather.get("temp_symbol") or "")
return _to_celsius(om_today, temp_symbol)
def _bucket_value(row: Dict[str, Any]) -> Optional[float]:
for key in ("value", "temp"):
value = _sf(row.get(key))
if value is not None:
return value
label = str(row.get("label") or "").strip()
m = re.search(r"(-?\d+(?:\.\d+)?)", label)
if not m:
return None
return _sf(m.group(1))
def _bucket_bounds(row: Dict[str, Any]) -> Optional[Tuple[Optional[float], Optional[float]]]:
value = _bucket_value(row)
if value is None:
return None
label = str(row.get("label") or "").lower()
is_upper_tail = any(key in label for key in ("+", "or higher", "or above", "and above"))
is_lower_tail = any(key in label for key in ("<=", "or lower", "or below", "and below"))
if is_upper_tail and not is_lower_tail:
return value, None
if is_lower_tail and not is_upper_tail:
return None, value
return value, value
def _distance_to_bucket(target: float, bounds: Tuple[Optional[float], Optional[float]]) -> float:
lower, upper = bounds
if lower is not None and target < lower:
return lower - target
if upper is not None and target > upper:
return target - upper
return 0.0
def _pick_bucket_for_forecast(
rows: List[Dict[str, Any]],
forecast_settlement: Optional[int],
forecast_today_high_c: Optional[float],
) -> Optional[Dict[str, Any]]:
if not rows:
return None
target = (
float(forecast_settlement)
if forecast_settlement is not None
else forecast_today_high_c
)
if target is None:
return None
best_row: Optional[Dict[str, Any]] = None
best_distance: Optional[float] = None
best_probability = -1.0
best_rank = 10**9
for idx, row in enumerate(rows):
bounds = _bucket_bounds(row)
if not bounds:
continue
distance = _distance_to_bucket(target, bounds)
probability = _norm_probability(row.get("probability"))
probability_rank = probability if probability is not None else -1.0
if best_row is None:
best_row = row
best_distance = distance
best_probability = probability_rank
best_rank = idx
continue
assert best_distance is not None
if distance < best_distance:
best_row = row
best_distance = distance
best_probability = probability_rank
best_rank = idx
continue
if abs(distance - best_distance) <= 1e-9:
if probability_rank > best_probability:
best_row = row
best_distance = distance
best_probability = probability_rank
best_rank = idx
elif abs(probability_rank - best_probability) <= 1e-9 and idx < best_rank:
best_row = row
best_distance = distance
best_probability = probability_rank
best_rank = idx
return best_row
def _extract_market_snapshot(city_weather: Dict[str, Any]) -> Dict[str, Any]: def _extract_market_snapshot(city_weather: Dict[str, Any]) -> Dict[str, Any]:
scan = city_weather.get("market_scan") or {} scan = city_weather.get("market_scan") or {}
if not isinstance(scan, dict): if not isinstance(scan, dict):
@@ -491,10 +617,14 @@ def _extract_market_snapshot(city_weather: Dict[str, Any]) -> Dict[str, Any]:
top_bucket = None top_bucket = None
top_bucket_rows: List[Dict[str, Any]] = [] top_bucket_rows: List[Dict[str, Any]] = []
top_buckets = scan.get("top_buckets") or [] all_bucket_rows: List[Dict[str, Any]] = []
if isinstance(top_buckets, list): source_buckets = scan.get("all_buckets")
if not isinstance(source_buckets, list) or not source_buckets:
source_buckets = scan.get("top_buckets") or []
if isinstance(source_buckets, list):
normalized = [] normalized = []
for row in top_buckets: for row in source_buckets:
if not isinstance(row, dict): if not isinstance(row, dict):
continue continue
p = _norm_probability(row.get("probability")) p = _norm_probability(row.get("probability"))
@@ -504,15 +634,21 @@ def _extract_market_snapshot(city_weather: Dict[str, Any]) -> Dict[str, Any]:
if normalized: if normalized:
normalized.sort(key=lambda x: x[0], reverse=True) normalized.sort(key=lambda x: x[0], reverse=True)
top_bucket = normalized[0][1] top_bucket = normalized[0][1]
for p, row in normalized[:4]: for p, row in normalized:
top_bucket_rows.append( row_slug = str(row.get("slug") or "").strip()
row_market_url = f"https://polymarket.com/market/{row_slug}" if row_slug else None
all_bucket_rows.append(
{ {
"label": _bucket_label(row), "label": _bucket_label(row),
"probability": p, "probability": p,
"yes_buy": _norm_probability(row.get("yes_buy")), "yes_buy": _norm_probability(row.get("yes_buy")),
"yes_sell": _norm_probability(row.get("yes_sell")), "yes_sell": _norm_probability(row.get("yes_sell")),
"value": _sf(row.get("value") or row.get("temp")),
"slug": row_slug or None,
"market_url": row_market_url,
} }
) )
top_bucket_rows = all_bucket_rows[:4]
market_url = None market_url = None
websocket = scan.get("websocket") or {} websocket = scan.get("websocket") or {}
@@ -525,6 +661,17 @@ def _extract_market_snapshot(city_weather: Dict[str, Any]) -> Dict[str, Any]:
if slug: if slug:
market_url = f"https://polymarket.com/market/{slug}" market_url = f"https://polymarket.com/market/{slug}"
open_meteo_today_high_c = _extract_open_meteo_today_high_c(city_weather)
open_meteo_settlement = wu_round(open_meteo_today_high_c)
forecast_bucket = _pick_bucket_for_forecast(
rows=all_bucket_rows,
forecast_settlement=open_meteo_settlement,
forecast_today_high_c=open_meteo_today_high_c,
)
forecast_market_url = None
if isinstance(forecast_bucket, dict):
forecast_market_url = str(forecast_bucket.get("market_url") or "").strip() or None
return { return {
"available": True, "available": True,
"selected_bucket": _bucket_label(scan.get("temperature_bucket")), "selected_bucket": _bucket_label(scan.get("temperature_bucket")),
@@ -541,7 +688,12 @@ def _extract_market_snapshot(city_weather: Dict[str, Any]) -> Dict[str, Any]:
"signal_label": scan.get("signal_label"), "signal_label": scan.get("signal_label"),
"confidence": scan.get("confidence"), "confidence": scan.get("confidence"),
"top_bucket_rows": top_bucket_rows, "top_bucket_rows": top_bucket_rows,
"market_url": market_url, "all_bucket_rows": all_bucket_rows,
"open_meteo_today_high_c": open_meteo_today_high_c,
"open_meteo_settlement": open_meteo_settlement,
"forecast_bucket": forecast_bucket,
"primary_market_url": market_url,
"market_url": forecast_market_url or market_url,
} }
@@ -786,6 +938,81 @@ def _build_telegram_messages(
return {"zh": "\n".join(lines_zh), "en": "\n".join(lines_en)} return {"zh": "\n".join(lines_zh), "en": "\n".join(lines_en)}
def _build_telegram_messages_mispricing(
city_weather: Dict[str, Any],
rules: Dict[str, Dict[str, Any]],
market_snapshot: Optional[Dict[str, Any]] = None,
) -> Dict[str, str]:
temp_symbol = str(city_weather.get("temp_symbol") or "°C")
city_name = city_weather.get("display_name") or city_weather.get("name", "").title()
current = city_weather.get("current") or {}
current_temp = _sf(current.get("temp"))
if current_temp is None:
return {"zh": "", "en": ""}
snapshot = market_snapshot or _extract_market_snapshot(city_weather)
momentum = rules.get("momentum_spike", {})
local_time = str(city_weather.get("local_time") or "").strip()
obs_time = str(current.get("obs_time") or "").strip()
delta_temp = _sf(momentum.get("delta_temp"))
delta_min = momentum.get("delta_minutes")
momentum_emoji = "➡️"
if delta_temp is not None:
momentum_emoji = "🚀" if delta_temp > 0 else ("📉" if delta_temp < 0 else "➡️")
dynamic_text = f"实测 {current_temp:.1f}{temp_symbol}"
if delta_temp is not None and delta_min is not None:
dynamic_text = (
f"实测 {current_temp:.1f}{temp_symbol} "
f"({int(delta_min)}min 内 {delta_temp:+.1f}{temp_symbol}) {momentum_emoji}"
)
om_high_c = _sf(snapshot.get("open_meteo_today_high_c"))
om_settle = snapshot.get("open_meteo_settlement")
forecast_bucket = snapshot.get("forecast_bucket") or {}
match_bucket_label = str(forecast_bucket.get("label") or "--").strip() or "--"
match_bucket_yes = _fmt_cents(forecast_bucket.get("yes_buy"))
market_url = str(
snapshot.get("market_url")
or snapshot.get("primary_market_url")
or ""
).strip()
lines_zh = [f"🚨 PolyWeather 错价雷达 [{city_name}]"]
lines_zh.append("")
if om_high_c is not None and om_settle is not None:
lines_zh.append(
f"基准:Open-Meteo 今日高温 {om_high_c:.1f}C(结算参考 {om_settle}C"
)
else:
lines_zh.append("基准:Open-Meteo 今日高温 --(结算参考 --)")
lines_zh.append(f"命中桶:{match_bucket_label} | Yes: {match_bucket_yes}")
lines_zh.append("触发:该桶 Yes 价格 < 10c,疑似低估")
lines_zh.append("")
lines_zh.append(f"动态:{dynamic_text}")
if local_time or obs_time:
if local_time and obs_time:
lines_zh.append(f"时间:当地 {local_time} | 观测 {obs_time}")
elif local_time:
lines_zh.append(f"时间:当地 {local_time}")
else:
lines_zh.append(f"时间:观测 {obs_time}")
lines_zh.append("")
if market_url:
lines_zh.append(f"市场链接:{market_url}")
lines_en = [
f"🚨 PolyWeather Mispricing Radar [{city_name}]",
"",
f"Now: {dynamic_text}",
]
if market_url:
lines_en.append(f"Market link: {market_url}")
return {"zh": "\n".join(lines_zh), "en": "\n".join(lines_en)}
def build_trading_alerts( def build_trading_alerts(
city_weather: Dict[str, Any], city_weather: Dict[str, Any],
map_url: Optional[str] = None, map_url: Optional[str] = None,
@@ -833,12 +1060,10 @@ def build_trading_alerts(
if force_push and severity == "none": if force_push and severity == "none":
severity = "medium" severity = "medium"
telegram = _build_telegram_messages( telegram = _build_telegram_messages_mispricing(
city_weather=city_weather, city_weather=city_weather,
rules=rules, rules=rules,
map_url=map_url,
market_snapshot=market_snapshot, market_snapshot=market_snapshot,
suppression=suppression,
) )
return { return {
+12 -2
View File
@@ -486,12 +486,21 @@ class PolymarketReadOnlyLayer:
signal_label, confidence = self._derive_signal(edge_percent, liquidity) signal_label, confidence = self._derive_signal(edge_percent, liquidity)
top_buckets = self._build_top_temperature_buckets( top_bucket_limit = max(
1,
_safe_int(os.getenv("POLYMARKET_TOP_BUCKET_LIMIT", "4"), 4),
)
all_bucket_limit = max(
top_bucket_limit,
_safe_int(os.getenv("POLYMARKET_ALL_BUCKET_LIMIT", "24"), 24),
)
all_buckets = self._build_top_temperature_buckets(
city_key=city_key, city_key=city_key,
target_date=date_str, target_date=date_str,
primary_market=market, primary_market=market,
limit=4, limit=all_bucket_limit,
) )
top_buckets = list(all_buckets[:top_bucket_limit])
yes_payload = { yes_payload = {
"outcome": yes_token.get("outcome") or "Yes", "outcome": yes_token.get("outcome") or "Yes",
@@ -560,6 +569,7 @@ class PolymarketReadOnlyLayer:
"volume": volume, "volume": volume,
"sparkline": sparkline_values, "sparkline": sparkline_values,
"top_buckets": top_buckets, "top_buckets": top_buckets,
"all_buckets": all_buckets,
"websocket": { "websocket": {
"market_url": market_url, "market_url": market_url,
"asset_ids": [ "asset_ids": [
+80
View File
@@ -36,6 +36,28 @@ def _env_int(name: str, default: int) -> int:
return default return default
def _env_float(name: str, default: float) -> float:
raw = os.getenv(name)
if raw is None:
return default
try:
return float(raw)
except Exception:
return default
def _norm_prob(v: Any) -> Optional[float]:
if v is None:
return None
try:
n = float(v)
except Exception:
return None
if n > 1.0:
n = n / 100.0
return max(0.0, min(1.0, n))
def _parse_city_list(raw: Optional[str]) -> List[str]: def _parse_city_list(raw: Optional[str]) -> List[str]:
if not raw: if not raw:
return list(CITY_REGISTRY.keys()) return list(CITY_REGISTRY.keys())
@@ -111,6 +133,58 @@ def _severity_ok(alert_payload: Dict[str, Any], min_severity: str, min_trigger_c
return SEVERITY_RANK.get(severity, 0) >= SEVERITY_RANK.get(min_severity, 0) return SEVERITY_RANK.get(severity, 0) >= SEVERITY_RANK.get(min_severity, 0)
def _market_price_cap_ok(alert_payload: Dict[str, Any], max_yes_buy: float) -> bool:
if max_yes_buy >= 1.0:
return True
market = alert_payload.get("market_snapshot") or {}
if not isinstance(market, dict) or not market.get("available"):
return True
# Prefer the market bucket that maps to Open-Meteo forecast settlement.
forecast_bucket = market.get("forecast_bucket") or {}
yes_buy = None
bucket_label = None
if isinstance(forecast_bucket, dict):
yes_buy = _norm_prob(forecast_bucket.get("yes_buy"))
bucket_label = str(forecast_bucket.get("label") or "").strip() or None
# Backward-compatible fallback.
if yes_buy is None:
yes_buy = _norm_prob(market.get("yes_buy"))
if not bucket_label:
bucket_label = str(market.get("selected_bucket") or "").strip() or None
if yes_buy is None:
# Fallback to first bucket with valid yes_buy if aggregate field is missing.
top_rows = market.get("top_bucket_rows") or []
if isinstance(top_rows, list):
for row in top_rows:
if not isinstance(row, dict):
continue
yes_buy = _norm_prob(row.get("yes_buy"))
if yes_buy is not None:
if not bucket_label:
bucket_label = str(row.get("label") or "").strip() or None
break
if yes_buy is None:
return True
if yes_buy >= max_yes_buy:
logger.info(
"trade alert skipped by mispricing cap city={} bucket={} om_settle={} yes_buy={} cap={}".format(
alert_payload.get("city"),
bucket_label or "--",
market.get("open_meteo_settlement"),
round(yes_buy, 4),
round(max_yes_buy, 4),
)
)
return False
return True
def _trigger_type_key(alert_payload: Dict[str, Any]) -> str: def _trigger_type_key(alert_payload: Dict[str, Any]) -> str:
trigger_types = sorted( trigger_types = sorted(
str(alert.get("type") or "").strip() str(alert.get("type") or "").strip()
@@ -218,6 +292,12 @@ def _maybe_send_alert(
last_by_city = state.setdefault("last_by_city", {}) last_by_city = state.setdefault("last_by_city", {})
last_city = last_by_city.get(city) or {} last_city = last_by_city.get(city) or {}
is_active = _severity_ok(alert_payload, min_severity, min_trigger_count) is_active = _severity_ok(alert_payload, min_severity, min_trigger_count)
max_yes_buy = max(
0.0,
min(1.0, _env_float("TELEGRAM_ALERT_MISPRICING_MAX_YES_BUY", 0.10)),
)
if not _market_price_cap_ok(alert_payload, max_yes_buy):
is_active = False
message = ((alert_payload.get("telegram") or {}).get("zh") or "").strip() message = ((alert_payload.get("telegram") or {}).get("zh") or "").strip()
if not is_active or not message: if not is_active or not message: