diff --git a/bot_listener.py b/bot_listener.py
index 8057bb05..d6854b73 100644
--- a/bot_listener.py
+++ b/bot_listener.py
@@ -10,9 +10,8 @@ if project_root not in sys.path:
sys.path.insert(0, project_root)
from src.utils.config_loader import load_config # type: ignore # noqa: E402
-from src.utils.telegram_push import start_trade_alert_push_loop, build_trade_alert_for_city # type: ignore # noqa: E402
+from src.utils.telegram_push import start_trade_alert_push_loop # type: ignore # noqa: E402
from src.data_collection.weather_sources import WeatherDataCollector # type: ignore # noqa: E402
-from src.data_collection.city_registry import ALIASES, CITY_REGISTRY # type: ignore # noqa: E402
from src.data_collection.city_risk_profiles import get_city_risk_profile # type: ignore # noqa: E402
from src.analysis.deb_algorithm import calculate_dynamic_weights, update_daily_record # noqa: E402
@@ -42,7 +41,6 @@ def start_bot():
"可用指令:\n"
"/city [城市名] - 查询城市天气预测与实测\n"
"/deb [城市名] - 查看 DEB 融合预测准确率\n"
- "/tradealert [城市名] - 预览当前异动预警推送\n"
"/id - 获取当前聊天的 Chat ID\n\n"
"示例: /city 伦敦"
)
@@ -238,105 +236,6 @@ def start_bot():
except Exception as e:
bot.reply_to(message, f"❌ 查询失败: {e}")
- @bot.message_handler(commands=["tradealert"])
- def tradealert_preview(message):
- """Preview the current trade-alert push payload for one city."""
- try:
- parts = message.text.split()
- if len(parts) < 2:
- bot.reply_to(
- message,
- "用法: /tradealert ankara 或 /tradealert ankara 2026-03-06",
- parse_mode="HTML",
- )
- return
-
- target_date = None
- city_tokens = parts[1:]
- if city_tokens:
- last_token = city_tokens[-1].strip()
- try:
- from datetime import datetime as _dt
- _dt.strptime(last_token, "%Y-%m-%d")
- target_date = last_token
- city_tokens = city_tokens[:-1]
- except Exception:
- pass
-
- city_input = " ".join(city_tokens).strip().lower()
- if not city_input:
- bot.reply_to(
- message,
- "用法: /tradealert ankara 或 /tradealert ankara 2026-03-06",
- parse_mode="HTML",
- )
- return
-
- city_name = ALIASES.get(city_input)
-
- if not city_name and city_input in CITY_REGISTRY:
- city_name = city_input
-
- if not city_name and len(city_input) >= 2:
- for alias, resolved in ALIASES.items():
- if alias.startswith(city_input):
- city_name = resolved
- break
- if not city_name:
- for full_name in CITY_REGISTRY:
- if full_name.startswith(city_input):
- city_name = full_name
- break
-
- if not city_name:
- bot.reply_to(
- message,
- f"❌ 未找到城市: {city_input}",
- parse_mode="HTML",
- )
- return
-
- bot.send_message(
- message.chat.id,
- f"📡 正在生成 {city_name.title()} 的异动预警预览"
- f"{f' ({target_date})' if target_date else ''}...",
- )
-
- payload = build_trade_alert_for_city(
- city_name,
- config,
- force_refresh=True,
- target_date=target_date,
- )
-
- preview = ((payload.get("telegram") or {}).get("zh") or "").strip()
- trigger_count = int(payload.get("trigger_count") or 0)
- severity = str(payload.get("severity") or "none")
- resolved_date = payload.get("target_date")
- header = (
- f"[TEST PREVIEW] city={city_name} "
- f"date={resolved_date} "
- f"severity={severity} triggers={trigger_count}"
- )
-
- if not preview:
- bot.reply_to(message, f"{header}\n\nNo Telegram preview available.")
- return
-
- if trigger_count <= 0:
- bot.send_message(
- message.chat.id,
- f"{header}\n\n当前没有 active trade alert。\n\n{preview}",
- )
- return
-
- bot.send_message(message.chat.id, f"{header}\n\n{preview}")
- except Exception as e:
- import traceback
-
- logger.error(f"trade alert preview failed: {e}\n{traceback.format_exc()}")
- bot.reply_to(message, f"❌ tradealert failed: {e}")
-
@bot.message_handler(commands=["city"])
def get_city_info(message):
"""查询指定城市的天气详情"""
diff --git a/frontend/public/static/app.js b/frontend/public/static/app.js
index 5905496b..66e711cd 100644
--- a/frontend/public/static/app.js
+++ b/frontend/public/static/app.js
@@ -255,6 +255,85 @@ async function fetchCityDetail(cityName, force = false) {
return await res.json();
}
+async function fetchCityMarket(cityName, targetDate, force = false) {
+ const urlName = cityName.replace(/\s/g, "-");
+ const params = new URLSearchParams({
+ force_refresh: String(force),
+ });
+ if (targetDate) {
+ params.set("target_date", targetDate);
+ }
+
+ const res = await fetch(`/api/polymarket/${encodeURIComponent(urlName)}?${params}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return await res.json();
+}
+
+function hasMarketSnapshot(data, targetDate) {
+ return Boolean(
+ data?.polymarket &&
+ data.polymarket.target_date === targetDate &&
+ !data.polymarket.loading &&
+ !data.polymarket.fetch_error &&
+ Array.isArray(data.polymarket.markets),
+ );
+}
+
+async function hydrateCityMarketData(data, force = false) {
+ if (!data?.name) return null;
+
+ const targetDate = data.local_date || null;
+ if (!force && data.polymarket?.loading && data.polymarket.target_date === targetDate) {
+ return data.polymarket;
+ }
+ if (!force && hasMarketSnapshot(data, targetDate)) {
+ return data.polymarket;
+ }
+
+ const existingMarkets = Array.isArray(data.polymarket?.markets)
+ ? data.polymarket.markets
+ : [];
+
+ data.polymarket = {
+ ...(data.polymarket || {}),
+ target_date: targetDate,
+ loading: true,
+ fetch_error: null,
+ markets: existingMarkets,
+ };
+
+ if (selectedCity === data.name) {
+ renderMarketPrices(data);
+ }
+
+ try {
+ const snapshot = await fetchCityMarket(data.name, targetDate, force);
+ data.polymarket = {
+ ...snapshot,
+ loading: false,
+ fetch_error: null,
+ };
+ } catch (e) {
+ console.error(`Failed to load market for ${data.name}:`, e);
+ data.polymarket = {
+ ...(data.polymarket || {}),
+ target_date: targetDate,
+ loading: false,
+ fetch_error: e.message || "Unknown error",
+ markets: existingMarkets,
+ };
+ }
+
+ cityDataCache[data.name] = data;
+ saveCache();
+
+ if (selectedCity === data.name) {
+ renderMarketPrices(data);
+ }
+
+ return data.polymarket;
+}
+
// ──────────────────────────────────────────────────────────
// Nearby Map Stations Rendering
// ──────────────────────────────────────────────────────────
@@ -344,8 +423,10 @@ async function loadCityDetail(cityName, force = false) {
setSelectedMarker(cityName);
if (!force && cityDataCache[cityName]) {
- renderPanel(cityDataCache[cityName]);
- renderNearbyStations(cityDataCache[cityName]);
+ const cachedData = cityDataCache[cityName];
+ renderPanel(cachedData);
+ renderNearbyStations(cachedData);
+ hydrateCityMarketData(cachedData, false);
return;
}
@@ -356,6 +437,7 @@ async function loadCityDetail(cityName, force = false) {
cityDataCache[cityName] = data;
saveCache();
renderPanel(data);
+ hydrateCityMarketData(data, force);
// Render nearby stations and zoom camera (cinematic or bounds)
renderNearbyStations(data);
@@ -412,6 +494,8 @@ function renderPanel(data) {
renderChart(data);
// Probabilities
renderProbabilities(data);
+ // Market prices
+ renderMarketPrices(data);
// Multi-model & Forecast synchronization
if (!selectedForecastDate) {
selectedForecastDate = data.local_date;
@@ -903,6 +987,175 @@ function renderProbabilities(data) {
});
}
+function escapeHtml(value) {
+ return String(value ?? "").replace(/[&<>"']/g, (ch) => {
+ return (
+ {
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'",
+ }[ch] || ch
+ );
+ });
+}
+
+function formatCents(price) {
+ const n = Number(price);
+ if (!Number.isFinite(n)) return "--";
+ const cents = Math.round(n * 1000) / 10;
+ return Number.isInteger(cents) ? `${cents.toFixed(0)}c` : `${cents.toFixed(1)}c`;
+}
+
+function formatCompactUsd(value) {
+ const n = Number(value);
+ if (!Number.isFinite(n)) return "--";
+ if (n >= 1000) {
+ const compact = n >= 10000 ? (n / 1000).toFixed(0) : (n / 1000).toFixed(1);
+ return `$${compact}k`;
+ }
+ return `$${Math.round(n)}`;
+}
+
+function formatMarketThreshold(market, fallbackUnit) {
+ const threshold = Number(market?.threshold);
+ if (!Number.isFinite(threshold)) {
+ return market?.question || "Market";
+ }
+
+ const isInteger = Math.abs(threshold - Math.round(threshold)) < 0.001;
+ const value = isInteger ? threshold.toFixed(0) : threshold.toFixed(1);
+ const unitRaw = String(market?.threshold_unit || fallbackUnit || "C").toUpperCase();
+ const unit = unitRaw === "F" ? "°F" : "°C";
+
+ if (market?.contract_type === "exceed") {
+ return `${value}${unit}+`;
+ }
+ return `${value}${unit}`;
+}
+
+function findOutcome(market, outcomeName) {
+ const target = String(outcomeName || "").toLowerCase();
+ return (market?.outcomes || []).find(
+ (outcome) => String(outcome?.name || "").toLowerCase() === target,
+ );
+}
+
+function renderMarketPrices(data) {
+ const summary = document.getElementById("marketSummary");
+ const container = document.getElementById("marketBook");
+ const snapshot = data.polymarket || {};
+ const markets = Array.isArray(snapshot.markets) ? [...snapshot.markets] : [];
+ const targetDate = snapshot.target_date || data.local_date || "--";
+
+ if (snapshot.loading && markets.length === 0) {
+ summary.innerHTML =
+ '姝e湪鍔犺浇褰撳ぉ Polymarket 鐩樺彛...';
+ container.innerHTML = "";
+ return;
+ }
+
+ if (snapshot.fetch_error && markets.length === 0) {
+ summary.innerHTML = `鐩樺彛鍔犺浇澶辫触: ${escapeHtml(snapshot.fetch_error)}`;
+ container.innerHTML = "";
+ return;
+ }
+
+ if (markets.length === 0) {
+ summary.innerHTML =
+ '褰撳ぉ鏆傛棤鍙敤鐨?Polymarket 甯傚満浠锋牸';
+ container.innerHTML = "";
+ return;
+ }
+
+ markets.sort((a, b) => {
+ const left = Number(a?.threshold);
+ const right = Number(b?.threshold);
+ const safeLeft = Number.isFinite(left) ? left : Number.MAX_SAFE_INTEGER;
+ const safeRight = Number.isFinite(right) ? right : Number.MAX_SAFE_INTEGER;
+ return safeLeft - safeRight;
+ });
+
+ const primaryUrl = markets.find((market) => market?.url)?.url;
+ const updatedAtTs = snapshot.updated_at ? Date.parse(snapshot.updated_at) : NaN;
+ const updatedAt = Number.isFinite(updatedAtTs)
+ ? new Date(updatedAtTs).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+ : null;
+
+ summary.innerHTML = `
+