Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3530fca9f9 | |||
| 1a8713f927 | |||
| 9d76e90a28 | |||
| 48c1b7e538 | |||
| 2bbb040370 | |||
| f421bc4a24 | |||
| 59cd48997f | |||
| 7a5fe8b571 | |||
| 459909539c | |||
| 5bad2ec398 | |||
| ee1162f15f | |||
| a2f169f008 | |||
| 35a9d8e943 | |||
| a983c8094e | |||
| 15de44767e | |||
| a5f6bca527 | |||
| 1da32c4593 | |||
| ad9b33f957 | |||
| f9b7b49690 | |||
| 409ab0ffb6 | |||
| 515c7c1d40 | |||
| bbf2db4da1 | |||
| 5404fdef2b | |||
| 02521d6fdb | |||
| 168b813754 | |||
| 1df13bb9e6 | |||
| 709a567525 | |||
| b8d4970f9a | |||
| a680fca73e | |||
| d7ab68182e | |||
| 1d11a5cbc6 | |||
| a76ec62882 | |||
| ac565c4a00 | |||
| 578f547fa7 | |||
| 3439e0e47a | |||
| 1a5bab9fad | |||
| 0fef948fac | |||
| 07c5451f1d |
+10
@@ -1,9 +1,19 @@
|
||||
# Secrets
|
||||
.env
|
||||
secrets/
|
||||
*.service-account.json
|
||||
gcp-sa.json
|
||||
|
||||
# Scratch / temp scripts
|
||||
scratch/
|
||||
|
||||
# Private trading layer (never commit strategy/bot execution code here)
|
||||
private-trading/
|
||||
trading-private/
|
||||
polyweather-trading-private/
|
||||
internal-trading/
|
||||
ops-trading-private/
|
||||
|
||||
# Data and Logs
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
|
||||
@@ -60,7 +60,7 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
||||
This repository is licensed under **GNU AGPL-3.0 only** from `2026-03-30` onward.
|
||||
|
||||
- Public in repo: weather aggregation, core analysis, dashboard, bot baseline, and standard payment flow.
|
||||
- Not included in this repository: private production data, internal operating thresholds, commercial risk rules, pricing strategy details, and growth tooling.
|
||||
- Not included in this repository: private production data, internal operating thresholds, commercial risk rules, pricing strategy details, growth tooling, internal mispricing strategy, position sizing rules, and trading bot execution code.
|
||||
- Trademark, brand, domain, production databases, and hosted-service operations are **not** granted by the code license.
|
||||
|
||||
See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@
|
||||
本仓库自 `2026-03-30` 起采用 **GNU AGPL-3.0-only**。
|
||||
|
||||
- 仓库公开部分:天气聚合、基础分析、前端看板、Bot 基础能力、标准支付流程。
|
||||
- 不包含在仓库中的部分:生产私有数据、商业风控规则、运营阈值、收费策略细节、内部对账与增长工具。
|
||||
- 不包含在仓库中的部分:生产私有数据、商业风控规则、运营阈值、收费策略细节、内部对账与增长工具、内部错价策略、仓位规则与交易 Bot 执行代码。
|
||||
- 商标、品牌、域名、生产数据库与托管服务运营能力,不因代码许可证一并授权。
|
||||
|
||||
详细见:[AGPL-3.0 与商用边界](docs/OPEN_CORE_POLICY.md)
|
||||
|
||||
+9
-1
@@ -3,7 +3,15 @@ $PROJECT = "/root/PolyWeather"
|
||||
|
||||
Write-Host "🚀 Deploying to $VPS..." -ForegroundColor Cyan
|
||||
|
||||
ssh $VPS "cd $PROJECT && git pull && docker compose up -d --build"
|
||||
ssh $VPS @"
|
||||
cd $PROJECT || exit 1
|
||||
git pull || exit 1
|
||||
docker compose stop polyweather || true
|
||||
pkill -TERM -f 'python(3)? .*bot_[l]istener\.py' || true
|
||||
sleep 2
|
||||
pkill -KILL -f 'python(3)? .*bot_[l]istener\.py' || true
|
||||
docker compose up -d --build
|
||||
"@
|
||||
|
||||
Write-Host "✅ Deploy complete. Checking health..." -ForegroundColor Green
|
||||
Start-Sleep 8
|
||||
|
||||
@@ -143,6 +143,24 @@ read_env_file_value() {
|
||||
' .env | tail -n 1
|
||||
}
|
||||
|
||||
stop_existing_bot_receivers() {
|
||||
echo "Stopping existing Telegram bot receivers..."
|
||||
docker compose stop polyweather || true
|
||||
|
||||
local legacy_pattern='python(3)? .*bot_[l]istener\.py'
|
||||
if pgrep -af "$legacy_pattern" >/dev/null 2>&1; then
|
||||
echo "Stopping legacy host bot_listener.py processes..."
|
||||
pkill -TERM -f "$legacy_pattern" || true
|
||||
sleep 3
|
||||
if pgrep -af "$legacy_pattern" >/dev/null 2>&1; then
|
||||
echo "Force-stopping legacy host bot_listener.py processes..."
|
||||
pkill -KILL -f "$legacy_pattern" || true
|
||||
fi
|
||||
else
|
||||
echo "No legacy host bot_listener.py process found"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_env_value() {
|
||||
local primary_key="$1"
|
||||
local fallback_key="${2:-}"
|
||||
@@ -175,6 +193,7 @@ if [ -n "$resolved_supabase_anon_key" ]; then
|
||||
else
|
||||
unset SUPABASE_ANON_KEY
|
||||
fi
|
||||
stop_existing_bot_receivers
|
||||
pull_ok=0
|
||||
for pull_attempt in $(seq 1 6); do
|
||||
docker compose pull && pull_ok=1 && break
|
||||
@@ -278,6 +297,14 @@ wait_for_scan_terminal_snapshot() {
|
||||
echo "✅ $name ready after attempt $i/$attempts"
|
||||
return 0
|
||||
fi
|
||||
if printf '%s' "$compact" | grep -q '"status":"stale"'; then
|
||||
echo "✅ $name stale snapshot available after attempt $i/$attempts"
|
||||
return 0
|
||||
fi
|
||||
if printf '%s' "$compact" | grep -q '"stale_reason":"市场扫描快照正在初始化"'; then
|
||||
echo "✅ $name initializing after attempt $i/$attempts"
|
||||
return 0
|
||||
fi
|
||||
status="$(printf '%s' "$compact" | sed -n 's/.*"status":"\([^"]*\)".*/\1/p' | head -n 1)"
|
||||
echo " $name not ready attempt $i/$attempts http=${http_status:-unknown} status=${status:-unknown}"
|
||||
else
|
||||
@@ -288,7 +315,7 @@ wait_for_scan_terminal_snapshot() {
|
||||
fi
|
||||
done
|
||||
|
||||
echo "❌ $name did not return status=ready or http=401"
|
||||
echo "❌ $name did not return status=ready/stale or http=401"
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -353,6 +380,9 @@ compose_up_retry "cache warmer" -d --no-deps polyweather_warmer
|
||||
echo "Updating training settlement worker..."
|
||||
compose_up_retry "training settlement" -d --no-deps polyweather_training_settlement
|
||||
|
||||
echo "Updating WeatherNext2 worker..."
|
||||
compose_up_retry "WeatherNext2 worker" -d --no-deps polyweather_weathernext2_worker
|
||||
|
||||
echo "Updating frontend..."
|
||||
compose_up_retry "frontend" -d --no-deps polyweather_frontend
|
||||
|
||||
|
||||
@@ -139,6 +139,9 @@ services:
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
|
||||
SUPABASE_URL: ${SUPABASE_URL}
|
||||
UVICORN_WORKERS: ${UVICORN_WORKERS:-2}
|
||||
WEATHERNEXT2_CITY_HIGHS_PATH: ${WEATHERNEXT2_CITY_HIGHS_PATH:-/app/data/weathernext2_city_highs.json}
|
||||
WEATHERNEXT2_ENABLED: ${WEATHERNEXT2_ENABLED:-1}
|
||||
WEATHERNEXT2_MODEL_DIR: ${WEATHERNEXT2_MODEL_DIR:-/app/data/models/weathernext2_calibrator}
|
||||
healthcheck:
|
||||
interval: 30s
|
||||
retries: 3
|
||||
@@ -296,6 +299,56 @@ services:
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
polyweather_weathernext2_worker:
|
||||
command: python -m web.weathernext2_worker
|
||||
container_name: polyweather_weathernext2_worker
|
||||
depends_on:
|
||||
polyweather_redis:
|
||||
condition: service_healthy
|
||||
polyweather_web:
|
||||
condition: service_started
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
cpus: ${POLYWEATHER_WEATHERNEXT2_CPUS:-1.50}
|
||||
env_file: *id001
|
||||
environment:
|
||||
GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-/app/secrets/gcp-sa.json}
|
||||
POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}
|
||||
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
|
||||
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
|
||||
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||
POLYWEATHER_SERVICE_ROLE: weathernext2_worker
|
||||
WEATHERNEXT2_BACKEND: ${WEATHERNEXT2_BACKEND:-gcs_zarr}
|
||||
WEATHERNEXT2_CITY_HIGHS_PATH: ${WEATHERNEXT2_CITY_HIGHS_PATH:-/app/data/weathernext2_city_highs.json}
|
||||
WEATHERNEXT2_DATA_ROOT: ${WEATHERNEXT2_DATA_ROOT:-/app/data/weathernext2}
|
||||
WEATHERNEXT2_ENABLED: ${WEATHERNEXT2_ENABLED:-1}
|
||||
WEATHERNEXT2_GCS_ZARR_URI: ${WEATHERNEXT2_GCS_ZARR_URI:-gs://weathernext/weathernext_2_0_0/zarr}
|
||||
WEATHERNEXT2_MODEL_DIR: ${WEATHERNEXT2_MODEL_DIR:-/app/data/models/weathernext2_calibrator}
|
||||
WEATHERNEXT2_WORKER_INITIAL_DELAY_SEC: ${WEATHERNEXT2_WORKER_INITIAL_DELAY_SEC:-90}
|
||||
WEATHERNEXT2_WORKER_INTERVAL_SEC: ${WEATHERNEXT2_WORKER_INTERVAL_SEC:-21600}
|
||||
healthcheck:
|
||||
interval: 60s
|
||||
retries: 3
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db');
|
||||
c.execute('SELECT 1'); c.close()
|
||||
timeout: 10s
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
mem_limit: ${POLYWEATHER_WEATHERNEXT2_MEM_LIMIT:-2g}
|
||||
memswap_limit: ${POLYWEATHER_WEATHERNEXT2_MEMSWAP_LIMIT:-3g}
|
||||
pids_limit: 512
|
||||
restart: unless-stopped
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
- ./secrets:/app/secrets:ro
|
||||
x-polyweather-base:
|
||||
env_file: *id001
|
||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AGPL-3.0 与商用边界
|
||||
|
||||
最后更新:`2026-03-30`
|
||||
最后更新:`2026-06-29`
|
||||
|
||||
## 1. 当前许可证
|
||||
|
||||
@@ -18,27 +18,37 @@
|
||||
- 天气数据采集与标准化(METAR / Open-Meteo / MGM / 官方结算源接口层)。
|
||||
- DEB、基础趋势分析、概率桶、历史对账、前端看板与 Bot 基础能力。
|
||||
- 标准支付流程、链上收款合约与公开 API/BFF 结构。
|
||||
- 面向用户的天气概率解释、公开市场简报与不含交易执行建议的市场映射。
|
||||
|
||||
## 4. 不在仓库许可证授权范围内的资产
|
||||
|
||||
- 商标、品牌名、域名、Logo、商店素材与市场宣传文案。
|
||||
- 生产数据库、用户资料、钱包映射、订阅审计日志、内部报表。
|
||||
- 私有运营脚本、增长工具、内部风控参数、收费策略细节与内部阈值。
|
||||
- 内部交易策略层:mispricing/edge 规则、YES/NO 方向判断、仓位规则、Kelly 或其他资金管理参数、自动/半自动下单 Bot、执行日志、PnL 复盘与任何钱包执行凭据。
|
||||
- 托管服务本身、SLA、客服、运维值守与内部告警路由。
|
||||
|
||||
## 5. 配置与数据安全红线
|
||||
## 5. 内部交易层边界
|
||||
|
||||
- 公开仓库可以输出天气证据、DEB、多模型一致性、观测进度与高斯分布概率。
|
||||
- 内部交易层只能在私有仓库或私有部署资产中实现,不在本仓库提交源码、配置或策略阈值。
|
||||
- 内部交易层如需使用本仓库数据,应通过稳定 API 或导出数据结构读取,不把下单策略反向写入公开产品层。
|
||||
- 禁止在公开页面、普通用户接口或公开文档中暴露 BUY YES / BUY NO、仓位建议、做市商错价评分、自动下单状态或内部策略参数。
|
||||
|
||||
## 6. 配置与数据安全红线
|
||||
|
||||
- 不提交:`.env`、私钥、API key、机器人 token、第三方 service role key。
|
||||
- 不提交:生产数据库、运行态快照、支付流水快照、用户身份信息。
|
||||
- 不提交:仅用于线上商业判断的私有规则库与内部操作手册。
|
||||
- 不提交:`private-trading/`、`trading-private/`、`polyweather-trading-private/`、`internal-trading/`、`ops-trading-private/` 下的任何内容。
|
||||
|
||||
## 6. 对部署者的要求
|
||||
## 7. 对部署者的要求
|
||||
|
||||
- 若你提供公开网络服务,应在产品界面中提供清晰可访问的源码入口。
|
||||
- 若你修改了本项目再对外提供网络服务,应公开与你实际运行版本对应的源码。
|
||||
- 若你使用了仓库外的私有数据、商标或运营资产,这些额外资产不因 AGPL 自动获得授权。
|
||||
|
||||
## 7. 法务与运营建议
|
||||
## 8. 法务与运营建议
|
||||
|
||||
- 代码许可证与商标/品牌授权应继续分离管理。
|
||||
- 官网应补充服务条款、隐私政策与付费权益说明。
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Ops Market Multimodel Context Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make Ops market opportunities evaluate and display full multi-model context instead of relying mainly on DEB and median.
|
||||
|
||||
**Architecture:** Keep the existing Ops opportunities API and page. Extend each opportunity row with multi-model source values and bucket-relative counts, then use those counts in the late NO filter so high/low multi-model consensus is preserved for manual review.
|
||||
|
||||
**Tech Stack:** Python FastAPI service code, pytest, Next.js React TypeScript, business state tests.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend Multi-Model Signals
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/services/ops/market_opportunities.py`
|
||||
- Test: `tests/test_ops_market_opportunities.py`
|
||||
|
||||
- [ ] **Step 1: Add tests**
|
||||
|
||||
Add tests asserting opportunities include `model_cluster_sources`, `model_min`, `model_max`, `models_in_bucket`, `models_above_bucket`, `models_below_bucket`, and `models_above_deb`.
|
||||
|
||||
- [ ] **Step 2: Implement source extraction**
|
||||
|
||||
Create helper functions to parse `row["model_cluster_sources"]`, classify model values against a market option, and return counts plus min/max.
|
||||
|
||||
- [ ] **Step 3: Return fields on opportunity rows**
|
||||
|
||||
Include the new fields in every row produced by `build_market_opportunity_rows`.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run `python -m pytest tests/test_ops_market_opportunities.py -q`.
|
||||
|
||||
### Task 2: Conservative Late NO Filtering
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/services/ops/market_opportunities.py`
|
||||
- Test: `tests/test_ops_market_opportunities.py`
|
||||
|
||||
- [ ] **Step 1: Update tests**
|
||||
|
||||
Change the late NO tests so rows are filtered only when multi-model consensus is inside the target bucket, and preserved when models mostly sit above or below the target bucket.
|
||||
|
||||
- [ ] **Step 2: Implement filtering**
|
||||
|
||||
Pass model relation counts into `_is_late_priced_no_noise`; return `False` when outside-bucket model count is greater than inside-bucket count.
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
|
||||
Run `python -m pytest tests/test_ops_market_opportunities.py -q`.
|
||||
|
||||
### Task 3: Frontend Display
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/ops/market-opportunities/MarketOpportunitiesPageClient.tsx`
|
||||
- Test: `frontend/components/ops/__tests__/opsMarketOpportunities.test.ts`
|
||||
|
||||
- [ ] **Step 1: Add frontend assertions**
|
||||
|
||||
Assert the Ops market opportunities page includes `多模型`, `models_above_bucket`, and `model_cluster_sources`.
|
||||
|
||||
- [ ] **Step 2: Display compact context**
|
||||
|
||||
Add a `多模型` column showing min/max range, in/high/low bucket counts, and compact source values.
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
|
||||
Run `cd frontend && npm run test:business` and `cd frontend && npm run typecheck`.
|
||||
|
||||
### Task 4: Final Validation and Deploy
|
||||
|
||||
**Files:**
|
||||
- No new source files.
|
||||
|
||||
- [ ] **Step 1: Run full checks**
|
||||
|
||||
Run `python -m ruff check .`, `python -m pytest`, `cd frontend && npm run test:business`, and `cd frontend && npm run typecheck`.
|
||||
|
||||
- [ ] **Step 2: Commit and push**
|
||||
|
||||
Commit the backend and frontend changes, push `main`, monitor GitHub Actions, and smoke check production.
|
||||
@@ -0,0 +1,97 @@
|
||||
# WeatherNext 2 接入说明
|
||||
|
||||
PolyWeather 已新增 WeatherNext 2 的内部接入层,当前定位是“概率底座”和“DEB 参考模型”,不是公开 API。
|
||||
|
||||
## 已落地
|
||||
|
||||
- `src.data_collection.weathernext2_sources`
|
||||
- 将 64 成员最高温预报聚合成 Polymarket 口径概率桶。
|
||||
- 摄氏城市按单温度选项,例如 `33°C`。
|
||||
- 华氏城市按两度市场选项,例如 `94-95°F`。
|
||||
- 支持从 hourly 成员序列按城市当地日期切出今日最高温。
|
||||
- `WeatherDataCollector.fetch_weathernext2_probability`
|
||||
- 默认关闭,不影响现有生产采集。
|
||||
- `WEATHERNEXT2_ENABLED=1` 后启用。
|
||||
- 优先读取 worker 生成的 `/app/data/weathernext2_city_highs.json`,再降级读取 fixture。
|
||||
- 若存在 LightGBM 校准模型,会对 raw 成员分布做 quantile residual 校准后再生成市场桶概率。
|
||||
- `web.weathernext2_worker`
|
||||
- 独立低频 worker,不在 API/Telegram 请求中训练或访问 GCS。
|
||||
- 从 GCS Zarr 对城市经纬度最近格点取 2m temperature hourly ensemble。
|
||||
- 按城市当地日期切今日最高温,写入离线 JSON 产物。
|
||||
- 使用 `training_feature_records_store` + `truth_records_store` 训练 LightGBM q10/q50/q90 residual calibrator。
|
||||
- `trend_engine`
|
||||
- 若 `weather_data["weathernext2"]` 存在,`distribution_full` 使用 WeatherNext 2 概率桶,`probability_engine=weathernext2`。
|
||||
- WeatherNext 2 ensemble median/mean 会作为 `WeatherNext 2` 进入 `current_forecasts`,供 DEB 融合参考。
|
||||
|
||||
## 环境变量
|
||||
|
||||
```bash
|
||||
WEATHERNEXT2_ENABLED=1
|
||||
WEATHERNEXT2_BACKEND=gcs_zarr
|
||||
WEATHERNEXT2_DATA_ROOT=/app/data/weathernext2
|
||||
WEATHERNEXT2_CITY_HIGHS_PATH=/app/data/weathernext2_city_highs.json
|
||||
WEATHERNEXT2_MODEL_DIR=/app/data/models/weathernext2_calibrator
|
||||
WEATHERNEXT2_WORKER_INTERVAL_SEC=21600
|
||||
WEATHERNEXT2_CACHE_TTL_SEC=21600
|
||||
WEATHERNEXT2_GCS_ZARR_URI=gs://weathernext/weathernext_2_0_0/zarr
|
||||
WEATHERNEXT2_MEAN_GCS_ZARR_URI=gs://weathernext/weathernext_2_0_0_mean/zarr
|
||||
GOOGLE_APPLICATION_CREDENTIALS=/app/secrets/gcp-sa.json
|
||||
```
|
||||
|
||||
Google 官方说明中 WeatherNext 2 GCS Zarr 路径为 `gs://weathernext/weathernext_2_0_0/zarr`,访问前需要 WeatherNext Data Request 权限。缺 GCP 凭据或无访问权限时,worker 会失败退出本轮,不覆盖旧产物;Web/API 仍按现有数据源运行。
|
||||
|
||||
## Artifact / Fixture 格式
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source": "weathernext2",
|
||||
"backend": "gcs_zarr",
|
||||
"generated_at": "2026-07-01T10:00:00+00:00",
|
||||
"cities": {
|
||||
"houston": {
|
||||
"target_date": "2026-06-29",
|
||||
"source_run": "2026-06-29T00:00:00Z",
|
||||
"member_highs": {
|
||||
"member_00": 94.1,
|
||||
"member_01": 94.8,
|
||||
"member_02": 95.2,
|
||||
"member_03": 96.7
|
||||
},
|
||||
"summary": {
|
||||
"members": 4,
|
||||
"median": 95.0
|
||||
},
|
||||
"buckets": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
也可以提供 hourly 成员序列:
|
||||
|
||||
```json
|
||||
{
|
||||
"shanghai": {
|
||||
"target_date": "2026-06-29",
|
||||
"timezone_offset_seconds": 28800,
|
||||
"utc_times": ["2026-06-28T16:00:00Z", "2026-06-29T06:00:00Z"],
|
||||
"member_hourly": {
|
||||
"member_00": [31.2, 33.0],
|
||||
"member_01": [30.8, 32.6]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 后续真实拉取
|
||||
|
||||
Google 官方 WeatherNext 2 数据源包括 GCS Zarr、BigQuery、Earth Engine 和 Vertex AI。当前第一版已经按独立离线 job 落地:
|
||||
|
||||
- 每 6 小时拉取最近一次 WeatherNext 2 run。
|
||||
- 对 50 个城市插值到机场/结算点。
|
||||
- 切城市当地日期今日最高温。
|
||||
- 写入 `/app/data/weathernext2_city_highs.json` 或数据库表。
|
||||
- Web/API 只读本地结果,避免实时请求 Google 数据集影响响应时间。
|
||||
|
||||
后续可以评估 BigQuery 后端作为替代路径,但不应让 API 请求直接依赖外部 WeatherNext 数据集。
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const upstream = new URL(`${API_BASE}/api/ops/market-opportunities`);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
upstream.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(upstream.toString(), {
|
||||
cache: "no-store",
|
||||
headers: auth.headers,
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
},
|
||||
status: res.status,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch market opportunities",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import {
|
||||
buildForceRefreshProxyCachePolicy,
|
||||
buildScanTerminalResponseCacheControl,
|
||||
NO_STORE_CACHE_CONTROL,
|
||||
} from "@/lib/proxy-cache-policy";
|
||||
import { DASHBOARD_REFRESH_POLICY_SEC } from "@/lib/refresh-policy";
|
||||
import {
|
||||
createProxyTimer,
|
||||
finishProxyTimedResponse,
|
||||
@@ -12,10 +10,10 @@ import {
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number(
|
||||
process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "35000",
|
||||
process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "60000",
|
||||
);
|
||||
|
||||
export const maxDuration = 45;
|
||||
export const maxDuration = 70;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const timer = createProxyTimer(req, "scan_terminal");
|
||||
@@ -31,7 +29,6 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
for (const key of [
|
||||
"scan_mode",
|
||||
"min_price",
|
||||
@@ -56,11 +53,6 @@ export async function GET(req: NextRequest) {
|
||||
if (tradingRegion != null && tradingRegion !== "") {
|
||||
params.set("region", tradingRegion);
|
||||
}
|
||||
const cachePolicy = buildForceRefreshProxyCachePolicy(
|
||||
forceRefresh,
|
||||
DASHBOARD_REFRESH_POLICY_SEC.scanRows,
|
||||
);
|
||||
|
||||
const url = `${API_BASE}/api/scan/terminal?${params.toString()}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
@@ -68,15 +60,10 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
return await proxyBackendJsonGet(req, {
|
||||
cacheControl: cachePolicy.responseCacheControl,
|
||||
cacheControlForData: (data) =>
|
||||
buildScanTerminalResponseCacheControl(
|
||||
data,
|
||||
cachePolicy.responseCacheControl,
|
||||
),
|
||||
cacheControl: NO_STORE_CACHE_CONTROL,
|
||||
cacheControlForData: () => NO_STORE_CACHE_CONTROL,
|
||||
fetchCache: "no-store",
|
||||
publicMessage: "Failed to fetch scan terminal data",
|
||||
revalidateSeconds: cachePolicy.revalidateSeconds,
|
||||
includeSupabaseIdentity: true,
|
||||
signal: controller.signal,
|
||||
timeoutPublicMessage: "Scan terminal request timed out",
|
||||
|
||||
@@ -1,17 +1,38 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { BriefDetailPageView } from "@/components/public-content/PublicContentPages";
|
||||
import {
|
||||
LANDING_LOCALE_COOKIE,
|
||||
LANDING_LOCALE_QUERY_PARAM,
|
||||
pickLandingLocale,
|
||||
type LandingLocale,
|
||||
} from "@/components/landing/landingLocale";
|
||||
import {
|
||||
PUBLIC_BRIEFS,
|
||||
absolutePublicUrl,
|
||||
briefPath,
|
||||
getBrief,
|
||||
localizeBrief,
|
||||
} from "@/content/public-content";
|
||||
|
||||
type BriefPageParams = {
|
||||
city: string;
|
||||
date: string;
|
||||
};
|
||||
type BriefSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
async function resolvePublicContentLocale(searchParams: BriefSearchParams): Promise<LandingLocale> {
|
||||
const params = await searchParams;
|
||||
const rawLocale = params[LANDING_LOCALE_QUERY_PARAM];
|
||||
const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale;
|
||||
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
|
||||
return pickLandingLocale(
|
||||
queryLocale,
|
||||
cookieStore.get(LANDING_LOCALE_COOKIE)?.value,
|
||||
headerStore.get("accept-language"),
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return PUBLIC_BRIEFS.map((brief) => ({
|
||||
@@ -22,10 +43,15 @@ export function generateStaticParams() {
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<BriefPageParams>;
|
||||
searchParams: BriefSearchParams;
|
||||
}): Promise<Metadata> {
|
||||
const { city, date } = await params;
|
||||
const [{ city, date }, locale] = await Promise.all([
|
||||
params,
|
||||
resolvePublicContentLocale(searchParams),
|
||||
]);
|
||||
const brief = getBrief(city, date);
|
||||
|
||||
if (!brief) {
|
||||
@@ -34,51 +60,58 @@ export async function generateMetadata({
|
||||
};
|
||||
}
|
||||
|
||||
const pathname = briefPath(brief);
|
||||
const localizedBrief = localizeBrief(brief, locale);
|
||||
const pathname = briefPath(localizedBrief);
|
||||
|
||||
return {
|
||||
title: brief.title,
|
||||
description: brief.description,
|
||||
title: localizedBrief.title,
|
||||
description: localizedBrief.description,
|
||||
alternates: {
|
||||
canonical: pathname,
|
||||
},
|
||||
openGraph: {
|
||||
type: "article",
|
||||
title: brief.title,
|
||||
description: brief.description,
|
||||
title: localizedBrief.title,
|
||||
description: localizedBrief.description,
|
||||
url: pathname,
|
||||
publishedTime: brief.publishedAt,
|
||||
modifiedTime: brief.updatedAt,
|
||||
publishedTime: localizedBrief.publishedAt,
|
||||
modifiedTime: localizedBrief.updatedAt,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: brief.title,
|
||||
description: brief.description,
|
||||
title: localizedBrief.title,
|
||||
description: localizedBrief.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BriefDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<BriefPageParams>;
|
||||
searchParams: BriefSearchParams;
|
||||
}) {
|
||||
const { city, date } = await params;
|
||||
const [{ city, date }, locale] = await Promise.all([
|
||||
params,
|
||||
resolvePublicContentLocale(searchParams),
|
||||
]);
|
||||
const brief = getBrief(city, date);
|
||||
|
||||
if (!brief) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const pathname = briefPath(brief);
|
||||
const localizedBrief = localizeBrief(brief, locale);
|
||||
const pathname = briefPath(localizedBrief);
|
||||
const jsonLd = [
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
headline: brief.title,
|
||||
description: brief.description,
|
||||
datePublished: brief.publishedAt,
|
||||
dateModified: brief.updatedAt,
|
||||
headline: localizedBrief.title,
|
||||
description: localizedBrief.description,
|
||||
datePublished: localizedBrief.publishedAt,
|
||||
dateModified: localizedBrief.updatedAt,
|
||||
mainEntityOfPage: absolutePublicUrl(pathname),
|
||||
author: {
|
||||
"@type": "Organization",
|
||||
@@ -91,9 +124,9 @@ export default async function BriefDetailPage({
|
||||
url: "https://polyweather.top",
|
||||
},
|
||||
about: [
|
||||
brief.cityName,
|
||||
brief.market,
|
||||
brief.settlementSource,
|
||||
localizedBrief.cityName,
|
||||
localizedBrief.market,
|
||||
localizedBrief.settlementSource,
|
||||
"DEB forecast methodology",
|
||||
],
|
||||
},
|
||||
@@ -110,7 +143,7 @@ export default async function BriefDetailPage({
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: brief.cityName,
|
||||
name: localizedBrief.cityName,
|
||||
item: absolutePublicUrl(pathname),
|
||||
},
|
||||
],
|
||||
@@ -123,7 +156,7 @@ export default async function BriefDetailPage({
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<BriefDetailPageView brief={brief} />
|
||||
<BriefDetailPageView brief={brief} locale={locale} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,54 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { BriefsIndexPageView } from "@/components/public-content/PublicContentPages";
|
||||
import {
|
||||
LANDING_LOCALE_COOKIE,
|
||||
LANDING_LOCALE_QUERY_PARAM,
|
||||
pickLandingLocale,
|
||||
type LandingLocale,
|
||||
} from "@/components/landing/landingLocale";
|
||||
import { PUBLIC_CONTENT_COPY } from "@/content/public-content";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Weather Market Brief",
|
||||
description:
|
||||
"Public PolyWeather market briefs for temperature judgment, settlement-source checks, DEB context, and source freshness notes.",
|
||||
alternates: {
|
||||
canonical: "/briefs",
|
||||
},
|
||||
openGraph: {
|
||||
title: "Weather Market Brief | PolyWeather",
|
||||
description:
|
||||
"Public market briefs for temperature judgment, settlement-source checks, DEB context, and source freshness notes.",
|
||||
url: "/briefs",
|
||||
},
|
||||
};
|
||||
type BriefsSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
export default function BriefsPage() {
|
||||
return <BriefsIndexPageView />;
|
||||
async function resolvePublicContentLocale(searchParams: BriefsSearchParams): Promise<LandingLocale> {
|
||||
const params = await searchParams;
|
||||
const rawLocale = params[LANDING_LOCALE_QUERY_PARAM];
|
||||
const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale;
|
||||
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
|
||||
return pickLandingLocale(
|
||||
queryLocale,
|
||||
cookieStore.get(LANDING_LOCALE_COOKIE)?.value,
|
||||
headerStore.get("accept-language"),
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: BriefsSearchParams;
|
||||
}): Promise<Metadata> {
|
||||
const locale = await resolvePublicContentLocale(searchParams);
|
||||
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||
return {
|
||||
title: copy.briefIndexEyebrow,
|
||||
description: copy.briefIndexDescription,
|
||||
alternates: {
|
||||
canonical: "/briefs",
|
||||
},
|
||||
openGraph: {
|
||||
title: `Weather Market Brief | PolyWeather`,
|
||||
description: copy.briefIndexDescription,
|
||||
url: "/briefs",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BriefsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: BriefsSearchParams;
|
||||
}) {
|
||||
const locale = await resolvePublicContentLocale(searchParams);
|
||||
return <BriefsIndexPageView locale={locale} />;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { MethodologyDetailPageView } from "@/components/public-content/PublicContentPages";
|
||||
import {
|
||||
LANDING_LOCALE_COOKIE,
|
||||
LANDING_LOCALE_QUERY_PARAM,
|
||||
pickLandingLocale,
|
||||
type LandingLocale,
|
||||
} from "@/components/landing/landingLocale";
|
||||
import {
|
||||
METHODOLOGY_PAGES,
|
||||
absolutePublicUrl,
|
||||
getMethodologyPage,
|
||||
localizeMethodologyPage,
|
||||
methodologyPath,
|
||||
} from "@/content/public-content";
|
||||
|
||||
type MethodologyPageParams = {
|
||||
slug: string;
|
||||
};
|
||||
type MethodologySearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
async function resolvePublicContentLocale(searchParams: MethodologySearchParams): Promise<LandingLocale> {
|
||||
const params = await searchParams;
|
||||
const rawLocale = params[LANDING_LOCALE_QUERY_PARAM];
|
||||
const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale;
|
||||
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
|
||||
return pickLandingLocale(
|
||||
queryLocale,
|
||||
cookieStore.get(LANDING_LOCALE_COOKIE)?.value,
|
||||
headerStore.get("accept-language"),
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return METHODOLOGY_PAGES.map((page) => ({ slug: page.slug }));
|
||||
@@ -18,10 +39,15 @@ export function generateStaticParams() {
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<MethodologyPageParams>;
|
||||
searchParams: MethodologySearchParams;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const [{ slug }, locale] = await Promise.all([
|
||||
params,
|
||||
resolvePublicContentLocale(searchParams),
|
||||
]);
|
||||
const page = getMethodologyPage(slug);
|
||||
|
||||
if (!page) {
|
||||
@@ -29,17 +55,18 @@ export async function generateMetadata({
|
||||
title: "Methodology not found",
|
||||
};
|
||||
}
|
||||
const localizedPage = localizeMethodologyPage(page, locale);
|
||||
|
||||
return {
|
||||
title: page.title,
|
||||
description: page.description,
|
||||
title: localizedPage.title,
|
||||
description: localizedPage.description,
|
||||
alternates: {
|
||||
canonical: methodologyPath(page),
|
||||
},
|
||||
openGraph: {
|
||||
type: "article",
|
||||
title: page.title,
|
||||
description: page.description,
|
||||
title: localizedPage.title,
|
||||
description: localizedPage.description,
|
||||
url: methodologyPath(page),
|
||||
modifiedTime: page.updatedAt,
|
||||
},
|
||||
@@ -48,22 +75,28 @@ export async function generateMetadata({
|
||||
|
||||
export default async function MethodologyDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<MethodologyPageParams>;
|
||||
searchParams: MethodologySearchParams;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const [{ slug }, locale] = await Promise.all([
|
||||
params,
|
||||
resolvePublicContentLocale(searchParams),
|
||||
]);
|
||||
const page = getMethodologyPage(slug);
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const localizedPage = localizeMethodologyPage(page, locale);
|
||||
const pathname = methodologyPath(page);
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "TechArticle",
|
||||
headline: page.title,
|
||||
description: page.description,
|
||||
headline: localizedPage.title,
|
||||
description: localizedPage.description,
|
||||
dateModified: page.updatedAt,
|
||||
mainEntityOfPage: absolutePublicUrl(pathname),
|
||||
author: {
|
||||
@@ -79,7 +112,7 @@ export default async function MethodologyDetailPage({
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<MethodologyDetailPageView page={page} />
|
||||
<MethodologyDetailPageView page={page} locale={locale} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,54 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { MethodologyIndexPageView } from "@/components/public-content/PublicContentPages";
|
||||
import {
|
||||
LANDING_LOCALE_COOKIE,
|
||||
LANDING_LOCALE_QUERY_PARAM,
|
||||
pickLandingLocale,
|
||||
type LandingLocale,
|
||||
} from "@/components/landing/landingLocale";
|
||||
import { PUBLIC_CONTENT_COPY } from "@/content/public-content";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Methodology",
|
||||
description:
|
||||
"PolyWeather public methodology for DEB forecast blending, settlement-source priority, freshness, and market weather analysis.",
|
||||
alternates: {
|
||||
canonical: "/methodology",
|
||||
},
|
||||
openGraph: {
|
||||
title: "Methodology | PolyWeather",
|
||||
description:
|
||||
"Public methodology for DEB forecast blending, settlement-source priority, freshness, and market weather analysis.",
|
||||
url: "/methodology",
|
||||
},
|
||||
};
|
||||
type MethodologySearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
export default function MethodologyPage() {
|
||||
return <MethodologyIndexPageView />;
|
||||
async function resolvePublicContentLocale(searchParams: MethodologySearchParams): Promise<LandingLocale> {
|
||||
const params = await searchParams;
|
||||
const rawLocale = params[LANDING_LOCALE_QUERY_PARAM];
|
||||
const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale;
|
||||
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
|
||||
return pickLandingLocale(
|
||||
queryLocale,
|
||||
cookieStore.get(LANDING_LOCALE_COOKIE)?.value,
|
||||
headerStore.get("accept-language"),
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: MethodologySearchParams;
|
||||
}): Promise<Metadata> {
|
||||
const locale = await resolvePublicContentLocale(searchParams);
|
||||
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||
return {
|
||||
title: copy.methodologyIndexEyebrow,
|
||||
description: copy.methodologyIndexDescription,
|
||||
alternates: {
|
||||
canonical: "/methodology",
|
||||
},
|
||||
openGraph: {
|
||||
title: `${copy.methodologyIndexEyebrow} | PolyWeather`,
|
||||
description: copy.methodologyIndexDescription,
|
||||
url: "/methodology",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function MethodologyPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: MethodologySearchParams;
|
||||
}) {
|
||||
const locale = await resolvePublicContentLocale(searchParams);
|
||||
return <MethodologyIndexPageView locale={locale} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireOpsAdmin } from "@/lib/ops-admin";
|
||||
import { MarketOpportunitiesPageClient } from "@/components/ops/market-opportunities/MarketOpportunitiesPageClient";
|
||||
|
||||
export const metadata: Metadata = { title: "市场机会 — PolyWeather Ops" };
|
||||
|
||||
export default async function MarketOpportunitiesPage() {
|
||||
await requireOpsAdmin("/ops/market-opportunities");
|
||||
return <MarketOpportunitiesPageClient />;
|
||||
}
|
||||
@@ -1,16 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { SourceDetailPageView } from "@/components/public-content/PublicContentPages";
|
||||
import {
|
||||
LANDING_LOCALE_COOKIE,
|
||||
LANDING_LOCALE_QUERY_PARAM,
|
||||
pickLandingLocale,
|
||||
type LandingLocale,
|
||||
} from "@/components/landing/landingLocale";
|
||||
import {
|
||||
SOURCE_PAGES,
|
||||
absolutePublicUrl,
|
||||
getSourcePage,
|
||||
localizeSourcePage,
|
||||
sourcePath,
|
||||
} from "@/content/public-content";
|
||||
|
||||
type SourcePageParams = {
|
||||
slug: string;
|
||||
};
|
||||
type SourceSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
async function resolvePublicContentLocale(searchParams: SourceSearchParams): Promise<LandingLocale> {
|
||||
const params = await searchParams;
|
||||
const rawLocale = params[LANDING_LOCALE_QUERY_PARAM];
|
||||
const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale;
|
||||
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
|
||||
return pickLandingLocale(
|
||||
queryLocale,
|
||||
cookieStore.get(LANDING_LOCALE_COOKIE)?.value,
|
||||
headerStore.get("accept-language"),
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return SOURCE_PAGES.map((source) => ({ slug: source.slug }));
|
||||
@@ -18,10 +39,15 @@ export function generateStaticParams() {
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<SourcePageParams>;
|
||||
searchParams: SourceSearchParams;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const [{ slug }, locale] = await Promise.all([
|
||||
params,
|
||||
resolvePublicContentLocale(searchParams),
|
||||
]);
|
||||
const source = getSourcePage(slug);
|
||||
|
||||
if (!source) {
|
||||
@@ -29,17 +55,18 @@ export async function generateMetadata({
|
||||
title: "Source not found",
|
||||
};
|
||||
}
|
||||
const localizedSource = localizeSourcePage(source, locale);
|
||||
|
||||
return {
|
||||
title: source.title,
|
||||
description: source.description,
|
||||
title: localizedSource.title,
|
||||
description: localizedSource.description,
|
||||
alternates: {
|
||||
canonical: sourcePath(source),
|
||||
},
|
||||
openGraph: {
|
||||
type: "article",
|
||||
title: source.title,
|
||||
description: source.description,
|
||||
title: localizedSource.title,
|
||||
description: localizedSource.description,
|
||||
url: sourcePath(source),
|
||||
modifiedTime: source.updatedAt,
|
||||
},
|
||||
@@ -48,22 +75,28 @@ export async function generateMetadata({
|
||||
|
||||
export default async function SourceDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<SourcePageParams>;
|
||||
searchParams: SourceSearchParams;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const [{ slug }, locale] = await Promise.all([
|
||||
params,
|
||||
resolvePublicContentLocale(searchParams),
|
||||
]);
|
||||
const source = getSourcePage(slug);
|
||||
|
||||
if (!source) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const localizedSource = localizeSourcePage(source, locale);
|
||||
const pathname = sourcePath(source);
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Dataset",
|
||||
name: source.title,
|
||||
description: source.description,
|
||||
name: localizedSource.title,
|
||||
description: localizedSource.description,
|
||||
url: absolutePublicUrl(pathname),
|
||||
dateModified: source.updatedAt,
|
||||
creator: {
|
||||
@@ -83,7 +116,7 @@ export default async function SourceDetailPage({
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<SourceDetailPageView source={source} />
|
||||
<SourceDetailPageView source={source} locale={locale} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,54 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { SourcesIndexPageView } from "@/components/public-content/PublicContentPages";
|
||||
import {
|
||||
LANDING_LOCALE_COOKIE,
|
||||
LANDING_LOCALE_QUERY_PARAM,
|
||||
pickLandingLocale,
|
||||
type LandingLocale,
|
||||
} from "@/components/landing/landingLocale";
|
||||
import { PUBLIC_CONTENT_COPY } from "@/content/public-content";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Weather Sources",
|
||||
description:
|
||||
"PolyWeather public source notes for MGM, METAR, HKO, NOAA, ECMWF, and settlement-source weather analysis.",
|
||||
alternates: {
|
||||
canonical: "/sources",
|
||||
},
|
||||
openGraph: {
|
||||
title: "Weather Sources | PolyWeather",
|
||||
description:
|
||||
"Public source notes for MGM, METAR, HKO, NOAA, ECMWF, and settlement-source weather analysis.",
|
||||
url: "/sources",
|
||||
},
|
||||
};
|
||||
type SourcesSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
export default function SourcesPage() {
|
||||
return <SourcesIndexPageView />;
|
||||
async function resolvePublicContentLocale(searchParams: SourcesSearchParams): Promise<LandingLocale> {
|
||||
const params = await searchParams;
|
||||
const rawLocale = params[LANDING_LOCALE_QUERY_PARAM];
|
||||
const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale;
|
||||
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
|
||||
return pickLandingLocale(
|
||||
queryLocale,
|
||||
cookieStore.get(LANDING_LOCALE_COOKIE)?.value,
|
||||
headerStore.get("accept-language"),
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SourcesSearchParams;
|
||||
}): Promise<Metadata> {
|
||||
const locale = await resolvePublicContentLocale(searchParams);
|
||||
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||
return {
|
||||
title: copy.sourceIndexEyebrow,
|
||||
description: copy.sourceIndexDescription,
|
||||
alternates: {
|
||||
canonical: "/sources",
|
||||
},
|
||||
openGraph: {
|
||||
title: `${copy.sourceIndexEyebrow} | PolyWeather`,
|
||||
description: copy.sourceIndexDescription,
|
||||
url: "/sources",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function SourcesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SourcesSearchParams;
|
||||
}) {
|
||||
const locale = await resolvePublicContentLocale(searchParams);
|
||||
return <SourcesIndexPageView locale={locale} />;
|
||||
}
|
||||
|
||||
@@ -94,6 +94,12 @@ export function runTests() {
|
||||
paymentFlowSource.includes("/validate"),
|
||||
"manual payment flow must validate tx hashes with the backend before submission",
|
||||
);
|
||||
assert(
|
||||
paymentFlowSource.includes("confirmRes.status === 503") &&
|
||||
paymentFlowSource.includes('lowerRaw.includes("cannot connect payment rpc")') &&
|
||||
paymentFlowSource.includes('lowerRaw.includes("payment rpc chain mismatch")'),
|
||||
"manual payment confirm must treat transient payment RPC failures as pending after tx hash submission",
|
||||
);
|
||||
assert(
|
||||
paymentFlowSource.includes("await waitForReceipt(txHashNorm, eth)") &&
|
||||
paymentFlowSource.indexOf("await waitForReceipt(txHashNorm, eth)") <
|
||||
|
||||
@@ -826,6 +826,9 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
const lowerRaw = raw.toLowerCase();
|
||||
const maybePending =
|
||||
confirmRes.status === 408 ||
|
||||
(confirmRes.status === 503 &&
|
||||
(lowerRaw.includes("cannot connect payment rpc") ||
|
||||
lowerRaw.includes("payment rpc chain mismatch"))) ||
|
||||
(confirmRes.status === 409 && (lowerRaw.includes("confirmations not enough") || lowerRaw.includes("tx indexed partially")));
|
||||
if (maybePending) {
|
||||
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Search,
|
||||
Table2,
|
||||
UserRound,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
@@ -48,6 +49,7 @@ import { scanRootClass } from "@/components/dashboard/scan-root-styles";
|
||||
import { useRelativeTime } from "@/hooks/useRelativeTime";
|
||||
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
|
||||
import { UsageGuideDashboard } from "@/components/dashboard/scan-terminal/UsageGuideDashboard";
|
||||
import { ModelSummaryDashboard } from "@/components/dashboard/scan-terminal/ModelSummaryDashboard";
|
||||
import {
|
||||
LiveTemperatureThresholdChart,
|
||||
clearCityDetailCache,
|
||||
@@ -99,6 +101,7 @@ const TrainingDashboard = dynamic(
|
||||
const ONLINE_USERS_REFRESH_MS = 5 * 60_000;
|
||||
const TERMINAL_NAV_ITEMS = [
|
||||
{ key: "thresholds", Icon: Activity, labelEn: "Decision", labelZh: "天气决策" },
|
||||
{ key: "modelSummary", Icon: Table2, labelEn: "Model Summary", labelZh: "模型汇总" },
|
||||
{ key: "training", Icon: GraduationCap, labelEn: "Training", labelZh: "训练数据" },
|
||||
{ key: "guide", Icon: BookOpenCheck, labelEn: "Guide", labelZh: "使用指南" },
|
||||
] as const;
|
||||
@@ -139,6 +142,14 @@ function createLocalAccess(): ProAccessState {
|
||||
};
|
||||
}
|
||||
|
||||
function hasTerminalForecastRows(rows: ScanOpportunityRow[]) {
|
||||
return rows.some((row) => {
|
||||
if (row.deb_prediction != null) return true;
|
||||
const sources = row.model_cluster_sources;
|
||||
return Boolean(sources && Object.keys(sources).length > 0);
|
||||
});
|
||||
}
|
||||
|
||||
function isFutureAccessExpiry(value: string | null | undefined, now = Date.now()) {
|
||||
if (!value) return true;
|
||||
const ts = Date.parse(value);
|
||||
@@ -748,6 +759,28 @@ function PolyWeatherTerminal({
|
||||
const [onlineCount, setOnlineCount] = useState<number | null>(null);
|
||||
const [feedbackDraft, setFeedbackDraft] = useState<FeedbackDraft | null>(null);
|
||||
const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0);
|
||||
const { terminalData: modelSummaryTerminalData } = useScanTerminalQuery({
|
||||
cacheScope: "model-summary",
|
||||
isPro: activeNavKey === "modelSummary",
|
||||
modelSummary: true,
|
||||
proAccessLoading: false,
|
||||
terminalActivationRefreshKey,
|
||||
timezoneOffsetSeconds: null,
|
||||
tradingRegion: "all",
|
||||
});
|
||||
const modelSummaryRows = useMemo(
|
||||
() =>
|
||||
sortRowsByUserTime(
|
||||
modelSummaryTerminalData?.rows?.length
|
||||
? modelSummaryTerminalData.rows
|
||||
: hasTerminalForecastRows(rows)
|
||||
? rows
|
||||
: [],
|
||||
),
|
||||
[modelSummaryTerminalData?.rows, rows],
|
||||
);
|
||||
const modelSummaryGeneratedText =
|
||||
useRelativeTime(modelSummaryTerminalData?.generated_at ?? null) || generatedText;
|
||||
const trialExpiryMs = Date.parse(String(trialSubscriptionExpiresAt || ""));
|
||||
const trialHoursLeft = Number.isFinite(trialExpiryMs)
|
||||
? Math.max(0, Math.ceil((trialExpiryMs - Date.now()) / 3_600_000))
|
||||
@@ -1122,6 +1155,12 @@ function PolyWeatherTerminal({
|
||||
<main className="min-h-0 flex-1 overflow-hidden flex flex-col p-2 bg-[#eef2f6]">
|
||||
{activeNavKey === "training" ? (
|
||||
<TrainingDashboard isEn={isEn} />
|
||||
) : activeNavKey === "modelSummary" ? (
|
||||
<ModelSummaryDashboard
|
||||
rows={modelSummaryRows}
|
||||
isEn={isEn}
|
||||
generatedText={modelSummaryGeneratedText}
|
||||
/>
|
||||
) : activeNavKey === "guide" ? (
|
||||
<UsageGuideDashboard isEn={isEn} />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ChevronRight, Search, Table2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import {
|
||||
MODEL_SUMMARY_MODEL_COLUMNS,
|
||||
buildModelSummaryRows,
|
||||
filterModelSummaryRows,
|
||||
formatModelSummaryProbability,
|
||||
formatModelSummaryLocalTime,
|
||||
formatModelSummaryTemp,
|
||||
hasModelSummaryForecastData,
|
||||
type ModelSummaryRow,
|
||||
} from "@/lib/model-summary";
|
||||
|
||||
type ModelSummaryDashboardProps = {
|
||||
rows: ScanOpportunityRow[];
|
||||
isEn: boolean;
|
||||
generatedText?: string;
|
||||
};
|
||||
|
||||
const SUMMARY_TEXT = {
|
||||
title: { en: "Model Summary", zh: "模型汇总" },
|
||||
subtitle: {
|
||||
en: "Today high-temperature view across DEB and model cluster sources.",
|
||||
zh: "按今日最高温口径汇总 DEB 与多模型预测。",
|
||||
},
|
||||
search: { en: "Search city or model", zh: "搜索城市或模型" },
|
||||
city: { en: "City", zh: "城市" },
|
||||
region: { en: "Region", zh: "区域" },
|
||||
localTime: { en: "Local Time", zh: "当地时间" },
|
||||
gaussianMu: { en: "Gaussian μ", zh: "高斯 μ" },
|
||||
detailedProbability: { en: "Gaussian Distribution Probability", zh: "高斯分布概率" },
|
||||
noProbabilityDistribution: { en: "No probability distribution", zh: "暂无详细概率" },
|
||||
median: { en: "Median", zh: "模型中位数" },
|
||||
spread: { en: "Spread", zh: "分歧范围" },
|
||||
empty: { en: "No model summary rows match the current filters.", zh: "当前筛选下没有模型汇总数据。" },
|
||||
generated: { en: "Generated", zh: "生成" },
|
||||
total: { en: "rows", zh: "行" },
|
||||
} as const;
|
||||
|
||||
const MODEL_SUMMARY_COLUMN_COUNT = MODEL_SUMMARY_MODEL_COLUMNS.length + 7;
|
||||
|
||||
function copy(key: keyof typeof SUMMARY_TEXT, isEn: boolean) {
|
||||
return SUMMARY_TEXT[key][isEn ? "en" : "zh"];
|
||||
}
|
||||
|
||||
function TemperatureCell({
|
||||
value,
|
||||
symbol,
|
||||
emphasis,
|
||||
}: {
|
||||
value: number | null;
|
||||
symbol: string;
|
||||
emphasis?: "deb" | "median";
|
||||
}) {
|
||||
const missing = value == null;
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"font-mono tabular-nums",
|
||||
missing ? "font-semibold text-slate-300" : "font-bold text-slate-800",
|
||||
emphasis === "deb" && !missing && "text-orange-600",
|
||||
emphasis === "median" && !missing && "text-blue-700",
|
||||
)}
|
||||
>
|
||||
{formatModelSummaryTemp(value, symbol)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterToggle({
|
||||
checked,
|
||||
label,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
label: string;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
"inline-flex h-8 cursor-pointer select-none items-center gap-2 rounded border px-2.5 text-[11px] font-bold transition-colors",
|
||||
checked
|
||||
? "border-blue-300 bg-blue-50 text-blue-700"
|
||||
: "border-slate-300 bg-white text-slate-600 hover:bg-slate-50 hover:text-slate-900",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5 accent-blue-600"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelSummaryRowView({
|
||||
row,
|
||||
nowMs,
|
||||
isEn,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
row: ModelSummaryRow;
|
||||
nowMs: number | null;
|
||||
isEn: boolean;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<tr className="group border-b border-slate-100 hover:bg-blue-50/40">
|
||||
<th
|
||||
scope="row"
|
||||
className="sticky left-0 z-10 w-[160px] min-w-[160px] max-w-[160px] border-r border-slate-200 bg-white px-2 py-2 text-left align-middle text-xs font-black text-slate-900 group-hover:bg-blue-50"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={onToggle}
|
||||
className="flex w-full min-w-0 items-center gap-1.5 rounded px-1 py-0.5 text-left outline-none transition hover:bg-blue-100/70 focus-visible:ring-2 focus-visible:ring-blue-300"
|
||||
>
|
||||
<ChevronRight
|
||||
size={13}
|
||||
className={clsx(
|
||||
"shrink-0 text-slate-400 transition-transform",
|
||||
expanded && "rotate-90 text-blue-600",
|
||||
)}
|
||||
/>
|
||||
<span className="block truncate">{row.cityName}</span>
|
||||
</button>
|
||||
</th>
|
||||
<td className="min-w-[96px] max-w-[112px] px-2 py-2 text-xs font-semibold text-slate-600">
|
||||
<span className="block truncate">{row.regionLabel}</span>
|
||||
</td>
|
||||
<td className="min-w-[96px] px-3 py-2 text-right font-mono text-[11px] font-bold text-slate-700">
|
||||
{formatModelSummaryLocalTime(row, nowMs)}
|
||||
</td>
|
||||
<td className="min-w-[90px] px-3 py-2 text-right">
|
||||
<TemperatureCell value={row.debPrediction} symbol={row.tempSymbol} emphasis="deb" />
|
||||
</td>
|
||||
{MODEL_SUMMARY_MODEL_COLUMNS.map((column) => (
|
||||
<td key={column.key} className="min-w-[92px] px-3 py-2 text-right">
|
||||
<TemperatureCell value={row.models[column.key]} symbol={row.tempSymbol} />
|
||||
</td>
|
||||
))}
|
||||
<td className="min-w-[110px] px-3 py-2 text-right">
|
||||
<TemperatureCell value={row.modelMedian} symbol={row.tempSymbol} emphasis="median" />
|
||||
</td>
|
||||
<td className="min-w-[100px] px-3 py-2 text-right">
|
||||
<TemperatureCell value={row.modelSpread} symbol={row.tempSymbol} />
|
||||
</td>
|
||||
<td className="min-w-[92px] px-3 py-2 text-right">
|
||||
<TemperatureCell value={row.gaussianMu} symbol={row.tempSymbol} emphasis="median" />
|
||||
</td>
|
||||
</tr>
|
||||
{expanded ? (
|
||||
<tr className="border-b border-blue-100 bg-blue-50/35">
|
||||
<td colSpan={MODEL_SUMMARY_COLUMN_COUNT} className="px-3 py-2">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-start">
|
||||
<div className="flex w-full shrink-0 items-center gap-2 text-xs md:w-[220px]">
|
||||
<span className="font-black text-slate-800">
|
||||
{copy("detailedProbability", isEn)}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] font-bold text-violet-700">
|
||||
μ {formatModelSummaryTemp(row.gaussianMu, row.tempSymbol)}
|
||||
</span>
|
||||
</div>
|
||||
{row.probabilityBuckets.length ? (
|
||||
<div className="flex flex-1 flex-wrap items-center gap-1.5">
|
||||
{row.probabilityBuckets.map((bucket) => {
|
||||
const isTopBucket = bucket.key === row.topProbabilityBucketKey;
|
||||
return (
|
||||
<span
|
||||
key={bucket.key}
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1 rounded border px-2 py-1 font-mono text-[10px] font-bold tabular-nums",
|
||||
isTopBucket
|
||||
? "border-violet-300 bg-violet-100 text-violet-800 shadow-sm"
|
||||
: "border-slate-200 bg-white text-slate-600",
|
||||
)}
|
||||
title={bucket.label}
|
||||
>
|
||||
<span>{bucket.label}</span>
|
||||
<span>{formatModelSummaryProbability(bucket.probability)}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs font-bold text-slate-400">
|
||||
{copy("noProbabilityDistribution", isEn)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function mergeWithLastGoodSummaryRows(
|
||||
incomingRows: ModelSummaryRow[],
|
||||
lastGoodRows: ModelSummaryRow[],
|
||||
) {
|
||||
if (!lastGoodRows.length) return incomingRows;
|
||||
if (!incomingRows.length) return lastGoodRows;
|
||||
|
||||
const incomingCityKeys = new Set<string>();
|
||||
const lastGoodByCity = new Map(lastGoodRows.map((row) => [row.cityKey, row]));
|
||||
const mergedRows = incomingRows.map((incomingRow) => {
|
||||
incomingCityKeys.add(incomingRow.cityKey);
|
||||
const lastGoodRow = lastGoodByCity.get(incomingRow.cityKey);
|
||||
if (!lastGoodRow) return incomingRow;
|
||||
|
||||
return {
|
||||
...lastGoodRow,
|
||||
cityName: incomingRow.cityName,
|
||||
regionLabel: incomingRow.regionLabel,
|
||||
regionLabelZh: incomingRow.regionLabelZh,
|
||||
regionSort: incomingRow.regionSort,
|
||||
tempSymbol: incomingRow.tempSymbol || lastGoodRow.tempSymbol,
|
||||
localTime: incomingRow.localTime || lastGoodRow.localTime,
|
||||
timezoneOffsetSeconds:
|
||||
incomingRow.timezoneOffsetSeconds ?? lastGoodRow.timezoneOffsetSeconds,
|
||||
searchText: incomingRow.searchText || lastGoodRow.searchText,
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
...mergedRows,
|
||||
...lastGoodRows.filter((row) => !incomingCityKeys.has(row.cityKey)),
|
||||
].sort((a, b) => {
|
||||
if (a.regionSort !== b.regionSort) return a.regionSort - b.regionSort;
|
||||
return a.cityName.localeCompare(b.cityName, "en", { sensitivity: "base" });
|
||||
});
|
||||
}
|
||||
|
||||
export function ModelSummaryDashboard({
|
||||
rows,
|
||||
isEn,
|
||||
generatedText,
|
||||
}: ModelSummaryDashboardProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [debOnly, setDebOnly] = useState(false);
|
||||
const [wideSpreadOnly, setWideSpreadOnly] = useState(false);
|
||||
const [nowMs, setNowMs] = useState<number | null>(null);
|
||||
const [expandedCityKeys, setExpandedCityKeys] = useState<Set<string>>(() => new Set());
|
||||
const lastGoodSummaryRowsRef = useRef<ModelSummaryRow[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncClock = () => setNowMs(Date.now());
|
||||
syncClock();
|
||||
const timer = window.setInterval(syncClock, 30_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const incomingSummaryRows = useMemo(() => buildModelSummaryRows(rows, isEn), [rows, isEn]);
|
||||
const incomingHasForecastData = hasModelSummaryForecastData(incomingSummaryRows);
|
||||
const summaryRows = useMemo(
|
||||
() =>
|
||||
incomingHasForecastData
|
||||
? incomingSummaryRows
|
||||
: mergeWithLastGoodSummaryRows(incomingSummaryRows, lastGoodSummaryRowsRef.current),
|
||||
[incomingSummaryRows, incomingHasForecastData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (incomingHasForecastData) {
|
||||
lastGoodSummaryRowsRef.current = incomingSummaryRows;
|
||||
}
|
||||
}, [incomingSummaryRows, incomingHasForecastData]);
|
||||
|
||||
const visibleRows = useMemo(
|
||||
() =>
|
||||
filterModelSummaryRows(summaryRows, {
|
||||
query,
|
||||
debOnly,
|
||||
wideSpreadOnly,
|
||||
}),
|
||||
[summaryRows, query, debOnly, wideSpreadOnly],
|
||||
);
|
||||
|
||||
const toggleExpandedCity = useCallback((cityKey: string) => {
|
||||
setExpandedCityKeys((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(cityKey)) {
|
||||
next.delete(cityKey);
|
||||
} else {
|
||||
next.add(cityKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const visibleCityKeys = new Set(visibleRows.map((row) => row.cityKey));
|
||||
setExpandedCityKeys((current) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
current.forEach((cityKey) => {
|
||||
if (visibleCityKeys.has(cityKey)) {
|
||||
next.add(cityKey);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, [visibleRows]);
|
||||
|
||||
return (
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-hidden rounded border border-[#d2d9e2] bg-white shadow-sm">
|
||||
<header className="flex shrink-0 flex-col gap-3 border-b border-slate-200 bg-white px-3 py-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Table2 size={16} className="text-blue-600" />
|
||||
<h2 className="truncate text-sm font-black text-slate-900">{copy("title", isEn)}</h2>
|
||||
<span className="rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[10px] font-bold text-slate-500">
|
||||
{visibleRows.length}/{summaryRows.length} {copy("total", isEn)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-medium text-slate-500">
|
||||
{copy("subtitle", isEn)}
|
||||
{generatedText ? (
|
||||
<span className="ml-2 font-mono text-[11px] text-slate-400">
|
||||
{copy("generated", isEn)} {generatedText}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative w-full sm:w-[240px]">
|
||||
<Search size={14} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={copy("search", isEn)}
|
||||
className="h-8 w-full rounded border border-slate-300 bg-white pl-8 pr-2 text-xs font-semibold text-slate-700 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
</div>
|
||||
<FilterToggle
|
||||
checked={debOnly}
|
||||
label={isEn ? "Only DEB" : "仅 DEB"}
|
||||
onChange={setDebOnly}
|
||||
/>
|
||||
<FilterToggle
|
||||
checked={wideSpreadOnly}
|
||||
label={isEn ? "Large spread" : "分歧较大"}
|
||||
onChange={setWideSpreadOnly}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<table
|
||||
className="w-full border-collapse text-xs"
|
||||
style={{ minWidth: "1520px" }}
|
||||
>
|
||||
<thead className="sticky top-0 z-20 bg-slate-50 text-[10px] font-black uppercase tracking-wide text-slate-500 shadow-[0_1px_0_0_#e2e8f0]">
|
||||
<tr>
|
||||
<th className="sticky left-0 z-30 w-[160px] min-w-[160px] border-r border-slate-200 bg-slate-50 px-3 py-2 text-left">
|
||||
{copy("city", isEn)}
|
||||
</th>
|
||||
<th className="min-w-[96px] max-w-[112px] px-2 py-2 text-left">{copy("region", isEn)}</th>
|
||||
<th className="min-w-[96px] px-3 py-2 text-right">{copy("localTime", isEn)}</th>
|
||||
<th className="min-w-[90px] px-3 py-2 text-right text-orange-600">DEB</th>
|
||||
{MODEL_SUMMARY_MODEL_COLUMNS.map((column) => (
|
||||
<th key={column.key} className="min-w-[92px] px-3 py-2 text-right">
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
<th className="min-w-[110px] px-3 py-2 text-right text-blue-700">
|
||||
{copy("median", isEn)}
|
||||
</th>
|
||||
<th className="min-w-[100px] px-3 py-2 text-right">{copy("spread", isEn)}</th>
|
||||
<th className="min-w-[92px] px-3 py-2 text-right text-violet-700">
|
||||
{copy("gaussianMu", isEn)}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 bg-white">
|
||||
{visibleRows.map((row) => (
|
||||
<ModelSummaryRowView
|
||||
key={row.cityKey}
|
||||
row={row}
|
||||
nowMs={nowMs}
|
||||
isEn={isEn}
|
||||
expanded={expandedCityKeys.has(row.cityKey)}
|
||||
onToggle={() => toggleExpandedCity(row.cityKey)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!visibleRows.length && (
|
||||
<div className="flex h-40 items-center justify-center border-t border-slate-100 text-xs font-bold text-slate-400">
|
||||
{copy("empty", isEn)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,19 @@ type DebQuality = {
|
||||
recommendation?: string | null;
|
||||
recent_hit_rate?: number | null;
|
||||
recent_samples?: number | null;
|
||||
ensemble_signal?: DebEnsembleSignal | null;
|
||||
};
|
||||
|
||||
type DebEnsembleSignal = {
|
||||
available?: boolean;
|
||||
stance?: string | null;
|
||||
label_zh?: string | null;
|
||||
label_en?: string | null;
|
||||
reason_zh?: string | null;
|
||||
reason_en?: string | null;
|
||||
spread?: number | null;
|
||||
deb_distance?: number | null;
|
||||
confidence_delta?: number | null;
|
||||
};
|
||||
|
||||
function debQualityLabel(quality: DebQuality | null | undefined, isEn: boolean) {
|
||||
@@ -55,6 +68,9 @@ function debQualityLabel(quality: DebQuality | null | undefined, isEn: boolean)
|
||||
}
|
||||
|
||||
function debQualityClass(quality: DebQuality | null | undefined) {
|
||||
const stance = quality?.ensemble_signal?.available ? quality.ensemble_signal.stance : null;
|
||||
if (stance === "caution") return "border-amber-300 bg-amber-50 text-amber-700";
|
||||
if (stance === "supporting") return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
const tier = quality?.quality_tier;
|
||||
if (tier === "high") return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
if (tier === "medium") return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
@@ -62,22 +78,46 @@ function debQualityClass(quality: DebQuality | null | undefined) {
|
||||
return "border-slate-200 bg-slate-50 text-slate-500";
|
||||
}
|
||||
|
||||
function DebQualityBadge({ quality, isEn }: { quality?: DebQuality | null; isEn: boolean }) {
|
||||
function debEnsembleShortLabel(signal: DebEnsembleSignal | null | undefined, isEn: boolean) {
|
||||
if (!signal?.available) return "";
|
||||
if (signal.stance === "supporting") return isEn ? "Ens+" : "集+";
|
||||
if (signal.stance === "caution") return isEn ? "Ens!" : "集警";
|
||||
return "";
|
||||
}
|
||||
|
||||
function debQualityTitle(quality: DebQuality | null | undefined, isEn: boolean) {
|
||||
const label = debQualityLabel(quality, isEn);
|
||||
if (!label) return null;
|
||||
const hitRate = quality?.recent_hit_rate;
|
||||
const samples = quality?.recent_samples;
|
||||
const ensemble = quality?.ensemble_signal;
|
||||
const titleParts = [
|
||||
isEn ? `DEB recommendation: ${label}` : `DEB 建议:${label}`,
|
||||
label ? (isEn ? `DEB recommendation: ${label}` : `DEB 建议:${label}`) : null,
|
||||
hitRate == null ? null : `${hitRate.toFixed(0)}%`,
|
||||
samples == null ? null : `n=${samples}`,
|
||||
ensemble?.available
|
||||
? `${isEn ? ensemble.label_en || "Ensemble" : ensemble.label_zh || "集合"}: ${
|
||||
isEn ? ensemble.reason_en || "" : ensemble.reason_zh || ""
|
||||
}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
return titleParts.join(" · ");
|
||||
}
|
||||
|
||||
function DebQualityBadge({ quality, isEn }: { quality?: DebQuality | null; isEn: boolean }) {
|
||||
const label = debQualityLabel(quality, isEn);
|
||||
const ensembleLabel = debEnsembleShortLabel(quality?.ensemble_signal, isEn);
|
||||
if (!label && !ensembleLabel) return null;
|
||||
return (
|
||||
<span
|
||||
className={clsx("ml-1.5 inline-flex items-center rounded border px-1.5 py-0.5 text-[9px] font-black uppercase leading-none", debQualityClass(quality))}
|
||||
title={titleParts.join(" · ")}
|
||||
title={debQualityTitle(quality, isEn)}
|
||||
>
|
||||
{label}
|
||||
{label || "DEB"}
|
||||
{ensembleLabel && (
|
||||
<span className="ml-1 border-l border-current/30 pl-1">
|
||||
{ensembleLabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -313,3 +353,6 @@ export function TemperatureStatsBars({
|
||||
|
||||
export const __buildTemperatureStatsLabelsForTest = buildStatsLabels;
|
||||
export const __buildDebQualityLabelForTest = debQualityLabel;
|
||||
export const __buildDebQualityClassForTest = debQualityClass;
|
||||
export const __buildDebQualityTitleForTest = debQualityTitle;
|
||||
export const __buildDebEnsembleLabelForTest = debEnsembleShortLabel;
|
||||
|
||||
@@ -24,22 +24,22 @@ const UPDATE_ANNOUNCEMENT_SEEN_KEY = "polyweather_update_announcement_seen_v1";
|
||||
|
||||
const STATIC_UPDATE_ANNOUNCEMENTS: StaticUpdateAnnouncement[] = [
|
||||
{
|
||||
id: "live-observation-chart-2026-06",
|
||||
publishedAt: "2026-06-17T00:00:00+08:00",
|
||||
expiresAt: "2026-07-31T00:00:00+08:00",
|
||||
id: "model-summary-table-local-time-2026-06-29",
|
||||
publishedAt: "2026-06-29T19:35:00+08:00",
|
||||
expiresAt: "2026-08-15T00:00:00+08:00",
|
||||
zh: {
|
||||
title: "更新公告:实时观测和图表稳定性升级",
|
||||
title: "更新公告:模型汇总表上线",
|
||||
body:
|
||||
"实时观测链路已和模型缓存拆分:SSE 到达会立即更新图表;如果 SSE 断线,终端会每 3 分钟拉取一次轻量观测兜底。DEB、模型曲线、概率和历史数据继续走独立缓存,不再跟着实时温度强刷。\n\n" +
|
||||
"这次也补齐了更多官方观测覆盖,包括东京 JMA、韩国 AMOS、土耳其 MGM、台北 CWA 等,并修正 NOAA MADIS 只应用于美国城市,避免非美国城市串台。\n\n" +
|
||||
"图表侧同步修复了预测曲线缺段、首屏 loading 不明显、旧缓存覆盖最新观测等问题。刷新终端后即可使用新逻辑。",
|
||||
"终端左侧新增「模型汇总」菜单,按今日最高温口径汇总全部城市:当地时间、DEB 预测,以及 ECMWF、ECMWF AIFS、GFS、ICON、ICON-EU、GEM、GDPS、JMA、AROME HD、HRRR、NAM 等模型高温预测集中到一张表里。\n\n" +
|
||||
"缺失模型会显示 —;模型中位数和分歧范围只基于已有模型值计算,方便快速发现 DEB 与多模型之间的偏离。\n\n" +
|
||||
"页面支持搜索城市或模型,并提供「仅 DEB」和「分歧较大」筛选。移动端可横向滚动,首列城市固定。刷新终端后即可使用。",
|
||||
},
|
||||
en: {
|
||||
title: "Update: live observations and chart stability",
|
||||
title: "Update: Model Summary table is live",
|
||||
body:
|
||||
"Live observations are now separated from cached model detail. SSE patches update charts immediately; if SSE is unavailable, the terminal falls back to a lightweight observation fetch every 3-minute interval. DEB, model curves, probabilities, and historical data stay on their own cache path instead of refreshing with every live temperature update.\n\n" +
|
||||
"Official observation coverage has also expanded, including Tokyo JMA, Korea AMOS, Turkey MGM, and Taipei CWA. NOAA MADIS is now restricted to US cities to avoid cross-region source leakage.\n\n" +
|
||||
"Charts also include fixes for truncated forecast curves, first-load chart loading visibility, and stale cache overriding newer observations. Refresh the terminal to use the new flow.",
|
||||
"The left terminal navigation now includes Model Summary, a city-by-city table for today's high-temperature view: local time, DEB forecast, plus ECMWF, ECMWF AIFS, GFS, ICON, ICON-EU, GEM, GDPS, JMA, AROME HD, HRRR, and NAM model highs in one workspace.\n\n" +
|
||||
"Missing models render as —. Model median and spread are calculated only from available model values, making it easier to spot where DEB diverges from the model cluster.\n\n" +
|
||||
"The page supports city or model search, plus Only DEB and Large spread filters. Mobile keeps the city column sticky with horizontal table scrolling. Refresh the terminal to use it.",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
MODEL_SUMMARY_MODEL_COLUMNS,
|
||||
buildModelSummaryRows,
|
||||
filterModelSummaryRows,
|
||||
formatModelSummaryProbability,
|
||||
formatModelSummaryLocalTime,
|
||||
formatModelSummaryTemp,
|
||||
hasModelSummaryForecastData,
|
||||
} from "@/lib/model-summary";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const rows = [
|
||||
{
|
||||
city: "beijing",
|
||||
city_display_name: "Beijing",
|
||||
trading_region_label: "East Asia",
|
||||
trading_region_label_zh: "东亚",
|
||||
trading_region_sort: 1,
|
||||
temp_symbol: "°C",
|
||||
current_max_so_far: 28,
|
||||
deb_prediction: 29.1,
|
||||
local_time: "18:46",
|
||||
tz_offset_seconds: 28800,
|
||||
model_cluster_sources: {
|
||||
ECMWF: 28.8,
|
||||
GFS: 29.4,
|
||||
"WeatherNext 2": 29.8,
|
||||
},
|
||||
},
|
||||
{
|
||||
city: "paris",
|
||||
city_display_name: "Paris",
|
||||
trading_region_label: "Europe / Africa",
|
||||
trading_region_label_zh: "欧洲 / 非洲",
|
||||
trading_region_sort: 5,
|
||||
temp_symbol: "°C",
|
||||
current_max_so_far: 32,
|
||||
deb_prediction: 31.6,
|
||||
local_time: "2026-06-29T10:00",
|
||||
model_cluster_sources: {
|
||||
ECMWF: 32.2,
|
||||
"ECMWF AIFS": 31.9,
|
||||
GFS: 33.4,
|
||||
ICON: 31.2,
|
||||
"ICON-EU": 31.4,
|
||||
GEM: 32.8,
|
||||
GDPS: 32.5,
|
||||
JMA: 30.9,
|
||||
"AROME HD": 32.1,
|
||||
},
|
||||
distribution_full: [
|
||||
{ value: 32, model_probability: 0.41, range: "[31.5~32.5)" },
|
||||
{ value: 33, model_probability: 0.56, range: "[32.5~33.5)" },
|
||||
{ value: 34, model_probability: 0.03, range: "[33.5~34.5)" },
|
||||
],
|
||||
probability_engine: "legacy",
|
||||
all_buckets: [
|
||||
{
|
||||
label: "32°C",
|
||||
lower: 31.5,
|
||||
upper: 32.5,
|
||||
},
|
||||
{
|
||||
label: "33°C",
|
||||
lower: 32.5,
|
||||
upper: 33.5,
|
||||
},
|
||||
{
|
||||
label: "34°C",
|
||||
lower: 33.5,
|
||||
upper: 34.5,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
city: "madrid",
|
||||
city_display_name: "Madrid",
|
||||
trading_region_label: "Europe / Africa",
|
||||
trading_region_label_zh: "欧洲 / 非洲",
|
||||
trading_region_sort: 5,
|
||||
temp_symbol: "°C",
|
||||
current_max_so_far: 34.2,
|
||||
deb_prediction: null,
|
||||
local_time: "2026-06-29T10:05",
|
||||
model_cluster_sources: {
|
||||
ECMWF: 37,
|
||||
GFS: 38.2,
|
||||
},
|
||||
distribution_preview: [
|
||||
{ value: 35, probability: 0.18 },
|
||||
{ value: 36, probability: 0.52 },
|
||||
],
|
||||
},
|
||||
{
|
||||
city: "houston",
|
||||
city_display_name: "Houston",
|
||||
trading_region_label: "North America",
|
||||
trading_region_label_zh: "北美",
|
||||
trading_region_sort: 7,
|
||||
temp_symbol: "°F",
|
||||
current_max_so_far: 90,
|
||||
deb_prediction: 94.2,
|
||||
local_time: "09:30",
|
||||
model_cluster_sources: {
|
||||
ECMWF: 94,
|
||||
GFS: 96,
|
||||
},
|
||||
distribution_full: [
|
||||
{ value: 94, probability: 0.4 },
|
||||
{ value: 95, probability: 0.25 },
|
||||
{ value: 96, probability: 0.08 },
|
||||
{ value: 97, probability: 0.02 },
|
||||
],
|
||||
},
|
||||
{
|
||||
city: "amsterdam",
|
||||
city_display_name: "Amsterdam",
|
||||
trading_region_label: "West Asia / Middle East",
|
||||
trading_region_label_zh: "西亚 / 中东",
|
||||
trading_region_sort: 4,
|
||||
temp_symbol: "°C",
|
||||
current_max_so_far: 16,
|
||||
deb_prediction: 17.1,
|
||||
local_time: "12:11",
|
||||
model_cluster_sources: {
|
||||
ECMWF: 15.6,
|
||||
GFS: 17.2,
|
||||
},
|
||||
},
|
||||
] as any;
|
||||
const originalFirstModelSources = rows[0].model_cluster_sources;
|
||||
const summaryRows = buildModelSummaryRows(rows, false);
|
||||
const amsterdamRow = summaryRows.find((row) => row.cityName === "Amsterdam");
|
||||
const beijingRow = summaryRows.find((row) => row.cityName === "Beijing");
|
||||
const madridRow = summaryRows.find((row) => row.cityName === "Madrid");
|
||||
const parisRow = summaryRows.find((row) => row.cityName === "Paris");
|
||||
const houstonRow = summaryRows.find((row) => row.cityName === "Houston");
|
||||
|
||||
assert(
|
||||
MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("AROME HD") &&
|
||||
MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("HRRR") &&
|
||||
MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("NAM") &&
|
||||
MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("WeatherNext 2"),
|
||||
"model summary must expose the fixed model columns including optional short-range and WeatherNext 2 models",
|
||||
);
|
||||
assert(summaryRows.length === 5, "model summary should keep one row per city");
|
||||
assert(summaryRows[0].cityName === "Beijing", "model summary should sort by resolved region then city name");
|
||||
assert(amsterdamRow?.regionLabel === "欧洲 / 非洲", "model summary should override stale backend timezone regions for known European cities");
|
||||
if (!madridRow || !parisRow) throw new Error("model summary should keep European rows");
|
||||
assert(beijingRow?.localTime === "18:46", "model summary should keep stale source local_time only as a fallback");
|
||||
assert(
|
||||
beijingRow &&
|
||||
formatModelSummaryLocalTime(beijingRow, Date.parse("2026-06-29T12:05:00Z")) === "20:05",
|
||||
"model summary should display live local time from timezone offset instead of stale cached local_time",
|
||||
);
|
||||
assert(parisRow.debPrediction === 31.6, "model summary should preserve DEB prediction");
|
||||
assert(parisRow.models.GFS === 33.4, "model summary should preserve model high temperature");
|
||||
assert(parisRow.models.HRRR === null, "missing models should be normalized to null");
|
||||
assert(parisRow.modelMedian === 32.1, "model median should use available model values only");
|
||||
assert(parisRow.modelSpread === 2.5, "model spread should use available model min/max only");
|
||||
assert(parisRow.gaussianMu === 32.6, "model summary should compute Gaussian mu from probability buckets");
|
||||
assert(parisRow.probabilityEngine === "legacy", "model summary should preserve probability engine metadata");
|
||||
assert(parisRow.probabilityBuckets.length === 3, "model summary should keep every market-option probability bucket");
|
||||
assert(
|
||||
parisRow.probabilityBucketMap["32°C"]?.probability === 0.41 &&
|
||||
parisRow.probabilityBucketMap["33°C"]?.probability === 0.56 &&
|
||||
parisRow.topProbabilityBucketKey === "33°C" &&
|
||||
!parisRow.probabilityBucketMap["31.5-32.5°C"],
|
||||
"model summary should map Celsius probabilities to market option labels instead of half-degree ranges",
|
||||
);
|
||||
assert(
|
||||
houstonRow?.probabilityBucketMap["94-95°F"]?.probability === 0.65 &&
|
||||
houstonRow.probabilityBucketMap["96-97°F"]?.probability === 0.1 &&
|
||||
houstonRow.topProbabilityBucketKey === "94-95°F",
|
||||
"model summary should aggregate Fahrenheit probabilities into two-degree market option labels",
|
||||
);
|
||||
assert(parisRow.marketMatches.length === 3, "model summary should keep every Polymarket tradable bucket");
|
||||
assert(beijingRow?.models["WeatherNext 2"] === 29.8, "model summary should preserve WeatherNext 2 model representative");
|
||||
assert(
|
||||
parisRow.marketMatches[0].label === "32°C" &&
|
||||
parisRow.marketMatches[0].modelProbability === null &&
|
||||
parisRow.marketMatches[0].marketUrl === null,
|
||||
"model summary should expose model probability for market-matched buckets without requiring market price",
|
||||
);
|
||||
assert(
|
||||
parisRow.marketMatches[1].label === "33°C" &&
|
||||
parisRow.marketMatches[1].modelProbability === null,
|
||||
"model summary should keep low-probability tradable buckets for manual NO review",
|
||||
);
|
||||
assert(formatModelSummaryProbability(null) === "—", "missing probability should render as an em dash");
|
||||
assert(formatModelSummaryProbability(0.424) === "42%", "probability buckets should render as rounded percentages");
|
||||
assert(formatModelSummaryTemp(null, "°C") === "—", "missing model temperatures should render as an em dash");
|
||||
assert(formatModelSummaryTemp(32.16, "°C") === "32.2°C", "model temperatures should render to one decimal");
|
||||
assert(hasModelSummaryForecastData(summaryRows), "model summary should recognize populated forecast rows");
|
||||
assert(
|
||||
!hasModelSummaryForecastData(
|
||||
buildModelSummaryRows([
|
||||
{
|
||||
id: "fallback:beijing",
|
||||
city: "beijing",
|
||||
city_display_name: "Beijing",
|
||||
trading_region_label: "East Asia",
|
||||
trading_region_label_zh: "东亚",
|
||||
trading_region_sort: 1,
|
||||
local_time: "21:11",
|
||||
tz_offset_seconds: 28800,
|
||||
},
|
||||
] as any, false),
|
||||
),
|
||||
"model summary should recognize fallback-only rows without DEB or model forecasts",
|
||||
);
|
||||
|
||||
const searched = filterModelSummaryRows(summaryRows, {
|
||||
debOnly: true,
|
||||
query: "par",
|
||||
wideSpreadOnly: false,
|
||||
});
|
||||
assert(searched.length === 1 && searched[0].cityName === "Paris", "model summary search and DEB filter should compose");
|
||||
const wideSpread = filterModelSummaryRows(summaryRows, {
|
||||
debOnly: false,
|
||||
query: "",
|
||||
wideSpreadOnly: true,
|
||||
});
|
||||
assert(
|
||||
wideSpread.length === 2 &&
|
||||
wideSpread.some((row) => row.cityName === "Paris") &&
|
||||
wideSpread.some((row) => row.cityName === "Houston"),
|
||||
"wide-spread filter should only keep rows with model spread >= 2°C",
|
||||
);
|
||||
assert(rows[0].model_cluster_sources === originalFirstModelSources, "model summary filters must not mutate source rows");
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const dashboardSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "ScanTerminalDashboard.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const modelSummarySource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "scan-terminal", "ModelSummaryDashboard.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
assert(
|
||||
dashboardSource.includes("modelSummary") &&
|
||||
dashboardSource.includes("模型汇总") &&
|
||||
dashboardSource.includes("Model Summary") &&
|
||||
dashboardSource.includes("Table2"),
|
||||
"terminal sidebar must expose the model summary nav item",
|
||||
);
|
||||
assert(
|
||||
dashboardSource.includes("<ModelSummaryDashboard") &&
|
||||
dashboardSource.includes("rows={modelSummaryRows}") &&
|
||||
dashboardSource.includes("cacheScope: \"model-summary\"") &&
|
||||
dashboardSource.includes("modelSummary: true") &&
|
||||
dashboardSource.includes("hasTerminalForecastRows(rows)") &&
|
||||
dashboardSource.includes("generatedText={modelSummaryGeneratedText}"),
|
||||
"terminal model summary view must use direct scan terminal rows without city fallback rows",
|
||||
);
|
||||
assert(
|
||||
modelSummarySource.includes("MODEL_SUMMARY_MODEL_COLUMNS") &&
|
||||
modelSummarySource.includes("lastGoodSummaryRowsRef") &&
|
||||
modelSummarySource.includes("hasModelSummaryForecastData") &&
|
||||
modelSummarySource.includes("Gaussian μ") &&
|
||||
modelSummarySource.includes("高斯 μ") &&
|
||||
modelSummarySource.includes("Gaussian Distribution Probability") &&
|
||||
modelSummarySource.includes("高斯分布概率") &&
|
||||
modelSummarySource.includes("expandedCityKeys") &&
|
||||
modelSummarySource.includes("toggleExpandedCity") &&
|
||||
modelSummarySource.includes("aria-expanded") &&
|
||||
modelSummarySource.includes("ChevronRight") &&
|
||||
modelSummarySource.includes("probabilityBuckets.map") &&
|
||||
!modelSummarySource.includes("Market Option Probability") &&
|
||||
!modelSummarySource.includes("市场选项概率") &&
|
||||
!modelSummarySource.includes("Detailed Probability") &&
|
||||
!modelSummarySource.includes("详细概率分布") &&
|
||||
!modelSummarySource.includes("Market Match") &&
|
||||
!modelSummarySource.includes("市场匹配") &&
|
||||
!modelSummarySource.includes("marketMatches.map") &&
|
||||
!modelSummarySource.includes("formatModelSummaryEdge") &&
|
||||
!modelSummarySource.includes("marketProbability") &&
|
||||
!modelSummarySource.includes("edgePercent") &&
|
||||
!modelSummarySource.includes("Probability Distribution") &&
|
||||
!modelSummarySource.includes("probabilityColumns") &&
|
||||
modelSummarySource.includes("min-w-[96px]") &&
|
||||
modelSummarySource.includes("Local Time") &&
|
||||
modelSummarySource.includes("当地时间") &&
|
||||
!modelSummarySource.includes("Current High") &&
|
||||
!modelSummarySource.includes("当前最高") &&
|
||||
modelSummarySource.includes("Only DEB") &&
|
||||
modelSummarySource.includes("仅 DEB") &&
|
||||
modelSummarySource.includes("Large spread") &&
|
||||
modelSummarySource.includes("分歧较大"),
|
||||
"model summary dashboard must render the fixed model table and filters",
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildCityListCacheControl,
|
||||
buildScanTerminalResponseCacheControl,
|
||||
buildCityDetailProxyCachePolicy,
|
||||
buildForceRefreshProxyCachePolicy,
|
||||
buildPublicEdgeCacheControl,
|
||||
@@ -40,42 +39,19 @@ export function runTests() {
|
||||
const scanForced = buildForceRefreshProxyCachePolicy("true", 10);
|
||||
assert.equal(scanForced.fetchMode, "no-store");
|
||||
|
||||
const normalScanCache = "public, max-age=0, s-maxage=300, stale-while-revalidate=900";
|
||||
assert.equal(
|
||||
buildScanTerminalResponseCacheControl({ status: "ready", stale: false }, normalScanCache),
|
||||
normalScanCache,
|
||||
);
|
||||
assert.equal(
|
||||
buildScanTerminalResponseCacheControl({ status: "failed", stale: false }, normalScanCache),
|
||||
NO_STORE_CACHE_CONTROL,
|
||||
);
|
||||
assert.equal(
|
||||
buildScanTerminalResponseCacheControl({ status: "partial", stale: false }, normalScanCache),
|
||||
NO_STORE_CACHE_CONTROL,
|
||||
);
|
||||
assert.equal(
|
||||
buildScanTerminalResponseCacheControl({ status: "ready", stale: true }, normalScanCache),
|
||||
NO_STORE_CACHE_CONTROL,
|
||||
);
|
||||
|
||||
const scanTerminalProxySource = fs.readFileSync(
|
||||
path.join(process.cwd(), "app", "api", "scan", "terminal", "route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
scanTerminalProxySource,
|
||||
/DASHBOARD_REFRESH_POLICY_SEC\.scanRows/,
|
||||
"scan terminal proxy cache TTL should match the dashboard scan refresh cadence instead of a short literal TTL",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
scanTerminalProxySource,
|
||||
/buildForceRefreshProxyCachePolicy\(forceRefresh,\s*10\)/,
|
||||
"scan terminal proxy must not use the old 10 second edge cache because it over-drives the slow scan endpoint",
|
||||
/buildForceRefreshProxyCachePolicy/,
|
||||
"scan terminal proxy must not use public edge cache because rows depend on user entitlement and live scan state",
|
||||
);
|
||||
assert.match(
|
||||
scanTerminalProxySource,
|
||||
/cacheControlForData:\s*\(data\)\s*=>\s*buildScanTerminalResponseCacheControl/,
|
||||
"scan terminal proxy must not CDN-cache failed, stale, or partial business payloads",
|
||||
/cacheControlForData:\s*\(\)\s*=>\s*NO_STORE_CACHE_CONTROL/,
|
||||
"scan terminal proxy must keep all scan payloads out of shared CDN caches",
|
||||
);
|
||||
assert.match(
|
||||
scanTerminalProxySource,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
readScanCache,
|
||||
writeScanCache,
|
||||
} from "@/components/dashboard/scan-terminal/use-scan-terminal-query";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const originalLocalStorage = globalThis.localStorage;
|
||||
const storage = new Map<string, string>();
|
||||
const memoryStorage = {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
setItem: (key: string, value: string) => { storage.set(key, value); },
|
||||
} as Storage;
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: memoryStorage,
|
||||
});
|
||||
|
||||
try {
|
||||
writeScanCache(
|
||||
{
|
||||
generated_at: "2026-07-02T12:00:00Z",
|
||||
rows: [{ city: "ankara", deb_prediction: 20.3 } as any],
|
||||
status: "ready",
|
||||
} as any,
|
||||
"all",
|
||||
"model-summary",
|
||||
);
|
||||
assert(
|
||||
readScanCache("all", "model-summary", { allowStale: true })?.rows?.length === 1,
|
||||
"non-empty scan cache should remain readable",
|
||||
);
|
||||
|
||||
writeScanCache(
|
||||
{
|
||||
generated_at: "2026-07-02T12:05:00Z",
|
||||
rows: [],
|
||||
status: "ready",
|
||||
} as any,
|
||||
"all",
|
||||
"model-summary",
|
||||
);
|
||||
assert(
|
||||
readScanCache("all", "model-summary", { allowStale: true }) === null,
|
||||
"empty scan cache responses must be ignored so model summary can revalidate rows",
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: originalLocalStorage,
|
||||
});
|
||||
}
|
||||
}
|
||||
+38
-1
@@ -1,4 +1,10 @@
|
||||
import { __buildDebQualityLabelForTest, __buildTemperatureStatsLabelsForTest } from "@/components/dashboard/scan-terminal/TemperatureStatsBars";
|
||||
import {
|
||||
__buildDebEnsembleLabelForTest,
|
||||
__buildDebQualityClassForTest,
|
||||
__buildDebQualityLabelForTest,
|
||||
__buildDebQualityTitleForTest,
|
||||
__buildTemperatureStatsLabelsForTest,
|
||||
} from "@/components/dashboard/scan-terminal/TemperatureStatsBars";
|
||||
import { temp } from "@/components/dashboard/scan-terminal/utils";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
@@ -71,4 +77,35 @@ export function runTests() {
|
||||
__buildDebQualityLabelForTest({ recommendation: "insufficient" }, false) === "样本少",
|
||||
"thin-sample DEB should render a Chinese low-sample label",
|
||||
);
|
||||
const supportingSignal = {
|
||||
available: true,
|
||||
stance: "supporting",
|
||||
label_zh: "集合支撑",
|
||||
label_en: "Ensemble support",
|
||||
reason_zh: "集合区间较窄",
|
||||
reason_en: "Ensemble spread is tight",
|
||||
};
|
||||
assert(
|
||||
__buildDebEnsembleLabelForTest(supportingSignal, false) === "集+",
|
||||
"supporting ensemble signal should render a compact Chinese marker",
|
||||
);
|
||||
assert(
|
||||
__buildDebEnsembleLabelForTest({ ...supportingSignal, stance: "caution" }, true) === "Ens!",
|
||||
"caution ensemble signal should render a compact English marker",
|
||||
);
|
||||
assert(
|
||||
__buildDebQualityClassForTest({
|
||||
recommendation: "primary",
|
||||
quality_tier: "high",
|
||||
ensemble_signal: { ...supportingSignal, stance: "caution" },
|
||||
}).includes("amber"),
|
||||
"ensemble caution should override the DEB quality badge color",
|
||||
);
|
||||
assert(
|
||||
__buildDebQualityTitleForTest(
|
||||
{ recommendation: "primary", ensemble_signal: supportingSignal },
|
||||
false,
|
||||
).includes("集合支撑"),
|
||||
"DEB badge title should include the ensemble signal reason",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ export function runTests() {
|
||||
assert(
|
||||
scanQuerySource.includes("MAX_STALE_SCAN_CACHE_MS") &&
|
||||
scanQuerySource.includes("allowStale") &&
|
||||
scanQuerySource.includes("setCachedRows(readScanCache(tradingRegion || \"\", { allowStale: true }))"),
|
||||
scanQuerySource.includes("setCachedRows(readScanCache(tradingRegion || \"\", cacheScope, { allowStale: true }))"),
|
||||
"terminal data hook must render stale scan rows immediately while revalidating the first-screen API",
|
||||
);
|
||||
assert(
|
||||
|
||||
@@ -70,32 +70,42 @@ export function runTests() {
|
||||
"announcement component must persist seen announcement ids and render an unread indicator for unseen updates",
|
||||
);
|
||||
assert(
|
||||
componentSource.includes("实时观测链路已和模型缓存拆分") &&
|
||||
componentSource.includes("SSE") &&
|
||||
componentSource.includes("3 分钟") &&
|
||||
componentSource.includes("模型汇总表上线") &&
|
||||
componentSource.includes("模型汇总") &&
|
||||
componentSource.includes("当地时间") &&
|
||||
componentSource.includes("DEB") &&
|
||||
componentSource.includes("ECMWF") &&
|
||||
componentSource.includes("ECMWF AIFS") &&
|
||||
componentSource.includes("GFS") &&
|
||||
componentSource.includes("ICON-EU") &&
|
||||
componentSource.includes("JMA") &&
|
||||
componentSource.includes("AMOS") &&
|
||||
componentSource.includes("MGM") &&
|
||||
componentSource.includes("CWA") &&
|
||||
componentSource.includes("NOAA MADIS") &&
|
||||
componentSource.includes("预测曲线缺段") &&
|
||||
componentSource.includes("首屏 loading"),
|
||||
"terminal announcement should summarize the live-observation architecture update and recent chart fixes in Chinese",
|
||||
componentSource.includes("AROME HD") &&
|
||||
componentSource.includes("HRRR") &&
|
||||
componentSource.includes("NAM") &&
|
||||
componentSource.includes("模型中位数") &&
|
||||
componentSource.includes("分歧范围") &&
|
||||
componentSource.includes("仅 DEB") &&
|
||||
componentSource.includes("分歧较大"),
|
||||
"terminal announcement should summarize the model summary table release in Chinese",
|
||||
);
|
||||
assert(
|
||||
componentSource.includes("Live observations are now separated from cached model detail") &&
|
||||
componentSource.includes("SSE") &&
|
||||
componentSource.includes("3-minute") &&
|
||||
componentSource.includes("Model Summary table is live") &&
|
||||
componentSource.includes("city-by-city table") &&
|
||||
componentSource.includes("local time") &&
|
||||
componentSource.includes("DEB") &&
|
||||
componentSource.includes("ECMWF") &&
|
||||
componentSource.includes("ECMWF AIFS") &&
|
||||
componentSource.includes("GFS") &&
|
||||
componentSource.includes("ICON-EU") &&
|
||||
componentSource.includes("JMA") &&
|
||||
componentSource.includes("AMOS") &&
|
||||
componentSource.includes("MGM") &&
|
||||
componentSource.includes("CWA") &&
|
||||
componentSource.includes("NOAA MADIS") &&
|
||||
componentSource.includes("truncated forecast curves") &&
|
||||
componentSource.includes("first-load chart loading"),
|
||||
"terminal announcement should summarize the live-observation architecture update and recent chart fixes in English",
|
||||
componentSource.includes("AROME HD") &&
|
||||
componentSource.includes("HRRR") &&
|
||||
componentSource.includes("NAM") &&
|
||||
componentSource.includes("Model median") &&
|
||||
componentSource.includes("spread") &&
|
||||
componentSource.includes("Only DEB") &&
|
||||
componentSource.includes("Large spread"),
|
||||
"terminal announcement should summarize the model summary table release in English",
|
||||
);
|
||||
assert(
|
||||
!middlewareSource.includes("/api/system/update-announcement"),
|
||||
|
||||
@@ -26,6 +26,7 @@ const SCAN_TERMINAL_PAYLOAD_VERSION = "runway-slim-v1";
|
||||
|
||||
type TerminalQueryOptions = {
|
||||
forceRefresh?: boolean;
|
||||
modelSummary?: boolean;
|
||||
signal?: AbortSignal;
|
||||
sinceSnapshotId?: string | null;
|
||||
timezoneOffsetSeconds?: number | null;
|
||||
@@ -123,6 +124,7 @@ async function readJsonOrThrow<T>(path: string, init?: RequestInit): Promise<T>
|
||||
|
||||
async function getTerminal({
|
||||
forceRefresh = false,
|
||||
modelSummary = false,
|
||||
signal,
|
||||
sinceSnapshotId,
|
||||
timezoneOffsetSeconds,
|
||||
@@ -139,14 +141,14 @@ async function getTerminal({
|
||||
limit: "180",
|
||||
force_refresh: String(forceRefresh),
|
||||
});
|
||||
if (tradingRegion && tradingRegion !== "all") {
|
||||
if (!modelSummary && tradingRegion && tradingRegion !== "all") {
|
||||
params.set("trading_region", tradingRegion);
|
||||
}
|
||||
if (Number.isFinite(timezoneOffsetSeconds)) {
|
||||
if (!modelSummary && Number.isFinite(timezoneOffsetSeconds)) {
|
||||
params.set("timezone_offset_seconds", String(Math.trunc(Number(timezoneOffsetSeconds))));
|
||||
}
|
||||
const trimmedSnapshotId = String(sinceSnapshotId || "").trim();
|
||||
if (!forceRefresh && trimmedSnapshotId) {
|
||||
if (!modelSummary && !forceRefresh && trimmedSnapshotId) {
|
||||
params.set("diff", "true");
|
||||
params.set("since_snapshot_id", trimmedSnapshotId);
|
||||
}
|
||||
|
||||
@@ -1605,7 +1605,16 @@ function seedRunwayPlateHistoryFromRow(
|
||||
type ChartRenderState = {
|
||||
forecastTodayHigh?: number | null;
|
||||
debPrediction?: number | null;
|
||||
debQuality?: Pick<DebForecast, "quality_tier" | "recommendation" | "recent_hit_rate" | "recent_samples" | "recent_hits" | "recent_mae"> | null;
|
||||
debQuality?: Pick<
|
||||
DebForecast,
|
||||
| "quality_tier"
|
||||
| "recommendation"
|
||||
| "recent_hit_rate"
|
||||
| "recent_samples"
|
||||
| "recent_hits"
|
||||
| "recent_mae"
|
||||
| "ensemble_signal"
|
||||
> | null;
|
||||
debHourlyPath?: DebHourlyPath | null;
|
||||
localDate?: string | null;
|
||||
localTime?: string | null;
|
||||
@@ -1995,6 +2004,7 @@ function parseFullChartDetailFromCityDetail(json: CityDetail | null): FullChartD
|
||||
recent_samples: json.deb.recent_samples,
|
||||
recent_hits: json.deb.recent_hits,
|
||||
recent_mae: json.deb.recent_mae,
|
||||
ensemble_signal: json.deb.ensemble_signal,
|
||||
} : null,
|
||||
debHourlyPath: json.deb?.hourly_path || null,
|
||||
localDate: json.local_date || (json as any)?.overview?.local_date || null,
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
ScanTerminalResponse,
|
||||
} from "@/lib/dashboard-types";
|
||||
|
||||
const SCAN_CACHE_PREFIX = "polyweather_scan_v2";
|
||||
const SCAN_CACHE_PREFIX = "polyweather_scan_v3";
|
||||
const SCAN_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.scanRows;
|
||||
const FOREGROUND_SCAN_REFRESH_AFTER_SUCCESS_MS = 60_000;
|
||||
const MAX_STALE_SCAN_CACHE_MS = 6 * 60 * 60 * 1000;
|
||||
@@ -32,29 +32,42 @@ const DERIVED_SCAN_PATCH_NUMBER_FIELDS = [
|
||||
"deb_prediction",
|
||||
] as const;
|
||||
|
||||
function scanCacheKey(tradingRegion: string): string {
|
||||
return `${SCAN_CACHE_PREFIX}:${tradingRegion || "all"}`;
|
||||
function scanCacheKey(tradingRegion: string, cacheScope: string): string {
|
||||
return `${SCAN_CACHE_PREFIX}:${cacheScope || "terminal"}:${tradingRegion || "all"}`;
|
||||
}
|
||||
|
||||
function readScanCache(
|
||||
export function readScanCache(
|
||||
tradingRegion: string,
|
||||
cacheScope: string,
|
||||
options?: { allowStale?: boolean },
|
||||
): ScanTerminalResponse | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(scanCacheKey(tradingRegion));
|
||||
const raw = localStorage.getItem(scanCacheKey(tradingRegion, cacheScope));
|
||||
if (!raw) return null;
|
||||
const cached = JSON.parse(raw);
|
||||
const age = Date.now() - Number(cached.ts || 0);
|
||||
const maxAge = options?.allowStale ? MAX_STALE_SCAN_CACHE_MS : SCAN_CACHE_TTL_MS;
|
||||
if (cached.ts && age >= 0 && age < maxAge && cached.data?.rows) {
|
||||
if (
|
||||
cached.ts &&
|
||||
age >= 0 &&
|
||||
age < maxAge &&
|
||||
Array.isArray(cached.data?.rows) &&
|
||||
cached.data.rows.length > 0
|
||||
) {
|
||||
return cached.data;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeScanCache(data: ScanTerminalResponse, tradingRegion: string) {
|
||||
try { localStorage.setItem(scanCacheKey(tradingRegion), JSON.stringify({ ts: Date.now(), data })); } catch { /* ignore */ }
|
||||
export function writeScanCache(data: ScanTerminalResponse, tradingRegion: string, cacheScope: string) {
|
||||
try {
|
||||
if (!Array.isArray(data.rows) || data.rows.length <= 0) {
|
||||
localStorage.removeItem(scanCacheKey(tradingRegion, cacheScope));
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(scanCacheKey(tradingRegion, cacheScope), JSON.stringify({ ts: Date.now(), data }));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function normalizeCityKey(city: string | null | undefined) {
|
||||
@@ -225,13 +238,17 @@ function mergeScanTerminalIncrementalResponse(
|
||||
}
|
||||
|
||||
export function useScanTerminalQuery({
|
||||
cacheScope = "terminal",
|
||||
isPro,
|
||||
modelSummary = false,
|
||||
proAccessLoading,
|
||||
terminalActivationRefreshKey = 0,
|
||||
timezoneOffsetSeconds,
|
||||
tradingRegion,
|
||||
}: {
|
||||
cacheScope?: string;
|
||||
isPro: boolean;
|
||||
modelSummary?: boolean;
|
||||
proAccessLoading: boolean;
|
||||
terminalActivationRefreshKey?: number;
|
||||
timezoneOffsetSeconds?: number | null;
|
||||
@@ -252,7 +269,7 @@ export function useScanTerminalQuery({
|
||||
const patchVersion = useSsePatchVersion();
|
||||
const [cachedRows, setCachedRows] = useState<ScanTerminalResponse | null>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return readScanCache(tradingRegion || "", { allowStale: true });
|
||||
return readScanCache(tradingRegion || "", cacheScope, { allowStale: true });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -264,8 +281,8 @@ export function useScanTerminalQuery({
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
setCachedRows(readScanCache(tradingRegion || "", { allowStale: true }));
|
||||
}, [tradingRegion]);
|
||||
setCachedRows(readScanCache(tradingRegion || "", cacheScope, { allowStale: true }));
|
||||
}, [cacheScope, tradingRegion]);
|
||||
|
||||
const fetchScanTerminal = useCallback(
|
||||
async ({
|
||||
@@ -287,6 +304,7 @@ export function useScanTerminalQuery({
|
||||
const previous = latestScanDataRef.current;
|
||||
const next = await scanTerminalClient.getTerminal({
|
||||
forceRefresh,
|
||||
modelSummary,
|
||||
signal,
|
||||
sinceSnapshotId: !forceRefresh ? previous?.snapshot_id : null,
|
||||
timezoneOffsetSeconds,
|
||||
@@ -297,12 +315,12 @@ export function useScanTerminalQuery({
|
||||
showLoading,
|
||||
onSuccess: (data) => {
|
||||
lastScanSuccessAtRef.current = Date.now();
|
||||
writeScanCache(data, tradingRegion || "");
|
||||
writeScanCache(data, tradingRegion || "", cacheScope);
|
||||
setCachedRows(data);
|
||||
},
|
||||
});
|
||||
},
|
||||
[isPro, proAccessLoading, run, timezoneOffsetSeconds, tradingRegion],
|
||||
[cacheScope, isPro, modelSummary, proAccessLoading, run, timezoneOffsetSeconds, tradingRegion],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -392,14 +410,14 @@ export function useScanTerminalQuery({
|
||||
if (!neighbors.length) return;
|
||||
|
||||
const preloadOne = (region: string) => {
|
||||
if (readScanCache(region)) return; // already cached
|
||||
if (readScanCache(region, cacheScope)) return; // already cached
|
||||
const idleFn = (window as any).requestIdleCallback || ((cb: () => void) => setTimeout(cb, 5000));
|
||||
const handle = idleFn(() => {
|
||||
void scanTerminalClient.getTerminal({
|
||||
forceRefresh: false,
|
||||
signal: new AbortController().signal,
|
||||
tradingRegion: region,
|
||||
}).then((data) => { writeScanCache(data, region); });
|
||||
}).then((data) => { writeScanCache(data, region, cacheScope); });
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
@@ -409,7 +427,7 @@ export function useScanTerminalQuery({
|
||||
const cancelFn = (window as any).cancelIdleCallback || clearTimeout;
|
||||
handles.forEach((h) => { try { cancelFn(h); } catch { /* */ } });
|
||||
};
|
||||
}, [tradingRegion, isPro]);
|
||||
}, [cacheScope, tradingRegion, isPro]);
|
||||
|
||||
return {
|
||||
refreshScanTerminalManually,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@/components/landing/LandingAuthActions";
|
||||
import {
|
||||
LANDING_LOCALE_COOKIE,
|
||||
landingLocaleHref,
|
||||
pickLandingLocale,
|
||||
type LandingLocale,
|
||||
} from "@/components/landing/landingLocale";
|
||||
@@ -324,8 +325,8 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
|
||||
<Link href="/docs/chart-guide" className="hover:text-slate-950">
|
||||
{isEn ? "Guide" : "读图"}
|
||||
</Link>
|
||||
<Link href="/briefs" className="hover:text-slate-950">
|
||||
Weather Market Brief
|
||||
<Link href={landingLocaleHref("/briefs", locale)} className="hover:text-slate-950">
|
||||
{isEn ? "Briefs" : "简报"}
|
||||
</Link>
|
||||
<a href="#pricing" className="hover:text-slate-950">
|
||||
{isEn ? "Pricing" : "定价"}
|
||||
|
||||
@@ -104,8 +104,12 @@ export function runTests() {
|
||||
"landing supported cities must render names and station codes from the generated city groups",
|
||||
);
|
||||
assert(source.includes("#contact"), "landing navigation must expose the contact section");
|
||||
assert(source.includes('href="/briefs"'), "landing navigation must expose public Weather Market Brief assets");
|
||||
assert(source.includes("Weather Market Brief"), "landing page must label the public brief entry clearly");
|
||||
assert(
|
||||
source.includes("landingLocaleHref") &&
|
||||
source.includes('href={landingLocaleHref("/briefs", locale)}'),
|
||||
"landing navigation must expose public Weather Market Brief assets while preserving the current locale",
|
||||
);
|
||||
assert(source.includes('isEn ? "Briefs" : "简报"'), "landing page must label the public brief entry in both languages");
|
||||
assert(source.includes('id="contact"'), "landing page must include a contact section");
|
||||
assert(source.includes("yhrsc30@gmail.com"), "landing page must show the operator contact email");
|
||||
assert(source.includes("mailto:${CONTACT_EMAIL}"), "landing contact email must be clickable");
|
||||
|
||||
@@ -38,3 +38,10 @@ export function pickLandingLocale(
|
||||
export function nextLandingLocale(locale: LandingLocale): LandingLocale {
|
||||
return locale === "zh-CN" ? "en-US" : "zh-CN";
|
||||
}
|
||||
|
||||
export function landingLocaleHref(pathname: string, locale: LandingLocale): string {
|
||||
const [pathWithQuery, hash] = pathname.split("#", 2);
|
||||
const separator = pathWithQuery.includes("?") ? "&" : "?";
|
||||
const localizedPath = `${pathWithQuery}${separator}${LANDING_LOCALE_QUERY_PARAM}=${encodeURIComponent(locale)}`;
|
||||
return hash ? `${localizedPath}#${hash}` : localizedPath;
|
||||
}
|
||||
|
||||
@@ -117,12 +117,12 @@ export function runTests() {
|
||||
assert.match(scanTerminalProxy, /timing:\s*timer/);
|
||||
assert.match(
|
||||
scanTerminalProxy,
|
||||
/POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS\s*\|\|\s*"35000"/,
|
||||
"scan terminal proxy should allow the production backend enough time to return before the 45 second route cap",
|
||||
/POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS\s*\|\|\s*"60000"/,
|
||||
"scan terminal proxy should allow the production backend enough time for batched model refreshes",
|
||||
);
|
||||
assert.match(
|
||||
scanTerminalProxy,
|
||||
/export const maxDuration = 45/,
|
||||
/export const maxDuration = 70/,
|
||||
"scan terminal proxy timeout budget should remain below the Next route execution cap",
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const sidebar = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "ops", "layout", "AdminSidebar.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const opsApi = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8");
|
||||
const pagePath = path.join(projectRoot, "app", "ops", "market-opportunities", "page.tsx");
|
||||
const proxyPath = path.join(projectRoot, "app", "api", "ops", "market-opportunities", "route.ts");
|
||||
const clientPath = path.join(
|
||||
projectRoot,
|
||||
"components",
|
||||
"ops",
|
||||
"market-opportunities",
|
||||
"MarketOpportunitiesPageClient.tsx",
|
||||
);
|
||||
|
||||
assert(
|
||||
sidebar.includes("/ops/market-opportunities") && sidebar.includes("市场机会"),
|
||||
"ops sidebar must expose the internal market opportunities page",
|
||||
);
|
||||
assert(
|
||||
fs.existsSync(pagePath) && fs.readFileSync(pagePath, "utf8").includes("requireOpsAdmin"),
|
||||
"market opportunities page must exist and require ops admin access",
|
||||
);
|
||||
assert(
|
||||
fs.existsSync(proxyPath) &&
|
||||
fs.readFileSync(proxyPath, "utf8").includes("requireOpsProxyAuth") &&
|
||||
fs.readFileSync(proxyPath, "utf8").includes("/api/ops/market-opportunities"),
|
||||
"market opportunities proxy must stay ops-admin protected",
|
||||
);
|
||||
assert(
|
||||
opsApi.includes("marketOpportunities") &&
|
||||
opsApi.includes("/api/ops/market-opportunities"),
|
||||
"ops API client must expose market opportunities",
|
||||
);
|
||||
const client = fs.existsSync(clientPath) ? fs.readFileSync(clientPath, "utf8") : "";
|
||||
for (const column of [
|
||||
"城市",
|
||||
"选项",
|
||||
"方向",
|
||||
"买入价",
|
||||
"方向概率",
|
||||
"Edge",
|
||||
"多模型",
|
||||
"市场链接",
|
||||
]) {
|
||||
assert(client.includes(column), `market opportunities table must include ${column}`);
|
||||
}
|
||||
assert(
|
||||
client.includes("positive_edge_only") && client.includes("显示全部低价"),
|
||||
"market opportunities page must default to positive edge and allow showing every low-price option",
|
||||
);
|
||||
assert(
|
||||
client.includes("最高买入价") &&
|
||||
client.includes("最小 Edge") &&
|
||||
client.includes('showAllLowPrice ? "-1" : minEdge'),
|
||||
"market opportunities filters must label price versus edge and not edge-filter the show-all-low-price view",
|
||||
);
|
||||
assert(
|
||||
client.includes("side_probability") &&
|
||||
client.includes("yes_probability") &&
|
||||
client.includes("YES"),
|
||||
"NO opportunities must show side probability while keeping the underlying YES probability visible",
|
||||
);
|
||||
assert(
|
||||
client.includes("model_cluster_sources") &&
|
||||
client.includes("models_above_bucket") &&
|
||||
client.includes("models_below_bucket") &&
|
||||
client.includes("models_in_bucket"),
|
||||
"market opportunities must expose multi-model context and bucket-relative model counts",
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Users,
|
||||
UserCheck,
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
Settings,
|
||||
FileText,
|
||||
ScrollText,
|
||||
@@ -27,6 +28,7 @@ const navGroups = [
|
||||
{ href: "/ops/system", icon: Cpu, label: "系统状态" },
|
||||
{ href: "/ops/training", icon: Database, label: "训练数据" },
|
||||
{ href: "/ops/analytics", icon: BarChart3, label: "转化分析" },
|
||||
{ href: "/ops/market-opportunities", icon: TrendingUp, label: "市场机会" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ExternalLink, RefreshCcw, Search, TrendingUp } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
|
||||
type MarketOpportunityRow = {
|
||||
id?: string;
|
||||
city?: string;
|
||||
display_name?: string;
|
||||
target_date?: string;
|
||||
bucket_label?: string;
|
||||
side?: string;
|
||||
ask_price?: number;
|
||||
model_probability?: number;
|
||||
yes_probability?: number;
|
||||
side_probability?: number;
|
||||
model_cluster_sources?: Record<string, number>;
|
||||
model_count?: number;
|
||||
model_min?: number | null;
|
||||
model_max?: number | null;
|
||||
models_below_bucket?: number;
|
||||
models_in_bucket?: number;
|
||||
models_above_bucket?: number;
|
||||
models_above_deb?: number;
|
||||
edge?: number;
|
||||
liquidity?: number | null;
|
||||
volume?: number | null;
|
||||
market_url?: string;
|
||||
current_max_so_far?: number | null;
|
||||
deb_prediction?: number | null;
|
||||
model_median?: number | null;
|
||||
model_spread?: number | null;
|
||||
local_time?: string;
|
||||
region?: string;
|
||||
};
|
||||
|
||||
type MarketOpportunitiesPayload = {
|
||||
generated_at?: string;
|
||||
summary?: {
|
||||
opportunity_count?: number;
|
||||
positive_edge_count?: number;
|
||||
min_price?: number | null;
|
||||
max_edge?: number | null;
|
||||
quote_status?: string;
|
||||
scanned_city_count?: number;
|
||||
matched_event_count?: number;
|
||||
error?: string | null;
|
||||
};
|
||||
rows?: MarketOpportunityRow[];
|
||||
};
|
||||
|
||||
function percent(value?: number | null) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function cents(value?: number | null) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
||||
return `${(value * 100).toFixed(value * 100 < 1 ? 1 : 0)}¢`;
|
||||
}
|
||||
|
||||
function numberLabel(value?: number | null) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
||||
if (Math.abs(value) >= 1000) return value.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
function tempLabel(value?: number | null) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
function generatedLabel(value?: string) {
|
||||
if (!value) return "—";
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
function sideTone(side?: string) {
|
||||
return String(side).toLowerCase() === "no"
|
||||
? "border-red-200 bg-red-50 text-red-700"
|
||||
: "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
}
|
||||
|
||||
function yesProbability(row: MarketOpportunityRow) {
|
||||
return typeof row.yes_probability === "number" ? row.yes_probability : row.model_probability;
|
||||
}
|
||||
|
||||
function sideProbability(row: MarketOpportunityRow) {
|
||||
if (typeof row.side_probability === "number") return row.side_probability;
|
||||
const yes = yesProbability(row);
|
||||
if (String(row.side).toLowerCase() === "no" && typeof yes === "number") {
|
||||
return 1 - yes;
|
||||
}
|
||||
return yes;
|
||||
}
|
||||
|
||||
function modelSourceSummary(row: MarketOpportunityRow) {
|
||||
const sources = row.model_cluster_sources || {};
|
||||
return Object.entries(sources)
|
||||
.filter(([, value]) => typeof value === "number" && Number.isFinite(value))
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.slice(0, 6)
|
||||
.map(([name, value]) => `${name} ${value.toFixed(1)}`)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||||
<div className="text-xs font-bold uppercase tracking-wide text-slate-500">{label}</div>
|
||||
<div className="mt-1 text-2xl font-black text-slate-950">{value}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">{sub}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarketOpportunitiesPageClient() {
|
||||
const [payload, setPayload] = useState<MarketOpportunitiesPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [side, setSide] = useState("both");
|
||||
const [maxPrice, setMaxPrice] = useState("0.20");
|
||||
const [minEdge, setMinEdge] = useState("0");
|
||||
const [showAllLowPrice, setShowAllLowPrice] = useState(false);
|
||||
|
||||
const positive_edge_only = !showAllLowPrice;
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await opsApi.marketOpportunities({
|
||||
q: query.trim(),
|
||||
side,
|
||||
max_price: maxPrice,
|
||||
min_edge: showAllLowPrice ? "-1" : minEdge,
|
||||
positive_edge_only,
|
||||
limit: 200,
|
||||
});
|
||||
setPayload(data as MarketOpportunitiesPayload);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "加载市场机会失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [side, showAllLowPrice]);
|
||||
|
||||
const rows = useMemo(() => payload?.rows ?? [], [payload]);
|
||||
const summary = payload?.summary ?? {};
|
||||
const quoteStatus = summary.quote_status || "unknown";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold text-white">
|
||||
<TrendingUp className="h-5 w-5 text-blue-300" />
|
||||
市场机会
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-300">
|
||||
仅 Ops 内部使用,按价格上限扫描 Yes/No 选项,并按模型概率与市场价格差排序。
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={load} className="gap-1.5">
|
||||
<RefreshCcw className="h-3.5 w-3.5" /> 刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
|
||||
<Stat label="机会数" value={summary.opportunity_count ?? rows.length} sub="当前筛选结果" />
|
||||
<Stat label="正边际" value={summary.positive_edge_count ?? 0} sub="方向概率高于买入价" />
|
||||
<Stat label="最低价格" value={cents(summary.min_price)} sub="低价合约下限" />
|
||||
<Stat label="最大 Edge" value={percent(summary.max_edge)} sub="方向概率 - 市场价" />
|
||||
<Stat label="报价状态" value={quoteStatus} sub={`${summary.matched_event_count ?? 0}/${summary.scanned_city_count ?? 0} 城市匹配`} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3">
|
||||
<CardTitle>低价选项扫描</CardTitle>
|
||||
<div className="grid items-end gap-2 md:grid-cols-[minmax(180px,1fr)_120px_132px_132px_auto]">
|
||||
<label className="relative block">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void load();
|
||||
}}
|
||||
className="h-9 w-full rounded-md border border-slate-300 bg-white pl-8 pr-3 text-sm font-semibold text-slate-800 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
|
||||
placeholder="搜索城市或选项"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="sr-only">方向</span>
|
||||
<select
|
||||
value={side}
|
||||
onChange={(event) => setSide(event.target.value)}
|
||||
className="h-9 w-full rounded-md border border-slate-300 bg-white px-2 text-sm font-semibold text-slate-700"
|
||||
>
|
||||
<option value="both">Yes / No</option>
|
||||
<option value="yes">仅 Yes</option>
|
||||
<option value="no">仅 No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-[11px] font-black uppercase tracking-wide text-slate-500">
|
||||
最高买入价
|
||||
</span>
|
||||
<input
|
||||
value={maxPrice}
|
||||
onChange={(event) => setMaxPrice(event.target.value)}
|
||||
className="h-9 w-full rounded-md border border-slate-300 bg-white px-2 text-sm font-semibold text-slate-700"
|
||||
inputMode="decimal"
|
||||
aria-label="最高买入价"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-[11px] font-black uppercase tracking-wide text-slate-500">
|
||||
最小 Edge
|
||||
</span>
|
||||
<input
|
||||
value={minEdge}
|
||||
onChange={(event) => setMinEdge(event.target.value)}
|
||||
className="h-9 w-full rounded-md border border-slate-300 bg-white px-2 text-sm font-semibold text-slate-700"
|
||||
inputMode="decimal"
|
||||
aria-label="最小 Edge"
|
||||
/>
|
||||
</label>
|
||||
<Button variant="outline" size="sm" onClick={load}>
|
||||
应用筛选
|
||||
</Button>
|
||||
</div>
|
||||
<label className="flex w-fit items-center gap-2 text-sm font-semibold text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showAllLowPrice}
|
||||
onChange={(event) => setShowAllLowPrice(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-slate-300"
|
||||
/>
|
||||
显示全部低价
|
||||
</label>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error ? (
|
||||
<div className="mb-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{summary.error ? (
|
||||
<div className="mb-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm font-semibold text-amber-700">
|
||||
报价不可用:{summary.error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-200">
|
||||
<table className="min-w-[1460px] w-full border-collapse text-sm">
|
||||
<thead className="bg-slate-50 text-left text-xs font-black uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2">城市</th>
|
||||
<th className="px-3 py-2">选项</th>
|
||||
<th className="px-3 py-2">方向</th>
|
||||
<th className="px-3 py-2 text-right">买入价</th>
|
||||
<th className="px-3 py-2 text-right">方向概率</th>
|
||||
<th className="px-3 py-2 text-right">Edge</th>
|
||||
<th className="px-3 py-2 text-right">当前最高</th>
|
||||
<th className="px-3 py-2 text-right">DEB</th>
|
||||
<th className="px-3 py-2 text-right">模型中位数</th>
|
||||
<th className="px-3 py-2">多模型</th>
|
||||
<th className="px-3 py-2 text-right">分歧</th>
|
||||
<th className="px-3 py-2 text-right">流动性</th>
|
||||
<th className="px-3 py-2 text-right">成交量</th>
|
||||
<th className="px-3 py-2">市场链接</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 bg-white">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={14} className="px-3 py-10 text-center text-sm font-semibold text-slate-400">
|
||||
加载中...
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.length ? (
|
||||
rows.map((row) => (
|
||||
<tr key={row.id || `${row.city}-${row.bucket_label}-${row.side}`} className="hover:bg-blue-50/40">
|
||||
<td className="px-3 py-2 font-black text-slate-900">
|
||||
<div>{row.display_name || row.city || "—"}</div>
|
||||
<div className="mt-0.5 text-[11px] font-semibold text-slate-400">
|
||||
{row.local_time || "—"} · {row.target_date || "—"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-bold text-slate-800">{row.bucket_label || "—"}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-flex min-w-10 justify-center rounded-md border px-2 py-1 text-xs font-black uppercase ${sideTone(row.side)}`}>
|
||||
{row.side || "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-black text-slate-950">{cents(row.ask_price)}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="font-bold text-blue-700">{percent(sideProbability(row))}</div>
|
||||
{String(row.side).toLowerCase() === "no" ? (
|
||||
<div className="mt-0.5 text-[11px] font-semibold text-slate-400">
|
||||
YES {percent(yesProbability(row))}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-black text-emerald-700">{percent(row.edge)}</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-slate-700">{tempLabel(row.current_max_so_far)}</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-orange-700">{tempLabel(row.deb_prediction)}</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-slate-700">{tempLabel(row.model_median)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-bold text-slate-800">
|
||||
{tempLabel(row.model_min)}-{tempLabel(row.model_max)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] font-semibold text-slate-500">
|
||||
低 {row.models_below_bucket ?? 0} · 内 {row.models_in_bucket ?? 0} · 高 {row.models_above_bucket ?? 0}
|
||||
</div>
|
||||
<div className="mt-0.5 max-w-[260px] truncate text-[11px] font-semibold text-slate-400" title={modelSourceSummary(row)}>
|
||||
{modelSourceSummary(row) || "—"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-slate-700">{tempLabel(row.model_spread)}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-600">{numberLabel(row.liquidity)}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-600">{numberLabel(row.volume)}</td>
|
||||
<td className="px-3 py-2">
|
||||
{row.market_url ? (
|
||||
<a
|
||||
href={row.market_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-black text-blue-700 hover:text-blue-900"
|
||||
>
|
||||
打开 <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-slate-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={14} className="px-3 py-10 text-center text-sm font-semibold text-slate-400">
|
||||
当前筛选下没有低价市场机会。
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-3 text-xs font-semibold text-slate-400">
|
||||
生成时间:{generatedLabel(payload?.generated_at)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import { LandingLocaleToggle } from "@/components/landing/LandingLocaleToggle";
|
||||
import { landingLocaleHref, type LandingLocale } from "@/components/landing/landingLocale";
|
||||
import {
|
||||
METHODOLOGY_PAGES,
|
||||
PUBLIC_CONTENT_COPY,
|
||||
PUBLIC_BRIEFS,
|
||||
SOURCE_PAGES,
|
||||
absolutePublicUrl,
|
||||
briefPath,
|
||||
methodologyPath,
|
||||
sourcePath,
|
||||
localizeBrief,
|
||||
localizeBriefs,
|
||||
localizeMethodologyPage,
|
||||
localizeSourcePage,
|
||||
type MethodologyPage,
|
||||
type PublicBrief,
|
||||
type SourcePage,
|
||||
@@ -31,27 +38,32 @@ const primaryButton =
|
||||
const secondaryButton =
|
||||
"inline-flex min-h-10 items-center justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-900 transition hover:border-slate-400 hover:bg-slate-50";
|
||||
|
||||
function PublicHeader() {
|
||||
function PublicHeader({ locale = "en-US" }: { locale?: LandingLocale }) {
|
||||
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||
|
||||
return (
|
||||
<header className="border-b border-slate-200 bg-white/90">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8">
|
||||
<Link className="text-base font-black text-slate-950" href="/">
|
||||
<Link className="text-base font-black text-slate-950" href={landingLocaleHref("/", locale)}>
|
||||
PolyWeather
|
||||
</Link>
|
||||
<nav className="flex flex-wrap gap-2 text-sm font-semibold text-slate-700">
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/briefs">
|
||||
Briefs
|
||||
</Link>
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/methodology">
|
||||
Methodology
|
||||
</Link>
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/sources">
|
||||
Sources
|
||||
</Link>
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/docs/intro">
|
||||
Docs
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||
<nav className="flex flex-wrap gap-2 text-sm font-semibold text-slate-700">
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href={landingLocaleHref("/briefs", locale)}>
|
||||
{locale === "en-US" ? "Briefs" : "简报"}
|
||||
</Link>
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href={landingLocaleHref("/methodology", locale)}>
|
||||
{copy.methodology}
|
||||
</Link>
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href={landingLocaleHref("/sources", locale)}>
|
||||
{copy.sources}
|
||||
</Link>
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href={landingLocaleHref("/docs/intro", locale)}>
|
||||
{copy.docs}
|
||||
</Link>
|
||||
</nav>
|
||||
<LandingLocaleToggle locale={locale} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
@@ -81,19 +93,20 @@ function PageIntro({
|
||||
);
|
||||
}
|
||||
|
||||
function SourceLinks({ slugs }: { slugs: string[] }) {
|
||||
function SourceLinks({ locale = "en-US", slugs }: { locale?: LandingLocale; slugs: string[] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{slugs.map((slug) => {
|
||||
const source = SOURCE_PAGES.find((entry) => entry.slug === slug);
|
||||
if (!source) return null;
|
||||
const localizedSource = localizeSourcePage(source, locale);
|
||||
return (
|
||||
<Link
|
||||
className="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-300 hover:bg-slate-50"
|
||||
href={sourcePath(source)}
|
||||
href={landingLocaleHref(sourcePath(localizedSource), locale)}
|
||||
key={slug}
|
||||
>
|
||||
{source.title}
|
||||
{localizedSource.title}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -101,19 +114,20 @@ function SourceLinks({ slugs }: { slugs: string[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MethodologyLinks({ slugs }: { slugs: string[] }) {
|
||||
function MethodologyLinks({ locale = "en-US", slugs }: { locale?: LandingLocale; slugs: string[] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{slugs.map((slug) => {
|
||||
const page = METHODOLOGY_PAGES.find((entry) => entry.slug === slug);
|
||||
if (!page) return null;
|
||||
const localizedPage = localizeMethodologyPage(page, locale);
|
||||
return (
|
||||
<Link
|
||||
className="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-300 hover:bg-slate-50"
|
||||
href={methodologyPath(page)}
|
||||
href={landingLocaleHref(methodologyPath(localizedPage), locale)}
|
||||
key={slug}
|
||||
>
|
||||
{page.title}
|
||||
{localizedPage.title}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -121,7 +135,15 @@ function MethodologyLinks({ slugs }: { slugs: string[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function BriefCard({ brief }: { brief: PublicBrief }) {
|
||||
function BriefCard({
|
||||
brief,
|
||||
locale = "en-US",
|
||||
readBriefLabel,
|
||||
}: {
|
||||
brief: PublicBrief;
|
||||
locale?: LandingLocale;
|
||||
readBriefLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<article className={`${panel} flex flex-col gap-5 p-5`}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
@@ -130,7 +152,7 @@ function BriefCard({ brief }: { brief: PublicBrief }) {
|
||||
{brief.cityName} / {brief.date}
|
||||
</p>
|
||||
<h2 className="mt-2 text-xl font-black text-slate-950">
|
||||
<Link href={briefPath(brief)}>{brief.title}</Link>
|
||||
<Link href={landingLocaleHref(briefPath(brief), locale)}>{brief.title}</Link>
|
||||
</h2>
|
||||
</div>
|
||||
<span className="rounded-md bg-emerald-50 px-2.5 py-1 text-xs font-bold text-emerald-800">
|
||||
@@ -148,53 +170,61 @@ function BriefCard({ brief }: { brief: PublicBrief }) {
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Link className={secondaryButton} href={briefPath(brief)}>
|
||||
Read brief
|
||||
<Link className={secondaryButton} href={landingLocaleHref(briefPath(brief), locale)}>
|
||||
{readBriefLabel}
|
||||
</Link>
|
||||
<SourceLinks slugs={brief.sourceSlugs.slice(0, 2)} />
|
||||
<SourceLinks locale={locale} slugs={brief.sourceSlugs.slice(0, 2)} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function BriefsIndexPageView() {
|
||||
export function BriefsIndexPageView({ locale = "en-US" }: { locale?: LandingLocale }) {
|
||||
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||
const briefs = localizeBriefs(locale);
|
||||
|
||||
return (
|
||||
<div className={pageShell}>
|
||||
<PublicHeader />
|
||||
<PublicHeader locale={locale} />
|
||||
<PageIntro
|
||||
description="Public Weather Market Brief pages turn selected city-market reads into indexable evidence: settlement source, DEB context, model disagreement, freshness notes, and a clear research disclaimer."
|
||||
eyebrow="Weather Market Brief"
|
||||
title="Public market briefs for temperature judgment"
|
||||
description={copy.briefIndexDescription}
|
||||
eyebrow={copy.briefIndexEyebrow}
|
||||
title={copy.briefIndexTitle}
|
||||
/>
|
||||
<div className={contentWrap}>
|
||||
<section className="grid gap-4">
|
||||
{PUBLIC_BRIEFS.map((brief) => (
|
||||
<BriefCard brief={brief} key={`${brief.city}-${brief.date}`} />
|
||||
{briefs.map((brief) => (
|
||||
<BriefCard
|
||||
brief={brief}
|
||||
key={`${brief.city}-${brief.date}`}
|
||||
locale={locale}
|
||||
readBriefLabel={copy.readBrief}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
<section className={`${panel} grid gap-5 p-5 md:grid-cols-2`}>
|
||||
<div>
|
||||
<p className={sectionTitle}>Methodology</p>
|
||||
<p className={sectionTitle}>{copy.methodology}</p>
|
||||
<h2 className="mt-2 text-2xl font-black text-slate-950">
|
||||
How the public read is produced
|
||||
{copy.methodologyPanelTitle}
|
||||
</h2>
|
||||
<p className={`${bodyText} mt-3`}>
|
||||
Briefs cross-link to the DEB methodology and settlement-source priority pages so readers can audit why PolyWeather does not treat generic city forecasts as market truth.
|
||||
{copy.methodologyPanelBody}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<MethodologyLinks slugs={["deb", "settlement-sources"]} />
|
||||
<MethodologyLinks locale={locale} slugs={["deb", "settlement-sources"]} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className={sectionTitle}>Source notes</p>
|
||||
<p className={sectionTitle}>{copy.sourceNotes}</p>
|
||||
<h2 className="mt-2 text-2xl font-black text-slate-950">
|
||||
Official-source context
|
||||
{copy.sourcePanelTitle}
|
||||
</h2>
|
||||
<p className={`${bodyText} mt-3`}>
|
||||
Source pages explain why MGM, METAR, HKO, NOAA, and model guidance are displayed separately in PolyWeather workflows.
|
||||
{copy.sourcePanelBody}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<SourceLinks slugs={["mgm", "metar", "hko", "noaa"]} />
|
||||
<SourceLinks locale={locale} slugs={["mgm", "metar", "hko", "noaa"]} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -203,25 +233,34 @@ export function BriefsIndexPageView() {
|
||||
);
|
||||
}
|
||||
|
||||
export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
|
||||
export function BriefDetailPageView({
|
||||
brief,
|
||||
locale = "en-US",
|
||||
}: {
|
||||
brief: PublicBrief;
|
||||
locale?: LandingLocale;
|
||||
}) {
|
||||
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||
const localizedBrief = localizeBrief(brief, locale);
|
||||
|
||||
return (
|
||||
<div className={pageShell}>
|
||||
<PublicContentAnalytics
|
||||
eventType="brief_view"
|
||||
onceKey={`brief:${brief.city}:${brief.date}`}
|
||||
payload={{ city: brief.city, date: brief.date, source: brief.settlementSource }}
|
||||
onceKey={`brief:${localizedBrief.city}:${localizedBrief.date}`}
|
||||
payload={{ city: localizedBrief.city, date: localizedBrief.date, source: localizedBrief.settlementSource }}
|
||||
/>
|
||||
<PublicHeader />
|
||||
<PublicHeader locale={locale} />
|
||||
<PageIntro
|
||||
description={brief.description}
|
||||
eyebrow={`${brief.cityName}, ${brief.countryName} / ${brief.date}`}
|
||||
title={brief.title}
|
||||
description={localizedBrief.description}
|
||||
eyebrow={`${localizedBrief.cityName}, ${localizedBrief.countryName} / ${localizedBrief.date}`}
|
||||
title={localizedBrief.title}
|
||||
/>
|
||||
<div className={contentWrap}>
|
||||
<section className="grid gap-5 lg:grid-cols-[1.6fr_0.9fr]">
|
||||
<article className={`${panel} p-5`}>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{brief.signals.map((signal) => (
|
||||
{localizedBrief.signals.map((signal) => (
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-4" key={signal.label}>
|
||||
<p className="text-xs font-semibold text-slate-500">{signal.label}</p>
|
||||
<p className="mt-1 font-mono text-2xl font-black text-slate-950">{signal.value}</p>
|
||||
@@ -230,30 +269,30 @@ export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 grid gap-5">
|
||||
<BriefSection title="DEB read" body={brief.debRead} />
|
||||
<BriefSection title="Settlement-source read" body={brief.sourceRead} />
|
||||
<BriefSection title="Model context" body={brief.modelRead} />
|
||||
<BriefSection title="Risk notes" body={brief.riskRead} />
|
||||
<BriefSection title={copy.detailLabels.debRead} body={localizedBrief.debRead} />
|
||||
<BriefSection title={copy.detailLabels.settlementSourceRead} body={localizedBrief.sourceRead} />
|
||||
<BriefSection title={copy.detailLabels.modelContext} body={localizedBrief.modelRead} />
|
||||
<BriefSection title={copy.detailLabels.riskNotes} body={localizedBrief.riskRead} />
|
||||
</div>
|
||||
</article>
|
||||
<aside className={`${panel} h-fit p-5`}>
|
||||
<p className={sectionTitle}>Snapshot</p>
|
||||
<p className={sectionTitle}>{copy.snapshot}</p>
|
||||
<dl className="mt-4 grid gap-3 text-sm">
|
||||
<InfoRow label="Market" value={brief.market} />
|
||||
<InfoRow label="Settlement source" value={brief.settlementSource} />
|
||||
<InfoRow label="Updated" value={formatDateTime(brief.updatedAt)} />
|
||||
<InfoRow label="Freshness" value={brief.dataFreshness} />
|
||||
<InfoRow label={copy.market} value={localizedBrief.market} />
|
||||
<InfoRow label={copy.settlementSource} value={localizedBrief.settlementSource} />
|
||||
<InfoRow label={copy.updated} value={formatDateTime(localizedBrief.updatedAt, locale)} />
|
||||
<InfoRow label={copy.freshness} value={localizedBrief.dataFreshness} />
|
||||
</dl>
|
||||
<div className="mt-5 flex flex-col gap-3">
|
||||
<PublicContentCta
|
||||
className={primaryButton}
|
||||
href="/terminal"
|
||||
payload={{ city: brief.city, date: brief.date, cta: "terminal" }}
|
||||
payload={{ city: localizedBrief.city, date: localizedBrief.date, cta: "terminal" }}
|
||||
>
|
||||
{brief.primaryCtaLabel}
|
||||
{localizedBrief.primaryCtaLabel}
|
||||
</PublicContentCta>
|
||||
<Link className={secondaryButton} href="/briefs">
|
||||
All public briefs
|
||||
<Link className={secondaryButton} href={landingLocaleHref("/briefs", locale)}>
|
||||
{copy.allPublicBriefs}
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -261,9 +300,9 @@ export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
|
||||
|
||||
<section className="grid gap-5 lg:grid-cols-[1fr_1fr]">
|
||||
<div className={`${panel} p-5`}>
|
||||
<p className={sectionTitle}>Checks before acting</p>
|
||||
<p className={sectionTitle}>{copy.checksBeforeActing}</p>
|
||||
<ul className="mt-4 grid gap-3">
|
||||
{brief.checkpoints.map((checkpoint) => (
|
||||
{localizedBrief.checkpoints.map((checkpoint) => (
|
||||
<li className={bodyText} key={checkpoint}>
|
||||
{checkpoint}
|
||||
</li>
|
||||
@@ -271,15 +310,15 @@ export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
|
||||
</ul>
|
||||
</div>
|
||||
<div className={`${panel} p-5`}>
|
||||
<p className={sectionTitle}>Distribution copy</p>
|
||||
<p className={`${bodyText} mt-4`}>{brief.distributionText}</p>
|
||||
<p className={sectionTitle}>{copy.distributionCopy}</p>
|
||||
<p className={`${bodyText} mt-4`}>{localizedBrief.distributionText}</p>
|
||||
<div className="mt-4">
|
||||
<PublicContentOutboundLink
|
||||
className={secondaryButton}
|
||||
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(brief.distributionText)}&url=${encodeURIComponent(absolutePublicUrl(briefPath(brief)))}`}
|
||||
payload={{ city: brief.city, date: brief.date, destination: "x_intent" }}
|
||||
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(localizedBrief.distributionText)}&url=${encodeURIComponent(absolutePublicUrl(landingLocaleHref(briefPath(localizedBrief), locale)))}`}
|
||||
payload={{ city: localizedBrief.city, date: localizedBrief.date, destination: "x_intent" }}
|
||||
>
|
||||
Share on X
|
||||
{copy.shareOnX}
|
||||
</PublicContentOutboundLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,21 +326,21 @@ export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
|
||||
|
||||
<section className={`${panel} grid gap-5 p-5 md:grid-cols-2`}>
|
||||
<div>
|
||||
<p className={sectionTitle}>Methodology links</p>
|
||||
<p className={sectionTitle}>{copy.methodologyLinks}</p>
|
||||
<div className="mt-4">
|
||||
<MethodologyLinks slugs={brief.methodologySlugs} />
|
||||
<MethodologyLinks locale={locale} slugs={localizedBrief.methodologySlugs} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className={sectionTitle}>Source links</p>
|
||||
<p className={sectionTitle}>{copy.sourceLinks}</p>
|
||||
<div className="mt-4">
|
||||
<SourceLinks slugs={brief.sourceSlugs} />
|
||||
<SourceLinks locale={locale} slugs={localizedBrief.sourceSlugs} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="rounded-md border border-amber-200 bg-amber-50 p-4 text-sm leading-6 text-amber-900">
|
||||
{brief.notFinancialAdvice}
|
||||
{localizedBrief.notFinancialAdvice}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -326,26 +365,33 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function MethodologyIndexPageView() {
|
||||
export function MethodologyIndexPageView({
|
||||
locale = "en-US",
|
||||
}: {
|
||||
locale?: LandingLocale;
|
||||
} = {}) {
|
||||
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||
const pages = METHODOLOGY_PAGES.map((page) => localizeMethodologyPage(page, locale));
|
||||
|
||||
return (
|
||||
<div className={pageShell}>
|
||||
<PublicHeader />
|
||||
<PublicHeader locale={locale} />
|
||||
<PageIntro
|
||||
description="Public methodology pages explain how PolyWeather handles DEB blending, settlement-source priority, freshness, and source reconciliation for prediction-market weather analysis."
|
||||
eyebrow="Methodology"
|
||||
title="How PolyWeather reads weather markets"
|
||||
description={copy.methodologyIndexDescription}
|
||||
eyebrow={copy.methodologyIndexEyebrow}
|
||||
title={copy.methodologyIndexTitle}
|
||||
/>
|
||||
<div className={contentWrap}>
|
||||
<section className="grid gap-4 md:grid-cols-2">
|
||||
{METHODOLOGY_PAGES.map((page) => (
|
||||
{pages.map((page) => (
|
||||
<article className={`${panel} p-5`} key={page.slug}>
|
||||
<p className={sectionTitle}>{formatDate(page.updatedAt)}</p>
|
||||
<p className={sectionTitle}>{formatDate(page.updatedAt, locale)}</p>
|
||||
<h2 className="mt-2 text-2xl font-black text-slate-950">
|
||||
<Link href={methodologyPath(page)}>{page.title}</Link>
|
||||
<Link href={landingLocaleHref(methodologyPath(page), locale)}>{page.title}</Link>
|
||||
</h2>
|
||||
<p className={`${bodyText} mt-3`}>{page.description}</p>
|
||||
<Link className={`${secondaryButton} mt-5`} href={methodologyPath(page)}>
|
||||
Read methodology
|
||||
<Link className={`${secondaryButton} mt-5`} href={landingLocaleHref(methodologyPath(page), locale)}>
|
||||
{copy.readMethodology}
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
@@ -355,7 +401,16 @@ export function MethodologyIndexPageView() {
|
||||
);
|
||||
}
|
||||
|
||||
export function MethodologyDetailPageView({ page }: { page: MethodologyPage }) {
|
||||
export function MethodologyDetailPageView({
|
||||
page,
|
||||
locale = "en-US",
|
||||
}: {
|
||||
page: MethodologyPage;
|
||||
locale?: LandingLocale;
|
||||
}) {
|
||||
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||
const localizedPage = localizeMethodologyPage(page, locale);
|
||||
|
||||
return (
|
||||
<div className={pageShell}>
|
||||
<PublicContentAnalytics
|
||||
@@ -363,13 +418,17 @@ export function MethodologyDetailPageView({ page }: { page: MethodologyPage }) {
|
||||
onceKey={`methodology:${page.slug}`}
|
||||
payload={{ slug: page.slug, content_type: "methodology" }}
|
||||
/>
|
||||
<PublicHeader />
|
||||
<PageIntro description={page.description} eyebrow="Methodology" title={page.title} />
|
||||
<PublicHeader locale={locale} />
|
||||
<PageIntro
|
||||
description={localizedPage.description}
|
||||
eyebrow={copy.methodologyIndexEyebrow}
|
||||
title={localizedPage.title}
|
||||
/>
|
||||
<div className={contentWrap}>
|
||||
<article className={`${panel} p-5`}>
|
||||
<p className="max-w-3xl text-base leading-7 text-slate-700">{page.summary}</p>
|
||||
<p className="max-w-3xl text-base leading-7 text-slate-700">{localizedPage.summary}</p>
|
||||
<div className="mt-8 grid gap-8">
|
||||
{page.sections.map((section) => (
|
||||
{localizedPage.sections.map((section) => (
|
||||
<section key={section.heading}>
|
||||
<h2 className="text-xl font-black text-slate-950">{section.heading}</h2>
|
||||
<p className={`${bodyText} mt-3`}>{section.body}</p>
|
||||
@@ -389,26 +448,33 @@ export function MethodologyDetailPageView({ page }: { page: MethodologyPage }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function SourcesIndexPageView() {
|
||||
export function SourcesIndexPageView({
|
||||
locale = "en-US",
|
||||
}: {
|
||||
locale?: LandingLocale;
|
||||
} = {}) {
|
||||
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||
const sources = SOURCE_PAGES.map((source) => localizeSourcePage(source, locale));
|
||||
|
||||
return (
|
||||
<div className={pageShell}>
|
||||
<PublicHeader />
|
||||
<PublicHeader locale={locale} />
|
||||
<PageIntro
|
||||
description="Source pages separate official observations, airport observations, and model guidance so readers can inspect why PolyWeather prioritizes settlement-relevant evidence."
|
||||
eyebrow="Sources"
|
||||
title="Weather source notes for public audit"
|
||||
description={copy.sourceIndexDescription}
|
||||
eyebrow={copy.sourceIndexEyebrow}
|
||||
title={copy.sourceIndexTitle}
|
||||
/>
|
||||
<div className={contentWrap}>
|
||||
<section className="grid gap-4 md:grid-cols-2">
|
||||
{SOURCE_PAGES.map((source) => (
|
||||
{sources.map((source) => (
|
||||
<article className={`${panel} p-5`} key={source.slug}>
|
||||
<p className={sectionTitle}>{source.operator}</p>
|
||||
<h2 className="mt-2 text-2xl font-black text-slate-950">
|
||||
<Link href={sourcePath(source)}>{source.title}</Link>
|
||||
<Link href={landingLocaleHref(sourcePath(source), locale)}>{source.title}</Link>
|
||||
</h2>
|
||||
<p className={`${bodyText} mt-3`}>{source.description}</p>
|
||||
<Link className={`${secondaryButton} mt-5`} href={sourcePath(source)}>
|
||||
Read source note
|
||||
<Link className={`${secondaryButton} mt-5`} href={landingLocaleHref(sourcePath(source), locale)}>
|
||||
{copy.readSourceNote}
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
@@ -418,32 +484,60 @@ export function SourcesIndexPageView() {
|
||||
);
|
||||
}
|
||||
|
||||
export function SourceDetailPageView({ source }: { source: SourcePage }) {
|
||||
export function SourceDetailPageView({
|
||||
source,
|
||||
locale = "en-US",
|
||||
}: {
|
||||
source: SourcePage;
|
||||
locale?: LandingLocale;
|
||||
}) {
|
||||
const localizedSource = localizeSourcePage(source, locale);
|
||||
const labels =
|
||||
locale === "en-US"
|
||||
? {
|
||||
cadence: "Cadence",
|
||||
coverage: "Coverage",
|
||||
operator: "Operator",
|
||||
reliability: "Reliability notes",
|
||||
relatedMethodology: "Related methodology",
|
||||
settlementUse: "Settlement use",
|
||||
sourceNote: "Source note",
|
||||
}
|
||||
: {
|
||||
cadence: "频率",
|
||||
coverage: "覆盖范围",
|
||||
operator: "运营方",
|
||||
reliability: "可靠性备注",
|
||||
relatedMethodology: "相关方法",
|
||||
settlementUse: "结算用途",
|
||||
sourceNote: "来源说明",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={pageShell}>
|
||||
<PublicHeader />
|
||||
<PageIntro description={source.description} eyebrow="Source note" title={source.title} />
|
||||
<PublicHeader locale={locale} />
|
||||
<PageIntro description={localizedSource.description} eyebrow={labels.sourceNote} title={localizedSource.title} />
|
||||
<div className={contentWrap}>
|
||||
<article className={`${panel} grid gap-6 p-5 lg:grid-cols-[0.9fr_1.4fr]`}>
|
||||
<dl className="grid gap-3 text-sm">
|
||||
<InfoRow label="Operator" value={source.operator} />
|
||||
<InfoRow label="Coverage" value={source.coverage} />
|
||||
<InfoRow label="Cadence" value={source.cadence} />
|
||||
<InfoRow label="Settlement use" value={source.settlementUse} />
|
||||
<InfoRow label={labels.operator} value={localizedSource.operator} />
|
||||
<InfoRow label={labels.coverage} value={localizedSource.coverage} />
|
||||
<InfoRow label={labels.cadence} value={localizedSource.cadence} />
|
||||
<InfoRow label={labels.settlementUse} value={localizedSource.settlementUse} />
|
||||
</dl>
|
||||
<div>
|
||||
<p className={sectionTitle}>Reliability notes</p>
|
||||
<p className={sectionTitle}>{labels.reliability}</p>
|
||||
<ul className="mt-4 grid gap-3">
|
||||
{source.reliabilityNotes.map((note) => (
|
||||
{localizedSource.reliabilityNotes.map((note) => (
|
||||
<li className={bodyText} key={note}>
|
||||
{note}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-6">
|
||||
<p className={sectionTitle}>Related methodology</p>
|
||||
<p className={sectionTitle}>{labels.relatedMethodology}</p>
|
||||
<div className="mt-4">
|
||||
<MethodologyLinks slugs={source.relatedMethodologySlugs} />
|
||||
<MethodologyLinks locale={locale} slugs={localizedSource.relatedMethodologySlugs} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -453,15 +547,15 @@ export function SourceDetailPageView({ source }: { source: SourcePage }) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return new Intl.DateTimeFormat("en", {
|
||||
function formatDateTime(value: string, locale: LandingLocale = "en-US") {
|
||||
return new Intl.DateTimeFormat(locale === "en-US" ? "en" : "zh-CN", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat("en", {
|
||||
function formatDate(value: string, locale: LandingLocale = "en-US") {
|
||||
return new Intl.DateTimeFormat(locale === "en-US" ? "en" : "zh-CN", {
|
||||
dateStyle: "medium",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export function runTests() {
|
||||
const analyticsPath = path.join(root, "lib", "app-analytics.ts");
|
||||
const analyticsIslandPath = path.join(root, "components", "public-content", "PublicContentAnalytics.tsx");
|
||||
const publicPagesPath = path.join(root, "components", "public-content", "PublicContentPages.tsx");
|
||||
const localeHelperPath = path.join(root, "components", "landing", "landingLocale.ts");
|
||||
const sitemapPath = path.join(root, "app", "sitemap.ts");
|
||||
|
||||
for (const requiredPath of [
|
||||
@@ -35,11 +36,14 @@ export function runTests() {
|
||||
|
||||
const content = fs.readFileSync(contentPath, "utf8");
|
||||
const briefDetail = fs.readFileSync(briefDetailPath, "utf8");
|
||||
const methodologyIndex = fs.readFileSync(methodologyIndexPath, "utf8");
|
||||
const methodologyDetail = fs.readFileSync(methodologyDetailPath, "utf8");
|
||||
const sourcesIndex = fs.readFileSync(sourcesIndexPath, "utf8");
|
||||
const sourceDetail = fs.readFileSync(sourceDetailPath, "utf8");
|
||||
const analytics = fs.readFileSync(analyticsPath, "utf8");
|
||||
const analyticsIsland = fs.readFileSync(analyticsIslandPath, "utf8");
|
||||
const publicPages = fs.readFileSync(publicPagesPath, "utf8");
|
||||
const localeHelper = fs.readFileSync(localeHelperPath, "utf8");
|
||||
const sitemap = fs.readFileSync(sitemapPath, "utf8");
|
||||
const briefsIndex = fs.readFileSync(briefsIndexPath, "utf8");
|
||||
|
||||
@@ -59,6 +63,28 @@ export function runTests() {
|
||||
content.includes("distributionText"),
|
||||
"public briefs must carry disclaimer, freshness, settlement source, and shareable distribution copy",
|
||||
);
|
||||
assert(
|
||||
content.includes("PUBLIC_CONTENT_COPY") &&
|
||||
content.includes('"zh-CN"') &&
|
||||
content.includes('"en-US"') &&
|
||||
content.includes("公开天气市场简报") &&
|
||||
content.includes("安卡拉") &&
|
||||
content.includes("阅读简报"),
|
||||
"public brief content must provide Chinese and English localized copy",
|
||||
);
|
||||
assert(
|
||||
content.includes("METHODOLOGY_PAGE_LOCALIZATIONS") &&
|
||||
content.includes("SOURCE_PAGE_LOCALIZATIONS") &&
|
||||
content.includes("DEB 不是结算预言机") &&
|
||||
content.includes("官方摘要可能滞后于原始点位观测"),
|
||||
"public methodology and source pages must provide Chinese body-level localization, not title-only translation",
|
||||
);
|
||||
assert(
|
||||
content.includes("24.5°C") &&
|
||||
content.includes("27.1°C") &&
|
||||
!/\b\d+(?:\.\d+)? C\b/.test(content),
|
||||
"public brief temperatures must use the degree Celsius symbol instead of a bare C",
|
||||
);
|
||||
assert(
|
||||
briefDetail.includes("generateStaticParams") &&
|
||||
briefDetail.includes("generateMetadata") &&
|
||||
@@ -77,11 +103,42 @@ export function runTests() {
|
||||
"methodology and source detail routes must expose structured data for GEO/SEO",
|
||||
);
|
||||
assert(
|
||||
publicPages.includes('MethodologyLinks slugs={["deb", "settlement-sources"]}') &&
|
||||
publicPages.includes('SourceLinks slugs={["mgm", "metar", "hko", "noaa"]}') &&
|
||||
publicPages.includes('MethodologyLinks locale={locale} slugs={["deb", "settlement-sources"]}') &&
|
||||
publicPages.includes('SourceLinks locale={locale} slugs={["mgm", "metar", "hko", "noaa"]}') &&
|
||||
briefsIndex.includes("Weather Market Brief"),
|
||||
"brief index must cross-link to DEB methodology and settlement source pages",
|
||||
);
|
||||
assert(
|
||||
publicPages.includes("LandingLocaleToggle") &&
|
||||
publicPages.includes("localizeBrief") &&
|
||||
publicPages.includes("PUBLIC_CONTENT_COPY") &&
|
||||
publicPages.includes('locale === "en-US" ? "Briefs" : "简报"'),
|
||||
"public content pages must render the shared language toggle and localized brief copy",
|
||||
);
|
||||
assert(
|
||||
localeHelper.includes("export function landingLocaleHref") &&
|
||||
localeHelper.includes("LANDING_LOCALE_QUERY_PARAM"),
|
||||
"landing locale helpers must expose a shared href builder for explicit locale navigation",
|
||||
);
|
||||
assert(
|
||||
publicPages.includes("landingLocaleHref") &&
|
||||
publicPages.includes('href={landingLocaleHref("/briefs", locale)}') &&
|
||||
publicPages.includes("href={landingLocaleHref(briefPath(brief), locale)}") &&
|
||||
publicPages.includes("href={landingLocaleHref(methodologyPath(localizedPage), locale)}") &&
|
||||
publicPages.includes("href={landingLocaleHref(sourcePath(localizedSource), locale)}"),
|
||||
"public content brief cards, nav, methodology, and source links must preserve the current locale",
|
||||
);
|
||||
assert(
|
||||
methodologyIndex.includes("resolvePublicContentLocale(searchParams)") &&
|
||||
methodologyIndex.includes("<MethodologyIndexPageView locale={locale} />") &&
|
||||
methodologyDetail.includes("resolvePublicContentLocale(searchParams)") &&
|
||||
methodologyDetail.includes("<MethodologyDetailPageView page={page} locale={locale} />") &&
|
||||
sourcesIndex.includes("resolvePublicContentLocale(searchParams)") &&
|
||||
sourcesIndex.includes("<SourcesIndexPageView locale={locale} />") &&
|
||||
sourceDetail.includes("resolvePublicContentLocale(searchParams)") &&
|
||||
sourceDetail.includes("<SourceDetailPageView source={source} locale={locale} />"),
|
||||
"methodology and source public routes must resolve and pass the same locale as brief routes",
|
||||
);
|
||||
assert(
|
||||
analytics.includes('"brief_view"') &&
|
||||
analytics.includes('"brief_cta_click"') &&
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { LandingLocale } from "@/components/landing/landingLocale";
|
||||
|
||||
export const PUBLIC_CONTENT_BASE_URL = "https://polyweather.top";
|
||||
|
||||
export type PublicBriefSignal = {
|
||||
@@ -57,6 +59,140 @@ export type SourcePage = {
|
||||
relatedMethodologySlugs: string[];
|
||||
};
|
||||
|
||||
export type PublicContentCopy = {
|
||||
allPublicBriefs: string;
|
||||
briefIndexDescription: string;
|
||||
briefIndexEyebrow: string;
|
||||
briefIndexTitle: string;
|
||||
checksBeforeActing: string;
|
||||
distributionCopy: string;
|
||||
docs: string;
|
||||
freshness: string;
|
||||
market: string;
|
||||
methodology: string;
|
||||
methodologyIndexDescription: string;
|
||||
methodologyIndexEyebrow: string;
|
||||
methodologyIndexTitle: string;
|
||||
methodologyLinks: string;
|
||||
methodologyPanelBody: string;
|
||||
methodologyPanelTitle: string;
|
||||
openLiveTerminal: string;
|
||||
readBrief: string;
|
||||
readMethodology: string;
|
||||
readSourceNote: string;
|
||||
settlementSource: string;
|
||||
shareOnX: string;
|
||||
snapshot: string;
|
||||
sourceIndexDescription: string;
|
||||
sourceIndexEyebrow: string;
|
||||
sourceIndexTitle: string;
|
||||
sourceLinks: string;
|
||||
sourceNotes: string;
|
||||
sourcePanelBody: string;
|
||||
sourcePanelTitle: string;
|
||||
sources: string;
|
||||
updated: string;
|
||||
detailLabels: {
|
||||
debRead: string;
|
||||
modelContext: string;
|
||||
riskNotes: string;
|
||||
settlementSourceRead: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const PUBLIC_CONTENT_COPY: Record<LandingLocale, PublicContentCopy> = {
|
||||
"zh-CN": {
|
||||
allPublicBriefs: "全部公开简报",
|
||||
briefIndexDescription:
|
||||
"公开天气市场简报把选定城市的市场读数整理为可索引证据:结算源、DEB 背景、模型分歧、新鲜度备注,以及明确的研究免责声明。",
|
||||
briefIndexEyebrow: "天气市场简报",
|
||||
briefIndexTitle: "公开天气市场简报",
|
||||
checksBeforeActing: "行动前检查",
|
||||
distributionCopy: "分发文案",
|
||||
docs: "文档",
|
||||
freshness: "新鲜度",
|
||||
market: "市场",
|
||||
methodology: "方法",
|
||||
methodologyIndexDescription:
|
||||
"公开方法页解释 PolyWeather 如何处理 DEB 融合、结算源优先级、新鲜度和来源校验。",
|
||||
methodologyIndexEyebrow: "方法",
|
||||
methodologyIndexTitle: "PolyWeather 如何读取天气市场",
|
||||
methodologyLinks: "方法链接",
|
||||
methodologyPanelBody:
|
||||
"简报会交叉链接 DEB 方法和结算源优先级页面,让读者能审计为什么 PolyWeather 不把通用城市预报直接当作市场真实值。",
|
||||
methodologyPanelTitle: "公开读数如何产生",
|
||||
openLiveTerminal: "打开实时终端",
|
||||
readBrief: "阅读简报",
|
||||
readMethodology: "阅读方法",
|
||||
readSourceNote: "阅读来源说明",
|
||||
settlementSource: "结算源",
|
||||
shareOnX: "分享到 X",
|
||||
snapshot: "快照",
|
||||
sourceIndexDescription:
|
||||
"来源页把官方观测、机场观测和模型指引分开展示,方便读者审计 PolyWeather 为什么优先使用与结算相关的证据。",
|
||||
sourceIndexEyebrow: "来源",
|
||||
sourceIndexTitle: "用于公开审计的天气来源说明",
|
||||
sourceLinks: "来源链接",
|
||||
sourceNotes: "来源说明",
|
||||
sourcePanelBody:
|
||||
"来源页解释为什么 MGM、METAR、HKO、NOAA 和模型指引会在 PolyWeather 工作流中分开展示。",
|
||||
sourcePanelTitle: "官方来源上下文",
|
||||
sources: "来源",
|
||||
updated: "更新",
|
||||
detailLabels: {
|
||||
debRead: "DEB 读数",
|
||||
modelContext: "模型上下文",
|
||||
riskNotes: "风险备注",
|
||||
settlementSourceRead: "结算源读数",
|
||||
},
|
||||
},
|
||||
"en-US": {
|
||||
allPublicBriefs: "All public briefs",
|
||||
briefIndexDescription:
|
||||
"Public Weather Market Brief pages turn selected city-market reads into indexable evidence: settlement source, DEB context, model disagreement, freshness notes, and a clear research disclaimer.",
|
||||
briefIndexEyebrow: "Weather Market Brief",
|
||||
briefIndexTitle: "Public market briefs for temperature judgment",
|
||||
checksBeforeActing: "Checks before acting",
|
||||
distributionCopy: "Distribution copy",
|
||||
docs: "Docs",
|
||||
freshness: "Freshness",
|
||||
market: "Market",
|
||||
methodology: "Methodology",
|
||||
methodologyIndexDescription:
|
||||
"Public methodology pages explain how PolyWeather handles DEB blending, settlement-source priority, freshness, and source reconciliation for prediction-market weather analysis.",
|
||||
methodologyIndexEyebrow: "Methodology",
|
||||
methodologyIndexTitle: "How PolyWeather reads weather markets",
|
||||
methodologyLinks: "Methodology links",
|
||||
methodologyPanelBody:
|
||||
"Briefs cross-link to the DEB methodology and settlement-source priority pages so readers can audit why PolyWeather does not treat generic city forecasts as market truth.",
|
||||
methodologyPanelTitle: "How the public read is produced",
|
||||
openLiveTerminal: "Open live terminal",
|
||||
readBrief: "Read brief",
|
||||
readMethodology: "Read methodology",
|
||||
readSourceNote: "Read source note",
|
||||
settlementSource: "Settlement source",
|
||||
shareOnX: "Share on X",
|
||||
snapshot: "Snapshot",
|
||||
sourceIndexDescription:
|
||||
"Source pages separate official observations, airport observations, and model guidance so readers can inspect why PolyWeather prioritizes settlement-relevant evidence.",
|
||||
sourceIndexEyebrow: "Sources",
|
||||
sourceIndexTitle: "Weather source notes for public audit",
|
||||
sourceLinks: "Source links",
|
||||
sourceNotes: "Source notes",
|
||||
sourcePanelBody:
|
||||
"Source pages explain why MGM, METAR, HKO, NOAA, and model guidance are displayed separately in PolyWeather workflows.",
|
||||
sourcePanelTitle: "Official-source context",
|
||||
sources: "Sources",
|
||||
updated: "Updated",
|
||||
detailLabels: {
|
||||
debRead: "DEB read",
|
||||
modelContext: "Model context",
|
||||
riskNotes: "Risk notes",
|
||||
settlementSourceRead: "Settlement-source read",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const PUBLIC_BRIEFS: PublicBrief[] = [
|
||||
{
|
||||
city: "ankara",
|
||||
@@ -75,7 +211,7 @@ export const PUBLIC_BRIEFS: PublicBrief[] = [
|
||||
debRead:
|
||||
"DEB kept the intraday high-temperature read below the isolated MGM spike and closer to the observed official range.",
|
||||
sourceRead:
|
||||
"MGM is treated as the primary settlement reference. A single 27.1 C point should be checked against adjacent official readings before it is accepted as a new high.",
|
||||
"MGM is treated as the primary settlement reference. A single 27.1°C point should be checked against adjacent official readings before it is accepted as a new high.",
|
||||
modelRead:
|
||||
"ECMWF was warmer than the DEB blend in the early afternoon window, but the public brief weights official observations above model-only movement.",
|
||||
riskRead:
|
||||
@@ -83,19 +219,19 @@ export const PUBLIC_BRIEFS: PublicBrief[] = [
|
||||
notFinancialAdvice:
|
||||
"This brief is weather-research content for prediction-market preparation. It is not financial advice and does not guarantee settlement outcomes.",
|
||||
distributionText:
|
||||
"Ankara 2026-06-24 public Weather Market Brief: MGM official readings favored a 24.5 C observed high over an isolated 27.1 C spike; DEB stayed below the outlier. Not financial advice.",
|
||||
"Ankara 2026-06-24 public Weather Market Brief: MGM official readings favored a 24.5°C observed high over an isolated 27.1°C spike; DEB stayed below the outlier. Not financial advice.",
|
||||
primaryCtaLabel: "Open live terminal",
|
||||
sourceSlugs: ["mgm", "metar", "ecmwf"],
|
||||
methodologySlugs: ["deb", "settlement-sources"],
|
||||
signals: [
|
||||
{
|
||||
label: "Observed high so far",
|
||||
value: "24.5 C",
|
||||
value: "24.5°C",
|
||||
detail: "Official-source value to compare against any isolated higher point.",
|
||||
},
|
||||
{
|
||||
label: "Outlier under review",
|
||||
value: "27.1 C",
|
||||
value: "27.1°C",
|
||||
detail: "A sudden single-source value needs neighboring-time validation.",
|
||||
},
|
||||
{
|
||||
@@ -384,6 +520,324 @@ export const SOURCE_PAGES: SourcePage[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const BRIEF_LOCALIZATIONS: Record<string, Partial<PublicBrief>> = {
|
||||
"ankara:2026-06-24": {
|
||||
cityName: "安卡拉",
|
||||
countryName: "土耳其",
|
||||
title: "安卡拉天气市场简报 - 2026年6月24日",
|
||||
description:
|
||||
"安卡拉最高温公开简报,聚焦 MGM 结算源行为、DEB 融合预报背景和异常值复核。",
|
||||
market: "当日最高温判断",
|
||||
settlementSource: "MGM 官方站",
|
||||
dataFreshness:
|
||||
"静态公开快照。付费终端用户行动前应核对最新官方观测和 SSE replay 状态。",
|
||||
debRead:
|
||||
"DEB 将日内最高温读数压在孤立 MGM 尖峰下方,更接近已观测的官方区间。",
|
||||
sourceRead:
|
||||
"MGM 被视为主要结算参考。单个 27.1°C 点位在接受为新高前,需要与相邻官方读数比对。",
|
||||
modelRead:
|
||||
"ECMWF 在午后窗口比 DEB 融合更暖,但公开简报把官方观测置于纯模型移动之上。",
|
||||
riskRead:
|
||||
"主要风险是晚些时候官方更新或来源侧修正,导致公开快照后的认可高温发生变化。",
|
||||
notFinancialAdvice:
|
||||
"本简报是用于预测市场准备的天气研究内容,不构成金融建议,也不保证结算结果。",
|
||||
distributionText:
|
||||
"安卡拉 2026-06-24 公开天气市场简报:MGM 官方读数更支持 24.5°C 已观测高温,而非孤立 27.1°C 尖峰;DEB 仍低于异常点。非金融建议。",
|
||||
primaryCtaLabel: "打开实时终端",
|
||||
signals: [
|
||||
{
|
||||
label: "目前官方高温",
|
||||
value: "24.5°C",
|
||||
detail: "用于对比任何孤立更高点的官方来源值。",
|
||||
},
|
||||
{
|
||||
label: "异常点待复核",
|
||||
value: "27.1°C",
|
||||
detail: "突然出现的单源值需要相邻时间验证。",
|
||||
},
|
||||
{
|
||||
label: "DEB 公开读数",
|
||||
value: "低于尖峰",
|
||||
detail: "融合路径仍更接近已验证观测带。",
|
||||
},
|
||||
],
|
||||
checkpoints: [
|
||||
"检查疑似尖峰是否进入官方高温摘要。",
|
||||
"在把单点视为结算相关之前,先比较相邻 MGM 观测。",
|
||||
"市场关闭前查看付费终端的实时图表 patch 和来源新鲜度。",
|
||||
],
|
||||
},
|
||||
"hong-kong:2026-06-24": {
|
||||
cityName: "香港",
|
||||
countryName: "香港",
|
||||
title: "香港天气市场简报 - 2026年6月24日",
|
||||
description:
|
||||
"公开简报展示 PolyWeather 如何把 HKO/长洲/机场观测与 DEB、模型分歧放在一起解释最高温市场。",
|
||||
market: "城市与机场最高温判断",
|
||||
settlementSource: "HKO 官方网络",
|
||||
dataFreshness:
|
||||
"静态公开快照。HKO 和站点缓存刷新后,实时终端数值可能不同。",
|
||||
debRead:
|
||||
"到午后后段,实况观测与模型分歧趋于一致,DEB 支持较窄的高温窗口。",
|
||||
sourceRead:
|
||||
"解释机场单独移动前,应先把 HKO 网络观测作为需要校验的来源族。",
|
||||
modelRead:
|
||||
"模型分歧有限,因此来源新鲜度和站点选择比大尺度天气不确定性更重要。",
|
||||
riskRead:
|
||||
"主要风险是午后后段特定站点保温,或官方摘要出现较晚修订。",
|
||||
notFinancialAdvice:
|
||||
"本简报是用于预测市场准备的天气研究内容,不构成金融建议,也不保证结算结果。",
|
||||
distributionText:
|
||||
"香港 2026-06-24 公开天气市场简报:来源选择和 HKO 新鲜度比模型分歧更关键。非金融建议。",
|
||||
primaryCtaLabel: "打开实时终端",
|
||||
signals: [
|
||||
{
|
||||
label: "来源族",
|
||||
value: "HKO",
|
||||
detail: "先使用官方站上下文,再解释机场读数。",
|
||||
},
|
||||
{
|
||||
label: "模型分歧",
|
||||
value: "低",
|
||||
detail: "晚间不确定性主要来自站点行为。",
|
||||
},
|
||||
{
|
||||
label: "终端需要",
|
||||
value: "新鲜度",
|
||||
detail: "实时来源时间戳决定公开快照是否仍有用。",
|
||||
},
|
||||
],
|
||||
checkpoints: [
|
||||
"比较市场温度桶前先确认 HKO 站点时间戳。",
|
||||
"把机场 METAR 观测与 HKO 网络读数分开解释。",
|
||||
"如果公开快照已超过一个刷新周期,检查终端来源健康状态。",
|
||||
],
|
||||
},
|
||||
"new-york:2026-06-24": {
|
||||
cityName: "纽约",
|
||||
countryName: "美国",
|
||||
title: "纽约天气市场简报 - 2026年6月24日",
|
||||
description:
|
||||
"纽约温度市场公开简报,连接 METAR、NOAA 上下文、DEB 融合和日内尾段风险检查。",
|
||||
market: "机场关联最高温判断",
|
||||
settlementSource: "METAR 与 NOAA 站点上下文",
|
||||
dataFreshness:
|
||||
"静态公开快照。付费终端用户应检查当前 METAR 观测和官方摘要。",
|
||||
debRead:
|
||||
"DEB 用最新观测趋势校验偏暖模型指引,而不是直接追随原始模型高温。",
|
||||
sourceRead:
|
||||
"METAR 提供快速机场证据,NOAA 上下文帮助确认机场读数是否具代表性。",
|
||||
modelRead:
|
||||
"偏暖模型指引只有在和机场实况、云量/风场上下文校验后才有价值。",
|
||||
riskRead:
|
||||
"主要风险是日内尾段云层短暂打开,将机场观测推入更高温度桶。",
|
||||
notFinancialAdvice:
|
||||
"本简报是用于预测市场准备的天气研究内容,不构成金融建议,也不保证结算结果。",
|
||||
distributionText:
|
||||
"纽约 2026-06-24 公开天气市场简报:DEB 将偏暖模型指引与机场实时证据融合。非金融建议。",
|
||||
primaryCtaLabel: "打开实时终端",
|
||||
signals: [
|
||||
{
|
||||
label: "快速证据",
|
||||
value: "METAR",
|
||||
detail: "机场观测定义短周期读数。",
|
||||
},
|
||||
{
|
||||
label: "复核",
|
||||
value: "NOAA",
|
||||
detail: "官方上下文帮助审计最终最高温解释。",
|
||||
},
|
||||
{
|
||||
label: "DEB 立场",
|
||||
value: "融合",
|
||||
detail: "没有实况确认,不追随偏暖模型运行。",
|
||||
},
|
||||
],
|
||||
checkpoints: [
|
||||
"在每日高温窗口结束前观察最后两个 METAR 周期。",
|
||||
"检查模型偏暖是否得到云量和风场观测支持。",
|
||||
"最终解释结算前先使用官方来源上下文。",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const METHODOLOGY_PAGE_LOCALIZATIONS: Record<string, Partial<MethodologyPage>> = {
|
||||
deb: {
|
||||
title: "DEB 预测方法",
|
||||
description:
|
||||
"PolyWeather 如何把 DEB 融合预报用于预测市场温度判断,同时不替代结算源证据。",
|
||||
summary:
|
||||
"DEB 是 PolyWeather 融合预报层的公开名称。它把模型指引、实时观测动量、来源新鲜度和站点上下文放在一起,帮助用户用更少单模型误判来判断最高温区间。",
|
||||
sections: [
|
||||
{
|
||||
heading: "DEB 用来做什么",
|
||||
body:
|
||||
"DEB 不是结算预言机。它是决策辅助层,让实时观测路径和模型分歧更容易对比。",
|
||||
bullets: [
|
||||
"当模型指引与结算源证据冲突时,优先看结算源证据。",
|
||||
"把过期或孤立的来源值视为需要质检的候选点。",
|
||||
"展示预报区间以及区间变宽或收窄的原因。",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "关键输入",
|
||||
body:
|
||||
"融合层有用,是因为它组合了不同证据类别,而不是假设一次模型运行就足够。",
|
||||
bullets: [
|
||||
"最新官方或机场观测及其新鲜度。",
|
||||
"ECMWF、GFS、ICON、GEM 以及可用本地来源之间的模型共识和分歧。",
|
||||
"城市特定来源行为、站点选择和日内最高温时段。",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "如何复盘 DEB 偏差",
|
||||
body:
|
||||
"DEB 偏差应拆开来源新鲜度、模型分歧和结算源修订来复盘。",
|
||||
bullets: [
|
||||
"如果晚到来源 patch 改变了观测高温,将其归类为新鲜度或 replay 问题。",
|
||||
"如果所有来源都新鲜但高温落在区间外,复盘模型权重和本地站点特征。",
|
||||
"如果只有单一来源跳变,先审计相邻观测,再围绕异常点重训。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"settlement-sources": {
|
||||
title: "结算源优先级",
|
||||
description:
|
||||
"为什么 PolyWeather 先展示官方结算相关观测,而不是通用天气 API 数值。",
|
||||
summary:
|
||||
"预测市场用户关心的是合约最终结算数字。因此 PolyWeather 把官方站、机场和运营方特定来源行为放在泛消费者天气均值之上。",
|
||||
sections: [
|
||||
{
|
||||
heading: "为什么通用天气值不够",
|
||||
body:
|
||||
"消费者天气应用经常平滑站点数据,或展示城市级近似值。市场结算可能依赖更窄的官方来源。",
|
||||
bullets: [
|
||||
"一个城市标签可能隐藏多个日高温不同的站点。",
|
||||
"机场 METAR 可能比公开摘要更新更快,但未必是最终结算源。",
|
||||
"官方来源修订往往比平滑的预报曲线更重要。",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "PolyWeather 展示什么",
|
||||
body:
|
||||
"终端会拆开来源标签、观测时间戳、预报模型和新鲜度状态,让用户审计数字形成路径。",
|
||||
bullets: [
|
||||
"图表中的结算源标签和站点上下文。",
|
||||
"来源新鲜度、缓存策略和 SSE patch 可见性。",
|
||||
"DEB 预报上下文与实时观测并列展示,而不是替代观测。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const SOURCE_PAGE_LOCALIZATIONS: Record<string, Partial<SourcePage>> = {
|
||||
mgm: {
|
||||
title: "MGM 天气来源",
|
||||
description: "PolyWeather 针对土耳其 MGM 观测的来源说明,用于安卡拉类温度市场分析。",
|
||||
operator: "土耳其国家气象局",
|
||||
coverage: "土耳其官方站网络,包含安卡拉市场上下文。",
|
||||
cadence: "来源频率会随站点和发布路径变化;终端仍需要检查新鲜度。",
|
||||
settlementUse: "当安卡拉市场引用土耳其官方观测时,作为主要官方来源族使用。",
|
||||
reliabilityNotes: [
|
||||
"单点尖峰在接受前应与相邻时间戳比对。",
|
||||
"官方摘要可能滞后于原始点位观测。",
|
||||
"数值突然出现时,应检查缓存和 SSE replay 状态。",
|
||||
],
|
||||
},
|
||||
metar: {
|
||||
title: "METAR 机场观测",
|
||||
description: "PolyWeather 针对机场 METAR 观测的来源说明,用作温度市场工作流中的快速证据。",
|
||||
operator: "机场天气观测网络",
|
||||
coverage: "覆盖受支持市场的机场关联观测。",
|
||||
cadence: "通常为小时级或更短,取决于机场和发布行为。",
|
||||
settlementUse: "适合快速证据和机场关联合约;仍必须与合约的精确结算源校验。",
|
||||
reliabilityNotes: [
|
||||
"METAR 可能比官方日摘要更新更快。",
|
||||
"机场暴露环境可能不同于市区官方站。",
|
||||
"临近收盘的晚些 METAR 周期可能改变最高温判断。",
|
||||
],
|
||||
},
|
||||
ecmwf: {
|
||||
title: "ECMWF 模型指引",
|
||||
description: "PolyWeather 针对 ECMWF 模型指引的来源说明,它是 DEB 融合预报的一个输入。",
|
||||
operator: "欧洲中期天气预报中心",
|
||||
coverage: "全球数值天气预报指引。",
|
||||
cadence: "模型运行频率取决于产品和摄取时间。",
|
||||
settlementUse: "仅用于预报上下文,不替代实时官方观测的结算解释。",
|
||||
reliabilityNotes: [
|
||||
"模型偏暖或偏冷都应由实时观测验证。",
|
||||
"当来源证据尚未稳定时,模型运行间变化可以提供参考。",
|
||||
"模型分歧应与结算源数据并列展示,而不是凌驾其上。",
|
||||
],
|
||||
},
|
||||
hko: {
|
||||
title: "HKO 官方观测",
|
||||
description: "PolyWeather 针对香港天文台观测的来源说明,用于香港市场分析。",
|
||||
operator: "香港天文台",
|
||||
coverage: "香港官方观测网络和站点级上下文。",
|
||||
cadence: "观测产品频率不同;终端仍应检查来源新鲜度。",
|
||||
settlementUse: "作为香港站点和城市市场解释中的官方来源族使用。",
|
||||
reliabilityNotes: [
|
||||
"站点选择可能显著改变最高温读数。",
|
||||
"机场观测应与更广泛的 HKO 网络读数分开解释。",
|
||||
"湿度、风和午后短时日照会影响最终高温。",
|
||||
],
|
||||
},
|
||||
noaa: {
|
||||
title: "NOAA 天气上下文",
|
||||
description: "PolyWeather 针对 NOAA 上下文的来源说明,用于校验美国天气市场观测和摘要。",
|
||||
operator: "美国国家海洋和大气管理局",
|
||||
coverage: "美国官方天气观测、摘要和上下文产品。",
|
||||
cadence: "频率取决于产品族和站点报告行为。",
|
||||
settlementUse: "当合约规则引用 NOAA/NWS 数据时,用于审计和解释美国官方观测。",
|
||||
reliabilityNotes: [
|
||||
"官方摘要可能晚于快速机场观测到达。",
|
||||
"日高温解释应匹配合约的站点和时区规则。",
|
||||
"使用 NOAA 上下文确认机场观测是否具代表性。",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function localizeBrief(brief: PublicBrief, locale: LandingLocale): PublicBrief {
|
||||
if (locale === "en-US") return brief;
|
||||
const localized = BRIEF_LOCALIZATIONS[`${brief.city}:${brief.date}`] || {};
|
||||
return {
|
||||
...brief,
|
||||
...localized,
|
||||
checkpoints: localized.checkpoints || brief.checkpoints,
|
||||
methodologySlugs: localized.methodologySlugs || brief.methodologySlugs,
|
||||
signals: localized.signals || brief.signals,
|
||||
sourceSlugs: localized.sourceSlugs || brief.sourceSlugs,
|
||||
};
|
||||
}
|
||||
|
||||
export function localizeBriefs(locale: LandingLocale) {
|
||||
return PUBLIC_BRIEFS.map((brief) => localizeBrief(brief, locale));
|
||||
}
|
||||
|
||||
export function localizeMethodologyPage(page: MethodologyPage, locale: LandingLocale): MethodologyPage {
|
||||
if (locale === "en-US") return page;
|
||||
const localized = METHODOLOGY_PAGE_LOCALIZATIONS[page.slug] || {};
|
||||
return {
|
||||
...page,
|
||||
...localized,
|
||||
sections: localized.sections || page.sections,
|
||||
};
|
||||
}
|
||||
|
||||
export function localizeSourcePage(page: SourcePage, locale: LandingLocale): SourcePage {
|
||||
if (locale === "en-US") return page;
|
||||
const localized = SOURCE_PAGE_LOCALIZATIONS[page.slug] || {};
|
||||
return {
|
||||
...page,
|
||||
...localized,
|
||||
reliabilityNotes: localized.reliabilityNotes || page.reliabilityNotes,
|
||||
relatedMethodologySlugs: localized.relatedMethodologySlugs || page.relatedMethodologySlugs,
|
||||
};
|
||||
}
|
||||
|
||||
export function briefPath(brief: PublicBrief) {
|
||||
return `/briefs/${brief.city}/${brief.date}`;
|
||||
}
|
||||
|
||||
@@ -221,6 +221,21 @@ export interface DebHourlyPath {
|
||||
correction?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface DebEnsembleSignal {
|
||||
available?: boolean;
|
||||
stance?: "supporting" | "neutral" | "caution" | "unavailable" | string;
|
||||
confidence_delta?: number | null;
|
||||
median?: number | null;
|
||||
p10?: number | null;
|
||||
p90?: number | null;
|
||||
spread?: number | null;
|
||||
deb_distance?: number | null;
|
||||
label_zh?: string | null;
|
||||
label_en?: string | null;
|
||||
reason_zh?: string | null;
|
||||
reason_en?: string | null;
|
||||
}
|
||||
|
||||
export interface DebForecast {
|
||||
prediction: number | null;
|
||||
raw_prediction?: number | null;
|
||||
@@ -239,6 +254,7 @@ export interface DebForecast {
|
||||
intraday_adjustment?: number | null;
|
||||
hourly_path?: DebHourlyPath | null;
|
||||
hourly_correction?: Record<string, unknown> | null;
|
||||
ensemble_signal?: DebEnsembleSignal | null;
|
||||
}
|
||||
|
||||
export interface CitySummary {
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import {
|
||||
REGIONS,
|
||||
getCityRegion,
|
||||
} from "@/components/dashboard/scan-terminal/continent-grouping";
|
||||
|
||||
export const MODEL_SUMMARY_MODEL_COLUMNS = [
|
||||
{ key: "ECMWF", label: "ECMWF" },
|
||||
{ key: "ECMWF AIFS", label: "ECMWF AIFS" },
|
||||
{ key: "GFS", label: "GFS" },
|
||||
{ key: "ICON", label: "ICON" },
|
||||
{ key: "ICON-EU", label: "ICON-EU" },
|
||||
{ key: "GEM", label: "GEM" },
|
||||
{ key: "GDPS", label: "GDPS" },
|
||||
{ key: "JMA", label: "JMA" },
|
||||
{ key: "AROME HD", label: "AROME HD" },
|
||||
{ key: "HRRR", label: "HRRR" },
|
||||
{ key: "NAM", label: "NAM" },
|
||||
{ key: "WeatherNext 2", label: "WeatherNext 2" },
|
||||
] as const;
|
||||
|
||||
export type ModelSummaryColumnKey = (typeof MODEL_SUMMARY_MODEL_COLUMNS)[number]["key"];
|
||||
|
||||
export type ModelSummaryProbabilityBucket = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
lower: number;
|
||||
upper: number;
|
||||
probability: number;
|
||||
};
|
||||
|
||||
export type ModelSummaryMarketMatch = {
|
||||
key: string;
|
||||
label: string;
|
||||
modelProbability: number | null;
|
||||
marketUrl: string | null;
|
||||
};
|
||||
|
||||
export type ModelSummaryRow = {
|
||||
cityKey: string;
|
||||
cityName: string;
|
||||
regionLabel: string;
|
||||
regionLabelZh: string;
|
||||
regionSort: number;
|
||||
tempSymbol: string;
|
||||
localTime: string;
|
||||
timezoneOffsetSeconds: number | null;
|
||||
debPrediction: number | null;
|
||||
models: Record<ModelSummaryColumnKey, number | null>;
|
||||
modelMedian: number | null;
|
||||
modelSpread: number | null;
|
||||
probabilityBuckets: ModelSummaryProbabilityBucket[];
|
||||
probabilityBucketMap: Record<string, ModelSummaryProbabilityBucket>;
|
||||
gaussianMu: number | null;
|
||||
probabilityEngine: string | null;
|
||||
topProbabilityBucketKey: string | null;
|
||||
marketMatches: ModelSummaryMarketMatch[];
|
||||
searchText: string;
|
||||
};
|
||||
|
||||
export type ModelSummaryFilters = {
|
||||
query: string;
|
||||
debOnly: boolean;
|
||||
wideSpreadOnly: boolean;
|
||||
};
|
||||
|
||||
const WIDE_SPREAD_THRESHOLD = 2;
|
||||
|
||||
function finiteNumber(value: unknown): number | null {
|
||||
if (value == null) return null;
|
||||
if (typeof value === "string" && value.trim() === "") return null;
|
||||
const numericValue = Number(value);
|
||||
return Number.isFinite(numericValue) ? numericValue : null;
|
||||
}
|
||||
|
||||
function roundToOneDecimal(value: number) {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function median(values: number[]) {
|
||||
if (!values.length) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
if (sorted.length % 2 === 1) return roundToOneDecimal(sorted[mid]);
|
||||
return roundToOneDecimal((sorted[mid - 1] + sorted[mid]) / 2);
|
||||
}
|
||||
|
||||
function spread(values: number[]) {
|
||||
if (!values.length) return null;
|
||||
return roundToOneDecimal(Math.max(...values) - Math.min(...values));
|
||||
}
|
||||
|
||||
function probabilityFromBucket(bucket: Record<string, unknown>) {
|
||||
const raw = finiteNumber(bucket.probability ?? bucket.model_probability);
|
||||
if (raw == null) return null;
|
||||
return raw > 1 ? raw / 100 : raw;
|
||||
}
|
||||
|
||||
function formatBucketBound(value: number) {
|
||||
return Number(value.toFixed(1)).toString();
|
||||
}
|
||||
|
||||
function probabilityBucketKey(lower: number, upper: number, unit: string) {
|
||||
return `${formatBucketBound(lower)}-${formatBucketBound(upper)}${unit || "°C"}`;
|
||||
}
|
||||
|
||||
function probabilityBucketLabel(lower: number, upper: number, unit: string) {
|
||||
return probabilityBucketKey(lower, upper, unit);
|
||||
}
|
||||
|
||||
function isFahrenheitUnit(unit: string) {
|
||||
return unit.toUpperCase().includes("F");
|
||||
}
|
||||
|
||||
function marketOptionBucketForValue(value: number, unit: string) {
|
||||
const settledValue = Math.round(value);
|
||||
if (isFahrenheitUnit(unit)) {
|
||||
const lowerValue = settledValue % 2 === 0 ? settledValue : settledValue - 1;
|
||||
const upperValue = lowerValue + 1;
|
||||
return {
|
||||
key: `${lowerValue}-${upperValue}${unit || "°F"}`,
|
||||
label: `${lowerValue}-${upperValue}${unit || "°F"}`,
|
||||
lower: lowerValue - 0.5,
|
||||
upper: upperValue + 0.5,
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: `${settledValue}${unit || "°C"}`,
|
||||
label: `${settledValue}${unit || "°C"}`,
|
||||
lower: settledValue - 0.5,
|
||||
upper: settledValue + 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
function roundProbability(value: number) {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
function sourceMarketBuckets(row: ScanOpportunityRow) {
|
||||
const marketRow = row as ScanOpportunityRow & {
|
||||
all_buckets?: Array<Record<string, unknown>> | null;
|
||||
top_buckets?: Array<Record<string, unknown>> | null;
|
||||
};
|
||||
return (
|
||||
Array.isArray(marketRow.all_buckets) && marketRow.all_buckets.length
|
||||
? marketRow.all_buckets
|
||||
: Array.isArray(marketRow.top_buckets)
|
||||
? marketRow.top_buckets
|
||||
: []
|
||||
) as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function parseMarketOptionBucket(bucket: Record<string, unknown>, unit: string) {
|
||||
const rawLabel = String(bucket.label || bucket.bucket || bucket.range || "").trim();
|
||||
const labelNumbers = rawLabel.match(/-?\d+(?:\.\d+)?/g)?.map(Number) || [];
|
||||
const lower = finiteNumber(bucket.lower);
|
||||
const upper = finiteNumber(bucket.upper);
|
||||
let lowerValue: number | null = null;
|
||||
let upperValue: number | null = null;
|
||||
|
||||
if (/below/i.test(rawLabel) && labelNumbers.length) {
|
||||
upperValue = Math.round(labelNumbers[0]);
|
||||
} else if (/higher/i.test(rawLabel) && labelNumbers.length) {
|
||||
lowerValue = Math.round(labelNumbers[0]);
|
||||
} else if (labelNumbers.length >= 2) {
|
||||
lowerValue = Math.round(labelNumbers[0]);
|
||||
upperValue = Math.round(labelNumbers[1]);
|
||||
} else if (labelNumbers.length === 1) {
|
||||
lowerValue = Math.round(labelNumbers[0]);
|
||||
upperValue = Math.round(labelNumbers[0]);
|
||||
} else if (lower != null && upper != null && upper > lower) {
|
||||
lowerValue = Math.ceil(lower);
|
||||
upperValue = Math.ceil(upper) - 1;
|
||||
} else {
|
||||
const value = finiteNumber(bucket.value ?? bucket.temp ?? bucket.temperature);
|
||||
if (value == null) return null;
|
||||
const option = marketOptionBucketForValue(value, unit);
|
||||
lowerValue = Math.ceil(option.lower);
|
||||
upperValue = Math.ceil(option.upper) - 1;
|
||||
}
|
||||
|
||||
const finiteLower = lowerValue ?? Number.NEGATIVE_INFINITY;
|
||||
const finiteUpper = upperValue ?? Number.POSITIVE_INFINITY;
|
||||
if (finiteUpper < finiteLower) return null;
|
||||
|
||||
let label = rawLabel;
|
||||
if (!label || /\.5\b/.test(label)) {
|
||||
const representative =
|
||||
Number.isFinite(finiteLower) && Number.isFinite(finiteUpper)
|
||||
? (finiteLower + finiteUpper) / 2
|
||||
: Number.isFinite(finiteLower)
|
||||
? finiteLower
|
||||
: finiteUpper;
|
||||
label = marketOptionBucketForValue(representative, unit).label;
|
||||
}
|
||||
|
||||
return {
|
||||
key: label,
|
||||
label,
|
||||
lowerValue: finiteLower,
|
||||
upperValue: finiteUpper,
|
||||
sortValue: Number.isFinite(finiteLower) ? finiteLower : finiteUpper,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProbabilityBuckets(row: ScanOpportunityRow): ModelSummaryProbabilityBucket[] {
|
||||
const rawBuckets = (
|
||||
Array.isArray(row.distribution_full) && row.distribution_full.length
|
||||
? row.distribution_full
|
||||
: Array.isArray(row.distribution_preview)
|
||||
? row.distribution_preview
|
||||
: []
|
||||
) as Array<Record<string, unknown>>;
|
||||
const unit = row.temp_symbol || "°C";
|
||||
const rawProbabilityPoints = rawBuckets
|
||||
.map((bucket) => {
|
||||
const value = finiteNumber(bucket.value ?? bucket.temp ?? bucket.temperature);
|
||||
const probability = probabilityFromBucket(bucket);
|
||||
if (value == null || probability == null || probability <= 0) return null;
|
||||
return { value, settledValue: Math.round(value), probability };
|
||||
})
|
||||
.filter((point): point is { value: number; settledValue: number; probability: number } => point !== null);
|
||||
|
||||
const marketBuckets = sourceMarketBuckets(row)
|
||||
.map((bucket) => parseMarketOptionBucket(bucket, unit))
|
||||
.filter((bucket): bucket is NonNullable<typeof bucket> => bucket !== null);
|
||||
|
||||
if (marketBuckets.length && rawProbabilityPoints.length) {
|
||||
const fromMarketBuckets = marketBuckets
|
||||
.map((bucket) => {
|
||||
const matchingPoints = rawProbabilityPoints.filter(
|
||||
(point) =>
|
||||
point.settledValue >= bucket.lowerValue &&
|
||||
point.settledValue <= bucket.upperValue,
|
||||
);
|
||||
const probability = matchingPoints.reduce((sum, point) => sum + point.probability, 0);
|
||||
if (probability <= 0) return null;
|
||||
const weightedValue = matchingPoints.reduce(
|
||||
(sum, point) => sum + point.value * point.probability,
|
||||
0,
|
||||
);
|
||||
return {
|
||||
key: bucket.key,
|
||||
label: bucket.label,
|
||||
value: roundToOneDecimal(weightedValue / probability),
|
||||
lower: bucket.sortValue,
|
||||
upper: bucket.sortValue,
|
||||
probability: roundProbability(probability),
|
||||
};
|
||||
})
|
||||
.filter((bucket): bucket is ModelSummaryProbabilityBucket => bucket !== null)
|
||||
.sort((a, b) => a.lower - b.lower || a.upper - b.upper);
|
||||
|
||||
if (fromMarketBuckets.length) return fromMarketBuckets;
|
||||
}
|
||||
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
lower: number;
|
||||
upper: number;
|
||||
probability: number;
|
||||
weightedValue: number;
|
||||
}
|
||||
>();
|
||||
|
||||
rawProbabilityPoints.forEach(({ value, probability }) => {
|
||||
const option = marketOptionBucketForValue(value, unit);
|
||||
const existing = grouped.get(option.key);
|
||||
if (existing) {
|
||||
existing.probability += probability;
|
||||
existing.weightedValue += value * probability;
|
||||
return;
|
||||
}
|
||||
grouped.set(option.key, {
|
||||
label: option.label,
|
||||
lower: option.lower,
|
||||
upper: option.upper,
|
||||
probability,
|
||||
weightedValue: value * probability,
|
||||
});
|
||||
});
|
||||
|
||||
return [...grouped.entries()]
|
||||
.map(([key, bucket]) => ({
|
||||
key,
|
||||
label: bucket.label,
|
||||
value:
|
||||
bucket.probability > 0
|
||||
? roundToOneDecimal(bucket.weightedValue / bucket.probability)
|
||||
: roundToOneDecimal((bucket.lower + bucket.upper) / 2),
|
||||
lower: bucket.lower,
|
||||
upper: bucket.upper,
|
||||
probability: roundProbability(bucket.probability),
|
||||
}))
|
||||
.sort((a, b) => a.lower - b.lower || a.upper - b.upper);
|
||||
}
|
||||
|
||||
function weightedProbabilityMu(buckets: ModelSummaryProbabilityBucket[]) {
|
||||
const totalProbability = buckets.reduce((sum, bucket) => sum + bucket.probability, 0);
|
||||
if (totalProbability <= 0) return null;
|
||||
const weightedValue = buckets.reduce(
|
||||
(sum, bucket) => sum + bucket.value * bucket.probability,
|
||||
0,
|
||||
);
|
||||
return roundToOneDecimal(weightedValue / totalProbability);
|
||||
}
|
||||
|
||||
function normalizeProbability(value: unknown) {
|
||||
const numericValue = finiteNumber(value);
|
||||
if (numericValue == null) return null;
|
||||
return numericValue > 1 ? numericValue / 100 : numericValue;
|
||||
}
|
||||
|
||||
function marketBucketLabel(bucket: Record<string, unknown>, tempSymbol: string) {
|
||||
const textLabel = String(bucket.label || bucket.bucket || bucket.range || "").trim();
|
||||
if (textLabel) return textLabel;
|
||||
const lower = finiteNumber(bucket.lower);
|
||||
const upper = finiteNumber(bucket.upper);
|
||||
if (lower != null && upper != null && upper > lower) {
|
||||
return probabilityBucketLabel(lower, upper, String(bucket.unit || tempSymbol || "°C"));
|
||||
}
|
||||
const value = finiteNumber(bucket.value ?? bucket.temp ?? bucket.temperature);
|
||||
return value == null ? "—" : `${formatBucketBound(value)}${bucket.unit || tempSymbol || "°C"}`;
|
||||
}
|
||||
|
||||
function buildMarketMatches(row: ScanOpportunityRow): ModelSummaryMarketMatch[] {
|
||||
const marketRow = row as ScanOpportunityRow & {
|
||||
all_buckets?: Array<Record<string, unknown>> | null;
|
||||
top_buckets?: Array<Record<string, unknown>> | null;
|
||||
};
|
||||
const sourceBuckets = (
|
||||
Array.isArray(marketRow.all_buckets) && marketRow.all_buckets.length
|
||||
? marketRow.all_buckets
|
||||
: Array.isArray(marketRow.top_buckets)
|
||||
? marketRow.top_buckets
|
||||
: []
|
||||
) as Array<Record<string, unknown>>;
|
||||
const tempSymbol = row.temp_symbol || "°C";
|
||||
|
||||
return sourceBuckets
|
||||
.map((bucket, index) => {
|
||||
const label = marketBucketLabel(bucket, tempSymbol);
|
||||
const modelProbability = normalizeProbability(bucket.model_probability ?? bucket.probability);
|
||||
return {
|
||||
key: `${label}-${index}`,
|
||||
label,
|
||||
modelProbability,
|
||||
marketUrl: typeof bucket.market_url === "string" ? bucket.market_url : null,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return (b.modelProbability ?? -1) - (a.modelProbability ?? -1);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeCityKey(row: ScanOpportunityRow, index: number) {
|
||||
const rawKey = row.city || row.city_display_name || row.display_name || `row-${index}`;
|
||||
return String(rawKey).trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeLocalTime(value: unknown) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) return "";
|
||||
const match = text.match(/(\d{1,2}):(\d{2})/);
|
||||
if (!match) return text;
|
||||
return `${match[1].padStart(2, "0")}:${match[2]}`;
|
||||
}
|
||||
|
||||
function resolveRegion(row: ScanOpportunityRow, isEn: boolean) {
|
||||
const configuredRegionKey = getCityRegion(row);
|
||||
const configuredRegion = configuredRegionKey
|
||||
? REGIONS.find((region) => region.key === configuredRegionKey)
|
||||
: null;
|
||||
if (configuredRegion) {
|
||||
return {
|
||||
label: isEn ? configuredRegion.labelEn : configuredRegion.labelZh,
|
||||
labelEn: configuredRegion.labelEn,
|
||||
labelZh: configuredRegion.labelZh,
|
||||
sort: configuredRegion.sort,
|
||||
};
|
||||
}
|
||||
|
||||
const labelEn = row.trading_region_label || row.trading_region_label_zh || "—";
|
||||
const labelZh = row.trading_region_label_zh || row.trading_region_label || "—";
|
||||
return {
|
||||
label: isEn ? labelEn : labelZh,
|
||||
labelEn,
|
||||
labelZh,
|
||||
sort: finiteNumber(row.trading_region_sort) ?? 999,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatModelSummaryTemp(value: number | null | undefined, symbol = "°C") {
|
||||
const numericValue = finiteNumber(value);
|
||||
if (numericValue == null) return "—";
|
||||
return `${numericValue.toFixed(1)}${symbol || "°C"}`;
|
||||
}
|
||||
|
||||
export function formatModelSummaryProbability(value: number | null | undefined) {
|
||||
const numericValue = finiteNumber(value);
|
||||
if (numericValue == null) return "—";
|
||||
return `${Math.round((numericValue > 1 ? numericValue / 100 : numericValue) * 100)}%`;
|
||||
}
|
||||
|
||||
export function formatModelSummaryLocalTime(
|
||||
row: Pick<ModelSummaryRow, "localTime" | "timezoneOffsetSeconds">,
|
||||
nowMs: number | null | undefined = Date.now(),
|
||||
) {
|
||||
const offsetSeconds = finiteNumber(row.timezoneOffsetSeconds);
|
||||
const timestampMs = finiteNumber(nowMs);
|
||||
if (offsetSeconds == null || timestampMs == null) return row.localTime || "—";
|
||||
const localDate = new Date(timestampMs + offsetSeconds * 1000);
|
||||
const hours = String(localDate.getUTCHours()).padStart(2, "0");
|
||||
const minutes = String(localDate.getUTCMinutes()).padStart(2, "0");
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
export function buildModelSummaryRows(
|
||||
rows: ScanOpportunityRow[],
|
||||
isEn: boolean,
|
||||
): ModelSummaryRow[] {
|
||||
const byCity = new Map<string, ModelSummaryRow>();
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
const cityKey = normalizeCityKey(row, index);
|
||||
if (byCity.has(cityKey)) return;
|
||||
|
||||
const cityName = row.city_display_name || row.display_name || row.city || "—";
|
||||
const region = resolveRegion(row, isEn);
|
||||
const rawModelSources = row.model_cluster_sources || {};
|
||||
const models = MODEL_SUMMARY_MODEL_COLUMNS.reduce(
|
||||
(acc, column) => {
|
||||
acc[column.key] = finiteNumber(rawModelSources[column.key]);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<ModelSummaryColumnKey, number | null>,
|
||||
);
|
||||
const modelValues = MODEL_SUMMARY_MODEL_COLUMNS.map((column) => models[column.key]).filter(
|
||||
(value): value is number => value != null,
|
||||
);
|
||||
const modelSearchText = MODEL_SUMMARY_MODEL_COLUMNS.filter(
|
||||
(column) => models[column.key] != null,
|
||||
)
|
||||
.map((column) => column.label)
|
||||
.join(" ");
|
||||
const probabilityBuckets = buildProbabilityBuckets(row);
|
||||
const probabilityBucketMap = Object.fromEntries(
|
||||
probabilityBuckets.map((bucket) => [bucket.key, bucket]),
|
||||
);
|
||||
const topProbabilityBucket =
|
||||
probabilityBuckets.length > 0
|
||||
? probabilityBuckets.reduce((best, bucket) =>
|
||||
bucket.probability > best.probability ? bucket : best,
|
||||
)
|
||||
: null;
|
||||
const probabilitySearchText = probabilityBuckets
|
||||
.map((bucket) => `${bucket.label} ${formatModelSummaryProbability(bucket.probability)}`)
|
||||
.join(" ");
|
||||
const marketMatches = buildMarketMatches(row);
|
||||
const marketSearchText = marketMatches
|
||||
.map(
|
||||
(match) =>
|
||||
`${match.label} ${formatModelSummaryProbability(match.modelProbability)}`,
|
||||
)
|
||||
.join(" ");
|
||||
|
||||
byCity.set(cityKey, {
|
||||
cityKey,
|
||||
cityName,
|
||||
regionLabel: region.label,
|
||||
regionLabelZh: region.labelZh,
|
||||
regionSort: region.sort,
|
||||
tempSymbol: row.temp_symbol || "°C",
|
||||
localTime: normalizeLocalTime(row.local_time),
|
||||
timezoneOffsetSeconds: finiteNumber(row.tz_offset_seconds),
|
||||
debPrediction: finiteNumber(row.deb_prediction),
|
||||
models,
|
||||
modelMedian: median(modelValues),
|
||||
modelSpread: spread(modelValues),
|
||||
probabilityBuckets,
|
||||
probabilityBucketMap,
|
||||
gaussianMu: weightedProbabilityMu(probabilityBuckets),
|
||||
probabilityEngine: row.probability_engine || (probabilityBuckets.length ? "legacy" : null),
|
||||
topProbabilityBucketKey: topProbabilityBucket?.key || null,
|
||||
marketMatches,
|
||||
searchText:
|
||||
`${cityName} ${row.city || ""} ${region.labelEn} ${region.labelZh} ${modelSearchText} ${probabilitySearchText} ${marketSearchText}`.toLowerCase(),
|
||||
});
|
||||
});
|
||||
|
||||
return [...byCity.values()].sort((a, b) => {
|
||||
if (a.regionSort !== b.regionSort) return a.regionSort - b.regionSort;
|
||||
return a.cityName.localeCompare(b.cityName, isEn ? "en" : "zh-CN", {
|
||||
sensitivity: "base",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function filterModelSummaryRows(
|
||||
rows: ModelSummaryRow[],
|
||||
filters: ModelSummaryFilters,
|
||||
): ModelSummaryRow[] {
|
||||
const query = filters.query.trim().toLowerCase();
|
||||
|
||||
return rows.filter((row) => {
|
||||
if (query && !row.searchText.includes(query)) return false;
|
||||
if (filters.debOnly && row.debPrediction == null) return false;
|
||||
if (
|
||||
filters.wideSpreadOnly &&
|
||||
(row.modelSpread == null || row.modelSpread < WIDE_SPREAD_THRESHOLD)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function hasModelSummaryForecastData(rows: ModelSummaryRow[]) {
|
||||
return rows.some((row) => {
|
||||
if (row.debPrediction != null) return true;
|
||||
return MODEL_SUMMARY_MODEL_COLUMNS.some((column) => row.models[column.key] != null);
|
||||
});
|
||||
}
|
||||
@@ -293,6 +293,29 @@ export const opsApi = {
|
||||
};
|
||||
}>("/api/ops/training/accuracy");
|
||||
},
|
||||
marketOpportunities(params: Record<string, string | number | boolean | undefined> = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === "") return;
|
||||
qs.set(key, String(value));
|
||||
});
|
||||
const suffix = qs.toString() ? `?${qs.toString()}` : "";
|
||||
return opsFetch<{
|
||||
generated_at?: string;
|
||||
filters?: Record<string, unknown>;
|
||||
summary?: {
|
||||
opportunity_count?: number;
|
||||
positive_edge_count?: number;
|
||||
min_price?: number | null;
|
||||
max_edge?: number | null;
|
||||
quote_status?: string;
|
||||
scanned_city_count?: number;
|
||||
matched_event_count?: number;
|
||||
error?: string | null;
|
||||
};
|
||||
rows?: Array<Record<string, unknown>>;
|
||||
}>(`/api/ops/market-opportunities${suffix}`);
|
||||
},
|
||||
telegramAudit() {
|
||||
return opsFetch<{
|
||||
anomalies: Array<{
|
||||
|
||||
+145
-8
@@ -4,6 +4,7 @@ aiohappyeyeballs==2.6.2
|
||||
# via aiohttp
|
||||
aiohttp==3.14.1
|
||||
# via
|
||||
# gcsfs
|
||||
# pytelegrambotapi
|
||||
# web3
|
||||
aiosignal==1.4.0
|
||||
@@ -28,6 +29,8 @@ certifi==2026.5.20
|
||||
# httpx
|
||||
# netcdf4
|
||||
# requests
|
||||
cffi==2.0.0 ; platform_python_implementation != 'PyPy'
|
||||
# via cryptography
|
||||
cftime==1.6.5
|
||||
# via netcdf4
|
||||
charset-normalizer==3.4.7
|
||||
@@ -40,8 +43,14 @@ colorama==0.4.6 ; sys_platform == 'win32'
|
||||
# via
|
||||
# click
|
||||
# loguru
|
||||
cryptography==49.0.0
|
||||
# via google-auth
|
||||
cytoolz==1.1.0 ; implementation_name == 'cpython'
|
||||
# via eth-utils
|
||||
decorator==5.3.1
|
||||
# via gcsfs
|
||||
donfig==0.8.1.post1
|
||||
# via zarr
|
||||
eth-abi==5.2.0
|
||||
# via
|
||||
# eth-account
|
||||
@@ -81,6 +90,58 @@ frozenlist==1.8.0
|
||||
# via
|
||||
# aiohttp
|
||||
# aiosignal
|
||||
fsspec==2026.6.0
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# gcsfs
|
||||
gcsfs==2026.6.0
|
||||
# via -r requirements.txt
|
||||
google-api-core==2.31.0
|
||||
# via
|
||||
# google-cloud-core
|
||||
# google-cloud-storage
|
||||
# google-cloud-storage-control
|
||||
google-auth==2.55.1
|
||||
# via
|
||||
# gcsfs
|
||||
# google-api-core
|
||||
# google-auth-oauthlib
|
||||
# google-cloud-core
|
||||
# google-cloud-storage
|
||||
# google-cloud-storage-control
|
||||
google-auth-oauthlib==1.4.0
|
||||
# via gcsfs
|
||||
google-cloud-core==2.6.0
|
||||
# via google-cloud-storage
|
||||
google-cloud-storage==3.12.0
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# gcsfs
|
||||
google-cloud-storage-control==1.12.0
|
||||
# via gcsfs
|
||||
google-crc32c==1.8.0
|
||||
# via
|
||||
# google-cloud-storage
|
||||
# google-resumable-media
|
||||
# zarr
|
||||
google-resumable-media==2.10.0
|
||||
# via google-cloud-storage
|
||||
googleapis-common-protos==1.75.0
|
||||
# via
|
||||
# google-api-core
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
grpc-google-iam-v1==0.14.4
|
||||
# via google-cloud-storage-control
|
||||
grpcio==1.81.1
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-storage-control
|
||||
# googleapis-common-protos
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
grpcio-status==1.81.1
|
||||
# via google-api-core
|
||||
h11==0.16.0
|
||||
# via
|
||||
# httpcore
|
||||
@@ -100,33 +161,72 @@ idna==3.18
|
||||
# httpx
|
||||
# requests
|
||||
# yarl
|
||||
joblib==1.5.3
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# scikit-learn
|
||||
lightgbm==4.6.0
|
||||
# via -r requirements.txt
|
||||
loguru==0.7.3
|
||||
# via -r requirements.txt
|
||||
multidict==6.7.1
|
||||
# via
|
||||
# aiohttp
|
||||
# yarl
|
||||
narwhals==2.23.0
|
||||
# via scikit-learn
|
||||
netcdf4==1.7.4
|
||||
# via -r requirements.txt
|
||||
numcodecs==0.16.5
|
||||
# via zarr
|
||||
numpy==2.4.6
|
||||
|
||||
torch
|
||||
typing_extensions==4.15.0
|
||||
# via torch
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# cftime
|
||||
# lightgbm
|
||||
# netcdf4
|
||||
# numcodecs
|
||||
# pandas
|
||||
# scikit-learn
|
||||
# scipy
|
||||
# xarray
|
||||
# zarr
|
||||
oauthlib==3.3.1
|
||||
# via requests-oauthlib
|
||||
packaging==26.2
|
||||
# via
|
||||
# xarray
|
||||
# zarr
|
||||
pandas==3.0.3
|
||||
# via xarray
|
||||
parsimonious==0.10.0
|
||||
# via eth-abi
|
||||
propcache==0.5.2
|
||||
# via
|
||||
# aiohttp
|
||||
# yarl
|
||||
proto-plus==1.28.0
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-storage-control
|
||||
protobuf==7.35.1
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-storage-control
|
||||
# googleapis-common-protos
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
# proto-plus
|
||||
pyaes==1.6.1
|
||||
# via telethon
|
||||
pyasn1==0.6.3
|
||||
# via rsa
|
||||
# via
|
||||
# pyasn1-modules
|
||||
# rsa
|
||||
pyasn1-modules==0.4.2
|
||||
# via google-auth
|
||||
pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
|
||||
# via cffi
|
||||
pycryptodome==3.23.0
|
||||
# via
|
||||
# eth-hash
|
||||
@@ -141,6 +241,8 @@ pydantic-core==2.46.4
|
||||
# via pydantic
|
||||
pytelegrambotapi==4.34.0
|
||||
# via -r requirements.txt
|
||||
python-dateutil==2.9.0.post0
|
||||
# via pandas
|
||||
python-dotenv==1.2.2
|
||||
# via -r requirements.txt
|
||||
pyunormalize==17.0.0
|
||||
@@ -148,7 +250,9 @@ pyunormalize==17.0.0
|
||||
pywin32==312 ; sys_platform == 'win32'
|
||||
# via web3
|
||||
pyyaml==6.0.3
|
||||
# via -r requirements.txt
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# donfig
|
||||
redis==6.4.0
|
||||
# via -r requirements.txt
|
||||
regex==2026.5.9
|
||||
@@ -156,18 +260,40 @@ regex==2026.5.9
|
||||
requests==2.34.2
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# gcsfs
|
||||
# google-api-core
|
||||
# google-cloud-storage
|
||||
# pytelegrambotapi
|
||||
# requests-oauthlib
|
||||
# web3
|
||||
requests-oauthlib==2.0.0
|
||||
# via google-auth-oauthlib
|
||||
rlp==4.1.0
|
||||
# via
|
||||
# eth-account
|
||||
# eth-rlp
|
||||
rsa==4.9.1
|
||||
# via telethon
|
||||
scikit-learn==1.9.0
|
||||
# via -r requirements.txt
|
||||
scipy==1.17.1 ; python_full_version < '3.12'
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# lightgbm
|
||||
# scikit-learn
|
||||
scipy==1.18.0 ; python_full_version >= '3.12'
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# lightgbm
|
||||
# scikit-learn
|
||||
six==1.17.0
|
||||
# via python-dateutil
|
||||
starlette==1.3.1
|
||||
# via fastapi
|
||||
telethon==1.43.2
|
||||
# via -r requirements.txt
|
||||
threadpoolctl==3.6.0
|
||||
# via scikit-learn
|
||||
toolz==1.1.0 ; implementation_name == 'cpython' or implementation_name == 'pypy'
|
||||
# via
|
||||
# cytoolz
|
||||
@@ -181,17 +307,22 @@ typing-extensions==4.15.0
|
||||
# anyio
|
||||
# eth-typing
|
||||
# fastapi
|
||||
# grpcio
|
||||
# numcodecs
|
||||
# pydantic
|
||||
# pydantic-core
|
||||
# starlette
|
||||
# typing-inspection
|
||||
# web3
|
||||
# zarr
|
||||
typing-inspection==0.4.2
|
||||
# via
|
||||
# fastapi
|
||||
# pydantic
|
||||
tzdata==2026.2 ; sys_platform == 'win32'
|
||||
# via -r requirements.txt
|
||||
tzdata==2026.2 ; sys_platform == 'emscripten' or sys_platform == 'win32'
|
||||
# via
|
||||
# -r requirements.txt
|
||||
# pandas
|
||||
urllib3==2.7.0
|
||||
# via
|
||||
# requests
|
||||
@@ -206,5 +337,11 @@ websockets==15.0.1
|
||||
# web3
|
||||
win32-setctime==1.2.0 ; sys_platform == 'win32'
|
||||
# via loguru
|
||||
xarray==2026.4.0
|
||||
# via -r requirements.txt
|
||||
yarl==1.24.2
|
||||
# via aiohttp
|
||||
zarr==3.1.6 ; python_full_version < '3.12'
|
||||
# via -r requirements.txt
|
||||
zarr==3.2.1 ; python_full_version >= '3.12'
|
||||
# via -r requirements.txt
|
||||
|
||||
@@ -7,6 +7,15 @@ python-dotenv>=1.1,<2
|
||||
PyYAML>=6.0,<7
|
||||
netCDF4>=1.7,<2
|
||||
numpy>=2.2,<3
|
||||
scipy>=1.10,<2
|
||||
scikit-learn>=1.4,<2
|
||||
lightgbm>=4.6,<5
|
||||
joblib>=1.3,<2
|
||||
xarray>=2024.7,<2027
|
||||
zarr>=2.18,<4
|
||||
fsspec>=2024.10,<2027
|
||||
gcsfs>=2024.10,<2027
|
||||
google-cloud-storage>=2.18,<4
|
||||
web3>=7.13,<8
|
||||
fastapi>=0.115,<1
|
||||
uvicorn>=0.34,<1
|
||||
|
||||
@@ -64,8 +64,7 @@ SCAN_EXPRESSION = """(
|
||||
http.host eq "polyweather.top"
|
||||
and http.request.method in {"GET" "HEAD"}
|
||||
and (
|
||||
http.request.uri.path eq "/api/scan/terminal"
|
||||
or http.request.uri.path eq "/api/system/status"
|
||||
http.request.uri.path eq "/api/system/status"
|
||||
)
|
||||
)"""
|
||||
|
||||
@@ -135,7 +134,7 @@ def build_managed_cache_rules() -> List[Dict[str, Any]]:
|
||||
_cache_rule("pages", "PolyWeather: cache public pages for ten minutes", PUBLIC_PAGES_EXPRESSION, 600),
|
||||
_cache_rule("cities", "PolyWeather: cache the public city list for five minutes", CITIES_EXPRESSION, 300),
|
||||
_cache_rule("city_detail", "PolyWeather: cache public city detail when the origin allows it", CITY_DETAIL_EXPRESSION),
|
||||
_cache_rule("scan", "PolyWeather: cache scan and system status when the origin allows it", SCAN_EXPRESSION),
|
||||
_cache_rule("system_status", "PolyWeather: cache system status when the origin allows it", SCAN_EXPRESSION),
|
||||
{
|
||||
"ref": f"{MANAGED_RULE_REF_PREFIX}bypass",
|
||||
"description": "PolyWeather: bypass backend, sensitive, realtime, and force-refresh requests",
|
||||
|
||||
@@ -253,7 +253,6 @@ main() {
|
||||
|
||||
check_cached_endpoint "/api/history/ankara" "history"
|
||||
check_if_none_match_304 "/api/history/ankara" "history"
|
||||
check_cloudflare_cache_hit "/api/scan/terminal?limit=1" "scan terminal edge cache"
|
||||
|
||||
print_line ""
|
||||
if [ "$FAIL_COUNT" -gt 0 ]; then
|
||||
|
||||
@@ -110,6 +110,7 @@ def _deb_model_priority(model_name: str) -> int:
|
||||
"hko": 45,
|
||||
"lgbm": 50,
|
||||
"openmeteo": 15,
|
||||
"weathernext2": 30,
|
||||
}.get(normalized, 10)
|
||||
|
||||
|
||||
@@ -880,6 +881,7 @@ def update_daily_record(
|
||||
shadow_probabilities=None,
|
||||
calibration_summary=None,
|
||||
hourly_error=None,
|
||||
actual_is_final=True,
|
||||
):
|
||||
"""
|
||||
保存/更新某城市某天的各个模型预报与最终实测值
|
||||
@@ -986,7 +988,7 @@ def update_daily_record(
|
||||
|
||||
next_mu = round(mu, 2) if mu is not None else None
|
||||
if (
|
||||
old_actual == actual_high
|
||||
old_actual == (actual_high if actual_is_final else old_actual)
|
||||
and old_forecasts == merged_forecasts
|
||||
and (deb_prediction is None or old_deb == deb_prediction)
|
||||
and (mu is None or old_mu == next_mu)
|
||||
@@ -1007,15 +1009,21 @@ def update_daily_record(
|
||||
):
|
||||
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
|
||||
# Only final settlement truth may update actual_high. Intraday max_so_far is
|
||||
# useful context, but treating it as settled truth poisons DEB training.
|
||||
next_actual_high = old_actual
|
||||
if actual_is_final:
|
||||
next_actual_high = actual_high
|
||||
# actual_high 应该是日内最高温,理论上不应下降;防止异常写入覆盖已确认高值
|
||||
if old_actual is not None and actual_high is not None:
|
||||
try:
|
||||
next_actual_high = max(float(old_actual), float(actual_high))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
existing["forecasts"] = merged_forecasts
|
||||
existing["actual_high"] = actual_high
|
||||
if actual_is_final or next_actual_high is not None:
|
||||
existing["actual_high"] = next_actual_high
|
||||
if deb_prediction is not None:
|
||||
existing["deb_prediction"] = deb_prediction
|
||||
if mu is not None:
|
||||
@@ -1031,16 +1039,16 @@ def update_daily_record(
|
||||
if next_hourly_error is not None:
|
||||
existing["hourly_error"] = next_hourly_error
|
||||
|
||||
if actual_high is not None:
|
||||
if actual_is_final and next_actual_high is not None:
|
||||
try:
|
||||
_persist_truth_record(
|
||||
city_name,
|
||||
date_str,
|
||||
float(actual_high),
|
||||
float(next_actual_high),
|
||||
updated_by="runtime:update_daily_record",
|
||||
reason="update_daily_record",
|
||||
source_payload={
|
||||
"actual_high": actual_high,
|
||||
"actual_high": next_actual_high,
|
||||
"deb_prediction": deb_prediction,
|
||||
"mu": next_mu,
|
||||
},
|
||||
@@ -1161,8 +1169,12 @@ def calculate_dynamic_weight_components(
|
||||
sorted_dates = sorted(city_data.keys(), reverse=True)
|
||||
today_str = datetime.now().strftime("%Y-%m-%d")
|
||||
available_days = sum(
|
||||
1 for d in sorted_dates
|
||||
if d != today_str and city_data[d].get("actual_high") is not None
|
||||
1
|
||||
for d in sorted_dates
|
||||
if d != today_str
|
||||
and city_data[d].get("actual_high") is not None
|
||||
and isinstance(city_data[d].get("forecasts"), dict)
|
||||
and any(v is not None for v in city_data[d].get("forecasts", {}).values())
|
||||
)
|
||||
|
||||
# ── 改进3: 自适应 lookback — 数据多的城市用更多历史 ──
|
||||
@@ -1187,6 +1199,7 @@ def calculate_dynamic_weight_components(
|
||||
if actual is None:
|
||||
continue
|
||||
|
||||
usable_day = False
|
||||
for model in forecasts.keys():
|
||||
if model in past_forecasts and past_forecasts[model] is not None:
|
||||
try:
|
||||
@@ -1194,6 +1207,7 @@ def calculate_dynamic_weight_components(
|
||||
av = float(actual)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
usable_day = True
|
||||
# Track signed error for bias
|
||||
model_biases[model] += (pv - av) # positive = model overpredicts
|
||||
bias_samples[model] += 1
|
||||
@@ -1208,6 +1222,8 @@ def calculate_dynamic_weight_components(
|
||||
decay_weight = decay_factor ** days_used
|
||||
errors[model].append((blended_error, decay_weight))
|
||||
|
||||
if not usable_day:
|
||||
continue
|
||||
days_used += 1
|
||||
if days_used >= effective_lookback:
|
||||
break
|
||||
|
||||
+216
-10
@@ -20,6 +20,7 @@ from src.analysis.deb_hourly_consensus import build_deb_hourly_consensus_path
|
||||
from src.analysis.settlement_rounding import apply_city_settlement, is_exact_settlement_city
|
||||
from src.data_collection.city_registry import CITY_REGISTRY
|
||||
from src.data_collection.city_risk_profiles import get_city_risk_profile
|
||||
from src.data_collection.multi_model_freshness import multi_model_forecasts_for_local_date
|
||||
|
||||
SETTLEMENT_SOURCE_LABELS = {
|
||||
"metar": "METAR",
|
||||
@@ -49,6 +50,49 @@ def _sf(v):
|
||||
return None
|
||||
|
||||
|
||||
def _weathernext2_probability_payload(
|
||||
weather_data: Dict[str, Any],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
source = weather_data.get("weathernext2")
|
||||
if not isinstance(source, dict):
|
||||
return None
|
||||
raw_buckets = source.get("buckets")
|
||||
if not isinstance(raw_buckets, list) or not raw_buckets:
|
||||
return None
|
||||
|
||||
buckets = []
|
||||
for bucket in raw_buckets:
|
||||
if not isinstance(bucket, dict):
|
||||
continue
|
||||
probability = _sf(bucket.get("probability"))
|
||||
if probability is None or probability <= 0:
|
||||
continue
|
||||
copied = dict(bucket)
|
||||
copied["probability"] = round(probability, 3)
|
||||
buckets.append(copied)
|
||||
if not buckets:
|
||||
return None
|
||||
|
||||
summary = source.get("summary") if isinstance(source.get("summary"), dict) else {}
|
||||
mu = _sf(summary.get("median"))
|
||||
if mu is None:
|
||||
mu = _sf(summary.get("mean"))
|
||||
if mu is None:
|
||||
top_bucket = max(buckets, key=lambda item: _sf(item.get("probability")) or 0)
|
||||
mu = _sf(top_bucket.get("value"))
|
||||
|
||||
return {
|
||||
"engine": "weathernext2",
|
||||
"mu": mu,
|
||||
"probabilities": sorted(
|
||||
buckets,
|
||||
key=lambda item: _sf(item.get("probability")) or 0,
|
||||
reverse=True,
|
||||
)[:4],
|
||||
"probabilities_all": buckets,
|
||||
}
|
||||
|
||||
|
||||
def _median(values: List[float]) -> Optional[float]:
|
||||
if not values:
|
||||
return None
|
||||
@@ -59,6 +103,116 @@ def _median(values: List[float]) -> Optional[float]:
|
||||
return (sorted_values[mid - 1] + sorted_values[mid]) / 2.0
|
||||
|
||||
|
||||
def _build_deb_ensemble_signal(
|
||||
*,
|
||||
deb_prediction: Optional[float],
|
||||
ens_median: Optional[float],
|
||||
ens_p10: Optional[float],
|
||||
ens_p90: Optional[float],
|
||||
temp_symbol: str,
|
||||
) -> Dict[str, Any]:
|
||||
unavailable = {
|
||||
"available": False,
|
||||
"stance": "unavailable",
|
||||
"confidence_delta": 0.0,
|
||||
"median": ens_median,
|
||||
"p10": ens_p10,
|
||||
"p90": ens_p90,
|
||||
"spread": None,
|
||||
"deb_distance": None,
|
||||
"label_zh": "集合缺失",
|
||||
"label_en": "No ensemble",
|
||||
"reason_zh": "集合预报数据不完整,DEB 不做 ensemble 置信度校验。",
|
||||
"reason_en": "Ensemble data is incomplete, so DEB confidence is not ensemble-checked.",
|
||||
}
|
||||
if (
|
||||
deb_prediction is None
|
||||
or ens_median is None
|
||||
or ens_p10 is None
|
||||
or ens_p90 is None
|
||||
):
|
||||
return unavailable
|
||||
|
||||
low = min(ens_p10, ens_p90)
|
||||
high = max(ens_p10, ens_p90)
|
||||
spread = high - low
|
||||
deb_distance = abs(deb_prediction - ens_median)
|
||||
scale = 1.8 if "F" in str(temp_symbol).upper() else 1.0
|
||||
narrow_spread = 1.5 * scale
|
||||
wide_spread = 3.5 * scale
|
||||
aligned_gap = 0.7 * scale
|
||||
divergent_gap = 1.5 * scale
|
||||
|
||||
rounded_spread = round(spread, 1)
|
||||
rounded_distance = round(deb_distance, 1)
|
||||
unit = temp_symbol or "°"
|
||||
|
||||
if spread >= wide_spread or deb_distance >= max(divergent_gap, spread * 0.45):
|
||||
return {
|
||||
"available": True,
|
||||
"stance": "caution",
|
||||
"confidence_delta": -0.12,
|
||||
"median": round(ens_median, 1),
|
||||
"p10": round(low, 1),
|
||||
"p90": round(high, 1),
|
||||
"spread": rounded_spread,
|
||||
"deb_distance": rounded_distance,
|
||||
"label_zh": "集合分歧",
|
||||
"label_en": "Ensemble caution",
|
||||
"reason_zh": (
|
||||
f"集合区间宽度 {rounded_spread}{unit},DEB 距集合中位数 "
|
||||
f"{rounded_distance}{unit},该点位应降低置信度。"
|
||||
),
|
||||
"reason_en": (
|
||||
f"Ensemble spread is {rounded_spread}{unit}; DEB is "
|
||||
f"{rounded_distance}{unit} from the ensemble median, so confidence is reduced."
|
||||
),
|
||||
}
|
||||
|
||||
if spread <= narrow_spread and deb_distance <= aligned_gap:
|
||||
return {
|
||||
"available": True,
|
||||
"stance": "supporting",
|
||||
"confidence_delta": 0.08,
|
||||
"median": round(ens_median, 1),
|
||||
"p10": round(low, 1),
|
||||
"p90": round(high, 1),
|
||||
"spread": rounded_spread,
|
||||
"deb_distance": rounded_distance,
|
||||
"label_zh": "集合支撑",
|
||||
"label_en": "Ensemble support",
|
||||
"reason_zh": (
|
||||
f"集合区间较窄,DEB 仅距集合中位数 {rounded_distance}{unit},"
|
||||
"可作为置信度加分。"
|
||||
),
|
||||
"reason_en": (
|
||||
f"Ensemble spread is tight and DEB is only {rounded_distance}{unit} "
|
||||
"from the ensemble median, adding confidence."
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"available": True,
|
||||
"stance": "neutral",
|
||||
"confidence_delta": 0.0,
|
||||
"median": round(ens_median, 1),
|
||||
"p10": round(low, 1),
|
||||
"p90": round(high, 1),
|
||||
"spread": rounded_spread,
|
||||
"deb_distance": rounded_distance,
|
||||
"label_zh": "集合中性",
|
||||
"label_en": "Ensemble neutral",
|
||||
"reason_zh": (
|
||||
f"集合区间宽度 {rounded_spread}{unit},DEB 距集合中位数 "
|
||||
f"{rounded_distance}{unit},暂不调整置信度。"
|
||||
),
|
||||
"reason_en": (
|
||||
f"Ensemble spread is {rounded_spread}{unit}; DEB is "
|
||||
f"{rounded_distance}{unit} from the median, so confidence is unchanged."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _peak_hours_from_hourly_values(
|
||||
hourly_values: List[Tuple[str, float]],
|
||||
*,
|
||||
@@ -354,11 +508,6 @@ def analyze_weather_trend(
|
||||
if weather_data.get("cwa_forecast") is not None:
|
||||
current_forecasts["CWA(台气象)"] = _sf(weather_data.get("cwa_forecast"))
|
||||
|
||||
mm_forecasts = weather_data.get("multi_model", {}).get("forecasts", {})
|
||||
for m_name, m_val in mm_forecasts.items():
|
||||
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]
|
||||
forecast_high = max(forecast_highs) if forecast_highs else None
|
||||
forecast_median = (
|
||||
@@ -435,11 +584,30 @@ def analyze_weather_trend(
|
||||
current_forecasts["Open-Meteo"] = local_day_high
|
||||
except Exception:
|
||||
pass
|
||||
forecast_highs = [h for h in current_forecasts.values() if h is not None]
|
||||
forecast_high = max(forecast_highs) if forecast_highs else None
|
||||
forecast_median = (
|
||||
sorted(forecast_highs)[len(forecast_highs) // 2] if forecast_highs else None
|
||||
mm_forecasts = multi_model_forecasts_for_local_date(
|
||||
weather_data.get("multi_model", {}),
|
||||
local_date_str,
|
||||
)
|
||||
for m_name, m_val in mm_forecasts.items():
|
||||
if m_val is not None and not _is_excluded_model_name(m_name):
|
||||
current_forecasts[m_name] = _sf(m_val)
|
||||
weathernext2 = weather_data.get("weathernext2")
|
||||
if isinstance(weathernext2, dict):
|
||||
weathernext2_summary = (
|
||||
weathernext2.get("summary")
|
||||
if isinstance(weathernext2.get("summary"), dict)
|
||||
else {}
|
||||
)
|
||||
weathernext2_median = _sf(weathernext2_summary.get("median"))
|
||||
if weathernext2_median is None:
|
||||
weathernext2_median = _sf(weathernext2_summary.get("mean"))
|
||||
if weathernext2_median is not None:
|
||||
current_forecasts["WeatherNext 2"] = weathernext2_median
|
||||
forecast_highs = [h for h in current_forecasts.values() if h is not None]
|
||||
forecast_high = max(forecast_highs) if forecast_highs else None
|
||||
forecast_median = (
|
||||
sorted(forecast_highs)[len(forecast_highs) // 2] if forecast_highs else None
|
||||
)
|
||||
|
||||
# === DEB ===
|
||||
deb_prediction = None
|
||||
@@ -636,6 +804,13 @@ def analyze_weather_trend(
|
||||
ens_p90 = _sf(ensemble.get("p90"))
|
||||
ens_median = _sf(ensemble.get("median"))
|
||||
ens_data = {"p10": ens_p10, "p90": ens_p90, "median": ens_median}
|
||||
deb_ensemble_signal = _build_deb_ensemble_signal(
|
||||
deb_prediction=deb_prediction,
|
||||
ens_median=ens_median,
|
||||
ens_p10=ens_p10,
|
||||
ens_p90=ens_p90,
|
||||
temp_symbol=temp_symbol,
|
||||
)
|
||||
|
||||
sigma = None
|
||||
fallback_sigma = False
|
||||
@@ -648,6 +823,14 @@ def analyze_weather_trend(
|
||||
if not is_cooling:
|
||||
insights.append(msg1)
|
||||
ai_features.append(msg1)
|
||||
if deb_ensemble_signal.get("available") and deb_prediction is not None:
|
||||
ensemble_deb_msg = (
|
||||
f"🧬 {deb_ensemble_signal['label_zh']}: "
|
||||
f"{deb_ensemble_signal['reason_zh']}"
|
||||
)
|
||||
ai_features.append(ensemble_deb_msg)
|
||||
if deb_ensemble_signal.get("stance") == "caution":
|
||||
insights.append(ensemble_deb_msg)
|
||||
|
||||
if om_today is not None:
|
||||
if om_today > ens_p90 and (
|
||||
@@ -737,9 +920,12 @@ def analyze_weather_trend(
|
||||
# === Probability Engine ===
|
||||
probabilities: List[Dict[str, Any]] = []
|
||||
probabilities_all: List[Dict[str, Any]] = []
|
||||
probability_engine = "legacy"
|
||||
forecast_miss_deg = 0.0
|
||||
weathernext2_probs = _weathernext2_probability_payload(weather_data)
|
||||
|
||||
if is_dead_market:
|
||||
probability_engine = "dead_market"
|
||||
settled_wu = apply_city_settlement(city_name, max_so_far) if max_so_far is not None else 0
|
||||
dead_msg = (
|
||||
f"🎲 <b>结算预测</b>:已锁定 {settled_wu}{temp_symbol} "
|
||||
@@ -753,6 +939,24 @@ def analyze_weather_trend(
|
||||
{"value": settled_wu, "range": f"[{settled_wu-0.5}~{settled_wu+0.5})", "probability": 1.0}
|
||||
]
|
||||
probabilities_all = probabilities
|
||||
elif weathernext2_probs:
|
||||
if max_so_far is not None and forecast_median is not None:
|
||||
forecast_miss_deg = round(forecast_median - max_so_far, 1)
|
||||
probability_engine = "weathernext2"
|
||||
mu = weathernext2_probs.get("mu") or mu
|
||||
probabilities = weathernext2_probs.get("probabilities", [])
|
||||
probabilities_all = weathernext2_probs.get("probabilities_all", probabilities)
|
||||
prob_parts = []
|
||||
for bucket in probabilities[:4]:
|
||||
label = str(bucket.get("label") or bucket.get("range") or bucket.get("value") or "").strip()
|
||||
probability = _sf(bucket.get("probability"))
|
||||
if label and probability is not None:
|
||||
prob_parts.append(f"{label} {probability * 100:.0f}%")
|
||||
if prob_parts:
|
||||
mu_label = f"μ={mu:.1f}" if mu is not None else "μ=--"
|
||||
prob_str = " | ".join(prob_parts)
|
||||
insights.append(f"🎲 <b>WeatherNext 2 概率</b> ({mu_label}):{prob_str}")
|
||||
ai_features.append(f"🎲 WeatherNext 2 概率分布:{prob_str}")
|
||||
elif (ens_p10 is not None and ens_p90 is not None) or fallback_sigma:
|
||||
# Forecast miss magnitude
|
||||
if max_so_far is not None and forecast_median is not None:
|
||||
@@ -974,6 +1178,7 @@ def analyze_weather_trend(
|
||||
deb_prediction=_deb_to_save,
|
||||
mu=mu,
|
||||
probabilities=_prob_list,
|
||||
actual_is_final=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -988,7 +1193,7 @@ def analyze_weather_trend(
|
||||
"mu": mu,
|
||||
"probabilities": probabilities,
|
||||
"probabilities_all": probabilities_all or probabilities,
|
||||
"probability_engine": "legacy",
|
||||
"probability_engine": probability_engine,
|
||||
"trend_info": {
|
||||
"direction": trend_direction if 'trend_direction' in dir() else "unknown",
|
||||
"recent": recent_list,
|
||||
@@ -1007,6 +1212,7 @@ def analyze_weather_trend(
|
||||
"deb_bias_samples": deb_bias_samples,
|
||||
"deb_weights": deb_weights,
|
||||
"deb_quality": deb_quality,
|
||||
"deb_ensemble_signal": deb_ensemble_signal,
|
||||
"current_forecasts": current_forecasts,
|
||||
"ens_data": ens_data,
|
||||
"forecast_miss_deg": forecast_miss_deg,
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
from src.data_collection.weathernext2_sources import build_weathernext2_city_probability
|
||||
|
||||
|
||||
QUANTILES = {"q10": 0.10, "q50": 0.50, "q90": 0.90}
|
||||
FEATURE_NAMES = [
|
||||
"city_code",
|
||||
"wn2_mean",
|
||||
"wn2_median",
|
||||
"wn2_p10",
|
||||
"wn2_p25",
|
||||
"wn2_p75",
|
||||
"wn2_p90",
|
||||
"wn2_spread",
|
||||
"deb_prediction_c",
|
||||
"model_median_c",
|
||||
"model_spread",
|
||||
"current_max_so_far_c",
|
||||
"local_hour",
|
||||
"month",
|
||||
"day_of_year",
|
||||
"observation_progress",
|
||||
]
|
||||
|
||||
|
||||
def _sf(value: Any) -> Optional[float]:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if math.isfinite(parsed) else None
|
||||
|
||||
|
||||
def _date_parts(value: Any) -> tuple[float, float]:
|
||||
text = str(value or "").strip()
|
||||
try:
|
||||
parsed = time.strptime(text[:10], "%Y-%m-%d")
|
||||
return float(parsed.tm_mon), float(parsed.tm_yday)
|
||||
except Exception:
|
||||
return 0.0, 0.0
|
||||
|
||||
|
||||
def _summary_from_record(record: Dict[str, Any]) -> Dict[str, Any]:
|
||||
wn2 = record.get("weathernext2") if isinstance(record.get("weathernext2"), dict) else {}
|
||||
summary = wn2.get("summary") if isinstance(wn2.get("summary"), dict) else {}
|
||||
return summary if isinstance(summary, dict) else {}
|
||||
|
||||
|
||||
def _city_key(value: Any) -> str:
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
|
||||
def _build_city_index(records: Iterable[Dict[str, Any]]) -> Dict[str, int]:
|
||||
cities = sorted({_city_key(record.get("city")) for record in records if _city_key(record.get("city"))})
|
||||
return {city: idx for idx, city in enumerate(cities)}
|
||||
|
||||
|
||||
def _feature_row(record: Dict[str, Any], city_index: Dict[str, int]) -> Optional[list[float]]:
|
||||
summary = _summary_from_record(record)
|
||||
median = _sf(summary.get("median"))
|
||||
if median is None:
|
||||
return None
|
||||
target_date = record.get("target_date") or record.get("date")
|
||||
month, day_of_year = _date_parts(target_date)
|
||||
city = _city_key(record.get("city"))
|
||||
current = _sf(record.get("current_max_so_far_c"))
|
||||
progress = _sf(record.get("observation_progress"))
|
||||
if progress is None:
|
||||
local_hour = _sf(record.get("local_hour"))
|
||||
progress = min(max((local_hour or 0.0) / 24.0, 0.0), 1.0)
|
||||
|
||||
def fallback(name: str, default: float) -> float:
|
||||
parsed = _sf(summary.get(name))
|
||||
return default if parsed is None else parsed
|
||||
|
||||
return [
|
||||
float(city_index.get(city, -1)),
|
||||
fallback("mean", median),
|
||||
median,
|
||||
fallback("p10", median),
|
||||
fallback("p25", median),
|
||||
fallback("p75", median),
|
||||
fallback("p90", median),
|
||||
fallback("spread", 0.0),
|
||||
_sf(record.get("deb_prediction_c")) or median,
|
||||
_sf(record.get("model_median_c")) or median,
|
||||
_sf(record.get("model_spread")) or 0.0,
|
||||
current if current is not None else median,
|
||||
_sf(record.get("local_hour")) or 0.0,
|
||||
month,
|
||||
day_of_year,
|
||||
progress,
|
||||
]
|
||||
|
||||
|
||||
def _training_xy(
|
||||
records: Iterable[Dict[str, Any]],
|
||||
city_index: Dict[str, int],
|
||||
) -> tuple[list[list[float]], list[float], list[Dict[str, Any]]]:
|
||||
features: list[list[float]] = []
|
||||
residuals: list[float] = []
|
||||
kept: list[Dict[str, Any]] = []
|
||||
for record in records:
|
||||
summary = _summary_from_record(record)
|
||||
median = _sf(summary.get("median"))
|
||||
actual = _sf(record.get("actual_high_c", record.get("actual_high")))
|
||||
row = _feature_row(record, city_index)
|
||||
if median is None or actual is None or row is None:
|
||||
continue
|
||||
features.append(row)
|
||||
residuals.append(actual - median)
|
||||
kept.append(record)
|
||||
return features, residuals, kept
|
||||
|
||||
|
||||
def train_lightgbm_quantile_calibrator(
|
||||
records: Iterable[Dict[str, Any]],
|
||||
*,
|
||||
model_dir: os.PathLike[str] | str,
|
||||
min_global_samples: int = 150,
|
||||
min_city_samples: int = 5,
|
||||
) -> Dict[str, Any]:
|
||||
rows = [record for record in records if isinstance(record, dict)]
|
||||
city_index = _build_city_index(rows)
|
||||
features, residuals, kept = _training_xy(rows, city_index)
|
||||
if len(features) < int(min_global_samples):
|
||||
return {
|
||||
"trained": False,
|
||||
"reason": "insufficient_global_samples",
|
||||
"samples": len(features),
|
||||
}
|
||||
|
||||
city_counts: Dict[str, int] = {}
|
||||
for record in kept:
|
||||
city_counts[_city_key(record.get("city"))] = city_counts.get(_city_key(record.get("city")), 0) + 1
|
||||
if not any(count >= int(min_city_samples) for count in city_counts.values()):
|
||||
return {
|
||||
"trained": False,
|
||||
"reason": "insufficient_city_samples",
|
||||
"samples": len(features),
|
||||
}
|
||||
|
||||
try:
|
||||
import joblib # type: ignore
|
||||
from lightgbm import LGBMRegressor # type: ignore
|
||||
except Exception as exc:
|
||||
return {
|
||||
"trained": False,
|
||||
"reason": "missing_lightgbm",
|
||||
"samples": len(features),
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
target_dir = Path(model_dir)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
models = {}
|
||||
for key, alpha in QUANTILES.items():
|
||||
model = LGBMRegressor(
|
||||
objective="quantile",
|
||||
alpha=alpha,
|
||||
n_estimators=45,
|
||||
learning_rate=0.08,
|
||||
num_leaves=15,
|
||||
min_child_samples=5,
|
||||
random_state=42,
|
||||
n_jobs=2,
|
||||
verbosity=-1,
|
||||
)
|
||||
model.fit(features, residuals)
|
||||
models[key] = model
|
||||
joblib.dump(model, target_dir / f"{key}.pkl")
|
||||
|
||||
metadata = {
|
||||
"model_version": f"weathernext2_lightgbm_quantile_{int(time.time())}",
|
||||
"engine": "lightgbm_quantile",
|
||||
"samples": len(features),
|
||||
"feature_names": FEATURE_NAMES,
|
||||
"city_index": city_index,
|
||||
"city_counts": city_counts,
|
||||
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
}
|
||||
(target_dir / "metadata.json").write_text(
|
||||
json.dumps(metadata, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
ordered = True
|
||||
for feature in features[: min(len(features), 50)]:
|
||||
preds = sorted(float(models[key].predict([feature])[0]) for key in ("q10", "q50", "q90"))
|
||||
if preds[0] > preds[1] or preds[1] > preds[2]:
|
||||
ordered = False
|
||||
break
|
||||
return {
|
||||
"trained": True,
|
||||
"samples": len(features),
|
||||
"model_dir": str(target_dir),
|
||||
"model_version": metadata["model_version"],
|
||||
"validation": {"ordered_quantiles": ordered},
|
||||
}
|
||||
|
||||
|
||||
def _load_model_bundle(model_dir: os.PathLike[str] | str) -> Optional[Dict[str, Any]]:
|
||||
target_dir = Path(model_dir)
|
||||
metadata_path = target_dir / "metadata.json"
|
||||
if not metadata_path.is_file():
|
||||
return None
|
||||
try:
|
||||
import joblib # type: ignore
|
||||
|
||||
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
return {
|
||||
"metadata": metadata,
|
||||
"models": {
|
||||
key: joblib.load(target_dir / f"{key}.pkl")
|
||||
for key in QUANTILES
|
||||
},
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _predict_residual_quantiles(record: Dict[str, Any], bundle: Dict[str, Any]) -> Optional[Dict[str, float]]:
|
||||
metadata = bundle.get("metadata") or {}
|
||||
city_index = metadata.get("city_index") if isinstance(metadata.get("city_index"), dict) else {}
|
||||
feature = _feature_row(record, city_index)
|
||||
if feature is None:
|
||||
return None
|
||||
raw = {
|
||||
key: float(model.predict([feature])[0])
|
||||
for key, model in (bundle.get("models") or {}).items()
|
||||
}
|
||||
values = sorted([raw.get("q10", 0.0), raw.get("q50", 0.0), raw.get("q90", 0.0)])
|
||||
return {"q10": values[0], "q50": values[1], "q90": values[2]}
|
||||
|
||||
|
||||
def _calibrate_members(
|
||||
member_highs: Dict[str, Any],
|
||||
raw_summary: Dict[str, Any],
|
||||
residuals: Dict[str, float],
|
||||
) -> Dict[str, float]:
|
||||
raw_median = _sf(raw_summary.get("median"))
|
||||
raw_p10 = _sf(raw_summary.get("p10"))
|
||||
raw_p90 = _sf(raw_summary.get("p90"))
|
||||
if raw_median is None:
|
||||
return {}
|
||||
target_p10 = (raw_p10 if raw_p10 is not None else raw_median) + residuals["q10"]
|
||||
target_median = raw_median + residuals["q50"]
|
||||
target_p90 = (raw_p90 if raw_p90 is not None else raw_median) + residuals["q90"]
|
||||
|
||||
calibrated = {}
|
||||
for member_id, value in (member_highs or {}).items():
|
||||
parsed = _sf(value)
|
||||
if parsed is None:
|
||||
continue
|
||||
if parsed <= raw_median:
|
||||
raw_span = max(raw_median - (raw_p10 if raw_p10 is not None else raw_median), 0.1)
|
||||
target_span = max(target_median - target_p10, 0.1)
|
||||
adjusted = target_median - (raw_median - parsed) / raw_span * target_span
|
||||
else:
|
||||
raw_span = max((raw_p90 if raw_p90 is not None else raw_median) - raw_median, 0.1)
|
||||
target_span = max(target_p90 - target_median, 0.1)
|
||||
adjusted = target_median + (parsed - raw_median) / raw_span * target_span
|
||||
calibrated[str(member_id)] = round(adjusted, 1)
|
||||
return calibrated
|
||||
|
||||
|
||||
def apply_quantile_calibration_to_payload(
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
model_dir: os.PathLike[str] | str,
|
||||
) -> Dict[str, Any]:
|
||||
bundle = _load_model_bundle(model_dir)
|
||||
if not bundle:
|
||||
return dict(payload)
|
||||
raw_summary = payload.get("summary") if isinstance(payload.get("summary"), dict) else {}
|
||||
member_highs = payload.get("member_highs") if isinstance(payload.get("member_highs"), dict) else {}
|
||||
if not raw_summary or not member_highs:
|
||||
return dict(payload)
|
||||
|
||||
record = {
|
||||
"city": payload.get("city"),
|
||||
"target_date": payload.get("target_date"),
|
||||
"weathernext2": {"summary": raw_summary},
|
||||
}
|
||||
residuals = _predict_residual_quantiles(record, bundle)
|
||||
if residuals is None:
|
||||
return dict(payload)
|
||||
|
||||
calibrated_members = _calibrate_members(member_highs, raw_summary, residuals)
|
||||
if not calibrated_members:
|
||||
return dict(payload)
|
||||
|
||||
calibrated = build_weathernext2_city_probability(
|
||||
city=str(payload.get("city") or ""),
|
||||
member_highs=calibrated_members,
|
||||
temp_symbol=str(payload.get("temp_symbol") or "°C"),
|
||||
target_date=payload.get("target_date"),
|
||||
source_run=payload.get("source_run"),
|
||||
generated_at=payload.get("generated_at"),
|
||||
)
|
||||
metadata = bundle.get("metadata") or {}
|
||||
calibrated["calibration"] = {
|
||||
"engine": "lightgbm_quantile",
|
||||
"model_version": metadata.get("model_version"),
|
||||
"samples": metadata.get("samples"),
|
||||
"residual_quantiles": {key: round(value, 3) for key, value in residuals.items()},
|
||||
"raw_summary": raw_summary,
|
||||
"calibrated_summary": calibrated.get("summary"),
|
||||
}
|
||||
calibrated["raw_weathernext2"] = {
|
||||
"summary": raw_summary,
|
||||
"buckets": payload.get("buckets") or [],
|
||||
"top_bucket": payload.get("top_bucket"),
|
||||
}
|
||||
return calibrated
|
||||
@@ -2,6 +2,11 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from src.data_collection.multi_model_freshness import (
|
||||
multi_model_has_current_window,
|
||||
open_meteo_forecast_has_current_window,
|
||||
)
|
||||
|
||||
|
||||
def _open_meteo_cache_key(
|
||||
lat: float,
|
||||
@@ -46,8 +51,9 @@ def _read_open_meteo_bundle_from_cache(
|
||||
with collector._open_meteo_cache_lock:
|
||||
om_cached = collector._open_meteo_cache.get(om_key)
|
||||
|
||||
if om_cached and isinstance(om_cached.get("data"), dict):
|
||||
results["open-meteo"] = dict(om_cached["data"])
|
||||
om_data = om_cached.get("data") if isinstance(om_cached, dict) else None
|
||||
if isinstance(om_data, dict) and open_meteo_forecast_has_current_window(om_data):
|
||||
results["open-meteo"] = dict(om_data)
|
||||
if include_multi_model:
|
||||
mm_key = _multi_model_cache_key(
|
||||
collector,
|
||||
@@ -58,8 +64,9 @@ def _read_open_meteo_bundle_from_cache(
|
||||
)
|
||||
with collector._multi_model_cache_lock:
|
||||
mm_cached = collector._multi_model_cache.get(mm_key)
|
||||
if mm_cached and isinstance(mm_cached.get("data"), dict):
|
||||
results["multi_model"] = dict(mm_cached["data"])
|
||||
mm_data = mm_cached.get("data") if isinstance(mm_cached, dict) else None
|
||||
if isinstance(mm_data, dict) and multi_model_has_current_window(mm_data):
|
||||
results["multi_model"] = dict(mm_data)
|
||||
return results
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def _parse_date(value: Any) -> Optional[date]:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(text[:10]).date()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _numeric(value: Any) -> Optional[float]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _date_strings_from_hourly(multi_model: Dict[str, Any]) -> set[str]:
|
||||
dates = set()
|
||||
for raw_time in multi_model.get("hourly_times") or []:
|
||||
text = str(raw_time or "")
|
||||
if len(text) >= 10:
|
||||
dates.add(text[:10])
|
||||
return dates
|
||||
|
||||
|
||||
def _has_numeric_hourly_for_date(multi_model: Dict[str, Any], local_date: str) -> bool:
|
||||
times = multi_model.get("hourly_times") or []
|
||||
forecasts = multi_model.get("hourly_forecasts") or {}
|
||||
if not isinstance(forecasts, dict):
|
||||
return False
|
||||
for idx, raw_time in enumerate(times):
|
||||
if not str(raw_time or "").startswith(local_date):
|
||||
continue
|
||||
for values in forecasts.values():
|
||||
if isinstance(values, list) and idx < len(values) and _numeric(values[idx]) is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def multi_model_has_current_window(multi_model: Any, *, today: Optional[date] = None) -> bool:
|
||||
"""Return False when cached multi-model data is definitely older than today."""
|
||||
if not isinstance(multi_model, dict):
|
||||
return False
|
||||
today = today or datetime.now(timezone.utc).date()
|
||||
raw_date_values = []
|
||||
raw_date_values.extend(multi_model.get("dates") or [])
|
||||
daily = multi_model.get("daily_forecasts")
|
||||
if isinstance(daily, dict):
|
||||
raw_date_values.extend(daily.keys())
|
||||
raw_date_values.extend(_date_strings_from_hourly(multi_model))
|
||||
parsed_dates = [parsed for raw in raw_date_values for parsed in [_parse_date(raw)] if parsed]
|
||||
if not parsed_dates:
|
||||
return True
|
||||
return max(parsed_dates) >= today
|
||||
|
||||
|
||||
def open_meteo_forecast_has_current_window(
|
||||
forecast: Any,
|
||||
*,
|
||||
today: Optional[date] = None,
|
||||
) -> bool:
|
||||
"""Return False when cached Open-Meteo forecast data is definitely older than today."""
|
||||
if not isinstance(forecast, dict):
|
||||
return False
|
||||
today = today or datetime.now(timezone.utc).date()
|
||||
raw_date_values = []
|
||||
daily = forecast.get("daily") if isinstance(forecast.get("daily"), dict) else {}
|
||||
raw_date_values.extend(daily.get("time") or [])
|
||||
hourly = forecast.get("hourly") if isinstance(forecast.get("hourly"), dict) else {}
|
||||
raw_date_values.extend(hourly.get("time") or [])
|
||||
parsed_dates = [parsed for raw in raw_date_values for parsed in [_parse_date(raw)] if parsed]
|
||||
if not parsed_dates:
|
||||
return True
|
||||
return max(parsed_dates) >= today
|
||||
|
||||
|
||||
def multi_model_covers_local_date(multi_model: Any, local_date: str) -> bool:
|
||||
if not isinstance(multi_model, dict):
|
||||
return False
|
||||
wanted = str(local_date or "").strip()
|
||||
if not wanted:
|
||||
return bool(multi_model.get("forecasts"))
|
||||
|
||||
daily = multi_model.get("daily_forecasts") if isinstance(multi_model.get("daily_forecasts"), dict) else {}
|
||||
day_models = daily.get(wanted) if isinstance(daily.get(wanted), dict) else {}
|
||||
if any(_numeric(value) is not None for value in day_models.values()):
|
||||
return True
|
||||
|
||||
if _has_numeric_hourly_for_date(multi_model, wanted):
|
||||
return True
|
||||
|
||||
dates = [str(value) for value in (multi_model.get("dates") or [])]
|
||||
if dates:
|
||||
return wanted in dates
|
||||
|
||||
has_dated_payload = bool(daily) or bool(multi_model.get("hourly_times"))
|
||||
return bool(multi_model.get("forecasts")) and not has_dated_payload
|
||||
|
||||
|
||||
def multi_model_forecasts_for_local_date(multi_model: Any, local_date: str) -> Dict[str, float]:
|
||||
if not isinstance(multi_model, dict) or not multi_model_covers_local_date(multi_model, local_date):
|
||||
return {}
|
||||
wanted = str(local_date or "").strip()
|
||||
models: Dict[str, float] = {}
|
||||
|
||||
daily = multi_model.get("daily_forecasts") if isinstance(multi_model.get("daily_forecasts"), dict) else {}
|
||||
day_models = daily.get(wanted) if isinstance(daily.get(wanted), dict) else {}
|
||||
for model, value in day_models.items():
|
||||
parsed = _numeric(value)
|
||||
if parsed is not None:
|
||||
models[str(model)] = parsed
|
||||
|
||||
times = multi_model.get("hourly_times") or []
|
||||
hourly = multi_model.get("hourly_forecasts") if isinstance(multi_model.get("hourly_forecasts"), dict) else {}
|
||||
day_indexes = [
|
||||
idx
|
||||
for idx, raw_time in enumerate(times)
|
||||
if wanted and str(raw_time or "").startswith(wanted)
|
||||
]
|
||||
for model, values in hourly.items():
|
||||
if not isinstance(values, list):
|
||||
continue
|
||||
day_values = [
|
||||
parsed
|
||||
for idx in day_indexes
|
||||
if idx < len(values)
|
||||
for parsed in [_numeric(values[idx])]
|
||||
if parsed is not None
|
||||
]
|
||||
if day_values:
|
||||
current = models.get(str(model))
|
||||
models[str(model)] = max(day_values) if current is None else max(current, max(day_values))
|
||||
|
||||
if models:
|
||||
return models
|
||||
|
||||
forecasts = multi_model.get("forecasts") if isinstance(multi_model.get("forecasts"), dict) else {}
|
||||
for model, value in forecasts.items():
|
||||
parsed = _numeric(value)
|
||||
if parsed is not None:
|
||||
models[str(model)] = parsed
|
||||
return models
|
||||
@@ -6,6 +6,10 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from src.data_collection.multi_model_freshness import (
|
||||
multi_model_has_current_window,
|
||||
open_meteo_forecast_has_current_window,
|
||||
)
|
||||
from src.utils.metrics import record_source_call
|
||||
|
||||
|
||||
@@ -527,16 +531,18 @@ class NwsOpenMeteoSourceMixin:
|
||||
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):
|
||||
stale_data = stale.get("data") if isinstance(stale, dict) else None
|
||||
if isinstance(stale_data, dict) and open_meteo_forecast_has_current_window(stale_data):
|
||||
record_source_call("open_meteo", "forecast", "stale_cache", (time.perf_counter() - started) * 1000.0)
|
||||
return dict(stale["data"])
|
||||
return dict(stale_data)
|
||||
# Memory miss: force-reload from disk and retry once
|
||||
self._load_open_meteo_disk_cache()
|
||||
with self._open_meteo_cache_lock:
|
||||
stale2 = self._open_meteo_cache.get(cache_key)
|
||||
if stale2 and isinstance(stale2.get("data"), dict):
|
||||
stale2_data = stale2.get("data") if isinstance(stale2, dict) else None
|
||||
if isinstance(stale2_data, dict) and open_meteo_forecast_has_current_window(stale2_data):
|
||||
record_source_call("open_meteo", "forecast", "disk_fallback", (time.perf_counter() - started) * 1000.0)
|
||||
return dict(stale2["data"])
|
||||
return dict(stale2_data)
|
||||
record_source_call("open_meteo", "forecast", "cooldown_skip", (time.perf_counter() - started) * 1000.0)
|
||||
return None
|
||||
with self._open_meteo_cache_lock:
|
||||
@@ -546,6 +552,11 @@ class NwsOpenMeteoSourceMixin:
|
||||
and now_ts - float(cached.get("t", 0)) < self.open_meteo_cache_ttl_sec
|
||||
):
|
||||
cached_data = cached.get("data")
|
||||
if isinstance(cached_data, dict):
|
||||
if not open_meteo_forecast_has_current_window(cached_data):
|
||||
self._open_meteo_cache.pop(cache_key, None)
|
||||
record_source_call("open_meteo", "forecast", "expired_cache_skip", (time.perf_counter() - started) * 1000.0)
|
||||
cached_data = None
|
||||
if isinstance(cached_data, dict):
|
||||
record_source_call("open_meteo", "forecast", "cache_hit", (time.perf_counter() - started) * 1000.0)
|
||||
return dict(cached_data)
|
||||
@@ -671,8 +682,9 @@ class NwsOpenMeteoSourceMixin:
|
||||
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"])
|
||||
stale_data = stale.get("data") if isinstance(stale, dict) else None
|
||||
if isinstance(stale_data, dict) and open_meteo_forecast_has_current_window(stale_data):
|
||||
fallback = dict(stale_data)
|
||||
fallback["stale_cache"] = True
|
||||
record_source_call("open_meteo", "forecast", "stale_cache", (time.perf_counter() - started) * 1000.0)
|
||||
return fallback
|
||||
@@ -868,15 +880,17 @@ class NwsOpenMeteoSourceMixin:
|
||||
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):
|
||||
stale_data = stale.get("data") if isinstance(stale, dict) else None
|
||||
if isinstance(stale_data, dict) and multi_model_has_current_window(stale_data):
|
||||
record_source_call("open_meteo", "multi_model", "stale_cache", (time.perf_counter() - started) * 1000.0)
|
||||
return dict(stale["data"])
|
||||
return dict(stale_data)
|
||||
self._load_open_meteo_disk_cache()
|
||||
with self._multi_model_cache_lock:
|
||||
stale2 = self._multi_model_cache.get(cache_key)
|
||||
if stale2 and isinstance(stale2.get("data"), dict):
|
||||
stale2_data = stale2.get("data") if isinstance(stale2, dict) else None
|
||||
if isinstance(stale2_data, dict) and multi_model_has_current_window(stale2_data):
|
||||
record_source_call("open_meteo", "multi_model", "disk_fallback", (time.perf_counter() - started) * 1000.0)
|
||||
return dict(stale2["data"])
|
||||
return dict(stale2_data)
|
||||
record_source_call("open_meteo", "multi_model", "cooldown_skip", (time.perf_counter() - started) * 1000.0)
|
||||
return None
|
||||
|
||||
@@ -888,6 +902,11 @@ class NwsOpenMeteoSourceMixin:
|
||||
< self.open_meteo_multi_model_cache_ttl_sec
|
||||
):
|
||||
cached_data = cached.get("data")
|
||||
if isinstance(cached_data, dict):
|
||||
if not multi_model_has_current_window(cached_data):
|
||||
self._multi_model_cache.pop(cache_key, None)
|
||||
record_source_call("open_meteo", "multi_model", "expired_cache_skip", (time.perf_counter() - started) * 1000.0)
|
||||
cached_data = None
|
||||
if isinstance(cached_data, dict):
|
||||
record_source_call("open_meteo", "multi_model", "cache_hit", (time.perf_counter() - started) * 1000.0)
|
||||
return dict(cached_data)
|
||||
@@ -996,8 +1015,9 @@ class NwsOpenMeteoSourceMixin:
|
||||
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"])
|
||||
stale_data = stale.get("data") if isinstance(stale, dict) else None
|
||||
if isinstance(stale_data, dict) and multi_model_has_current_window(stale_data):
|
||||
fallback = dict(stale_data)
|
||||
fallback["stale_cache"] = True
|
||||
record_source_call("open_meteo", "multi_model", "stale_cache", (time.perf_counter() - started) * 1000.0)
|
||||
return fallback
|
||||
|
||||
@@ -18,6 +18,7 @@ from src.data_collection.metar_sources import MetarSourceMixin
|
||||
from src.data_collection.mgm_sources import MgmSourceMixin
|
||||
from src.data_collection.jma_amedas_sources import JmaAmedasSourceMixin
|
||||
from src.data_collection.nws_open_meteo_sources import NwsOpenMeteoSourceMixin
|
||||
from src.data_collection.weathernext2_sources import WeatherNext2SourceMixin
|
||||
from src.data_collection.amos_station_sources import AmosStationSourceMixin
|
||||
from src.data_collection.amsc_awos_sources import AmscAwosSourceMixin
|
||||
from src.data_collection.fmi_sources import FmiSourceMixin
|
||||
@@ -35,7 +36,7 @@ from src.data_collection.forecast_source_bundle import fetch_open_meteo_forecast
|
||||
from src.database.db_manager import DBManager
|
||||
|
||||
|
||||
class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, JmaAmedasSourceMixin, NwsOpenMeteoSourceMixin, AmosStationSourceMixin, AmscAwosSourceMixin, FmiSourceMixin, KnmiSourceMixin, HkoObsSourceMixin, CowinSourceMixin, MadisSourceMixin, SingaporeMssSourceMixin, ImsSourceMixin, NcmSourceMixin, AerowebSourceMixin, WundergroundHistoricalMixin):
|
||||
class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, JmaAmedasSourceMixin, NwsOpenMeteoSourceMixin, WeatherNext2SourceMixin, AmosStationSourceMixin, AmscAwosSourceMixin, FmiSourceMixin, KnmiSourceMixin, HkoObsSourceMixin, CowinSourceMixin, MadisSourceMixin, SingaporeMssSourceMixin, ImsSourceMixin, NcmSourceMixin, AerowebSourceMixin, WundergroundHistoricalMixin):
|
||||
"""
|
||||
Multi-source weather data collector
|
||||
|
||||
@@ -189,9 +190,11 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
self._open_meteo_cache: Dict[str, Dict] = {}
|
||||
self._ensemble_cache: Dict[str, Dict] = {}
|
||||
self._multi_model_cache: Dict[str, Dict] = {}
|
||||
self._weathernext2_cache: Dict[str, Dict] = {}
|
||||
self._open_meteo_cache_lock = threading.Lock()
|
||||
self._ensemble_cache_lock = threading.Lock()
|
||||
self._multi_model_cache_lock = threading.Lock()
|
||||
self._weathernext2_cache_lock = threading.Lock()
|
||||
# Open-Meteo 共享 429 冷却计时器:触发限流后所有 OM 端点暂停请求
|
||||
self._open_meteo_rate_limit_until: float = 0.0
|
||||
self._open_meteo_rl_cooldown: int = int(
|
||||
@@ -1740,6 +1743,26 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
if multi_model_data:
|
||||
results["multi_model"] = multi_model_data
|
||||
|
||||
def _attach_weathernext2_model(
|
||||
self,
|
||||
results: Dict,
|
||||
city: str,
|
||||
lat: float,
|
||||
lon: float,
|
||||
use_fahrenheit: bool,
|
||||
*,
|
||||
timezone_offset_seconds: Optional[int] = None,
|
||||
) -> None:
|
||||
payload = self.fetch_weathernext2_probability(
|
||||
city,
|
||||
lat,
|
||||
lon,
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
timezone_offset_seconds=timezone_offset_seconds,
|
||||
)
|
||||
if payload:
|
||||
results["weathernext2"] = payload
|
||||
|
||||
def fetch_all_sources(
|
||||
self,
|
||||
city: str,
|
||||
@@ -1794,6 +1817,14 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
results["open-meteo"] = open_meteo
|
||||
# 获取时区偏移以过滤 METAR
|
||||
utc_offset = open_meteo.get("utc_offset", 0)
|
||||
self._attach_weathernext2_model(
|
||||
results,
|
||||
city_lower,
|
||||
lat,
|
||||
lon,
|
||||
use_fahrenheit,
|
||||
timezone_offset_seconds=utc_offset,
|
||||
)
|
||||
if supports_aviationweather:
|
||||
metar_data = self.fetch_metar(
|
||||
city, use_fahrenheit=use_fahrenheit, utc_offset=utc_offset
|
||||
@@ -1844,6 +1875,14 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
fallback_utc_offset = int(
|
||||
self.CITY_REGISTRY.get(city_lower, {}).get("tz_offset", 0)
|
||||
)
|
||||
self._attach_weathernext2_model(
|
||||
results,
|
||||
city_lower,
|
||||
lat,
|
||||
lon,
|
||||
use_fahrenheit,
|
||||
timezone_offset_seconds=fallback_utc_offset,
|
||||
)
|
||||
if supports_aviationweather:
|
||||
metar_data = self.fetch_metar(
|
||||
city,
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Mapping, Optional, Sequence
|
||||
|
||||
|
||||
TEMPERATURE_VARIABLE_CANDIDATES = (
|
||||
"temperature_2m",
|
||||
"2m_temperature",
|
||||
"t2m",
|
||||
"air_temperature_2m",
|
||||
"temperature",
|
||||
)
|
||||
|
||||
|
||||
def _mapping_keys(dataset: Any) -> list[str]:
|
||||
if isinstance(dataset, Mapping):
|
||||
return [str(key) for key in dataset.keys()]
|
||||
data_vars = getattr(dataset, "data_vars", None)
|
||||
if data_vars is not None:
|
||||
try:
|
||||
return [str(key) for key in data_vars.keys()]
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def select_temperature_variable(dataset: Any, preferred: Optional[str] = None) -> str:
|
||||
if preferred:
|
||||
keys = set(_mapping_keys(dataset))
|
||||
if preferred in keys:
|
||||
return preferred
|
||||
raise KeyError(f"WeatherNext2 temperature variable not found: {preferred}")
|
||||
|
||||
keys = _mapping_keys(dataset)
|
||||
lowered = {key.lower(): key for key in keys}
|
||||
for candidate in TEMPERATURE_VARIABLE_CANDIDATES:
|
||||
if candidate.lower() in lowered:
|
||||
return lowered[candidate.lower()]
|
||||
|
||||
for key in keys:
|
||||
normalized = key.lower().replace("-", "_")
|
||||
if "temperature" in normalized and ("2m" in normalized or "two_meter" in normalized):
|
||||
return key
|
||||
raise KeyError("WeatherNext2 2m temperature variable was not found")
|
||||
|
||||
|
||||
def normalize_temperature_value(
|
||||
value: Any,
|
||||
units: str = "",
|
||||
*,
|
||||
use_fahrenheit: bool = False,
|
||||
) -> Optional[float]:
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not math.isfinite(parsed):
|
||||
return None
|
||||
|
||||
unit = str(units or "").strip().lower()
|
||||
if unit in {"k", "kelvin"} or parsed > 150:
|
||||
celsius = parsed - 273.15
|
||||
elif unit in {"f", "fahrenheit", "degf", "degree_fahrenheit"}:
|
||||
celsius = (parsed - 32.0) * 5.0 / 9.0
|
||||
else:
|
||||
celsius = parsed
|
||||
|
||||
if use_fahrenheit:
|
||||
return round(celsius * 9.0 / 5.0 + 32.0, 1)
|
||||
return round(celsius, 1)
|
||||
|
||||
|
||||
def _get_dataset_value(dataset: Any, key: str) -> Any:
|
||||
if isinstance(dataset, Mapping):
|
||||
return dataset[key]
|
||||
return dataset[key]
|
||||
|
||||
|
||||
def _as_list(values: Any) -> list[Any]:
|
||||
if hasattr(values, "values"):
|
||||
try:
|
||||
values = values.values
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(values, "tolist"):
|
||||
try:
|
||||
return values.tolist()
|
||||
except Exception:
|
||||
pass
|
||||
return list(values or [])
|
||||
|
||||
|
||||
def _nearest_index(values: Sequence[Any], target: float) -> int:
|
||||
numeric_values = [float(value) for value in values]
|
||||
return min(range(len(numeric_values)), key=lambda idx: abs(numeric_values[idx] - target))
|
||||
|
||||
|
||||
def _variable_units(dataset: Any, temp_var: str) -> str:
|
||||
if isinstance(dataset, Mapping):
|
||||
units = dataset.get("units")
|
||||
if isinstance(units, Mapping):
|
||||
return str(units.get(temp_var) or "")
|
||||
return ""
|
||||
variable = dataset[temp_var]
|
||||
attrs = getattr(variable, "attrs", {}) or {}
|
||||
return str(attrs.get("units") or attrs.get("unit") or "")
|
||||
|
||||
|
||||
def _variable_values(dataset: Any, temp_var: str) -> Any:
|
||||
variable = _get_dataset_value(dataset, temp_var)
|
||||
if hasattr(variable, "values"):
|
||||
return variable.values
|
||||
return variable
|
||||
|
||||
|
||||
def _index_nested(values: Any, indexes: Sequence[int]) -> Any:
|
||||
current = values
|
||||
for idx in indexes:
|
||||
current = current[idx]
|
||||
return current
|
||||
|
||||
|
||||
def _dataset_dimension_values(dataset: Any, candidates: Sequence[str]) -> tuple[str, list[Any]]:
|
||||
keys = _mapping_keys(dataset)
|
||||
lowered = {key.lower(): key for key in keys}
|
||||
for candidate in candidates:
|
||||
key = lowered.get(candidate.lower())
|
||||
if key is not None:
|
||||
return key, _as_list(_get_dataset_value(dataset, key))
|
||||
|
||||
coords = getattr(dataset, "coords", None)
|
||||
if coords is not None:
|
||||
for candidate in candidates:
|
||||
if candidate in coords:
|
||||
return candidate, _as_list(coords[candidate])
|
||||
raise KeyError(f"WeatherNext2 dimension not found: {candidates}")
|
||||
|
||||
|
||||
def _parse_time(value: Any) -> str:
|
||||
if isinstance(value, datetime):
|
||||
dt = value
|
||||
else:
|
||||
text = str(value or "").strip()
|
||||
if text.endswith("Z"):
|
||||
text = f"{text[:-1]}+00:00"
|
||||
try:
|
||||
dt = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return str(value)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def extract_member_hourly_from_grid_dataset(
|
||||
dataset: Any,
|
||||
*,
|
||||
lat: float,
|
||||
lon: float,
|
||||
temp_var: Optional[str] = None,
|
||||
use_fahrenheit: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Extract all member hourly temperatures for the nearest grid point."""
|
||||
selected_var = select_temperature_variable(dataset, temp_var or os.getenv("WEATHERNEXT2_TEMP_VAR") or None)
|
||||
lat_name, lat_values = _dataset_dimension_values(dataset, ("lat", "latitude"))
|
||||
lon_name, lon_values = _dataset_dimension_values(dataset, ("lon", "longitude"))
|
||||
member_name, member_values = _dataset_dimension_values(dataset, ("member", "realization", "ensemble_member"))
|
||||
time_name, time_values = _dataset_dimension_values(dataset, ("time", "valid_time"))
|
||||
|
||||
lat_idx = _nearest_index(lat_values, lat)
|
||||
lon_idx = _nearest_index(lon_values, lon)
|
||||
units = _variable_units(dataset, selected_var)
|
||||
|
||||
variable = _get_dataset_value(dataset, selected_var)
|
||||
dims = list(getattr(variable, "dims", []) or [member_name, time_name, lat_name, lon_name])
|
||||
values = _variable_values(dataset, selected_var)
|
||||
dim_indexes = {
|
||||
member_name: None,
|
||||
time_name: None,
|
||||
lat_name: lat_idx,
|
||||
lon_name: lon_idx,
|
||||
}
|
||||
|
||||
member_hourly: Dict[str, list[Optional[float]]] = {}
|
||||
for member_idx, member in enumerate(member_values):
|
||||
hourly = []
|
||||
for time_idx, _time_value in enumerate(time_values):
|
||||
dim_indexes[member_name] = member_idx
|
||||
dim_indexes[time_name] = time_idx
|
||||
indexes = [int(dim_indexes[dim]) for dim in dims]
|
||||
hourly.append(
|
||||
normalize_temperature_value(
|
||||
_index_nested(values, indexes),
|
||||
units,
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
)
|
||||
)
|
||||
try:
|
||||
member_label = f"member_{int(member):02d}"
|
||||
except Exception:
|
||||
member_label = f"member_{member_idx:02d}"
|
||||
member_hourly[member_label] = hourly
|
||||
|
||||
return {
|
||||
"temp_var": selected_var,
|
||||
"units": units,
|
||||
"lat_dim": lat_name,
|
||||
"lon_dim": lon_name,
|
||||
"member_dim": member_name,
|
||||
"time_dim": time_name,
|
||||
"nearest_lat": float(lat_values[lat_idx]),
|
||||
"nearest_lon": float(lon_values[lon_idx]),
|
||||
"utc_times": [_parse_time(value) for value in time_values],
|
||||
"member_hourly": member_hourly,
|
||||
}
|
||||
|
||||
|
||||
def open_weathernext2_zarr_dataset(uri: str):
|
||||
"""Open a WeatherNext 2 Zarr dataset lazily with optional runtime dependencies."""
|
||||
try:
|
||||
import xarray as xr # type: ignore
|
||||
except Exception as exc: # pragma: no cover - depends on deployment extras
|
||||
raise RuntimeError("xarray is required for WeatherNext2 GCS/Zarr access") from exc
|
||||
|
||||
storage_options = {}
|
||||
credentials = str(os.getenv("GOOGLE_APPLICATION_CREDENTIALS", "") or "").strip()
|
||||
if credentials:
|
||||
storage_options["token"] = "google_default"
|
||||
try:
|
||||
return xr.open_zarr(
|
||||
uri,
|
||||
storage_options=storage_options or None,
|
||||
consolidated=True,
|
||||
)
|
||||
except Exception:
|
||||
return xr.open_zarr(
|
||||
uri,
|
||||
storage_options=storage_options or None,
|
||||
consolidated=False,
|
||||
)
|
||||
@@ -0,0 +1,581 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Iterable, Mapping, Optional, Sequence, Union
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
WEATHERNEXT2_SOURCE = "weathernext2"
|
||||
WEATHERNEXT2_PROVIDER = "google_deepmind"
|
||||
WEATHERNEXT2_GCS_ZARR_URI = "gs://weathernext/weathernext_2_0_0/zarr"
|
||||
WEATHERNEXT2_MEAN_GCS_ZARR_URI = "gs://weathernext/weathernext_2_0_0_mean/zarr"
|
||||
|
||||
Number = Union[int, float]
|
||||
|
||||
|
||||
def _numeric(value: Any) -> Optional[float]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if math.isfinite(parsed) else None
|
||||
|
||||
|
||||
def _round1(value: Number) -> float:
|
||||
return round(float(value), 1)
|
||||
|
||||
|
||||
def _round3(value: Number) -> float:
|
||||
return round(float(value), 3)
|
||||
|
||||
|
||||
def _settle_integer(value: Number) -> int:
|
||||
parsed = float(value)
|
||||
if parsed >= 0:
|
||||
return int(math.floor(parsed + 0.5))
|
||||
return int(math.ceil(parsed - 0.5))
|
||||
|
||||
|
||||
def _is_fahrenheit(temp_symbol: str) -> bool:
|
||||
return "F" in str(temp_symbol or "").upper()
|
||||
|
||||
|
||||
def _unit(temp_symbol: str) -> str:
|
||||
return "°F" if _is_fahrenheit(temp_symbol) else "°C"
|
||||
|
||||
|
||||
def market_bucket_for_temperature(value: Number, temp_symbol: str = "°C") -> Dict[str, Any]:
|
||||
"""Return the tradable market option bucket for a single member temperature."""
|
||||
unit = _unit(temp_symbol)
|
||||
settled = _settle_integer(value)
|
||||
if _is_fahrenheit(unit):
|
||||
lower_value = settled if settled % 2 == 0 else settled - 1
|
||||
upper_value = lower_value + 1
|
||||
return {
|
||||
"key": f"{lower_value}-{upper_value}{unit}",
|
||||
"label": f"{lower_value}-{upper_value}{unit}",
|
||||
"lower": float(lower_value) - 0.5,
|
||||
"upper": float(upper_value) + 0.5,
|
||||
"sort_value": float(lower_value),
|
||||
}
|
||||
return {
|
||||
"key": f"{settled}{unit}",
|
||||
"label": f"{settled}{unit}",
|
||||
"lower": float(settled) - 0.5,
|
||||
"upper": float(settled) + 0.5,
|
||||
"sort_value": float(settled),
|
||||
}
|
||||
|
||||
|
||||
def _member_items(member_highs: Union[Mapping[str, Any], Sequence[Any]]) -> Iterable[tuple[str, float]]:
|
||||
if isinstance(member_highs, Mapping):
|
||||
iterable = member_highs.items()
|
||||
else:
|
||||
iterable = ((f"member_{idx:02d}", value) for idx, value in enumerate(member_highs))
|
||||
for member_id, value in iterable:
|
||||
parsed = _numeric(value)
|
||||
if parsed is not None:
|
||||
yield str(member_id), parsed
|
||||
|
||||
|
||||
def summarize_member_highs(member_highs: Union[Mapping[str, Any], Sequence[Any]]) -> Dict[str, Any]:
|
||||
values = sorted(value for _member_id, value in _member_items(member_highs))
|
||||
if not values:
|
||||
return {
|
||||
"members": 0,
|
||||
"mean": None,
|
||||
"median": None,
|
||||
"p10": None,
|
||||
"p25": None,
|
||||
"p75": None,
|
||||
"p90": None,
|
||||
"min": None,
|
||||
"max": None,
|
||||
"spread": None,
|
||||
}
|
||||
|
||||
def percentile(q: float) -> float:
|
||||
if len(values) == 1:
|
||||
return values[0]
|
||||
position = (len(values) - 1) * q
|
||||
lower_idx = int(math.floor(position))
|
||||
upper_idx = int(math.ceil(position))
|
||||
if lower_idx == upper_idx:
|
||||
return values[lower_idx]
|
||||
weight = position - lower_idx
|
||||
return values[lower_idx] * (1 - weight) + values[upper_idx] * weight
|
||||
|
||||
return {
|
||||
"members": len(values),
|
||||
"mean": _round1(sum(values) / len(values)),
|
||||
"median": _round1(percentile(0.5)),
|
||||
"p10": _round1(percentile(0.1)),
|
||||
"p25": _round1(percentile(0.25)),
|
||||
"p75": _round1(percentile(0.75)),
|
||||
"p90": _round1(percentile(0.9)),
|
||||
"min": _round1(values[0]),
|
||||
"max": _round1(values[-1]),
|
||||
"spread": _round1(values[-1] - values[0]),
|
||||
}
|
||||
|
||||
|
||||
def build_market_bucket_probabilities(
|
||||
member_highs: Union[Mapping[str, Any], Sequence[Any]],
|
||||
temp_symbol: str = "°C",
|
||||
) -> list[Dict[str, Any]]:
|
||||
grouped: Dict[str, Dict[str, Any]] = {}
|
||||
total_members = 0
|
||||
|
||||
for _member_id, value in _member_items(member_highs):
|
||||
total_members += 1
|
||||
option = market_bucket_for_temperature(value, temp_symbol)
|
||||
bucket = grouped.setdefault(
|
||||
option["key"],
|
||||
{
|
||||
"key": option["key"],
|
||||
"label": option["label"],
|
||||
"lower": option["lower"],
|
||||
"upper": option["upper"],
|
||||
"sort_value": option["sort_value"],
|
||||
"weighted_sum": 0.0,
|
||||
"member_count": 0,
|
||||
},
|
||||
)
|
||||
bucket["weighted_sum"] += value
|
||||
bucket["member_count"] += 1
|
||||
|
||||
if total_members <= 0:
|
||||
return []
|
||||
|
||||
buckets = []
|
||||
for bucket in grouped.values():
|
||||
member_count = int(bucket["member_count"])
|
||||
buckets.append(
|
||||
{
|
||||
"key": bucket["key"],
|
||||
"label": bucket["label"],
|
||||
"lower": bucket["lower"],
|
||||
"upper": bucket["upper"],
|
||||
"value": _round1(bucket["weighted_sum"] / member_count),
|
||||
"probability": _round3(member_count / total_members),
|
||||
"member_count": member_count,
|
||||
"total_members": total_members,
|
||||
}
|
||||
)
|
||||
|
||||
return sorted(buckets, key=lambda item: (float(item["lower"]), float(item["upper"])))
|
||||
|
||||
|
||||
def _parse_utc_time(value: Any) -> Optional[datetime]:
|
||||
if isinstance(value, datetime):
|
||||
parsed = value
|
||||
else:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.endswith("Z"):
|
||||
text = f"{text[:-1]}+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _parse_date(value: Any) -> Optional[date]:
|
||||
if isinstance(value, date) and not isinstance(value, datetime):
|
||||
return value
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(text[:10]).date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def build_city_local_daily_highs_from_hourly(
|
||||
member_hourly: Mapping[str, Sequence[Any]],
|
||||
utc_times: Sequence[Any],
|
||||
timezone_offset_seconds: int,
|
||||
target_local_date: Any,
|
||||
) -> Dict[str, float]:
|
||||
"""Reduce hourly ensemble member temperatures to local-date daily highs."""
|
||||
target_date = _parse_date(target_local_date)
|
||||
if target_date is None:
|
||||
return {}
|
||||
|
||||
parsed_times = [_parse_utc_time(value) for value in utc_times]
|
||||
highs: Dict[str, float] = {}
|
||||
offset = timedelta(seconds=int(timezone_offset_seconds or 0))
|
||||
|
||||
for member_id, values in member_hourly.items():
|
||||
member_values = []
|
||||
for idx, utc_time in enumerate(parsed_times):
|
||||
if utc_time is None or idx >= len(values):
|
||||
continue
|
||||
local_date = (utc_time + offset).date()
|
||||
if local_date != target_date:
|
||||
continue
|
||||
parsed = _numeric(values[idx])
|
||||
if parsed is not None:
|
||||
member_values.append(parsed)
|
||||
if member_values:
|
||||
highs[str(member_id)] = _round1(max(member_values))
|
||||
|
||||
return highs
|
||||
|
||||
|
||||
def build_weathernext2_city_probability(
|
||||
*,
|
||||
city: str,
|
||||
member_highs: Union[Mapping[str, Any], Sequence[Any]],
|
||||
temp_symbol: str = "°C",
|
||||
target_date: Optional[str] = None,
|
||||
source_run: Optional[str] = None,
|
||||
generated_at: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build a WeatherNext 2 probability payload aligned with tradable market options."""
|
||||
buckets = build_market_bucket_probabilities(member_highs, temp_symbol=temp_symbol)
|
||||
summary = summarize_member_highs(member_highs)
|
||||
normalized_member_highs = {
|
||||
member_id: _round1(value)
|
||||
for member_id, value in _member_items(member_highs)
|
||||
}
|
||||
top_bucket = (
|
||||
max(buckets, key=lambda item: (float(item["probability"]), float(item["lower"])))
|
||||
if buckets
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"source": WEATHERNEXT2_SOURCE,
|
||||
"provider": WEATHERNEXT2_PROVIDER,
|
||||
"city": str(city or "").strip(),
|
||||
"target_date": target_date,
|
||||
"source_run": source_run,
|
||||
"generated_at": generated_at or datetime.now(timezone.utc).isoformat(),
|
||||
"temp_symbol": _unit(temp_symbol),
|
||||
"members": int(summary["members"] or 0),
|
||||
"member_highs": normalized_member_highs,
|
||||
"summary": summary,
|
||||
"buckets": buckets,
|
||||
"top_bucket": top_bucket,
|
||||
"bucket_policy": "celsius_single_fahrenheit_two_degree_market_options",
|
||||
"gcs_zarr_uri": os.getenv("WEATHERNEXT2_GCS_ZARR_URI", WEATHERNEXT2_GCS_ZARR_URI),
|
||||
"mean_gcs_zarr_uri": os.getenv("WEATHERNEXT2_MEAN_GCS_ZARR_URI", WEATHERNEXT2_MEAN_GCS_ZARR_URI),
|
||||
}
|
||||
|
||||
|
||||
def _env_enabled(name: str, default: str = "0") -> bool:
|
||||
return str(os.getenv(name, default) or "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
|
||||
|
||||
def _city_key(city: str) -> str:
|
||||
return str(city or "").strip().lower()
|
||||
|
||||
|
||||
def _load_fixture_payload(path: str, city: str) -> Optional[Dict[str, Any]]:
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
payload = json.load(fh)
|
||||
except Exception as exc:
|
||||
logger.warning("WeatherNext2 fixture load failed path={}: {}", path, exc)
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
key = _city_key(city)
|
||||
if isinstance(payload.get(key), dict):
|
||||
return dict(payload[key])
|
||||
for candidate, value in payload.items():
|
||||
if _city_key(candidate) == key and isinstance(value, dict):
|
||||
return dict(value)
|
||||
if "member_highs" in payload or "member_hourly" in payload:
|
||||
return dict(payload)
|
||||
return None
|
||||
|
||||
|
||||
def _weathernext2_data_root() -> str:
|
||||
return str(os.getenv("WEATHERNEXT2_DATA_ROOT", "/app/data/weathernext2") or "").strip()
|
||||
|
||||
|
||||
def _weathernext2_artifact_path() -> str:
|
||||
configured = str(os.getenv("WEATHERNEXT2_CITY_HIGHS_PATH", "") or "").strip()
|
||||
if configured:
|
||||
return configured
|
||||
return os.path.join(_weathernext2_data_root(), "weathernext2_city_highs.json")
|
||||
|
||||
|
||||
def _weathernext2_model_dir() -> str:
|
||||
return str(
|
||||
os.getenv(
|
||||
"WEATHERNEXT2_MODEL_DIR",
|
||||
"/app/data/models/weathernext2_calibrator",
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
|
||||
def _load_artifact_payload(path: str, city: str) -> Optional[Dict[str, Any]]:
|
||||
if not path or not os.path.isfile(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
payload = json.load(fh)
|
||||
except Exception as exc:
|
||||
logger.warning("WeatherNext2 artifact load failed path={}: {}", path, exc)
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
key = _city_key(city)
|
||||
city_payloads: Any = payload.get("cities")
|
||||
if isinstance(city_payloads, dict):
|
||||
if isinstance(city_payloads.get(key), dict):
|
||||
return dict(city_payloads[key])
|
||||
for candidate, value in city_payloads.items():
|
||||
if _city_key(candidate) == key and isinstance(value, dict):
|
||||
return dict(value)
|
||||
if isinstance(city_payloads, list):
|
||||
for value in city_payloads:
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
if _city_key(value.get("city")) == key:
|
||||
return dict(value)
|
||||
|
||||
if isinstance(payload.get(key), dict):
|
||||
return dict(payload[key])
|
||||
for candidate, value in payload.items():
|
||||
if _city_key(candidate) == key and isinstance(value, dict):
|
||||
return dict(value)
|
||||
return None
|
||||
|
||||
|
||||
class WeatherNext2SourceMixin:
|
||||
"""Optional WeatherNext 2 probability source.
|
||||
|
||||
The live Google datasets require project access and optional heavy client
|
||||
dependencies. This mixin keeps production safe by supporting a fixture or
|
||||
prepared payload first, while reserving backend configuration for the real
|
||||
GCS/BigQuery fetcher.
|
||||
"""
|
||||
|
||||
def _weathernext2_enabled(self) -> bool:
|
||||
return _env_enabled("WEATHERNEXT2_ENABLED")
|
||||
|
||||
def _weathernext2_cache_key(
|
||||
self,
|
||||
city: str,
|
||||
lat: float,
|
||||
lon: float,
|
||||
use_fahrenheit: bool,
|
||||
target_date: Optional[str],
|
||||
) -> str:
|
||||
return (
|
||||
f"{_city_key(city)}:{round(float(lat), 4)}:{round(float(lon), 4)}:"
|
||||
f"{'f' if use_fahrenheit else 'c'}:{target_date or ''}"
|
||||
)
|
||||
|
||||
def _weathernext2_cache_state(self) -> tuple[Dict[str, Dict[str, Any]], threading.Lock, int]:
|
||||
if not hasattr(self, "_weathernext2_cache"):
|
||||
self._weathernext2_cache = {}
|
||||
if not hasattr(self, "_weathernext2_cache_lock"):
|
||||
self._weathernext2_cache_lock = threading.Lock()
|
||||
ttl_sec = int(os.getenv("WEATHERNEXT2_CACHE_TTL_SEC", "21600"))
|
||||
return self._weathernext2_cache, self._weathernext2_cache_lock, ttl_sec
|
||||
|
||||
def _weathernext2_target_date(
|
||||
self,
|
||||
timezone_offset_seconds: Optional[int],
|
||||
target_date: Optional[str],
|
||||
) -> str:
|
||||
if target_date:
|
||||
return str(target_date)
|
||||
offset = timedelta(seconds=int(timezone_offset_seconds or 0))
|
||||
return (datetime.now(timezone.utc) + offset).date().isoformat()
|
||||
|
||||
def _weathernext2_from_fixture(
|
||||
self,
|
||||
*,
|
||||
city: str,
|
||||
use_fahrenheit: bool,
|
||||
target_date: str,
|
||||
timezone_offset_seconds: Optional[int],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
fixture_path = str(os.getenv("WEATHERNEXT2_FIXTURE_PATH", "") or "").strip()
|
||||
fixture = _load_fixture_payload(fixture_path, city)
|
||||
if not fixture:
|
||||
return None
|
||||
|
||||
temp_symbol = "°F" if use_fahrenheit else "°C"
|
||||
fixture_target_date = str(fixture.get("target_date") or target_date)
|
||||
member_highs = fixture.get("member_highs")
|
||||
if member_highs is None and isinstance(fixture.get("member_hourly"), dict):
|
||||
member_highs = build_city_local_daily_highs_from_hourly(
|
||||
fixture["member_hourly"],
|
||||
fixture.get("utc_times") or fixture.get("times") or [],
|
||||
int(timezone_offset_seconds or fixture.get("timezone_offset_seconds") or 0),
|
||||
fixture_target_date,
|
||||
)
|
||||
if member_highs is None:
|
||||
return None
|
||||
|
||||
return build_weathernext2_city_probability(
|
||||
city=city,
|
||||
member_highs=member_highs,
|
||||
temp_symbol=temp_symbol,
|
||||
target_date=fixture_target_date,
|
||||
source_run=fixture.get("source_run"),
|
||||
generated_at=fixture.get("generated_at"),
|
||||
)
|
||||
|
||||
def _weathernext2_normalize_prepared_payload(
|
||||
self,
|
||||
*,
|
||||
payload: Mapping[str, Any],
|
||||
city: str,
|
||||
use_fahrenheit: bool,
|
||||
target_date: str,
|
||||
timezone_offset_seconds: Optional[int],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
temp_symbol = "°F" if use_fahrenheit else "°C"
|
||||
payload_target_date = str(payload.get("target_date") or target_date)
|
||||
member_highs = payload.get("member_highs")
|
||||
if member_highs is None and isinstance(payload.get("member_hourly"), dict):
|
||||
member_highs = build_city_local_daily_highs_from_hourly(
|
||||
payload["member_hourly"],
|
||||
payload.get("utc_times") or payload.get("times") or [],
|
||||
int(timezone_offset_seconds or payload.get("timezone_offset_seconds") or 0),
|
||||
payload_target_date,
|
||||
)
|
||||
if member_highs is None and isinstance(payload.get("buckets"), list):
|
||||
normalized = dict(payload)
|
||||
normalized.setdefault("source", WEATHERNEXT2_SOURCE)
|
||||
normalized.setdefault("provider", WEATHERNEXT2_PROVIDER)
|
||||
normalized.setdefault("city", city)
|
||||
normalized.setdefault("target_date", payload_target_date)
|
||||
normalized.setdefault("temp_symbol", temp_symbol)
|
||||
normalized.setdefault(
|
||||
"gcs_zarr_uri",
|
||||
os.getenv("WEATHERNEXT2_GCS_ZARR_URI", WEATHERNEXT2_GCS_ZARR_URI),
|
||||
)
|
||||
return normalized
|
||||
if member_highs is None:
|
||||
return None
|
||||
return build_weathernext2_city_probability(
|
||||
city=city,
|
||||
member_highs=member_highs,
|
||||
temp_symbol=temp_symbol,
|
||||
target_date=payload_target_date,
|
||||
source_run=payload.get("source_run"),
|
||||
generated_at=payload.get("generated_at"),
|
||||
)
|
||||
|
||||
def _weathernext2_from_artifact(
|
||||
self,
|
||||
*,
|
||||
city: str,
|
||||
use_fahrenheit: bool,
|
||||
target_date: str,
|
||||
timezone_offset_seconds: Optional[int],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
artifact = _load_artifact_payload(_weathernext2_artifact_path(), city)
|
||||
if not artifact:
|
||||
return None
|
||||
payload = self._weathernext2_normalize_prepared_payload(
|
||||
payload=artifact,
|
||||
city=city,
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
target_date=target_date,
|
||||
timezone_offset_seconds=timezone_offset_seconds,
|
||||
)
|
||||
if payload is None:
|
||||
return None
|
||||
try:
|
||||
from src.analysis.weathernext2_calibration import apply_quantile_calibration_to_payload
|
||||
|
||||
return apply_quantile_calibration_to_payload(
|
||||
payload,
|
||||
model_dir=_weathernext2_model_dir(),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("WeatherNext2 calibration apply failed city={}: {}", city, exc)
|
||||
return payload
|
||||
|
||||
def fetch_weathernext2_probability(
|
||||
self,
|
||||
city: str,
|
||||
lat: float,
|
||||
lon: float,
|
||||
*,
|
||||
use_fahrenheit: bool = False,
|
||||
target_date: Optional[str] = None,
|
||||
timezone_offset_seconds: Optional[int] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self._weathernext2_enabled():
|
||||
return None
|
||||
if lat is None or lon is None:
|
||||
return None
|
||||
|
||||
resolved_target_date = self._weathernext2_target_date(
|
||||
timezone_offset_seconds,
|
||||
target_date,
|
||||
)
|
||||
cache_key = self._weathernext2_cache_key(
|
||||
city,
|
||||
lat,
|
||||
lon,
|
||||
use_fahrenheit,
|
||||
resolved_target_date,
|
||||
)
|
||||
cache, lock, ttl_sec = self._weathernext2_cache_state()
|
||||
now_ts = time.time()
|
||||
with lock:
|
||||
cached = cache.get(cache_key)
|
||||
cached_data = cached.get("data") if isinstance(cached, dict) else None
|
||||
if isinstance(cached_data, dict) and now_ts - float(cached.get("t", 0)) < ttl_sec:
|
||||
return dict(cached_data)
|
||||
|
||||
payload = self._weathernext2_from_artifact(
|
||||
city=city,
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
target_date=resolved_target_date,
|
||||
timezone_offset_seconds=timezone_offset_seconds,
|
||||
)
|
||||
if payload is None:
|
||||
payload = self._weathernext2_from_fixture(
|
||||
city=city,
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
target_date=resolved_target_date,
|
||||
timezone_offset_seconds=timezone_offset_seconds,
|
||||
)
|
||||
if payload is None:
|
||||
backend = str(os.getenv("WEATHERNEXT2_BACKEND", "") or "").strip().lower()
|
||||
if backend:
|
||||
logger.warning(
|
||||
"WeatherNext2 backend={} configured but live fetcher is not installed; use fixture/prepared payload first",
|
||||
backend,
|
||||
)
|
||||
return None
|
||||
|
||||
with lock:
|
||||
cache[cache_key] = {"t": now_ts, "data": dict(payload)}
|
||||
return payload
|
||||
@@ -2470,18 +2470,32 @@ class PaymentContractCheckoutService:
|
||||
else:
|
||||
self._require_user_wallet(user_id, from_addr)
|
||||
|
||||
validation_pending_reasons = {
|
||||
"payment_tx_validation_failed",
|
||||
"tx_not_mined",
|
||||
}
|
||||
try:
|
||||
validation = self._validate_loaded_intent_tx(intent, tx_hash_text)
|
||||
except Exception as exc:
|
||||
raise PaymentCheckoutError(
|
||||
400,
|
||||
f"payment_tx_validation_failed: {exc}",
|
||||
) from exc
|
||||
if intent.payment_mode != "direct":
|
||||
raise PaymentCheckoutError(
|
||||
400,
|
||||
f"payment_tx_validation_failed: {exc}",
|
||||
) from exc
|
||||
validation = {
|
||||
"valid": False,
|
||||
"reason": "payment_tx_validation_failed",
|
||||
"detail": str(exc),
|
||||
}
|
||||
if not bool(validation.get("valid")):
|
||||
reason = str(validation.get("reason") or "payment_tx_invalid").strip()
|
||||
detail = str(validation.get("detail") or reason).strip()
|
||||
message = reason if detail == reason else f"{reason}: {detail}"
|
||||
raise PaymentCheckoutError(400, message)
|
||||
is_pending_direct_validation = (
|
||||
intent.payment_mode == "direct" and reason in validation_pending_reasons
|
||||
)
|
||||
if not is_pending_direct_validation:
|
||||
raise PaymentCheckoutError(400, message)
|
||||
|
||||
now_iso = _to_iso(now)
|
||||
self._rest(
|
||||
|
||||
@@ -144,9 +144,7 @@ def test_scan_terminal_response_includes_backend_server_timing(monkeypatch):
|
||||
assert "scan_terminal_assert_entitlement" in server_timing
|
||||
assert "scan_terminal_build_payload" in server_timing
|
||||
assert "scan_terminal_total" in server_timing
|
||||
assert response.headers["cache-control"] == (
|
||||
"public, max-age=0, s-maxage=300, stale-while-revalidate=900"
|
||||
)
|
||||
assert response.headers["cache-control"] == "no-store, max-age=0"
|
||||
assert response.headers["cloudflare-cdn-cache-control"] == response.headers["cache-control"]
|
||||
|
||||
|
||||
|
||||
@@ -185,11 +185,21 @@ def test_bindtopic_rejects_non_admin(monkeypatch):
|
||||
assert "管理员" in bot.replies[0]["text"]
|
||||
|
||||
|
||||
def test_start_bind_token_binds_telegram_to_web_account():
|
||||
def test_start_bind_token_binds_telegram_to_web_account(monkeypatch):
|
||||
import src.bot.handlers.basic as basic
|
||||
|
||||
bot = DummyBot()
|
||||
consumed = []
|
||||
bound = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
basic,
|
||||
"TelegramGroupPricing",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("web bind tokens must not require Telegram group membership")
|
||||
),
|
||||
)
|
||||
|
||||
def _consume(token):
|
||||
consumed.append(token)
|
||||
return {
|
||||
|
||||
@@ -14,4 +14,4 @@ def test_frontend_cache_validator_checks_cloudflare_edge_hits():
|
||||
assert "MISS" in script
|
||||
assert "REVALIDATED" in script
|
||||
assert 'check_cloudflare_cache_hit "/api/cities" "cities edge cache"' in script
|
||||
assert 'check_cloudflare_cache_hit "/api/scan/terminal?limit=1" "scan terminal edge cache"' in script
|
||||
assert "/api/scan/terminal?limit=1" not in script
|
||||
|
||||
@@ -14,7 +14,7 @@ def test_cloudflare_managed_rules_apply_path_specific_edge_ttls_then_bypass_sens
|
||||
f"{MANAGED_RULE_REF_PREFIX}pages",
|
||||
f"{MANAGED_RULE_REF_PREFIX}cities",
|
||||
f"{MANAGED_RULE_REF_PREFIX}city_detail",
|
||||
f"{MANAGED_RULE_REF_PREFIX}scan",
|
||||
f"{MANAGED_RULE_REF_PREFIX}system_status",
|
||||
f"{MANAGED_RULE_REF_PREFIX}bypass",
|
||||
]
|
||||
assert [
|
||||
@@ -31,6 +31,8 @@ def test_cloudflare_managed_rules_apply_path_specific_edge_ttls_then_bypass_sens
|
||||
"cache": True,
|
||||
"browser_ttl": {"mode": "respect_origin"},
|
||||
}
|
||||
assert 'http.request.uri.path eq "/api/system/status"' in rules[4]["expression"]
|
||||
assert "/api/scan/terminal" not in rules[4]["expression"]
|
||||
assert rules[-1]["action_parameters"]["cache"] is False
|
||||
assert 'http.host eq "api.polyweather.top"' in rules[-1]["expression"]
|
||||
assert 'http.request.uri.query contains "force_refresh=true"' in rules[-1]["expression"]
|
||||
@@ -64,7 +66,7 @@ def test_cloudflare_rule_merge_preserves_unmanaged_rules_and_puts_bypass_last():
|
||||
f"{MANAGED_RULE_REF_PREFIX}pages",
|
||||
f"{MANAGED_RULE_REF_PREFIX}cities",
|
||||
f"{MANAGED_RULE_REF_PREFIX}city_detail",
|
||||
f"{MANAGED_RULE_REF_PREFIX}scan",
|
||||
f"{MANAGED_RULE_REF_PREFIX}system_status",
|
||||
f"{MANAGED_RULE_REF_PREFIX}bypass",
|
||||
]
|
||||
assert merged[-1]["action_parameters"]["cache"] is False
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
from src.database.runtime_state import (
|
||||
DailyRecordRepository,
|
||||
RuntimeStateDB,
|
||||
TrainingFeatureRecordRepository,
|
||||
TruthRecordRepository,
|
||||
)
|
||||
|
||||
|
||||
def _wire_runtime_repos(monkeypatch, tmp_path):
|
||||
from src.analysis import deb_algorithm
|
||||
|
||||
db = RuntimeStateDB(str(tmp_path / "polyweather.db"))
|
||||
daily_repo = DailyRecordRepository(db)
|
||||
truth_repo = TruthRecordRepository(db)
|
||||
feature_repo = TrainingFeatureRecordRepository(db)
|
||||
monkeypatch.setattr(deb_algorithm, "_daily_record_repo", daily_repo)
|
||||
monkeypatch.setattr(deb_algorithm, "_truth_record_repo", truth_repo)
|
||||
monkeypatch.setattr(deb_algorithm, "_training_feature_repo", feature_repo)
|
||||
monkeypatch.setattr(deb_algorithm, "_history_cache", {})
|
||||
monkeypatch.setattr(deb_algorithm, "_history_mtime", 0)
|
||||
monkeypatch.setenv("POLYWEATHER_STATE_STORAGE_MODE", "sqlite")
|
||||
return db
|
||||
|
||||
|
||||
def test_intraday_daily_record_does_not_persist_unfinal_actual_as_truth(monkeypatch, tmp_path):
|
||||
from src.analysis.deb_algorithm import update_daily_record
|
||||
|
||||
db = _wire_runtime_repos(monkeypatch, tmp_path)
|
||||
|
||||
update_daily_record(
|
||||
"ankara",
|
||||
"2026-06-24",
|
||||
{"ECMWF": 28.0, "GFS": 27.0},
|
||||
24.0,
|
||||
deb_prediction=27.5,
|
||||
mu=27.2,
|
||||
actual_is_final=False,
|
||||
)
|
||||
|
||||
with db.connect() as conn:
|
||||
daily = conn.execute(
|
||||
"""
|
||||
SELECT actual_high, deb_prediction, mu, payload_json
|
||||
FROM daily_records_store
|
||||
WHERE city = ? AND target_date = ?
|
||||
""",
|
||||
("ankara", "2026-06-24"),
|
||||
).fetchone()
|
||||
truth_count = conn.execute(
|
||||
"""
|
||||
SELECT count(*) AS n
|
||||
FROM truth_records_store
|
||||
WHERE city = ? AND target_date = ?
|
||||
""",
|
||||
("ankara", "2026-06-24"),
|
||||
).fetchone()["n"]
|
||||
|
||||
assert daily is not None
|
||||
assert daily["actual_high"] is None
|
||||
assert daily["deb_prediction"] == 27.5
|
||||
assert daily["mu"] == 27.2
|
||||
assert truth_count == 0
|
||||
|
||||
|
||||
def test_dynamic_weights_skip_actual_only_rows_when_selecting_recent_training_days(monkeypatch):
|
||||
from src.analysis.deb_algorithm import calculate_dynamic_weight_components
|
||||
|
||||
history = {"ankara": {}}
|
||||
for day in range(23, 15, -1):
|
||||
history["ankara"][f"2026-06-{day:02d}"] = {"actual_high": 20.0}
|
||||
history["ankara"]["2026-06-15"] = {
|
||||
"actual_high": 20.0,
|
||||
"forecasts": {"ECMWF": 20.0, "GFS": 30.0},
|
||||
}
|
||||
history["ankara"]["2026-06-14"] = {
|
||||
"actual_high": 20.0,
|
||||
"forecasts": {"ECMWF": 20.0, "GFS": 30.0},
|
||||
}
|
||||
monkeypatch.setattr("src.analysis.deb_algorithm.load_history", lambda _path: history)
|
||||
|
||||
components = calculate_dynamic_weight_components(
|
||||
"ankara",
|
||||
{"ECMWF": 20.0, "GFS": 30.0},
|
||||
lookback_days=7,
|
||||
)
|
||||
|
||||
assert components["weights"]["ECMWF"] > components["weights"]["GFS"]
|
||||
assert components["prediction"] < 25.0
|
||||
@@ -100,6 +100,13 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
|
||||
training_settlement_block = compose.split(
|
||||
" polyweather_training_settlement:",
|
||||
1,
|
||||
)[1].split(
|
||||
"\n polyweather_weathernext2_worker:",
|
||||
1,
|
||||
)[0]
|
||||
weathernext2_block = compose.split(
|
||||
" polyweather_weathernext2_worker:",
|
||||
1,
|
||||
)[1].split(
|
||||
"\nx-polyweather-base:",
|
||||
1,
|
||||
@@ -110,6 +117,7 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
|
||||
assert "POLYWEATHER_SERVICE_ROLE: collector" in collector_block
|
||||
assert "POLYWEATHER_SERVICE_ROLE: warmer" in warmer_block
|
||||
assert "POLYWEATHER_SERVICE_ROLE: training_settlement" in training_settlement_block
|
||||
assert "POLYWEATHER_SERVICE_ROLE: weathernext2_worker" in weathernext2_block
|
||||
assert "redis-server --appendonly yes --maxmemory ${POLYWEATHER_REDIS_MAXMEMORY:-512mb} --maxmemory-policy noeviction" in compose
|
||||
assert "POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'" in bot_block
|
||||
assert "POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}" in web_block
|
||||
@@ -126,7 +134,9 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
|
||||
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'true'" in collector_block
|
||||
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in warmer_block
|
||||
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in training_settlement_block
|
||||
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in weathernext2_block
|
||||
assert "command: python -m web.training_settlement_worker" in training_settlement_block
|
||||
assert "command: python -m web.weathernext2_worker" in weathernext2_block
|
||||
assert (
|
||||
"POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC: "
|
||||
"${POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC:-21600}"
|
||||
@@ -142,6 +152,25 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
|
||||
assert "POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS:-3000}" in web_block
|
||||
assert "POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_PARTIAL_TIMEOUT_MS:-8000}" in web_block
|
||||
assert "UVICORN_WORKERS: ${UVICORN_WORKERS:-2}" in web_block
|
||||
assert "WEATHERNEXT2_ENABLED: ${WEATHERNEXT2_ENABLED:-1}" in web_block
|
||||
assert "WEATHERNEXT2_ENABLED: ${WEATHERNEXT2_ENABLED:-1}" in weathernext2_block
|
||||
assert "WEATHERNEXT2_BACKEND: ${WEATHERNEXT2_BACKEND:-gcs_zarr}" in weathernext2_block
|
||||
assert (
|
||||
"WEATHERNEXT2_GCS_ZARR_URI: "
|
||||
"${WEATHERNEXT2_GCS_ZARR_URI:-gs://weathernext/weathernext_2_0_0/zarr}"
|
||||
in weathernext2_block
|
||||
)
|
||||
assert (
|
||||
"WEATHERNEXT2_MODEL_DIR: "
|
||||
"${WEATHERNEXT2_MODEL_DIR:-/app/data/models/weathernext2_calibrator}"
|
||||
in weathernext2_block
|
||||
)
|
||||
assert (
|
||||
"GOOGLE_APPLICATION_CREDENTIALS: "
|
||||
"${GOOGLE_APPLICATION_CREDENTIALS:-/app/secrets/gcp-sa.json}"
|
||||
in weathernext2_block
|
||||
)
|
||||
assert "./secrets:/app/secrets:ro" in weathernext2_block
|
||||
assert "POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''" in bot_block
|
||||
assert "POLYWEATHER_COLLECTOR_PATCH_ENDPOINT: ''" in web_block
|
||||
assert (
|
||||
@@ -193,7 +222,7 @@ def test_scan_terminal_backend_timeout_returns_before_next_proxy_abort():
|
||||
ROOT / "web" / "services" / "scan_terminal_config.py"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert 'POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "35000"' in route_source
|
||||
assert 'POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "60000"' in route_source
|
||||
assert '"POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC",\n 30,' in config_source
|
||||
assert '"POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS",\n 1,' in config_source
|
||||
assert (
|
||||
@@ -277,7 +306,11 @@ def test_deploy_script_retries_startup_smoke_checks():
|
||||
assert "smoke_check()" in script
|
||||
assert "wait_for_scan_terminal_snapshot()" in script
|
||||
assert '"status":"ready"' in script
|
||||
assert '"status":"stale"' in script
|
||||
assert "stale snapshot available" in script
|
||||
assert "http=401" in script
|
||||
assert '"stale_reason":"市场扫描快照正在初始化"' in script
|
||||
assert "initializing after attempt" in script
|
||||
assert 'wait_for_scan_terminal_snapshot "scan terminal snapshot" "http://127.0.0.1:3001/api/scan/terminal"' in script
|
||||
assert script.index("wait_for_scan_terminal_snapshot") < script.index("run_public_smoke_checks")
|
||||
assert 'smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5' in script
|
||||
@@ -308,6 +341,7 @@ def test_deploy_script_retries_compose_recreate_races():
|
||||
assert 'compose_up_retry "observation collector" -d --no-deps polyweather_collector' in script
|
||||
assert 'compose_up_retry "cache warmer" -d --no-deps polyweather_warmer' in script
|
||||
assert 'compose_up_retry "training settlement" -d --no-deps polyweather_training_settlement' in script
|
||||
assert 'compose_up_retry "WeatherNext2 worker" -d --no-deps polyweather_weathernext2_worker' in script
|
||||
assert 'compose_up_retry "frontend" -d --no-deps polyweather_frontend' in script
|
||||
|
||||
|
||||
|
||||
@@ -416,10 +416,12 @@ def test_direct_submit_tx_does_not_require_from_address(monkeypatch, tmp_path):
|
||||
assert submitted["tx_hash"] == "0x" + "2" * 64
|
||||
|
||||
|
||||
def test_submit_rejects_direct_tx_until_validation_passes(monkeypatch, tmp_path):
|
||||
def test_direct_submit_accepts_pending_validation_for_background_confirm(monkeypatch, tmp_path):
|
||||
_setup_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
tx_hash = "0x" + "7" * 64
|
||||
submitted = {}
|
||||
transactions = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
@@ -458,17 +460,76 @@ def test_submit_rejects_direct_tx_until_validation_passes(monkeypatch, tmp_path)
|
||||
return []
|
||||
if method == "GET" and table == "payment_intents":
|
||||
return []
|
||||
raise AssertionError(f"submit must not mutate {table} before validation passes")
|
||||
if method == "PATCH" and table == "payment_intents":
|
||||
submitted.update(kwargs["payload"])
|
||||
return [{"ok": True}]
|
||||
if method == "POST" and table == "payment_transactions":
|
||||
transactions.append(kwargs["payload"])
|
||||
return [kwargs["payload"]]
|
||||
raise AssertionError((method, table, kwargs))
|
||||
|
||||
monkeypatch.setattr(service, "_rest", fake_rest)
|
||||
|
||||
try:
|
||||
service.submit_intent_tx("user-1", "intent-direct-pending", tx_hash, "")
|
||||
except Exception as exc:
|
||||
assert getattr(exc, "status_code", None) == 400
|
||||
assert "tx_not_mined" in getattr(exc, "detail", "")
|
||||
else:
|
||||
raise AssertionError("expected tx_not_mined rejection")
|
||||
result = service.submit_intent_tx("user-1", "intent-direct-pending", tx_hash, "")
|
||||
|
||||
assert result["status"] == "submitted"
|
||||
assert submitted["status"] == "submitted"
|
||||
assert submitted["tx_hash"] == tx_hash
|
||||
assert transactions[0]["status"] == "submitted"
|
||||
|
||||
|
||||
def test_direct_submit_accepts_validation_exception_for_background_confirm(monkeypatch, tmp_path):
|
||||
_setup_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
tx_hash = "0x" + "a" * 64
|
||||
submitted = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"get_intent",
|
||||
lambda user_id, intent_id: service._serialize_intent(
|
||||
{
|
||||
"id": intent_id,
|
||||
"plan_code": "pro_monthly",
|
||||
"plan_id": 101,
|
||||
"chain_id": 137,
|
||||
"token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
|
||||
"receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32",
|
||||
"amount_units": "5000000",
|
||||
"payment_mode": "direct",
|
||||
"allowed_wallet": None,
|
||||
"order_id_hex": "0x" + "1" * 64,
|
||||
"status": "created",
|
||||
"expires_at": "2099-01-01T00:00:00+00:00",
|
||||
"tx_hash": None,
|
||||
"metadata": {},
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def fail_validation(*args, **kwargs):
|
||||
raise RuntimeError("rpc timeout")
|
||||
|
||||
monkeypatch.setattr(service, "_validate_loaded_intent_tx", fail_validation)
|
||||
|
||||
def fake_rest(method, table, **kwargs):
|
||||
if method == "GET" and table == "payment_transactions":
|
||||
return []
|
||||
if method == "GET" and table == "payment_intents":
|
||||
return []
|
||||
if method == "PATCH" and table == "payment_intents":
|
||||
submitted.update(kwargs["payload"])
|
||||
return [{"ok": True}]
|
||||
if method == "POST" and table == "payment_transactions":
|
||||
return [kwargs["payload"]]
|
||||
raise AssertionError((method, table, kwargs))
|
||||
|
||||
monkeypatch.setattr(service, "_rest", fake_rest)
|
||||
|
||||
result = service.submit_intent_tx("user-1", "intent-direct-rpc-pending", tx_hash, "")
|
||||
|
||||
assert result["status"] == "submitted"
|
||||
assert submitted["tx_hash"] == tx_hash
|
||||
|
||||
|
||||
def test_submit_rejects_mined_direct_tx_when_receiver_mismatches(monkeypatch, tmp_path):
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import time
|
||||
|
||||
from src.data_collection.nws_open_meteo_sources import (
|
||||
OPEN_METEO_MULTI_MODEL_ORDER,
|
||||
_parse_open_meteo_multi_model_daily,
|
||||
@@ -268,6 +271,8 @@ def test_fetch_all_sources_delegates_non_hf_forecast_bundle(monkeypatch, tmp_pat
|
||||
def test_open_meteo_cache_only_reads_multi_model_without_forecast_cache():
|
||||
from src.data_collection.forecast_source_bundle import fetch_open_meteo_forecast_bundle
|
||||
|
||||
today = datetime.now(timezone.utc).date().isoformat()
|
||||
|
||||
class DummyLock:
|
||||
def __enter__(self):
|
||||
return None
|
||||
@@ -283,7 +288,7 @@ def test_open_meteo_cache_only_reads_multi_model_without_forecast_cache():
|
||||
_multi_model_cache = {
|
||||
"48.9694:2.4414:paris:c:v5": {
|
||||
"data": {
|
||||
"hourly_times": ["2026-06-16T15:00"],
|
||||
"hourly_times": [f"{today}T15:00"],
|
||||
"hourly_forecasts": {"ECMWF": [24.5]},
|
||||
"forecasts": {"ECMWF": 27.0},
|
||||
}
|
||||
@@ -450,6 +455,123 @@ def test_persisted_open_meteo_cooldown_skips_outbound_request(monkeypatch, tmp_p
|
||||
assert result is not None # cooldown returns cached data
|
||||
|
||||
|
||||
def test_fetch_multi_model_ignores_cache_when_dates_are_stale(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("OPEN_METEO_DISK_CACHE_PATH", str(tmp_path / "om-cache.json"))
|
||||
collector = WeatherDataCollector({})
|
||||
today = datetime.now(timezone.utc).date()
|
||||
old_dates = [
|
||||
(today - timedelta(days=11)).isoformat(),
|
||||
(today - timedelta(days=10)).isoformat(),
|
||||
]
|
||||
fresh_dates = [today.isoformat(), (today + timedelta(days=1)).isoformat()]
|
||||
cache_key = (
|
||||
f"{round(float(48.9694), 4)}:{round(float(2.4414), 4)}:paris:"
|
||||
f"c:{collector.multi_model_cache_version}"
|
||||
)
|
||||
collector._multi_model_cache[cache_key] = {
|
||||
"t": time.time(),
|
||||
"data": {
|
||||
"dates": old_dates,
|
||||
"daily_forecasts": {old_dates[0]: {"ECMWF": 24.0}},
|
||||
"forecasts": {"ECMWF": 24.0},
|
||||
"hourly_times": [f"{old_dates[0]}T15:00"],
|
||||
"hourly_forecasts": {"ECMWF": [23.0]},
|
||||
},
|
||||
}
|
||||
calls = []
|
||||
|
||||
class FakeResponse:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {
|
||||
"daily": {
|
||||
"time": fresh_dates,
|
||||
"temperature_2m_max_ecmwf_ifs025": [39.2, 33.0],
|
||||
"temperature_2m_max_gfs_seamless": [38.6, 32.5],
|
||||
},
|
||||
"hourly": {
|
||||
"time": [f"{fresh_dates[0]}T15:00", f"{fresh_dates[0]}T16:00"],
|
||||
"temperature_2m_ecmwf_ifs025": [38.8, 39.2],
|
||||
"temperature_2m_gfs_seamless": [38.0, 38.6],
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(collector, "_wait_open_meteo_slot", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
collector,
|
||||
"_http_get",
|
||||
lambda *args, **kwargs: calls.append((args, kwargs)) or FakeResponse(),
|
||||
)
|
||||
|
||||
result = collector.fetch_multi_model(48.9694, 2.4414, city="paris")
|
||||
|
||||
assert len(calls) == 1
|
||||
assert result["dates"][:2] == fresh_dates
|
||||
assert result["forecasts"]["ECMWF"] == 39.2
|
||||
assert result["forecasts"]["GFS"] == 38.6
|
||||
|
||||
|
||||
def test_fetch_open_meteo_ignores_cache_when_dates_are_stale(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("OPEN_METEO_DISK_CACHE_PATH", str(tmp_path / "om-cache.json"))
|
||||
collector = WeatherDataCollector({})
|
||||
today = datetime.now(timezone.utc).date()
|
||||
old_dates = [
|
||||
(today - timedelta(days=11)).isoformat(),
|
||||
(today - timedelta(days=10)).isoformat(),
|
||||
]
|
||||
fresh_dates = [today.isoformat(), (today + timedelta(days=1)).isoformat()]
|
||||
cache_key = f"{round(float(48.9694), 4)}:{round(float(2.4414), 4)}:14:c"
|
||||
collector._open_meteo_cache[cache_key] = {
|
||||
"t": time.time(),
|
||||
"data": {
|
||||
"source": "open-meteo",
|
||||
"daily": {
|
||||
"time": old_dates,
|
||||
"temperature_2m_max": [24.5, 26.6],
|
||||
},
|
||||
"hourly": {"time": [f"{old_dates[0]}T15:00"], "temperature_2m": [24.0]},
|
||||
},
|
||||
}
|
||||
calls = []
|
||||
|
||||
class FakeResponse:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {
|
||||
"current_weather": {"temperature": 32.0},
|
||||
"utc_offset_seconds": 7200,
|
||||
"timezone": "Europe/Paris",
|
||||
"daily": {
|
||||
"time": fresh_dates,
|
||||
"temperature_2m_max": [39.2, 33.0],
|
||||
"sunrise": [f"{fresh_dates[0]}T05:30", f"{fresh_dates[1]}T05:31"],
|
||||
"sunset": [f"{fresh_dates[0]}T21:55", f"{fresh_dates[1]}T21:55"],
|
||||
"sunshine_duration": [36000, 33000],
|
||||
},
|
||||
"hourly": {
|
||||
"time": [f"{fresh_dates[0]}T15:00", f"{fresh_dates[0]}T16:00"],
|
||||
"temperature_2m": [38.8, 39.2],
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(collector, "_wait_open_meteo_slot", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
collector,
|
||||
"_http_get",
|
||||
lambda *args, **kwargs: calls.append((args, kwargs)) or FakeResponse(),
|
||||
)
|
||||
|
||||
result = collector.fetch_from_open_meteo(48.9694, 2.4414)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert result["daily"]["time"][:2] == fresh_dates
|
||||
assert result["daily"]["temperature_2m_max"][0] == 39.2
|
||||
|
||||
|
||||
def test_multi_model_hourly_parser():
|
||||
from src.data_collection.nws_open_meteo_sources import _parse_open_meteo_multi_model_hourly
|
||||
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
import web.services.ops.market_opportunities as market_opportunities
|
||||
from web.services.ops.market_opportunities import (
|
||||
build_market_opportunity_rows,
|
||||
get_ops_market_opportunities,
|
||||
parse_market_option_from_question,
|
||||
)
|
||||
|
||||
|
||||
def _row(city="shanghai", symbol="°C"):
|
||||
return {
|
||||
"city": city,
|
||||
"city_display_name": city.title(),
|
||||
"local_date": "2026-07-03",
|
||||
"local_time": "18:10",
|
||||
"temp_symbol": symbol,
|
||||
"current_max_so_far": 31.0,
|
||||
"deb_prediction": 32.2,
|
||||
"model_cluster_sources": {"ECMWF": 32.0, "GFS": 33.0, "ICON": 31.0},
|
||||
"distribution_full": [
|
||||
{"value": 31, "probability": 0.10},
|
||||
{"value": 32, "probability": 0.41},
|
||||
{"value": 33, "probability": 0.46},
|
||||
{"value": 34, "probability": 0.03},
|
||||
],
|
||||
"trading_region_label_zh": "东亚",
|
||||
}
|
||||
|
||||
|
||||
def test_parse_market_option_supports_celsius_single_temperature():
|
||||
option = parse_market_option_from_question(
|
||||
"Will the highest temperature in Shanghai be 33°C on July 3?",
|
||||
"°C",
|
||||
)
|
||||
|
||||
assert option["label"] == "33°C"
|
||||
assert option["lower"] == 33
|
||||
assert option["upper"] == 33
|
||||
|
||||
|
||||
def test_parse_market_option_supports_fahrenheit_two_degree_range():
|
||||
option = parse_market_option_from_question(
|
||||
"Will the highest temperature in Houston be between 94-95°F on July 3?",
|
||||
"°F",
|
||||
)
|
||||
|
||||
assert option["label"] == "94-95°F"
|
||||
assert option["lower"] == 94
|
||||
assert option["upper"] == 95
|
||||
|
||||
|
||||
def test_build_market_opportunities_scans_yes_and_no_low_price_edges():
|
||||
event = {
|
||||
"slug": "highest-temperature-in-shanghai-on-july-3-2026",
|
||||
"title": "Highest temperature in Shanghai on July 3?",
|
||||
"markets": [
|
||||
{
|
||||
"question": "Will the highest temperature in Shanghai be 33°C on July 3?",
|
||||
"slug": "highest-temperature-in-shanghai-on-july-3-2026-33c",
|
||||
"active": True,
|
||||
"closed": False,
|
||||
"enableOrderBook": True,
|
||||
"liquidity": "47000",
|
||||
"volume": "13000",
|
||||
"outcomes": '["Yes", "No"]',
|
||||
"clobTokenIds": '["yes-33", "no-33"]',
|
||||
},
|
||||
{
|
||||
"question": "Will the highest temperature in Shanghai be 31°C on July 3?",
|
||||
"slug": "highest-temperature-in-shanghai-on-july-3-2026-31c",
|
||||
"active": True,
|
||||
"closed": False,
|
||||
"enableOrderBook": True,
|
||||
"liquidity": "27000",
|
||||
"volume": "6000",
|
||||
"outcomes": '["Yes", "No"]',
|
||||
"clobTokenIds": '["yes-31", "no-31"]',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
rows = build_market_opportunity_rows(
|
||||
[_row()],
|
||||
{"shanghai": event},
|
||||
{
|
||||
"yes-33": 0.18,
|
||||
"no-33": 0.62,
|
||||
"yes-31": 0.12,
|
||||
"no-31": 0.18,
|
||||
},
|
||||
max_price=0.20,
|
||||
side="both",
|
||||
positive_edge_only=True,
|
||||
min_edge=0.0,
|
||||
limit=20,
|
||||
)
|
||||
|
||||
yes = next(row for row in rows if row["bucket_label"] == "33°C" and row["side"] == "yes")
|
||||
no = next(row for row in rows if row["bucket_label"] == "31°C" and row["side"] == "no")
|
||||
|
||||
assert yes["model_probability"] == 0.46
|
||||
assert yes["yes_probability"] == 0.46
|
||||
assert yes["side_probability"] == 0.46
|
||||
assert yes["model_cluster_sources"] == {"ECMWF": 32.0, "GFS": 33.0, "ICON": 31.0}
|
||||
assert yes["model_min"] == 31.0
|
||||
assert yes["model_max"] == 33.0
|
||||
assert yes["models_below_bucket"] == 2
|
||||
assert yes["models_in_bucket"] == 1
|
||||
assert yes["models_above_bucket"] == 0
|
||||
assert yes["models_above_deb"] == 1
|
||||
assert yes["ask_price"] == 0.18
|
||||
assert round(yes["edge"], 2) == 0.28
|
||||
assert no["model_probability"] == 0.10
|
||||
assert no["yes_probability"] == 0.10
|
||||
assert no["side_probability"] == 0.90
|
||||
assert no["ask_price"] == 0.18
|
||||
assert round(no["edge"], 2) == 0.72
|
||||
assert all(row["ask_price"] < 0.20 for row in rows)
|
||||
assert rows[0]["edge"] >= rows[-1]["edge"]
|
||||
|
||||
|
||||
def test_build_market_opportunities_includes_ask_equal_to_max_price():
|
||||
event = {
|
||||
"slug": "highest-temperature-in-shanghai-on-july-3-2026",
|
||||
"markets": [
|
||||
{
|
||||
"question": "Will the highest temperature in Shanghai be 33°C on July 3?",
|
||||
"slug": "highest-temperature-in-shanghai-on-july-3-2026-33c",
|
||||
"active": True,
|
||||
"closed": False,
|
||||
"enableOrderBook": True,
|
||||
"outcomes": '["Yes", "No"]',
|
||||
"clobTokenIds": '["yes-33", "no-33"]',
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
rows = build_market_opportunity_rows(
|
||||
[_row()],
|
||||
{"shanghai": event},
|
||||
{"yes-33": 0.18, "no-33": 0.82},
|
||||
max_price=0.18,
|
||||
side="yes",
|
||||
positive_edge_only=True,
|
||||
min_edge=0.0,
|
||||
limit=20,
|
||||
)
|
||||
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["ask_price"] == 0.18
|
||||
|
||||
|
||||
def test_build_market_opportunities_aggregates_fahrenheit_distribution():
|
||||
event = {
|
||||
"slug": "highest-temperature-in-houston-on-july-3-2026",
|
||||
"markets": [
|
||||
{
|
||||
"question": "Will the highest temperature in Houston be between 94-95°F on July 3?",
|
||||
"slug": "highest-temperature-in-houston-on-july-3-2026-94-95f",
|
||||
"active": True,
|
||||
"closed": False,
|
||||
"enableOrderBook": True,
|
||||
"liquidity": "10000",
|
||||
"volume": "2000",
|
||||
"outcomes": '["Yes", "No"]',
|
||||
"clobTokenIds": '["yes-94", "no-94"]',
|
||||
}
|
||||
],
|
||||
}
|
||||
row = _row("houston", "°F")
|
||||
row["distribution_full"] = [
|
||||
{"value": 94, "probability": 0.40},
|
||||
{"value": 95, "probability": 0.25},
|
||||
{"value": 96, "probability": 0.10},
|
||||
]
|
||||
|
||||
rows = build_market_opportunity_rows(
|
||||
[row],
|
||||
{"houston": event},
|
||||
{"yes-94": 0.19, "no-94": 0.81},
|
||||
max_price=0.20,
|
||||
side="yes",
|
||||
positive_edge_only=True,
|
||||
min_edge=0.0,
|
||||
limit=20,
|
||||
)
|
||||
|
||||
assert rows[0]["bucket_label"] == "94-95°F"
|
||||
assert rows[0]["model_probability"] == 0.65
|
||||
assert round(rows[0]["edge"], 2) == 0.46
|
||||
|
||||
|
||||
def test_build_market_opportunities_filters_late_priced_no_when_models_are_in_bucket():
|
||||
event = {
|
||||
"slug": "highest-temperature-in-jeddah-on-july-3-2026",
|
||||
"markets": [
|
||||
{
|
||||
"question": "Will the highest temperature in Jeddah be 36°C on July 3?",
|
||||
"slug": "highest-temperature-in-jeddah-on-july-3-2026-36c",
|
||||
"active": True,
|
||||
"closed": False,
|
||||
"enableOrderBook": True,
|
||||
"liquidity": "154",
|
||||
"volume": "6833",
|
||||
"outcomes": '["Yes", "No"]',
|
||||
"clobTokenIds": '["yes-36", "no-36"]',
|
||||
}
|
||||
],
|
||||
}
|
||||
row = _row("jeddah")
|
||||
row["local_time"] = "19:22"
|
||||
row["current_max_so_far"] = None
|
||||
row["deb_prediction"] = 35.8
|
||||
row["model_cluster_sources"] = {"ECMWF": 35.7, "GFS": 36.1, "ICON": 36.2}
|
||||
row["distribution_full"] = [
|
||||
{"value": 36, "probability": 0.10},
|
||||
{"value": 37, "probability": 0.90},
|
||||
]
|
||||
|
||||
rows = build_market_opportunity_rows(
|
||||
[row],
|
||||
{"jeddah": event},
|
||||
{"yes-36": 0.99, "no-36": 0.01},
|
||||
max_price=0.20,
|
||||
side="both",
|
||||
positive_edge_only=True,
|
||||
min_edge=0.0,
|
||||
limit=20,
|
||||
)
|
||||
|
||||
assert rows == []
|
||||
|
||||
|
||||
def test_build_market_opportunities_filters_priced_no_when_models_support_market_bucket():
|
||||
event = {
|
||||
"slug": "highest-temperature-in-buenos-aires-on-july-3-2026",
|
||||
"markets": [
|
||||
{
|
||||
"question": "Will the highest temperature in Buenos Aires be 10°C on July 3?",
|
||||
"slug": "highest-temperature-in-buenos-aires-on-july-3-2026-10c",
|
||||
"active": True,
|
||||
"closed": False,
|
||||
"enableOrderBook": True,
|
||||
"liquidity": "141",
|
||||
"volume": "7749",
|
||||
"outcomes": '["Yes", "No"]',
|
||||
"clobTokenIds": '["yes-10", "no-10"]',
|
||||
}
|
||||
],
|
||||
}
|
||||
row = _row("buenos aires")
|
||||
row["local_time"] = "15:21"
|
||||
row["current_max_so_far"] = None
|
||||
row["deb_prediction"] = 8.7
|
||||
row["model_cluster_sources"] = {
|
||||
"AI-GFS": 8.4,
|
||||
"ECMWF": 8.3,
|
||||
"ECMWF AIFS": 9.2,
|
||||
"GFS": 9.5,
|
||||
"ICON": 9.1,
|
||||
"GEM": 9.0,
|
||||
"GDPS": 8.9,
|
||||
"JMA": 8.8,
|
||||
"AROME HD": 9.5,
|
||||
}
|
||||
row["distribution_full"] = [
|
||||
{"value": 9, "probability": 0.27},
|
||||
{"value": 10, "probability": 0.73},
|
||||
]
|
||||
|
||||
rows = build_market_opportunity_rows(
|
||||
[row],
|
||||
{"buenos aires": event},
|
||||
{"yes-10": 0.94, "no-10": 0.08},
|
||||
max_price=0.20,
|
||||
side="both",
|
||||
positive_edge_only=True,
|
||||
min_edge=0.0,
|
||||
limit=20,
|
||||
)
|
||||
|
||||
assert rows == []
|
||||
|
||||
|
||||
def test_build_market_opportunities_keeps_late_no_when_bucket_conflicts_with_anchors():
|
||||
event = {
|
||||
"slug": "highest-temperature-in-jeddah-on-july-3-2026",
|
||||
"markets": [
|
||||
{
|
||||
"question": "Will the highest temperature in Jeddah be 36°C on July 3?",
|
||||
"slug": "highest-temperature-in-jeddah-on-july-3-2026-36c",
|
||||
"active": True,
|
||||
"closed": False,
|
||||
"enableOrderBook": True,
|
||||
"liquidity": "154",
|
||||
"volume": "6833",
|
||||
"outcomes": '["Yes", "No"]',
|
||||
"clobTokenIds": '["yes-36", "no-36"]',
|
||||
}
|
||||
],
|
||||
}
|
||||
row = _row("jeddah")
|
||||
row["local_time"] = "19:22"
|
||||
row["current_max_so_far"] = None
|
||||
row["deb_prediction"] = 31.0
|
||||
row["model_cluster_sources"] = {"ECMWF": 30.5, "GFS": 31.0, "ICON": 31.5}
|
||||
row["distribution_full"] = [
|
||||
{"value": 36, "probability": 0.10},
|
||||
{"value": 31, "probability": 0.90},
|
||||
]
|
||||
|
||||
rows = build_market_opportunity_rows(
|
||||
[row],
|
||||
{"jeddah": event},
|
||||
{"yes-36": 0.99, "no-36": 0.01},
|
||||
max_price=0.20,
|
||||
side="both",
|
||||
positive_edge_only=True,
|
||||
min_edge=0.0,
|
||||
limit=20,
|
||||
)
|
||||
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["bucket_label"] == "36°C"
|
||||
assert rows[0]["side"] == "no"
|
||||
assert rows[0]["side_probability"] == 0.90
|
||||
|
||||
|
||||
def test_build_market_opportunities_keeps_late_no_when_models_are_above_bucket():
|
||||
event = {
|
||||
"slug": "highest-temperature-in-jeddah-on-july-3-2026",
|
||||
"markets": [
|
||||
{
|
||||
"question": "Will the highest temperature in Jeddah be 36°C on July 3?",
|
||||
"slug": "highest-temperature-in-jeddah-on-july-3-2026-36c",
|
||||
"active": True,
|
||||
"closed": False,
|
||||
"enableOrderBook": True,
|
||||
"liquidity": "154",
|
||||
"volume": "6833",
|
||||
"outcomes": '["Yes", "No"]',
|
||||
"clobTokenIds": '["yes-36", "no-36"]',
|
||||
}
|
||||
],
|
||||
}
|
||||
row = _row("jeddah")
|
||||
row["local_time"] = "19:22"
|
||||
row["current_max_so_far"] = None
|
||||
row["deb_prediction"] = 35.3
|
||||
row["model_cluster_sources"] = {"ECMWF": 37.0, "GFS": 38.5, "ICON": 36.8}
|
||||
row["distribution_full"] = [
|
||||
{"value": 36, "probability": 0.10},
|
||||
{"value": 37, "probability": 0.90},
|
||||
]
|
||||
|
||||
rows = build_market_opportunity_rows(
|
||||
[row],
|
||||
{"jeddah": event},
|
||||
{"yes-36": 0.99, "no-36": 0.01},
|
||||
max_price=0.20,
|
||||
side="both",
|
||||
positive_edge_only=True,
|
||||
min_edge=0.0,
|
||||
limit=20,
|
||||
)
|
||||
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["models_above_bucket"] == 3
|
||||
assert rows[0]["models_in_bucket"] == 0
|
||||
assert rows[0]["side"] == "no"
|
||||
|
||||
|
||||
def test_ops_market_opportunities_requires_ops_admin(monkeypatch):
|
||||
def deny(_request):
|
||||
raise HTTPException(status_code=403, detail="ops only")
|
||||
|
||||
monkeypatch.setattr(market_opportunities, "_require_ops", deny)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
get_ops_market_opportunities(object())
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_ops_market_opportunities_degrades_when_quotes_fail(monkeypatch):
|
||||
monkeypatch.setattr(market_opportunities, "_require_ops", lambda _request: {"email": "ops@example.com"})
|
||||
monkeypatch.setattr(
|
||||
market_opportunities,
|
||||
"build_scan_terminal_payload",
|
||||
lambda *_args, **_kwargs: {"rows": [_row()]},
|
||||
)
|
||||
|
||||
def fail_quotes(*_args, **_kwargs):
|
||||
raise RuntimeError("polymarket down")
|
||||
|
||||
monkeypatch.setattr(market_opportunities, "_collect_events_and_prices", fail_quotes)
|
||||
|
||||
payload = get_ops_market_opportunities(object())
|
||||
|
||||
assert payload["summary"]["quote_status"] == "unavailable"
|
||||
assert payload["summary"]["error"] == "polymarket down"
|
||||
assert payload["rows"] == []
|
||||
|
||||
|
||||
def test_ops_market_opportunities_reuses_prewarmed_terminal_snapshot(monkeypatch):
|
||||
captured = {}
|
||||
monkeypatch.setattr(market_opportunities, "_require_ops", lambda _request: {"email": "ops@example.com"})
|
||||
|
||||
def fake_scan(filters, *, force_refresh=False):
|
||||
captured["filters"] = filters
|
||||
captured["force_refresh"] = force_refresh
|
||||
return {"rows": [_row()]}
|
||||
|
||||
monkeypatch.setattr(market_opportunities, "build_scan_terminal_payload", fake_scan)
|
||||
monkeypatch.setattr(
|
||||
market_opportunities,
|
||||
"_collect_events_and_prices",
|
||||
lambda rows, _scanner: ({row["city"]: None for row in rows}, {}, "ready"),
|
||||
)
|
||||
|
||||
payload = get_ops_market_opportunities(object(), max_price=0.90, min_edge=0.0)
|
||||
|
||||
assert payload["summary"]["scanned_city_count"] == 1
|
||||
assert captured["force_refresh"] is False
|
||||
assert captured["filters"]["limit"] == 180
|
||||
assert captured["filters"]["min_edge_pct"] == 2
|
||||
assert captured["filters"]["min_liquidity"] == 500
|
||||
|
||||
|
||||
def test_ops_market_opportunities_force_refreshes_empty_terminal_snapshot(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(market_opportunities, "_require_ops", lambda _request: {"email": "ops@example.com"})
|
||||
|
||||
def fake_scan(_filters, *, force_refresh=False):
|
||||
calls.append(force_refresh)
|
||||
return {"rows": [_row()]} if force_refresh else {"rows": [], "status": "ready"}
|
||||
|
||||
monkeypatch.setattr(market_opportunities, "build_scan_terminal_payload", fake_scan)
|
||||
monkeypatch.setattr(
|
||||
market_opportunities,
|
||||
"_collect_events_and_prices",
|
||||
lambda rows, _scanner: ({row["city"]: None for row in rows}, {}, "ready"),
|
||||
)
|
||||
|
||||
payload = get_ops_market_opportunities(object(), max_price=0.90, min_edge=0.0)
|
||||
|
||||
assert calls == [False, True]
|
||||
assert payload["summary"]["scanned_city_count"] == 1
|
||||
assert payload["summary"]["quote_status"] == "ready"
|
||||
assert payload["summary"]["scan_source"] == "force_refresh"
|
||||
|
||||
|
||||
def test_ops_market_opportunities_reports_scan_empty_when_refresh_is_empty(monkeypatch):
|
||||
monkeypatch.setattr(market_opportunities, "_require_ops", lambda _request: {"email": "ops@example.com"})
|
||||
monkeypatch.setattr(
|
||||
market_opportunities,
|
||||
"build_scan_terminal_payload",
|
||||
lambda *_args, **_kwargs: {"rows": [], "status": "ready"},
|
||||
)
|
||||
|
||||
payload = get_ops_market_opportunities(object(), max_price=0.90, min_edge=0.0)
|
||||
|
||||
assert payload["summary"]["scanned_city_count"] == 0
|
||||
assert payload["summary"]["quote_status"] == "scan_empty"
|
||||
assert payload["summary"]["matched_event_count"] == 0
|
||||
assert payload["rows"] == []
|
||||
|
||||
|
||||
def test_collect_events_and_prices_uses_gamma_hint_prices_without_clob_fanout():
|
||||
class FakeScanner:
|
||||
def fetch_event(self, _slug):
|
||||
return {
|
||||
"slug": "highest-temperature-in-shanghai-on-july-3-2026",
|
||||
"markets": [
|
||||
{
|
||||
"question": "Will the highest temperature in Shanghai be 33°C on July 3?",
|
||||
"outcomes": '["Yes", "No"]',
|
||||
"clobTokenIds": '["yes-33", "no-33"]',
|
||||
"outcomePrices": '["0.12", "0.88"]',
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def fetch_ask_price(self, _token_id):
|
||||
raise AssertionError("CLOB should not be called when Gamma outcomePrices are present")
|
||||
|
||||
events, prices, status = market_opportunities._collect_events_and_prices(
|
||||
[_row()],
|
||||
FakeScanner(),
|
||||
)
|
||||
|
||||
assert status == "ready"
|
||||
assert "shanghai" in events
|
||||
assert prices == {"yes-33": 0.12, "no-33": 0.88}
|
||||
|
||||
|
||||
def test_collect_events_and_prices_stops_on_time_budget():
|
||||
class SlowScanner:
|
||||
def fetch_event(self, _slug):
|
||||
raise AssertionError("time budget should stop external event fetching")
|
||||
|
||||
def fetch_ask_price(self, _token_id):
|
||||
raise AssertionError("time budget should stop external price fetching")
|
||||
|
||||
events, prices, status = market_opportunities._collect_events_and_prices(
|
||||
[_row()],
|
||||
SlowScanner(),
|
||||
time_budget_sec=0,
|
||||
)
|
||||
|
||||
assert status == "partial"
|
||||
assert events == {}
|
||||
assert prices == {}
|
||||
@@ -1,3 +1,6 @@
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from web.scan_terminal_filters import normalize_scan_terminal_filters
|
||||
from web import scan_terminal_cache
|
||||
from web import scan_terminal_service
|
||||
@@ -18,6 +21,10 @@ from web.routers.scan import router as scan_router
|
||||
from web.scan_terminal_service import _scan_terminal_prewarm_filters
|
||||
|
||||
|
||||
def _local_date_for_offset(offset_seconds):
|
||||
return (datetime.now(timezone.utc) + timedelta(seconds=offset_seconds)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
class _FakeRedis:
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
@@ -52,6 +59,19 @@ def test_scan_terminal_cache_hydrates_success_payload_from_redis(monkeypatch):
|
||||
assert cached["summary"]["candidate_total"] == 1
|
||||
|
||||
|
||||
def test_scan_terminal_redis_cache_prefix_skips_legacy_blank_snapshots(monkeypatch):
|
||||
monkeypatch.delenv("POLYWEATHER_SCAN_TERMINAL_REDIS_CACHE_PREFIX", raising=False)
|
||||
|
||||
assert scan_terminal_cache._redis_cache_prefix() == "polyweather:scan_terminal:v2:"
|
||||
|
||||
monkeypatch.setenv(
|
||||
"POLYWEATHER_SCAN_TERMINAL_REDIS_CACHE_PREFIX",
|
||||
"polyweather:scan_terminal:v1:",
|
||||
)
|
||||
|
||||
assert scan_terminal_cache._redis_cache_prefix() == "polyweather:scan_terminal:v2:"
|
||||
|
||||
|
||||
def test_scan_terminal_failure_state_preserves_redis_success_payload(monkeypatch):
|
||||
fake_redis = _FakeRedis()
|
||||
monkeypatch.setenv("POLYWEATHER_SCAN_TERMINAL_REDIS_CACHE_ENABLED", "true")
|
||||
@@ -257,7 +277,7 @@ def test_scan_terminal_prewarm_queues_city_refresh_without_analyze(monkeypatch):
|
||||
def test_scan_city_terminal_rows_reuses_persisted_panel_cache(monkeypatch):
|
||||
payload = {
|
||||
"display_name": "Paris",
|
||||
"local_date": "2026-06-10",
|
||||
"local_date": _local_date_for_offset(3600),
|
||||
"local_time": "12:00",
|
||||
"current": {"temp": 18.0},
|
||||
"risk": {},
|
||||
@@ -270,7 +290,7 @@ def test_scan_city_terminal_rows_reuses_persisted_panel_cache(monkeypatch):
|
||||
@staticmethod
|
||||
def get_city_cache(kind, city):
|
||||
assert (kind, city) == ("panel", "paris")
|
||||
return {"payload": payload}
|
||||
return {"payload": payload, "updated_at_ts": time.time()}
|
||||
|
||||
monkeypatch.setattr(scan_terminal_city_row, "_PANEL_CACHE_DB", _Cache())
|
||||
monkeypatch.setattr(
|
||||
@@ -291,6 +311,553 @@ def test_scan_city_terminal_rows_reuses_persisted_panel_cache(monkeypatch):
|
||||
assert result["rows"][0]["current_temp"] == 18.0
|
||||
|
||||
|
||||
def test_scan_city_terminal_rows_rejects_expired_panel_cache(monkeypatch):
|
||||
enqueued = []
|
||||
payload = {
|
||||
"display_name": "Paris",
|
||||
"local_date": _local_date_for_offset(3600),
|
||||
"local_time": "12:00",
|
||||
"current": {"temp": 18.0},
|
||||
"risk": {},
|
||||
"deb": {"prediction": 20.0},
|
||||
"probabilities": {},
|
||||
"multi_model_daily": {},
|
||||
}
|
||||
|
||||
class _Cache:
|
||||
@staticmethod
|
||||
def get_city_cache(kind, city):
|
||||
assert (kind, city) == ("panel", "paris")
|
||||
return {"payload": payload, "updated_at_ts": 1}
|
||||
|
||||
@staticmethod
|
||||
def get_canonical_temperature(city):
|
||||
assert city == "paris"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def enqueue_observation_refresh_request(**kwargs):
|
||||
enqueued.append(kwargs)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(scan_terminal_city_row, "_PANEL_CACHE_DB", _Cache())
|
||||
monkeypatch.setattr(scan_terminal_city_row._weather, "fetch_multi_model", lambda *args, **kwargs: None)
|
||||
|
||||
result = scan_terminal_city_row._scan_city_terminal_rows(
|
||||
"paris",
|
||||
{"market_type": "maxtemp"},
|
||||
force_refresh=False,
|
||||
)
|
||||
|
||||
assert result["city"] == "paris"
|
||||
assert result["rows"] == []
|
||||
assert enqueued == [
|
||||
{
|
||||
"city": "paris",
|
||||
"kind": "panel",
|
||||
"priority": "high",
|
||||
"reason": "scan_terminal_stale_panel",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_scan_city_terminal_rows_refreshes_models_for_wrong_local_date_panel(monkeypatch):
|
||||
enqueued = []
|
||||
today = _local_date_for_offset(3600)
|
||||
payload = {
|
||||
"display_name": "Paris",
|
||||
"local_date": "2000-01-01",
|
||||
"local_time": "12:00",
|
||||
"current": {"temp": 18.0},
|
||||
"risk": {},
|
||||
"deb": {"prediction": 20.0},
|
||||
"probabilities": {},
|
||||
"multi_model_daily": {
|
||||
"2000-01-01": {
|
||||
"deb": {"prediction": 20.0},
|
||||
"models": {"ECMWF": 19.0},
|
||||
}
|
||||
},
|
||||
}
|
||||
fetched = []
|
||||
|
||||
class _Cache:
|
||||
@staticmethod
|
||||
def get_city_cache(kind, city):
|
||||
assert (kind, city) == ("panel", "paris")
|
||||
return {"payload": payload, "updated_at_ts": time.time()}
|
||||
|
||||
@staticmethod
|
||||
def get_canonical_temperature(city):
|
||||
assert city == "paris"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def enqueue_observation_refresh_request(**kwargs):
|
||||
enqueued.append(kwargs)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(scan_terminal_city_row, "_PANEL_CACHE_DB", _Cache())
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_city_row._weather,
|
||||
"fetch_multi_model",
|
||||
lambda lat, lon, city="", use_fahrenheit=False: (
|
||||
fetched.append((lat, lon, city, use_fahrenheit))
|
||||
or {
|
||||
"daily_forecasts": {
|
||||
today: {"ECMWF": 22.0, "GFS": 23.0},
|
||||
},
|
||||
"forecasts": {"ECMWF": 99.0},
|
||||
"dates": [today],
|
||||
"unit": "celsius",
|
||||
}
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_city_row,
|
||||
"calculate_deb_prediction",
|
||||
lambda city, forecasts, raw_calculator=None: {
|
||||
"prediction": 22.4,
|
||||
"raw_prediction": 22.5,
|
||||
"version": "test-deb",
|
||||
},
|
||||
)
|
||||
|
||||
result = scan_terminal_city_row._scan_city_terminal_rows(
|
||||
"paris",
|
||||
{"market_type": "maxtemp"},
|
||||
force_refresh=False,
|
||||
)
|
||||
|
||||
assert result["city"] == "paris"
|
||||
assert fetched, "scan terminal should fetch today's multi-model forecast when panel date is stale"
|
||||
assert result["rows"][0]["local_date"] == today
|
||||
assert result["rows"][0]["forecast_refreshed"] is True
|
||||
assert result["rows"][0]["forecast_source_local_date"] == today
|
||||
assert result["rows"][0]["deb_prediction"] == 22.4
|
||||
assert result["rows"][0]["model_cluster_sources"] == {"ECMWF": 22.0, "GFS": 23.0}
|
||||
assert enqueued == [
|
||||
{
|
||||
"city": "paris",
|
||||
"kind": "panel",
|
||||
"priority": "high",
|
||||
"reason": "scan_terminal_stale_panel_date",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_scan_city_terminal_rows_reuses_cached_today_models_when_direct_fetch_disabled(monkeypatch):
|
||||
today = _local_date_for_offset(3600)
|
||||
enqueued = []
|
||||
payload = {
|
||||
"display_name": "Paris",
|
||||
"local_date": "2000-01-01",
|
||||
"local_time": "12:00",
|
||||
"utc_offset_seconds": 3600,
|
||||
"current": {"max_so_far": 20.0},
|
||||
"risk": {},
|
||||
"deb": {"prediction": 20.0},
|
||||
"probabilities": {},
|
||||
"multi_model_daily": {
|
||||
today: {
|
||||
"deb": {"prediction": 22.4},
|
||||
"models": {"ECMWF": 22.0, "GFS": 23.0},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
class _Cache:
|
||||
@staticmethod
|
||||
def get_city_cache(kind, city):
|
||||
assert (kind, city) == ("panel", "paris")
|
||||
return {"payload": payload, "updated_at_ts": 1}
|
||||
|
||||
@staticmethod
|
||||
def get_canonical_temperature(city):
|
||||
assert city == "paris"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def enqueue_observation_refresh_request(**kwargs):
|
||||
enqueued.append(kwargs)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_city_row,
|
||||
"CITIES",
|
||||
{"paris": {"lat": 48.85, "lon": 2.35, "tz": 3600}},
|
||||
)
|
||||
monkeypatch.setattr(scan_terminal_city_row, "_PANEL_CACHE_DB", _Cache())
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_city_row._weather,
|
||||
"fetch_multi_model",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("direct multi-model fetch should stay disabled")
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_city_row,
|
||||
"calculate_deb_prediction",
|
||||
lambda city, forecasts, raw_calculator=None: {"prediction": 22.4},
|
||||
)
|
||||
|
||||
result = scan_terminal_city_row._scan_city_terminal_rows(
|
||||
"paris",
|
||||
{"market_type": "maxtemp"},
|
||||
force_refresh=False,
|
||||
allow_direct_fetch=False,
|
||||
)
|
||||
|
||||
row = result["rows"][0]
|
||||
assert row["local_date"] == today
|
||||
assert row["forecast_refreshed"] is True
|
||||
assert row["forecast_source_local_date"] == today
|
||||
assert row["deb_prediction"] == 22.4
|
||||
assert row["model_cluster_sources"] == {"ECMWF": 22.0, "GFS": 23.0}
|
||||
assert enqueued == [
|
||||
{
|
||||
"city": "paris",
|
||||
"kind": "panel",
|
||||
"priority": "high",
|
||||
"reason": "scan_terminal_stale_panel",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_scan_terminal_fetches_multi_model_daily_in_batches(monkeypatch):
|
||||
today_paris = _local_date_for_offset(3600)
|
||||
today_houston = _local_date_for_offset(-18000)
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_city_row,
|
||||
"CITIES",
|
||||
{
|
||||
"paris": {"lat": 48.85, "lon": 2.35, "tz": 3600},
|
||||
"houston": {"lat": 29.76, "lon": -95.36, "tz": -18000, "f": True},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_city_row._weather,
|
||||
"_multi_model_cache",
|
||||
{},
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_city_row._weather,
|
||||
"_maybe_reload_open_meteo_disk_cache",
|
||||
lambda: None,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_city_row._weather,
|
||||
"_flush_open_meteo_disk_cache",
|
||||
lambda: None,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
requests = []
|
||||
|
||||
class _Response:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
@staticmethod
|
||||
def raise_for_status():
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
def _http_get(_url, *, params, timeout):
|
||||
requests.append((params, timeout))
|
||||
unit_is_f = params.get("temperature_unit") == "fahrenheit"
|
||||
date = today_houston if unit_is_f else today_paris
|
||||
value = 94.0 if unit_is_f else 24.0
|
||||
return _Response(
|
||||
[
|
||||
{
|
||||
"daily": {
|
||||
"time": [date],
|
||||
"temperature_2m_max_ecmwf_ifs025": [value],
|
||||
"temperature_2m_max_gfs_seamless": [value + 1],
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(scan_terminal_city_row._weather, "_http_get", _http_get, raising=False)
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_city_row._weather,
|
||||
"_wait_open_meteo_slot",
|
||||
lambda _endpoint: None,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
result = scan_terminal_city_row._fetch_scan_terminal_multi_model_batch(["paris", "houston"])
|
||||
|
||||
assert len(requests) == 2
|
||||
assert requests[0][0]["latitude"] == "48.85"
|
||||
assert requests[1][0]["temperature_unit"] == "fahrenheit"
|
||||
assert result["paris"]["daily_forecasts"][today_paris]["ECMWF"] == 24.0
|
||||
assert result["paris"]["daily_forecasts"][today_paris]["GFS"] == 25.0
|
||||
assert result["houston"]["daily_forecasts"][today_houston]["ECMWF"] == 94.0
|
||||
|
||||
|
||||
def test_scan_terminal_multi_model_batch_chunks_long_city_lists(monkeypatch):
|
||||
today = _local_date_for_offset(0)
|
||||
cities = {
|
||||
f"city{i}": {"lat": float(i), "lon": float(i + 1), "tz": 0}
|
||||
for i in range(45)
|
||||
}
|
||||
monkeypatch.setattr(scan_terminal_city_row, "CITIES", cities)
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_city_row._weather,
|
||||
"_multi_model_cache",
|
||||
{},
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_city_row._weather,
|
||||
"_maybe_reload_open_meteo_disk_cache",
|
||||
lambda: None,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_city_row._weather,
|
||||
"_flush_open_meteo_disk_cache",
|
||||
lambda: None,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_city_row._weather,
|
||||
"_wait_open_meteo_slot",
|
||||
lambda _endpoint: None,
|
||||
raising=False,
|
||||
)
|
||||
requests = []
|
||||
|
||||
class _Response:
|
||||
def __init__(self, count):
|
||||
self.count = count
|
||||
|
||||
@staticmethod
|
||||
def raise_for_status():
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return [
|
||||
{
|
||||
"daily": {
|
||||
"time": [today],
|
||||
"temperature_2m_max_ecmwf_ifs025": [20.0 + idx],
|
||||
}
|
||||
}
|
||||
for idx in range(self.count)
|
||||
]
|
||||
|
||||
def _http_get(_url, *, params, timeout):
|
||||
latitude_count = len(str(params["latitude"]).split(","))
|
||||
requests.append(latitude_count)
|
||||
return _Response(latitude_count)
|
||||
|
||||
monkeypatch.setattr(scan_terminal_city_row._weather, "_http_get", _http_get, raising=False)
|
||||
|
||||
result = scan_terminal_city_row._fetch_scan_terminal_multi_model_batch(list(cities.keys()))
|
||||
|
||||
assert requests == [20, 20, 5]
|
||||
assert len(result) == 45
|
||||
assert result["city0"]["daily_forecasts"][today]["ECMWF"] == 20.0
|
||||
assert result["city44"]["daily_forecasts"][today]["ECMWF"] == 24.0
|
||||
|
||||
|
||||
def test_scan_terminal_uncached_passes_batch_multi_model_overrides(monkeypatch):
|
||||
scan_terminal_cache._SCAN_TERMINAL_CACHE.clear()
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_service,
|
||||
"CITIES",
|
||||
{"paris": {"tz": 3600}, "houston": {"tz": -18000}},
|
||||
)
|
||||
monkeypatch.setattr(scan_terminal_service, "SCAN_TERMINAL_MAX_WORKERS", 2)
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_service,
|
||||
"_fetch_scan_terminal_multi_model_batch",
|
||||
lambda cities: {"paris": {"source": "batch-paris"}},
|
||||
)
|
||||
seen = []
|
||||
|
||||
def _scan_city(
|
||||
city,
|
||||
_filters,
|
||||
*,
|
||||
force_refresh=False,
|
||||
multi_model_override=None,
|
||||
allow_direct_fetch=True,
|
||||
):
|
||||
seen.append((city, force_refresh, multi_model_override, allow_direct_fetch))
|
||||
return {
|
||||
"city": city,
|
||||
"candidate_total": 1,
|
||||
"primary_scores": [1.0],
|
||||
"rows": [
|
||||
{
|
||||
"id": f"{city}:today",
|
||||
"market_key": f"{city}:today",
|
||||
"final_score": 1.0,
|
||||
"edge_percent": 0.0,
|
||||
"volume": 0.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(scan_terminal_service, "_scan_city_terminal_rows", _scan_city)
|
||||
|
||||
payload = scan_terminal_service._build_scan_terminal_payload_uncached(
|
||||
{"limit": 2},
|
||||
force_refresh=True,
|
||||
timeout_sec=5,
|
||||
)
|
||||
|
||||
assert payload["status"] == "ready"
|
||||
assert sorted(seen) == [
|
||||
("paris", True, {"source": "batch-paris"}, False),
|
||||
]
|
||||
assert payload["summary"]["total_city_count"] == 2
|
||||
assert payload["summary"]["scanned_city_count"] == 1
|
||||
|
||||
|
||||
def test_scan_city_terminal_rows_builds_forecast_only_row_from_batch_override(monkeypatch):
|
||||
today = _local_date_for_offset(3600)
|
||||
|
||||
class _Cache:
|
||||
@staticmethod
|
||||
def get_city_cache(*_args, **_kwargs):
|
||||
raise AssertionError("batch forecast rows should not read panel cache")
|
||||
|
||||
@staticmethod
|
||||
def get_canonical_temperature(*_args, **_kwargs):
|
||||
raise AssertionError("batch forecast rows should not read canonical cache")
|
||||
|
||||
monkeypatch.setattr(scan_terminal_city_row, "_PANEL_CACHE_DB", _Cache())
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_city_row,
|
||||
"calculate_deb_prediction",
|
||||
lambda city, forecasts, raw_calculator=None: {"prediction": 24.5},
|
||||
)
|
||||
|
||||
result = scan_terminal_city_row._scan_city_terminal_rows(
|
||||
"paris",
|
||||
{"market_type": "maxtemp"},
|
||||
force_refresh=True,
|
||||
multi_model_override={
|
||||
"daily_forecasts": {today: {"ECMWF": 24.0, "GFS": 25.0}},
|
||||
"dates": [today],
|
||||
"unit": "celsius",
|
||||
},
|
||||
allow_direct_fetch=False,
|
||||
)
|
||||
|
||||
row = result["rows"][0]
|
||||
assert row["local_date"] == today
|
||||
assert row["deb_prediction"] == 24.5
|
||||
assert row["model_cluster_sources"] == {"ECMWF": 24.0, "GFS": 25.0}
|
||||
assert row["forecast_refreshed"] is True
|
||||
|
||||
|
||||
def test_scan_timeout_prefers_partial_rows_with_models_over_blank_stale_cache(monkeypatch):
|
||||
cached_entry = {
|
||||
"success_payload": {
|
||||
"rows": [
|
||||
{"id": "old-1", "model_cluster_sources": {}},
|
||||
{"id": "old-2", "model_cluster_sources": {}},
|
||||
]
|
||||
},
|
||||
"last_failed_at": None,
|
||||
}
|
||||
|
||||
stale = scan_terminal_service._build_stale_payload_for_timeout_if_better_cached(
|
||||
filters={"limit": 2},
|
||||
cached_entry=cached_entry,
|
||||
ranked_rows=[
|
||||
{
|
||||
"id": "new-1",
|
||||
"model_cluster_sources": {"ECMWF": 24.0},
|
||||
}
|
||||
],
|
||||
timeout_message="scan terminal build timed out after 30s",
|
||||
)
|
||||
|
||||
assert stale is None
|
||||
|
||||
|
||||
def test_scan_terminal_refresh_without_model_rows_keeps_previous_model_snapshot(monkeypatch):
|
||||
cached_entry = {
|
||||
"success_payload": {
|
||||
"generated_at": "2026-06-01T00:00:00Z",
|
||||
"snapshot_id": "good-model-snapshot",
|
||||
"rows": [
|
||||
{
|
||||
"id": "paris:today",
|
||||
"market_key": "paris:today",
|
||||
"model_cluster_sources": {"ECMWF": 24.0},
|
||||
}
|
||||
],
|
||||
"summary": {"candidate_total": 1},
|
||||
"top_signal": None,
|
||||
},
|
||||
"last_failed_at": "2026-06-01T00:01:00Z",
|
||||
}
|
||||
failures = []
|
||||
|
||||
monkeypatch.setattr(scan_terminal_service, "CITIES", {"paris": {"tz": 3600}})
|
||||
monkeypatch.setattr(scan_terminal_service, "_fetch_scan_terminal_multi_model_batch", lambda _cities: {})
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_service,
|
||||
"get_scan_terminal_cache_entry",
|
||||
lambda _filters: cached_entry,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_service,
|
||||
"set_scan_terminal_failure_state",
|
||||
lambda _filters, *, error_message: failures.append(error_message),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_service,
|
||||
"set_cached_scan_terminal_payload",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("blank model refresh must not overwrite success payload")
|
||||
),
|
||||
)
|
||||
|
||||
def _scan_city(*_args, **_kwargs):
|
||||
return {
|
||||
"city": "paris",
|
||||
"candidate_total": 1,
|
||||
"primary_scores": [0.0],
|
||||
"rows": [
|
||||
{
|
||||
"id": "paris:today",
|
||||
"market_key": "paris:today",
|
||||
"final_score": 0.0,
|
||||
"edge_percent": 0.0,
|
||||
"volume": 0.0,
|
||||
"model_cluster_sources": {},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(scan_terminal_service, "_scan_city_terminal_rows", _scan_city)
|
||||
|
||||
payload = scan_terminal_service._build_scan_terminal_payload_uncached(
|
||||
{"limit": 1},
|
||||
timeout_sec=5,
|
||||
)
|
||||
|
||||
assert payload["status"] == "stale"
|
||||
assert payload["stale"] is True
|
||||
assert payload["snapshot_id"] == "good-model-snapshot"
|
||||
assert payload["rows"][0]["model_cluster_sources"] == {"ECMWF": 24.0}
|
||||
assert failures == ["scan terminal refresh returned rows without model forecasts"]
|
||||
|
||||
|
||||
def test_scan_city_terminal_rows_uses_canonical_without_analyze(monkeypatch):
|
||||
enqueued = []
|
||||
|
||||
@@ -325,6 +892,7 @@ def test_scan_city_terminal_rows_uses_canonical_without_analyze(monkeypatch):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(scan_terminal_city_row, "_PANEL_CACHE_DB", _Cache())
|
||||
monkeypatch.setattr(scan_terminal_city_row._weather, "fetch_multi_model", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
scan_terminal_city_row,
|
||||
"_analyze",
|
||||
|
||||
@@ -241,6 +241,130 @@ class TestMuCalculation:
|
||||
assert sd["peak_hours"] == ["15:00", "16:00"]
|
||||
assert sd["peak_status"] == "before"
|
||||
|
||||
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
|
||||
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
|
||||
@patch("src.analysis.trend_engine.update_daily_record")
|
||||
def test_weathernext2_probability_replaces_legacy_distribution(
|
||||
self, _udr, _deb_acc, _dw
|
||||
):
|
||||
data = _make_weather_data(
|
||||
cur_temp=30.0,
|
||||
max_so_far=31.0,
|
||||
om_today_high=33.0,
|
||||
ens_median=32.0,
|
||||
local_time="2026-03-04 11:00",
|
||||
)
|
||||
data["weathernext2"] = {
|
||||
"summary": {"median": 33.1},
|
||||
"buckets": [
|
||||
{
|
||||
"label": "32°C",
|
||||
"value": 32.1,
|
||||
"lower": 31.5,
|
||||
"upper": 32.5,
|
||||
"probability": 0.41,
|
||||
},
|
||||
{
|
||||
"label": "33°C",
|
||||
"value": 33.0,
|
||||
"lower": 32.5,
|
||||
"upper": 33.5,
|
||||
"probability": 0.56,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
_, ai_context, sd = analyze_weather_trend(data, "°C", "test_city")
|
||||
|
||||
assert sd["probability_engine"] == "weathernext2"
|
||||
assert sd["probabilities_all"] == data["weathernext2"]["buckets"]
|
||||
assert sd["mu"] == 33.1
|
||||
assert "WeatherNext 2 概率分布" in ai_context
|
||||
|
||||
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
|
||||
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
|
||||
@patch("src.analysis.trend_engine.update_daily_record")
|
||||
def test_weathernext2_median_enters_current_forecasts(
|
||||
self, _udr, _deb_acc, _dw
|
||||
):
|
||||
data = _make_weather_data(local_time="2026-03-04 11:00")
|
||||
data["weathernext2"] = {
|
||||
"summary": {"median": 33.1},
|
||||
"buckets": [
|
||||
{"label": "33°C", "value": 33.0, "probability": 0.56},
|
||||
],
|
||||
}
|
||||
|
||||
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
|
||||
|
||||
assert sd["current_forecasts"]["WeatherNext 2"] == 33.1
|
||||
|
||||
|
||||
class TestDebEnsembleSignal:
|
||||
@patch(
|
||||
"src.analysis.trend_engine.calculate_deb_prediction",
|
||||
return_value={
|
||||
"prediction": 30.1,
|
||||
"raw_prediction": 30.1,
|
||||
"weights_info": "test weights",
|
||||
},
|
||||
)
|
||||
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
|
||||
@patch("src.analysis.trend_engine.update_daily_record")
|
||||
def test_narrow_ensemble_supports_aligned_deb(self, _udr, _deb_acc, _deb):
|
||||
data = _make_weather_data(
|
||||
cur_temp=25.0,
|
||||
max_so_far=25.2,
|
||||
om_today_high=30.0,
|
||||
ens_median=30.0,
|
||||
ens_p10=29.4,
|
||||
ens_p90=30.6,
|
||||
local_time="2026-03-04 10:00",
|
||||
multi_model={"ECMWF": 30.2, "GFS": 30.0},
|
||||
)
|
||||
|
||||
_, ai_context, sd = analyze_weather_trend(data, "°C", "test_city")
|
||||
|
||||
signal = sd["deb_ensemble_signal"]
|
||||
assert signal["available"] is True
|
||||
assert signal["stance"] == "supporting"
|
||||
assert signal["confidence_delta"] > 0
|
||||
assert signal["spread"] == 1.2
|
||||
assert "集合支撑" in ai_context
|
||||
assert "GEFS" not in sd["current_forecasts"]
|
||||
assert not any("ensemble" in name.lower() for name in sd["current_forecasts"])
|
||||
|
||||
@patch(
|
||||
"src.analysis.trend_engine.calculate_deb_prediction",
|
||||
return_value={
|
||||
"prediction": 31.0,
|
||||
"raw_prediction": 31.0,
|
||||
"weights_info": "test weights",
|
||||
},
|
||||
)
|
||||
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
|
||||
@patch("src.analysis.trend_engine.update_daily_record")
|
||||
def test_wide_ensemble_marks_deb_as_caution(self, _udr, _deb_acc, _deb):
|
||||
data = _make_weather_data(
|
||||
cur_temp=25.0,
|
||||
max_so_far=25.2,
|
||||
om_today_high=30.0,
|
||||
ens_median=29.0,
|
||||
ens_p10=24.0,
|
||||
ens_p90=34.0,
|
||||
local_time="2026-03-04 10:00",
|
||||
multi_model={"ECMWF": 30.2, "GFS": 31.0},
|
||||
)
|
||||
|
||||
_, ai_context, sd = analyze_weather_trend(data, "°C", "test_city")
|
||||
|
||||
signal = sd["deb_ensemble_signal"]
|
||||
assert signal["available"] is True
|
||||
assert signal["stance"] == "caution"
|
||||
assert signal["confidence_delta"] < 0
|
||||
assert signal["spread"] == 10.0
|
||||
assert "集合分歧" in ai_context
|
||||
|
||||
|
||||
# ─── Tests: Dead Market ───
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.analysis.weathernext2_calibration import (
|
||||
apply_quantile_calibration_to_payload,
|
||||
train_lightgbm_quantile_calibrator,
|
||||
)
|
||||
from src.data_collection.weathernext2_sources import build_weathernext2_city_probability
|
||||
|
||||
|
||||
def _synthetic_training_rows(count: int = 170):
|
||||
rows = []
|
||||
for idx in range(count):
|
||||
city = "houston" if idx % 2 == 0 else "shanghai"
|
||||
median = 30.0 + (idx % 7) * 0.2
|
||||
residual = 1.0 if city == "houston" else -0.5
|
||||
rows.append(
|
||||
{
|
||||
"city": city,
|
||||
"target_date": f"2026-05-{idx % 28 + 1:02d}",
|
||||
"actual_high_c": median + residual,
|
||||
"weathernext2": {
|
||||
"summary": {
|
||||
"mean": median,
|
||||
"median": median,
|
||||
"p10": median - 1.0,
|
||||
"p25": median - 0.5,
|
||||
"p75": median + 0.5,
|
||||
"p90": median + 1.0,
|
||||
"spread": 2.0,
|
||||
}
|
||||
},
|
||||
"deb_prediction_c": median + 0.2,
|
||||
"model_median_c": median + 0.1,
|
||||
"model_spread": 1.4,
|
||||
"current_max_so_far_c": median - 2.0,
|
||||
"local_hour": 12,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def test_lightgbm_quantile_calibrator_trains_and_saves_ordered_quantiles(tmp_path):
|
||||
result = train_lightgbm_quantile_calibrator(
|
||||
_synthetic_training_rows(),
|
||||
model_dir=tmp_path,
|
||||
min_global_samples=150,
|
||||
min_city_samples=5,
|
||||
)
|
||||
|
||||
assert result["trained"] is True
|
||||
assert result["samples"] == 170
|
||||
assert (tmp_path / "metadata.json").is_file()
|
||||
assert (tmp_path / "q10.pkl").is_file()
|
||||
assert (tmp_path / "q50.pkl").is_file()
|
||||
assert (tmp_path / "q90.pkl").is_file()
|
||||
assert result["validation"]["ordered_quantiles"] is True
|
||||
|
||||
|
||||
def test_lightgbm_quantile_calibrator_skips_when_samples_are_insufficient(tmp_path):
|
||||
result = train_lightgbm_quantile_calibrator(
|
||||
_synthetic_training_rows(20),
|
||||
model_dir=tmp_path,
|
||||
min_global_samples=150,
|
||||
min_city_samples=5,
|
||||
)
|
||||
|
||||
assert result["trained"] is False
|
||||
assert result["reason"] == "insufficient_global_samples"
|
||||
|
||||
|
||||
def test_calibrated_distribution_rebuilds_market_buckets_from_shifted_members(tmp_path):
|
||||
train_lightgbm_quantile_calibrator(
|
||||
_synthetic_training_rows(),
|
||||
model_dir=tmp_path,
|
||||
min_global_samples=150,
|
||||
min_city_samples=5,
|
||||
)
|
||||
raw = build_weathernext2_city_probability(
|
||||
city="houston",
|
||||
member_highs=[30.0, 30.2, 30.4, 30.6],
|
||||
temp_symbol="°C",
|
||||
target_date="2026-06-29",
|
||||
)
|
||||
|
||||
calibrated = apply_quantile_calibration_to_payload(raw, model_dir=tmp_path)
|
||||
|
||||
assert calibrated["calibration"]["engine"] == "lightgbm_quantile"
|
||||
assert calibrated["calibration"]["samples"] == 170
|
||||
assert calibrated["calibration"]["raw_summary"]["median"] == raw["summary"]["median"]
|
||||
assert calibrated["calibration"]["calibrated_summary"]["median"] > raw["summary"]["median"]
|
||||
assert calibrated["buckets"]
|
||||
assert calibrated["top_bucket"]["label"].endswith("°C")
|
||||
@@ -0,0 +1,259 @@
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
|
||||
from src.data_collection.weather_sources import WeatherDataCollector
|
||||
from src.data_collection.weathernext2_fetcher import (
|
||||
extract_member_hourly_from_grid_dataset,
|
||||
normalize_temperature_value,
|
||||
open_weathernext2_zarr_dataset,
|
||||
select_temperature_variable,
|
||||
)
|
||||
from src.data_collection.weathernext2_sources import (
|
||||
build_city_local_daily_highs_from_hourly,
|
||||
build_weathernext2_city_probability,
|
||||
market_bucket_for_temperature,
|
||||
summarize_member_highs,
|
||||
)
|
||||
|
||||
|
||||
def test_market_bucket_for_temperature_uses_single_celsius_options():
|
||||
bucket = market_bucket_for_temperature(32.6, "°C")
|
||||
|
||||
assert bucket["label"] == "33°C"
|
||||
assert bucket["lower"] == 32.5
|
||||
assert bucket["upper"] == 33.5
|
||||
|
||||
|
||||
def test_market_bucket_for_temperature_groups_fahrenheit_by_two_degree_market_option():
|
||||
assert market_bucket_for_temperature(94.2, "°F")["label"] == "94-95°F"
|
||||
assert market_bucket_for_temperature(95.4, "°F")["label"] == "94-95°F"
|
||||
assert market_bucket_for_temperature(96.1, "°F")["label"] == "96-97°F"
|
||||
|
||||
|
||||
def test_weathernext2_probability_aggregates_member_highs_to_market_buckets():
|
||||
probability = build_weathernext2_city_probability(
|
||||
city="Houston",
|
||||
member_highs={
|
||||
"member_00": 94.1,
|
||||
"member_01": 94.8,
|
||||
"member_02": 95.2,
|
||||
"member_03": 96.7,
|
||||
},
|
||||
temp_symbol="°F",
|
||||
target_date="2026-06-29",
|
||||
source_run="2026-06-29T00:00:00Z",
|
||||
)
|
||||
|
||||
assert probability["source"] == "weathernext2"
|
||||
assert probability["members"] == 4
|
||||
assert probability["top_bucket"]["label"] == "94-95°F"
|
||||
assert probability["top_bucket"]["probability"] == 0.75
|
||||
assert probability["buckets"] == [
|
||||
{
|
||||
"key": "94-95°F",
|
||||
"label": "94-95°F",
|
||||
"lower": 93.5,
|
||||
"upper": 95.5,
|
||||
"value": 94.7,
|
||||
"probability": 0.75,
|
||||
"member_count": 3,
|
||||
"total_members": 4,
|
||||
},
|
||||
{
|
||||
"key": "96-97°F",
|
||||
"label": "96-97°F",
|
||||
"lower": 95.5,
|
||||
"upper": 97.5,
|
||||
"value": 96.7,
|
||||
"probability": 0.25,
|
||||
"member_count": 1,
|
||||
"total_members": 4,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_weathernext2_probability_keeps_celsius_market_option_labels():
|
||||
probability = build_weathernext2_city_probability(
|
||||
city="Shanghai",
|
||||
member_highs=[31.6, 32.2, 32.8, 33.1],
|
||||
temp_symbol="°C",
|
||||
target_date="2026-06-29",
|
||||
)
|
||||
|
||||
labels = [bucket["label"] for bucket in probability["buckets"]]
|
||||
|
||||
assert labels == ["32°C", "33°C"]
|
||||
assert probability["top_bucket"]["label"] == "33°C"
|
||||
assert probability["top_bucket"]["probability"] == 0.5
|
||||
|
||||
|
||||
def test_city_local_daily_highs_from_hourly_uses_target_local_date():
|
||||
highs = build_city_local_daily_highs_from_hourly(
|
||||
member_hourly={
|
||||
"member_00": [21.0, 26.0, 28.0, 24.0],
|
||||
"member_01": [20.0, 25.0, None, 27.0],
|
||||
},
|
||||
utc_times=[
|
||||
datetime(2026, 6, 28, 15, tzinfo=timezone.utc), # local 2026-06-28 23:00
|
||||
datetime(2026, 6, 28, 16, tzinfo=timezone.utc), # local 2026-06-29 00:00
|
||||
datetime(2026, 6, 29, 6, tzinfo=timezone.utc), # local 2026-06-29 14:00
|
||||
datetime(2026, 6, 29, 16, tzinfo=timezone.utc), # local 2026-06-30 00:00
|
||||
],
|
||||
timezone_offset_seconds=8 * 3600,
|
||||
target_local_date="2026-06-29",
|
||||
)
|
||||
|
||||
assert highs == {"member_00": 28.0, "member_01": 25.0}
|
||||
|
||||
|
||||
def test_summarize_member_highs_reports_ensemble_shape():
|
||||
summary = summarize_member_highs([30, 32, 34, 36, 38])
|
||||
|
||||
assert summary == {
|
||||
"members": 5,
|
||||
"mean": 34.0,
|
||||
"median": 34.0,
|
||||
"p10": 30.8,
|
||||
"p25": 32.0,
|
||||
"p75": 36.0,
|
||||
"p90": 37.2,
|
||||
"min": 30.0,
|
||||
"max": 38.0,
|
||||
"spread": 8.0,
|
||||
}
|
||||
|
||||
|
||||
def test_collector_reads_weathernext2_fixture_when_enabled(monkeypatch, tmp_path):
|
||||
fixture_path = tmp_path / "weathernext2.json"
|
||||
fixture_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"houston": {
|
||||
"target_date": "2026-06-29",
|
||||
"source_run": "2026-06-29T00:00:00Z",
|
||||
"member_highs": [94.1, 94.8, 95.2, 96.7],
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("WEATHERNEXT2_ENABLED", "1")
|
||||
monkeypatch.setenv("WEATHERNEXT2_FIXTURE_PATH", str(fixture_path))
|
||||
|
||||
collector = WeatherDataCollector({})
|
||||
|
||||
payload = collector.fetch_weathernext2_probability(
|
||||
"Houston",
|
||||
lat=29.7604,
|
||||
lon=-95.3698,
|
||||
use_fahrenheit=True,
|
||||
target_date="2026-06-29",
|
||||
timezone_offset_seconds=-5 * 3600,
|
||||
)
|
||||
|
||||
assert payload["source"] == "weathernext2"
|
||||
assert payload["top_bucket"]["label"] == "94-95°F"
|
||||
assert payload["buckets"][0]["probability"] == 0.75
|
||||
|
||||
|
||||
def test_collector_reads_weathernext2_worker_artifact_before_fixture(monkeypatch, tmp_path):
|
||||
artifact_path = tmp_path / "weathernext2_city_highs.json"
|
||||
fixture_path = tmp_path / "fixture.json"
|
||||
artifact_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"cities": {
|
||||
"houston": {
|
||||
"target_date": "2026-06-29",
|
||||
"source_run": "2026-06-29T00:00:00Z",
|
||||
"member_highs": [94.1, 94.8, 95.2, 96.7],
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
fixture_path.write_text(
|
||||
json.dumps({"houston": {"member_highs": [80.0, 80.2]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("WEATHERNEXT2_ENABLED", "1")
|
||||
monkeypatch.setenv("WEATHERNEXT2_CITY_HIGHS_PATH", str(artifact_path))
|
||||
monkeypatch.setenv("WEATHERNEXT2_FIXTURE_PATH", str(fixture_path))
|
||||
monkeypatch.setenv("WEATHERNEXT2_MODEL_DIR", str(tmp_path / "missing-model"))
|
||||
|
||||
collector = WeatherDataCollector({})
|
||||
|
||||
payload = collector.fetch_weathernext2_probability(
|
||||
"Houston",
|
||||
lat=29.7604,
|
||||
lon=-95.3698,
|
||||
use_fahrenheit=True,
|
||||
target_date="2026-06-29",
|
||||
timezone_offset_seconds=-5 * 3600,
|
||||
)
|
||||
|
||||
assert payload["source"] == "weathernext2"
|
||||
assert payload["top_bucket"]["label"] == "94-95°F"
|
||||
assert payload["buckets"][0]["probability"] == 0.75
|
||||
|
||||
|
||||
def test_weathernext2_grid_extractor_selects_temp_var_nearest_point_and_converts_kelvin():
|
||||
dataset = {
|
||||
"lat": [10.0, 20.0],
|
||||
"lon": [30.0, 40.0],
|
||||
"member": [0, 1],
|
||||
"time": [
|
||||
"2026-06-29T00:00:00Z",
|
||||
"2026-06-29T06:00:00Z",
|
||||
"2026-06-29T12:00:00Z",
|
||||
],
|
||||
"temperature_2m": [
|
||||
[
|
||||
[[290.0, 291.0], [292.0, 293.0]],
|
||||
[[294.0, 295.0], [296.0, 297.0]],
|
||||
[[298.0, 299.0], [300.0, 301.0]],
|
||||
],
|
||||
[
|
||||
[[289.0, 290.0], [291.0, 292.0]],
|
||||
[[293.0, 294.0], [295.0, 296.0]],
|
||||
[[297.0, 298.0], [299.0, 300.0]],
|
||||
],
|
||||
],
|
||||
"units": {"temperature_2m": "K"},
|
||||
}
|
||||
|
||||
assert select_temperature_variable(dataset) == "temperature_2m"
|
||||
assert normalize_temperature_value(300.15, "K") == 27.0
|
||||
|
||||
extracted = extract_member_hourly_from_grid_dataset(
|
||||
dataset,
|
||||
lat=18.0,
|
||||
lon=38.0,
|
||||
)
|
||||
|
||||
assert extracted["temp_var"] == "temperature_2m"
|
||||
assert extracted["nearest_lat"] == 20.0
|
||||
assert extracted["nearest_lon"] == 40.0
|
||||
assert extracted["member_hourly"]["member_00"] == [19.9, 23.9, 27.9]
|
||||
assert extracted["member_hourly"]["member_01"] == [18.9, 22.9, 26.9]
|
||||
|
||||
|
||||
def test_weathernext2_zarr_uses_google_default_credentials(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class FakeXarray:
|
||||
@staticmethod
|
||||
def open_zarr(uri, **kwargs):
|
||||
calls.append((uri, kwargs))
|
||||
return {"ok": True}
|
||||
|
||||
import src.data_collection.weathernext2_fetcher as fetcher
|
||||
|
||||
monkeypatch.setattr(fetcher, "xr", FakeXarray, raising=False)
|
||||
monkeypatch.setitem(__import__("sys").modules, "xarray", FakeXarray)
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/app/secrets/gcp-sa.json")
|
||||
|
||||
assert open_weathernext2_zarr_dataset("gs://weathernext/example") == {"ok": True}
|
||||
assert calls[0][1]["storage_options"]["token"] == "google_default"
|
||||
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
|
||||
from src.database.runtime_state import (
|
||||
RuntimeStateDB,
|
||||
TrainingFeatureRecordRepository,
|
||||
TruthRecordRepository,
|
||||
)
|
||||
from web.weathernext2_worker_service import run_weathernext2_cycle
|
||||
|
||||
|
||||
def _seed_training_rows(feature_repo, truth_repo, count: int = 170):
|
||||
for idx in range(count):
|
||||
city = "houston" if idx % 2 == 0 else "shanghai"
|
||||
target_date = (date(2026, 1, 1) + timedelta(days=idx)).isoformat()
|
||||
median = 30.0 + (idx % 7) * 0.2
|
||||
residual = 1.0 if city == "houston" else -0.5
|
||||
feature_repo.upsert_record(
|
||||
city,
|
||||
target_date,
|
||||
{
|
||||
"weathernext2": {
|
||||
"summary": {
|
||||
"mean": median,
|
||||
"median": median,
|
||||
"p10": median - 1.0,
|
||||
"p25": median - 0.5,
|
||||
"p75": median + 0.5,
|
||||
"p90": median + 1.0,
|
||||
"spread": 2.0,
|
||||
}
|
||||
},
|
||||
"deb_prediction": median + 0.2,
|
||||
"model_median_c": median + 0.1,
|
||||
"model_spread": 1.4,
|
||||
"current_max_so_far_c": median - 2.0,
|
||||
"local_hour": 12,
|
||||
},
|
||||
)
|
||||
truth_repo.upsert_truth(
|
||||
city=city,
|
||||
target_date=target_date,
|
||||
actual_high=median + residual,
|
||||
settlement_source="metar",
|
||||
settlement_station_code="TEST",
|
||||
settlement_station_label="TEST",
|
||||
truth_version="test",
|
||||
updated_by="test",
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
|
||||
def test_weathernext2_worker_writes_city_artifact_and_trains_calibrator(tmp_path):
|
||||
db = RuntimeStateDB(str(tmp_path / "polyweather.db"))
|
||||
feature_repo = TrainingFeatureRecordRepository(db)
|
||||
truth_repo = TruthRecordRepository(db)
|
||||
_seed_training_rows(feature_repo, truth_repo)
|
||||
|
||||
def fake_fetcher(city, meta, target_date):
|
||||
return {
|
||||
"city": city,
|
||||
"target_date": target_date,
|
||||
"source_run": "2026-06-29T00:00:00Z",
|
||||
"member_highs": [30.0, 30.2, 30.4, 30.6],
|
||||
}
|
||||
|
||||
output_path = tmp_path / "weathernext2_city_highs.json"
|
||||
model_dir = tmp_path / "model"
|
||||
result = run_weathernext2_cycle(
|
||||
city_registry={
|
||||
"houston": {"name": "Houston", "lat": 29.7, "lon": -95.3, "use_fahrenheit": False, "tz_offset": -5 * 3600},
|
||||
},
|
||||
output_path=str(output_path),
|
||||
model_dir=str(model_dir),
|
||||
fetcher=fake_fetcher,
|
||||
feature_repo=feature_repo,
|
||||
truth_repo=truth_repo,
|
||||
min_global_samples=150,
|
||||
min_city_samples=5,
|
||||
)
|
||||
|
||||
assert result["status"] == "written"
|
||||
assert result["city_count"] == 1
|
||||
assert result["calibration"]["trained"] is True
|
||||
assert (model_dir / "metadata.json").is_file()
|
||||
payload = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
assert payload["cities"]["houston"]["member_highs"] == [30.0, 30.2, 30.4, 30.6]
|
||||
assert payload["calibration_training"]["samples"] == 170
|
||||
|
||||
|
||||
def test_weathernext2_worker_does_not_overwrite_artifact_when_no_city_payloads(tmp_path):
|
||||
db = RuntimeStateDB(str(tmp_path / "polyweather.db"))
|
||||
output_path = tmp_path / "weathernext2_city_highs.json"
|
||||
output_path.write_text('{"cities":{"old":{"member_highs":[1]}}}', encoding="utf-8")
|
||||
|
||||
result = run_weathernext2_cycle(
|
||||
city_registry={"houston": {"name": "Houston", "lat": 29.7, "lon": -95.3}},
|
||||
output_path=str(output_path),
|
||||
model_dir=str(tmp_path / "model"),
|
||||
fetcher=lambda _city, _meta, _target_date: None,
|
||||
feature_repo=TrainingFeatureRecordRepository(db),
|
||||
truth_repo=TruthRecordRepository(db),
|
||||
)
|
||||
|
||||
assert result["status"] == "no_city_payloads"
|
||||
assert json.loads(output_path.read_text(encoding="utf-8"))["cities"]["old"]["member_highs"] == [1]
|
||||
@@ -1401,6 +1401,8 @@ def test_chart_data_cache_hit_starts_full_stale_refresh(monkeypatch):
|
||||
def test_chart_data_cache_hit_overlays_cached_multi_model_hourly(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
local_date = datetime.now(timezone.utc).date().isoformat()
|
||||
|
||||
class FakeCache:
|
||||
def get_city_cache(self, kind, city):
|
||||
assert kind == "full"
|
||||
@@ -1408,7 +1410,7 @@ def test_chart_data_cache_hit_overlays_cached_multi_model_hourly(monkeypatch):
|
||||
"payload": {
|
||||
"name": city,
|
||||
"display_name": city.title(),
|
||||
"local_date": "2026-06-16",
|
||||
"local_date": local_date,
|
||||
"local_time": "15:20",
|
||||
"temp_symbol": "°C",
|
||||
"current": {"temp": 20.0},
|
||||
@@ -1433,7 +1435,7 @@ def test_chart_data_cache_hit_overlays_cached_multi_model_hourly(monkeypatch):
|
||||
monkeypatch.setattr(collector, "_multi_model_cache", {
|
||||
"48.9694:2.4414:paris:c:v5": {
|
||||
"data": {
|
||||
"hourly_times": ["2026-06-16T15:00"],
|
||||
"hourly_times": [f"{local_date}T15:00"],
|
||||
"hourly_forecasts": {"ECMWF": [24.5]},
|
||||
"forecasts": {"ECMWF": 27.0},
|
||||
}
|
||||
@@ -1458,6 +1460,9 @@ def test_chart_data_cache_hit_overlays_cached_multi_model_hourly(monkeypatch):
|
||||
def test_chart_data_cache_hit_replaces_stale_multi_model_hourly(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
local_date = datetime.now(timezone.utc).date().isoformat()
|
||||
stale_date = (datetime.now(timezone.utc).date() - timedelta(days=3)).isoformat()
|
||||
|
||||
class FakeCache:
|
||||
def get_city_cache(self, kind, city):
|
||||
assert kind == "full"
|
||||
@@ -1465,13 +1470,13 @@ def test_chart_data_cache_hit_replaces_stale_multi_model_hourly(monkeypatch):
|
||||
"payload": {
|
||||
"name": city,
|
||||
"display_name": city.title(),
|
||||
"local_date": "2026-06-17",
|
||||
"local_date": local_date,
|
||||
"local_time": "15:20",
|
||||
"temp_symbol": "°C",
|
||||
"current": {"temp": 20.0},
|
||||
"hourly": {"times": ["15:00"], "temps": [20.0]},
|
||||
"multi_model": {
|
||||
"hourly_times": ["2026-06-14T15:00", "2026-06-16T23:00"],
|
||||
"hourly_times": [f"{stale_date}T15:00", f"{stale_date}T23:00"],
|
||||
"hourly_forecasts": {"ECMWF": [21.0, 22.0]},
|
||||
},
|
||||
},
|
||||
@@ -1493,7 +1498,7 @@ def test_chart_data_cache_hit_replaces_stale_multi_model_hourly(monkeypatch):
|
||||
monkeypatch.setattr(collector, "_multi_model_cache", {
|
||||
"48.9694:2.4414:paris:c:v5": {
|
||||
"data": {
|
||||
"hourly_times": ["2026-06-17T15:00", "2026-06-17T16:00"],
|
||||
"hourly_times": [f"{local_date}T15:00", f"{local_date}T16:00"],
|
||||
"hourly_forecasts": {"ECMWF": [24.5, 25.0]},
|
||||
"forecasts": {"ECMWF": 27.0},
|
||||
}
|
||||
@@ -1512,10 +1517,29 @@ def test_chart_data_cache_hit_replaces_stale_multi_model_hourly(monkeypatch):
|
||||
|
||||
payload = asyncio.run(city_api._get_city_chart_data("paris", force_refresh=False))
|
||||
|
||||
assert payload["multi_model"]["hourly_times"] == ["2026-06-17T15:00", "2026-06-17T16:00"]
|
||||
assert payload["multi_model"]["hourly_times"] == [f"{local_date}T15:00", f"{local_date}T16:00"]
|
||||
assert payload["multi_model"]["hourly_forecasts"]["ECMWF"] == [24.5, 25.0]
|
||||
|
||||
|
||||
def test_multi_model_daily_models_for_date_rejects_stale_dated_forecasts():
|
||||
local_date = datetime.now(timezone.utc).date().isoformat()
|
||||
stale_date = (datetime.now(timezone.utc).date() - timedelta(days=7)).isoformat()
|
||||
|
||||
models = city_api._multi_model_daily_models_for_date(
|
||||
{
|
||||
"daily_forecasts": {
|
||||
stale_date: {"ECMWF": 24.0},
|
||||
},
|
||||
"hourly_times": [f"{stale_date}T15:00"],
|
||||
"hourly_forecasts": {"ECMWF": [24.0]},
|
||||
"forecasts": {"ECMWF": 24.0},
|
||||
},
|
||||
local_date,
|
||||
)
|
||||
|
||||
assert models == {}
|
||||
|
||||
|
||||
def test_chart_data_cache_hit_refreshes_when_multi_model_cache_is_stale(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
@@ -1592,6 +1616,8 @@ def test_chart_data_cache_hit_refreshes_when_multi_model_cache_is_stale(monkeypa
|
||||
|
||||
def test_chart_data_floors_stale_forecast_and_deb_with_observed_high(monkeypatch):
|
||||
import asyncio
|
||||
local_date = datetime.now(timezone.utc).date().isoformat()
|
||||
stale_date = (datetime.now(timezone.utc).date() - timedelta(days=7)).isoformat()
|
||||
|
||||
class FakeCache:
|
||||
def get_city_cache(self, kind, city):
|
||||
@@ -1600,7 +1626,7 @@ def test_chart_data_floors_stale_forecast_and_deb_with_observed_high(monkeypatch
|
||||
"payload": {
|
||||
"name": city,
|
||||
"display_name": "Lucknow",
|
||||
"local_date": "2026-06-21",
|
||||
"local_date": local_date,
|
||||
"local_time": "13:30",
|
||||
"temp_symbol": "°C",
|
||||
"current": {"temp": 38.0, "max_so_far": 38.0},
|
||||
@@ -1608,11 +1634,18 @@ def test_chart_data_floors_stale_forecast_and_deb_with_observed_high(monkeypatch
|
||||
"airport_primary": {"temp": 38.0, "max_so_far": 38.0},
|
||||
"forecast": {
|
||||
"today_high": 36.2,
|
||||
"daily": [{"date": "2026-06-14", "max_temp": 36.2}],
|
||||
"daily": [{"date": stale_date, "max_temp": 36.2}],
|
||||
},
|
||||
"deb": {
|
||||
"prediction": 36.0,
|
||||
"raw_prediction": 36.0,
|
||||
"hourly_path": {
|
||||
"times": ["13:00", "14:00"],
|
||||
"temps": [36.0, 37.0],
|
||||
},
|
||||
},
|
||||
"deb": {"prediction": 36.0, "raw_prediction": 36.0},
|
||||
"multi_model_daily": {
|
||||
"2026-06-14": {"models": {"Open-Meteo": 36.2}},
|
||||
stale_date: {"models": {"Open-Meteo": 36.2}},
|
||||
},
|
||||
"multi_model": {},
|
||||
"hourly": {"times": ["13:00"], "temps": [36.0]},
|
||||
@@ -1649,16 +1682,16 @@ def test_chart_data_floors_stale_forecast_and_deb_with_observed_high(monkeypatch
|
||||
cache_key: {
|
||||
"data": {
|
||||
"hourly_times": [
|
||||
"2026-06-21T13:00",
|
||||
"2026-06-21T14:00",
|
||||
"2026-06-21T15:00",
|
||||
f"{local_date}T13:00",
|
||||
f"{local_date}T14:00",
|
||||
f"{local_date}T15:00",
|
||||
],
|
||||
"hourly_forecasts": {
|
||||
"ECMWF": [38.7, 39.0, 38.0],
|
||||
"GFS": [42.0, 44.1, 43.0],
|
||||
},
|
||||
"daily_forecasts": {
|
||||
"2026-06-21": {"ECMWF": 39.0, "GFS": 44.1},
|
||||
local_date: {"ECMWF": 39.0, "GFS": 44.1},
|
||||
},
|
||||
"forecasts": {"ECMWF": 39.0, "GFS": 44.1},
|
||||
}
|
||||
@@ -1681,10 +1714,12 @@ def test_chart_data_floors_stale_forecast_and_deb_with_observed_high(monkeypatch
|
||||
|
||||
assert payload["forecast"]["today_high"] >= 38.0
|
||||
assert payload["deb"]["prediction"] >= 38.0
|
||||
assert payload["multi_model_daily"]["2026-06-21"]["models"]["GFS"] == 44.1
|
||||
assert max(payload["deb"]["hourly_path"]["temps"]) >= 38.0
|
||||
assert payload["multi_model_daily"][local_date]["models"]["GFS"] == 44.1
|
||||
assert detail["forecast"]["today_high"] >= 38.0
|
||||
assert detail["overview"]["deb_prediction"] >= 38.0
|
||||
assert detail["multi_model_daily"]["2026-06-21"]["models"]["GFS"] == 44.1
|
||||
assert max(detail["deb"]["hourly_path"]["temps"]) >= 38.0
|
||||
assert detail["multi_model_daily"][local_date]["models"]["GFS"] == 44.1
|
||||
|
||||
|
||||
def test_chart_data_cache_hit_overlays_latest_amsc_raw(monkeypatch):
|
||||
|
||||
@@ -36,6 +36,7 @@ from src.data_collection.country_networks import build_country_network_snapshot
|
||||
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
|
||||
from src.data_collection.city_time import get_city_utc_offset_seconds
|
||||
from src.data_collection.forecast_source_bundle import ensure_multi_model_hourly_payload
|
||||
from src.data_collection.multi_model_freshness import multi_model_forecasts_for_local_date
|
||||
from src.database.runtime_state import IntradayPathSnapshotRepository
|
||||
from web.services.city_payloads import (
|
||||
build_city_chart_detail_payload as _city_chart_payload_detail,
|
||||
@@ -894,7 +895,7 @@ def _analyze(
|
||||
current_forecasts: Dict[str, float] = {}
|
||||
if om_today is not None:
|
||||
current_forecasts["Open-Meteo"] = om_today
|
||||
for m, v in mm.get("forecasts", {}).items():
|
||||
for m, v in multi_model_forecasts_for_local_date(mm, local_date_str).items():
|
||||
if v is not None and not _is_excluded_model_name(m):
|
||||
temp_val = _sf(v)
|
||||
if temp_val is not None:
|
||||
@@ -1075,6 +1076,7 @@ def _analyze(
|
||||
probabilities_all = []
|
||||
mu = None
|
||||
dynamic_commentary = {"summary": "", "notes": []}
|
||||
deb_ensemble_signal = {}
|
||||
try:
|
||||
_, _ai_context, sd = _trend_analyze(raw, sym, city)
|
||||
|
||||
@@ -1082,6 +1084,7 @@ def _analyze(
|
||||
probabilities = sd.get("probabilities", [])
|
||||
probabilities_all = sd.get("probabilities_all", probabilities)
|
||||
dynamic_commentary = sd.get("dynamic_commentary") or dynamic_commentary
|
||||
deb_ensemble_signal = sd.get("deb_ensemble_signal") or {}
|
||||
trend_info["is_dead_market"] = sd.get("trend_info", {}).get("is_dead_market", False)
|
||||
trend_info["direction"] = sd.get("trend_info", {}).get("direction", trend_info.get("direction", "unknown"))
|
||||
trend_info["is_cooling"] = sd.get("trend_info", {}).get("is_cooling", False)
|
||||
@@ -1630,6 +1633,7 @@ def _analyze(
|
||||
"hourly_consensus": deb_hourly_consensus,
|
||||
"hourly_path": deb_hourly_path,
|
||||
"hourly_correction": (deb_hourly_path or {}).get("correction") if isinstance(deb_hourly_path, dict) else None,
|
||||
"ensemble_signal": deb_ensemble_signal,
|
||||
**deb_quality,
|
||||
},
|
||||
"deviation_monitor": deviation_monitor,
|
||||
@@ -1921,7 +1925,7 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
current_forecasts: Dict[str, float] = {}
|
||||
if om_today is not None:
|
||||
current_forecasts["Open-Meteo"] = om_today
|
||||
for m, v in mm.get("forecasts", {}).items():
|
||||
for m, v in multi_model_forecasts_for_local_date(mm, local_date_str).items():
|
||||
if v is not None and not _is_excluded_model_name(m):
|
||||
temp_val = _sf(v)
|
||||
if temp_val is not None:
|
||||
|
||||
@@ -12,6 +12,7 @@ from web.services.ops_api import (
|
||||
get_ops_sensitive_config,
|
||||
get_ops_memberships_growth,
|
||||
get_ops_memberships_overview,
|
||||
get_ops_market_opportunities,
|
||||
get_ops_health_check,
|
||||
get_ops_logs,
|
||||
get_ops_observation_collector_status,
|
||||
@@ -355,3 +356,26 @@ async def ops_telegram_audit(request: Request):
|
||||
return get_ops_telegram_audit(request)
|
||||
|
||||
|
||||
@router.get("/api/ops/market-opportunities")
|
||||
async def ops_market_opportunities(
|
||||
request: Request,
|
||||
max_price: float = 0.20,
|
||||
side: str = "both",
|
||||
positive_edge_only: bool = True,
|
||||
min_edge: float = 0.0,
|
||||
limit: int = 200,
|
||||
q: str = "",
|
||||
region: str = "",
|
||||
):
|
||||
return get_ops_market_opportunities(
|
||||
request,
|
||||
max_price=max_price,
|
||||
side=side,
|
||||
positive_edge_only=positive_edge_only,
|
||||
min_edge=min_edge,
|
||||
limit=limit,
|
||||
query=q,
|
||||
region=region,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+3
-12
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from web.services.cache_headers import NO_STORE_CACHE_CONTROL, public_edge_cache_control
|
||||
from web.services.cache_headers import NO_STORE_CACHE_CONTROL
|
||||
from web.services.scan_api import (
|
||||
get_scan_terminal_overview_payload,
|
||||
get_scan_terminal_payload,
|
||||
@@ -14,9 +14,6 @@ from web.services.request_timing import attach_server_timing_header
|
||||
|
||||
router = APIRouter(tags=["scan"])
|
||||
|
||||
SCAN_TERMINAL_CACHE_CONTROL = public_edge_cache_control(300, 900)
|
||||
|
||||
|
||||
@router.get("/api/scan/terminal")
|
||||
async def scan_terminal(
|
||||
request: Request,
|
||||
@@ -53,17 +50,11 @@ async def scan_terminal(
|
||||
region=region or trading_region or None,
|
||||
timezone_offset_seconds=timezone_offset_seconds,
|
||||
)
|
||||
status = str(payload.get("status") or "").strip().lower()
|
||||
cache_control = (
|
||||
SCAN_TERMINAL_CACHE_CONTROL
|
||||
if not force_refresh and status == "ready" and payload.get("stale") is not True
|
||||
else NO_STORE_CACHE_CONTROL
|
||||
)
|
||||
response = JSONResponse(
|
||||
content=payload,
|
||||
headers={
|
||||
"Cache-Control": cache_control,
|
||||
"Cloudflare-CDN-Cache-Control": cache_control,
|
||||
"Cache-Control": NO_STORE_CACHE_CONTROL,
|
||||
"Cloudflare-CDN-Cache-Control": NO_STORE_CACHE_CONTROL,
|
||||
},
|
||||
)
|
||||
attach_server_timing_header(response, request, "scan_terminal_server_timing")
|
||||
|
||||
@@ -43,10 +43,13 @@ def _redis_cache_ttl_sec() -> int:
|
||||
|
||||
|
||||
def _redis_cache_prefix() -> str:
|
||||
return os.getenv(
|
||||
prefix = os.getenv(
|
||||
"POLYWEATHER_SCAN_TERMINAL_REDIS_CACHE_PREFIX",
|
||||
"polyweather:scan_terminal:v1:",
|
||||
"polyweather:scan_terminal:v2:",
|
||||
)
|
||||
if prefix.endswith(":v1:"):
|
||||
return f"{prefix[:-4]}:v2:"
|
||||
return prefix
|
||||
|
||||
|
||||
def _redis_entry_key(cache_key: str) -> str:
|
||||
|
||||
+510
-16
@@ -2,11 +2,20 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from src.analysis.deb_algorithm import calculate_deb_prediction, calculate_dynamic_weights
|
||||
from src.analysis.trend_engine import calculate_prob_distribution
|
||||
from src.data_collection.nws_open_meteo_sources import (
|
||||
OPEN_METEO_MULTI_MODEL_ORDER,
|
||||
_parse_open_meteo_multi_model_daily,
|
||||
)
|
||||
from src.data_collection.multi_model_freshness import multi_model_forecasts_for_local_date
|
||||
from src.database.db_manager import DBManager
|
||||
from web.core import CITIES, _sf as _safe_float
|
||||
from src.utils.refresh_policy import SCAN_ROWS_REFRESH_SEC
|
||||
from web.core import CITIES, _sf as _safe_float, _weather
|
||||
from web.scan_terminal_filters import (
|
||||
market_region_from_tz_offset as _market_region_from_tz_offset,
|
||||
safe_int as _safe_int,
|
||||
@@ -17,8 +26,10 @@ from web.services.city_payloads import aggregate_runway_history
|
||||
|
||||
SCAN_ROW_RUNWAY_HISTORY_RESOLUTION = "10m"
|
||||
SCAN_ROW_MAX_RUNWAY_POINTS = 144
|
||||
SCAN_TERMINAL_MULTI_MODEL_BATCH_SIZE = 20
|
||||
_PANEL_CACHE_DB = DBManager()
|
||||
_analyze = None # compatibility hook for tests that assert scan terminal stays cache-only.
|
||||
SCAN_PANEL_CACHE_MAX_AGE_SEC = max(300, int(SCAN_ROWS_REFRESH_SEC) * 3)
|
||||
|
||||
|
||||
def _compact_runway_plate_history_for_scan(raw_history: Any) -> Dict[str, List[Dict[str, Any]]]:
|
||||
@@ -47,12 +58,454 @@ def _enqueue_scan_terminal_refresh(city: str, *, reason: str) -> None:
|
||||
return
|
||||
|
||||
|
||||
def _load_scan_panel_payload(city: str, *, force_refresh: bool) -> Optional[Dict[str, Any]]:
|
||||
if not force_refresh:
|
||||
cached_entry = _PANEL_CACHE_DB.get_city_cache("panel", city)
|
||||
cached_payload = cached_entry.get("payload") if isinstance(cached_entry, dict) else None
|
||||
if isinstance(cached_payload, dict):
|
||||
def _city_local_now(city: str, utc_offset_seconds: Optional[int] = None) -> datetime:
|
||||
city_meta = CITIES.get(city) or {}
|
||||
offset = utc_offset_seconds
|
||||
if offset is None:
|
||||
offset = _safe_int(city_meta.get("tz"), 0)
|
||||
try:
|
||||
offset = int(offset or 0)
|
||||
except Exception:
|
||||
offset = 0
|
||||
return datetime.now(timezone.utc) + timedelta(seconds=offset)
|
||||
|
||||
|
||||
def _city_local_date(city: str, utc_offset_seconds: Optional[int] = None) -> str:
|
||||
return _city_local_now(city, utc_offset_seconds).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _city_local_time(city: str, utc_offset_seconds: Optional[int] = None) -> str:
|
||||
return _city_local_now(city, utc_offset_seconds).strftime("%H:%M")
|
||||
|
||||
|
||||
def _model_sources_with_weathernext2(
|
||||
base_models: Any,
|
||||
data: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
models = (
|
||||
{
|
||||
str(k): v
|
||||
for k, v in (base_models or {}).items()
|
||||
if v is not None
|
||||
}
|
||||
if isinstance(base_models, dict)
|
||||
else {}
|
||||
)
|
||||
weathernext2 = data.get("weathernext2")
|
||||
if isinstance(weathernext2, dict):
|
||||
summary = (
|
||||
weathernext2.get("summary")
|
||||
if isinstance(weathernext2.get("summary"), dict)
|
||||
else {}
|
||||
)
|
||||
representative = _safe_float(summary.get("median"))
|
||||
if representative is None:
|
||||
representative = _safe_float(summary.get("mean"))
|
||||
if representative is not None:
|
||||
models["WeatherNext 2"] = round(float(representative), 1)
|
||||
return models
|
||||
|
||||
|
||||
def _panel_cache_stale_reason(city: str, cached_entry: Dict[str, Any], payload: Dict[str, Any]) -> Optional[str]:
|
||||
updated_at_ts = _safe_float(cached_entry.get("updated_at_ts"))
|
||||
if updated_at_ts is None or time.time() - updated_at_ts > SCAN_PANEL_CACHE_MAX_AGE_SEC:
|
||||
return "scan_terminal_stale_panel"
|
||||
|
||||
tz_offset = payload.get("utc_offset_seconds")
|
||||
if tz_offset is None:
|
||||
tz_offset = (CITIES.get(city) or {}).get("tz")
|
||||
expected_date = _city_local_date(city, _safe_int(tz_offset, 0))
|
||||
payload_date = str(payload.get("local_date") or "").strip()
|
||||
if payload_date and payload_date != expected_date:
|
||||
return "scan_terminal_stale_panel_date"
|
||||
return None
|
||||
|
||||
|
||||
def _cached_panel_multi_model_for_local_date(
|
||||
payload: Dict[str, Any],
|
||||
local_date: str,
|
||||
*,
|
||||
use_fahrenheit: bool,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
daily = payload.get("multi_model_daily")
|
||||
if isinstance(daily, dict):
|
||||
daily_entry = daily.get(local_date)
|
||||
if isinstance(daily_entry, dict):
|
||||
raw_models = daily_entry.get("models")
|
||||
if isinstance(raw_models, dict):
|
||||
forecasts = {
|
||||
str(model): value
|
||||
for model, value in raw_models.items()
|
||||
if _safe_float(value) is not None
|
||||
}
|
||||
if forecasts:
|
||||
return {
|
||||
"source": "cached_panel_multi_model_daily",
|
||||
"provider": "panel-cache",
|
||||
"forecasts": forecasts,
|
||||
"daily_forecasts": {local_date: forecasts},
|
||||
"hourly_times": [],
|
||||
"hourly_forecasts": {},
|
||||
"model_metadata": {},
|
||||
"model_keys": list(forecasts.keys()),
|
||||
"dates": [local_date],
|
||||
"unit": "fahrenheit" if use_fahrenheit else "celsius",
|
||||
"scan_terminal_panel_cache": True,
|
||||
}
|
||||
|
||||
multi_model = payload.get("multi_model")
|
||||
if isinstance(multi_model, dict) and multi_model_forecasts_for_local_date(
|
||||
multi_model,
|
||||
local_date,
|
||||
):
|
||||
return dict(multi_model)
|
||||
return None
|
||||
|
||||
|
||||
def _model_spread_sigma(forecasts: Dict[str, float], temp_symbol: str) -> float:
|
||||
values = [
|
||||
value
|
||||
for value in (_safe_float(item) for item in forecasts.values())
|
||||
if value is not None
|
||||
]
|
||||
scale = 1.8 if "F" in str(temp_symbol or "").upper() else 1.0
|
||||
if len(values) < 2:
|
||||
return 1.2 * scale
|
||||
spread = max(values) - min(values)
|
||||
return max(0.8 * scale, min(4.0 * scale, spread / 2.0))
|
||||
|
||||
|
||||
def _multi_model_cache_key(
|
||||
city: str,
|
||||
lat: float,
|
||||
lon: float,
|
||||
*,
|
||||
use_fahrenheit: bool,
|
||||
) -> str:
|
||||
version = str(getattr(_weather, "multi_model_cache_version", "v5") or "v5")
|
||||
return (
|
||||
f"{round(float(lat), 4)}:{round(float(lon), 4)}:{str(city or '').strip().lower()}:"
|
||||
f"{'f' if use_fahrenheit else 'c'}:{version}"
|
||||
)
|
||||
|
||||
|
||||
def _read_cached_multi_model_for_today(
|
||||
city: str,
|
||||
*,
|
||||
lat: float,
|
||||
lon: float,
|
||||
use_fahrenheit: bool,
|
||||
local_date: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
maybe_reload = getattr(_weather, "_maybe_reload_open_meteo_disk_cache", None)
|
||||
if callable(maybe_reload):
|
||||
try:
|
||||
maybe_reload()
|
||||
except Exception:
|
||||
pass
|
||||
cache = getattr(_weather, "_multi_model_cache", None)
|
||||
lock = getattr(_weather, "_multi_model_cache_lock", None)
|
||||
if not isinstance(cache, dict) or lock is None:
|
||||
return None
|
||||
key = _multi_model_cache_key(city, lat, lon, use_fahrenheit=use_fahrenheit)
|
||||
try:
|
||||
with lock:
|
||||
entry = cache.get(key)
|
||||
data = entry.get("data") if isinstance(entry, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
if isinstance(data, dict) and multi_model_forecasts_for_local_date(data, local_date):
|
||||
return dict(data)
|
||||
return None
|
||||
|
||||
|
||||
def _store_multi_model_cache(
|
||||
city: str,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
lat: float,
|
||||
lon: float,
|
||||
use_fahrenheit: bool,
|
||||
) -> None:
|
||||
cache = getattr(_weather, "_multi_model_cache", None)
|
||||
lock = getattr(_weather, "_multi_model_cache_lock", None)
|
||||
if not isinstance(cache, dict) or lock is None:
|
||||
return
|
||||
key = _multi_model_cache_key(city, lat, lon, use_fahrenheit=use_fahrenheit)
|
||||
try:
|
||||
with lock:
|
||||
cache[key] = {"t": time.time(), "data": dict(payload)}
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _fetch_scan_terminal_multi_model_batch(city_names: List[str]) -> Dict[str, Dict[str, Any]]:
|
||||
"""Fetch daily max multi-model payloads for scan rows in batch.
|
||||
|
||||
The scan terminal only needs today's max per model. A batched daily request
|
||||
avoids 50 per-city calls while still preserving city-local dates.
|
||||
"""
|
||||
grouped: Dict[bool, List[Dict[str, Any]]] = {False: [], True: []}
|
||||
results: Dict[str, Dict[str, Any]] = {}
|
||||
for city in city_names:
|
||||
city_meta = CITIES.get(city) or {}
|
||||
lat = _safe_float(city_meta.get("lat"))
|
||||
lon = _safe_float(city_meta.get("lon"))
|
||||
if lat is None or lon is None:
|
||||
continue
|
||||
use_fahrenheit = bool(city_meta.get("f"))
|
||||
local_date = _city_local_date(city, _safe_int(city_meta.get("tz"), 0))
|
||||
cached = _read_cached_multi_model_for_today(
|
||||
city,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
local_date=local_date,
|
||||
)
|
||||
if cached:
|
||||
results[city] = cached
|
||||
continue
|
||||
grouped[use_fahrenheit].append(
|
||||
{
|
||||
"city": city,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"local_date": local_date,
|
||||
}
|
||||
)
|
||||
|
||||
http_get = getattr(_weather, "_http_get", None)
|
||||
if not callable(http_get):
|
||||
return results
|
||||
|
||||
stored_any = False
|
||||
for use_fahrenheit, unit_items in grouped.items():
|
||||
if not unit_items:
|
||||
continue
|
||||
for start in range(0, len(unit_items), SCAN_TERMINAL_MULTI_MODEL_BATCH_SIZE):
|
||||
items = unit_items[start : start + SCAN_TERMINAL_MULTI_MODEL_BATCH_SIZE]
|
||||
if not items:
|
||||
continue
|
||||
try:
|
||||
wait_slot = getattr(_weather, "_wait_open_meteo_slot", None)
|
||||
if callable(wait_slot):
|
||||
wait_slot("scan-terminal-multi-model-batch")
|
||||
params: Dict[str, Any] = {
|
||||
"latitude": ",".join(str(item["lat"]) for item in items),
|
||||
"longitude": ",".join(str(item["lon"]) for item in items),
|
||||
"daily": "temperature_2m_max",
|
||||
"models": ",".join(OPEN_METEO_MULTI_MODEL_ORDER),
|
||||
"timezone": "auto",
|
||||
"forecast_days": 3,
|
||||
}
|
||||
if use_fahrenheit:
|
||||
params["temperature_unit"] = "fahrenheit"
|
||||
response = http_get(
|
||||
"https://api.open-meteo.com/v1/forecast",
|
||||
params=params,
|
||||
timeout=max(10.0, float(getattr(_weather, "open_meteo_timeout_sec", 5.0))),
|
||||
)
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
payloads = raw if isinstance(raw, list) else [raw]
|
||||
for item, location_payload in zip(items, payloads):
|
||||
daily = location_payload.get("daily", {}) if isinstance(location_payload, dict) else {}
|
||||
if not isinstance(daily, dict):
|
||||
continue
|
||||
dates, daily_forecasts, model_metadata, model_keys = _parse_open_meteo_multi_model_daily(daily)
|
||||
if not daily_forecasts:
|
||||
continue
|
||||
local_date = item["local_date"]
|
||||
forecasts = daily_forecasts.get(local_date) or {}
|
||||
if not forecasts:
|
||||
continue
|
||||
city = str(item["city"])
|
||||
result = {
|
||||
"source": "multi_model",
|
||||
"provider": "open-meteo",
|
||||
"forecasts": forecasts,
|
||||
"daily_forecasts": daily_forecasts,
|
||||
"hourly_times": [],
|
||||
"hourly_forecasts": {},
|
||||
"model_metadata": model_metadata,
|
||||
"model_keys": model_keys,
|
||||
"dates": dates,
|
||||
"unit": "fahrenheit" if use_fahrenheit else "celsius",
|
||||
"attribution": "Open-Meteo forecast model API; underlying models from ECMWF, DWD, ECCC, NOAA/NCEP, Google and JMA.",
|
||||
"scan_terminal_batch": True,
|
||||
}
|
||||
results[city] = result
|
||||
_store_multi_model_cache(
|
||||
city,
|
||||
result,
|
||||
lat=float(item["lat"]),
|
||||
lon=float(item["lon"]),
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
)
|
||||
stored_any = True
|
||||
except Exception:
|
||||
continue
|
||||
if stored_any:
|
||||
flush = getattr(_weather, "_flush_open_meteo_disk_cache", None)
|
||||
if callable(flush):
|
||||
try:
|
||||
flush()
|
||||
except Exception:
|
||||
pass
|
||||
return results
|
||||
|
||||
|
||||
def _fetch_today_forecast_panel_payload(
|
||||
city: str,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
multi_model_override: Optional[Dict[str, Any]] = None,
|
||||
allow_direct_fetch: bool = True,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
city_meta = CITIES.get(city) or {}
|
||||
lat = _safe_float(city_meta.get("lat"))
|
||||
lon = _safe_float(city_meta.get("lon"))
|
||||
if lat is None or lon is None:
|
||||
return None
|
||||
|
||||
use_fahrenheit = bool(city_meta.get("f"))
|
||||
temp_symbol = "°F" if use_fahrenheit else "°C"
|
||||
tz_offset = payload.get("utc_offset_seconds")
|
||||
if tz_offset is None:
|
||||
tz_offset = city_meta.get("tz")
|
||||
tz_offset_int = _safe_int(tz_offset, 0)
|
||||
local_date = _city_local_date(city, tz_offset_int)
|
||||
local_time = _city_local_time(city, tz_offset_int)
|
||||
|
||||
multi_model = multi_model_override
|
||||
if not isinstance(multi_model, dict):
|
||||
if not allow_direct_fetch:
|
||||
return None
|
||||
try:
|
||||
multi_model = _weather.fetch_multi_model(
|
||||
lat,
|
||||
lon,
|
||||
city=city,
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
)
|
||||
except Exception:
|
||||
multi_model = None
|
||||
forecasts = multi_model_forecasts_for_local_date(multi_model, local_date)
|
||||
if not forecasts:
|
||||
return None
|
||||
|
||||
try:
|
||||
deb_result = calculate_deb_prediction(
|
||||
city,
|
||||
forecasts,
|
||||
raw_calculator=calculate_dynamic_weights,
|
||||
)
|
||||
except Exception:
|
||||
deb_result = {}
|
||||
deb_prediction = _safe_float(deb_result.get("prediction"))
|
||||
|
||||
probabilities: Dict[str, Any] = {"mu": None, "distribution": [], "distribution_all": []}
|
||||
if deb_prediction is not None:
|
||||
sigma = _model_spread_sigma(forecasts, temp_symbol)
|
||||
current = payload.get("current") if isinstance(payload.get("current"), dict) else {}
|
||||
max_so_far = _safe_float(current.get("max_so_far"))
|
||||
probs = calculate_prob_distribution(
|
||||
deb_prediction,
|
||||
sigma,
|
||||
max_so_far,
|
||||
temp_symbol,
|
||||
city,
|
||||
)
|
||||
probabilities = {
|
||||
"mu": probs.get("mu", deb_prediction),
|
||||
"sigma": probs.get("sigma", sigma),
|
||||
"distribution": probs.get("probabilities") or [],
|
||||
"distribution_all": probs.get("probabilities_all") or probs.get("probabilities") or [],
|
||||
"engine": "legacy_gaussian_live_multimodel",
|
||||
"calibration_mode": "scan_terminal_live_refresh",
|
||||
}
|
||||
|
||||
source_local_date = str(payload.get("local_date") or "").strip()
|
||||
return {
|
||||
**payload,
|
||||
"local_date": local_date,
|
||||
"local_time": local_time,
|
||||
"utc_offset_seconds": tz_offset_int,
|
||||
"temp_symbol": temp_symbol,
|
||||
"multi_model": multi_model or {},
|
||||
"multi_model_daily": {
|
||||
local_date: {
|
||||
"models": forecasts,
|
||||
"deb": deb_result if deb_result else {"prediction": deb_prediction},
|
||||
}
|
||||
},
|
||||
"deb": deb_result if deb_result else {"prediction": deb_prediction},
|
||||
"probabilities": probabilities,
|
||||
"forecast_refreshed": True,
|
||||
"forecast_source_local_date": local_date,
|
||||
"forecast_previous_local_date": source_local_date or None,
|
||||
}
|
||||
|
||||
|
||||
def _build_forecast_only_panel_payload(
|
||||
city: str,
|
||||
multi_model: Dict[str, Any],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
city_meta = CITIES.get(city) or {}
|
||||
temp_symbol = "°F" if bool(city_meta.get("f")) else "°C"
|
||||
return _fetch_today_forecast_panel_payload(
|
||||
city,
|
||||
{
|
||||
"display_name": city_meta.get("name") or city_meta.get("display_name") or city,
|
||||
"current": {},
|
||||
"risk": {},
|
||||
"probabilities": {},
|
||||
"temp_symbol": temp_symbol,
|
||||
"utc_offset_seconds": _safe_int(city_meta.get("tz"), 0),
|
||||
},
|
||||
multi_model_override=multi_model,
|
||||
allow_direct_fetch=False,
|
||||
)
|
||||
|
||||
|
||||
def _load_scan_panel_payload(
|
||||
city: str,
|
||||
*,
|
||||
force_refresh: bool,
|
||||
multi_model_override: Optional[Dict[str, Any]] = None,
|
||||
allow_direct_fetch: bool = True,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
refresh_already_queued = False
|
||||
cached_entry = _PANEL_CACHE_DB.get_city_cache("panel", city)
|
||||
cached_payload = cached_entry.get("payload") if isinstance(cached_entry, dict) else None
|
||||
if isinstance(cached_payload, dict):
|
||||
stale_reason = _panel_cache_stale_reason(city, cached_entry, cached_payload)
|
||||
if not force_refresh and stale_reason is None:
|
||||
return cached_payload
|
||||
effective_multi_model_override = multi_model_override
|
||||
if not isinstance(effective_multi_model_override, dict):
|
||||
city_meta = CITIES.get(city) or {}
|
||||
tz_offset = cached_payload.get("utc_offset_seconds")
|
||||
if tz_offset is None:
|
||||
tz_offset = city_meta.get("tz")
|
||||
local_date = _city_local_date(city, _safe_int(tz_offset, 0))
|
||||
cached_panel_multi_model = _cached_panel_multi_model_for_local_date(
|
||||
cached_payload,
|
||||
local_date,
|
||||
use_fahrenheit=bool(city_meta.get("f")),
|
||||
)
|
||||
if cached_panel_multi_model:
|
||||
effective_multi_model_override = cached_panel_multi_model
|
||||
_enqueue_scan_terminal_refresh(city, reason=stale_reason or "scan_terminal_force_forecast_refresh")
|
||||
refresh_already_queued = True
|
||||
refreshed_payload = _fetch_today_forecast_panel_payload(
|
||||
city,
|
||||
cached_payload,
|
||||
multi_model_override=effective_multi_model_override,
|
||||
allow_direct_fetch=allow_direct_fetch,
|
||||
)
|
||||
if refreshed_payload:
|
||||
return refreshed_payload
|
||||
|
||||
canonical_getter = getattr(_PANEL_CACHE_DB, "get_canonical_temperature", None)
|
||||
canonical_entry = canonical_getter(city) if callable(canonical_getter) else None
|
||||
@@ -66,10 +519,19 @@ def _load_scan_panel_payload(city: str, *, force_refresh: bool) -> Optional[Dict
|
||||
city_meta = CITIES.get(city) or {}
|
||||
payload.setdefault("display_name", city_meta.get("name") or city_meta.get("display_name") or city)
|
||||
payload.setdefault("temp_symbol", canonical.get("temp_symbol") or "°C")
|
||||
refreshed_payload = _fetch_today_forecast_panel_payload(
|
||||
city,
|
||||
payload,
|
||||
multi_model_override=multi_model_override,
|
||||
allow_direct_fetch=allow_direct_fetch,
|
||||
)
|
||||
if refreshed_payload:
|
||||
payload = refreshed_payload
|
||||
_enqueue_scan_terminal_refresh(city, reason="scan_terminal_canonical_fallback")
|
||||
return payload
|
||||
|
||||
_enqueue_scan_terminal_refresh(city, reason="scan_terminal_cold_start")
|
||||
if not refresh_already_queued:
|
||||
_enqueue_scan_terminal_refresh(city, reason="scan_terminal_cold_start")
|
||||
return None
|
||||
|
||||
|
||||
@@ -177,7 +639,12 @@ def _build_terminal_row(
|
||||
"distribution_full": scan.get("distribution_full") or scan.get("distribution_preview") or row.get("distribution_preview") or [],
|
||||
"probability_engine": scan.get("probability_engine") or (data.get("probabilities") or {}).get("engine"),
|
||||
"probability_calibration_mode": scan.get("probability_calibration_mode") or (data.get("probabilities") or {}).get("calibration_mode"),
|
||||
"model_cluster_sources": daily_entry.get("models") if isinstance(daily_entry.get("models"), dict) else data.get("multi_model", {}).get("forecasts"),
|
||||
"model_cluster_sources": _model_sources_with_weathernext2(
|
||||
daily_entry.get("models")
|
||||
if isinstance(daily_entry.get("models"), dict)
|
||||
else data.get("multi_model", {}).get("forecasts"),
|
||||
data,
|
||||
),
|
||||
"window_phase": row.get("window_phase") or scan.get("window_phase"),
|
||||
"window_score": row.get("window_score") if row.get("window_score") is not None else scan.get("window_score"),
|
||||
"signal_status": scan.get("signal_status"),
|
||||
@@ -203,8 +670,16 @@ def _scan_city_terminal_rows(
|
||||
filters: Dict[str, Any],
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
multi_model_override: Optional[Dict[str, Any]] = None,
|
||||
allow_direct_fetch: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
return _scan_city_terminal_rows_quick(city, filters, force_refresh=force_refresh)
|
||||
return _scan_city_terminal_rows_quick(
|
||||
city,
|
||||
filters,
|
||||
force_refresh=force_refresh,
|
||||
multi_model_override=multi_model_override,
|
||||
allow_direct_fetch=allow_direct_fetch,
|
||||
)
|
||||
|
||||
|
||||
def _scan_city_terminal_rows_quick(
|
||||
@@ -212,10 +687,28 @@ def _scan_city_terminal_rows_quick(
|
||||
filters: Dict[str, Any],
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
multi_model_override: Optional[Dict[str, Any]] = None,
|
||||
allow_direct_fetch: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Fast path that returns cached analysis rows only — returns a single row per city
|
||||
with cached analysis data (Obs, DEB, probabilities) but no market prices."""
|
||||
data = _load_scan_panel_payload(city, force_refresh=force_refresh)
|
||||
if isinstance(multi_model_override, dict) and multi_model_override:
|
||||
data = _build_forecast_only_panel_payload(city, multi_model_override)
|
||||
if data:
|
||||
row = _build_quick_row(city=city, data=data)
|
||||
return {
|
||||
"city": city,
|
||||
"rows": [row] if row else [],
|
||||
"candidate_total": 1,
|
||||
"primary_scores": [float(row.get("final_score") or 0)] if row else [],
|
||||
}
|
||||
|
||||
data = _load_scan_panel_payload(
|
||||
city,
|
||||
force_refresh=force_refresh,
|
||||
multi_model_override=multi_model_override,
|
||||
allow_direct_fetch=allow_direct_fetch,
|
||||
)
|
||||
if not data:
|
||||
return {
|
||||
"city": city,
|
||||
@@ -284,18 +777,19 @@ def _build_quick_row(
|
||||
"network_provider": data.get("official_network_source") or official_status.get("provider_code"),
|
||||
"network_provider_label": official_status.get("provider_label"),
|
||||
"deb_prediction": deb.get("prediction"),
|
||||
"model_cluster_sources": (
|
||||
"model_cluster_sources": _model_sources_with_weathernext2(
|
||||
daily_entry.get("models")
|
||||
if isinstance(daily_entry.get("models"), dict)
|
||||
else {
|
||||
str(k): v for k, v in multi.get("forecasts", {}).items()
|
||||
if v is not None
|
||||
}
|
||||
else multi.get("forecasts", {}),
|
||||
data,
|
||||
),
|
||||
"distribution_preview": distribution[:6] if distribution else [],
|
||||
"distribution_full": probs.get("distribution_all") or distribution,
|
||||
"probability_engine": probs.get("engine"),
|
||||
"probability_calibration_mode": probs.get("calibration_mode"),
|
||||
"forecast_refreshed": bool(data.get("forecast_refreshed")),
|
||||
"forecast_source_local_date": data.get("forecast_source_local_date"),
|
||||
"forecast_previous_local_date": data.get("forecast_previous_local_date"),
|
||||
"trading_region": market_region["key"],
|
||||
"trading_region_label": market_region["label_en"],
|
||||
"trading_region_label_zh": market_region["label_zh"],
|
||||
|
||||
+108
-46
@@ -29,7 +29,10 @@ from web.scan_terminal_cache import (
|
||||
set_cached_scan_terminal_payload,
|
||||
set_scan_terminal_failure_state,
|
||||
)
|
||||
from web.scan_terminal_city_row import _scan_city_terminal_rows
|
||||
from web.scan_terminal_city_row import (
|
||||
_fetch_scan_terminal_multi_model_batch,
|
||||
_scan_city_terminal_rows,
|
||||
)
|
||||
from web.scan_terminal_filters import (
|
||||
normalize_scan_terminal_filters as _normalize_scan_terminal_filters,
|
||||
)
|
||||
@@ -67,6 +70,19 @@ def _rows_count(payload: Dict[str, Any]) -> int:
|
||||
return len(rows) if isinstance(rows, list) else 0
|
||||
|
||||
|
||||
def _model_bearing_rows_count(rows: Any) -> int:
|
||||
if not isinstance(rows, list):
|
||||
return 0
|
||||
count = 0
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
sources = row.get("model_cluster_sources")
|
||||
if isinstance(sources, dict) and sources:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _success_payload_within_stale_window(cached_entry: Dict[str, Any]) -> bool:
|
||||
timestamp = cached_entry.get("success_t") or cached_entry.get("t")
|
||||
try:
|
||||
@@ -116,6 +132,10 @@ def _build_stale_payload_for_timeout_if_better_cached(
|
||||
success_payload = cached_entry.get("success_payload")
|
||||
if not isinstance(success_payload, dict) or not success_payload.get("rows"):
|
||||
return None
|
||||
if _model_bearing_rows_count(ranked_rows) > _model_bearing_rows_count(
|
||||
success_payload.get("rows")
|
||||
):
|
||||
return None
|
||||
if _rows_count(success_payload) < len(ranked_rows):
|
||||
return None
|
||||
|
||||
@@ -184,59 +204,86 @@ def _build_scan_terminal_payload_uncached(
|
||||
return _tz_region(tz)["key"] == region_filter
|
||||
|
||||
city_names = [c for c in city_names if _city_in_region(c)]
|
||||
max_workers = max(1, min(SCAN_TERMINAL_MAX_WORKERS, len(city_names)))
|
||||
city_results: List[Dict[str, Any]] = []
|
||||
failed_cities: List[str] = []
|
||||
failed_reasons: List[str] = []
|
||||
multi_model_overrides = _fetch_scan_terminal_multi_model_batch(city_names)
|
||||
scan_city_names = (
|
||||
[city_name for city_name in city_names if city_name in multi_model_overrides]
|
||||
if multi_model_overrides
|
||||
else city_names
|
||||
)
|
||||
max_workers = max(1, min(SCAN_TERMINAL_MAX_WORKERS, len(scan_city_names)))
|
||||
|
||||
timed_out = False
|
||||
timeout_message: Optional[str] = None
|
||||
executor = ThreadPoolExecutor(max_workers=max_workers)
|
||||
future_map = {
|
||||
executor.submit(
|
||||
_scan_city_terminal_rows,
|
||||
city_name,
|
||||
filters,
|
||||
force_refresh=force_refresh,
|
||||
): city_name
|
||||
for city_name in city_names
|
||||
}
|
||||
try:
|
||||
try:
|
||||
completed = as_completed(
|
||||
future_map,
|
||||
timeout=build_timeout_sec,
|
||||
)
|
||||
for future in completed:
|
||||
city_name = future_map[future]
|
||||
try:
|
||||
city_results.append(future.result())
|
||||
except Exception as exc:
|
||||
failed_cities.append(city_name)
|
||||
failed_reasons.append(str(exc))
|
||||
logger.warning(
|
||||
"scan terminal city failed city={}: {}", city_name, exc
|
||||
if multi_model_overrides:
|
||||
for city_name in scan_city_names:
|
||||
try:
|
||||
city_results.append(
|
||||
_scan_city_terminal_rows(
|
||||
city_name,
|
||||
filters,
|
||||
force_refresh=force_refresh,
|
||||
multi_model_override=multi_model_overrides.get(city_name),
|
||||
allow_direct_fetch=False,
|
||||
)
|
||||
except FutureTimeoutError:
|
||||
timed_out = True
|
||||
timeout_message = (
|
||||
f"scan terminal build timed out after {build_timeout_sec:g}s"
|
||||
)
|
||||
failed_reasons.append(timeout_message)
|
||||
for future, city_name in future_map.items():
|
||||
if not future.done():
|
||||
future.cancel()
|
||||
failed_cities.append(city_name)
|
||||
logger.warning(
|
||||
"{}; completed={}/{}",
|
||||
timeout_message,
|
||||
len(city_results),
|
||||
len(city_names),
|
||||
)
|
||||
finally:
|
||||
executor.shutdown(wait=False)
|
||||
)
|
||||
except Exception as exc:
|
||||
failed_cities.append(city_name)
|
||||
failed_reasons.append(str(exc))
|
||||
logger.warning(
|
||||
"scan terminal city failed city={}: {}", city_name, exc
|
||||
)
|
||||
else:
|
||||
executor = ThreadPoolExecutor(max_workers=max_workers)
|
||||
future_map = {
|
||||
executor.submit(
|
||||
_scan_city_terminal_rows,
|
||||
city_name,
|
||||
filters,
|
||||
force_refresh=force_refresh,
|
||||
multi_model_override=multi_model_overrides.get(city_name),
|
||||
allow_direct_fetch=False,
|
||||
): city_name
|
||||
for city_name in scan_city_names
|
||||
}
|
||||
try:
|
||||
try:
|
||||
completed = as_completed(
|
||||
future_map,
|
||||
timeout=build_timeout_sec,
|
||||
)
|
||||
for future in completed:
|
||||
city_name = future_map[future]
|
||||
try:
|
||||
city_results.append(future.result())
|
||||
except Exception as exc:
|
||||
failed_cities.append(city_name)
|
||||
failed_reasons.append(str(exc))
|
||||
logger.warning(
|
||||
"scan terminal city failed city={}: {}", city_name, exc
|
||||
)
|
||||
except FutureTimeoutError:
|
||||
timed_out = True
|
||||
timeout_message = (
|
||||
f"scan terminal build timed out after {build_timeout_sec:g}s"
|
||||
)
|
||||
failed_reasons.append(timeout_message)
|
||||
for future, city_name in future_map.items():
|
||||
if not future.done():
|
||||
future.cancel()
|
||||
failed_cities.append(city_name)
|
||||
logger.warning(
|
||||
"{}; completed={}/{}",
|
||||
timeout_message,
|
||||
len(city_results),
|
||||
len(scan_city_names),
|
||||
)
|
||||
finally:
|
||||
executor.shutdown(wait=False)
|
||||
|
||||
if city_names and len(failed_cities) >= len(city_names):
|
||||
if scan_city_names and len(failed_cities) >= len(scan_city_names):
|
||||
error_message = (
|
||||
failed_reasons[0] if failed_reasons else "all city market scans failed"
|
||||
)
|
||||
@@ -288,6 +335,21 @@ def _build_scan_terminal_payload_uncached(
|
||||
)
|
||||
if stale_payload is not None:
|
||||
return stale_payload
|
||||
success_payload = cached_entry.get("success_payload")
|
||||
if (
|
||||
isinstance(success_payload, dict)
|
||||
and _model_bearing_rows_count(success_payload.get("rows")) > 0
|
||||
and _model_bearing_rows_count(ranked_rows) == 0
|
||||
):
|
||||
error_message = "scan terminal refresh returned rows without model forecasts"
|
||||
set_scan_terminal_failure_state(filters, error_message=error_message)
|
||||
failed_entry = get_scan_terminal_cache_entry(filters) or {}
|
||||
return build_stale_scan_terminal_payload(
|
||||
filters=filters,
|
||||
success_payload=success_payload,
|
||||
error_message=error_message,
|
||||
failed_at=failed_entry.get("last_failed_at"),
|
||||
)
|
||||
|
||||
payload = {
|
||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||
|
||||
+37
-25
@@ -18,6 +18,7 @@ from src.data_collection.forecast_source_bundle import (
|
||||
_multi_model_cache_key,
|
||||
fetch_open_meteo_forecast_bundle,
|
||||
)
|
||||
from src.data_collection.multi_model_freshness import multi_model_forecasts_for_local_date
|
||||
import web.routes as legacy_routes
|
||||
from web.analysis_service import _runway_history_temp_for_city
|
||||
from web.services.canonical_temperature import build_city_weather_from_canonical
|
||||
@@ -868,6 +869,7 @@ def _floor_chart_forecast_with_observed_high(payload: Dict[str, Any]) -> Dict[st
|
||||
rounded_floor = round(float(observed_floor), 1)
|
||||
next_payload = _floor_forecast_today_high(next_payload, local_date, rounded_floor)
|
||||
next_payload = _floor_deb_prediction(next_payload, rounded_floor)
|
||||
next_payload = _floor_deb_hourly_path(next_payload, rounded_floor)
|
||||
next_payload = _floor_multi_model_daily_deb(next_payload, local_date, rounded_floor)
|
||||
next_payload = _floor_probability_mu(next_payload, rounded_floor)
|
||||
return next_payload
|
||||
@@ -923,31 +925,13 @@ def _first_float(block: Dict[str, Any], keys: Tuple[str, ...]) -> Optional[float
|
||||
|
||||
|
||||
def _multi_model_daily_models_for_date(multi_model: Any, local_date: str) -> Dict[str, float]:
|
||||
if not isinstance(multi_model, dict) or not local_date:
|
||||
return {}
|
||||
|
||||
models: Dict[str, float] = {}
|
||||
daily = multi_model.get("daily_forecasts") if isinstance(multi_model.get("daily_forecasts"), dict) else {}
|
||||
day_models = daily.get(local_date) if isinstance(daily, dict) else {}
|
||||
if isinstance(day_models, dict):
|
||||
for model, value in day_models.items():
|
||||
parsed = _float_or_none(value)
|
||||
if parsed is not None:
|
||||
models[str(model)] = round(parsed, 1)
|
||||
|
||||
hourly_models = _multi_model_daily_models_from_hourly(multi_model, local_date)
|
||||
for model, value in hourly_models.items():
|
||||
current = models.get(model)
|
||||
models[model] = value if current is None else round(max(current, value), 1)
|
||||
|
||||
if not models:
|
||||
forecasts = multi_model.get("forecasts") if isinstance(multi_model.get("forecasts"), dict) else {}
|
||||
for model, value in forecasts.items():
|
||||
parsed = _float_or_none(value)
|
||||
if parsed is not None:
|
||||
models[str(model)] = round(parsed, 1)
|
||||
|
||||
return models
|
||||
return {
|
||||
model: round(value, 1)
|
||||
for model, value in multi_model_forecasts_for_local_date(
|
||||
multi_model,
|
||||
local_date,
|
||||
).items()
|
||||
}
|
||||
|
||||
|
||||
def _multi_model_daily_models_from_hourly(multi_model: Dict[str, Any], local_date: str) -> Dict[str, float]:
|
||||
@@ -1062,6 +1046,34 @@ def _floor_deb_prediction(payload: Dict[str, Any], observed_floor: float) -> Dic
|
||||
return next_payload
|
||||
|
||||
|
||||
def _floor_deb_hourly_path(payload: Dict[str, Any], observed_floor: float) -> Dict[str, Any]:
|
||||
deb = payload.get("deb") if isinstance(payload.get("deb"), dict) else {}
|
||||
path = deb.get("hourly_path") if isinstance(deb.get("hourly_path"), dict) else {}
|
||||
temps = path.get("temps") if isinstance(path.get("temps"), list) else []
|
||||
parsed_temps = [_float_or_none(value) for value in temps]
|
||||
valid_temps = [value for value in parsed_temps if value is not None]
|
||||
if not valid_temps:
|
||||
return payload
|
||||
path_max = max(valid_temps)
|
||||
if path_max >= observed_floor:
|
||||
return payload
|
||||
|
||||
offset = observed_floor - path_max
|
||||
next_payload = deepcopy(payload)
|
||||
next_deb = dict(next_payload.get("deb") or {})
|
||||
next_path = dict(next_deb.get("hourly_path") or {})
|
||||
next_path["temps"] = [
|
||||
round(value + offset, 1) if value is not None else raw_value
|
||||
for raw_value, value in zip(temps, parsed_temps)
|
||||
]
|
||||
next_path["observed_floor_applied"] = True
|
||||
next_path["observed_floor_offset"] = round(offset, 1)
|
||||
next_deb["hourly_path"] = next_path
|
||||
next_deb["observed_floor_applied"] = True
|
||||
next_payload["deb"] = next_deb
|
||||
return next_payload
|
||||
|
||||
|
||||
def _floor_multi_model_daily_deb(
|
||||
payload: Dict[str, Any],
|
||||
local_date: str,
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
|
||||
|
||||
import requests
|
||||
from fastapi import Request
|
||||
|
||||
from web.scan_terminal_service import build_scan_terminal_payload
|
||||
|
||||
GAMMA_API_BASE = "https://gamma-api.polymarket.com"
|
||||
CLOB_API_BASE = "https://clob.polymarket.com"
|
||||
_QUOTE_CACHE_TTL_SEC = 60
|
||||
_EVENT_CACHE_TTL_SEC = 180
|
||||
_QUOTE_COLLECTION_TIME_BUDGET_SEC = 18.0
|
||||
|
||||
_CACHE_LOCK = threading.Lock()
|
||||
_EVENT_CACHE: Dict[str, Tuple[float, Optional[Dict[str, Any]]]] = {}
|
||||
_PRICE_CACHE: Dict[str, Tuple[float, Optional[float]]] = {}
|
||||
|
||||
|
||||
def _require_ops(request: Request) -> Dict[str, Any] | None:
|
||||
from web.services.ops_api import _require_ops as _real
|
||||
|
||||
return _real(request)
|
||||
|
||||
|
||||
def _finite_number(value: Any) -> Optional[float]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not math.isfinite(number):
|
||||
return None
|
||||
return number
|
||||
|
||||
|
||||
def _json_list(value: Any) -> List[Any]:
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_key(value: Any) -> str:
|
||||
return re.sub(r"\s+", " ", str(value or "").strip().lower())
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
normalized = unicodedata.normalize("NFKD", value)
|
||||
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
|
||||
ascii_text = ascii_text.lower().replace("&", " and ")
|
||||
return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", ascii_text)).strip("-")
|
||||
|
||||
|
||||
def _city_slug(row: Mapping[str, Any]) -> str:
|
||||
city = _normalize_key(row.get("city_display_name") or row.get("display_name") or row.get("city"))
|
||||
if city in {"new york", "new york city"}:
|
||||
return "nyc"
|
||||
return _slugify(city)
|
||||
|
||||
|
||||
def _date_slug(value: Any) -> Optional[str]:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
return f"{parsed.strftime('%B').lower()}-{parsed.day}-{parsed.year}"
|
||||
|
||||
|
||||
def _event_slug_for_row(row: Mapping[str, Any]) -> Optional[str]:
|
||||
date_key = row.get("selected_date") or row.get("local_date")
|
||||
date_slug = _date_slug(date_key)
|
||||
city_slug = _city_slug(row)
|
||||
if not date_slug or not city_slug:
|
||||
return None
|
||||
return f"highest-temperature-in-{city_slug}-on-{date_slug}"
|
||||
|
||||
|
||||
def _market_url(slug: str) -> str:
|
||||
return f"https://polymarket.com/event/{slug}"
|
||||
|
||||
|
||||
def _round_probability(value: float) -> float:
|
||||
return round(value + 0.0000000001, 4)
|
||||
|
||||
|
||||
def _round_price(value: float) -> float:
|
||||
return round(value + 0.0000000001, 4)
|
||||
|
||||
|
||||
def _probability_from_bucket(bucket: Mapping[str, Any]) -> Optional[float]:
|
||||
raw = _finite_number(bucket.get("probability") or bucket.get("model_probability"))
|
||||
if raw is None:
|
||||
return None
|
||||
return raw / 100.0 if raw > 1 else raw
|
||||
|
||||
|
||||
def _distribution_points(row: Mapping[str, Any]) -> List[Tuple[int, float]]:
|
||||
raw = row.get("distribution_full") or row.get("distribution_preview") or []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
points: List[Tuple[int, float]] = []
|
||||
for bucket in raw:
|
||||
if not isinstance(bucket, Mapping):
|
||||
continue
|
||||
value = _finite_number(bucket.get("value") or bucket.get("temp") or bucket.get("temperature"))
|
||||
probability = _probability_from_bucket(bucket)
|
||||
if value is None or probability is None or probability <= 0:
|
||||
continue
|
||||
points.append((int(round(value)), float(probability)))
|
||||
return points
|
||||
|
||||
|
||||
def parse_market_option_from_question(question: str, unit: str) -> Dict[str, Any]:
|
||||
text = str(question or "").strip()
|
||||
unit_text = "°F" if "f" in str(unit or "").lower() else "°C"
|
||||
|
||||
between = re.search(
|
||||
r"between\s+(-?\d+)\s*-\s*(-?\d+)\s*°?\s*([CF])",
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if between:
|
||||
lower = int(between.group(1))
|
||||
upper = int(between.group(2))
|
||||
parsed_unit = f"°{between.group(3).upper()}"
|
||||
return {
|
||||
"label": f"{lower}-{upper}{parsed_unit}",
|
||||
"lower": min(lower, upper),
|
||||
"upper": max(lower, upper),
|
||||
"unit": parsed_unit,
|
||||
}
|
||||
|
||||
below = re.search(r"(-?\d+)\s*°?\s*([CF])\s+or\s+below", text, flags=re.IGNORECASE)
|
||||
if below:
|
||||
upper = int(below.group(1))
|
||||
parsed_unit = f"°{below.group(2).upper()}"
|
||||
return {
|
||||
"label": f"{upper}{parsed_unit} or below",
|
||||
"lower": None,
|
||||
"upper": upper,
|
||||
"unit": parsed_unit,
|
||||
}
|
||||
|
||||
higher = re.search(r"(-?\d+)\s*°?\s*([CF])\s+or\s+higher", text, flags=re.IGNORECASE)
|
||||
if higher:
|
||||
lower = int(higher.group(1))
|
||||
parsed_unit = f"°{higher.group(2).upper()}"
|
||||
return {
|
||||
"label": f"{lower}{parsed_unit} or higher",
|
||||
"lower": lower,
|
||||
"upper": None,
|
||||
"unit": parsed_unit,
|
||||
}
|
||||
|
||||
exact = re.search(r"\bbe\s+(-?\d+)\s*°?\s*([CF])\b", text, flags=re.IGNORECASE)
|
||||
if exact:
|
||||
value = int(exact.group(1))
|
||||
parsed_unit = f"°{exact.group(2).upper()}"
|
||||
return {
|
||||
"label": f"{value}{parsed_unit}",
|
||||
"lower": value,
|
||||
"upper": value,
|
||||
"unit": parsed_unit,
|
||||
}
|
||||
|
||||
value_match = re.search(r"(-?\d+)\s*°?\s*([CF])", text, flags=re.IGNORECASE)
|
||||
if value_match:
|
||||
value = int(value_match.group(1))
|
||||
parsed_unit = f"°{value_match.group(2).upper()}"
|
||||
return {
|
||||
"label": f"{value}{parsed_unit}",
|
||||
"lower": value,
|
||||
"upper": value,
|
||||
"unit": parsed_unit,
|
||||
}
|
||||
|
||||
return {"label": text or "—", "lower": None, "upper": None, "unit": unit_text}
|
||||
|
||||
|
||||
def _bucket_probability(row: Mapping[str, Any], option: Mapping[str, Any]) -> Optional[float]:
|
||||
points = _distribution_points(row)
|
||||
if not points:
|
||||
return None
|
||||
lower = option.get("lower")
|
||||
upper = option.get("upper")
|
||||
lower_number = int(lower) if lower is not None else None
|
||||
upper_number = int(upper) if upper is not None else None
|
||||
probability = 0.0
|
||||
for settled_value, point_probability in points:
|
||||
if lower_number is not None and settled_value < lower_number:
|
||||
continue
|
||||
if upper_number is not None and settled_value > upper_number:
|
||||
continue
|
||||
probability += point_probability
|
||||
return _round_probability(probability)
|
||||
|
||||
|
||||
def _model_sources(row: Mapping[str, Any]) -> Dict[str, float]:
|
||||
sources = row.get("model_cluster_sources") or {}
|
||||
if not isinstance(sources, Mapping):
|
||||
return {}
|
||||
result: Dict[str, float] = {}
|
||||
for name, value in sources.items():
|
||||
number = _finite_number(value)
|
||||
if number is None:
|
||||
continue
|
||||
result[str(name)] = round(number, 1)
|
||||
return result
|
||||
|
||||
|
||||
def _model_stats(row: Mapping[str, Any]) -> Tuple[Optional[float], Optional[float]]:
|
||||
values = sorted(_model_sources(row).values())
|
||||
if not values:
|
||||
return None, None
|
||||
mid = len(values) // 2
|
||||
median = values[mid] if len(values) % 2 else (values[mid - 1] + values[mid]) / 2
|
||||
return round(median, 1), round(max(values) - min(values), 1)
|
||||
|
||||
|
||||
def _model_option_relation(
|
||||
row: Mapping[str, Any],
|
||||
option: Mapping[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
sources = _model_sources(row)
|
||||
values = list(sources.values())
|
||||
lower = _finite_number(option.get("lower"))
|
||||
upper = _finite_number(option.get("upper"))
|
||||
unit = str(option.get("unit") or "").upper()
|
||||
half_width = 1.0 if "F" in unit and lower != upper else 0.5
|
||||
effective_lower = lower - half_width if lower is not None else None
|
||||
effective_upper = upper + half_width if upper is not None else None
|
||||
below = 0
|
||||
inside = 0
|
||||
above = 0
|
||||
for value in values:
|
||||
if effective_lower is not None and value < effective_lower:
|
||||
below += 1
|
||||
elif effective_upper is not None and value > effective_upper:
|
||||
above += 1
|
||||
else:
|
||||
inside += 1
|
||||
deb = _finite_number(row.get("deb_prediction"))
|
||||
above_deb = sum(1 for value in values if deb is not None and value > deb)
|
||||
return {
|
||||
"model_cluster_sources": sources,
|
||||
"model_count": len(values),
|
||||
"model_min": round(min(values), 1) if values else None,
|
||||
"model_max": round(max(values), 1) if values else None,
|
||||
"models_below_bucket": below,
|
||||
"models_in_bucket": inside,
|
||||
"models_above_bucket": above,
|
||||
"models_above_deb": above_deb,
|
||||
}
|
||||
|
||||
|
||||
def _local_hour(row: Mapping[str, Any]) -> Optional[int]:
|
||||
text = str(row.get("local_time") or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
hour = int(text.split(":", 1)[0])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return hour if 0 <= hour <= 23 else None
|
||||
|
||||
|
||||
def _option_near_value(option: Mapping[str, Any], value: Any) -> bool:
|
||||
number = _finite_number(value)
|
||||
if number is None:
|
||||
return False
|
||||
unit = str(option.get("unit") or "").upper()
|
||||
tolerance = 2.0 if "F" in unit else 1.0
|
||||
lower = _finite_number(option.get("lower"))
|
||||
upper = _finite_number(option.get("upper"))
|
||||
if lower is not None and upper is not None:
|
||||
return lower - tolerance <= number <= upper + tolerance
|
||||
if lower is not None:
|
||||
return number >= lower - tolerance
|
||||
if upper is not None:
|
||||
return number <= upper + tolerance
|
||||
return False
|
||||
|
||||
|
||||
def _option_effective_bounds(option: Mapping[str, Any]) -> Tuple[Optional[float], Optional[float]]:
|
||||
lower = _finite_number(option.get("lower"))
|
||||
upper = _finite_number(option.get("upper"))
|
||||
unit = str(option.get("unit") or "").upper()
|
||||
half_width = 1.0 if "F" in unit and lower != upper else 0.5
|
||||
effective_lower = lower - half_width if lower is not None else None
|
||||
effective_upper = upper + half_width if upper is not None else None
|
||||
return effective_lower, effective_upper
|
||||
|
||||
|
||||
def _is_priced_no_noise(
|
||||
row: Mapping[str, Any],
|
||||
option: Mapping[str, Any],
|
||||
ask_prices_by_token: Mapping[str, Optional[float]],
|
||||
tokens: Mapping[str, Optional[str]],
|
||||
*,
|
||||
no_ask: float,
|
||||
model_median: Optional[float],
|
||||
model_relation: Optional[Mapping[str, Any]] = None,
|
||||
) -> bool:
|
||||
if no_ask > 0.20:
|
||||
return False
|
||||
yes_token = tokens.get("yes")
|
||||
yes_ask = _finite_number(ask_prices_by_token.get(yes_token)) if yes_token else None
|
||||
if yes_ask is not None and yes_ask < 0.80:
|
||||
return False
|
||||
relation = model_relation or _model_option_relation(row, option)
|
||||
above = int(relation.get("models_above_bucket") or 0)
|
||||
below = int(relation.get("models_below_bucket") or 0)
|
||||
inside = int(relation.get("models_in_bucket") or 0)
|
||||
if above > max(inside, below):
|
||||
return False
|
||||
anchors = (
|
||||
row.get("current_max_so_far"),
|
||||
row.get("deb_prediction"),
|
||||
model_median,
|
||||
)
|
||||
if any(_option_near_value(option, anchor) for anchor in anchors):
|
||||
return True
|
||||
effective_lower, _effective_upper = _option_effective_bounds(option)
|
||||
model_max = _finite_number(relation.get("model_max"))
|
||||
if above == 0 and inside > 0 and effective_lower is not None and model_max is not None:
|
||||
return model_max >= effective_lower
|
||||
return False
|
||||
|
||||
|
||||
def _market_tokens(market: Mapping[str, Any]) -> Dict[str, Optional[str]]:
|
||||
outcomes = [str(item).strip().lower() for item in _json_list(market.get("outcomes"))]
|
||||
token_ids = [str(item).strip() for item in _json_list(market.get("clobTokenIds"))]
|
||||
result = {"yes": None, "no": None}
|
||||
for index, outcome in enumerate(outcomes):
|
||||
if index >= len(token_ids) or not token_ids[index]:
|
||||
continue
|
||||
if outcome in result:
|
||||
result[outcome] = token_ids[index]
|
||||
return result
|
||||
|
||||
|
||||
def _market_hint_prices(market: Mapping[str, Any]) -> Dict[str, Optional[float]]:
|
||||
outcomes = [str(item).strip().lower() for item in _json_list(market.get("outcomes"))]
|
||||
prices = _json_list(market.get("outcomePrices"))
|
||||
result: Dict[str, Optional[float]] = {"yes": None, "no": None}
|
||||
for index, outcome in enumerate(outcomes):
|
||||
if index >= len(prices) or outcome not in result:
|
||||
continue
|
||||
result[outcome] = _finite_number(prices[index])
|
||||
return result
|
||||
|
||||
|
||||
def _iter_market_sides(side: str) -> Iterable[str]:
|
||||
normalized = str(side or "both").strip().lower()
|
||||
if normalized in {"yes", "no"}:
|
||||
yield normalized
|
||||
return
|
||||
yield "yes"
|
||||
yield "no"
|
||||
|
||||
|
||||
def build_market_opportunity_rows(
|
||||
scan_rows: List[Dict[str, Any]],
|
||||
events_by_city: Mapping[str, Optional[Dict[str, Any]]],
|
||||
ask_prices_by_token: Mapping[str, Optional[float]],
|
||||
*,
|
||||
max_price: float = 0.20,
|
||||
side: str = "both",
|
||||
positive_edge_only: bool = True,
|
||||
min_edge: float = 0.0,
|
||||
limit: int = 200,
|
||||
query: str = "",
|
||||
region: str = "",
|
||||
) -> List[Dict[str, Any]]:
|
||||
opportunities: List[Dict[str, Any]] = []
|
||||
query_text = _normalize_key(query)
|
||||
region_text = _normalize_key(region)
|
||||
|
||||
for scan_row in scan_rows:
|
||||
city_key = _normalize_key(scan_row.get("city"))
|
||||
display_name = str(scan_row.get("city_display_name") or scan_row.get("display_name") or scan_row.get("city") or "—")
|
||||
if query_text and query_text not in _normalize_key(display_name) and query_text not in city_key:
|
||||
continue
|
||||
row_region = str(scan_row.get("trading_region_label_zh") or scan_row.get("trading_region_label") or "")
|
||||
if region_text and region_text not in {"all", ""} and region_text not in _normalize_key(row_region):
|
||||
continue
|
||||
|
||||
event = events_by_city.get(city_key)
|
||||
if not isinstance(event, Mapping):
|
||||
continue
|
||||
market_url = _market_url(str(event.get("slug") or ""))
|
||||
model_median, model_spread = _model_stats(scan_row)
|
||||
for market in event.get("markets") or []:
|
||||
if not isinstance(market, Mapping):
|
||||
continue
|
||||
if market.get("active") is False or market.get("closed") is True:
|
||||
continue
|
||||
if market.get("enableOrderBook") is False:
|
||||
continue
|
||||
option = parse_market_option_from_question(
|
||||
str(market.get("question") or ""),
|
||||
str(scan_row.get("temp_symbol") or "°C"),
|
||||
)
|
||||
model_probability = _bucket_probability(scan_row, option)
|
||||
if model_probability is None:
|
||||
continue
|
||||
tokens = _market_tokens(market)
|
||||
model_relation = _model_option_relation(scan_row, option)
|
||||
for option_side in _iter_market_sides(side):
|
||||
token_id = tokens.get(option_side)
|
||||
if not token_id:
|
||||
continue
|
||||
ask = ask_prices_by_token.get(token_id)
|
||||
ask_number = _finite_number(ask)
|
||||
if ask_number is None or ask_number <= 0 or ask_number > float(max_price):
|
||||
continue
|
||||
if option_side == "no" and _is_priced_no_noise(
|
||||
scan_row,
|
||||
option,
|
||||
ask_prices_by_token,
|
||||
tokens,
|
||||
no_ask=ask_number,
|
||||
model_median=model_median,
|
||||
model_relation=model_relation,
|
||||
):
|
||||
continue
|
||||
target_probability = (
|
||||
model_probability
|
||||
if option_side == "yes"
|
||||
else _round_probability(1.0 - model_probability)
|
||||
)
|
||||
edge = _round_probability(target_probability - ask_number)
|
||||
if positive_edge_only and edge <= float(min_edge):
|
||||
continue
|
||||
if not positive_edge_only and edge < float(min_edge):
|
||||
continue
|
||||
market_slug = str(market.get("slug") or "")
|
||||
opportunities.append(
|
||||
{
|
||||
"id": f"{city_key}:{market_slug}:{option_side}",
|
||||
"city": city_key,
|
||||
"display_name": display_name,
|
||||
"target_date": scan_row.get("selected_date") or scan_row.get("local_date"),
|
||||
"bucket_label": option["label"],
|
||||
"side": option_side,
|
||||
"ask_price": _round_price(ask_number),
|
||||
"model_probability": model_probability,
|
||||
"yes_probability": model_probability,
|
||||
"side_probability": target_probability,
|
||||
"edge": edge,
|
||||
"liquidity": _finite_number(market.get("liquidity")),
|
||||
"volume": _finite_number(market.get("volume")),
|
||||
"market_url": market_url,
|
||||
"market_slug": market_slug,
|
||||
"question": market.get("question"),
|
||||
"current_max_so_far": _finite_number(scan_row.get("current_max_so_far")),
|
||||
"deb_prediction": _finite_number(scan_row.get("deb_prediction")),
|
||||
"model_median": model_median,
|
||||
"model_spread": model_spread,
|
||||
**model_relation,
|
||||
"local_time": scan_row.get("local_time"),
|
||||
"region": row_region,
|
||||
}
|
||||
)
|
||||
|
||||
opportunities.sort(
|
||||
key=lambda row: (
|
||||
float(row.get("edge") or 0),
|
||||
-float(row.get("ask_price") or 0),
|
||||
float(row.get("liquidity") or 0),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
safe_limit = max(1, min(int(limit or 200), 500))
|
||||
return opportunities[:safe_limit]
|
||||
|
||||
|
||||
class PolymarketQuoteScanner:
|
||||
def __init__(self, session: Optional[requests.Session] = None) -> None:
|
||||
self.session = session or requests.Session()
|
||||
|
||||
def fetch_event(self, slug: str) -> Optional[Dict[str, Any]]:
|
||||
now = time.time()
|
||||
with _CACHE_LOCK:
|
||||
cached = _EVENT_CACHE.get(slug)
|
||||
if cached and now - cached[0] < _EVENT_CACHE_TTL_SEC:
|
||||
return cached[1]
|
||||
response = self.session.get(
|
||||
f"{GAMMA_API_BASE}/events",
|
||||
params={"slug": slug},
|
||||
timeout=12,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
event = payload[0] if isinstance(payload, list) and payload else None
|
||||
with _CACHE_LOCK:
|
||||
_EVENT_CACHE[slug] = (now, event if isinstance(event, dict) else None)
|
||||
return event if isinstance(event, dict) else None
|
||||
|
||||
def fetch_ask_price(self, token_id: str) -> Optional[float]:
|
||||
now = time.time()
|
||||
with _CACHE_LOCK:
|
||||
cached = _PRICE_CACHE.get(token_id)
|
||||
if cached and now - cached[0] < _QUOTE_CACHE_TTL_SEC:
|
||||
return cached[1]
|
||||
response = self.session.get(
|
||||
f"{CLOB_API_BASE}/price",
|
||||
params={"token_id": token_id, "side": "SELL"},
|
||||
timeout=8,
|
||||
)
|
||||
response.raise_for_status()
|
||||
price = _finite_number((response.json() or {}).get("price"))
|
||||
with _CACHE_LOCK:
|
||||
_PRICE_CACHE[token_id] = (now, price)
|
||||
return price
|
||||
|
||||
|
||||
def _collect_events_and_prices(
|
||||
rows: List[Dict[str, Any]],
|
||||
scanner: PolymarketQuoteScanner,
|
||||
*,
|
||||
time_budget_sec: float = _QUOTE_COLLECTION_TIME_BUDGET_SEC,
|
||||
) -> Tuple[Dict[str, Optional[Dict[str, Any]]], Dict[str, Optional[float]], str]:
|
||||
events_by_city: Dict[str, Optional[Dict[str, Any]]] = {}
|
||||
prices: Dict[str, Optional[float]] = {}
|
||||
status = "ready"
|
||||
started_at = time.monotonic()
|
||||
|
||||
def budget_exhausted() -> bool:
|
||||
return time.monotonic() - started_at >= max(0.0, float(time_budget_sec))
|
||||
|
||||
for row in rows:
|
||||
if budget_exhausted():
|
||||
status = "partial"
|
||||
break
|
||||
city_key = _normalize_key(row.get("city"))
|
||||
if not city_key or city_key in events_by_city:
|
||||
continue
|
||||
slug = _event_slug_for_row(row)
|
||||
if not slug:
|
||||
events_by_city[city_key] = None
|
||||
continue
|
||||
try:
|
||||
event = scanner.fetch_event(slug)
|
||||
except Exception:
|
||||
status = "partial"
|
||||
event = None
|
||||
events_by_city[city_key] = event
|
||||
if not isinstance(event, Mapping):
|
||||
continue
|
||||
for market in event.get("markets") or []:
|
||||
if not isinstance(market, Mapping):
|
||||
continue
|
||||
hint_prices = _market_hint_prices(market)
|
||||
for market_side, token_id in _market_tokens(market).items():
|
||||
if not token_id or token_id in prices:
|
||||
continue
|
||||
hint_price = hint_prices.get(market_side)
|
||||
if hint_price is not None:
|
||||
prices[token_id] = hint_price
|
||||
continue
|
||||
if budget_exhausted():
|
||||
status = "partial"
|
||||
continue
|
||||
try:
|
||||
prices[token_id] = scanner.fetch_ask_price(token_id)
|
||||
except Exception:
|
||||
status = "partial"
|
||||
prices[token_id] = hint_price
|
||||
return events_by_city, prices, status
|
||||
|
||||
|
||||
def get_ops_market_opportunities(
|
||||
request: Request,
|
||||
*,
|
||||
max_price: float = 0.20,
|
||||
side: str = "both",
|
||||
positive_edge_only: bool = True,
|
||||
min_edge: float = 0.0,
|
||||
limit: int = 200,
|
||||
query: str = "",
|
||||
region: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
_require_ops(request)
|
||||
filters = {
|
||||
"scan_mode": "tradable",
|
||||
"min_price": 0.05,
|
||||
"max_price": 0.95,
|
||||
"min_edge_pct": 2,
|
||||
"min_liquidity": 500,
|
||||
"market_type": "maxtemp",
|
||||
"time_range": "today",
|
||||
"limit": 180,
|
||||
}
|
||||
scan_payload = build_scan_terminal_payload(filters, force_refresh=False)
|
||||
scan_rows = scan_payload.get("rows") if isinstance(scan_payload, dict) else []
|
||||
if not isinstance(scan_rows, list):
|
||||
scan_rows = []
|
||||
scan_source = "cache"
|
||||
if not scan_rows:
|
||||
refreshed_payload = build_scan_terminal_payload(filters, force_refresh=True)
|
||||
refreshed_rows = refreshed_payload.get("rows") if isinstance(refreshed_payload, dict) else []
|
||||
if isinstance(refreshed_rows, list) and refreshed_rows:
|
||||
scan_payload = refreshed_payload
|
||||
scan_rows = refreshed_rows
|
||||
scan_source = "force_refresh"
|
||||
|
||||
quote_status = "ready"
|
||||
if not scan_rows:
|
||||
events_by_city = {}
|
||||
ask_prices = {}
|
||||
quote_status = "scan_empty"
|
||||
error = (
|
||||
str(scan_payload.get("stale_reason") or scan_payload.get("error") or "")
|
||||
if isinstance(scan_payload, dict)
|
||||
else ""
|
||||
) or "scan terminal returned no rows"
|
||||
else:
|
||||
try:
|
||||
events_by_city, ask_prices, quote_status = _collect_events_and_prices(
|
||||
scan_rows,
|
||||
PolymarketQuoteScanner(),
|
||||
)
|
||||
except Exception as exc:
|
||||
events_by_city = {}
|
||||
ask_prices = {}
|
||||
quote_status = "unavailable"
|
||||
error = str(exc)
|
||||
else:
|
||||
error = None
|
||||
|
||||
rows = build_market_opportunity_rows(
|
||||
scan_rows,
|
||||
events_by_city,
|
||||
ask_prices,
|
||||
max_price=float(max_price),
|
||||
side=side,
|
||||
positive_edge_only=positive_edge_only,
|
||||
min_edge=float(min_edge),
|
||||
limit=limit,
|
||||
query=query,
|
||||
region=region,
|
||||
)
|
||||
prices = [float(row["ask_price"]) for row in rows if _finite_number(row.get("ask_price")) is not None]
|
||||
edges = [float(row["edge"]) for row in rows if _finite_number(row.get("edge")) is not None]
|
||||
return {
|
||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||
"filters": {
|
||||
"max_price": float(max_price),
|
||||
"side": side,
|
||||
"positive_edge_only": bool(positive_edge_only),
|
||||
"min_edge": float(min_edge),
|
||||
"limit": int(limit),
|
||||
"query": query,
|
||||
"region": region,
|
||||
},
|
||||
"summary": {
|
||||
"opportunity_count": len(rows),
|
||||
"positive_edge_count": sum(1 for row in rows if float(row.get("edge") or 0) > 0),
|
||||
"min_price": min(prices) if prices else None,
|
||||
"max_edge": max(edges) if edges else None,
|
||||
"quote_status": quote_status,
|
||||
"scan_source": scan_source,
|
||||
"scanned_city_count": len(scan_rows),
|
||||
"matched_event_count": sum(1 for event in events_by_city.values() if isinstance(event, Mapping)),
|
||||
"error": error,
|
||||
},
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PolymarketQuoteScanner",
|
||||
"build_market_opportunity_rows",
|
||||
"get_ops_market_opportunities",
|
||||
"parse_market_option_from_question",
|
||||
]
|
||||
@@ -70,6 +70,13 @@ from web.services.ops.health import ( # noqa: E402, F401
|
||||
get_ops_truth_history,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal market opportunities
|
||||
# ---------------------------------------------------------------------------
|
||||
from web.services.ops.market_opportunities import ( # noqa: E402, F401
|
||||
get_ops_market_opportunities,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config / Subscriptions / Logs / Telegram
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Standalone WeatherNext 2 offline worker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from types import FrameType
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from web.weathernext2_worker_service import run_weathernext2_cycle
|
||||
|
||||
|
||||
_STOP_EVENT = threading.Event()
|
||||
|
||||
|
||||
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 _handle_stop_signal(signum: int, _frame: Optional[FrameType]) -> None:
|
||||
logger.info("WeatherNext2 worker stopping signal={}", signum)
|
||||
_STOP_EVENT.set()
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Fetch WeatherNext 2 city highs and train LightGBM calibration."
|
||||
)
|
||||
parser.add_argument("--once", action="store_true", help="Run one cycle and exit.")
|
||||
parser.add_argument(
|
||||
"--interval-sec",
|
||||
type=int,
|
||||
default=_env_int("WEATHERNEXT2_WORKER_INTERVAL_SEC", 21600),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--initial-delay-sec",
|
||||
type=int,
|
||||
default=_env_int("WEATHERNEXT2_WORKER_INITIAL_DELAY_SEC", 90),
|
||||
)
|
||||
parser.add_argument("--cities", nargs="*", default=None)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _run_once(*, cities: Optional[list[str]]) -> dict:
|
||||
result = run_weathernext2_cycle(cities=cities)
|
||||
logger.info("WeatherNext2 worker result={}", json.dumps(result, ensure_ascii=False))
|
||||
return result
|
||||
|
||||
|
||||
def main() -> None:
|
||||
signal.signal(signal.SIGINT, _handle_stop_signal)
|
||||
signal.signal(signal.SIGTERM, _handle_stop_signal)
|
||||
args = _parse_args()
|
||||
|
||||
if args.once:
|
||||
result = _run_once(cities=args.cities)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return
|
||||
|
||||
interval_sec = max(1800, int(args.interval_sec or 21600))
|
||||
initial_delay_sec = max(0, int(args.initial_delay_sec or 0))
|
||||
logger.info("WeatherNext2 worker started interval={}s", interval_sec)
|
||||
if initial_delay_sec and _STOP_EVENT.wait(initial_delay_sec):
|
||||
return
|
||||
|
||||
while not _STOP_EVENT.is_set():
|
||||
started = time.time()
|
||||
try:
|
||||
_run_once(cities=args.cities)
|
||||
except Exception as exc:
|
||||
logger.exception("WeatherNext2 cycle failed: {}", exc)
|
||||
elapsed = time.time() - started
|
||||
wait_for = max(5.0, interval_sec - elapsed)
|
||||
if _STOP_EVENT.wait(wait_for):
|
||||
break
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Offline WeatherNext 2 fetch and calibration worker service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Iterable, Mapping, Optional, Sequence
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from src.analysis.weathernext2_calibration import train_lightgbm_quantile_calibrator
|
||||
from src.data_collection.city_registry import CITY_REGISTRY
|
||||
from src.data_collection.weathernext2_fetcher import (
|
||||
extract_member_hourly_from_grid_dataset,
|
||||
open_weathernext2_zarr_dataset,
|
||||
)
|
||||
from src.data_collection.weathernext2_sources import (
|
||||
WEATHERNEXT2_GCS_ZARR_URI,
|
||||
build_city_local_daily_highs_from_hourly,
|
||||
build_weathernext2_city_probability,
|
||||
)
|
||||
from src.database.runtime_state import (
|
||||
TrainingFeatureRecordRepository,
|
||||
TruthRecordRepository,
|
||||
)
|
||||
|
||||
|
||||
CityFetcher = Callable[[str, Mapping[str, Any], str], Optional[Mapping[str, Any]]]
|
||||
|
||||
|
||||
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 _normalize_city(city: str) -> str:
|
||||
return str(city or "").strip().lower().replace("-", " ")
|
||||
|
||||
|
||||
def _selected_city_names(
|
||||
city_registry: Mapping[str, Mapping[str, Any]],
|
||||
cities: Optional[Iterable[str]],
|
||||
) -> Sequence[str]:
|
||||
selected = {_normalize_city(city) for city in (cities or []) if _normalize_city(city)}
|
||||
names = []
|
||||
for city in sorted(city_registry.keys()):
|
||||
normalized = _normalize_city(city)
|
||||
if selected and normalized not in selected:
|
||||
continue
|
||||
names.append(normalized)
|
||||
return tuple(names)
|
||||
|
||||
|
||||
def _city_target_date(city_meta: Mapping[str, Any]) -> str:
|
||||
offset_seconds = int(city_meta.get("tz_offset") or 0)
|
||||
now_utc = datetime.now(timezone.utc).timestamp()
|
||||
return datetime.fromtimestamp(now_utc + offset_seconds, tz=timezone.utc).date().isoformat()
|
||||
|
||||
|
||||
def _safe_float(value: Any) -> Optional[float]:
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if parsed == parsed else None
|
||||
|
||||
|
||||
def _dataset_source_run(dataset: Any) -> Optional[str]:
|
||||
attrs = getattr(dataset, "attrs", None)
|
||||
if isinstance(attrs, Mapping):
|
||||
for key in ("source_run", "forecast_reference_time", "init_time", "run_time"):
|
||||
value = attrs.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
for key in ("init_time", "forecast_reference_time", "time"):
|
||||
try:
|
||||
values = dataset[key].values
|
||||
except Exception:
|
||||
try:
|
||||
values = dataset[key]
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
first = values[-1]
|
||||
except Exception:
|
||||
first = values
|
||||
if first is not None:
|
||||
return str(first)
|
||||
return None
|
||||
|
||||
|
||||
def _default_city_fetcher(dataset: Any, temp_var: Optional[str]) -> CityFetcher:
|
||||
source_run = _dataset_source_run(dataset)
|
||||
|
||||
def fetch(city: str, meta: Mapping[str, Any], target_date: str) -> Optional[Mapping[str, Any]]:
|
||||
lat = _safe_float(meta.get("lat"))
|
||||
lon = _safe_float(meta.get("lon"))
|
||||
if lat is None or lon is None:
|
||||
return None
|
||||
use_fahrenheit = bool(meta.get("use_fahrenheit") or meta.get("f"))
|
||||
extracted = extract_member_hourly_from_grid_dataset(
|
||||
dataset,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
temp_var=temp_var,
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
)
|
||||
member_highs = build_city_local_daily_highs_from_hourly(
|
||||
extracted["member_hourly"],
|
||||
extracted["utc_times"],
|
||||
int(meta.get("tz_offset") or 0),
|
||||
target_date,
|
||||
)
|
||||
if not member_highs:
|
||||
return None
|
||||
payload = build_weathernext2_city_probability(
|
||||
city=city,
|
||||
member_highs=member_highs,
|
||||
temp_symbol="°F" if use_fahrenheit else "°C",
|
||||
target_date=target_date,
|
||||
source_run=source_run,
|
||||
)
|
||||
payload["fetch_metadata"] = {
|
||||
"backend": "gcs_zarr",
|
||||
"temp_var": extracted.get("temp_var"),
|
||||
"units": extracted.get("units"),
|
||||
"nearest_lat": extracted.get("nearest_lat"),
|
||||
"nearest_lon": extracted.get("nearest_lon"),
|
||||
"requested_lat": lat,
|
||||
"requested_lon": lon,
|
||||
}
|
||||
return payload
|
||||
|
||||
return fetch
|
||||
|
||||
|
||||
def _atomic_write_json(path: Path, payload: Mapping[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
|
||||
tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(str(tmp_path), str(path))
|
||||
|
||||
|
||||
def _flatten_training_records_for_weathernext2(
|
||||
*,
|
||||
feature_repo: Optional[TrainingFeatureRecordRepository] = None,
|
||||
truth_repo: Optional[TruthRecordRepository] = None,
|
||||
) -> list[Dict[str, Any]]:
|
||||
features = (feature_repo or TrainingFeatureRecordRepository()).load_all()
|
||||
truth = (truth_repo or TruthRecordRepository()).load_all()
|
||||
rows: list[Dict[str, Any]] = []
|
||||
for city, by_date in features.items():
|
||||
city_truth = truth.get(city, {})
|
||||
for target_date, payload in by_date.items():
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
actual = payload.get("actual_high")
|
||||
if actual is None and isinstance(city_truth.get(target_date), dict):
|
||||
actual = city_truth[target_date].get("actual_high")
|
||||
if actual is None:
|
||||
continue
|
||||
row = dict(payload)
|
||||
row["city"] = city
|
||||
row["target_date"] = target_date
|
||||
row["actual_high_c"] = actual
|
||||
if "deb_prediction_c" not in row and row.get("deb_prediction") is not None:
|
||||
row["deb_prediction_c"] = row.get("deb_prediction")
|
||||
if "weathernext2" not in row and isinstance(row.get("weather_data"), dict):
|
||||
wn2 = row["weather_data"].get("weathernext2")
|
||||
if isinstance(wn2, dict):
|
||||
row["weathernext2"] = wn2
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def run_weathernext2_cycle(
|
||||
*,
|
||||
city_registry: Optional[Mapping[str, Mapping[str, Any]]] = None,
|
||||
cities: Optional[Iterable[str]] = None,
|
||||
output_path: Optional[str] = None,
|
||||
model_dir: Optional[str] = None,
|
||||
fetcher: Optional[CityFetcher] = None,
|
||||
dataset: Any = None,
|
||||
feature_repo: Optional[TrainingFeatureRecordRepository] = None,
|
||||
truth_repo: Optional[TruthRecordRepository] = None,
|
||||
min_global_samples: Optional[int] = None,
|
||||
min_city_samples: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
registry = city_registry or CITY_REGISTRY
|
||||
data_root = Path(os.getenv("WEATHERNEXT2_DATA_ROOT", "/app/data/weathernext2"))
|
||||
out_path = Path(output_path or os.getenv("WEATHERNEXT2_CITY_HIGHS_PATH", "") or data_root / "weathernext2_city_highs.json")
|
||||
calibrator_dir = Path(model_dir or os.getenv("WEATHERNEXT2_MODEL_DIR", "/app/data/models/weathernext2_calibrator"))
|
||||
selected = _selected_city_names(registry, cities)
|
||||
backend = str(os.getenv("WEATHERNEXT2_BACKEND", "gcs_zarr") or "gcs_zarr").strip().lower()
|
||||
|
||||
generated_at = datetime.now(timezone.utc).isoformat()
|
||||
opened_dataset = dataset
|
||||
if fetcher is None:
|
||||
if opened_dataset is None:
|
||||
if backend != "gcs_zarr":
|
||||
return {"ok": False, "status": "skipped", "reason": "unsupported_backend", "backend": backend}
|
||||
uri = os.getenv("WEATHERNEXT2_GCS_ZARR_URI", WEATHERNEXT2_GCS_ZARR_URI)
|
||||
opened_dataset = open_weathernext2_zarr_dataset(uri)
|
||||
fetcher = _default_city_fetcher(
|
||||
opened_dataset,
|
||||
str(os.getenv("WEATHERNEXT2_TEMP_VAR", "") or "").strip() or None,
|
||||
)
|
||||
|
||||
city_payloads: Dict[str, Dict[str, Any]] = {}
|
||||
failed = 0
|
||||
items = []
|
||||
for city in selected:
|
||||
meta = registry.get(city) or {}
|
||||
target_date = _city_target_date(meta)
|
||||
try:
|
||||
payload = fetcher(city, meta, target_date)
|
||||
if not isinstance(payload, Mapping):
|
||||
items.append({"city": city, "ok": True, "status": "empty", "target_date": target_date})
|
||||
continue
|
||||
city_payloads[city] = dict(payload)
|
||||
items.append({"city": city, "ok": True, "status": "written", "target_date": target_date})
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
logger.warning("WeatherNext2 city fetch failed city={}: {}", city, exc)
|
||||
items.append({"city": city, "ok": False, "status": "failed", "target_date": target_date, "error": str(exc)})
|
||||
|
||||
training_rows = _flatten_training_records_for_weathernext2(
|
||||
feature_repo=feature_repo,
|
||||
truth_repo=truth_repo,
|
||||
)
|
||||
calibration = train_lightgbm_quantile_calibrator(
|
||||
training_rows,
|
||||
model_dir=calibrator_dir,
|
||||
min_global_samples=min_global_samples
|
||||
if min_global_samples is not None
|
||||
else _env_int("WEATHERNEXT2_MIN_GLOBAL_SAMPLES", 150),
|
||||
min_city_samples=min_city_samples
|
||||
if min_city_samples is not None
|
||||
else _env_int("WEATHERNEXT2_MIN_CITY_SAMPLES", 5),
|
||||
)
|
||||
|
||||
if not city_payloads:
|
||||
return {
|
||||
"ok": failed == 0,
|
||||
"status": "no_city_payloads",
|
||||
"backend": backend,
|
||||
"generated_at": generated_at,
|
||||
"city_count": 0,
|
||||
"failed": failed,
|
||||
"items": items,
|
||||
"calibration": calibration,
|
||||
"output_path": str(out_path),
|
||||
}
|
||||
|
||||
artifact = {
|
||||
"schema_version": 1,
|
||||
"source": "weathernext2",
|
||||
"backend": backend,
|
||||
"gcs_zarr_uri": os.getenv("WEATHERNEXT2_GCS_ZARR_URI", WEATHERNEXT2_GCS_ZARR_URI),
|
||||
"generated_at": generated_at,
|
||||
"city_count": len(city_payloads),
|
||||
"cities": city_payloads,
|
||||
"fetch_metadata": {
|
||||
"selected_city_count": len(selected),
|
||||
"failed": failed,
|
||||
},
|
||||
"calibration_training": calibration,
|
||||
}
|
||||
_atomic_write_json(out_path, artifact)
|
||||
|
||||
return {
|
||||
"ok": failed == 0,
|
||||
"status": "written",
|
||||
"backend": backend,
|
||||
"generated_at": generated_at,
|
||||
"city_count": len(city_payloads),
|
||||
"failed": failed,
|
||||
"items": items,
|
||||
"calibration": calibration,
|
||||
"output_path": str(out_path),
|
||||
}
|
||||
Reference in New Issue
Block a user