Compare commits

..

57 Commits

Author SHA1 Message Date
2569718930@qq.com 4e5bf5a4f8 chore: release v1.2 2026-03-12 02:49:07 +08:00
2569718930@qq.com de6cf68ee7 feat: Implement reconciliation of recent actual high temperatures using METAR data and integrate it into the DEB command. 2026-03-12 02:43:19 +08:00
2569718930@qq.com 1060945d08 feat: Implement Dynamic Ensemble Blending (DEB) algorithm with historical data management, dynamic weight calculation, and accuracy tracking. 2026-03-12 02:32:10 +08:00
2569718930@qq.com ad2b1aa4b6 feat: Implement CitySidebar component with risk-level grouping and internationalization utilities. 2026-03-12 02:11:48 +08:00
2569718930@qq.com ba0916ea51 feat: add Polymarket wallet activity watcher and configuration for immediate size delta notifications. 2026-03-12 00:50:19 +08:00
2569718930@qq.com a46a98f25e feat: Add API route to fetch cities from the backend. 2026-03-12 00:40:28 +08:00
2569718930@qq.com 294e038f57 feat: Implement Polymarket wallet activity watcher and add related configuration options. 2026-03-12 00:36:33 +08:00
2569718930@qq.com eca0c7f514 feat: Introduce multi-source weather data collection, including city configuration and robust caching. 2026-03-12 00:29:34 +08:00
2569718930@qq.com 9295899aeb feat: implement PolyWeather Web Map API, a FastAPI backend integrating weather data collection and analysis to support an interactive map. 2026-03-11 12:22:06 +08:00
2569718930@qq.com db3b1c995a feat: Implement PolyWeather web map API with integrated weather data analysis, market alert engine, and Telegram push utility. 2026-03-11 12:15:09 +08:00
2569718930@qq.com e89fdb7e09 feat: enhance project documentation with new overview diagrams, commercialization roadmap, and technical debt backlog. 2026-03-11 11:45:57 +08:00
2569718930@qq.com 35345a1b04 docs: Introduce commercialization and technical debt documentation in English and Chinese, and update README diagram formatting. 2026-03-11 11:42:16 +08:00
2569718930@qq.com 025feaa96e feat: Implement PolyWeather web map API backend and integrate new frontend dashboard components for city weather details. 2026-03-11 11:37:07 +08:00
2569718930@qq.com 23e2959404 feat: implement PolyWeather web map API backend with data processing, caching, and entitlement, and add related documentation. 2026-03-11 11:29:56 +08:00
2569718930@qq.com 6273768cc2 docs: Add Chinese API documentation and a commercialization roadmap, alongside minor formatting updates to the main README. 2026-03-11 11:24:41 +08:00
2569718930@qq.com 44af26da70 docs: Add comprehensive documentation for commercialization and technical debt, and streamline the main README. 2026-03-11 11:18:48 +08:00
2569718930@qq.com 3cbef28b13 feat: Initialize PolyWeather frontend project with Next.js, including core dependencies, root layout, and a dashboard detail panel. 2026-03-11 11:14:03 +08:00
2569718930@qq.com d8cc193618 feat: Integrate Polymarket read-only data collection and display in dashboard panels. 2026-03-11 11:04:24 +08:00
2569718930@qq.com 1958b2764b feat: Implement core PolyWeather application with city weather query service, trend analysis, data collection, and dashboard UI. 2026-03-11 10:49:35 +08:00
2569718930@qq.com d2a40462c5 feat: Add city weather query service with city resolution, forecast processing, and weather summary generation. 2026-03-11 10:30:46 +08:00
2569718930@qq.com b3f46430ad feat: Introduce comprehensive weather data querying, analysis, and display services, integrate Polymarket data collection, and add Telegram notification utilities. 2026-03-11 10:23:33 +08:00
2569718930@qq.com 878e3280d1 fix: remove closed position template from wallet activity watcher 2026-03-11 09:22:07 +08:00
2569718930@qq.com 723694d77c style: translate polymarket wallet activity alerts to Chinese 2026-03-11 09:20:38 +08:00
2569718930@qq.com af4bee12f5 feat: Introduce a web frontend with new city API routes and refactor bot city query logic into a dedicated service. 2026-03-11 08:46:32 +08:00
2569718930@qq.com b1e75d13d8 feat: introduce WeatherDataCollector class for multi-source weather data retrieval, caching, and rate limiting. 2026-03-11 07:11:17 +08:00
2569718930@qq.com 8b6c66eab0 feat: Add fallback logic for future daily forecasts using multi-model medians, Meteoblue highs, and NWS periods when Open-Meteo data is unavailable. 2026-03-11 06:41:03 +08:00
2569718930@qq.com f7e7e0ea20 feat: Add multi-source weather data collection with a new Flask API and refine the bot's forecast display logic. 2026-03-11 06:33:25 +08:00
2569718930@qq.com 5d943f2aae feat: Create PolyWeather web API to serve analyzed weather and Polymarket data. 2026-03-11 05:56:40 +08:00
2569718930@qq.com 8712335ffd feat: Implement PolyWeather web map API by reusing existing analysis logic and centralize weather data collection. 2026-03-11 05:42:05 +08:00
2569718930@qq.com 41efaa1140 feat(weather_sources): parse Retry-After header from Open-Meteo 429 response to dynamically adjust cooldown instead of hardcoded 15m 2026-03-11 05:10:53 +08:00
2569718930@qq.com 2b681513ab fix(trend-engine): fallback to non-ensemble probability computation when OM drops to keep UI alive 2026-03-11 05:07:41 +08:00
2569718930@qq.com c5010a8b97 fix: safely fallback to metric unit per city instead of overwriting F to C randomly; allow trend engine to run even if Open-Meteo drops 2026-03-11 04:57:58 +08:00
2569718930@qq.com 535b2b0446 feat: add disk-based cache persistence for Open-Meteo to eliminate cold-start empty cache gaps 2026-03-11 04:39:20 +08:00
2569718930@qq.com 7f84314bcc fix: add Open-Meteo 429 shared cooldown timer to break retry death spiral on cold start 2026-03-11 04:32:32 +08:00
2569718930@qq.com 4b51e0dd15 feat: add 10min function-level cache to fetch_metar with stale fallback on error 2026-03-11 04:23:13 +08:00
2569718930@qq.com cc1a9a3b77 perf: increase Open-Meteo and Meteoblue cache TTL to 2 hours to reduce API rate limit bursts 2026-03-11 04:10:23 +08:00
2569718930@qq.com 46412511c8 fix: SyntaxError global declaration must be at function top not inside with-block 2026-03-11 03:59:55 +08:00
2569718930@qq.com c23020038e feat: add TTL cache and stale-cache fallback to Groq AI analyzer 2026-03-11 03:49:26 +08:00
2569718930@qq.com d4760dbcb2 fix: show MB/NWS/MGM as comp_parts when Open-Meteo daily data is missing 2026-03-11 03:44:10 +08:00
2569718930@qq.com ffb88ae61d feat: implement multi-source weather data collection including OpenWeatherMap, Visual Crossing, and METAR with caching. 2026-03-11 03:40:25 +08:00
2569718930@qq.com 4e6cb1071c feat: Implement Telegram push notifications for weather and market conditions and a Polymarket read-only data interface. 2026-03-11 02:13:38 +08:00
2569718930@qq.com d6434bf174 feat: Add weather alert engine, Polymarket data reader, and Telegram notification utility. 2026-03-11 01:56:22 +08:00
2569718930@qq.com 79b4708b7e revert: restore wallet activity watcher original closed-position behavior 2026-03-11 01:03:56 +08:00
2569718930@qq.com a6bef9a57f feat: wallet activity filter max_price to 0.10 and apply price filter to closed positions 2026-03-11 00:55:11 +08:00
2569718930@qq.com d08982a898 add logging for verifying price filter settings 2026-03-10 13:35:09 +08:00
2569718930@qq.com c53bfc629b feat: implement avg_price filtering for wallet activity watcher 2026-03-10 13:33:21 +08:00
2569718930@qq.com f638850048 feat: Add Polymarket wallet activity watcher and configure average price display thresholds. 2026-03-10 13:23:35 +08:00
2569718930@qq.com dd72e6a34b fix: resolve bot_listener encoding issues and syntax errors 2026-03-10 12:54:15 +08:00
2569718930@qq.com 5af63557df fix: Correct garbled Chinese characters in bot messages and logs. 2026-03-10 12:48:09 +08:00
2569718930@qq.com 35b846f1d6 feat: Add Polymarket and Polygon wallet activity watchers and integrate them into the bot. 2026-03-10 12:19:23 +08:00
2569718930@qq.com fb1c88463f feat: Add multi-source weather data collection (OpenWeatherMap, Meteoblue, Visual Crossing, METAR) and Polygon wallet watching functionality, introducing new configuration variables. 2026-03-10 11:51:43 +08:00
2569718930@qq.com d1b591106e feat: add dashboard panel components for hero summary and temperature chart 2026-03-10 10:37:14 +08:00
2569718930@qq.com 1afca8ecbb feat: Implement new dashboard panel sections, utilities, and a future forecast modal. 2026-03-10 10:17:59 +08:00
2569718930@qq.com e5011c5c8f feat: Introduce dashboard data types, utilities, market alert engine, and panel sections. 2026-03-10 10:07:18 +08:00
2569718930@qq.com 732b3c10a5 feat: Update README to detail React Quant Dashboard v2.0 features and refine Edge Analytics & Alerts descriptions. 2026-03-10 09:16:23 +08:00
2569718930@qq.com 396c373cba feat: implement PolyWeather dashboard with map UI, data collection, analysis, and comprehensive documentation. 2026-03-10 09:02:56 +08:00
2569718930@qq.com aab4477ab3 feat: Implement the PolyWeather dashboard including frontend components, data collection, analysis, and API endpoints. 2026-03-10 04:45:40 +08:00
59 changed files with 10076 additions and 2345 deletions
+60 -1
View File
@@ -6,11 +6,18 @@ TELEGRAM_ALERT_PUSH_INTERVAL_SEC=300
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
TELEGRAM_ALERT_MIN_SEVERITY=medium
TELEGRAM_ALERT_CITIES=ankara,london,paris,seoul,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
# Mispricing radar: skip push when YES buy price is above this cap (10c = 0.10)
TELEGRAM_ALERT_MISPRICING_MAX_YES_BUY=0.10
TELEGRAM_ALERT_CITIES=ankara,london,paris,seoul,hong kong,shanghai,singapore,tokyo,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
# AI
GROQ_API_KEY=your_groq_api_key_here
# Open-Meteo (forecast data changes ~hourly, no need to refresh more often)
OPEN_METEO_CACHE_TTL_SEC=7200
OPEN_METEO_ENSEMBLE_CACHE_TTL_SEC=7200
OPEN_METEO_MULTI_MODEL_CACHE_TTL_SEC=7200
# Proxy Setting (optional)
HTTPS_PROXY=http://127.0.0.1:7890
HTTP_PROXY=http://127.0.0.1:7890
@@ -19,3 +26,55 @@ HTTP_PROXY=http://127.0.0.1:7890
LOG_LEVEL=INFO
ENV=production
POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/
# Backend entitlement guard (for /api/cities, /api/city/*, /api/history/*)
POLYWEATHER_REQUIRE_ENTITLEMENT=false
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
# Polymarket P0 Read-Only Market Layer
POLYMARKET_MARKET_SCAN_ENABLED=true
POLYMARKET_GAMMA_URL=https://gamma-api.polymarket.com
POLYMARKET_CLOB_URL=https://clob.polymarket.com
POLYMARKET_CHAIN_ID=137
POLYMARKET_HTTP_TIMEOUT_SEC=8
POLYMARKET_MARKET_CACHE_TTL_SEC=180
POLYMARKET_PRICE_CACHE_TTL_SEC=10
POLYMARKET_DISCOVERY_PAGES=6
POLYMARKET_DISCOVERY_LIMIT=200
POLYMARKET_SIGNAL_MIN_LIQUIDITY=500
POLYMARKET_SIGNAL_EDGE_PCT=2
# Polygon Wallet Watcher (Single Chain P0)
POLYGON_WALLET_WATCH_ENABLED=false
POLYGON_RPC_URL=https://polygon-rpc.com
POLYGON_WALLET_WATCH_ADDRESSES=0x0000000000000000000000000000000000000000
POLYGON_WALLET_WATCH_INTERVAL_SEC=8
POLYGON_WALLET_WATCH_CONFIRMATIONS=2
POLYGON_WALLET_WATCH_MAX_BLOCKS_PER_CYCLE=30
POLYGON_WALLET_WATCH_SEEN_TTL_SEC=604800
POLYGON_WALLET_WATCH_RPC_TIMEOUT_SEC=10
POLYGON_WALLET_WATCH_TX_BASE=https://polygonscan.com/tx
POLYGON_WALLET_WATCH_ADDR_BASE=https://polygonscan.com/address
POLYGON_WALLET_WATCH_POLYMARKET_ONLY=true
POLYGON_WALLET_WATCH_INCLUDE_DEFAULT_PM_CONTRACTS=true
# Optional custom Polymarket contracts, format: LABEL:0x...,LABEL2:0x...
POLYGON_WALLET_WATCH_POLYMARKET_CONTRACTS=
# Polymarket Wallet Activity Watcher (all markets, not weather-only)
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
POLYMARKET_WALLET_ACTIVITY_USERS=0x0000000000000000000000000000000000000000
POLYMARKET_WALLET_ACTIVITY_DATA_API_URL=https://data-api.polymarket.com
POLYMARKET_WALLET_ACTIVITY_INTERVAL_SEC=20
POLYMARKET_WALLET_ACTIVITY_TIMEOUT_SEC=10
POLYMARKET_WALLET_ACTIVITY_MIN_SIZE_ABS=0.001
POLYMARKET_WALLET_ACTIVITY_MIN_SIZE_DELTA=0.001
POLYMARKET_WALLET_ACTIVITY_MIN_AVG_PRICE_DELTA=0.002
POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_ON_SIZE_DELTA=true
POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_SIZE_DELTA_MIN=0.001
POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_COOLDOWN_SEC=20
POLYMARKET_WALLET_ACTIVITY_MAX_CHANGES_PER_MSG=5
POLYMARKET_WALLET_ACTIVITY_NOTIFY_CLOSED=false
POLYMARKET_WALLET_ACTIVITY_BOOTSTRAP_ALERT=false
POLYMARKET_WALLET_ACTIVITY_UPDATE_DEBOUNCE_SEC=30
POLYMARKET_WALLET_ACTIVITY_UPDATE_MAX_HOLD_SEC=120
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MIN=0.01
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MAX=0.99
+115 -134
View File
@@ -1,168 +1,149 @@
# 🌡️ PolyWeather Pro
# PolyWeather Pro
> **Professional Weather Intelligence System** — Specialized in edge data collection, DEB smart blending, and real-time decision alerts.
Production weather-intelligence stack for temperature settlement markets.
---
Official dashboard: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
## 💎 Project Vision
## What This Project Does
PolyWeather is a specialized intelligence system built for **Polymarket** high-stakes participants. We aggregate top-tier meteorological sources, apply proprietary **DEB (Dynamic Error Balancing)** logic, and surface **actionable shift signals** at critical decision windows.
- Aggregates weather observations and forecasts for monitored cities.
- Blends multi-model forecasts with DEB (Dynamic Error Balancing).
- Computes settlement-oriented probability buckets (mu-centered distribution).
- Maps model view to Polymarket read-only market data for mispricing/risk scan.
- Delivers the same core logic to web dashboard and Telegram bot.
---
## Overview Diagram
## 🏗️ Production Architecture
```mermaid
flowchart TD
A["PolyWeather Pro"]
This project uses a decoupled production setup for reliability and iteration speed:
subgraph DL["Data Layer"]
DL1["METAR (Aviation Weather / METAR)"]
DL2["MGM (Turkey MGM)"]
DL3["Station 17130 (Ankara Center 17130)"]
DL4["Open-Meteo"]
DL5["weather.gov (US cities)"]
DL6["Polymarket (P0 Read-only)"]
end
- **Frontend**: A **Next.js** dashboard on **Vercel** with React component rendering.
- **Backend API**: A **FastAPI** service on VPS for low-latency weather aggregation and analysis.
- **Bot & Alert Heartbeat**: A **Telegram Bot** on VPS for minute-level scanning and push alerts.
subgraph AL["Analysis Layer"]
AL1["DEB (Dynamic Error Balancing)"]
AL2["Probability Engine (mu + buckets)"]
AL3["Trend Engine"]
AL4["Risk Profiles"]
AL5["Mispricing Radar"]
end
🔗 **Official Visit**: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
subgraph DEL["Delivery Layer"]
DEL1["FastAPI"]
DEL2["Next.js Dashboard"]
DEL3["Telegram Bot"]
DEL4["Alert Push"]
end
---
subgraph OL["Ops Layer"]
OL1["Docker Compose (VPS backend + bot)"]
OL2["Vercel (frontend)"]
OL3["Cache + force_refresh"]
OL4["Speed Insights"]
end
## 🖼️ Preview & Interaction
A --> DL
A --> AL
A --> DEL
A --> OL
```
<p align="center">
<img src="docs/images/demo_ankara.png" alt="PolyWeather Demo - Ankara Live Analysis" width="450">
<br>
<em>📊 <b>Deep Query View</b>: DEB blended forecast + settlement probability + AI analysis context</em>
</p>
<p align="center">
<img src="./docs/images/demo_map.png" alt="PolyWeather Web Map" width="850">
<br>
<em>🗺️ <b>Omni-Dashboard</b>: global station markers + nearby station context + right-side city intelligence panel</em>
</p>
---
## 🚀 Core Features
- **📡 Full-Spectrum Collection**
- **Major Models**: ECMWF, GFS, ICON, GEM, JMA, Open-Meteo, and city-level daily/hourly guidance.
- **Observed Data**: Aviation Weather / METAR as the primary observation source, plus Turkish MGM coverage for Ankara.
- **City Specialization**: `17130` (`Ankara (Bölge/Center)`) remains the Ankara lead station without replacing LTAC settlement observation.
- **⚖️ DEB Smart Blending**
- Dynamic weighting based on city-level performance and current model spread.
- **🧩 React Dashboard Runtime**
- Typed store + typed API client + Leaflet/Chart.js lifecycle wrappers.
- City click workflow: map focus + right panel open + nearby stations render.
- Today analysis workflow: open modal + freeze map motion.
- **🔔 Alert Engine**
- **Momentum Spike**: Captures rapid short-window temperature slope changes.
- **Forecast Breakthrough**: Fires when observations break model envelopes plus margin.
- **Advection Monitoring**: Tracks warm/cold advection using lead-station behavior and wind direction.
---
## 🔐 Alert Logic Details
| Trigger Name | Core Logic | Trading Value |
| :--------------- | :-------------------------------------------- | :-------------------------------------------- |
| **Center Hit** | Detects DEB trigger only at Ankara HQ `17130` | **Highest priority signal**, the "truth" |
| **Momentum** | 30min temperature slope exceed threshold | Captures sudden weather fronts |
| **Breakthrough** | Pierces all model highs + margin | Captures high-volatility outlier events |
| **Advection** | Lead station rise + Wind match | Gain 20-40 minutes of lead time for execution |
---
## 🧭 Current Data Logic
- **Primary observation source**: Aviation Weather / METAR
- **Ankara enhancement**:
- Settlement observation: `LTAC / Esenboğa`
- Official lead station: `Ankara (Bölge/Center)` / `17130`
- Nearby station layer: Turkish MGM network (Ankara-specific preferred station ordering)
- **Other cities nearby layer**:
- Production currently uses Aviation Weather METAR clusters
- U.S. cities may later receive Mesonet augmentation while METAR stays baseline
- **Frontend request optimization**:
- Initial map temperatures preload via `/api/city/{name}/summary`
- City detail cache TTL = 5 minutes, revision probe avoids unnecessary refetch
- Map movements, panel toggles, and modal open/close do not trigger redundant requests
- Manual refresh always bypasses cache (`force_refresh=true`)
---
## 🏗️ System Architecture
## Architecture
```mermaid
graph TD
subgraph "Client / Terminals"
Web[Next.js React Web App]
TG[Telegram Client]
end
User[Web / Telegram User] --> FE[Next.js Frontend on Vercel]
User --> Bot[Telegram Bot on VPS]
FE --> API[FastAPI Service]
Bot --> API
subgraph "Edge Deployment (Vercel)"
Web --> |BFF Routes| Fast[FastAPI API]
end
API --> WX[Weather Data Collector]
WX --> METAR[METAR / Aviation Weather]
WX --> MGM[MGM API / nearby stations]
WX --> OM[Open-Meteo]
WX --> NWS[weather.gov]
subgraph "Core Hub (VPS)"
Fast --- |Shared Logic| Worker[Alert Engine / Worker]
Bot[Telegram Bot] --- |Shared Logic| Worker
end
subgraph "External Sources"
Worker --> |Pull| MGM[MGM Weather]
Worker --> |Pull| METAR[Airport METAR]
Worker --> |Pull| OM[Open-Meteo]
Worker --> |Pull| MM[Multi-Model Integration]
end
Worker --> |Push Alert| TG
Bot --> |Query| Worker
API --> DEB[DEB + Trend + Probability Engines]
API --> PM[Polymarket Read-only Layer]
PM --> Gamma[Gamma API]
PM --> CLOB[CLOB / py-clob-client]
```
---
## Current Source Policy
## 🛠️ Deployment
| Domain | Source Policy |
| :------------------ | :--------------------------------------------------- |
| Primary observation | Aviation Weather / METAR |
| Ankara enhancement | MGM + nearby stations, lead station fixed to `17130` |
| Forecast baseline | Open-Meteo |
| US official context | weather.gov |
| Market layer | Polymarket P0 read-only discovery + quotes |
| Removed source | Meteoblue (fully removed from code and docs) |
### 1. Backend / Bot (VPS)
## Recent Changes (2026-03-11)
- Removed all Meteoblue API integration and references.
- Fixed market top-bucket rendering path by deduplicating repeated temperature buckets.
- Added frontend fallback guard when market top buckets collapse to low-quality duplicates.
- Fixed detail panel accessibility issue (`aria-hidden` focus conflict) using `inert` + active-element blur.
- Added Vercel Speed Insights integration in `frontend/app/layout.tsx`.
## Repositories and Runtime Paths
- Frontend: `frontend/` (Next.js App Router)
- Backend API: `web/app.py` and `src/`
- Telegram runtime: `bot_listener.py` + `src/analysis/*`
- Docs: `docs/`
## Quick Start
### Backend + Bot (VPS / Docker)
```bash
# Pull source
git pull
# Environment
# Edit .env with TELEGRAM_BOT_TOKEN and other keys
# Launch
docker-compose up -d --build
docker compose up -d --build
```
### 2. Frontend (Vercel)
### Frontend (local)
Set `frontend` as the Vercel root directory for automatic CI/CD.
```bash
cd frontend
npm install
npm run dev
```
---
### Frontend production build check
## 💬 Bot Commands
```bash
cd frontend
npm run build
```
| Command | Description | Example |
| :-------- | :-------------------------------------- | :------------- |
| `/city` | Query real-time analysis for a city | `/city ankara` |
| `/deb` | View historical accuracy of DEB model | `/deb london` |
| `/top` | View activity leaderboard | `/top` |
| `/help` | Get detailed instructions | `/help` |
## Command Surface (Telegram)
---
| Command | Purpose |
| :------------- | :---------------------------- |
| `/city <name>` | City real-time analysis |
| `/deb <name>` | DEB historical reconciliation |
| `/top` | User leaderboard |
| `/help` | Help and command usage |
> [!NOTE]
> **Commercialization**: Current plans keep **Web Dashboard ($5/mo)** and **Telegram Signal Channel ($1/mo)** as the core entry offers.
> User entitlement and payment automation are tracked in `docs/COMMERCIALIZATION.md`.
## Documentation Index
> [!NOTE]
> **Frontend Model**: Production rendering is now fully handled by React components under `frontend/components/dashboard` and hooks under `frontend/hooks`.
> Legacy static files are retained for reference, but no longer act as the main runtime path.
- Chinese API guide: `docs/API_ZH.md`
- Commercial roadmap: `docs/COMMERCIALIZATION.md`
- Tech debt (EN): `docs/TECH_DEBT.md`
- Tech debt (ZH): `docs/TECH_DEBT_ZH.md`
- Chinese overview: `README_ZH.md`
---
## Status
---
**📅 Last Updated**: 2026-03-09
**🚀 Status**: v1.1 Stable - React Dashboard Runtime in Production
> [!TIP]
> **Production Note**: The UI layout and visual contract remain unchanged while data flow, map lifecycle, and modal interaction are now managed by typed React modules.
- Version: `v1.3`
- Last Updated: `2026-03-11`
- Runtime: Stable (web + bot + market read-only layer in production)
+114 -134
View File
@@ -1,168 +1,148 @@
# 🌡️ PolyWeather Pro
# PolyWeather Pro
> **专业级博弈情报系统** —— 专注边缘气象数据采集、DEB 智能融合与实时决策预警
面向温度结算市场的生产级气象情报系统
---
官方看板:[polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
## 💎 项目愿景
## 这个项目在做什么
PolyWeather 是一套专为 **Polymarket** 深度博弈者设计的实时情报系统。我们不只提供天气预报,而是通过聚合全球气象源、应用自研 **DEB (Dynamic Error Balancing)** 算法,并在关键时间节点输出**可执行的异动信号**
- 聚合监控城市的实测与预报数据
- 用 DEBDynamic Error Balancing)做动态融合预测。
- 计算结算导向的温度概率分布(`μ` + 温度桶)。
- 将模型概率与 Polymarket 只读市场数据对齐,输出错价/风险信号。
- Web 仪表盘与 Telegram 机器人共用同一套核心逻辑。
---
## 概览图
## 🏗️ 生产架构
```mermaid
flowchart TD
A["PolyWeather Pro"]
本项目采用生产级解耦架构,确保高可用与迭代效率:
subgraph DL["数据层"]
DL1["METAR (Aviation Weather / METAR)"]
DL2["MGM (土耳其 MGM)"]
DL3["安卡拉主站 (17130 Center)"]
DL4["Open-Meteo"]
DL5["weather.gov (美国城市)"]
DL6["Polymarket (P0 只读)"]
end
- **前端**:部署在 **Vercel** 上的 **Next.js + React 组件化仪表盘**
- **后端 API**:运行在 VPS 上的 **FastAPI**,负责多源聚合与分析计算。
- **机器人与预警心跳**:运行在 VPS 上的 **Telegram Bot**,执行分钟级扫描与推送。
subgraph AL["分析层"]
AL1["DEB (动态误差平衡)"]
AL2["概率引擎 (mu + 桶分布)"]
AL3["趋势引擎"]
AL4["城市风险档案"]
AL5["错价雷达"]
end
🔗 **官方访问地址**[polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
subgraph DEL["交付层"]
DEL1["FastAPI"]
DEL2["Next.js 仪表盘"]
DEL3["Telegram Bot"]
DEL4["预警推送"]
end
---
subgraph OL["运维层"]
OL1["Docker Compose (VPS)"]
OL2["Vercel (前端)"]
OL3["缓存 + force_refresh"]
OL4["Speed Insights"]
end
## 🖼️ 预览与交互
A --> DL
A --> AL
A --> DEL
A --> OL
```
<p align="center">
<img src="docs/images/demo_ankara.png" alt="PolyWeather 效果展示 - 安卡拉实时分析" width="450">
<br>
<em>📊 <b>深度查询效果</b>DEB 融合预测 + 结算概率 + AI 分析上下文</em>
</p>
<p align="center">
<img src="./docs/images/demo_map.png" alt="PolyWeather Web Map" width="850">
<br>
<em>🗺️ <b>全景仪表盘</b>:全球站点标记 + 周边站点联动 + 右侧城市详情卡片</em>
</p>
---
## 🚀 核心功能
- **📡 多源全量采集**
- **主流模型**ECMWF、GFS、ICON、GEM、JMA、Open-Meteo 的日/小时指导。
- **实测数据**Aviation Weather / METAR 为主观测源,安卡拉叠加 Turkish MGM 官方网络。
- **城市特化**:安卡拉保留 `17130``Ankara (Bölge/Center)`)领先站逻辑,不替代 LTAC 结算主站。
- **⚖️ DEB 智能融合**
- 基于城市历史表现与当前模型分歧动态调整权重。
- **🧩 React 仪表盘运行时**
- 类型化 Store + 类型化 Data Client + Leaflet/Chart.js 生命周期封装。
- 点击城市:地图聚焦 + 右侧卡片打开 + 周边站点展示。
- 点击“今日日内分析”:打开模态框并冻结地图动画。
- **🔔 异动预警系统**
- **动量突变**:捕捉短窗口温度斜率变化。
- **预测突破**:实测突破模型包络与安全边际时触发。
- **平流监测**:结合前导站和风向识别冷暖平流。
---
## 🔐 预警逻辑深度说明
| 触发器名称 | 核心逻辑 | 博弈价值 |
| :--------------- | :------------------------------------------- | :--------------------------------- |
| **Center Hit** | 仅识别安卡拉总部 `17130` 站点的 DEB 触发信号 | **最高级信号**,定盘星 |
| **Momentum** | 30min 温度斜率超过 | 捕捉突发天气系统(如锋面) |
| **Breakthrough** | 击穿所有预报上限 + 安全边际 | 捕捉市场极少数情况下的暴利点 |
| **Advection** | 前导站温升 + 风向匹配 | 获得 20-40 分钟的提前离场/建仓时间 |
---
## 🧭 当前数据逻辑
- **主观测源**Aviation Weather / METAR
- **安卡拉增强逻辑**
- 结算主观测:`LTAC / Esenboğa`
- 官方领先站:`Ankara (Bölge/Center)` / `17130`
- 周边站层:土耳其 MGM 网络(含安卡拉优先站筛选)
- **其他城市周边站层**
- 当前生产环境使用 Aviation Weather METAR cluster
- 美国城市后续可叠加 Mesonet,但 METAR 仍为基础层
- **前端请求优化口径**
- 首屏先走 `/api/city/{name}/summary` 预热地图温度
- 城市详情 5 分钟 TTL,revision 不变则跳过重拉
- 地图联动、侧卡开关、modal 开关不会重复请求
- 手动刷新强制绕过缓存(`force_refresh=true`
---
## 🏗️ 架构解析
## 系统架构
```mermaid
graph TD
subgraph "客户端 / 终端"
Web[Next.js React 网页端]
TG[Telegram 客户端]
end
User[Web / Telegram 用户] --> FE[Vercel Next.js 前端]
User --> Bot[VPS Telegram Bot]
FE --> API[FastAPI 服务]
Bot --> API
subgraph "云端部署 (Vercel)"
Web --> |BFF 路由| Fast[FastAPI API]
end
API --> WX[Weather Collector]
WX --> METAR[METAR / Aviation Weather]
WX --> MGM[MGM API / 周边站]
WX --> OM[Open-Meteo]
WX --> NWS[weather.gov]
subgraph "核心引擎 (VPS)"
Fast --- |Shared Logic| Worker[Alert Engine / Worker]
Bot[Telegram Bot] --- |Shared Logic| Worker
end
subgraph "外部数据源"
Worker --> |Pull| MGM[MGM 气象局]
Worker --> |Pull| METAR[机场实测]
Worker --> |Pull| OM[Open-Meteo]
Worker --> |Pull| MM[多模型集成]
end
Worker --> |Push Alert| TG
Bot --> |Query| Worker
API --> DEB[DEB + 趋势 + 概率引擎]
API --> PM[Polymarket 只读层]
PM --> Gamma[Gamma API]
PM --> CLOB[CLOB / py-clob-client]
```
---
## 当前数据源口径
## 🛠️ 部署指南
| 领域 | 当前口径 |
| :------------- | :-------------------------------- |
| 主观测源 | Aviation Weather / METAR |
| Ankara 增强 | MGM + 周边站,领先站固定 `17130` |
| 预报基线 | Open-Meteo |
| 美国官方语义层 | weather.gov |
| 市场层 | Polymarket P0 只读发现 + 报价 |
| 已移除 | Meteoblue(代码与文档已全部移除) |
### 1. 后端 / 机器人 (VPS)
## 最近更新(2026-03-11
- 完整移除 Meteoblue API 及全部引用。
- 修复市场“最热温度桶”重复温度刷屏问题(后端按温度去重 + 前端兜底去重)。
- 修复详情面板可访问性告警(`aria-hidden` 焦点冲突),改为 `inert + blur`
- 集成 Vercel Speed Insights`frontend/app/layout.tsx`)。
## 目录说明
- 前端:`frontend/`Next.js App Router
- 后端:`web/app.py``src/`
- 机器人:`bot_listener.py` + `src/analysis/*`
- 文档:`docs/`
## 快速启动
### 后端 + 机器人(VPS / Docker
```bash
# 获取源码
git pull
# 环境配置
# 编辑 .env 文件,填入 TELEGRAM_BOT_TOKEN 等关键参数
# 一键启动
docker-compose up -d --build
docker compose up -d --build
```
### 2. 前端 (Vercel)
### 前端本地运行
关联本项目 `frontend` 目录作为根目录,启用自动 CI/CD。
```bash
cd frontend
npm install
npm run dev
```
---
### 前端构建校验
## 💬 机器人指令
```bash
cd frontend
npm run build
```
| 命令 | 说明 | 示例 |
| :-------- | :------------------------ | :------------- |
| `/city` | 查询指定城市实时分析 | `/city ankara` |
| `/deb` | 查看 DEB 模型的历史准确率 | `/deb london` |
| `/top` | 查看活跃积分排行榜 | `/top` |
| `/help` | 获取详细功能说明 | `/help` |
## Telegram 命令
---
| 命令 | 用途 |
| :------------- | :----------- |
| `/city <name>` | 城市实时分析 |
| `/deb <name>` | DEB 历史对账 |
| `/top` | 用户排行榜 |
| `/help` | 帮助说明 |
> [!NOTE]
> **商业化提示**:当前仍以 **Web 仪表盘 ($5/月)** 与 **Telegram 信号频道 ($1/月)** 为核心入口套餐。
> 自动化支付与订阅鉴权规划见 `docs/COMMERCIALIZATION.md`。
## 文档索引
> [!NOTE]
> **前端现状**:生产环境页面已由 `frontend/components/dashboard` 与 `frontend/hooks` 完整接管渲染。
> legacy 静态文件仅保留为历史参考,不再作为主运行入口。
- API 文档(中文):`docs/API_ZH.md`
- 商业化路线:`docs/COMMERCIALIZATION.md`
- 技术债(英文):`docs/TECH_DEBT.md`
- 技术债(中文):`docs/TECH_DEBT_ZH.md`
- 英文总览:`README.md`
---
## 当前状态
---
**📅 最后更新**2026-03-09
**🚀 状态**v1.1 稳定版 - React 运行时已上线
> [!TIP]
> **生产提示**:在不改变既有 UI 布局与视觉层级的前提下,数据流、地图联动和模态行为已迁移到类型化 React 组件体系。
- 版本:`v1.3`
- 最后更新:`2026-03-11`
- 状态:稳定运行(Web + Bot + 市场只读层)
+62 -458
View File
@@ -1,6 +1,5 @@
import sys
import sys
import os
from typing import List
import telebot # type: ignore
from loguru import logger # type: ignore
@@ -11,10 +10,14 @@ if project_root not in sys.path:
from src.utils.config_loader import load_config # type: ignore # noqa: E402
from src.utils.telegram_push import start_trade_alert_push_loop # type: ignore # noqa: E402
from src.onchain.polygon_wallet_watcher import start_polygon_wallet_watch_loop # type: ignore # noqa: E402
from src.onchain.polymarket_wallet_activity_watcher import start_polymarket_wallet_activity_loop # type: ignore # noqa: E402
from src.data_collection.weather_sources import WeatherDataCollector # 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
from src.database.db_manager import DBManager
from src.analysis.city_query_service import (
resolve_city_name,
build_city_query_report,
)
MESSAGE_POINTS = 4
MESSAGE_DAILY_CAP = 50
@@ -24,13 +27,6 @@ CITY_QUERY_COST = 1
DEB_QUERY_COST = 1
def analyze_weather_trend(weather_data, temp_symbol, city_name=None):
"""Thin wrapper — delegates to shared trend_engine module."""
from src.analysis.trend_engine import analyze_weather_trend as _analyze
display_str, ai_context, _structured = _analyze(weather_data, temp_symbol, city_name)
return display_str, ai_context
def start_bot():
config = load_config()
token = os.getenv("TELEGRAM_BOT_TOKEN")
@@ -42,6 +38,8 @@ def start_bot():
db = DBManager()
weather = WeatherDataCollector(config)
start_trade_alert_push_loop(bot, config)
start_polygon_wallet_watch_loop(bot)
start_polymarket_wallet_activity_loop(bot)
def _display_name(user) -> str:
return user.username or user.first_name or f"User_{user.id}"
@@ -63,7 +61,7 @@ def start_bot():
f"当前积分: <code>{balance}</code>\n"
f"需要积分: <code>{required}</code>\n"
f"还差积分: <code>{missing}</code>\n\n"
f"积分规则:每日签到(有效发言满 {MESSAGE_MIN_LENGTH})获得 <b>{MESSAGE_POINTS}</b> 积分,"
f"积分规则:每日签到有效发言满 {MESSAGE_MIN_LENGTH}获得 <b>{MESSAGE_POINTS}</b> 积分,"
f"每日上限 {MESSAGE_DAILY_CAP} 分。"
),
parse_mode="HTML",
@@ -73,7 +71,7 @@ def start_bot():
@bot.message_handler(commands=["start", "help"])
def send_welcome(message):
welcome_text = (
"🌡️ <b>PolyWeather 天气查询机器人</b>\n\n"
"🚀 <b>PolyWeather 天气查询机器人</b>\n\n"
"可用指令:\n"
f"/city [城市名] - 查询城市天气预测与实测 (消耗 {CITY_QUERY_COST} 积分)\n"
f"/deb [城市名] - 查看 DEB 融合预测准确率 (消耗 {DEB_QUERY_COST} 积分)\n"
@@ -111,10 +109,10 @@ def start_bot():
rank_text += "────────────────────\n"
rank_text += (
f"👤 <b>我的状态:</b>\n"
f" 积分: <code>{user_info['points']}</code>\n"
f" 发言: <code>{user_info['message_count']}</code> 次\n"
f" 今日发言积分: <code>{user_info.get('daily_points') or 0}/{MESSAGE_DAILY_CAP}</code>\n"
f" /city 消耗: <code>{CITY_QUERY_COST}</code> | /deb 消耗: <code>{DEB_QUERY_COST}</code>"
f" 积分: <code>{user_info['points']}</code>\n"
f" 发言: <code>{user_info['message_count']}</code> 次\n"
f" 今日发言积分: <code>{user_info.get('daily_points') or 0}/{MESSAGE_DAILY_CAP}</code>\n"
f" /city 消耗: <code>{CITY_QUERY_COST}</code> | /deb 消耗: <code>{DEB_QUERY_COST}</code>"
)
bot.send_message(message.chat.id, rank_text, parse_mode="HTML")
@@ -134,7 +132,11 @@ def start_bot():
from datetime import datetime as _dt, timedelta as _td
import os as _os
from src.analysis.deb_algorithm import load_history
from src.analysis.deb_algorithm import (
load_history,
_is_excluded_model_name,
reconcile_recent_actual_highs,
)
from src.data_collection.city_registry import ALIASES
city_input = parts[1].strip().lower()
@@ -155,6 +157,9 @@ def start_bot():
if not _ensure_query_points(message, DEB_QUERY_COST, "/deb"):
return
reconcile_info = reconcile_recent_actual_highs(city_name, lookback_days=7)
# Reload in case reconciliation updated the file
data = load_history(history_file)
city_data = data[city_name]
today = _dt.now().date()
today_str = today.strftime("%Y-%m-%d")
@@ -174,8 +179,19 @@ def start_bot():
lines = [
f"📊 <b>DEB 准确率报告 - {city_name.title()}</b>",
"",
"📅 <b>近7日记录:</b>",
"📅 <b>近日记录:</b>",
]
if (
isinstance(reconcile_info, dict)
and reconcile_info.get("ok")
and int(reconcile_info.get("updated") or 0) > 0
):
lines.extend(
[
f"🔁 已用 METAR 历史回填修正 {int(reconcile_info.get('updated'))} 天实测最高温",
"",
]
)
total_days = 0
hits = 0
deb_errors = []
@@ -198,7 +214,11 @@ def start_bot():
continue
if deb_pred is None and forecasts:
valid_preds = [float(v) for v in forecasts.values() if v is not None]
valid_preds = [
float(v)
for k, v in forecasts.items()
if v is not None and not _is_excluded_model_name(k)
]
if valid_preds:
deb_pred = round(sum(valid_preds) / len(valid_preds), 1)
@@ -234,6 +254,8 @@ def start_bot():
if date_str != today_str and actual is not None:
for model, pred in forecasts.items():
if _is_excluded_model_name(model):
continue
if pred is None:
continue
try:
@@ -246,7 +268,7 @@ def start_bot():
deb_mae = sum(deb_errors) / len(deb_errors)
lines.append("")
lines.append(
f"🎯 <b>DEB 总战绩:</b>WU命中 {hits}/{total_days} (<b>{hit_rate:.0f}%</b>) | MAE: {deb_mae:.1f}°"
f"🏁 <b>DEB 总战绩:</b>WU命中 {hits}/{total_days} (<b>{hit_rate:.0f}%</b>) | MAE: {deb_mae:.1f}°"
)
if model_errors:
@@ -271,7 +293,7 @@ def start_bot():
lines.append(f" ⚠️ {bias_label}:平均偏差 {mean_bias:+.1f}°")
else:
lines.append(f" ✅ 整体无明显系统偏差:平均偏差 {mean_bias:+.1f}°")
lines.append(f" 低估 {underest} 次 | 高估 {overest} 次 | 准确 {accurate}")
lines.append(f" (低估 {underest} 次 | 高估 {overest} 次 | 准确 {accurate})")
lines.append("")
lines.append("💡 <b>建议:</b>")
@@ -293,19 +315,18 @@ def start_bot():
lines.append("")
lines.append("📝 MAE = 平均绝对误差,越小越准。⭐ = 优于 DEB 融合。")
lines.append("🗓 统计窗口:近7天滚动样本。")
lines.append("📅 统计窗口:近7天滚动样本。")
else:
lines.append("")
lines.append("7天尚无完整的 DEB 预测记录。")
lines.append("🔔 7 天尚无完整的 DEB 预测记录。")
lines.append("")
lines.append(f"💳 本次消耗 <code>{DEB_QUERY_COST}</code> 积分。")
lines.append(f"💸 本次消耗 <code>{DEB_QUERY_COST}</code> 积分。")
bot.reply_to(message, "\n".join(lines), parse_mode="HTML")
except Exception as e:
bot.reply_to(message, f"❌ 查询失败: {e}")
@bot.message_handler(commands=["city"])
def get_city_info(message):
"""查询指定城市的天气详情"""
try:
@@ -313,43 +334,18 @@ def start_bot():
if len(parts) < 2:
bot.reply_to(
message,
" 请输入城市名称\n\n用法: <code>/city chicago</code>",
" 请输入城市名称\n\n用法: <code>/city chicago</code>",
parse_mode="HTML",
)
return
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
city_input = parts[1].strip().lower()
# --- 使用统一注册表解析城市 ---
SUPPORTED_CITIES = list(CITY_REGISTRY.keys())
# 1. 第一优先级:全称或别名完全匹配
city_name = ALIASES.get(city_input)
if not city_name and city_input in SUPPORTED_CITIES:
city_name = city_input
# 2. 第二优先级:前缀模糊匹配
if not city_name and len(city_input) >= 2:
# 搜别名
for k, v in ALIASES.items():
if k.startswith(city_input):
city_name = v
break
# 搜城市全名
if not city_name:
for full_name in SUPPORTED_CITIES:
if full_name.startswith(city_input):
city_name = full_name
break
# 3. 未找到 → 报错
city_name, supported_cities = resolve_city_name(city_input)
if not city_name:
city_list = ", ".join(sorted(SUPPORTED_CITIES))
city_list = ", ".join(supported_cities)
bot.reply_to(
message,
f"❌ 未找到城市: <b>{city_input}</b>\n\n"
f"支持的城市: {city_list}",
f"❌ 未找到城市: <b>{city_input}</b>\n\n支持的城市: {city_list}",
parse_mode="HTML",
)
return
@@ -367,411 +363,17 @@ def start_bot():
return
weather_data = weather.fetch_all_sources(
city_name, lat=coords["lat"], lon=coords["lon"]
city_name,
lat=coords["lat"],
lon=coords["lon"],
force_refresh=True,
)
open_meteo = weather_data.get("open-meteo", {})
metar = weather_data.get("metar", {})
mgm = weather_data.get("mgm") or {}
# 数值归一化
def _sf(v):
if v is None:
return None
try:
return float(v)
except Exception:
return None
temp_unit = open_meteo.get("unit", "celsius")
temp_symbol = "°F" if temp_unit == "fahrenheit" else "°C"
# --- 1. 紧凑 Header (城市 + 时间 + 风险状态) ---
local_time = open_meteo.get("current", {}).get("local_time", "")
time_str = local_time.split(" ")[1][:5] if " " in local_time else "N/A"
risk_profile = get_city_risk_profile(city_name)
risk_emoji = risk_profile.get("risk_level", "") if risk_profile else ""
msg_header = f"📍 <b>{city_name.title()}</b> ({time_str}) {risk_emoji}"
msg_lines = [msg_header]
# --- 2. 紧凑 风险提示 ---
if risk_profile:
bias = risk_profile.get("bias", "±0.0")
msg_lines.append(
f"⚠️ {risk_profile.get('airport_name', '')}: {bias}{temp_symbol} | {risk_profile.get('warning', '')}"
)
# --- 3. 紧凑 预测区 ---
daily = open_meteo.get("daily", {})
dates = daily.get("time", [])[:3]
max_temps = daily.get("temperature_2m_max", [])[:3]
nws_high = _sf(weather_data.get("nws", {}).get("today_high"))
mgm_high = _sf(mgm.get("today_high"))
mb_high = _sf(weather_data.get("meteoblue", {}).get("today_high"))
# 今天对比
today_t = max_temps[0] if max_temps else "N/A"
comp_parts = []
sources = ["Open-Meteo"]
if mb_high is not None:
sources.append("MB")
comp_parts.append(
f"MB: {mb_high:.1f}{temp_symbol}"
if isinstance(mb_high, (int, float))
else f"MB: {mb_high}"
)
if nws_high is not None:
sources.append("NWS")
comp_parts.append(
f"NWS: {nws_high:.1f}{temp_symbol}"
if isinstance(nws_high, (int, float))
else f"NWS: {nws_high}"
)
if mgm_high is not None:
sources.append("MGM")
comp_parts.append(
f"🇹🇷 MGM: {mgm_high:.1f}{temp_symbol}"
if isinstance(mgm_high, (int, float))
else f"🇹🇷 MGM: {mgm_high}"
)
# 检查是否有显著分歧 (超过 5°F 或 2.5°C)
divergence_warning = ""
if mb_high is not None and max_temps:
diff = abs(mb_high - (_sf(max_temps[0]) or 0))
threshold = 5.0 if temp_unit == "fahrenheit" else 2.5
if diff > threshold:
divergence_warning = (
f" ⚠️ <b>模型显著分歧 ({diff:.1f}{temp_symbol})</b>"
)
comp_str = f" ({' | '.join(comp_parts)})" if comp_parts else ""
sources_str = " | ".join(sources)
msg_lines.append(f"\n📊 <b>预报 ({sources_str})</b>")
msg_lines.append(
f"👉 <b>今天: {today_t}{temp_symbol}{comp_str}</b>{divergence_warning}"
city_report = build_city_query_report(
city_name=city_name,
weather_data=weather_data,
city_query_cost=CITY_QUERY_COST,
)
# 明后天
if len(dates) > 1:
future_forecasts = []
mgm_daily = mgm.get("daily_forecasts", {}) or {}
for d, t in zip(dates[1:], max_temps[1:]):
# 检查 MGM 是否有该日期的预报
mgm_f = mgm_daily.get(d)
if mgm_f is not None:
future_forecasts.append(
f"{d[5:]}: {t}{temp_symbol} | 🇹🇷 <b>MGM: {mgm_f}{temp_symbol}</b>"
)
else:
future_forecasts.append(f"{d[5:]}: {t}{temp_symbol}")
msg_lines.append("📅 " + " | ".join(future_forecasts))
# --- 3.5 日出日落 + 日照时长 ---
sunrises = daily.get("sunrise", [])
sunsets = daily.get("sunset", [])
sunshine_durations = daily.get("sunshine_duration", [])
if sunrises and sunsets:
sunrise_t = (
sunrises[0].split("T")[1][:5]
if "T" in str(sunrises[0])
else sunrises[0]
)
sunset_t = (
sunsets[0].split("T")[1][:5]
if "T" in str(sunsets[0])
else sunsets[0]
)
sun_line = f"🌅 日出 {sunrise_t} | 🌇 日落 {sunset_t}"
if sunshine_durations:
sunshine_hours = sunshine_durations[0] / 3600 # 秒 -> 小时
sun_line += f" | ☀️ 日照 {sunshine_hours:.1f}h"
msg_lines.append(sun_line)
# --- 4. 核心 实测区 (合并 METAR 和 MGM) ---
# 基础数据优先用 METAR
cur_temp = _sf(
metar.get("current", {}).get("temp")
if metar
else mgm.get("current", {}).get("temp")
)
max_p = _sf(
metar.get("current", {}).get("max_temp_so_far") if metar else None
)
max_p_time = (
metar.get("current", {}).get("max_temp_time") if metar else None
)
obs_t_str = "N/A"
metar_age_min = None # METAR 数据年龄(分钟)
main_source = "METAR" if metar else "MGM"
if metar:
obs_t = metar.get("observation_time", "")
try:
if "T" in obs_t:
from datetime import datetime, timezone, timedelta
dt = datetime.fromisoformat(obs_t.replace("Z", "+00:00"))
utc_offset = open_meteo.get("utc_offset", 0)
local_dt = dt.astimezone(
timezone(timedelta(seconds=utc_offset))
)
obs_t_str = local_dt.strftime("%H:%M")
# 计算数据年龄
now_utc = datetime.now(timezone.utc)
metar_age_min = int((now_utc - dt).total_seconds() / 60)
elif " " in obs_t:
obs_t_str = obs_t.split(" ")[1][:5]
else:
obs_t_str = obs_t
except Exception:
obs_t_str = obs_t[:16]
elif mgm:
m_time = mgm.get("current", {}).get("time", "")
if "T" in m_time:
from datetime import datetime, timezone, timedelta
dt = datetime.fromisoformat(m_time.replace("Z", "+00:00"))
m_time = dt.astimezone(timezone(timedelta(hours=3))).strftime(
"%H:%M"
)
elif " " in m_time:
m_time = m_time.split(" ")[1][:5]
obs_t_str = m_time
# 数据年龄标注
age_tag = ""
if metar_age_min is not None:
if metar_age_min >= 60:
age_tag = f" ⚠️{metar_age_min}分钟前"
elif metar_age_min >= 30:
age_tag = f"{metar_age_min}分钟前"
max_str = ""
if max_p is not None:
import math
settled_val = math.floor(max_p + 0.5)
max_str = f" (最高: {max_p}{temp_symbol}"
if max_p_time:
max_str += f" @{max_p_time}"
max_str += f" → WU {settled_val}{temp_symbol})"
# --- 天气状况总结 ---
wx_summary = ""
# 优先使用 METAR 天气现象
metar_wx = metar.get("current", {}).get("wx_desc", "") if metar else ""
metar_clouds = metar.get("current", {}).get("clouds", []) if metar else []
mgm_cloud = mgm.get("current", {}).get("cloud_cover") if mgm else None
if metar_wx:
wx_upper = metar_wx.upper().strip()
wx_tokens = set(wx_upper.split())
rain_codes = {
"RA",
"DZ",
"-RA",
"+RA",
"-DZ",
"+DZ",
"TSRA",
"SHRA",
"FZRA",
}
snow_codes = {"SN", "GR", "GS", "-SN", "+SN", "BLSN"}
fog_codes = {"FG", "BR", "HZ", "FZFG"}
ts_codes = {"TS", "TSRA"}
if ts_codes & wx_tokens:
wx_summary = "⛈️ 雷暴"
elif {"+RA", "+SN"} & wx_tokens:
wx_summary = "🌧️ 大雨" if "+RA" in wx_tokens else "❄️ 大雪"
elif rain_codes & wx_tokens:
wx_summary = (
"🌧️ 小雨" if {"-RA", "-DZ", "DZ"} & wx_tokens else "🌧️ 下雨"
)
elif snow_codes & wx_tokens:
wx_summary = "❄️ 下雪"
elif fog_codes & wx_tokens:
wx_summary = "🌫️ 雾/霾"
# 如果 METAR 没有特殊现象,用云量推断
if not wx_summary:
# 优先 METAR 云层,回退 MGM
cover_code = ""
if metar_clouds:
cover_code = metar_clouds[-1].get("cover", "")
if cover_code in ("SKC", "CLR") or (
cover_code == "" and mgm_cloud is not None and mgm_cloud <= 1
):
wx_summary = "☀️ 晴"
elif cover_code == "FEW" or (
cover_code == "" and mgm_cloud is not None and mgm_cloud <= 2
):
wx_summary = "🌤️ 晴间少云"
elif cover_code == "SCT" or (
cover_code == "" and mgm_cloud is not None and mgm_cloud <= 4
):
wx_summary = "⛅ 晴间多云"
elif cover_code == "BKN" or (
cover_code == "" and mgm_cloud is not None and mgm_cloud <= 6
):
wx_summary = "🌥️ 多云"
elif cover_code == "OVC" or (
cover_code == "" and mgm_cloud is not None and mgm_cloud <= 8
):
wx_summary = "☁️ 阴天"
elif mgm_cloud is not None:
cloud_names = {
0: "☀️ 晴",
1: "🌤️ 晴",
2: "🌤️ 少云",
3: "⛅ 散云",
4: "⛅ 散云",
5: "🌥️ 多云",
6: "🌥️ 多云",
7: "☁️ 阴",
8: "☁️ 阴天",
}
wx_summary = cloud_names.get(mgm_cloud, "")
wx_display = f" {wx_summary}" if wx_summary else ""
msg_lines.append(
f"\n✈️ <b>实测 ({main_source}): {cur_temp}{temp_symbol}</b>{max_str} |{wx_display} | {obs_t_str}{age_tag}"
)
if mgm:
m_c = mgm.get("current", {})
# 翻译风向
wind_dir = m_c.get("wind_dir")
wind_speed_ms = m_c.get("wind_speed_ms")
dir_str = ""
if wind_dir is not None:
dirs = ["", "东北", "", "东南", "", "西南", "西", "西北"]
dir_str = dirs[int((float(wind_dir) + 22.5) % 360 / 45)] + ""
# 体感和湿度(跳过缺失数据)
feels_like = m_c.get("feels_like")
humidity = m_c.get("humidity")
if feels_like is not None or humidity is not None:
parts = []
if feels_like is not None:
parts.append(f"🌡️ 体感: {feels_like}°C")
# 针对安卡拉,补充市区(Center)实测值
ankara_center = next((s for s in weather_data.get("mgm_nearby", []) if "Bölge/Center" in s.get("name", "")), None)
if ankara_center:
parts.append(f"Ankara (Bölge/Center): <b>{ankara_center['temp']}°C</b>")
if humidity is not None:
parts.append(f"💧 {humidity}%")
msg_lines.append(f" [MGM] {' | '.join(parts)}")
# 风况(跳过缺失数据)
if wind_dir is not None and wind_speed_ms is not None:
msg_lines.append(
f" [MGM] 🌬️ {dir_str}{wind_dir}° ({wind_speed_ms} m/s) | 💧 降水: {m_c.get('rain_24h') or 0}mm"
)
# 新增:气压和云量
extra_parts = []
pressure = m_c.get("pressure")
if pressure is not None:
extra_parts.append(f"🌡 气压: {pressure}hPa")
cloud_cover = m_c.get("cloud_cover")
if cloud_cover is not None:
cloud_desc_map = {
0: "晴朗",
1: "少云",
2: "少云",
3: "散云",
4: "散云",
5: "多云",
6: "多云",
7: "很多云",
8: "阴天",
}
cloud_text = cloud_desc_map.get(cloud_cover, f"{cloud_cover}/8")
extra_parts.append(f"☁️ 云量: {cloud_text}({cloud_cover}/8)")
mgm_max = m_c.get("mgm_max_temp")
if mgm_max is not None:
extra_parts.append(f"🌡️ MGM最高: {mgm_max}°C")
if extra_parts:
msg_lines.append(f" [MGM] {' | '.join(extra_parts)}")
if metar:
m_c = metar.get("current", {})
wind = m_c.get("wind_speed_kt")
wind_dir = m_c.get("wind_dir")
vis = m_c.get("visibility_mi")
clouds = m_c.get("clouds", [])
cloud_desc = ""
if clouds:
c_map = {
"BKN": "多云",
"OVC": "阴天",
"FEW": "少云",
"SCT": "散云",
"SKC": "",
"CLR": "",
}
main = clouds[-1]
cloud_desc = f"☁️ {c_map.get(main.get('cover'), main.get('cover'))}"
prefix = "[METAR]" if mgm else " "
if not mgm:
msg_lines.append(
f" {prefix} 💨 {wind or 0}kt ({wind_dir or 0}°) | 👁️ {vis or 10}mi"
)
if cloud_desc:
msg_lines.append(
f" {prefix} {cloud_desc} | 👁️ {vis or 10}mi | 💨 {wind or 0}kt"
)
# --- 5. 态势特征提取 ---
feature_str, ai_context = analyze_weather_trend(
weather_data, temp_symbol, city_name
)
if feature_str:
# 仅将最核心的信息展示给用户作为"态势分析"
# 但后面会把更全的数据传给 AI
msg_lines.append("\n💡 <b>分析</b>:")
for line in feature_str.split("\n"):
if line.strip():
msg_lines.append(f"- {line.strip()}")
# --- 6. Groq AI 深度分析 ---
try:
from src.analysis.ai_analyzer import get_ai_analysis
# 构建更全的背景数据给 AI
# 补充多模型分歧
mm = weather_data.get("multi_model", {})
if mm.get("forecasts"):
mm_str = " | ".join(
[
f"{k}:{v}{temp_symbol}"
for k, v in mm["forecasts"].items()
if v
]
)
ai_context += f"\n模型分歧: {mm_str}"
ai_result = get_ai_analysis(ai_context, city_name, temp_symbol)
if ai_result:
msg_lines.append(f"\n{ai_result}")
except Exception as e:
logger.error(f"调用 Groq AI 分析失败: {e}")
msg_lines.append(f"\n💳 本次消耗 <b>{CITY_QUERY_COST}</b> 积分。")
bot.send_message(message.chat.id, "\n".join(msg_lines), parse_mode="HTML")
bot.send_message(message.chat.id, city_report, parse_mode="HTML")
except Exception as e:
import traceback
@@ -810,3 +412,5 @@ def start_bot():
if __name__ == "__main__":
start_bot()
+20 -2
View File
@@ -1,6 +1,5 @@
# Weather API Configuration
weather:
meteoblue_api_key: null # Set via METEOBLUE_API_KEY env var
timeout: 30
# Target Cities
@@ -45,7 +44,26 @@ cities:
country: "Germany"
latitude: 48.3538
longitude: 11.7861
- id: "hong_kong"
city: "Hong Kong"
country: "China"
latitude: 22.3080
longitude: 113.9185
- id: "shanghai"
city: "Shanghai"
country: "China"
latitude: 31.1434
longitude: 121.8052
- id: "singapore"
city: "Singapore"
country: "Singapore"
latitude: 1.3644
longitude: 103.9915
- id: "tokyo"
city: "Tokyo"
country: "Japan"
latitude: 35.5523
longitude: 139.7798
# Logging
logging:
level: "INFO"
+183 -170
View File
@@ -1,30 +1,69 @@
# PolyWeather API 接口文档 (v1.2)
# PolyWeather API 文档(v1.2
本文档说明当前 PolyWeather 后端实际提供的 HTTP API。后端由 `web/app.py` 提供,前端通过 Next.js BFF 路由代理访问这些接口。
本文档描述当前后端真实可用接口(`web/app.py`)。
前端一般通过 Next.js BFF 路由代理访问这些接口。
---
## 1. 基础信息
- **本地 Base URL**: `http://127.0.0.1:8000`
- **生产 Base URL**: `http://<your-vps-ip>:8000` 或绑定的 HTTPS API 域名
- **响应格式**: JSON
- **缓存策略**:
- 后端 `web/app.py` 内部分析缓存:默认 5 分钟(Ankara 60 秒)
- 前端城市详情缓存:5 分钟 TTL + revision 校验
- 前端手动刷新:强制 `force_refresh=true` 过缓存
- 本地地址:`http://127.0.0.1:8000`
- 生产地址:`http://<vps-ip>:8000`绑定的 HTTPS 域名
- 返回格式:`application/json`
- 缓存策略
- 后端分析缓存:默认 5 分钟(Ankara 特殊口径 60 秒)
- 前端详情缓存:5 分钟 + revision 检查
- 手动刷新:`force_refresh=true` 强制绕过缓存
---
## 2. 接口列表
## 2. API 思维导图
### 2.1 获取监控城市列表
```mermaid
flowchart TD
A["PolyWeather API"]
- **URL**: `/api/cities`
- **Method**: `GET`
- **用途**: 返回首页左侧监控城市与地图 marker 的基础元数据。
subgraph E["接口分组"]
E1["GET /api/cities"]
E2["GET /api/city/{name}"]
E3["GET /api/city/{name}/summary"]
E4["GET /api/city/{name}/detail"]
E5["GET /api/history/{name}"]
end
**响应示例**
subgraph O["关键对象"]
O1["current"]
O2["forecast"]
O3["probabilities (mu + distribution)"]
O4["multi_model / multi_model_daily"]
O5["market_scan (P0 只读)"]
end
A --> E
A --> O
```
---
## 3. 接口总览
| 接口 | 方法 | 用途 |
| :------------------------- | :--- | :------------------------------------ |
| `/api/cities` | GET | 城市清单与地图基础信息 |
| `/api/city/{name}` | GET | 城市主分析数据(侧栏/今日分析主来源) |
| `/api/city/{name}/summary` | GET | 轻量摘要(首屏预热/低开销更新) |
| `/api/city/{name}/detail` | GET | 聚合详情 + Polymarket P0 只读市场层 |
| `/api/history/{name}` | GET | 历史对账数据 |
---
## 4. 关键接口详解
### 4.1 `GET /api/cities`
返回监控城市列表(地图 Marker 与侧边栏基础数据)。
示例:
```json
{
@@ -32,8 +71,8 @@
{
"name": "ankara",
"display_name": "Ankara",
"lat": 40.1281,
"lon": 32.9951,
"lat": 39.9334,
"lon": 32.8597,
"risk_level": "medium",
"risk_emoji": "🟠",
"airport": "Esenboğa",
@@ -45,54 +84,91 @@
}
```
### 2.2 获取城市实时分析
### 4.2 `GET /api/city/{name}`
- **URL**: `/api/city/{name}`
- **Method**: `GET`
- **参数**:
- `name`: 城市名或别名,如 `ankara``new-york`
- `force_refresh` (可选): `true` 时跳过缓存
- **用途**: 右侧详情卡片、今日分析 modal、图表和周边站点的主数据接口。
主数据接口,前端详情面板和今日分析最常用。
**当前核心字段**
可选参数:
- `display_name`
- `local_time`
- `local_date`
- `temp_symbol`
- `force_refresh=true|false`
核心字段:
- `name`, `display_name`, `local_date`, `local_time`, `temp_symbol`
- `risk`
- `current`
- `mgm`
- `mgm_nearby`
- `forecast`
- `multi_model`
- `mgm`, `mgm_nearby`
- `multi_model`, `multi_model_daily`
- `deb`
- `ensemble`
- `probabilities`
- `trend`
- `metar_today_obs`
- `metar_recent_obs`
- `hourly`
- `hourly_next_48h`
- `source_forecasts`
- `multi_model_daily`
- `probabilities``mu` + `distribution`
- `trend`, `peak`
- `hourly`, `hourly_next_48h`
- `source_forecasts`(当前只保留 `weather_gov`
- `market_scan`
- `updated_at`
**说明**
说明:
- `current.raw_metar` 为 Aviation Weather 返回的原始报文字段
- `mgm` 仅在具备官方 MGM 覆盖的城市(如 Ankara)有效
- `mgm_nearby` 为统一周边站点字段:
- AnkaraMGM 官方周边站
- 其他多数城市:METAR cluster
- `current.raw_metar` 是原始 METAR 报文
- Ankara 专项增强使用 MGM 站网,领先站固定 `17130`
- Meteoblue 已彻底移除,不再出现在接口字段中。
### 2.3 获取历史对账数据
### 4.3 `GET /api/city/{name}/summary`
- **URL**: `/api/history/{name}`
- **Method**: `GET`
- **用途**: 历史对账弹窗与 `/deb` 指令的历史样本来源。
轻量温度摘要,用于地图首屏预热和低成本刷新。
**响应示例**
典型字段:
- `name`, `display_name`, `icao`
- `local_time`, `temp_symbol`
- `current.temp`, `current.obs_time`
- `deb.prediction`
- `risk.level`, `risk.warning`
- `updated_at`
### 4.4 `GET /api/city/{name}/detail`
聚合视图接口,包含天气分析和市场只读层。
可选参数:
- `force_refresh=true|false`
- `market_slug=<slug>`(调试/定向市场匹配)
关键结构:
- `overview`
- `official`
- `timeseries`
- `models`
- `probabilities`
- `market_scan`
- `risk`
- `ai_analysis`
`market_scan`P0 只读)重点字段:
- `primary_market`, `selected_condition_id`, `selected_slug`
- `yes_token`, `no_token`
- `yes_buy`, `yes_sell`, `no_buy`, `no_sell`
- `market_price`, `model_probability`, `edge_percent`
- `temperature_bucket`
- `top_buckets`(前端展示前会再去重)
- `signal_label``BUY YES` / `BUY NO` / `MONITOR`
- `websocket.asset_ids`, `websocket.condition_ids`(订阅标识,不涉及下单)
注意:
- 后端已做温度桶去重与方向优先(优先与主市场同方向的 `or higher`/`or lower` 桶)。
- 前端还有二次去重兜底,避免重复温度桶刷屏。
### 4.5 `GET /api/history/{name}`
历史对账数据来源。
示例:
```json
{
@@ -108,134 +184,71 @@
}
```
**说明**
---
- 网页端历史图默认展示近期样本,但统计口径只使用已结算日期。
- 当天未结算样本可用于可视化趋势,不计入胜率与 MAE。
## 5. 请求链路(以 `/api/city/{name}` 为例)
### 2.4 获取城市摘要
```mermaid
sequenceDiagram
participant FE as Frontend
participant API as FastAPI
participant WX as Weather Collector
participant PM as Polymarket RO Layer
- **URL**: `/api/city/{name}/summary`
- **Method**: `GET`
- **用途**: 轻量级温度摘要接口,用于首屏地图温度预热与低开销列表更新。
FE->>API: GET /api/city/{name}?force_refresh=...
API->>WX: fetch_all_sources(city)
WX-->>API: METAR / MGM / Open-Meteo / weather.gov / Multi-model
API->>API: DEB + trend + probability
API->>PM: build_market_scan(...)
PM-->>API: market_scan (read-only)
API-->>FE: merged city payload
```
**字段**
---
## 6. 数据口径
### 6.1 主观测
- Aviation Weather / METAR 是全局主观测源。
- Ankara:结算主站仍是 `LTAC`,领先信号强化使用 MGM`17130`)。
### 6.2 预测源
- Open-Meteo
- weather.gov(美国城市)
- 多模型:ECMWF / GFS / ICON / GEM / JMA
### 6.3 概率口径
- `mu`:动态分布中心,不是固定结算值。
- `distribution`:按温度桶输出概率分布,面向结算决策而非通用天气展示。
---
## 7. 常见问题
### 7.1 接口 500
- 先检查容器是否启动:`docker compose ps`
- 查看日志:`docker compose logs -f polyweather_web`
### 7.2 METAR 看起来“延迟”
优先核对:
- `name`
- `display_name`
- `icao`
- `local_time`
- `temp_symbol`
- `current.temp`
- `current.obs_time`
- `deb.prediction`
- `risk.level`
- `risk.warning`
- `updated_at`
- `current.report_time`
- `current.receipt_time`
### 2.5 获取城市聚合详情
通常是上游发布节奏,不一定是本地轮询问题。
- **URL**: `/api/city/{name}/detail`
- **Method**: `GET`
- **用途**: 面向后续商业化聚合视图的单请求聚合接口。
### 7.3 前端仍显示旧内容
**当前结构**
- `overview`
- `official`
- `timeseries`
- `models`
- `probabilities`
- `market_scan`
- `risk`
- `ai_analysis`
**说明**
- 当前生产前端主链路仍以 `/api/city/{name}` + `/api/history/{name}` 为主。
- `/api/city/{name}/detail` 已提供聚合结构,供后续产品层扩展接入。
- 确认 Vercel 已部署最新构建
- 浏览器强刷(`Ctrl+F5`
- 检查是否命中前端 5 分钟 TTL
---
## 3. 核心对象定义
### 3.1 风险等级
- `low`: 低风险,模型与实测整体较一致
- `medium`: 中风险,存在一定分歧或站点偏置
- `high`: 高风险,模型冲突较大或盘面波动价值高
### 3.2 DEB
`DEB` 是 PolyWeather 的动态融合预测层,不是简单平均值。它会综合:
- 多模型预测值
- 近期表现
- 城市级偏差特征
- 实况修正上下文
### 3.3 μ
`μ` 表示当前结算概率分布中心(动态期望值),会随模型分歧与实况变化而更新。
它不应直接按固定 forecast 口径做静态历史对账。
---
## 4. 数据源与第三方 API
### 4.1 主观测源
- **Aviation Weather / METAR**
- 全球机场主观测源
- 同时提供结构化字段与原始 METAR 报文
### 4.2 Ankara 专属源
- **Turkish MGM**
- Ankara 官方增强层
-`Ankara (Bölge/Center)` 与周边站点
### 4.3 预测源
- **Open-Meteo**
- **weather.gov**(美国城市)
- **Meteoblue**(部分城市)
- **多模型集成**: ECMWF / GFS / ICON / GEM / JMA
---
## 5. 当前口径说明
- 地图 marker 显示当前温度(首屏通过 `summary` 预热)。
- 点击城市后打开右侧详情卡片,保持当前布局与样式不变。
- “今日日内分析”在 modal 中展示:
- 今日温度走势(含 METAR 实测点)
- 结算概率分布
- 多模型预报
- 今日日内结构信号(规则引擎)
- AI 深度分析 + 0-2 小时临近判断
- modal 打开时地图停止动画;点击空白地图仅关闭右侧卡片,不重置视角。
---
## 6. 常见问题
- **接口 500**
- 先检查 `polyweather_web` 是否启动成功
- 再看 `docker-compose logs -f polyweather_web`
- **METAR 看起来慢几分钟**
- 常见原因是上游发布延迟,不一定是本地轮询问题
- 建议同时查看:
- `current.obs_time`
- `current.report_time`
- `current.receipt_time`
- **网页显示旧内容**
- 先确认 Vercel 已部署最新版本
- 再强刷浏览器缓存
- 如为详情数据,确认是否命中前端 5 分钟 TTL
---
**最后更新**: 2026-03-09
最后更新:`2026-03-11`
+89 -64
View File
@@ -1,95 +1,120 @@
# 📈 Commercialization Roadmap
# Commercialization Roadmap
> **Target**: Transforming PolyWeather for paid weather intelligence delivery.
Target: make PolyWeather a sustainable paid weather-intelligence product.
---
## 🎯 Product Focus
## 1. Product Positioning
PolyWeather is positioned as a **premium intelligence service** for weather-driven prediction markets (**Polymarket**). The core differentiators remain **Ankara specialization**, **advection-aware signal logic**, and **DEB-weighted consensus**.
PolyWeather is not a generic weather app.
It is a decision-support layer for temperature-settlement markets:
- observation-first (METAR/MGM),
- settlement-aware probability modeling (DEB + mu/buckets),
- market mapping (Polymarket read-only) for actionable edge detection.
---
## 💰 Pricing & Monetization
## 2. Business Overview Diagram
| Tier | Price | Primary Value Proposition |
| :------------------- | :------------ | :------------------------------------------------------------ |
| **Telegram Channel** | **$1 / mo** | High-fidelity proactive alerts, low noise. |
| **Web Dashboard** | **$5 / mo** | Full multi-model context + historical DEB benchmarking. |
| **VIP Bundle** | **$5.5 / mo** | Unified access to dashboard + signal stream. |
```mermaid
flowchart TD
A["PolyWeather Monetization"]
### 🛠️ Payment Infrastructure
subgraph P["Product"]
P1["Telegram Signal Channel"]
P2["Web Dashboard"]
P3["VIP Bundle"]
end
- **Currency**: Polygon / USDC.
- **Method**: Phase-1 manual activation; Phase-2 automatic deposit detection and entitlement sync.
subgraph R["Pricing"]
R1["Entry 1 USD"]
R2["Dashboard 5 USD"]
R3["Bundle 5.5 USD"]
end
subgraph AC["Access Control"]
AC1["Manual activation (P1)"]
AC2["Wallet/USDC detection (P2)"]
AC3["Entitlement middleware"]
end
subgraph G["Growth"]
G1["Accuracy reports"]
G2["Retention analytics"]
G3["User preference center"]
end
A --> P
A --> R
A --> AC
A --> G
```
---
## 🗺️ Execution Roadmap
## 3. Packaging and Pricing
| Tier | Price | Value |
| :--------------- | :----------- | :---------------------------------------- |
| Telegram Channel | $1 / month | Low-noise proactive signal feed |
| Web Dashboard | $5 / month | Full multi-model context + reconciliation |
| VIP Bundle | $5.5 / month | Dashboard + signal stream |
Payment direction:
- Currency: Polygon USDC
- Phasing: manual activation first, then automated entitlement sync
---
## 4. Execution Phases
```mermaid
graph LR
P1[Phase 1: Manual Beta] --> P2[Phase 2: USDC Automation]
P2 --> P3[Phase 3: Scaling & Analytics]
subgraph P1_Detail [Manual Operations]
P1 -->|DM Bot| Pay[Manual Payment]
Pay -->|Invite| Link[One-time Link]
end
subgraph P2_Detail [Smart Automation]
P2 -->|Monitor| Chain[Polygon/USDC]
Chain -->|Auto| Access[JWT/Sub Activation]
end
P1[Phase 1 Manual Beta] --> P2[Phase 2 Payment Automation]
P2 --> P3[Phase 3 Growth and B2B]
```
### 📦 Phase 1: Manual Beta
### Phase 1: Manual Beta
- **Goal**: Stabilize signal quality and convert initial paid users.
- **Actions**:
- Manual subscription activation via Telegram DM.
- Small paid Telegram channel for low-noise signal validation.
- Invite-based Web access while entitlement layer is being finalized.
- Keep Ankara as flagship strategy city for product credibility.
- Keep paid channel small, optimize signal quality first.
- Manual payment confirmation + manual entitlement grant.
- Invite-gated dashboard while access control hardens.
### 🛠️ Phase 2: Automation (USDC)
### Phase 2: Payment Automation
- **Goal**: Reduce operational friction and improve payment reliability.
- **Actions**:
- **On-chain monitoring**: Detect USDC deposits to dedicated addresses.
- **One-time Links**: Bot-generated invite links with strict member limits.
- **JWT Auth**: Subscriber-only access control for the Next.js frontend.
- Detect wallet payment events (USDC).
- Auto-issue/refresh entitlement.
- Enforce route-level and API-level access guards.
### 🌐 Phase 3: Scaling & Analytics
### Phase 3: Growth and Expansion
- **Goal**: Improve retention and expand B2C/B2B utility.
- **Actions**:
- **Accuracy Leaderboard**: Monthly DEB vs settled-actual reports.
- **Self-Serve Portal**: Billing, subscription status, and alert preferences.
- **Usage Telemetry**: Feature-level analytics for conversion optimization.
### 📡 API Expansion Priority
- **P0-1 Market Layer**
- Polymarket Gamma discovery + `py-clob-client` pricing / order book
- **P0-2 Official Observation Layer**
- Aviation Weather / METAR
- weather.gov official forecast / observation / alert context
- **P1 Lead Layer**
- Ankara keeps Turkish MGM nearby network
- U.S. cities may later receive Mesonet enhancement without replacing METAR
- **P2 Product Layer**
- Stripe / Polygon-USDC automation
- Realtime entitlement sync and subscriber state management
- Self-serve billing and subscription panel.
- Operator analytics and feature usage telemetry.
- Optional B2B API package for quant teams.
---
## 🚧 Critical Constraints
## 5. Technical Dependencies for Revenue
- **Weather-First**: The product is built around physical weather shifts, not exchange-side execution tooling.
- **Quality > Quantity**: Alert fatigue directly harms retention; thresholds must favor actionable rarity.
- **UI Stability**: Commercial rollout assumes layout consistency; visual contract stays fixed while internals evolve.
| Dependency | Why it matters |
| :------------------- | :--------------------------------------------------------------- |
| Entitlement guard | Prevents unpaid dashboard/API access |
| Subscriber store | Persistent paid user state |
| Audit trail | Explains why each alert fired |
| Observability | Detects degradation before churn |
| Frontend performance | Impacts conversion and retention (Speed Insights now integrated) |
---
**📅 Last Updated**: 2026-03-09
## 6. Immediate Commercial Priorities
1. Finish robust entitlement middleware in frontend and backend.
2. Persist subscriber/payment state in managed DB.
3. Publish transparent monthly accuracy and signal-quality reports.
4. Add support playbooks for false-alert and stale-data incidents.
---
Last Updated: `2026-03-11`
+65 -42
View File
@@ -1,69 +1,92 @@
# 🛠️ Technical Debt & Engineering Backlog
# Technical Debt Backlog
> **Vision**: Moving from a research script to a production SaaS.
Purpose: keep engineering debt explicit while shipping production features.
---
## 🏛️ System Health: 82%
## 1. Debt Landscape
```mermaid
pie title System Health & Tech Debt
"Stable Engine" : 82
"Entitlement/Payments Debt" : 8
"Test/Replay Debt" : 6
"Observability Debt" : 4
flowchart TD
A["Tech Debt"]
subgraph AR["Architecture"]
AR1["Monolithic bot entry"]
AR2["Shared runtime coupling"]
end
subgraph PI["Product Infra"]
PI1["Entitlement hardening"]
PI2["Subscription persistence"]
end
subgraph Q["Quality"]
Q1["Replay harness"]
Q2["Broader regression tests"]
end
subgraph O["Observability"]
O1["Alert evidence trace"]
O2["SLO dashboards"]
end
A --> AR
A --> PI
A --> Q
A --> O
```
The core weather engine and React dashboard runtime are now stable, but product-layer infrastructure debt is still material.
### Current Stable Modules
- [x] Multi-source Weather Aggregation
- [x] DEB Blending Algorithm
- [x] Proactive Telegram Alert Engine
- [x] Vercel Dashboard Infrastructure
- [x] React component-driven dashboard runtime (replacing legacy `public/static/app.js` rendering path)
Current system health estimate: **84% stable / 16% debt**.
---
## 🔴 High Priority: Immediate Focus
## 2. Recently Closed (2026-03-11)
| Debt Item | Impact | Suggested Fix |
| :--------------------- | :-------------------------------------------------- | :--------------------------------------------------------------- |
| **Monolithic Bot** | `bot_listener.py` is hard to test and evolve. | Isolate UI interaction from business logic into `src/analysis`. |
| **Subscription Store** | No persistent record of who has paid. | Migrate from in-memory user checks to **Supabase/PostgreSQL**. |
| **Alert Transparency** | Operators cannot easily audit "why" an alert fired. | Add an `Evidence` metadata block to all internal alert payloads. |
| **Entitlement Guard** | Dashboard routes are public by default. | Add JWT/session gating in Next.js middleware + backend checks. |
- Meteoblue API path fully removed from backend, frontend, config and docs.
- Market top-bucket duplicate temperature issue fixed (backend dedupe + frontend guard).
- Detail panel a11y conflict fixed (`aria-hidden` focus conflict resolved with `inert` + blur).
- Vercel Speed Insights integrated for frontend performance telemetry.
---
## 🟡 Medium Priority: Quality of Life
## 3. High Priority Debt
| Debt Item | Impact | Suggested Fix |
| :------------------------ | :-------------------------------------------------- | :--------------------------------------------------------------------------- |
| **Hard-coded Thresholds** | Modification requires code changes (e.g., 5s CD). | Extract all business constants into a structured `config.yaml`. |
| **Simulation Harness** | No way to "replay" a rainy day to test alert logic. | Build a `ReplayEngine` using `data/daily_records.json`. |
| **Backend Naming** | Artifacts of "market price" logic remain in naming. | Systematic refactor of variable names to reflect weather-intelligence focus. |
| **Chart Regression Tests**| UI relies on custom Chart.js lifecycles. | Add snapshot + interaction tests for chart datasets and legends. |
| Item | Impact | Suggested Work |
| :-- | :-- | :-- |
| Monolithic bot entry (`bot_listener.py`) | Hard to test and safely refactor | Split orchestration, IO and analysis modules |
| Entitlement enforcement consistency | Revenue leakage risk | Align frontend middleware and backend enforcement |
| Subscriber persistence model | Manual operations do not scale | Move to managed PostgreSQL/Supabase state |
| Alert explainability | Operator trust and debugging cost | Standardize evidence payload per alert |
---
## 🟢 Low Priority: Optimization
## 4. Medium Priority Debt
| Debt Item | Impact | Suggested Fix |
| :------------------------- | :---------------------------------------------- | :------------------------------------------------------------- |
| **Serverless Cold Starts** | Initial Vercel API calls can be slow. | Implement edge-cache or warming cron for major city endpoints. |
| **Local SQLite Files** | Not compatible with Vercel's ephemeral storage. | Full transition to a remote DB (Supabase/Redis). |
| Item | Impact | Suggested Work |
| :-- | :-- | :-- |
| Replay simulation harness | Hard to reproduce edge cases | Build deterministic replay over stored records |
| Chart/UI regression coverage | Visual regressions can slip | Add snapshot + interaction test coverage |
| Config centralization | Threshold changes are error-prone | Consolidate runtime knobs into structured config |
| Naming cleanup | Legacy terms reduce clarity | Refactor naming in market/weather boundary layer |
---
## 🗓️ Next Milestones
## 5. Low Priority Debt
1. **DB Integration**: Connect Supabase to `src/database/db_manager.py`.
2. **Entitlement Layer**: Enforce paid-access middleware on dashboard and API proxy routes.
3. **Alert Transparency**: Append logic metrics (slope, lead delta, advection factors) to push payloads.
4. **Replay & QA**: Add deterministic replay tests for map/panel/modal interaction regressions.
| Item | Impact | Suggested Work |
| :-- | :-- | :-- |
| Cold-start behavior | First request latency variance | Add warming strategy for top city routes |
| Storage abstraction | Local file assumptions remain | Continue moving state to remote services |
---
**📅 Last Updated**: 2026-03-09
## 6. Next Milestones
1. Entitlement parity: one policy across frontend and backend.
2. Subscriber DB integration and migration scripts.
3. Alert evidence schema + tooling for quick operator audit.
4. Replay runner for weather/market mixed regression scenarios.
---
Last Updated: `2026-03-11`
+65 -42
View File
@@ -1,69 +1,92 @@
# 🛠️ 技术债与工程待办
# 技术债与工程待办
> **愿景**:从研究脚本演进为可持续的生产级 SaaS
目标:在持续交付的同时,把关键技术债显式化、可追踪化
---
## 🏛️ 系统健康度:82%
## 1. 技术债全景
```mermaid
pie title 系统健康度与技术债
"稳定引擎" : 82
"权限与支付债务" : 8
"测试/回放债务" : 6
"可观测性债务" : 4
flowchart TD
A["技术债"]
subgraph AR["架构层"]
AR1["机器人入口过于集中"]
AR2["共享运行时耦合"]
end
subgraph PI["产品基础设施"]
PI1["订阅权限一致性"]
PI2["付费用户持久化"]
end
subgraph Q["质量保障"]
Q1["回放测试能力"]
Q2["UI 回归覆盖不足"]
end
subgraph O["可观测性"]
O1["告警证据链"]
O2["SLO 看板"]
end
A --> AR
A --> PI
A --> Q
A --> O
```
核心天气引擎与 React 仪表盘运行时已基本稳定,但产品层基础设施债务仍然明显
### 当前稳定模块
- [x] 多源天气聚合
- [x] DEB 融合算法
- [x] 主动式 Telegram 预警引擎
- [x] Vercel 仪表盘基础设施
- [x] React 组件驱动仪表盘运行时(已替换 legacy `public/static/app.js` 渲染路径)
当前系统健康度估计:**84% 稳定 / 16% 技术债**
---
## 🔴 高优先级:立即处理
## 2. 最近已关闭项(2026-03-11
| 债务项 | 影响 | 建议修复 |
| :-------------------- | :------------------------------------------------- | :--------------------------------------------------------------- |
| **Monolithic Bot** | `bot_listener.py` 可测试性差,演进成本高。 | 将 UI 交互与业务逻辑解耦,沉入 `src/analysis`。 |
| **Subscription Store**| 付费用户缺少持久化记录。 | 从内存校验迁移到 **Supabase/PostgreSQL**。 |
| **Alert Transparency**| 运维侧难以审计“告警为何触发”。 | 为所有内部告警载荷增加 `Evidence` 元数据块。 |
| **Entitlement Guard** | 仪表盘路由默认仍是公开可访问。 | 在 Next.js middleware 与后端校验中加入 JWT/会话权限守卫。 |
- Meteoblue API 全链路移除(后端/前端/配置/文档)。
- 市场温度桶重复刷屏问题修复(后端去重 + 前端兜底)。
- 详情面板可访问性告警修复(`aria-hidden` 焦点冲突改为 `inert + blur`)。
- 前端已接入 Vercel Speed Insights。
---
## 🟡 中优先级:体验与效率
## 3. 高优先级技术债
| 债务项 | 影响 | 建议修复 |
| :---------------------- | :------------------------------------------------- | :---------------------------------------------------------------------- |
| **Hard-coded Thresholds** | 阈值修改需要改代码(如 5s 冷却)。 | 将业务常量统一抽离到结构化 `config.yaml` |
| **Simulation Harness** | 无法“回放历史天气日”验证告警逻辑。 | 基于 `data/daily_records.json` 构建 `ReplayEngine` |
| **Backend Naming** | 仍有“市场价格时代”的命名残留。 | 系统化重命名,统一为 weather-intelligence 语义。 |
| **Chart Regression Tests**| 图表依赖自定义 Chart.js 生命周期,回归风险高。 | 增加图表数据集与图例的快照测试 + 交互测试。 |
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 机器人入口单体化(`bot_listener.py`) | 测试和重构风险高 | 拆分为编排层、IO 层、分析层 |
| 订阅权限策略不完全统一 | 可能造成付费泄露 | 前后端统一权限校验策略 |
| 付费用户状态持久化不足 | 人工运营不可扩展 | 迁移到托管 DBPostgreSQL/Supabase |
| 告警可解释性不足 | 运维排障成本高 | 统一告警证据字段(Evidence Schema |
---
## 🟢 低优先级:性能优化
## 4. 中优先级技术债
| 债务项 | 影响 | 建议修复 |
| :----------------------- | :------------------------------------------------- | :--------------------------------------------------------------- |
| **Serverless Cold Starts** | Vercel 首次 API 调用可能偏慢。 | 为主要城市接口增加边缘缓存或预热任务。 |
| **Local SQLite Files** | 与 Vercel 短暂文件系统不兼容。 | 全面迁移到远程数据库(Supabase/Redis)。 |
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 回放仿真能力不足 | 边缘场景难复现 | 基于历史记录构建可重复 Replay |
| 图表/UI 回归覆盖不足 | 视觉回归风险 | 增加快照与交互自动化测试 |
| 阈值配置分散 | 改动成本高且易错 | 统一收口到结构化配置 |
| 命名历史包袱 | 认知成本高 | 系统化命名治理 |
---
## 🗓️ 下一阶段里程碑
## 5. 低优先级技术债
1. **DB Integration**:将 Supabase 接入 `src/database/db_manager.py`
2. **Entitlement Layer**:在仪表盘与 API 代理路由上落实付费访问中间件。
3. **Alert Transparency**:在推送载荷中附加逻辑指标(斜率、领先差、平流因子)。
4. **Replay & QA**:为地图/侧卡/modal 联动补齐可复现回放测试。
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 冷启动波动 | 首次请求延迟不稳定 | 热点城市路由预热 |
| 本地文件状态依赖 | 云端弹性场景受限 | 持续迁移到远程存储 |
---
**📅 最后更新**2026-03-09
## 6. 下阶段里程碑
1. 完成前后端订阅权限一致化。
2. 上线付费用户持久化与迁移脚本。
3. 建立告警证据标准并接入运维排障流。
4. 落地天气+市场混合回放回归测试。
---
最后更新:`2026-03-11`
+5
View File
@@ -1 +1,6 @@
POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000
# Optional dashboard guard (Next.js middleware)
# If set, open dashboard with: /?access_token=<token>
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
# Shared secret forwarded by Next API routes to backend
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
+4 -2
View File
@@ -1,6 +1,8 @@
import { NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export const dynamic = "force-dynamic";
export async function GET() {
if (!API_BASE) {
@@ -12,8 +14,8 @@ export async function GET() {
try {
const res = await fetch(`${API_BASE}/api/cities`, {
headers: { Accept: "application/json" },
next: { revalidate: 120 },
headers: buildBackendRequestHeaders(),
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(
req: NextRequest,
context: { params: Promise<{ name: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
const { name } = await context.params;
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const marketSlug = req.nextUrl.searchParams.get("market_slug");
const targetDate = req.nextUrl.searchParams.get("target_date");
const searchParams = new URLSearchParams({
force_refresh: forceRefresh,
});
if (marketSlug) {
searchParams.set("market_slug", marketSlug);
}
if (targetDate) {
searchParams.set("target_date", targetDate);
}
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
try {
const res = await fetch(url, {
headers: buildBackendRequestHeaders(),
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
return NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: 502 },
);
}
const data = await res.json();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch city detail aggregate", detail: String(error) },
{ status: 500 },
);
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -19,7 +20,7 @@ export async function GET(
try {
const res = await fetch(url, {
headers: { Accept: "application/json" },
headers: buildBackendRequestHeaders(),
cache: "no-store",
});
if (!res.ok) {
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -19,7 +20,7 @@ export async function GET(
try {
const res = await fetch(url, {
headers: { Accept: "application/json" },
headers: buildBackendRequestHeaders(),
cache: "no-store",
});
if (!res.ok) {
+2 -1
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -18,7 +19,7 @@ export async function GET(
try {
const res = await fetch(url, {
headers: { Accept: "application/json" },
headers: buildBackendRequestHeaders(),
cache: "no-store",
});
if (!res.ok) {
@@ -0,0 +1,46 @@
type Props = {
searchParams?: Promise<{ next?: string }>;
};
export default async function EntitlementRequiredPage({ searchParams }: Props) {
const params = (await searchParams) || {};
const nextPath = params.next || "/";
return (
<main
style={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
background:
"radial-gradient(circle at 20% 20%, #13264f 0%, #071127 45%, #040812 100%)",
color: "#d6e2ff",
padding: "24px",
}}
>
<section
style={{
width: "100%",
maxWidth: 720,
border: "1px solid rgba(68, 92, 140, 0.45)",
borderRadius: 16,
padding: 24,
background: "rgba(9, 18, 36, 0.88)",
boxShadow: "0 20px 50px rgba(0, 0, 0, 0.35)",
}}
>
<h1 style={{ margin: 0, fontSize: 28, lineHeight: 1.2 }}>
Entitlement Required
</h1>
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
This dashboard is protected. Append{" "}
<code>?access_token=&lt;your-token&gt;</code> to the URL once, and
the session cookie will be set automatically.
</p>
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
Requested path: <code>{nextPath}</code>
</p>
</section>
</main>
);
}
+2
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Analytics } from "@vercel/analytics/react";
import { SpeedInsights } from "@vercel/speed-insights/next";
import "./globals.css";
export const metadata: Metadata = {
@@ -28,6 +29,7 @@ export default function RootLayout({
<body className="min-h-screen font-sans antialiased">
{children}
<Analytics />
<SpeedInsights />
</body>
</html>
);
+2 -2
View File
@@ -2,9 +2,9 @@ import type { Metadata } from "next";
import { DashboardEntry } from "@/components/dashboard/DashboardEntry";
export const metadata: Metadata = {
title: "PolyWeather - 天气衍生品智能地图",
title: "PolyWeather - Global Weather Intelligence Map",
description:
"PolyWeather 天气衍生品智能地图,聚合 METARMGMDEB、多模型预报与历史对账分析。",
"PolyWeather dashboard with METAR, MGM, DEB fusion forecast, multi-model comparison, and history reconciliation.",
};
export default function HomePage() {
+130 -40
View File
@@ -1,65 +1,155 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import clsx from "clsx";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { CityListItem } from "@/lib/dashboard-types";
type RiskGroupKey = "high" | "medium" | "low" | "other";
function toRiskGroup(level?: string): RiskGroupKey {
if (level === "high" || level === "medium" || level === "low") return level;
return "other";
}
export function CitySidebar() {
const store = useDashboardStore();
const sortedCities = [...store.cities].sort((a, b) => {
const order = { high: 0, medium: 1, low: 2 };
return (
(order[a.risk_level as keyof typeof order] ?? 3) -
(order[b.risk_level as keyof typeof order] ?? 3)
);
const { t } = useI18n();
const selectedCity = store.selectedCity;
const riskOrder = { high: 0, medium: 1, low: 2, other: 3 };
const [expandedGroups, setExpandedGroups] = useState<Record<RiskGroupKey, boolean>>({
high: true,
medium: true,
low: false,
other: false,
});
const sortedCities = useMemo(() => [...store.cities].sort((a, b) => {
const aSelected = a.name === selectedCity;
const bSelected = b.name === selectedCity;
if (aSelected !== bSelected) return aSelected ? -1 : 1;
const aGroup = toRiskGroup(a.risk_level);
const bGroup = toRiskGroup(b.risk_level);
return (
(riskOrder[aGroup] ?? 3) -
(riskOrder[bGroup] ?? 3) ||
a.display_name.localeCompare(b.display_name)
);
}), [store.cities, selectedCity]);
const groupedCities = useMemo(() => {
const groups: Record<RiskGroupKey, CityListItem[]> = {
high: [],
medium: [],
low: [],
other: [],
};
sortedCities.forEach((city) => {
groups[toRiskGroup(city.risk_level)].push(city);
});
return groups;
}, [sortedCities]);
useEffect(() => {
if (!selectedCity) return;
const selected = store.cities.find((city) => city.name === selectedCity);
if (!selected) return;
const groupKey = toRiskGroup(selected.risk_level);
setExpandedGroups((current) =>
current[groupKey] ? current : { ...current, [groupKey]: true },
);
}, [selectedCity, store.cities]);
const groupMeta: Array<{ key: RiskGroupKey; label: string }> = [
{ key: "high", label: t("sidebar.group.high") },
{ key: "medium", label: t("sidebar.group.medium") },
{ key: "low", label: t("sidebar.group.low") },
{ key: "other", label: t("sidebar.group.other") },
];
return (
<nav className="city-list">
<div className="city-list-header">
<span></span>
<span>{t("sidebar.title")}</span>
<span className="city-count">{store.cities.length}</span>
</div>
<div className="city-list-items">
{sortedCities.map((city) => {
const detail = store.cityDetailsByName[city.name];
const summary = store.citySummariesByName[city.name];
const snapshot = detail || summary;
const isActive = store.selectedCity === city.name;
{groupMeta.map((group) => {
const citiesInGroup = groupedCities[group.key];
if (!citiesInGroup.length) return null;
const expanded = expandedGroups[group.key];
return (
<button
key={city.name}
type="button"
className={clsx("city-item", isActive && "active")}
onClick={() => void store.selectCity(city.name)}
<section
key={group.key}
className={clsx("city-group", !expanded && "collapsed")}
>
<div className="city-item-main">
<span className={clsx("risk-dot", city.risk_level)} />
<span className="city-name-text">{city.display_name}</span>
<span
className={clsx(
"city-temp",
snapshot?.current?.temp != null && "loaded",
)}
>
{snapshot?.current?.temp != null
? `${snapshot.current.temp}${snapshot.temp_symbol || "°C"}`
: "--"}
<button
type="button"
className="city-group-header"
aria-expanded={expanded}
onClick={() =>
setExpandedGroups((current) => ({
...current,
[group.key]: !current[group.key],
}))
}
>
<span className="city-group-title">{group.label}</span>
<span className="city-group-meta">
<span className="city-group-count">{citiesInGroup.length}</span>
<span className={clsx("city-group-arrow", expanded && "expanded")}>
</span>
</span>
</div>
</button>
<div className="city-item-info">
<span className="city-local-time">
{snapshot?.local_time ? `🕐 ${snapshot.local_time}` : ""}
</span>
<span className="city-max-info">
{detail?.current?.max_temp_time
? `峰值 @ ${detail.current.max_temp_time}`
: ""}
</span>
<div className="city-group-items">
{citiesInGroup.map((city) => {
const detail = store.cityDetailsByName[city.name];
const summary = store.citySummariesByName[city.name];
const snapshot = detail || summary;
const isActive = store.selectedCity === city.name;
return (
<button
key={city.name}
type="button"
className={clsx("city-item", isActive && "active")}
onClick={() => void store.selectCity(city.name)}
>
<div className="city-item-main">
<span className={clsx("risk-dot", city.risk_level)} />
<span className="city-name-text">{city.display_name}</span>
<span
className={clsx(
"city-temp",
snapshot?.current?.temp != null && "loaded",
)}
>
{snapshot?.current?.temp != null
? `${snapshot.current.temp}${snapshot.temp_symbol || "°C"}`
: t("common.na")}
</span>
</div>
<div className="city-item-info">
<span className="city-local-time">
{snapshot?.local_time ? `🕒 ${snapshot.local_time}` : ""}
</span>
<span className="city-max-info">
{detail?.current?.max_temp_time
? t("sidebar.peakAt", { time: detail.current.max_temp_time })
: ""}
</span>
</div>
</button>
);
})}
</div>
</button>
</section>
);
})}
</div>
@@ -33,7 +33,7 @@
/* Spacing */
--panel-width: 560px;
--header-height: 56px;
--sidebar-width: 280px;
--sidebar-width: 260px;
/* Effects */
--glass-blur: 20px;
@@ -174,6 +174,40 @@
gap: 12px;
}
.root :global(.lang-switch) {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px;
border-radius: 10px;
border: 1px solid var(--border-glass);
background: var(--bg-glass);
}
.root :global(.lang-btn) {
border: none;
background: transparent;
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
line-height: 1;
padding: 6px 8px;
border-radius: 7px;
cursor: pointer;
transition: var(--transition);
}
.root :global(.lang-btn:hover) {
color: var(--text-primary);
background: rgba(99, 102, 241, 0.12);
}
.root :global(.lang-btn.active) {
color: var(--text-primary);
background: rgba(34, 211, 238, 0.16);
box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.26);
}
.root :global(.live-badge) {
display: flex;
align-items: center;
@@ -280,7 +314,7 @@
.root :global(.city-list-items) {
overflow-y: auto;
flex: 1;
padding: 4px;
padding: 6px 6px 8px;
}
.root :global(.city-list-items::-webkit-scrollbar) {
width: 4px;
@@ -293,15 +327,90 @@
border-radius: 2px;
}
.root :global(.city-group) {
border: 1px solid rgba(99, 102, 241, 0.12);
border-radius: 10px;
background: rgba(15, 23, 42, 0.35);
margin-bottom: 8px;
overflow: hidden;
}
.root :global(.city-group:last-child) {
margin-bottom: 0;
}
.root :global(.city-group-header) {
width: 100%;
border: none;
background: rgba(99, 102, 241, 0.08);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
cursor: pointer;
font-family: inherit;
}
.root :global(.city-group-title) {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.3px;
}
.root :global(.city-group-meta) {
display: inline-flex;
align-items: center;
gap: 8px;
}
.root :global(.city-group-count) {
min-width: 18px;
height: 18px;
border-radius: 9px;
padding: 0 6px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(99, 102, 241, 0.26);
color: var(--text-primary);
font-size: 10px;
font-weight: 700;
}
.root :global(.city-group-arrow) {
font-size: 11px;
color: var(--text-muted);
transform: rotate(-90deg);
transition: transform 0.2s ease;
}
.root :global(.city-group-arrow.expanded) {
transform: rotate(0deg);
}
.root :global(.city-group-items) {
padding: 4px;
}
.root :global(.city-group.collapsed .city-group-items) {
display: none;
}
.root :global(.city-item) {
width: 100%;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
padding: 8px 10px;
border-radius: 10px;
cursor: pointer;
transition: var(--transition);
border: 1px solid transparent;
background: transparent;
color: inherit;
font-family: inherit;
text-align: left;
}
.root :global(.city-item:hover) {
background: rgba(99, 102, 241, 0.08);
@@ -315,34 +424,22 @@
.root :global(.city-item-main) {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
width: 100%;
}
.root :global(.city-item .city-name-text) {
font-size: 15px;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.root :global(.city-item .city-temp) {
margin-left: auto;
font-size: 16px;
font-weight: 800;
color: var(--accent-cyan);
opacity: 0;
transition: var(--transition);
}
.root :global(.city-item .city-temp.loaded) {
opacity: 1;
}
.root :global(.city-item-info) {
display: flex;
justify-content: space-between;
align-items: center;
padding-left: 20px; /* Align with name text, after the dot */
font-size: 11px;
font-size: 10px;
color: var(--text-muted);
}
@@ -372,8 +469,8 @@
.root :global(.city-item .city-temp) {
margin-left: auto;
font-size: 12px;
font-weight: 600;
font-size: 13px;
font-weight: 700;
color: var(--accent-cyan);
opacity: 0;
transition: var(--transition);
@@ -688,6 +785,29 @@
background: rgba(99, 102, 241, 0.15);
}
.root :global(.prob-market-inline) {
min-width: 120px;
text-align: right;
font-size: 12px;
font-weight: 700;
border-radius: 999px;
padding: 4px 10px;
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
}
.root :global(.prob-market-inline.yes) {
color: #4ade80;
background: rgba(74, 222, 128, 0.12);
border: 1px solid rgba(74, 222, 128, 0.3);
}
.root :global(.prob-market-inline.no) {
color: #fb7185;
background: rgba(251, 113, 133, 0.12);
border: 1px solid rgba(251, 113, 133, 0.26);
}
/* ── Model Bars ── */
.root :global(.model-bars) {
display: flex;
@@ -1333,6 +1453,12 @@
overflow: hidden;
}
.root :global(.modal-content.history-modal) {
width: min(96vw, 1180px);
max-width: 1180px;
max-height: calc(100vh - 32px);
}
.root :global(.modal-header) {
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
@@ -1346,6 +1472,41 @@
color: var(--text-primary);
margin: 0;
}
.root :global(.future-modal-title-with-actions) {
display: flex;
align-items: center;
gap: 12px;
}
.root :global(.future-refresh-btn) {
background: transparent;
border: none;
cursor: pointer;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 6px;
transition: all 0.2s ease;
}
.root :global(.future-refresh-btn:hover) {
color: var(--accent-cyan);
background: rgba(34, 211, 238, 0.1);
}
.root :global(.future-refresh-btn.spinning svg) {
animation: spin 1s linear infinite;
color: var(--accent-cyan);
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
.root :global(.modal-close) {
background: none;
border: none;
@@ -1363,6 +1524,10 @@
overflow-y: auto;
}
.root :global(.history-modal .modal-body) {
padding: 24px 28px 28px;
}
.root :global(.history-stats) {
display: flex;
gap: 12px;
@@ -1391,12 +1556,92 @@
color: var(--text-primary);
}
.root :global(.h-stat-card .h-stat-note) {
display: block;
margin-top: 6px;
font-size: 11px;
color: var(--text-muted);
line-height: 1.35;
}
.root :global(.history-chart-wrapper) {
position: relative;
height: 300px;
width: 100%;
}
.root :global(.history-modal .history-stats) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.root :global(.history-modal .h-stat-card) {
min-width: 0;
padding: 16px 18px;
border-radius: 12px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.035) 0%,
rgba(255, 255, 255, 0.02) 100%
);
}
.root :global(.history-modal .h-stat-card .label) {
font-size: 12px;
margin-bottom: 8px;
}
.root :global(.history-modal .h-stat-card .val) {
font-size: 32px;
line-height: 1.1;
letter-spacing: -0.02em;
}
.root :global(.history-modal .history-chart-wrapper) {
height: 420px;
border: 1px solid var(--border-subtle);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
padding: 12px 14px 8px;
}
.root :global(.history-modal .history-chart-wrapper canvas) {
width: 100% !important;
height: 100% !important;
}
@media (max-width: 980px) {
.root :global(.history-modal .modal-body) {
padding: 18px;
}
.root :global(.history-modal .history-stats) {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.root :global(.history-modal .history-chart-wrapper) {
height: 360px;
}
}
@media (max-width: 640px) {
.root :global(.history-modal .history-stats) {
grid-template-columns: 1fr;
}
.root :global(.history-modal .h-stat-card .val) {
font-size: 28px;
}
.root :global(.history-modal .history-chart-wrapper) {
height: 300px;
padding: 8px 10px 6px;
}
}
/* ── Info Button ── */
.root :global(.info-btn) {
background: rgba(99, 102, 241, 0.1);
@@ -1435,6 +1680,301 @@
padding-right: 14px;
}
.root :global(.future-v2-layout) {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 14px;
align-items: start;
}
.root :global(.future-v2-left) {
display: grid;
gap: 12px;
}
.root :global(.future-v2-right) {
display: grid;
gap: 14px;
}
.root :global(.future-v2-card) {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border-subtle);
border-radius: 14px;
padding: 14px;
}
.root :global(.future-v2-hero-card) {
background: linear-gradient(
180deg,
rgba(99, 102, 241, 0.12) 0%,
rgba(255, 255, 255, 0.02) 100%
);
}
.root :global(.future-v2-hero-title) {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.root :global(.future-v2-hero-main) {
display: flex;
align-items: center;
gap: 14px;
margin-top: 14px;
}
.root :global(.future-v2-hero-temp) {
font-size: 56px;
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1;
color: #f8fafc;
text-shadow: 0 10px 30px rgba(34, 211, 238, 0.14);
}
.root :global(.future-v2-hero-divider) {
width: 1px;
height: 56px;
background: rgba(255, 255, 255, 0.12);
}
.root :global(.future-v2-hero-weather) {
display: grid;
gap: 4px;
font-size: 15px;
color: var(--text-secondary);
}
.root :global(.future-v2-hero-icon) {
display: inline-flex;
align-items: center;
line-height: 1;
}
.root :global(.future-v2-hero-obs) {
margin-top: 10px;
color: var(--text-secondary);
font-size: 26px;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.root :global(.future-v2-mini-grid) {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.root :global(.future-v2-mini-grid.future-v2-mini-grid-tight) {
margin-top: 8px;
}
.root :global(.future-v2-mini-item) {
border-radius: 10px;
border: 1px solid var(--border-subtle);
background: rgba(255, 255, 255, 0.025);
padding: 10px;
display: grid;
gap: 4px;
}
.root :global(.future-v2-mini-item span) {
color: var(--text-muted);
font-size: 11px;
}
.root :global(.future-v2-mini-item strong) {
color: var(--text-primary);
font-size: 23px;
line-height: 1.15;
font-weight: 700;
}
.root :global(.future-v2-card-title) {
margin: 0;
color: var(--text-primary);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.02em;
}
.root :global(.future-v2-market-v3) {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 16px;
position: relative;
}
.root :global(.market-layer-loading-overlay) {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(2px);
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--accent-cyan);
font-size: 13px;
font-weight: 500;
}
.root :global(.market-sub-title) {
color: var(--text-secondary);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
font-weight: 600;
}
.root :global(.market-layer-target) {
background: rgba(34, 211, 238, 0.04);
border: 1px solid rgba(34, 211, 238, 0.15);
border-radius: 8px;
padding: 12px;
}
.root :global(.market-target-header) {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-size: 13px;
color: var(--text-secondary);
}
.root :global(.market-target-bucket) {
font-size: 16px;
color: var(--text-primary);
font-weight: 700;
}
.root :global(.market-edge-box) {
background: rgba(15, 23, 42, 0.4);
border-radius: 6px;
padding: 10px;
}
.root :global(.market-edge-header) {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 12px;
color: var(--text-primary);
font-weight: 600;
}
.root :global(.market-edge-val) {
font-size: 15px;
font-weight: 700;
}
.root :global(.market-edge-val.positive) {
color: var(--accent-green);
}
.root :global(.market-edge-val.negative) {
color: var(--text-secondary);
}
.root :global(.market-edge-compare) {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 8px;
}
.root :global(.edge-stat) {
display: flex;
flex-direction: column;
gap: 2px;
}
.root :global(.edge-label) {
font-size: 10px;
color: var(--text-muted);
}
.root :global(.edge-value) {
font-size: 13px;
color: var(--text-secondary);
font-weight: 600;
}
.root :global(.market-layer-book),
.root :global(.market-layer-context) {
padding: 0 4px;
}
.root :global(.market-book-row),
.root :global(.market-context-row) {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
padding: 6px 0;
border-bottom: 1px dashed rgba(255, 255, 255, 0.05);
}
.root :global(.market-book-row:last-child),
.root :global(.market-context-row:last-child) {
border-bottom: none;
}
.root :global(.book-label),
.root :global(.market-context-row span) {
color: var(--text-secondary);
}
.root :global(.book-quote strong),
.root :global(.market-context-row strong) {
color: var(--text-primary);
font-weight: 600;
}
.root :global(.book-quote span) {
color: var(--text-muted);
font-size: 11px;
}
.root :global(.book-spread) {
color: var(--text-muted);
font-size: 11px;
}
.root :global(.mt-3) {
margin-top: 12px;
}
.root :global(.future-v2-market-signal) {
margin-top: 10px;
padding: 8px 10px;
border-radius: 10px;
background: rgba(34, 211, 238, 0.08);
border: 1px solid rgba(34, 211, 238, 0.22);
color: var(--accent-cyan);
font-size: 12px;
font-weight: 600;
}
.root :global(.future-v2-main-chart) {
min-height: 340px;
}
.root :global(.future-modal-section) {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border-subtle);
@@ -1544,9 +2084,12 @@
border-radius: 999px;
background: linear-gradient(
90deg,
rgba(245, 158, 11, 0.35),
rgba(255, 255, 255, 0.06) 50%,
rgba(52, 211, 153, 0.35)
rgba(56, 189, 248, 0.4) 0%,
rgba(56, 189, 248, 0.4) 30%,
rgba(255, 255, 255, 0.06) 45%,
rgba(255, 255, 255, 0.06) 55%,
rgba(245, 158, 11, 0.4) 70%,
rgba(245, 158, 11, 0.4) 100%
);
overflow: hidden;
}
@@ -1559,9 +2102,11 @@
width: 14px;
height: 14px;
border-radius: 999px;
background: var(--accent-cyan);
background: var(--text-primary);
transform: translate(-50%, -50%);
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.14);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
z-index: 2;
transition: left 0.5s ease;
}
.root :global(.future-front-meta) {
@@ -1583,6 +2128,14 @@
}
@media (max-width: 960px) {
.root :global(.future-v2-layout) {
grid-template-columns: 1fr;
}
.root :global(.future-v2-mini-item strong) {
font-size: 18px;
}
.root :global(.future-modal-grid),
.root :global(.future-trend-grid) {
grid-template-columns: 1fr;
@@ -1676,6 +2229,31 @@
line-height: 1.7;
}
.root :global(.detail-mini-chart-wrap) {
display: grid;
gap: 8px;
}
.root :global(.detail-mini-chart) {
position: relative;
height: 190px;
border: 1px solid var(--border-subtle);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
padding: 10px;
}
.root :global(.detail-mini-chart canvas) {
width: 100% !important;
height: 100% !important;
}
.root :global(.detail-mini-meta) {
color: var(--text-muted);
font-size: 11px;
line-height: 1.55;
}
.root :global(.insight-list) {
display: grid;
gap: 10px;
+177 -49
View File
@@ -1,19 +1,134 @@
"use client";
import { ChartConfiguration } from "chart.js/auto";
import clsx from "clsx";
import { useEffect, useRef } from "react";
import { ForecastTable } from "@/components/dashboard/PanelSections";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getCityScenery } from "@/lib/dashboard-scenery";
import { CityDetail } from "@/lib/dashboard-types";
import {
getCityProfileStats,
getClimateDrivers,
getRiskBadgeLabel,
getSettlementRiskNarrative,
getTemperatureChartData,
} from "@/lib/dashboard-utils";
import { ForecastTable } from "@/components/dashboard/PanelSections";
function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
const { locale, t } = useI18n();
const chartData = getTemperatureChartData(detail, locale);
const canvasRef = useChart(
() => {
if (!chartData) {
return {
data: { datasets: [], labels: [] },
type: "line",
} satisfies ChartConfiguration<"line">;
}
const forecastPoints = chartData.datasets.hasMgmHourly
? chartData.datasets.mgmHourlyPoints
: chartData.datasets.debPast.map(
(value, index) => value ?? chartData.datasets.debFuture[index],
);
return {
data: {
datasets: [
{
borderColor: chartData.datasets.hasMgmHourly
? "rgba(250, 204, 21, 0.92)"
: "rgba(52, 211, 153, 0.86)",
borderWidth: 1.8,
data: forecastPoints,
fill: false,
label: chartData.datasets.hasMgmHourly
? locale === "en-US"
? "MGM Forecast"
: "MGM 预测"
: locale === "en-US"
? "DEB Forecast"
: "DEB 预测",
pointRadius: 0,
spanGaps: true,
tension: 0.28,
},
{
backgroundColor: "#22d3ee",
borderColor: "#22d3ee",
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
label: locale === "en-US" ? "METAR Observation" : "METAR 实测",
pointHoverRadius: 6,
pointRadius: 3.8,
showLine: false,
},
],
labels: chartData.times,
},
options: {
interaction: { intersect: false, mode: "index" },
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.95)",
borderColor: "rgba(34, 211, 238, 0.25)",
borderWidth: 1,
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.03)" },
ticks: {
callback: (_value, index) =>
typeof index === "number" && index % 4 === 0
? chartData.times[index]
: "",
color: "#64748b",
font: { size: 10 },
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(255,255,255,0.03)" },
max: chartData.max,
min: chartData.min,
ticks: {
callback: (value) => `${value}${detail.temp_symbol || "°C"}`,
color: "#64748b",
font: { size: 10 },
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
},
[chartData, detail.temp_symbol, locale],
);
return (
<div className="detail-mini-chart-wrap">
<div className="detail-mini-chart">
<canvas ref={canvasRef} />
</div>
<div className="detail-mini-meta">
{chartData?.legendText || t("detail.chartLegendEmpty")}
</div>
</div>
);
}
export function DetailPanel() {
const store = useDashboardStore();
const { locale, t } = useI18n();
const detail = store.selectedDetail;
const panelRef = useRef<HTMLElement | null>(null);
const isOverlayOpen =
Boolean(store.futureModalDate) ||
store.historyState.isOpen ||
@@ -24,53 +139,88 @@ export function DetailPanel() {
Boolean(detail) &&
!store.loadingState.cityDetail &&
!isOverlayOpen;
const profileStats = detail ? getCityProfileStats(detail) : [];
const riskLines = detail ? getSettlementRiskNarrative(detail) : [];
const climateDrivers = detail ? getClimateDrivers(detail) : [];
const profileStats = detail ? getCityProfileStats(detail, locale) : [];
const scenery = getCityScenery(detail?.name);
const blurActiveElement = () => {
if (typeof document === "undefined") return;
const active = document.activeElement;
if (active instanceof HTMLElement) {
active.blur();
}
};
useEffect(() => {
const panel = panelRef.current;
if (!panel) return;
if (!isVisible) {
panel.setAttribute("inert", "");
if (
typeof document !== "undefined" &&
panel.contains(document.activeElement)
) {
const active = document.activeElement;
if (active instanceof HTMLElement) {
active.blur();
}
}
return;
}
panel.removeAttribute("inert");
}, [isVisible]);
return (
<aside
ref={panelRef}
className={clsx("detail-panel", isVisible && "visible")}
aria-hidden={!isVisible}
>
<div className="panel-header">
<button
type="button"
className="panel-close"
aria-label="关闭城市详情面板"
onClick={store.closePanel}
aria-label={t("detail.closeAria")}
onClick={() => {
blurActiveElement();
store.closePanel();
}}
>
×
</button>
<div className="panel-title-area">
<h2>{detail?.display_name?.toUpperCase() || ""}</h2>
<h2>{detail?.display_name?.toUpperCase() || "..."}</h2>
<div className="panel-meta">
<span className={clsx("risk-badge", detail?.risk?.level || "low")}>
{getRiskBadgeLabel(detail?.risk?.level)}
{getRiskBadgeLabel(detail?.risk?.level, locale)}
</span>
<span className="local-time">
{detail
? `${detail.local_date} ${detail.local_time}`
: "等待选择城市"}
: t("detail.waitSelect")}
</span>
<button
type="button"
className="history-btn"
title="查看今日日内分析"
onClick={store.openTodayModal}
title={t("detail.todayAnalysis")}
onClick={() => {
blurActiveElement();
void store.openTodayModal();
}}
disabled={!detail}
>
{t("detail.todayAnalysis")}
</button>
<button
type="button"
className="history-btn"
title="查看历史对账"
onClick={() => void store.openHistory()}
title={t("detail.history")}
onClick={() => {
blurActiveElement();
void store.openHistory();
}}
disabled={!detail}
>
{t("detail.history")}
</button>
</div>
</div>
@@ -81,8 +231,8 @@ export function DetailPanel() {
<section>
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
{store.loadingState.cityDetail
? "正在加载城市详情..."
: "从左侧城市列表选择一个城市查看详情。"}
? t("detail.loading")
: t("detail.emptyHint")}
</div>
</section>
) : (
@@ -93,7 +243,7 @@ export function DetailPanel() {
<img
className="detail-scenery-image"
src={scenery.imageUrl}
alt={`${detail.display_name} 风景照`}
alt={t("detail.sceneryAlt", { city: detail.display_name })}
/>
<div className="detail-scenery-overlay">
<div className="detail-scenery-copy">
@@ -113,21 +263,19 @@ export function DetailPanel() {
</>
) : (
<div className="detail-scenery-fallback">
<span className="detail-scenery-kicker">
{detail.display_name}
</span>
<span className="detail-scenery-kicker">{detail.display_name}</span>
<strong className="detail-scenery-title">
{t("detail.sceneryTitle")}
</strong>
<span className="detail-scenery-subtitle">
{t("detail.sceneryFallback")}
</span>
</div>
)}
</section>
<section className="detail-section">
<h3></h3>
<h3>{t("detail.profile")}</h3>
<div className="detail-grid">
{profileStats.map((item) => (
<div key={item.label} className="detail-card">
@@ -139,30 +287,10 @@ export function DetailPanel() {
</section>
<section className="detail-section">
<h3></h3>
<div className="risk-info">
{riskLines.map((line) => (
<div key={line} className="risk-row">
<span style={{ color: "var(--accent-cyan)", opacity: 0.6 }}>
</span>
<span>{line}</span>
</div>
))}
</div>
<h3>{t("detail.todayMiniTrend")}</h3>
<DetailMiniTemperatureChart detail={detail} />
</section>
<section className="detail-section">
<h3></h3>
<div className="insight-list">
{climateDrivers.map((driver) => (
<div key={driver.label} className="insight-item">
<div className="insight-title">{driver.label}</div>
<div className="insight-text">{driver.text}</div>
</div>
))}
</div>
</section>
<ForecastTable />
</>
)}
File diff suppressed because it is too large Load Diff
+61 -34
View File
@@ -1,36 +1,66 @@
"use client";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
const GUIDE_CARDS = [
{
body: "Dynamic Ensemble Blending 是系统的核心预测层。它不是对 ECMWF、GFS、ICON、GEM、JMA 等模型的简单平均,而是结合近期样本表现、当前实况与城市偏置后得到的动态加权结果。",
title: "DEB 动态融合预测",
},
{
body: "右侧的结算概率分布基于 DEB 预测值与多模型离散度动态计算。μ 代表当前分布中心,会随着模型、实况和时间变化而变化,不是固定结算值。",
title: "结算概率引擎",
},
{
body: "Polymarket 结算逻辑以机场 METAR 为主。系统优先使用 Aviation Weather API 的机场报文与原始 METAR,并区分观测时间与接收时间,避免把发布延迟误认为温度变化。",
title: "结算点与主观测源",
},
{
body: "Ankara 不走通用城市逻辑。结算主站以 LTAC / Esenboğa 为准,周边领先信号优先参考 Turkish MGM 站网,其中 Ankara (Bölge/Center) 是重点监控站,不用 Etimesgut 代替。",
title: "Ankara 专属增强",
},
{
body: "点击多日预报后的模态框,主要用于分析下一个交易日。6-48 小时趋势以 weather.gov 和 Open-Meteo 为主,部分城市补充 Meteoblue;0-2 小时临近判断优先看 METAR 与周边站。",
title: "未来日期分析",
},
{
body: "历史准确率对账只统计已结算样本。网页端采用近 15 天滚动视图,不把当天尚未结算的样本算入胜率和 MAE。",
title: "历史对账规则",
},
];
const GUIDE_CARDS = {
"zh-CN": [
{
body: "Dynamic Ensemble Blending 是系统的核心预测层。它不是对 ECMWF、GFS、ICON、GEM、JMA 等模型的简单平均,而是结合近期样本表现、当前实况与城市偏置后得到的动态加权结果。",
title: "DEB 动态融合预测",
},
{
body: "右侧的结算概率分布基于 DEB 预测值与多模型离散度动态计算。μ 代表当前分布中心,会随着模型、实况和时间变化而变化,不是固定结算值。",
title: "结算概率引擎",
},
{
body: "Polymarket 结算逻辑以机场 METAR 为主。系统优先使用 Aviation Weather API 的机场报文与原始 METAR,并区分观测时间与接收时间,避免把发布延迟误认为温度变化。",
title: "结算点与主观测源",
},
{
body: "Ankara 不走通用城市逻辑。结算主站以 LTAC / Esenboğa 为准,周边领先信号优先参考 Turkish MGM 站网,其中 Ankara (Bölge/Center) 是重点监控站,不用 Etimesgut 代替。",
title: "Ankara 专属增强",
},
{
body: "点击多日预报后的模态框,主要用于分析下一个交易日。6-48 小时趋势以 weather.gov 和 Open-Meteo 为主;0-2 小时临近判断优先看 METAR 与周边站。",
title: "未来日期分析",
},
{
body: "历史准确率对账只统计已结算样本。网页端采用近 15 天滚动视图,不把当天尚未结算的样本算入胜率和 MAE。",
title: "历史对账规则",
},
],
"en-US": [
{
body: "Dynamic Ensemble Blending (DEB) is the core prediction layer. It is not a simple average across ECMWF/GFS/ICON/GEM/JMA, but a dynamically weighted blend adjusted by recent model performance, current observations, and city bias.",
title: "DEB Dynamic Fusion",
},
{
body: "Settlement probability distribution is dynamically computed from DEB forecast and model spread. μ is the current distribution center and shifts with model updates, observations, and time.",
title: "Settlement Probability Engine",
},
{
body: "Polymarket settlement follows airport METAR observations. The system prioritizes Aviation Weather API raw METAR and distinguishes observation time from receipt time to avoid misreading publication delay as temperature change.",
title: "Settlement Source Logic",
},
{
body: "Ankara does not follow the generic city path. LTAC / Esenboğa is the settlement station, with Turkish MGM network for leading signals. Ankara (Bölge/Center) is a key station and is not replaced by Etimesgut.",
title: "Ankara-specific Enhancement",
},
{
body: "The multi-day modal focuses on next-session analysis. 6-48h trend mainly relies on weather.gov and Open-Meteo; 0-2h nowcast prioritizes METAR and nearby stations.",
title: "Future-date Analysis",
},
{
body: "History reconciliation only uses settled samples. The web dashboard uses a rolling 15-day window and excludes same-day unsettled samples from hit-rate and MAE.",
title: "History Rules",
},
],
} as const;
export function GuideModal() {
const store = useDashboardStore();
const { locale, t } = useI18n();
if (!store.isGuideOpen) return null;
@@ -48,29 +78,26 @@ export function GuideModal() {
>
<div className="modal-content large">
<div className="modal-header">
<h2 id="guide-modal-title">📎 PolyWeather </h2>
<h2 id="guide-modal-title">{t("guide.title")}</h2>
<button
type="button"
className="modal-close"
aria-label="关闭技术说明"
aria-label={t("guide.closeAria")}
onClick={store.closeGuide}
>
×
</button>
</div>
<div className="modal-body">
<div className="guide-grid">
{GUIDE_CARDS.map((card) => (
{GUIDE_CARDS[locale].map((card) => (
<div key={card.title} className="guide-card">
<h3>{card.title}</h3>
<p>{card.body}</p>
</div>
))}
</div>
<div className="guide-footer">
Aviation Weather / METARTurkish MGMOpen-Meteoweather.gov
Meteoblue
</div>
<div className="guide-footer">{t("guide.footer")}</div>
</div>
</div>
</div>
+46 -22
View File
@@ -2,38 +2,62 @@
import clsx from "clsx";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
export function HeaderBar() {
const store = useDashboardStore();
const { locale, setLocale, t } = useI18n();
return (
<header className="header">
<div className="brand">
<h1>PolyWeather</h1>
<span className="subtitle"></span>
<span className="subtitle">{t("header.subtitle")}</span>
</div>
<button
type="button"
className="info-btn"
title="查看系统技术说明"
aria-label="查看系统技术说明"
onClick={store.openGuide}
>
</button>
<div className="live-badge" id="liveBadge">
<span className="pulse-dot" />
<span></span>
<div className="header-right">
<div className="lang-switch" role="group" aria-label={t("header.langAria")}>
<button
type="button"
className={clsx("lang-btn", locale === "zh-CN" && "active")}
onClick={() => setLocale("zh-CN")}
>
{t("header.langZh")}
</button>
<button
type="button"
className={clsx("lang-btn", locale === "en-US" && "active")}
onClick={() => setLocale("en-US")}
>
{t("header.langEn")}
</button>
</div>
<button
type="button"
className="info-btn"
title={t("header.infoAria")}
aria-label={t("header.infoAria")}
onClick={store.openGuide}
>
{t("header.info")}
</button>
<div className="live-badge" id="liveBadge">
<span className="pulse-dot" />
<span>{t("header.live")}</span>
</div>
<button
type="button"
className={clsx("refresh-btn", store.loadingState.refresh && "spinning")}
title={t("header.refreshAria")}
aria-label={t("header.refreshAria")}
onClick={() => void store.refreshAll()}
>
</button>
</div>
<button
type="button"
className={clsx("refresh-btn", store.loadingState.refresh && "spinning")}
title="刷新所有数据"
aria-label="刷新所有数据"
onClick={() => void store.refreshAll()}
>
</button>
</header>
);
}
+42 -21
View File
@@ -4,10 +4,12 @@ import { ChartConfiguration } from "chart.js/auto";
import { useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getHistorySummary } from "@/lib/dashboard-utils";
function HistoryChart() {
const store = useDashboardStore();
const { locale } = useI18n();
const { data } = useHistoryData();
const summary = useMemo(
() => getHistorySummary(data, store.selectedDetail?.local_date),
@@ -25,10 +27,11 @@ function HistoryChart() {
borderColor: "#f87171",
borderWidth: 2,
data: summary.actuals,
label: "实测最高温",
label: locale === "en-US" ? "Observed High" : "实测最高温",
pointBackgroundColor: "#f87171",
pointBorderColor: "#fff",
pointRadius: 4,
pointHoverRadius: 7,
pointRadius: 5,
tension: 0.2,
},
{
@@ -37,8 +40,9 @@ function HistoryChart() {
borderDash: [5, 4],
borderWidth: 2,
data: summary.debs,
label: "DEB 融合",
pointRadius: 3,
label: locale === "en-US" ? "DEB Fusion" : "DEB 融合",
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
},
];
@@ -49,8 +53,9 @@ function HistoryChart() {
borderColor: "#fb923c",
borderWidth: 2,
data: summary.mgms,
label: "MGM 官方预报",
pointRadius: 3,
label: locale === "en-US" ? "MGM Official Forecast" : "MGM 官方预报",
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
});
}
@@ -66,14 +71,19 @@ function HistoryChart() {
plugins: {
legend: {
labels: {
boxHeight: 12,
boxWidth: 34,
color: "#94a3b8",
font: { family: "Inter", size: 12 },
font: { family: "Inter", size: 14 },
padding: 18,
},
},
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.9)",
borderColor: "rgba(255, 255, 255, 0.1)",
borderWidth: 1,
bodyFont: { family: "Inter", size: 13 },
titleFont: { family: "Inter", size: 13, weight: 600 },
callbacks: {
label: (ctx) => `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}°`,
},
@@ -83,18 +93,26 @@ function HistoryChart() {
scales: {
x: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: { color: "#64748b", font: { family: "Inter", size: 10 } },
ticks: {
color: "#64748b",
font: { family: "Inter", size: 12 },
padding: 8,
},
},
y: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: { color: "#64748b", font: { family: "Inter", size: 10 } },
ticks: {
color: "#64748b",
font: { family: "Inter", size: 12 },
padding: 8,
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
},
[hasMgm, summary],
[hasMgm, summary, locale],
);
if (!summary.recentData.length) return null;
@@ -108,6 +126,7 @@ function HistoryChart() {
export function HistoryModal() {
const store = useDashboardStore();
const { t } = useI18n();
const { data, error, isLoading, isOpen } = useHistoryData();
const summary = useMemo(
() => getHistorySummary(data, store.selectedDetail?.local_date),
@@ -128,45 +147,47 @@ export function HistoryModal() {
}
}}
>
<div className="modal-content">
<div className="modal-content history-modal">
<div className="modal-header">
<h2 id="history-modal-title">
📊 - {store.selectedCity?.toUpperCase()}
{t("history.title", { city: store.selectedCity?.toUpperCase() || "" })}
</h2>
<button
type="button"
className="modal-close"
aria-label="关闭历史对账"
aria-label={t("history.closeAria")}
onClick={store.closeHistory}
>
×
</button>
</div>
<div className="modal-body">
<div className="history-stats">
{isLoading ? (
<span style={{ color: "var(--text-muted)" }}>...</span>
<span style={{ color: "var(--text-muted)" }}>{t("history.loading")}</span>
) : error ? (
<span style={{ color: "var(--accent-red)" }}></span>
<span style={{ color: "var(--accent-red)" }}>{t("history.error")}</span>
) : !summary.recentData.length ? (
<span style={{ color: "var(--text-muted)" }}> 15 </span>
<span style={{ color: "var(--text-muted)" }}>{t("history.empty")}</span>
) : (
<>
<div className="h-stat-card">
<span className="label">DEB (WU)</span>
<span className="label">{t("history.hitRate")}</span>
<span className="val">
{summary.hitRate != null ? `${summary.hitRate}%` : "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label">DEB MAE</span>
<span className="label">{t("history.mae")}</span>
<span className="val">
{summary.debMae != null ? `${summary.debMae}°` : "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label"> 15 </span>
<span className="val">{summary.settledCount} </span>
<span className="label">{t("history.sample")}</span>
<span className="val">
{t("history.sampleDays", { count: summary.settledCount })}
</span>
</div>
</>
)}
+555 -226
View File
@@ -4,26 +4,159 @@ import { ChartConfiguration } from "chart.js/auto";
import clsx from "clsx";
import { useChart } from "@/hooks/useChart";
import { useCityData, useDashboardStore } from "@/hooks/useDashboardStore";
import { CityDetail } from "@/lib/dashboard-types";
import { useI18n } from "@/hooks/useI18n";
import {
CityDetail,
MarketScan,
MarketTopBucket,
ProbabilityBucket,
} from "@/lib/dashboard-types";
import {
getHeroMetaItems,
getModelView,
getProbabilityView,
getRiskBadgeLabel,
getTemperatureChartData,
getWeatherSummary,
parseAiAnalysis,
} from "@/lib/dashboard-utils";
function EmptyState({ text }: { text: string }) {
return <div style={{ color: "var(--text-muted)", fontSize: "13px" }}>{text}</div>;
return (
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>{text}</div>
);
}
function toPercent(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
return `${(numeric * 100).toFixed(1)}%`;
}
function toPriceCents(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
const normalized = numeric > 1 ? numeric / 100 : numeric;
const cents = normalized * 100;
const rounded = Math.round(cents * 10) / 10;
const text = Number.isInteger(rounded)
? String(rounded.toFixed(0))
: String(rounded);
return `${text}c`;
}
function parseTempFromText(value: unknown) {
const text = String(value || "");
const match = text.match(/(-?\d+(?:\.\d+)?)/);
if (!match) return null;
const numeric = Number(match[1]);
return Number.isFinite(numeric) ? numeric : null;
}
function getBucketTemp(bucket: ProbabilityBucket) {
if (bucket.value != null) {
const byValue = Number(bucket.value);
if (Number.isFinite(byValue)) return byValue;
}
return parseTempFromText(bucket.label || bucket.bucket || bucket.range);
}
function getMarketBucketTemp(scan?: MarketScan | null) {
if (!scan) return null;
if (scan.temperature_bucket?.value != null) {
const byBucketValue = Number(scan.temperature_bucket.value);
if (Number.isFinite(byBucketValue)) return byBucketValue;
}
const byBucketLabel = parseTempFromText(
scan.temperature_bucket?.label ||
scan.temperature_bucket?.bucket ||
scan.temperature_bucket?.range,
);
if (byBucketLabel != null) return byBucketLabel;
const slug = String(scan.selected_slug || scan.primary_market?.slug || "");
const slugMatch = slug.match(/-(-?\d+(?:\.\d+)?)c(?:$|[^a-z0-9])/i);
if (slugMatch) {
const numeric = Number(slugMatch[1]);
if (Number.isFinite(numeric)) return numeric;
}
return parseTempFromText(scan.primary_market?.question);
}
function getMarketYesPrice(scan?: MarketScan | null) {
if (scan?.market_price != null) {
const preferred = Number(scan.market_price);
if (Number.isFinite(preferred)) return preferred;
}
if (scan?.yes_token?.implied_probability != null) {
const implied = Number(scan.yes_token.implied_probability);
if (Number.isFinite(implied)) return implied;
}
return null;
}
function getMarketNoPrice(scan?: MarketScan | null) {
if (scan?.no_buy != null) {
const direct = Number(scan.no_buy);
if (Number.isFinite(direct)) return direct;
}
const marketYes = getMarketYesPrice(scan);
if (marketYes != null) return Math.max(0, Math.min(1, 1 - marketYes));
return null;
}
function normalizeMarketProbability(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100));
return Math.max(0, Math.min(1, numeric));
}
function getMarketTopBuckets(scan?: MarketScan | null) {
const buckets = Array.isArray(scan?.top_buckets) ? scan.top_buckets : [];
if (!buckets.length) return [];
return buckets
.map((item) => ({
...item,
probability: normalizeMarketProbability(item.probability),
}))
.filter(
(item): item is MarketTopBucket & { probability: number } =>
item.probability != null,
);
}
function getMarketTopBucketKey(bucket: MarketTopBucket) {
if (bucket?.value != null) {
const valueNum = Number(bucket.value);
if (Number.isFinite(valueNum)) return `v:${valueNum.toFixed(2)}`;
}
if (bucket?.temp != null) {
const tempNum = Number(bucket.temp);
if (Number.isFinite(tempNum)) return `t:${tempNum.toFixed(2)}`;
}
const parsed = parseTempFromText(bucket?.label);
if (parsed != null) return `l:${parsed.toFixed(2)}`;
return `s:${String(bucket?.slug || bucket?.question || bucket?.label || "")}`;
}
export function HeroSummary() {
const { data } = useCityData();
const { locale } = useI18n();
if (!data) return null;
const { weatherIcon, weatherText } = getWeatherSummary(data);
const metaItems = getHeroMetaItems(data);
const { weatherIcon, weatherText } = getWeatherSummary(data, locale);
const metaItems = getHeroMetaItems(data, locale);
const current = data.current || {};
const isMax =
current.max_so_far != null &&
@@ -45,12 +178,16 @@ export function HeroSummary() {
</div>
<div className="hero-max-time">
{isMax && current.max_temp_time
? `该城市今日最高温出现在当地时间 ${current.max_temp_time}`
? locale === "en-US"
? `Today's peak temperature appeared at local time ${current.max_temp_time}`
: `该城市今日最高温出现在当地时间 ${current.max_temp_time}`
: ""}
</div>
<div className="hero-details">
<div className="hero-item">
<span className="label"></span>
<span className="label">
{locale === "en-US" ? "Current Obs" : "当前实测"}
</span>
<span className="value">
{current.temp != null
? `${current.temp}${data.temp_symbol} @${current.obs_time || "--"}`
@@ -58,7 +195,9 @@ export function HeroSummary() {
</span>
</div>
<div className="hero-item">
<span className="label">WU </span>
<span className="label">
{locale === "en-US" ? "WU Settlement Ref" : "WU 结算参考"}
</span>
<span className="value highlight">
{current.wu_settlement != null
? `${current.wu_settlement}${data.temp_symbol}`
@@ -66,7 +205,9 @@ export function HeroSummary() {
</span>
</div>
<div className="hero-item">
<span className="label">DEB </span>
<span className="label">
{locale === "en-US" ? "DEB Forecast" : "DEB 预测"}
</span>
<span className="value">
{data.deb?.prediction != null
? `${data.deb.prediction}${data.temp_symbol}`
@@ -85,152 +226,154 @@ export function HeroSummary() {
export function TemperatureChart() {
const { data } = useCityData();
const chartData = data ? getTemperatureChartData(data) : null;
const canvasRef = useChart(
() => {
if (!data || !chartData) {
return {
data: { datasets: [], labels: [] },
type: "line",
} satisfies ChartConfiguration<"line">;
}
const datasets: NonNullable<ChartConfiguration<"line">["data"]>["datasets"] = [];
if (chartData.datasets.hasMgmHourly) {
datasets.push({
backgroundColor: "rgba(234, 179, 8, 0.05)",
borderColor: "rgba(234, 179, 8, 0.8)",
borderWidth: 2,
data: chartData.datasets.mgmHourlyPoints,
fill: false,
label: "MGM 预报",
pointHoverRadius: 6,
pointRadius: 3,
spanGaps: true,
tension: 0.3,
});
} else {
datasets.push({
backgroundColor: "rgba(52, 211, 153, 0.05)",
borderColor: "rgba(52, 211, 153, 0.6)",
borderWidth: 1.5,
data: chartData.datasets.debPast,
fill: true,
label: "DEB 预报",
pointHoverRadius: 3,
pointRadius: 0,
tension: 0.3,
});
datasets.push({
borderColor: "rgba(52, 211, 153, 0.35)",
borderDash: [5, 3],
borderWidth: 1.5,
data: chartData.datasets.debFuture,
fill: false,
label: "DEB 预报",
pointRadius: 0,
tension: 0.3,
});
}
datasets.push({
backgroundColor: "#22d3ee",
borderColor: "#22d3ee",
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
label: "METAR 实测",
order: 0,
pointHoverRadius: 7,
pointRadius: 5,
});
if (chartData.datasets.mgmPoints.some((value) => value != null)) {
datasets.push({
backgroundColor: "#facc15",
borderColor: "#facc15",
borderWidth: 0,
data: chartData.datasets.mgmPoints,
fill: false,
label: "MGM 实测",
order: -1,
pointHoverRadius: 9,
pointRadius: 7,
showLine: false,
});
}
if (
!chartData.datasets.hasMgmHourly &&
Math.abs(chartData.datasets.offset) > 0.3
) {
datasets.push({
borderColor: "rgba(99, 102, 241, 0.2)",
borderDash: [2, 4],
borderWidth: 1,
data: chartData.datasets.temps,
fill: false,
label: "OM 原始",
pointRadius: 0,
tension: 0.3,
});
}
const { locale, t } = useI18n();
const chartData = data ? getTemperatureChartData(data, locale) : null;
const canvasRef = useChart(() => {
if (!data || !chartData) {
return {
data: {
datasets,
labels: chartData.times,
},
options: {
interaction: { intersect: false, mode: "index" },
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.9)",
borderColor: "rgba(52, 211, 153, 0.3)",
borderWidth: 1,
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
callback: (_value, index) =>
typeof index === "number" && index % 3 === 0
? chartData.times[index]
: "",
color: "#64748b",
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(255,255,255,0.04)" },
max: chartData.max,
min: chartData.min,
ticks: {
callback: (value) => `${value}${data.temp_symbol || "°C"}`,
color: "#64748b",
},
},
},
},
data: { datasets: [], labels: [] },
type: "line",
} satisfies ChartConfiguration<"line">;
},
[data, chartData],
);
}
const datasets: NonNullable<
ChartConfiguration<"line">["data"]
>["datasets"] = [];
if (chartData.datasets.hasMgmHourly) {
datasets.push({
backgroundColor: "rgba(234, 179, 8, 0.05)",
borderColor: "rgba(234, 179, 8, 0.8)",
borderWidth: 2,
data: chartData.datasets.mgmHourlyPoints,
fill: false,
label: locale === "en-US" ? "MGM Forecast" : "MGM 预报",
pointHoverRadius: 6,
pointRadius: 3,
spanGaps: true,
tension: 0.3,
});
} else {
datasets.push({
backgroundColor: "rgba(52, 211, 153, 0.05)",
borderColor: "rgba(52, 211, 153, 0.6)",
borderWidth: 1.5,
data: chartData.datasets.debPast,
fill: true,
label: locale === "en-US" ? "DEB Forecast" : "DEB 预报",
pointHoverRadius: 3,
pointRadius: 0,
tension: 0.3,
});
datasets.push({
borderColor: "rgba(52, 211, 153, 0.35)",
borderDash: [5, 3],
borderWidth: 1.5,
data: chartData.datasets.debFuture,
fill: false,
label: locale === "en-US" ? "DEB Forecast" : "DEB 预报",
pointRadius: 0,
tension: 0.3,
});
}
datasets.push({
backgroundColor: "#22d3ee",
borderColor: "#22d3ee",
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
label: locale === "en-US" ? "METAR Observation" : "METAR 实测",
order: 0,
pointHoverRadius: 7,
pointRadius: 5,
});
if (chartData.datasets.mgmPoints.some((value) => value != null)) {
datasets.push({
backgroundColor: "#facc15",
borderColor: "#facc15",
borderWidth: 0,
data: chartData.datasets.mgmPoints,
fill: false,
label: locale === "en-US" ? "MGM Observation" : "MGM 实测",
order: -1,
pointHoverRadius: 9,
pointRadius: 7,
showLine: false,
});
}
if (
!chartData.datasets.hasMgmHourly &&
Math.abs(chartData.datasets.offset) > 0.3
) {
datasets.push({
borderColor: "rgba(99, 102, 241, 0.2)",
borderDash: [2, 4],
borderWidth: 1,
data: chartData.datasets.temps,
fill: false,
label: locale === "en-US" ? "OM Raw" : "OM 原始",
pointRadius: 0,
tension: 0.3,
});
}
return {
data: {
datasets,
labels: chartData.times,
},
options: {
interaction: { intersect: false, mode: "index" },
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.9)",
borderColor: "rgba(52, 211, 153, 0.3)",
borderWidth: 1,
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
callback: (_value, index) =>
typeof index === "number" && index % 3 === 0
? chartData.times[index]
: "",
color: "#64748b",
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(255,255,255,0.04)" },
max: chartData.max,
min: chartData.min,
ticks: {
callback: (value) => `${value}${data.temp_symbol || "°C"}`,
color: "#64748b",
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [data, chartData, locale]);
return (
<section className="chart-section">
<h3></h3>
<h3>{t("section.todayTempTrend")}</h3>
<div className="chart-wrapper">
<canvas ref={canvasRef} />
</div>
<div className="chart-legend">{chartData?.legendText || "暂无小时级数据"}</div>
<div className="chart-legend">
{chartData?.legendText || t("section.chartEmpty")}
</div>
</section>
);
}
@@ -239,35 +382,115 @@ export function ProbabilityDistribution({
detail,
hideTitle = false,
targetDate,
marketScan,
}: {
detail: CityDetail;
hideTitle?: boolean;
targetDate?: string | null;
marketScan?: MarketScan | null;
}) {
const { locale, t } = useI18n();
const view = getProbabilityView(detail, targetDate);
const marketBucketTemp = getMarketBucketTemp(marketScan);
const marketYesPrice = getMarketYesPrice(marketScan);
const marketNoPrice = getMarketNoPrice(marketScan);
const marketYesText = toPercent(marketYesPrice);
const marketNoText = toPercent(marketNoPrice);
const isToday = !targetDate || targetDate === detail.local_date;
const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : [];
const sortedMarketTopBuckets = (() => {
const sorted = [...marketTopBuckets].sort(
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
);
const deduped: Array<MarketTopBucket & { probability: number }> = [];
const seenKeys = new Set<string>();
for (const row of sorted) {
const key = getMarketTopBucketKey(row);
if (seenKeys.has(key)) continue;
seenKeys.add(key);
deduped.push(row);
if (deduped.length >= 4) break;
}
return deduped;
})();
const useMarketTopBuckets =
marketScan?.available && sortedMarketTopBuckets.length >= 2;
const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability);
return (
<section className="prob-section">
{!hideTitle && <h3></h3>}
{!hideTitle && <h3>{t("section.probability")}</h3>}
<div className="prob-bars">
{view.mu != null && (
<div
style={{ color: "var(--text-muted)", fontSize: "11px", marginBottom: "6px" }}
style={{
color: "var(--text-muted)",
fontSize: "11px",
marginBottom: "6px",
}}
>
μ = {view.mu.toFixed(1)}
{detail.temp_symbol}
{t("section.mu", {
unit: detail.temp_symbol || "",
value: view.mu.toFixed(1),
})}
</div>
)}
{view.probabilities.length === 0 ? (
<EmptyState text="暂无概率数据" />
) : (
view.probabilities.slice(0, 6).map((bucket, index) => {
const probability = Math.round(Number(bucket.probability || 0) * 100);
{marketScan?.available && (topMarketBucketText || marketYesText) && (
<div
style={{
color: "var(--text-secondary)",
fontSize: "11px",
marginBottom: "6px",
}}
>
{useMarketTopBuckets
? locale === "en-US"
? `Market top-4 buckets (top): ${topMarketBucketText}`
: `市场概率(前4温度桶):最高 ${topMarketBucketText}`
: locale === "en-US"
? `Market probability (this bucket): ${marketYesText}`
: `市场概率(该温度桶): ${marketYesText}`}
</div>
)}
{useMarketTopBuckets ? (
sortedMarketTopBuckets.map((bucket, index) => {
const probability = Math.round(
Number(bucket.probability || 0) * 100,
);
let bucketLabel =
bucket.label ||
(bucket.value != null
? `${bucket.value}${detail.temp_symbol}`
: `${bucket.temp ?? "--"}${detail.temp_symbol}`);
if (bucketLabel) {
let str = String(bucketLabel).toUpperCase().replace(/\s+/g, "");
str = str.replace(/°?C($|\+|-)/g, "℃$1");
if (!str.includes("℃") && /[0-9]/.test(str)) {
str += "℃";
}
bucketLabel = str;
}
const buyYesText = toPriceCents(
bucket.yes_buy ?? bucket.market_price ?? bucket.probability,
);
const buyNoText = toPriceCents(bucket.no_buy);
const marketTag = buyYesText
? locale === "en-US"
? `Buy Yes: ${buyYesText}`
: `买 Yes: ${buyYesText}`
: buyNoText
? locale === "en-US"
? `Buy No: ${buyNoText}`
: `买 No: ${buyNoText}`
: null;
return (
<div key={`${bucket.label || bucket.value || index}`} className="prob-row">
<div className="prob-label">
{bucket.label || `${bucket.value}${detail.temp_symbol}`}
</div>
<div
key={`${bucket.slug || bucket.label || index}`}
className="prob-row"
>
<div className="prob-label">{bucketLabel}</div>
<div className="prob-bar-track">
<div
className={clsx("prob-bar-fill", `rank-${index}`)}
@@ -276,6 +499,82 @@ export function ProbabilityDistribution({
{probability}%
</div>
</div>
{marketTag && (
<div className={clsx("prob-market-inline", "yes")}>
{marketTag}
</div>
)}
</div>
);
})
) : view.probabilities.length === 0 ? (
<EmptyState text={t("section.noProb")} />
) : (
view.probabilities.slice(0, 6).map((bucket, index) => {
const probability = Math.round(
Number(bucket.probability || 0) * 100,
);
const bucketTemp = getBucketTemp(bucket);
const isMarketBucket =
marketYesText != null &&
marketBucketTemp != null &&
bucketTemp != null &&
Math.abs(bucketTemp - marketBucketTemp) < 0.26;
const marketTag = isMarketBucket
? locale === "en-US"
? `Buy Yes: ${marketYesText || "--"}`
: `买 Yes: ${marketYesText || "--"}`
: marketNoText
? locale === "en-US"
? `Buy No: ${marketNoText}`
: `买 No: ${marketNoText}`
: null;
const yesPriceText = toPriceCents(marketYesPrice);
const noPriceText = toPriceCents(marketNoPrice);
const marketTagFinal = isMarketBucket
? locale === "en-US"
? `Buy Yes: ${yesPriceText || "--"}`
: `买 Yes: ${yesPriceText || "--"}`
: noPriceText
? locale === "en-US"
? `Buy No: ${noPriceText}`
: `买 No: ${noPriceText}`
: marketTag;
let bucketLabel =
bucket.label || `${bucket.value}${detail.temp_symbol}`;
if (bucketLabel) {
let str = String(bucketLabel).toUpperCase().replace(/\s+/g, "");
str = str.replace(/°?C($|\+|-)/g, "℃$1");
if (!str.includes("℃") && /[0-9]/.test(str)) {
str += "℃";
}
bucketLabel = str;
}
return (
<div
key={`${bucket.label || bucket.value || index}`}
className="prob-row"
>
<div className="prob-label">{bucketLabel}</div>
<div className="prob-bar-track">
<div
className={clsx("prob-bar-fill", `rank-${index}`)}
style={{ width: `${Math.max(probability, 8)}%` }}
>
{probability}%
</div>
</div>
{marketTagFinal && (
<div
className={clsx(
"prob-market-inline",
isMarketBucket ? "yes" : "no",
)}
>
{marketTagFinal}
</div>
)}
</div>
);
})
@@ -294,81 +593,102 @@ export function ModelForecast({
hideTitle?: boolean;
targetDate?: string | null;
}) {
const { locale, t } = useI18n();
const view = getModelView(detail, targetDate);
const modelEntries = Object.entries(view.models).filter(([, value]) =>
Number.isFinite(Number(value)),
const modelsMap = { ...view.models };
const modelEntries = Object.entries(modelsMap).filter(
([, value]) =>
value !== null && value !== undefined && Number.isFinite(Number(value)),
);
// 如果没有任何数值,给出提示
if (modelEntries.length === 0) {
return (
<section className="models-section">
{!hideTitle && <h3>{t("section.models")}</h3>}
<div className="model-bars">
<EmptyState text={t("section.noModels")} />
</div>
</section>
);
}
const numericValues = modelEntries.map(([, value]) => Number(value));
const comparisonValues =
view.deb != null ? [...numericValues, Number(view.deb)] : numericValues;
const minValue = comparisonValues.length ? Math.min(...comparisonValues) - 1 : 0;
const maxValue = comparisonValues.length ? Math.max(...comparisonValues) + 1 : 1;
const minValue = comparisonValues.length
? Math.min(...comparisonValues) - 1
: 0;
const maxValue = comparisonValues.length
? Math.max(...comparisonValues) + 1
: 1;
const range = Math.max(maxValue - minValue, 1);
return (
<section className="models-section">
{!hideTitle && <h3></h3>}
{!hideTitle && <h3>{t("section.models")}</h3>}
<div className="model-bars">
{!modelEntries.length ? (
<EmptyState text="暂无多模型预报" />
) : (
<>
{modelEntries
.sort((a, b) => Number(b[1] || 0) - Number(a[1] || 0))
.map(([name, value]) => {
const numeric = Number(value);
const width = ((numeric - minValue) / range) * 100;
const debLine =
view.deb != null
? ((Number(view.deb) - minValue) / range) * 100
: null;
{modelEntries
.sort((a, b) => Number(b[1] || 0) - Number(a[1] || 0))
.map(([name, value]) => {
const numeric = Number(value);
const width = ((numeric - minValue) / range) * 100;
const debLine =
view.deb != null
? ((Number(view.deb) - minValue) / range) * 100
: null;
return (
<div key={name} className="model-row">
<div className="model-name" title={name}>
{name}
</div>
<div className="model-bar-track">
<div className="model-bar-fill" style={{ width: `${width}%` }}>
{numeric}
{detail.temp_symbol}
</div>
{debLine != null && (
<div className="model-deb-line" style={{ left: `${debLine}%` }} />
)}
</div>
</div>
);
})}
{view.deb != null && (
<div
className="model-row"
style={{
borderTop: "1px solid rgba(255,255,255,0.06)",
marginTop: "6px",
paddingTop: "6px",
}}
>
<div
className="model-name"
style={{ color: "var(--accent-cyan)", fontWeight: 700 }}
>
DEB
return (
<div key={name} className="model-row">
<div className="model-name" title={name}>
{name}
</div>
<div className="model-bar-track">
<div
className="model-bar-fill deb"
style={{
width: `${((Number(view.deb) - minValue) / range) * 100}%`,
}}
className="model-bar-fill"
style={{ width: `${width}%` }}
>
{Number(view.deb)}
{numeric}
{detail.temp_symbol}
</div>
{debLine != null && (
<div
className="model-deb-line"
style={{ left: `${debLine}%` }}
/>
)}
</div>
</div>
)}
</>
);
})}
{view.deb != null && (
<div
className="model-row"
style={{
borderTop: "1px solid rgba(255,255,255,0.06)",
marginTop: "6px",
paddingTop: "6px",
}}
>
<div
className="model-name"
style={{ color: "var(--accent-cyan)", fontWeight: 700 }}
>
DEB
</div>
<div className="model-bar-track">
<div
className="model-bar-fill deb"
style={{
width: `${((Number(view.deb) - minValue) / range) * 100}%`,
}}
>
{Number(view.deb)}
{detail.temp_symbol}
</div>
</div>
</div>
)}
</div>
</section>
@@ -378,15 +698,16 @@ export function ModelForecast({
export function ForecastTable() {
const store = useDashboardStore();
const { data } = useCityData();
const { t } = useI18n();
if (!data) return null;
const daily = data.forecast?.daily || [];
return (
<section className="forecast-section">
<h3></h3>
<h3>{t("forecast.title")}</h3>
<div className="forecast-table">
{daily.length === 0 ? (
<EmptyState text="暂无多日预报" />
<EmptyState text={t("forecast.empty")} />
) : (
daily.map((day, index) => {
const isToday = day.date === data.local_date || index === 0;
@@ -397,13 +718,19 @@ export function ForecastTable() {
<button
key={day.date}
type="button"
className={clsx("forecast-day", isToday && "today", isSelected && "selected")}
className={clsx(
"forecast-day",
isToday && "today",
isSelected && "selected",
)}
onClick={() => {
store.openFutureModal(day.date);
}}
>
<div className="f-date">
{isToday ? "今天" : day.date.substring(5).replace("-", "/")}
{isToday
? t("forecast.today")
: day.date.substring(5).replace("-", "/")}
</div>
<div className="f-temp">
{day.max_temp}
@@ -420,17 +747,16 @@ export function ForecastTable() {
export function AiAnalysis() {
const { data } = useCityData();
const { t } = useI18n();
if (!data) return null;
const ai = parseAiAnalysis(data.ai_analysis);
return (
<section className="ai-section">
<h3>AI </h3>
<h3>{t("section.ai")}</h3>
<div className="ai-box">
{!ai.summary && ai.bullets.length === 0 ? (
<span className="ai-placeholder">
AI
</span>
<span className="ai-placeholder">{t("section.aiEmpty")}</span>
) : (
<>
{ai.summary && <div className="ai-summary">{ai.summary}</div>}
@@ -450,30 +776,33 @@ export function AiAnalysis() {
export function RiskInfo() {
const { data } = useCityData();
const { t } = useI18n();
if (!data) return null;
const risk = data.risk || {};
return (
<section className="risk-section">
<h3></h3>
<h3>{t("section.risk")}</h3>
<div className="risk-info">
{!risk.airport ? (
<span style={{ color: "var(--text-muted)" }}></span>
<span style={{ color: "var(--text-muted)" }}>
{t("section.noRiskProfile")}
</span>
) : (
<>
<div className="risk-row">
<span className="risk-label"></span>
<span className="risk-label">{t("section.airport")}</span>
<span>
{risk.airport} ({risk.icao})
</span>
</div>
<div className="risk-row">
<span className="risk-label"></span>
<span className="risk-label">{t("section.distance")}</span>
<span>{risk.distance_km}km</span>
</div>
{risk.warning && (
<div className="risk-row">
<span className="risk-label"></span>
<span className="risk-label">{t("section.note")}</span>
<span>{risk.warning}</span>
</div>
)}
@@ -6,6 +6,7 @@ import {
DashboardStoreProvider,
useDashboardStore,
} from "@/hooks/useDashboardStore";
import { I18nProvider, useI18n } from "@/hooks/useI18n";
import { CitySidebar } from "@/components/dashboard/CitySidebar";
import { DetailPanel } from "@/components/dashboard/DetailPanel";
import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal";
@@ -16,6 +17,7 @@ import { MapCanvas } from "@/components/dashboard/MapCanvas";
function DashboardScreen() {
const store = useDashboardStore();
const { t } = useI18n();
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
@@ -61,7 +63,7 @@ function DashboardScreen() {
{showLoading && (
<div className="loading-overlay">
<div className="loading-spinner" />
<span>...</span>
<span>{t("dashboard.loading")}</span>
</div>
)}
</div>
@@ -70,8 +72,10 @@ function DashboardScreen() {
export function PolyWeatherDashboard() {
return (
<DashboardStoreProvider>
<DashboardScreen />
</DashboardStoreProvider>
<I18nProvider>
<DashboardStoreProvider>
<DashboardScreen />
</DashboardStoreProvider>
</I18nProvider>
);
}
+222 -9
View File
@@ -21,6 +21,7 @@ import {
HistoryPoint,
HistoryState,
LoadingState,
MarketScan,
} from "@/lib/dashboard-types";
interface DashboardStoreValue extends DashboardState {
@@ -32,16 +33,18 @@ interface DashboardStoreValue extends DashboardState {
futureModalDate: string | null;
isGuideOpen: boolean;
loadCities: () => Promise<void>;
openFutureModal: (dateStr: string) => void;
openFutureModal: (dateStr: string, forceRefresh?: boolean) => void;
openGuide: () => void;
openHistory: () => Promise<void>;
openTodayModal: () => void;
openTodayModal: (forceRefresh?: boolean) => Promise<void>;
registerMapStopMotion: (stopMotion: () => void) => void;
refreshAll: () => Promise<void>;
refreshSelectedCity: () => Promise<void>;
selectedMarketScan: MarketScan | null;
selectedDetail: CityDetail | null;
selectCity: (cityName: string) => Promise<void>;
setForecastDate: (dateStr: string | null) => void;
marketScanByCityName: Record<string, MarketScan>;
}
const DashboardStoreContext = createContext<DashboardStoreValue | null>(null);
@@ -52,6 +55,7 @@ function getInitialLoadingState(): LoadingState {
cityDetail: false,
history: false,
refresh: false,
marketScan: false,
};
}
@@ -64,6 +68,101 @@ function getInitialHistoryState(): HistoryState {
};
}
const AI_EMPTY_PATTERNS = [
/暂无\s*AI\s*分析/i,
/当前以结构化气象与模型数据为主/i,
/No\s*AI\s*analysis\s*available/i,
/Structured\s+meteorological\s+and\s+model\s+data/i,
];
function normalizeText(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function extractAiPayload(analysis: CityDetail["ai_analysis"]) {
if (!analysis) {
return {
bullets: [] as string[],
summary: "",
};
}
if (typeof analysis === "string") {
return {
bullets: [] as string[],
summary: normalizeText(analysis),
};
}
const summary =
normalizeText(analysis.summary) ||
normalizeText(analysis.text) ||
normalizeText(analysis.message);
const bulletsSource = Array.isArray(analysis.highlights)
? analysis.highlights
: Array.isArray(analysis.points)
? analysis.points
: [];
return {
bullets: bulletsSource.map((item) => normalizeText(item)).filter(Boolean),
summary,
};
}
function hasMeaningfulAiAnalysis(analysis: CityDetail["ai_analysis"]) {
const parsed = extractAiPayload(analysis);
const hasBullets = parsed.bullets.length > 0;
const hasSummary =
Boolean(parsed.summary) &&
!AI_EMPTY_PATTERNS.some((pattern) => pattern.test(parsed.summary));
return hasBullets || hasSummary;
}
function normalizeMetarSignature(detail?: CityDetail) {
if (!detail) return "";
const metar = normalizeText(detail.current?.raw_metar)
.replace(/\s+/g, " ")
.toUpperCase();
const obsTime = normalizeText(detail.current?.obs_time);
return [metar, obsTime].filter(Boolean).join("|");
}
function mergeAiAnalysisIfStable(
previousDetail: CityDetail | undefined,
nextDetail: CityDetail,
) {
if (!previousDetail) return nextDetail;
if (hasMeaningfulAiAnalysis(nextDetail.ai_analysis)) return nextDetail;
if (!hasMeaningfulAiAnalysis(previousDetail.ai_analysis)) return nextDetail;
const prevTemp = Number(previousDetail.current?.temp);
const nextTemp = Number(nextDetail.current?.temp);
const tempUnchanged =
Number.isFinite(prevTemp) &&
Number.isFinite(nextTemp) &&
prevTemp === nextTemp;
const prevMetar = normalizeMetarSignature(previousDetail);
const nextMetar = normalizeMetarSignature(nextDetail);
const metarUnchanged =
Boolean(prevMetar) && Boolean(nextMetar) && prevMetar === nextMetar;
if (!tempUnchanged && !metarUnchanged) {
return nextDetail;
}
return {
...nextDetail,
ai_analysis: previousDetail.ai_analysis,
};
}
function getMarketScanCacheKey(cityName: string, targetDate?: string | null) {
const normalizedDate = String(targetDate || "").trim() || "local";
return `${cityName}::${normalizedDate}`;
}
export function DashboardStoreProvider({
children,
}: {
@@ -87,6 +186,9 @@ export function DashboardStoreProvider({
const [cityDetailMetaByName, setCityDetailMetaByName] = useState<
Record<string, { cachedAt: number; revision: string }>
>(() => initialCache.meta);
const [marketScanByCityName, setMarketScanByCityName] = useState<
Record<string, MarketScan>
>({});
const [selectedCity, setSelectedCity] = useState<string | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [selectedForecastDate, setSelectedForecastDate] = useState<
@@ -113,6 +215,14 @@ export function DashboardStoreProvider({
const selectedDetail = selectedCity
? cityDetailsByName[selectedCity] || null
: null;
const selectedMarketDate =
futureModalDate || selectedForecastDate || selectedDetail?.local_date || null;
const selectedMarketScanKey = selectedCity
? getMarketScanCacheKey(selectedCity, selectedMarketDate)
: null;
const selectedMarketScan = selectedCity
? marketScanByCityName[selectedMarketScanKey || ""] || null
: null;
useEffect(() => {
dashboardClient.writeCityDetailCacheBundle(
@@ -151,7 +261,10 @@ export function DashboardStoreProvider({
}
}
const detail = await dashboardClient.getCityDetail(cityName, { force });
const latestDetail = await dashboardClient.getCityDetail(cityName, {
force,
});
const detail = mergeAiAnalysisIfStable(cached, latestDetail);
setCityDetailsByName((current) => ({
...current,
[cityName]: detail,
@@ -170,6 +283,32 @@ export function DashboardStoreProvider({
return detail;
};
const ensureCityMarketScan = async (
cityName: string,
force = false,
marketSlug?: string | null,
targetDate?: string | null,
) => {
const cacheKey = getMarketScanCacheKey(cityName, targetDate);
const cached = marketScanByCityName[cacheKey];
if (!force && cached && !marketSlug) {
return cached;
}
const latestScan = await dashboardClient.getCityMarketScan(cityName, {
force,
marketSlug,
targetDate,
});
if (latestScan) {
setMarketScanByCityName((current) => ({
...current,
[cacheKey]: latestScan,
}));
}
return latestScan;
};
const loadCities = async () => {
setLoadingState((current) => ({ ...current, cities: true }));
try {
@@ -239,6 +378,10 @@ export function DashboardStoreProvider({
try {
const detail = await ensureCityDetail(cityName);
setSelectedForecastDate(detail.local_date);
// 预热市场数据,不做 await 阻塞,后台静默拉取
void ensureCityMarketScan(cityName, false, null, detail.local_date).catch(
() => {},
);
} finally {
setLoadingState((current) => ({ ...current, cityDetail: false }));
}
@@ -256,15 +399,22 @@ export function DashboardStoreProvider({
};
const refreshAll = async () => {
const previousSelectedDetail = selectedCity
? cityDetailsByName[selectedCity]
: undefined;
dashboardClient.clearCityDetailCache();
setCityDetailsByName({});
setCityDetailMetaByName({});
if (selectedCity) {
setLoadingState((current) => ({ ...current, refresh: true }));
try {
const detail = await dashboardClient.getCityDetail(selectedCity, {
const latestDetail = await dashboardClient.getCityDetail(selectedCity, {
force: true,
});
const detail = mergeAiAnalysisIfStable(
previousSelectedDetail,
latestDetail,
);
setCityDetailsByName({ [selectedCity]: detail });
setCitySummariesByName((current) => ({
...current,
@@ -329,16 +479,75 @@ export function DashboardStoreProvider({
isGuideOpen,
loadCities,
loadingState,
openFutureModal: (dateStr: string) => {
openFutureModal: (dateStr: string, forceRefresh = false) => {
mapStopMotionRef.current();
setFutureModalDate(dateStr);
if (!selectedCity) return;
const cacheKey = getMarketScanCacheKey(selectedCity, dateStr);
setLoadingState((current) => ({ ...current, marketScan: true }));
void ensureCityMarketScan(
selectedCity,
forceRefresh || !marketScanByCityName[cacheKey],
null,
dateStr,
)
.catch(() => {})
.finally(() => {
setLoadingState((current) => ({ ...current, marketScan: false }));
});
},
openGuide: () => setIsGuideOpen(true),
openHistory,
openTodayModal: () => {
if (selectedDetail?.local_date) {
mapStopMotionRef.current();
setFutureModalDate(selectedDetail.local_date);
openTodayModal: async (forceRefresh?: boolean) => {
if (!selectedCity || loadingState.cityDetail) {
return;
}
mapStopMotionRef.current();
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 {
// 如果缓存里没有或者想要强制刷新,则拉取最新市场数据
const marketKey = getMarketScanCacheKey(
selectedCity,
detail.local_date,
);
await ensureCityMarketScan(
selectedCity,
forceRefresh || !marketScanByCityName[marketKey],
null,
detail.local_date,
);
} catch {}
} catch {
if (cachedDetail?.local_date) {
setFutureModalDate(cachedDetail.local_date);
}
} finally {
setLoadingState((current) => ({
...current,
refresh: false,
marketScan: false,
}));
}
},
registerMapStopMotion: (stopMotion: () => void) => {
@@ -346,12 +555,14 @@ export function DashboardStoreProvider({
},
refreshAll,
refreshSelectedCity,
selectedMarketScan,
selectedCity,
selectedDetail,
selectedForecastDate,
selectCity,
setForecastDate: (dateStr: string | null) =>
setSelectedForecastDate(dateStr),
marketScanByCityName,
}),
[
cities,
@@ -362,6 +573,8 @@ export function DashboardStoreProvider({
isPanelOpen,
isGuideOpen,
loadingState,
marketScanByCityName,
selectedMarketScan,
selectedCity,
selectedDetail,
selectedForecastDate,
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import {
formatMessage,
getInitialLocaleFromNavigator,
Locale,
LOCALE_STORAGE_KEY,
normalizeLocale,
} from "@/lib/i18n";
interface I18nContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
toggleLocale: () => void;
t: (key: string, params?: Record<string, string | number>) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [locale, setLocale] = useState<Locale>(() => {
if (typeof window === "undefined") {
return "zh-CN";
}
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
return stored ? normalizeLocale(stored) : getInitialLocaleFromNavigator();
});
useEffect(() => {
if (typeof window === "undefined") return;
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
document.documentElement.lang = locale;
}, [locale]);
const value = useMemo<I18nContextValue>(
() => ({
locale,
setLocale,
t: (key, params) => formatMessage(locale, key, params),
toggleLocale: () => {
setLocale((current) => (current === "zh-CN" ? "en-US" : "zh-CN"));
},
}),
[locale],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n() {
const context = useContext(I18nContext);
if (!context) {
throw new Error("useI18n must be used within I18nProvider");
}
return context;
}
+66 -63
View File
@@ -110,6 +110,7 @@ export function useLeafletMap({
const nearbyLayerRef = useRef<L.LayerGroup | null>(null);
const autoNearbyCityRef = useRef<string | null>(null);
const loadingAutoNearbyRef = useRef(false);
const handlingAutoNearbyRef = useRef(false);
const lastMovedCityRef = useRef<string | null>(null);
const suspendMotionRef = useRef(suspendMotion);
const hasFittedInitialBoundsRef = useRef(false);
@@ -335,77 +336,79 @@ export function useLeafletMap({
}
async function maybeAutoShowNearbyStations() {
if (suspendMotion) {
map.stop();
if (handlingAutoNearbyRef.current) {
return;
}
if (selectedDetail) {
// Just render stations, no camera move from here
renderNearbyStations(selectedDetail, true);
return;
}
// If no city selected, reset the move tracker
lastMovedCityRef.current = null;
if (map.getZoom() < AUTO_NEARBY_MIN_ZOOM) {
autoNearbyCityRef.current = null;
layer.clearLayers();
return;
}
const center = map.getCenter();
let best: { cityName: string; distance: number } | null = null;
for (const [cityName, entry] of Object.entries(markersRef.current)) {
const distance = map.distance(
center,
L.latLng(entry.city.lat, entry.city.lon),
);
if (distance > AUTO_NEARBY_MAX_DISTANCE_M) continue;
if (!best || distance < best.distance) {
best = { cityName, distance };
}
}
const targetCity = best?.cityName || null;
if (!targetCity) {
autoNearbyCityRef.current = null;
layer.clearLayers();
return;
}
if (
autoNearbyCityRef.current === targetCity &&
layer.getLayers().length > 0
) {
return;
}
autoNearbyCityRef.current = targetCity;
const cachedDetail = cityDetailsByName[targetCity];
if (cachedDetail) {
renderNearbyStations(cachedDetail, true);
return;
}
if (loadingAutoNearbyRef.current) return;
loadingAutoNearbyRef.current = true;
handlingAutoNearbyRef.current = true;
try {
const detail = await onEnsureCityDetailRef.current(targetCity, false);
renderNearbyStations(detail, true);
} catch {
if (selectedDetail) {
// Just render stations, no camera move from here
renderNearbyStations(selectedDetail, true);
return;
}
if (suspendMotion) {
return;
}
// If no city selected, reset the move tracker
lastMovedCityRef.current = null;
if (map.getZoom() < AUTO_NEARBY_MIN_ZOOM) {
autoNearbyCityRef.current = null;
layer.clearLayers();
return;
}
const center = map.getCenter();
let best: { cityName: string; distance: number } | null = null;
for (const [cityName, entry] of Object.entries(markersRef.current)) {
const distance = map.distance(
center,
L.latLng(entry.city.lat, entry.city.lon),
);
if (distance > AUTO_NEARBY_MAX_DISTANCE_M) continue;
if (!best || distance < best.distance) {
best = { cityName, distance };
}
}
const targetCity = best?.cityName || null;
if (!targetCity) {
autoNearbyCityRef.current = null;
layer.clearLayers();
return;
}
if (
autoNearbyCityRef.current === targetCity &&
layer.getLayers().length > 0
) {
return;
}
autoNearbyCityRef.current = targetCity;
const cachedDetail = cityDetailsByName[targetCity];
if (cachedDetail) {
renderNearbyStations(cachedDetail, true);
return;
}
if (loadingAutoNearbyRef.current) return;
loadingAutoNearbyRef.current = true;
try {
const detail = await onEnsureCityDetailRef.current(targetCity, false);
renderNearbyStations(detail, true);
} catch {
} finally {
loadingAutoNearbyRef.current = false;
}
} finally {
loadingAutoNearbyRef.current = false;
handlingAutoNearbyRef.current = false;
}
}
const syncVisibility = () => {
if (suspendMotion) {
map.stop();
return;
}
if (map.getZoom() < 7) {
if (map.hasLayer(layer)) {
map.removeLayer(layer);
+14
View File
@@ -0,0 +1,14 @@
export const BACKEND_ENTITLEMENT_HEADER = "x-polyweather-entitlement";
export function buildBackendRequestHeaders(): HeadersInit {
const headers: HeadersInit = {
Accept: "application/json",
};
const token = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim();
if (token) {
headers[BACKEND_ENTITLEMENT_HEADER] = token;
}
return headers;
}
+80 -10
View File
@@ -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<string, Promise<CityDetail>>();
const pendingHistoryRequests = new Map<string, Promise<HistoryPoint[]>>();
const pendingCitySummaryRequests = new Map<string, Promise<CitySummary>>();
const pendingMarketScanRequests = new Map<string, Promise<MarketScan | null>>();
type CityCacheMeta = {
cachedAt: number;
@@ -134,20 +136,88 @@ 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<CityDetail>(
`/api/city/${normalizeCityName(cityName)}?force_refresh=false`,
).finally(() => {
pendingCityDetailRequests.delete(requestKey);
});
pendingCityDetailRequests.set(requestKey, request);
return request;
}
const request = fetchJson<CityDetail>(
`/api/city/${normalizeCityName(cityName)}?force_refresh=${force}`,
).finally(() => {
pendingCityDetailRequests.delete(requestKey);
const params = new URLSearchParams({
force_refresh: "true",
_ts: String(Date.now()),
});
return fetchJson<CityDetail>(
`/api/city/${normalizeCityName(cityName)}?${params.toString()}`,
);
},
pendingCityDetailRequests.set(requestKey, request);
return request;
async getCityMarketScan(
cityName: string,
options?: {
force?: boolean;
marketSlug?: string | null;
targetDate?: string | null;
},
) {
const force = options?.force ?? false;
const marketSlug = options?.marketSlug || null;
const targetDate = options?.targetDate || null;
if (!force) {
const requestKey = `${cityName}::cached::${marketSlug || "-"}::${
targetDate || "-"
}`;
const existing = pendingMarketScanRequests.get(requestKey);
if (existing) {
return existing;
}
const params = new URLSearchParams({
force_refresh: "false",
});
if (marketSlug) {
params.set("market_slug", marketSlug);
}
if (targetDate) {
params.set("target_date", targetDate);
}
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);
}
if (targetDate) {
params.set("target_date", targetDate);
}
return fetchJson<{ market_scan?: MarketScan }>(
`/api/city/${normalizeCityName(cityName)}/detail?${params.toString()}`,
).then((data) => data.market_scan || null);
},
async getHistory(cityName: string) {
+67 -3
View File
@@ -168,9 +168,6 @@ export interface SourceForecasts {
weather_gov?: {
forecast_periods?: WeatherGovPeriod[];
};
meteoblue?: {
daily_highs?: Array<number | null>;
};
}
export interface DailyModelForecast {
@@ -181,6 +178,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<string, unknown>;
}
export interface AiAnalysisStructured {
summary?: string | null;
text?: string | null;
@@ -227,6 +289,7 @@ export interface CityDetail {
updated_at?: string;
multi_model_daily?: Record<string, DailyModelForecast>;
source_forecasts?: SourceForecasts;
market_scan?: MarketScan;
}
export interface HistoryPoint {
@@ -241,6 +304,7 @@ export interface LoadingState {
cityDetail: boolean;
refresh: boolean;
history: boolean;
marketScan?: boolean;
}
export interface HistoryState {
+409 -169
View File
@@ -1,55 +1,112 @@
import { AiAnalysisStructured, CityDetail, HistoryPoint, NearbyStation } from "@/lib/dashboard-types";
import { Locale } from "@/lib/i18n";
import {
AiAnalysisStructured,
CityDetail,
HistoryPoint,
NearbyStation,
} from "@/lib/dashboard-types";
const METAR_WX_MAP: Record<string, { label: string; icon: string }> = {
RA: { label: "降雨", icon: "🌧️" },
"-RA": { label: "小雨", icon: "🌦️" },
"+RA": { label: "强降雨", icon: "⛈️" },
SN: { label: "降雪", icon: "❄️" },
"-SN": { label: "小雪", icon: "🌨️" },
"+SN": { label: "大雪", icon: "🌨️" },
DZ: { label: "毛毛雨", icon: "🌦️" },
FG: { label: "", icon: "🌫️" },
BR: { label: "薄雾", icon: "🌫️" },
HZ: { label: "", icon: "🌫️" },
TS: { label: "雷暴", icon: "⛈️" },
VCTS: { label: "附近雷暴", icon: "⛈️" },
SQ: { label: "飑线", icon: "💨" },
GS: { label: "冰雹", icon: "🌨️" },
const METAR_WX_MAP: Record<
string,
{ en: string; icon: string; zh: string }
> = {
RA: { en: "Rain", icon: "🌧️", zh: "降雨" },
"-RA": { en: "Light rain", icon: "🌦️", zh: "小雨" },
"+RA": { en: "Heavy rain", icon: "⛈️", zh: "强降雨" },
SN: { en: "Snow", icon: "❄️", zh: "降雪" },
"-SN": { en: "Light snow", icon: "🌨️", zh: "小雪" },
"+SN": { en: "Heavy snow", icon: "🌨️", zh: "大雪" },
DZ: { en: "Drizzle", icon: "🌦️", zh: "毛毛雨" },
FG: { en: "Fog", icon: "🌫️", zh: "雾" },
BR: { en: "Mist", icon: "🌫️", zh: "薄雾" },
HZ: { en: "Haze", icon: "🌫️", zh: "霾" },
TS: { en: "Thunderstorm", icon: "⛈️", zh: "雷暴" },
VCTS: { en: "Nearby thunderstorm", icon: "⛈️", zh: "附近雷暴" },
SQ: { en: "Squall", icon: "💨", zh: "飑线" },
GS: { en: "Hail", icon: "🌨️", zh: "冰雹" },
};
export function translateMetar(code?: string | null) {
if (!code) return null;
for (const [key, value] of Object.entries(METAR_WX_MAP)) {
if (String(code).includes(key)) return value;
}
return { label: code, icon: "🌤️" };
function isEnglish(locale: Locale) {
return locale === "en-US";
}
export function getRiskBadgeLabel(level?: string | null) {
function normalizeCloudSummary(
cloudDesc: string | null | undefined,
locale: Locale,
): { icon: string; text: string } {
const raw = String(cloudDesc || "").trim();
if (!raw) {
return { icon: "🔍", text: isEnglish(locale) ? "Unknown" : "未知" };
}
const lower = raw.toLowerCase();
if (
raw.includes("晴") ||
raw.includes("晴朗") ||
lower.includes("clear") ||
lower.includes("sunny")
) {
return { icon: "☀️", text: isEnglish(locale) ? "Clear" : "晴朗" };
}
if (raw.includes("阴") || lower.includes("overcast")) {
return { icon: "☁️", text: isEnglish(locale) ? "Overcast" : "阴天" };
}
if (raw.includes("多云") || lower.includes("cloud")) {
return { icon: "☁️", text: isEnglish(locale) ? "Cloudy" : "多云" };
}
if (raw.includes("少云") || lower.includes("few")) {
return { icon: "🌤️", text: isEnglish(locale) ? "Mostly clear" : "少云" };
}
if (raw.includes("散云") || lower.includes("scattered")) {
return { icon: "⛅", text: isEnglish(locale) ? "Partly cloudy" : "散云" };
}
return { icon: "🔍", text: raw };
}
export function translateMetar(code?: string | null, locale: Locale = "zh-CN") {
if (!code) return null;
const metarCode = String(code);
for (const [key, value] of Object.entries(METAR_WX_MAP)) {
if (metarCode.includes(key)) {
return {
icon: value.icon,
label: isEnglish(locale) ? value.en : value.zh,
};
}
}
return { icon: "🔍", label: metarCode };
}
export function getRiskBadgeLabel(
level?: string | null,
locale: Locale = "zh-CN",
) {
if (isEnglish(locale)) {
return (
{
high: "🔴 High Risk",
low: "🟢 Low Risk",
medium: "🟠 Medium Risk",
}[String(level || "low")] || "Unknown Risk"
);
}
return (
{
high: "🔴 高风险",
medium: "🟠 中风险",
low: "🟢 低风险",
medium: "🟠 中风险",
}[String(level || "low")] || "未知风险"
);
}
export function getWeatherSummary(detail: CityDetail) {
export function getWeatherSummary(detail: CityDetail, locale: Locale = "zh-CN") {
const current = detail.current || {};
let weatherText = current.cloud_desc || "未知";
let weatherIcon =
{
: "☁️",
: "☁️",
: "🌤️",
: "⛅",
: "☀️",
: "☀️",
}[String(current.cloud_desc || "")] || "🌤️";
const cloud = normalizeCloudSummary(current.cloud_desc, locale);
let weatherText = cloud.text;
let weatherIcon = cloud.icon;
if (current.wx_desc) {
const translated = translateMetar(current.wx_desc);
const translated = translateMetar(current.wx_desc, locale);
if (translated) {
weatherText = translated.label;
weatherIcon = translated.icon;
@@ -59,25 +116,28 @@ export function getWeatherSummary(detail: CityDetail) {
return { weatherIcon, weatherText };
}
export function getHeroMetaItems(detail: CityDetail) {
export function getHeroMetaItems(detail: CityDetail, locale: Locale = "zh-CN") {
const current = detail.current || {};
const parts: string[] = [];
if (current.obs_time) {
const ageText =
current.obs_age_min != null && current.obs_age_min >= 30
? `${current.obs_age_min} 分钟前)`
? isEnglish(locale)
? ` (${current.obs_age_min} min ago)`
: `${current.obs_age_min} 分钟前)`
: "";
parts.push(`✈️ METAR ${current.obs_time}${ageText}`);
}
if (current.wx_desc) {
const translated = translateMetar(current.wx_desc);
const translated = translateMetar(current.wx_desc, locale);
if (translated) {
parts.push(`${translated.icon} ${translated.label}`);
}
} else if (current.cloud_desc) {
parts.push(`☁️ ${current.cloud_desc}`);
const cloud = normalizeCloudSummary(current.cloud_desc, locale);
parts.push(`${cloud.icon} ${cloud.text}`);
}
if (current.wind_speed_kt != null) {
@@ -91,26 +151,40 @@ export function getHeroMetaItems(detail: CityDetail) {
if (detail.mgm?.temp != null) {
const timeMatch = detail.mgm.time?.match(/T?(\d{2}:\d{2})/);
const timeText = timeMatch ? ` @${timeMatch[1]}` : "";
parts.push(`📡 MGM 实测: ${detail.mgm.temp}${detail.temp_symbol}${timeText}`);
parts.push(
isEnglish(locale)
? `🛰 MGM Obs: ${detail.mgm.temp}${detail.temp_symbol}${timeText}`
: `🛰 MGM 实测: ${detail.mgm.temp}${detail.temp_symbol}${timeText}`,
);
}
const trend = detail.trend || {};
if (trend.is_dead_market) {
parts.push("☠️ 死盘");
parts.push(isEnglish(locale) ? "☠️ Flat market" : "☠️ 死盘");
} else if (trend.direction && trend.direction !== "unknown") {
const labels: Record<string, string> = {
rising: "📈 升温中",
falling: "📉 降温中",
stagnant: "⏸️ 持平",
mixed: "📊 波动中",
};
const labels: Record<string, string> = isEnglish(locale)
? {
falling: "📉 Cooling",
mixed: "📊 Choppy",
rising: "📈 Warming",
stagnant: "⏸ Flat",
}
: {
falling: "📉 降温中",
mixed: "📊 波动中",
rising: "📈 升温中",
stagnant: "⏸ 持平",
};
parts.push(labels[trend.direction] || trend.direction);
}
return parts;
}
export function getTemperatureChartData(detail: CityDetail) {
export function getTemperatureChartData(
detail: CityDetail,
locale: Locale = "zh-CN",
) {
const hourly = detail.hourly || {};
const times = hourly.times || [];
const temps = hourly.temps || [];
@@ -199,10 +273,18 @@ export function getTemperatureChartData(detail: CityDetail) {
}
if (!hasMgmHourly && debMax != null && omMax != null && Math.abs(offset) > 0.3) {
const sign = offset > 0 ? "+" : "";
legendParts.push(`DEB 偏移 ${sign}${offset.toFixed(1)}${detail.temp_symbol} vs OM`);
legendParts.push(
isEnglish(locale)
? `DEB offset ${sign}${offset.toFixed(1)}${detail.temp_symbol} vs OM`
: `DEB 偏移 ${sign}${offset.toFixed(1)}${detail.temp_symbol} vs OM`,
);
}
if (hasMgmHourly) {
legendParts.push("已使用 MGM 小时预报替代 DEB 曲线");
legendParts.push(
isEnglish(locale)
? "Using MGM hourly forecast to replace DEB curve"
: "已使用 MGM 小时预报替代 DEB 曲线",
);
}
if (detail.trend?.recent?.length) {
const recentText = [...detail.trend.recent]
@@ -354,7 +436,17 @@ function trendBucketFromDir(direction?: number | null) {
return "westerly";
}
function bucketLabel(bucket: string | null) {
function bucketLabel(bucket: string | null, locale: Locale = "zh-CN") {
if (isEnglish(locale)) {
return (
{
southerly: "S / SW wind",
northerly: "N / NW wind",
easterly: "E wind",
westerly: "W wind",
}[bucket || ""] || "Unknown wind direction"
);
}
return (
{
southerly: "南 / 西南风",
@@ -365,6 +457,14 @@ function bucketLabel(bucket: string | null) {
);
}
export function wuRound(value: number | null | undefined) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
return numeric >= 0
? Math.floor(numeric + 0.5)
: Math.ceil(numeric - 0.5);
}
export function formatDelta(value: number | null | undefined, suffix = "") {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return "--";
@@ -379,7 +479,11 @@ function getForecastTextForDate(detail: CityDetail, dateStr: string) {
);
}
export function computeFrontTrendSignal(detail: CityDetail, dateStr: string) {
export function computeFrontTrendSignal(
detail: CityDetail,
dateStr: string,
locale: Locale = "zh-CN",
) {
const slice = getFutureSlice(detail, dateStr);
const currentTemp = Number(detail.current?.temp);
const currentDew = Number(detail.current?.dewpoint);
@@ -387,7 +491,7 @@ export function computeFrontTrendSignal(detail: CityDetail, dateStr: string) {
if (!slice.length) {
return {
confidence: "low",
label: "监控中",
label: isEnglish(locale) ? "Monitoring" : "监控中",
metrics: [] as Array<{
label: string;
note: string;
@@ -396,7 +500,9 @@ export function computeFrontTrendSignal(detail: CityDetail, dateStr: string) {
}>,
precipMax: 0,
score: 0,
summary: "未来 48 小时结构化数据不足,暂时只保留基础监控。",
summary: isEnglish(locale)
? "Insufficient 48h structured data. Keep baseline monitoring."
: "未来 48 小时结构化数据不足,暂时只保留基础监控。",
weatherGovPeriods: [] as ReturnType<typeof getForecastTextForDate>,
};
}
@@ -480,12 +586,14 @@ export function computeFrontTrendSignal(detail: CityDetail, dateStr: string) {
}
const score = Math.max(-100, Math.min(100, warmScore - coldScore));
const label =
score >= 18
? "暖平流 / 暖锋倾向"
: score <= -18
? "冷平流 / 冷锋倾向"
: "监控中";
const warmLabel = isEnglish(locale)
? "Warm advection / warm-front tendency"
: "暖平流 / 暖锋倾向";
const coldLabel = isEnglish(locale)
? "Cold advection / cold-front tendency"
: "冷平流 / 冷锋倾向";
const monitorLabel = isEnglish(locale) ? "Monitoring" : "监控中";
const label = score >= 18 ? warmLabel : score <= -18 ? coldLabel : monitorLabel;
const confidence =
Math.abs(score) >= 45 ? "high" : Math.abs(score) >= 22 ? "medium" : "low";
@@ -494,37 +602,47 @@ export function computeFrontTrendSignal(detail: CityDetail, dateStr: string) {
label,
metrics: [
{
label: "温度变化",
note: "Open-Meteo 未来小时温度变化",
label: isEnglish(locale) ? "Temperature delta" : "温度变化",
note: isEnglish(locale)
? "Open-Meteo upcoming hourly temperature change"
: "Open-Meteo 未来小时温度变化",
tone: tempDelta >= 0.8 ? "warm" : tempDelta <= -0.8 ? "cold" : "",
value: formatDelta(tempDelta, detail.temp_symbol),
},
{
label: "露点变化",
note: "露点上升更偏向暖湿平流",
label: isEnglish(locale) ? "Dew point delta" : "露点变化",
note: isEnglish(locale)
? "Rising dew point often supports warm/wet advection"
: "露点上升更偏向暖湿平流",
tone: dewDelta >= 0.8 ? "warm" : dewDelta <= -0.8 ? "cold" : "",
value: formatDelta(dewDelta, detail.temp_symbol),
},
{
label: "气压变化",
note: "气压回升更偏向冷空气压入",
label: isEnglish(locale) ? "Pressure delta" : "气压变化",
note: isEnglish(locale)
? "Pressure rebound usually implies cold-air push"
: "气压回升更偏向冷空气压入",
tone: pressureDelta >= 1 ? "cold" : pressureDelta <= -1 ? "warm" : "",
value: formatDelta(pressureDelta, " hPa"),
},
{
label: "风向演变",
note: "关注是否转南风或转北风",
value: `${bucketLabel(firstBucket)} -> ${bucketLabel(lastBucket)}`,
label: isEnglish(locale) ? "Wind-direction evolution" : "风向演变",
note: isEnglish(locale)
? "Focus on switch to southerly or northerly flow"
: "关注是否转南风或转北风",
value: `${bucketLabel(firstBucket, locale)} -> ${bucketLabel(lastBucket, locale)}`,
},
{
label: "降水概率",
note: "weather.gov / Open-Meteo 降水提示",
label: isEnglish(locale) ? "Precip probability" : "降水概率",
note: "weather.gov / Open-Meteo",
tone: precipMax >= 50 ? "cold" : "",
value: `${Math.round(precipMax)}%`,
},
{
label: "云量变化",
note: "云量抬升但未降温,常见于暖平流前段",
label: isEnglish(locale) ? "Cloud-cover delta" : "云量变化",
note: isEnglish(locale)
? "Cloud increase without cooling may imply warm advection"
: "云量抬升但未降温,常见于暖平流前段",
tone:
cloudDelta >= 15 && tempDelta >= 0
? "warm"
@@ -537,18 +655,26 @@ export function computeFrontTrendSignal(detail: CityDetail, dateStr: string) {
precipMax,
score,
summary:
label === "暖平流 / 暖锋倾向"
? "风向更偏南 / 西南,露点与温度整体抬升,未来 6-48 小时偏向暖平流。"
: label === "冷平流 / 冷锋倾向"
? "温度下滑、气压回升或风向转北,未来 6-48 小时更像冷锋或冷平流压制。"
: detail.name !== "ankara" && Boolean(detail.source_forecasts?.meteoblue)
? "结构化来源以 weather.gov、Open-Meteo、Meteoblue 为主,用于判断未来 6-48 小时冷暖平流趋势。"
: "结构化来源以 weather.gov 与 Open-Meteo 为主,用于判断未来 6-48 小时冷暖平流趋势。",
label === warmLabel
? isEnglish(locale)
? "Southerly flow strengthens with rising dew point and temperature. Next 6-48h leans warm advection."
: "风向更偏南 / 西南,露点与温度整体抬升,未来 6-48 小时偏向暖平流。"
: label === coldLabel
? isEnglish(locale)
? "Temperature declines with pressure rebound and/or northerly shift. Next 6-48h leans cold-front suppression."
: "温度下滑、气压回升或风向转北,未来 6-48 小时更像冷锋或冷平流压制。"
: isEnglish(locale)
? "Structured trend layer mainly uses weather.gov and Open-Meteo for 6-48h warm/cold flow judgement."
: "结构化来源以 weather.gov 和 Open-Meteo 为主,用于判断未来 6-48 小时冷暖平流趋势。",
weatherGovPeriods,
};
}
export function getFutureModalView(detail: CityDetail, dateStr: string) {
export function getFutureModalView(
detail: CityDetail,
dateStr: string,
locale: Locale = "zh-CN",
) {
const forecastEntry =
detail.forecast?.daily?.find((item) => item.date === dateStr) || null;
const dailyModel = detail.multi_model_daily?.[dateStr] || {};
@@ -571,7 +697,7 @@ export function getFutureModalView(detail: CityDetail, dateStr: string) {
return {
deb,
forecastEntry,
front: computeFrontTrendSignal(detail, dateStr),
front: computeFrontTrendSignal(detail, dateStr, locale),
models: dailyModel.models || {},
mu: Number.isFinite(Number(mu)) ? Number(mu) : null,
probabilities,
@@ -579,7 +705,11 @@ export function getFutureModalView(detail: CityDetail, dateStr: string) {
};
}
export function getShortTermNowcastLines(detail: CityDetail, dateStr: string) {
export function getShortTermNowcastLines(
detail: CityDetail,
dateStr: string,
locale: Locale = "zh-CN",
) {
const slice = getFutureSlice(detail, dateStr);
if (dateStr !== detail.local_date) {
const afternoon = slice.filter((point) => {
@@ -589,8 +719,13 @@ export function getShortTermNowcastLines(detail: CityDetail, dateStr: string) {
const target = afternoon.length ? afternoon : slice;
if (!target.length) {
return [
["目标日期", dateStr],
["峰值窗口", "暂无足够的小时级 forecast 数据,无法生成目标日午后峰值窗口判断。"],
[isEnglish(locale) ? "Target date" : "目标日期", dateStr],
[
isEnglish(locale) ? "Peak window" : "峰值窗口",
isEnglish(locale)
? "No sufficient hourly forecast data for target-day peak-window diagnostics."
: "暂无足够的小时级 forecast 数据,无法生成目标日午后峰值窗口判断。",
],
] as const;
}
@@ -625,23 +760,45 @@ export function getShortTermNowcastLines(detail: CityDetail, dateStr: string) {
const maxCloud = cloudValues.length ? Math.max(...cloudValues) : 0;
return [
["目标日期", dateStr],
["峰值窗口", `${start.label} - ${end.label}(优先取 12:00-18:00`],
[isEnglish(locale) ? "Target date" : "目标日期", dateStr],
[
"峰值预估",
isEnglish(locale) ? "Peak window" : "峰值窗口",
isEnglish(locale)
? `${start.label} - ${end.label} (prefer 12:00-18:00)`
: `${start.label} - ${end.label}(优先取 12:00-18:00`,
],
[
isEnglish(locale) ? "Peak estimate" : "峰值预估",
`${Number.isFinite(Number(peakPoint.temp)) ? Number(peakPoint.temp).toFixed(1) : "--"}${detail.temp_symbol} @ ${peakPoint.label || "--"}`,
],
[
"窗口温度",
`${Number.isFinite(startTemp) ? startTemp.toFixed(1) : "--"}${detail.temp_symbol} -> ${Number.isFinite(endTemp) ? endTemp.toFixed(1) : "--"}${detail.temp_symbol}${formatDelta(endTemp - startTemp, detail.temp_symbol)}`,
isEnglish(locale) ? "Window temperature" : "窗口温度",
`${Number.isFinite(startTemp) ? startTemp.toFixed(1) : "--"}${detail.temp_symbol} -> ${Number.isFinite(endTemp) ? endTemp.toFixed(1) : "--"}${detail.temp_symbol} (${formatDelta(endTemp - startTemp, detail.temp_symbol)})`,
],
["露点变化", `${formatDelta(endDew - startDew, detail.temp_symbol)},用于判断午后暖湿输送是否增强。`],
[
"风向演变",
`${bucketLabel(trendBucketFromDir(start.windDir))} -> ${bucketLabel(trendBucketFromDir(end.windDir))},关注峰值前后是否转南风或回摆北风。`,
isEnglish(locale) ? "Dew-point delta" : "露点变化",
isEnglish(locale)
? `${formatDelta(endDew - startDew, detail.temp_symbol)} for diagnosing warm/wet transport in afternoon.`
: `${formatDelta(endDew - startDew, detail.temp_symbol)},用于判断午后暖湿输送是否增强。`,
],
[
isEnglish(locale) ? "Wind shift" : "风向演变",
isEnglish(locale)
? `${bucketLabel(trendBucketFromDir(start.windDir), locale)} -> ${bucketLabel(trendBucketFromDir(end.windDir), locale)} around peak window.`
: `${bucketLabel(trendBucketFromDir(start.windDir), locale)} -> ${bucketLabel(trendBucketFromDir(end.windDir), locale)},关注峰值前后是否转南风或回摆北风。`,
],
[
isEnglish(locale) ? "Pressure delta" : "气压变化",
isEnglish(locale)
? `${formatDelta(endPressure - startPressure, " hPa")} (higher pressure usually favors cold-air push).`
: `${formatDelta(endPressure - startPressure, " hPa")},上升更偏向冷空气压入。`,
],
[
isEnglish(locale) ? "Precip / cloud" : "降水 / 云量",
isEnglish(locale)
? `${Math.round(maxPrecip)}% / ${Math.round(maxCloud)}% for cloud-suppression judgement around peak hours.`
: `${Math.round(maxPrecip)}% / ${Math.round(maxCloud)}%,用于判断峰值时段是否受云系压制。`,
],
["气压变化", `${formatDelta(endPressure - startPressure, " hPa")},上升更偏向冷空气压入。`],
["降水 / 云量", `${Math.round(maxPrecip)}% / ${Math.round(maxCloud)}%,用于判断峰值时段是否受云系压制。`],
] as const;
}
@@ -649,7 +806,14 @@ export function getShortTermNowcastLines(detail: CityDetail, dateStr: string) {
? detail.metar_recent_obs.slice(-4)
: [];
const nearby = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby : [];
const sourceLabel = detail.name === "ankara" ? "MGM 周边站" : "METAR 周边站";
const sourceLabel =
detail.name === "ankara"
? isEnglish(locale)
? "MGM nearby stations"
: "MGM 周边站"
: isEnglish(locale)
? "METAR nearby stations"
: "METAR 周边站";
const currentTemp = Number(detail.current?.temp);
const recentTemps = recent
.map((point) => Number(point.temp))
@@ -668,25 +832,55 @@ export function getShortTermNowcastLines(detail: CityDetail, dateStr: string) {
if (!nearbyLead || Math.abs(diff) > Math.abs(nearbyLead.diff)) {
nearbyLead = {
diff,
name: station.name || station.icao || "周边站",
name:
station.name ||
station.icao ||
(isEnglish(locale) ? "Nearby station" : "周边站"),
temp,
};
}
}
const rows: Array<readonly [string, string]> = [
["当前主站", `${detail.current?.temp ?? "--"}${detail.temp_symbol} @ ${detail.current?.obs_time || "--"}`],
["原始 METAR", detail.current?.raw_metar || "暂无"],
["近 0-2 小时", `${formatDelta(shortDelta, detail.temp_symbol)},依据最近 METAR 序列判断短时动量。`],
[sourceLabel, `${nearby.length} 个站点参与邻近监控。`],
[
isEnglish(locale) ? "Primary station" : "当前主站",
`${detail.current?.temp ?? "--"}${detail.temp_symbol} @ ${detail.current?.obs_time || "--"}`,
],
[
isEnglish(locale) ? "Raw METAR" : "原始 METAR",
detail.current?.raw_metar || (isEnglish(locale) ? "N/A" : "暂无"),
],
[
isEnglish(locale) ? "Next 0-2h" : "近 0-2 小时",
isEnglish(locale)
? `${formatDelta(shortDelta, detail.temp_symbol)} based on latest METAR sequence short-term momentum.`
: `${formatDelta(shortDelta, detail.temp_symbol)},依据最近 METAR 序列判断短时动量。`,
],
[
sourceLabel,
isEnglish(locale)
? `${nearby.length} stations joined the nearby scan.`
: `${nearby.length} 个站点参与邻近监控。`,
],
];
if (nearbyLead) {
const tone =
nearbyLead.diff > 0 ? "偏暖" : nearbyLead.diff < 0 ? "偏冷" : "持平";
const tone = isEnglish(locale)
? nearbyLead.diff > 0
? "warmer"
: nearbyLead.diff < 0
? "cooler"
: "flat"
: nearbyLead.diff > 0
? "偏暖"
: nearbyLead.diff < 0
? "偏冷"
: "持平";
rows.push([
"领先站",
`${nearbyLead.name} ${nearbyLead.temp}${detail.temp_symbol},相对主站 ${formatDelta(nearbyLead.diff, detail.temp_symbol)}${tone})。`,
isEnglish(locale) ? "Leading station" : "领先站",
isEnglish(locale)
? `${nearbyLead.name} ${nearbyLead.temp}${detail.temp_symbol}, relative to primary station ${formatDelta(nearbyLead.diff, detail.temp_symbol)} (${tone}).`
: `${nearbyLead.name} ${nearbyLead.temp}${detail.temp_symbol},相对主站 ${formatDelta(nearbyLead.diff, detail.temp_symbol)}${tone})。`,
]);
}
@@ -719,7 +913,7 @@ export function getHistorySummary(
settledData.forEach((row) => {
if (row.actual != null && row.deb != null) {
debErrors.push(Math.abs(row.actual - row.deb));
if (Math.round(row.actual) === Math.round(row.deb)) {
if (wuRound(row.actual) === wuRound(row.deb)) {
hits += 1;
}
}
@@ -745,136 +939,182 @@ export function getHistorySummary(
};
}
export function getCityProfileStats(detail: CityDetail) {
export function getCityProfileStats(detail: CityDetail, locale: Locale = "zh-CN") {
const risk = detail.risk || {};
const current = detail.current || {};
const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0;
return [
{
label: "结算机场",
value: risk.airport && risk.icao ? `${risk.airport} (${risk.icao})` : "暂无档案",
label: isEnglish(locale) ? "Settlement airport" : "结算机场",
value:
risk.airport && risk.icao
? `${risk.airport} (${risk.icao})`
: isEnglish(locale)
? "No profile"
: "暂无档案",
},
{
label: "站点距离",
label: isEnglish(locale) ? "Station distance" : "站点距离",
value:
risk.distance_km != null && Number.isFinite(Number(risk.distance_km))
? `${risk.distance_km} km`
: "未标注",
: isEnglish(locale)
? "Not marked"
: "未标注",
},
{
label: "观测更新",
value: current.obs_time || detail.updated_at || "未提供",
label: isEnglish(locale) ? "Observation update" : "观测更新",
value:
current.obs_time ||
detail.updated_at ||
(isEnglish(locale) ? "Unavailable" : "未提供"),
},
{
label: "周边站点",
value: nearbyCount > 0 ? `${nearbyCount} 个参与监控` : "暂无周边站",
label: isEnglish(locale) ? "Nearby stations" : "周边站点",
value:
nearbyCount > 0
? isEnglish(locale)
? `${nearbyCount} participating stations`
: `${nearbyCount} 个参与监控`
: isEnglish(locale)
? "No nearby stations"
: "暂无周边站",
},
];
}
export function getSettlementRiskNarrative(detail: CityDetail) {
export function getSettlementRiskNarrative(
detail: CityDetail,
locale: Locale = "zh-CN",
) {
const risk = detail.risk || {};
const lines: string[] = [];
if (risk.warning) {
lines.push(`当前主要风险是:${risk.warning}`);
lines.push(
isEnglish(locale)
? `Current key risk: ${risk.warning}`
: `当前主要风险是:${risk.warning}`,
);
}
if (risk.distance_km != null) {
if (risk.distance_km >= 60) {
lines.push("结算机场与城市核心区域距离偏大,盘面温度与结算值可能出现明显背离。");
lines.push(
isEnglish(locale)
? "Settlement airport is far from urban core; market feel and settlement value may diverge significantly."
: "结算机场与城市核心区域距离偏大,盘面温度与结算值可能出现明显背离。",
);
} else if (risk.distance_km >= 25) {
lines.push("结算机场与城区存在可感知距离,午后峰值和夜间降温节奏需要优先看机场站。");
lines.push(
isEnglish(locale)
? "Settlement airport has material distance from downtown; peak/overnight rhythm should prioritize airport station."
: "结算机场与城区存在可感知距离,午后峰值和夜间降温节奏需要优先看机场站。",
);
} else {
lines.push("结算机场距离较近,城市体感与结算温度通常更同步。");
lines.push(
isEnglish(locale)
? "Settlement airport is close enough; city feel and settlement temperature are usually more synchronized."
: "结算机场距离较近,城市体感与结算温度通常更同步。",
);
}
}
if (detail.name === "ankara") {
lines.push("Ankara 需要重点看 LTAC / Esenboğa 与 MGM 周边站联动,不能只看城区体感。");
lines.push(
isEnglish(locale)
? "For Ankara, focus on LTAC / Esenboğa plus MGM nearby-station linkage, not urban sensation alone."
: "Ankara 需要重点看 LTAC / Esenboğa 与 MGM 周边站联动,不能只看城区体感。",
);
}
if (detail.current?.obs_age_min != null) {
if (detail.current.obs_age_min >= 45) {
lines.push(`当前 METAR 已有 ${detail.current.obs_age_min} 分钟时滞,临近判断要结合周边站而不是只看主站快照。`);
lines.push(
isEnglish(locale)
? `Current METAR is ${detail.current.obs_age_min} minutes old. Blend nearby stations for nowcast instead of single-station snapshot.`
: `当前 METAR 已有 ${detail.current.obs_age_min} 分钟时滞,临近判断要结合周边站而不是只看主站快照。`,
);
} else {
lines.push("当前主站观测较新,短时判断可以把主站温度作为主要锚点。");
lines.push(
isEnglish(locale)
? "Primary station observation is fresh enough; short-term judgement can anchor on it."
: "当前主站观测较新,短时判断可以把主站温度作为主要锚点。",
);
}
}
return lines;
}
export function getClimateDrivers(detail: CityDetail) {
export function getClimateDrivers(detail: CityDetail, locale: Locale = "zh-CN") {
const drivers: Array<{ label: string; text: string }> = [];
const lat = Math.abs(Number(detail.lat));
const current = detail.current || {};
const temp = Number(current.temp);
const dewPoint = Number(current.dewpoint);
const humidity = Number(current.humidity);
const windSpeed = Number(current.wind_speed_kt);
const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0;
const nearbyCount = Array.isArray(detail.mgm_nearby)
? detail.mgm_nearby.length
: 0;
const distanceKm = Number(detail.risk?.distance_km);
if (lat >= 50) {
drivers.push({
label: "高纬冷空气",
text: "这座城市处在较高纬度,气温更容易受冷空气南下、短波槽和日照角度变化影响,波动通常偏快。",
label: isEnglish(locale) ? "High-latitude cold air" : "高纬冷空气",
text: isEnglish(locale)
? "At higher latitude, temperature rhythm is more affected by cold-air surges, trough passage, and seasonal radiation angle."
: "该城市位于较高纬度,温度变化更容易受到冷空气南下、短波槽和日照角度变化影响。",
});
} else if (lat >= 35) {
drivers.push({
label: "中纬西风带",
text: "这座城市主要受中纬度西风带和锋面活动控制,升温或降温往往来自气团切换,而不是单一的日照变化。",
label: isEnglish(locale) ? "Mid-latitude westerlies" : "中纬西风带",
text: isEnglish(locale)
? "Temperature shifts are often controlled by frontal transitions rather than pure daytime radiation."
: "该城市主要受中纬西风带和锋面活动控制,升降温常来自气团切换,而不是单一日照变化。",
});
} else if (lat >= 20) {
drivers.push({
label: "副热带高压",
text: "这座城市更容易受到副热带高压、晴空辐射和低层暖平流影响,午后冲高能力通常比高纬城市更强。",
label: isEnglish(locale) ? "Subtropical highs" : "副热带高压",
text: isEnglish(locale)
? "Subtropical ridge, clear-sky radiation and low-level warm advection often dominate warming efficiency."
: "该城市更容易受副热带高压、晴空辐射和低层暖平流影响,午后增温能力通常更强。",
});
} else {
drivers.push({
label: "热带水汽与对流",
text: "这座城市更偏热带环境,温度与体感常受水汽输送、云对流和阵雨触发影响,不完全由晴空辐射主导。",
label: isEnglish(locale) ? "Tropical moisture & convection" : "热带水汽与对流",
text: isEnglish(locale)
? "Temperature and feels-like are often modulated by moisture transport, cloud convection and showers."
: "该城市偏热带环境,温度与体感常受水汽输送、云对流和阵雨触发影响。",
});
}
if (Number.isFinite(windSpeed) && windSpeed >= 12) {
drivers.push({
label: "平流输送",
text: `当前风速约 ${windSpeed}kt,说明低层输送比较明显,盘面短时方向更容易被外来气团带动。`,
});
} else if (detail.trend?.is_dead_market) {
drivers.push({
label: "本地辐射主导",
text: "近期更像本地辐射和地表热量收支在主导,若无新气团介入,温度节奏通常更平滑。",
});
}
drivers.push({
label: isEnglish(locale) ? "Dry-wet boundary layer" : "干湿边界层",
text: isEnglish(locale)
? "Boundary-layer humidity controls daytime warming efficiency; dry boundary warms faster, wet boundary is more cloud/precip-sensitive."
: "低层干湿状态会决定午后升温效率。干空气通常升温更快,湿空气更容易受云量和降水过程抑制。",
});
if (
Number.isFinite(temp) &&
Number.isFinite(dewPoint) &&
temp - dewPoint <= 3
) {
drivers.push({
label: isEnglish(locale) ? "Advection transport" : "平流输送",
text: isEnglish(locale)
? "Short-term trend is usually driven by low-level air-mass transport. Persistent wind origin tends to sustain thermal direction."
: "短时趋势常由低层气团输送控制。若风向持续来自同一侧,温度通常更容易沿该方向延续。",
});
if (Number.isFinite(distanceKm) && distanceKm >= 25) {
drivers.push({
label: "湿度与云量约束",
text: "当前温度和露点接近,说明低层湿度较高。午后峰值容易受云量和降水触发抑制。",
});
} else if (Number.isFinite(humidity) && humidity >= 70) {
drivers.push({
label: "湿层偏厚",
text: "相对湿度偏高,说明局地升温效率会受到水汽和云层反馈影响,冲高空间要比干空气场景更小心。",
});
} else {
drivers.push({
label: "干暖边界层",
text: "低层空气相对偏干,晴空时段的升温效率通常更高,午后冲顶更依赖辐射和风向切换。",
label: isEnglish(locale) ? "Station representativeness" : "站点代表性",
text: isEnglish(locale)
? "When settlement station is not near city core, perceived temperature and settlement value may diverge."
: "结算站与城市核心区存在一定距离时,体感温度和结算温度可能分离,评估时应优先以结算站观测为准。",
});
}
if (nearbyCount >= 4) {
drivers.push({
label: "局地差异",
text: "周边可用站点较多,说明地形、城区热岛或下垫面差异可能明显,结算站与城区体感需要分开看。",
label: isEnglish(locale) ? "Local heterogeneity" : "局地差异",
text: isEnglish(locale)
? "More nearby stations suggest terrain/urban-heat heterogeneity; settlement station and downtown sensation should be evaluated separately."
: "周边可用站点较多,说明地形、城区热岛或下垫面差异可能明显,结算站与城区体感需要分开评估。",
});
}
+247
View File
@@ -0,0 +1,247 @@
export type Locale = "zh-CN" | "en-US";
type MessageParams = Record<string, string | number>;
const DEFAULT_LOCALE: Locale = "zh-CN";
export const LOCALE_STORAGE_KEY = "polyweather.locale";
const MESSAGES: Record<Locale, Record<string, string>> = {
"zh-CN": {
"header.subtitle": "天气衍生品智能分析",
"header.info": "技术说明",
"header.infoAria": "查看系统技术说明",
"header.live": "实时",
"header.refreshAria": "刷新所有数据",
"header.langAria": "切换语言",
"header.langZh": "中文",
"header.langEn": "EN",
"sidebar.title": "监控城市",
"sidebar.peakAt": "峰值 @ {time}",
"sidebar.group.high": "高风险",
"sidebar.group.medium": "中风险",
"sidebar.group.low": "低风险",
"sidebar.group.other": "其他",
"dashboard.loading": "正在获取气象数据,请稍候...",
"detail.closeAria": "关闭城市详情面板",
"detail.waitSelect": "等待选择城市",
"detail.todayAnalysis": "今日日内分析",
"detail.history": "历史对账",
"detail.loading": "正在加载城市详情...",
"detail.emptyHint": "从左侧城市列表选择一个城市查看详情。",
"detail.sceneryAlt": "{city} 风景照",
"detail.sceneryTitle": "城市风景与微气候",
"detail.sceneryFallback":
"当前没有匹配到风景图,可从下方城市档案查看站点与观测结构。",
"detail.profile": "城市档案",
"detail.todayMiniTrend": "今日日内走势(简版)",
"detail.chartLegendEmpty": "暂无小时级实测或预测曲线。",
"forecast.title": "多日预报",
"forecast.empty": "暂无多日预报",
"forecast.today": "今天",
"guide.title": "📎 PolyWeather 系统技术说明",
"guide.closeAria": "关闭技术说明",
"guide.footer":
"数据源以 Aviation Weather / METAR、Turkish MGM、Open-Meteo、weather.gov 为主。",
"history.title": "📊 历史准确率对账 - {city}",
"history.closeAria": "关闭历史对账",
"history.loading": "正在获取历史数据...",
"history.error": "获取历史信息失败",
"history.empty": "近 15 天暂无该城市历史数据",
"history.hitRate": "DEB 结算胜率 (WU)",
"history.mae": "DEB MAE",
"history.sample": "近 15 天已结算样本",
"history.sampleDays": "{count} 天",
"future.todayTitle": "{city} · 今日日内分析",
"future.dateTitle": "{city} · {date} 未来日期分析",
"future.closeTodayAria": "关闭今日日内分析",
"future.closeDateAria": "关闭未来日期分析",
"future.currentObs": "当前实测",
"future.currentWeather": "当前天气",
"future.wuRef": "WU 结算参考",
"future.sunrise": "日出时间",
"future.sunset": "日落时间",
"future.sunshine": "日照时长",
"future.todayForecastHigh": "今日预报高温",
"future.targetForecast": "目标日预报",
"future.deb": "DEB 预测",
"future.mu": "动态分布中心",
"future.score": "趋势评分",
"future.todayTempTrend": "今日温度走势",
"future.targetTempTrend": "目标日小时走势",
"future.probability": "模型结算概率分布",
"future.models": "多模型预报",
"future.structureToday": "今日日内结构信号",
"future.structureDate": "未来 6-48 小时趋势",
"future.judgement": "判断",
"future.confidence": "置信度",
"future.maxPrecip": "最大降水概率",
"future.ai": "AI 深度分析",
"future.noAi": "暂无 AI 分析,当前以结构化气象与模型数据为主。",
"future.weatherGov": "weather.gov 文本",
"future.risk": "结算与偏差风险",
"future.climate": "当地气候主要受什么影响",
"future.chartLegendEmpty": "暂无机场报文或小时级实测数据",
"confidence.high": "高",
"confidence.medium": "中",
"confidence.low": "低",
"section.todayTempTrend": "今日温度走势",
"section.chartEmpty": "暂无小时级数据",
"section.probability": "模型结算概率分布",
"section.mu": "动态分布中心 μ = {value}{unit}",
"section.noProb": "暂无概率数据",
"section.models": "多模型预报",
"section.noModels": "暂无多模型预报",
"section.ai": "AI 深度分析",
"section.aiEmpty": "暂无 AI 分析,当前以结构化气象与模型数据为主。",
"section.risk": "数据偏差风险",
"section.noRiskProfile": "暂无风险档案",
"section.airport": "机场",
"section.distance": "距离",
"section.note": "注意",
"common.na": "--",
},
"en-US": {
"header.subtitle": "Weather Derivatives Intelligence",
"header.info": "Tech Notes",
"header.infoAria": "Open system technical notes",
"header.live": "LIVE",
"header.refreshAria": "Refresh all data",
"header.langAria": "Switch language",
"header.langZh": "中文",
"header.langEn": "EN",
"sidebar.title": "Monitored Cities",
"sidebar.peakAt": "Peak @ {time}",
"sidebar.group.high": "High Risk",
"sidebar.group.medium": "Medium Risk",
"sidebar.group.low": "Low Risk",
"sidebar.group.other": "Others",
"dashboard.loading": "Loading weather data, please wait...",
"detail.closeAria": "Close city detail panel",
"detail.waitSelect": "Waiting for city selection",
"detail.todayAnalysis": "Today's Intraday",
"detail.history": "History Reconciliation",
"detail.loading": "Loading city details...",
"detail.emptyHint": "Select a city from the left list to view details.",
"detail.sceneryAlt": "{city} scenery",
"detail.sceneryTitle": "City Scenery & Microclimate",
"detail.sceneryFallback":
"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.",
"forecast.title": "Multi-day Forecast",
"forecast.empty": "No multi-day forecast available",
"forecast.today": "Today",
"guide.title": "📎 PolyWeather Technical Overview",
"guide.closeAria": "Close technical overview",
"guide.footer":
"Primary data sources are Aviation Weather / METAR, Turkish MGM, Open-Meteo, and weather.gov.",
"history.title": "📊 Historical Reconciliation - {city}",
"history.closeAria": "Close history reconciliation",
"history.loading": "Loading historical data...",
"history.error": "Failed to load historical data",
"history.empty": "No historical records for this city in the last 15 days",
"history.hitRate": "DEB Settlement Hit Rate (WU)",
"history.mae": "DEB MAE",
"history.sample": "Settled Samples (Last 15 Days)",
"history.sampleDays": "{count} days",
"future.todayTitle": "{city} · Intraday Analysis",
"future.dateTitle": "{city} · {date} Future-date Analysis",
"future.closeTodayAria": "Close intraday analysis",
"future.closeDateAria": "Close future-date analysis",
"future.currentObs": "Current Observation",
"future.currentWeather": "Current Weather",
"future.wuRef": "WU Settlement Ref",
"future.sunrise": "Sunrise",
"future.sunset": "Sunset",
"future.sunshine": "Sunshine Duration",
"future.todayForecastHigh": "Today's Forecast High",
"future.targetForecast": "Target-day Forecast",
"future.deb": "DEB Forecast",
"future.mu": "Dynamic Distribution Center",
"future.score": "Trend Score",
"future.todayTempTrend": "Today's Temperature Trend",
"future.targetTempTrend": "Target-day Hourly Trend",
"future.probability": "Model Settlement Probabilities",
"future.models": "Multi-model Forecast",
"future.structureToday": "Intraday Structural Signal",
"future.structureDate": "6-48h Structural Trend",
"future.judgement": "Judgement",
"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.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",
"confidence.high": "High",
"confidence.medium": "Medium",
"confidence.low": "Low",
"section.todayTempTrend": "Today's Temperature Trend",
"section.chartEmpty": "No hourly data available",
"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.risk": "Data Deviation Risk",
"section.noRiskProfile": "No risk profile available",
"section.airport": "Airport",
"section.distance": "Distance",
"section.note": "Note",
"common.na": "--",
},
};
export function normalizeLocale(value?: string | null): Locale {
if (!value) return DEFAULT_LOCALE;
const normalized = value.toLowerCase();
if (normalized.startsWith("en")) return "en-US";
return "zh-CN";
}
export function getInitialLocaleFromNavigator(): Locale {
if (typeof window === "undefined") return DEFAULT_LOCALE;
return normalizeLocale(window.navigator.language);
}
export function formatMessage(
locale: Locale,
key: string,
params?: MessageParams,
): string {
const template =
MESSAGES[locale]?.[key] || MESSAGES[DEFAULT_LOCALE][key] || key;
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, token) => {
const value = params[token];
return value == null ? "" : String(value);
});
}
+14 -1
View File
@@ -186,7 +186,6 @@ export interface ModelComparison {
JMA?: number;
MGM?: number;
NWS?: number;
Meteoblue?: number;
}
export interface DEBAnalysis {
@@ -292,6 +291,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;
}
+69
View File
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server";
const SESSION_COOKIE = "polyweather_entitlement";
function isStaticAsset(pathname: string) {
return (
pathname.startsWith("/_next/") ||
pathname.startsWith("/favicon") ||
pathname.startsWith("/robots.txt") ||
pathname.startsWith("/sitemap.xml") ||
pathname.startsWith("/icons/") ||
pathname.startsWith("/images/") ||
pathname.startsWith("/static/")
);
}
function isPublicPage(pathname: string) {
return pathname === "/entitlement-required";
}
export function middleware(request: NextRequest) {
const requiredToken = process.env.POLYWEATHER_DASHBOARD_ACCESS_TOKEN?.trim();
if (!requiredToken) {
return NextResponse.next();
}
const { pathname, searchParams } = request.nextUrl;
if (isStaticAsset(pathname) || isPublicPage(pathname)) {
return NextResponse.next();
}
const cookieToken = request.cookies.get(SESSION_COOKIE)?.value;
if (cookieToken && cookieToken === requiredToken) {
return NextResponse.next();
}
const queryToken = searchParams.get("access_token");
if (queryToken && queryToken === requiredToken) {
const cleanUrl = request.nextUrl.clone();
cleanUrl.searchParams.delete("access_token");
const response = NextResponse.redirect(cleanUrl);
response.cookies.set(SESSION_COOKIE, requiredToken, {
httpOnly: true,
sameSite: "lax",
secure: cleanUrl.protocol === "https:",
path: "/",
maxAge: 60 * 60 * 12,
});
return response;
}
if (pathname.startsWith("/api/")) {
return NextResponse.json(
{ error: "Unauthorized", detail: "Entitlement token required" },
{ status: 401 },
);
}
const deniedUrl = request.nextUrl.clone();
deniedUrl.pathname = "/entitlement-required";
deniedUrl.search = "";
deniedUrl.searchParams.set("next", pathname);
return NextResponse.redirect(deniedUrl);
}
export const config = {
matcher: ["/((?!_next/static|_next/image).*)"],
};
+44
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@radix-ui/react-slot": "^1.1.2",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^2.0.0",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -822,6 +823,43 @@
}
}
},
"node_modules/@vercel/speed-insights": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/@vercel/speed-insights/-/speed-insights-2.0.0.tgz",
"integrity": "sha512-jwkNcrTeafWxjmWq4AHBaptSqZiJkYU5adLC9QBSqeim0GcqDMgN5Ievh8OG1rJ6W3A4l1oiP7qr9CWxGuzu3w==",
"peerDependencies": {
"@sveltejs/kit": "^1 || ^2",
"next": ">= 13",
"nuxt": ">= 3",
"react": "^18 || ^19 || ^19.0.0-rc",
"svelte": ">= 4",
"vue": "^3",
"vue-router": "^4"
},
"peerDependenciesMeta": {
"@sveltejs/kit": {
"optional": true
},
"next": {
"optional": true
},
"nuxt": {
"optional": true
},
"react": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
},
"vue-router": {
"optional": true
}
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz",
@@ -2509,6 +2547,12 @@
"integrity": "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==",
"requires": {}
},
"@vercel/speed-insights": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/@vercel/speed-insights/-/speed-insights-2.0.0.tgz",
"integrity": "sha512-jwkNcrTeafWxjmWq4AHBaptSqZiJkYU5adLC9QBSqeim0GcqDMgN5Ievh8OG1rJ6W3A4l1oiP7qr9CWxGuzu3w==",
"requires": {}
},
"any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz",
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@radix-ui/react-slot": "^1.1.2",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^2.0.0",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+3 -6
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
@@ -151,7 +151,7 @@
</div>
<div class="guide-card">
<h3>&#128198; &#x672A;&#x6765;&#x65E5;&#x671F;&#x5206;&#x6790;</h3>
<p>&#x70B9;&#x51FB;&#x591A;&#x65E5;&#x9884;&#x62A5;&#x540E;&#x6253;&#x5F00;&#x7684;&#x6A21;&#x6001;&#x6846;&#xFF0C;&#x4E3B;&#x8981;&#x7528;&#x4E8E;&#x5206;&#x6790;&#x4E0B;&#x4E00;&#x4E2A;&#x4EA4;&#x6613;&#x65E5;&#x3002; 6-48 &#x5C0F;&#x65F6;&#x8D8B;&#x52BF;&#x4EE5; weather.gov &#x548C; Open-Meteo &#x4E3A;&#x4E3B;&#xFF0C;&#x90E8;&#x5206;&#x57CE;&#x5E02;&#x53EF;&#x4F1A;&#x8865;&#x5145; Meteoblue&#xFF1B; 0-2 &#x5C0F;&#x65F6;&#x4E34;&#x8FD1;&#x5224;&#x65AD;&#x5219;&#x4F18;&#x5148;&#x770B; METAR &#x4E0E;&#x5468;&#x8FB9;&#x7AD9;&#x3002;</p>
<p>&#x70B9;&#x51FB;&#x591A;&#x65E5;&#x9884;&#x62A5;&#x540E;&#x6253;&#x5F00;&#x7684;&#x6A21;&#x6001;&#x6846;&#xFF0C;&#x4E3B;&#x8981;&#x7528;&#x4E8E;&#x5206;&#x6790;&#x4E0B;&#x4E00;&#x4E2A;&#x4EA4;&#x6613;&#x65E5;&#x3002; 6-48 &#x5C0F;&#x65F6;&#x8D8B;&#x52BF;&#x4EE5; weather.gov &#x548C; Open-Meteo &#x4E3A;&#x4E3B;&#xFF1B; 0-2 &#x5C0F;&#x65F6;&#x4E34;&#x8FD1;&#x5224;&#x65AD;&#x5219;&#x4F18;&#x5148;&#x770B; METAR &#x4E0E;&#x5468;&#x8FB9;&#x7AD9;&#x3002;</p>
</div>
<div class="guide-card">
<h3>&#128202; &#x5386;&#x53F2;&#x5BF9;&#x8D26;&#x89C4;&#x5219;</h3>
@@ -159,7 +159,7 @@
</div>
</div>
<div class="guide-footer">
<p>&#x203B; &#x6570;&#x636E;&#x6E90;&#x4EE5; Aviation Weather / METAR&#x3001;Turkish MGM&#x3001;Open-Meteo&#x3001;weather.gov &#x4E3A;&#x4E3B;&#xFF0C;&#x90E8;&#x5206;&#x57CE;&#x5E02;&#x8865;&#x5145; Meteoblue&#x3002;</p>
<p>&#x203B; &#x6570;&#x636E;&#x6E90;&#x4EE5; Aviation Weather / METAR&#x3001;Turkish MGM&#x3001;Open-Meteo&#x3001;weather.gov &#x4E3A;&#x4E3B;&#x3002;</p>
</div>
</div>
</div>
@@ -236,6 +236,3 @@
</html>
+60 -2
View File
@@ -1,4 +1,6 @@
import hashlib
import os
import threading
import time
import requests
from loguru import logger
@@ -9,17 +11,58 @@ MODELS = [
"llama-3.1-8b-instant",
]
# ── 本地缓存 ──────────────────────────────────────────────
# key: sha1(city_name + weather_insights[:200])
# value: {"result": str, "t": float}
_ai_cache: dict = {}
_ai_cache_lock = threading.Lock()
# 全局 429 冷却期:触发限流后暂停一段时间内的所有 Groq 请求
_rate_limit_until: float = 0.0
_rate_limit_lock = threading.Lock()
def get_ai_analysis(weather_insights: str, city_name: str, temp_symbol: str) -> str:
"""
通过 Groq API (LLaMA 3.3 70B) 对天气态势进行极速交易分析
内置自动重试 + 模型降级机制
内置自动重试 + 模型降级机制 + 本地 TTL 缓存 + 全局 429 冷却期
"""
api_key = os.getenv("GROQ_API_KEY")
if not api_key:
logger.warning("GROQ_API_KEY 未配置,跳过 AI 分析")
return ""
global _rate_limit_until # 必须在函数顶部声明,不能放在 with 块内
# ── 缓存配置 ─────────────────────────────────────────
cache_ttl = int(os.getenv("GROQ_CACHE_TTL_SEC", "1200")) # 默认 20 分钟
rl_cooldown = int(os.getenv("GROQ_RATE_LIMIT_COOLDOWN_SEC", "600")) # 默认 10 分钟
# 缓存 key:城市名 + 天气摘要前 200 字符(同城市、同数据不重复打 API)
cache_raw = f"{city_name}:{weather_insights[:200]}"
cache_key = hashlib.sha1(cache_raw.encode("utf-8")).hexdigest()[:16]
now = time.time()
# ── 命中缓存则直接返回 ────────────────────────────────
with _ai_cache_lock:
cached = _ai_cache.get(cache_key)
if cached and now - cached["t"] < cache_ttl:
logger.debug(f"Groq AI cache hit city={city_name} age={int(now - cached['t'])}s")
return cached["result"]
# ── 全局 429 冷却期检查 ───────────────────────────────
with _rate_limit_lock:
if now < _rate_limit_until:
remaining = int(_rate_limit_until - now)
logger.warning(f"Groq 冷却期中,还需等待 {remaining}s,跳过本次请求")
# 如果有旧缓存,返回旧结果(过期但总比没有好)
with _ai_cache_lock:
stale = _ai_cache.get(cache_key)
if stale:
return stale["result"] + "\n<i>(AI 分析来自缓存,数据可能略旧)</i>"
return "\n⚠️ Groq AI 限流中,请稍后再试"
url = "https://api.groq.com/openai/v1/chat/completions"
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
@@ -103,6 +146,9 @@ P4 **预报背景**(最低优先级):
if model != MODELS[0]:
logger.info(f"Groq 降级到备用模型 {model} 成功")
# ── 写入缓存 ─────────────────────────────────
with _ai_cache_lock:
_ai_cache[cache_key] = {"result": content, "t": time.time()}
return content
except requests.exceptions.HTTPError as e:
@@ -115,6 +161,12 @@ P4 **预报背景**(最低优先级):
logger.warning(
f"Groq {model} 失败 (HTTP {status}): {error_body}. 尝试下一个..."
)
if status == 429:
# 触发限流:设置全局冷却期,后续请求不再尝试
with _rate_limit_lock:
_rate_limit_until = time.time() + rl_cooldown
logger.warning(f"Groq 触发限流,设置 {rl_cooldown}s 全局冷却期")
break # 不再尝试其他模型,直接走 stale cache 逻辑
if status in (500, 502, 503) and attempt == 0:
time.sleep(1.5)
continue
@@ -125,4 +177,10 @@ P4 **预报背景**(最低优先级):
break
logger.error("所有 Groq 模型均不可用")
return "\n⚠️ Groq AI 暂时不可用,请稍后再试"
# ── 有旧缓存则返回旧结果 ──────────────────────────────
with _ai_cache_lock:
stale = _ai_cache.get(cache_key)
if stale:
logger.info(f"Groq 不可用,返回旧缓存结果 city={city_name} age={int(time.time()-stale['t'])}s")
return stale["result"] + "\n<i>(⚠️ AI 分析来自上次缓存)</i>"
return ""
+517
View File
@@ -0,0 +1,517 @@
from __future__ import annotations
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional, Tuple
from loguru import logger
from src.analysis.trend_engine import analyze_weather_trend
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
from src.data_collection.city_risk_profiles import get_city_risk_profile
FAHRENHEIT_CITIES = {
"dallas",
"new york",
"chicago",
"miami",
"atlanta",
"seattle",
}
def _sf(value: Any) -> Optional[float]:
if value is None:
return None
try:
return float(value)
except Exception:
return None
def resolve_city_name(city_input: str) -> Tuple[Optional[str], List[str]]:
city_input_norm = city_input.strip().lower()
supported = list(CITY_REGISTRY.keys())
# 1) Exact alias/name
city_name = ALIASES.get(city_input_norm)
if not city_name and city_input_norm in supported:
city_name = city_input_norm
# 2) Prefix match
if not city_name and len(city_input_norm) >= 2:
for alias, canonical in ALIASES.items():
if alias.startswith(city_input_norm):
city_name = canonical
break
if not city_name:
for canonical in supported:
if canonical.startswith(city_input_norm):
city_name = canonical
break
return city_name, sorted(supported)
def _render_local_time(
open_meteo: Dict[str, Any],
metar: Dict[str, Any],
fallback_utc_offset: int,
) -> str:
utc_offset = open_meteo.get("utc_offset")
if utc_offset is None:
utc_offset = fallback_utc_offset
try:
local_now = datetime.now(timezone.utc).astimezone(
timezone(timedelta(seconds=int(utc_offset)))
)
return local_now.strftime("%H:%M")
except Exception:
pass
local_time = (open_meteo.get("current") or {}).get("local_time", "")
if " " in str(local_time):
return str(local_time).split(" ")[1][:5]
metar_obs = metar.get("observation_time", "") if metar else ""
if "T" in str(metar_obs):
try:
dt = datetime.fromisoformat(str(metar_obs).replace("Z", "+00:00"))
utc_offset = open_meteo.get("utc_offset")
if utc_offset is None:
utc_offset = fallback_utc_offset
local_dt = dt.astimezone(timezone(timedelta(seconds=int(utc_offset))))
return local_dt.strftime("%H:%M")
except Exception:
return str(metar_obs).split("T")[1][:5]
if " " in str(metar_obs):
return str(metar_obs).split(" ")[1][:5]
if metar_obs:
return str(metar_obs)[:5]
return "N/A"
def _derive_mgm_daily_highs_from_hourly(
mgm: Dict[str, Any],
fallback_utc_offset: int,
) -> Dict[str, float]:
if not isinstance(mgm, dict):
return {}
hourly = mgm.get("hourly")
if not isinstance(hourly, list) or not hourly:
return {}
samples: List[Tuple[str, float]] = []
parsed_datetimes: List[datetime] = []
local_tz = timezone(timedelta(seconds=int(fallback_utc_offset)))
for row in hourly:
if not isinstance(row, dict):
continue
temp = _sf(row.get("temp"))
raw_time = str(row.get("time") or "").strip()
if temp is None or not raw_time:
continue
date_key = None
if "T" in raw_time:
try:
dt = datetime.fromisoformat(raw_time.replace("Z", "+00:00"))
if dt.tzinfo is not None:
dt = dt.astimezone(local_tz)
else:
dt = dt.replace(tzinfo=local_tz)
parsed_datetimes.append(dt)
date_key = dt.strftime("%Y-%m-%d")
except Exception:
if len(raw_time) >= 10 and raw_time[4] == "-" and raw_time[7] == "-":
date_key = raw_time[:10]
elif len(raw_time) >= 10 and raw_time[4] == "-" and raw_time[7] == "-":
date_key = raw_time[:10]
if not date_key:
continue
samples.append((date_key, temp))
if not samples:
return {}
# Guardrail: do not derive "daily highs" from short intraday snippets.
if parsed_datetimes:
parsed_datetimes.sort()
horizon_hours = (
parsed_datetimes[-1] - parsed_datetimes[0]
).total_seconds() / 3600.0
if horizon_hours < 30:
return {}
elif len(samples) < 24:
return {}
daily_highs: Dict[str, float] = {}
for date_key, temp in samples:
prev = daily_highs.get(date_key)
daily_highs[date_key] = temp if prev is None else max(prev, temp)
return daily_highs
def _append_future_forecast_lines(
lines: List[str],
weather_data: Dict[str, Any],
dates: List[str],
max_temps: List[Any],
temp_symbol: str,
fallback_utc_offset: int,
) -> None:
mgm = weather_data.get("mgm") or {}
mgm_daily = (mgm.get("daily_forecasts") or {}) if isinstance(mgm, dict) else {}
mgm_hourly_daily = _derive_mgm_daily_highs_from_hourly(mgm, fallback_utc_offset)
if not isinstance(mgm_daily, dict):
mgm_daily = {}
for date_key, day_high in mgm_hourly_daily.items():
if date_key not in mgm_daily:
mgm_daily[date_key] = day_high
mm_raw = weather_data.get("multi_model") or {}
mm_daily = mm_raw.get("daily_forecasts", {}) if isinstance(mm_raw, dict) else {}
nws_periods = (weather_data.get("nws") or {}).get("forecast_periods", []) or []
if len(dates) > 1:
future_forecasts = []
for d, t in zip(dates[1:], max_temps[1:]):
mgm_value = mgm_daily.get(d) if isinstance(mgm_daily, dict) else None
if mgm_value is not None:
mgm_display = f"{float(mgm_value):.1f}"
future_forecasts.append(
f"{d[5:]}: {t}{temp_symbol} | <b>MGM: {mgm_display}{temp_symbol}</b>"
)
else:
future_forecasts.append(f"{d[5:]}: {t}{temp_symbol}")
lines.append("📅 " + " | ".join(future_forecasts))
return
local_now = datetime.now(timezone.utc).astimezone(
timezone(timedelta(seconds=int(fallback_utc_offset)))
)
today_local = local_now.strftime("%Y-%m-%d")
if isinstance(mgm_daily, dict) and mgm_daily:
future = []
for day in sorted(mgm_daily.keys()):
if day <= today_local:
continue
day_temp = mgm_daily.get(day)
if day_temp is None:
continue
future.append(f"{day[5:]}: {day_temp}{temp_symbol}")
if len(future) >= 2:
break
if future:
lines.append("📅 " + " | ".join(future))
return
if isinstance(mm_daily, dict) and mm_daily:
future = []
for day in sorted(mm_daily.keys()):
if day <= today_local:
continue
models = mm_daily.get(day, {}) or {}
vals = [_sf(v) for v in models.values()]
vals = [v for v in vals if v is not None]
if not vals:
continue
vals.sort()
median = vals[len(vals) // 2]
future.append(f"{day[5:]}: MM中位 {median:.1f}{temp_symbol}")
if len(future) >= 2:
break
if future:
lines.append("📅 " + " | ".join(future))
return
if isinstance(nws_periods, list) and nws_periods:
future = []
seen_days = set()
for period in nws_periods:
if not period.get("is_daytime"):
continue
day_temp = _sf(period.get("temperature"))
start_time = str(period.get("start_time") or "")
if day_temp is None or "T" not in start_time:
continue
day = start_time[:10]
if day <= today_local or day in seen_days:
continue
seen_days.add(day)
future.append(f"{day[5:]}: NWS {day_temp:.0f}{temp_symbol}")
if len(future) >= 2:
break
if future:
lines.append("📅 " + " | ".join(future))
def _build_wx_summary(
metar_current: Dict[str, Any],
metar_clouds: List[Dict[str, Any]],
mgm_cloud: Optional[Any],
) -> str:
wx_desc = str(metar_current.get("wx_desc") or "").upper().strip()
if wx_desc:
tokens = set(wx_desc.split())
rain_codes = {"RA", "DZ", "-RA", "+RA", "-DZ", "+DZ", "TSRA", "SHRA", "FZRA"}
snow_codes = {"SN", "GR", "GS", "-SN", "+SN", "BLSN"}
fog_codes = {"FG", "BR", "HZ", "FZFG"}
ts_codes = {"TS", "TSRA"}
if ts_codes & tokens:
return "⛈️ 雷暴"
if {"+RA", "+SN"} & tokens:
return "🌧️ 大雨" if "+RA" in tokens else "❄️ 大雪"
if rain_codes & tokens:
return "🌧️ 小雨" if {"-RA", "-DZ", "DZ"} & tokens else "🌧️ 下雨"
if snow_codes & tokens:
return "❄️ 下雪"
if fog_codes & tokens:
return "🌫️ 雾 / 霾"
cover_code = ""
if metar_clouds:
cover_code = str((metar_clouds[-1] or {}).get("cover") or "")
if cover_code in ("SKC", "CLR") or (cover_code == "" and mgm_cloud is not None and mgm_cloud <= 1):
return "☀️ 晴"
if cover_code == "FEW" or (cover_code == "" and mgm_cloud is not None and mgm_cloud <= 2):
return "🌤️ 晴间少云"
if cover_code == "SCT" or (cover_code == "" and mgm_cloud is not None and mgm_cloud <= 4):
return "⛅ 晴间多云"
if cover_code == "BKN" or (cover_code == "" and mgm_cloud is not None and mgm_cloud <= 6):
return "🌥️ 多云"
if cover_code == "OVC" or (cover_code == "" and mgm_cloud is not None and mgm_cloud <= 8):
return "☁️ 阴天"
if mgm_cloud is not None:
cloud_names = {
0: "☀️ 晴",
1: "☀️ 晴",
2: "🌤️ 少云",
3: "⛅ 散云",
4: "⛅ 散云",
5: "🌥️ 多云",
6: "🌥️ 多云",
7: "☁️ 阴",
8: "☁️ 阴天",
}
return cloud_names.get(int(mgm_cloud), "")
return ""
def build_city_query_report(
city_name: str,
weather_data: Dict[str, Any],
city_query_cost: int,
) -> str:
open_meteo = weather_data.get("open-meteo", {}) or {}
metar = weather_data.get("metar", {}) or {}
mgm = weather_data.get("mgm") or {}
city_meta = CITY_REGISTRY.get(city_name.lower(), {})
fallback_utc_offset = int(city_meta.get("tz_offset", 0))
nws_periods = ((weather_data.get("nws") or {}).get("forecast_periods") or [])
if nws_periods:
try:
first_start = nws_periods[0].get("start_time")
if first_start:
maybe_dt = datetime.fromisoformat(str(first_start))
if maybe_dt.utcoffset() is not None:
fallback_utc_offset = int(maybe_dt.utcoffset().total_seconds())
except Exception:
pass
city_is_fahrenheit = city_name.strip().lower() in FAHRENHEIT_CITIES
temp_symbol = "°F" if city_is_fahrenheit else "°C"
time_str = _render_local_time(open_meteo, metar, fallback_utc_offset)
risk_profile = get_city_risk_profile(city_name)
risk_emoji = risk_profile.get("risk_level", "⚠️") if risk_profile else "⚠️"
msg_lines = [f"📍 <b>{city_name.title()}</b> ({time_str}) {risk_emoji}"]
if risk_profile:
bias = risk_profile.get("bias", "±0.0")
msg_lines.append(
f"⚠️ {risk_profile.get('airport_name', '')}: {bias}{temp_symbol} | {risk_profile.get('warning', '')}"
)
daily = open_meteo.get("daily", {}) or {}
dates = (daily.get("time") or [])[:3]
max_temps = (daily.get("temperature_2m_max") or [])[:3]
nws_high = _sf((weather_data.get("nws") or {}).get("today_high"))
mgm_high = _sf((mgm.get("today_high") if isinstance(mgm, dict) else None))
metar_max_so_far = _sf((metar.get("current") or {}).get("max_temp_so_far"))
today_t = _sf(max_temps[0]) if max_temps else None
fallback_source = None
metar_only_fallback = False
if today_t is None:
for source_name, candidate in (("NWS", nws_high), ("MGM", mgm_high)):
if candidate is not None:
today_t = candidate
fallback_source = source_name
break
if today_t is None and metar_max_so_far is not None:
today_t = metar_max_so_far
metar_only_fallback = True
today_t_display = f"{today_t:.1f}" if isinstance(today_t, (int, float)) else "N/A"
sources = ["Open-Meteo"] if max_temps else []
comp_parts: List[str] = []
if nws_high is not None:
if "NWS" not in sources:
sources.append("NWS")
if fallback_source != "NWS":
comp_parts.append(f"NWS: {nws_high:.1f}{temp_symbol}")
if mgm_high is not None:
if "MGM" not in sources:
sources.append("MGM")
if fallback_source != "MGM":
comp_parts.append(f"MGM: {mgm_high:.1f}{temp_symbol}")
if fallback_source and fallback_source not in sources:
sources.append(fallback_source)
if metar_only_fallback:
if not sources:
sources = ["Model unavailable"]
comp_parts.append(f"METAR实测回退: {metar_max_so_far:.1f}{temp_symbol}")
if not sources:
sources = ["N/A"]
comp_str = f" ({' | '.join(comp_parts)})" if comp_parts else ""
msg_lines.append(f"\n📊 <b>预报 ({' | '.join(sources)})</b>")
msg_lines.append(
f"👉 <b>今天: {today_t_display}{temp_symbol}{comp_str}</b>"
)
_append_future_forecast_lines(
lines=msg_lines,
weather_data=weather_data,
dates=dates,
max_temps=max_temps,
temp_symbol=temp_symbol,
fallback_utc_offset=fallback_utc_offset,
)
sunrises = daily.get("sunrise", []) or []
sunsets = daily.get("sunset", []) or []
sunshine_durations = daily.get("sunshine_duration", []) or []
if sunrises and sunsets:
sunrise_t = str(sunrises[0]).split("T")[1][:5] if "T" in str(sunrises[0]) else str(sunrises[0])
sunset_t = str(sunsets[0]).split("T")[1][:5] if "T" in str(sunsets[0]) else str(sunsets[0])
sun_line = f"🌅 日出 {sunrise_t} | 🌇 日落 {sunset_t}"
if sunshine_durations:
sun_line += f" | ☀️ 日照 {float(sunshine_durations[0]) / 3600:.1f}h"
msg_lines.append(sun_line)
metar_current = metar.get("current", {}) if isinstance(metar, dict) else {}
mgm_current = mgm.get("current", {}) if isinstance(mgm, dict) else {}
cur_temp = _sf(metar_current.get("temp"))
if cur_temp is None:
cur_temp = _sf(mgm_current.get("temp"))
max_p = _sf(metar_current.get("max_temp_so_far"))
max_p_time = metar_current.get("max_temp_time")
obs_t_str = "N/A"
metar_age_min = None
main_source = "METAR" if metar else "MGM"
if metar and metar.get("observation_time"):
obs_t = str(metar.get("observation_time"))
try:
if "T" in obs_t:
dt = datetime.fromisoformat(obs_t.replace("Z", "+00:00"))
utc_offset = open_meteo.get("utc_offset")
if utc_offset is None:
utc_offset = fallback_utc_offset
local_dt = dt.astimezone(timezone(timedelta(seconds=int(utc_offset))))
obs_t_str = local_dt.strftime("%H:%M")
metar_age_min = int((datetime.now(timezone.utc) - dt).total_seconds() / 60)
elif " " in obs_t:
obs_t_str = obs_t.split(" ")[1][:5]
else:
obs_t_str = obs_t
except Exception:
obs_t_str = obs_t[:16]
elif mgm:
mgm_time = str(mgm_current.get("time") or "")
if "T" in mgm_time:
dt = datetime.fromisoformat(mgm_time.replace("Z", "+00:00"))
mgm_time = dt.astimezone(timezone(timedelta(hours=3))).strftime("%H:%M")
elif " " in mgm_time:
mgm_time = mgm_time.split(" ")[1][:5]
obs_t_str = mgm_time or "N/A"
age_tag = ""
if metar_age_min is not None:
if metar_age_min >= 60:
age_tag = f" ⚠️{metar_age_min}分钟前"
elif metar_age_min >= 30:
age_tag = f" 🔔{metar_age_min}分钟前"
max_str = ""
if max_p is not None:
settled_val = int(max_p + 0.5)
max_str = f" (最高: {max_p}{temp_symbol}"
if max_p_time:
max_str += f" @{max_p_time}"
max_str += f" → WU {settled_val}{temp_symbol})"
metar_clouds = metar_current.get("clouds", []) if isinstance(metar_current, dict) else []
mgm_cloud = mgm_current.get("cloud_cover") if isinstance(mgm_current, dict) else None
wx_summary = _build_wx_summary(metar_current, metar_clouds, mgm_cloud)
wx_display = f" {wx_summary}" if wx_summary else ""
msg_lines.append(
f"\n✈️ <b>实测 ({main_source}): {cur_temp}{temp_symbol}</b>{max_str} |{wx_display} | {obs_t_str}{age_tag}"
)
if metar:
wind = metar_current.get("wind_speed_kt")
wind_dir = metar_current.get("wind_dir")
vis = metar_current.get("visibility_mi")
if not mgm:
msg_lines.append(f" [METAR] 🌪 {wind or 0}kt ({wind_dir or 0}°) | 👁️ {vis or 10}mi")
if mgm:
wind_dir = mgm_current.get("wind_dir")
wind_speed_ms = mgm_current.get("wind_speed_ms")
if wind_dir is not None and wind_speed_ms is not None:
dirs = ["", "东北", "", "东南", "", "西南", "西", "西北"]
dir_str = dirs[int((float(wind_dir) + 22.5) % 360 / 45)] + ""
msg_lines.append(
f" [MGM] 🌬️ {dir_str}{wind_dir}° ({wind_speed_ms} m/s) | 💧 降水: {mgm_current.get('rain_24h') or 0}mm"
)
feature_str, ai_context, _structured = analyze_weather_trend(weather_data, temp_symbol, city_name)
if feature_str:
msg_lines.append("\n💡 <b>分析</b>:")
for line in feature_str.split("\n"):
if line.strip():
msg_lines.append(f"- {line.strip()}")
try:
from src.analysis.ai_analyzer import get_ai_analysis
mm = weather_data.get("multi_model", {}) or {}
if not isinstance(mm, dict):
mm = {}
if mm.get("forecasts"):
mm_parts = [
f"{k}:{v}{temp_symbol}"
for k, v in (mm.get("forecasts") or {}).items()
if v is not None
]
if mm_parts:
ai_context += f"\n模型分歧: {' | '.join(mm_parts)}"
ai_result = get_ai_analysis(ai_context, city_name, temp_symbol)
if ai_result:
msg_lines.append(f"\n{ai_result}")
except Exception as exc:
logger.error(f"调用 Groq AI 分析失败: {exc}")
msg_lines.append(f"\n💸 本次消耗 <b>{city_query_cost}</b> 积分。")
return "\n".join(msg_lines)
+198 -19
View File
@@ -1,6 +1,8 @@
import os
import json
from datetime import datetime, timedelta
import requests
from src.analysis.settlement_rounding import wu_round
# Cross-platform file locking
import sys
@@ -36,6 +38,11 @@ _history_cache = {}
_history_mtime = 0
def _is_excluded_model_name(model_name: str) -> bool:
normalized = str(model_name or "").strip().lower().replace(" ", "").replace("_", "").replace("-", "")
return "meteoblue" in normalized
def load_history(filepath):
global _history_cache, _history_mtime
if not os.path.exists(filepath):
@@ -74,6 +81,141 @@ def save_history(filepath, data):
print(f"Error saving history: {e}")
def _parse_metar_row_time(row):
"""Parse METAR row timestamp from aviationweather API payload."""
candidates = [
row.get("reportTime"),
row.get("receiptTime"),
row.get("observation_time"),
]
for raw in candidates:
if not raw:
continue
try:
return datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
except Exception:
continue
obs_epoch = row.get("obsTime")
if obs_epoch is not None:
try:
return datetime.utcfromtimestamp(int(obs_epoch))
except Exception:
pass
return None
def reconcile_recent_actual_highs(city_name: str, lookback_days: int = 7):
"""
Reconcile recent `actual_high` values using historical METAR data from
aviationweather.gov to fix stale/wrong daily records.
"""
try:
from src.data_collection.city_registry import CITY_REGISTRY, ALIASES
city_key = str(city_name or "").strip().lower()
city_key = ALIASES.get(city_key, city_key)
city_meta = CITY_REGISTRY.get(city_key)
if not isinstance(city_meta, dict):
return {"ok": False, "reason": "unknown_city", "updated": 0}
icao = str(city_meta.get("icao") or "").strip().upper()
if not icao:
return {"ok": False, "reason": "missing_icao", "updated": 0}
tz_offset = int(city_meta.get("tz_offset") or 0)
use_fahrenheit = bool(city_meta.get("use_fahrenheit"))
project_root = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
history_file = os.path.join(project_root, "data", "daily_records.json")
data = load_history(history_file)
city_data = data.get(city_key) or {}
if not isinstance(city_data, dict) or not city_data:
return {"ok": True, "reason": "no_city_history", "updated": 0}
local_now = datetime.utcnow() + timedelta(seconds=tz_offset)
local_today = local_now.strftime("%Y-%m-%d")
cutoff = (local_now - timedelta(days=max(lookback_days, 1) + 1)).strftime(
"%Y-%m-%d"
)
target_dates = sorted(
d for d in city_data.keys() if isinstance(d, str) and cutoff <= d < local_today
)
if not target_dates:
return {"ok": True, "reason": "no_target_dates", "updated": 0}
try:
min_target = datetime.strptime(target_dates[0], "%Y-%m-%d")
span_hours = int((local_now - min_target).total_seconds() / 3600) + 12
except Exception:
span_hours = (lookback_days + 3) * 24
span_hours = max(72, min(240, span_hours))
url = (
f"https://aviationweather.gov/api/data/metar"
f"?ids={icao}&format=json&hours={span_hours}"
)
resp = requests.get(url, timeout=12)
resp.raise_for_status()
rows = resp.json() or []
if not isinstance(rows, list):
rows = []
daily_max_c = {}
for row in rows:
if not isinstance(row, dict):
continue
temp = row.get("temp")
if temp is None:
continue
obs_dt = _parse_metar_row_time(row)
if obs_dt is None:
continue
local_dt = obs_dt + timedelta(seconds=tz_offset)
d = local_dt.strftime("%Y-%m-%d")
if d < cutoff or d >= local_today:
continue
try:
t = float(temp)
except Exception:
continue
prev = daily_max_c.get(d)
if prev is None or t > prev:
daily_max_c[d] = t
updated = 0
for d in target_dates:
t_c = daily_max_c.get(d)
if t_c is None:
continue
corrected = round(t_c * 9 / 5 + 32, 1) if use_fahrenheit else round(t_c, 1)
rec = city_data.get(d) or {}
old = rec.get("actual_high")
try:
old_val = float(old) if old is not None else None
except Exception:
old_val = None
if old_val is None or abs(old_val - corrected) >= 0.1:
rec["actual_high"] = corrected
city_data[d] = rec
updated += 1
if updated > 0:
data[city_key] = city_data
save_history(history_file, data)
return {
"ok": True,
"updated": updated,
"scanned_dates": len(target_dates),
"metar_rows": len(rows),
"icao": icao,
}
except Exception as e:
return {"ok": False, "reason": str(e), "updated": 0}
def update_daily_record(
city_name, date_str, forecasts, actual_high, deb_prediction=None,
mu=None, probabilities=None
@@ -99,27 +241,51 @@ def update_daily_record(
if date_str not in data[city_name]:
data[city_name][date_str] = {}
# 避免无意义的频繁磁盘写入
old_actual = data[city_name][date_str].get("actual_high")
if (
old_actual == actual_high
and data[city_name][date_str].get("forecasts") == forecasts
):
return
# 统一过滤已弃用模型,避免历史/展示残留
forecasts = {
k: v for k, v in (forecasts or {}).items() if not _is_excluded_model_name(k)
}
data[city_name][date_str]["forecasts"] = forecasts
data[city_name][date_str]["actual_high"] = actual_high
if deb_prediction is not None:
data[city_name][date_str]["deb_prediction"] = deb_prediction
if mu is not None:
data[city_name][date_str]["mu"] = round(mu, 2)
compact_probs = None
if probabilities is not None:
# Store compact: [{"v": 25, "p": 0.8}, ...]
data[city_name][date_str]["prob_snapshot"] = [
compact_probs = [
{"v": p["value"], "p": p["probability"]}
for p in probabilities[:4]
]
# 避免无意义的频繁磁盘写入
existing = data[city_name][date_str]
old_actual = existing.get("actual_high")
old_deb = existing.get("deb_prediction")
old_mu = existing.get("mu")
old_probs = existing.get("prob_snapshot")
next_mu = round(mu, 2) if mu is not None else None
if (
old_actual == actual_high
and existing.get("forecasts") == forecasts
and (deb_prediction is None or old_deb == deb_prediction)
and (mu is None or old_mu == next_mu)
and (compact_probs is None or old_probs == compact_probs)
):
return
# actual_high 应该是日内最高温,理论上不应下降;防止异常写入覆盖已确认高值
if old_actual is not None and actual_high is not None:
try:
actual_high = max(float(old_actual), float(actual_high))
except Exception:
pass
existing["forecasts"] = forecasts
existing["actual_high"] = actual_high
if deb_prediction is not None:
existing["deb_prediction"] = deb_prediction
if mu is not None:
existing["mu"] = next_mu
if probabilities is not None:
existing["prob_snapshot"] = compact_probs
# 自动清理:只保留最近 14 天的记录(DEB 只用 7 天,14 天留足余量)
cutoff = (datetime.now() - timedelta(days=14)).strftime("%Y-%m-%d")
for city in list(data.keys()):
@@ -142,6 +308,12 @@ def calculate_dynamic_weights(city_name, current_forecasts, lookback_days=7):
history_file = os.path.join(project_root, "data", "daily_records.json")
data = load_history(history_file)
current_forecasts = {
k: v
for k, v in (current_forecasts or {}).items()
if not _is_excluded_model_name(k)
}
if city_name not in data or not data[city_name]:
# 没有历史数据,返回简单的平均/中位数
valid_vals = [v for v in current_forecasts.values() if v is not None]
@@ -173,7 +345,12 @@ def calculate_dynamic_weights(city_name, current_forecasts, lookback_days=7):
for model in current_forecasts.keys():
if model in past_forecasts and past_forecasts[model] is not None:
errors[model].append(abs(past_forecasts[model] - actual))
try:
pv = float(past_forecasts[model])
av = float(actual)
except (TypeError, ValueError):
continue
errors[model].append(abs(pv - av))
days_used += 1
if days_used >= lookback_days:
@@ -182,6 +359,8 @@ def calculate_dynamic_weights(city_name, current_forecasts, lookback_days=7):
# 如果有效历史天数 < 2 天,还是使用等权
if days_used < 2:
valid_vals = [v for v in current_forecasts.values() if v is not None]
if not valid_vals:
return None, f"暂无有效模型数据(由于仅{days_used}天历史)"
avg = sum(valid_vals) / len(valid_vals)
return round(avg, 1), f"等权平均(由于仅{days_used}天历史)"
@@ -263,8 +442,8 @@ def get_deb_accuracy(city_name):
continue
total += 1
deb_wu = round(deb_pred)
actual_wu = round(actual)
deb_wu = wu_round(deb_pred)
actual_wu = wu_round(actual)
if deb_wu == actual_wu:
hits += 1
errors.append(abs(deb_pred - actual))
@@ -328,13 +507,13 @@ def get_mu_accuracy(city_name):
total += 1
mu_errors.append(abs(mu_val - actual))
if round(mu_val) == round(actual):
if wu_round(mu_val) == wu_round(actual):
mu_hits += 1
# Brier Score from probability snapshot
prob_snap = record.get("prob_snapshot", [])
if prob_snap:
actual_wu = round(actual)
actual_wu = wu_round(actual)
bs = 0.0
for entry in prob_snap:
predicted_p = entry.get("p", 0)
+641 -3
View File
@@ -5,8 +5,11 @@ 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
from typing import Any, Dict, List, Optional, Tuple
from src.analysis.settlement_rounding import wu_round
def _sf(v: Any) -> Optional[float]:
@@ -421,6 +424,336 @@ 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:
normalized = re.sub(
r"(?<!°)(-?\d+(?:\.\d+)?)\s*C(\+)?",
r"\1°C\2",
direct,
flags=re.IGNORECASE,
)
return normalized
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 _row_yes_buy_prob(row: Dict[str, Any]) -> Optional[float]:
if not isinstance(row, dict):
return None
return _norm_probability(row.get("yes_buy"))
def _has_actionable_yes_buy_quote(row: Dict[str, Any]) -> bool:
quote = _row_yes_buy_prob(row)
# 0 usually means no actionable orderbook bid, not a tradable quote.
return quote is not None and quote > 0.0
def _to_celsius(temp: Optional[float], temp_symbol: str) -> Optional[float]:
if temp is None:
return None
if "F" in (temp_symbol or "").upper():
return (temp - 32.0) * 5.0 / 9.0
return temp
def _extract_open_meteo_today_high_c(city_weather: Dict[str, Any]) -> Optional[float]:
forecast = city_weather.get("forecast") or {}
om_today = _sf(forecast.get("today_high"))
if om_today is None:
om = city_weather.get("open-meteo") or {}
daily = om.get("daily") or {}
series = daily.get("temperature_2m_max") or []
if isinstance(series, list) and series:
om_today = _sf(series[0])
if om_today is None:
return None
temp_symbol = str(city_weather.get("temp_symbol") or "")
return _to_celsius(om_today, temp_symbol)
def _extract_multi_model_anchor_high_c(
city_weather: Dict[str, Any],
) -> Tuple[Optional[float], Optional[str]]:
multi_model = city_weather.get("multi_model") or {}
temp_symbol = str(city_weather.get("temp_symbol") or "")
if isinstance(multi_model, dict):
anchor_model: Optional[str] = None
anchor_high_c: Optional[float] = None
for model_name, raw_value in multi_model.items():
value = _to_celsius(_sf(raw_value), temp_symbol)
if value is None:
continue
if anchor_high_c is None or value > anchor_high_c:
anchor_high_c = value
anchor_model = str(model_name or "").strip() or None
if anchor_high_c is not None:
return anchor_high_c, anchor_model
# Fallback keeps behavior resilient when multi-model data is unexpectedly missing.
fallback_high_c = _extract_open_meteo_today_high_c(city_weather)
if fallback_high_c is not None:
return fallback_high_c, "Open-Meteo"
return None, None
def _bucket_value(row: Dict[str, Any]) -> Optional[float]:
for key in ("value", "temp"):
value = _sf(row.get(key))
if value is not None:
return value
label = str(row.get("label") or "").strip()
m = re.search(r"(-?\d+(?:\.\d+)?)", label)
if not m:
return None
return _sf(m.group(1))
def _bucket_bounds(row: Dict[str, Any]) -> Optional[Tuple[Optional[float], Optional[float]]]:
value = _bucket_value(row)
if value is None:
return None
label = str(row.get("label") or "").lower()
is_upper_tail = any(key in label for key in ("+", "or higher", "or above", "and above"))
is_lower_tail = any(key in label for key in ("<=", "or lower", "or below", "and below"))
if is_upper_tail and not is_lower_tail:
return value, None
if is_lower_tail and not is_upper_tail:
return None, value
return value, value
def _distance_to_bucket(target: float, bounds: Tuple[Optional[float], Optional[float]]) -> float:
lower, upper = bounds
if lower is not None and target < lower:
return lower - target
if upper is not None and target > upper:
return target - upper
return 0.0
def _pick_bucket_for_forecast(
rows: List[Dict[str, Any]],
forecast_settlement: Optional[int],
forecast_today_high_c: Optional[float],
) -> Optional[Dict[str, Any]]:
if not rows:
return None
target = (
float(forecast_settlement)
if forecast_settlement is not None
else forecast_today_high_c
)
if target is None:
return None
best_row: Optional[Dict[str, Any]] = None
best_distance: Optional[float] = None
best_has_quote = False
best_probability = -1.0
best_rank = 10**9
for idx, row in enumerate(rows):
bounds = _bucket_bounds(row)
if not bounds:
continue
distance = _distance_to_bucket(target, bounds)
has_quote = _has_actionable_yes_buy_quote(row)
probability = _norm_probability(row.get("probability"))
probability_rank = probability if probability is not None else -1.0
if best_row is None:
best_row = row
best_distance = distance
best_has_quote = has_quote
best_probability = probability_rank
best_rank = idx
continue
assert best_distance is not None
if distance < best_distance:
best_row = row
best_distance = distance
best_has_quote = has_quote
best_probability = probability_rank
best_rank = idx
continue
if abs(distance - best_distance) <= 1e-9:
if has_quote and not best_has_quote:
best_row = row
best_distance = distance
best_has_quote = has_quote
best_probability = probability_rank
best_rank = idx
elif has_quote == best_has_quote and probability_rank > best_probability:
best_row = row
best_distance = distance
best_has_quote = has_quote
best_probability = probability_rank
best_rank = idx
elif (
has_quote == best_has_quote
and abs(probability_rank - best_probability) <= 1e-9
and idx < best_rank
):
best_row = row
best_distance = distance
best_has_quote = has_quote
best_probability = probability_rank
best_rank = idx
return best_row
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]] = []
all_bucket_rows: List[Dict[str, Any]] = []
source_buckets = scan.get("all_buckets")
if not isinstance(source_buckets, list) or not source_buckets:
source_buckets = scan.get("top_buckets") or []
if isinstance(source_buckets, list):
normalized = []
for row in source_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:
row_slug = str(row.get("slug") or "").strip()
row_market_url = f"https://polymarket.com/market/{row_slug}" if row_slug else None
all_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")),
"value": _sf(row.get("value") or row.get("temp")),
"slug": row_slug or None,
"market_url": row_market_url,
}
)
top_bucket_rows = all_bucket_rows[:4]
market_url = None
websocket = scan.get("websocket") or {}
if isinstance(websocket, dict):
market_url = str(websocket.get("market_url") or "").strip() or None
if not market_url:
primary_market = scan.get("primary_market") or {}
if isinstance(primary_market, dict):
slug = str(primary_market.get("slug") or "").strip()
if slug:
market_url = f"https://polymarket.com/market/{slug}"
anchor_today_high_c, anchor_model = _extract_multi_model_anchor_high_c(city_weather)
anchor_settlement = wu_round(anchor_today_high_c)
forecast_bucket = _pick_bucket_for_forecast(
rows=all_bucket_rows,
forecast_settlement=anchor_settlement,
forecast_today_high_c=anchor_today_high_c,
)
forecast_market_url = None
if isinstance(forecast_bucket, dict):
forecast_market_url = str(forecast_bucket.get("market_url") or "").strip() or None
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,
"all_bucket_rows": all_bucket_rows,
"anchor_today_high_c": anchor_today_high_c,
"anchor_settlement": anchor_settlement,
"anchor_model": anchor_model,
# Backward-compatible aliases for existing consumers.
"open_meteo_today_high_c": anchor_today_high_c,
"open_meteo_settlement": anchor_settlement,
"forecast_bucket": forecast_bucket,
"primary_market_url": market_url,
"market_url": forecast_market_url or market_url,
}
def _build_advice_cn(
rules: Dict[str, Dict[str, Any]],
temp_symbol: str,
@@ -472,6 +805,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 +816,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 +894,32 @@ 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'))})"
)
if market_snapshot.get("market_url"):
lines_zh.append(f"市场链接:{market_snapshot.get('market_url')}")
lines_zh.append(f"AI 建议:{advice}")
lines_zh.append(f"点击查看实时地图:{final_map}")
@@ -602,12 +963,279 @@ 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} | 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'))})"
)
if market_snapshot.get("market_url"):
lines_en.append(f"Market link: {market_snapshot.get('market_url')}")
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_telegram_messages_mispricing(
city_weather: Dict[str, Any],
rules: Dict[str, Dict[str, Any]],
market_snapshot: Optional[Dict[str, Any]] = None,
) -> Dict[str, str]:
temp_symbol = str(city_weather.get("temp_symbol") or "°C")
city_name = city_weather.get("display_name") or city_weather.get("name", "").title()
current = city_weather.get("current") or {}
current_temp = _sf(current.get("temp"))
if current_temp is None:
return {"zh": "", "en": ""}
snapshot = market_snapshot or _extract_market_snapshot(city_weather)
momentum = rules.get("momentum_spike", {})
local_time = str(city_weather.get("local_time") or "").strip()
obs_time = str(current.get("obs_time") or "").strip()
delta_temp = _sf(momentum.get("delta_temp"))
delta_min = momentum.get("delta_minutes")
momentum_emoji = "➡️"
if delta_temp is not None:
momentum_emoji = "🚀" if delta_temp > 0 else ("📉" if delta_temp < 0 else "➡️")
dynamic_text = f"实测 {current_temp:.1f}{temp_symbol}"
if delta_temp is not None and delta_min is not None:
dynamic_text = (
f"实测 {current_temp:.1f}{temp_symbol} "
f"({int(delta_min)}min 内 {delta_temp:+.1f}{temp_symbol}) {momentum_emoji}"
)
anchor_high_c = _sf(snapshot.get("anchor_today_high_c"))
if anchor_high_c is None:
anchor_high_c = _sf(snapshot.get("open_meteo_today_high_c"))
anchor_settle = snapshot.get("anchor_settlement")
if anchor_settle is None:
anchor_settle = snapshot.get("open_meteo_settlement")
anchor_model = str(snapshot.get("anchor_model") or "").strip()
forecast_bucket = snapshot.get("forecast_bucket") or {}
match_bucket_label = str(forecast_bucket.get("label") or "--").strip() or "--"
match_bucket_yes_prob = _norm_probability(forecast_bucket.get("yes_buy"))
match_bucket_yes = (
_fmt_cents(match_bucket_yes_prob)
if match_bucket_yes_prob is not None and match_bucket_yes_prob > 0.0
else "--"
)
market_url = str(
snapshot.get("market_url")
or snapshot.get("primary_market_url")
or ""
).strip()
lines_zh = [f"🚨 PolyWeather 错价雷达 [{city_name}]"]
lines_zh.append("")
if anchor_high_c is not None and anchor_settle is not None:
if anchor_model:
lines_zh.append(
f"基准:多模型最高温 {anchor_model} {anchor_high_c:.1f}C(结算参考 {anchor_settle}C"
)
else:
lines_zh.append(
f"基准:多模型最高温 {anchor_high_c:.1f}C(结算参考 {anchor_settle}C"
)
else:
lines_zh.append("基准:多模型最高温 --(结算参考 --)")
lines_zh.append(f"命中桶:{match_bucket_label} | Yes: {match_bucket_yes}")
lines_zh.append("触发:该桶 Yes 价格 < 10c,疑似低估")
lines_zh.append("")
lines_zh.append(f"动态:{dynamic_text}")
if local_time or obs_time:
if local_time and obs_time:
lines_zh.append(f"时间:当地 {local_time} | 观测 {obs_time}")
elif local_time:
lines_zh.append(f"时间:当地 {local_time}")
else:
lines_zh.append(f"时间:观测 {obs_time}")
lines_zh.append("")
if market_url:
lines_zh.append(f"市场链接:{market_url}")
lines_en = [
f"🚨 PolyWeather Mispricing Radar [{city_name}]",
"",
f"Now: {dynamic_text}",
]
if market_url:
lines_en.append(f"Market link: {market_url}")
return {"zh": "\n".join(lines_zh), "en": "\n".join(lines_en)}
def _select_rule_evidence(rule: Dict[str, Any], keys: List[str]) -> Dict[str, Any]:
out: Dict[str, Any] = {}
for key in keys:
if key in rule:
out[key] = rule.get(key)
return out
def _build_alert_evidence(
city_weather: Dict[str, Any],
rules: Dict[str, Dict[str, Any]],
triggered: List[Dict[str, Any]],
suppression: Dict[str, Any],
market_snapshot: Dict[str, Any],
temp_symbol: str,
) -> Dict[str, Any]:
current = city_weather.get("current") or {}
deb = city_weather.get("deb") or {}
momentum = rules.get("momentum_spike") or {}
breakthrough = rules.get("forecast_breakthrough") or {}
advection = rules.get("advection") or {}
ankara_center = rules.get("ankara_center_deb_hit") or {}
top_rows = []
for row in (market_snapshot.get("top_bucket_rows") or [])[:4]:
if not isinstance(row, dict):
continue
top_rows.append(
{
"label": row.get("label"),
"probability": row.get("probability"),
"yes_buy": row.get("yes_buy"),
"yes_sell": row.get("yes_sell"),
"market_url": row.get("market_url"),
}
)
trigger_types = [row.get("type") for row in triggered if row.get("type")]
forecast_bucket = market_snapshot.get("forecast_bucket") or {}
return {
"version": 1,
"city": city_weather.get("name"),
"generated_local_time": city_weather.get("local_time"),
"observed_at": current.get("obs_time"),
"temp_symbol": temp_symbol,
"inputs": {
"current_temp": _sf(current.get("temp")),
"deb_prediction": _sf(deb.get("prediction")),
"wu_settle": current.get("wu_settle"),
"obs_age_min": current.get("obs_age_min"),
},
"trigger_summary": {
"trigger_count": len(trigger_types),
"trigger_types": trigger_types,
"suppressed": bool(suppression.get("suppressed")),
"suppression_reason": suppression.get("reason"),
"suppression_snapshot": _select_rule_evidence(
suppression,
[
"minutes_since_peak",
"rollback",
"rollback_threshold",
"max_temp_time",
"max_so_far",
"current_temp",
],
),
},
"rules": {
"momentum_spike": _select_rule_evidence(
momentum,
[
"triggered",
"direction",
"delta_temp",
"delta_minutes",
"slope_30m",
"threshold_30m",
],
),
"forecast_breakthrough": _select_rule_evidence(
breakthrough,
[
"triggered",
"baseline_model",
"baseline_high",
"current_temp",
"margin",
"threshold",
"model_coverage",
],
),
"advection": _select_rule_evidence(
advection,
[
"triggered",
"lead_delta",
"threshold_delta",
"wind_now",
"wind_prev",
"turned_southerly",
"wind_alignment_deg",
"lead_window_minutes",
],
),
"ankara_center_deb_hit": _select_rule_evidence(
ankara_center,
[
"triggered",
"deb_prediction",
"airport_temp",
"margin_vs_deb",
"center_lead_vs_airport",
],
),
},
"market": {
"available": bool(market_snapshot.get("available")),
"market_prob": market_snapshot.get("market_prob"),
"model_prob": market_snapshot.get("model_prob"),
"edge_percent": market_snapshot.get("edge_percent"),
"yes_buy": market_snapshot.get("yes_buy"),
"yes_sell": market_snapshot.get("yes_sell"),
"spread": market_snapshot.get("spread"),
"signal_label": market_snapshot.get("signal_label"),
"confidence": market_snapshot.get("confidence"),
"top_bucket": market_snapshot.get("top_bucket"),
"top_bucket_prob": market_snapshot.get("top_bucket_prob"),
"anchor_today_high_c": market_snapshot.get("anchor_today_high_c"),
"anchor_settlement": market_snapshot.get("anchor_settlement"),
"anchor_model": market_snapshot.get("anchor_model"),
"open_meteo_today_high_c": market_snapshot.get("open_meteo_today_high_c"),
"open_meteo_settlement": market_snapshot.get("open_meteo_settlement"),
"forecast_bucket": {
"label": forecast_bucket.get("label"),
"probability": forecast_bucket.get("probability"),
"yes_buy": forecast_bucket.get("yes_buy"),
"yes_sell": forecast_bucket.get("yes_sell"),
"market_url": forecast_bucket.get("market_url"),
}
if isinstance(forecast_bucket, dict)
else None,
"top4": top_rows,
"market_url": market_snapshot.get("market_url"),
"primary_market_url": market_snapshot.get("primary_market_url"),
},
}
def build_trading_alerts(
city_weather: Dict[str, Any],
map_url: Optional[str] = None,
@@ -618,6 +1246,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),
@@ -654,11 +1283,18 @@ def build_trading_alerts(
if force_push and severity == "none":
severity = "medium"
telegram = _build_telegram_messages(
telegram = _build_telegram_messages_mispricing(
city_weather=city_weather,
rules=rules,
map_url=map_url,
market_snapshot=market_snapshot,
)
evidence = _build_alert_evidence(
city_weather=city_weather,
rules=rules,
triggered=triggered,
suppression=suppression,
market_snapshot=market_snapshot,
temp_symbol=temp_symbol,
)
return {
@@ -668,8 +1304,10 @@ def build_trading_alerts(
"severity": severity,
"trigger_count": len(triggered),
"rules": rules,
"market_snapshot": market_snapshot,
"suppression": suppression,
"triggered_alerts": triggered,
"evidence": evidence,
"telegram": telegram,
}
+20
View File
@@ -0,0 +1,20 @@
import math
from typing import Optional, Union
Number = Union[int, float]
def wu_round(value: Optional[Number]) -> Optional[int]:
"""
WU 结算口径四舍五入0.5 一律进位:
- 正数: floor(x + 0.5)
- 负数: ceil(x - 0.5)
"""
if value is None:
return None
x = float(value)
if x >= 0:
return int(math.floor(x + 0.5))
return int(math.ceil(x - 0.5))
+109 -49
View File
@@ -6,13 +6,16 @@ for both Telegram bot and web dashboard.
"""
import math
from datetime import datetime, timezone, timedelta
from typing import List, Optional, Tuple, Dict, Any
from src.analysis.deb_algorithm import (
calculate_dynamic_weights,
get_deb_accuracy,
update_daily_record,
_is_excluded_model_name,
)
from src.analysis.settlement_rounding import wu_round
from src.data_collection.city_risk_profiles import get_city_risk_profile
@@ -61,15 +64,15 @@ def analyze_weather_trend(
metar = weather_data.get("metar", {})
open_meteo = weather_data.get("open-meteo", {})
mgm = weather_data.get("mgm") or {}
mb = weather_data.get("meteoblue", {})
nws = weather_data.get("nws", {})
empty_result = ("", "", {})
if not metar or not open_meteo:
if not metar and not mgm:
return empty_result
max_so_far = _sf(metar.get("current", {}).get("max_temp_so_far"))
cur_temp = _sf(metar.get("current", {}).get("temp"))
max_so_far = _sf(metar.get("current", {}).get("max_temp_so_far")) if metar else _sf(mgm.get("current", {}).get("mgm_max_temp"))
cur_temp = _sf(metar.get("current", {}).get("temp")) if metar else _sf(mgm.get("current", {}).get("temp"))
daily = open_meteo.get("daily", {})
hourly = open_meteo.get("hourly", {})
times = hourly.get("time", [])
@@ -79,8 +82,6 @@ def analyze_weather_trend(
current_forecasts: Dict[str, Optional[float]] = {}
if daily.get("temperature_2m_max"):
current_forecasts["Open-Meteo"] = _sf(daily.get("temperature_2m_max")[0])
if mb.get("today_high") is not None:
current_forecasts["Meteoblue"] = _sf(mb.get("today_high"))
if nws.get("today_high") is not None:
current_forecasts["NWS"] = _sf(nws.get("today_high"))
@@ -90,7 +91,7 @@ def analyze_weather_trend(
mm_forecasts = weather_data.get("multi_model", {}).get("forecasts", {})
for m_name, m_val in mm_forecasts.items():
if m_val is not None:
if m_val is not None and not _is_excluded_model_name(m_name):
current_forecasts[m_name] = _sf(m_val)
forecast_highs = [h for h in current_forecasts.values() if h is not None]
@@ -101,18 +102,54 @@ def analyze_weather_trend(
wind_speed = metar.get("current", {}).get("wind_speed_kt", 0)
# === Local time ===
local_time_full = open_meteo.get("current", {}).get("local_time", "")
try:
local_date_str = local_time_full.split(" ")[0]
time_parts = local_time_full.split(" ")[1].split(":")
local_hour = int(time_parts[0])
local_minute = int(time_parts[1]) if len(time_parts) > 1 else 0
except Exception:
from datetime import datetime
local_date_str = datetime.now().strftime("%Y-%m-%d")
local_hour = datetime.now().hour
local_minute = datetime.now().minute
# === Local time/date (do not trust cached Open-Meteo local_time for date key) ===
utc_offset = _sf(open_meteo.get("utc_offset"))
if utc_offset is None and city_name:
try:
from src.data_collection.city_registry import CITY_REGISTRY
city_meta = CITY_REGISTRY.get(str(city_name).lower())
if isinstance(city_meta, dict):
utc_offset = _sf(city_meta.get("tz_offset"))
except Exception:
pass
city_now = None
if utc_offset is not None:
try:
city_now = datetime.now(timezone.utc).astimezone(
timezone(timedelta(seconds=int(utc_offset)))
)
except Exception:
city_now = None
local_time_full = str((open_meteo.get("current") or {}).get("local_time") or "").strip()
if city_now is not None:
local_date_str = city_now.strftime("%Y-%m-%d")
local_hour = city_now.hour
local_minute = city_now.minute
else:
try:
local_date_str = local_time_full.split(" ")[0]
time_parts = local_time_full.split(" ")[1].split(":")
local_hour = int(time_parts[0])
local_minute = int(time_parts[1]) if len(time_parts) > 1 else 0
except Exception:
fallback_now = datetime.now()
local_date_str = fallback_now.strftime("%Y-%m-%d")
local_hour = fallback_now.hour
local_minute = fallback_now.minute
# Use METAR observation date in city local time when available (reliable for actual_high date key).
metar_obs_time_raw = str(metar.get("observation_time") or "").strip()
if metar_obs_time_raw and utc_offset is not None:
try:
metar_obs_dt = datetime.fromisoformat(metar_obs_time_raw.replace("Z", "+00:00"))
local_date_str = metar_obs_dt.astimezone(
timezone(timedelta(seconds=int(utc_offset)))
).strftime("%Y-%m-%d")
except Exception:
pass
local_hour_frac = local_hour + local_minute / 60
# === DEB ===
@@ -212,6 +249,8 @@ def analyze_weather_trend(
ens_data = {"p10": ens_p10, "p90": ens_p90, "median": ens_median}
sigma = None
fallback_sigma = False
if ens_p10 is not None and ens_p90 is not None and ens_median is not None:
msg1 = (
f"📊 <b>集合预报</b>:中位数 {ens_median}{temp_symbol}"
@@ -281,6 +320,23 @@ def analyze_weather_trend(
sigma *= 0.3
elif first_peak_h <= local_hour_frac <= last_peak_h:
sigma *= 0.7
else:
# Fallback for sigma when ensemble is missing
fallback_sigma = True
if forecast_highs and len(forecast_highs) > 1:
sigma = max(0.6, (max(forecast_highs) - min(forecast_highs)) / 2.0)
else:
sigma = 1.0
if city_name:
acc = get_deb_accuracy(city_name)
if acc and acc[1] > sigma:
sigma = acc[1]
if local_hour_frac > last_peak_h:
sigma *= 0.3
elif first_peak_h <= local_hour_frac <= last_peak_h:
sigma *= 0.7
# === Dead Market ===
is_dead_market = False
@@ -294,30 +350,34 @@ def analyze_weather_trend(
probabilities: List[Dict[str, Any]] = []
forecast_miss_deg = 0.0
if ens_p10 is not None and ens_p90 is not None and not is_dead_market:
# Forecast miss magnitude
if max_so_far is not None and forecast_median is not None:
forecast_miss_deg = round(forecast_median - max_so_far, 1)
# Reality-anchored μ
if (
max_so_far is not None
and forecast_median is not None
and peak_status in ("past", "in_window")
and max_so_far < forecast_median - 2.0
):
if is_cooling or peak_status == "past":
mu = max_so_far
if (ens_p10 is not None and ens_p90 is not None) or fallback_sigma:
if not is_dead_market:
# Forecast miss magnitude
if max_so_far is not None and forecast_median is not None:
forecast_miss_deg = round(forecast_median - max_so_far, 1)
fallback_center = forecast_median if forecast_median is not None else (forecast_high if forecast_high is not None else cur_temp)
center = ens_median if ens_median is not None else fallback_center
# Reality-anchored μ
if (
max_so_far is not None
and forecast_median is not None
and peak_status in ("past", "in_window")
and max_so_far < forecast_median - 2.0
):
if is_cooling or peak_status == "past":
mu = max_so_far
else:
mu = max_so_far + 0.5
else:
mu = max_so_far + 0.5
else:
mu = (
forecast_median * 0.7 + ens_median * 0.3
if forecast_median is not None
else ens_median
)
if max_so_far is not None and max_so_far > mu:
mu = max_so_far + (0.3 if not is_cooling else 0.0)
mu = (
forecast_median * 0.7 + center * 0.3
if forecast_median is not None and center is not None
else center
)
if max_so_far is not None and mu is not None and max_so_far > mu:
mu = max_so_far + (0.3 if not is_cooling else 0.0)
# Forecast miss severity for AI
if forecast_miss_deg > 2.0 and peak_status in ("past", "in_window"):
@@ -348,7 +408,7 @@ def analyze_weather_trend(
ai_features.append(f"🎲 数学概率分布:{prob_str}")
elif is_dead_market:
settled_wu = round(max_so_far) if max_so_far is not None else 0
settled_wu = wu_round(max_so_far) if max_so_far is not None else 0
dead_msg = f"🎲 <b>结算预测</b>:已锁定 {settled_wu}{temp_symbol} (死盘确认)"
insights.append(dead_msg)
ai_features.append("🎲 状态: 确认死盘,结算已无悬念。")
@@ -375,7 +435,7 @@ def analyze_weather_trend(
# === Settlement boundary ===
if max_so_far is not None:
settled = round(max_so_far)
settled = wu_round(max_so_far)
fractional = max_so_far - int(max_so_far)
dist_to_boundary = abs(fractional - 0.5)
if dist_to_boundary <= 0.3:
@@ -437,7 +497,7 @@ def analyze_weather_trend(
ai_features.append(f"🌡️ 当前实测温度: {cur_temp}{temp_symbol}")
if max_so_far is not None:
ai_features.append(
f"🏔️ 今日实测最高温: {max_so_far}{temp_symbol} (WU结算={round(max_so_far)}{temp_symbol})。"
f"🏔️ 今日实测最高温: {max_so_far}{temp_symbol} (WU结算={wu_round(max_so_far)}{temp_symbol})。"
)
if city_name:
_profile = get_city_risk_profile(city_name)
@@ -486,7 +546,7 @@ def analyze_weather_trend(
for t, p in sorted_probs[:4]
]
elif is_dead_market and max_so_far is not None:
_prob_list = [{"value": round(max_so_far), "probability": 1.0}]
_prob_list = [{"value": wu_round(max_so_far), "probability": 1.0}]
update_daily_record(
city_name,
@@ -524,7 +584,7 @@ def analyze_weather_trend(
"forecast_miss_deg": forecast_miss_deg,
"max_so_far": max_so_far,
"cur_temp": cur_temp,
"wu_settle": round(max_so_far) if max_so_far is not None else None,
"wu_settle": wu_round(max_so_far) if max_so_far is not None else None,
}
display_str = "\n".join(insights) if insights else ""
return display_str, "\n".join(ai_features), structured
@@ -543,12 +603,12 @@ def calculate_prob_distribution(
# 0.5 * (1 + erf( (x-m)/(s*sqrt(2)) ))
return 0.5 * (1 + math.erf((x - m) / (sigma * math.sqrt(2))))
min_possible_wu = round(max_so_far) if max_so_far is not None else -999
min_possible_wu = wu_round(max_so_far) if max_so_far is not None else -999
probs = {}
# Range: mu +/- 3 sigma or at least +/- 2 degrees
search_range = max(2, int(sigma * 2.5))
target_mu = round(mu)
target_mu = wu_round(mu)
for n in range(target_mu - search_range, target_mu + search_range + 1):
if n < min_possible_wu:
+65 -1
View File
@@ -58,6 +58,62 @@ CITY_REGISTRY = {
"distance_km": 48.8,
"warning": "距离太远,海洋性vs大陆性气候差异大。",
},
"hong kong": {
"name": "Hong Kong",
"lat": 22.3080,
"lon": 113.9185,
"icao": "VHHH",
"tz_offset": 28800,
"use_fahrenheit": False,
"is_major": True,
"risk_level": "medium",
"risk_emoji": "🟡",
"airport_name": "Hong Kong 国际机场",
"distance_km": 31.0,
"warning": "海风与地形共同作用,午后对流触发后温度回落可能偏快。",
},
"shanghai": {
"name": "Shanghai",
"lat": 31.1434,
"lon": 121.8052,
"icao": "ZSPD",
"tz_offset": 28800,
"use_fahrenheit": False,
"is_major": True,
"risk_level": "medium",
"risk_emoji": "🟡",
"airport_name": "浦东国际机场",
"distance_km": 33.0,
"warning": "沿海平流与城市热岛叠加,午后最高温落点易出现1-2°C偏差。",
},
"singapore": {
"name": "Singapore",
"lat": 1.3644,
"lon": 103.9915,
"icao": "WSSS",
"tz_offset": 28800,
"use_fahrenheit": False,
"is_major": True,
"risk_level": "low",
"risk_emoji": "🟢",
"airport_name": "樟宜机场",
"distance_km": 17.5,
"warning": "全年温度振幅较小,主要受午后阵雨时段影响。",
},
"tokyo": {
"name": "Tokyo",
"lat": 35.5523,
"lon": 139.7798,
"icao": "RJTT",
"tz_offset": 32400,
"use_fahrenheit": False,
"is_major": True,
"risk_level": "medium",
"risk_emoji": "🟡",
"airport_name": "羽田机场",
"distance_km": 15.0,
"warning": "湾风与热岛效应并存,日最高温时点有时会后移。",
},
"toronto": {
"name": "Toronto",
"lat": 43.6777,
@@ -234,7 +290,10 @@ ALIASES = {
"nyc": "new york", "ny": "new york", "chi": "chicago",
"dal": "dallas", "mia": "miami", "atl": "atlanta",
"sea": "seattle", "tor": "toronto", "sel": "seoul",
"seo": "seoul", "ba": "buenos aires", "wel": "wellington",
"seo": "seoul", "hkg": "hong kong", "hk": "hong kong",
"sha": "shanghai", "sh": "shanghai", "sin": "singapore",
"sg": "singapore", "tok": "tokyo", "tyo": "tokyo",
"ba": "buenos aires", "wel": "wellington",
"luc": "lucknow", "sp": "sao paulo", "mun": "munich",
# Chinese names
@@ -249,6 +308,11 @@ ALIASES = {
"西雅图": "seattle",
"多伦多": "toronto",
"首尔": "seoul",
"香港": "hong kong",
"上海": "shanghai",
"新加坡": "singapore",
"东京": "tokyo",
"東京": "tokyo",
"布宜诺斯艾利斯": "buenos aires",
"惠灵顿": "wellington",
"勒克瑙": "lucknow",
File diff suppressed because it is too large Load Diff
+501 -120
View File
@@ -1,7 +1,9 @@
import csv
import os
import requests
import re
import time
import threading
from typing import Optional, Dict, List
from datetime import datetime, timedelta
from loguru import logger
@@ -30,6 +32,10 @@ class WeatherDataCollector:
"new york": ["KLGA", "KJFK", "KEWR", "KTEB", "KHPN"],
"paris": ["LFPG", "LFPO", "LFPB"],
"seoul": ["RKSI", "RKSS"],
"hong kong": ["VHHH", "VMMC", "ZGSZ"],
"shanghai": ["ZSPD", "ZSSS", "ZSNB", "ZSHC"],
"singapore": ["WSSS", "WSAP", "WMKK"],
"tokyo": ["RJTT", "RJAA", "RJAH", "RJTJ"],
"toronto": ["CYYZ", "CYTZ", "CYKF"],
"chicago": ["KORD", "KMDW", "KPWK", "KDPA"],
"dallas": ["KDAL", "KDFW", "KADS", "KGKY"],
@@ -44,10 +50,55 @@ class WeatherDataCollector:
self.config = config
weather_cfg = config.get("weather", {})
self.wunderground_key = weather_cfg.get("wunderground_api_key")
self.meteoblue_key = weather_cfg.get("meteoblue_api_key")
self.timeout = 30 # 增加超时以支持高延迟 VPS
self.session = requests.Session()
self.open_meteo_cache_ttl_sec = int(
os.getenv("OPEN_METEO_CACHE_TTL_SEC", "900")
)
self.open_meteo_ensemble_cache_ttl_sec = int(
os.getenv("OPEN_METEO_ENSEMBLE_CACHE_TTL_SEC", "900")
)
self.open_meteo_multi_model_cache_ttl_sec = int(
os.getenv("OPEN_METEO_MULTI_MODEL_CACHE_TTL_SEC", "900")
)
self._open_meteo_cache: Dict[str, Dict] = {}
self._ensemble_cache: Dict[str, Dict] = {}
self._multi_model_cache: Dict[str, Dict] = {}
self._open_meteo_cache_lock = threading.Lock()
self._ensemble_cache_lock = threading.Lock()
self._multi_model_cache_lock = threading.Lock()
# Open-Meteo 共享 429 冷却计时器:触发限流后所有 OM 端点暂停请求
self._open_meteo_rate_limit_until: float = 0.0
self._open_meteo_rl_cooldown: int = int(
os.getenv("OPEN_METEO_RATE_LIMIT_COOLDOWN_SEC", "900") # 默认 15 分钟
)
self._open_meteo_rl_lock = threading.Lock()
# Open-Meteo burst control: avoid hammering API with many cities at once.
self._open_meteo_min_interval_sec: float = float(
os.getenv("OPEN_METEO_MIN_CALL_INTERVAL_SEC", "3")
)
self._open_meteo_last_call_ts: float = 0.0
self._open_meteo_call_lock = threading.Lock()
self.metar_cache_ttl_sec = int(
os.getenv("METAR_CACHE_TTL_SEC", "600") # 默认 10 分钟
)
self._metar_cache: Dict[str, Dict] = {}
self._metar_cache_lock = threading.Lock()
# 磁盘持久化缓存:重启后即可加载上次的预报数据,避免冷启动请求爆发
self._disk_cache_path = os.getenv(
"OPEN_METEO_DISK_CACHE_PATH", "/app/data/open_meteo_cache.json"
)
self._disk_cache_max_age_sec = int(
os.getenv("OPEN_METEO_DISK_CACHE_MAX_AGE_SEC", "86400")
)
self._disk_cache_lock = threading.Lock()
self._disk_cache_last_mtime: float = 0.0
self._load_open_meteo_disk_cache()
logger.info(
f"Open-Meteo 磁盘缓存路径: {self._disk_cache_path} (max_age={self._disk_cache_max_age_sec}s)"
)
# 设置代理
proxy = config.get("proxy")
@@ -59,6 +110,117 @@ class WeatherDataCollector:
logger.info("天气数据采集器初始化完成。")
def _load_open_meteo_disk_cache(self) -> None:
"""启动时从磁盘加载 Open-Meteo 三类缓存,避免重启后冷启动打爆 API"""
import json as _json
try:
path = self._disk_cache_path
if not os.path.exists(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
_json.dump(
{
"forecast": {},
"ensemble": {},
"multi_model": {},
"saved_at": time.time(),
},
f,
)
self._disk_cache_last_mtime = os.path.getmtime(path)
return
current_mtime = os.path.getmtime(path)
if current_mtime <= self._disk_cache_last_mtime:
return
with open(path, "r", encoding="utf-8") as f:
saved = _json.load(f)
now = time.time()
max_age = max(600, self._disk_cache_max_age_sec)
loaded = 0
with self._open_meteo_cache_lock:
for key, entry in saved.get("forecast", {}).items():
if now - float(entry.get("t", 0)) < max_age:
old = self._open_meteo_cache.get(key)
if old is None or float(entry.get("t", 0)) >= float(old.get("t", 0)):
self._open_meteo_cache[key] = entry
loaded += 1
with self._ensemble_cache_lock:
for key, entry in saved.get("ensemble", {}).items():
if now - float(entry.get("t", 0)) < max_age:
old = self._ensemble_cache.get(key)
if old is None or float(entry.get("t", 0)) >= float(old.get("t", 0)):
self._ensemble_cache[key] = entry
loaded += 1
with self._multi_model_cache_lock:
for key, entry in saved.get("multi_model", {}).items():
if now - float(entry.get("t", 0)) < max_age:
old = self._multi_model_cache.get(key)
if old is None or float(entry.get("t", 0)) >= float(old.get("t", 0)):
self._multi_model_cache[key] = entry
loaded += 1
self._disk_cache_last_mtime = current_mtime
if loaded:
logger.info(f"✅ 从磁盘加载 Open-Meteo 缓存 {loaded} 条 ({self._disk_cache_path})")
except Exception as e:
logger.warning(f"磁盘缓存加载失败(首次启动不影响运行): {e}")
def _maybe_reload_open_meteo_disk_cache(self) -> None:
"""跨进程共享缓存:当缓存文件有更新时增量重载到当前进程内存"""
try:
path = self._disk_cache_path
if not os.path.exists(path):
return
current_mtime = os.path.getmtime(path)
if current_mtime <= self._disk_cache_last_mtime:
return
self._load_open_meteo_disk_cache()
except Exception:
# 不影响主流程
pass
def _flush_open_meteo_disk_cache(self) -> None:
"""将三类 Open-Meteo 内存缓存持久化到磁盘"""
import json as _json
try:
os.makedirs(os.path.dirname(self._disk_cache_path), exist_ok=True)
with self._open_meteo_cache_lock:
forecast_snapshot = dict(self._open_meteo_cache)
with self._ensemble_cache_lock:
ensemble_snapshot = dict(self._ensemble_cache)
with self._multi_model_cache_lock:
multi_model_snapshot = dict(self._multi_model_cache)
payload = {
"forecast": forecast_snapshot,
"ensemble": ensemble_snapshot,
"multi_model": multi_model_snapshot,
"saved_at": time.time(),
}
with self._disk_cache_lock:
tmp_path = self._disk_cache_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
_json.dump(payload, f)
os.replace(tmp_path, self._disk_cache_path) # 原子替换,防止写入一半时被读到
self._disk_cache_last_mtime = os.path.getmtime(self._disk_cache_path)
except Exception as e:
logger.warning(f"磁盘缓存写入失败: {e}")
def _wait_open_meteo_slot(self, endpoint: str) -> None:
"""Simple per-process rate gate for Open-Meteo endpoints."""
min_interval = self._open_meteo_min_interval_sec
if min_interval <= 0:
return
with self._open_meteo_call_lock:
now_ts = time.time()
wait_for = min_interval - (now_ts - self._open_meteo_last_call_ts)
if wait_for > 0:
logger.debug(
f"Open-Meteo {endpoint} 限流保护:sleep {wait_for:.2f}s (min_interval={min_interval:.2f}s)"
)
time.sleep(wait_for)
now_ts = time.time()
self._open_meteo_last_call_ts = now_ts
def fetch_from_openweather(self, city: str, country: str = None) -> Optional[Dict]:
"""
Fetch current weather and forecast from OpenWeatherMap
@@ -231,6 +393,14 @@ class WeatherDataCollector:
logger.warning(f"未找到城市 {city} 对应的 ICAO 代码")
return None
cache_key = f"{icao}:{utc_offset}:{use_fahrenheit}"
now_ts = time.time()
with self._metar_cache_lock:
cached = self._metar_cache.get(cache_key)
if cached and now_ts - cached["t"] < self.metar_cache_ttl_sec:
logger.debug(f"METAR cache hit {icao} age={int(now_ts - cached['t'])}s")
return cached["d"]
try:
# NOAA Aviation Weather API (免费,无需 Key)
url = "https://aviationweather.gov/api/data/metar"
@@ -244,7 +414,6 @@ class WeatherDataCollector:
response = self.session.get(
url,
params=params,
headers={"Cache-Control": "no-cache", "Pragma": "no-cache"},
timeout=self.timeout,
)
response.raise_for_status()
@@ -253,6 +422,7 @@ class WeatherDataCollector:
if not data:
return None
# 1. 取最新的观测作为当前状态
latest = data[0]
temp_c = latest.get("temp")
@@ -422,11 +592,17 @@ class WeatherDataCollector:
f"✈️ METAR {icao}: {temp:.1f}°{'F' if use_fahrenheit else 'C'} "
f"(obs: {obs_time})"
)
with self._metar_cache_lock:
self._metar_cache[cache_key] = {"d": result, "t": now_ts}
return result
except requests.exceptions.RequestException as e:
logger.error(f"METAR 请求失败 ({icao}): {e}")
with self._metar_cache_lock:
stale = self._metar_cache.get(cache_key)
if stale:
logger.warning(f"METAR {icao} 请求失败,使用缓存回退")
return stale["d"]
return None
except (KeyError, IndexError, TypeError) as e:
logger.error(f"METAR 数据解析失败 ({icao}): {e}")
@@ -576,22 +752,48 @@ class WeatherDataCollector:
# 5. Fallback for daily_forecasts from hourly data
if not results.get("daily_forecasts") and results.get("hourly"):
from collections import defaultdict
daily_max = defaultdict(list)
for h in results["hourly"]:
t = h.get("time", "")
temp = h.get("temp")
if t and temp is not None:
# Extract date from ISO timestamp like "2026-03-05T12:00:00.000Z"
date_str = t[:10]
daily_max[date_str].append(temp)
if daily_max:
results["daily_forecasts"] = {}
for d, temps in sorted(daily_max.items()):
results["daily_forecasts"][d] = max(temps)
# Guardrail: avoid treating short intraday snippets as full-day highs.
hourly_rows = results.get("hourly") or []
parsed_times = []
for h in hourly_rows:
t = str(h.get("time") or "")
if "T" not in t:
continue
try:
parsed_times.append(datetime.fromisoformat(t.replace("Z", "+00:00")))
except Exception:
continue
horizon_hours = 0.0
if len(parsed_times) >= 2:
parsed_times.sort()
horizon_hours = (
parsed_times[-1] - parsed_times[0]
).total_seconds() / 3600.0
if len(hourly_rows) >= 24 or horizon_hours >= 30:
from collections import defaultdict
daily_max = defaultdict(list)
for h in hourly_rows:
t = h.get("time", "")
temp = h.get("temp")
if t and temp is not None:
# Extract date from ISO timestamp like "2026-03-05T12:00:00.000Z"
date_str = t[:10]
daily_max[date_str].append(temp)
if daily_max:
results["daily_forecasts"] = {}
for d, temps in sorted(daily_max.items()):
results["daily_forecasts"][d] = max(temps)
logger.info(
f"📋 MGM daily_forecasts (from hourly fallback): "
f"{dict(results['daily_forecasts'])}"
)
else:
logger.info(
f"📋 MGM daily_forecasts (from hourly fallback): "
f"{dict(results['daily_forecasts'])}"
"📋 Skip MGM daily_forecasts hourly fallback: "
f"hourly points={len(hourly_rows)}, horizon={horizon_hours:.1f}h"
)
return results if "current" in results else None
@@ -910,6 +1112,31 @@ class WeatherDataCollector:
forecast_days: Number of forecast days to fetch (default 14 to cover all market dates)
use_fahrenheit: Whether to return temperatures in Fahrenheit (for US markets)
"""
cache_key = (
f"{round(float(lat), 4)}:{round(float(lon), 4)}:"
f"{forecast_days}:{'f' if use_fahrenheit else 'c'}"
)
self._maybe_reload_open_meteo_disk_cache()
now_ts = time.time()
# ── 429 冷却期检查(所有 Open-Meteo 端点共享)─────────────────
with self._open_meteo_rl_lock:
if now_ts < self._open_meteo_rate_limit_until:
remaining = int(self._open_meteo_rate_limit_until - now_ts)
logger.debug(f"Open-Meteo 冷却期中,跳过请求,还需 {remaining}s")
with self._open_meteo_cache_lock:
stale = self._open_meteo_cache.get(cache_key)
if stale and isinstance(stale.get("data"), dict):
return dict(stale["data"])
return None
with self._open_meteo_cache_lock:
cached = self._open_meteo_cache.get(cache_key)
if (
cached
and now_ts - float(cached.get("t", 0)) < self.open_meteo_cache_ttl_sec
):
cached_data = cached.get("data")
if isinstance(cached_data, dict):
return dict(cached_data)
try:
url = "https://api.open-meteo.com/v1/forecast"
params = {
@@ -920,7 +1147,6 @@ class WeatherDataCollector:
"daily": "temperature_2m_max,apparent_temperature_max,sunrise,sunset,sunshine_duration",
"timezone": "auto",
"forecast_days": forecast_days,
"_t": int(time.time()), # 禁用缓存,强制刷新
}
# 显式指定单位,防止 API 默认行为漂移
@@ -929,10 +1155,10 @@ class WeatherDataCollector:
else:
params["temperature_unit"] = "celsius"
self._wait_open_meteo_slot("forecast")
response = self.session.get(
url,
params=params,
headers={"Cache-Control": "no-cache", "Pragma": "no-cache"},
timeout=self.timeout,
)
response.raise_for_status()
@@ -982,7 +1208,7 @@ class WeatherDataCollector:
local_now = now_utc + timedelta(seconds=utc_offset)
local_time_str = local_now.strftime("%Y-%m-%d %H:%M")
return {
result = {
"source": "open-meteo",
"timestamp": now_utc.isoformat(),
"timezone": timezone_name,
@@ -995,8 +1221,41 @@ class WeatherDataCollector:
"daily": daily_data,
"unit": "fahrenheit" if use_fahrenheit else "celsius",
}
with self._open_meteo_cache_lock:
self._open_meteo_cache[cache_key] = {
"t": time.time(),
"data": dict(result),
}
self._flush_open_meteo_disk_cache()
return result
except Exception as e:
logger.error(f"Open-Meteo forecast failed: {e}")
status_code = getattr(getattr(e, "response", None), "status_code", None)
if status_code == 429:
retry_after_str = getattr(e.response, "headers", {}).get("Retry-After")
cooldown_to_use = self._open_meteo_rl_cooldown
if retry_after_str:
try:
parsed = int(retry_after_str)
if parsed > 0:
cooldown_to_use = min(parsed + 60, 3600) # Add 60s buffer, max 1 hour
logger.info(f"Open-Meteo 响应包含 Retry-After: {retry_after_str}s")
except ValueError:
pass
logger.warning(
f"Open-Meteo rate limited (429), fallback to cache if available: lat={lat}, lon={lon}"
)
# 设置全局冷却期,避免短时内重复触发 429
with self._open_meteo_rl_lock:
self._open_meteo_rate_limit_until = time.time() + cooldown_to_use
logger.warning(f"Open-Meteo 触发限流,设置 {cooldown_to_use}s 冷却期")
else:
logger.error(f"Open-Meteo forecast failed: {e}")
with self._open_meteo_cache_lock:
stale = self._open_meteo_cache.get(cache_key)
if stale and isinstance(stale.get("data"), dict):
fallback = dict(stale["data"])
fallback["stale_cache"] = True
return fallback
return None
def fetch_ensemble(
@@ -1009,6 +1268,33 @@ class WeatherDataCollector:
Open-Meteo Ensemble API 获取 51 成员集合预报
用于计算预报不确定性范围散度
"""
cache_key = (
f"{round(float(lat), 4)}:{round(float(lon), 4)}:"
f"{'f' if use_fahrenheit else 'c'}"
)
self._maybe_reload_open_meteo_disk_cache()
now_ts = time.time()
# ── 429 冷却期检查(所有 Open-Meteo 端点共享)─────────────────
with self._open_meteo_rl_lock:
if now_ts < self._open_meteo_rate_limit_until:
remaining = int(self._open_meteo_rate_limit_until - now_ts)
logger.debug(f"Open-Meteo Ensemble 冷却期中,跳过请求,还需 {remaining}s")
with self._ensemble_cache_lock:
stale = self._ensemble_cache.get(cache_key)
if stale and isinstance(stale.get("data"), dict):
return dict(stale["data"])
return None
with self._ensemble_cache_lock:
cached = self._ensemble_cache.get(cache_key)
if (
cached
and now_ts - float(cached.get("t", 0))
< self.open_meteo_ensemble_cache_ttl_sec
):
cached_data = cached.get("data")
if isinstance(cached_data, dict):
return dict(cached_data)
try:
url = "https://ensemble-api.open-meteo.com/v1/ensemble"
params = {
@@ -1017,17 +1303,16 @@ class WeatherDataCollector:
"daily": "temperature_2m_max",
"timezone": "auto",
"forecast_days": 3,
"_t": int(time.time()),
}
if use_fahrenheit:
params["temperature_unit"] = "fahrenheit"
else:
params["temperature_unit"] = "celsius"
self._wait_open_meteo_slot("ensemble")
response = self.session.get(
url,
params=params,
headers={"Cache-Control": "no-cache"},
timeout=self.timeout,
)
response.raise_for_status()
@@ -1077,9 +1362,38 @@ class WeatherDataCollector:
f"📊 Ensemble ({n} members): median={median:.1f}, "
f"p10={p10:.1f}, p90={p90:.1f}"
)
with self._ensemble_cache_lock:
self._ensemble_cache[cache_key] = {
"t": time.time(),
"data": dict(result),
}
self._flush_open_meteo_disk_cache()
return result
except Exception as e:
logger.warning(f"Ensemble API 请求失败: {e}")
status_code = getattr(getattr(e, "response", None), "status_code", None)
if status_code == 429:
retry_after_str = getattr(e.response, "headers", {}).get("Retry-After")
cooldown_to_use = self._open_meteo_rl_cooldown
if retry_after_str:
try:
parsed = int(retry_after_str)
if parsed > 0:
cooldown_to_use = min(parsed + 60, 3600)
except ValueError:
pass
logger.warning(
f"Ensemble API rate limited (429), fallback to cache if available: lat={lat}, lon={lon}"
)
with self._open_meteo_rl_lock:
self._open_meteo_rate_limit_until = time.time() + cooldown_to_use
else:
logger.warning(f"Ensemble API 请求失败: {e}")
with self._ensemble_cache_lock:
stale = self._ensemble_cache.get(cache_key)
if stale and isinstance(stale.get("data"), dict):
fallback = dict(stale["data"])
fallback["stale_cache"] = True
return fallback
return None
def fetch_multi_model(
@@ -1101,6 +1415,33 @@ class WeatherDataCollector:
返回 3 天的预报数据支持今日+明日共识分析
"""
cache_key = (
f"{round(float(lat), 4)}:{round(float(lon), 4)}:"
f"{'f' if use_fahrenheit else 'c'}"
)
self._maybe_reload_open_meteo_disk_cache()
now_ts = time.time()
# ── 429 冷却期检查(所有 Open-Meteo 端点共享)─────────────────
with self._open_meteo_rl_lock:
if now_ts < self._open_meteo_rate_limit_until:
remaining = int(self._open_meteo_rate_limit_until - now_ts)
logger.debug(f"Open-Meteo Multi-model 冷却期中,跳过请求,还需 {remaining}s")
with self._multi_model_cache_lock:
stale = self._multi_model_cache.get(cache_key)
if stale and isinstance(stale.get("data"), dict):
return dict(stale["data"])
return None
with self._multi_model_cache_lock:
cached = self._multi_model_cache.get(cache_key)
if (
cached
and now_ts - float(cached.get("t", 0))
< self.open_meteo_multi_model_cache_ttl_sec
):
cached_data = cached.get("data")
if isinstance(cached_data, dict):
return dict(cached_data)
try:
url = "https://api.open-meteo.com/v1/forecast"
models = "ecmwf_ifs025,gfs_seamless,icon_seamless,gem_seamless,jma_seamless"
@@ -1111,15 +1452,14 @@ class WeatherDataCollector:
"models": models,
"timezone": "auto",
"forecast_days": 3,
"_t": int(time.time()),
}
if use_fahrenheit:
params["temperature_unit"] = "fahrenheit"
self._wait_open_meteo_slot("multi-model")
response = self.session.get(
url,
params=params,
headers={"Cache-Control": "no-cache"},
timeout=self.timeout,
)
response.raise_for_status()
@@ -1161,84 +1501,45 @@ class WeatherDataCollector:
f"🔬 Multi-model ({len(forecasts)}个, {len(daily_forecasts)}天): {labels_str}"
)
return {
result = {
"source": "multi_model",
"forecasts": forecasts, # 今天 {"ECMWF": 12.3, "GFS": 11.8, ...} (向后兼容)
"daily_forecasts": daily_forecasts, # 按天 {"2026-02-23": {...}, "2026-02-24": {...}}
"dates": dates,
"unit": "fahrenheit" if use_fahrenheit else "celsius",
}
except Exception as e:
logger.warning(f"Multi-model API 请求失败: {e}")
return None
def fetch_from_meteoblue(
self,
lat: float,
lon: float,
timezone_name: str = "UTC",
use_fahrenheit: bool = False,
) -> Optional[Dict]:
"""
通过 Meteoblue 官方 API 获取高精度预测数据
"""
if not self.meteoblue_key:
logger.warning("Meteoblue API Key 未配置,跳过抓取。")
return None
try:
# 1. 调用官方 API (使用 basic-day 包,它是多模型 ML 融合结果)
# 格式: https://my.meteoblue.com/packages/basic-day?apikey=KEY&lat=LAT&lon=LON&format=json
url = "https://my.meteoblue.com/packages/basic-day"
params = {
"apikey": self.meteoblue_key,
"lat": lat,
"lon": lon,
"format": "json",
"as_daylight": "true",
}
response = self.session.get(url, params=params, timeout=self.timeout)
response.raise_for_status()
data = response.json()
day_data = data.get("data_day", {})
max_temps = day_data.get("temperature_max", [])
if not max_temps:
logger.warning(
f"Meteoblue API 返回数据中找不到最高温 (坐标: {lat},{lon})"
)
return None
# 2. 转换单位
def c_to_f(c):
return round((c * 9 / 5) + 32, 1)
result = {
"source": "meteoblue",
"today_high": None,
"daily_highs": [],
"unit": "fahrenheit" if use_fahrenheit else "celsius",
"url": f"https://www.meteoblue.com/en/weather/week/{lat}N{lon}E", # 仅供参考
}
# 提取今日最高
mb_today_c = max_temps[0]
result["today_high"] = c_to_f(mb_today_c) if use_fahrenheit else mb_today_c
# 提取接下来几天的最高温
if use_fahrenheit:
result["daily_highs"] = [c_to_f(t) for t in max_temps]
else:
result["daily_highs"] = max_temps
logger.info(
f"✅ Meteoblue API 获取成功 ({lat},{lon}): 今天 {result['today_high']}{result['unit']}"
)
with self._multi_model_cache_lock:
self._multi_model_cache[cache_key] = {
"t": time.time(),
"data": dict(result),
}
self._flush_open_meteo_disk_cache()
return result
except Exception as e:
logger.error(f"Meteoblue API fetch failed: {e}")
status_code = getattr(getattr(e, "response", None), "status_code", None)
if status_code == 429:
retry_after_str = getattr(e.response, "headers", {}).get("Retry-After")
cooldown_to_use = self._open_meteo_rl_cooldown
if retry_after_str:
try:
parsed = int(retry_after_str)
if parsed > 0:
cooldown_to_use = min(parsed + 60, 3600)
except ValueError:
pass
logger.warning(
f"Multi-model API rate limited (429), fallback to cache if available: lat={lat}, lon={lon}"
)
with self._open_meteo_rl_lock:
self._open_meteo_rate_limit_until = time.time() + cooldown_to_use
else:
logger.warning(f"Multi-model API 请求失败: {e}")
with self._multi_model_cache_lock:
stale = self._multi_model_cache.get(cache_key)
if stale and isinstance(stale.get("data"), dict):
fallback = dict(stale["data"])
fallback["stale_cache"] = True
return fallback
return None
def extract_date_from_title(self, title: str) -> Optional[str]:
@@ -1359,6 +1660,16 @@ class WeatherDataCollector:
"亚特兰大": "Atlanta",
"seoul": "Seoul",
"首尔": "Seoul",
"hong kong": "Hong Kong",
"hong kong international airport": "Hong Kong",
"香港": "Hong Kong",
"shanghai": "Shanghai",
"上海": "Shanghai",
"singapore": "Singapore",
"新加坡": "Singapore",
"tokyo": "Tokyo",
"东京": "Tokyo",
"東京": "Tokyo",
"toronto": "Toronto",
"多伦多": "Toronto",
"ankara": "Ankara",
@@ -1407,8 +1718,43 @@ class WeatherDataCollector:
return None
def _evict_city_caches(
self,
city: str,
lat: Optional[float],
lon: Optional[float],
use_fahrenheit: bool,
) -> None:
"""Drop in-memory caches for one city before a force-refresh query."""
if lat is not None and lon is not None:
base = f"{round(float(lat), 4)}:{round(float(lon), 4)}"
unit = "f" if use_fahrenheit else "c"
open_meteo_key = f"{base}:14:{unit}"
ensemble_key = f"{base}:{unit}"
multi_model_key = ensemble_key
with self._open_meteo_cache_lock:
self._open_meteo_cache.pop(open_meteo_key, None)
with self._ensemble_cache_lock:
self._ensemble_cache.pop(ensemble_key, None)
with self._multi_model_cache_lock:
self._multi_model_cache.pop(multi_model_key, None)
icao = self.get_icao_code(city)
if icao:
prefix = f"{icao}:"
with self._metar_cache_lock:
for key in list(self._metar_cache.keys()):
if key.startswith(prefix):
self._metar_cache.pop(key, None)
def fetch_all_sources(
self, city: str, lat: float = None, lon: float = None, country: str = None
self,
city: str,
lat: float = None,
lon: float = None,
country: str = None,
force_refresh: bool = False,
) -> Dict:
"""
Fetch weather data from all available sources
@@ -1445,6 +1791,20 @@ class WeatherDataCollector:
# 严格判断是否为美国市场(必须完全匹配列表或缩写)
use_fahrenheit = city_lower in us_cities
if force_refresh:
self._evict_city_caches(
city=city,
lat=lat,
lon=lon,
use_fahrenheit=use_fahrenheit,
)
# Turkish cities: keep MGM model fallback alive when Open-Meteo is rate-limited.
turkish_provinces = {
"ankara": ("17130", "Ankara"), # MGM center station
"istanbul": ("17060", "Istanbul"),
}
if use_fahrenheit:
logger.info(f"🌡️ {city} 使用华氏度 (°F)")
else:
@@ -1466,23 +1826,14 @@ class WeatherDataCollector:
# 对土耳其城市,额外获取 MGM 官方数据与周边测站
turkish_provinces = {
"ankara": ("17128", "Ankara"), # 使用机场站 (Esenboğa Havalimanı) 作为结算参考主站
"ankara": ("17130", "Ankara"), # use one MGM station consistently
"istanbul": ("17060", "Istanbul"),
}
if city_lower in turkish_provinces:
istno, province = turkish_provinces[city_lower]
# 核心逻辑:实测用 istno (17128), 预报强制去 17130 拿
# Use one station for both current conditions and forecasts.
mgm_data = self.fetch_from_mgm(istno)
# 如果当前是机场站 (17128),我们额外去 17130 拿一次预报
if istno == "17128":
mgm_city_center = self.fetch_from_mgm("17130")
if mgm_city_center and mgm_data:
# 用市中心的预报覆盖机场可能缺失的预报
mgm_data["today_high"] = mgm_city_center.get("today_high")
mgm_data["daily_forecasts"] = mgm_city_center.get("daily_forecasts")
logger.info("⚡ 已同步 MGM 安卡拉总部 (17130) 的官方最高温预报")
if mgm_data:
results["mgm"] = mgm_data
nearby = self.fetch_mgm_nearby_stations(province, root_ist_no=istno)
@@ -1503,15 +1854,6 @@ class WeatherDataCollector:
# 获取时区偏移以过滤 METAR
utc_offset = open_meteo.get("utc_offset", 0)
mb_data = self.fetch_from_meteoblue(
lat,
lon,
timezone_name=open_meteo.get("timezone", "UTC"),
use_fahrenheit=use_fahrenheit,
)
if mb_data:
results["meteoblue"] = mb_data
# 对美国城市,额外获取 NWS 高精预报
if use_fahrenheit:
nws_data = self.fetch_nws(lat, lon)
@@ -1531,13 +1873,52 @@ class WeatherDataCollector:
results["multi_model"] = mm_data
else:
# Open-Meteo 失败时,仍然尝试获取 METAR 和 NWS
metar_data = self.fetch_metar(city, use_fahrenheit=use_fahrenheit)
fallback_utc_offset = int(
self.CITY_REGISTRY.get(city_lower, {}).get("tz_offset", 0)
)
metar_data = self.fetch_metar(
city,
use_fahrenheit=use_fahrenheit,
utc_offset=fallback_utc_offset,
)
if metar_data:
results["metar"] = metar_data
# Turkish fallback: keep MGM forecasts and nearby stations available
if city_lower in turkish_provinces:
istno, province = turkish_provinces[city_lower]
mgm_data = self.fetch_from_mgm(istno)
if mgm_data:
results["mgm"] = mgm_data
nearby = self.fetch_mgm_nearby_stations(
province, root_ist_no=istno
)
if nearby:
results["mgm_nearby"] = nearby
# Global nearby fallback from METAR clusters
if city_lower in self.CITY_METAR_CLUSTERS and "mgm_nearby" not in results:
cluster_icaos = self.CITY_METAR_CLUSTERS[city_lower]
cluster_data = self.fetch_metar_nearby_cluster(
cluster_icaos, use_fahrenheit=use_fahrenheit
)
if cluster_data:
results["mgm_nearby"] = cluster_data
if use_fahrenheit:
nws_data = self.fetch_nws(lat, lon)
if nws_data:
results["nws"] = nws_data
# Still try ensemble / multi-model from stale cache while OM is cooling down
ens_data = self.fetch_ensemble(lat, lon, use_fahrenheit=use_fahrenheit)
if ens_data:
results["ensemble"] = ens_data
mm_data = self.fetch_multi_model(
lat, lon, use_fahrenheit=use_fahrenheit
)
if mm_data:
results["multi_model"] = mm_data
else:
# 降级方案(无经纬度)
metar_data = self.fetch_metar(city, use_fahrenheit=use_fahrenheit)
+1
View File
@@ -0,0 +1 @@
"""On-chain monitoring modules."""
+528
View File
@@ -0,0 +1,528 @@
import json
import os
import threading
import time
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, List, Optional, Set, Tuple
from loguru import logger
from web3 import Web3
TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
APPROVAL_TOPIC = "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925"
ERC20_ABI = [
{
"constant": True,
"inputs": [],
"name": "symbol",
"outputs": [{"name": "", "type": "string"}],
"type": "function",
},
{
"constant": True,
"inputs": [],
"name": "decimals",
"outputs": [{"name": "", "type": "uint8"}],
"type": "function",
},
]
# Source: Polymarket official developer docs (Polygon contract addresses)
# https://docs.polymarket.com/developers/market-makers/setup
DEFAULT_POLYMARKET_CONTRACTS: Dict[str, str] = {
"USDC.e": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
"CTF": "0x4d97dcd97ec945f40cf65f87097ace5ea0476045",
"CTF_EXCHANGE": "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E",
"NEG_RISK_CTF_EXCHANGE": "0xC5d563A36AE78145C45a50134d48A1215220f80a",
"NEG_RISK_ADAPTER": "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296",
}
def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _env_int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
return int(raw)
except Exception:
return default
def _short(addr: str, left: int = 6, right: int = 4) -> str:
if not addr:
return "unknown"
if len(addr) <= left + right + 2:
return addr
return f"{addr[:left + 2]}...{addr[-right:]}"
def _state_file() -> str:
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return os.path.join(root, "data", "polygon_wallet_watch_state.json")
def _load_state(path: str) -> Dict[str, Any]:
if not os.path.exists(path):
return {"last_scanned_block": 0, "seen_tx": {}}
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
if isinstance(data, dict):
data.setdefault("last_scanned_block", 0)
data.setdefault("seen_tx", {})
return data
except Exception as exc:
logger.warning(f"failed to load polygon watch state: {exc}")
return {"last_scanned_block": 0, "seen_tx": {}}
def _save_state(path: str, state: Dict[str, Any]) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = f"{path}.tmp"
with open(tmp_path, "w", encoding="utf-8") as fh:
json.dump(state, fh, ensure_ascii=False, indent=2)
os.replace(tmp_path, path)
def _cleanup_seen_tx(state: Dict[str, Any], now_ts: int, keep_sec: int) -> None:
seen = state.get("seen_tx", {})
if not isinstance(seen, dict):
state["seen_tx"] = {}
return
stale = [key for key, value in seen.items() if now_ts - int(value or 0) > keep_sec]
for tx_hash in stale:
seen.pop(tx_hash, None)
def _normalize_addr(value: Any) -> str:
if value is None:
return ""
text = str(value).strip().lower()
if text.startswith("0x") and len(text) == 42:
return text
return ""
def _parse_addresses(raw: Optional[str]) -> Set[str]:
out: Set[str] = set()
if not raw:
return out
for part in raw.split(","):
addr = _normalize_addr(part)
if addr:
out.add(addr)
return out
def _parse_polymarket_contracts(raw: Optional[str]) -> Dict[str, str]:
"""
Parse env like:
- "0xabc...,0xdef..."
- "CTF:0xabc...,EXCHANGE:0xdef..."
"""
result: Dict[str, str] = {}
if not raw:
return result
for part in raw.split(","):
segment = str(part).strip()
if not segment:
continue
label = "CUSTOM_PM"
address_part = segment
if ":" in segment:
maybe_label, maybe_addr = segment.split(":", 1)
maybe_addr_n = _normalize_addr(maybe_addr)
if maybe_addr_n:
label = (maybe_label or "CUSTOM_PM").strip() or "CUSTOM_PM"
address_part = maybe_addr_n
addr = _normalize_addr(address_part)
if not addr:
continue
if addr not in result:
result[addr] = label
return result
def _build_polymarket_contract_map() -> Dict[str, str]:
include_defaults = _env_bool("POLYGON_WALLET_WATCH_INCLUDE_DEFAULT_PM_CONTRACTS", True)
merged: Dict[str, str] = {}
if include_defaults:
for label, addr in DEFAULT_POLYMARKET_CONTRACTS.items():
normalized = _normalize_addr(addr)
if normalized:
merged[normalized] = label
custom = _parse_polymarket_contracts(os.getenv("POLYGON_WALLET_WATCH_POLYMARKET_CONTRACTS"))
for addr, label in custom.items():
merged[addr] = label
return merged
def _polygon_scan_tx_url(tx_hash: str) -> str:
base = os.getenv("POLYGON_WALLET_WATCH_TX_BASE") or "https://polygonscan.com/tx/"
return f"{base.rstrip('/')}/{tx_hash}"
def _polygon_scan_addr_url(address: str) -> str:
base = os.getenv("POLYGON_WALLET_WATCH_ADDR_BASE") or "https://polygonscan.com/address/"
return f"{base.rstrip('/')}/{address}"
def _format_matic(wei_value: int) -> str:
try:
matic = Decimal(wei_value) / Decimal(10**18)
except (InvalidOperation, ValueError):
return "0"
if matic == matic.to_integral_value():
return f"{int(matic)}"
return f"{matic.normalize():f}".rstrip("0").rstrip(".")
def _format_amount(amount: Decimal) -> str:
if amount == amount.to_integral_value():
return str(int(amount))
return f"{amount.normalize():f}".rstrip("0").rstrip(".")
def _safe_lower(value: Any) -> str:
if value is None:
return ""
return str(value).lower()
def _topic_to_addr(topic: Any) -> str:
try:
return _normalize_addr("0x" + topic.hex()[-40:])
except Exception:
return ""
def _get_token_meta(
w3: Web3,
token_addr: str,
token_meta_cache: Dict[str, Tuple[str, int]],
) -> Tuple[str, int]:
symbol, decimals = token_meta_cache.get(token_addr, ("ERC20", 18))
if token_addr in token_meta_cache:
return symbol, decimals
try:
token = w3.eth.contract(address=Web3.to_checksum_address(token_addr), abi=ERC20_ABI)
symbol_raw = token.functions.symbol().call()
decimals_raw = token.functions.decimals().call()
symbol = str(symbol_raw or "ERC20")
decimals = int(decimals_raw)
except Exception:
symbol = "ERC20"
decimals = 18
token_meta_cache[token_addr] = (symbol, decimals)
return symbol, decimals
def _extract_receipt_signals(
w3: Web3,
receipt: Any,
watch_set: Set[str],
pm_contracts: Dict[str, str],
token_meta_cache: Dict[str, Tuple[str, int]],
) -> Dict[str, Any]:
transfer_lines: List[str] = []
approval_lines: List[str] = []
touched_labels: Set[str] = set()
pm_hit = False
for log in receipt.logs or []:
try:
log_addr = _normalize_addr(log.address)
if log_addr in pm_contracts:
pm_hit = True
touched_labels.add(pm_contracts[log_addr])
topics = log.topics or []
if not topics:
continue
topic0 = topics[0].hex().lower()
if topic0 == TRANSFER_TOPIC and len(topics) >= 3:
from_addr = _topic_to_addr(topics[1])
to_addr = _topic_to_addr(topics[2])
if from_addr not in watch_set and to_addr not in watch_set:
continue
other_addr = to_addr if from_addr in watch_set else from_addr
other_label = pm_contracts.get(other_addr)
if other_label:
pm_hit = True
touched_labels.add(other_label)
symbol, decimals = _get_token_meta(w3, log_addr, token_meta_cache)
amount_int = int(log.data.hex(), 16) if log.data else 0
amount = Decimal(amount_int) / (Decimal(10) ** Decimal(max(decimals, 0)))
if from_addr in watch_set and to_addr in watch_set:
direction = "SELF"
elif to_addr in watch_set:
direction = "IN"
else:
direction = "OUT"
# Keep transfer line only when it is clearly Polymarket related.
if other_label or log_addr in pm_contracts:
target = other_label or pm_contracts.get(log_addr) or _short(other_addr)
transfer_lines.append(
f"- {direction} {symbol}: {_format_amount(amount)} (对手: {target})"
)
if topic0 == APPROVAL_TOPIC and len(topics) >= 3:
owner = _topic_to_addr(topics[1])
spender = _topic_to_addr(topics[2])
if owner not in watch_set:
continue
spender_label = pm_contracts.get(spender)
if not spender_label:
continue
pm_hit = True
touched_labels.add(spender_label)
symbol, decimals = _get_token_meta(w3, log_addr, token_meta_cache)
amount_int = int(log.data.hex(), 16) if log.data else 0
amount = Decimal(amount_int) / (Decimal(10) ** Decimal(max(decimals, 0)))
approval_lines.append(
f"- APPROVE {symbol}: {_format_amount(amount)} -> {spender_label}"
)
except Exception:
continue
return {
"pm_hit": pm_hit,
"transfer_lines": transfer_lines,
"approval_lines": approval_lines,
"touched_labels": sorted(touched_labels),
}
def _build_message(
tx: Any,
block_ts: int,
matched_wallet: str,
touched_labels: List[str],
transfer_lines: List[str],
approval_lines: List[str],
tx_to_label: Optional[str],
) -> str:
tx_hash = tx["hash"].hex()
from_addr = _safe_lower(tx.get("from"))
to_addr = _safe_lower(tx.get("to"))
if from_addr == matched_wallet and to_addr == matched_wallet:
direction = "SELF"
elif to_addr == matched_wallet:
direction = "IN"
elif from_addr == matched_wallet:
direction = "OUT"
else:
direction = "RELATED"
matic_value = int(tx.get("value", 0) or 0)
block_time = datetime.fromtimestamp(block_ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
selector = str(tx.get("input") or "")[:10] if tx.get("input") else "0x"
lines = [
"⛓ Polymarket 钱包动作",
f"钱包: {_short(matched_wallet)}",
f"方向: {direction}",
f"MATIC: {_format_matic(matic_value)}",
f"区块: {tx.get('blockNumber')}",
f"时间: {block_time}",
f"方法选择器: {selector}",
]
if tx_to_label:
lines.append(f"直连合约: {tx_to_label}")
if touched_labels:
lines.append(f"相关合约: {', '.join(touched_labels)}")
if transfer_lines:
lines.append("Token 动作:")
lines.extend(transfer_lines[:6])
if approval_lines:
lines.append("授权动作:")
lines.extend(approval_lines[:4])
lines.append(f"Tx: {tx_hash}")
lines.append(f"交易链接: {_polygon_scan_tx_url(tx_hash)}")
lines.append(f"钱包链接: {_polygon_scan_addr_url(matched_wallet)}")
return "\n".join(lines)
def start_polygon_wallet_watch_loop(bot: Any) -> Optional[threading.Thread]:
enabled = _env_bool("POLYGON_WALLET_WATCH_ENABLED", False)
chat_id = os.getenv("TELEGRAM_CHAT_ID")
rpc_url = os.getenv("POLYGON_RPC_URL")
watch_set = _parse_addresses(os.getenv("POLYGON_WALLET_WATCH_ADDRESSES"))
polymarket_only = _env_bool("POLYGON_WALLET_WATCH_POLYMARKET_ONLY", True)
pm_contracts = _build_polymarket_contract_map()
if not enabled:
logger.info("polygon wallet watcher disabled")
return None
if not chat_id:
logger.warning("polygon wallet watcher skipped: TELEGRAM_CHAT_ID is not set")
return None
if not rpc_url:
logger.warning("polygon wallet watcher skipped: POLYGON_RPC_URL is not set")
return None
if not watch_set:
logger.warning("polygon wallet watcher skipped: POLYGON_WALLET_WATCH_ADDRESSES is empty")
return None
if polymarket_only and not pm_contracts:
logger.warning("polygon wallet watcher skipped: no polymarket contracts configured")
return None
poll_sec = max(3, _env_int("POLYGON_WALLET_WATCH_INTERVAL_SEC", 8))
confirmations = max(0, _env_int("POLYGON_WALLET_WATCH_CONFIRMATIONS", 2))
max_blocks_per_cycle = max(1, _env_int("POLYGON_WALLET_WATCH_MAX_BLOCKS_PER_CYCLE", 30))
seen_ttl_sec = max(3600, _env_int("POLYGON_WALLET_WATCH_SEEN_TTL_SEC", 7 * 86400))
state_path = _state_file()
provider_timeout = max(5, _env_int("POLYGON_WALLET_WATCH_RPC_TIMEOUT_SEC", 10))
w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": provider_timeout}))
def _runner() -> None:
token_meta_cache: Dict[str, Tuple[str, int]] = {}
state = _load_state(state_path)
if not w3.is_connected():
logger.error("polygon wallet watcher failed: cannot connect to POLYGON_RPC_URL")
return
try:
chain_id = int(w3.eth.chain_id)
if chain_id != 137:
logger.warning(f"polygon wallet watcher connected to unexpected chain_id={chain_id}")
except Exception as exc:
logger.warning(f"polygon wallet watcher cannot read chain id: {exc}")
latest_block = int(w3.eth.block_number)
if int(state.get("last_scanned_block") or 0) <= 0:
state["last_scanned_block"] = max(0, latest_block - confirmations)
_save_state(state_path, state)
logger.info(
f"polygon wallet watcher started wallets={len(watch_set)} "
f"polymarket_only={polymarket_only} pm_contracts={len(pm_contracts)} "
f"poll={poll_sec}s confirmations={confirmations} state_path={state_path}"
)
while True:
cycle_ts = int(time.time())
try:
_cleanup_seen_tx(state, cycle_ts, seen_ttl_sec)
latest = int(w3.eth.block_number)
safe_latest = latest - confirmations
last_scanned = int(state.get("last_scanned_block") or 0)
if safe_latest <= last_scanned:
time.sleep(poll_sec)
continue
from_block = last_scanned + 1
to_block = min(safe_latest, from_block + max_blocks_per_cycle - 1)
for block_num in range(from_block, to_block + 1):
block = w3.eth.get_block(block_num, full_transactions=True)
block_ts = int(block.get("timestamp") or cycle_ts)
for tx in block.transactions or []:
tx_hash = tx["hash"].hex().lower()
from_addr = _safe_lower(tx.get("from"))
to_addr = _safe_lower(tx.get("to"))
matched_wallet = ""
if from_addr in watch_set:
matched_wallet = from_addr
elif to_addr in watch_set:
matched_wallet = to_addr
if not matched_wallet:
continue
if tx_hash in state.get("seen_tx", {}):
continue
tx_to = _normalize_addr(tx.get("to"))
tx_to_label = pm_contracts.get(tx_to)
pm_hit = bool(tx_to_label)
transfer_lines: List[str] = []
approval_lines: List[str] = []
touched_labels: List[str] = [tx_to_label] if tx_to_label else []
try:
receipt = w3.eth.get_transaction_receipt(tx["hash"])
parsed = _extract_receipt_signals(
w3=w3,
receipt=receipt,
watch_set=watch_set,
pm_contracts=pm_contracts,
token_meta_cache=token_meta_cache,
)
pm_hit = pm_hit or bool(parsed.get("pm_hit"))
transfer_lines = parsed.get("transfer_lines") or []
approval_lines = parsed.get("approval_lines") or []
touched = parsed.get("touched_labels") or []
touched_labels = sorted(set(touched_labels + touched))
except Exception:
transfer_lines = []
approval_lines = []
if polymarket_only and not pm_hit:
continue
message = _build_message(
tx=tx,
block_ts=block_ts,
matched_wallet=matched_wallet,
touched_labels=touched_labels,
transfer_lines=transfer_lines,
approval_lines=approval_lines,
tx_to_label=tx_to_label,
)
bot.send_message(chat_id, message, disable_web_page_preview=True)
state.setdefault("seen_tx", {})[tx_hash] = cycle_ts
logger.info(
f"polygon wallet alert pushed wallet={matched_wallet} "
f"tx={tx_hash} block={block_num} polymarket={pm_hit}"
)
state["last_scanned_block"] = block_num
_save_state(state_path, state)
time.sleep(1)
except Exception:
logger.exception("polygon wallet watcher cycle failed")
time.sleep(poll_sec)
thread = threading.Thread(
target=_runner,
name="polygon-wallet-watcher",
daemon=True,
)
thread.start()
return thread
@@ -0,0 +1,690 @@
import json
import os
import threading
import time
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from typing import Any, Dict, List, Optional, Tuple
import requests
from loguru import logger
def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _env_int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
return int(raw)
except Exception:
return default
def _env_float(name: str, default: float) -> float:
raw = os.getenv(name)
if raw is None:
return default
try:
return float(raw)
except Exception:
return default
def _safe_float(value: Any, default: float = 0.0) -> float:
try:
if value is None:
return default
return float(value)
except Exception:
return default
def _normalize_addr(value: Any) -> str:
if value is None:
return ""
text = str(value).strip().lower()
if text.startswith("0x") and len(text) == 42:
return text
return ""
def _short(addr: str, left: int = 6, right: int = 4) -> str:
if not addr:
return "unknown"
if len(addr) <= left + right + 2:
return addr
return f"{addr[:left + 2]}...{addr[-right:]}"
def _parse_addresses(raw: Optional[str]) -> List[str]:
out: List[str] = []
if not raw:
return out
seen = set()
for part in raw.split(","):
addr = _normalize_addr(part)
if addr and addr not in seen:
out.append(addr)
seen.add(addr)
return out
def _state_file() -> str:
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return os.path.join(root, "data", "polymarket_wallet_activity_state.json")
def _load_state(path: str) -> Dict[str, Any]:
if not os.path.exists(path):
return {"users": {}}
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
if isinstance(data, dict):
data.setdefault("users", {})
return data
except Exception as exc:
logger.warning(f"failed to load wallet activity state: {exc}")
return {"users": {}}
def _save_state(path: str, state: Dict[str, Any]) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = f"{path}.tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(state, fh, ensure_ascii=False, indent=2)
os.replace(tmp, path)
def _market_url(position: Dict[str, Any]) -> str:
slug = str(position.get("slug") or "").strip()
event_slug = str(position.get("event_slug") or "").strip()
if slug and event_slug:
return f"https://polymarket.com/event/{event_slug}/{slug}"
if slug:
return f"https://polymarket.com/market/{slug}"
if event_slug:
return f"https://polymarket.com/event/{event_slug}"
return ""
def _position_key(position: Dict[str, Any]) -> str:
asset = str(position.get("asset") or "").strip().lower()
condition_id = str(position.get("condition_id") or "").strip().lower()
outcome = str(position.get("outcome") or "").strip().lower()
if asset:
return f"asset:{asset}"
return f"condition:{condition_id}|outcome:{outcome}"
def _normalize_position(row: Dict[str, Any]) -> Dict[str, Any]:
title = (
str(
row.get("title")
or row.get("question")
or row.get("market")
or row.get("name")
or ""
)
.strip()
)
outcome = str(row.get("outcome") or row.get("side") or "").strip()
return {
"proxy_wallet": _normalize_addr(row.get("proxyWallet") or row.get("proxy_wallet")),
"asset": str(row.get("asset") or row.get("assetId") or "").strip(),
"condition_id": str(row.get("conditionId") or row.get("condition_id") or "").strip(),
"title": title,
"slug": str(row.get("slug") or row.get("marketSlug") or "").strip(),
"event_slug": str(row.get("eventSlug") or row.get("event_slug") or "").strip(),
"outcome": outcome,
"size": _safe_float(row.get("size") or row.get("shares")),
"avg_price": _safe_float(row.get("avgPrice") or row.get("avg_price")),
"position_value": _safe_float(row.get("currentValue") or row.get("positionValue")),
"cash_pnl": _safe_float(row.get("cashPnl") or row.get("realizedPnl") or row.get("pnl")),
"percent_pnl": _safe_float(
row.get("percentPnl")
or row.get("pnlPercent")
or row.get("pnl_pct")
),
"cur_price": _safe_float(row.get("curPrice") or row.get("lastPrice")),
"updated_at": int(time.time()),
}
def _fetch_positions(
session: requests.Session,
base_url: str,
user: str,
timeout_sec: int,
) -> List[Dict[str, Any]]:
url = f"{base_url.rstrip('/')}/positions"
resp = session.get(url, params={"user": user}, timeout=timeout_sec)
resp.raise_for_status()
data = resp.json()
if isinstance(data, list):
return [row for row in data if isinstance(row, dict)]
if isinstance(data, dict):
for key in ("positions", "data", "results", "items"):
maybe = data.get(key)
if isinstance(maybe, list):
return [row for row in maybe if isinstance(row, dict)]
return []
def _build_snapshot(
rows: List[Dict[str, Any]],
min_size_abs: float,
) -> Dict[str, Dict[str, Any]]:
snap: Dict[str, Dict[str, Any]] = {}
for row in rows:
pos = _normalize_position(row)
if abs(pos["size"]) < min_size_abs:
continue
key = _position_key(pos)
snap[key] = pos
return snap
def _diff_positions(
previous: Dict[str, Dict[str, Any]],
current: Dict[str, Dict[str, Any]],
min_size_delta: float,
notify_closed: bool,
min_price: float = 0.0,
max_price: float = 1.0,
min_avg_price_delta: float = 0.002,
) -> List[Tuple[str, Dict[str, Any]]]:
changes: List[Tuple[str, Dict[str, Any]]] = []
min_avg_price_delta = max(0.0, min_avg_price_delta)
for key, now_pos in current.items():
# 价格过滤:如果设置了价格区间,不符合的直接跳过
price = _safe_float(now_pos.get("avg_price"))
if price < min_price or price > max_price:
logger.debug(f"skipped position due to price range limit: market={now_pos.get('title')} price={price}")
continue
old_pos = previous.get(key)
if old_pos is None:
changes.append(("new", now_pos))
continue
size_delta = now_pos["size"] - old_pos.get("size", 0.0)
avg_delta = now_pos["avg_price"] - old_pos.get("avg_price", 0.0)
if abs(size_delta) >= min_size_delta or abs(avg_delta) >= min_avg_price_delta:
merged = {**now_pos}
merged["size_delta"] = size_delta
merged["old_size"] = old_pos.get("size", 0.0)
merged["old_avg_price"] = old_pos.get("avg_price", 0.0)
changes.append(("update", merged))
if notify_closed:
for key, old_pos in previous.items():
if key not in current:
changes.append(("closed", old_pos))
return changes
def _merge_pending_update(
pending_updates: Dict[str, Dict[str, Any]],
pos_key: str,
pos: Dict[str, Any],
now_ts: int,
) -> None:
entry = pending_updates.get(pos_key)
size_delta = _safe_float(pos.get("size_delta"))
old_size = _safe_float(pos.get("old_size"))
new_size = _safe_float(pos.get("size"))
old_avg = _safe_float(pos.get("old_avg_price"))
new_avg = _safe_float(pos.get("avg_price"))
if entry is None:
pending_updates[pos_key] = {
"count": 1,
"first_ts": now_ts,
"last_ts": now_ts,
"title": pos.get("title"),
"slug": pos.get("slug"),
"event_slug": pos.get("event_slug"),
"outcome": pos.get("outcome"),
"asset": pos.get("asset"),
"condition_id": pos.get("condition_id"),
"old_size": old_size,
"size": new_size,
"size_delta": size_delta,
"old_avg_price": old_avg,
"avg_price": new_avg,
"position_value": _safe_float(pos.get("position_value")),
"cash_pnl": _safe_float(pos.get("cash_pnl")),
"percent_pnl": _safe_float(pos.get("percent_pnl")),
}
return
entry["count"] = int(entry.get("count", 1)) + 1
entry["last_ts"] = now_ts
entry["size_delta"] = _safe_float(entry.get("size_delta")) + size_delta
entry["size"] = new_size
entry["avg_price"] = new_avg
entry["position_value"] = _safe_float(pos.get("position_value"))
entry["cash_pnl"] = _safe_float(pos.get("cash_pnl"))
entry["percent_pnl"] = _safe_float(pos.get("percent_pnl"))
pending_updates[pos_key] = entry
def _finalize_pending_update(
pending_entry: Dict[str, Any],
now_ts: int,
) -> Dict[str, Any]:
first_ts = int(pending_entry.get("first_ts") or now_ts)
last_ts = int(pending_entry.get("last_ts") or now_ts)
return {
"title": pending_entry.get("title") or "",
"slug": pending_entry.get("slug") or "",
"event_slug": pending_entry.get("event_slug") or "",
"outcome": pending_entry.get("outcome") or "",
"asset": pending_entry.get("asset") or "",
"condition_id": pending_entry.get("condition_id") or "",
"old_size": _safe_float(pending_entry.get("old_size")),
"size": _safe_float(pending_entry.get("size")),
"size_delta": _safe_float(pending_entry.get("size_delta")),
"old_avg_price": _safe_float(pending_entry.get("old_avg_price")),
"avg_price": _safe_float(pending_entry.get("avg_price")),
"position_value": _safe_float(pending_entry.get("position_value")),
"cash_pnl": _safe_float(pending_entry.get("cash_pnl")),
"percent_pnl": _safe_float(pending_entry.get("percent_pnl")),
"agg_count": int(pending_entry.get("count") or 1),
"agg_span_sec": max(0, last_ts - first_ts),
}
def _flush_ready_pending_updates(
pending_updates: Dict[str, Dict[str, Any]],
now_ts: int,
debounce_sec: int,
max_hold_sec: int,
force_keys: Optional[set] = None,
) -> List[Tuple[str, Dict[str, Any]]]:
out: List[Tuple[str, Dict[str, Any]]] = []
keys = list(pending_updates.keys())
for key in keys:
entry = pending_updates.get(key)
if not isinstance(entry, dict):
pending_updates.pop(key, None)
continue
if force_keys and key in force_keys:
out.append(("update", _finalize_pending_update(entry, now_ts)))
pending_updates.pop(key, None)
continue
first_ts = int(entry.get("first_ts") or now_ts)
last_ts = int(entry.get("last_ts") or now_ts)
quiet_enough = (now_ts - last_ts) >= debounce_sec
held_too_long = (now_ts - first_ts) >= max_hold_sec
if quiet_enough or held_too_long:
out.append(("update", _finalize_pending_update(entry, now_ts)))
pending_updates.pop(key, None)
return out
def _fmt_pct(value: float) -> str:
# Data API may return either ratio (0.12) or percent (12.0).
display = value * 100.0 if abs(value) <= 1.5 else value
return f"{display:.1f}%"
def _fmt_usd(value: float) -> str:
return f"${value:.2f}"
def _fmt_price(value: float) -> str:
return f"{value:.3f}"
def _should_show_avg_price(avg_price: float) -> bool:
# Extreme prices near 0/1 are usually not informative for activity alerts.
min_show = max(
0.0,
min(1.0, _env_float("POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MIN", 0.01)),
)
max_show = max(
0.0,
min(1.0, _env_float("POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MAX", 0.99)),
)
if min_show > max_show:
min_show, max_show = max_show, min_show
return min_show <= avg_price <= max_show
def _format_change_block(
change_type: str,
wallet: str,
pos: Dict[str, Any],
now_utc: str,
) -> str:
title = pos.get("title") or "Unknown market"
outcome = pos.get("outcome") or "Unknown"
market_url = _market_url(pos)
lines: List[str] = []
if change_type == "new":
lines.append("🆕 新开仓位")
elif change_type == "closed":
lines.append("❌ 仓位关闭")
else:
agg_count = int(pos.get("agg_count") or 1)
if agg_count > 1:
lines.append("🔁 连续仓位变动汇总")
else:
lines.append("🔄 仓位更新")
lines.append(f"钱包: {_short(wallet)}")
if market_url:
lines.append(f"市场: {title} ({market_url})")
else:
lines.append(f"市场: {title}")
lines.append(f"买入方向: {outcome}")
if change_type == "update":
old_size = _safe_float(pos.get("old_size"))
now_size = _safe_float(pos.get("size"))
delta = _safe_float(pos.get("size_delta"))
lines.append(f"持有数量: {old_size:.3f} -> {now_size:.3f}{delta:+.3f})")
agg_count = int(pos.get("agg_count") or 1)
if agg_count > 1:
span_sec = int(_safe_float(pos.get("agg_span_sec")))
lines.append(f"变动次数: {agg_count} 次 | 聚合窗口: {span_sec}s")
else:
lines.append(f"持有数量: {_safe_float(pos.get('size')):.3f}")
avg_price = _safe_float(pos.get("avg_price"))
old_avg_price = _safe_float(pos.get("old_avg_price"))
agg_count = int(pos.get("agg_count") or 1)
if change_type == "update" and agg_count > 1:
if _should_show_avg_price(old_avg_price) or _should_show_avg_price(avg_price):
lines.append(f"建仓均价: {_fmt_price(old_avg_price)} -> {_fmt_price(avg_price)}")
elif _should_show_avg_price(avg_price):
lines.append(f"建仓均价: {_fmt_price(avg_price)}")
lines.append(f"当前价值: {_fmt_usd(_safe_float(pos.get('position_value')))}")
pnl = _safe_float(pos.get("cash_pnl"))
pnl_pct = _safe_float(pos.get("percent_pnl"))
lines.append(f"盈亏: {_fmt_usd(pnl)} ({_fmt_pct(pnl_pct)})")
lines.append(f"时间: {now_utc}")
return "\n".join(lines)
def _build_message(
wallet: str,
changes: List[Tuple[str, Dict[str, Any]]],
max_changes: int,
) -> str:
now_bj = (
datetime.now(timezone.utc)
.astimezone(ZoneInfo("Asia/Shanghai"))
.strftime("%Y-%m-%d %H:%M:%S")
)
shown = changes[:max_changes]
lines = [f"🚨 钱包异动监控 ({len(changes)} 个异动):", ""]
for idx, (change_type, pos) in enumerate(shown):
lines.append(_format_change_block(change_type, wallet, pos, f"{now_bj} 北京时间"))
if idx != len(shown) - 1:
lines.append("")
if len(changes) > max_changes:
lines.append("")
lines.append(f"... 以及其他 {len(changes) - max_changes} 个异动")
return "\n".join(lines)
def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread]:
enabled = _env_bool("POLYMARKET_WALLET_ACTIVITY_ENABLED", False)
chat_id = os.getenv("TELEGRAM_CHAT_ID")
users = _parse_addresses(os.getenv("POLYMARKET_WALLET_ACTIVITY_USERS"))
if not enabled:
logger.info("polymarket wallet activity watcher disabled")
return None
if not chat_id:
logger.warning("polymarket wallet activity watcher skipped: TELEGRAM_CHAT_ID is not set")
return None
if not users:
logger.warning("polymarket wallet activity watcher skipped: POLYMARKET_WALLET_ACTIVITY_USERS is empty")
return None
data_api_url = str(
os.getenv("POLYMARKET_WALLET_ACTIVITY_DATA_API_URL", "https://data-api.polymarket.com")
).strip()
poll_sec = max(5, _env_int("POLYMARKET_WALLET_ACTIVITY_INTERVAL_SEC", 20))
timeout_sec = max(5, _env_int("POLYMARKET_WALLET_ACTIVITY_TIMEOUT_SEC", 10))
min_size_abs = max(0.0, _env_float("POLYMARKET_WALLET_ACTIVITY_MIN_SIZE_ABS", 0.001))
min_size_delta = max(0.0, _env_float("POLYMARKET_WALLET_ACTIVITY_MIN_SIZE_DELTA", 0.001))
min_avg_price_delta = max(
0.0,
_env_float("POLYMARKET_WALLET_ACTIVITY_MIN_AVG_PRICE_DELTA", 0.002),
)
immediate_on_size_delta = _env_bool(
"POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_ON_SIZE_DELTA",
True,
)
immediate_size_delta_min = max(
min_size_delta,
_env_float(
"POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_SIZE_DELTA_MIN",
min_size_delta,
),
)
immediate_cooldown_sec = max(
0,
_env_int("POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_COOLDOWN_SEC", 20),
)
max_changes = max(1, _env_int("POLYMARKET_WALLET_ACTIVITY_MAX_CHANGES_PER_MSG", 5))
notify_closed = _env_bool("POLYMARKET_WALLET_ACTIVITY_NOTIFY_CLOSED", False)
bootstrap_alert = _env_bool("POLYMARKET_WALLET_ACTIVITY_BOOTSTRAP_ALERT", False)
default_debounce_sec = max(poll_sec, 30)
update_debounce_sec = max(
poll_sec,
_env_int("POLYMARKET_WALLET_ACTIVITY_UPDATE_DEBOUNCE_SEC", default_debounce_sec),
)
default_update_max_hold_sec = max(update_debounce_sec, 120)
update_max_hold_sec = max(
update_debounce_sec,
_env_int("POLYMARKET_WALLET_ACTIVITY_UPDATE_MAX_HOLD_SEC", default_update_max_hold_sec),
)
# 价格过滤范围配置
min_price = _env_float("POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MIN", 0.0)
max_price = _env_float("POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MAX", 1.0)
state_path = _state_file()
session = requests.Session()
def _runner() -> None:
state = _load_state(state_path)
users_state = state.setdefault("users", {})
logger.info(
f"polymarket wallet activity watcher started users={len(users)} "
f"poll={poll_sec}s data_api={data_api_url} price_filter={min_price}-{max_price} "
f"min_avg_price_delta={min_avg_price_delta} "
f"immediate_on_size_delta={immediate_on_size_delta} "
f"immediate_size_delta_min={immediate_size_delta_min} "
f"immediate_cooldown={immediate_cooldown_sec}s "
f"update_debounce={update_debounce_sec}s update_max_hold={update_max_hold_sec}s"
)
while True:
cycle_started = time.time()
touched = False
for user in users:
try:
now_ts = int(time.time())
rows = _fetch_positions(
session=session,
base_url=data_api_url,
user=user,
timeout_sec=timeout_sec,
)
current = _build_snapshot(rows, min_size_abs=min_size_abs)
user_state = users_state.get(user) if isinstance(users_state.get(user), dict) else {}
prev = (user_state.get("positions") if isinstance(user_state, dict) else {}) or {}
if not isinstance(prev, dict):
prev = {}
pending_updates = (
user_state.get("pending_updates") if isinstance(user_state, dict) else {}
) or {}
if not isinstance(pending_updates, dict):
pending_updates = {}
update_push_meta = (
user_state.get("update_push_meta") if isinstance(user_state, dict) else {}
) or {}
if not isinstance(update_push_meta, dict):
update_push_meta = {}
initialized = bool(user_state.get("initialized")) if isinstance(user_state, dict) else False
# First cycle for each wallet only initializes baseline unless bootstrap alert is enabled.
if not initialized:
if not bootstrap_alert:
users_state[user] = {
"positions": current,
"pending_updates": {},
"update_push_meta": {},
"initialized": True,
"updated_at": now_ts,
}
touched = True
continue
prev = {}
changes = _diff_positions(
previous=prev,
current=current,
min_size_delta=min_size_delta,
notify_closed=notify_closed,
min_price=min_price,
max_price=max_price,
min_avg_price_delta=min_avg_price_delta,
)
outgoing: List[Tuple[str, Dict[str, Any]]] = []
for change_type, pos in changes:
pos_key = _position_key(pos)
if change_type == "update":
size_delta_abs = abs(_safe_float(pos.get("size_delta")))
if (
immediate_on_size_delta
and size_delta_abs >= immediate_size_delta_min
):
last_push_ts = int(update_push_meta.get(pos_key) or 0)
if now_ts - last_push_ts >= immediate_cooldown_sec:
outgoing.extend(
_flush_ready_pending_updates(
pending_updates=pending_updates,
now_ts=now_ts,
debounce_sec=update_debounce_sec,
max_hold_sec=update_max_hold_sec,
force_keys={pos_key},
)
)
outgoing.append((change_type, pos))
update_push_meta[pos_key] = now_ts
continue
_merge_pending_update(
pending_updates=pending_updates,
pos_key=pos_key,
pos=pos,
now_ts=now_ts,
)
continue
outgoing.extend(
_flush_ready_pending_updates(
pending_updates=pending_updates,
now_ts=now_ts,
debounce_sec=update_debounce_sec,
max_hold_sec=update_max_hold_sec,
force_keys={pos_key},
)
)
outgoing.append((change_type, pos))
# If a key disappeared from snapshot, flush pending summary now.
missing_keys = {k for k in pending_updates.keys() if k not in current}
if missing_keys:
outgoing.extend(
_flush_ready_pending_updates(
pending_updates=pending_updates,
now_ts=now_ts,
debounce_sec=update_debounce_sec,
max_hold_sec=update_max_hold_sec,
force_keys=missing_keys,
)
)
for missing_key in missing_keys:
update_push_meta.pop(missing_key, None)
outgoing.extend(
_flush_ready_pending_updates(
pending_updates=pending_updates,
now_ts=now_ts,
debounce_sec=update_debounce_sec,
max_hold_sec=update_max_hold_sec,
)
)
if outgoing:
msg = _build_message(user, outgoing, max_changes=max_changes)
bot.send_message(chat_id, msg, disable_web_page_preview=True)
logger.info(
f"wallet activity pushed user={user} changes={len(outgoing)}"
)
users_state[user] = {
"positions": current,
"pending_updates": pending_updates,
"update_push_meta": update_push_meta,
"initialized": True,
"updated_at": now_ts,
}
touched = True
except Exception:
logger.exception(f"wallet activity cycle failed user={user}")
if touched:
try:
_save_state(state_path, state)
except Exception:
logger.exception("failed to save wallet activity state")
elapsed = time.time() - cycle_started
sleep_sec = max(0.0, poll_sec - elapsed)
time.sleep(sleep_sec)
thread = threading.Thread(
target=_runner,
name="polymarket-wallet-activity-watcher",
daemon=True,
)
thread.start()
return thread
-1
View File
@@ -19,7 +19,6 @@ def load_config():
"openweather_api_key": get_env_or_none("OPENWEATHER_API_KEY"),
"wunderground_api_key": get_env_or_none("WUNDERGROUND_API_KEY"),
"visualcrossing_api_key": get_env_or_none("VISUALCROSSING_API_KEY"),
"meteoblue_api_key": get_env_or_none("METEOBLUE_API_KEY"),
"proxy": os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY"),
},
"telegram": {
+179 -5
View File
@@ -36,6 +36,28 @@ def _env_int(name: str, default: int) -> int:
return default
def _env_float(name: str, default: float) -> float:
raw = os.getenv(name)
if raw is None:
return default
try:
return float(raw)
except Exception:
return default
def _norm_prob(v: Any) -> Optional[float]:
if v is None:
return None
try:
n = float(v)
except Exception:
return None
if n > 1.0:
n = n / 100.0
return max(0.0, min(1.0, n))
def _parse_city_list(raw: Optional[str]) -> List[str]:
if not raw:
return list(CITY_REGISTRY.keys())
@@ -111,15 +133,125 @@ def _severity_ok(alert_payload: Dict[str, Any], min_severity: str, min_trigger_c
return SEVERITY_RANK.get(severity, 0) >= SEVERITY_RANK.get(min_severity, 0)
def _market_price_cap_ok(
alert_payload: Dict[str, Any],
max_yes_buy: float,
require_actionable_quote: bool = False,
) -> bool:
if max_yes_buy >= 1.0:
return True
market = alert_payload.get("market_snapshot") or {}
if not isinstance(market, dict) or not market.get("available"):
if require_actionable_quote:
logger.info(
"trade alert skipped: market snapshot unavailable city={}".format(
alert_payload.get("city"),
)
)
return False
return True
# Strict rule: use the bucket mapped from multi-model anchor settlement.
forecast_bucket = market.get("forecast_bucket") or {}
settle_ref = market.get("anchor_settlement")
if settle_ref is None:
settle_ref = market.get("open_meteo_settlement")
anchor_model = str(market.get("anchor_model") or "").strip() or "--"
yes_buy = None
bucket_label = None
if isinstance(forecast_bucket, dict):
yes_buy = _norm_prob(forecast_bucket.get("yes_buy"))
bucket_label = str(forecast_bucket.get("label") or "").strip() or None
if yes_buy is None or yes_buy <= 0.0:
logger.info(
"trade alert skipped: no actionable mapped bucket quote city={} bucket={} anchor_model={} anchor_settle={}".format(
alert_payload.get("city"),
bucket_label or "--",
anchor_model,
settle_ref,
)
)
return False
if yes_buy >= max_yes_buy:
logger.info(
"trade alert skipped by mispricing cap city={} bucket={} anchor_model={} anchor_settle={} yes_buy={} cap={}".format(
alert_payload.get("city"),
bucket_label or "--",
anchor_model,
settle_ref,
round(yes_buy, 4),
round(max_yes_buy, 4),
)
)
return False
return True
def _trigger_type_key(alert_payload: Dict[str, Any]) -> str:
trigger_types = sorted(
str(alert.get("type") or "").strip()
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)
def _evidence_brief(alert_payload: Dict[str, Any]) -> str:
evidence = alert_payload.get("evidence") or {}
if not isinstance(evidence, dict):
return "--"
trigger_summary = evidence.get("trigger_summary") or {}
rules = evidence.get("rules") or {}
market = evidence.get("market") or {}
momentum = rules.get("momentum_spike") or {}
advection = rules.get("advection") or {}
breakthrough = rules.get("forecast_breakthrough") or {}
parts: List[str] = []
trigger_types = trigger_summary.get("trigger_types")
if isinstance(trigger_types, list) and trigger_types:
parts.append(f"triggers={','.join(str(t) for t in trigger_types)}")
slope = momentum.get("slope_30m")
if slope is not None:
parts.append(f"slope_30m={slope}")
lead_delta = advection.get("lead_delta")
if lead_delta is not None:
parts.append(f"lead_delta={lead_delta}")
margin = breakthrough.get("margin")
if margin is not None:
parts.append(f"break_margin={margin}")
edge = market.get("edge_percent")
if edge is not None:
parts.append(f"edge_pct={edge}")
forecast_bucket = market.get("forecast_bucket") or {}
if isinstance(forecast_bucket, dict):
label = str(forecast_bucket.get("label") or "").strip()
yes_buy = forecast_bucket.get("yes_buy")
if label:
parts.append(f"bucket={label}")
if yes_buy is not None:
parts.append(f"yes_buy={yes_buy}")
if not parts:
return "--"
return "; ".join(parts)
def _alert_signature(alert_payload: Dict[str, Any]) -> str:
rules = alert_payload.get("rules") or {}
center_deb = rules.get("ankara_center_deb_hit") or {}
@@ -127,6 +259,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 +282,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 +305,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")
@@ -186,11 +339,22 @@ def _maybe_send_alert(
cooldown_sec: int,
min_severity: str,
min_trigger_count: int,
mispricing_only: bool,
) -> bool:
now_ts = int(time.time())
last_by_city = state.setdefault("last_by_city", {})
last_city = last_by_city.get(city) or {}
is_active = _severity_ok(alert_payload, min_severity, min_trigger_count)
max_yes_buy = max(
0.0,
min(1.0, _env_float("TELEGRAM_ALERT_MISPRICING_MAX_YES_BUY", 0.10)),
)
if not _market_price_cap_ok(
alert_payload,
max_yes_buy,
require_actionable_quote=mispricing_only,
):
is_active = False
message = ((alert_payload.get("telegram") or {}).get("zh") or "").strip()
if not is_active or not message:
@@ -212,7 +376,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:
@@ -227,11 +391,13 @@ def _maybe_send_alert(
"severity": alert_payload.get("severity"),
"ts": now_ts,
"active": True,
"evidence": alert_payload.get("evidence"),
}
state.setdefault("by_signature", {})[signature] = now_ts
logger.info(
f"trade alert pushed city={city} severity={alert_payload.get('severity')} "
f"trigger_count={alert_payload.get('trigger_count')} trigger_key={trigger_key}"
f"trigger_count={alert_payload.get('trigger_count')} trigger_key={trigger_key} "
f"evidence={_evidence_brief(alert_payload)}"
)
return True
@@ -246,7 +412,13 @@ def start_trade_alert_push_loop(bot: Any, config: Dict[str, Any]) -> Optional[th
logger.warning("telegram alert push loop skipped: TELEGRAM_CHAT_ID is not set")
return None
interval_sec = max(60, _env_int("TELEGRAM_ALERT_PUSH_INTERVAL_SEC", 300))
mispricing_only = _env_bool("TELEGRAM_ALERT_MISPRICING_ONLY", True)
if mispricing_only:
interval_sec = max(
300, _env_int("TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC", 7200)
)
else:
interval_sec = max(60, _env_int("TELEGRAM_ALERT_PUSH_INTERVAL_SEC", 300))
cooldown_sec = max(interval_sec, _env_int("TELEGRAM_ALERT_PUSH_COOLDOWN_SEC", 1800))
min_trigger_count = max(1, _env_int("TELEGRAM_ALERT_MIN_TRIGGER_COUNT", 2))
min_severity = os.getenv("TELEGRAM_ALERT_MIN_SEVERITY", "medium").strip().lower()
@@ -259,7 +431,8 @@ def start_trade_alert_push_loop(bot: Any, config: Dict[str, Any]) -> Optional[th
except Exception:
logger.exception(f"failed to initialize telegram push state path={state_path}")
logger.info(
f"telegram alert push loop started cities={len(cities)} interval={interval_sec}s "
f"telegram alert push loop started mode={'mispricing-only' if mispricing_only else 'full'} "
f"cities={len(cities)} interval={interval_sec}s "
f"cooldown={cooldown_sec}s min_triggers={min_trigger_count} min_severity={min_severity} "
f"state_path={state_path}"
)
@@ -280,6 +453,7 @@ def start_trade_alert_push_loop(bot: Any, config: Dict[str, Any]) -> Optional[th
cooldown_sec=cooldown_sec,
min_severity=min_severity,
min_trigger_count=min_trigger_count,
mispricing_only=mispricing_only,
):
try:
_save_state(state_path, state)
+144
View File
@@ -0,0 +1,144 @@
from src.data_collection.polymarket_readonly import PolymarketReadOnlyLayer
def test_normalize_orderbook_uses_sorted_best_prices():
layer = PolymarketReadOnlyLayer()
raw = {
"bids": [
{"price": "0.24", "size": "10"},
{"price": "0.31", "size": "5"},
{"price": "0.27", "size": "8"},
],
"asks": [
{"price": "0.44", "size": "9"},
{"price": "0.39", "size": "6"},
{"price": "0.42", "size": "4"},
],
}
book, _liquidity = layer._normalize_orderbook(raw)
assert book is not None
assert book["best_bid"] == 0.31
assert book["best_ask"] == 0.39
assert book["bid_levels"][0][0] == 0.31
assert book["ask_levels"][0][0] == 0.39
def test_fetch_token_market_data_prefers_orderbook_executable_prices():
class FakeClob:
@staticmethod
def get_price(_token_id: str, side: str):
if side == "BUY":
return {"price": "0.11"}
return {"price": "0.88"}
@staticmethod
def get_midpoint(_token_id: str):
return {"midpoint": "0.50"}
@staticmethod
def get_last_trade_price(_token_id: str):
return {"price": "0.49"}
@staticmethod
def get_order_book(_token_id: str):
return {
"bids": [{"price": "0.24", "size": "10"}],
"asks": [{"price": "0.26", "size": "12"}],
}
layer = PolymarketReadOnlyLayer()
layer._get_clob_client = lambda: FakeClob()
data = layer._fetch_token_market_data("token-1")
# Executable BUY should match best ask from the book.
assert data["buy"] == 0.26
# Executable SELL should match best bid from the book.
assert data["sell"] == 0.24
assert data["midpoint"] == 0.5
assert data["last_trade_price"] == 0.49
def test_build_top_temperature_buckets_dedupes_same_temperature():
layer = PolymarketReadOnlyLayer()
primary_market = {
"slug": "highest-temperature-in-ankara-on-march-12-2026-14c-or-higher",
"question": "Will the highest temperature in Ankara be 14C or higher on March 12?",
"volumeNum": 1000,
}
markets = [
primary_market,
{
"slug": "highest-temperature-in-ankara-on-march-12-2026-14c-or-higher-v2",
"question": "Will the highest temperature in Ankara be 14C or higher on March 12? (v2)",
"volumeNum": 900,
},
{
"slug": "highest-temperature-in-ankara-on-march-12-2026-13c-or-higher",
"question": "Will the highest temperature in Ankara be 13C or higher on March 12?",
"volumeNum": 1100,
},
{
"slug": "highest-temperature-in-ankara-on-march-12-2026-12c-or-higher",
"question": "Will the highest temperature in Ankara be 12C or higher on March 12?",
"volumeNum": 1200,
},
{
"slug": "highest-temperature-in-ankara-on-march-12-2026-14c-or-lower",
"question": "Will the highest temperature in Ankara be 14C or lower on March 12?",
"volumeNum": 1300,
},
]
layer._collect_related_temperature_markets = (
lambda city_key, target_date, primary_market: markets
)
def _fake_extract_market_tokens(market):
slug = str(market.get("slug") or "")
return [
{"outcome": "Yes", "token_id": f"{slug}|yes"},
{"outcome": "No", "token_id": f"{slug}|no"},
]
layer._extract_market_tokens = _fake_extract_market_tokens
midpoint_map = {
"highest-temperature-in-ankara-on-march-12-2026-14c-or-higher": 0.79,
"highest-temperature-in-ankara-on-march-12-2026-14c-or-higher-v2": 0.16,
"highest-temperature-in-ankara-on-march-12-2026-13c-or-higher": 0.06,
"highest-temperature-in-ankara-on-march-12-2026-12c-or-higher": 0.01,
"highest-temperature-in-ankara-on-march-12-2026-14c-or-lower": 0.92,
}
def _fake_get_token_market_data(token_id):
slug, side = str(token_id).split("|", 1)
if side == "yes":
midpoint = midpoint_map.get(slug, 0.5)
return {
"midpoint": midpoint,
"buy": max(0.0, min(1.0, midpoint + 0.01)),
"sell": max(0.0, min(1.0, midpoint - 0.01)),
}
midpoint = 1.0 - midpoint_map.get(slug, 0.5)
return {
"midpoint": midpoint,
"buy": max(0.0, min(1.0, midpoint + 0.01)),
"sell": max(0.0, min(1.0, midpoint - 0.01)),
}
layer._get_token_market_data = _fake_get_token_market_data
rows = layer._build_top_temperature_buckets(
city_key="ankara",
target_date="2026-03-12",
primary_market=primary_market,
limit=4,
)
values = [row.get("value") for row in rows]
assert len(values) == len(set(values))
assert rows[0]["value"] == 14.0
assert all(not str(row.get("label") or "").startswith("<=") for row in rows)
-1
View File
@@ -62,7 +62,6 @@ def _make_weather_data(
"p90": ens_p90,
},
"multi_model": {"forecasts": multi_model or {}},
"meteoblue": {},
"nws": {},
}
return data
View File
View File
+252 -50
View File
@@ -21,14 +21,16 @@ if _root not in sys.path:
if _file_dir not in sys.path:
sys.path.insert(0, _file_dir)
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from loguru import logger
from src.utils.config_loader import load_config
from src.data_collection.weather_sources import WeatherDataCollector
from src.data_collection.city_risk_profiles import CITY_RISK_PROFILES
from src.data_collection.polymarket_readonly import PolymarketReadOnlyLayer
from src.analysis.deb_algorithm import calculate_dynamic_weights, get_deb_accuracy
from src.analysis.settlement_rounding import wu_round
# ──────────────────────────────────────────────────────────
# Setup
@@ -49,6 +51,7 @@ app.add_middleware(
_config = load_config()
_weather = WeatherDataCollector(_config)
_market_layer = PolymarketReadOnlyLayer()
from src.data_collection.city_registry import CITY_REGISTRY, ALIASES
@@ -74,6 +77,45 @@ CACHE_TTL = 300
CACHE_TTL_ANKARA = 60 # Ankara measurement updates frequent, narrower cache
def _env_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
_ENTITLEMENT_GUARD_ENABLED = _env_bool("POLYWEATHER_REQUIRE_ENTITLEMENT", False)
_ENTITLEMENT_HEADER = "x-polyweather-entitlement"
_ENTITLEMENT_TOKEN = (os.getenv("POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN") or "").strip()
def _extract_bearer_token(auth_header: Optional[str]) -> Optional[str]:
if not auth_header:
return None
parts = auth_header.strip().split()
if len(parts) == 2 and parts[0].lower() == "bearer":
return parts[1].strip()
return None
def _assert_entitlement(request: Request) -> None:
if not _ENTITLEMENT_GUARD_ENABLED:
return
if not _ENTITLEMENT_TOKEN:
raise HTTPException(
status_code=503,
detail="Entitlement guard is enabled but backend token is not configured",
)
token = request.headers.get(_ENTITLEMENT_HEADER)
if not token:
token = _extract_bearer_token(request.headers.get("authorization"))
if token != _ENTITLEMENT_TOKEN:
raise HTTPException(status_code=401, detail="Unauthorized")
def _sf(v) -> Optional[float]:
"""Safe float conversion."""
if v is None:
@@ -84,6 +126,18 @@ def _sf(v) -> Optional[float]:
return None
def _is_excluded_model_name(model_name: str) -> bool:
normalized = (
str(model_name or "")
.strip()
.lower()
.replace(" ", "")
.replace("_", "")
.replace("-", "")
)
return "meteoblue" in normalized
# ──────────────────────────────────────────────────────────
# Core Analysis (replicates bot_listener logic → JSON)
# ──────────────────────────────────────────────────────────
@@ -102,12 +156,27 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
sym = "°F" if is_f else "°C"
# ── 1. Fetch raw data ──
raw = _weather.fetch_all_sources(city, lat=lat, lon=lon)
raw = _weather.fetch_all_sources(
city,
lat=lat,
lon=lon,
force_refresh=force_refresh,
)
om = raw.get("open-meteo", {})
metar = raw.get("metar", {})
mgm = raw.get("mgm") or {}
ens_raw = raw.get("ensemble", {})
mm = raw.get("multi_model", {})
if not isinstance(om, dict):
om = {}
if not isinstance(metar, dict):
metar = {}
if not isinstance(mgm, dict):
mgm = {}
if not isinstance(ens_raw, dict):
ens_raw = {}
if not isinstance(mm, dict):
mm = {}
risk = CITY_RISK_PROFILES.get(city, {})
# ── 2. Current conditions (METAR primary, MGM fallback) ──
@@ -129,14 +198,27 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
if " " in max_temp_time:
max_temp_time = max_temp_time.split(" ")[1][:5]
wu_settle = round(max_so_far) if max_so_far is not None else None
wu_settle = wu_round(max_so_far) if max_so_far is not None else None
# Observation time → local
obs_time_str = ""
metar_age_min = None
obs_t = metar.get("observation_time", "") if metar else ""
# 优先从 API 获取偏移,若失败则使用 CITIES 预设的静态偏移 (兜底当地时间)
utc_offset = om.get("utc_offset", info.get("tz", 0))
# 优先从 API 获取偏移;若缺失则尝试 NWS 动态偏移;最后回退静态配置
utc_offset = om.get("utc_offset")
if utc_offset is None:
try:
nws_periods = (raw.get("nws", {}) or {}).get("forecast_periods", []) or []
if nws_periods:
first_start = nws_periods[0].get("start_time")
if first_start:
maybe_dt = datetime.fromisoformat(str(first_start))
if maybe_dt.utcoffset() is not None:
utc_offset = int(maybe_dt.utcoffset().total_seconds())
except Exception:
utc_offset = None
if utc_offset is None:
utc_offset = info.get("tz", 0)
if obs_t and "T" in obs_t:
try:
dt = datetime.fromisoformat(obs_t.replace("Z", "+00:00"))
@@ -180,6 +262,22 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
om_today = _sf(maxtemps[0]) if maxtemps else None
forecast_daily = [{"date": d, "max_temp": t} for d, t in zip(dates, maxtemps)]
if om_today is None:
nws_high = _sf(raw.get("nws", {}).get("today_high"))
mgm_high = _sf(mgm.get("today_high")) if mgm else None
fallback_high = (
nws_high
if nws_high is not None
else mgm_high
if mgm_high is not None
else max_so_far
if max_so_far is not None
else cur_temp
)
if fallback_high is not None:
om_today = float(fallback_high)
if not forecast_daily:
forecast_daily = [{"date": local_date_str, "max_temp": om_today}]
sunrise = (
sunrises[0].split("T")[1][:5]
if sunrises and "T" in str(sunrises[0])
@@ -197,14 +295,11 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
if om_today is not None:
current_forecasts["Open-Meteo"] = om_today
for m, v in mm.get("forecasts", {}).items():
if v is not None:
if v is not None and not _is_excluded_model_name(m):
current_forecasts[m] = _sf(v)
nws_high = _sf(raw.get("nws", {}).get("today_high"))
if nws_high is not None:
current_forecasts["NWS"] = nws_high
mb_high = _sf(raw.get("meteoblue", {}).get("today_high"))
if mb_high is not None:
current_forecasts["Meteoblue"] = mb_high
mgm_high = _sf(mgm.get("today_high")) if mgm else None
if mgm_high is not None:
current_forecasts["MGM"] = mgm_high
@@ -268,6 +363,29 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
h_wdir = hourly.get("wind_direction_10m", [])
h_precip_prob = hourly.get("precipitation_probability", [])
h_cloud_cover = hourly.get("cloud_cover", [])
if (not h_times or not h_temps) and metar:
metar_today_obs = metar.get("today_obs", []) or []
parsed_obs = []
for item in metar_today_obs:
try:
t_str, t_val = item
if t_str is None or t_val is None:
continue
hh, minute_part = str(t_str).split(":")
parsed_obs.append((int(hh), int(minute_part), float(t_val)))
except Exception:
continue
if parsed_obs:
parsed_obs.sort(key=lambda x: (x[0], x[1]))
h_times = [f"{local_date_str}T{hh:02d}:{mm:02d}" for hh, mm, _ in parsed_obs]
h_temps = [v for _, _, v in parsed_obs]
h_rad = [0 for _ in parsed_obs]
h_dew = [None for _ in parsed_obs]
h_pressure = [None for _ in parsed_obs]
h_wspd = [None for _ in parsed_obs]
h_wdir = [None for _ in parsed_obs]
h_precip_prob = [None for _ in parsed_obs]
h_cloud_cover = [None for _ in parsed_obs]
peak_hours = []
if h_times and h_temps and om_today is not None:
@@ -488,6 +606,10 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
mgm_daily = mgm.get("daily_forecasts", {})
if d_str in mgm_daily:
day_m["MGM"] = _sf(mgm_daily[d_str])
day_m = {
m: v for m, v in day_m.items() if not _is_excluded_model_name(m)
}
d_val, d_winfo = None, ""
d_probs = []
@@ -568,7 +690,6 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
},
"source_forecasts": {
"weather_gov": raw.get("nws") or {},
"meteoblue": raw.get("meteoblue") or {},
},
"multi_model": {k: v for k, v in current_forecasts.items() if v is not None},
"multi_model_daily": multi_model_daily,
@@ -604,8 +725,9 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
# Routes
# ──────────────────────────────────────────────────────────
@app.get("/api/cities")
async def list_cities():
async def list_cities(request: Request):
"""Return all supported cities with coordinates and risk level."""
_assert_entitlement(request)
try:
out = []
for name, info in CITIES.items():
@@ -633,8 +755,9 @@ async def list_cities():
@app.get("/api/city/{name}")
async def city_detail(name: str, force_refresh: bool = False):
async def city_detail(request: Request, name: str, force_refresh: bool = False):
"""Return full weather analysis for a single city."""
_assert_entitlement(request)
name = name.lower().strip().replace("-", " ")
name = ALIASES.get(name, name)
if name not in CITIES:
@@ -672,9 +795,100 @@ def _build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]:
}
def _build_city_detail_payload(data: Dict[str, Any]) -> Dict[str, Any]:
distribution = data.get("probabilities", {}).get("distribution", []) or []
primary_bucket = distribution[0] if distribution else None
def _build_city_detail_payload(
data: Dict[str, Any],
market_slug: Optional[str] = None,
target_date: Optional[str] = None,
) -> Dict[str, Any]:
local_date = str(data.get("local_date") or "").strip()
requested_date = str(target_date or "").strip()
selected_date = requested_date or local_date
multi_model_daily = data.get("multi_model_daily") or {}
selected_daily = (
multi_model_daily.get(selected_date)
if isinstance(multi_model_daily, dict)
else None
)
if not isinstance(selected_daily, dict):
selected_daily = {}
selected_date = local_date
distribution = selected_daily.get("probabilities")
if not isinstance(distribution, list) or not distribution:
distribution = data.get("probabilities", {}).get("distribution", []) or []
model_map = selected_daily.get("models") or data.get("multi_model") or {}
if not isinstance(model_map, dict):
model_map = {}
# Mispricing anchor temperature:
# use the highest value across all available model highs.
anchor_temp = None
anchor_model = None
for model_name, raw_value in model_map.items():
value = _sf(raw_value)
if value is None:
continue
if anchor_temp is None or value > anchor_temp:
anchor_temp = value
anchor_model = str(model_name or "").strip() or None
anchor_temp_c = anchor_temp
temp_symbol = str(data.get("temp_symbol") or "")
if anchor_temp_c is not None and "F" in temp_symbol.upper():
anchor_temp_c = (anchor_temp_c - 32.0) * 5.0 / 9.0
anchor_settlement = wu_round(anchor_temp_c) if anchor_temp_c is not None else None
primary_bucket = None
if isinstance(distribution, list) and distribution:
if anchor_temp is None:
primary_bucket = distribution[0]
else:
ranked_buckets = []
for idx, row in enumerate(distribution):
if not isinstance(row, dict):
continue
bucket_temp = _sf(row.get("value"))
bucket_prob = _sf(row.get("probability"))
if bucket_temp is None:
continue
prob_rank = bucket_prob if bucket_prob is not None else -1.0
ranked_buckets.append((abs(bucket_temp - anchor_temp), -prob_rank, idx, row))
if ranked_buckets:
ranked_buckets.sort(key=lambda x: (x[0], x[1], x[2]))
primary_bucket = ranked_buckets[0][3]
else:
primary_bucket = distribution[0]
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]
if isinstance(p, dict)
]
market_scan = _market_layer.build_market_scan(
city=data.get("name"),
target_date=selected_date or data.get("local_date"),
temperature_bucket=primary_bucket if isinstance(primary_bucket, dict) else None,
model_probability=model_probability,
fallback_sparkline=fallback_sparkline,
forced_market_slug=market_slug,
)
if isinstance(market_scan, dict):
market_scan["anchor_model"] = anchor_model
market_scan["anchor_high"] = anchor_temp
market_scan["anchor_settlement"] = anchor_settlement
# Keep legacy key for compatibility with old checks.
market_scan["open_meteo_settlement"] = anchor_settlement
return {
"city": data.get("name"),
"fetched_at": data.get("updated_at"),
@@ -716,38 +930,13 @@ def _build_city_detail_payload(data: Dict[str, Any]) -> Dict[str, Any]:
"mgm_hourly": (data.get("mgm") or {}).get("hourly", []),
"forecast_daily": (data.get("forecast") or {}).get("daily", []),
},
"models": data.get("multi_model") or {},
"probabilities": data.get("probabilities") or {"mu": None, "distribution": []},
"market_scan": {
"available": False,
"reason": "Market layer is not available on the current backend build.",
"primary_market": None,
"selected_date": data.get("local_date"),
"selected_condition_id": None,
"selected_slug": None,
"temperature_bucket": primary_bucket,
"model_probability": (
(primary_bucket.get("probability") / 100.0)
if isinstance(primary_bucket, dict) and primary_bucket.get("probability") is not None
else None
),
"market_price": None,
"edge_percent": None,
"signal_label": "MONITOR",
"confidence": "low",
"yes_token": None,
"no_token": None,
"yes_buy": None,
"yes_sell": None,
"no_buy": None,
"no_sell": None,
"last_trade_price": None,
"liquidity": None,
"volume": None,
"sparkline": [p.get("probability", 0) for p in distribution[:8] if isinstance(p, dict)],
"recent_trades": [],
"websocket": {},
"models": {
k: v
for k, v in (data.get("multi_model") or {}).items()
if not _is_excluded_model_name(k)
},
"probabilities": data.get("probabilities") or {"mu": None, "distribution": []},
"market_scan": market_scan,
"risk": data.get("risk"),
"ai_analysis": data.get("ai_analysis") or "",
"errors": {},
@@ -755,8 +944,9 @@ def _build_city_detail_payload(data: Dict[str, Any]) -> Dict[str, Any]:
@app.get("/api/history/{name}")
async def city_history(name: str):
async def city_history(request: Request, name: str):
"""Return historical accuracy data (DEB, mu, actuals) for a city."""
_assert_entitlement(request)
name = name.lower().strip().replace("-", " ")
name = ALIASES.get(name, name)
@@ -790,17 +980,29 @@ async def city_history(name: str):
@app.get("/api/city/{name}/summary")
async def city_summary(name: str, force_refresh: bool = False):
async def city_summary(request: Request, name: str, force_refresh: bool = False):
_assert_entitlement(request)
city = _normalize_city_or_404(name)
data = _analyze(city, force_refresh=force_refresh)
return _build_city_summary_payload(data)
@app.get("/api/city/{name}/detail")
async def city_detail_aggregate(name: str, force_refresh: bool = False):
async def city_detail_aggregate(
request: Request,
name: str,
force_refresh: bool = False,
market_slug: Optional[str] = None,
target_date: Optional[str] = None,
):
_assert_entitlement(request)
city = _normalize_city_or_404(name)
data = _analyze(city, force_refresh=force_refresh)
return _build_city_detail_payload(data)
return _build_city_detail_payload(
data,
market_slug=market_slug,
target_date=target_date,
)
# ──────────────────────────────────────────────────────────
# Entrypoint