feat: introduce PolyWeather web map API for interactive weather data visualization, analysis, and market-related data collection.
This commit is contained in:
@@ -0,0 +1,605 @@
|
||||
"""
|
||||
Rule-based weather/market alert engine for short-horizon Polymarket trading.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
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 _convert_temp(value: float, from_unit: Optional[str], temp_symbol: str) -> float:
|
||||
from_u = (from_unit or "").upper()
|
||||
to_f = "F" in (temp_symbol or "").upper()
|
||||
if from_u == "F" and not to_f:
|
||||
return (value - 32.0) * 5.0 / 9.0
|
||||
if from_u == "C" and to_f:
|
||||
return (value * 9.0 / 5.0) + 32.0
|
||||
return value
|
||||
|
||||
|
||||
def _extract_numbers(text: str) -> List[float]:
|
||||
out: List[float] = []
|
||||
for m in re.finditer(r"-?\d+(?:\.\d+)?", text or ""):
|
||||
try:
|
||||
out.append(float(m.group(0)))
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _extract_market_strikes(
|
||||
market_snapshot: Dict[str, Any],
|
||||
temp_symbol: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
candidates: List[Dict[str, Any]] = []
|
||||
for market in market_snapshot.get("markets", []) or []:
|
||||
m_question = market.get("question") or ""
|
||||
m_id = market.get("id")
|
||||
|
||||
threshold = _sf(market.get("threshold"))
|
||||
threshold_unit = market.get("threshold_unit")
|
||||
if threshold is not None:
|
||||
candidates.append(
|
||||
{
|
||||
"strike": _convert_temp(threshold, threshold_unit, temp_symbol),
|
||||
"source": "market_threshold",
|
||||
"market_id": m_id,
|
||||
"question": m_question,
|
||||
}
|
||||
)
|
||||
|
||||
for outcome in market.get("outcomes", []) or []:
|
||||
name = str(outcome.get("name") or "")
|
||||
name_l = name.lower()
|
||||
if name_l in ("yes", "no"):
|
||||
continue
|
||||
if not any(tok in name_l for tok in ("-", "to", "below", "under", "above", "over", "deg", "f", "c")):
|
||||
continue
|
||||
vals = [v for v in _extract_numbers(name) if -80 <= v <= 160]
|
||||
for v in vals:
|
||||
candidates.append(
|
||||
{
|
||||
"strike": _convert_temp(v, threshold_unit, temp_symbol),
|
||||
"source": "outcome_number",
|
||||
"market_id": m_id,
|
||||
"question": m_question,
|
||||
}
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
def _find_market_by_id(markets: List[Dict[str, Any]], market_id: Any) -> Optional[Dict[str, Any]]:
|
||||
for m in markets:
|
||||
if str(m.get("id")) == str(market_id):
|
||||
return m
|
||||
return None
|
||||
|
||||
|
||||
def _calc_kill_zone_alert(
|
||||
city_weather: Dict[str, Any],
|
||||
market_snapshot: 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": "kill_zone",
|
||||
"triggered": False,
|
||||
"reason": "current temperature unavailable",
|
||||
}
|
||||
|
||||
candidates = _extract_market_strikes(market_snapshot, temp_symbol)
|
||||
if not candidates:
|
||||
return {
|
||||
"type": "kill_zone",
|
||||
"triggered": False,
|
||||
"reason": "no market strike candidates found",
|
||||
}
|
||||
|
||||
nearest = min(candidates, key=lambda row: abs(current_temp - row["strike"]))
|
||||
strike = _sf(nearest.get("strike"))
|
||||
if strike is None:
|
||||
return {
|
||||
"type": "kill_zone",
|
||||
"triggered": False,
|
||||
"reason": "failed to parse strike temperature",
|
||||
}
|
||||
|
||||
threshold = _to_unit_delta(0.3, temp_symbol)
|
||||
distance = abs(current_temp - strike)
|
||||
triggered = distance < threshold
|
||||
|
||||
no_probability = None
|
||||
markets = market_snapshot.get("markets", []) or []
|
||||
target_market = _find_market_by_id(markets, nearest.get("market_id"))
|
||||
if target_market:
|
||||
yes_price = None
|
||||
no_price = None
|
||||
for oc in target_market.get("outcomes", []) or []:
|
||||
oc_name = str(oc.get("name") or "").strip().lower()
|
||||
candidate_price = _sf(oc.get("buy_price"))
|
||||
if candidate_price is None:
|
||||
candidate_price = _sf(oc.get("last_price"))
|
||||
if oc_name == "yes":
|
||||
yes_price = candidate_price
|
||||
elif oc_name == "no":
|
||||
no_price = candidate_price
|
||||
if no_price is None and yes_price is not None:
|
||||
no_price = max(0.0, min(1.0, 1.0 - yes_price))
|
||||
no_probability = no_price
|
||||
|
||||
return {
|
||||
"type": "kill_zone",
|
||||
"triggered": triggered,
|
||||
"current_temp": round(current_temp, 2),
|
||||
"strike_price": round(strike, 2),
|
||||
"distance": round(distance, 2),
|
||||
"threshold": round(threshold, 2),
|
||||
"market_id": nearest.get("market_id"),
|
||||
"question": nearest.get("question"),
|
||||
"strike_source": nearest.get("source"),
|
||||
"no_probability": round(no_probability, 4) if no_probability is not None else None,
|
||||
}
|
||||
|
||||
|
||||
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 _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 _join_trigger_types_cn(rules: Dict[str, Dict[str, Any]]) -> str:
|
||||
mapping = [
|
||||
("momentum_spike", "动量突变"),
|
||||
("forecast_breakthrough", "预测突破"),
|
||||
("kill_zone", "临界触发"),
|
||||
("advection", "暖平流"),
|
||||
]
|
||||
parts = [name for key, name in mapping if rules.get(key, {}).get("triggered")]
|
||||
return " + ".join(parts)
|
||||
|
||||
|
||||
def _build_advice_cn(
|
||||
rules: Dict[str, Dict[str, Any]],
|
||||
temp_symbol: str,
|
||||
) -> str:
|
||||
parts: List[str] = []
|
||||
advection = rules.get("advection", {})
|
||||
momentum = rules.get("momentum_spike", {})
|
||||
breakthrough = rules.get("forecast_breakthrough", {})
|
||||
kill_zone = rules.get("kill_zone", {})
|
||||
|
||||
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("实测已击穿主流模型上沿")
|
||||
|
||||
no_prob = _sf(kill_zone.get("no_probability"))
|
||||
strike = _sf(kill_zone.get("strike_price"))
|
||||
if kill_zone.get("triggered") and no_prob is not None and strike is not None:
|
||||
parts.append(f'{no_prob * 100:.0f}% 概率的 {strike:.1f}{temp_symbol} "No" 单需谨慎')
|
||||
elif kill_zone.get("triggered") and strike is not None:
|
||||
parts.append(f"接近 {strike:.1f}{temp_symbol} 结算阻力位,波动率可能激增")
|
||||
|
||||
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],
|
||||
) -> 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"))
|
||||
momentum = rules.get("momentum_spike", {})
|
||||
kill_zone = rules.get("kill_zone", {})
|
||||
advection = rules.get("advection", {})
|
||||
|
||||
if current_temp is None:
|
||||
return {"zh": "", "en": ""}
|
||||
|
||||
types_cn = _join_trigger_types_cn(rules) or "盘口异动"
|
||||
delta_temp = _sf(momentum.get("delta_temp"))
|
||||
delta_min = momentum.get("delta_minutes")
|
||||
strike = _sf(kill_zone.get("strike_price"))
|
||||
distance = _sf(kill_zone.get("distance"))
|
||||
|
||||
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}"
|
||||
|
||||
strike_line = ""
|
||||
if strike is not None and distance is not None:
|
||||
if current_temp < strike:
|
||||
strike_line = f"距离 {strike:.1f}{temp_symbol} 档位:还差 {distance:.1f}{temp_symbol}"
|
||||
else:
|
||||
strike_line = f"距离 {strike:.1f}{temp_symbol} 档位:高出 {distance:.1f}{temp_symbol}"
|
||||
|
||||
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}"
|
||||
|
||||
advice = _build_advice_cn(rules, temp_symbol)
|
||||
final_map = map_url or "https://polyweather.vercel.app"
|
||||
|
||||
lines_zh = [
|
||||
f"🚨 PolyWeather 异动预警 [{city_name}]",
|
||||
"",
|
||||
f"类型:{types_cn}",
|
||||
f"动态:{dyn}",
|
||||
]
|
||||
if strike_line:
|
||||
lines_zh.append(strike_line)
|
||||
if lead_line:
|
||||
lines_zh.append(lead_line)
|
||||
lines_zh.append(f"AI 建议:{advice}")
|
||||
lines_zh.append(f"点击查看实时地图:{final_map}")
|
||||
|
||||
type_en = []
|
||||
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("kill_zone", {}).get("triggered"):
|
||||
type_en.append("Kill Zone")
|
||||
if rules.get("advection", {}).get("triggered"):
|
||||
type_en.append("Advection")
|
||||
type_en_str = " + ".join(type_en) or "Market anomaly"
|
||||
|
||||
lines_en = [
|
||||
f"🚨 PolyWeather Alert [{city_name}]",
|
||||
"",
|
||||
f"Type: {type_en_str}",
|
||||
f"Now: {current_temp:.1f}{temp_symbol}",
|
||||
]
|
||||
if strike is not None and distance is not None:
|
||||
lines_en.append(f"Distance to {strike:.1f}{temp_symbol} strike: {distance:.1f}{temp_symbol}")
|
||||
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],
|
||||
market_snapshot: Dict[str, Any],
|
||||
map_url: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Build weather+market 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()
|
||||
|
||||
rules: Dict[str, Dict[str, Any]] = {
|
||||
"momentum_spike": _calc_momentum_alert(city_weather, temp_symbol),
|
||||
"forecast_breakthrough": _calc_forecast_breakthrough_alert(city_weather, temp_symbol),
|
||||
"kill_zone": _calc_kill_zone_alert(city_weather, market_snapshot, temp_symbol),
|
||||
"advection": _calc_advection_alert(city_weather, temp_symbol),
|
||||
}
|
||||
|
||||
triggered = [
|
||||
{
|
||||
"type": key,
|
||||
**value,
|
||||
}
|
||||
for key, value in rules.items()
|
||||
if value.get("triggered")
|
||||
]
|
||||
severity = "high" if len(triggered) >= 2 else ("medium" if len(triggered) == 1 else "none")
|
||||
|
||||
telegram = _build_telegram_messages(
|
||||
city_weather=city_weather,
|
||||
rules=rules,
|
||||
map_url=map_url,
|
||||
)
|
||||
|
||||
return {
|
||||
"city": city,
|
||||
"generated_at": now,
|
||||
"temp_symbol": temp_symbol,
|
||||
"severity": severity,
|
||||
"trigger_count": len(triggered),
|
||||
"rules": rules,
|
||||
"triggered_alerts": triggered,
|
||||
"telegram": telegram,
|
||||
}
|
||||
|
||||
|
||||
@@ -590,6 +590,9 @@ def build_city_market_snapshot(
|
||||
"question": market.get("question"),
|
||||
"city": market.get("city"),
|
||||
"date": market.get("date"),
|
||||
"threshold": market.get("threshold"),
|
||||
"threshold_unit": market.get("threshold_unit"),
|
||||
"contract_type": market.get("contract_type"),
|
||||
"slug": market.get("slug"),
|
||||
"url": market.get("url"),
|
||||
"volume": market.get("volume"),
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
from src.analysis.market_alert_engine import build_trading_alerts
|
||||
|
||||
|
||||
def _sample_weather_payload():
|
||||
return {
|
||||
"name": "ankara",
|
||||
"display_name": "Ankara",
|
||||
"lat": 40.1281,
|
||||
"lon": 32.9951,
|
||||
"temp_symbol": "°C",
|
||||
"current": {
|
||||
"temp": 11.3,
|
||||
"wind_dir": 180.0,
|
||||
"wind_speed_kt": 11.0,
|
||||
},
|
||||
"trend": {
|
||||
"recent": [
|
||||
{"time": "10:30", "temp": 11.3},
|
||||
{"time": "10:00", "temp": 10.3},
|
||||
{"time": "09:30", "temp": 9.9},
|
||||
]
|
||||
},
|
||||
"multi_model": {
|
||||
"MGM": 10.8,
|
||||
"GFS": 10.4,
|
||||
"ECMWF": 10.6,
|
||||
},
|
||||
"metar_recent_obs": [
|
||||
{"time": "10:30", "wdir": 180},
|
||||
{"time": "10:00", "wdir": 60},
|
||||
],
|
||||
"mgm_nearby": [
|
||||
{
|
||||
"name": "Ankara (Bölge/Center)",
|
||||
"istNo": "17130",
|
||||
"lat": 39.95,
|
||||
"lon": 32.97,
|
||||
"temp": 12.4,
|
||||
},
|
||||
{
|
||||
"name": "Airport (MGM/17128)",
|
||||
"istNo": "17128",
|
||||
"lat": 40.1281,
|
||||
"lon": 32.9951,
|
||||
"temp": 11.2,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _sample_market_snapshot():
|
||||
return {
|
||||
"city": "ankara",
|
||||
"target_date": "2026-03-07",
|
||||
"markets": [
|
||||
{
|
||||
"id": "m1",
|
||||
"question": "Will temperature in Ankara exceed 11.5°C on March 7?",
|
||||
"threshold": 11.5,
|
||||
"threshold_unit": "C",
|
||||
"contract_type": "exceed",
|
||||
"outcomes": [
|
||||
{"name": "Yes", "buy_price": 0.73, "last_price": 0.72},
|
||||
{"name": "No", "buy_price": 0.27, "last_price": 0.28},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_trading_alerts_all_core_rules_trigger():
|
||||
out = build_trading_alerts(
|
||||
city_weather=_sample_weather_payload(),
|
||||
market_snapshot=_sample_market_snapshot(),
|
||||
map_url="https://example.com/map",
|
||||
)
|
||||
|
||||
assert out["trigger_count"] >= 3
|
||||
assert out["rules"]["momentum_spike"]["triggered"] is True
|
||||
assert out["rules"]["forecast_breakthrough"]["triggered"] is True
|
||||
assert out["rules"]["kill_zone"]["triggered"] is True
|
||||
assert out["rules"]["advection"]["triggered"] is True
|
||||
|
||||
msg = out["telegram"]["zh"]
|
||||
assert "PolyWeather 异动预警" in msg
|
||||
assert "动量突变" in msg
|
||||
assert "No\" 单需谨慎" in msg
|
||||
assert "https://example.com/map" in msg
|
||||
|
||||
|
||||
def test_forecast_breakthrough_not_triggered_when_current_not_above_margin():
|
||||
city_weather = _sample_weather_payload()
|
||||
city_weather["current"]["temp"] = 11.0
|
||||
|
||||
out = build_trading_alerts(
|
||||
city_weather=city_weather,
|
||||
market_snapshot=_sample_market_snapshot(),
|
||||
)
|
||||
assert out["rules"]["forecast_breakthrough"]["triggered"] is False
|
||||
|
||||
+48
-1
@@ -399,6 +399,8 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
"pressure": _sf(mgc.get("pressure")),
|
||||
"cloud_cover": mgc.get("cloud_cover"),
|
||||
"rain_24h": _sf(mgc.get("rain_24h")),
|
||||
"today_high": _sf(mgm.get("today_high")),
|
||||
"today_low": _sf(mgm.get("today_low")),
|
||||
"hourly": [],
|
||||
}
|
||||
|
||||
@@ -528,6 +530,7 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
{"time": t, "temp": v}
|
||||
for t, v in (metar.get("today_obs", []) if metar else [])
|
||||
],
|
||||
"metar_recent_obs": metar.get("recent_obs", []) if metar else [],
|
||||
"ai_analysis": ai_text,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
@@ -636,12 +639,13 @@ async def city_polymarket_alerts(
|
||||
force_refresh: bool = False,
|
||||
):
|
||||
"""
|
||||
Return only anomaly alerts for Polymarket city/date orderbooks.
|
||||
Return orderbook anomalies plus strategy-focused trading alerts.
|
||||
"""
|
||||
city = _normalize_city_or_404(name)
|
||||
resolved_date = _resolve_target_date(city, target_date)
|
||||
|
||||
from src.data_collection.polymarket_client import build_city_market_snapshot
|
||||
from src.analysis.market_alert_engine import build_trading_alerts
|
||||
|
||||
proxy = (
|
||||
(_config.get("polymarket", {}) or {}).get("proxy")
|
||||
@@ -653,15 +657,58 @@ async def city_polymarket_alerts(
|
||||
proxy=proxy,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
city_weather = _analyze(city, force_refresh=force_refresh)
|
||||
map_url = os.getenv("POLYWEATHER_MAP_URL") or "https://polyweather.vercel.app"
|
||||
trade_alerts = build_trading_alerts(
|
||||
city_weather=city_weather,
|
||||
market_snapshot=snapshot,
|
||||
map_url=map_url,
|
||||
)
|
||||
|
||||
return {
|
||||
"city": snapshot.get("city"),
|
||||
"target_date": snapshot.get("target_date"),
|
||||
"updated_at": snapshot.get("updated_at"),
|
||||
"summary": snapshot.get("summary"),
|
||||
"alerts": snapshot.get("alerts", []),
|
||||
"trade_alerts": trade_alerts,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/polymarket/{name}/trade-alerts")
|
||||
async def city_trade_alerts(
|
||||
name: str,
|
||||
target_date: Optional[str] = None,
|
||||
force_refresh: bool = False,
|
||||
):
|
||||
"""
|
||||
Return trading alerts and Telegram-ready notification payload.
|
||||
"""
|
||||
city = _normalize_city_or_404(name)
|
||||
resolved_date = _resolve_target_date(city, target_date)
|
||||
|
||||
from src.data_collection.polymarket_client import build_city_market_snapshot
|
||||
from src.analysis.market_alert_engine import build_trading_alerts
|
||||
|
||||
proxy = (
|
||||
(_config.get("polymarket", {}) or {}).get("proxy")
|
||||
or (_config.get("app", {}) or {}).get("proxy")
|
||||
)
|
||||
snapshot = build_city_market_snapshot(
|
||||
city=city,
|
||||
target_date=resolved_date,
|
||||
proxy=proxy,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
city_weather = _analyze(city, force_refresh=force_refresh)
|
||||
map_url = os.getenv("POLYWEATHER_MAP_URL") or "https://polyweather.vercel.app"
|
||||
return build_trading_alerts(
|
||||
city_weather=city_weather,
|
||||
market_snapshot=snapshot,
|
||||
map_url=map_url,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/history/{name}")
|
||||
async def city_history(name: str):
|
||||
"""Return historical accuracy data (DEB, mu, actuals) for a city."""
|
||||
|
||||
Reference in New Issue
Block a user