835 lines
30 KiB
Python
835 lines
30 KiB
Python
"""
|
||
Rule-based weather alert engine for short-horizon trading signals.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from datetime import datetime, timezone
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
|
||
def _sf(v: Any) -> Optional[float]:
|
||
if v is None:
|
||
return None
|
||
try:
|
||
return float(v)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _to_unit_delta(celsius_delta: float, temp_symbol: str) -> float:
|
||
if "F" in (temp_symbol or "").upper():
|
||
return celsius_delta * 9.0 / 5.0
|
||
return celsius_delta
|
||
|
||
|
||
def _minute_of_day(hhmm: Optional[str]) -> Optional[int]:
|
||
if not hhmm or ":" not in str(hhmm):
|
||
return None
|
||
try:
|
||
hh, mm = str(hhmm).split(":")[:2]
|
||
h = int(hh)
|
||
m = int(mm)
|
||
if not (0 <= h <= 23 and 0 <= m <= 59):
|
||
return None
|
||
return h * 60 + m
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _minutes_delta(newer_hhmm: Optional[str], older_hhmm: Optional[str]) -> Optional[int]:
|
||
newer = _minute_of_day(newer_hhmm)
|
||
older = _minute_of_day(older_hhmm)
|
||
if newer is None or older is None:
|
||
return None
|
||
d = newer - older
|
||
if d <= 0:
|
||
d += 24 * 60
|
||
return d
|
||
|
||
|
||
def _angle_diff(a: float, b: float) -> float:
|
||
d = abs((a - b) % 360.0)
|
||
return min(d, 360.0 - d)
|
||
|
||
|
||
def _bearing_deg(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||
phi1 = math.radians(lat1)
|
||
phi2 = math.radians(lat2)
|
||
d_lon = math.radians(lon2 - lon1)
|
||
x = math.sin(d_lon) * math.cos(phi2)
|
||
y = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(d_lon)
|
||
bearing = math.degrees(math.atan2(x, y))
|
||
return (bearing + 360.0) % 360.0
|
||
|
||
|
||
def _is_southerly(wdir: Optional[float]) -> bool:
|
||
if wdir is None:
|
||
return False
|
||
return 120.0 <= wdir <= 240.0
|
||
|
||
|
||
def _calc_momentum_alert(city_weather: Dict[str, Any], temp_symbol: str) -> Dict[str, Any]:
|
||
recent = (city_weather.get("trend") or {}).get("recent") or []
|
||
threshold_30m = _to_unit_delta(0.8, temp_symbol)
|
||
|
||
if len(recent) < 2:
|
||
return {
|
||
"type": "momentum_spike",
|
||
"triggered": False,
|
||
"reason": "insufficient recent observations",
|
||
}
|
||
|
||
newest = recent[0]
|
||
newest_temp = _sf(newest.get("temp"))
|
||
newest_time = newest.get("time")
|
||
if newest_temp is None:
|
||
return {
|
||
"type": "momentum_spike",
|
||
"triggered": False,
|
||
"reason": "latest observation missing temperature",
|
||
}
|
||
|
||
anchor = None
|
||
anchor_dt = None
|
||
for row in recent[1:]:
|
||
dt = _minutes_delta(newest_time, row.get("time"))
|
||
if dt is None:
|
||
continue
|
||
# Prefer a point close to 30 minutes.
|
||
if 20 <= dt <= 45:
|
||
anchor = row
|
||
anchor_dt = dt
|
||
break
|
||
if anchor is None:
|
||
anchor = row
|
||
anchor_dt = dt
|
||
|
||
if not anchor or not anchor_dt:
|
||
return {
|
||
"type": "momentum_spike",
|
||
"triggered": False,
|
||
"reason": "no usable time delta in recent observations",
|
||
}
|
||
|
||
anchor_temp = _sf(anchor.get("temp"))
|
||
if anchor_temp is None:
|
||
return {
|
||
"type": "momentum_spike",
|
||
"triggered": False,
|
||
"reason": "anchor observation missing temperature",
|
||
}
|
||
|
||
delta_temp = newest_temp - anchor_temp
|
||
slope_30m = delta_temp / anchor_dt * 30.0
|
||
is_up = slope_30m > threshold_30m
|
||
is_down = slope_30m < -threshold_30m
|
||
|
||
return {
|
||
"type": "momentum_spike",
|
||
"triggered": bool(is_up or is_down),
|
||
"direction": "up" if is_up else ("down" if is_down else "neutral"),
|
||
"newest_temp": round(newest_temp, 2),
|
||
"anchor_temp": round(anchor_temp, 2),
|
||
"delta_temp": round(delta_temp, 2),
|
||
"delta_minutes": anchor_dt,
|
||
"slope_30m": round(slope_30m, 2),
|
||
"threshold_30m": round(threshold_30m, 2),
|
||
}
|
||
|
||
|
||
def _pick_model_value(multi_model: Dict[str, Any], model_name: str) -> Optional[float]:
|
||
for k, v in (multi_model or {}).items():
|
||
if str(k).strip().upper() == model_name.upper():
|
||
return _sf(v)
|
||
return None
|
||
|
||
|
||
def _calc_forecast_breakthrough_alert(city_weather: Dict[str, Any], temp_symbol: str) -> Dict[str, Any]:
|
||
current_temp = _sf((city_weather.get("current") or {}).get("temp"))
|
||
if current_temp is None:
|
||
return {
|
||
"type": "forecast_breakthrough",
|
||
"triggered": False,
|
||
"reason": "current temperature unavailable",
|
||
}
|
||
|
||
mm = city_weather.get("multi_model") or {}
|
||
mgm_high = _pick_model_value(mm, "MGM")
|
||
gfs_high = _pick_model_value(mm, "GFS")
|
||
ecmwf_high = _pick_model_value(mm, "ECMWF")
|
||
model_rows = [("MGM", mgm_high), ("GFS", gfs_high), ("ECMWF", ecmwf_high)]
|
||
available = [(k, v) for k, v in model_rows if v is not None]
|
||
|
||
if not available:
|
||
return {
|
||
"type": "forecast_breakthrough",
|
||
"triggered": False,
|
||
"reason": "MGM/GFS/ECMWF highs are unavailable",
|
||
}
|
||
|
||
baseline_name, baseline_val = max(available, key=lambda item: item[1])
|
||
threshold = _to_unit_delta(0.2, temp_symbol)
|
||
margin = current_temp - baseline_val
|
||
triggered = margin > threshold and len(available) >= 2
|
||
|
||
return {
|
||
"type": "forecast_breakthrough",
|
||
"triggered": triggered,
|
||
"current_temp": round(current_temp, 2),
|
||
"model_highs": {k: v for k, v in available},
|
||
"baseline_model": baseline_name,
|
||
"baseline_high": round(baseline_val, 2),
|
||
"margin": round(margin, 2),
|
||
"threshold": round(threshold, 2),
|
||
"model_coverage": f"{len(available)}/3",
|
||
}
|
||
|
||
|
||
def _pick_leading_station(city: str, nearby: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||
if not nearby:
|
||
return None
|
||
city_l = (city or "").lower()
|
||
|
||
def _temp(row: Dict[str, Any]) -> float:
|
||
return _sf(row.get("temp")) or -999.0
|
||
|
||
if city_l == "ankara":
|
||
priority_rows = []
|
||
for row in nearby:
|
||
name = str(row.get("name") or "").lower()
|
||
sid = str(row.get("istNo") or "").strip()
|
||
if sid == "17130" or "center" in name or "bölge" in name or "etimesgut" in name:
|
||
priority_rows.append(row)
|
||
if priority_rows:
|
||
return max(priority_rows, key=_temp)
|
||
|
||
return max(nearby, key=_temp)
|
||
|
||
|
||
def _pick_ankara_center_station(nearby: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||
if not nearby:
|
||
return None
|
||
|
||
for row in nearby:
|
||
name = str(row.get("name") or "").strip().lower()
|
||
sid = str(row.get("istNo") or "").strip()
|
||
if sid == "17130":
|
||
return row
|
||
if name in {"ankara (bölge/center)", "ankara (bolge/center)"}:
|
||
return row
|
||
return None
|
||
|
||
|
||
def _calc_ankara_center_deb_alert(
|
||
city_weather: Dict[str, Any],
|
||
temp_symbol: str,
|
||
) -> Dict[str, Any]:
|
||
city = (city_weather.get("name") or "").lower()
|
||
if city != "ankara":
|
||
return {
|
||
"type": "ankara_center_deb_hit",
|
||
"triggered": False,
|
||
"reason": "city is not ankara",
|
||
}
|
||
|
||
deb_prediction = _sf((city_weather.get("deb") or {}).get("prediction"))
|
||
if deb_prediction is None:
|
||
return {
|
||
"type": "ankara_center_deb_hit",
|
||
"triggered": False,
|
||
"reason": "deb prediction unavailable",
|
||
}
|
||
|
||
center_station = _pick_ankara_center_station(city_weather.get("mgm_nearby") or [])
|
||
if not center_station:
|
||
return {
|
||
"type": "ankara_center_deb_hit",
|
||
"triggered": False,
|
||
"reason": "ankara center station unavailable",
|
||
}
|
||
|
||
center_temp = _sf(center_station.get("temp"))
|
||
if center_temp is None:
|
||
return {
|
||
"type": "ankara_center_deb_hit",
|
||
"triggered": False,
|
||
"reason": "ankara center temperature unavailable",
|
||
}
|
||
|
||
airport_temp = _sf((city_weather.get("current") or {}).get("temp"))
|
||
epsilon = _to_unit_delta(0.05, temp_symbol)
|
||
triggered = center_temp + epsilon >= deb_prediction
|
||
|
||
return {
|
||
"type": "ankara_center_deb_hit",
|
||
"triggered": triggered,
|
||
"force_push": triggered,
|
||
"center_station": {
|
||
"name": center_station.get("name"),
|
||
"istNo": center_station.get("istNo"),
|
||
"temp": round(center_temp, 2),
|
||
},
|
||
"deb_prediction": round(deb_prediction, 2),
|
||
"airport_temp": round(airport_temp, 2) if airport_temp is not None else None,
|
||
"margin_vs_deb": round(center_temp - deb_prediction, 2),
|
||
"center_lead_vs_airport": (
|
||
round(center_temp - airport_temp, 2)
|
||
if airport_temp is not None
|
||
else None
|
||
),
|
||
}
|
||
|
||
|
||
def _calc_advection_alert(city_weather: Dict[str, Any], temp_symbol: str) -> Dict[str, Any]:
|
||
city = (city_weather.get("name") or "").lower()
|
||
current = city_weather.get("current") or {}
|
||
current_temp = _sf(current.get("temp"))
|
||
wind_now = _sf(current.get("wind_dir"))
|
||
wind_speed = _sf(current.get("wind_speed_kt"))
|
||
|
||
if current_temp is None:
|
||
return {
|
||
"type": "advection",
|
||
"triggered": False,
|
||
"reason": "current temperature unavailable",
|
||
}
|
||
|
||
recent_obs = city_weather.get("metar_recent_obs") or []
|
||
wind_prev = None
|
||
for obs in recent_obs[1:]:
|
||
w = _sf(obs.get("wdir"))
|
||
if w is not None:
|
||
wind_prev = w
|
||
break
|
||
|
||
nearby = city_weather.get("mgm_nearby") or []
|
||
lead_station = _pick_leading_station(city, nearby)
|
||
if not lead_station:
|
||
return {
|
||
"type": "advection",
|
||
"triggered": False,
|
||
"reason": "no nearby stations available",
|
||
}
|
||
|
||
lead_temp = _sf(lead_station.get("temp"))
|
||
if lead_temp is None:
|
||
return {
|
||
"type": "advection",
|
||
"triggered": False,
|
||
"reason": "leading station temperature unavailable",
|
||
}
|
||
|
||
lead_delta = lead_temp - current_temp
|
||
min_delta = _to_unit_delta(1.0, temp_symbol)
|
||
if city == "ankara":
|
||
# Ankara center station often leads airport by a bit less than 1C.
|
||
min_delta = _to_unit_delta(0.8, temp_symbol)
|
||
|
||
turned_southerly = _is_southerly(wind_now) and (wind_prev is not None and not _is_southerly(wind_prev))
|
||
warm_flow_now = _is_southerly(wind_now) and (wind_speed is None or wind_speed >= 6.0)
|
||
|
||
alignment = None
|
||
aligned = True
|
||
st_lat = _sf(lead_station.get("lat"))
|
||
st_lon = _sf(lead_station.get("lon"))
|
||
city_lat = _sf(city_weather.get("lat"))
|
||
city_lon = _sf(city_weather.get("lon"))
|
||
if all(v is not None for v in (st_lat, st_lon, city_lat, city_lon, wind_now)):
|
||
station_to_city = _bearing_deg(st_lat, st_lon, city_lat, city_lon)
|
||
wind_to_dir = (wind_now + 180.0) % 360.0 # meteorological wind_dir is "from"
|
||
alignment = _angle_diff(station_to_city, wind_to_dir)
|
||
aligned = alignment <= 70.0
|
||
|
||
triggered = lead_delta >= min_delta and aligned and (turned_southerly or warm_flow_now)
|
||
|
||
lead_minutes = None
|
||
if triggered:
|
||
if lead_delta >= _to_unit_delta(1.5, temp_symbol) and (alignment is None or alignment <= 45):
|
||
lead_minutes = "20-30"
|
||
else:
|
||
lead_minutes = "20-40"
|
||
|
||
return {
|
||
"type": "advection",
|
||
"triggered": triggered,
|
||
"lead_station": {
|
||
"name": lead_station.get("name"),
|
||
"istNo": lead_station.get("istNo"),
|
||
"temp": round(lead_temp, 2),
|
||
},
|
||
"lead_delta": round(lead_delta, 2),
|
||
"threshold_delta": round(min_delta, 2),
|
||
"wind_now": round(wind_now, 1) if wind_now is not None else None,
|
||
"wind_prev": round(wind_prev, 1) if wind_prev is not None else None,
|
||
"turned_southerly": turned_southerly,
|
||
"wind_alignment_deg": round(alignment, 1) if alignment is not None else None,
|
||
"lead_window_minutes": lead_minutes,
|
||
}
|
||
|
||
|
||
def _calc_peak_passed_guard(city_weather: Dict[str, Any], temp_symbol: str) -> Dict[str, Any]:
|
||
current = city_weather.get("current") or {}
|
||
current_temp = _sf(current.get("temp"))
|
||
max_so_far = _sf(current.get("max_so_far"))
|
||
max_temp_time = current.get("max_temp_time")
|
||
local_time = city_weather.get("local_time")
|
||
|
||
if current_temp is None or max_so_far is None:
|
||
return {"suppressed": False, "reason": "missing current/max_so_far"}
|
||
|
||
local_min = _minute_of_day(local_time)
|
||
peak_min = _minute_of_day(max_temp_time)
|
||
if local_min is None or peak_min is None:
|
||
return {"suppressed": False, "reason": "missing local_time/max_temp_time"}
|
||
|
||
# Do not suppress in the morning; many cities still make their daily high later.
|
||
if local_min < (14 * 60 + 30):
|
||
return {"suppressed": False, "reason": "too early in local day"}
|
||
|
||
if peak_min >= local_min:
|
||
return {"suppressed": False, "reason": "peak has not passed yet"}
|
||
|
||
minutes_since_peak = local_min - peak_min
|
||
rollback = max_so_far - current_temp
|
||
rollback_threshold = _to_unit_delta(0.8, temp_symbol)
|
||
cooled_off = rollback >= rollback_threshold
|
||
suppressed = minutes_since_peak >= 45 and cooled_off
|
||
|
||
return {
|
||
"suppressed": suppressed,
|
||
"reason": "late-day peak already passed" if suppressed else "cool-off threshold not met",
|
||
"current_temp": round(current_temp, 2),
|
||
"max_so_far": round(max_so_far, 2),
|
||
"max_temp_time": max_temp_time,
|
||
"local_time": local_time,
|
||
"minutes_since_peak": minutes_since_peak,
|
||
"rollback": round(rollback, 2),
|
||
"rollback_threshold": round(rollback_threshold, 2),
|
||
}
|
||
|
||
|
||
def _join_trigger_types_cn(rules: Dict[str, Dict[str, Any]]) -> str:
|
||
mapping = [
|
||
("ankara_center_deb_hit", "Center达到DEB"),
|
||
("momentum_spike", "动量突变"),
|
||
("forecast_breakthrough", "预测突破"),
|
||
("advection", "暖平流"),
|
||
]
|
||
parts = [name for key, name in mapping if rules.get(key, {}).get("triggered")]
|
||
return " + ".join(parts)
|
||
|
||
|
||
def _norm_probability(v: Any) -> Optional[float]:
|
||
n = _sf(v)
|
||
if n is None:
|
||
return None
|
||
if n > 1.0:
|
||
n = n / 100.0
|
||
return max(0.0, min(1.0, n))
|
||
|
||
|
||
def _fmt_percent(v: Any) -> str:
|
||
n = _norm_probability(v)
|
||
if n is None:
|
||
return "--"
|
||
return f"{n * 100:.1f}%"
|
||
|
||
|
||
def _fmt_cents(v: Any) -> str:
|
||
n = _norm_probability(v)
|
||
if n is None:
|
||
return "--"
|
||
cents = n * 100.0
|
||
return f"{cents:.1f}c"
|
||
|
||
|
||
def _bucket_label(bucket: Any) -> Optional[str]:
|
||
if not isinstance(bucket, dict):
|
||
return None
|
||
direct = (
|
||
str(bucket.get("label") or "").strip()
|
||
or str(bucket.get("bucket") or "").strip()
|
||
or str(bucket.get("range") or "").strip()
|
||
)
|
||
if direct:
|
||
return direct
|
||
value = _sf(bucket.get("value"))
|
||
if value is not None:
|
||
return f"{round(value)}C"
|
||
temp = _sf(bucket.get("temp"))
|
||
if temp is not None:
|
||
return f"{round(temp)}C"
|
||
return None
|
||
|
||
|
||
def _extract_market_snapshot(city_weather: Dict[str, Any]) -> Dict[str, Any]:
|
||
scan = city_weather.get("market_scan") or {}
|
||
if not isinstance(scan, dict):
|
||
return {"available": False}
|
||
if not scan.get("available"):
|
||
return {"available": False}
|
||
|
||
yes_buy = _norm_probability(scan.get("yes_buy"))
|
||
yes_sell = _norm_probability(scan.get("yes_sell"))
|
||
market_prob = _norm_probability(
|
||
scan.get("market_price")
|
||
or ((scan.get("yes_token") or {}).get("implied_probability"))
|
||
)
|
||
model_prob = _norm_probability(scan.get("model_probability"))
|
||
spread = None
|
||
if yes_buy is not None and yes_sell is not None:
|
||
spread = abs(yes_sell - yes_buy)
|
||
|
||
top_bucket = None
|
||
top_bucket_rows: List[Dict[str, Any]] = []
|
||
top_buckets = scan.get("top_buckets") or []
|
||
if isinstance(top_buckets, list):
|
||
normalized = []
|
||
for row in top_buckets:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
p = _norm_probability(row.get("probability"))
|
||
if p is None:
|
||
continue
|
||
normalized.append((p, row))
|
||
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(
|
||
{
|
||
"label": _bucket_label(row),
|
||
"probability": p,
|
||
"yes_buy": _norm_probability(row.get("yes_buy")),
|
||
"yes_sell": _norm_probability(row.get("yes_sell")),
|
||
}
|
||
)
|
||
|
||
return {
|
||
"available": True,
|
||
"selected_bucket": _bucket_label(scan.get("temperature_bucket")),
|
||
"top_bucket": _bucket_label(top_bucket) if isinstance(top_bucket, dict) else None,
|
||
"top_bucket_prob": _norm_probability(
|
||
top_bucket.get("probability") if isinstance(top_bucket, dict) else None
|
||
),
|
||
"market_prob": market_prob,
|
||
"model_prob": model_prob,
|
||
"yes_buy": yes_buy,
|
||
"yes_sell": yes_sell,
|
||
"spread": spread,
|
||
"edge_percent": _sf(scan.get("edge_percent")),
|
||
"signal_label": scan.get("signal_label"),
|
||
"confidence": scan.get("confidence"),
|
||
"top_bucket_rows": top_bucket_rows,
|
||
}
|
||
|
||
|
||
def _build_advice_cn(
|
||
rules: Dict[str, Dict[str, Any]],
|
||
temp_symbol: str,
|
||
suppression: Optional[Dict[str, Any]] = None,
|
||
) -> str:
|
||
if (suppression or {}).get("suppressed"):
|
||
max_so_far = _sf((suppression or {}).get("max_so_far"))
|
||
max_temp_time = (suppression or {}).get("max_temp_time")
|
||
rollback = _sf((suppression or {}).get("rollback"))
|
||
if max_so_far is not None and max_temp_time and rollback is not None:
|
||
return (
|
||
f"当地高温大概率已在 {max_temp_time} 前后兑现,"
|
||
f"较日内高点 {max_so_far:.1f}{temp_symbol} 已回落 {rollback:.1f}{temp_symbol},暂停主动推送。"
|
||
)
|
||
return "当地高温大概率已经兑现,当前进入回落阶段,暂停主动推送。"
|
||
|
||
parts: List[str] = []
|
||
center_deb = rules.get("ankara_center_deb_hit", {})
|
||
advection = rules.get("advection", {})
|
||
momentum = rules.get("momentum_spike", {})
|
||
breakthrough = rules.get("forecast_breakthrough", {})
|
||
|
||
if center_deb.get("triggered"):
|
||
deb_prediction = _sf(center_deb.get("deb_prediction"))
|
||
center_temp = _sf(((center_deb.get("center_station") or {}).get("temp")))
|
||
if deb_prediction is not None and center_temp is not None:
|
||
parts.append(
|
||
f"Ankara Center {center_temp:.1f}{temp_symbol} 已触及 DEB {deb_prediction:.1f}{temp_symbol}"
|
||
)
|
||
else:
|
||
parts.append("Ankara Center 已触及 DEB 预测值")
|
||
if advection.get("triggered"):
|
||
parts.append("风向转南,暖平流增强")
|
||
if momentum.get("triggered"):
|
||
d = _sf(momentum.get("slope_30m")) or 0.0
|
||
if d > 0:
|
||
parts.append("短时升温斜率过快")
|
||
else:
|
||
parts.append("短时降温斜率过快")
|
||
if breakthrough.get("triggered"):
|
||
parts.append("实测已击穿主流模型上沿")
|
||
|
||
if not parts:
|
||
return "当前未触发高优先级天气异动,继续观察实测与模型联动。"
|
||
return ",".join(parts) + "。"
|
||
|
||
|
||
def _build_telegram_messages(
|
||
city_weather: Dict[str, Any],
|
||
rules: Dict[str, Dict[str, Any]],
|
||
map_url: Optional[str],
|
||
market_snapshot: Optional[Dict[str, Any]] = None,
|
||
suppression: Optional[Dict[str, Any]] = None,
|
||
) -> Dict[str, str]:
|
||
temp_symbol = city_weather.get("temp_symbol", "°C")
|
||
city_name = city_weather.get("display_name") or city_weather.get("name", "").title()
|
||
current_temp = _sf((city_weather.get("current") or {}).get("temp"))
|
||
local_time = str(city_weather.get("local_time") or "").strip()
|
||
obs_time = str(((city_weather.get("current") or {}).get("obs_time")) or "").strip()
|
||
center_deb = rules.get("ankara_center_deb_hit", {})
|
||
momentum = rules.get("momentum_spike", {})
|
||
advection = rules.get("advection", {})
|
||
market_snapshot = market_snapshot or _extract_market_snapshot(city_weather)
|
||
|
||
if current_temp is None:
|
||
return {"zh": "", "en": ""}
|
||
|
||
suppressed = bool((suppression or {}).get("suppressed"))
|
||
has_active_trigger = any(rule.get("triggered") for rule in rules.values())
|
||
if suppressed:
|
||
types_cn = "高温已过(暂停推送)"
|
||
else:
|
||
types_cn = _join_trigger_types_cn(rules) or "天气状态快照"
|
||
delta_temp = _sf(momentum.get("delta_temp"))
|
||
delta_min = momentum.get("delta_minutes")
|
||
center_station = center_deb.get("center_station") or {}
|
||
|
||
dyn = f"实测 {current_temp:.1f}{temp_symbol}"
|
||
if delta_temp is not None and delta_min is not None:
|
||
icon = "🚀" if delta_temp > 0 else ("🧊" if delta_temp < 0 else "➖")
|
||
dyn += f" ({int(delta_min)}min 内 {delta_temp:+.1f}{temp_symbol}) {icon}"
|
||
|
||
lead_line = ""
|
||
if advection.get("triggered"):
|
||
st_name = ((advection.get("lead_station") or {}).get("name")) or "nearby station"
|
||
lead_delta = _sf(advection.get("lead_delta"))
|
||
if lead_delta is not None:
|
||
lead_line = f"联动:{st_name} 已领先 {lead_delta:+.1f}{temp_symbol}"
|
||
|
||
center_deb_line = ""
|
||
if center_deb.get("triggered"):
|
||
center_name = center_station.get("name") or "Ankara Center"
|
||
center_temp = _sf(center_station.get("temp"))
|
||
deb_prediction = _sf(center_deb.get("deb_prediction"))
|
||
airport_temp = _sf(center_deb.get("airport_temp"))
|
||
lead_gap = _sf(center_deb.get("center_lead_vs_airport"))
|
||
if center_temp is not None and deb_prediction is not None:
|
||
center_deb_line = (
|
||
f"Center信号:{center_name} {center_temp:.1f}{temp_symbol} 已达到 DEB {deb_prediction:.1f}{temp_symbol}"
|
||
)
|
||
if airport_temp is not None:
|
||
center_deb_line += f" | 机场 {airport_temp:.1f}{temp_symbol}"
|
||
if lead_gap is not None:
|
||
center_deb_line += f" | 领先 {lead_gap:+.1f}{temp_symbol}"
|
||
|
||
peak_line = ""
|
||
if suppressed:
|
||
max_so_far = _sf((suppression or {}).get("max_so_far"))
|
||
max_temp_time = (suppression or {}).get("max_temp_time")
|
||
rollback = _sf((suppression or {}).get("rollback"))
|
||
if max_so_far is not None and max_temp_time and rollback is not None:
|
||
peak_line = (
|
||
f"高温状态:日内高点 {max_so_far:.1f}{temp_symbol} @ {max_temp_time},"
|
||
f"当前已回落 {rollback:.1f}{temp_symbol}"
|
||
)
|
||
|
||
advice = _build_advice_cn(rules, temp_symbol, suppression=suppression)
|
||
final_map = map_url or "https://polyweather-pro.vercel.app/"
|
||
title_zh = "🚨 PolyWeather 异动预警" if has_active_trigger else "📍 PolyWeather 状态快照"
|
||
title_en = "🚨 PolyWeather Alert" if has_active_trigger else "📍 PolyWeather Status"
|
||
|
||
lines_zh = [
|
||
f"{title_zh} [{city_name}]",
|
||
"",
|
||
f"类型:{types_cn}",
|
||
f"动态:{dyn}",
|
||
]
|
||
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}")
|
||
if center_deb_line:
|
||
lines_zh.append(center_deb_line)
|
||
if peak_line:
|
||
lines_zh.append(peak_line)
|
||
if lead_line:
|
||
lines_zh.append(lead_line)
|
||
if market_snapshot.get("available") and market_snapshot.get("top_bucket_rows"):
|
||
lines_zh.append("市场结算概率分布(Top4):")
|
||
for row in (market_snapshot.get("top_bucket_rows") or [])[:4]:
|
||
label = row.get("label") or "--"
|
||
prob_text = _fmt_percent(row.get("probability"))
|
||
yes_buy_text = _fmt_cents(row.get("yes_buy"))
|
||
lines_zh.append(f"{label} {prob_text} | 买Yes: {yes_buy_text}")
|
||
if market_snapshot.get("available") and not market_snapshot.get("top_bucket_rows"):
|
||
market_edge = _sf(market_snapshot.get("edge_percent"))
|
||
market_edge_text = f"{market_edge:+.1f}%" if market_edge is not None else "--"
|
||
lines_zh.append(
|
||
"市场联动:同桶 "
|
||
f"模型 {_fmt_percent(market_snapshot.get('model_prob'))} vs "
|
||
f"市场 {_fmt_percent(market_snapshot.get('market_prob'))} | "
|
||
f"Yes {_fmt_cents(market_snapshot.get('yes_buy'))}/{_fmt_cents(market_snapshot.get('yes_sell'))} | "
|
||
f"点差 {_fmt_cents(market_snapshot.get('spread'))} | "
|
||
f"偏差 {market_edge_text} | "
|
||
f"信号 {market_snapshot.get('signal_label') or '--'}/{market_snapshot.get('confidence') or '--'}"
|
||
)
|
||
if market_snapshot.get("top_bucket"):
|
||
lines_zh.append(
|
||
f"市场最热桶:{market_snapshot.get('top_bucket')} "
|
||
f"({_fmt_percent(market_snapshot.get('top_bucket_prob'))})"
|
||
)
|
||
lines_zh.append(f"AI 建议:{advice}")
|
||
lines_zh.append(f"点击查看实时地图:{final_map}")
|
||
|
||
type_en = []
|
||
if rules.get("ankara_center_deb_hit", {}).get("triggered"):
|
||
type_en.append("Center Reached DEB")
|
||
if rules.get("momentum_spike", {}).get("triggered"):
|
||
type_en.append("Momentum Spike")
|
||
if rules.get("forecast_breakthrough", {}).get("triggered"):
|
||
type_en.append("Forecast Breakthrough")
|
||
if rules.get("advection", {}).get("triggered"):
|
||
type_en.append("Advection")
|
||
type_en_str = "Peak Passed (suppressed)" if suppressed else (" + ".join(type_en) or "Weather snapshot")
|
||
|
||
lines_en = [
|
||
f"{title_en} [{city_name}]",
|
||
"",
|
||
f"Type: {type_en_str}",
|
||
f"Now: {current_temp:.1f}{temp_symbol}",
|
||
]
|
||
if local_time or obs_time:
|
||
if local_time and obs_time:
|
||
lines_en.append(f"Time: local {local_time} | observed {obs_time}")
|
||
elif local_time:
|
||
lines_en.append(f"Time: local {local_time}")
|
||
else:
|
||
lines_en.append(f"Time: observed {obs_time}")
|
||
if center_deb_line:
|
||
center_temp = _sf(center_station.get("temp"))
|
||
deb_prediction = _sf(center_deb.get("deb_prediction"))
|
||
if center_temp is not None and deb_prediction is not None:
|
||
lines_en.append(
|
||
f"Center signal: {center_temp:.1f}{temp_symbol} has reached DEB {deb_prediction:.1f}{temp_symbol}"
|
||
)
|
||
if peak_line:
|
||
max_so_far = _sf((suppression or {}).get("max_so_far"))
|
||
max_temp_time = (suppression or {}).get("max_temp_time")
|
||
rollback = _sf((suppression or {}).get("rollback"))
|
||
if max_so_far is not None and max_temp_time and rollback is not None:
|
||
lines_en.append(
|
||
f"Peak state: intraday high {max_so_far:.1f}{temp_symbol} at {max_temp_time}, "
|
||
f"now off by {rollback:.1f}{temp_symbol}"
|
||
)
|
||
if market_snapshot.get("available") and market_snapshot.get("top_bucket_rows"):
|
||
lines_en.append("Settlement distribution (Top4):")
|
||
for row in (market_snapshot.get("top_bucket_rows") or [])[:4]:
|
||
label = row.get("label") or "--"
|
||
prob_text = _fmt_percent(row.get("probability"))
|
||
yes_buy_text = _fmt_cents(row.get("yes_buy"))
|
||
lines_en.append(f"{label} {prob_text} | Buy Yes: {yes_buy_text}")
|
||
if market_snapshot.get("available") and not market_snapshot.get("top_bucket_rows"):
|
||
market_edge = _sf(market_snapshot.get("edge_percent"))
|
||
market_edge_text = f"{market_edge:+.1f}%" if market_edge is not None else "--"
|
||
lines_en.append(
|
||
"Market: same-bucket "
|
||
f"model {_fmt_percent(market_snapshot.get('model_prob'))} vs "
|
||
f"market {_fmt_percent(market_snapshot.get('market_prob'))} | "
|
||
f"Yes {_fmt_cents(market_snapshot.get('yes_buy'))}/{_fmt_cents(market_snapshot.get('yes_sell'))} | "
|
||
f"spread {_fmt_cents(market_snapshot.get('spread'))} | "
|
||
f"edge {market_edge_text} | "
|
||
f"signal {market_snapshot.get('signal_label') or '--'}/{market_snapshot.get('confidence') or '--'}"
|
||
)
|
||
if market_snapshot.get("top_bucket"):
|
||
lines_en.append(
|
||
f"Top market bucket: {market_snapshot.get('top_bucket')} "
|
||
f"({_fmt_percent(market_snapshot.get('top_bucket_prob'))})"
|
||
)
|
||
lines_en.append(f"Action: {advice}")
|
||
lines_en.append(f"Map: {final_map}")
|
||
|
||
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,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Build weather-driven trading alerts for paid Telegram delivery and web usage.
|
||
"""
|
||
temp_symbol = city_weather.get("temp_symbol", "°C")
|
||
city = city_weather.get("name", "")
|
||
now = datetime.now(timezone.utc).isoformat()
|
||
market_snapshot = _extract_market_snapshot(city_weather)
|
||
|
||
rules: Dict[str, Dict[str, Any]] = {
|
||
"ankara_center_deb_hit": _calc_ankara_center_deb_alert(city_weather, temp_symbol),
|
||
"momentum_spike": _calc_momentum_alert(city_weather, temp_symbol),
|
||
"forecast_breakthrough": _calc_forecast_breakthrough_alert(city_weather, temp_symbol),
|
||
"advection": _calc_advection_alert(city_weather, temp_symbol),
|
||
}
|
||
|
||
triggered = [
|
||
{
|
||
"type": key,
|
||
**value,
|
||
}
|
||
for key, value in rules.items()
|
||
if value.get("triggered")
|
||
]
|
||
suppression = _calc_peak_passed_guard(city_weather, temp_symbol)
|
||
if suppression.get("suppressed") and triggered:
|
||
suppression["raw_trigger_types"] = [alert.get("type") for alert in triggered if alert.get("type")]
|
||
for alert in triggered:
|
||
rule = rules.get(alert.get("type") or "")
|
||
if not rule:
|
||
continue
|
||
rule["raw_triggered"] = True
|
||
rule["triggered"] = False
|
||
rule["suppressed"] = True
|
||
rule["suppression_reason"] = suppression.get("reason")
|
||
triggered = []
|
||
force_push = False
|
||
severity = "none"
|
||
else:
|
||
force_push = any(alert.get("force_push") for alert in triggered)
|
||
severity = "high" if len(triggered) >= 2 else ("medium" if len(triggered) == 1 else "none")
|
||
if force_push and severity == "none":
|
||
severity = "medium"
|
||
|
||
telegram = _build_telegram_messages(
|
||
city_weather=city_weather,
|
||
rules=rules,
|
||
map_url=map_url,
|
||
market_snapshot=market_snapshot,
|
||
suppression=suppression,
|
||
)
|
||
|
||
return {
|
||
"city": city,
|
||
"generated_at": now,
|
||
"temp_symbol": temp_symbol,
|
||
"severity": severity,
|
||
"trigger_count": len(triggered),
|
||
"rules": rules,
|
||
"market_snapshot": market_snapshot,
|
||
"suppression": suppression,
|
||
"triggered_alerts": triggered,
|
||
"telegram": telegram,
|
||
}
|
||
|
||
|