{day.max_temp}
@@ -471,7 +732,9 @@ export function RiskInfo() {
{!risk.airport ? (
-
{t("section.noRiskProfile")}
+
+ {t("section.noRiskProfile")}
+
) : (
<>
diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx
index 72f266cf..8d13afba 100644
--- a/frontend/hooks/useDashboardStore.tsx
+++ b/frontend/hooks/useDashboardStore.tsx
@@ -21,6 +21,7 @@ import {
HistoryPoint,
HistoryState,
LoadingState,
+ MarketScan,
} from "@/lib/dashboard-types";
interface DashboardStoreValue extends DashboardState {
@@ -35,13 +36,15 @@ interface DashboardStoreValue extends DashboardState {
openFutureModal: (dateStr: string) => void;
openGuide: () => void;
openHistory: () => Promise;
- openTodayModal: () => Promise;
+ openTodayModal: (forceRefresh?: boolean) => Promise;
registerMapStopMotion: (stopMotion: () => void) => void;
refreshAll: () => Promise;
refreshSelectedCity: () => Promise;
+ selectedMarketScan: MarketScan | null;
selectedDetail: CityDetail | null;
selectCity: (cityName: string) => Promise;
setForecastDate: (dateStr: string | null) => void;
+ marketScanByCityName: Record;
}
const DashboardStoreContext = createContext(null);
@@ -52,6 +55,7 @@ function getInitialLoadingState(): LoadingState {
cityDetail: false,
history: false,
refresh: false,
+ marketScan: false,
};
}
@@ -177,6 +181,9 @@ export function DashboardStoreProvider({
const [cityDetailMetaByName, setCityDetailMetaByName] = useState<
Record
>(() => initialCache.meta);
+ const [marketScanByCityName, setMarketScanByCityName] = useState<
+ Record
+ >({});
const [selectedCity, setSelectedCity] = useState(null);
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [selectedForecastDate, setSelectedForecastDate] = useState<
@@ -203,6 +210,9 @@ export function DashboardStoreProvider({
const selectedDetail = selectedCity
? cityDetailsByName[selectedCity] || null
: null;
+ const selectedMarketScan = selectedCity
+ ? marketScanByCityName[selectedCity] || null
+ : null;
useEffect(() => {
dashboardClient.writeCityDetailCacheBundle(
@@ -241,7 +251,9 @@ export function DashboardStoreProvider({
}
}
- const latestDetail = await dashboardClient.getCityDetail(cityName, { force });
+ const latestDetail = await dashboardClient.getCityDetail(cityName, {
+ force,
+ });
const detail = mergeAiAnalysisIfStable(cached, latestDetail);
setCityDetailsByName((current) => ({
...current,
@@ -261,6 +273,29 @@ export function DashboardStoreProvider({
return detail;
};
+ const ensureCityMarketScan = async (
+ cityName: string,
+ force = false,
+ marketSlug?: string | null,
+ ) => {
+ const cached = marketScanByCityName[cityName];
+ if (!force && cached && !marketSlug) {
+ return cached;
+ }
+
+ const latestScan = await dashboardClient.getCityMarketScan(cityName, {
+ force,
+ marketSlug,
+ });
+ if (latestScan) {
+ setMarketScanByCityName((current) => ({
+ ...current,
+ [cityName]: latestScan,
+ }));
+ }
+ return latestScan;
+ };
+
const loadCities = async () => {
setLoadingState((current) => ({ ...current, cities: true }));
try {
@@ -330,6 +365,8 @@ export function DashboardStoreProvider({
try {
const detail = await ensureCityDetail(cityName);
setSelectedForecastDate(detail.local_date);
+ // 预热市场数据,不做 await 阻塞,后台静默拉取
+ void ensureCityMarketScan(cityName, false).catch(() => {});
} finally {
setLoadingState((current) => ({ ...current, cityDetail: false }));
}
@@ -433,24 +470,50 @@ export function DashboardStoreProvider({
},
openGuide: () => setIsGuideOpen(true),
openHistory,
- openTodayModal: async () => {
- if (!selectedCity || loadingState.cityDetail || loadingState.refresh) {
+ openTodayModal: async (forceRefresh?: boolean) => {
+ if (!selectedCity || loadingState.cityDetail) {
return;
}
mapStopMotionRef.current();
- setLoadingState((current) => ({ ...current, refresh: true }));
+ const cachedDetail = cityDetailsByName[selectedCity];
+
+ // 乐观 UI: 有缓存则立刻秒开 modal,不阻塞显示
+ if (cachedDetail?.local_date) {
+ setSelectedForecastDate(cachedDetail.local_date);
+ setFutureModalDate(cachedDetail.local_date);
+ setLoadingState((current) => ({ ...current, marketScan: true }));
+ } else {
+ setLoadingState((current) => ({
+ ...current,
+ refresh: true,
+ marketScan: true,
+ }));
+ }
+
+ // 异步静默拉取最新气象与市场数据
try {
const detail = await ensureCityDetail(selectedCity, true);
setSelectedForecastDate(detail.local_date);
setFutureModalDate(detail.local_date);
+
+ try {
+ // 如果缓存里没有或者想要强制刷新,则拉取最新市场数据
+ await ensureCityMarketScan(
+ selectedCity,
+ forceRefresh || !marketScanByCityName[selectedCity],
+ );
+ } catch {}
} catch {
- const fallback = cityDetailsByName[selectedCity];
- if (fallback?.local_date) {
- setFutureModalDate(fallback.local_date);
+ if (cachedDetail?.local_date) {
+ setFutureModalDate(cachedDetail.local_date);
}
} finally {
- setLoadingState((current) => ({ ...current, refresh: false }));
+ setLoadingState((current) => ({
+ ...current,
+ refresh: false,
+ marketScan: false,
+ }));
}
},
registerMapStopMotion: (stopMotion: () => void) => {
@@ -458,12 +521,14 @@ export function DashboardStoreProvider({
},
refreshAll,
refreshSelectedCity,
+ selectedMarketScan,
selectedCity,
selectedDetail,
selectedForecastDate,
selectCity,
setForecastDate: (dateStr: string | null) =>
setSelectedForecastDate(dateStr),
+ marketScanByCityName,
}),
[
cities,
@@ -474,6 +539,8 @@ export function DashboardStoreProvider({
isPanelOpen,
isGuideOpen,
loadingState,
+ marketScanByCityName,
+ selectedMarketScan,
selectedCity,
selectedDetail,
selectedForecastDate,
diff --git a/frontend/lib/dashboard-client.ts b/frontend/lib/dashboard-client.ts
index 3785c792..c4ef86e9 100644
--- a/frontend/lib/dashboard-client.ts
+++ b/frontend/lib/dashboard-client.ts
@@ -3,6 +3,7 @@
import {
CityDetail,
CityListItem,
+ MarketScan,
CitySummary,
HistoryPoint,
} from "@/lib/dashboard-types";
@@ -12,6 +13,7 @@ const CACHE_TTL_MS = 5 * 60 * 1000;
const pendingCityDetailRequests = new Map>();
const pendingHistoryRequests = new Map>();
const pendingCitySummaryRequests = new Map>();
+const pendingMarketScanRequests = new Map>();
type CityCacheMeta = {
cachedAt: number;
@@ -134,20 +136,75 @@ export const dashboardClient = {
async getCityDetail(cityName: string, options?: { force?: boolean }) {
const force = options?.force ?? false;
- const requestKey = `${cityName}::${force ? "force" : "cached"}`;
- const existing = pendingCityDetailRequests.get(requestKey);
- if (existing) {
- return existing;
+ if (!force) {
+ const requestKey = `${cityName}::cached`;
+ const existing = pendingCityDetailRequests.get(requestKey);
+ if (existing) {
+ return existing;
+ }
+
+ const request = fetchJson(
+ `/api/city/${normalizeCityName(cityName)}?force_refresh=false`,
+ ).finally(() => {
+ pendingCityDetailRequests.delete(requestKey);
+ });
+
+ pendingCityDetailRequests.set(requestKey, request);
+ return request;
}
- const request = fetchJson(
- `/api/city/${normalizeCityName(cityName)}?force_refresh=${force}`,
- ).finally(() => {
- pendingCityDetailRequests.delete(requestKey);
+ const params = new URLSearchParams({
+ force_refresh: "true",
+ _ts: String(Date.now()),
});
+ return fetchJson(
+ `/api/city/${normalizeCityName(cityName)}?${params.toString()}`,
+ );
+ },
- pendingCityDetailRequests.set(requestKey, request);
- return request;
+ async getCityMarketScan(
+ cityName: string,
+ options?: { force?: boolean; marketSlug?: string | null },
+ ) {
+ const force = options?.force ?? false;
+ const marketSlug = options?.marketSlug || null;
+ if (!force) {
+ const requestKey = `${cityName}::cached::${marketSlug || "-"}`;
+ const existing = pendingMarketScanRequests.get(requestKey);
+ if (existing) {
+ return existing;
+ }
+
+ const params = new URLSearchParams({
+ force_refresh: "false",
+ });
+ if (marketSlug) {
+ params.set("market_slug", marketSlug);
+ }
+
+ const request = fetchJson<{ market_scan?: MarketScan }>(
+ `/api/city/${normalizeCityName(cityName)}/detail?${params.toString()}`,
+ )
+ .then((data) => data.market_scan || null)
+ .finally(() => {
+ pendingMarketScanRequests.delete(requestKey);
+ });
+
+ pendingMarketScanRequests.set(requestKey, request);
+ return request;
+ }
+
+ const params = new URLSearchParams({
+ force_refresh: "true",
+ _ts: String(Date.now()),
+ });
+ if (marketSlug) {
+ params.set("market_slug", marketSlug);
+ }
+
+ return fetchJson<{ market_scan?: MarketScan }>(
+ `/api/city/${normalizeCityName(cityName)}/detail?${params.toString()}`,
+ ).then((data) => data.market_scan || null);
},
async getHistory(cityName: string) {
diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts
index 68b84747..f8de322e 100644
--- a/frontend/lib/dashboard-types.ts
+++ b/frontend/lib/dashboard-types.ts
@@ -181,6 +181,71 @@ export interface DailyModelForecast {
probabilities?: ProbabilityBucket[];
}
+export interface MarketToken {
+ outcome?: string | null;
+ token_id?: string | null;
+ implied_probability?: number | null;
+ buy_price?: number | null;
+ sell_price?: number | null;
+ midpoint?: number | null;
+ last_trade_price?: number | null;
+}
+
+export interface MarketPrimary {
+ id?: string | null;
+ question?: string | null;
+ slug?: string | null;
+ condition_id?: string | null;
+ end_date?: string | null;
+ active?: boolean;
+ closed?: boolean;
+ liquidity?: number | null;
+ volume?: number | null;
+}
+
+export interface MarketTopBucket {
+ label?: string | null;
+ value?: number | null;
+ temp?: number | null;
+ probability?: number | null;
+ market_price?: number | null;
+ yes_buy?: number | null;
+ yes_sell?: number | null;
+ no_buy?: number | null;
+ no_sell?: number | null;
+ slug?: string | null;
+ question?: string | null;
+ is_primary?: boolean;
+}
+
+export interface MarketScan {
+ available?: boolean;
+ reason?: string | null;
+ primary_market?: MarketPrimary | null;
+ selected_date?: string | null;
+ selected_condition_id?: string | null;
+ selected_slug?: string | null;
+ temperature_bucket?: ProbabilityBucket | null;
+ model_probability?: number | null;
+ market_price?: number | null;
+ edge_percent?: number | null;
+ signal_label?: string | null;
+ confidence?: string | null;
+ yes_token?: MarketToken | null;
+ no_token?: MarketToken | null;
+ yes_buy?: number | null;
+ yes_sell?: number | null;
+ no_buy?: number | null;
+ no_sell?: number | null;
+ last_trade_price?: number | null;
+ liquidity?: number | null;
+ volume?: number | null;
+ sparkline?: number[];
+ top_buckets?: MarketTopBucket[] | null;
+ recent_trades?: unknown[];
+ websocket?: Record;
+}
+
export interface AiAnalysisStructured {
summary?: string | null;
text?: string | null;
@@ -227,6 +292,7 @@ export interface CityDetail {
updated_at?: string;
multi_model_daily?: Record;
source_forecasts?: SourceForecasts;
+ market_scan?: MarketScan;
}
export interface HistoryPoint {
@@ -241,6 +307,7 @@ export interface LoadingState {
cityDetail: boolean;
refresh: boolean;
history: boolean;
+ marketScan?: boolean;
}
export interface HistoryState {
diff --git a/frontend/lib/i18n.ts b/frontend/lib/i18n.ts
index f8e3cf9f..0564db33 100644
--- a/frontend/lib/i18n.ts
+++ b/frontend/lib/i18n.ts
@@ -71,7 +71,7 @@ const MESSAGES: Record> = {
"future.score": "趋势评分",
"future.todayTempTrend": "今日温度走势",
"future.targetTempTrend": "目标日小时走势",
- "future.probability": "结算概率分布",
+ "future.probability": "模型结算概率分布",
"future.models": "多模型预报",
"future.structureToday": "今日日内结构信号",
"future.structureDate": "未来 6-48 小时趋势",
@@ -91,7 +91,7 @@ const MESSAGES: Record> = {
"section.todayTempTrend": "今日温度走势",
"section.chartEmpty": "暂无小时级数据",
- "section.probability": "结算概率分布",
+ "section.probability": "模型结算概率分布",
"section.mu": "动态分布中心 μ = {value}{unit}",
"section.noProb": "暂无概率数据",
"section.models": "多模型预报",
@@ -133,7 +133,8 @@ const MESSAGES: Record> = {
"No scenery image matched. You can still review station and observation profile below.",
"detail.profile": "City Profile",
"detail.todayMiniTrend": "Today's Intraday Trend (Compact)",
- "detail.chartLegendEmpty": "No hourly observations or forecast curve available.",
+ "detail.chartLegendEmpty":
+ "No hourly observations or forecast curve available.",
"forecast.title": "Multi-day Forecast",
"forecast.empty": "No multi-day forecast available",
@@ -171,7 +172,7 @@ const MESSAGES: Record> = {
"future.score": "Trend Score",
"future.todayTempTrend": "Today's Temperature Trend",
"future.targetTempTrend": "Target-day Hourly Trend",
- "future.probability": "Settlement Probability Distribution",
+ "future.probability": "Model Settlement Probabilities",
"future.models": "Multi-model Forecast",
"future.structureToday": "Intraday Structural Signal",
"future.structureDate": "6-48h Structural Trend",
@@ -179,11 +180,13 @@ const MESSAGES: Record> = {
"future.confidence": "Confidence",
"future.maxPrecip": "Max Precip Probability",
"future.ai": "AI Deep Analysis",
- "future.noAi": "No AI analysis available. Structured meteorological and model data are used as baseline.",
+ "future.noAi":
+ "No AI analysis available. Structured meteorological and model data are used as baseline.",
"future.weatherGov": "weather.gov text",
"future.risk": "Settlement & Deviation Risk",
"future.climate": "What Mainly Drives Local Climate",
- "future.chartLegendEmpty": "No METAR bulletin or hourly observations available",
+ "future.chartLegendEmpty":
+ "No METAR bulletin or hourly observations available",
"confidence.high": "High",
"confidence.medium": "Medium",
@@ -191,13 +194,14 @@ const MESSAGES: Record> = {
"section.todayTempTrend": "Today's Temperature Trend",
"section.chartEmpty": "No hourly data available",
- "section.probability": "Settlement Probability Distribution",
+ "section.probability": "Model Settlement Probabilities",
"section.mu": "Dynamic center μ = {value}{unit}",
"section.noProb": "No probability data available",
"section.models": "Multi-model Forecast",
"section.noModels": "No multi-model forecast available",
"section.ai": "AI Deep Analysis",
- "section.aiEmpty": "No AI analysis available. Structured meteorological and model data are currently used.",
+ "section.aiEmpty":
+ "No AI analysis available. Structured meteorological and model data are currently used.",
"section.risk": "Data Deviation Risk",
"section.noRiskProfile": "No risk profile available",
"section.airport": "Airport",
diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts
index d3dbb834..4cd3d9c9 100644
--- a/frontend/lib/types.ts
+++ b/frontend/lib/types.ts
@@ -292,6 +292,20 @@ export interface MarketScan {
liquidity: number | null;
volume: number | null;
sparkline: number[];
+ top_buckets?: Array<{
+ label?: string | null;
+ value?: number | null;
+ temp?: number | null;
+ probability?: number | null;
+ market_price?: number | null;
+ yes_buy?: number | null;
+ yes_sell?: number | null;
+ no_buy?: number | null;
+ no_sell?: number | null;
+ slug?: string | null;
+ question?: string | null;
+ is_primary?: boolean;
+ }>;
recent_trades: Trade[];
websocket: any;
}
diff --git a/src/analysis/market_alert_engine.py b/src/analysis/market_alert_engine.py
index 16d06f77..ae79141d 100644
--- a/src/analysis/market_alert_engine.py
+++ b/src/analysis/market_alert_engine.py
@@ -421,6 +421,111 @@ def _join_trigger_types_cn(rules: Dict[str, Dict[str, Any]]) -> str:
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,
@@ -472,6 +577,7 @@ 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")
@@ -482,6 +588,7 @@ def _build_telegram_messages(
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": ""}
@@ -559,6 +666,30 @@ def _build_telegram_messages(
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}")
@@ -602,6 +733,30 @@ def _build_telegram_messages(
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}")
@@ -618,6 +773,7 @@ def build_trading_alerts(
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),
@@ -658,6 +814,7 @@ def build_trading_alerts(
city_weather=city_weather,
rules=rules,
map_url=map_url,
+ market_snapshot=market_snapshot,
suppression=suppression,
)
@@ -668,6 +825,7 @@ def build_trading_alerts(
"severity": severity,
"trigger_count": len(triggered),
"rules": rules,
+ "market_snapshot": market_snapshot,
"suppression": suppression,
"triggered_alerts": triggered,
"telegram": telegram,
diff --git a/src/data_collection/polymarket_readonly.py b/src/data_collection/polymarket_readonly.py
index 8ee58f5c..c371e4b9 100644
--- a/src/data_collection/polymarket_readonly.py
+++ b/src/data_collection/polymarket_readonly.py
@@ -394,6 +394,7 @@ class PolymarketReadOnlyLayer:
"liquidity": None,
"volume": None,
"sparkline": fallback_sparkline or [],
+ "top_buckets": [],
"recent_trades": [],
"websocket": {},
}
@@ -485,6 +486,13 @@ class PolymarketReadOnlyLayer:
signal_label, confidence = self._derive_signal(edge_percent, liquidity)
+ top_buckets = self._build_top_temperature_buckets(
+ city_key=city_key,
+ target_date=date_str,
+ primary_market=market,
+ limit=4,
+ )
+
yes_payload = {
"outcome": yes_token.get("outcome") or "Yes",
"token_id": yes_token.get("token_id"),
@@ -551,6 +559,7 @@ class PolymarketReadOnlyLayer:
"liquidity": liquidity,
"volume": volume,
"sparkline": sparkline_values,
+ "top_buckets": top_buckets,
"websocket": {
"market_url": market_url,
"asset_ids": [
@@ -1174,3 +1183,232 @@ class PolymarketReadOnlyLayer:
if slug:
return f"https://polymarket.com/market/{slug}"
return None
+
+ def _build_top_temperature_buckets(
+ self,
+ city_key: str,
+ target_date: str,
+ primary_market: Dict[str, Any],
+ limit: int = 4,
+ ) -> List[Dict[str, Any]]:
+ candidate_markets = self._collect_related_temperature_markets(
+ city_key=city_key,
+ target_date=target_date,
+ primary_market=primary_market,
+ )
+ if not candidate_markets:
+ return []
+
+ ranked: List[
+ Tuple[
+ float,
+ float,
+ Dict[str, Any],
+ Dict[str, Any],
+ Dict[str, Any],
+ Dict[str, Any],
+ Dict[str, Any],
+ ]
+ ] = []
+ for market in candidate_markets:
+ tokens = self._extract_market_tokens(market)
+ yes_token, no_token = self._resolve_yes_no_tokens(tokens)
+ if not yes_token or not no_token:
+ continue
+
+ yes_token_id = str(yes_token.get("token_id") or "").strip()
+ no_token_id = str(no_token.get("token_id") or "").strip()
+ yes_prices = self._get_token_market_data(yes_token_id) if yes_token_id else {}
+ no_prices = self._get_token_market_data(no_token_id) if no_token_id else {}
+
+ yes_midpoint = _extract_price(yes_prices.get("midpoint"))
+ yes_implied = _extract_price(yes_token.get("implied_probability"))
+ no_implied = _extract_price(no_token.get("implied_probability"))
+ market_prob = (
+ yes_midpoint
+ if yes_midpoint is not None
+ else (
+ yes_implied
+ if yes_implied is not None
+ else (1.0 - no_implied if no_implied is not None else None)
+ )
+ )
+ if market_prob is None:
+ continue
+
+ market_prob = max(0.0, min(1.0, float(market_prob)))
+ volume = (
+ _extract_price(
+ market.get("volumeNum")
+ or market.get("volume")
+ or market.get("volume24hr")
+ )
+ or 0.0
+ )
+ ranked.append(
+ (
+ market_prob,
+ volume,
+ market,
+ yes_token,
+ no_token,
+ yes_prices,
+ no_prices,
+ )
+ )
+
+ if not ranked:
+ return []
+
+ ranked.sort(key=lambda item: (item[0], item[1]), reverse=True)
+ top_rows: List[Dict[str, Any]] = []
+ max_items = max(1, int(limit or 4))
+ primary_slug = str(primary_market.get("slug") or "").strip().lower()
+
+ for market_prob, _volume, market, yes_token, no_token, yes_prices, no_prices in ranked[
+ :max_items
+ ]:
+ yes_buy = _extract_price(yes_prices.get("buy"))
+ yes_sell = _extract_price(yes_prices.get("sell"))
+ yes_midpoint = _extract_price(yes_prices.get("midpoint")) or market_prob
+ no_buy = _extract_price(no_prices.get("buy"))
+ no_sell = _extract_price(no_prices.get("sell"))
+
+ if no_buy is None and yes_buy is not None:
+ no_buy = max(0.0, min(1.0, 1.0 - yes_buy))
+ if no_sell is None and yes_sell is not None:
+ no_sell = max(0.0, min(1.0, 1.0 - yes_sell))
+
+ bucket_temp = self._extract_market_bucket_temp(market)
+ market_slug = str(market.get("slug") or "").strip()
+
+ top_rows.append(
+ {
+ "label": self._extract_market_bucket_label(market, bucket_temp),
+ "value": bucket_temp,
+ "temp": bucket_temp,
+ "probability": market_prob,
+ "market_price": yes_midpoint,
+ "yes_buy": yes_buy,
+ "yes_sell": yes_sell,
+ "no_buy": no_buy,
+ "no_sell": no_sell,
+ "slug": market_slug or None,
+ "question": market.get("question") or market.get("title"),
+ "is_primary": bool(
+ primary_slug
+ and market_slug
+ and primary_slug == market_slug.strip().lower()
+ ),
+ }
+ )
+
+ return top_rows
+
+ def _collect_related_temperature_markets(
+ self,
+ city_key: str,
+ target_date: str,
+ primary_market: Dict[str, Any],
+ ) -> List[Dict[str, Any]]:
+ related: List[Dict[str, Any]] = []
+ canonical_event_slug = self._build_weather_event_slug(city_key, target_date)
+ if canonical_event_slug:
+ related.extend(self._load_event_markets(canonical_event_slug))
+
+ event_slug = self._extract_event_slug(primary_market)
+ if event_slug and event_slug != canonical_event_slug:
+ related.extend(self._load_event_markets(event_slug))
+
+ if not related:
+ for market in self._load_markets(active_only=True):
+ if self._score_market(city_key, target_date, market) <= 0:
+ continue
+ if self._extract_market_bucket_temp(market) is None:
+ continue
+ related.append(market)
+
+ related.append(primary_market)
+
+ unique: List[Dict[str, Any]] = []
+ seen = set()
+ for market in related:
+ if not isinstance(market, dict):
+ continue
+ dedupe_key = str(
+ market.get("id")
+ or market.get("slug")
+ or market.get("conditionId")
+ or ""
+ ).strip()
+ if not dedupe_key:
+ continue
+ if dedupe_key in seen:
+ continue
+ seen.add(dedupe_key)
+ unique.append(market)
+ return unique
+
+ def _extract_event_slug(self, market: Dict[str, Any]) -> Optional[str]:
+ event_slug = str(market.get("eventSlug") or "").strip().lower()
+ if event_slug:
+ return event_slug
+
+ slug = str(market.get("slug") or "").strip().lower()
+ if not slug:
+ return None
+
+ trimmed = re.sub(
+ r"-(?:m)?\d+(?:-\d+)?c(?:-or-(?:higher|lower|above|below))?$",
+ "",
+ slug,
+ )
+ trimmed = trimmed.strip("-")
+ return trimmed or None
+
+ def _load_event_markets(self, event_slug: str) -> List[Dict[str, Any]]:
+ normalized_slug = str(event_slug or "").strip().lower()
+ if not normalized_slug:
+ return []
+
+ try:
+ resp = self._session.get(
+ f"{self.gamma_url}/events",
+ params={"slug": normalized_slug, "limit": 5},
+ timeout=self.http_timeout,
+ )
+ resp.raise_for_status()
+ payload = resp.json()
+ except Exception:
+ return []
+
+ events = payload if isinstance(payload, list) else []
+ out: List[Dict[str, Any]] = []
+ for event in events:
+ if not isinstance(event, dict):
+ continue
+ event_item_slug = str(event.get("slug") or "").strip().lower()
+ if event_item_slug and event_item_slug != normalized_slug:
+ continue
+ for market in event.get("markets") or []:
+ if not isinstance(market, dict):
+ continue
+ market["eventSlug"] = market.get("eventSlug") or event_item_slug
+ market["eventTitle"] = market.get("eventTitle") or event.get("title")
+ out.append(market)
+ return out
+
+ def _extract_market_bucket_label(
+ self,
+ market: Dict[str, Any],
+ bucket_temp: Optional[float],
+ ) -> str:
+ question = str(market.get("question") or market.get("title") or "").strip()
+ text = question.lower()
+ if bucket_temp is not None:
+ if "or higher" in text or "or above" in text or "and above" in text:
+ return f"{bucket_temp:g}C+"
+ if "or lower" in text or "or below" in text or "and below" in text:
+ return f"<={bucket_temp:g}C"
+ return f"{bucket_temp:g}C"
+ return question or str(market.get("slug") or "")
diff --git a/src/utils/telegram_push.py b/src/utils/telegram_push.py
index 08082633..0b21cc6d 100644
--- a/src/utils/telegram_push.py
+++ b/src/utils/telegram_push.py
@@ -117,6 +117,12 @@ def _trigger_type_key(alert_payload: Dict[str, Any]) -> str:
for alert in (alert_payload.get("triggered_alerts") or [])
if alert.get("type")
)
+ market = alert_payload.get("market_snapshot") or {}
+ if isinstance(market, dict) and market.get("available"):
+ signal = str(market.get("signal_label") or "").strip()
+ bucket = str(market.get("selected_bucket") or "").strip()
+ if signal:
+ trigger_types.append(f"mkt:{signal}:{bucket}")
return "|".join(trigger_types)
@@ -127,6 +133,7 @@ def _alert_signature(alert_payload: Dict[str, Any]) -> str:
breakthrough = rules.get("forecast_breakthrough") or {}
advection = rules.get("advection") or {}
suppression = alert_payload.get("suppression") or {}
+ market = alert_payload.get("market_snapshot") or {}
signature_payload = {
"city": alert_payload.get("city"),
@@ -149,6 +156,18 @@ def _alert_signature(alert_payload: Dict[str, Any]) -> str:
"suppression_reason": suppression.get("reason"),
"suppression_peak_time": suppression.get("max_temp_time"),
"suppression_rollback": round(float(suppression.get("rollback") or 0.0), 1),
+ "market_available": bool(market.get("available")),
+ "market_bucket": market.get("selected_bucket"),
+ "market_top_bucket": market.get("top_bucket"),
+ "market_top_bucket_prob": round(float(market.get("top_bucket_prob") or 0.0), 3),
+ "market_prob": round(float(market.get("market_prob") or 0.0), 3),
+ "model_prob": round(float(market.get("model_prob") or 0.0), 3),
+ "market_yes_buy": round(float(market.get("yes_buy") or 0.0), 3),
+ "market_yes_sell": round(float(market.get("yes_sell") or 0.0), 3),
+ "market_spread": round(float(market.get("spread") or 0.0), 3),
+ "market_edge_percent": round(float(market.get("edge_percent") or 0.0), 2),
+ "market_signal": market.get("signal_label"),
+ "market_confidence": market.get("confidence"),
}
raw = json.dumps(signature_payload, sort_keys=True, ensure_ascii=True)
return hashlib.sha1(raw.encode("utf-8")).hexdigest()
@@ -160,10 +179,18 @@ def build_trade_alert_for_city(
force_refresh: bool = False,
target_date: Optional[str] = None,
) -> Dict[str, Any]:
- from web.app import _analyze
+ from web.app import _analyze, _build_city_detail_payload
from src.analysis.market_alert_engine import build_trading_alerts
city_weather = _analyze(city, force_refresh=force_refresh)
+ try:
+ aggregate_detail = _build_city_detail_payload(city_weather)
+ market_scan = aggregate_detail.get("market_scan")
+ if isinstance(market_scan, dict):
+ city_weather = {**city_weather, "market_scan": market_scan}
+ except Exception as exc:
+ logger.debug(f"market scan attach skipped city={city}: {exc}")
+
resolved_target_date = target_date or city_weather.get("local_date")
if resolved_target_date:
datetime.strptime(resolved_target_date, "%Y-%m-%d")
@@ -212,7 +239,7 @@ def _maybe_send_alert(
last_sig_ts = int((state.get("by_signature") or {}).get(signature) or 0)
last_city_active = bool(last_city.get("active"))
- if last_city_active and last_city_key == trigger_key:
+ if last_city_active and last_city_key == trigger_key and last_city_sig == signature:
return False
if last_city_ts and now_ts - last_city_ts < cooldown_sec:
diff --git a/web/app.py b/web/app.py
index 2df189c3..8618f227 100644
--- a/web/app.py
+++ b/web/app.py
@@ -681,11 +681,15 @@ def _build_city_detail_payload(
) -> Dict[str, Any]:
distribution = data.get("probabilities", {}).get("distribution", []) or []
primary_bucket = distribution[0] if distribution else None
- model_probability = (
- (primary_bucket.get("probability") / 100.0)
- if isinstance(primary_bucket, dict) and primary_bucket.get("probability") is not None
- else None
- )
+ model_probability = None
+ if isinstance(primary_bucket, dict) and primary_bucket.get("probability") is not None:
+ try:
+ raw_probability = float(primary_bucket.get("probability"))
+ model_probability = (
+ raw_probability / 100.0 if raw_probability > 1.0 else raw_probability
+ )
+ except Exception:
+ model_probability = None
fallback_sparkline = [
p.get("probability", 0)
for p in distribution[:8]