Compare commits
435 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 022d4b8743 | |||
| 58e6404987 | |||
| 32a3ba0ea4 | |||
| 4832678d72 | |||
| 20c8395c0b | |||
| 2be0b71018 | |||
| 5db20061eb | |||
| 2415167132 | |||
| 5aa2a9e384 | |||
| 89394e12ef | |||
| b2dd758977 | |||
| 0012eddc81 | |||
| e79bc2d291 | |||
| 864b1fa1f9 | |||
| 3bad968844 | |||
| 4c97314805 | |||
| 219fda39d9 | |||
| 6cbc56ac5a | |||
| f5fc3ca0cd | |||
| 5817d4157d | |||
| 348dfe132a | |||
| eeb5f563db | |||
| 442a9c8560 | |||
| f661350990 | |||
| 2aa44dd5c7 | |||
| 678b94000c | |||
| 0a7aaf19a6 | |||
| c18ff504e4 | |||
| 53225118c1 | |||
| 160dd65a54 | |||
| cfda3a796c | |||
| a80f34137c | |||
| dd65adb9d8 | |||
| faf88387a1 | |||
| 73a51d73e8 | |||
| f49a5bfbb2 | |||
| f8f0d69e5c | |||
| b1b1f76bcb | |||
| 891b4d2422 | |||
| 41d740d611 | |||
| 60ac4bae31 | |||
| 79b33599ac | |||
| b8a3c223f5 | |||
| 105713c799 | |||
| 5e0dc3ec53 | |||
| dc164205f6 | |||
| 5176dff82d | |||
| fe6f8b43b0 | |||
| 24f2a82893 | |||
| dbc8923b76 | |||
| fb81e01aae | |||
| a39a74de2f | |||
| 4fc4b538a5 | |||
| ecec3fc087 | |||
| 89a82b5fb5 | |||
| 9b9f548de9 | |||
| 1edee81cf3 | |||
| 8022464e78 | |||
| 982d192499 | |||
| 0234104a63 | |||
| 2f0b496066 | |||
| cb625a2b0e | |||
| 0f8d160c54 | |||
| 1766a71f63 | |||
| b36c712ee4 | |||
| a0005b1e37 | |||
| 717ad3c6f4 | |||
| b0662f6fef | |||
| d04d6c9efb | |||
| 75dd7464cc | |||
| db955d59ea | |||
| 133d341605 | |||
| 2f9889d63a | |||
| ef2691a37b | |||
| 84a2d4ce06 | |||
| 8ba9567f64 | |||
| 0c24a3ed6d | |||
| dd01383b3a | |||
| edf41efad6 | |||
| 871d21792e | |||
| 5fa3bb1b59 | |||
| 10a0788398 | |||
| 573393409d | |||
| 9b2ac614cb | |||
| 4e59c41c81 | |||
| b00790850c | |||
| 44a273c9d1 | |||
| 6f7e847b01 | |||
| 66512d2623 | |||
| 05f5d3c2dd | |||
| 452dfe2218 | |||
| 56bcb89d3d | |||
| 5ef0b49299 | |||
| 82d7c0ea6d | |||
| 781d8cf476 | |||
| 9a5f9abf21 | |||
| 60093d3162 | |||
| 6e3b7f60a2 | |||
| 5e4070ad27 | |||
| 4de009b401 | |||
| 23f7e29acc | |||
| 9ff0685756 | |||
| 95192e8b58 | |||
| 72e93f8e93 | |||
| 1af33c3ab8 | |||
| df892ce9a6 | |||
| 76d5a0abe8 | |||
| 3fd52ad9d4 | |||
| a714817cdf | |||
| 4488d6a9b1 | |||
| e1e000e854 | |||
| d50ced1b8f | |||
| f4949212d8 | |||
| 82d60cf05f | |||
| a5c508cce8 | |||
| 0c2e22e770 | |||
| ac4f70e33d | |||
| 89914a296b | |||
| 6e2baa14ad | |||
| 9d6eb54a4f | |||
| bddf7f1372 | |||
| ae37e79fbb | |||
| a6e7d6682d | |||
| f2ab62ba83 | |||
| 3ca5b71299 | |||
| a7be2adbbb | |||
| 49238cf420 | |||
| 6b741cc1ec | |||
| 3b8098051e | |||
| 7e0d955d22 | |||
| 4c6a93498c | |||
| 96b642ffac | |||
| 3d4a0148fe | |||
| 8cb5b3b310 | |||
| d07aaf49b4 | |||
| d83733399e | |||
| 9db37c6d7b | |||
| cd9de0926b | |||
| 3c013e300a | |||
| e56040a855 | |||
| 53c9ed2118 | |||
| b9130cb30a | |||
| c4d4d0cdbc | |||
| cb7526e799 | |||
| 6d132e6a7f | |||
| 476a4f85d8 | |||
| b93a75516d | |||
| 19bd8f3636 | |||
| 1326176a85 | |||
| c38dc80f58 | |||
| 5b1402fc7c | |||
| eaa695ec47 | |||
| 4d5a5b674d | |||
| 473ed82202 | |||
| d4640a578d | |||
| 0e4e7aebb2 | |||
| 2b6b18ec20 | |||
| 0e0aad3171 | |||
| aec47adda1 | |||
| 2fa27f54ac | |||
| 671a862400 | |||
| a9d71c18aa | |||
| e12d935cde | |||
| 546bc0c21e | |||
| 67701a4715 | |||
| 073d0efbb0 | |||
| 2d1cb55151 | |||
| 2d459f3052 | |||
| 6bdad2eae9 | |||
| ff420c4bec | |||
| 426bd7deab | |||
| 3e941a238a | |||
| ad16ae6b3b | |||
| 1c4287380e | |||
| 8c640b8616 | |||
| 8a5e8957c5 | |||
| 06ac6bab32 | |||
| abdce3cd3b | |||
| c6321253fe | |||
| 4a8eeaae5e | |||
| 78b23ef361 | |||
| 1b2731ed12 | |||
| 6041d25f23 | |||
| d99a3c25e9 | |||
| fd59cd018d | |||
| 6cee86ec7b | |||
| 44e8921e5c | |||
| 4acd0d9da6 | |||
| bd21b05fbc | |||
| 22e2409e13 | |||
| 415f03466d | |||
| 4c6eaa6390 | |||
| 3351991fe1 | |||
| 1645fd88d0 | |||
| 9d8c59f741 | |||
| deec8221ea | |||
| 9e400a3802 | |||
| c74c193b02 | |||
| 07f61f8ca9 | |||
| 38ac9844c1 | |||
| 1ab10a9c90 | |||
| a83200d505 | |||
| 8ade6dd7d2 | |||
| 5c2977fe71 | |||
| ff6d6c550f | |||
| 55099c3dd8 | |||
| 02fd7d9e49 | |||
| d39534dff4 | |||
| 58e6557c66 | |||
| 2d9aa576dd | |||
| 04e0369255 | |||
| c60baaa1e8 | |||
| 341c6d0a93 | |||
| a83d9e7649 | |||
| 07bb8e1d15 | |||
| 8e2317592d | |||
| 80ca892611 | |||
| a24185e6dc | |||
| 4a938395be | |||
| 2e02133696 | |||
| b27bedbca3 | |||
| 884a7899f3 | |||
| 04d0ae1125 | |||
| 6f14ef20db | |||
| 9d3aa7fb53 | |||
| 5d514a4950 | |||
| 87b24aa071 | |||
| e7a13c9be7 | |||
| 6a4ec08b12 | |||
| 6746650bb1 | |||
| 861b394e49 | |||
| cbf6a12f79 | |||
| 767e0e7de1 | |||
| e93f052559 | |||
| a614cbd298 | |||
| f7fb2ec83b | |||
| 30de937d04 | |||
| 7aea20c956 | |||
| b45010a92e | |||
| 5e8548999a | |||
| 747c8aa401 | |||
| e7fc3c97cb | |||
| 1a284c9990 | |||
| 77488e561a | |||
| c2a78e7b62 | |||
| 3845432ac2 | |||
| c031dd0e28 | |||
| c6f1eac6c1 | |||
| f47ea93ec1 | |||
| 856e9aa6d1 | |||
| e981188bf8 | |||
| d8639f40f9 | |||
| 6655476fd4 | |||
| 06df0a87dd | |||
| 00400f1392 | |||
| bbb47b1634 | |||
| 189a77d03d | |||
| 0f7322dbd8 | |||
| 9f7bbffca1 | |||
| 9868784494 | |||
| d4892a5294 | |||
| e590150fa9 | |||
| 644b592fe8 | |||
| 4cc579ccb3 | |||
| c4b1844a67 | |||
| f9154ff0f2 | |||
| c96a630d1b | |||
| 3bca2093f0 | |||
| 2ee00f8016 | |||
| 6c08a68413 | |||
| 37494a7192 | |||
| a79abc02de | |||
| f4a37e4bdd | |||
| 5617e5b112 | |||
| eb056a890b | |||
| 5ad89b4c29 | |||
| ac90ef9206 | |||
| c8103179e1 | |||
| 4f13faa311 | |||
| 8dd9aa59b3 | |||
| 96676e7097 | |||
| c9d03fd3e1 | |||
| 02add6d994 | |||
| 0e220d890f | |||
| 4bda402270 | |||
| 8bfe23138c | |||
| f5acae3c81 | |||
| 294ac30026 | |||
| fb4ba622a8 | |||
| d7d1744313 | |||
| 357e01be3c | |||
| 3529d97d58 | |||
| 329e3afe38 | |||
| 6eca1da3f9 | |||
| 4ab749c136 | |||
| d2472fa0c2 | |||
| 6c4f43fba6 | |||
| 79944432fb | |||
| c006d13fea | |||
| 7a28810f25 | |||
| c5fdb93ae5 | |||
| 2202f8e211 | |||
| fc48795299 | |||
| fd12c15518 | |||
| 22b414f69e | |||
| 5e653c2ec1 | |||
| 9dd59ceb4e | |||
| a9b1449680 | |||
| a7ba7aed3a | |||
| 1240fbb673 | |||
| d56889c2e5 | |||
| 1fba498c37 | |||
| b94037f328 | |||
| 1493c72138 | |||
| a221b3e969 | |||
| bfffbdedd0 | |||
| 26fff84c3a | |||
| 7038b14904 | |||
| c9006f250f | |||
| 1a4d75c126 | |||
| 7555da8e6a | |||
| d97bb76480 | |||
| c779b20a4f | |||
| cac84dcc0c | |||
| e734c93b34 | |||
| 067ee63ba4 | |||
| 88918677c0 | |||
| 041c9fb4fa | |||
| 1d3ccbb7cf | |||
| 8dd212f0af | |||
| ded09df749 | |||
| 239c9c446d | |||
| 538cc7db2d | |||
| 6055f8328e | |||
| 611811101b | |||
| 9e80719cbe | |||
| db9071101a | |||
| 86467d4e92 | |||
| 1680bd5871 | |||
| 6802f647b3 | |||
| 557882600e | |||
| 40f231b76d | |||
| 30b289ec8f | |||
| 9adcfdb203 | |||
| cfb746300a | |||
| a173da1b00 | |||
| 7372b7a73f | |||
| 8a8a47e31d | |||
| fe9bf61ad6 | |||
| 7084bdf1ec | |||
| 470cd6c95f | |||
| 6f83b9d7ea | |||
| a3bef5185e | |||
| 963e717523 | |||
| a29ebfae27 | |||
| b1d0e41fd4 | |||
| c8eed2f315 | |||
| f7a453e39b | |||
| 191be9c5fd | |||
| 12c2b39b16 | |||
| 84e7518b04 | |||
| 209027afb3 | |||
| 83d2de5438 | |||
| 4d584f4828 | |||
| 5d630bb910 | |||
| a32a49f3c7 | |||
| 203094a97c | |||
| a5522b4b16 | |||
| 9026ccf4c0 | |||
| e062fedb3a | |||
| 4006e82ded | |||
| fc146b6e0c | |||
| 106dd3305b | |||
| 574f007607 | |||
| 88b8366067 | |||
| 2061dfe8b5 | |||
| 12b0c76caf | |||
| 5a6a487a97 | |||
| 8b1abf3d56 | |||
| a6fbbb4bef | |||
| 9a8ed0e15e | |||
| 64f8ff21ec | |||
| ff4c8b0139 | |||
| c56c490b60 | |||
| c50a057562 | |||
| 349f3e53c7 | |||
| eb47e0a078 | |||
| ff7938187c | |||
| 3d4e803488 | |||
| e344fae75f | |||
| 52ad2dfde4 | |||
| 55bb06d213 | |||
| 76b4a5df59 | |||
| 9ff2a99618 | |||
| 3c2d1ce6fa | |||
| 787d04619b | |||
| 81e9aeb98f | |||
| 78cdb006e5 | |||
| 2ad4c7f1b4 | |||
| 17b93803af | |||
| b26ec05b81 | |||
| 8075fb66b7 | |||
| 23e5d10f65 | |||
| f4f613ad02 | |||
| 5512ebf133 | |||
| 40cd8fc2a6 | |||
| 3f351ac60c | |||
| e1d21c6ce3 | |||
| 163ad8cb4e | |||
| e23a90a961 | |||
| 13a67cdc85 | |||
| eed0345045 | |||
| fe3f48f255 | |||
| ba352feb34 | |||
| 04b2f0e4a3 | |||
| bf5e92e656 | |||
| 27010d5fb3 | |||
| 058ab7a619 | |||
| fe681503ae | |||
| 341825747f | |||
| d94476f943 | |||
| bf70d0b42a | |||
| e2bea367a1 | |||
| 27095025e7 | |||
| 2b2784d811 | |||
| b3ea8dcfa7 | |||
| c0bb2acf78 | |||
| 8cc7e9a996 | |||
| c9d3a27e88 | |||
| e52f1a46f6 | |||
| ce1da6a686 | |||
| de0f037bf4 | |||
| 9635387beb | |||
| 5ff9fade5f | |||
| 0e529358c9 |
@@ -13,17 +13,12 @@ POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
OPEN_METEO_DISK_CACHE_PATH=/var/lib/polyweather/open_meteo_cache.json
|
||||
UVICORN_WORKERS=1
|
||||
# Optional: host user/group mapping for Docker on Linux.
|
||||
# Windows / macOS can usually keep the defaults.
|
||||
UID=1000
|
||||
GID=1000
|
||||
POLYWEATHER_STATE_STORAGE_MODE=sqlite
|
||||
POLYWEATHER_PROMETHEUS_PORT=9090
|
||||
POLYWEATHER_ALERTMANAGER_PORT=9093
|
||||
POLYWEATHER_ALERT_RELAY_PORT=9099
|
||||
POLYWEATHER_GRAFANA_PORT=3001
|
||||
POLYWEATHER_GRAFANA_ADMIN_USER=admin
|
||||
POLYWEATHER_GRAFANA_ADMIN_PASSWORD=polyweather
|
||||
# Backend CORS allowlist. Add your Vercel production/preview domains when
|
||||
# NEXT_PUBLIC_POLYWEATHER_API_BASE_URL points browsers directly at this backend.
|
||||
WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather-pro.vercel.app
|
||||
@@ -34,21 +29,35 @@ WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
TELEGRAM_CHAT_IDS=
|
||||
POLYWEATHER_TELEGRAM_GROUP_ID=
|
||||
# Optional: restrict message-points accrual to these chat IDs.
|
||||
# Example: POLYWEATHER_BOT_POINTS_CHAT_IDS=-1003965137823
|
||||
POLYWEATHER_BOT_POINTS_CHAT_IDS=
|
||||
POLYWEATHER_GROUP_MEMBER_PRICE_USDC=5
|
||||
POLYWEATHER_PUBLIC_PRICE_USDC=10
|
||||
TELEGRAM_QUERY_TOPIC_CHAT_ID=
|
||||
TELEGRAM_QUERY_TOPIC_ID=
|
||||
TELEGRAM_QUERY_TOPIC_MAP=
|
||||
POLYWEATHER_BOT_GROUP_INVITE_URL=
|
||||
POLYWEATHER_APP_URL=https://polyweather-pro.vercel.app
|
||||
# High-frequency airport push loop. Keep this at 1 on shared 1CPU VPS.
|
||||
TELEGRAM_AIRPORT_PUSH_ENABLED=true
|
||||
TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC=60
|
||||
TELEGRAM_AIRPORT_PUSH_MAX_WORKERS=1
|
||||
|
||||
########################################
|
||||
# 3) Weather + cache
|
||||
########################################
|
||||
OPEN_METEO_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_ENSEMBLE_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_MULTI_MODEL_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_CACHE_TTL_SEC=21600
|
||||
OPEN_METEO_ENSEMBLE_CACHE_TTL_SEC=21600
|
||||
OPEN_METEO_MULTI_MODEL_CACHE_TTL_SEC=21600
|
||||
OPEN_METEO_MULTI_MODEL_CACHE_VERSION=v2
|
||||
OPEN_METEO_RATE_LIMIT_COOLDOWN_SEC=900
|
||||
OPEN_METEO_RATE_LIMIT_COOLDOWN_SEC=3600
|
||||
OPEN_METEO_RATE_CACHE_TTL_SEC=3600
|
||||
OPEN_METEO_MIN_CALL_INTERVAL_SEC=1
|
||||
OPEN_METEO_MIN_CALL_INTERVAL_SEC=5
|
||||
POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=1
|
||||
POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=600
|
||||
POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=45
|
||||
POLYWEATHER_HTTP_TIMEOUT_SEC=8
|
||||
POLYWEATHER_HTTP_RETRY_COUNT=0
|
||||
POLYWEATHER_HTTP_RETRY_BACKOFF_SEC=0.2
|
||||
@@ -57,26 +66,19 @@ POLYWEATHER_METAR_TIMEOUT_SEC=4
|
||||
POLYWEATHER_METAR_CLUSTER_TIMEOUT_SEC=3.5
|
||||
METAR_CACHE_TTL_SEC=600
|
||||
JMA_AMEDAS_CACHE_TTL_SEC=120
|
||||
METEOBLUE_CACHE_TTL_SEC=7200
|
||||
# Probability engine modes:
|
||||
# - legacy: production-safe primary path.
|
||||
# - emos_shadow: user-facing probability stays legacy, EMOS is generated for comparison.
|
||||
# - emos_primary: only after offline evaluation passes and manual rollout is approved.
|
||||
POLYWEATHER_PROBABILITY_ENGINE=legacy
|
||||
POLYWEATHER_EMOS_AUTO_MIN_SAMPLES=50
|
||||
POLYWEATHER_EMOS_AUTO_MAX_DELTA_CRPS=0
|
||||
POLYWEATHER_EMOS_AUTO_MAX_DELTA_MAE=0.05
|
||||
POLYWEATHER_EMOS_AUTO_MIN_DELTA_BUCKET_HIT_RATE=-0.05
|
||||
# Optional: cap recent probability snapshots used by EMOS retraining.
|
||||
# Recommended on VPS: do not train there; pull the SQLite DB to a local machine.
|
||||
# POLYWEATHER_EMOS_TRAINING_SNAPSHOT_LIMIT=20000
|
||||
# Optional: set this to a writable runtime path if you manually deploy a
|
||||
# locally trained EMOS calibration file.
|
||||
# POLYWEATHER_PROBABILITY_CALIBRATION_FILE=/var/lib/polyweather/probability_calibration/default.json
|
||||
POLYWEATHER_LGBM_ENABLED=false
|
||||
POLYWEATHER_LGBM_MODEL_PATH=/app/artifacts/models/lgbm_daily_high.txt
|
||||
POLYWEATHER_LGBM_SCHEMA_PATH=/app/artifacts/models/lgbm_daily_high_schema.json
|
||||
POLYWEATHER_LGBM_MIN_HISTORY_POINTS=3
|
||||
|
||||
# ── Country-specific data source URLs ──
|
||||
# These are kept in .env to avoid exposing competitive data-source discovery
|
||||
# work on the public GitHub repository. Leave empty to use built-in defaults.
|
||||
# AMSC_AWOS_BASE_URL=https://www.amsc.net.cn/gateway/api/saas/rest/amc/AwosController/getWindPlate
|
||||
# KMA_BASE_URL=https://www.weather.go.kr
|
||||
# AMOS_BASE_URL=https://global.amo.go.kr/amosobsnew/AmosRealTimeImage.do
|
||||
# JMA_AMEDAS_BASE_URL=https://www.jma.go.jp
|
||||
# MGM_BASE_URL=https://servis.mgm.gov.tr/web
|
||||
# MGM_ORIGIN_URL=https://www.mgm.gov.tr
|
||||
# FMI_BASE_URL=https://opendata.fmi.fi/wfs
|
||||
# HKO_BASE_URL=https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather
|
||||
# SINGAPORE_MSS_BASE_URL=https://api.data.gov.sg/v1/environment/air-temperature
|
||||
|
||||
########################################
|
||||
# 4) Auth / entitlement
|
||||
@@ -92,16 +94,12 @@ SUPABASE_HTTP_TIMEOUT_SEC=8
|
||||
SUPABASE_AUTH_CACHE_TTL_SEC=30
|
||||
SUPABASE_SUB_CACHE_TTL_SEC=60
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
POLYWEATHER_SIGNUP_TRIAL_ENABLED=false
|
||||
POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION=decline
|
||||
|
||||
########################################
|
||||
# 5) Alerts / operations
|
||||
# 5) Operations
|
||||
########################################
|
||||
TELEGRAM_ALERT_PUSH_ENABLED=true
|
||||
TELEGRAM_ALERT_PUSH_INTERVAL_SEC=300
|
||||
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
|
||||
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
|
||||
TELEGRAM_ALERT_MIN_SEVERITY=medium
|
||||
TELEGRAM_ALERT_CITIES=ankara,london,paris,seoul,hong kong,shanghai,singapore,tokyo,tel aviv,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
|
||||
POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
|
||||
|
||||
########################################
|
||||
@@ -116,17 +114,21 @@ NEXT_PUBLIC_POLYWEATHER_DISABLE_EAGER_SUMMARIES=false
|
||||
# bypass Vercel Functions / Fluid Compute instead of going through Next.js API proxies.
|
||||
# Example: NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=https://api.example.com
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=
|
||||
# Set to "false" to disable app analytics event tracking (conversion funnel etc.)
|
||||
# Default: enabled. Only set this if you need to opt out.
|
||||
NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=true
|
||||
|
||||
########################################
|
||||
# 7) Optional modules
|
||||
# 7) Admin / Ops
|
||||
########################################
|
||||
# Comma-separated admin email list for /ops dashboard access
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=
|
||||
# KNMI 10-minute observation data (Amsterdam)
|
||||
KNMI_API_KEY=
|
||||
|
||||
# Optional Groq commentary rewrite for intraday structure cards
|
||||
POLYWEATHER_GROQ_COMMENTARY_ENABLED=false
|
||||
GROQ_API_KEY=
|
||||
POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b
|
||||
POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8
|
||||
POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
|
||||
########################################
|
||||
# 8) Optional modules
|
||||
########################################
|
||||
|
||||
# Optional OpenAI-compatible market scan review for Pro users
|
||||
# Temporary default provider: MiMo via https://token-plan-cn.xiaomimimo.com/v1.
|
||||
@@ -149,7 +151,6 @@ POLYWEATHER_SCAN_AI_MAX_ROWS=40
|
||||
POLYWEATHER_SCAN_AI_MAX_TOKENS=3200
|
||||
POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900
|
||||
POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS=55000
|
||||
POLYWEATHER_PREWARM_CITIES=ankara,istanbul,shanghai,beijing,shenzhen,guangzhou,wuhan,chengdu,chongqing,hong kong,taipei,singapore,tokyo,seoul,busan,london,paris,madrid
|
||||
POLYWEATHER_CITY_SUMMARY_CACHE_TTL_SEC=1800
|
||||
POLYWEATHER_CITY_PANEL_CACHE_TTL_SEC=1800
|
||||
POLYWEATHER_CITY_NEARBY_CACHE_TTL_SEC=1800
|
||||
@@ -180,7 +181,8 @@ POLYWEATHER_PAYMENT_CHAIN_ID=137
|
||||
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
|
||||
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
|
||||
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174
|
||||
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
|
||||
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x3c499c542cef5e3811e1192ce70d8cc03d5c3359
|
||||
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
|
||||
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
|
||||
@@ -232,36 +234,9 @@ POLYGON_WALLET_WATCH_POLYMARKET_ONLY=true
|
||||
POLYGON_WALLET_WATCH_INCLUDE_DEFAULT_PM_CONTRACTS=true
|
||||
POLYGON_WALLET_WATCH_POLYMARKET_CONTRACTS=
|
||||
|
||||
# Polymarket wallet activity (retired; replaced by market monitor digests + critical alerts)
|
||||
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
||||
POLYMARKET_WALLET_ACTIVITY_USERS=
|
||||
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=
|
||||
POLYMARKET_WALLET_ACTIVITY_CHAT_IDS=
|
||||
POLYMARKET_WALLET_ACTIVITY_TOPIC_CHAT_ID=
|
||||
POLYMARKET_WALLET_ACTIVITY_TOPIC_ID=
|
||||
POLYMARKET_WALLET_ACTIVITY_USER_ALIASES=
|
||||
POLYMARKET_WALLET_ACTIVITY_DATA_API_URL=https://data-api.polymarket.com
|
||||
POLYMARKET_WALLET_ACTIVITY_INTERVAL_SEC=20
|
||||
POLYMARKET_WALLET_ACTIVITY_TIMEOUT_SEC=10
|
||||
POLYMARKET_WALLET_ACTIVITY_MIN_SIZE_ABS=0.001
|
||||
POLYMARKET_WALLET_ACTIVITY_MIN_SIZE_DELTA=0.001
|
||||
POLYMARKET_WALLET_ACTIVITY_MIN_AVG_PRICE_DELTA=0.002
|
||||
POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_ON_SIZE_DELTA=true
|
||||
POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_SIZE_DELTA_MIN=0.001
|
||||
POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_COOLDOWN_SEC=20
|
||||
POLYMARKET_WALLET_ACTIVITY_MAX_CHANGES_PER_MSG=5
|
||||
POLYMARKET_WALLET_ACTIVITY_NOTIFY_CLOSED=false
|
||||
POLYMARKET_WALLET_ACTIVITY_BOOTSTRAP_ALERT=false
|
||||
POLYMARKET_WALLET_ACTIVITY_LINK_PREVIEW=true
|
||||
POLYMARKET_WALLET_ACTIVITY_UPDATE_DEBOUNCE_SEC=30
|
||||
POLYMARKET_WALLET_ACTIVITY_UPDATE_MAX_HOLD_SEC=120
|
||||
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MIN=0.01
|
||||
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MAX=0.99
|
||||
POLYMARKET_WALLET_ACTIVITY_MIN_POSITION_VALUE_USD=0
|
||||
POLYMARKET_WALLET_ACTIVITY_MIN_VALUE_EXEMPT_USERS=
|
||||
|
||||
########################################
|
||||
# 8) Optional proxies
|
||||
########################################
|
||||
HTTPS_PROXY=
|
||||
HTTP_PROXY=
|
||||
POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION=decline
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
# Telegram
|
||||
########################################
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
POLYWEATHER_TELEGRAM_GROUP_ID=
|
||||
POLYWEATHER_GROUP_MEMBER_PRICE_USDC=10
|
||||
POLYWEATHER_PUBLIC_PRICE_USDC=10
|
||||
|
||||
########################################
|
||||
# Supabase
|
||||
@@ -32,6 +35,7 @@ METEOBLUE_API_KEY=
|
||||
########################################
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
|
||||
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
|
||||
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
|
||||
|
||||
|
||||
@@ -62,3 +62,21 @@ jobs:
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t polyweather-ci .
|
||||
|
||||
deploy:
|
||||
needs: [python-quality, frontend-quality, docker-build]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy to VPS
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
|
||||
cd /root/PolyWeather
|
||||
git fetch origin main && git reset --hard origin/main
|
||||
docker compose up -d --build
|
||||
sleep 10
|
||||
curl -s http://localhost:8000/healthz
|
||||
"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Secrets
|
||||
.env
|
||||
|
||||
# Scratch / temp scripts
|
||||
scratch/
|
||||
|
||||
# Data and Logs
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
@@ -63,3 +66,9 @@ frontend/.next-start.log
|
||||
.codex/skills/.system/**
|
||||
!.codex/prompts/
|
||||
!.codex/prompts/**
|
||||
tmp_apikey.js
|
||||
tmp_obs.js
|
||||
tmp_rctp.html
|
||||
playwright-home-check.png
|
||||
.codex-backend-*.log
|
||||
frontend-next-*.log
|
||||
|
||||
@@ -1,6 +1,41 @@
|
||||
# Changelog
|
||||
|
||||
## 1.6.0 - 2026-05-10
|
||||
## 1.7.0 - 2026-05-23
|
||||
|
||||
### 新增能力
|
||||
- 市场监控面板(MonitorPanel):22 城实时温度监控,温度分辨率链(AMOS 跑道 → airport_primary → airport_current → current),按数据源新鲜度驱动刷新
|
||||
- 中国城市天气日报:AI 生成每日天气摘要,接入 CMA weather.com.cn 预报数据,推送至 Telegram 论坛群
|
||||
- 后台管理系统重写:从 1694 行单页拆分为 9 个模块(总览、会员、订阅、支付、训练、Telegram 审计、健康检查、配置、日志),含漏斗图、KPI 卡片、缓存饼图、增长趋势图
|
||||
- 跑道观测系统重构:全跑道展示、结算跑道标注、热力模型、风场分析,推送增加市场状态标签(超预期/升温中/冲顶观察/降温中)
|
||||
- 新增 6 个高频数据源:AEROWEB (Météo-France)、NCM (沙特)、IMS Lod (以色列)、AMSC AWOS (中国跑道)、MSS 1 分钟 (新加坡)、AROME HD 15 分钟 (巴黎)
|
||||
- 接入 HKO 1 分钟、流浮山 LFS 1 分钟、CWA 10 分钟 (台北松山) 实时温度
|
||||
- NOAA MADIS HFMETAR 适配新格式(netCDF stationId 替代 icaoId)+ 目录迁移适配
|
||||
- KNMI 适配新数据布局 (station,time) + 5 位 WMO 码 + S3 下载认证修复
|
||||
- 新增 GET /api/cities/model-range 端点
|
||||
- 积分转账功能:管理员手动扣除/划转用户积分
|
||||
- 支付提交前 Tx 预校验:链上验签收款地址与金额
|
||||
- CI 全流程自动化:测试通过后自动 SSH 部署到 VPS
|
||||
- 一键部署脚本:deploy.sh + deploy.ps1
|
||||
|
||||
### 移除
|
||||
- 删除 LGBM 全部代码和模型文件,EMOS 简化为纯 legacy 高斯分桶
|
||||
- 删除 Polymarket 价格拉取与 UI 层(MarketDecisionLine)
|
||||
- 删除 Groq、Meteoblue、NMC、俄罗斯 pogodaiklimat 数据源
|
||||
- 删除预热(prewarm)系统
|
||||
- 删除市场提醒引擎(market_alert_engine)
|
||||
- 删除 Lagos、Masroor Air Base 城市
|
||||
- 移除季付/年付计划,统一月付 10 USDC
|
||||
|
||||
### 修复与优化
|
||||
- 修复移动端城市列表搜索无数据、Leaflet flyTo NaN 崩溃
|
||||
- 修复 MacBook Safari 布局崩溃(100vw/dvh、-webkit-backdrop-filter、grid minmax 溢出)
|
||||
- 修复温度曲线图三个渲染问题:数据点过少、张力过高、canvas CSS 拉伸
|
||||
- 修复 Open-Meteo 冷却期无限循环导致多模型数据缺失
|
||||
- 修复转化漏斗数据显示 3750%(前端重复乘以 100)
|
||||
- 多模型缓存优化 + ETag 缓存 + stale-while-revalidate
|
||||
- 性能优化:Context 重渲染、LGBM 循环移除、TTL 对齐
|
||||
- 账户页 Pro 状态偶发性丢失修复
|
||||
- 机场推送重构:观测缓存分离 + 全城市覆盖 + 四路并发
|
||||
|
||||
- 全面修复前端 UI 设计审查 15 项问题:消除工程债务、统一 token 体系、提升可维护性
|
||||
- CSS 架构:消除 !important 滥用(134→49,仅保留 Leaflet/图表所必需项)、浅色主题重构为 `html.light` 选择器体系
|
||||
@@ -54,7 +89,7 @@
|
||||
- 右侧详情面板识别稀疏 detail / 单日 forecast 中间态,并显示同步占位卡,避免用户把未补齐数据误认为完整结果
|
||||
- 概率区改为“校准模型概率”:有 LGBM 时展示 LGBM 校准概率;模型共识与市场价格降级为辅助参考
|
||||
- 模型层补齐 DWD ICON、ECMWF AIFS、ECCC GEM/GDPS/RDPS/HRDPS 等开放模型说明,并明确 AIFS 不称作“AI 预报”
|
||||
- 新增 / 补齐 Manila、Karachi、Masroor Air Base 等城市说明;机场市场以 METAR / 机场主站为结算锚点,Wunderground 仅作为历史页面或参考入口
|
||||
- 新增 / 补齐 Manila、Karachi 等城市说明;机场市场以 METAR / 机场主站为结算锚点,Wunderground 仅作为历史页面或参考入口
|
||||
- 历史对账、模型栈、LGBM、监控、前端 README 与网页 `/docs` 文档同步更新到当前产品口径
|
||||
|
||||
## 1.5.3 - 2026-04-10
|
||||
|
||||
@@ -4,15 +4,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
PolyWeather Pro — a production weather-intelligence stack for temperature settlement markets. Aggregates observations and forecasts for 52 monitored cities globally, blends multi-model highs using DEB (Dynamic Error Balancing), generates calibrated probability buckets for settlement, maps weather to Polymarket quotes for mispricing scans, and serves both a Next.js dashboard (Vercel) and a Telegram bot.
|
||||
PolyWeather Pro — a production weather-intelligence stack for temperature settlement markets. Aggregates observations and forecasts for 52 monitored cities globally, blends multi-model highs using DEB (Dynamic Error Balancing), generates calibrated probability buckets for settlement, and serves both a Next.js dashboard (Vercel) and a Telegram bot.
|
||||
|
||||
## Environment & Preferences (ALWAYS follow)
|
||||
|
||||
### Working Directory
|
||||
- All commands run from the repo root: `E:/web/PolyWeather`
|
||||
- All commands run from the repo root
|
||||
- Python virtual env: `venv\Scripts\activate` (Windows) / `source venv/bin/activate` (Linux/macOS)
|
||||
- Frontend dev server: `cd frontend && npm run dev` → http://localhost:3000
|
||||
- Backend API server: `uvicorn web.app:app --reload --host 0.0.0.0 --port 8000` → http://localhost:8000
|
||||
- When I say "start the server", assume the correct working directory is `E:/web/PolyWeather`
|
||||
- When I say "start the server", assume the working directory is the repo root
|
||||
|
||||
### Git Conventions
|
||||
- **Commit language: Chinese (简体中文) ONLY**
|
||||
@@ -38,12 +39,15 @@ Users (Web / Telegram) → Next.js Frontend (Vercel) → FastAPI /web/app.py
|
||||
Payment Layer (Intent + Event + Confirm Loop)
|
||||
```
|
||||
|
||||
- **Backend**: FastAPI on port 8000 (`web/app.py` → `web/core.py` + `web/routes.py` + `web/analysis_service.py`)
|
||||
- **Frontend**: Next.js 15 + React 19 + TypeScript + Tailwind CSS 3 on port 3000 (dev)
|
||||
- **Backend**: FastAPI on port 8000 (`web/app.py` → `web/app_factory.py` → `web/routers/` (8 route modules: `system`, `city`, `auth`, `analytics`, `scan`, `payments`, `ops`, `routes` (legacy)) + `web/services/` (14 service modules) + `web/core.py`)
|
||||
- **Frontend**: Next.js 15 + React 19 + TypeScript + Tailwind CSS 3 + shadcn/ui (new-york style) on port 3000 (dev)
|
||||
- **Bot**: Telegram bot via `bot_listener.py` → `src/bot/`
|
||||
- **Shared analysis core** in `src/` is used by both web API and bot
|
||||
- **Scan Terminal**: Real-time city opportunity scanning (`web/scan_terminal_service.py` and `frontend/components/dashboard/scan-terminal/`)
|
||||
- **Dashboard**: Main dashboard with interactive map, city sidebar, detail panels, and probability views
|
||||
- **Market Monitor** (`MonitorPanel`): Real-time temperature monitoring board for 22 trading cities. Uses a temperature resolution chain (AMOS runway → AMOS → `airport_primary` → `airport_current` → `current`) defined in `frontend/components/dashboard/monitoring/monitor-temperature.ts`. Per-city refresh decisions driven by source-aware freshness (`source-freshness.ts`) instead of uniform `obs_age_min`. Seoul/Busan display runway surface temperature from AMOS; US cities get 5-min MADIS HFMETAR via `airport_primary`; others fall back to METAR.
|
||||
- **High-Freq Airport Pipeline**: 19 of 22 monitor cities have dedicated realtime sources (AMOS, MADIS, JMA, MGM, FMI, KNMI, AROME). Data flows: `weather_sources.py` (fetch) → `country_networks.py` (`_airport_primary_from_raw`, per-country providers) → API `airport_primary` field. Plain METAR stays in `airport_current`. Documented in `docs/AIRPORT_REALTIME_SOURCES.md`.
|
||||
- **Country Network Providers**: `country_networks.py` routes per-city to the right provider (Turkey→MGM, Korea→KMA, Japan→JMA, etc.) via `get_country_network_provider()`. Each provider controls `airport_primary_current`, `official_nearby_current`, and `official_network_status`. US cities use the default `GlobalMetarNetworkProvider` but get MADIS overrides injected via `results["madis_hfmetar_current"]`.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -51,9 +55,12 @@ Users (Web / Telegram) → Next.js Frontend (Vercel) → FastAPI /web/app.py
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run dev # Next.js dev server
|
||||
npm run build # Production build
|
||||
npm run lint # ESLint via next lint
|
||||
npm run dev # Next.js dev server (runs sync-next-server-chunks.mjs first)
|
||||
npm run build # Production build (runs sync-next-server-chunks.mjs after)
|
||||
npm run start # Production server
|
||||
npm run lint # ESLint via next lint
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run test:business # Business state tests via scripts/run-business-state-tests.mjs (also runs in CI)
|
||||
```
|
||||
|
||||
### Backend (dev on port 8000)
|
||||
@@ -70,17 +77,23 @@ python run.py
|
||||
|
||||
### Docker (production-like stack)
|
||||
```bash
|
||||
docker compose up -d --build # bot + web API
|
||||
docker compose --profile workers up -d # + prewarm worker
|
||||
docker compose --profile monitoring up -d # + Prometheus/Grafana/Alertmanager
|
||||
docker compose up -d --build # bot + web API (polyweather + polyweather_web)
|
||||
```
|
||||
The compose file defines two services: `polyweather` (bot) and `polyweather_web` (FastAPI on :8000). Prewarm worker and monitoring profiles were removed in v1.6.0.
|
||||
|
||||
### Python tests
|
||||
```bash
|
||||
pytest tests/ # all tests
|
||||
pytest tests/test_web_observability.py # single test file
|
||||
python -m pytest tests/ # all tests
|
||||
python -m pytest tests/test_web_observability.py # single test file
|
||||
```
|
||||
|
||||
### Version bump (see RELEASE.md)
|
||||
```bash
|
||||
python scripts/bump_version.py patch # or minor / major / 1.5.0
|
||||
python scripts/sync_version.py # verify sync across files
|
||||
```
|
||||
`VERSION` file is the single source of truth; frontend `package.json` and docs sync from it.
|
||||
|
||||
### Lint & Format
|
||||
```bash
|
||||
ruff check . # Python lint (pycodestyle + Pyflakes, line-length 88)
|
||||
@@ -98,21 +111,25 @@ curl http://127.0.0.1:8000/metrics
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `src/data_collection/` | Weather sources (METAR, TAF, Open-Meteo, JMA, KMA, MGM, NMC, Russia stations, settlement sources), city registry (52 cities), Polymarket readonly layer |
|
||||
| `src/analysis/` | DEB algorithm, trend engine, probability calibration (EMOS/LGBM), market alert engine, settlement rounding |
|
||||
| `src/models/` | LightGBM daily-high model training and feature engineering |
|
||||
| `src/payments/` | Onchain checkout, event listener, confirm loop, contract audit |
|
||||
| `src/analysis/` | DEB algorithm, trend engine, market alert engine, settlement rounding |
|
||||
| `src/auth/` | Supabase entitlement checks, Telegram group pricing |
|
||||
| `src/bot/` | Telegram bot handlers and orchestrator |
|
||||
| `src/database/` | SQLite-based runtime state, DB manager, daily/truth/training feature repositories |
|
||||
| `web/` | FastAPI app, routes (~65K), analysis service (~130K), scan terminal service (~56K), AI scan modules |
|
||||
| `src/data_collection/` | Weather sources (METAR, TAF, Open-Meteo, JMA, KMA, MGM, NMC, Russia stations, settlement sources), city registry (52 cities), Polymarket readonly layer. Also: `madis_sources.py` (NOAA 5-min NetCDF), `amos_station_sources.py` (Korean runway sensors), `country_networks.py` (per-country provider routing + `_airport_primary_from_raw`) |
|
||||
| `src/data_mining/` | Historical data fetch utilities |
|
||||
| `src/onchain/` | Polygon wallet watcher |
|
||||
| `src/payments/` | Onchain checkout, event listener, confirm loop, contract audit |
|
||||
| `src/strategy/` | Trading strategy modules |
|
||||
| `src/trading/` | Trading execution modules |
|
||||
| `src/utils/` | Shared utilities: config loader, logging, metrics, Telegram push, chat ID helpers |
|
||||
| `web/` | FastAPI app (`app.py` → `app_factory.py`), `routers/` (8 route modules), `services/` (14 service modules), `core.py`, scan terminal modules (AI fallback, AI prompts, METAR gate, city rows, ranker, cache) |
|
||||
| `frontend/app/` | Next.js App Router pages (dashboard, account, auth, docs, ops, probabilities, scan) |
|
||||
| `frontend/components/dashboard/` | Dashboard UI components (map, sidebar, detail panel, modals, charts, scan terminal). `scan-root-styles.ts` is the CSS Module barrel, combining 22 module roots into one pre-composed className |
|
||||
| `frontend/lib/` | Shared client logic: types, API client, chart utils, i18n, dashboard utils |
|
||||
| `frontend/components/dashboard/` | Dashboard UI components (map, sidebar, detail panel, modals, charts, scan terminal). `scan-root-styles.ts` is the CSS Module barrel, combining 22 module roots into one pre-composed className. `monitoring/` subdirectory: `MonitorPanel`, `monitor-temperature.ts` (temp resolution chain), `monitor-refresh-policy.ts`. |
|
||||
| `frontend/lib/` | Shared client logic: types (`dashboard-types.ts`, including `AirportCurrentConditions`, `CityDetail`), API client, chart utils, i18n, `source-freshness.ts` (per-source freshness with `expected_next_update_at`), dashboard utils |
|
||||
| `frontend/hooks/` | React hooks: dashboard store (global state), Leaflet map, chart helper |
|
||||
| `scripts/` | Operational scripts: probability calibration training, backfills, payment reconciliation, prewarm worker |
|
||||
| `scripts/` | Operational scripts: backfills, payment reconciliation. `supabase/` subdirectory: DB schema and migration SQL. |
|
||||
| `config/` | YAML config (city list, weather settings, logging) |
|
||||
| `docs/` | Bilingual product & technical docs |
|
||||
| `monitoring/` | Prometheus/Grafana/Alertmanager configs |
|
||||
|
||||
## Key Technical Details
|
||||
|
||||
@@ -121,9 +138,9 @@ curl http://127.0.0.1:8000/metrics
|
||||
- **Frontend package manager**: npm
|
||||
- **State storage**: SQLite primary path (set via `POLYWEATHER_STATE_STORAGE_MODE=sqlite` + `POLYWEATHER_DB_PATH`). Legacy JSON/JSONL files are migration/fallback only.
|
||||
- **Runtime data**: External dir recommended (`POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather`) to avoid git conflicts
|
||||
- **Auth gating** (frontend middleware): Token-based (`POLYWEATHER_DASHBOARD_ACCESS_TOKEN`) or Supabase session-based (`POLYWEATHER_AUTH_ENABLED`). Local dev hosts bypass auth.
|
||||
- **Configuration**: `.env.example` is the comprehensive reference (8 config sections: runtime, Telegram, weather cache, auth, ops, frontend, optional modules, Polygon monitor). Copy to `.env` and fill in secrets.
|
||||
- **Auth gating** (frontend middleware): Three-tier priority in `middleware.ts` — (1) local dev hosts (localhost / 127.0.0.1 / ::1) bypass auth entirely, (2) Supabase session-based when `POLYWEATHER_AUTH_ENABLED=true` via `handleSupabaseAuthGate` or `handleSupabaseOptionalSession`, (3) legacy token fallback via `POLYWEATHER_DASHBOARD_ACCESS_TOKEN` cookie/query-param. Public pages (`/`, `/docs`, `/auth/*`, `/entitlement-required`) and public API routes are always accessible.
|
||||
- **CORS**: Allowed origins from `WEB_CORS_ORIGINS` env var (defaults: localhost:3000, polyweather-pro.vercel.app)
|
||||
- **EMOS/CRPS calibration**: Trainable but production should use `legacy` or `emos_shadow` engine; `emos_primary` only after local evaluation + manual rollout
|
||||
- **API proxy**: Frontend uses Next.js rewrites to proxy `/api/*` to the FastAPI backend; see `frontend/lib/api-proxy.ts` and `frontend/lib/backend-api.ts`
|
||||
|
||||
## Commit Convention
|
||||
@@ -138,6 +155,7 @@ This repo uses the **Lore Commit Protocol** — structured decision records with
|
||||
- When modifying UI components, update both **dark-mode and light-mode CSS files** in the same edit batch.
|
||||
- **CSS Variables First**: Prefer `var(--color-*)` / `var(--color-signal-*)` tokens over hardcoded hex values. The token system is defined in `globals.css` with light-theme overrides under `html.light`.
|
||||
- **Avoid `!important`**: Only use it for Leaflet map overrides (inline style conflict) and chart canvas sizing. For light-theme overrides, use `html.light .root` prefix for higher specificity.
|
||||
- **Monitoring CSS note**: `MonitorPanel.module.css` scopes its light-theme overrides to `.scan-terminal.light` (the terminal's built-in toggle), NOT `html.light`. When adding light styles for monitoring components, match this scoping.
|
||||
- **New CSS Modules**: Add the module root class to `scan-root-styles.ts` barrel file instead of importing it separately in `ScanTerminalDashboard.tsx`.
|
||||
|
||||
## Quality Gates (MANDATORY)
|
||||
@@ -146,7 +164,7 @@ Before marking any task as complete, you MUST:
|
||||
|
||||
1. **Type check** — Run `npx tsc --noEmit` (frontend) or `python -m ruff check .` (backend) on modified files
|
||||
2. **No Unicode escapes** — Verify that NO `\uXXXX` sequences were introduced; if found, revert and fix
|
||||
3. **Dual-theme CSS** — For any UI change, confirm BOTH the dark CSS module AND `ScanTerminalLightTheme.module.css` were updated
|
||||
3. **Dual-theme CSS** — For any UI change, confirm BOTH dark and light styles. Most components need `ScanTerminalLightTheme.module.css` updated; monitoring components (`MonitorPanel.module.css`) contain their own `.scan-terminal.light` blocks inline.
|
||||
4. **No new hardcoded palette colors** — Use `var(--color-*)` token references instead of `#4DA3FF` / `#E6EDF3` / `#9FB2C7` / `#6B7A90` hex values
|
||||
5. **Show the diff** — Output `git diff --stat` and test results before declaring success
|
||||
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM python:3.11-slim
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 设置环境变量
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_ROOT_USER_ACTION=ignore \
|
||||
TZ=UTC
|
||||
|
||||
# 安装系统依赖 (如果有必要的包可以取消注释)
|
||||
# RUN apt-get update && apt-get install -y --no-install-recommends gcc && rm -rf /var/lib/apt/lists/*
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc libhdf5-dev libnetcdf-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 复制 requirements 文件
|
||||
COPY requirements.txt .
|
||||
|
||||
# 安装 Python 依赖
|
||||
RUN pip install --no-cache-dir --prefer-binary -r requirements.txt
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --prefer-binary -r requirements.txt
|
||||
|
||||
# 复制项目代码
|
||||
COPY . .
|
||||
|
||||
# 启动机器人
|
||||
CMD ["python", "bot_listener.py"]
|
||||
|
||||
@@ -21,10 +21,12 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
||||
|
||||
[](https://star-history.com/#yangyuan-zhen/PolyWeather&Date)
|
||||
|
||||
## Product Status (2026-04-27)
|
||||
## Product Status (2026-05-23)
|
||||
|
||||
- Subscription live: `Pro Monthly 5 USDC`.
|
||||
- Points redemption live: `500 points = 1 USDC`, max `3 USDC` off.
|
||||
- Subscription live: `Pro Monthly 10 USDC`.
|
||||
- Points system live: earn via group chat, welcome bonus (+20), first-message-of-day bonus (+2), weekly participation rewards.
|
||||
- `/city` and `/deb` now free (daily cap 10 each); points redeemable for payment discount (`500 pts = 1 USDC`, max `3 USDC`).
|
||||
- Weekly leaderboard rewards restructured: smaller point bonuses for winners (200/100/50), all active users receive participation rewards.
|
||||
- Onchain checkout live: Polygon contract checkout (USDC / USDC.e).
|
||||
- Auto-reconciliation live: event listener + periodic confirm loop.
|
||||
- Ops dashboard live: `/ops` for memberships, leaderboard, manual point grants, and payment incident triage.
|
||||
@@ -33,22 +35,18 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
||||
- EMOS/CRPS calibration is wired and trainable, but production should stay on `legacy` or `emos_shadow`; `emos_primary` is only for candidates that pass local offline evaluation and manual rollout.
|
||||
- Intraday analysis is now positioned as a professional meteorology read: headline, confidence, base/upside/downside paths, next observation point, evidence chain, failure modes, and confirmation rules.
|
||||
- Intraday modal now blocks stale cached detail during refresh, so users do not briefly trade off old city/date data before full detail arrives.
|
||||
- City decision cards now include the AI airport read: METAR, DEB, model cluster, and the AI expected-high center are resolved before mapping the result to Polymarket temperature buckets.
|
||||
- City decision cards now include the AI airport read: METAR, DEB, model cluster, and the AI expected-high center are resolved before mapping the result to temperature buckets.
|
||||
- AI airport reads now use in-page memory cache, browser `localStorage`, and backend short-TTL cache; returning from another dashboard tab restores existing stream text or final results before any new request is needed.
|
||||
- Market bucket matching now uses the full `all_buckets` surface and strict exact / range / or-higher / or-lower direction checks, reducing bad matches to unreasonable tail buckets.
|
||||
- The card label “model-market difference” means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully.
|
||||
- Calibrated model probability is now the primary probability panel. It shows the active production probability engine; EMOS/LGBM are surfaced only when evaluated or shadowed, while model consensus and market prices remain secondary references.
|
||||
- Calibrated model probability is now the primary probability panel. It shows the active production probability engine (legacy Gaussian or EMOS), while model consensus remains a secondary reference.
|
||||
- Non-Hong Kong airport cities now ingest `TAF` and parse `FM / TEMPO / BECMG / PROB30/40`.
|
||||
- Temperature chart now overlays `TAF Timing` markers near the expected peak window.
|
||||
- Trade cue now combines upper-air structure, `TAF`, market crowding, and `edge_percent`.
|
||||
- Browser extension now uses `DEB` for multi-day forecast and stays positioned as a lightweight lead-in to the main site.
|
||||
- Official nearby-network layer now covers `MGM` (Turkey), `CMA/NMC` (Mainland China), `JMA AMeDAS` (Japan), `KMA` (Korea), `HKO` (Hong Kong), and `CWA` (Taiwan).
|
||||
- Official nearby-network layer now covers `MGM` (Turkey), `CMA/NMC` (Mainland China), `JMA AMeDAS` (Japan), `AMOS` (Korea, runway-level, Seoul/Busan), `HKO` (Hong Kong), and `CWA` (Taiwan).
|
||||
- Tokyo now ingests Haneda `JMA AMeDAS` 10-minute temperature as the official enhancement layer.
|
||||
- Dashboard prewarm is now supported through a dedicated worker / cron path, with runtime status exposed in `/api/system/status` and `/ops`.
|
||||
- `/ops` now exposes cache bucket counts, summary cache hit / miss rate, and prewarm runtime heartbeat.
|
||||
- Intraday commentary can optionally use `Groq` as a bilingual rewrite layer, while rule-based commentary remains the fallback.
|
||||
- Vercel frontend guidance now includes cost controls for analytics, eager fetches, and edge-side scanner blocking.
|
||||
- Frontend design system overhauled: unified CSS token system, eliminated `!important` abuse (68→6 in light theme), consolidated breakpoints (18→10), migrated hardcoded colors to CSS variables, added ARIA attributes and focus-visible keyboard navigation. See `docs/frontend-ui-design-review.md` for the full audit trail.
|
||||
- Frontend design system overhauled: unified CSS token system, eliminated `!important` abuse (134→49 in light theme), consolidated breakpoints (18→10), migrated hardcoded colors to CSS variables, added ARIA attributes and focus-visible keyboard navigation. See `docs/frontend-ui-design-review.md` for the full audit trail.
|
||||
|
||||
## License & Commercial Boundary
|
||||
|
||||
@@ -62,17 +60,15 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
- Aggregates observations and forecasts for 52 monitored cities.
|
||||
- Aggregates observations and forecasts for 51 monitored cities.
|
||||
- Uses DEB (Dynamic Error Balancing) to blend multi-model highs.
|
||||
- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution), with `LGBM` metadata surfaced when the calibrated engine is active.
|
||||
- Maps weather view to Polymarket quotes for mispricing scan.
|
||||
- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution) via legacy Gaussian or EMOS/CRPS calibration.
|
||||
- Adds city decision cards that combine AI airport reads, expected-high centers, full market-bucket mapping, and model-market difference in one view.
|
||||
- Reuses one analysis core across web dashboard and Telegram bot.
|
||||
- Adds payment audit trails, replay tooling, and incident visibility in ops.
|
||||
- Adds peak-window-oriented intraday analysis with meteorology headline, path buckets, evidence chain, invalidation rules, and confirmation rules.
|
||||
- Adds airport-side `TAF` timing overlays and airport suppression/disruption interpretation for non-Hong Kong airport cities.
|
||||
- Adds official nearby-network enhancement layers for China, Japan, Korea, Hong Kong, Taiwan, and Turkey without replacing airport settlement anchors.
|
||||
- Adds optional dashboard prewarm worker so hot cities can be refreshed before user clicks.
|
||||
- Adds official nearby-network and runway-level enhancement layers for China, Japan, Korea (AMOS runway sensors for Seoul/Busan), Hong Kong, Taiwan, and Turkey without replacing airport settlement anchors.
|
||||
|
||||
## Reference Architecture
|
||||
|
||||
@@ -89,22 +85,20 @@ flowchart LR
|
||||
WX --> MGM["MGM (Turkey station network)"]
|
||||
WX --> OM["Open-Meteo"]
|
||||
WX --> JMA["JMA AMeDAS (Japan)"]
|
||||
WX --> KMA["KMA (Korea)"]
|
||||
WX --> AMOS["AMOS runway sensors (Korea)"]
|
||||
WX --> HKO["HKO / CWA / NOAA / Official settlement sources"]
|
||||
|
||||
API --> ANA["DEB + Trend + Probability + Market Scan"]
|
||||
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"]
|
||||
ANA --> PM["Polymarket Read-only Layer"]
|
||||
ANA --> LLM["Optional Groq Commentary Rewrite"]
|
||||
API --> PREWARM["Dashboard Prewarm API / Worker"]
|
||||
ANA --> STATE["SQLite runtime state"]
|
||||
```
|
||||
|
||||
## Monitored Cities (52)
|
||||
## Monitored Cities (51)
|
||||
|
||||
- Europe / Middle East / Africa: Ankara, Istanbul, Moscow, London, Paris, Munich, Milan, Warsaw, Madrid, Tel Aviv, Amsterdam, Helsinki, Lagos, Cape Town, Jeddah
|
||||
- APAC: Seoul, Busan, Hong Kong, Lau Fau Shan, Taipei, Shanghai, Beijing, Qingdao, Wuhan, Chengdu, Chongqing, Shenzhen, Guangzhou, Singapore, Tokyo, Kuala Lumpur, Jakarta, Manila, Wellington
|
||||
- Americas: Toronto, New York, Los Angeles, San Francisco, Aurora, Austin, Houston, Chicago, Dallas, Miami, Atlanta, Seattle, Mexico City, Buenos Aires, Sao Paulo, Panama City
|
||||
- South Asia: Lucknow, Karachi, Masroor Air Base
|
||||
- South Asia: Lucknow, Karachi
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -129,7 +123,7 @@ npm run dev
|
||||
- Hong Kong keeps `HKO` official readings in dashboard and history, without falling back to airport METAR lines.
|
||||
- Intraday analysis now separates meteorology conclusion, evidence chain, invalidation rules, confirmation rules, calibrated probability, and market reference.
|
||||
- `TAF` is used as an airport-side confirmation layer, not as the main temperature model.
|
||||
- `LGBM` can power the calibrated probability panel; model vote counts remain an explanatory consensus line, not the final probability.
|
||||
- Calibrated probability uses legacy Gaussian (default) or EMOS/CRPS when evaluated; model vote counts remain an explanatory consensus line, not the final probability.
|
||||
- Browser extension remains a lightweight monitoring + basic-bias product, while the site holds the full analysis experience.
|
||||
|
||||
## Runtime Data (Recommended on VPS)
|
||||
@@ -165,20 +159,6 @@ curl http://127.0.0.1:8000/api/system/status
|
||||
curl http://127.0.0.1:8000/metrics
|
||||
```
|
||||
|
||||
### Dashboard prewarm worker
|
||||
|
||||
```bash
|
||||
docker compose --profile workers up -d polyweather_prewarm
|
||||
curl http://127.0.0.1:8000/api/system/status
|
||||
```
|
||||
|
||||
Check:
|
||||
|
||||
- `prewarm.thread_alive`
|
||||
- `prewarm.runtime.cycle_count`
|
||||
- `cache.analysis.hit_rate`
|
||||
- `cache.open_meteo_forecast_entries`
|
||||
|
||||
### Frontend cache headers
|
||||
|
||||
```bash
|
||||
@@ -197,12 +177,6 @@ docker compose logs -f polyweather | egrep "payment event loop started|payment c
|
||||
curl http://127.0.0.1:8000/api/payments/runtime
|
||||
```
|
||||
|
||||
### Wallet activity logs
|
||||
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed"
|
||||
```
|
||||
|
||||
## Telegram Commands
|
||||
|
||||
| Command | Purpose |
|
||||
@@ -220,26 +194,25 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
|
||||
- Chinese API guide: [docs/API_ZH.md](docs/API_ZH.md)
|
||||
- TAF signal guide (ZH): [docs/TAF_SIGNAL_ZH.md](docs/TAF_SIGNAL_ZH.md)
|
||||
- Model stack & DEB (ZH): [docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
|
||||
- EMOS + LGBM system (ZH): [docs/EMOS_LGBM_SYSTEM_ZH.md](docs/EMOS_LGBM_SYSTEM_ZH.md)
|
||||
- Commercialization: [docs/COMMERCIALIZATION.md](docs/COMMERCIALIZATION.md)
|
||||
- AGPL-3.0 policy: [docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
|
||||
- Supabase setup (ZH): [docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
|
||||
- Configuration & secrets (ZH): [docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
|
||||
- LightGBM daily-high model (ZH): [docs/LGBM_DAILY_HIGH_ZH.md](docs/LGBM_DAILY_HIGH_ZH.md)
|
||||
- Frontend deployment (ZH): [docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
|
||||
- Tech debt (EN): [docs/TECH_DEBT.md](docs/TECH_DEBT.md)
|
||||
- Tech debt (ZH): [docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
|
||||
- Airport realtime sources: [docs/AIRPORT_REALTIME_SOURCES.md](docs/AIRPORT_REALTIME_SOURCES.md)
|
||||
- Airport market monitor (ZH): [docs/AIRPORT_MARKET_MONITOR_ZH.md](docs/AIRPORT_MARKET_MONITOR_ZH.md)
|
||||
- Services overview (ZH): [docs/SERVICES_ZH.md](docs/SERVICES_ZH.md)
|
||||
- Payment verification: [docs/payments/POLYGONSCAN_VERIFY.md](docs/payments/POLYGONSCAN_VERIFY.md)
|
||||
- Payment audit: [docs/payments/PAYMENT_AUDIT_ZH.md](docs/payments/PAYMENT_AUDIT_ZH.md)
|
||||
- Payment V2 upgrade: [docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
|
||||
- Ops admin guide: [docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
|
||||
- Monitoring guide (ZH): [docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
|
||||
- Deep research report: [docs/deep-research-report.md](docs/deep-research-report.md)
|
||||
- Frontend report: [FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md)
|
||||
- Release process: [RELEASE.md](RELEASE.md)
|
||||
- Changelog: [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## Version
|
||||
|
||||
- Version: `v1.5.4`
|
||||
- Last Updated: `2026-04-19`
|
||||
- Version: `v1.7.0`
|
||||
- Last Updated: `2026-05-23`
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
|
||||

|
||||
|
||||
## 当前产品状态(2026-04-27)
|
||||
## 当前产品状态(2026-05-23)
|
||||
|
||||
- 已上线订阅制:`Pro 月付 5 USDC`。
|
||||
- 已上线积分抵扣:`500 积分 = 1 USDC`,最多抵扣 `3 USDC`。
|
||||
- 已上线订阅制:`Pro 月付 10 USDC`。
|
||||
- 已上线积分体系:群内发言赚分 + 首次发言欢迎奖励 (+20) + 每日首条消息奖励 (+2) + 每周全员参与奖。
|
||||
- `/city` 与 `/deb` 已改为免费(每日各 10 次);积分可用于支付抵扣(`500 分 = 1 USDC`,最多抵 `3 USDC`)。
|
||||
- 周榜奖励已改造:降低赢家积分加成 (200/100/50),所有周活跃用户均享参与奖。
|
||||
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)。
|
||||
- 已上线自动补单:事件监听 + 周期确认双链路。
|
||||
- 已上线支付运行态与审计接口:`/api/payments/runtime`。
|
||||
@@ -30,7 +32,7 @@
|
||||
- `MGM`(土耳其)
|
||||
- `CMA/NMC`(中国内地)
|
||||
- `JMA AMeDAS`(日本)
|
||||
- `KMA`(韩国)
|
||||
- `AMOS`(韩国,跑道级传感器,首尔/釜山)
|
||||
- `HKO`(香港)
|
||||
- `CWA`(台湾)
|
||||
- 东京现已接入羽田 `JMA AMeDAS` 10 分钟温度作为官方增强层。
|
||||
@@ -38,13 +40,12 @@
|
||||
- `/ops` 现已展示缓存桶数量、summary cache hit/miss 与 prewarm heartbeat。
|
||||
- 今日日内分析已改为“专业气象判断台”:顶部先给气象主判断、置信度、基准/上修/下修路径、下一观测点,再展示证据链、失效条件、确认条件和模型层。
|
||||
- 日内分析弹窗在 full detail / market detail 同步完成前会锁住旧内容并显示刷新状态,避免用户短暂看到上一轮缓存数据后误判。
|
||||
- 城市决策卡已接入 AI 机场报文解读:先用 METAR、DEB、多模型集群和 AI 最高温中枢判断天气路径,再映射到 Polymarket 温度桶。
|
||||
- 城市决策卡已接入 AI 机场报文解读:先用 METAR、DEB、多模型集群和 AI 最高温中枢判断天气路径,再映射到温度桶。
|
||||
- AI 机场报文解读现在同时使用页面内存缓存、浏览器 `localStorage` 和后端短 TTL 缓存;从其他选项卡切回决策卡时会优先恢复已有流式内容或最终结果。
|
||||
- 市场温度桶匹配已改为完整 `all_buckets` 映射,按 exact / range / or higher / or lower 方向严格匹配,避免把天气中枢错配到不合理尾部桶。
|
||||
- 决策卡中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。
|
||||
- 概率区已改为“校准模型概率”;默认展示生产概率引擎输出,EMOS/LGBM 只在通过评估或作为 shadow 时进入解释层。
|
||||
- 今日日内结构解读已支持可选 `Groq` 改写层,失败时自动回退规则文案。
|
||||
- 前端部署文档已补充 Vercel 节流建议,包括 analytics 关闭、eager fetch 开关与扫描流量防火墙规则。
|
||||
- 概率区已改为”校准模型概率”;默认展示生产概率引擎输出(legacy 高斯或 EMOS),模型共识作为辅助参考。
|
||||
- 今日日内结构解读已支持可选 Groq 改写层,失败时自动回退规则文案。
|
||||
- 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md`。
|
||||
|
||||
## 许可证与商用边界(重要)
|
||||
@@ -59,15 +60,13 @@
|
||||
|
||||
## 核心能力
|
||||
|
||||
- 聚合 52 个监控城市的实测与预报数据。
|
||||
- 聚合 51 个监控城市的实测与预报数据。
|
||||
- DEB(Dynamic Error Balancing)融合多模型最高温。
|
||||
- 输出结算导向校准概率分布(`mu` + 温度桶),并在 LGBM 生效时展示校准引擎元数据。
|
||||
- 将模型观点映射到 Polymarket 行情,做错价扫描。
|
||||
- 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯或 EMOS/CRPS 校准引擎。
|
||||
- 地图城市决策卡把 AI 机场报文解读、最高温中枢、完整市场温度桶和模型-市场差放在同一张卡中展示。
|
||||
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
|
||||
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
|
||||
- 官方增强层支持按国家 provider 统一接入,但不替代机场主站、METAR 或明确官方结算站。
|
||||
- 支持后台预热热点城市,降低用户点击城市后的冷启动成本。
|
||||
- 官方增强层与跑道级传感器支持按国家 provider 统一接入(含韩国 AMOS 首尔/釜山跑道实测),不替代机场主站、METAR 或明确官方结算站。
|
||||
|
||||
## 参考架构
|
||||
|
||||
@@ -82,25 +81,21 @@ flowchart LR
|
||||
WX --> METAR["Aviation Weather(METAR)"]
|
||||
WX --> MGM["MGM(土耳其站网)"]
|
||||
WX --> JMA["JMA AMeDAS(日本)"]
|
||||
WX --> KMA["KMA(韩国)"]
|
||||
WX --> AMOS["AMOS 跑道传感器(韩国)"]
|
||||
WX --> OM["Open-Meteo"]
|
||||
WX --> HKO["HKO / CWA / NOAA 等官方结算源"]
|
||||
|
||||
API --> ANA["DEB + 趋势 + 概率 + 市场扫描"]
|
||||
ANA --> PAY["支付状态(Intent + Event + Confirm Loop)"]
|
||||
ANA --> PM["Polymarket 只读层"]
|
||||
API --> OBS["healthz / system status / metrics"]
|
||||
API --> PREWARM["Dashboard 预热接口 / Worker"]
|
||||
ANA --> LLM["可选 Groq 文案改写层"]
|
||||
ANA --> STATE["SQLite runtime state<br/>legacy files only for migration/export fallback"]
|
||||
```
|
||||
|
||||
## 监控城市(52)
|
||||
## 监控城市(51)
|
||||
|
||||
- 欧洲/中东/非洲:Ankara、Istanbul、Moscow、London、Paris、Munich、Milan、Warsaw、Madrid、Tel Aviv、Amsterdam、Helsinki、Lagos、Cape Town、Jeddah
|
||||
- 亚太:Seoul、Busan、Hong Kong、Lau Fau Shan、Taipei、Shanghai、Beijing、Wuhan、Chengdu、Chongqing、Shenzhen、Guangzhou、Singapore、Tokyo、Kuala Lumpur、Jakarta、Manila、Wellington
|
||||
- 美洲:Toronto、New York、Los Angeles、San Francisco、Aurora、Austin、Houston、Chicago、Dallas、Miami、Atlanta、Seattle、Mexico City、Buenos Aires、Sao Paulo、Panama City
|
||||
- 南亚:Lucknow、Karachi、Masroor Air Base
|
||||
- 南亚:Lucknow、Karachi
|
||||
|
||||
## 快速启动
|
||||
|
||||
@@ -156,21 +151,7 @@ curl http://127.0.0.1:8000/api/system/status
|
||||
curl http://127.0.0.1:8000/metrics
|
||||
```
|
||||
|
||||
### Dashboard 预热 Worker
|
||||
|
||||
```bash
|
||||
docker compose --profile workers up -d polyweather_prewarm
|
||||
curl http://127.0.0.1:8000/api/system/status
|
||||
```
|
||||
|
||||
重点关注:
|
||||
|
||||
- `prewarm.thread_alive`
|
||||
- `prewarm.runtime.cycle_count`
|
||||
- `prewarm.runtime.last_summary_ok`
|
||||
- `cache.analysis.hit_rate`
|
||||
|
||||
### 前端缓存头
|
||||
### 外部监控栈
|
||||
|
||||
```bash
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
|
||||
@@ -213,12 +194,6 @@ curl http://127.0.0.1:8000/api/payments/runtime
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
```
|
||||
|
||||
### 钱包异动监听日志
|
||||
|
||||
```bash
|
||||
docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed"
|
||||
```
|
||||
|
||||
## Telegram 指令
|
||||
|
||||
| 指令 | 用途 |
|
||||
@@ -239,24 +214,21 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
|
||||
- Supabase 接入:[docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
|
||||
- 配置与密钥管理:[docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
|
||||
- 前端部署(Vercel):[docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
|
||||
- EMOS 训练报告:[docs/EMOS_TRAINING_REPORT_ZH.md](docs/EMOS_TRAINING_REPORT_ZH.md)
|
||||
- 概率快照归档:[docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md](docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md)
|
||||
- 技术债(中文镜像):[docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
|
||||
- 技术债(主文档):[docs/TECH_DEBT.md](docs/TECH_DEBT.md)
|
||||
- 技术债:[docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
|
||||
- 机场实时数据源:[docs/AIRPORT_REALTIME_SOURCES.md](docs/AIRPORT_REALTIME_SOURCES.md)
|
||||
- 机场市场监控(中文):[docs/AIRPORT_MARKET_MONITOR_ZH.md](docs/AIRPORT_MARKET_MONITOR_ZH.md)
|
||||
- 外部服务总览:[docs/SERVICES_ZH.md](docs/SERVICES_ZH.md)
|
||||
- 支付合约验证:[docs/payments/POLYGONSCAN_VERIFY.md](docs/payments/POLYGONSCAN_VERIFY.md)
|
||||
- 支付审计说明:[docs/payments/PAYMENT_AUDIT_ZH.md](docs/payments/PAYMENT_AUDIT_ZH.md)
|
||||
- 支付 V2 升级方案:[docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
|
||||
- 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
|
||||
- 外部监控说明:[docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
|
||||
- 模型栈与 DEB:[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md)
|
||||
- LightGBM 日最高温模型:[docs/LGBM_DAILY_HIGH_ZH.md](docs/LGBM_DAILY_HIGH_ZH.md)
|
||||
- EMOS + LGBM 系统:[docs/EMOS_LGBM_SYSTEM_ZH.md](docs/EMOS_LGBM_SYSTEM_ZH.md)
|
||||
- 深度评估报告:[docs/deep-research-report.md](docs/deep-research-report.md)
|
||||
- 前端报告:[FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md)
|
||||
- 发布流程:[RELEASE.md](RELEASE.md)
|
||||
- 变更记录:[CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## 当前版本
|
||||
|
||||
- 版本:`v1.5.4`
|
||||
- 文档最后更新:`2026-04-19`
|
||||
- 版本:`v1.7.0`
|
||||
- 文档最后更新:`2026-05-23`
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
|
||||
示例:
|
||||
|
||||
- `1.4.0 -> 1.4.1`:告警逻辑修正、缓存修正、文档修正
|
||||
- `1.4.0 -> 1.5.0`:新增支付能力、新增页面、新增 API
|
||||
- `1.4.0 -> 2.0.0`:接口重构或数据结构不兼容
|
||||
- `1.7.0 -> 1.7.1`:告警逻辑修正、缓存修正、文档修正
|
||||
- `1.7.0 -> 1.8.0`:新增能力、接口扩展、向后兼容的功能迭代
|
||||
- `1.7.0 -> 2.0.0`:不兼容变更、核心架构升级
|
||||
|
||||
## 日常升版步骤
|
||||
|
||||
@@ -29,7 +29,7 @@ python scripts/bump_version.py patch
|
||||
```bash
|
||||
python scripts/bump_version.py minor
|
||||
python scripts/bump_version.py major
|
||||
python scripts/bump_version.py 1.5.0
|
||||
python scripts/bump_version.py 1.8.0
|
||||
```
|
||||
|
||||
### 2. 检查同步结果
|
||||
@@ -68,15 +68,15 @@ python -m pytest
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "release: v1.4.1"
|
||||
git tag v1.4.1
|
||||
git commit -m "release: v1.7.1"
|
||||
git tag v1.7.1
|
||||
```
|
||||
|
||||
### 6. 推送
|
||||
|
||||
```bash
|
||||
git push
|
||||
git push origin v1.4.1
|
||||
git push origin v1.7.1
|
||||
```
|
||||
|
||||
## 当前约束
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"model_type": "LightGBMRegressor",
|
||||
"target": "actual_high",
|
||||
"horizon": "D0",
|
||||
"feature_names": [
|
||||
"actual_high_lag_1",
|
||||
"actual_high_lag_2",
|
||||
"actual_high_lag_3",
|
||||
"actual_high_lag_7",
|
||||
"actual_high_mean_7",
|
||||
"actual_high_mean_14",
|
||||
"actual_high_trend_3",
|
||||
"open_meteo",
|
||||
"ecmwf",
|
||||
"gfs",
|
||||
"gem",
|
||||
"jma",
|
||||
"icon",
|
||||
"mgm",
|
||||
"nws",
|
||||
"deb_prediction",
|
||||
"model_median",
|
||||
"model_spread",
|
||||
"current_temp",
|
||||
"max_so_far",
|
||||
"humidity",
|
||||
"wind_speed_kt",
|
||||
"visibility_mi",
|
||||
"local_hour",
|
||||
"month",
|
||||
"weekday",
|
||||
"peak_status_code"
|
||||
],
|
||||
"base_model_columns": [
|
||||
"open_meteo",
|
||||
"ecmwf",
|
||||
"gfs",
|
||||
"gem",
|
||||
"jma",
|
||||
"icon",
|
||||
"mgm",
|
||||
"nws"
|
||||
],
|
||||
"model_path": "artifacts\\models\\lgbm_daily_high.txt",
|
||||
"sample_count": 1247,
|
||||
"train_count": 998,
|
||||
"validation_count": 249,
|
||||
"metrics": {
|
||||
"validation": {
|
||||
"sample_count": 249,
|
||||
"lgbm_mae": 2.626,
|
||||
"deb_mae": 2.745,
|
||||
"best_single_mae": 1.733,
|
||||
"median_mae": 2.866
|
||||
},
|
||||
"full_sample": {
|
||||
"sample_count": 1247,
|
||||
"lgbm_mae": 0.953,
|
||||
"deb_mae": 1.872,
|
||||
"best_single_mae": 1.052,
|
||||
"median_mae": 1.952
|
||||
}
|
||||
},
|
||||
"generated_at": "2026-05-06T10:45:04.319587Z",
|
||||
"trained_at": "2026-05-06T10:45:04.319587Z"
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
{
|
||||
"version": "emos-auto-20260421122743",
|
||||
"trained_at": "2026-04-21T12:28:05.031165+00:00",
|
||||
"global": {
|
||||
"mu": {
|
||||
"intercept": 0.34330438,
|
||||
"raw_mu_coef": 0.48749629,
|
||||
"deb_coef": 0.45733479,
|
||||
"ens_median_coef": 0.05955297,
|
||||
"max_so_far_gap_coef": -0.104935
|
||||
},
|
||||
"sigma": {
|
||||
"intercept": -0.62765582,
|
||||
"raw_sigma_coef": 0.38492261,
|
||||
"spread_coef": 0.29095921,
|
||||
"peak_flag_coef": 0.33966327,
|
||||
"max_so_far_gap_coef": 0.08357376
|
||||
}
|
||||
},
|
||||
"sigma_constraints": {
|
||||
"min_ratio": 0.85,
|
||||
"max_ratio": 1.35,
|
||||
"absolute_min": 0.25,
|
||||
"absolute_max": 3.0
|
||||
},
|
||||
"selection_guardrails": {
|
||||
"max_mae_increase": 0.02,
|
||||
"max_bucket_hit_drop": 0.01,
|
||||
"max_bucket_brier_increase": 0.05
|
||||
},
|
||||
"blending": {
|
||||
"alpha_mu": 0.25,
|
||||
"alpha_sigma": 1.0
|
||||
},
|
||||
"cities": {
|
||||
"istanbul": {
|
||||
"samples": 20,
|
||||
"mu_bias": -0.438949,
|
||||
"sigma_scale": 0.553793,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"buenos aires": {
|
||||
"samples": 22,
|
||||
"mu_bias": -1.044971,
|
||||
"sigma_scale": 1.389753,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"ankara": {
|
||||
"samples": 23,
|
||||
"mu_bias": 0.067406,
|
||||
"sigma_scale": 1.270298,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"london": {
|
||||
"samples": 22,
|
||||
"mu_bias": -0.010833,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"paris": {
|
||||
"samples": 22,
|
||||
"mu_bias": -0.21677,
|
||||
"sigma_scale": 1.095669,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"new york": {
|
||||
"samples": 21,
|
||||
"mu_bias": 1.072189,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"lagos": {
|
||||
"samples": 10,
|
||||
"mu_bias": -0.092732,
|
||||
"sigma_scale": 1.558646,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"madrid": {
|
||||
"samples": 22,
|
||||
"mu_bias": -0.627862,
|
||||
"sigma_scale": 1.761239,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"toronto": {
|
||||
"samples": 22,
|
||||
"mu_bias": -0.424101,
|
||||
"sigma_scale": 1.326675,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"wellington": {
|
||||
"samples": 22,
|
||||
"mu_bias": -0.347796,
|
||||
"sigma_scale": 0.893459,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"los angeles": {
|
||||
"samples": 20,
|
||||
"mu_bias": -0.659983,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"san francisco": {
|
||||
"samples": 20,
|
||||
"mu_bias": 0.941818,
|
||||
"sigma_scale": 1.979403,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"aurora": {
|
||||
"samples": 20,
|
||||
"mu_bias": 2.040009,
|
||||
"sigma_scale": 0.982492,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"austin": {
|
||||
"samples": 20,
|
||||
"mu_bias": 0.990023,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"houston": {
|
||||
"samples": 20,
|
||||
"mu_bias": 0.188787,
|
||||
"sigma_scale": 1.819594,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"chicago": {
|
||||
"samples": 21,
|
||||
"mu_bias": 2.285098,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"dallas": {
|
||||
"samples": 21,
|
||||
"mu_bias": 0.258095,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"atlanta": {
|
||||
"samples": 22,
|
||||
"mu_bias": -2.093105,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"seattle": {
|
||||
"samples": 21,
|
||||
"mu_bias": -0.982188,
|
||||
"sigma_scale": 1.695172,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"sao paulo": {
|
||||
"samples": 22,
|
||||
"mu_bias": -0.786242,
|
||||
"sigma_scale": 1.427037,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"munich": {
|
||||
"samples": 22,
|
||||
"mu_bias": 0.096559,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"milan": {
|
||||
"samples": 23,
|
||||
"mu_bias": -0.045038,
|
||||
"sigma_scale": 1.626331,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"warsaw": {
|
||||
"samples": 23,
|
||||
"mu_bias": -0.50853,
|
||||
"sigma_scale": 1.239146,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"helsinki": {
|
||||
"samples": 17,
|
||||
"mu_bias": 0.442247,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"amsterdam": {
|
||||
"samples": 17,
|
||||
"mu_bias": 0.129889,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"panama city": {
|
||||
"samples": 17,
|
||||
"mu_bias": -0.040792,
|
||||
"sigma_scale": 1.393259,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"cape town": {
|
||||
"samples": 10,
|
||||
"mu_bias": 0.507293,
|
||||
"sigma_scale": 1.420004,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"miami": {
|
||||
"samples": 22,
|
||||
"mu_bias": -2.736316,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"manila": {
|
||||
"samples": 5,
|
||||
"mu_bias": 1.772229,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.625
|
||||
},
|
||||
"singapore": {
|
||||
"samples": 22,
|
||||
"mu_bias": 0.289639,
|
||||
"sigma_scale": 1.124006,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"tokyo": {
|
||||
"samples": 22,
|
||||
"mu_bias": -0.332129,
|
||||
"sigma_scale": 0.729483,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"seoul": {
|
||||
"samples": 22,
|
||||
"mu_bias": 1.6533,
|
||||
"sigma_scale": 0.82648,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"busan": {
|
||||
"samples": 16,
|
||||
"mu_bias": 0.343239,
|
||||
"sigma_scale": 1.886868,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"mexico city": {
|
||||
"samples": 20,
|
||||
"mu_bias": 0.087122,
|
||||
"sigma_scale": 1.108884,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"shanghai": {
|
||||
"samples": 23,
|
||||
"mu_bias": 0.348022,
|
||||
"sigma_scale": 0.810527,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"beijing": {
|
||||
"samples": 20,
|
||||
"mu_bias": -0.288176,
|
||||
"sigma_scale": 0.952972,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"shenzhen": {
|
||||
"samples": 21,
|
||||
"mu_bias": -0.006114,
|
||||
"sigma_scale": 0.728494,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"wuhan": {
|
||||
"samples": 20,
|
||||
"mu_bias": -0.771154,
|
||||
"sigma_scale": 1.30329,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"chengdu": {
|
||||
"samples": 20,
|
||||
"mu_bias": 0.445906,
|
||||
"sigma_scale": 0.796989,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"chongqing": {
|
||||
"samples": 20,
|
||||
"mu_bias": -0.021301,
|
||||
"sigma_scale": 1.106566,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"hong kong": {
|
||||
"samples": 24,
|
||||
"mu_bias": 0.37527,
|
||||
"sigma_scale": 0.517756,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"taipei": {
|
||||
"samples": 23,
|
||||
"mu_bias": 0.600671,
|
||||
"sigma_scale": 0.5,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"guangzhou": {
|
||||
"samples": 6,
|
||||
"mu_bias": 0.7082,
|
||||
"sigma_scale": 1.975103,
|
||||
"confidence": 0.75
|
||||
},
|
||||
"lau fau shan": {
|
||||
"samples": 19,
|
||||
"mu_bias": 0.41371,
|
||||
"sigma_scale": 0.656578,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"kuala lumpur": {
|
||||
"samples": 16,
|
||||
"mu_bias": 0.499197,
|
||||
"sigma_scale": 1.361171,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"jakarta": {
|
||||
"samples": 16,
|
||||
"mu_bias": 0.293537,
|
||||
"sigma_scale": 0.751498,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"lucknow": {
|
||||
"samples": 22,
|
||||
"mu_bias": -1.593437,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"karachi": {
|
||||
"samples": 5,
|
||||
"mu_bias": -0.035595,
|
||||
"sigma_scale": 0.588612,
|
||||
"confidence": 0.625
|
||||
},
|
||||
"jeddah": {
|
||||
"samples": 10,
|
||||
"mu_bias": 0.516879,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"moscow": {
|
||||
"samples": 20,
|
||||
"mu_bias": -0.609602,
|
||||
"sigma_scale": 0.783723,
|
||||
"confidence": 1.0
|
||||
},
|
||||
"tel aviv": {
|
||||
"samples": 22,
|
||||
"mu_bias": 0.436837,
|
||||
"sigma_scale": 1.46524,
|
||||
"confidence": 1.0
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"sample_count": 978,
|
||||
"mean_crps": 1.330733,
|
||||
"legacy_mean_crps": 1.376936,
|
||||
"legacy_mean_mae": 1.589438,
|
||||
"legacy_bucket_hit_rate": 0.43865,
|
||||
"legacy_bucket_brier": 0.824571,
|
||||
"selected_mean_crps": 1.311763,
|
||||
"selected_mean_mae": 1.553658,
|
||||
"selected_bucket_hit_rate": 0.432515,
|
||||
"selected_bucket_brier": 0.789817,
|
||||
"selected_score": 2.910718,
|
||||
"legacy_score": 2.996333,
|
||||
"filled_actual_from_history": 0,
|
||||
"settlement_history_city_count": 30,
|
||||
"legacy_archive_samples": 54
|
||||
},
|
||||
"source": "artifacts\\local_runtime\\probability_calibration\\candidates\\emos-auto-20260421122743\\default.json"
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
{
|
||||
"summary": {
|
||||
"sample_count": 74,
|
||||
"filled_actual_from_history": 0,
|
||||
"legacy": {
|
||||
"mean_crps": 3.474108,
|
||||
"mean_mae": 3.679324,
|
||||
"bucket_hit_rate": 0.5
|
||||
},
|
||||
"emos": {
|
||||
"mean_crps": 3.33124,
|
||||
"mean_mae": 3.622584,
|
||||
"bucket_hit_rate": 0.5
|
||||
},
|
||||
"delta": {
|
||||
"crps": -0.142868,
|
||||
"mae": -0.056741,
|
||||
"bucket_hit_rate": 0.0
|
||||
}
|
||||
},
|
||||
"by_city": {
|
||||
"ankara": {
|
||||
"samples": 6,
|
||||
"legacy_mean_crps": 1.365135,
|
||||
"emos_mean_crps": 1.392829,
|
||||
"legacy_mean_mae": 1.466667,
|
||||
"emos_mean_mae": 1.444819,
|
||||
"legacy_bucket_hit_rate": 0.666667,
|
||||
"emos_bucket_hit_rate": 0.666667
|
||||
},
|
||||
"atlanta": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 30.449382,
|
||||
"emos_mean_crps": 29.886236,
|
||||
"legacy_mean_mae": 32.015,
|
||||
"emos_mean_mae": 31.322804,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"buenos aires": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 9.113412,
|
||||
"emos_mean_crps": 8.463522,
|
||||
"legacy_mean_mae": 10.27,
|
||||
"emos_mean_mae": 10.024533,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"busan": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 0.276202,
|
||||
"emos_mean_crps": 0.326485,
|
||||
"legacy_mean_mae": 0.3,
|
||||
"emos_mean_mae": 0.239636,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"chengdu": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 0.306469,
|
||||
"emos_mean_crps": 0.378812,
|
||||
"legacy_mean_mae": 0.3,
|
||||
"emos_mean_mae": 0.153224,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"chicago": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 1.250268,
|
||||
"emos_mean_crps": 0.714669,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"emos_mean_mae": 0.319756,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"dallas": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 2.173363,
|
||||
"emos_mean_crps": 0.840297,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"emos_mean_mae": 1.028146,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"hong kong": {
|
||||
"samples": 7,
|
||||
"legacy_mean_crps": 0.706099,
|
||||
"emos_mean_crps": 0.731926,
|
||||
"legacy_mean_mae": 0.657143,
|
||||
"emos_mean_mae": 0.669911,
|
||||
"legacy_bucket_hit_rate": 0.714286,
|
||||
"emos_bucket_hit_rate": 0.571429
|
||||
},
|
||||
"istanbul": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 1.264103,
|
||||
"emos_mean_crps": 1.214967,
|
||||
"legacy_mean_mae": 1.5,
|
||||
"emos_mean_mae": 1.469837,
|
||||
"legacy_bucket_hit_rate": 0.333333,
|
||||
"emos_bucket_hit_rate": 0.666667
|
||||
},
|
||||
"london": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 3.885033,
|
||||
"emos_mean_crps": 3.799841,
|
||||
"legacy_mean_mae": 4.135,
|
||||
"emos_mean_mae": 4.027503,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.5
|
||||
},
|
||||
"lucknow": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 2.487193,
|
||||
"emos_mean_crps": 2.107441,
|
||||
"legacy_mean_mae": 3.205,
|
||||
"emos_mean_mae": 2.876943,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"madrid": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 6.27726,
|
||||
"emos_mean_crps": 5.810228,
|
||||
"legacy_mean_mae": 7.33,
|
||||
"emos_mean_mae": 7.195133,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"miami": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 28.637631,
|
||||
"emos_mean_crps": 27.592664,
|
||||
"legacy_mean_mae": 30.175,
|
||||
"emos_mean_mae": 29.284979,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"milan": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 4.401392,
|
||||
"emos_mean_crps": 3.782613,
|
||||
"legacy_mean_mae": 4.06,
|
||||
"emos_mean_mae": 3.989808,
|
||||
"legacy_bucket_hit_rate": 0.666667,
|
||||
"emos_bucket_hit_rate": 0.666667
|
||||
},
|
||||
"munich": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 3.145192,
|
||||
"emos_mean_crps": 2.990712,
|
||||
"legacy_mean_mae": 3.64,
|
||||
"emos_mean_mae": 3.61502,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"new york": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 3.692845,
|
||||
"emos_mean_crps": 3.021798,
|
||||
"legacy_mean_mae": 4.94,
|
||||
"emos_mean_mae": 4.54437,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"paris": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 4.013782,
|
||||
"emos_mean_crps": 3.915303,
|
||||
"legacy_mean_mae": 4.265,
|
||||
"emos_mean_mae": 4.179694,
|
||||
"legacy_bucket_hit_rate": 0.5,
|
||||
"emos_bucket_hit_rate": 0.5
|
||||
},
|
||||
"sao paulo": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 5.540967,
|
||||
"emos_mean_crps": 5.018605,
|
||||
"legacy_mean_mae": 6.57,
|
||||
"emos_mean_mae": 6.314389,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"seattle": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 0.315488,
|
||||
"emos_mean_crps": 0.524119,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"emos_mean_mae": 0.673609,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"seoul": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 0.508331,
|
||||
"emos_mean_crps": 0.520762,
|
||||
"legacy_mean_mae": 0.2,
|
||||
"emos_mean_mae": 0.247862,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"shanghai": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 0.299116,
|
||||
"emos_mean_crps": 0.402453,
|
||||
"legacy_mean_mae": 0.1,
|
||||
"emos_mean_mae": 0.189437,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"shenzhen": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 1.198351,
|
||||
"emos_mean_crps": 1.490963,
|
||||
"legacy_mean_mae": 1.3,
|
||||
"emos_mean_mae": 1.628189,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"singapore": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.281993,
|
||||
"emos_mean_crps": 0.370374,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"emos_mean_mae": 0.118005,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"taipei": {
|
||||
"samples": 5,
|
||||
"legacy_mean_crps": 0.950739,
|
||||
"emos_mean_crps": 1.042332,
|
||||
"legacy_mean_mae": 0.94,
|
||||
"emos_mean_mae": 0.951241,
|
||||
"legacy_bucket_hit_rate": 0.6,
|
||||
"emos_bucket_hit_rate": 0.6
|
||||
},
|
||||
"tel aviv": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.446758,
|
||||
"emos_mean_crps": 0.578691,
|
||||
"legacy_mean_mae": 0.3,
|
||||
"emos_mean_mae": 0.116966,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"tokyo": {
|
||||
"samples": 5,
|
||||
"legacy_mean_crps": 0.879366,
|
||||
"emos_mean_crps": 0.876608,
|
||||
"legacy_mean_mae": 1.022,
|
||||
"emos_mean_mae": 1.008317,
|
||||
"legacy_bucket_hit_rate": 0.2,
|
||||
"emos_bucket_hit_rate": 0.4
|
||||
},
|
||||
"toronto": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 5.497916,
|
||||
"emos_mean_crps": 5.268783,
|
||||
"legacy_mean_mae": 6.33,
|
||||
"emos_mean_mae": 6.388522,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"warsaw": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 1.618875,
|
||||
"emos_mean_crps": 1.524219,
|
||||
"legacy_mean_mae": 2.056667,
|
||||
"emos_mean_mae": 2.006524,
|
||||
"legacy_bucket_hit_rate": 0.333333,
|
||||
"emos_bucket_hit_rate": 0.333333
|
||||
},
|
||||
"wellington": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.364919,
|
||||
"emos_mean_crps": 0.484124,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"emos_mean_mae": 0.123335,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"wuhan": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 0.476225,
|
||||
"emos_mean_crps": 0.578405,
|
||||
"legacy_mean_mae": 0.8,
|
||||
"emos_mean_mae": 0.962852,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
{
|
||||
"evaluation_report_path": "E:\\web\\PolyWeather\\artifacts\\probability_calibration\\evaluation_report.json",
|
||||
"shadow_report_path": "E:\\web\\PolyWeather\\artifacts\\probability_calibration\\shadow_report.json",
|
||||
"evaluation_report_exists": true,
|
||||
"shadow_report_exists": true,
|
||||
"decision": {
|
||||
"decision": "hold",
|
||||
"ready_for_primary": false,
|
||||
"summary": "当前指标不足以切换 emos_primary,应继续保持 shadow。",
|
||||
"thresholds": {
|
||||
"evaluation_min_samples": 80,
|
||||
"shadow_min_samples": 50,
|
||||
"max_delta_mae": 0.05,
|
||||
"min_delta_crps": -0.02,
|
||||
"min_delta_bucket_hit_rate": 0.0,
|
||||
"max_delta_bucket_brier_promote": 0.02,
|
||||
"max_delta_bucket_brier_observe": 0.15
|
||||
},
|
||||
"evaluation": {
|
||||
"sample_count": 54,
|
||||
"delta_crps": -0.086732,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0
|
||||
},
|
||||
"shadow": {
|
||||
"sample_count": 48,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.041666,
|
||||
"delta_bucket_brier": 0.123252
|
||||
},
|
||||
"blocking_reasons": [
|
||||
"离线评估样本不足:54 < 80",
|
||||
"shadow 样本不足:48 < 50",
|
||||
"shadow bucket brier 退化超限:delta=0.123252"
|
||||
],
|
||||
"worst_shadow_regressions": [
|
||||
{
|
||||
"city": "dallas",
|
||||
"samples": 1,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.792585
|
||||
},
|
||||
{
|
||||
"city": "chicago",
|
||||
"samples": 1,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.791878
|
||||
},
|
||||
{
|
||||
"city": "seattle",
|
||||
"samples": 1,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.61609
|
||||
},
|
||||
{
|
||||
"city": "wellington",
|
||||
"samples": 2,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.509203
|
||||
},
|
||||
{
|
||||
"city": "tel aviv",
|
||||
"samples": 2,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.439879
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,933 +0,0 @@
|
||||
{
|
||||
"generated_at": "2026-04-02T16:23:24.376528Z",
|
||||
"summary": {
|
||||
"samples": 48,
|
||||
"legacy_mean_mae": 3.04125,
|
||||
"shadow_mean_mae": 3.04125,
|
||||
"legacy_bucket_hit_rate": 0.5,
|
||||
"shadow_bucket_hit_rate": 0.5,
|
||||
"legacy_bucket_brier": 0.68666,
|
||||
"shadow_bucket_brier": 0.814079,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.127419
|
||||
},
|
||||
"by_city": {
|
||||
"ankara": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.1,
|
||||
"shadow_mean_mae": 0.1,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.494847,
|
||||
"shadow_bucket_brier": 0.654648,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.159801
|
||||
},
|
||||
"atlanta": {
|
||||
"samples": 1,
|
||||
"legacy_mean_mae": 17.06,
|
||||
"shadow_mean_mae": 17.06,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.029097,
|
||||
"shadow_bucket_brier": 1.064101,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.035004
|
||||
},
|
||||
"buenos aires": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 10.27,
|
||||
"shadow_mean_mae": 10.27,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.117726,
|
||||
"shadow_bucket_brier": 1.116732,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.000994
|
||||
},
|
||||
"chicago": {
|
||||
"samples": 1,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"shadow_mean_mae": 0.0,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.0,
|
||||
"shadow_bucket_brier": 0.791878,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.791878
|
||||
},
|
||||
"dallas": {
|
||||
"samples": 1,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"shadow_mean_mae": 0.0,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.0,
|
||||
"shadow_bucket_brier": 0.792585,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.792585
|
||||
},
|
||||
"hong kong": {
|
||||
"samples": 3,
|
||||
"legacy_mean_mae": 0.1,
|
||||
"shadow_mean_mae": 0.1,
|
||||
"legacy_bucket_hit_rate": 0.333333,
|
||||
"shadow_bucket_hit_rate": 0.666667,
|
||||
"legacy_bucket_brier": 0.882203,
|
||||
"shadow_bucket_brier": 0.437187,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.333334,
|
||||
"delta_bucket_brier": -0.445016
|
||||
},
|
||||
"london": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 4.135,
|
||||
"shadow_mean_mae": 4.135,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 0.929652,
|
||||
"shadow_bucket_brier": 1.039551,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.109899
|
||||
},
|
||||
"lucknow": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 3.205,
|
||||
"shadow_mean_mae": 3.205,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.673607,
|
||||
"shadow_bucket_brier": 0.96272,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.710887
|
||||
},
|
||||
"madrid": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 7.33,
|
||||
"shadow_mean_mae": 7.33,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.148213,
|
||||
"shadow_bucket_brier": 1.133888,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.014325
|
||||
},
|
||||
"miami": {
|
||||
"samples": 1,
|
||||
"legacy_mean_mae": 11.04,
|
||||
"shadow_mean_mae": 11.04,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.182605,
|
||||
"shadow_bucket_brier": 1.067257,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.115348
|
||||
},
|
||||
"milan": {
|
||||
"samples": 3,
|
||||
"legacy_mean_mae": 4.06,
|
||||
"shadow_mean_mae": 4.06,
|
||||
"legacy_bucket_hit_rate": 0.666667,
|
||||
"shadow_bucket_hit_rate": 0.666667,
|
||||
"legacy_bucket_brier": 0.587548,
|
||||
"shadow_bucket_brier": 0.78616,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.198612
|
||||
},
|
||||
"munich": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 3.64,
|
||||
"shadow_mean_mae": 3.64,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 0.918378,
|
||||
"shadow_bucket_brier": 0.945234,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.026856
|
||||
},
|
||||
"new york": {
|
||||
"samples": 1,
|
||||
"legacy_mean_mae": 4.94,
|
||||
"shadow_mean_mae": 4.94,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.111711,
|
||||
"shadow_bucket_brier": 1.099556,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.012155
|
||||
},
|
||||
"paris": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 4.265,
|
||||
"shadow_mean_mae": 4.265,
|
||||
"legacy_bucket_hit_rate": 0.5,
|
||||
"shadow_bucket_hit_rate": 0.5,
|
||||
"legacy_bucket_brier": 0.90186,
|
||||
"shadow_bucket_brier": 0.9967,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.09484
|
||||
},
|
||||
"sao paulo": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 6.57,
|
||||
"shadow_mean_mae": 6.57,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.263988,
|
||||
"shadow_bucket_brier": 1.159352,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.104636
|
||||
},
|
||||
"seattle": {
|
||||
"samples": 1,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"shadow_mean_mae": 0.0,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.0,
|
||||
"shadow_bucket_brier": 0.61609,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.61609
|
||||
},
|
||||
"seoul": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"shadow_mean_mae": 0.15,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.258977,
|
||||
"shadow_bucket_brier": 0.548485,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.289508
|
||||
},
|
||||
"shanghai": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"shadow_mean_mae": 0.15,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.1156,
|
||||
"shadow_bucket_brier": 0.501323,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.385723
|
||||
},
|
||||
"singapore": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"shadow_mean_mae": 0.15,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.306527,
|
||||
"shadow_bucket_brier": 0.553647,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.24712
|
||||
},
|
||||
"taipei": {
|
||||
"samples": 3,
|
||||
"legacy_mean_mae": 0.1,
|
||||
"shadow_mean_mae": 0.1,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 0.333333,
|
||||
"legacy_bucket_brier": 0.195261,
|
||||
"shadow_bucket_brier": 0.684998,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": -0.666667,
|
||||
"delta_bucket_brier": 0.489737
|
||||
},
|
||||
"tel aviv": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.3,
|
||||
"shadow_mean_mae": 0.3,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.257895,
|
||||
"shadow_bucket_brier": 0.697774,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.439879
|
||||
},
|
||||
"tokyo": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.25,
|
||||
"shadow_mean_mae": 0.25,
|
||||
"legacy_bucket_hit_rate": 0.5,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.284759,
|
||||
"shadow_bucket_brier": 0.69701,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.5,
|
||||
"delta_bucket_brier": 0.412251
|
||||
},
|
||||
"toronto": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 6.33,
|
||||
"shadow_mean_mae": 6.33,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"shadow_bucket_hit_rate": 0.0,
|
||||
"legacy_bucket_brier": 1.256736,
|
||||
"shadow_bucket_brier": 1.221422,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.035314
|
||||
},
|
||||
"warsaw": {
|
||||
"samples": 3,
|
||||
"legacy_mean_mae": 2.056667,
|
||||
"shadow_mean_mae": 2.056667,
|
||||
"legacy_bucket_hit_rate": 0.333333,
|
||||
"shadow_bucket_hit_rate": 0.333333,
|
||||
"legacy_bucket_brier": 0.86425,
|
||||
"shadow_bucket_brier": 0.750985,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": -0.113265
|
||||
},
|
||||
"wellington": {
|
||||
"samples": 2,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"shadow_mean_mae": 0.15,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 1.0,
|
||||
"legacy_bucket_brier": 0.095481,
|
||||
"shadow_bucket_brier": 0.604684,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.0,
|
||||
"delta_bucket_brier": 0.509203
|
||||
}
|
||||
},
|
||||
"by_date": {
|
||||
"2026-03-17": {
|
||||
"samples": 4,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"shadow_mean_mae": 0.15,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"shadow_bucket_hit_rate": 0.5,
|
||||
"legacy_bucket_brier": 0.101543,
|
||||
"shadow_bucket_brier": 0.6636,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": -0.5,
|
||||
"delta_bucket_brier": 0.562057
|
||||
},
|
||||
"2026-03-18": {
|
||||
"samples": 25,
|
||||
"legacy_mean_mae": 2.5476,
|
||||
"shadow_mean_mae": 2.5476,
|
||||
"legacy_bucket_hit_rate": 0.52,
|
||||
"shadow_bucket_hit_rate": 0.56,
|
||||
"legacy_bucket_brier": 0.657516,
|
||||
"shadow_bucket_brier": 0.74574,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.04,
|
||||
"delta_bucket_brier": 0.088224
|
||||
},
|
||||
"2026-03-19": {
|
||||
"samples": 19,
|
||||
"legacy_mean_mae": 4.299474,
|
||||
"shadow_mean_mae": 4.299474,
|
||||
"legacy_bucket_hit_rate": 0.368421,
|
||||
"shadow_bucket_hit_rate": 0.421053,
|
||||
"legacy_bucket_brier": 0.848191,
|
||||
"shadow_bucket_brier": 0.935678,
|
||||
"delta_mae": 0.0,
|
||||
"delta_bucket_hit_rate": 0.052632,
|
||||
"delta_bucket_brier": 0.087487
|
||||
}
|
||||
},
|
||||
"recent_observations": [
|
||||
{
|
||||
"city": "wellington",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 18.0,
|
||||
"actual_bucket": 18,
|
||||
"legacy_mu": 18.3,
|
||||
"shadow_mu": 18.3,
|
||||
"legacy_top_bucket": 18,
|
||||
"shadow_top_bucket": 18,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "warsaw",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 7.0,
|
||||
"actual_bucket": 7,
|
||||
"legacy_mu": 12.03,
|
||||
"shadow_mu": 12.03,
|
||||
"legacy_top_bucket": 12,
|
||||
"shadow_top_bucket": 12,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "toronto",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": -2.0,
|
||||
"actual_bucket": -2,
|
||||
"legacy_mu": 5.67,
|
||||
"shadow_mu": 5.67,
|
||||
"legacy_top_bucket": 6,
|
||||
"shadow_top_bucket": 6,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "tokyo",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 16.0,
|
||||
"actual_bucket": 16,
|
||||
"legacy_mu": 16.5,
|
||||
"shadow_mu": 16.5,
|
||||
"legacy_top_bucket": 17,
|
||||
"shadow_top_bucket": 16,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "tel aviv",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 21.0,
|
||||
"actual_bucket": 21,
|
||||
"legacy_mu": 21.3,
|
||||
"shadow_mu": 21.3,
|
||||
"legacy_top_bucket": 21,
|
||||
"shadow_top_bucket": 21,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "taipei",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 22.0,
|
||||
"actual_bucket": 22,
|
||||
"legacy_mu": 21.7,
|
||||
"shadow_mu": 21.7,
|
||||
"legacy_top_bucket": 22,
|
||||
"shadow_top_bucket": 21,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "singapore",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 32.0,
|
||||
"actual_bucket": 32,
|
||||
"legacy_mu": 32.3,
|
||||
"shadow_mu": 32.3,
|
||||
"legacy_top_bucket": 32,
|
||||
"shadow_top_bucket": 32,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "shanghai",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 12.0,
|
||||
"actual_bucket": 12,
|
||||
"legacy_mu": 12.3,
|
||||
"shadow_mu": 12.3,
|
||||
"legacy_top_bucket": 12,
|
||||
"shadow_top_bucket": 12,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "seoul",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 10.0,
|
||||
"actual_bucket": 10,
|
||||
"legacy_mu": 10.3,
|
||||
"shadow_mu": 10.3,
|
||||
"legacy_top_bucket": 10,
|
||||
"shadow_top_bucket": 10,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "sao paulo",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 21.0,
|
||||
"actual_bucket": 21,
|
||||
"legacy_mu": 26.5,
|
||||
"shadow_mu": 26.5,
|
||||
"legacy_top_bucket": 26,
|
||||
"shadow_top_bucket": 26,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "paris",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 8.0,
|
||||
"actual_bucket": 8,
|
||||
"legacy_mu": 16.06,
|
||||
"shadow_mu": 16.06,
|
||||
"legacy_top_bucket": 16,
|
||||
"shadow_top_bucket": 16,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "munich",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 5.0,
|
||||
"actual_bucket": 5,
|
||||
"legacy_mu": 11.62,
|
||||
"shadow_mu": 11.62,
|
||||
"legacy_top_bucket": 12,
|
||||
"shadow_top_bucket": 12,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "milan",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 5.0,
|
||||
"actual_bucket": 5,
|
||||
"legacy_mu": 16.58,
|
||||
"shadow_mu": 16.58,
|
||||
"legacy_top_bucket": 17,
|
||||
"shadow_top_bucket": 17,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "madrid",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 8.0,
|
||||
"actual_bucket": 8,
|
||||
"legacy_mu": 18.23,
|
||||
"shadow_mu": 18.23,
|
||||
"legacy_top_bucket": 18,
|
||||
"shadow_top_bucket": 18,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "lucknow",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 30.0,
|
||||
"actual_bucket": 30,
|
||||
"legacy_mu": 35.3,
|
||||
"shadow_mu": 35.3,
|
||||
"legacy_top_bucket": 35,
|
||||
"shadow_top_bucket": 35,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "london",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 8.0,
|
||||
"actual_bucket": 8,
|
||||
"legacy_mu": 15.71,
|
||||
"shadow_mu": 15.71,
|
||||
"legacy_top_bucket": 16,
|
||||
"shadow_top_bucket": 16,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 27.3,
|
||||
"actual_bucket": 27,
|
||||
"legacy_mu": 27.6,
|
||||
"shadow_mu": 27.6,
|
||||
"legacy_top_bucket": 28,
|
||||
"shadow_top_bucket": 27,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "buenos aires",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 16.0,
|
||||
"actual_bucket": 16,
|
||||
"legacy_mu": 27.39,
|
||||
"shadow_mu": 27.39,
|
||||
"legacy_top_bucket": 27,
|
||||
"shadow_top_bucket": 27,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "ankara",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 11.0,
|
||||
"actual_bucket": 11,
|
||||
"legacy_mu": 11.0,
|
||||
"shadow_mu": 11.0,
|
||||
"legacy_top_bucket": 11,
|
||||
"shadow_top_bucket": 11,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "wellington",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 21.0,
|
||||
"actual_bucket": 21,
|
||||
"legacy_mu": 21.0,
|
||||
"shadow_mu": 21.0,
|
||||
"legacy_top_bucket": 21,
|
||||
"shadow_top_bucket": 21,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "warsaw",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 13.0,
|
||||
"actual_bucket": 13,
|
||||
"legacy_mu": 13.84,
|
||||
"shadow_mu": 13.84,
|
||||
"legacy_top_bucket": 14,
|
||||
"shadow_top_bucket": 14,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "toronto",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": -6.0,
|
||||
"actual_bucket": -6,
|
||||
"legacy_mu": -1.01,
|
||||
"shadow_mu": -1.01,
|
||||
"legacy_top_bucket": -1,
|
||||
"shadow_top_bucket": -1,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "tokyo",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 17.0,
|
||||
"actual_bucket": 17,
|
||||
"legacy_mu": 17.0,
|
||||
"shadow_mu": 17.0,
|
||||
"legacy_top_bucket": 17,
|
||||
"shadow_top_bucket": 17,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "tel aviv",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 30.0,
|
||||
"actual_bucket": 30,
|
||||
"legacy_mu": 30.3,
|
||||
"shadow_mu": 30.3,
|
||||
"legacy_top_bucket": 30,
|
||||
"shadow_top_bucket": 30,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "taipei",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 29.0,
|
||||
"actual_bucket": 29,
|
||||
"legacy_mu": 29.0,
|
||||
"shadow_mu": 29.0,
|
||||
"legacy_top_bucket": 29,
|
||||
"shadow_top_bucket": 29,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "singapore",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 32.0,
|
||||
"actual_bucket": 32,
|
||||
"legacy_mu": 32.0,
|
||||
"shadow_mu": 32.0,
|
||||
"legacy_top_bucket": 32,
|
||||
"shadow_top_bucket": 32,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "shanghai",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 13.0,
|
||||
"actual_bucket": 13,
|
||||
"legacy_mu": 13.0,
|
||||
"shadow_mu": 13.0,
|
||||
"legacy_top_bucket": 13,
|
||||
"shadow_top_bucket": 13,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "seoul",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 8.0,
|
||||
"actual_bucket": 8,
|
||||
"legacy_mu": 8.0,
|
||||
"shadow_mu": 8.0,
|
||||
"legacy_top_bucket": 8,
|
||||
"shadow_top_bucket": 8,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "seattle",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 55.9,
|
||||
"actual_bucket": 56,
|
||||
"legacy_mu": 55.9,
|
||||
"shadow_mu": 55.9,
|
||||
"legacy_top_bucket": 56,
|
||||
"shadow_top_bucket": 56,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "sao paulo",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 22.0,
|
||||
"actual_bucket": 22,
|
||||
"legacy_mu": 29.64,
|
||||
"shadow_mu": 29.64,
|
||||
"legacy_top_bucket": 30,
|
||||
"shadow_top_bucket": 30,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "paris",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 14.0,
|
||||
"actual_bucket": 14,
|
||||
"legacy_mu": 14.47,
|
||||
"shadow_mu": 14.47,
|
||||
"legacy_top_bucket": 14,
|
||||
"shadow_top_bucket": 14,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "new york",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 32.0,
|
||||
"actual_bucket": 32,
|
||||
"legacy_mu": 36.94,
|
||||
"shadow_mu": 36.94,
|
||||
"legacy_top_bucket": 37,
|
||||
"shadow_top_bucket": 37,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "munich",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 10.0,
|
||||
"actual_bucket": 10,
|
||||
"legacy_mu": 10.66,
|
||||
"shadow_mu": 10.66,
|
||||
"legacy_top_bucket": 11,
|
||||
"shadow_top_bucket": 11,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "milan",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 14.0,
|
||||
"actual_bucket": 14,
|
||||
"legacy_mu": 14.3,
|
||||
"shadow_mu": 14.3,
|
||||
"legacy_top_bucket": 14,
|
||||
"shadow_top_bucket": 14,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "miami",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 59.0,
|
||||
"actual_bucket": 59,
|
||||
"legacy_mu": 70.04,
|
||||
"shadow_mu": 70.04,
|
||||
"legacy_top_bucket": 70,
|
||||
"shadow_top_bucket": 70,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "madrid",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 14.0,
|
||||
"actual_bucket": 14,
|
||||
"legacy_mu": 18.43,
|
||||
"shadow_mu": 18.43,
|
||||
"legacy_top_bucket": 18,
|
||||
"shadow_top_bucket": 18,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "lucknow",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 34.0,
|
||||
"actual_bucket": 34,
|
||||
"legacy_mu": 35.11,
|
||||
"shadow_mu": 35.11,
|
||||
"legacy_top_bucket": 35,
|
||||
"shadow_top_bucket": 35,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "london",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 16.0,
|
||||
"actual_bucket": 16,
|
||||
"legacy_mu": 16.56,
|
||||
"shadow_mu": 16.56,
|
||||
"legacy_top_bucket": 17,
|
||||
"shadow_top_bucket": 17,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 27.8,
|
||||
"actual_bucket": 27,
|
||||
"legacy_mu": 27.8,
|
||||
"shadow_mu": 27.8,
|
||||
"legacy_top_bucket": 28,
|
||||
"shadow_top_bucket": 27,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "dallas",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 73.9,
|
||||
"actual_bucket": 74,
|
||||
"legacy_mu": 73.9,
|
||||
"shadow_mu": 73.9,
|
||||
"legacy_top_bucket": 74,
|
||||
"shadow_top_bucket": 74,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "chicago",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 46.0,
|
||||
"actual_bucket": 46,
|
||||
"legacy_mu": 46.0,
|
||||
"shadow_mu": 46.0,
|
||||
"legacy_top_bucket": 46,
|
||||
"shadow_top_bucket": 46,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "buenos aires",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 17.0,
|
||||
"actual_bucket": 17,
|
||||
"legacy_mu": 26.15,
|
||||
"shadow_mu": 26.15,
|
||||
"legacy_top_bucket": 26,
|
||||
"shadow_top_bucket": 26,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "atlanta",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 37.0,
|
||||
"actual_bucket": 37,
|
||||
"legacy_mu": 54.06,
|
||||
"shadow_mu": 54.06,
|
||||
"legacy_top_bucket": 54,
|
||||
"shadow_top_bucket": 54,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "ankara",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 15.0,
|
||||
"actual_bucket": 15,
|
||||
"legacy_mu": 15.2,
|
||||
"shadow_mu": 15.2,
|
||||
"legacy_top_bucket": 15,
|
||||
"shadow_top_bucket": 15,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "warsaw",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 11.0,
|
||||
"actual_bucket": 11,
|
||||
"legacy_mu": 11.3,
|
||||
"shadow_mu": 11.3,
|
||||
"legacy_top_bucket": 11,
|
||||
"shadow_top_bucket": 11,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "taipei",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 26.7,
|
||||
"actual_bucket": 27,
|
||||
"legacy_mu": 26.7,
|
||||
"shadow_mu": 26.7,
|
||||
"legacy_top_bucket": 27,
|
||||
"shadow_top_bucket": 26,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "milan",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 17.0,
|
||||
"actual_bucket": 17,
|
||||
"legacy_mu": 17.3,
|
||||
"shadow_mu": 17.3,
|
||||
"legacy_top_bucket": 17,
|
||||
"shadow_top_bucket": 17,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 24.0,
|
||||
"actual_bucket": 24,
|
||||
"legacy_mu": 24.0,
|
||||
"shadow_mu": 24.0,
|
||||
"legacy_top_bucket": 24,
|
||||
"shadow_top_bucket": 23,
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibration_mode": "emos_shadow"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,981 +0,0 @@
|
||||
{
|
||||
"sample_count": 54,
|
||||
"snapshot_sample_count": 1,
|
||||
"daily_record_sample_count": 53,
|
||||
"filled_actual_from_history": 2,
|
||||
"samples": [
|
||||
{
|
||||
"city": "shenzhen",
|
||||
"date": "2026-03-25",
|
||||
"timestamp": "2026-03-25T08:57:11.783182+00:00",
|
||||
"actual_high": 28.0,
|
||||
"raw_mu": 26.7,
|
||||
"raw_sigma": 0.18016764322916676,
|
||||
"deb_prediction": 28.1,
|
||||
"ens_median": 31.4,
|
||||
"ensemble_spread": 0.5078125000000002,
|
||||
"max_so_far_gap": 1.4000000000000021,
|
||||
"peak_flag": 1.0,
|
||||
"sample_source": "snapshot",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "ankara",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 15.0,
|
||||
"raw_mu": 15.2,
|
||||
"raw_sigma": 1.2000000000000002,
|
||||
"deb_prediction": 15.4,
|
||||
"ens_median": 15.8,
|
||||
"ensemble_spread": 1.2000000000000002,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "ankara",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 11.0,
|
||||
"raw_mu": 11.0,
|
||||
"raw_sigma": 2.05,
|
||||
"deb_prediction": 9.8,
|
||||
"ens_median": 10.1,
|
||||
"ensemble_spread": 2.05,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "ankara",
|
||||
"date": "2026-03-29",
|
||||
"actual_high": 10.0,
|
||||
"raw_mu": 10.0,
|
||||
"raw_sigma": 0.9000000000000004,
|
||||
"deb_prediction": 9.7,
|
||||
"ens_median": 10.0,
|
||||
"ensemble_spread": 0.9000000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "atlanta",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 37.0,
|
||||
"raw_mu": 54.06,
|
||||
"raw_sigma": 4.0,
|
||||
"deb_prediction": 52.5,
|
||||
"ens_median": 52.2,
|
||||
"ensemble_spread": 4.0,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "atlanta",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 18.6,
|
||||
"raw_mu": 65.57,
|
||||
"raw_sigma": 1.5500000000000007,
|
||||
"deb_prediction": 65.7,
|
||||
"ens_median": 65.9,
|
||||
"ensemble_spread": 1.5500000000000007,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "buenos aires",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 17.0,
|
||||
"raw_mu": 26.15,
|
||||
"raw_sigma": 2.0,
|
||||
"deb_prediction": 26.4,
|
||||
"ens_median": 26.0,
|
||||
"ensemble_spread": 2.0,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "buenos aires",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 16.0,
|
||||
"raw_mu": 27.39,
|
||||
"raw_sigma": 2.0999999999999996,
|
||||
"deb_prediction": 26.8,
|
||||
"ens_median": 27.9,
|
||||
"ensemble_spread": 2.0999999999999996,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "chicago",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 46.0,
|
||||
"raw_mu": 46.0,
|
||||
"raw_sigma": 5.350000000000001,
|
||||
"deb_prediction": 42.7,
|
||||
"ens_median": 44.2,
|
||||
"ensemble_spread": 5.350000000000001,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "dallas",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 73.9,
|
||||
"raw_mu": 73.9,
|
||||
"raw_sigma": 9.299999999999997,
|
||||
"deb_prediction": 75.2,
|
||||
"ens_median": 74.4,
|
||||
"ensemble_spread": 9.299999999999997,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 24.0,
|
||||
"raw_mu": 24.0,
|
||||
"raw_sigma": 1.9000000000000004,
|
||||
"deb_prediction": 23.1,
|
||||
"ens_median": 23.1,
|
||||
"ensemble_spread": 1.9000000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 27.8,
|
||||
"raw_mu": 27.8,
|
||||
"raw_sigma": 0.6,
|
||||
"deb_prediction": 24.5,
|
||||
"ens_median": 24.9,
|
||||
"ensemble_spread": 0.6,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 27.3,
|
||||
"raw_mu": 27.6,
|
||||
"raw_sigma": 0.6,
|
||||
"deb_prediction": 24.9,
|
||||
"ens_median": 25.0,
|
||||
"ensemble_spread": 0.6,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "hong kong",
|
||||
"date": "2026-03-23",
|
||||
"actual_high": 27.4,
|
||||
"raw_mu": 27.4,
|
||||
"raw_sigma": 1.6999999999999993,
|
||||
"deb_prediction": 25.2,
|
||||
"ens_median": 25.1,
|
||||
"ensemble_spread": 1.6999999999999993,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "london",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 16.0,
|
||||
"raw_mu": 16.56,
|
||||
"raw_sigma": 1.299999999999999,
|
||||
"deb_prediction": 16.5,
|
||||
"ens_median": 16.8,
|
||||
"ensemble_spread": 1.299999999999999,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "london",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 8.0,
|
||||
"raw_mu": 15.71,
|
||||
"raw_sigma": 0.6,
|
||||
"deb_prediction": 15.9,
|
||||
"ens_median": 15.8,
|
||||
"ensemble_spread": 0.6,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "lucknow",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 34.0,
|
||||
"raw_mu": 35.11,
|
||||
"raw_sigma": 1.3000000000000007,
|
||||
"deb_prediction": 34.3,
|
||||
"ens_median": 34.6,
|
||||
"ensemble_spread": 1.3000000000000007,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "lucknow",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 30.0,
|
||||
"raw_mu": 35.3,
|
||||
"raw_sigma": 1.75,
|
||||
"deb_prediction": 34.5,
|
||||
"ens_median": 34.7,
|
||||
"ensemble_spread": 1.75,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "madrid",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 14.0,
|
||||
"raw_mu": 18.43,
|
||||
"raw_sigma": 1.8499999999999996,
|
||||
"deb_prediction": 18.3,
|
||||
"ens_median": 18.7,
|
||||
"ensemble_spread": 1.8499999999999996,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "madrid",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 8.0,
|
||||
"raw_mu": 18.23,
|
||||
"raw_sigma": 1.8999999999999995,
|
||||
"deb_prediction": 18.4,
|
||||
"ens_median": 18.8,
|
||||
"ensemble_spread": 1.8999999999999995,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "miami",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 59.0,
|
||||
"raw_mu": 70.04,
|
||||
"raw_sigma": 2.8999999999999986,
|
||||
"deb_prediction": 69.1,
|
||||
"ens_median": 70.4,
|
||||
"ensemble_spread": 2.8999999999999986,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "miami",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 24.6,
|
||||
"raw_mu": 73.91,
|
||||
"raw_sigma": 2.5500000000000043,
|
||||
"deb_prediction": 74.2,
|
||||
"ens_median": 74.0,
|
||||
"ensemble_spread": 2.5500000000000043,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "milan",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 17.0,
|
||||
"raw_mu": 17.3,
|
||||
"raw_sigma": 9.1,
|
||||
"deb_prediction": 12.5,
|
||||
"ens_median": 15.1,
|
||||
"ensemble_spread": 9.1,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "milan",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 14.0,
|
||||
"raw_mu": 14.3,
|
||||
"raw_sigma": 0.6,
|
||||
"deb_prediction": 13.7,
|
||||
"ens_median": 13.8,
|
||||
"ensemble_spread": 0.6,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "milan",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 5.0,
|
||||
"raw_mu": 16.58,
|
||||
"raw_sigma": 1.25,
|
||||
"deb_prediction": 16.9,
|
||||
"ens_median": 17.0,
|
||||
"ensemble_spread": 1.25,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "munich",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 10.0,
|
||||
"raw_mu": 10.66,
|
||||
"raw_sigma": 0.6,
|
||||
"deb_prediction": 10.6,
|
||||
"ens_median": 10.6,
|
||||
"ensemble_spread": 0.6,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "munich",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 5.0,
|
||||
"raw_mu": 11.62,
|
||||
"raw_sigma": 1.3000000000000007,
|
||||
"deb_prediction": 11.5,
|
||||
"ens_median": 11.8,
|
||||
"ensemble_spread": 1.3000000000000007,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "new york",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 32.0,
|
||||
"raw_mu": 36.94,
|
||||
"raw_sigma": 2.25,
|
||||
"deb_prediction": 36.8,
|
||||
"ens_median": 37.0,
|
||||
"ensemble_spread": 2.25,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "paris",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 14.0,
|
||||
"raw_mu": 14.47,
|
||||
"raw_sigma": 0.9000000000000004,
|
||||
"deb_prediction": 14.2,
|
||||
"ens_median": 14.8,
|
||||
"ensemble_spread": 0.9000000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "paris",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 8.0,
|
||||
"raw_mu": 16.06,
|
||||
"raw_sigma": 0.6,
|
||||
"deb_prediction": 16.2,
|
||||
"ens_median": 16.3,
|
||||
"ensemble_spread": 0.6,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "sao paulo",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 22.0,
|
||||
"raw_mu": 29.64,
|
||||
"raw_sigma": 2.450000000000001,
|
||||
"deb_prediction": 30.0,
|
||||
"ens_median": 29.4,
|
||||
"ensemble_spread": 2.450000000000001,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "sao paulo",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 21.0,
|
||||
"raw_mu": 26.5,
|
||||
"raw_sigma": 1.200000000000001,
|
||||
"deb_prediction": 25.5,
|
||||
"ens_median": 26.5,
|
||||
"ensemble_spread": 1.200000000000001,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "seattle",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 55.9,
|
||||
"raw_mu": 55.9,
|
||||
"raw_sigma": 1.3500000000000014,
|
||||
"deb_prediction": 55.8,
|
||||
"ens_median": 55.9,
|
||||
"ensemble_spread": 1.3500000000000014,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "seoul",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 8.0,
|
||||
"raw_mu": 8.0,
|
||||
"raw_sigma": 0.7999999999999998,
|
||||
"deb_prediction": 7.4,
|
||||
"ens_median": 7.4,
|
||||
"ensemble_spread": 0.7999999999999998,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "seoul",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 10.0,
|
||||
"raw_mu": 10.3,
|
||||
"raw_sigma": 1.7999999999999998,
|
||||
"deb_prediction": 7.6,
|
||||
"ens_median": 6.3,
|
||||
"ensemble_spread": 1.7999999999999998,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "shanghai",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 13.0,
|
||||
"raw_mu": 13.0,
|
||||
"raw_sigma": 1.1500000000000004,
|
||||
"deb_prediction": 13.2,
|
||||
"ens_median": 13.0,
|
||||
"ensemble_spread": 1.1500000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "shanghai",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 12.0,
|
||||
"raw_mu": 12.3,
|
||||
"raw_sigma": 0.7999999999999998,
|
||||
"deb_prediction": 10.7,
|
||||
"ens_median": 11.2,
|
||||
"ensemble_spread": 0.7999999999999998,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "shanghai",
|
||||
"date": "2026-03-29",
|
||||
"actual_high": 18.0,
|
||||
"raw_mu": 18.0,
|
||||
"raw_sigma": 1.6999999999999993,
|
||||
"deb_prediction": 18.4,
|
||||
"ens_median": 17.6,
|
||||
"ensemble_spread": 1.6999999999999993,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "singapore",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 32.0,
|
||||
"raw_mu": 32.0,
|
||||
"raw_sigma": 0.9500000000000011,
|
||||
"deb_prediction": 30.2,
|
||||
"ens_median": 30.1,
|
||||
"ensemble_spread": 0.9500000000000011,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "singapore",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 32.0,
|
||||
"raw_mu": 32.3,
|
||||
"raw_sigma": 1.3499999999999996,
|
||||
"deb_prediction": 31.5,
|
||||
"ens_median": 32.1,
|
||||
"ensemble_spread": 1.3499999999999996,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "taipei",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 26.7,
|
||||
"raw_mu": 26.7,
|
||||
"raw_sigma": 2.25,
|
||||
"deb_prediction": 24.9,
|
||||
"ens_median": 25.4,
|
||||
"ensemble_spread": 2.25,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "taipei",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 29.0,
|
||||
"raw_mu": 29.0,
|
||||
"raw_sigma": 1.0500000000000007,
|
||||
"deb_prediction": 27.2,
|
||||
"ens_median": 27.5,
|
||||
"ensemble_spread": 1.0500000000000007,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "taipei",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 22.0,
|
||||
"raw_mu": 21.7,
|
||||
"raw_sigma": 1.1500000000000004,
|
||||
"deb_prediction": 21.5,
|
||||
"ens_median": 21.3,
|
||||
"ensemble_spread": 1.1500000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "tel aviv",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 30.0,
|
||||
"raw_mu": 30.3,
|
||||
"raw_sigma": 2.1500000000000004,
|
||||
"deb_prediction": 29.0,
|
||||
"ens_median": 28.9,
|
||||
"ensemble_spread": 2.1500000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "tel aviv",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 21.0,
|
||||
"raw_mu": 21.3,
|
||||
"raw_sigma": 1.5,
|
||||
"deb_prediction": 20.7,
|
||||
"ens_median": 21.1,
|
||||
"ensemble_spread": 1.5,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "tokyo",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 17.0,
|
||||
"raw_mu": 17.0,
|
||||
"raw_sigma": 1.5499999999999998,
|
||||
"deb_prediction": 15.4,
|
||||
"ens_median": 15.8,
|
||||
"ensemble_spread": 1.5499999999999998,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "tokyo",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 16.0,
|
||||
"raw_mu": 16.5,
|
||||
"raw_sigma": 2.0999999999999996,
|
||||
"deb_prediction": 17.8,
|
||||
"ens_median": 18.5,
|
||||
"ensemble_spread": 2.0999999999999996,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "toronto",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": -6.0,
|
||||
"raw_mu": -1.01,
|
||||
"raw_sigma": 0.8,
|
||||
"deb_prediction": -0.6,
|
||||
"ens_median": -1.1,
|
||||
"ensemble_spread": 0.8,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "toronto",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": -2.0,
|
||||
"raw_mu": 5.67,
|
||||
"raw_sigma": 2.1500000000000004,
|
||||
"deb_prediction": 6.4,
|
||||
"ens_median": 6.3,
|
||||
"ensemble_spread": 2.1500000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "warsaw",
|
||||
"date": "2026-03-17",
|
||||
"actual_high": 11.0,
|
||||
"raw_mu": 11.3,
|
||||
"raw_sigma": 0.6499999999999995,
|
||||
"deb_prediction": 10.4,
|
||||
"ens_median": 10.5,
|
||||
"ensemble_spread": 0.6499999999999995,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "warsaw",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 13.0,
|
||||
"raw_mu": 13.84,
|
||||
"raw_sigma": 1.4000000000000004,
|
||||
"deb_prediction": 13.6,
|
||||
"ens_median": 14.2,
|
||||
"ensemble_spread": 1.4000000000000004,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "warsaw",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 7.0,
|
||||
"raw_mu": 12.03,
|
||||
"raw_sigma": 1.5999999999999996,
|
||||
"deb_prediction": 11.8,
|
||||
"ens_median": 12.3,
|
||||
"ensemble_spread": 1.5999999999999996,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "wellington",
|
||||
"date": "2026-03-18",
|
||||
"actual_high": 21.0,
|
||||
"raw_mu": 21.0,
|
||||
"raw_sigma": 0.9500000000000011,
|
||||
"deb_prediction": 19.2,
|
||||
"ens_median": 19.1,
|
||||
"ensemble_spread": 0.9500000000000011,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
},
|
||||
{
|
||||
"city": "wellington",
|
||||
"date": "2026-03-19",
|
||||
"actual_high": 18.0,
|
||||
"raw_mu": 18.3,
|
||||
"raw_sigma": 2.0999999999999996,
|
||||
"deb_prediction": 17.9,
|
||||
"ens_median": 17.1,
|
||||
"ensemble_spread": 2.0999999999999996,
|
||||
"max_so_far_gap": null,
|
||||
"peak_flag": 0.0,
|
||||
"sample_source": "daily_record",
|
||||
"settlement_source": null,
|
||||
"settlement_station_code": null,
|
||||
"truth_version": null,
|
||||
"truth_updated_by": null,
|
||||
"truth_updated_at": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
$VPS = "root@38.54.27.70"
|
||||
$PROJECT = "/root/PolyWeather"
|
||||
|
||||
Write-Host "🚀 Deploying to $VPS..." -ForegroundColor Cyan
|
||||
|
||||
ssh $VPS "cd $PROJECT && git pull && docker compose up -d --build"
|
||||
|
||||
Write-Host "✅ Deploy complete. Checking health..." -ForegroundColor Green
|
||||
Start-Sleep 8
|
||||
ssh $VPS "curl -s http://localhost:8000/healthz"
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
VPS="root@38.54.27.70"
|
||||
PROJECT="/root/PolyWeather"
|
||||
|
||||
echo "🚀 Deploying to $VPS..."
|
||||
|
||||
ssh "$VPS" "cd $PROJECT && git pull && docker compose up -d --build"
|
||||
|
||||
echo "✅ Deploy complete. Checking health..."
|
||||
sleep 8
|
||||
ssh "$VPS" "curl -s http://localhost:8000/healthz"
|
||||
@@ -10,15 +10,15 @@ services:
|
||||
container_name: polyweather_bot
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Persist runtime data outside git workspace.
|
||||
# Host path defaults to /var/lib/polyweather and can be overridden in .env.
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
# Keep /app/data compatibility for existing cache/state defaults.
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
- ./bot.log:/app/bot.log # 挂载日志文件
|
||||
# UID/GID are mainly useful on Linux hosts to avoid root-owned output files.
|
||||
# Windows / macOS can usually keep the fallback values.
|
||||
- ./bot.log:/app/bot.log
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db'); c.execute('SELECT 1'); c.close()"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
polyweather_web:
|
||||
<<: *polyweather-base
|
||||
@@ -26,87 +26,14 @@ services:
|
||||
restart: unless-stopped
|
||||
command: python web/app.py
|
||||
volumes:
|
||||
# Web service shares the same runtime data directory as bot/state tasks.
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
ports:
|
||||
- "8000:8000"
|
||||
# UID/GID are mainly useful on Linux hosts to avoid root-owned output files.
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "from urllib.request import urlopen; urlopen('http://localhost:8000/healthz')"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
polyweather_prewarm:
|
||||
<<: *polyweather-base
|
||||
container_name: polyweather_prewarm
|
||||
restart: unless-stopped
|
||||
profiles: ["workers"]
|
||||
command: python scripts/prewarm_dashboard_worker.py --include-detail --include-market
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
|
||||
polyweather_prometheus:
|
||||
image: prom/prometheus:v3.4.1
|
||||
container_name: polyweather_prometheus
|
||||
restart: unless-stopped
|
||||
profiles: ["monitoring"]
|
||||
depends_on:
|
||||
- polyweather_web
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
- "--storage.tsdb.path=/prometheus"
|
||||
- "--storage.tsdb.retention.time=15d"
|
||||
- "--web.enable-lifecycle"
|
||||
volumes:
|
||||
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- ./monitoring/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}/monitoring/prometheus:/prometheus
|
||||
ports:
|
||||
- "${POLYWEATHER_PROMETHEUS_PORT:-9090}:9090"
|
||||
|
||||
polyweather_alertmanager:
|
||||
image: prom/alertmanager:v0.28.1
|
||||
container_name: polyweather_alertmanager
|
||||
restart: unless-stopped
|
||||
profiles: ["monitoring"]
|
||||
depends_on:
|
||||
- polyweather_alert_relay
|
||||
command:
|
||||
- "--config.file=/etc/alertmanager/alertmanager.yml"
|
||||
- "--storage.path=/alertmanager"
|
||||
volumes:
|
||||
- ./monitoring/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}/monitoring/alertmanager:/alertmanager
|
||||
ports:
|
||||
- "${POLYWEATHER_ALERTMANAGER_PORT:-9093}:9093"
|
||||
|
||||
polyweather_alert_relay:
|
||||
<<: *polyweather-base
|
||||
container_name: polyweather_alert_relay
|
||||
restart: unless-stopped
|
||||
profiles: ["monitoring"]
|
||||
command: python scripts/alertmanager_telegram_relay.py
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
ports:
|
||||
- "${POLYWEATHER_ALERT_RELAY_PORT:-9099}:9099"
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
|
||||
polyweather_grafana:
|
||||
image: grafana/grafana-oss:12.0.2
|
||||
container_name: polyweather_grafana
|
||||
restart: unless-stopped
|
||||
profiles: ["monitoring"]
|
||||
depends_on:
|
||||
- polyweather_prometheus
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: ${POLYWEATHER_GRAFANA_ADMIN_USER:-admin}
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${POLYWEATHER_GRAFANA_ADMIN_PASSWORD:-polyweather}
|
||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||
volumes:
|
||||
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}/monitoring/grafana:/var/lib/grafana
|
||||
ports:
|
||||
- "${POLYWEATHER_GRAFANA_PORT:-3001}:3000"
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# 机场高频数据接入市场监控频道方案
|
||||
|
||||
## 背景
|
||||
|
||||
### 现有数据
|
||||
|
||||
| 城市 | 站点 | ICAO/站点 | 数据类型 | 数据源 | 刷新频率 |
|
||||
|------|------|-----------|---------|--------|---------|
|
||||
| 首尔 | 仁川国际 | RKSI | 跑道对温度(2 对) | AMOS | 1 分钟 |
|
||||
| 釜山 | 金海国际 | RKPK | 跑道对温度(1 对) | AMOS | 1 分钟 |
|
||||
| 东京 | 羽田 | RJTT | 机场站点实时温度 | JMA AMeDAS | 10 分钟 |
|
||||
| 安卡拉 | Esenboğa | 17128 | 机场站点实时温度 | MGM | 不定,约 5-15 分钟 |
|
||||
|
||||
### 现有 Telegram 推送系统
|
||||
|
||||
- **循环**: `start_trade_alert_push_loop`,默认每 30 分钟跑一轮
|
||||
- **覆盖城市**: `TELEGRAM_ALERT_CITIES`(默认全部 51 城)
|
||||
- **3 条规则**: Ankara Center DEB 命中、预报突破、暖平流
|
||||
- **门禁**: 严重度/触发数/冷却期 多层过滤
|
||||
- **消息**: 中英双语,包含触发类型、实况温度
|
||||
|
||||
### 问题
|
||||
|
||||
四座机场城市的实时数据已就绪,但现有推送系统 30 分钟一轮对所有城市一视同仁。1-10 分钟级高频数据在接近交易高峰期时,温度变化可能比 30 分钟窗口更快,需要更灵敏的监控。
|
||||
|
||||
---
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 核心思路
|
||||
|
||||
在现有 30 分钟主循环之上叠加高频通道,对四座机场城市用 10 分钟间隔独立检测温度急变。温度波动达到阈值时推送告警,包含当前温度 + DEB 预测最高温。不做市场分析、不输出 AI 建议、不约定时快照。
|
||||
|
||||
### 1. 高频机场城市快速通道
|
||||
|
||||
在现有 30 分钟主循环之外,为 `{seoul, busan, tokyo, ankara}` 单独跑一个 10 分钟间隔的子循环,每个城市独立检测温度急变。
|
||||
|
||||
**配置(写死在代码中)**:
|
||||
```python
|
||||
HIGH_FREQ_AIRPORT_CITIES = {"seoul", "busan", "tokyo", "ankara"}
|
||||
HIGH_FREQ_PUSH_INTERVAL_SEC = 600 # 10 分钟
|
||||
HIGH_FREQ_MOMENTUM_THRESHOLD_C = 0.5 # 比默认 0.8°C 更灵敏
|
||||
HIGH_FREQ_COOLDOWN_SEC = 7200 # 同一城市冷却 2 小时
|
||||
```
|
||||
|
||||
**逻辑**:
|
||||
- 主循环 30 分钟照常跑全部城市(不变)
|
||||
- 每 10 分钟对四座机场城市各检查一次温度急变
|
||||
- 高频轮次仅检查 `airport_rapid_temp_change` 一条规则
|
||||
- 各城市独立冷却,触发后 2 小时内同一城市不再重复推送
|
||||
- **最高温已锁定则跳过**:当日最高已过且持续下降,不再推送
|
||||
|
||||
### 2. 机场观测积累与趋势检测
|
||||
|
||||
**新增数据库表**: `airport_obs_log`
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS airport_obs_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
icao TEXT NOT NULL,
|
||||
city TEXT NOT NULL,
|
||||
temp_c REAL,
|
||||
wind_kt REAL,
|
||||
pressure_hpa REAL,
|
||||
obs_time TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_airport_obs_log_icao_time
|
||||
ON airport_obs_log(icao, created_at DESC);
|
||||
```
|
||||
|
||||
**写入**: 在 AMOS/JMA/MGM 成功获取数据后自动调用 `append_airport_obs()` 写入。自动清理 2 小时前的旧数据。
|
||||
|
||||
**读取**: `get_airport_obs_recent(icao, minutes=30)` 返回最近 N 分钟观测列表,用于计算温度变化斜率。
|
||||
|
||||
### 3. 温度突变即时告警
|
||||
|
||||
基于积累的观测日志,新增告警规则 `airport_rapid_temp_change`。每条告警 per-city 独立推送。
|
||||
|
||||
| 参数 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| 滑动窗口 | 20 分钟 | 取最近 20 分钟内的观测 |
|
||||
| 最少样本 | 3 条 | 确保有足够数据点 |
|
||||
| 触发阈值 | > 0.5°C/10min | 比默认 0.8°C/30min 更灵敏 |
|
||||
| 冷却期 | 2 小时 | 同城市两次推送最小间隔 |
|
||||
| 锁定跳过 | 最高温已锁定 | 当日最高已过且持续下降,不推送 |
|
||||
|
||||
**告警消息示例**:
|
||||
|
||||
首尔/釜山(跑道对温度):
|
||||
```
|
||||
🚨 首尔/仁川 温度急变
|
||||
|
||||
15L/33R 14.6°C
|
||||
15R/33L 15.2°C
|
||||
DEB 预测最高 18.2°C
|
||||
```
|
||||
|
||||
东京/安卡拉(站点实时温度):
|
||||
```
|
||||
🚨 东京/羽田 温度急变
|
||||
|
||||
当前 24.1°C
|
||||
DEB 预测最高 26.5°C
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 改动文件清单
|
||||
|
||||
| 优先级 | 文件 | 改动 |
|
||||
|--------|------|------|
|
||||
| 1 | `src/database/db_manager.py` | 新增 `airport_obs_log` 表、`append_airport_obs()`、`get_airport_obs_recent()` |
|
||||
| 2 | `src/data_collection/weather_sources.py` | AMOS/JMA/MGM 成功后调用 `append_airport_obs()` 写日志 |
|
||||
| 3 | `src/analysis/market_alert_engine.py` | 新增 `airport_rapid_temp_change` 规则 |
|
||||
| 4 | `src/utils/telegram_push.py` | 10 分钟高频子循环、温度急变告警推送、最高温锁定跳过 |
|
||||
| 5 | `src/bot/runtime_coordinator.py` | 注册机场高频推送循环 |
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. **Phase 1 — DB 层**: `airport_obs_log` 表 + 读写方法
|
||||
2. **Phase 2 — 采集层**: AMOS/JMA/MGM 成功后自动写日志,部署观察 1-2 天确认数据积累正常
|
||||
3. **Phase 3 — 告警引擎**: `airport_rapid_temp_change` 规则 + 单元测试
|
||||
4. **Phase 4 — 推送层**: 高频快速通道,直接推送市场监控频道
|
||||
5. **Phase 5 — 调参**: 观察 3-7 天调整阈值
|
||||
|
||||
---
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
- **AMOS/JMA/MGM 站点可用性**: 各数据源可能偶发性不可用,需容错处理
|
||||
- **告警频率控制**: 高频循环可能产生过多告警,需要严格的冷却期和去重机制
|
||||
- **数据库体积**: `airport_obs_log` 每 1-10 分钟写入 4 条记录,2 小时约 48-480 条,自动清理后体积可控
|
||||
- **安卡拉 MGM 刷新频率**: `servis.mgm.gov.tr` 实测更新间隔 5-15 分钟不等,非固定周期
|
||||
@@ -0,0 +1,100 @@
|
||||
# 机场高频实时数据源
|
||||
|
||||
## 已接入城市
|
||||
|
||||
| 城市 | 机场 | ICAO/站点 | 数据源 | 频率 | 类型 | 费用 |
|
||||
|------|------|-----------|--------|------|------|------|
|
||||
| 首尔 | 仁川国际 | RKSI | AMOS (`global.amo.go.kr`) | 1 分钟 | 跑道对温度(2对) | 免费 |
|
||||
| 釜山 | 金海国际 | RKPK | AMOS (`global.amo.go.kr`) | 1 分钟 | 跑道对温度(1对) | 免费 |
|
||||
| 东京 | 羽田 | RJTT | JMA AMeDAS (`jma.go.jp`) | 10 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 安卡拉 | Esenboğa | 17128 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 伊斯坦布尔 | 伊斯坦布尔机场 | 17058 | MGM (`servis.mgm.gov.tr`) | 5-15 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 赫尔辛基 | Vantaa | EFHK | FMI (`opendata.fmi.fi`) | 10 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 阿姆斯特丹 | Schiphol | EHAM | KNMI (`dataplatform.knmi.nl`) | 10 分钟 | 机场站点实时温度 | 免费(需注册) |
|
||||
| 巴黎 | Le Bourget | LFPB | AROME HD (`api.open-meteo.com`) | 15 分钟 | 模型预报(非实测) | 免费 |
|
||||
| 新加坡 | Changi | WSSS | Singapore MSS (`api.data.gov.sg`) | 1 分钟 | 机场站点实时温度 (S24 站) | 免费 |
|
||||
| 纽约 | LaGuardia | KLGA | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 洛杉矶 | LAX | KLAX | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 芝加哥 | O'Hare | KORD | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 丹佛 | Buckley | KBKF | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 亚特兰大 | Hartsfield | KATL | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 迈阿密 | MIA | KMIA | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 旧金山 | SFO | KSFO | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 休斯顿 | Hobby | KHOU | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 达拉斯 | Love Field | KDAL | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 奥斯汀 | Bergstrom | KAUS | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
| 西雅图 | SeaTac | KSEA | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 |
|
||||
|
||||
> **Singapore MSS**: 新加坡气象局(MSS)通过 data.gov.sg 开放数据平台提供全国 15 个站点
|
||||
> 的干球温度(1 分钟均值),更新频率 ~1 分钟。选取 S24 Upper Changi Road North 站
|
||||
> 作为樟宜机场 (WSSS) 的实时温度锚点。数据公开免费,无需 API 密钥。
|
||||
> 后端通过 `singapore_mss_sources.py` 拉取并注入 `airport_primary`。
|
||||
|
||||
> **NOAA MADIS HFMETAR**: 美国 11 个城市的机场高频实时数据通过 NOAA MADIS 公共档案获取。
|
||||
> 数据源为 NetCDF 格式(`madis-data.ncep.noaa.gov/madisPublic1/data/LDAD/hfmetar/`),
|
||||
> 每 5 分钟全量更新一次,温度保留一位小数。匿名公开访问,无需 API 密钥。
|
||||
> 后端通过 `weather_sources.py` 拉取并注入 `airport_primary`,前端市场监控通过
|
||||
> `resolveMonitorTemperature` 优先读取 `airport_primary.temp` 获得小数精度温度。
|
||||
|
||||
## 推送机制
|
||||
|
||||
- 每城按原生频率独立推送,不捆绑
|
||||
- 首尔/釜山 60s,其余 600s
|
||||
- 循环轮询 60s 以匹配最快频率
|
||||
- 仅当当前温度距 DEB 预测最高 ≤3°C 时推送
|
||||
- 确认过峰值后自动停止
|
||||
|
||||
## 前端市场监控 freshness 契约
|
||||
|
||||
后端城市详情接口会在 `current.freshness` / `airport_current.freshness` 返回源感知更新时间信息,前端市场监控不再用统一的 `obs_age_min` 判断所有城市。
|
||||
|
||||
关键字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"source_code": "amos",
|
||||
"source_label": "AMOS",
|
||||
"observed_at": "2026-05-14T11:59:10+00:00",
|
||||
"observed_at_local": "20:59",
|
||||
"native_update_interval_sec": 60,
|
||||
"expected_next_update_at": "2026-05-14T12:00:10+00:00",
|
||||
"freshness_status": "fresh",
|
||||
"freshness_reason": "within_native_fresh_window",
|
||||
"age_sec": 50
|
||||
}
|
||||
```
|
||||
|
||||
前端刷新规则:
|
||||
|
||||
- 首次进入市场监控:强制刷新全部城市,绕过 30 分钟前端缓存。
|
||||
- 定时轮询:仍以 60s tick 检查,但只刷新已到 `expected_next_update_at`、`delayed`、`stale` 或缺失的城市。
|
||||
- BFF 代理:`force_refresh=true` 时使用 `no-store`,避免 Next fetch revalidate 缓存吞掉强刷。
|
||||
- 展示:卡片 tooltip 显示源端名称、原生更新间隔和当前 freshness 状态。
|
||||
|
||||
## 消息模板
|
||||
|
||||
```
|
||||
Seoul / Incheon 16:03
|
||||
|
||||
15L/33R 14.6°C
|
||||
15R/33L 15.2°C
|
||||
今日DEB预报最高:18.2°C
|
||||
今日实测最高:16.5°C(15:30)
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `TELEGRAM_AIRPORT_PUSH_ENABLED` | 启用机场推送 | `true` |
|
||||
| `TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC` | 循环轮询间隔 | `60` |
|
||||
| `KNMI_API_KEY` | KNMI API 密钥(阿姆斯特丹必填) | — |
|
||||
|
||||
## 未接入城市
|
||||
|
||||
| 城市 | 原因 |
|
||||
|------|------|
|
||||
| 马德里/Barajas | AEMET 注册页面失效 |
|
||||
| 伦敦/Heathrow | Met Office 仅 1 小时更新 |
|
||||
| 慕尼黑 | DWD 延迟 ~1 小时 |
|
||||
| 米兰/华沙/莫斯科 | 无已知实时源 |
|
||||
@@ -1,4 +1,4 @@
|
||||
# PolyWeather API 文档(v1.5.4)
|
||||
# PolyWeather API 文档(v1.7.0)
|
||||
|
||||
最后更新:`2026-04-27`
|
||||
|
||||
@@ -126,18 +126,14 @@ SSE 事件:
|
||||
|
||||
#### 2. `probabilities`
|
||||
|
||||
概率层现在按“校准模型概率”对外解释,而不是直接把模型票数或市场价格当成概率。
|
||||
概率层基于 legacy 高斯分桶,以 DEB 融合预测 μ 和 ensemble spread σ 生成 1°C 粒度概率分布。
|
||||
|
||||
新增 / 重点字段:
|
||||
概率字段:
|
||||
|
||||
- `engine`:概率引擎名称,例如 `lgbm_calibrated`、`emos`、`legacy`
|
||||
- `calibration_mode`:校准运行模式
|
||||
- `calibration_version`:校准产物版本
|
||||
- `raw_mu` / `raw_sigma`:原始分布参数
|
||||
- `calibrated_mu` / `calibrated_sigma`:校准后分布参数
|
||||
- `shadow_distribution`:shadow / 对照分布,供回归与灰度验证
|
||||
|
||||
当前前端展示 `probabilities.engine` 对应的生产概率分布;`EMOS` / `LGBM` 只有在评估通过、显式启用或 shadow 对照时才进入展示/解释层。模型共识与市场价格只作为辅助参考,不再作为主结论。
|
||||
- `engine`:固定为 `legacy`
|
||||
- `mu`:DEB 融合预测中心值
|
||||
- `distribution`:当天合约桶概率分布
|
||||
- `distribution_all`:包含外围桶的完整分布
|
||||
|
||||
#### 3. `detail_depth`
|
||||
|
||||
@@ -206,7 +202,7 @@ SSE 事件:
|
||||
|
||||
- 多数机场市场以 `METAR` / 机场主站实况为结算锚点。
|
||||
- `Wunderground` 是历史页面或参考入口,不应在产品文案里被描述成“站”。
|
||||
- `MGM / NMC / JMA / KMA / HKO / CWA` 等官方站网属于增强层或明确官方站点层;只有合约规则明确指定时,才作为最终结算站点。
|
||||
- `MGM / NMC / JMA / AMOS / HKO / CWA` 等官方站网属于增强层或明确官方站点层;只有合约规则明确指定时,才作为最终结算站点。
|
||||
|
||||
## 4. 鉴权与账户接口
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
|
||||
核心价值:
|
||||
|
||||
- 观测优先(METAR / 机场主站 / 明确官方站点;MGM、NMC、JMA、KMA 等作为增强层)
|
||||
- 观测优先(METAR / 机场主站 / 明确官方站点;MGM、NMC、JMA、AMOS 等作为增强层)
|
||||
- 结算导向(DEB + 校准概率桶)
|
||||
- 气象判断优先(证据链、失效条件、下一观测点)
|
||||
- 市场映射(行情对照 + 错价雷达),但不把交易建议放在第一层产品承诺
|
||||
@@ -18,7 +18,7 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
| 能力 | 状态 | 备注 |
|
||||
| :-- | :-- | :-- |
|
||||
| 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 |
|
||||
| 订阅套餐(Pro 月付) | 已上线 | `5 USDC / 30天` |
|
||||
| 订阅套餐(Pro 月付) | 已上线 | `10 USDC / 30天` |
|
||||
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U` |
|
||||
| 合约支付 | 已上线 | Polygon,USDC + USDC.e |
|
||||
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop |
|
||||
@@ -32,13 +32,13 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
||||
- Pro 用户:
|
||||
- 今日日内深度分析(含高温时段)
|
||||
- 专业气象结论条、证据链、失效条件、确认条件
|
||||
- LGBM / EMOS 等校准概率层
|
||||
- 概率分布层(基于 DEB 融合 + 高斯分桶)
|
||||
- 历史对账 + 未来日期分析
|
||||
- 全平台智能气象推送
|
||||
|
||||
## 4. 收费与积分规则(默认)
|
||||
|
||||
- 套餐:`pro_monthly`(5 USDC / 30 天)
|
||||
- 套餐:`pro_monthly`(10 USDC / 30 天)
|
||||
- 抵扣:500 积分抵 1 USDC,最高抵 3 USDC
|
||||
- 实付下限:2 USDC(当积分满额时)
|
||||
|
||||
|
||||
@@ -105,13 +105,9 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `POLYWEATHER_OPS_ADMIN_EMAILS`
|
||||
- `POLYWEATHER_STATE_STORAGE_MODE`
|
||||
- `POLYWEATHER_PAYMENT_ENABLED`
|
||||
- `POLYMARKET_MARKET_SCAN_ENABLED`
|
||||
- `POLYGON_WALLET_WATCH_ENABLED`
|
||||
- `TELEGRAM_ALERT_PUSH_ENABLED`
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
|
||||
- `POLYMARKET_WALLET_ACTIVITY_ENABLED`(已退役,建议保持 `false`)
|
||||
- `POLYWEATHER_DASHBOARD_PREWARM_ENABLED`
|
||||
- `POLYWEATHER_GROQ_COMMENTARY_ENABLED`
|
||||
|
||||
### 4.3 L3:运行调优项
|
||||
|
||||
@@ -131,29 +127,12 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N`
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS`
|
||||
- `TAF_CACHE_TTL_SEC`
|
||||
- `POLYWEATHER_PREWARM_INTERVAL_SEC`
|
||||
- `POLYWEATHER_PREWARM_JITTER_SEC`
|
||||
- `POLYWEATHER_PREWARM_CITIES`
|
||||
- `POLYWEATHER_PREWARM_INCLUDE_DETAIL`
|
||||
- `POLYWEATHER_PREWARM_INCLUDE_MARKET`
|
||||
- `POLYWEATHER_PREWARM_FORCE_REFRESH`
|
||||
- `POLYWEATHER_GROQ_COMMENTARY_MODEL`
|
||||
- `POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC`
|
||||
- `POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC`
|
||||
|
||||
策略:
|
||||
|
||||
- 先用默认值
|
||||
- 出现性能或运维问题时再调
|
||||
|
||||
当前默认预热名单优先覆盖:
|
||||
|
||||
- 亚洲:Shanghai、Beijing、Shenzhen、Wuhan、Chengdu、Chongqing、Hong Kong、Taipei、Singapore、Tokyo、Seoul、Busan
|
||||
- 中东:Ankara、Istanbul
|
||||
- 欧洲:London、Paris、Madrid
|
||||
|
||||
默认不再包含美国城市;如果线上 `.env` 已手动设置 `POLYWEATHER_PREWARM_CITIES`,则会以你的显式配置为准。
|
||||
|
||||
### 4.4 L4:敏感项
|
||||
|
||||
这些变量不应写进公开文档截图,也不应提交到仓库。
|
||||
@@ -164,10 +143,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
|
||||
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
|
||||
- `METEOBLUE_API_KEY`
|
||||
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
|
||||
- `POLYMARKET_SECRET_KEY`
|
||||
- `GROQ_API_KEY`
|
||||
|
||||
## 5. 推荐部署矩阵
|
||||
|
||||
@@ -259,17 +235,7 @@ TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
|
||||
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
||||
POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true
|
||||
POLYWEATHER_PREWARM_INTERVAL_SEC=300
|
||||
POLYWEATHER_PREWARM_JITTER_SEC=20
|
||||
POLYWEATHER_PREWARM_INCLUDE_DETAIL=true
|
||||
POLYWEATHER_PREWARM_INCLUDE_MARKET=true
|
||||
POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
|
||||
POLYWEATHER_GROQ_COMMENTARY_ENABLED=false
|
||||
POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b
|
||||
POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8
|
||||
POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
|
||||
POLYWEATHER_SCAN_AI_ENABLED=false
|
||||
POLYWEATHER_SCAN_AI_API_KEY=...
|
||||
POLYWEATHER_SCAN_AI_PROVIDER=mimo
|
||||
@@ -290,47 +256,14 @@ POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro
|
||||
- 机器人市场监控包含 `关键提醒` 与 `关注清单`:关键提醒逐城判断并受冷却控制,关注清单每轮先扫描完整城市列表,再按全局 Top N 推送;同一轮已经触发关键提醒的城市不会重复出现在关注清单里。
|
||||
- `POLYWEATHER_SCAN_AI_*` 走 OpenAI-compatible `/chat/completions`;当前临时默认 MiMo,可用 `POLYWEATHER_SCAN_AI_BASE_URL` 与 `POLYWEATHER_SCAN_AI_MODEL` 随时切回其他兼容 provider。
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。
|
||||
- `POLYMARKET_WALLET_ACTIVITY_ENABLED` 已退役,保留为 `false` 即可,不建议再启用钱包异动监听。
|
||||
- `POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true` 时,建议同时启用独立 worker 或 bot 内嵌预热线程。
|
||||
- `POLYWEATHER_BACKEND_URL` 仅在独立 `polyweather_prewarm` worker 容器中使用,建议设为 `http://polyweather_web:8000`,不要写 `127.0.0.1`。
|
||||
- `POLYWEATHER_GROQ_COMMENTARY_ENABLED=false` 表示默认仍走规则文案;只有在确实配置了 `GROQ_API_KEY` 时才建议开启。
|
||||
|
||||
### 6.3 Dashboard 预热 worker 推荐变量
|
||||
|
||||
```env
|
||||
POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true
|
||||
POLYWEATHER_PREWARM_INTERVAL_SEC=300
|
||||
POLYWEATHER_PREWARM_JITTER_SEC=20
|
||||
POLYWEATHER_PREWARM_CITIES=ankara,istanbul,shanghai,beijing,shenzhen,wuhan,chengdu,chongqing,hong kong,taipei,singapore,tokyo,seoul,busan,london,paris,madrid
|
||||
POLYWEATHER_PREWARM_INCLUDE_DETAIL=true
|
||||
POLYWEATHER_PREWARM_INCLUDE_MARKET=true
|
||||
POLYWEATHER_PREWARM_FORCE_REFRESH=false
|
||||
POLYWEATHER_BACKEND_URL=http://polyweather_web:8000
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 这组变量用于后台定向预热热点城市,避免用户点击城市时才冷启动拉 detail。
|
||||
- 如果使用独立 `polyweather_prewarm` 容器,`POLYWEATHER_BACKEND_URL` 必须指向容器网络中的 `polyweather_web`。
|
||||
|
||||
### 6.4 Groq 解读增强层
|
||||
|
||||
```env
|
||||
POLYWEATHER_GROQ_COMMENTARY_ENABLED=true
|
||||
GROQ_API_KEY=...
|
||||
POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b
|
||||
POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8
|
||||
POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 这层只负责把结构化信号改写成短摘要,不替代真实模型、机场锚点和结算逻辑。
|
||||
- Groq 调用失败时,系统会自动回退到规则文案。
|
||||
|
||||
### 6.5 机器人市场监控建议配置
|
||||
|
||||
这套配置用于替代旧的钱包异动监听,围绕市场本身做两类推送:
|
||||
这套配置围绕市场本身做两类推送:
|
||||
|
||||
- `关键提醒`:实时错价/触发条件满足时发送
|
||||
- `关注清单`:按亚洲时区定时推送当日重点市场摘要
|
||||
@@ -348,7 +281,6 @@ TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
|
||||
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
||||
```
|
||||
|
||||
说明:
|
||||
@@ -356,7 +288,6 @@ POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
||||
- `TELEGRAM_ALERT_MISPRICING_ONLY=true` 表示关键提醒优先围绕错价/市场触发,不把机器人做成泛通知器。
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800` 表示频道每 30 分钟主动推送一轮全局机会清单;每轮会先扫描完整 `TELEGRAM_ALERT_CITIES`,再选 Top N。
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5` 建议先保持较小,避免机器人一次推太多城市。
|
||||
- `POLYMARKET_WALLET_ACTIVITY_ENABLED=false` 表示停用旧的钱包异动监听,统一收敛到市场监控。
|
||||
|
||||
## 7. 当前建议的运维规则
|
||||
|
||||
|
||||
@@ -1,685 +0,0 @@
|
||||
# EMOS + LGBM 系统说明(中文)
|
||||
|
||||
最后更新:`2026-04-19`
|
||||
|
||||
本文档用于完整说明 PolyWeather 当前的两条统计/机器学习链路:
|
||||
|
||||
- `EMOS`:概率后处理与校准链路
|
||||
- `LGBM`:日最高温点预测辅助模型
|
||||
|
||||
重点不只是“模型怎么训练”,还包括:
|
||||
|
||||
- 这些模型依赖什么历史数据
|
||||
- 真值和训练特征现在如何长期保存
|
||||
- 为什么过去样本一直不够
|
||||
- 当前线上到底运行在哪个模式
|
||||
- 现在能做什么,不能做什么
|
||||
|
||||
本文档基于仓库当前实现与最近一轮重建结果,适合作为:
|
||||
|
||||
- 项目内部模型说明
|
||||
- 运维与数据治理说明
|
||||
- 未来继续扩展 EMOS/LGBM 的基线文档
|
||||
|
||||
---
|
||||
|
||||
## 1. 总览
|
||||
|
||||
PolyWeather 当前不是“用一个模型替代所有东西”,而是多层结构:
|
||||
|
||||
1. 多源天气采集层
|
||||
2. `DEB` 业务主预测层
|
||||
3. `LGBM` 轻量点预测辅助层
|
||||
4. `EMOS` 概率校准层
|
||||
5. 市场概率/桶命中评估层
|
||||
|
||||
可以简化理解为:
|
||||
|
||||
```text
|
||||
天气源 / 观测 / 历史真值
|
||||
↓
|
||||
DEB 主预测
|
||||
↓
|
||||
LGBM 辅助点预测
|
||||
↓
|
||||
EMOS 对概率分布做后处理
|
||||
↓
|
||||
市场概率 / shadow / rollout 门禁
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `DEB` 仍然是当前业务主路径
|
||||
- `LGBM` 是辅助预测源,不是主路径
|
||||
- `EMOS` 是概率后处理,不是基础天气模型
|
||||
|
||||
---
|
||||
|
||||
## 2. 两条链路各自负责什么
|
||||
|
||||
### 2.1 EMOS 负责什么
|
||||
|
||||
`EMOS` 的全称通常指 Ensemble Model Output Statistics。
|
||||
|
||||
在本项目里,它的角色不是重新预测温度,而是:
|
||||
|
||||
- 把已有的预测结果做概率后处理
|
||||
- 让输出分布更“可校准”
|
||||
- 让桶概率和市场评估更稳定
|
||||
|
||||
EMOS 关注的是:
|
||||
|
||||
- `raw_mu`
|
||||
- `raw_sigma`
|
||||
- `deb_prediction`
|
||||
- `ens_median`
|
||||
- `ensemble_spread`
|
||||
- `max_so_far_gap`
|
||||
- `peak_flag`
|
||||
- 最终真实 `actual_high`
|
||||
|
||||
它最终输出的是一套“经过校准的概率分布”,而不是单一温度值。
|
||||
|
||||
所以 EMOS 的核心衡量指标不是单纯 MAE,而更看重:
|
||||
|
||||
- `CRPS`
|
||||
- `bucket_hit_rate`
|
||||
- `bucket_brier`
|
||||
|
||||
### 2.2 LGBM 负责什么
|
||||
|
||||
`LGBM` 是一个轻量级的回归模型,用来预测:
|
||||
|
||||
- `actual_high`(日最高温)
|
||||
|
||||
它吃的是:
|
||||
|
||||
- 历史真值 lag 特征
|
||||
- 多模型 forecast
|
||||
- `deb_prediction`
|
||||
- 当前观测特征
|
||||
- 时间特征
|
||||
|
||||
它输出的是:
|
||||
|
||||
- 一个点预测 `actual_high`
|
||||
|
||||
然后这个点预测可以作为:
|
||||
|
||||
- 额外 forecast 源
|
||||
- 供 DEB / 运营 / 研究参考
|
||||
|
||||
所以它和 EMOS 的区别非常重要:
|
||||
|
||||
- `LGBM`:做点预测
|
||||
- `EMOS`:做概率校准
|
||||
|
||||
---
|
||||
|
||||
## 3. 当前代码结构
|
||||
|
||||
### 3.1 EMOS 相关
|
||||
|
||||
核心文件:
|
||||
|
||||
- [probability_calibration.py](/E:/web/PolyWeather/src/analysis/probability_calibration.py)
|
||||
- [probability_rollout.py](/E:/web/PolyWeather/src/analysis/probability_rollout.py)
|
||||
- [fit_probability_calibration.py](/E:/web/PolyWeather/scripts/fit_probability_calibration.py)
|
||||
- [evaluate_probability_calibration.py](/E:/web/PolyWeather/scripts/evaluate_probability_calibration.py)
|
||||
- [build_probability_shadow_report.py](/E:/web/PolyWeather/scripts/build_probability_shadow_report.py)
|
||||
- [judge_probability_rollout.py](/E:/web/PolyWeather/scripts/judge_probability_rollout.py)
|
||||
|
||||
核心产物:
|
||||
|
||||
- [default.json](/E:/web/PolyWeather/artifacts/probability_calibration/default.json)
|
||||
- [evaluation_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/evaluation_report.json)
|
||||
- [shadow_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/shadow_report.json)
|
||||
- [rollout_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/rollout_report.json)
|
||||
- [training_samples.json](/E:/web/PolyWeather/artifacts/probability_calibration/training_samples.json)
|
||||
|
||||
### 3.2 LGBM 相关
|
||||
|
||||
核心文件:
|
||||
|
||||
- [lgbm_daily_high.py](/E:/web/PolyWeather/src/models/lgbm_daily_high.py)
|
||||
- [lgbm_features.py](/E:/web/PolyWeather/src/models/lgbm_features.py)
|
||||
- [train_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/train_lgbm_daily_high.py)
|
||||
- [report_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/report_lgbm_daily_high.py)
|
||||
|
||||
核心产物:
|
||||
|
||||
- [lgbm_daily_high.txt](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high.txt)
|
||||
- [lgbm_daily_high_schema.json](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high_schema.json)
|
||||
|
||||
---
|
||||
|
||||
## 4. 为什么之前样本总是上不去
|
||||
|
||||
这件事是理解当前状态的关键。
|
||||
|
||||
过去项目里有一个结构性问题:
|
||||
|
||||
- `daily_records_store` 同时承担了
|
||||
- 运行态缓存
|
||||
- 历史训练数据来源
|
||||
|
||||
但运行态层会把 `daily_records` 硬裁成最近 14 天。
|
||||
|
||||
这意味着:
|
||||
|
||||
- 对线上运行来说没问题
|
||||
- 对训练来说,历史监督样本会不断被删掉
|
||||
|
||||
结果就是:
|
||||
|
||||
- 城市越来越多
|
||||
- 训练历史反而越来越稀
|
||||
- `LGBM` 很容易只有二十几条样本
|
||||
- `EMOS` 也只能靠有限 snapshot/daily_record 拼起来
|
||||
|
||||
这不是“模型太差”,而是“数据主存设计不对”。
|
||||
|
||||
---
|
||||
|
||||
## 5. 这次历史真值治理做了什么
|
||||
|
||||
现在已经把“运行态缓存”和“长期训练主存”拆开了。
|
||||
|
||||
### 5.1 `daily_records_store`
|
||||
|
||||
继续保留,但只作为:
|
||||
|
||||
- 最近 14 天运行态缓存
|
||||
|
||||
它不再承担长期训练历史职责。
|
||||
|
||||
### 5.2 `truth_records_store`
|
||||
|
||||
新增永久真值表,作为长期训练真值主存。
|
||||
|
||||
当前核心字段包括:
|
||||
|
||||
- `city`
|
||||
- `target_date`
|
||||
- `actual_high`
|
||||
- `settlement_source`
|
||||
- `settlement_station_code`
|
||||
- `settlement_station_label`
|
||||
- `truth_version`
|
||||
- `updated_by`
|
||||
- `updated_at`
|
||||
- `source_payload_json`
|
||||
- `is_final`
|
||||
|
||||
这张表的意义是:
|
||||
|
||||
- 长期保存监督真值
|
||||
- 不再被 14 天缓存裁剪
|
||||
- 真值来源变得可追溯
|
||||
|
||||
### 5.3 `truth_revisions_store`
|
||||
|
||||
新增真值修订审计表。
|
||||
|
||||
它记录:
|
||||
|
||||
- 老值是什么
|
||||
- 新值是什么
|
||||
- 来源怎么变了
|
||||
- 谁改的
|
||||
- 为什么改
|
||||
- 什么时候改
|
||||
|
||||
所以现在回填不会再是“静默覆盖”。
|
||||
|
||||
### 5.4 `training_feature_records_store`
|
||||
|
||||
新增长期训练特征表。
|
||||
|
||||
它长期留存:
|
||||
|
||||
- forecasts
|
||||
- deb_prediction
|
||||
- mu
|
||||
- probability_features
|
||||
- prob_snapshot
|
||||
- shadow_prob_snapshot
|
||||
- calibration 摘要
|
||||
|
||||
它的作用是:
|
||||
|
||||
- 从现在开始,不再继续丢失历史训练特征
|
||||
- 让未来 EMOS/LGBM 样本自然累积
|
||||
|
||||
---
|
||||
|
||||
## 6. 训练数据现在怎么来
|
||||
|
||||
### 6.1 EMOS 训练样本
|
||||
|
||||
EMOS 训练不只是需要真值,还要有“当时那一刻的预测快照”。
|
||||
|
||||
所以一条 EMOS 样本,本质上需要两部分:
|
||||
|
||||
1. 历史预测特征
|
||||
2. 对应日期最终真值
|
||||
|
||||
当前导出的 EMOS 样本里,核心字段包括:
|
||||
|
||||
- `city`
|
||||
- `date`
|
||||
- `actual_high`
|
||||
- `raw_mu`
|
||||
- `raw_sigma`
|
||||
- `deb_prediction`
|
||||
- `ens_median`
|
||||
- `ensemble_spread`
|
||||
- `max_so_far_gap`
|
||||
- `peak_flag`
|
||||
- `sample_source`
|
||||
- `settlement_source`
|
||||
- `settlement_station_code`
|
||||
- `truth_version`
|
||||
- `truth_updated_by`
|
||||
- `truth_updated_at`
|
||||
|
||||
也就是说,EMOS 训练样本现在已经带了真值 provenance。
|
||||
|
||||
### 6.2 LGBM 训练样本
|
||||
|
||||
LGBM 训练样本会优先从:
|
||||
|
||||
1. 永久真值表取监督目标
|
||||
2. 长期训练特征表取历史特征
|
||||
3. 再回退到必要的运行态/快照补充
|
||||
|
||||
当前 LGBM 样本会用到:
|
||||
|
||||
- 历史 `actual_high` lag
|
||||
- 历史均值/趋势
|
||||
- 多模型 forecast
|
||||
- `deb_prediction`
|
||||
- 当前观测
|
||||
- 时间特征
|
||||
|
||||
---
|
||||
|
||||
## 7. Wunderground 历史回填为什么重要
|
||||
|
||||
这次治理里一个重点是:
|
||||
|
||||
- `Taipei`
|
||||
- `Shenzhen`
|
||||
|
||||
这两个城市配置了 `Wunderground` 历史页面作为历史观测取数入口。
|
||||
|
||||
这里要注意产品文案口径:
|
||||
|
||||
- `Wunderground` 不是物理观测站
|
||||
- 它只是历史页面 / 数据入口
|
||||
- 机场类市场仍应以 METAR / 机场主站作为结算锚点
|
||||
- 明确官方站点市场才以规则指定的官方站点作为最终结算锚点
|
||||
|
||||
之前的问题是:
|
||||
|
||||
- 城市注册表已经写成 `wunderground`
|
||||
- 但历史回填链路还没有真正支持按指定历史日期抓 WU 历史页
|
||||
|
||||
所以过去它们的 `actual_high` 可能:
|
||||
|
||||
- 没有被正确回填
|
||||
- 或者被错误来源污染
|
||||
|
||||
现在已经补了正式历史回填函数:
|
||||
|
||||
- [wunderground_sources.py](/E:/web/PolyWeather/src/data_collection/wunderground_sources.py)
|
||||
|
||||
它会:
|
||||
|
||||
1. 按 `city + target_date` 拼出对应历史页
|
||||
2. 解析该日观测序列
|
||||
3. 取当日最高温
|
||||
4. 按市场规则做整度结算
|
||||
5. 写入永久真值表
|
||||
6. 记录来源与审计信息
|
||||
|
||||
这一步对 `Taipei/Shenzhen` 尤其关键,因为它们的历史页面取数和普通 METAR bootstrap 不同。
|
||||
|
||||
---
|
||||
|
||||
## 8. 当前线上/离线运行模式
|
||||
|
||||
### 8.1 概率引擎模式
|
||||
|
||||
当前生产主概率应保持:
|
||||
|
||||
- `legacy`
|
||||
|
||||
如果需要观察 EMOS 对照,可切:
|
||||
|
||||
- `emos_shadow`
|
||||
|
||||
而不是:
|
||||
|
||||
- `emos_primary`
|
||||
|
||||
原因不是工程没接好,而是主概率发布必须由离线评估结果决定。VPS 轻量训练候选未通过门禁;本地训练候选虽通过门禁,但仍建议先 shadow 观察,再人工决定是否切主。
|
||||
|
||||
### 8.2 LGBM 角色
|
||||
|
||||
当前 `LGBM` 仍然只能算:
|
||||
|
||||
- 辅助预测源
|
||||
- 研究/观测链路
|
||||
- 校准概率层的一个可用引擎输入
|
||||
|
||||
不适合替代 `DEB` 主路径。
|
||||
|
||||
前端展示上,`LGBM 校准概率` 代表概率层已使用 LGBM 上下文生成桶分布;它不是把模型四舍五入票数直接当成概率。模型共识仍只是解释层,市场价格也只作为参考层。
|
||||
|
||||
---
|
||||
|
||||
## 9. 当前最新状态
|
||||
|
||||
以下状态来自最近一轮恢复、回填和重训产物。
|
||||
|
||||
### 9.1 永久真值
|
||||
|
||||
当前永久真值表已恢复到长期历史:
|
||||
|
||||
- `truth_records_store`
|
||||
- 最早:`2023-01-01`
|
||||
- 最晚:`2026-04-02`
|
||||
- 行数:约 `35138`
|
||||
- 城市数:`30`
|
||||
|
||||
运行态缓存仍然只有近 14 天:
|
||||
|
||||
- `daily_records_store`
|
||||
- 仍然是近两周范围
|
||||
|
||||
这说明:
|
||||
|
||||
- 长期真值主存已经从运行态缓存里分离出来了
|
||||
|
||||
### 9.2 真值修订
|
||||
|
||||
当前已有 revision 审计记录:
|
||||
|
||||
- `truth_revisions_store`
|
||||
- 行数:`2`
|
||||
|
||||
这说明审计链路已经在工作。
|
||||
|
||||
### 9.3 Wunderground 回填
|
||||
|
||||
`Taipei` 与 `Shenzhen` 已按 WU 历史页完成回填。
|
||||
|
||||
当前这两城已经补到:
|
||||
|
||||
- `2026-04-02`
|
||||
|
||||
### 9.4 长期训练特征
|
||||
|
||||
当前 `training_feature_records_store` 已经接通,但历史上真正留存下来的特征仍然很少。
|
||||
|
||||
这意味着:
|
||||
|
||||
- 从现在开始不会继续丢
|
||||
- 但过去没留下的那部分特征,不会凭空恢复
|
||||
|
||||
这也是为什么:
|
||||
|
||||
- 真值恢复了
|
||||
- `EMOS` 样本量却没有同步大幅增长
|
||||
|
||||
---
|
||||
|
||||
## 10. 当前 EMOS 结果怎么理解
|
||||
|
||||
最近两轮评估给出了更清晰的结论。
|
||||
|
||||
VPS 轻量训练候选:
|
||||
|
||||
- 版本:`emos-auto-20260418204203`
|
||||
- `sample_count = 791`
|
||||
- `delta_crps = +0.004652`
|
||||
- `delta_mae = +0.102623`
|
||||
- `delta_bucket_hit_rate = -0.137800`
|
||||
- 结论:`hold`
|
||||
|
||||
本地训练候选:
|
||||
|
||||
- 版本:`emos-auto-20260418212046`
|
||||
- `sample_count = 847`
|
||||
- `delta_crps = -0.036170`
|
||||
- `delta_mae = -0.007896`
|
||||
- `delta_bucket_hit_rate = -0.009445`
|
||||
- 结论:`promote`
|
||||
|
||||
这说明:
|
||||
|
||||
- EMOS 工程链路有效,本地用更多 snapshot 训练时可以超过 legacy 的 CRPS/MAE。
|
||||
- 低配 VPS 不适合做主训练环境。
|
||||
- 通过门禁不等于立即默认主用,仍应先 `emos_shadow` 观察。
|
||||
|
||||
当前生产策略仍然是:
|
||||
|
||||
- 用户主概率默认 `legacy`
|
||||
- EMOS 通过本地训练产生候选
|
||||
- 通过门禁后先以 `emos_shadow` 灰度
|
||||
- 连续稳定后才考虑 `emos_primary`
|
||||
|
||||
这不是“EMOS 无效”,而是:
|
||||
|
||||
- 它还没有稳定到能切主路径
|
||||
|
||||
### 10.1 当前阻塞点
|
||||
|
||||
主要阻塞仍然是:
|
||||
|
||||
- 有效样本仍然不大,城市级样本分布不均
|
||||
- 桶概率容易受结算边界影响
|
||||
- 需要避免 VPS 训练消耗线上资源
|
||||
- `emos_primary` 发布需要明确人工门禁
|
||||
|
||||
也就是说,当前 EMOS 状态可以总结成:
|
||||
|
||||
- 工程链路完整
|
||||
- 数据治理大幅改善
|
||||
- 本地训练可通过门禁
|
||||
- 生产主用仍需 shadow 观察与人工发布
|
||||
|
||||
---
|
||||
|
||||
## 11. 当前 LGBM 结果怎么理解
|
||||
|
||||
最近一轮 LGBM 训练后,样本数已经从以前更少的状态提升到:
|
||||
|
||||
- `sample_count = 54`
|
||||
- `train_count = 42`
|
||||
- `validation_count = 12`
|
||||
|
||||
验证集指标大致为:
|
||||
|
||||
- `lgbm_mae = 1.349`
|
||||
- `deb_mae = 0.875`
|
||||
|
||||
这说明:
|
||||
|
||||
- LGBM 比以前样本更充足了
|
||||
- 但在验证集上仍然不如 DEB
|
||||
|
||||
所以当前它的定位仍然应该是:
|
||||
|
||||
- 辅助参考
|
||||
- 不替代 DEB
|
||||
|
||||
---
|
||||
|
||||
## 12. 为什么现在 EMOS 没有像 LGBM 那样明显涨样本
|
||||
|
||||
这点很容易误解。
|
||||
|
||||
答案不是“恢复失败”,而是两条链路对数据要求不一样。
|
||||
|
||||
### 12.1 LGBM
|
||||
|
||||
LGBM 更依赖:
|
||||
|
||||
- 长期真值
|
||||
- 基础 forecast 特征
|
||||
|
||||
这部分通过:
|
||||
|
||||
- `truth_records_store`
|
||||
- `training_feature_records_store`
|
||||
|
||||
已经改善很多。
|
||||
|
||||
### 12.2 EMOS
|
||||
|
||||
EMOS 更依赖:
|
||||
|
||||
- 某一时刻的概率快照/分布特征
|
||||
|
||||
如果过去那些 snapshot 没有长期保存下来,那么即使今天把真值补齐了:
|
||||
|
||||
- 也无法凭空重建完整 EMOS 样本
|
||||
|
||||
所以当前现实是:
|
||||
|
||||
- 真值问题已经大幅改善
|
||||
- 未来特征不会再继续丢
|
||||
- 但过去缺失的 EMOS 快照历史仍然限制样本增长
|
||||
|
||||
---
|
||||
|
||||
## 13. 当前最重要的工程判断
|
||||
|
||||
### 13.1 已经完成的
|
||||
|
||||
这些现在可以认为已经完成:
|
||||
|
||||
- 真值主存从运行态缓存里拆出
|
||||
- 真值 provenance 落库
|
||||
- revision 审计表落地
|
||||
- Wunderground 历史回填接通
|
||||
- `Taipei/Shenzhen` 真值口径修正
|
||||
- 长期训练特征表接通
|
||||
- `/ops` 已能可视化 truth / feature / EMOS / LGBM 覆盖情况
|
||||
|
||||
### 13.2 还没完成的
|
||||
|
||||
这些仍然是后续重点:
|
||||
|
||||
- EMOS 样本继续自然积累
|
||||
- shadow bucket brier 稳定下来
|
||||
- LGBM 验证效果超过 DEB
|
||||
- 让更多城市开始持续积累训练特征
|
||||
|
||||
---
|
||||
|
||||
## 14. 运维怎么看当前状态
|
||||
|
||||
现在最直接的入口是:
|
||||
|
||||
- `/ops`
|
||||
|
||||
这页已经能看到:
|
||||
|
||||
- 历史真值主表统计
|
||||
- 真值来源分布
|
||||
- 真值修订数量
|
||||
- 长期训练特征统计
|
||||
- `Taipei/Shenzhen` 的 WU 回填状态
|
||||
- 城市覆盖缺口
|
||||
- 模型城市覆盖
|
||||
- 城市覆盖矩阵
|
||||
|
||||
因此,运维现在可以快速回答:
|
||||
|
||||
- 哪些城市真值已经长期化
|
||||
- 哪些城市还没有特征积累
|
||||
- 哪些城市已经能支撑 EMOS/LGBM
|
||||
- 哪些城市目前仍然只能主要依赖 DEB
|
||||
|
||||
---
|
||||
|
||||
## 15. 推荐工作流
|
||||
|
||||
### 15.1 日常
|
||||
|
||||
1. 查看 `/ops`
|
||||
2. 看 `truth / feature / EMOS / LGBM` 覆盖有没有继续增长
|
||||
3. 看 `Taipei/Shenzhen` 的 WU 行数是否继续更新
|
||||
4. 看本地 EMOS 候选是否通过门禁
|
||||
5. 看 VPS 是否只加载已批准参数,不在低配机器上训练
|
||||
|
||||
### 15.2 周期性重训
|
||||
|
||||
建议在本地开发机执行,不建议在低配 VPS 上执行:
|
||||
|
||||
```powershell
|
||||
scp root@38.54.27.70:/var/lib/polyweather/polyweather.db E:\web\PolyWeather\data\polyweather-prod.db
|
||||
$env:POLYWEATHER_DB_PATH="E:\web\PolyWeather\data\polyweather-prod.db"
|
||||
$env:POLYWEATHER_RUNTIME_DATA_DIR="E:\web\PolyWeather\artifacts\local_runtime"
|
||||
python scripts\auto_retrain_probability_calibration.py --verbose --snapshot-limit 50000
|
||||
```
|
||||
|
||||
只有 `auto_retrain_report.json` 里 `ready_for_promotion=true` 时,才允许把候选 `default.json` 传回 VPS。
|
||||
|
||||
### 15.3 真值恢复/补数
|
||||
|
||||
当有新的历史真值补数或回填需要时:
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe scripts/restore_training_truth_history.py
|
||||
./venv/Scripts/python.exe scripts/restore_training_feature_history.py
|
||||
./venv/Scripts/python.exe scripts/backfill_recent_daily_actuals_from_metar.py --cities taipei shenzhen --lookback-days 14
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 脚本名里虽然还保留 `from_metar`
|
||||
- 但当前实现已经会按 `settlement_source` 自动分发
|
||||
- `wunderground` 会走 WU 历史回填分支
|
||||
|
||||
---
|
||||
|
||||
## 16. 当前最务实的结论
|
||||
|
||||
如果只用一句话概括当前状态:
|
||||
|
||||
**EMOS 和 LGBM 的工程基础已经补齐,但生产主概率仍必须由评估门禁控制;当前最正确的策略是继续以 `DEB/legacy` 为主路径,在本地训练 EMOS 候选,VPS 只加载已批准参数。**
|
||||
|
||||
更具体一点:
|
||||
|
||||
- `EMOS`
|
||||
- 已接好
|
||||
- 可训练
|
||||
- 可评估
|
||||
- 可 shadow
|
||||
- 通过门禁后可灰度
|
||||
- 不应在低配 VPS 上自动训练或自动主用
|
||||
|
||||
- `LGBM`
|
||||
- 已接好
|
||||
- 样本比以前更多
|
||||
- 但验证集还不如 DEB
|
||||
- 目前只能做辅助参考
|
||||
|
||||
- 数据层
|
||||
- 这次治理的真正价值,是防止未来继续丢历史
|
||||
- 这对两条模型链路都比继续“微调参数”更关键
|
||||
|
||||
---
|
||||
|
||||
## 17. 相关文档
|
||||
|
||||
若需要看更细分的历史说明,可继续参考:
|
||||
|
||||
- [EMOS_TRAINING_REPORT_ZH.md](/E:/web/PolyWeather/docs/EMOS_TRAINING_REPORT_ZH.md)
|
||||
- [LGBM_DAILY_HIGH_ZH.md](/E:/web/PolyWeather/docs/LGBM_DAILY_HIGH_ZH.md)
|
||||
- [PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md](/E:/web/PolyWeather/docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md)
|
||||
- [deep-research-report.md](/E:/web/PolyWeather/docs/deep-research-report.md)
|
||||
@@ -1,263 +0,0 @@
|
||||
# EMOS 训练与发布报告(2026-04-19)
|
||||
|
||||
## 1. 当前结论
|
||||
|
||||
- `EMOS` 工程链路已经接通:可以训练、评估、生成候选参数,并在前端以校准概率层展示。
|
||||
- 生产主概率当前不应默认使用 `emos_primary`。默认建议为 `legacy`;需要观察时使用 `emos_shadow`。
|
||||
- `emos_primary` 只允许在本地离线训练通过门禁、人工复核后手动灰度。
|
||||
- 低配 VPS(例如 1 vCPU / 2GB RAM)不适合做 EMOS 全量训练;VPS 只负责采集、服务和加载已批准的参数文件。
|
||||
- `LGBM` 当前仍不建议作为主路径,继续保持 `POLYWEATHER_LGBM_ENABLED=false`。
|
||||
|
||||
## 2. 最近两次训练结果
|
||||
|
||||
### 2.1 VPS 轻量训练:不通过
|
||||
|
||||
VPS 使用最近 `5000` 条 snapshot 训练的候选:
|
||||
|
||||
- 版本:`emos-auto-20260418204203`
|
||||
- 样本数:`791`
|
||||
- 结论:`hold`
|
||||
|
||||
| 指标 | 变化 |
|
||||
| :-- | --: |
|
||||
| `delta_crps` | `+0.004652` |
|
||||
| `delta_mae` | `+0.102623` |
|
||||
| `delta_bucket_hit_rate` | `-0.137800` |
|
||||
|
||||
解读:CRPS、MAE、桶命中全部弱于 legacy,因此不能晋级。
|
||||
|
||||
### 2.2 本地训练:通过门禁,但仍需灰度
|
||||
|
||||
本地电脑使用生产 SQLite 副本与最近 `50000` 条 snapshot 训练的候选:
|
||||
|
||||
- 版本:`emos-auto-20260418212046`
|
||||
- 样本数:`847`
|
||||
- 结论:`promote`
|
||||
|
||||
| 指标 | 变化 |
|
||||
| :-- | --: |
|
||||
| `delta_crps` | `-0.036170` |
|
||||
| `delta_mae` | `-0.007896` |
|
||||
| `delta_bucket_hit_rate` | `-0.009445` |
|
||||
|
||||
解读:
|
||||
|
||||
- CRPS 与 MAE 有改善,候选通过当前门禁。
|
||||
- 桶命中率轻微下降,虽然在门禁允许范围内,但仍建议先以 `emos_shadow` 观察,再决定是否切 `emos_primary`。
|
||||
|
||||
## 3. 生产运行策略
|
||||
|
||||
推荐生产 `.env`:
|
||||
|
||||
```env
|
||||
POLYWEATHER_PROBABILITY_ENGINE=legacy
|
||||
POLYWEATHER_PROBABILITY_CALIBRATION_FILE=/var/lib/polyweather/probability_calibration/default.json
|
||||
```
|
||||
|
||||
观察 EMOS 时:
|
||||
|
||||
```env
|
||||
POLYWEATHER_PROBABILITY_ENGINE=emos_shadow
|
||||
POLYWEATHER_PROBABILITY_CALIBRATION_FILE=/var/lib/polyweather/probability_calibration/default.json
|
||||
```
|
||||
|
||||
只有在候选连续通过评估、前端展示稳定、业务侧确认后,才切:
|
||||
|
||||
```env
|
||||
POLYWEATHER_PROBABILITY_ENGINE=emos_primary
|
||||
POLYWEATHER_PROBABILITY_CALIBRATION_FILE=/var/lib/polyweather/probability_calibration/default.json
|
||||
```
|
||||
|
||||
验证线上加载状态:
|
||||
|
||||
```bash
|
||||
docker compose exec -T polyweather_web python - <<'PY'
|
||||
from src.analysis.probability_calibration import load_calibration, resolve_probability_engine_mode
|
||||
cal = load_calibration()
|
||||
print("engine_mode =", resolve_probability_engine_mode())
|
||||
print("loaded_version =", cal.get("version"))
|
||||
print("sample_count =", (cal.get("metrics") or {}).get("sample_count"))
|
||||
print("has_global =", bool(cal.get("global")))
|
||||
PY
|
||||
```
|
||||
|
||||
## 4. 本地训练 SOP
|
||||
|
||||
### 4.1 拉取生产 SQLite 副本
|
||||
|
||||
推荐先在 VPS 上用 SQLite 在线备份生成快照:
|
||||
|
||||
```bash
|
||||
sqlite3 /var/lib/polyweather/polyweather.db ".backup '/var/lib/polyweather/polyweather-train-copy.db'"
|
||||
```
|
||||
|
||||
本地 PowerShell 拉取:
|
||||
|
||||
```powershell
|
||||
cd E:\web\PolyWeather
|
||||
scp root@38.54.27.70:/var/lib/polyweather/polyweather-train-copy.db E:\web\PolyWeather\data\polyweather-prod.db
|
||||
```
|
||||
|
||||
如果生产库写入压力很低,也可以直接拉主库副本:
|
||||
|
||||
```powershell
|
||||
scp root@38.54.27.70:/var/lib/polyweather/polyweather.db E:\web\PolyWeather\data\polyweather-prod.db
|
||||
```
|
||||
|
||||
### 4.2 本地训练
|
||||
|
||||
```powershell
|
||||
cd E:\web\PolyWeather
|
||||
$env:POLYWEATHER_DB_PATH="E:\web\PolyWeather\data\polyweather-prod.db"
|
||||
$env:POLYWEATHER_RUNTIME_DATA_DIR="E:\web\PolyWeather\artifacts\local_runtime"
|
||||
python scripts\auto_retrain_probability_calibration.py --verbose --snapshot-limit 50000
|
||||
```
|
||||
|
||||
如果本地机器仍然较慢,可先降到:
|
||||
|
||||
```powershell
|
||||
python scripts\auto_retrain_probability_calibration.py --verbose --snapshot-limit 20000
|
||||
```
|
||||
|
||||
训练报告:
|
||||
|
||||
```powershell
|
||||
Get-Content E:\web\PolyWeather\artifacts\local_runtime\probability_calibration\auto_retrain_report.json
|
||||
```
|
||||
|
||||
候选目录:
|
||||
|
||||
```text
|
||||
E:\web\PolyWeather\artifacts\local_runtime\probability_calibration\candidates\<version>\
|
||||
```
|
||||
|
||||
### 4.3 晋级判断
|
||||
|
||||
只有报告满足以下条件时,候选才可进入部署流程:
|
||||
|
||||
```json
|
||||
"ready_for_promotion": true
|
||||
```
|
||||
|
||||
同时人工检查:
|
||||
|
||||
- `delta_crps <= 0`
|
||||
- `delta_mae <= 0.05`
|
||||
- `delta_bucket_hit_rate >= -0.05`
|
||||
- 城市级结果没有出现关键城市大幅退化
|
||||
- 前端概率分布没有明显过度摊平或异常偏桶
|
||||
|
||||
## 5. 部署通过的候选
|
||||
|
||||
把本地候选上传到 VPS:
|
||||
|
||||
```powershell
|
||||
scp E:\web\PolyWeather\artifacts\local_runtime\probability_calibration\candidates\<version>\default.json root@38.54.27.70:/var/lib/polyweather/probability_calibration/default.json
|
||||
```
|
||||
|
||||
VPS 上优先设置为 `emos_shadow`:
|
||||
|
||||
```env
|
||||
POLYWEATHER_PROBABILITY_ENGINE=emos_shadow
|
||||
POLYWEATHER_PROBABILITY_CALIBRATION_FILE=/var/lib/polyweather/probability_calibration/default.json
|
||||
```
|
||||
|
||||
重启:
|
||||
|
||||
```bash
|
||||
cd /root/PolyWeather
|
||||
docker compose up -d polyweather_web
|
||||
```
|
||||
|
||||
观察稳定后再考虑 `emos_primary`。
|
||||
|
||||
## 6. VPS 定时训练策略
|
||||
|
||||
当前策略:**不在 VPS 上做 EMOS 定时训练**。
|
||||
|
||||
原因:
|
||||
|
||||
- 生产 SQLite 的 `probability_training_snapshots_store` 会持续增长。
|
||||
- 低配 VPS 全量扫描会造成 CPU/IO 飙升,严重时影响 SSH 和线上服务。
|
||||
- VPS 训练用较小 `--snapshot-limit` 虽然安全,但训练效果可能弱于本地。
|
||||
|
||||
如果曾经加过 cron,应删除:
|
||||
|
||||
```bash
|
||||
crontab -l | grep -v 'auto_retrain_probability_calibration.py' | crontab -
|
||||
```
|
||||
|
||||
确认:
|
||||
|
||||
```bash
|
||||
crontab -l
|
||||
```
|
||||
|
||||
## 7. 自动重训脚本说明
|
||||
|
||||
脚本:
|
||||
|
||||
```text
|
||||
python scripts\auto_retrain_probability_calibration.py
|
||||
```
|
||||
|
||||
默认行为:
|
||||
|
||||
- 生成新的 EMOS candidate。
|
||||
- 对 candidate 跑离线评估。
|
||||
- 写入候选目录和门禁报告。
|
||||
- 不覆盖线上 `default.json`。
|
||||
|
||||
重要参数:
|
||||
|
||||
- `--verbose`:输出训练/评估进度。
|
||||
- `--snapshot-limit N`:只使用最近 N 条 snapshot。
|
||||
- `--promote-if-passed`:门禁通过后覆盖目标参数文件。
|
||||
- `--run-tests`:晋级前跑测试。
|
||||
|
||||
当前不建议在 VPS 使用 `--promote-if-passed`。本地训练通过后,仍优先人工上传并使用 `emos_shadow`。
|
||||
|
||||
## 8. 门禁阈值
|
||||
|
||||
默认阈值:
|
||||
|
||||
- `POLYWEATHER_EMOS_AUTO_MIN_SAMPLES=50`
|
||||
- `POLYWEATHER_EMOS_AUTO_MAX_DELTA_CRPS=0`
|
||||
- `POLYWEATHER_EMOS_AUTO_MAX_DELTA_MAE=0.05`
|
||||
- `POLYWEATHER_EMOS_AUTO_MIN_DELTA_BUCKET_HIT_RATE=-0.05`
|
||||
|
||||
解释:
|
||||
|
||||
- `CRPS` 不允许比 legacy 更差。
|
||||
- `MAE` 最多允许轻微退化 `0.05`。
|
||||
- `bucket_hit_rate` 是业务参考指标,但对结算边界敏感,不单独作为唯一判断。
|
||||
|
||||
## 9. 前端说明
|
||||
|
||||
今日日内分析中的概率区展示的是当前生产概率引擎输出:
|
||||
|
||||
- `legacy`:展示现有动态概率。
|
||||
- `emos_shadow`:用户主概率仍为 legacy,EMOS 仅用于对照和评估。
|
||||
- `emos_primary`:用户主概率使用 EMOS 校准分布。
|
||||
|
||||
对外文案应避免暗示“EMOS 一定更准”。推荐解释为:
|
||||
|
||||
> EMOS 是 PolyWeather 基于 DEB 路径、多模型集合、METAR 实测进度和历史误差结构生成的统计校准概率,不是外部天气模型,也不是直接 API 结果。
|
||||
|
||||
## 10. 已验证
|
||||
|
||||
本地训练链路已验证:
|
||||
|
||||
```text
|
||||
python scripts\auto_retrain_probability_calibration.py --verbose --snapshot-limit 50000
|
||||
```
|
||||
|
||||
测试链路已验证:
|
||||
|
||||
```text
|
||||
python -m pytest tests\test_auto_retrain_probability_calibration.py tests\test_probability_calibration.py tests\test_probability_rollout.py
|
||||
```
|
||||
|
||||
当前工程结论:
|
||||
|
||||
**EMOS 可以继续本地训练与 shadow 观察,但生产主概率不应因为“机制接好”而默认切到 `emos_primary`。**
|
||||
@@ -93,7 +93,7 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||
```
|
||||
|
||||
只影响按钮跳转,不影响核心页面加载。
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
# LightGBM 日最高温模型(中文)
|
||||
|
||||
最后更新:`2026-04-18`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
这套 `LightGBM` 模型是给 PolyWeather 增加一个轻量级的统计学习预测源。
|
||||
|
||||
它的定位不是替代:
|
||||
|
||||
- `DEB`
|
||||
- `EMOS`
|
||||
- `ECMWF / GFS / GEM / JMA / ICON / Open-Meteo / MGM / NWS`
|
||||
|
||||
而是作为一个新的点预测源:
|
||||
|
||||
`现有模型 + 观测特征 -> LGBM -> 并入 current_forecasts -> DEB -> EMOS`
|
||||
|
||||
第一版只做:
|
||||
|
||||
- `D0` 当日最高温预测
|
||||
|
||||
不做:
|
||||
|
||||
- `D1-D3`
|
||||
- 小时级曲线
|
||||
- 原始独立概率分布
|
||||
- 独立结算源
|
||||
|
||||
注意:前端出现的“LGBM 校准概率”不是把 LGBM 模型票数直接当成概率,而是概率层基于 LGBM / DEB / 观测上下文输出的校准分布。模型共识只保留为解释性参考。
|
||||
|
||||
## 2. 适用场景
|
||||
|
||||
这条链路是为低资源 VPS 准备的。
|
||||
|
||||
当前项目线上环境只有 `2GB RAM` 时,不适合引入 `TimesFM` 这类大模型,但适合用 `LightGBM` 做轻量推理。
|
||||
|
||||
当前方案是:
|
||||
|
||||
1. 训练离线完成
|
||||
2. 训练产物直接提交到仓库
|
||||
3. VPS 线上只加载模型文件并推理
|
||||
4. VPS 不训练,不起额外服务
|
||||
|
||||
## 3. 文件结构
|
||||
|
||||
核心文件如下:
|
||||
|
||||
- 运行时推理:
|
||||
- [src/models/lgbm_daily_high.py](/E:/web/PolyWeather/src/models/lgbm_daily_high.py)
|
||||
- 特征构建:
|
||||
- [src/models/lgbm_features.py](/E:/web/PolyWeather/src/models/lgbm_features.py)
|
||||
- 训练脚本:
|
||||
- [scripts/train_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/train_lgbm_daily_high.py)
|
||||
- 训练报告脚本:
|
||||
- [scripts/report_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/report_lgbm_daily_high.py)
|
||||
- 模型文件:
|
||||
- [artifacts/models/lgbm_daily_high.txt](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high.txt)
|
||||
- 模型 schema / 指标:
|
||||
- [artifacts/models/lgbm_daily_high_schema.json](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high_schema.json)
|
||||
|
||||
接入链路位置:
|
||||
|
||||
- Web API 聚合:
|
||||
- [web/analysis_service.py](/E:/web/PolyWeather/web/analysis_service.py)
|
||||
- 共享趋势引擎:
|
||||
- [src/analysis/trend_engine.py](/E:/web/PolyWeather/src/analysis/trend_engine.py)
|
||||
|
||||
## 4. 特征说明
|
||||
|
||||
第一版特征固定为以下几组。
|
||||
|
||||
### 4.1 历史日高温特征
|
||||
|
||||
- `actual_high_lag_1`
|
||||
- `actual_high_lag_2`
|
||||
- `actual_high_lag_3`
|
||||
- `actual_high_lag_7`
|
||||
- `actual_high_mean_7`
|
||||
- `actual_high_mean_14`
|
||||
- `actual_high_trend_3`
|
||||
|
||||
### 4.2 当天模型特征
|
||||
|
||||
- `Open-Meteo`
|
||||
- `ECMWF`
|
||||
- `GFS`
|
||||
- `GEM`
|
||||
- `JMA`
|
||||
- `ICON`
|
||||
- `MGM`
|
||||
- `NWS`
|
||||
- `deb_prediction`
|
||||
- `model_median`
|
||||
- `model_spread`
|
||||
|
||||
### 4.3 当前观测特征
|
||||
|
||||
- `current_temp`
|
||||
- `max_so_far`
|
||||
- `humidity`
|
||||
- `wind_speed_kt`
|
||||
- `visibility_mi`
|
||||
|
||||
### 4.4 时间与状态特征
|
||||
|
||||
- `local_hour`
|
||||
- `month`
|
||||
- `weekday`
|
||||
- `peak_status_code`
|
||||
|
||||
其中:
|
||||
|
||||
- `before = 0`
|
||||
- `in_window = 1`
|
||||
- `past = 2`
|
||||
|
||||
## 5. 训练数据来源
|
||||
|
||||
训练数据主要来自两份运行时历史文件:
|
||||
|
||||
- [data/daily_records.json](/E:/web/PolyWeather/data/daily_records.json)
|
||||
- [data/probability_training_snapshots.jsonl](/E:/web/PolyWeather/data/probability_training_snapshots.jsonl)
|
||||
|
||||
作用分工:
|
||||
|
||||
- `daily_records.json`
|
||||
- 提供 `actual_high`
|
||||
- 提供当天各模型 forecast
|
||||
- 提供历史 `deb_prediction`
|
||||
|
||||
- `probability_training_snapshots.jsonl`
|
||||
- 提供 `max_so_far`
|
||||
- 提供 `peak_status`
|
||||
- 提供观测特征快照
|
||||
|
||||
为后续重训,概率快照归档现在还会额外写入:
|
||||
|
||||
- `current_temp`
|
||||
- `humidity`
|
||||
- `wind_speed_kt`
|
||||
- `visibility_mi`
|
||||
- `local_hour`
|
||||
|
||||
对应代码:
|
||||
|
||||
- [src/analysis/probability_snapshot_archive.py](/E:/web/PolyWeather/src/analysis/probability_snapshot_archive.py)
|
||||
|
||||
## 6. 训练流程
|
||||
|
||||
训练脚本:
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe scripts/train_lgbm_daily_high.py
|
||||
```
|
||||
|
||||
训练流程如下:
|
||||
|
||||
1. 从历史文件构造监督样本
|
||||
2. 目标值固定为 `actual_high`
|
||||
3. 按日期做简单的时间顺序切分
|
||||
4. 最后约 20% 做验证集
|
||||
5. 先训练并评估验证集
|
||||
6. 再用全量样本训练最终模型
|
||||
7. 输出模型文件和 schema 文件
|
||||
|
||||
输出产物:
|
||||
|
||||
- [artifacts/models/lgbm_daily_high.txt](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high.txt)
|
||||
- [artifacts/models/lgbm_daily_high_schema.json](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high_schema.json)
|
||||
|
||||
## 7. 如何看训练结果
|
||||
|
||||
查看训练报告:
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe scripts/report_lgbm_daily_high.py
|
||||
```
|
||||
|
||||
这个脚本会读取 schema,并打印:
|
||||
|
||||
- `Sample Count`
|
||||
- `Train Count`
|
||||
- `Valid Count`
|
||||
- `LGBM MAE`
|
||||
- `DEB MAE`
|
||||
- `Best Single MAE`
|
||||
- `Median MAE`
|
||||
- `Winner`
|
||||
|
||||
当前这版训练结果是:
|
||||
|
||||
- `sample_count = 29`
|
||||
- `validation_count = 12`
|
||||
- `validation.lgbm_mae = 2.975`
|
||||
- `validation.deb_mae = 2.267`
|
||||
- `validation.best_single_mae = 1.167`
|
||||
|
||||
这说明:
|
||||
|
||||
- 当前 `LGBM` 链路已经可用
|
||||
- 但现阶段验证集表现还没有超过 `DEB`
|
||||
- 所以默认配置仍建议保持关闭
|
||||
|
||||
## 8. 线上运行逻辑
|
||||
|
||||
运行时推理逻辑不是“直接替代 DEB”,而是:
|
||||
|
||||
1. 先收集现有模型 forecast
|
||||
2. 先算一版基线 `DEB`
|
||||
3. 把这版 `DEB` 当作 `LGBM` 的一个输入特征
|
||||
4. 输出 `LGBM` 点预测
|
||||
5. 把 `LGBM` 注入 `current_forecasts`
|
||||
6. 重新计算最终 `DEB`
|
||||
|
||||
这样做的原因是:
|
||||
|
||||
- `LGBM` 需要吃到 `deb_prediction` 特征
|
||||
- 但最终 `DEB` 又要把 `LGBM` 当成一个新的输入模型
|
||||
|
||||
## 8.1 前端概率展示口径
|
||||
|
||||
当前网页的概率区按以下顺序解释:
|
||||
|
||||
1. 如果后端 `probabilities.engine` 表示 LGBM 校准概率可用,则标题显示为 `LGBM 校准概率`。
|
||||
2. 如果 LGBM 不可用,但 EMOS / legacy 概率可用,则显示为 `校准模型概率`。
|
||||
3. 模型舍入票数只保留为“模型共识参考”,用于说明哪些模型四舍五入后落在同一温度档,不作为最终命中概率。
|
||||
4. 市场价格只保留为“市场参考”,不和校准概率混成同一结论。
|
||||
|
||||
这能避免用户把 `4/8 模型支持 82°F` 误读成 `82°F 有 50% 概率`。模型共识是解释层,概率引擎才是结论层。
|
||||
|
||||
## 9. 环境变量
|
||||
|
||||
示例配置见:
|
||||
|
||||
- [.env.example](/E:/web/PolyWeather/.env.example)
|
||||
|
||||
相关变量:
|
||||
|
||||
```env
|
||||
POLYWEATHER_LGBM_ENABLED=false
|
||||
POLYWEATHER_LGBM_MODEL_PATH=/app/artifacts/models/lgbm_daily_high.txt
|
||||
POLYWEATHER_LGBM_SCHEMA_PATH=/app/artifacts/models/lgbm_daily_high_schema.json
|
||||
POLYWEATHER_LGBM_MIN_HISTORY_POINTS=3
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `POLYWEATHER_LGBM_ENABLED`
|
||||
- 是否启用运行时推理
|
||||
- `POLYWEATHER_LGBM_MODEL_PATH`
|
||||
- 模型文件路径
|
||||
- `POLYWEATHER_LGBM_SCHEMA_PATH`
|
||||
- schema 文件路径
|
||||
- `POLYWEATHER_LGBM_MIN_HISTORY_POINTS`
|
||||
- 某城市最低历史样本门槛
|
||||
|
||||
默认是 `3`,原因不是最理想,而是当前整体样本仍然偏少。
|
||||
|
||||
如果门槛设太高,很多城市现在根本不会触发 `LGBM`。
|
||||
|
||||
## 10. VPS 部署建议
|
||||
|
||||
如果你的 VPS 只有 `2GB RAM`:
|
||||
|
||||
- 可以跑这套 `LightGBM`
|
||||
- 不要在 VPS 上训练
|
||||
- 不要起额外模型服务
|
||||
|
||||
推荐方式:
|
||||
|
||||
1. 在本地或开发环境训练
|
||||
2. 提交模型产物
|
||||
3. VPS 拉代码
|
||||
4. 开启 `POLYWEATHER_LGBM_ENABLED=true`
|
||||
5. 重启主服务
|
||||
|
||||
不推荐:
|
||||
|
||||
- 在 VPS 上跑训练脚本
|
||||
- 把 `LightGBM` 当成长任务服务单独部署
|
||||
- 同时引入大模型推理
|
||||
|
||||
## 11. 当前结论
|
||||
|
||||
这条链路已经完成了:
|
||||
|
||||
- 离线训练
|
||||
- 模型产物固化
|
||||
- 运行时懒加载
|
||||
- Web / 共享分析链路注入
|
||||
- 前端模型类型兼容
|
||||
|
||||
但当前样本量仍偏少,所以建议运营策略是:
|
||||
|
||||
1. 先继续积累历史 `actual_high`
|
||||
2. 继续积累概率快照观测字段
|
||||
3. 定期重训
|
||||
4. 只有当验证集 `MAE` 持续接近或优于 `DEB` 时,再考虑默认线上开启
|
||||
|
||||
## 12. 常用命令
|
||||
|
||||
### 训练
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe scripts/train_lgbm_daily_high.py
|
||||
```
|
||||
|
||||
### 查看训练报告
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe scripts/report_lgbm_daily_high.py
|
||||
```
|
||||
|
||||
### 本地测试
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe -m pytest tests/test_lgbm_features.py tests/test_lgbm_daily_high.py
|
||||
```
|
||||
|
||||
### 编译检查
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe -m compileall src web scripts tests
|
||||
```
|
||||
@@ -99,7 +99,7 @@ Web API 会把这部分元数据挂到:
|
||||
- RDPS
|
||||
- HRDPS
|
||||
|
||||
亚洲城市更依赖本地观测增强层,例如 JMA、KMA、NMC、HKO、CWA、METAR、TAF。
|
||||
亚洲城市更依赖本地观测增强层,例如 JMA、AMOS(首尔/釜山)、NMC、HKO、CWA、METAR、TAF。
|
||||
|
||||
## 4. DEB 家族去重
|
||||
|
||||
@@ -151,7 +151,6 @@ HRDPS > RDPS > GDPS > GEM
|
||||
- MGM
|
||||
- NWS
|
||||
- HKO
|
||||
- LGBM
|
||||
- Open-Meteo
|
||||
|
||||
ECMWF IFS 与 ECMWF AIFS 分开保留,因为前者是传统 NWP,后者是 AIFS 模型。
|
||||
@@ -219,7 +218,6 @@ raw current_forecasts
|
||||
当前前端把三层拆开展示:
|
||||
|
||||
- `模型区间与分歧`:解释不同模型当前给出的最高温范围和分歧,不直接等于命中概率。
|
||||
- `校准模型概率`:由当前生产概率引擎输出温度桶概率;默认可保持 legacy,EMOS / LGBM 只在评估通过、显式启用或 shadow 对照时进入展示。
|
||||
- `市场参考`:只展示市场价格和错价背景,不再作为主判断,也不默认输出 BUY YES / BUY NO。
|
||||
|
||||
模型票数只用于解释“哪些模型支持某个档位”,不等于最终概率。最终概率应优先读取 `probabilities.engine` 对应的校准分布。
|
||||
@@ -230,7 +228,6 @@ raw current_forecasts
|
||||
|
||||
- `tests/test_multi_model_sources.py`
|
||||
- `tests/test_deb_model_family.py`
|
||||
- `tests/test_lgbm_features.py`
|
||||
|
||||
重点覆盖:
|
||||
|
||||
|
||||
@@ -114,12 +114,6 @@ python scripts/check_ops_health.py --base-url http://127.0.0.1:8000
|
||||
|
||||
目前已覆盖:
|
||||
|
||||
- `prewarm` worker 是否启用、线程 / heartbeat 是否活着
|
||||
- 最近一轮 prewarm 的:
|
||||
- `cycle_count`
|
||||
- `success_count / failure_count`
|
||||
- `last_started_at / last_finished_at`
|
||||
- `last_summary_ok / last_detail_ok / last_market_ok`
|
||||
- 缓存桶条目数:
|
||||
- `api_cache`
|
||||
- `metar`
|
||||
|
||||
@@ -25,7 +25,6 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
- 系统健康
|
||||
- SQLite / rollout / metrics 摘要
|
||||
- 支付运行态
|
||||
- prewarm worker 运行态
|
||||
- 缓存桶状态与 summary cache hit/miss
|
||||
- 当前会员
|
||||
- 周榜
|
||||
@@ -101,11 +100,9 @@ python scripts/reconcile_subscription_by_email.py --email <user_email>
|
||||
|
||||
## 7. 备注
|
||||
|
||||
### 7.1 当前 prewarm / 缓存观测项
|
||||
|
||||
`/ops` 里的系统状态卡目前已额外展示:
|
||||
|
||||
- `prewarm` 是否启用
|
||||
- `thread_alive` / `heartbeat_age_sec`
|
||||
- 最近一轮:
|
||||
- `cycle_count`
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
# 概率训练样本归档说明(中文)
|
||||
|
||||
最后更新:`2026-04-19`
|
||||
|
||||
## 1. 目的
|
||||
|
||||
这份文档说明两件事:
|
||||
|
||||
1. 为什么 `EMOS` 训练不能只依赖历史实测天气
|
||||
2. 未来如何持续沉淀“历史预测记录”,让概率引擎越训越稳
|
||||
|
||||
一句话结论:
|
||||
|
||||
- 历史实测天气只能补 `actual_high`
|
||||
- 真正决定 `EMOS` 训练质量的是“当时那一刻的预测快照”
|
||||
|
||||
## 2. 什么是“历史预测记录”
|
||||
|
||||
对 PolyWeather 来说,一条可训练的历史预测记录,至少应该包含这些字段:
|
||||
|
||||
- `city`
|
||||
- `timestamp`
|
||||
- `date`
|
||||
- `raw_mu`
|
||||
- `raw_sigma`
|
||||
- `deb_prediction`
|
||||
- `ensemble p10 / p50 / p90`
|
||||
- `multi-model forecasts`
|
||||
- `max_so_far`
|
||||
- `peak_status`
|
||||
- `prob_snapshot`
|
||||
- `probability_engine`
|
||||
- `calibration_mode`
|
||||
- `calibration_version`
|
||||
- `raw_mu / raw_sigma`
|
||||
- `calibrated_mu / calibrated_sigma`
|
||||
- `shadow_distribution`
|
||||
- 当天最终 `actual_high`
|
||||
- 当天最终 `settlement bucket`
|
||||
|
||||
这类记录的核心价值是:
|
||||
|
||||
- 还原“当时系统实际看到什么”
|
||||
- 再对照“后来真实发生了什么”
|
||||
|
||||
只有这两者成对,`EMOS` 才能学习偏差。
|
||||
|
||||
## 3. 为什么不能只用历史天气实测
|
||||
|
||||
历史天气 CSV 只能告诉你:
|
||||
|
||||
- 当天最高温是多少
|
||||
- 某小时温度是多少
|
||||
|
||||
但它不能告诉你:
|
||||
|
||||
- 当天早上 09:00 时,系统的 `mu` 是多少
|
||||
- 当时的 `ensemble spread` 是多少
|
||||
- 当时 `DEB` 怎么看
|
||||
- 当时的 top bucket 是什么
|
||||
|
||||
所以:
|
||||
|
||||
- 历史实测天气是标签
|
||||
- 历史预测记录才是训练输入
|
||||
|
||||
缺少后者,EMOS 只能学到很有限的东西。
|
||||
|
||||
## 4. 当前项目里已经有的基础
|
||||
|
||||
### 4.1 已有历史日记录
|
||||
|
||||
文件:
|
||||
|
||||
- [daily_records.json](/E:/web/PolyWeather/data/daily_records.json)
|
||||
|
||||
当前已经保存了一部分训练相关字段,例如:
|
||||
|
||||
- `forecasts`
|
||||
- `actual_high`
|
||||
- `deb_prediction`
|
||||
- `mu`
|
||||
- `prob_snapshot`
|
||||
- `shadow_prob_snapshot`
|
||||
- `probability_calibration`
|
||||
- `probability_features`
|
||||
|
||||
这已经是“历史预测记录”的雏形。
|
||||
|
||||
### 4.2 已有历史天气 CSV
|
||||
|
||||
目录:
|
||||
|
||||
- [data/historical](/E:/web/PolyWeather/data/historical)
|
||||
|
||||
它们可以帮助补:
|
||||
|
||||
- `actual_high`
|
||||
- `settlement history`
|
||||
|
||||
但不能替代预测快照归档。
|
||||
|
||||
## 5. 未来应该怎么存历史预测记录
|
||||
|
||||
推荐做法是:
|
||||
|
||||
### 5.1 固定时点归档
|
||||
|
||||
每天为每个重点城市固定存几次快照,例如:
|
||||
|
||||
- 当地 `09:00`
|
||||
- 当地 `12:00`
|
||||
- 当地 `15:00`
|
||||
|
||||
这样能确保每个交易日都有稳定可比样本。
|
||||
|
||||
### 5.2 关键变化时补充归档
|
||||
|
||||
除了固定时点,还应该在以下情况额外存一次:
|
||||
|
||||
- `max_so_far` 创新高
|
||||
- `mu` 变化超过阈值
|
||||
- `top bucket` 发生变化
|
||||
- `shadow top bucket` 发生变化
|
||||
|
||||
这样能捕捉真正有训练价值的转折点。
|
||||
|
||||
### 5.3 建议的存储格式
|
||||
|
||||
建议新增一个文件,例如:
|
||||
|
||||
- `data/probability_training_snapshots.jsonl`
|
||||
|
||||
每一行保存一条 JSON 记录。
|
||||
|
||||
优点:
|
||||
|
||||
- 追加写入简单
|
||||
- 后续导出训练集方便
|
||||
- 不容易因为单个大 JSON 文件损坏而全盘受影响
|
||||
|
||||
## 6. 一条建议的快照结构
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"city": "ankara",
|
||||
"timestamp": "2026-03-20T12:00:00+03:00",
|
||||
"date": "2026-03-20",
|
||||
"raw_mu": 15.2,
|
||||
"raw_sigma": 1.2,
|
||||
"deb_prediction": 15.4,
|
||||
"ensemble": {
|
||||
"p10": 14.8,
|
||||
"median": 15.8,
|
||||
"p90": 17.9
|
||||
},
|
||||
"multi_model": {
|
||||
"ECMWF": 15.8,
|
||||
"GFS": 14.1,
|
||||
"ICON": 15.9,
|
||||
"GEM": 16.5,
|
||||
"JMA": 14.5
|
||||
},
|
||||
"max_so_far": 15.0,
|
||||
"peak_status": "before",
|
||||
"prob_snapshot": [
|
||||
{"v": 15, "p": 0.552},
|
||||
{"v": 16, "p": 0.377}
|
||||
],
|
||||
"shadow_prob_snapshot": [
|
||||
{"v": 15, "p": 0.324},
|
||||
{"v": 16, "p": 0.238}
|
||||
],
|
||||
"probability_engine": "legacy",
|
||||
"probability_mode": "emos_shadow",
|
||||
"calibration_mode": "emos_shadow",
|
||||
"calibration_version": "emos-20260320130245",
|
||||
"calibrated_mu": 15.4,
|
||||
"calibrated_sigma": 1.1
|
||||
}
|
||||
```
|
||||
|
||||
当天结束后,再由后处理脚本回填:
|
||||
|
||||
- `actual_high`
|
||||
- `settlement_bucket`
|
||||
|
||||
当前前端把这类快照解释为“校准模型概率”。如果 `probability_engine` 为 LGBM 相关值,则显示为 LGBM 校准概率;模型舍入票数和市场价格只用于解释,不直接作为最终概率。
|
||||
|
||||
## 7. 现阶段你可以执行的命令
|
||||
|
||||
### 7.1 回填历史天气 CSV
|
||||
|
||||
```bash
|
||||
python scripts/backfill_historical_weather.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 补全 30 城市历史天气时序 CSV
|
||||
|
||||
### 7.2 从历史 CSV 构建日级结算标签
|
||||
|
||||
```bash
|
||||
python scripts/build_settlement_history_from_csv.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 生成 [settlement_history.json](/E:/web/PolyWeather/artifacts/probability_calibration/settlement_history.json)
|
||||
|
||||
### 7.3 导出当前训练样本
|
||||
|
||||
```bash
|
||||
python scripts/export_probability_training_dataset.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 生成 [training_samples.json](/E:/web/PolyWeather/artifacts/probability_calibration/training_samples.json)
|
||||
|
||||
### 7.4 重训 EMOS
|
||||
|
||||
推荐在本地电脑使用生产 SQLite 副本训练,不建议在低配 VPS 上训练:
|
||||
|
||||
```powershell
|
||||
scp root@38.54.27.70:/var/lib/polyweather/polyweather.db E:\web\PolyWeather\data\polyweather-prod.db
|
||||
$env:POLYWEATHER_DB_PATH="E:\web\PolyWeather\data\polyweather-prod.db"
|
||||
$env:POLYWEATHER_RUNTIME_DATA_DIR="E:\web\PolyWeather\artifacts\local_runtime"
|
||||
python scripts\auto_retrain_probability_calibration.py --verbose --snapshot-limit 50000
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 生成新的候选 `default.json`
|
||||
- 同时生成 `evaluation_report.json` 与 `auto_retrain_report.json`
|
||||
- 不自动覆盖线上参数
|
||||
|
||||
### 7.5 离线评估训练效果
|
||||
|
||||
```bash
|
||||
python scripts/evaluate_probability_calibration.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 生成 [evaluation_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/evaluation_report.json)
|
||||
|
||||
### 7.6 回填 shadow 结果到历史记录
|
||||
|
||||
```bash
|
||||
python scripts/backfill_probability_shadow_history.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 把 `shadow_prob_snapshot` 和 `probability_calibration` 回填到 [daily_records.json](/E:/web/PolyWeather/data/daily_records.json)
|
||||
|
||||
### 7.7 生成线上 shadow 滚动报表
|
||||
|
||||
```bash
|
||||
python scripts/build_probability_shadow_report.py
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
- 生成 [shadow_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/shadow_report.json)
|
||||
|
||||
## 8. 推荐的一整套重训流程
|
||||
|
||||
如果过了十天、半个月,想重新训练一次,当前推荐流程是:
|
||||
|
||||
```powershell
|
||||
scp root@38.54.27.70:/var/lib/polyweather/polyweather.db E:\web\PolyWeather\data\polyweather-prod.db
|
||||
$env:POLYWEATHER_DB_PATH="E:\web\PolyWeather\data\polyweather-prod.db"
|
||||
$env:POLYWEATHER_RUNTIME_DATA_DIR="E:\web\PolyWeather\artifacts\local_runtime"
|
||||
python scripts\auto_retrain_probability_calibration.py --verbose --snapshot-limit 50000
|
||||
```
|
||||
|
||||
只有 `auto_retrain_report.json` 中 `ready_for_promotion=true`,才把候选参数传回 VPS,并优先用 `emos_shadow` 观察。
|
||||
|
||||
如果只是做历史真值补数,才需要额外执行:
|
||||
|
||||
```bash
|
||||
python scripts/backfill_historical_weather.py
|
||||
python scripts/build_settlement_history_from_csv.py
|
||||
```
|
||||
|
||||
## 9. 怎么判断这次训练有没有进步
|
||||
|
||||
重训后,不要只看一个指标。
|
||||
|
||||
至少看这 4 个:
|
||||
|
||||
1. `CRPS`
|
||||
- 越低越好
|
||||
|
||||
2. `MAE`
|
||||
- 越低越好
|
||||
- 至少不要明显变差
|
||||
|
||||
3. `Bucket Hit Rate`
|
||||
- 越高越好
|
||||
- 这是业务上非常关键的指标
|
||||
|
||||
4. `Bucket Brier`
|
||||
- 越低越好
|
||||
- 反映概率分布质量
|
||||
|
||||
当前自动门禁至少要求:
|
||||
|
||||
- `CRPS` 下降
|
||||
- `MAE` 最多轻微退化 `0.05`
|
||||
- `Bucket Hit Rate` 退化不超过 `0.05`
|
||||
|
||||
人工复核还应看城市级结果,避免少数关键城市大幅退化。`Bucket Hit Rate` 受整数结算边界影响大,不能单独作为唯一判断。
|
||||
|
||||
## 10. 当前最重要的现实判断
|
||||
|
||||
过去的“完整历史预测记录”通常没法完全补出来,除非:
|
||||
|
||||
1. 你之前就存过
|
||||
2. 你接入了支持 forecast archive 的商业数据源
|
||||
|
||||
所以现实里最重要的不是“把过去全补齐”,而是:
|
||||
|
||||
- 从现在开始系统化归档
|
||||
- 每天稳定沉淀可训练样本
|
||||
- 定期离线重训
|
||||
|
||||
## 11. 推荐的下一步
|
||||
|
||||
最值得做的改造是:
|
||||
|
||||
1. 新增 `probability_training_snapshots.jsonl`
|
||||
2. 每次分析时自动追加一条快照
|
||||
3. 当天结束后自动回填 `actual_high`
|
||||
4. 每 1-2 周在本地电脑重新训练一次
|
||||
5. VPS 只加载通过评估的参数文件,不做全量训练
|
||||
|
||||
## 12. 总结
|
||||
|
||||
如果只记住一句话,就记这个:
|
||||
|
||||
**EMOS 要想越训越好,关键不是多下载一点历史天气,而是持续保存“当时系统看到的预测快照”。**
|
||||
@@ -0,0 +1,53 @@
|
||||
# 外部服务依赖总览
|
||||
|
||||
最后更新:`2026-05-23`
|
||||
|
||||
项目调用了 20 个外部服务,按状态分为三类。
|
||||
|
||||
## 核心(必须有,挂了服务不可用)
|
||||
|
||||
| 服务 | 用途 | 状态 |
|
||||
| ---------------------- | --------------------- | ---- |
|
||||
| Open-Meteo | 52 城天气预报 | ✅ |
|
||||
| AviationWeather (NOAA) | METAR/TAF 航空观测 | ✅ |
|
||||
| MADIS (NOAA) | 美国 5 分钟高频观测 | ✅ |
|
||||
| Supabase | 用户认证 + 订阅 | ✅ |
|
||||
| Telegram Bot API | Bot 消息 + 群成员检查 | ✅ |
|
||||
| KNMI | Amsterdam 10 分钟观测 | ✅ |## 国家气象源(特定城市必须)
|
||||
|
||||
| 服务 | 城市 | 状态 |
|
||||
| -------------------- | --------------------- | ----------- |
|
||||
| JMA (日本) | Tokyo | ✅ |
|
||||
| KMA + AMOS (韩国) | Seoul, Busan | ✅ |
|
||||
| AMSC AWOS (中国) | 北京/上海/广州等 6 城 | ✅ |
|
||||
| MGM (土耳其) | Ankara, Istanbul | ✅ |
|
||||
| FMI (芬兰) | Helsinki | ✅ |
|
||||
| HKO (香港) | Hong Kong | ✅ |
|
||||
| CWA (台湾) | Taipei | ✅ |
|
||||
| NMC (中国) | 国内城市 fallback | ✅ |
|
||||
| Singapore MSS | Singapore | ✅ |
|
||||
| IMGW (波兰) | Warsaw | ⚠️ 未配 key |
|
||||
| Russia pogodaiklimat | Moscow | ❌ 已移除 |
|
||||
|
||||
## 可选 / 已禁用
|
||||
|
||||
| 服务 | 用途 | 状态 |
|
||||
| -------------- | ------------- | ----------- |
|
||||
| OpenWeatherMap | 天气 fallback | ⚠️ 未配 key |
|
||||
| VisualCrossing | 历史天气 | ⚠️ 未配 key |
|
||||
| Meteoblue | 天气预报 | ❌ 已移除 |
|
||||
| SynopticData | 美国站点观测 | ⚠️ 未配 key |
|
||||
|
||||
## AI / 其他
|
||||
|
||||
| 服务 | 用途 | 状态 |
|
||||
| ----------------- | ---------------- | ----------- |
|
||||
| MiMo (xiaomimimo) | 城市分析 AI 评论 | ✅ 当前使用 |
|
||||
| DeepSeek | AI fallback | - 备用 |
|
||||
| Groq | AI commentary | ❌ 已移除 |
|
||||
| Polygon RPC | 链上支付 | ✅ |
|
||||
| WalletConnect | 前端钱包连接 | ⚠️ 未配 key |
|
||||
|
||||
## 合计
|
||||
|
||||
15 个在用,3 个可选/未配置,3 个已移除。
|
||||
@@ -1,4 +1,4 @@
|
||||
# Supabase + 登录 + 支付接入说明(v1.5.1)
|
||||
# Supabase + 登录 + 支付接入说明(v1.7.0)
|
||||
|
||||
最后更新:`2026-03-14`
|
||||
|
||||
@@ -92,20 +92,7 @@ POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
|
||||
```
|
||||
|
||||
## 5. 钱包异动频道拆分(推荐)
|
||||
|
||||
如果要把“钱包异动监控”发到独立频道:
|
||||
|
||||
```env
|
||||
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=-1003821482461
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 设置了 `POLYMARKET_WALLET_ACTIVITY_CHAT_ID(S)` 后,钱包异动推送优先发该频道。
|
||||
- 未设置时,回退到全局 `TELEGRAM_CHAT_IDS/TELEGRAM_CHAT_ID`。
|
||||
|
||||
## 6. 验证步骤
|
||||
## 5. 验证步骤
|
||||
|
||||
1. 登录后请求 `/api/auth/me`,确认 `authenticated=true`。
|
||||
2. 请求 `/api/payments/config`,确认 `enabled=true`、`configured=true`。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 技术债与工程待办(v1.6.0)
|
||||
# 技术债与工程待办(v1.7.0)
|
||||
|
||||
最后更新:`2026-05-10`
|
||||
|
||||
@@ -29,7 +29,6 @@ flowchart TD
|
||||
end
|
||||
|
||||
subgraph S["状态与概率"]
|
||||
S1["EMOS 本地训练与 shadow 发布门禁"]
|
||||
end
|
||||
|
||||
A --> P
|
||||
@@ -47,16 +46,13 @@ flowchart TD
|
||||
- 支付运行态 API 与 SQLite 审计事件已补齐。
|
||||
- 钱包绑定支持浏览器钱包 + WalletConnect。
|
||||
- 账户中心与 Pro 权限展示链路打通。
|
||||
- 钱包异动支持独立频道路由。
|
||||
- 运行态状态/缓存与核心离线训练、评估、回填链路已完成 SQLite 主路径收口。
|
||||
- 轻量可观测性已上线(`/healthz`、`/api/system/status`、`/metrics`)。
|
||||
- EMOS/CRPS 校准链路已接通;生产主概率保持 `legacy` 或 `emos_shadow`,`emos_primary` 只允许本地训练通过门禁后人工灰度。
|
||||
|
||||
## 3. 高优先级技术债
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| EMOS 发布门禁 | 低配 VPS 不适合训练,主概率不能绕过评估 | 本地拉生产 SQLite 训练,`ready_for_promotion=true` 后先 `emos_shadow` |
|
||||
| 外部监控与告警 | 只有轻量指标,无外部抓取 | 接 Prometheus/Grafana 或最小巡检 |
|
||||
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
|
||||
|
||||
@@ -64,7 +60,7 @@ flowchart TD
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/奖励/手动补分) |
|
||||
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/首次消息奖励/欢迎奖励/周排名奖励/周参与奖/手动补分) |
|
||||
| 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 |
|
||||
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
|
||||
|
||||
@@ -77,6 +73,5 @@ flowchart TD
|
||||
|
||||
## 6. 下阶段里程碑
|
||||
|
||||
1. 固化 EMOS 本地训练流程,禁止低配 VPS 自动训练和自动主用。
|
||||
2. 补外部监控抓取与告警阈值。
|
||||
3. 评估并推进支付合约 V2 升级。
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# PolyWeather 数据链路架构审查
|
||||
|
||||
> 审查日期:2026-06 | 视角:系统架构师 | 范围:完整数据采集→分析→API→前端状态
|
||||
>
|
||||
> **修复状态:8/8 已完成**
|
||||
|
||||
## 一、数据架构总览
|
||||
|
||||
```
|
||||
外部数据源 Python 后端 Next.js 前端
|
||||
=========== ========== ===========
|
||||
|
||||
Open-Meteo (预报+多模型) ─┐
|
||||
METAR/TAF (航空气象) ─┤
|
||||
NWS (美国) / MGM (土耳其) ─┤
|
||||
JMA/AMOS/NMC/HKO/CWA ─┤
|
||||
Wunderground / NOAA ─┤
|
||||
Polymarket Gamma/CLOB ─┤
|
||||
├─ WeatherDataCollector ├─ dashboard-client.ts
|
||||
│ (内存缓存 + SQLite磁盘缓存) │ (ETag浏览器缓存 + SWR)
|
||||
│ │
|
||||
├─ _analyze() ├─ useDashboardStore
|
||||
│ ├─ DEB 融合 (11模型加权) │ (双Context拆分)
|
||||
│ └─ 趋势引擎 │ (扫描数据预加载)
|
||||
│ │
|
||||
├─ scan_terminal_service.py ├─ 扫描终端查询
|
||||
│ ├─ ThreadPoolExecutor(4) │ (120s TTL)
|
||||
│ └─ AI 增强层 (DeepSeek) │
|
||||
│ │
|
||||
└─ FastAPI routes └─ API代理 (Next.js rewrites)
|
||||
(36个端点 + ETag 304)
|
||||
```
|
||||
|
||||
## 二、数据采集层
|
||||
|
||||
### 源端(14个外部源)
|
||||
|
||||
| 源 | 类型 | 覆盖 | TTL |
|
||||
|------|------|---------|------|
|
||||
| Open-Meteo | 预报 + 多模型集合 | 全球 | 300s |
|
||||
| METAR | 机场观测 | 全球 ICAO | 60s |
|
||||
| TAF | 机场预报 | 全球 ICAO | 600s |
|
||||
| NWS | 国家预报 | 美国 | 按请求 |
|
||||
| MGM | 国家官方 | 土耳其 | 300s |
|
||||
| ECMWF/GFS/ICON/GEM/JMA | 多模型 NWP | 全球 | 300s |
|
||||
| HKO/CWA/NOAA/AMOS/NMC | 结算观测 | 特定国家 | 60s (AMOS) / 300s |
|
||||
| Wunderground | 个人气象站 | 全球备用 | 按请求 |
|
||||
| Polymarket Gamma | 市场发现 | 所有温度市场 | 60s |
|
||||
| Polymarket CLOB | 订单簿 | 匹配市场 | 30s |
|
||||
|
||||
### 待改进
|
||||
|
||||
| # | 问题 | 优先级 |
|
||||
|---|------|------|
|
||||
| 1 | 无源端健康状态检测 | 🟡 |
|
||||
| 2 | METAR TTL 60s 过于激进(机场每小时发一次) | 🟡 |
|
||||
| 3 | 无请求重试(`POLYWEATHER_HTTP_RETRY_COUNT` 默认 0) | 🟡 |
|
||||
|
||||
## 三、分析层
|
||||
|
||||
### DEB 动态集成混合
|
||||
|
||||
自适应加权:11 模型按过去 7 天 MAE 动态分配权重。回退链完善。
|
||||
|
||||
|
||||
### 概率校准
|
||||
|
||||
|
||||
**已修复:校准漂移检测** — `check_calibration_drift()` 对比最近 CRPS 与基线,漂移 >15% 时告警,集成在 `/api/system/status` 的 `probability.drift` 字段。
|
||||
|
||||
| # | 问题 | 优先级 |
|
||||
|---|------|------|
|
||||
| 4 | 校准系数静态 JSON 文件,数据分布变化需手动重新训练 | 🟡 |
|
||||
|
||||
## 四、API 与缓存层
|
||||
|
||||
**已修复:ETag 304** — 后端 `_etag_middleware` 对 GET /api/* 自动返回 ETag (MD5),支持 `If-None-Match`,匹配返回 304 + `Cache-Control: private, max-age=30`。
|
||||
|
||||
**已修复:TTL 匹配** — `SCAN_TERMINAL_PAYLOAD_TTL_SEC` 30s → 120s,匹配 ThreadPoolExecutor(4)×60 城的实际重算耗时。
|
||||
|
||||
| # | 问题 | 优先级 |
|
||||
|---|------|------|
|
||||
| 5 | 缓存键过粗(city::mode),微小变化也触发完整重算 | 🟡 |
|
||||
|
||||
## 五、前端状态管理
|
||||
|
||||
**已修复:sessionStorage 限制** — 只保留最近 3 个城市的详情,避免 3-10MB JSON 序列化阻塞主线程。
|
||||
|
||||
**已修复:Context 拆分** — `CityDetailsContext` 独立管理 `cityDetailsByName` 变更,新增 `useCityDetails` hook。只读详情数据的组件不因其他状态变化而重渲染。
|
||||
|
||||
**已修复:Stale-while-revalidate** — `ensureCityDetail` 过期缓存立即返回 + 后台异步刷新,用户不再看到 loading spinner。
|
||||
|
||||
**已修复:扫描数据复用** — `preloadCityFromRow()` 从扫描终端行预填充城市详情缓存,选城市后详情面板立即显示。
|
||||
|
||||
## 六、待办
|
||||
|
||||
| # | 问题 | 优先级 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 1 | 校准系数需手动重新训练 | 🟡 | 漂移检测已有,但自动触发重训练需要 GPU/算力资源 |
|
||||
| 2 | 缓存键过粗 — `city::mode` 粒度 | 🟢 | 微小温度变化触发完整重算,可考虑内容 hash 键 |
|
||||
|
||||
> 注:原审查中 METAR TTL 60s 实际为 600s(误诊);扫描终端轮询已有 `AbortController` + `requestSeq` 保护(误诊)。
|
||||
@@ -2,22 +2,20 @@
|
||||
|
||||
## 执行摘要
|
||||
|
||||
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的“生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution)**,并进一步映射到市场报价完成**错价扫描**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。
|
||||
从工程实现看,截至 `2026-04-27`,项目已经完成一轮更明确的工程化收口:多源天气采集仍保持现有业务能力,同时已完成采集层与 Web API 大文件拆分、CI 质量门禁、配置分级(`.env.example` / `.env.secrets.example` / 中文部署文档)、EMOS/CRPS 校准链路、运行态状态与缓存迁移到 SQLite 主路径,以及最小外部监控链路(`/healthz`、`/api/system/status`、`/metrics` + Prometheus + Alertmanager + Grafana + Telegram relay)。除此之外,项目还补上了**历史真值治理**:`daily_records` 继续只保留近 14 天运行态缓存,但新增了永久真值表、真值 revision 审计表和长期训练特征表,并开始把监督真值与训练特征从“短期缓存”正式拆到“长期可追溯存储”。2026-04 下旬新增的前端城市决策卡把“多模型 + METAR + 市场桶”进一步组合成面向单城点击的解释层:AI 机场报文解读、最高温中枢、完整市场桶匹配与“模型-市场差”已成为 Scan Terminal 的核心决策入口。
|
||||
这意味着报告里最初最突出的“工程地基缺失”问题,已经有一部分被关闭:`src/data_collection/weather_sources.py` 与 `web/app.py` 不再是原来的超大单文件;GitHub Actions 已覆盖 Python、前端和 Docker build;配置与密钥治理已成体系;运行态状态不再只能依赖 JSON/JSONL 文件;EMOS 也不再只是概念,而是进入了可训练、可评估、可 shadow、可门禁判断的阶段;更重要的是,监督真值与训练特征不再只能附着在 14 天运行态缓存上。
|
||||
但项目仍处在“从可用走向稳态”的中段,而不是终局。当前真正的高优先级问题已进一步收敛:**EMOS 仍未达到生产切换标准**,当前门禁结论明确为 `hold`,阻塞原因是 shadow bucket brier 明显退化,同时历史长期特征仍处在“刚开始积累”的阶段。SQLite 迁移方面,运行态主读切换和核心离线训练/回填链路已经完成验收:在移除 `data/*.json` / `data/*.jsonl` 后,训练、评估、shadow report 与关键 backfill 脚本仍可仅依赖运行时数据库正常执行;当前保留的 legacy 文件路径主要用于迁移、导出、校验和显式回退输入。历史真值治理方面,新增的永久真值表、revision 审计表与长期训练特征表已经落地,`Taipei` / `Shenzhen` 的历史页面回填也已接通,因此当前缺口已从“历史真值是否会继续丢失”转为“历史特征是否能持续增长并支撑 EMOS/LGBM 评估”。可观测性方面,最小外部监控链路已经补齐:Prometheus 抓取、Alertmanager 规则、Grafana 面板、Telegram 告警 relay 与巡检脚本均已落地;当前剩余缺口已从“有没有外部监控”转为“监控覆盖深度是否足够”,例如节点级资源、数据库体积趋势、支付细粒度指标、按城市/来源拆分的业务 SLA。支付链路方面,链下审计与容灾已明显增强:事件重放、SQLite 审计事件、RPC 多节点容灾、合约静态检查、`/ops` 支付异常单都已补齐;当前剩余风险主要集中在**链上合约本身仍是最小实现**,尚未升级到 SafeERC20、Pausable、链上套餐绑定等更强防护版本。
|
||||
因此,当前阶段最正确的策略已经不是继续做“大范围基础重构”,而是围绕**EMOS 上线门禁稳定化、长期训练特征持续积累、监控覆盖深挖、城市决策卡可观测性、支付合约防护升级**这五条线持续收口。短中期内更高 ROI 的方向依然不是引入新的大模型,而是把现有“采集→后处理→市场映射→前端决策→支付/订阅”的链路做成**状态一致、指标可见、发布可控、回退明确**的生产平台。
|
||||
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的”生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution)**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 现明确仓库代码采用 `AGPL-3.0-only`,同时将品牌、商标、生产私有数据与运营阈值保留在代码许可证之外。
|
||||
|
||||
> **2026-05-23 更新(v1.7.0)**:Polymarket 价格拉取与 UI 层(MarketDecisionLine)已删除,`market_scan` 当前返回空;LGBM 已完全移除,概率引擎仅保留 legacy 高斯 + EMOS/CRPS;Groq、Meteoblue、NMC、pogodaiklimat 数据源和 prewarm 预热系统已移除。
|
||||
## 项目概览
|
||||
|
||||
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。
|
||||
项目主功能可归纳为五层:
|
||||
**天气层(数据源/采集)**:聚合 52 个城市的实测与预报;支持 AviationWeather METAR(机场观测)、土耳其 MGM 站网、Open-Meteo(含多模型与集合预报)、美国 NWS(仅美国城市)、以及部分城市使用明确官方站点或历史页面入口(香港 HKO、台湾/深圳相关历史页面等)等。机场类市场仍以 METAR / 机场主站为结算锚点,Wunderground 不描述为物理观测站。
|
||||
**天气层(数据源/采集)**:聚合 51 个城市的实测与预报;支持 AviationWeather METAR(机场观测)、韩国 AMOS 跑道级观测(首尔/釜山)、土耳其 MGM 站网、Open-Meteo(含多模型与集合预报)、美国 NWS(仅美国城市)、以及部分城市使用明确官方站点或历史页面入口(香港 HKO、台湾/深圳相关历史页面等)等。机场类市场仍以 METAR / 机场主站为结算锚点,Wunderground 不描述为物理观测站。
|
||||
**分析层(DEB/趋势/概率/结算口径)**:
|
||||
DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加权,输出融合预报;运行态仍维护近 14 天 `daily_records` 缓存做当前对账,但长期监督真值与训练特征已经迁到 SQLite 永久表中,并支持基于 WU(Weather Underground 口径)四舍五入的结算命中评估。
|
||||
趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。
|
||||
**城市决策层(Scan Terminal / AI 机场报文解读)**:地图点击城市后加入城市决策卡,前端拉取 full detail、多模型区间、最新 METAR,并通过 `/api/scan/terminal/ai-city/stream` 生成城市级 AI 解读。该解读由 `final_judgment`、`metar_read`、`reasoning`、`model_cluster_note`、`risks` 与原始 METAR 证据组成;最高温中枢优先使用 AI `predicted_max`,再回退到 DEB、多模型中心、日内 pace 或当前实测。
|
||||
**市场层(Polymarket 行情对照)**:只读模式从 Gamma API 发现市场、从 CLOB(`py-clob-client` 或 REST 回退)读取价格/盘口,并用完整 `all_buckets` 对目标温度桶做 exact/range/“or higher”/“or lower” 严格匹配,计算“模型-市场差”(模型概率 − 市场隐含概率)生成信号标签。
|
||||
**商业化与支付**:订阅(`Pro Monthly 5 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。
|
||||
**市场层(Polymarket 行情对照)**:*[v1.7.0 已移除]* 原先从 Gamma API 发现市场、从 CLOB 读取价格/盘口并计算”模型-市场差”,已于 2026-05-23 随 Polymarket 价格拉取层一并删除。当前 `market_scan` 返回空。
|
||||
**商业化与支付**:订阅(`Pro Monthly 10 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。
|
||||
**支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
|
||||
**许可证**:仓库根目录 `LICENSE` 当前为 `AGPL-3.0-only`。同时 README 与策略文档明确:品牌、商标、生产私有数据与运营策略不随代码许可证一并授权。
|
||||
(插图:项目 README 中包含产品截图,可用于快速理解信息架构与 UI 形态)
|
||||
@@ -33,9 +31,8 @@ DEB(Dynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加
|
||||
| 运行时组件 | `frontend/` | Next.js 前端(Vercel) | 前端重构报告提到 App Router、Route Handlers(BFF)、缓存策略、支付与账户中心等;Scan Terminal 已新增城市决策卡、AI 机场报文解读、页面内存/localStorage 双层缓存、AI stream 小并发队列与完整市场桶映射。 |
|
||||
| 运行时组件 | `web/app.py` + `web/core.py` + `web/routes.py` + `web/analysis_service.py` + `web/scan_terminal_service.py` | FastAPI 后端 API | 已从单文件入口拆为启动入口、核心上下文、路由层、分析服务层;Scan Terminal 侧提供 `/api/scan/terminal/ai-city/stream`,城市 AI 默认 30s 超时并支持 stream parse failure 的非流式重试。 |
|
||||
| 运行时组件 | `bot_listener.py` + `src/bot/*` | Telegram Bot | 入口 `bot_listener.py` 调 `start_bot()`,并由 `StartupCoordinator` 启动多个后台 loop。 |
|
||||
| Python 域模块 | `src/data_collection/*` | 天气采集 + 城市注册 + 市场读取 | 采集层已拆为 `weather_sources.py` 编排层 + `open_meteo_cache.py`、`settlement_sources.py`、`metar_sources.py`、`mgm_sources.py`、`nws_open_meteo_sources.py`。 |
|
||||
| Python 域模块 | `src/data_collection/*` | 天气采集 + 城市注册 | 采集层已拆为 `weather_sources.py` 编排层 + `open_meteo_cache.py`、`settlement_sources.py`、`metar_sources.py`、`mgm_sources.py`、`amos_station_sources.py`、`jma_amedas_sources.py`、`nws_open_meteo_sources.py`、`country_networks.py` 等。v1.7.0 已移除 NMC、pogodaiklimat、Meteoblue 数据源。 |
|
||||
| Python 域模块 | `src/analysis/*` | DEB/趋势/概率/结算口径 | `deb_algorithm.py`、`trend_engine.py`、`settlement_rounding.py`。 |
|
||||
| Python 域模块 | `src/analysis/probability_calibration.py` + `src/analysis/probability_rollout.py` | 概率校准与上线门禁 | 已支持 `legacy / emos_shadow / emos_primary`,并可产出 rollout 判断。 |
|
||||
| Python 域模块 | `src/payments/*` + `contracts/*` | 支付合约 + 事件监听/补单 | Solidity 合约 + Python 侧事件扫描/确认循环 + SQLite 审计事件 + RPC 多节点容灾 + 合约静态检查。 |
|
||||
| Python 域模块 | `src/auth/*`、`docs/SUPABASE_SETUP_ZH.md`、`scripts/supabase/schema.sql` | Supabase 鉴权/订阅/积分 | 使用 `/auth/v1/user` 校验 JWT、`/rest/v1/subscriptions` 查订阅(服务端角色 key 必须保密)。 |
|
||||
| Python 域模块 | `src/database/runtime_state.py` | 运行态状态、永久真值与训练特征仓储 | 已接入 `daily_records`、`telegram_alert_state`、`probability_training_snapshots`、`open_meteo` 持久缓存,并新增永久真值表、真值修订审计表、长期训练特征表。 |
|
||||
@@ -69,11 +66,10 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
|
||||
OM[Open-Meteo Forecast/Ensemble/Multi-model]
|
||||
AW[AviationWeather Data API<br/>METAR]
|
||||
MGM[MGM Turkey]
|
||||
AMOS[global.amo.go.kr<br/>AMOS runway sensors]
|
||||
NWS[api.weather.gov]
|
||||
HKO[data.weather.gov.hk]
|
||||
CWA[opendata.cwa.gov.tw]
|
||||
PM_G[Polymarket Gamma API]
|
||||
PM_C[Polymarket CLOB API]
|
||||
SB[Supabase Auth/REST]
|
||||
RPC[Polygon RPC]
|
||||
end
|
||||
@@ -95,12 +91,11 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
|
||||
WX --> OM
|
||||
WX --> AW
|
||||
WX --> MGM
|
||||
WX --> AMOS
|
||||
WX --> NWS
|
||||
WX --> HKO
|
||||
WX --> CWA
|
||||
|
||||
FAST --> PM_G
|
||||
FAST --> PM_C
|
||||
FAST --> LLM
|
||||
|
||||
FAST --> SB
|
||||
@@ -118,7 +113,7 @@ JSON[Legacy JSON files<br/>migration/export/explicit fallback only]
|
||||
|
||||
|
||||
|
||||
### 城市决策卡工作流(2026-04 更新)
|
||||
### 城市决策卡工作流(2026-05 更新)
|
||||
|
||||
Scan Terminal 的城市决策卡现在承担“从天气分析到市场动作解释”的前端决策层:
|
||||
|
||||
@@ -148,13 +143,12 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
|
||||
|
||||
**测试**:仓库存在 `tests/test_trend_engine.py`,覆盖 μ 计算、死盘判定、预报崩盘提示、趋势方向等核心逻辑(通过 patch 隔离外部依赖)。前端侧已通过 `npm run build` 验证 Scan Terminal 改动可以编译;后续仍建议为城市决策卡补固定 fixture,覆盖 `all_buckets` 匹配、温度单位渲染、AI 缓存 key 与 stream 队列行为。
|
||||
**CI/CD**:已补齐 GitHub Actions 工作流,至少覆盖 Python lint/test、前端 build、Docker build 三条门禁;当前缺口不再是“有没有 CI”,而是“是否已在 GitHub 分支保护中强制执行”。
|
||||
**运维验收**:除 `scripts/validate_frontend_cache.sh` 外,现已新增配置校验、运行态迁移/核验、EMOS rollout 判断等脚本,并提供 `/healthz`、`/api/system/status`、`/metrics` 作为基础观测入口。
|
||||
**部署/更新**:Compose 用于启动服务;另有 `update.sh` 通过 `pkill` + `nohup` 重启 bot 与 web。
|
||||
## 优势与薄弱点
|
||||
|
||||
### 优势
|
||||
|
||||
**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。
|
||||
**产品闭环完整、目标明确**:从“天气→结算→市场→错价信号→付费体系(订阅/积分/链上支付)”形成可商业化闭环,并在 README 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。2026-05 完成积分制度改造:`/city` `/deb` 改为免费(每日各 10 次),新增首次发言欢迎奖励与每日首条消息奖励,周奖励降低赢家积分差距并增加全员参与奖。
|
||||
**复用一套分析内核服务多端**:趋势/概率/DEB 等核心逻辑被抽成分析模块,并被 web 与 bot 共用,避免“两套逻辑漂移”。前端城市决策卡在此基础上补足“机场报文解释 + 市场桶动作口径”,让用户从地图点击可以直接进入可解释决策。
|
||||
**面向外部 API 的工程防护意识较强**:Open-Meteo 429 冷却期、最小调用间隔、磁盘缓存、缓存 TTL 等措施表明作者已遭遇并处理速率限制与冷启动问题。 同时 AviationWeather 官方文档也明确建议控制频率并可使用 cache 文件降低负载,项目后续可进一步对齐最佳实践。
|
||||
**支付侧有“事件监听 + 确认补单”的双通路**:支付链路天然存在“交易 pending / RPC 延迟 / 日志索引不完整”等问题,项目通过 event loop 与 confirm loop 双机制提升最终一致性。
|
||||
@@ -164,41 +158,35 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
|
||||
**可复现性已从“缺模板”进入“模板与生产对齐”的阶段**:`.env.example`、`.env.secrets.example`、中文配置文档、前端部署文档、运行时配置校验器都已存在;当前风险主要在于线上历史 `.env` 与新模板并存、旧变量命名残留、以及密钥轮换与分层是否真正落实。
|
||||
**CI 已建立,但组织级质量门禁未必完全收口**:CI 现已覆盖 Python、前端与 Docker build。当前问题不再是“缺 CI”,而是是否把这些 status check 绑定到 `main` 保护策略,以及是否逐步引入更严格的 pre-merge 审查。
|
||||
**运行态状态/缓存与核心离线链路的 SQLite 收口已完成**:`daily_records`、`telegram_alert_state`、`probability_training_snapshots`、`open_meteo` 缓存已经支持并在生产中主读 SQLite,迁移/校验脚本可用;进一步地,在临时移除 `data/*.json` / `data/*.jsonl` 后,训练集导出、概率拟合、评估报告、shadow report 和关键 backfill 脚本已验证仍可运行。当前 legacy 文件路径主要是显式回退入口,而不再是默认主输入。
|
||||
**历史真值治理已从设计缺陷修复到可追溯运行**:`daily_records` 继续作为近 14 天运行态缓存,但已经不再承担长期监督真值职责;项目新增了永久真值表、真值 revision 审计表和长期训练特征表,并为 `Taipei` / `Shenzhen` 补上了历史页面回填链路。当前风险已不再是“监督真值会不会继续被 14 天裁剪吞掉”,而是“历史长期特征能否持续积累到足够支撑 EMOS/LGBM 重新评估”。
|
||||
**第三方服务合规与稳定性风险**:
|
||||
项目强依赖外部 API(Open-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase)以及城市 AI provider(OpenAI-compatible stream,当前临时 MiMo)。其中 AviationWeather Data API 有明确速率限制;Polymarket 官方说明 Gamma/Data/CLOB 三套 API 分属不同域,CLOB 交易端点需鉴权且策略可能变化;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。城市 AI 解读已经通过前端 2 并发队列、30s timeout、stream parse retry 与缓存 key 稳定化降低第三/第四城市失败概率,但仍需持续记录 stream duration、cache hit、retry、degraded 与 queue depth。
|
||||
**可观测性最小闭环已完成,但监控深度仍待加强**:项目现在已有 `/healthz`、`/api/system/status`、`/metrics`,并已补齐 Prometheus 抓取、Alertmanager 规则、Grafana 面板、Telegram relay 与巡检脚本。与此同时,`/ops` 已经逐步演进为后台管理台而不只是状态页:除支付、会员、用户与 EMOS 门禁外,还新增了训练数据治理卡片、城市覆盖矩阵,以及 `/ops/truth-history` 这种可直接查询 `actual_high / settlement_source / station_code / truth_version / updated_by / updated_at` 的真值表浏览页。当前缺口不再是“有没有外部监控”,而是节点级资源、数据库体积趋势、更细粒度支付指标、按城市/来源拆分的业务 SLA,以及是否需要进一步补 `truth revision` 明细页、趋势图和运营日报。
|
||||
**EMOS 已完成工程接入,但未完成生产发布**:EMOS/CRPS 校准、shadow 观测、rollout report、上线门禁都已实现;当前真实门禁结果为 `hold`,阻塞原因是 shadow bucket brier 明显退化。因此概率引擎标准化并非未做,而是“工程完成、发布未通过”。
|
||||
项目强依赖外部 API(Open-Meteo、AviationWeather、global.amo.go.kr AMOS、NWS、HKO、CWA、Supabase)以及城市 AI provider(OpenAI-compatible stream,当前使用 MiMo)。其中 AviationWeather Data API 有明确速率限制;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。城市 AI 解读已经通过前端 2 并发队列、30s timeout、stream parse retry 与缓存 key 稳定化降低第三/第四城市失败概率,但仍需持续记录 stream duration、cache hit、retry、degraded 与 queue depth。
|
||||
|
||||
> **v1.7.0 更新**:Polymarket(Gamma/CLOB)API 依赖已随市场价格拉取层一并移除。
|
||||
**许可证/商业使用的潜在冲突点**:仓库自身现为 `AGPL-3.0-only`,但如果未来尝试引入外部神经天气模型,仍需单独核验第三方代码与权重的商用条件:GraphCast 仓库代码 Apache-2.0,但权重使用 CC BY-NC-SA 4.0(非商业),Pangu-Weather 权重同样 BY-NC-SA 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。
|
||||
## 对标分析
|
||||
|
||||
为满足“至少 3 个相似开源项目或近期论文”对标,本报告选择三类代表:
|
||||
1)**AI 气象预报模型**(GraphCast / FourCastNet / Pangu-Weather):用于评估“若 PolyWeather 未来扩展到更强预测能力”的技术与许可边界;
|
||||
2)**概率后处理方法**(EMOS):作为 PolyWeather 概率引擎的更标准化替代/对照;
|
||||
3)**预测市场 API 客户端生态**(Polymarket/py-clob-client、aiopolymarket):用于评估市场层的工程选型。
|
||||
3)**预测市场 API 客户端生态**(Polymarket/py-clob-client、aiopolymarket):*[v1.7.0 后已不适用]* 市场价格拉取层已移除,此对标仅作历史参考。
|
||||
### 关键对比表
|
||||
|
||||
| 项目/论文 | 解决的问题 | 输出形态 | 性能/效果(公开描述) | 易用性与依赖 | 许可证要点 |
|
||||
| --------------------------------------------------------- | ---------------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| **PolyWeather**(本仓库) | 温度结算市场气象情报:多源→校准概率桶→错价扫描→城市决策卡→订阅/支付 | 生产级应用(Web+Bot+API+支付) | 以工程能力为主;内置 DEB、LGBM/EMOS 校准概率、死盘判定、市场扫描;当前覆盖 52 城市,并已补齐真值治理、后台运维视图、AI 机场报文解读与 full bucket 决策映射。 | 主要依赖外部 API 与城市 AI provider;Docker Compose 一键启动。 | 仓库 `AGPL-3.0-only`;品牌、生产私有数据与运营规则不随代码许可证授权。 |
|
||||
| **GraphCast**(google-deepmind/graphcast) | 10 天全球中期预报(ML 替代/增强 NWP) | 模型代码+权重+notebooks | 论文与介绍提到在大量指标上优于主流确定性系统;仓库提供预训练权重与示例数据入口,并提示 ERA5/HRES 数据条款需另行遵守。 | 完整训练需 ERA5 等;更适合科研/平台级推理,不是产品级 BFF。 | 代码 Apache-2.0;权重 CC BY-NC-SA 4.0(商业限制)。 |
|
||||
| **FourCastNet**(NVlabs/FourCastNet) | 高分辨率 data-driven 全球预报(AFNO/ViT) | 模型训练/推理代码+数据/权重链接 | README 描述:0.25° 分辨率、周尺度推理非常快,并可做大规模集合;适合平台型预报。 | 训练/数据依赖大(ERA5 子集 TB 级);工程集成成本高。 | BSD 3-Clause(代码)。 |
|
||||
| **Pangu-Weather**(198808xc/Pangu-Weather + Nature 论文) | 3D Transformer 架构的中期全球预报 | ONNX 推理代码+预训练模型 | Nature 论文称在 reanalysis 上对比 IFS 有更强确定性预报表现,并强调速度优势;仓库提供 ONNX 推理与 lite 版训练说明。 | 模型文件大(多份 ~GB 级),训练资源需求高;更适合科研推理或内部平台。 | 权重 BY-NC-SA 4.0、明确禁止商业用途。 |
|
||||
| **EMOS**(Gneiting & Raftery 等) | 集合预报校准:纠偏与解决 underdispersion | 统计后处理方法 | 提出用回归形式输出概率分布(常见为高斯),并以 CRPS 等指标拟合,属于成熟的气象概率校准路线。 | 易落地:对 PolyWeather 而言只需“历史库+拟合器”。 | 方法论(论文);可自行实现,无额外许可约束(注意论文版权)。 |
|
||||
| **Polymarket/py-clob-client** | Polymarket CLOB 读写 SDK | Python SDK | 官方 SDK,支持 read-only 与交易接口;协议与端点在官方文档中给出。 | 易用,适合增强 PolyWeather 市场层。 | MIT。 |
|
||||
| **aiopolymarket** | Polymarket APIs 的 async 客户端 | Python async 客户端 | 强调类型安全(Pydantic)、自动分页、重试与 backoff,适合高并发与健壮性诉求。 | 适合替换/补强当前同步 requests 与自定义缓存。 | 以仓库许可为准(此处建议上线前核验)。 |
|
||||
| **Polymarket/py-clob-client** | Polymarket CLOB 读写 SDK | Python SDK | *[2026-05 起不再使用]* 官方 SDK,支持 read-only 与交易接口。 | 曾用作 PolyWeather 市场层参考。 | MIT。 |
|
||||
| **aiopolymarket** | Polymarket APIs 的 async 客户端 | Python async 客户端 | *[2026-05 起不再使用]* 类型安全(Pydantic)、自动分页、重试与 backoff。 | 曾用作市场层升级候选。 | 以仓库许可为准。 |
|
||||
|
||||
**对标结论**:PolyWeather 与这类“全球神经天气模型”不在同一层级:PolyWeather 是“面向结算市场的产品化情报系统”,其价值核心是**将预测转成可交易/可结算的决策信息**。短中期内更高 ROI 的方向不是“自训大模型”,而是把现有“采集+后处理+市场映射”的链路做成**可复现、可观测、可评测、可扩展**的工程平台;在许可合规前提下,再评估引入外部模型推理作为额外信号源。
|
||||
## 优先级改进建议
|
||||
|
||||
下表按截至 `2026-04-27` 的真实状态重排优先级。已完成项不再继续列为“待做”,只保留当前仍需推进的事项。
|
||||
下表按截至 `2026-05-23` 的真实状态重排优先级。已完成项不再继续列为”待做”,只保留当前仍需推进的事项。
|
||||
| 优先级 | 改进项 | 预估工作量 | 主要收益 | 主要风险 | 可执行步骤(建议顺序) |
|
||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------- | -------------------: | ------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 高 | **稳定 EMOS shadow 并收紧上线门禁** | 1–2 周 | 让概率引擎升级具备明确发布条件,避免拍脑袋切换 | 当前 shadow bucket brier 退化明显,存在误上线风险 | 1) 持续积累 snapshot 样本 → 2) 定期重训与生成 `evaluation_report` / `shadow_report` / `rollout_report` → 3) 重点压 `bucket_brier` 退化 → 4) 只有门禁从 `hold` 进入 `observe/promote` 后才考虑上线 |
|
||||
| 高 | **持续积累长期训练特征,验证 SQLite 真值治理后的样本增长** | 1–2 周 | 让 EMOS/LGBM 的重训真正建立在长期可信样本上,而不是继续被短期特征缺口卡住 | 当前真值已长期化,但历史长期特征仍偏少,EMOS/LGBM 样本增长会滞后 | 1) 持续写入 `training_feature_records_store` → 2) 每日检查 `/ops` 训练数据与 `/ops/truth-history` → 3) 定期对 `Taipei` / `Shenzhen` 的历史页面回填做抽查 → 4) 观察样本是否自然增长后再重训 |
|
||||
| 中 | **把最小外部监控继续补深**:从“可告警”提升到“可运营” | 3–7 天 | 不再只知道服务坏没坏,还能看资源趋势、来源 SLA 和支付波动 | 指标过多会带来维护噪音 | 1) 增加节点 CPU/内存/磁盘 → 2) 增加 SQLite/支付体积与事件趋势 → 3) 把 HTTP/来源指标细分到城市/来源维度 → 4) 增加日报或异常摘要 |
|
||||
| 中 | **城市决策卡 AI 解读可观测性与回放测试** | 3–5 天 | 降低第三/第四/第五城市 AI 解读失败,验证缓存与队列是否真正生效 | 外部 AI stream 仍可能超时或输出截断,若无指标很难复盘 | 1) 记录 city-ai stream status/duration/retry/degraded/cache-hit/queue-depth → 2) 增加固定 METAR + detail + all_buckets fixture → 3) 回归断言 bucket 匹配、模型-市场差、温度单位与缓存 key → 4) 将生产 env 建议同步进部署文档 |
|
||||
| 中 | **市场层升级为 async + 类型安全**:引入 `aiopolymarket` 或在现有层加重试/backoff/连接池 | 4–7 天 | 行情层更稳,减少短时网络抖动;更易扩展更多市场/分页 | 依赖升级带来的行为差异 | 1) 把 requests.Session 替换为 aiohttp/httpx → 2) 在 Gamma/CLOB 调用侧实现指数退避 → 3) 引入 typed models,减少解析失败 |
|
||||
| - | ~~市场层升级为 async + 类型安全~~ | N/A | *[v1.7.0 已移除]* 市场价格拉取层已删除,此改进项不再适用 | - | - |
|
||||
| 中 | **支付合约从“最小可用”升级到“更强合约防护”** | 1–2 周 | 在已完成的链下审计与容灾之上,进一步收紧链上授权边界 | 合约升级需要重新部署、迁移配置并再次验证 | 1) 维持现有事件重放、SQLite 审计、多 RPC fallback → 2) 升级合约到 SafeERC20 + Pausable → 3) 评估链上 plan/amount/token 绑定或 EIP-712 签名校验 → 4) 迁移后更新 PolygonScan 验证与支付审计文档 |
|
||||
| 中 | **将 CI 与分支保护/发布流程真正绑定** | 1–3 天 | 让现有 CI 从“存在”变成“强制门禁” | 历史分支/热修流程可能受影响 | 1) GitHub `main` 开启 required checks → 2) 把 release/tag 流程绑定 CI → 3) 明确热修例外流程 |
|
||||
| 低 | **引入外部神经天气模型作为附加信号**(GraphCast/FourCastNet/Pangu-Weather 等) | 2–6 周(取决于范围) | 可能提升极端/中期预测能力与差异化 | **商业许可限制**(多为 CC BY-NC-SA/禁止商业)与算力成本 | 1) 先做合规评审(权重许可/数据条款)→ 2) 仅在研究/非商业环境评估 → 3) 若要商用,优先选择可商用权重或自研/购买授权 |
|
||||
@@ -206,7 +194,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
|
||||
### 文档、测试与贡献流程的具体补强建议(落到仓库层面)
|
||||
|
||||
1)**文档体系**:保留现有中文 API/TechDebt 文档的同时,增加三份“高价值”文档:
|
||||
(a)《运行与配置手册》:按环境(本地/测试/VPS/生产)列必需变量、默认值、敏感等级,并明确城市 AI 推荐配置(`POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30`、`POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900`、`POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=true`);(b)《数据源与合规说明》:列出 Open-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase 的使用条款要点、速率限制与降级策略(例如 AviationWeather 明确建议降低请求频率并提供 cache 文件)。 (c)《故障排查 Runbook》:429、支付 pending、市场扫描 miss、城市 AI stream timeout/JSON 截断、前端缓存异常、温度桶错配等典型故障处理。
|
||||
(a)《运行与配置手册》:按环境(本地/测试/VPS/生产)列必需变量、默认值、敏感等级,并明确城市 AI 推荐配置(`POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30`、`POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900`、`POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=true`);(b)《数据源与合规说明》:列出 Open-Meteo、AviationWeather、NWS、HKO、CWA、Supabase 的使用条款要点、速率限制与降级策略(例如 AviationWeather 明确建议降低请求频率并提供 cache 文件)。 (c)《故障排查 Runbook》:429、支付 pending、城市 AI stream timeout/JSON 截断、前端缓存异常、温度桶错配等典型故障处理。
|
||||
2)**测试金字塔**:在现有 `trend_engine` 单测基础上,补齐:
|
||||
(a)天气 provider 的“录制回放”测试(VCR 思路:固定响应→确保解析稳定);(b)市场层的契约测试(Gamma/CLOB schema 变更时提前失败);(c)城市决策卡 fixture 测试(固定 `detail/market_scan/all_buckets/METAR` → 断言 bucket mapping、模型-市场差、温度单位、AI 缓存 key 与排队提示);(d)支付链路的本地链集成测试(Hardhat/Anvil + 事件扫描回放)。这些测试能把“外部依赖漂移”尽量转成可控的回归失败。
|
||||
3)**贡献工作流**:引入 `CONTRIBUTING.md`(分支策略、PR 模板、变更日志、版本号策略)、`CODEOWNERS`(核心模块审查人)、`SECURITY.md`(漏洞披露与密钥处理),并把静态检查(ruff/eslint)作为 pre-commit + CI 必过项。
|
||||
@@ -221,34 +209,33 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
|
||||
**指标**
|
||||
1)确定性误差:MAE、RMSE(按城市、按季节、按风险等级分组);
|
||||
2)结算命中率:`WU_round(pred) == WU_round(actual)`(项目已有统计口径);
|
||||
3)概率质量:Brier Score(对离散温度桶),以及建议补充 CRPS(连续变量概率评分,EMOS 体系常用)。
|
||||
4)校准曲线:预测概率分箱的可靠性图(reliability diagram)与 Sharpness(分布集中度)。
|
||||
**基线**
|
||||
|
||||
- Baseline A:Open-Meteo 当日最高温(或 forecast median)作为点预测;
|
||||
- Baseline B:等权平均(DEB 在历史少时也会回退此策略);
|
||||
- Baseline C:当前 DEB;
|
||||
- Baseline D:EMOS(以 ensemble 均值/方差为输入,拟合 μ 与 σ,优化 CRPS)。
|
||||
**预期结果(定性)**
|
||||
|
||||
- 若历史样本足够,DEB 应在“系统性偏差明显”的城市提升 MAE;
|
||||
- EMOS 类方法通常能在概率校准(可靠性与 CRPS)上更稳定,尤其当 ensemble 信息可用(项目已接入 Open-Meteo ensemble/p10/p90)。
|
||||
**算力**:以上评测全部可在 CPU 上完成;数据量按“52 城市 × 180 天”级别,pandas/duckdb 即可。若引入更复杂拟合(如分层贝叶斯/分位数回归),也通常不需要 GPU。
|
||||
### 错价信号与市场有效性基准
|
||||
**算力**:以上评测全部可在 CPU 上完成;数据量按“51 城市 × 180 天”级别,pandas/duckdb 即可。若引入更复杂拟合(如分层贝叶斯/分位数回归),也通常不需要 GPU。
|
||||
### 错价信号与市场有效性基准 *[v1.7.0 已暂停]*
|
||||
|
||||
> **2026-05-23 更新**:Polymarket 价格拉取层与市场扫描(`market_scan`)已于 v1.7.0 移除。本节基准评测方案暂不适用,留待未来若重新引入市场数据层时参考。
|
||||
|
||||
**数据集**
|
||||
|
||||
- 保存每次扫描输出:`date/city/bucket/bucket_label/bucket_direction/model_probability/market_implied/model_market_diff/yes_buy/quote_source/liquidity/matching_reason`,并加上未来 `settled_bucket` 作为标签;Polymarket 市场发现与报价来自 Gamma/CLOB(官方文档说明三套 API:Gamma/Data/CLOB)。
|
||||
- 若恢复:保存每次扫描输出:`date/city/bucket/bucket_label/bucket_direction/model_probability/market_implied/model_market_diff/yes_buy/quote_source/liquidity/matching_reason`,并加上未来 `settled_bucket` 作为标签。
|
||||
**指标**
|
||||
|
||||
- Signal 覆盖率:能否找到正确 market / bucket;
|
||||
- Edge 稳健性:不同流动性分位的 edge 分布;
|
||||
- 交易模拟(如需):在考虑滑点/手续费/成交概率下的期望收益(即使项目当前只读,也可以离线评估“若执行”会怎样)。
|
||||
- 交易模拟(如需):在考虑滑点/手续费/成交概率下的期望收益。
|
||||
**基线**
|
||||
|
||||
- 简单策略:仅用市场中间价(不做模型)作为概率;
|
||||
- 当前策略:模型概率 vs 市场概率 edge 阈值;
|
||||
- 改进策略:引入“流动性/盘口深度/波动”作为信号置信度(aiopolymarket/py-clob-client 提供更完整的盘口读取能力)。
|
||||
- 改进策略:引入”流动性/盘口深度/波动”作为信号置信度。
|
||||
**算力**:CPU 即可;关键在于数据采样与回放。
|
||||
## 路线图与风险缓解
|
||||
|
||||
@@ -257,7 +244,6 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
|
||||
| ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
|
||||
| 第 1 周 | 城市决策卡稳定性补强 | city-ai stream/cache/queue 指标;固定 METAR + `all_buckets` fixture;温度桶匹配与模型-市场差回归测试;生产 env 文档化 | 前端为主,后端补指标 |
|
||||
| 第 2 周 | 市场层与 Scan Terminal 数据回放 | 保存 `bucket_label/bucket_direction/model_market_diff/matching_reason`;支持回放第三/第四/第五城市 AI 解读失败案例 | 用真实失败样本压回归 |
|
||||
| 第 3–4 周 | EMOS 与长期特征继续收口 | 持续积累 `training_feature_records_store`;定期生成 evaluation/shadow/rollout report;继续观察 `bucket_brier` 是否退出 hold | 数据工程为主 |
|
||||
| 第 4–5 周 | 监控深挖与运维日报 | 来源 SLA、城市维度延迟、AI stream 状态、SQLite 体积、支付事件趋势、异常摘要 | 避免指标过多,先覆盖高频故障 |
|
||||
| 第 6 周 | 支付合约与发布门禁升级 | SafeERC20/Pausable 方案评审;CI required checks 与 release/tag 流程绑定;热修例外流程 | 合约升级需单独部署验证 |
|
||||
|
||||
@@ -270,6 +256,8 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
|
||||
**代码公开与生产私有资产边界导致的“公开仓库与生产行为不一致”**:README 明确品牌、商标、生产私有数据与运营阈值不在代码许可证授权范围内。缓解:把“公开核心”的可复现与评测做扎实(接口/数据 schema/测试/评测),私有策略只作为可插拔 policy layer 接入。
|
||||
## 参考链接
|
||||
|
||||
> **v1.7.0 注**:以下 Polymarket 相关链接已不再被项目使用,保留作为历史参考。
|
||||
|
||||
- PolyWeather 仓库(本次评估对象):https://github.com/yangyuan-zhen/PolyWeather
|
||||
- Polymarket API 文档(Gamma/Data/CLOB):https://docs.polymarket.com/api-reference
|
||||
- AviationWeather Data API(METAR 等):https://aviationweather.gov/data/api/
|
||||
|
||||
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 947 KiB After Width: | Height: | Size: 947 KiB |
@@ -1,4 +1,4 @@
|
||||
# PolyWeatherCheckout PolygonScan 验证(v1.5.1)
|
||||
# PolyWeatherCheckout PolygonScan 验证(v1.7.0)
|
||||
|
||||
最后更新:`2026-03-20`
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# PolyWeather 前端产品审查报告
|
||||
|
||||
> 审查日期:2026-06 | 视角:产品经理 | 范围:`frontend/` 全部页面、组件、用户流程
|
||||
|
||||
## 一、产品概览
|
||||
|
||||
PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核心价值主张:**结合多模型气象预报 + AI 机场报文解读 + Polymarket 市场价格,为交易决策提供一站式证据链。**
|
||||
|
||||
### 产品分层
|
||||
|
||||
| 层级 | 功能 | 门槛 |
|
||||
|------|------|------|
|
||||
| 免费 | 交互式全球天气地图 + 城市简报 | 无需登录 |
|
||||
| Pro 试用 | 3 天全功能 | 注册后自动获得 |
|
||||
| Pro 订阅 | 城市决策卡(AI 机场报文 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 10 USDC/月(积分抵扣最多 3 USDC) |
|
||||
|
||||
### 页面结构(9 个路由)
|
||||
|
||||
| 路由 | 功能 | 是否必需登录 |
|
||||
|------|------|-------------|
|
||||
| `/` | 主看板 — AI 天气决策台 | 否 |
|
||||
| `/account` | 账户中心 — 身份/订阅/钱包/积分/Bot 绑定 | 是 |
|
||||
| `/auth/login` | 登录页(Google OAuth + 邮箱密码) | 否 |
|
||||
| `/docs/[...slug]` | 产品文档中心(8 篇双语文档) | 否 |
|
||||
| `/subscription-help` | 订阅 FAQ(双语) | 否 |
|
||||
| `/entitlement-required` | 访问被拒页面 | 否 |
|
||||
| `/ops` | 运营管理后台 | 是(管理员) |
|
||||
| `/ops/truth-history` | 真值历史查看器 | 是(管理员) |
|
||||
|
||||
## 二、用户流程分析
|
||||
|
||||
### 主看板的两个视图
|
||||
|
||||
```
|
||||
┌─ 分布视图(地图) ──────────────────────────────┐
|
||||
│ Leaflet 交互式地图,城市彩色气泡 │
|
||||
│ 点击城市 → 自动添加到决策卡工作区 + 切换到卡片视图 │
|
||||
│ 免费用户和 Pro 用户均可使用 │
|
||||
├─ 决策卡(分析) ──────────────────────────────┤
|
||||
│ 钉选的城市卡片:AI 机场解读 + 模型集群 + 市场层 + 图表 │
|
||||
│ 需要 Pro 订阅 │
|
||||
└──────────────────────────────────────────────┘
|
||||
└── 右侧栏:城市简报面板(始终可见,免费可用)
|
||||
```
|
||||
|
||||
### 关键用户路径
|
||||
|
||||
1. **新用户落地** → 看到 3 步引导 → 地图 + 城市列表 → 点击城市 → 看到城市简报 → 想深入分析 → 遇到 Pro 付费墙(含功能说明)
|
||||
2. **Pro 用户工作流** → 地图选城市 → 自动钉选到决策卡 → 展开卡片 → 阅读 AI 报文解读 → 查看市场层 → 判断交易方向
|
||||
|
||||
## 三、做得好的地方
|
||||
|
||||
1. **地图 → 决策卡的自动流转设计** — 点击地图城市自动钉选到分析工作区并切换视图,"零步骤发现"
|
||||
2. **双语覆盖完整** — 所有 UI 文案、文档、AI 解读都有中英文对照,覆盖率接近 100%
|
||||
3. **数据新鲜度可视化** — DataFreshnessBar 让用户一眼看到 METAR/模型/市场数据的新鲜度
|
||||
4. **AI 解读的产品化程度高** — 分层展示:快速判断 → 完整解读 → 证据链 → 风险提示
|
||||
5. **免费层有实际价值** — 地图 + 城市简报不是"空壳",用户可以看真实气象数据
|
||||
6. **支付链路完整** — 从钱包绑定到链上签约到支付恢复,处理了多种异常情况
|
||||
7. **空状态有引导文字** — "Click a city on the map" 告诉用户下一步做什么
|
||||
8. **浅色/深色主题都有** — 两个主题都经过完整设计
|
||||
9. **Ops 面板功能齐全** — 系统健康、转化漏斗、缓存状态、支付异常、用户管理一览无余
|
||||
|
||||
## 四、存在的问题与待办
|
||||
|
||||
| # | 问题 | 优先级 |
|
||||
|---|------|------|
|
||||
| 1 | **Docs 无搜索** — 8 篇文档没有搜索功能,用户必须逐篇浏览 | 🟢 待做 |
|
||||
| 2 | **Ops 面板无审计日志** — 管理员补发积分等操作没有审计记录 | 🟢 需后端 |
|
||||
| 3 | **注册后邮件验证引导** — 未验证邮箱的用户反复登录失败 | 🟡 需后端 |
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# AMSC AWOS Runway Observation 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:** Add a China-only AMSC AWOS runway observation source and expose it as a runway observation tab next to Market Monitor.
|
||||
|
||||
**Architecture:** Backend fetches and normalizes AMSC `getWindPlate?cccc=...` payloads into the existing `amos`/`runway_obs` shape so current dashboard consumers can reuse runway display logic. Frontend adds a dedicated `runway` scan terminal tab that fetches a domestic city whitelist and renders runway TDZ/MID/END air temperatures without changing settlement anchors.
|
||||
|
||||
**Tech Stack:** Python data collection + pytest, Next.js/React TypeScript, existing business-state test runner.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend parser and source
|
||||
|
||||
**Files:**
|
||||
- Create: `src/data_collection/amsc_awos_sources.py`
|
||||
- Create: `tests/test_amsc_awos_sources.py`
|
||||
- Modify: `src/data_collection/weather_sources.py`
|
||||
- Modify: `src/data_collection/country_networks.py`
|
||||
|
||||
- [ ] **Step 1: Write failing parser tests**
|
||||
|
||||
Add tests that import `_amsc_parse_wind_plate_payload`, `_amsc_supported_city_codes`, and `AmscAwosSourceMixin`, parse a ZBAA-style sample, assert runway point temperatures, UTC observation conversion, `runway_temp_range`, and unauthorized/no-data fallback.
|
||||
|
||||
- [ ] **Step 2: Run red test**
|
||||
|
||||
Run: `python -m pytest tests/test_amsc_awos_sources.py -q`
|
||||
Expected: FAIL because `src.data_collection.amsc_awos_sources` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement minimal backend source**
|
||||
|
||||
Create a source module with China whitelist: `shanghai=ZSPD`, `beijing=ZBAA`, `guangzhou=ZGGG`, `shenzhen=ZGSZ`, `chengdu=ZUUU`, `chongqing=ZUCK`, `wuhan=ZHHH`, `qingdao=ZSQD`. Fetch `https://www.amsc.net.cn/gateway/api/saas/rest/amc/AwosController/getWindPlate?cccc=<ICAO>`, optionally using `POLYWEATHER_AMSC_COOKIE` or `POLYWEATHER_AMSC_SESSION_ID`, and return existing-compatible `amos` payload with `source="amsc_awos"`.
|
||||
|
||||
- [ ] **Step 4: Run green backend tests**
|
||||
|
||||
Run: `python -m pytest tests/test_amsc_awos_sources.py tests/test_amos_station_sources.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
### Task 2: Backend integration
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/data_collection/weather_sources.py`
|
||||
- Modify: `src/data_collection/country_networks.py`
|
||||
|
||||
- [ ] **Step 1: Attach AMSC after AMOS**
|
||||
|
||||
Add `AmscAwosSourceMixin` to `WeatherDataCollector`, call `_attach_china_amsc_awos_data` in both Open-Meteo and fallback paths, and persist aggregate plus first runway rows to `airport_obs_log` like AMOS.
|
||||
|
||||
- [ ] **Step 2: Normalize airport primary source labels**
|
||||
|
||||
Teach `_airport_primary_from_raw` that `raw["amos"].source == "amsc_awos"` should use `source_code="amsc_awos"`, `source_label="AMSC AWOS"`.
|
||||
|
||||
- [ ] **Step 3: Compile check**
|
||||
|
||||
Run: `python -m py_compile src/data_collection/amsc_awos_sources.py src/data_collection/weather_sources.py src/data_collection/country_networks.py`
|
||||
Expected: exit 0.
|
||||
|
||||
### Task 3: Frontend runway tab
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/components/dashboard/scan-terminal/RunwayObservationsPanel.tsx`
|
||||
- Create: `frontend/components/dashboard/scan-terminal/__tests__/runwayObservationTab.test.ts`
|
||||
- Modify: `frontend/components/dashboard/scan-terminal/ScanTerminalShellParts.tsx`
|
||||
- Modify: `frontend/components/dashboard/ScanTerminalDashboard.tsx`
|
||||
- Modify: `frontend/lib/dashboard-types.ts`
|
||||
- Modify: `frontend/components/dashboard/monitoring/monitor-temperature.ts`
|
||||
- Modify: `frontend/components/dashboard/monitoring/MonitorPanel.tsx`
|
||||
|
||||
- [ ] **Step 1: Write failing frontend business-state test**
|
||||
|
||||
Add a source-scan test asserting `ScanTerminalContentView` includes `runway`, dashboard has a `跑道观测` tab, and the panel includes `AMSC AWOS` plus TDZ/MID/END labels.
|
||||
|
||||
- [ ] **Step 2: Run red frontend test**
|
||||
|
||||
Run: `cd frontend; npm run test:business`
|
||||
Expected: FAIL because the runway tab/panel strings do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement tab and panel**
|
||||
|
||||
Add `runway` view next to Monitor. The panel fetches domestic whitelist details with `ensureCityDetail(key, false, "panel")`, displays city cards with runway rows and local-time labels, and uses a not-available message for cities without AMSC data.
|
||||
|
||||
- [ ] **Step 4: Run green frontend checks**
|
||||
|
||||
Run: `cd frontend; npm run test:business; npm run typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
### Task 4: Final verification and publish
|
||||
|
||||
**Files:**
|
||||
- All changed files from Tasks 1-3.
|
||||
|
||||
- [ ] **Step 1: Full verification**
|
||||
|
||||
Run backend tests, Python compile, frontend business tests, typecheck, and build.
|
||||
|
||||
- [ ] **Step 2: Completion audit**
|
||||
|
||||
Map user requirement “国内几个城市的机场跑道温度,放在市场监控旁边 Tab” to changed backend source, frontend tab, tests, and build evidence.
|
||||
|
||||
- [ ] **Step 3: Commit/push/deploy**
|
||||
|
||||
If verification passes, commit, push `main`, and rely on configured deployment.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Telegram Group Pricing 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:** Add Telegram Login verification so backend decides Pro price: group member 5U, non-member 10U.
|
||||
|
||||
**Architecture:** Keep Supabase as the website account session, add Telegram Login as an identity link/price verification step. Backend verifies Telegram Login hash, checks getChatMember, stores the Telegram link in existing supabase_bindings, and payment intent creation recalculates price server-side.
|
||||
|
||||
**Tech Stack:** FastAPI, existing DBManager bindings, Telegram Bot HTTP API, Next.js proxy routes, React account center, pytest.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Telegram auth service
|
||||
|
||||
**Files:**
|
||||
- Create: src/auth/telegram_group_pricing.py
|
||||
- Test: ests/test_telegram_group_pricing.py
|
||||
|
||||
- [ ] Verify Telegram Login payload HMAC using bot token.
|
||||
- [ ] Call Telegram getChatMember and treat member, dministrator, creator as group members.
|
||||
- [ ] Return 5U/10U pricing payload.
|
||||
|
||||
### Task 2: Backend auth route
|
||||
|
||||
**Files:**
|
||||
- Modify: web/core.py
|
||||
- Modify: web/services/auth_api.py
|
||||
- Modify: web/routers/auth.py
|
||||
|
||||
- [ ] Add TelegramLoginRequest model.
|
||||
- [ ] Add POST /api/auth/telegram/login requiring Supabase identity.
|
||||
- [ ] Link Telegram id to current Supabase user via DBManager.bind_supabase_identity.
|
||||
- [ ] Return Telegram member status and effective price.
|
||||
|
||||
### Task 3: Payment dynamic price
|
||||
|
||||
**Files:**
|
||||
- Modify: src/payments/contract_checkout.py
|
||||
- Modify: web/services/payment_api.py
|
||||
|
||||
- [ ] At payment intent creation, check linked Telegram id and group membership.
|
||||
- [ ] Override pro_monthly amount to 5U for members, 10U otherwise.
|
||||
- [ ] Store pricing source in metadata.
|
||||
|
||||
### Task 4: Frontend Telegram Login entry
|
||||
|
||||
**Files:**
|
||||
- Modify: rontend/components/account/AccountCenter.tsx
|
||||
- Create: rontend/app/api/auth/telegram/login/route.ts
|
||||
|
||||
- [ ] Load Telegram Login widget with configured bot username.
|
||||
- [ ] Send payload to backend proxy.
|
||||
- [ ] Show group/member price status before checkout.
|
||||
|
||||
### Task 5: Verification
|
||||
|
||||
**Commands:**
|
||||
- python -m pytest tests\test_telegram_group_pricing.py tests\test_direct_payment.py tests\test_payments_runtime.py -q
|
||||
- python -m py_compile src\auth\telegram_group_pricing.py src\payments\contract_checkout.py web\core.py web\services\auth_api.py
|
||||
- python -m ruff check src\auth\telegram_group_pricing.py src\payments\contract_checkout.py web\core.py web\services\auth_api.py tests\test_telegram_group_pricing.py
|
||||
- cd frontend; npm run typecheck
|
||||
@@ -0,0 +1,189 @@
|
||||
# PolyWeather UX 研究员审查报告
|
||||
|
||||
> 审查日期:2026-06 | 视角:UX 研究员 | 范围:用户理解修正逻辑、第一眼认知、图表误导风险、普通用户语言、天气异常体验
|
||||
|
||||
## 一、修正逻辑:用户是否看懂"上修/下修/维持"?
|
||||
|
||||
### 当前状态
|
||||
|
||||
AI 后端提示词明确要求 AI 使用**上修 / 下修 / 维持**三个方向词(`scan_city_ai_prompt.py:47-51`)。但前端 `WeatherDecisionBand` 完全不用这三个词,而是用:
|
||||
|
||||
| AI 判断方向 | 前端实际展示 | 中文原文 |
|
||||
|------------|------------|---------|
|
||||
| 上修(偏暖) | "Watch hotter range" | "关注偏高温区间" |
|
||||
| 下修(偏冷) | "Avoid chasing high" | "暂不追高温" |
|
||||
| 维持(中性) | "Wait for peak-window confirmation" | "等待峰值窗口确认" |
|
||||
|
||||
### 问题
|
||||
|
||||
| # | 问题 | 严重度 |
|
||||
|---|------|------|
|
||||
| 1 | **AI 输出与前端展示词汇不一致** — AI 用"上修/下修/维持",用户看到的是"关注偏高温/暂不追/等待确认"。这是两套完全不同的语言体系,用户读完 AI 解读再看决策条可能对不上号 | 🔴 |
|
||||
| 2 | **没有"维持/不变"标签** — 中性状态被表述为动作("等待确认"),而不是状态("维持不变")。用户想知道"现在是什么判断"而不是"现在该做什么" | 🟡 |
|
||||
| 3 | **"偏高温区间" vs "暂不追高温" 不对称** — 上修方向指向一个温度区间,下修方向指向一个行为。一个说 where,一个说 what。逻辑结构不一致 | 🟡 |
|
||||
| 4 | **颜色语义双重解读** — warm=红色边框=偏暖(上修),cold=绿色边框=偏冷(下修)。天气直觉是"热=红、冷=蓝/绿",但金融直觉是"红=跌、绿=涨"。两类用户可能得出相反的解读 | 🟡 |
|
||||
|
||||
### 建议
|
||||
|
||||
统一词汇体系,前端和 AI 使用同一套语言:
|
||||
|
||||
| 方向 | 建议前端标签 | 建议说明 |
|
||||
|------|------------|---------|
|
||||
| 上修 | **"预计最高温上修"** / "Revise upward" | 比 DEB/模型集群基准偏高 |
|
||||
| 下修 | **"预计最高温下修"** / "Revise downward" | 比 DEB/模型集群基准偏低 |
|
||||
| 维持 | **"维持模型基准"** / "Stay with model base" | 无需显著上修或下修 |
|
||||
|
||||
---
|
||||
|
||||
## 二、第一眼理解:用户打开卡片看到什么?
|
||||
|
||||
### 视觉层级
|
||||
|
||||
```
|
||||
1. CityCardHeader
|
||||
├─ Kicker: "城市深度分析" / "Deep analysis"
|
||||
├─ 城市名(大号标题)
|
||||
├─ 状态标签(实测突破 / METAR 过旧 / 模型高度一致 等)
|
||||
├─ 数据新鲜度条(METAR / 模型 / 市场 / AI 各自的新鲜度)
|
||||
└─ 三指标:当前温度 | 预计最高温 | 峰值时间
|
||||
|
||||
2. WeatherDecisionBand(修正条)
|
||||
├─ Kicker: "天气优先判断 · 市场价格另列"
|
||||
├─ 主体判断(大号粗体): "关注偏高温区间" / "暂不追高温" / "等待峰值窗口确认"
|
||||
├─ 原因文字(高亮 pill 内)
|
||||
└─ 三指标:天气区间 | 路径偏差 | 报价状态
|
||||
|
||||
3. 图表 + AI 证据 + 模型证据
|
||||
```
|
||||
|
||||
### 问题
|
||||
|
||||
| # | 问题 | 严重度 |
|
||||
|---|------|------|
|
||||
| 5 | **Kicker 说了两遍"市场"** — Header 的 kicker 是"城市深度分析",Decision band 的 kicker 是"天气优先判断 · 市场价格另列"。两个 kicker 都提到了市场,但新手不知道"市场"指的是 Polymarket 温度合约。这个词对圈外人完全无意义 | 🟡 |
|
||||
| 6 | **"预计最高温"没有解释来源** — 用户看到这个数字,不知道它是 AI 独立判断的、还是 DEB 融合的、还是某一个模型给的。只有一个数字,没有可信度标签 | 🟡 |
|
||||
| 7 | **状态标签过多** — 一个卡片可能同时显示 3-4 个标签:实测突破 + 峰值窗口已过 + METAR 过旧 + 模型高度一致。这些标签颜色不同、含义各异,用户需要逐一解码 | 🟢 |
|
||||
|
||||
---
|
||||
|
||||
## 三、图表是否误导?
|
||||
|
||||
### 日内温度图表(`AiCityTemperatureChart`)
|
||||
|
||||
三条线:
|
||||
- 灰色虚线:DEB 原始路径(过去+未来都是虚线)
|
||||
- 蓝色实线:METAR 修正路径
|
||||
- 绿色散点:METAR 实测
|
||||
|
||||
### 问题
|
||||
|
||||
| # | 问题 | 严重度 |
|
||||
|---|------|------|
|
||||
| 8 | **DEB 路径永远是虚线** — 即使过去部分(已发生的几小时)也是虚线。虚线在图形语言中普遍表示"不确定/预测",但过去几小时 DEB 已经是根据已知观测计算的,不应该看起来不确定 | 🟡 |
|
||||
| 9 | **没有"现在"标记** — 图表上没有任何竖线或标记指示当前时间。用户不知道图表上哪里是"现在",哪里是"未来" | 🔴 |
|
||||
| 10 | **X 轴标签稀疏** — 每 4 个小时才显示一个标签,最多 6 个。用户可能以为数据只在标记的小时上有,实际上每个小时都有数据 | 🟢 |
|
||||
| 11 | **HistoryChart 缺 °C/°F** — 历史图 tooltip 只显示 `°`,没有 `C` 或 `F`。在混合温度单位的环境下可能混淆 | 🟡 |
|
||||
| 12 | **没有轴标题** — Y 轴只有数字 + °C,没有"温度"标签。对新手来说不够自解释(虽然常见于仪表盘类产品) | 🟢 |
|
||||
|
||||
### 建议
|
||||
|
||||
| 建议 | 实现 |
|
||||
|------|------|
|
||||
| 添加"现在"竖线 | `chart-utils.ts` 中在 `currentIndex` 位置画一条 annotation line |
|
||||
| 过去 DEB 改为实线 | `segment: { borderDash: past=[], future=[6,4] }` 给过去和未来不同样式 |
|
||||
| HistoryChart tooltip 补全单位 | 使用 `data.temp_symbol` 替代硬编码 `°` |
|
||||
|
||||
---
|
||||
|
||||
## 四、普通用户能看懂多少?
|
||||
|
||||
### 高频出现的专业术语
|
||||
|
||||
| 术语 | 出现次数 | 用户理解难度 | 是否有解释 |
|
||||
|------|---------|------------|----------|
|
||||
| **METAR** | ~50+ 处 | 高 — 只有飞行员/气象人员知道 | ❌ 无 |
|
||||
| **DEB** | ~30+ 处 | 高 — 项目内部术语 | ❌ 无 |
|
||||
| **TAF** | ~15 处 | 高 — 航空术语 | ❌ 无 |
|
||||
| **峰值窗口** | ~20 处 | 中 — 可推测含义 | ❌ 无 |
|
||||
| **模型集群** | ~10 处 | 中 — 可推测含义 | ❌ 无 |
|
||||
| **边界层** | 3 处 | 极高 — 气象学专业术语 | ❌ 无 |
|
||||
| **冷平流/暖平流** | 2 处 | 极高 — 气象学专业术语 | ❌ 无 |
|
||||
| **中枢** | ~8 处 | 中 — 在这个语境下表示中心值 | ❌ 无 |
|
||||
|
||||
### 问题
|
||||
|
||||
| # | 问题 | 严重度 |
|
||||
|---|------|------|
|
||||
| 13 | **所有专业术语都没有 tooltip 或解释** — METAR、DEB、TAF 在全站出现数十次,没有任何地方解释它们是什么。用户的唯一学习途径是 `/docs` 页面,但需要主动离开看板去查阅 | 🔴 |
|
||||
| 14 | **AI 输出的"最终判断"字段直接暴露给用户** — 如果 AI 提到了"冷平流支撑"、"边界层逆温"等术语,前端不做任何改写或解释,直接原样展示。用户要么读懂,要么跳过 | 🟡 |
|
||||
| 15 | **"DEB 融合"对普通用户完全无意义** — 这是一个内部算法名称。用户需要的是"综合预报"或"多模型加权平均",而不是一个缩写 | 🟡 |
|
||||
|
||||
### 建议
|
||||
|
||||
| 建议 | 实现 |
|
||||
|------|------|
|
||||
| 核心术语加 tooltip | 首次出现的 METAR/DEB/TAF 加 `title` 属性或悬浮解释 |
|
||||
| DEB 改用用户语言 | "DEB 融合" → "多模型综合预报" 或保留 DEB 但加括号说明 |
|
||||
| AI 术语过滤 | 在后端 `scan_city_ai_fallback.py` 或前端展示层过滤掉过于专业的术语 |
|
||||
|
||||
---
|
||||
|
||||
## 五、天气异常时的体验
|
||||
|
||||
### 当前异常信号和用户看到的反馈
|
||||
|
||||
| 异常事件 | 前端展示 | 评估 |
|
||||
|---------|---------|------|
|
||||
| 实测温度突破模型上沿 | 红色标签"实测突破" + "Observation has broken above the model range" | ✅ 清晰 |
|
||||
| METAR 数据过旧(超过一天) | 黄色标签"METAR 过旧" + "已过旧,仅作背景参考" + 数据新鲜度条标红 | ✅ 清晰 |
|
||||
| 峰值窗口已过 | 灰色标签"峰值窗口已过" + 温度图表可能显示下降趋势 | ⚠️ 图表不标注窗口起止 |
|
||||
| 模型数据不足(<2个模型) | "等待模型补齐" | ⚠️ 没说为什么模型少、什么时候能补上 |
|
||||
| 市场价格不可用 | "市场价暂不可用" + "天气证据可参考,但暂无可交易价格" | ✅ 清晰 |
|
||||
| 快速判断与完整解读不一致 | 先显示"快速判断已完成",再异步合并完整 AI 解读 | ⚠️ 两种状态切换可能让用户困惑 |
|
||||
|
||||
### 问题
|
||||
|
||||
| # | 问题 | 严重度 |
|
||||
|---|------|------|
|
||||
| 16 | **异常没有"下一步"指引** — 当实测突破模型上沿时,用户看到"Observation has broken above the model range",但不知道这意味着该做什么。是应该买入?卖出?等待?系统不给建议 | 🔴 |
|
||||
| 17 | **"快速判断"和"完整解读"的切换可能造成 flicker** — 卡片先显示快速判断文案,然后完整 AI 返回后替换。如果两者结论一致,用户感知不到变化;如果不一致,用户会困惑"刚才不是这么说的" | 🟡 |
|
||||
| 18 | **极端天气没有特别处理** — 如果城市出现 40°C+ 或 -10°C 以下的极端温度,卡片和普通温度展示方式完全一样,没有视觉强调 | 🟢 |
|
||||
|
||||
### 建议
|
||||
|
||||
| 建议 | 实现 |
|
||||
|------|------|
|
||||
| 异常加行动建议 | 在 `primaryReason` 后追加一句行动建议("建议等待下一报文后再做判断" / "建议关注更高温区间") |
|
||||
| 极端温度视觉强化 | 温度超过历史极值时加大字号或添加红色脉冲高亮 |
|
||||
| 快速→完整过渡加标记 | 文案更新时显示 "✓ 已更新" 小标记,避免用户以为信息没变 |
|
||||
|
||||
---
|
||||
|
||||
## 六、优先级总结
|
||||
|
||||
### P0 — 用户理解障碍
|
||||
|
||||
| # | 问题 | 建议 |
|
||||
|---|------|------|
|
||||
| 1 | AI 用"上修/下修",前端用"偏高温/暂不追" | 统一为"上修/下修/维持"三词 |
|
||||
| 9 | 图表没有"现在"标记 | 在 currentIndex 位置画竖线 |
|
||||
| 13 | METAR/DEB/TAF 全站无解释 | 加 title tooltip + 首次出现时加括号说明 |
|
||||
| 16 | 异常没有行动建议 | 在 primaryReason 后追加引导文字 |
|
||||
|
||||
### P1 — 体验提升
|
||||
|
||||
| # | 问题 | 建议 |
|
||||
|---|------|------|
|
||||
| 3 | 上修/下修文案不对称 | 统一用"方向 + 幅度"格式 |
|
||||
| 4 | 红/绿颜色双重解读 | 保留但加文字标签确认 |
|
||||
| 8 | DEB 路径全是虚线 | 过去部分用实线,未来部分用虚线 |
|
||||
| 15 | "DEB 融合"对普通用户无意义 | 改为"多模型综合预报" |
|
||||
|
||||
### P2 — 优化打磨
|
||||
|
||||
| # | 问题 | 建议 |
|
||||
|---|------|------|
|
||||
| 11 | HistoryChart 缺 °C/°F | 使用 temp_symbol |
|
||||
| 12 | 图表无轴标题 | 可加可不加(仪表盘惯例) |
|
||||
| 17 | 快速→完整 flicker | 加过渡标记 |
|
||||
| 18 | 极端温度无强调 | 加视觉强化 |
|
||||
@@ -1,12 +1,12 @@
|
||||
# PolyWeather Side Panel
|
||||
|
||||
`PolyWeather Side Panel` 是一个面向天气交易场景的 Chrome / Edge 浏览器侧边栏工具。
|
||||
`PolyWeather Side Panel` 是一个面向天气交易市场的 Chrome / Edge 浏览器侧边栏工具。
|
||||
|
||||
## 功能
|
||||
|
||||
1. 自动识别当前 Polymarket 页面中的城市,也支持手动切换。
|
||||
2. 展示城市档案:结算站点、站点距离、观测更新时间、周边站点数量。
|
||||
3. 展示今日日内走势(简版):`DEB` 走势与官方观测(`METAR / HKO / CWA / NOAA`)对照,可悬停查看时间与温度。
|
||||
3. 展示今日日内走势(简版):`DEB` 走势与机场/官方观测对照,可悬停查看时间与温度。
|
||||
4. 展示多日最高温预报(简版),当前以 `DEB` 优先。
|
||||
5. 支持一键刷新,强制拉取最新温度数据。
|
||||
6. 支持本地缓存,提升打开速度;插件版本更新时会刷新城市列表缓存。
|
||||
@@ -15,8 +15,9 @@
|
||||
## 数据说明
|
||||
|
||||
- 香港使用 `HKO`(香港天文台)结算源。
|
||||
- 其他城市按配置使用 `METAR / NOAA / 官方数据源`。
|
||||
- 其他城市按配置使用 `METAR / NOAA / AMOS / JMA / MGM / FMI / KNMI` 等官方数据源。
|
||||
- 城市展示名以主站返回值为准,例如 `aurora` 市场在插件中会显示为 `Denver`。
|
||||
- 首尔/釜山/东京/安卡拉/伊斯坦布尔/赫尔辛基/阿姆斯特丹已接入高频机场实时数据(1-10 分钟级)。
|
||||
|
||||
## 权限说明
|
||||
|
||||
@@ -33,14 +34,14 @@
|
||||
1. 打开 Chrome/Edge 扩展页面:
|
||||
- Chrome:`chrome://extensions`
|
||||
- Edge:`edge://extensions`
|
||||
2. 打开“开发者模式”。
|
||||
3. 选择“加载已解压的扩展程序”。
|
||||
2. 打开"开发者模式"。
|
||||
3. 选择"加载已解压的扩展程序"。
|
||||
4. 选择目录:`extension/`。
|
||||
5. 点击扩展图标,侧边栏会打开。
|
||||
|
||||
## 设置
|
||||
|
||||
首次建议打开扩展“选项页”并确认:
|
||||
首次建议打开扩展"选项页"并确认:
|
||||
|
||||
- `网站基础地址`:你的前端域名(例如 `https://polyweather-pro.vercel.app`)
|
||||
- `API 基础地址`:你的后端 API 域名(若同域也可填前端域名)
|
||||
@@ -48,9 +49,9 @@
|
||||
|
||||
## 说明
|
||||
|
||||
- 当前版本仍是轻量产品,重点是“监控 + 基础判断 + 导流回站”,未接入支付链路。
|
||||
- 当前版本仍是轻量产品,重点是"监控 + 基础判断 + 导流回站",未接入支付链路。
|
||||
- 若你的 API 做了严格鉴权,请先在设置页填写 token 再使用。
|
||||
- 插件城市列表来自主站 `/api/cities`,不是插件内置静态列表;本版已升级缓存版本,安装更新后会重新拉取 Manila、Karachi、Masroor Air Base 等最新城市。
|
||||
- 插件走势图与主站保持一致:`Wunderground / weather.com` 是历史参考页,不是物理实测站;机场市场统一显示机场 `METAR` / 官方观测点位。
|
||||
- 点击“打开网站查看更多”会回到主站继续查看完整分析。
|
||||
- 插件城市列表来自主站 `/api/cities`,非内置静态列表;安装更新后自动刷新最新城市。
|
||||
- 机场市场统一显示机场 `METAR` / 官方观测点位,部分城市已覆盖 1 分钟级实时数据。
|
||||
- 点击"打开网站查看更多"会回到主站继续查看完整分析。
|
||||
- 插件不会承载完整分析;完整结构判断、历史对账和更多信号仍以主站为准。
|
||||
|
||||
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 826 B After Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"name": "PolyWeather Side Panel",
|
||||
"description": "Weather side panel for Polymarket.",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.11",
|
||||
"icons": {
|
||||
"16": "icon-16.png",
|
||||
"32": "icon-32.png",
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
|
||||
> polyweather-frontend@1.5.4 start
|
||||
> next start -p 3002
|
||||
|
||||
▲ Next.js 15.5.12
|
||||
- Local: http://localhost:3002
|
||||
- Network: http://172.23.64.1:3002
|
||||
|
||||
✓ Starting...
|
||||
✓ Ready in 541ms
|
||||
@@ -15,6 +15,11 @@ NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
|
||||
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
|
||||
# 生产环境必须设为 https://polyweather-pro.vercel.app
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app
|
||||
|
||||
# 常用:前端鉴权开关
|
||||
# true: 启用 Supabase 登录
|
||||
# false: 关闭登录能力,访客模式
|
||||
@@ -39,4 +44,5 @@ NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=polyyuanbot
|
||||
|
||||
@@ -1 +1,48 @@
|
||||
# PolyWeather 前端最小配置(本地 / Vercel)
|
||||
# 只部署天气看板时,先填下面 4 项即可。
|
||||
|
||||
# 必填:后端 FastAPI 基础地址
|
||||
# 默认供 Next.js API Route 在服务端代理后端使用。
|
||||
POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000
|
||||
|
||||
# 可选:浏览器直连后端 FastAPI 基础地址。
|
||||
# 在 Vercel 免费额度下建议配置为 VPS HTTPS 域名,让 AI / METAR / scan 等
|
||||
# 长耗时请求绕过 Vercel Functions / Fluid Compute。
|
||||
# 例如:https://api.example.com
|
||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=
|
||||
|
||||
# 必填:Supabase 前端公钥(鉴权开启时必须)
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
|
||||
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
|
||||
# 生产环境必须设为 https://polyweather-pro.vercel.app
|
||||
NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app
|
||||
|
||||
# 常用:前端鉴权开关
|
||||
# true: 启用 Supabase 登录
|
||||
# false: 关闭登录能力,访客模式
|
||||
POLYWEATHER_AUTH_ENABLED=false
|
||||
|
||||
# 常用:是否强制登录
|
||||
# true: middleware 强制登录后才能访问主页面
|
||||
# false: 登录可选,访客可浏览
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
|
||||
# 可选:分享式看板访问令牌
|
||||
# 设置后,可通过 /?access_token=<token> 打开受保护看板
|
||||
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
|
||||
|
||||
# 可选:前端 API Route 转发到后端时附带的共享令牌
|
||||
# 仅当后端启用了 entitlement / 订阅校验时需要
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
|
||||
# 可选:钱包支付 / Telegram 入口
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app
|
||||
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=polyyuanbot
|
||||
|
||||
@@ -21,8 +21,8 @@ PolyWeather Pro 的生产前端工程。
|
||||
|
||||
## 当前前端能力
|
||||
|
||||
- 主站 Dashboard 支持地图、城市详情、今日日内分析、历史准确率对账和账户中心
|
||||
- `/docs` 已提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF、结算来源和历史对账
|
||||
- 主站 Dashboard 支持地图、城市详情、今日日内分析和账户中心
|
||||
- `/docs` 已提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF 和结算来源
|
||||
- 今日日内分析支持:
|
||||
- `锚点状态`
|
||||
- `当前节奏`
|
||||
@@ -31,9 +31,6 @@ PolyWeather Pro 的生产前端工程。
|
||||
- `专业气象结论条`
|
||||
- `气象证据链 / 失效条件 / 确认条件`
|
||||
- 非香港机场城市的 `TAF` 时段提示与走势图联动
|
||||
- 历史对账支持:
|
||||
- `DEB / 最佳单模型 / 实测最高温` 对比
|
||||
- 峰值前 12 小时 `DEB` 参考(近似)
|
||||
- `/ops` 已支持桌面表格 + 手机端卡片化视图
|
||||
- 点击城市图标后会显示地图顶部同步提醒与详情面板内同步徽标,避免用户误判为卡住
|
||||
- 城市详情会自动识别“单模型 / 单日”的稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
|
||||
@@ -43,8 +40,8 @@ PolyWeather Pro 的生产前端工程。
|
||||
- 城市决策卡的 AI 机场报文解读包括最终判断、METAR 解读、推理说明、模型集群备注、风险提示和原始 METAR
|
||||
- AI 机场报文解读按 `city + local_date + locale + METAR signature` 做页面内存缓存和 `localStorage` 最终结果缓存;切换选项卡返回时会优先恢复已有内容
|
||||
- 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率`
|
||||
- 概率区展示当前生产概率引擎输出;EMOS / LGBM 只在评估通过或 shadow 对照时进入解释层,模型共识和市场价格只作为辅助说明
|
||||
- `/ops` 现已展示 prewarm worker 运行态、缓存桶状态与 summary cache hit/miss
|
||||
- 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考
|
||||
- 缓存桶状态与 summary cache hit/miss
|
||||
|
||||
## 本地开发
|
||||
|
||||
@@ -98,7 +95,7 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
|
||||
# 社群入口
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
|
||||
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
|
||||
|
||||
# 推荐默认关闭的前端观测 / 预热开关
|
||||
NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS=false
|
||||
@@ -117,7 +114,6 @@ NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
|
||||
- `GET /api/city/[name]`
|
||||
- `GET /api/city/[name]/summary`
|
||||
- `GET /api/city/[name]/detail`
|
||||
- `GET /api/history/[name]`
|
||||
|
||||
鉴权:
|
||||
|
||||
@@ -154,7 +150,6 @@ Ops:
|
||||
|
||||
- 系统状态
|
||||
- SQLite / rollout / 支付运行态
|
||||
- prewarm worker 运行态
|
||||
- 缓存桶状态与 summary cache hit/miss
|
||||
- 用户查询
|
||||
- 当前会员
|
||||
@@ -205,4 +200,4 @@ Ops:
|
||||
|
||||
详见根目录策略文档:`docs/OPEN_CORE_POLICY.md`
|
||||
|
||||
最后更新:`2026-04-19`
|
||||
最后更新:`2026-05-23`
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function AccountErrorPage({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("Account page error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
padding: "2rem",
|
||||
gap: "1rem",
|
||||
backgroundColor: "var(--color-bg-base, #0B1220)",
|
||||
color: "var(--color-text-primary, #E6EDF3)",
|
||||
fontFamily: "var(--font-data, Inter, sans-serif)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "1.25rem",
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
color: "var(--color-accent-primary, #4DA3FF)",
|
||||
}}
|
||||
>
|
||||
账户页面出错
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--color-text-secondary, #9FB2C7)",
|
||||
fontSize: "0.875rem",
|
||||
margin: 0,
|
||||
maxWidth: 420,
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
如果是在支付或绑定钱包时出现此问题,常见原因是钱包插件冲突(例如同时开启了 MetaMask 和
|
||||
Rabby)。请尝试关闭其他钱包插件后刷新页面重试。
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--color-text-muted, #7D8FA3)",
|
||||
fontSize: "0.8rem",
|
||||
margin: 0,
|
||||
maxWidth: 420,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
If this happened during payment or wallet binding, the most common cause is
|
||||
conflicting wallet extensions. Try disabling other wallet extensions (e.g.
|
||||
MetaMask + Rabby) and refresh.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.5rem 1.25rem",
|
||||
borderRadius: "var(--radius-md, 10px)",
|
||||
border: "1px solid var(--color-border-default, rgba(159,178,199,0.16))",
|
||||
backgroundColor: "var(--color-bg-raised, #111A2E)",
|
||||
color: "var(--color-accent-primary, #4DA3FF)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 542 KiB After Width: | Height: | Size: 542 KiB |
@@ -10,7 +10,7 @@ import {
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const ANALYTICS_ENABLED =
|
||||
process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true";
|
||||
process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS !== "false";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!ANALYTICS_ENABLED) {
|
||||
@@ -26,7 +26,9 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/analytics/events`, {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const body = await req.text();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/auth/telegram/bind-by-token`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to bind Telegram account",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,33 +7,22 @@ import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
const response = NextResponse.json(
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
const { name } = await context.params;
|
||||
const url = `${API_BASE}/api/history/${encodeURIComponent(name)}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const fetchOptions = {
|
||||
const res = await fetch(`${API_BASE}/api/auth/telegram/bot-bind-link`, {
|
||||
method: "POST",
|
||||
headers: auth.headers,
|
||||
next: { revalidate: 60 },
|
||||
} as const;
|
||||
const res = await fetch(url, {
|
||||
...fetchOptions,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
@@ -41,16 +30,11 @@ export async function GET(
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
"public, max-age=0, s-maxage=60, stale-while-revalidate=300",
|
||||
);
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch history",
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to create Telegram bot bind link",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const body = await req.text();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/auth/telegram/login`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to verify Telegram login",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
@@ -21,30 +12,10 @@ export async function GET(req: NextRequest) {
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const res = await fetch(`${API_BASE}/api/cities`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
"no-store, max-age=0",
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch cities",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: "public, max-age=0, s-maxage=60, stale-while-revalidate=300",
|
||||
publicMessage: "Failed to fetch cities",
|
||||
revalidateSeconds: 60,
|
||||
url: `${API_BASE}/api/cities`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -24,6 +18,7 @@ export async function GET(
|
||||
|
||||
const { name } = await context.params;
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
|
||||
const depth = req.nextUrl.searchParams.get("depth");
|
||||
const marketSlug = req.nextUrl.searchParams.get("market_slug");
|
||||
const targetDate = req.nextUrl.searchParams.get("target_date");
|
||||
@@ -41,24 +36,12 @@ export async function GET(
|
||||
}
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(url, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch city detail aggregate",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: cachePolicy.responseCacheControl,
|
||||
fetchCache:
|
||||
cachePolicy.fetchMode === "no-store" ? "no-store" : undefined,
|
||||
publicMessage: "Failed to fetch city detail aggregate",
|
||||
revalidateSeconds: cachePolicy.revalidateSeconds,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import { buildForceRefreshProxyCachePolicy } from "@/lib/proxy-cache-policy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -26,6 +20,7 @@ export async function GET(
|
||||
const params = new URLSearchParams();
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
params.set("force_refresh", forceRefresh);
|
||||
const cachePolicy = buildForceRefreshProxyCachePolicy(forceRefresh, 20);
|
||||
|
||||
const targetDate = req.nextUrl.searchParams.get("target_date");
|
||||
if (targetDate) {
|
||||
@@ -44,32 +39,15 @@ export async function GET(
|
||||
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/market-scan?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(url, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 800,
|
||||
error: "Backend city market scan failed",
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch city market scan",
|
||||
status: 502,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: cachePolicy.responseCacheControl,
|
||||
detailLimit: 800,
|
||||
error: "Backend city market scan failed",
|
||||
fetchCache:
|
||||
cachePolicy.fetchMode === "no-store" ? "no-store" : undefined,
|
||||
publicMessage: "Failed to fetch city market scan",
|
||||
revalidateSeconds: cachePolicy.revalidateSeconds,
|
||||
statusOnException: 502,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -64,6 +66,9 @@ function buildFallbackCityDetail(name: string, depth: string, summary: Record<st
|
||||
sunshine_hours: null,
|
||||
},
|
||||
multi_model: {},
|
||||
multi_model_daily: {},
|
||||
source_forecasts: {},
|
||||
hourly: { times: [], temps: [], radiation: [] },
|
||||
probabilities: {
|
||||
mu: null,
|
||||
distribution: [],
|
||||
@@ -104,6 +109,12 @@ function buildFallbackCityDetail(name: string, depth: string, summary: Record<st
|
||||
function normalizeCityDetailPayload(data: unknown) {
|
||||
if (!data || typeof data !== "object") return data;
|
||||
const payload = data as Record<string, any>;
|
||||
|
||||
// Backend v2 nests hourly under timeseries; chart expects it at top level.
|
||||
if (!payload.hourly && payload.timeseries?.hourly) {
|
||||
payload.hourly = payload.timeseries.hourly;
|
||||
}
|
||||
|
||||
if (!payload.market_scan && payload.market_scan_payload) {
|
||||
return {
|
||||
...payload,
|
||||
@@ -128,29 +139,38 @@ export async function GET(
|
||||
const { name } = await context.params;
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
const depth = req.nextUrl.searchParams.get("depth") ?? "panel";
|
||||
const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15);
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}&depth=${encodeURIComponent(depth)}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const res = await fetch(url, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
...(cachePolicy.fetchMode === "no-store"
|
||||
? { cache: "no-store" as const }
|
||||
: { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const summaryUrl = `${API_BASE}/api/city/${encodeURIComponent(name)}/summary?force_refresh=${forceRefresh}`;
|
||||
const summaryRes = await fetch(summaryUrl, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
...(cachePolicy.fetchMode === "no-store"
|
||||
? { cache: "no-store" as const }
|
||||
: { next: { revalidate: 10 } }),
|
||||
});
|
||||
if (summaryRes.ok) {
|
||||
const summaryData = await summaryRes.json();
|
||||
const response = NextResponse.json(buildFallbackCityDetail(name, depth, summaryData), {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"X-PolyWeather-Fallback": "summary",
|
||||
},
|
||||
});
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
buildFallbackCityDetail(name, depth, summaryData),
|
||||
cachePolicy.fetchMode === "no-store"
|
||||
? cachePolicy.responseCacheControl
|
||||
: "public, max-age=0, s-maxage=10, stale-while-revalidate=30",
|
||||
);
|
||||
response.headers.set("X-PolyWeather-Fallback", "summary");
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
@@ -158,7 +178,11 @@ export async function GET(
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = normalizeCityDetailPayload(await res.json());
|
||||
const response = NextResponse.json(data);
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
cachePolicy.responseCacheControl,
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import { buildForceRefreshProxyCachePolicy } from "@/lib/proxy-cache-policy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -25,50 +18,15 @@ export async function GET(
|
||||
|
||||
const { name } = await context.params;
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
const bypassCache = forceRefresh === "true";
|
||||
const cachePolicy = buildForceRefreshProxyCachePolicy(forceRefresh, 20);
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/summary?force_refresh=${forceRefresh}`;
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const fetchOptions =
|
||||
bypassCache
|
||||
? {
|
||||
headers: auth.headers,
|
||||
cache: "no-store" as const,
|
||||
}
|
||||
: {
|
||||
headers: auth.headers,
|
||||
next: { revalidate: 20 },
|
||||
};
|
||||
const res = await fetch(url, {
|
||||
...fetchOptions,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (bypassCache) {
|
||||
const response = NextResponse.json(data, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
"public, max-age=0, s-maxage=20, stale-while-revalidate=60",
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch city summary",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: cachePolicy.responseCacheControl,
|
||||
fetchCache:
|
||||
cachePolicy.fetchMode === "no-store" ? "no-store" : undefined,
|
||||
publicMessage: "Failed to fetch city summary",
|
||||
revalidateSeconds: cachePolicy.revalidateSeconds,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const BACKEND = API_BASE ? `${API_BASE}/api/ops/config` : "";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Config fetch failed" }); }
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const body = await req.text();
|
||||
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Config update failed" }); }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/ops/health-check`, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Health check failed" }); }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const url = new URL(`${API_BASE}/api/ops/memberships/growth`);
|
||||
const days = req.nextUrl.searchParams.get("days");
|
||||
if (days) url.searchParams.set("days", days);
|
||||
const res = await fetch(url.toString(), { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Growth fetch failed" }); }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const ENTITLEMENT_TOKEN = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim() || "";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const body = await req.text();
|
||||
const headers: Record<string, string> = { ...auth.headers as Record<string, string>, "Content-Type": "application/json" };
|
||||
if (ENTITLEMENT_TOKEN) {
|
||||
headers.Authorization = `Bearer ${ENTITLEMENT_TOKEN}`;
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/api/ops/subscriptions/extend`, {
|
||||
method: "POST", headers, body, cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Subscription extend failed" }); }
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
function parseAdminEmails() {
|
||||
return String(process.env.POLYWEATHER_OPS_ADMIN_EMAILS || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function getBearerEmail(req: NextRequest) {
|
||||
const auth = String(req.headers.get("authorization") || "").trim();
|
||||
const token = auth.replace(/^bearer\s+/i, "").trim();
|
||||
const supabaseUrl = String(process.env.NEXT_PUBLIC_SUPABASE_URL || "").trim();
|
||||
const anonKey = String(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "").trim();
|
||||
if (!token || !supabaseUrl || !anonKey) return "";
|
||||
const res = await fetch(`${supabaseUrl.replace(/\/$/, "")}/auth/v1/user`, {
|
||||
headers: {
|
||||
apikey: anonKey,
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return "";
|
||||
const data = (await res.json()) as { email?: string };
|
||||
return String(data.email || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
async function findSupabaseUserIdByEmail(email: string) {
|
||||
const supabaseUrl = String(process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || "")
|
||||
.trim()
|
||||
.replace(/\/$/, "");
|
||||
const serviceRoleKey = String(process.env.SUPABASE_SERVICE_ROLE_KEY || "").trim();
|
||||
if (!supabaseUrl || !serviceRoleKey) {
|
||||
throw new Error("Supabase service role is not configured on Vercel");
|
||||
}
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/auth/v1/admin/users?filter=${encodeURIComponent(`email.eq.${email}`)}`,
|
||||
{
|
||||
headers: {
|
||||
apikey: serviceRoleKey,
|
||||
Authorization: `Bearer ${serviceRoleKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
users?: Array<{ id?: string }>;
|
||||
};
|
||||
if (!res.ok) throw new Error(`Supabase user lookup failed: ${JSON.stringify(data).slice(0, 200)}`);
|
||||
const userId = String(data.users?.[0]?.id || "").trim();
|
||||
if (!userId) {
|
||||
const error = new Error(`user not found: ${email}`);
|
||||
(error as Error & { status?: number }).status = 404;
|
||||
throw error;
|
||||
}
|
||||
return { supabaseUrl, serviceRoleKey, userId };
|
||||
}
|
||||
|
||||
async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, authEmail?: string | null) {
|
||||
const adminEmail = String(authEmail || (await getBearerEmail(req)) || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const allowedEmails = parseAdminEmails();
|
||||
if (!adminEmail) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!allowedEmails.includes(adminEmail)) {
|
||||
return NextResponse.json({ error: "ops admin required" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = JSON.parse(bodyText || "{}") as {
|
||||
email?: string;
|
||||
plan_code?: string;
|
||||
days?: number;
|
||||
};
|
||||
const email = String(body.email || "").trim().toLowerCase();
|
||||
const planCode = String(body.plan_code || "pro_monthly").trim();
|
||||
const days = Math.max(1, Math.min(365, Number(body.days || 30)));
|
||||
if (!email) return NextResponse.json({ error: "email is required" }, { status: 400 });
|
||||
if (planCode !== "pro_monthly") {
|
||||
return NextResponse.json({ error: "invalid plan_code" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { supabaseUrl, serviceRoleKey, userId } = await findSupabaseUserIdByEmail(email);
|
||||
const now = new Date();
|
||||
const expires = new Date(now.getTime() + days * 86_400_000);
|
||||
const payload = {
|
||||
user_id: userId,
|
||||
email,
|
||||
plan_code: planCode,
|
||||
"status": "active",
|
||||
starts_at: now.toISOString(),
|
||||
expires_at: expires.toISOString(),
|
||||
source: "ops_manual_grant_next_fallback",
|
||||
created_at: now.toISOString(),
|
||||
updated_at: now.toISOString(),
|
||||
};
|
||||
const insert = await fetch(`${supabaseUrl}/rest/v1/subscriptions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceRoleKey,
|
||||
Authorization: `Bearer ${serviceRoleKey}`,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await insert.text();
|
||||
if (!insert.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Supabase insert failed", detail: raw.slice(0, 300) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
user_id: userId,
|
||||
plan_code: planCode,
|
||||
days,
|
||||
expires_at: expires.toISOString(),
|
||||
fallback: "next_supabase_direct",
|
||||
});
|
||||
} catch (error) {
|
||||
const status = Number((error as Error & { status?: number }).status || 500);
|
||||
return NextResponse.json({ error: String(error) }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const body = await req.text();
|
||||
if (!API_BASE) {
|
||||
return grantSubscriptionDirectly(req, body, auth.authEmail);
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/ops/subscriptions/grant`, {
|
||||
method: "POST",
|
||||
headers: { ...(auth.headers as Record<string, string>), "Content-Type": "application/json" },
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
if (res.status === 404) {
|
||||
const fallback = await grantSubscriptionDirectly(req, body, auth.authEmail);
|
||||
return applyAuthResponseCookies(fallback, auth.response);
|
||||
}
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) {
|
||||
return buildProxyExceptionResponse(e, { publicMessage: "Subscription grant failed" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/ops/telegram/members-audit`, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Telegram audit failed" }); }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/ops/training/accuracy`, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Training accuracy fetch failed" }); }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const url = new URL(`${API_BASE}/api/ops/logs`);
|
||||
const level = req.nextUrl.searchParams.get("level");
|
||||
const lines = req.nextUrl.searchParams.get("lines");
|
||||
if (level) url.searchParams.set("level", level);
|
||||
if (lines) url.searchParams.set("lines", lines);
|
||||
const res = await fetch(url.toString(), { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Log fetch failed" }); }
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,28 +10,12 @@ export async function GET(req: NextRequest) {
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/payments/config`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch payment config",
|
||||
});
|
||||
}
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: "public, max-age=0, s-maxage=300, stale-while-revalidate=900",
|
||||
detailLimit: 350,
|
||||
includeSupabaseIdentity: true,
|
||||
publicMessage: "Failed to fetch payment config",
|
||||
revalidateSeconds: 300,
|
||||
url: `${API_BASE}/api/payments/config`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -24,6 +25,8 @@ export async function POST(
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -21,29 +14,11 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
const { intentId } = await context.params;
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch payment intent",
|
||||
});
|
||||
}
|
||||
return proxyBackendJsonGet(req, {
|
||||
detailLimit: 350,
|
||||
fetchCache: "no-store",
|
||||
includeSupabaseIdentity: true,
|
||||
publicMessage: "Failed to fetch payment intent",
|
||||
url: `${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -24,6 +25,8 @@ export async function POST(
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(
|
||||
@@ -37,8 +40,14 @@ export async function POST(
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
let detail = raw.slice(0, 350);
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.detail) detail = String(parsed.detail).slice(0, 350);
|
||||
} catch {}
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
error: detail || undefined,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ intentId: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
const { intentId } = await context.params;
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}/validate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: proxiedHeaders,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
let detail = raw.slice(0, 350);
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.detail) detail = String(parsed.detail).slice(0, 350);
|
||||
} catch {}
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
error: detail || undefined,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to validate payment tx",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -35,6 +36,8 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/intents`, {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
@@ -17,6 +18,8 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, {
|
||||
method: "POST",
|
||||
headers: auth.headers,
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -18,28 +11,13 @@ export async function GET(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/payments/runtime`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 500,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch payment runtime",
|
||||
});
|
||||
}
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: "no-store",
|
||||
conditionalResponse: false,
|
||||
detailLimit: 500,
|
||||
fetchCache: "no-store",
|
||||
includeSupabaseIdentity: true,
|
||||
publicMessage: "Failed to fetch payment runtime",
|
||||
url: `${API_BASE}/api/payments/runtime`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -20,6 +21,8 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
|
||||
|
||||
@@ -2,9 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
proxyBackendJsonGet,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
@@ -17,29 +19,15 @@ export async function GET(req: NextRequest) {
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch wallets",
|
||||
});
|
||||
}
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: "no-store",
|
||||
conditionalResponse: false,
|
||||
detailLimit: 350,
|
||||
fetchCache: "no-store",
|
||||
includeSupabaseIdentity: true,
|
||||
publicMessage: "Failed to fetch wallets",
|
||||
url: `${API_BASE}/api/payments/wallets`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
@@ -57,6 +45,8 @@ export async function DELETE(req: NextRequest) {
|
||||
}
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
requireBackendAuthUser,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
@@ -20,6 +21,8 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireBackendAuthUser(auth);
|
||||
if (authError) return authError;
|
||||
const proxiedHeaders = new Headers(auth.headers);
|
||||
proxiedHeaders.set("Content-Type", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets/verify`, {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const OVERVIEW_PROXY_TIMEOUT_MS = Math.max(
|
||||
35_000,
|
||||
Number(process.env.POLYWEATHER_SCAN_OVERVIEW_PROXY_TIMEOUT_MS || "45000") || 45_000,
|
||||
);
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), OVERVIEW_PROXY_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
auth = await buildBackendRequestHeaders(req);
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
headers.set("Accept", "application/json");
|
||||
const res = await fetch(`${API_BASE}/api/scan/terminal/overview`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const timedOut = controller.signal.aborted;
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: timedOut
|
||||
? "Market overview request timed out"
|
||||
: "Failed to fetch market overview",
|
||||
status: timedOut ? 504 : 500,
|
||||
});
|
||||
return auth ? applyAuthResponseCookies(response, auth.response) : response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,12 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import { buildForceRefreshProxyCachePolicy } from "@/lib/proxy-cache-policy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number(
|
||||
process.env.POLYWEATHER_SCAN_TERMINAL_PROXY_TIMEOUT_MS || "28000",
|
||||
);
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 30;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
@@ -25,6 +18,7 @@ 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",
|
||||
@@ -42,41 +36,24 @@ export async function GET(req: NextRequest) {
|
||||
params.set(key, value);
|
||||
}
|
||||
}
|
||||
const cachePolicy = buildForceRefreshProxyCachePolicy(forceRefresh, 10);
|
||||
|
||||
const url = `${API_BASE}/api/scan/terminal?${params.toString()}`;
|
||||
|
||||
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), SCAN_TERMINAL_PROXY_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(url, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
return await proxyBackendJsonGet(req, {
|
||||
cacheControl: cachePolicy.responseCacheControl,
|
||||
fetchCache:
|
||||
cachePolicy.fetchMode === "no-store" ? "no-store" : undefined,
|
||||
publicMessage: "Failed to fetch scan terminal data",
|
||||
revalidateSeconds: cachePolicy.revalidateSeconds,
|
||||
signal: controller.signal,
|
||||
timeoutPublicMessage: "Scan terminal request timed out",
|
||||
url,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const timedOut = controller.signal.aborted;
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: timedOut
|
||||
? "Scan terminal request timed out"
|
||||
: "Failed to fetch scan terminal data",
|
||||
status: timedOut ? 504 : 500,
|
||||
});
|
||||
return auth ? applyAuthResponseCookies(response, auth.response) : response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -18,28 +11,11 @@ export async function GET(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/system/status`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 500,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch system status",
|
||||
});
|
||||
}
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: "public, max-age=0, s-maxage=30, stale-while-revalidate=120",
|
||||
detailLimit: 500,
|
||||
publicMessage: "Failed to fetch system status",
|
||||
revalidateSeconds: 30,
|
||||
url: `${API_BASE}/api/system/status`,
|
||||
});
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
|
||||
import { getConfiguredSiteUrl } from "@/lib/site-url";
|
||||
|
||||
function normalizeNextPath(input: string | null) {
|
||||
const fallback = "/";
|
||||
@@ -11,6 +12,16 @@ function normalizeNextPath(input: string | null) {
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const configuredSiteUrl = getConfiguredSiteUrl();
|
||||
if (configuredSiteUrl) {
|
||||
const canonicalOrigin = new URL(configuredSiteUrl).origin;
|
||||
if (request.nextUrl.origin !== canonicalOrigin) {
|
||||
const canonicalCallbackUrl = new URL(request.nextUrl.pathname, canonicalOrigin);
|
||||
canonicalCallbackUrl.search = request.nextUrl.search;
|
||||
return NextResponse.redirect(canonicalCallbackUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next"));
|
||||
const redirectUrl = request.nextUrl.clone();
|
||||
redirectUrl.pathname = nextPath;
|
||||
@@ -29,4 +40,3 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Link from "next/link";
|
||||
|
||||
type Props = {
|
||||
searchParams?: Promise<{ next?: string }>;
|
||||
};
|
||||
@@ -21,37 +23,82 @@ export default async function EntitlementRequiredPage({ searchParams }: Props) {
|
||||
<section
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 720,
|
||||
maxWidth: 480,
|
||||
border: "1px solid rgba(68, 92, 140, 0.45)",
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
background: "rgba(9, 18, 36, 0.88)",
|
||||
boxShadow: "0 20px 50px rgba(0, 0, 0, 0.35)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<h1 style={{ margin: 0, fontSize: 28, lineHeight: 1.2 }}>
|
||||
Entitlement Required
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 22,
|
||||
lineHeight: 1.3,
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
需要登录方可访问此页面
|
||||
</h1>
|
||||
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
|
||||
This dashboard is protected. If Supabase auth is enabled, please go to{" "}
|
||||
<a
|
||||
本页面需要登录权限。请登录后重试。
|
||||
<br />
|
||||
Sign in required to access this page.
|
||||
</p>
|
||||
<div style={{ marginTop: 20, display: "flex", gap: 12, justifyContent: "center", flexWrap: "wrap" }}>
|
||||
<Link
|
||||
href={`/auth/login?next=${encodeURIComponent(nextPath)}`}
|
||||
style={{
|
||||
color: "#8fc5ff",
|
||||
display: "inline-flex",
|
||||
minHeight: 36,
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
minHeight: 40,
|
||||
padding: "8px 20px",
|
||||
borderRadius: 12,
|
||||
background: "linear-gradient(135deg, #2563EB, #4F46E5)",
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
textDecoration: "none",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
/auth/login
|
||||
</a>{" "}
|
||||
to sign in first.
|
||||
</p>
|
||||
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
|
||||
Legacy mode still supports <code>?access_token=<your-token></code>.
|
||||
</p>
|
||||
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
|
||||
Requested path: <code>{nextPath}</code>
|
||||
去登录 / Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
minHeight: 40,
|
||||
padding: "8px 20px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(68, 92, 140, 0.45)",
|
||||
background: "rgba(68, 92, 140, 0.2)",
|
||||
color: "#d6e2ff",
|
||||
fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
返回首页 / Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 20,
|
||||
fontSize: 12,
|
||||
color: "#7891b5",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
传统令牌模式仍支持 <code style={{ background: "rgba(255,255,255,0.06)", padding: "2px 6px", borderRadius: 4 }}>?access_token=<your-token></code>
|
||||
<br />
|
||||
<span style={{ marginTop: 4, display: "inline-block" }}>
|
||||
请求路径 / Requested path: <code>{nextPath}</code>
|
||||
</span>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function ErrorPage({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("Unhandled page error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
padding: "2rem",
|
||||
gap: "1rem",
|
||||
backgroundColor: "var(--color-bg-base, #0B1220)",
|
||||
color: "var(--color-text-primary, #E6EDF3)",
|
||||
fontFamily: "var(--font-data, Inter, sans-serif)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: "var(--radius-xl, 20px)",
|
||||
backgroundColor: "var(--color-bg-raised, #111A2E)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "1.8rem" }}>⚠</span>
|
||||
</div>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "1.25rem",
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
页面出错了
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--color-text-secondary, #9FB2C7)",
|
||||
fontSize: "0.875rem",
|
||||
margin: 0,
|
||||
maxWidth: 400,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
数据处理时遇到了意外问题,请尝试刷新页面。
|
||||
</p>
|
||||
{error.digest ? (
|
||||
<code
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--color-text-muted, #7D8FA3)",
|
||||
fontFamily: "var(--font-mono, monospace)",
|
||||
}}
|
||||
>
|
||||
{error.digest}
|
||||
</code>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.5rem 1.25rem",
|
||||
borderRadius: "var(--radius-md, 10px)",
|
||||
border: "1px solid var(--color-border-default, rgba(159,178,199,0.16))",
|
||||
backgroundColor: "var(--color-bg-raised, #111A2E)",
|
||||
color: "var(--color-accent-primary, #4DA3FF)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 826 B After Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("Unhandled root error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html lang="zh-CN" className="dark">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PolyWeather — 出错了</title>
|
||||
</head>
|
||||
<body
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
backgroundColor: "var(--color-bg-base, #0B1220)",
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
padding: "2rem",
|
||||
gap: "1rem",
|
||||
color: "var(--color-text-primary, #E6EDF3)",
|
||||
fontFamily: "var(--font-data, Inter, sans-serif)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: "var(--radius-xl, 20px)",
|
||||
backgroundColor: "var(--color-bg-raised, #111A2E)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "1.8rem" }}>⚠</span>
|
||||
</div>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "1.25rem",
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
页面出错了
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--color-text-secondary, #9FB2C7)",
|
||||
fontSize: "0.875rem",
|
||||
margin: 0,
|
||||
maxWidth: 400,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
PolyWeather 遇到了严重错误,请尝试刷新页面。如果问题持续出现,请联系我们。
|
||||
</p>
|
||||
{error.digest ? (
|
||||
<code
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--color-text-muted, #7D8FA3)",
|
||||
fontFamily: "var(--font-mono, monospace)",
|
||||
}}
|
||||
>
|
||||
{error.digest}
|
||||
</code>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.5rem 1.25rem",
|
||||
borderRadius: "var(--radius-md, 10px)",
|
||||
border: "1px solid var(--color-border-default, rgba(159,178,199,0.16))",
|
||||
backgroundColor: "var(--color-bg-raised, #111A2E)",
|
||||
color: "var(--color-accent-primary, #4DA3FF)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -9,28 +9,28 @@
|
||||
@layer base {
|
||||
:root {
|
||||
/* ── Background Scale ── */
|
||||
--color-bg-base: #0B1220;
|
||||
--color-bg-raised: #111A2E;
|
||||
--color-bg-overlay: #16213A;
|
||||
--color-bg-card: rgba(17, 26, 46, 0.88);
|
||||
--color-bg-input: rgba(22, 33, 58, 0.72);
|
||||
--color-bg-base: #f4f7fb;
|
||||
--color-bg-raised: #ffffff;
|
||||
--color-bg-overlay: #ffffff;
|
||||
--color-bg-card: rgba(255, 255, 255, 0.95);
|
||||
--color-bg-input: rgba(241, 245, 249, 0.88);
|
||||
|
||||
/* ── Text Scale ── */
|
||||
--color-text-primary: #E6EDF3;
|
||||
--color-text-secondary: #9FB2C7;
|
||||
--color-text-muted: #7D8FA3;
|
||||
--color-text-disabled: #7D8FA3;
|
||||
--color-text-primary: #0F172A;
|
||||
--color-text-secondary: #334155;
|
||||
--color-text-muted: #475569;
|
||||
--color-text-disabled: #94A3B8;
|
||||
|
||||
/* ── Accent Colors ── */
|
||||
--color-accent-primary: #4DA3FF;
|
||||
--color-accent-secondary: #6FB7FF;
|
||||
--color-accent-tertiary: #93C5FD;
|
||||
--color-accent-primary: #2563EB;
|
||||
--color-accent-secondary: #3B82F6;
|
||||
--color-accent-tertiary: #60A5FA;
|
||||
|
||||
/* ── Signal / Semantic Colors ── */
|
||||
--color-signal-success: #22C55E;
|
||||
--color-signal-warning: #F59E0B;
|
||||
--color-signal-danger: #EF4444;
|
||||
--color-signal-info: #4DA3FF;
|
||||
--color-signal-success: #00897b;
|
||||
--color-signal-warning: #d97706;
|
||||
--color-signal-danger: #dc2626;
|
||||
--color-signal-info: #2563eb;
|
||||
|
||||
/* ── Risk Colors (aliased from signal) ── */
|
||||
--color-risk-high: var(--color-signal-danger);
|
||||
@@ -38,22 +38,21 @@
|
||||
--color-risk-low: var(--color-signal-success);
|
||||
|
||||
/* ── Border ── */
|
||||
--color-border-default: rgba(159, 178, 199, 0.16);
|
||||
--color-border-hover: rgba(77, 163, 255, 0.38);
|
||||
--color-border-subtle: rgba(159, 178, 199, 0.08);
|
||||
--color-border-default: #d8e0ec;
|
||||
--color-border-hover: #b8c4d6;
|
||||
--color-border-subtle: #e8edf5;
|
||||
|
||||
/* ── Shadow / Elevation ── */
|
||||
--shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--shadow-elevation-2: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
--shadow-elevation-3: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
--shadow-glow-accent: 0 0 20px rgba(77, 163, 255, 0.24);
|
||||
--shadow-glow-secondary: 0 0 20px rgba(111, 183, 255, 0.22);
|
||||
--shadow-elevation-1: 0 1px 3px rgba(15, 23, 42, 0.05);
|
||||
--shadow-elevation-2: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
--shadow-elevation-3: 0 20px 60px rgba(15, 23, 42, 0.08);
|
||||
--shadow-glow-accent: 0 0 20px rgba(37, 99, 235, 0.08);
|
||||
--shadow-glow-secondary: 0 0 20px rgba(96, 165, 250, 0.08);
|
||||
|
||||
/* ── Typography ── */
|
||||
--font-data:
|
||||
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", "SF Mono", monospace;
|
||||
--font-data: var(--font-inter), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-display: var(--font-inter), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: var(--font-jetbrains-mono), "Fira Code", "SF Mono", monospace;
|
||||
|
||||
/* ── Spacing (4px grid) ── */
|
||||
--space-1: 4px;
|
||||
@@ -67,19 +66,19 @@
|
||||
--space-12: 48px;
|
||||
|
||||
/* ── Border Radius ── */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 10px;
|
||||
--radius-xl: 14px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* ── Glass / Blur ── */
|
||||
--glass-blur-1: blur(10px);
|
||||
--glass-blur-2: blur(16px);
|
||||
--glass-blur-3: blur(24px);
|
||||
--glass-opacity-1: 0.72;
|
||||
--glass-opacity-2: 0.85;
|
||||
--glass-opacity-3: 0.92;
|
||||
--glass-opacity-1: 0.86;
|
||||
--glass-opacity-2: 0.92;
|
||||
--glass-opacity-3: 0.96;
|
||||
|
||||
/* ── Layout ── */
|
||||
--header-height: 52px;
|
||||
@@ -90,55 +89,57 @@
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 400ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--transition: var(--transition-base);
|
||||
|
||||
/* ── shadcn/ui Tokens (used by Tailwind @apply border-border) ── */
|
||||
--background: 223 53% 4%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 223 46% 8%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--primary: 159 100% 44%;
|
||||
--primary-foreground: 222 47% 8%;
|
||||
--secondary: 224 30% 14%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--accent: 217 30% 18%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--border: 221 38% 22%;
|
||||
/* ── Legacy Variable Aliases ── */
|
||||
--accent-cyan: var(--color-accent-primary);
|
||||
--accent-blue: var(--color-accent-secondary);
|
||||
--accent-green: var(--color-signal-success);
|
||||
--bg-primary: var(--color-bg-base);
|
||||
--bg-secondary: var(--color-bg-raised);
|
||||
--bg-card: var(--color-bg-card);
|
||||
--bg-glass: var(--color-bg-card);
|
||||
--border-glass: var(--color-border-default);
|
||||
--border-subtle: var(--color-border-subtle);
|
||||
--text-primary: var(--color-text-primary);
|
||||
--text-secondary: var(--color-text-secondary);
|
||||
--text-muted: var(--color-text-muted);
|
||||
--risk-high: var(--color-risk-high);
|
||||
--risk-medium: var(--color-risk-medium);
|
||||
--risk-low: var(--color-risk-low);
|
||||
--shadow-lg: var(--shadow-elevation-2);
|
||||
--glass-blur: 10px;
|
||||
|
||||
/* ── shadcn/ui Tokens ── */
|
||||
--background: 210 40% 98%;
|
||||
--foreground: 222 47% 12%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 47% 12%;
|
||||
--primary: 221 83% 53%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary-foreground: 222 47% 12%;
|
||||
--accent: 210 40% 96%;
|
||||
--accent-foreground: 222 47% 12%;
|
||||
--border: 214 32% 91%;
|
||||
}
|
||||
|
||||
/* ── Light Theme Token Overrides ── */
|
||||
html.light,
|
||||
html[data-theme="light"] {
|
||||
--color-bg-base: #F7F9FC;
|
||||
--color-bg-raised: #EEF2F7;
|
||||
--color-bg-overlay: #FFFFFF;
|
||||
--color-bg-card: rgba(255, 255, 255, 0.92);
|
||||
--color-bg-input: rgba(238, 242, 247, 0.88);
|
||||
|
||||
--color-text-primary: #0F172A;
|
||||
--color-text-secondary: #334155;
|
||||
--color-text-muted: #475569;
|
||||
--color-text-disabled: #94A3B8;
|
||||
|
||||
--color-accent-primary: #2563EB;
|
||||
--color-accent-secondary: #3B82F6;
|
||||
--color-accent-tertiary: #60A5FA;
|
||||
|
||||
--color-border-default: rgba(148, 163, 184, 0.24);
|
||||
--color-border-hover: rgba(37, 99, 235, 0.38);
|
||||
--color-border-subtle: rgba(148, 163, 184, 0.12);
|
||||
|
||||
--shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
--shadow-elevation-2: 0 8px 24px rgba(40, 70, 110, 0.12);
|
||||
--shadow-elevation-3: 0 20px 60px rgba(40, 70, 110, 0.15);
|
||||
--shadow-glow-accent: 0 0 20px rgba(37, 99, 235, 0.14);
|
||||
--shadow-glow-secondary: 0 0 20px rgba(96, 165, 250, 0.12);
|
||||
|
||||
--glass-blur-1: blur(10px);
|
||||
--glass-blur-2: blur(16px);
|
||||
--glass-blur-3: blur(24px);
|
||||
--glass-opacity-1: 0.86;
|
||||
--glass-opacity-2: 0.92;
|
||||
--glass-opacity-3: 0.96;
|
||||
/* ── Monospaced numbers & data globally for professional feel ── */
|
||||
.font-mono,
|
||||
.nearby-temp,
|
||||
.nearby-wind,
|
||||
.nearby-time,
|
||||
.nearby-marker,
|
||||
.marker-bubble,
|
||||
.map-pill,
|
||||
[class*="temp"],
|
||||
[class*="value"],
|
||||
[class*="price"],
|
||||
[class*="number"],
|
||||
[class*="stat"],
|
||||
[class*="score"],
|
||||
[class*="time-"] {
|
||||
font-family: var(--font-mono) !important;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -173,23 +174,7 @@
|
||||
|
||||
body {
|
||||
font-family: var(--font-data);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 10% -10%,
|
||||
rgba(0, 224, 164, 0.1),
|
||||
transparent 40%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 90% 0%,
|
||||
rgba(123, 97, 255, 0.08),
|
||||
transparent 36%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 80% 100%,
|
||||
rgba(0, 224, 164, 0.06),
|
||||
transparent 48%
|
||||
),
|
||||
var(--color-bg-base);
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
@@ -305,6 +290,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Extreme temperature emphasis ── */
|
||||
.temp-extreme-hot {
|
||||
color: #f97316;
|
||||
text-shadow: 0 0 12px rgba(249, 115, 22, 0.35);
|
||||
}
|
||||
|
||||
.temp-extreme-cold {
|
||||
color: #38bdf8;
|
||||
text-shadow: 0 0 12px rgba(56, 189, 248, 0.35);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Map Marker Components (nearby stations, city bubbles)
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||