Compare commits

..

38 Commits

Author SHA1 Message Date
2569718930@qq.com 3530fca9f9 Filter priced market opportunity noise 2026-07-04 02:32:22 +08:00
2569718930@qq.com 1a8713f927 Add multimodel context to market opportunities 2026-07-04 01:59:48 +08:00
2569718930@qq.com 9d76e90a28 Handle empty ops market scan snapshots 2026-07-04 01:33:56 +08:00
2569718930@qq.com 48c1b7e538 Filter late priced market noise 2026-07-04 00:51:54 +08:00
2569718930@qq.com 2bbb040370 Clarify ops market opportunity side probability 2026-07-04 00:33:51 +08:00
2569718930@qq.com f421bc4a24 Avoid ops market quote timeout 2026-07-04 00:20:52 +08:00
2569718930@qq.com 59cd48997f Fix ops market opportunity scan snapshot 2026-07-04 00:07:24 +08:00
2569718930@qq.com 7a5fe8b571 Add ops low-price market opportunities 2026-07-03 23:52:03 +08:00
2569718930@qq.com 459909539c Add WeatherNext2 worker and prevent empty scan cache 2026-07-02 20:25:40 +08:00
2569718930@qq.com 5bad2ec398 Accept stale scan snapshot in deploy smoke 2026-06-30 18:39:13 +08:00
2569718930@qq.com ee1162f15f Rerun model summary deploy 2026-06-30 18:28:37 +08:00
2569718930@qq.com a2f169f008 Use direct scan rows for model summary 2026-06-30 18:23:05 +08:00
2569718930@qq.com 35a9d8e943 Allow initializing scan smoke during deploy 2026-06-30 17:18:27 +08:00
2569718930@qq.com a983c8094e Keep scan terminal model forecasts 2026-06-30 17:06:33 +08:00
2569718930@qq.com 15de44767e Batch scan terminal model forecasts 2026-06-30 13:22:11 +08:00
2569718930@qq.com a5f6bca527 Fetch model forecasts on scan refresh 2026-06-30 12:12:20 +08:00
2569718930@qq.com 1da32c4593 Refresh stale scan terminal forecasts 2026-06-30 12:02:30 +08:00
2569718930@qq.com ad9b33f957 Reject stale scan terminal panel data 2026-06-30 11:46:53 +08:00
2569718930@qq.com f9b7b49690 Clarify private trading boundary 2026-06-29 23:44:49 +08:00
2569718930@qq.com 409ab0ffb6 Use market option labels for probability buckets 2026-06-29 23:12:06 +08:00
2569718930@qq.com 515c7c1d40 Expand model summary probability rows 2026-06-29 22:46:52 +08:00
2569718930@qq.com bbf2db4da1 Show market buckets with model probability 2026-06-29 22:27:22 +08:00
2569718930@qq.com 5404fdef2b Render probability distribution per city row 2026-06-29 21:48:47 +08:00
2569718930@qq.com 02521d6fdb Show Gaussian probability buckets in model summary 2026-06-29 21:35:08 +08:00
2569718930@qq.com 168b813754 Keep model summary data during refresh gaps 2026-06-29 21:17:04 +08:00
2569718930@qq.com 1df13bb9e6 Fix model summary live local time 2026-06-29 20:08:41 +08:00
2569718930@qq.com 709a567525 Fix model summary local time and regions 2026-06-29 19:30:35 +08:00
2569718930@qq.com b8d4970f9a Publish model summary announcement 2026-06-29 19:17:51 +08:00
2569718930@qq.com a680fca73e Add model summary terminal view 2026-06-29 19:04:15 +08:00
2569718930@qq.com d7ab68182e Fix Telegram bot deploy receiver cleanup 2026-06-26 21:16:17 +08:00
2569718930@qq.com 1d11a5cbc6 Reject stale Open-Meteo forecast cache 2026-06-25 16:46:50 +08:00
2569718930@qq.com a76ec62882 Fix stale multi-model DEB inputs 2026-06-25 16:35:42 +08:00
2569718930@qq.com ac565c4a00 Add DEB ensemble confidence signal 2026-06-25 02:49:12 +08:00
2569718930@qq.com 578f547fa7 Complete public brief locale switching 2026-06-24 18:53:22 +08:00
2569718930@qq.com 3439e0e47a Fix public brief temperature units 2026-06-24 18:29:45 +08:00
2569718930@qq.com 1a5bab9fad Fix DEB training truth finality handling 2026-06-24 18:18:45 +08:00
2569718930@qq.com 0fef948fac Localize public brief pages 2026-06-24 18:14:35 +08:00
2569718930@qq.com 07c5451f1d Allow manual payment tx submission during transient validation 2026-06-24 17:03:45 +08:00
86 changed files with 9036 additions and 522 deletions
+10
View File
@@ -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-*
+1 -1
View File
@@ -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
View File
@@ -61,7 +61,7 @@
本仓库自 `2026-03-30` 起采用 **GNU AGPL-3.0-only**
- 仓库公开部分:天气聚合、基础分析、前端看板、Bot 基础能力、标准支付流程。
- 不包含在仓库中的部分:生产私有数据、商业风控规则、运营阈值、收费策略细节、内部对账与增长工具。
- 不包含在仓库中的部分:生产私有数据、商业风控规则、运营阈值、收费策略细节、内部对账与增长工具、内部错价策略、仓位规则与交易 Bot 执行代码
- 商标、品牌、域名、生产数据库与托管服务运营能力,不因代码许可证一并授权。
详细见:[AGPL-3.0 与商用边界](docs/OPEN_CORE_POLICY.md)
+9 -1
View File
@@ -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
+31 -1
View File
@@ -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
+53
View File
@@ -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}
+14 -4
View File
@@ -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.
+97
View File
@@ -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",
});
}
}
+5 -18
View File
@@ -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",
+54 -21
View File
@@ -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} />
</>
);
}
+49 -16
View File
@@ -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} />;
}
+42 -9
View File
@@ -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} />
</>
);
}
+49 -16
View File
@@ -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 />;
}
+42 -9
View File
@@ -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} />
</>
);
}
+49 -16
View File
@@ -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,
});
}
}
@@ -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"') &&
+458 -4
View File
@@ -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}`;
}
+16
View File
@@ -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 {
+526
View File
@@ -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);
});
}
+23
View File
@@ -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
View File
@@ -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
+9
View File
@@ -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
+2 -3
View File
@@ -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",
-1
View File
@@ -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
+29 -13
View File
@@ -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
View File
@@ -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,
+325
View File
@@ -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
+11 -4
View File
@@ -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
+32 -12
View File
@@ -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
+40 -1
View File
@@ -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,
+243
View File
@@ -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,
)
+581
View File
@@ -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
+19 -5
View File
@@ -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(
+1 -3
View File
@@ -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"]
+11 -1
View File
@@ -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 {
+1 -1
View File
@@ -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
+4 -2
View File
@@ -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
+88
View File
@@ -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
+35 -1
View File
@@ -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
+70 -9
View File
@@ -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):
+123 -1
View File
@@ -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
+515
View File
@@ -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 == {}
+570 -2
View File
@@ -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",
+124
View File
@@ -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 ───
+92
View File
@@ -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")
+259
View File
@@ -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"
+108
View File
@@ -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]
+51 -16
View File
@@ -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):
+6 -2
View File
@@ -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:
+24
View File
@@ -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
View File
@@ -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")
+5 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+695
View File
@@ -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",
]
+7
View File
@@ -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
# ---------------------------------------------------------------------------
+91
View File
@@ -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()
+288
View File
@@ -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),
}