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 re
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]:
@@ -471,6 +473,130 @@ def _bucket_label(bucket: Any) -> Optional[str]:
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]:
scan = city_weather.get("market_scan") or {}
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_rows: List[Dict[str, Any]] = []
top_buckets = scan.get("top_buckets") or []
if isinstance(top_buckets, list):
all_bucket_rows: List[Dict[str, Any]] = []
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 = []
for row in top_buckets:
for row in source_buckets:
if not isinstance(row, dict):
continue
p = _norm_probability(row.get("probability"))
@@ -504,15 +634,21 @@ def _extract_market_snapshot(city_weather: Dict[str, Any]) -> Dict[str, Any]:
if normalized:
normalized.sort(key=lambda x: x[0], reverse=True)
top_bucket = normalized[0][1]
for p, row in normalized[:4]:
top_bucket_rows.append(
for p, row in normalized:
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),
"probability": p,
"yes_buy": _norm_probability(row.get("yes_buy")),
"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
websocket = scan.get("websocket") or {}
@@ -525,6 +661,17 @@ def _extract_market_snapshot(city_weather: Dict[str, Any]) -> Dict[str, Any]:
if 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 {
"available": True,
"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"),
"confidence": scan.get("confidence"),
"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)}
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(
city_weather: Dict[str, Any],
map_url: Optional[str] = None,
@@ -833,12 +1060,10 @@ def build_trading_alerts(
if force_push and severity == "none":
severity = "medium"
telegram = _build_telegram_messages(
telegram = _build_telegram_messages_mispricing(
city_weather=city_weather,
rules=rules,
map_url=map_url,
market_snapshot=market_snapshot,
suppression=suppression,
)
return {
+12 -2
View File
@@ -486,12 +486,21 @@ class PolymarketReadOnlyLayer:
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,
target_date=date_str,
primary_market=market,
limit=4,
limit=all_bucket_limit,
)
top_buckets = list(all_buckets[:top_bucket_limit])
yes_payload = {
"outcome": yes_token.get("outcome") or "Yes",
@@ -560,6 +569,7 @@ class PolymarketReadOnlyLayer:
"volume": volume,
"sparkline": sparkline_values,
"top_buckets": top_buckets,
"all_buckets": all_buckets,
"websocket": {
"market_url": market_url,
"asset_ids": [
+80
View File
@@ -36,6 +36,28 @@ def _env_int(name: str, default: int) -> int:
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]:
if not raw:
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)
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:
trigger_types = sorted(
str(alert.get("type") or "").strip()
@@ -218,6 +292,12 @@ def _maybe_send_alert(
last_by_city = state.setdefault("last_by_city", {})
last_city = last_by_city.get(city) or {}
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()
if not is_active or not message: