Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e5bf5a4f8 | |||
| de6cf68ee7 | |||
| 1060945d08 | |||
| ad2b1aa4b6 | |||
| ba0916ea51 | |||
| a46a98f25e | |||
| 294e038f57 | |||
| eca0c7f514 | |||
| 9295899aeb | |||
| db3b1c995a | |||
| e89fdb7e09 | |||
| 35345a1b04 | |||
| 025feaa96e | |||
| 23e2959404 | |||
| 6273768cc2 | |||
| 44af26da70 | |||
| 3cbef28b13 | |||
| d8cc193618 | |||
| 1958b2764b | |||
| d2a40462c5 | |||
| b3f46430ad | |||
| 878e3280d1 | |||
| 723694d77c | |||
| af4bee12f5 | |||
| b1e75d13d8 | |||
| 8b6c66eab0 | |||
| f7e7e0ea20 | |||
| 5d943f2aae | |||
| 8712335ffd | |||
| 41efaa1140 | |||
| 2b681513ab | |||
| c5010a8b97 | |||
| 535b2b0446 | |||
| 7f84314bcc | |||
| 4b51e0dd15 | |||
| cc1a9a3b77 | |||
| 46412511c8 | |||
| c23020038e | |||
| d4760dbcb2 | |||
| ffb88ae61d | |||
| 4e6cb1071c | |||
| d6434bf174 | |||
| 79b4708b7e | |||
| a6bef9a57f | |||
| d08982a898 | |||
| c53bfc629b | |||
| f638850048 | |||
| dd72e6a34b | |||
| 5af63557df | |||
| 35b846f1d6 | |||
| fb1c88463f | |||
| d1b591106e | |||
| 1afca8ecbb | |||
| e5011c5c8f | |||
| 732b3c10a5 | |||
| 396c373cba | |||
| aab4477ab3 |
+60
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -1,168 +1,148 @@
|
||||
# 🌡️ PolyWeather Pro
|
||||
# PolyWeather Pro
|
||||
|
||||
> **专业级博弈情报系统** —— 专注边缘气象数据采集、DEB 智能融合与实时决策预警。
|
||||
面向温度结算市场的生产级气象情报系统。
|
||||
|
||||
---
|
||||
官方看板:[polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
|
||||
|
||||
## 💎 项目愿景
|
||||
## 这个项目在做什么
|
||||
|
||||
PolyWeather 是一套专为 **Polymarket** 深度博弈者设计的实时情报系统。我们不只提供天气预报,而是通过聚合全球气象源、应用自研 **DEB (Dynamic Error Balancing)** 算法,并在关键时间节点输出**可执行的异动信号**。
|
||||
- 聚合监控城市的实测与预报数据。
|
||||
- 用 DEB(Dynamic 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
@@ -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
@@ -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
@@ -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` 为统一周边站点字段:
|
||||
- Ankara:MGM 官方周边站
|
||||
- 其他多数城市: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
@@ -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
@@ -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
@@ -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 层、分析层 |
|
||||
| 订阅权限策略不完全统一 | 可能造成付费泄露 | 前后端统一权限校验策略 |
|
||||
| 付费用户状态持久化不足 | 人工运营不可扩展 | 迁移到托管 DB(PostgreSQL/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`
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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=<your-token></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>
|
||||
);
|
||||
}
|
||||
@@ -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,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 天气衍生品智能地图,聚合 METAR、MGM、DEB、多模型预报与历史对账分析。",
|
||||
"PolyWeather dashboard with METAR, MGM, DEB fusion forecast, multi-model comparison, and history reconciliation.",
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 / METAR、Turkish MGM、Open-Meteo、weather.gov
|
||||
为主,部分城市补充 Meteoblue。
|
||||
</div>
|
||||
<div className="guide-footer">{t("guide.footer")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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."
|
||||
: "周边可用站点较多,说明地形、城区热岛或下垫面差异可能明显,结算站与城区体感需要分开评估。",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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).*)"],
|
||||
};
|
||||
Generated
+44
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
@@ -151,7 +151,7 @@
|
||||
</div>
|
||||
<div class="guide-card">
|
||||
<h3>📆 未来日期分析</h3>
|
||||
<p>点击多日预报后打开的模态框,主要用于分析下一个交易日。 6-48 小时趋势以 weather.gov 和 Open-Meteo 为主,部分城市可会补充 Meteoblue; 0-2 小时临近判断则优先看 METAR 与周边站。</p>
|
||||
<p>点击多日预报后打开的模态框,主要用于分析下一个交易日。 6-48 小时趋势以 weather.gov 和 Open-Meteo 为主; 0-2 小时临近判断则优先看 METAR 与周边站。</p>
|
||||
</div>
|
||||
<div class="guide-card">
|
||||
<h3>📊 历史对账规则</h3>
|
||||
@@ -159,7 +159,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="guide-footer">
|
||||
<p>※ 数据源以 Aviation Weather / METAR、Turkish MGM、Open-Meteo、weather.gov 为主,部分城市补充 Meteoblue。</p>
|
||||
<p>※ 数据源以 Aviation Weather / METAR、Turkish MGM、Open-Meteo、weather.gov 为主。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,6 +236,3 @@
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""On-chain monitoring modules."""
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -62,7 +62,6 @@ def _make_weather_data(
|
||||
"p90": ens_p90,
|
||||
},
|
||||
"multi_model": {"forecasts": multi_model or {}},
|
||||
"meteoblue": {},
|
||||
"nws": {},
|
||||
}
|
||||
return data
|
||||
|
||||
+252
-50
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user