feat: Implement PolyWeather web application with map and market alert engine, including related utilities and tests.
This commit is contained in:
@@ -21,3 +21,4 @@ HTTP_PROXY=http://127.0.0.1:7890
|
|||||||
# Other Settings
|
# Other Settings
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
ENV=production
|
ENV=production
|
||||||
|
POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/
|
||||||
|
|||||||
@@ -256,6 +256,48 @@ def _find_market_by_id(markets: List[Dict[str, Any]], market_id: Any) -> Optiona
|
|||||||
return None
|
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(
|
def _calc_kill_zone_alert(
|
||||||
city_weather: Dict[str, Any],
|
city_weather: Dict[str, Any],
|
||||||
market_snapshot: Dict[str, Any],
|
market_snapshot: Dict[str, Any],
|
||||||
@@ -290,21 +332,14 @@ def _calc_kill_zone_alert(
|
|||||||
distance = abs(current_temp - strike)
|
distance = abs(current_temp - strike)
|
||||||
triggered = distance < threshold
|
triggered = distance < threshold
|
||||||
|
|
||||||
no_probability = None
|
|
||||||
markets = market_snapshot.get("markets", []) or []
|
markets = market_snapshot.get("markets", []) or []
|
||||||
target_market = _find_market_by_id(markets, nearest.get("market_id"))
|
target_market = _find_market_by_id(markets, nearest.get("market_id"))
|
||||||
|
market_prices = _extract_market_prices(target_market)
|
||||||
|
|
||||||
|
no_probability = None
|
||||||
if target_market:
|
if target_market:
|
||||||
yes_price = None
|
yes_price = market_prices.get("yes_buy")
|
||||||
no_price = None
|
no_price = market_prices.get("no_buy")
|
||||||
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:
|
if no_price is None and yes_price is not None:
|
||||||
no_price = max(0.0, min(1.0, 1.0 - yes_price))
|
no_price = max(0.0, min(1.0, 1.0 - yes_price))
|
||||||
no_probability = no_price
|
no_probability = no_price
|
||||||
@@ -314,12 +349,14 @@ def _calc_kill_zone_alert(
|
|||||||
"triggered": triggered,
|
"triggered": triggered,
|
||||||
"current_temp": round(current_temp, 2),
|
"current_temp": round(current_temp, 2),
|
||||||
"strike_price": round(strike, 2),
|
"strike_price": round(strike, 2),
|
||||||
|
"market_label": f"{_format_temp_display(strike, temp_symbol)} 档位",
|
||||||
"distance": round(distance, 2),
|
"distance": round(distance, 2),
|
||||||
"threshold": round(threshold, 2),
|
"threshold": round(threshold, 2),
|
||||||
"market_id": nearest.get("market_id"),
|
"market_id": nearest.get("market_id"),
|
||||||
"question": nearest.get("question"),
|
"question": nearest.get("question"),
|
||||||
"strike_source": nearest.get("source"),
|
"strike_source": nearest.get("source"),
|
||||||
"no_probability": round(no_probability, 4) if no_probability is not None else None,
|
"no_probability": round(no_probability, 4) if no_probability is not None else None,
|
||||||
|
"market_prices": market_prices,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -495,6 +532,8 @@ def _build_telegram_messages(
|
|||||||
delta_min = momentum.get("delta_minutes")
|
delta_min = momentum.get("delta_minutes")
|
||||||
strike = _sf(kill_zone.get("strike_price"))
|
strike = _sf(kill_zone.get("strike_price"))
|
||||||
distance = _sf(kill_zone.get("distance"))
|
distance = _sf(kill_zone.get("distance"))
|
||||||
|
market_label = str(kill_zone.get("market_label") or "").strip()
|
||||||
|
market_prices = kill_zone.get("market_prices") or {}
|
||||||
|
|
||||||
dyn = f"实测 {current_temp:.1f}{temp_symbol}"
|
dyn = f"实测 {current_temp:.1f}{temp_symbol}"
|
||||||
if delta_temp is not None and delta_min is not None:
|
if delta_temp is not None and delta_min is not None:
|
||||||
@@ -515,8 +554,20 @@ def _build_telegram_messages(
|
|||||||
if lead_delta is not None:
|
if lead_delta is not None:
|
||||||
lead_line = f"联动:{st_name} 已领先 {lead_delta:+.1f}{temp_symbol}"
|
lead_line = f"联动:{st_name} 已领先 {lead_delta:+.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'))}"
|
||||||
|
)
|
||||||
|
|
||||||
advice = _build_advice_cn(rules, temp_symbol)
|
advice = _build_advice_cn(rules, temp_symbol)
|
||||||
final_map = map_url or "https://polyweather.vercel.app"
|
final_map = map_url or "https://polyweather-pro.vercel.app/"
|
||||||
|
|
||||||
lines_zh = [
|
lines_zh = [
|
||||||
f"🚨 PolyWeather 异动预警 [{city_name}]",
|
f"🚨 PolyWeather 异动预警 [{city_name}]",
|
||||||
@@ -526,6 +577,8 @@ def _build_telegram_messages(
|
|||||||
]
|
]
|
||||||
if strike_line:
|
if strike_line:
|
||||||
lines_zh.append(strike_line)
|
lines_zh.append(strike_line)
|
||||||
|
if price_line:
|
||||||
|
lines_zh.append(price_line)
|
||||||
if lead_line:
|
if lead_line:
|
||||||
lines_zh.append(lead_line)
|
lines_zh.append(lead_line)
|
||||||
lines_zh.append(f"AI 建议:{advice}")
|
lines_zh.append(f"AI 建议:{advice}")
|
||||||
@@ -550,6 +603,13 @@ def _build_telegram_messages(
|
|||||||
]
|
]
|
||||||
if strike is not None and distance is not None:
|
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"Distance to {strike:.1f}{temp_symbol} strike: {distance:.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'))}"
|
||||||
|
)
|
||||||
lines_en.append(f"Action: {advice}")
|
lines_en.append(f"Action: {advice}")
|
||||||
lines_en.append(f"Map: {final_map}")
|
lines_en.append(f"Map: {final_map}")
|
||||||
|
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ def build_trade_alert_for_city(
|
|||||||
proxy=proxy,
|
proxy=proxy,
|
||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
)
|
)
|
||||||
map_url = os.getenv("POLYWEATHER_MAP_URL") or "https://polyweather.vercel.app"
|
map_url = os.getenv("POLYWEATHER_MAP_URL") or "https://polyweather-pro.vercel.app/"
|
||||||
alert_payload = build_trading_alerts(
|
alert_payload = build_trading_alerts(
|
||||||
city_weather=city_weather,
|
city_weather=city_weather,
|
||||||
market_snapshot=market_snapshot,
|
market_snapshot=market_snapshot,
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ def test_trading_alerts_all_core_rules_trigger():
|
|||||||
msg = out["telegram"]["zh"]
|
msg = out["telegram"]["zh"]
|
||||||
assert "PolyWeather 异动预警" in msg
|
assert "PolyWeather 异动预警" in msg
|
||||||
assert "动量突变" in msg
|
assert "动量突变" in msg
|
||||||
|
assert "盘口:" in msg
|
||||||
|
assert "Yes 买 73c / 卖 -" in msg
|
||||||
|
assert "No 买 27c / 卖 -" in msg
|
||||||
assert "No\" 单需谨慎" in msg
|
assert "No\" 单需谨慎" in msg
|
||||||
assert "https://example.com/map" in msg
|
assert "https://example.com/map" in msg
|
||||||
|
|
||||||
@@ -97,4 +100,3 @@ def test_forecast_breakthrough_not_triggered_when_current_not_above_margin():
|
|||||||
market_snapshot=_sample_market_snapshot(),
|
market_snapshot=_sample_market_snapshot(),
|
||||||
)
|
)
|
||||||
assert out["rules"]["forecast_breakthrough"]["triggered"] is False
|
assert out["rules"]["forecast_breakthrough"]["triggered"] is False
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -35,7 +35,7 @@ app = FastAPI(title="PolyWeather Map", version="1.0")
|
|||||||
|
|
||||||
_cors_origins = os.getenv(
|
_cors_origins = os.getenv(
|
||||||
"WEB_CORS_ORIGINS",
|
"WEB_CORS_ORIGINS",
|
||||||
"http://localhost:3000,http://127.0.0.1:3000,https://polyweather.vercel.app",
|
"http://localhost:3000,http://127.0.0.1:3000,https://polyweather-pro.vercel.app",
|
||||||
)
|
)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -658,7 +658,7 @@ async def city_polymarket_alerts(
|
|||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
)
|
)
|
||||||
city_weather = _analyze(city, force_refresh=force_refresh)
|
city_weather = _analyze(city, force_refresh=force_refresh)
|
||||||
map_url = os.getenv("POLYWEATHER_MAP_URL") or "https://polyweather.vercel.app"
|
map_url = os.getenv("POLYWEATHER_MAP_URL") or "https://polyweather-pro.vercel.app/"
|
||||||
trade_alerts = build_trading_alerts(
|
trade_alerts = build_trading_alerts(
|
||||||
city_weather=city_weather,
|
city_weather=city_weather,
|
||||||
market_snapshot=snapshot,
|
market_snapshot=snapshot,
|
||||||
@@ -701,7 +701,7 @@ async def city_trade_alerts(
|
|||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
)
|
)
|
||||||
city_weather = _analyze(city, force_refresh=force_refresh)
|
city_weather = _analyze(city, force_refresh=force_refresh)
|
||||||
map_url = os.getenv("POLYWEATHER_MAP_URL") or "https://polyweather.vercel.app"
|
map_url = os.getenv("POLYWEATHER_MAP_URL") or "https://polyweather-pro.vercel.app/"
|
||||||
return build_trading_alerts(
|
return build_trading_alerts(
|
||||||
city_weather=city_weather,
|
city_weather=city_weather,
|
||||||
market_snapshot=snapshot,
|
market_snapshot=snapshot,
|
||||||
|
|||||||
Reference in New Issue
Block a user