feat: Implement PolyWeather application with a map-based frontend, Python web services, market alert engine, and supporting utilities.

This commit is contained in:
2569718930@qq.com
2026-03-06 12:31:15 +08:00
parent 1e9b8a0d11
commit 43b7c1b480
16 changed files with 159 additions and 2008 deletions
+108 -234
View File
@@ -1,11 +1,10 @@
"""
Rule-based weather/market alert engine for short-horizon Polymarket trading.
Rule-based weather alert engine for short-horizon trading signals.
"""
from __future__ import annotations
import math
import re
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
@@ -188,178 +187,6 @@ def _calc_forecast_breakthrough_alert(city_weather: Dict[str, Any], temp_symbol:
}
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 _extract_market_prices(target_market: Optional[Dict[str, Any]]) -> Dict[str, Any]:
prices: Dict[str, Any] = {
"question": None,
"yes_buy": None,
"yes_sell": None,
"yes_last": None,
"no_buy": None,
"no_sell": None,
"no_last": None,
}
if not target_market:
return prices
prices["question"] = target_market.get("question")
for oc in target_market.get("outcomes", []) or []:
name = str(oc.get("name") or "").strip().lower()
if name not in ("yes", "no"):
continue
prefix = "yes" if name == "yes" else "no"
prices[f"{prefix}_buy"] = _sf(oc.get("buy_price"))
prices[f"{prefix}_sell"] = _sf(oc.get("sell_price"))
prices[f"{prefix}_last"] = _sf(oc.get("last_trade_price"))
if prices[f"{prefix}_last"] is None:
prices[f"{prefix}_last"] = _sf(oc.get("last_price"))
return prices
def _format_market_price(value: Optional[float]) -> str:
if value is None:
return "-"
return f"{value * 100:.0f}c"
def _format_temp_display(value: Optional[float], temp_symbol: str) -> str:
if value is None:
return f"-{temp_symbol}"
rounded = round(float(value), 1)
if abs(rounded - round(rounded)) < 1e-9:
return f"{int(round(rounded))}{temp_symbol}"
return f"{rounded:.1f}{temp_symbol}"
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
markets = market_snapshot.get("markets", []) or []
target_market = _find_market_by_id(markets, nearest.get("market_id"))
market_prices = _extract_market_prices(target_market)
no_probability = None
if target_market:
yes_price = market_prices.get("yes_buy")
no_price = market_prices.get("no_buy")
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),
"market_label": f"{_format_temp_display(strike, temp_symbol)} 档位",
"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,
"market_prices": market_prices,
}
def _pick_leading_station(city: str, nearby: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
if not nearby:
return None
@@ -546,12 +373,52 @@ def _calc_advection_alert(city_weather: Dict[str, Any], temp_symbol: str) -> Dic
}
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", "预测突破"),
("kill_zone", "临界触发"),
("advection", "暖平流"),
]
parts = [name for key, name in mapping if rules.get(key, {}).get("triggered")]
@@ -561,13 +428,24 @@ def _join_trigger_types_cn(rules: Dict[str, Dict[str, Any]]) -> str:
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", {})
kill_zone = rules.get("kill_zone", {})
if center_deb.get("triggered"):
deb_prediction = _sf(center_deb.get("deb_prediction"))
@@ -589,15 +467,8 @@ def _build_advice_cn(
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 "当前未触发高优先级天气异动,继续观察实测与模型联动。"
return "".join(parts) + ""
@@ -605,26 +476,26 @@ def _build_telegram_messages(
city_weather: Dict[str, Any],
rules: Dict[str, Dict[str, Any]],
map_url: Optional[str],
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"))
center_deb = rules.get("ankara_center_deb_hit", {})
momentum = rules.get("momentum_spike", {})
kill_zone = rules.get("kill_zone", {})
advection = rules.get("advection", {})
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())
types_cn = _join_trigger_types_cn(rules) or "盘口异动"
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")
strike = _sf(kill_zone.get("strike_price"))
distance = _sf(kill_zone.get("distance"))
market_label = str(kill_zone.get("market_label") or "").strip()
market_prices = kill_zone.get("market_prices") or {}
center_station = center_deb.get("center_station") or {}
dyn = f"实测 {current_temp:.1f}{temp_symbol}"
@@ -632,13 +503,6 @@ def _build_telegram_messages(
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"
@@ -662,20 +526,18 @@ def _build_telegram_messages(
if lead_gap is not None:
center_deb_line += f" | 领先 {lead_gap:+.1f}{temp_symbol}"
price_line = ""
if any(
market_prices.get(key) is not None
for key in ("yes_buy", "yes_sell", "no_buy", "no_sell")
):
price_prefix = f"盘口({market_label}):" if market_label else "盘口:"
price_line = (
price_prefix
+
f"Yes 买 {_format_market_price(market_prices.get('yes_buy'))} / 卖 {_format_market_price(market_prices.get('yes_sell'))} | "
f"No 买 {_format_market_price(market_prices.get('no_buy'))} / 卖 {_format_market_price(market_prices.get('no_sell'))}"
)
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)
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"
@@ -686,27 +548,25 @@ def _build_telegram_messages(
f"类型:{types_cn}",
f"动态:{dyn}",
]
if strike_line:
lines_zh.append(strike_line)
if center_deb_line:
lines_zh.append(center_deb_line)
if price_line:
lines_zh.append(price_line)
if peak_line:
lines_zh.append(peak_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("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("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"
type_en_str = "Peak Passed (suppressed)" if suppressed else (" + ".join(type_en) or "Weather snapshot")
lines_en = [
f"{title_en} [{city_name}]",
@@ -714,8 +574,6 @@ def _build_telegram_messages(
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}")
if center_deb_line:
center_temp = _sf(center_station.get("temp"))
deb_prediction = _sf(center_deb.get("deb_prediction"))
@@ -723,14 +581,15 @@ def _build_telegram_messages(
lines_en.append(
f"Center signal: {center_temp:.1f}{temp_symbol} has reached DEB {deb_prediction:.1f}{temp_symbol}"
)
if price_line:
price_label_en = f"Quotes ({market_label}): " if market_label else "Quotes: "
lines_en.append(
price_label_en
+
f"Yes buy {_format_market_price(market_prices.get('yes_buy'))} / sell {_format_market_price(market_prices.get('yes_sell'))} | "
f"No buy {_format_market_price(market_prices.get('no_buy'))} / sell {_format_market_price(market_prices.get('no_sell'))}"
)
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}"
)
lines_en.append(f"Action: {advice}")
lines_en.append(f"Map: {final_map}")
@@ -739,11 +598,10 @@ def _build_telegram_messages(
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.
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", "")
@@ -753,7 +611,6 @@ def build_trading_alerts(
"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),
"kill_zone": _calc_kill_zone_alert(city_weather, market_snapshot, temp_symbol),
"advection": _calc_advection_alert(city_weather, temp_symbol),
}
@@ -765,15 +622,31 @@ def build_trading_alerts(
for key, value in rules.items()
if value.get("triggered")
]
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"
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,
suppression=suppression,
)
return {
@@ -783,6 +656,7 @@ def build_trading_alerts(
"severity": severity,
"trigger_count": len(triggered),
"rules": rules,
"suppression": suppression,
"triggered_alerts": triggered,
"telegram": telegram,
}