Compare commits

..

283 Commits

Author SHA1 Message Date
2569718930@qq.com 5b0506a663 Refresh history loading UI and gate incomplete MGM series 2026-03-29 21:21:52 +08:00
2569718930@qq.com efb5e426b7 Refine history modal loading state and MGM chart gating 2026-03-29 21:09:16 +08:00
2569718930@qq.com d0e1bea941 Backfill missing history forecasts from probability snapshots 2026-03-29 21:01:24 +08:00
2569718930@qq.com 346836f561 Improve history peak scrolling and hide Ankara MGM observations 2026-03-29 20:53:48 +08:00
2569718930@qq.com 2b5d2cd801 Grant new users a three-day signup trial 2026-03-29 20:34:12 +08:00
2569718930@qq.com 6139960a9e Update Shenzhen settlement source to NOAA ZGSZ 2026-03-28 21:32:40 +08:00
2569718930@qq.com cbb704577a Add weather-driven city effects and keep upper-air cards visible 2026-03-27 23:45:40 +08:00
2569718930@qq.com 0c8ad54780 Visualize intraday analysis with 3D signals and metric meters 2026-03-27 23:34:48 +08:00
2569718930@qq.com 98bcfe2a21 Refresh dashboard loading overlay with weather terminal cues 2026-03-27 23:23:51 +08:00
2569718930@qq.com e32cfd8015 Add three.js weather aura layer to dashboard map 2026-03-27 23:09:08 +08:00
2569718930@qq.com 7237278f5a Generalize NOAA settlement handling across dashboard and docs 2026-03-27 20:58:38 +08:00
2569718930@qq.com 15a452dd49 Offset Shek Kong map marker to avoid overlap 2026-03-27 20:22:38 +08:00
2569718930@qq.com 2007ded86d Add HKO-backed Shek Kong settlement and history support 2026-03-27 20:11:08 +08:00
2569718930@qq.com 1ab7db065c Remove Speed Insights from the app layout 2026-03-27 17:48:47 +08:00
2569718930@qq.com 436afe121a Reduce Vercel CPU usage by narrowing auth checks 2026-03-26 17:04:56 +08:00
2569718930@qq.com 88b355e9de Allow scenery images through middleware 2026-03-26 01:32:06 +08:00
2569718930@qq.com b146c637b6 Allow guest city APIs and normalize auth probe responses 2026-03-26 01:25:51 +08:00
2569718930@qq.com 7caae4dceb Unify city detail panel access for guests and free users 2026-03-26 01:19:56 +08:00
2569718930@qq.com 28db3ed428 Fix guest city panel visibility and header labels 2026-03-26 00:53:38 +08:00
2569718930@qq.com 306d701a2d Force fresh Vercel production deploy 2026-03-26 00:47:13 +08:00
2569718930@qq.com 54c93db483 Trigger Vercel production rebuild 2026-03-26 00:42:01 +08:00
2569718930@qq.com 961451aeff Restore guest city panels while keeping Pro data protected 2026-03-26 00:29:14 +08:00
2569718930@qq.com a584da83f7 Avoid duplicate TAF marker points in chart data 2026-03-25 21:54:34 +08:00
2569718930@qq.com 443540c27e Clarify current and peak-window TAF chart markers 2026-03-25 21:46:33 +08:00
2569718930@qq.com ab5c5cfb23 feat: introduce WundergroundSourceMixin for fetching, parsing, and extracting weather observation data from Wunderground. 2026-03-25 19:28:35 +08:00
2569718930@qq.com 7de31ec429 Parse Wunderground history observations for settlement highs 2026-03-25 18:38:34 +08:00
2569718930@qq.com 7589fd74bf feat: Implement Wunderground data integration, enhance dashboard weather data processing, and add future forecast modal. 2026-03-25 18:03:56 +08:00
2569718930@qq.com fd210ac730 Show Wunderground settlement sources in the dashboard 2026-03-25 17:14:25 +08:00
2569718930@qq.com 2b0e6a6f67 Add Wunderground as Shenzhen settlement source 2026-03-25 17:00:36 +08:00
2569718930@qq.com b1ce479909 Remove duplicate docs button and localize docs copy 2026-03-25 16:28:52 +08:00
2569718930@qq.com 6d20d51bb9 Refine docs center content and correct Taipei settlement source 2026-03-25 16:13:42 +08:00
2569718930@qq.com 4e0ddfa771 Add bilingual docs center and replace the guide modal 2026-03-25 15:55:55 +08:00
2569718930@qq.com b866ebf6e3 Clarify TAF intraday summaries 2026-03-25 15:22:41 +08:00
2569718930@qq.com afffbb852b Clarify intraday trading cues with plain-language copy 2026-03-24 19:41:29 +08:00
2569718930@qq.com 69ea686750 Refine TAF summaries and trim peak-window marker overlaps 2026-03-24 19:21:28 +08:00
2569718930@qq.com b7a72bd9ff Update intraday TAF summaries for active and peak windows 2026-03-24 16:32:43 +08:00
2569718930@qq.com 77f2b6ac07 Fix English airport narrative fallback in dashboard 2026-03-24 15:12:49 +08:00
2569718930@qq.com 1dfdd18421 Document TAF signals and expand METAR weather translations 2026-03-24 04:21:23 +08:00
2569718930@qq.com 67e691ed95 Unify intraday summary priority across locales 2026-03-24 04:13:58 +08:00
2569718930@qq.com 1461d02488 feat: Integrate TAF data for non-Hong Kong airport cities, enhance intraday structural analysis, and add new Chinese documentation for API, configuration, and deployment. 2026-03-24 04:03:41 +08:00
2569718930@qq.com 54acfc05aa Clarify TAF timing labels and suppression wording 2026-03-24 03:44:12 +08:00
2569718930@qq.com bbc5dbb8d6 Clarify TAF and surface signal interplay in intraday summary 2026-03-24 03:35:34 +08:00
2569718930@qq.com 29f6542a72 Handle TAF hour rollover in period parsing 2026-03-24 03:27:50 +08:00
2569718930@qq.com 05184cb2ad Add TAF timing markers and market-aware intraday cues 2026-03-24 03:13:54 +08:00
2569718930@qq.com b365155622 Add TAF-based airport signals to intraday analysis 2026-03-24 02:58:29 +08:00
2569718930@qq.com fc72b3539d Align structural trade cues with market context 2026-03-24 02:07:15 +08:00
2569718930@qq.com 5796d380c5 Reorganize intraday signal cards for clearer trade guidance 2026-03-24 01:49:00 +08:00
2569718930@qq.com 58a09fe6fe Add upper-air structure signals to intraday analysis 2026-03-24 01:28:45 +08:00
2569718930@qq.com 4ac690228e Bootstrap recent METAR history for new cities 2026-03-24 01:02:18 +08:00
2569718930@qq.com 90822c81d4 Add METAR backfill script and remove settlement history fallback 2026-03-24 00:55:16 +08:00
2569718930@qq.com 5e83a5cca3 feat: Introduce core API routes for city data, history, authentication, and payments, along with a new frontend dashboard utility file. 2026-03-24 00:04:24 +08:00
2569718930@qq.com 578e50b2da Fix E402 imports in model gap backfill script 2026-03-23 23:46:02 +08:00
2569718930@qq.com 5ac5caf3ef Preserve model forecasts and add gap backfill script 2026-03-23 23:41:56 +08:00
2569718930@qq.com 7adec4e17c Add peak-12h DEB history reference and bump extension to 0.1.5 2026-03-23 23:17:48 +08:00
2569718930@qq.com 354d55f752 Use DEB values in multi-day forecasts 2026-03-23 21:41:27 +08:00
2569718930@qq.com 6feb4ff489 Show weekly points in account center 2026-03-23 21:32:18 +08:00
2569718930@qq.com fcaa9d5c7f Store HKO intraday observations in runtime state 2026-03-23 21:28:04 +08:00
2569718930@qq.com b1102400f6 Add HKO official observation series for Hong Kong charts 2026-03-23 21:22:41 +08:00
2569718930@qq.com 5e31bc9ea3 Add per-chat bot message cooldown overrides 2026-03-23 21:05:40 +08:00
2569718930@qq.com ff7d77ef06 Relax bot polling update restrictions 2026-03-23 20:53:09 +08:00
2569718930@qq.com ce22752ccc Add bot message activity diagnostics and bump extension to 0.1.4 2026-03-23 20:46:42 +08:00
2569718930@qq.com 8a091fd7cc Add basic decision bias and site CTA to side panel 2026-03-23 18:28:11 +08:00
2569718930@qq.com 06b1e1b9fe Release v1.5.1 and improve mobile dashboard layouts 2026-03-23 17:54:14 +08:00
2569718930@qq.com 21a42af01c Bump extension version to 0.1.3 2026-03-23 17:30:21 +08:00
2569718930@qq.com ace9c71743 Align Taipei settlement to NOAA RCTP across web and extension 2026-03-23 14:39:41 +08:00
2569718930@qq.com 7cdd6102c6 Restrict ops page access to configured admin emails 2026-03-23 00:33:11 +08:00
2569718930@qq.com f8b17d16ea Preserve hourly peak observations in temperature charts 2026-03-22 23:28:48 +08:00
2569718930@qq.com e2c51f5125 Use METAR fallback for sparse official observation charts 2026-03-22 22:43:06 +08:00
2569718930@qq.com 60785471e2 Clarify near-term warming and cooling trend summaries 2026-03-22 22:26:50 +08:00
2569718930@qq.com d2b27b1020 Add manual payment support note to account page 2026-03-22 20:24:48 +08:00
2569718930@qq.com 7171192ea6 Guard payments to allowed hosts and show checkout context 2026-03-22 13:42:48 +08:00
2569718930@qq.com e8a39000b9 Prefer Supabase auth emails in ops memberships 2026-03-22 13:15:20 +08:00
2569718930@qq.com 4519af0c6c Repair payment subscription reconciliation and recent intent sync 2026-03-22 13:06:57 +08:00
2569718930@qq.com 3cb5d2e8fc Fix intraday chart to avoid future observation points 2026-03-21 21:41:46 +08:00
2569718930@qq.com 2ba680954c Add Warsaw official nearby station data 2026-03-21 21:32:54 +08:00
2569718930@qq.com 33385c3dda Rename extension trend series label to Open-Meteo 2026-03-21 20:56:21 +08:00
2569718930@qq.com 3c950956e3 feat: Implement PolyWeather extension side panel with weather data display and interaction logic. 2026-03-21 19:15:21 +08:00
2569718930@qq.com 2eb46853bf Avoid duplicate backend summary notes in trend metrics 2026-03-21 19:01:14 +08:00
2569718930@qq.com c795d90993 Sync dynamic commentary across bot and dashboard 2026-03-21 18:54:23 +08:00
2569718930@qq.com 7fe89e097b Clarify sidebar current and peak temperature labels 2026-03-21 18:17:03 +08:00
2569718930@qq.com bddcd434da Add landing page and move dashboard to dedicated route 2026-03-21 15:34:19 +08:00
2569718930@qq.com 6aa3a7dda8 Add landing page and move dashboard to /dashboard 2026-03-21 15:30:23 +08:00
2569718930@qq.com dd720582d5 Update docs for ops, payments, and deployment guidance 2026-03-21 14:52:25 +08:00
2569718930@qq.com adf2924a1c Refresh payment config before checkout 2026-03-21 13:49:08 +08:00
2569718930@qq.com e662ef7d3b Add ops tools for payment incident triage and resolution 2026-03-21 13:44:32 +08:00
2569718930@qq.com 0425c237b1 Add script to reconcile subscriptions by email 2026-03-21 13:26:03 +08:00
2569718930@qq.com 13cd176449 Backfill ops memberships with Supabase auth user data 2026-03-21 13:10:27 +08:00
2569718930@qq.com 49d883e3a2 Deduplicate ops memberships by user and latest expiry 2026-03-21 13:06:10 +08:00
2569718930@qq.com 75c2baf1d1 Add ops membership list and official source links 2026-03-21 13:02:07 +08:00
2569718930@qq.com b11836a378 Deduplicate detected browser wallet options 2026-03-21 12:44:19 +08:00
2569718930@qq.com f7c5a2f443 Fix wallet deduping and refresh account state after binding 2026-03-21 12:40:11 +08:00
2569718930@qq.com 290dd2b3cf Add EIP-6963 wallet discovery for injected providers 2026-03-21 12:34:36 +08:00
2569718930@qq.com ac03fb74be Fix browser wallet selection for injected providers 2026-03-21 12:27:54 +08:00
2569718930@qq.com 4413b31396 Fix Pro payment recovery and wallet provider selection 2026-03-21 12:19:53 +08:00
2569718930@qq.com a19925fe7a Fix unstable dashboard modal opening 2026-03-21 00:58:43 +08:00
2569718930@qq.com 8959faac91 Fix ops dashboard rollout status rendering 2026-03-21 00:46:59 +08:00
2569718930@qq.com e7e52c0da7 Release v1.5.0 and add ops admin dashboard 2026-03-21 00:40:28 +08:00
2569718930@qq.com 345c56c4f4 Add EMOS snapshot fixtures and test httpx dependency 2026-03-21 00:04:11 +08:00
2569718930@qq.com e4b8d71653 Refresh docs for observability, payments, and SQLite rollout 2026-03-21 00:03:18 +08:00
2569718930@qq.com ddf909f690 Add script to grant points by Supabase email 2026-03-20 23:52:49 +08:00
2569718930@qq.com 98d36a9174 Fix weekly points display and clarify leaderboard stats 2026-03-20 23:50:40 +08:00
2569718930@qq.com a0b8a3595c feat: Implement payment processing with contract auditing, event loops, and web observability endpoints. 2026-03-20 23:33:50 +08:00
2569718930@qq.com 7225b8bcc7 docs: Add deep research report. 2026-03-20 23:07:15 +08:00
2569718930@qq.com 43749fff7c Unify runtime state in SQLite and add rollout observability 2026-03-20 23:00:07 +08:00
2569718930@qq.com 6b76290cff Restore web app compatibility exports 2026-03-20 22:25:33 +08:00
2569718930@qq.com 48619830a5 Handle empty MGM daily forecasts without 403 fallback 2026-03-20 22:23:17 +08:00
2569718930@qq.com 74e35b990b Restore web.app compatibility exports for bot alerts 2026-03-20 22:16:13 +08:00
2569718930@qq.com f6771247a6 Add config validation and deployment docs 2026-03-20 22:00:02 +08:00
2569718930@qq.com 3196552c78 Archive probability snapshots and wire them into training 2026-03-20 21:30:52 +08:00
2569718930@qq.com 03dcb4329b Refine EMOS calibration and add Chinese training report 2026-03-20 21:17:37 +08:00
2569718930@qq.com 1c84893bed Add CI and shadow calibration reporting 2026-03-20 20:59:30 +08:00
2569718930@qq.com 25ab512371 release: v1.4.0 2026-03-20 19:13:14 +08:00
2569718930@qq.com 2a19a23e4d docs: Add new READMEs for extension and frontend, create tech debt document, and update Chinese README. 2026-03-20 19:07:11 +08:00
2569718930@qq.com 6e3bc1ce75 docs: Add deep research report. 2026-03-20 19:02:46 +08:00
2569718930@qq.com 5e88e1480a feat: Implement initial PolyWeather browser extension including sidepanel, options page, and core weather display logic. 2026-03-20 18:43:01 +08:00
2569718930@qq.com e4cc5b4263 feat: Add initial web dashboard and centralize weather trend analysis logic into a shared engine. 2026-03-20 18:16:21 +08:00
2569718930@qq.com dbf10253a8 feat: Implement multi-source weather data collection with caching, rate limiting, and disk persistence. 2026-03-20 13:35:34 +08:00
2569718930@qq.com da9b0b36f7 docs: Add deep research report document. 2026-03-19 21:43:55 +08:00
2569718930@qq.com f049a69ee3 Fix mispricing alerts for bearish low-Yes signals 2026-03-19 17:38:04 +08:00
2569718930@qq.com b33a741385 Switch Ankara MGM station selection to 17128 airport 2026-03-18 21:01:31 +08:00
2569718930@qq.com 69f24ddc08 Optimize frontend performance and add route-level web vitals tracking 2026-03-18 20:38:43 +08:00
2569718930@qq.com f0fcb1e379 feat: implement initial PolyWeather browser extension with side panel and options page 2026-03-18 12:51:24 +08:00
2569718930@qq.com 5c209ceb75 Hide scrollbar in analysis modal 2026-03-18 00:47:33 +08:00
2569718930@qq.com 15a2fb91d1 Fix FutureForecast settlement info 2026-03-18 00:41:54 +08:00
2569718930@qq.com f2547e11b6 Remove RP5 multi-model block 2026-03-18 00:27:48 +08:00
2569718930@qq.com fef3097c36 Remove RP5 multi-model data 2026-03-18 00:20:04 +08:00
2569718930@qq.com 5057892255 Fix RP5 Shanghai station prediction 2026-03-18 00:05:37 +08:00
2569718930@qq.com 233112cf0a Merge branch 'main' of https://github.com/yangyuan-zhen/PolyWeather 2026-03-17 23:39:08 +08:00
2569718930@qq.com 60f83d0905 Explain git push permission error 2026-03-17 23:30:25 +08:00
2569718930@qq.com 1ae9b55509 Add RP5 forecast scraping support 2026-03-17 23:15:13 +08:00
root ebaa57b5d1 chore: ignore local env and vercel files 2026-03-17 19:24:09 +08:00
2569718930@qq.com 9ac0a13937 Assess PolyCop copytrade bot info 2026-03-17 19:18:37 +08:00
2569718930@qq.com 508c322c8b Fix wallet unlink 401 error 2026-03-17 15:38:40 +08:00
2569718930@qq.com 5a6d9eeda4 Fix wallet delete authorization 2026-03-17 15:32:09 +08:00
2569718930@qq.com 7345b70958 Fix vercel link scope error 2026-03-17 15:23:59 +08:00
2569718930@qq.com 5d2ed68cdf Adjust wallet unlink token handling 2026-03-17 15:13:57 +08:00
2569718930@qq.com 7510e0a1e0 feat: implement user account center with authentication, subscription management, and EVM wallet integration. 2026-03-17 14:32:15 +08:00
2569718930@qq.com 217414408c Handle wallet session expiration 2026-03-17 14:18:25 +08:00
2569718930@qq.com 93f7a852a8 Add Madrid METAR market note 2026-03-17 14:04:22 +08:00
2569718930@qq.com 90c998f06d feat: Introduce account center with subscription management and contract-based payment integration. 2026-03-17 13:57:44 +08:00
2569718930@qq.com 54544e40b9 feat: Implement core data models, backend services for weather and market data, and initial dashboard components. 2026-03-17 13:21:14 +08:00
2569718930@qq.com 609fc8914a feat: Implement user account center with authentication, subscription management, and cryptocurrency payment integration. 2026-03-16 20:30:46 +08:00
2569718930@qq.com 6a229a592b feat: Implement dashboard utility functions for weather data processing, localization, and chart data preparation. 2026-03-16 10:08:48 +08:00
2569718930@qq.com 1ebd07ca31 feat: introduce core bot command handlers, orchestrator, and command parser for initial bot functionality. 2026-03-15 09:03:59 +08:00
2569718930@qq.com 81604bfe8a feat: Implement basic and city command handlers for the bot, including user identity binding and unbinding. 2026-03-14 22:35:44 +08:00
2569718930@qq.com e7a5347907 feat: add new command handlers for the /city and /deb commands 2026-03-14 22:22:53 +08:00
2569718930@qq.com fd7ec30e58 feat: Add new command handlers for the /deb and /city commands. 2026-03-14 22:13:52 +08:00
2569718930@qq.com 9bff3b0683 feat: implement /deb and /city command handlers for the bot. 2026-03-14 22:05:09 +08:00
2569718930@qq.com 314cad82e6 feat: add bot handlers for activity tracking, city weather queries, and historical data queries. 2026-03-14 21:58:33 +08:00
2569718930@qq.com 1c57a6f56e feat: Add new handlers for /deb and /city commands, and a general activity tracker. 2026-03-14 21:47:21 +08:00
2569718930@qq.com 530080a75d feat: Add /city and /deb command handlers for weather data and historical weather queries. 2026-03-14 14:24:11 +08:00
2569718930@qq.com 6d1f77cac1 feat: implement user account management and dashboard components. 2026-03-14 13:33:06 +08:00
2569718930@qq.com 2397a7569b feat: add payment event loop for scanning blockchain and processing payment intents. 2026-03-14 13:06:03 +08:00
2569718930@qq.com c1fb52551d feat: Implement BotIOLayer for Telegram I/O, user points management, and message routing. 2026-03-14 11:55:57 +08:00
2569718930@qq.com d1f4a8b236 feat: add bot I/O layer for message routing and point management, updating .env.example with topic map configuration. 2026-03-14 11:51:14 +08:00
2569718930@qq.com 4bd531bbc9 feat: implement PolyWeather Telegram bot with weather and DEB analysis, a point system, and configuration. 2026-03-14 11:39:30 +08:00
2569718930@qq.com 64f8b37014 feat: Introduce open-core policy, commercialization, and payment verification documentation, and update README with new product status, payment architecture, and documentation index. 2026-03-14 10:53:53 +08:00
2569718930@qq.com 02e35a3a5f feat: Implement a new contract-based payment system with wallet binding, payment intents, and subscription plan management. 2026-03-14 10:35:30 +08:00
2569718930@qq.com be61c39795 feat: add Polymarket wallet activity watcher script. 2026-03-13 22:28:33 +08:00
2569718930@qq.com af2561d019 feat: Introduce a runtime coordinator for bot loops and add new payment checkout and confirmation modules. 2026-03-13 22:11:55 +08:00
2569718930@qq.com 4bd97cc5a5 feat: add utility script to reconcile payment transactions to user subscriptions with an emergency manual grant fallback. 2026-03-13 21:26:40 +08:00
2569718930@qq.com db2f22509b feat: Add account center with crypto payment checkout and wallet management. 2026-03-13 21:08:57 +08:00
2569718930@qq.com f27970f157 feat: Introduce Pro subscription unlock overlay, paywall, and related account and help pages. 2026-03-13 20:38:10 +08:00
2569718930@qq.com 3d9d4dcd64 feat: implement account center component for user management, subscriptions, and EVM payments. 2026-03-13 20:09:49 +08:00
2569718930@qq.com 786765e48b feat: implement SQLite database manager for user data, points, and Supabase bindings. 2026-03-13 19:48:40 +08:00
2569718930@qq.com cd0a5d519f feat: Introduce core bot components, on-chain wallet watchers, Telegram notifications, and configuration management. 2026-03-13 19:24:05 +08:00
2569718930@qq.com edf2a37976 feat: Implement core Telegram bot features including command handling, user points, and identity management, and add Polymarket wallet activity watcher. 2026-03-13 18:39:39 +08:00
2569718930@qq.com 7bde49bd5e Merge branch 'main' of https://github.com/yangyuan-zhen/PolyWeather 2026-03-13 18:06:11 +08:00
2569718930@qq.com 80acade251 feat: implement user account center with subscription and EVM wallet payment integration, along with initial project configuration files. 2026-03-13 18:06:08 +08:00
root ac5c3a2c76 chore: stop tracking runtime sqlite db file 2026-03-13 18:04:08 +08:00
2569718930@qq.com 08217deefb feat: Externalize runtime data, including SQLite and caches, to a configurable directory mounted via Docker volumes. 2026-03-13 17:18:06 +08:00
2569718930@qq.com ac9e537070 feat: Implement initial web frontend, comprehensive database management, and a bot-based weekly reward system. 2026-03-13 16:47:25 +08:00
2569718930@qq.com e780830f76 feat: Introduce web application with Supabase authentication, entitlement, and account management. 2026-03-13 15:39:25 +08:00
2569718930@qq.com aa98623cab feat: implement Pro subscription with a frontend unlock overlay and blockchain-based payment integration. 2026-03-13 13:58:41 +08:00
2569718930@qq.com f00360ed56 feat: add account center with subscription management and EVM wallet payment integration. 2026-03-13 13:13:51 +08:00
2569718930@qq.com e798a4f33a feat: Introduce account center for user management and crypto payments; add Telegram push utility. 2026-03-13 13:01:57 +08:00
2569718930@qq.com 50a2202930 feat: Add bot analysis and command services, bot settings, and a frontend account center with subscription and EVM wallet integration. 2026-03-13 12:45:39 +08:00
2569718930@qq.com c3958c29c7 feat: add UnlockProOverlay component for Pro subscription and point redemption. 2026-03-13 12:26:35 +08:00
2569718930@qq.com 663d6be506 feat: Add API routes for creating, confirming, and submitting payment intents, and verifying wallets. 2026-03-13 12:20:01 +08:00
2569718930@qq.com 2de083d53a feat: Add API route to proxy payments wallet challenge requests to the backend. 2026-03-13 12:10:07 +08:00
2569718930@qq.com 04fda60fb4 feat: implement account center for user profile management, payments, and wallet binding. 2026-03-13 11:51:01 +08:00
2569718930@qq.com c031d9caeb feat: add AccountCenter component for user profile, payments, and wallet management. 2026-03-13 11:40:50 +08:00
2569718930@qq.com 9ba0890ca9 feat: Implement PolyWeather Web Map API backend, integrating weather data, Polymarket, user authentication, and payment processing. 2026-03-13 11:18:56 +08:00
2569718930@qq.com fb630b47cb feat: implement PolyWeather Web Map API backend with FastAPI, integrating weather data, analysis, authentication, and payment processing. 2026-03-13 11:09:49 +08:00
2569718930@qq.com 5b7a257087 feat: Implement Supabase-backed authentication and entitlement checks for frontend-to-backend requests. 2026-03-13 11:01:44 +08:00
2569718930@qq.com 534eef4197 feat: implement new AccountCenter component for user profile, authentication, and payment management 2026-03-13 10:52:31 +08:00
2569718930@qq.com 25f11912dd feat: Add backend authentication utilities for constructing request headers with entitlement and user session tokens, and for applying auth response cookies. 2026-03-13 10:44:11 +08:00
2569718930@qq.com e201856700 feat: add AccountCenter component for user account management, payment configurations, and wallet binding. 2026-03-13 10:24:14 +08:00
2569718930@qq.com 74232907a9 feat: Add AccountCenter component for user profile, authentication, and payment management. 2026-03-13 10:19:59 +08:00
2569718930@qq.com 024df2dc5b feat: implement user account center with authentication, payments, and subscriptions. 2026-03-13 10:14:13 +08:00
2569718930@qq.com 9531cc4b64 feat: Implement AccountCenter component for user profile, points, and payment management. 2026-03-13 09:52:06 +08:00
2569718930@qq.com 990182e089 feat: Implement account center for user authentication, subscription, and payment management. 2026-03-13 09:50:04 +08:00
2569718930@qq.com 120f4b1261 feat: add Pro feature paywall and unlock overlay UI. 2026-03-13 09:34:58 +08:00
2569718930@qq.com f2e0940282 feat: Add UnlockProOverlay component and its styles for subscription upgrades. 2026-03-13 09:32:14 +08:00
2569718930@qq.com 3c3ffa1248 feat: add UnlockProOverlay component for displaying subscription details, features, and points-based discounts. 2026-03-13 09:25:37 +08:00
2569718930@qq.com 18a525af88 feat: add UnlockProOverlay component for managing subscriptions and displaying pricing with point-based discounts. 2026-03-13 09:20:05 +08:00
2569718930@qq.com c0d914eb88 feat: introduce UnlockProOverlay component for Pro subscription with point redemption. 2026-03-13 09:12:08 +08:00
2569718930@qq.com 0aaa09370b chore: Remove local npm/yarn cache and debug log entries from .gitignore. 2026-03-13 09:07:59 +08:00
2569718930@qq.com aa3546e2a9 feat: Add city detail panel with weather visualizations and integrate Pro feature paywall components. 2026-03-13 09:06:45 +08:00
2569718930@qq.com 003aff98d6 feat: Add dashboard, subscription, and account UI components, and update .gitignore to exclude npm cache files. 2026-03-13 08:51:06 +08:00
2569718930@qq.com 1e541433e5 feat: Implement AccountCenter component for user profile, subscription management, and payment processing. 2026-03-13 08:24:18 +08:00
2569718930@qq.com 0f51780566 feat: Introduce premium map dashboard with dark theme, pro features, and payment processing. 2026-03-13 08:15:27 +08:00
2569718930@qq.com a5948a35d4 feat: implement history and future forecast modals, alongside a new Pro feature paywall component. 2026-03-13 07:56:20 +08:00
2569718930@qq.com 5c3de0dbe0 feat: Add dashboard layout with dark theme and Pro feature paywall component. 2026-03-13 07:40:09 +08:00
2569718930@qq.com 31ee147734 feat: Implement contract checkout payment service and account management UI. 2026-03-13 07:31:47 +08:00
2569718930@qq.com 54c4a26162 feat: Implement subscription-based premium features, payment processing, and enhanced dashboard components. 2026-03-13 06:41:33 +08:00
2569718930@qq.com f7b649bb0a feat: Implement bot orchestration, command handling, and runtime coordination for background services. 2026-03-13 05:53:41 +08:00
2569718930@qq.com d5bbc43b52 feat: add AccountCenter component for user authentication, subscription, and payment management. 2026-03-13 05:25:46 +08:00
2569718930@qq.com e62a36e0bc feat: Introduce web application with payment processing and Supabase authentication. 2026-03-13 05:13:48 +08:00
2569718930@qq.com bd839d0cbe feat: Add client-side login and signup component using Supabase for email/password and Google OAuth authentication. 2026-03-13 03:54:31 +08:00
2569718930@qq.com e3bd90d186 feat: add client-side components for user login, signup, and account management. 2026-03-13 03:47:56 +08:00
2569718930@qq.com 9cc9ab93c4 feat: Implement account management center with authentication and subscription features, supported by new backend, i18n, and Supabase integration. 2026-03-13 03:27:56 +08:00
2569718930@qq.com 0a869459c4 feat: Implement Supabase authentication, account management UI, and entitlement services. 2026-03-13 02:23:01 +08:00
2569718930@qq.com 987aec2fa6 docs: Add commercialization, technical debt, and frontend documentation, and update project READMEs. 2026-03-12 12:01:29 +08:00
2569718930@qq.com f4fea03f35 feat: Implement new bot architecture including handlers, services, analysis modules, and comprehensive tests. 2026-03-12 11:44:52 +08:00
2569718930@qq.com c582015163 chore: release v1.3 2026-03-12 11:01:40 +08:00
2569718930@qq.com 27fe6ea494 feat: Introduce API and technical debt documentation, and enhance frontend caching, mispricing radar, and AI decision logic. 2026-03-12 10:43:31 +08:00
2569718930@qq.com 0877efe0c3 feat: Introduce new ai_analyzer and trend_engine modules for comprehensive weather trend and AI analysis. 2026-03-12 10:26:28 +08:00
2569718930@qq.com 20d3e940e0 feat: Add Polymarket read-only data layer, Telegram push utility, and market alert engine. 2026-03-12 10:06:09 +08:00
2569718930@qq.com c156809231 feat: Add a shell script to validate frontend caching behavior for PolyWeather API endpoints. 2026-03-12 09:41:48 +08:00
2569718930@qq.com be651cd1d7 feat: Implement HTTP caching for API routes and add a dashboard sidebar with city grouping and state persistence. 2026-03-12 09:29:29 +08:00
2569718930@qq.com 22082b9a83 feat: add Polymarket wallet activity watcher and update .env example with its configuration. 2026-03-12 08:17:11 +08:00
2569718930@qq.com 0c976ac846 feat: Introduce Polymarket wallet activity watcher with configurable user aliases and link previews. 2026-03-12 07:59:47 +08:00
2569718930@qq.com 47ff575588 docs: Add product screenshots to READMEs and include a new demo map image. 2026-03-12 03:18:07 +08:00
2569718930@qq.com b4d38590e1 feat: Add entitlement middleware, root layout, and PWA assets for the frontend. 2026-03-12 03:05:11 +08:00
2569718930@qq.com f065b67605 feat: add favicons and web manifest for PWA support. 2026-03-12 02:59:52 +08:00
2569718930@qq.com 4e5bf5a4f8 chore: release v1.2 2026-03-12 02:49:07 +08:00
2569718930@qq.com de6cf68ee7 feat: Implement reconciliation of recent actual high temperatures using METAR data and integrate it into the DEB command. 2026-03-12 02:43:19 +08:00
2569718930@qq.com 1060945d08 feat: Implement Dynamic Ensemble Blending (DEB) algorithm with historical data management, dynamic weight calculation, and accuracy tracking. 2026-03-12 02:32:10 +08:00
2569718930@qq.com ad2b1aa4b6 feat: Implement CitySidebar component with risk-level grouping and internationalization utilities. 2026-03-12 02:11:48 +08:00
2569718930@qq.com ba0916ea51 feat: add Polymarket wallet activity watcher and configuration for immediate size delta notifications. 2026-03-12 00:50:19 +08:00
2569718930@qq.com a46a98f25e feat: Add API route to fetch cities from the backend. 2026-03-12 00:40:28 +08:00
2569718930@qq.com 294e038f57 feat: Implement Polymarket wallet activity watcher and add related configuration options. 2026-03-12 00:36:33 +08:00
2569718930@qq.com eca0c7f514 feat: Introduce multi-source weather data collection, including city configuration and robust caching. 2026-03-12 00:29:34 +08:00
2569718930@qq.com 9295899aeb feat: implement PolyWeather Web Map API, a FastAPI backend integrating weather data collection and analysis to support an interactive map. 2026-03-11 12:22:06 +08:00
2569718930@qq.com db3b1c995a feat: Implement PolyWeather web map API with integrated weather data analysis, market alert engine, and Telegram push utility. 2026-03-11 12:15:09 +08:00
2569718930@qq.com e89fdb7e09 feat: enhance project documentation with new overview diagrams, commercialization roadmap, and technical debt backlog. 2026-03-11 11:45:57 +08:00
2569718930@qq.com 35345a1b04 docs: Introduce commercialization and technical debt documentation in English and Chinese, and update README diagram formatting. 2026-03-11 11:42:16 +08:00
2569718930@qq.com 025feaa96e feat: Implement PolyWeather web map API backend and integrate new frontend dashboard components for city weather details. 2026-03-11 11:37:07 +08:00
2569718930@qq.com 23e2959404 feat: implement PolyWeather web map API backend with data processing, caching, and entitlement, and add related documentation. 2026-03-11 11:29:56 +08:00
2569718930@qq.com 6273768cc2 docs: Add Chinese API documentation and a commercialization roadmap, alongside minor formatting updates to the main README. 2026-03-11 11:24:41 +08:00
2569718930@qq.com 44af26da70 docs: Add comprehensive documentation for commercialization and technical debt, and streamline the main README. 2026-03-11 11:18:48 +08:00
2569718930@qq.com 3cbef28b13 feat: Initialize PolyWeather frontend project with Next.js, including core dependencies, root layout, and a dashboard detail panel. 2026-03-11 11:14:03 +08:00
2569718930@qq.com d8cc193618 feat: Integrate Polymarket read-only data collection and display in dashboard panels. 2026-03-11 11:04:24 +08:00
2569718930@qq.com 1958b2764b feat: Implement core PolyWeather application with city weather query service, trend analysis, data collection, and dashboard UI. 2026-03-11 10:49:35 +08:00
2569718930@qq.com d2a40462c5 feat: Add city weather query service with city resolution, forecast processing, and weather summary generation. 2026-03-11 10:30:46 +08:00
2569718930@qq.com b3f46430ad feat: Introduce comprehensive weather data querying, analysis, and display services, integrate Polymarket data collection, and add Telegram notification utilities. 2026-03-11 10:23:33 +08:00
2569718930@qq.com 878e3280d1 fix: remove closed position template from wallet activity watcher 2026-03-11 09:22:07 +08:00
2569718930@qq.com 723694d77c style: translate polymarket wallet activity alerts to Chinese 2026-03-11 09:20:38 +08:00
2569718930@qq.com af4bee12f5 feat: Introduce a web frontend with new city API routes and refactor bot city query logic into a dedicated service. 2026-03-11 08:46:32 +08:00
2569718930@qq.com b1e75d13d8 feat: introduce WeatherDataCollector class for multi-source weather data retrieval, caching, and rate limiting. 2026-03-11 07:11:17 +08:00
2569718930@qq.com 8b6c66eab0 feat: Add fallback logic for future daily forecasts using multi-model medians, Meteoblue highs, and NWS periods when Open-Meteo data is unavailable. 2026-03-11 06:41:03 +08:00
2569718930@qq.com f7e7e0ea20 feat: Add multi-source weather data collection with a new Flask API and refine the bot's forecast display logic. 2026-03-11 06:33:25 +08:00
2569718930@qq.com 5d943f2aae feat: Create PolyWeather web API to serve analyzed weather and Polymarket data. 2026-03-11 05:56:40 +08:00
2569718930@qq.com 8712335ffd feat: Implement PolyWeather web map API by reusing existing analysis logic and centralize weather data collection. 2026-03-11 05:42:05 +08:00
2569718930@qq.com 41efaa1140 feat(weather_sources): parse Retry-After header from Open-Meteo 429 response to dynamically adjust cooldown instead of hardcoded 15m 2026-03-11 05:10:53 +08:00
2569718930@qq.com 2b681513ab fix(trend-engine): fallback to non-ensemble probability computation when OM drops to keep UI alive 2026-03-11 05:07:41 +08:00
2569718930@qq.com c5010a8b97 fix: safely fallback to metric unit per city instead of overwriting F to C randomly; allow trend engine to run even if Open-Meteo drops 2026-03-11 04:57:58 +08:00
2569718930@qq.com 535b2b0446 feat: add disk-based cache persistence for Open-Meteo to eliminate cold-start empty cache gaps 2026-03-11 04:39:20 +08:00
2569718930@qq.com 7f84314bcc fix: add Open-Meteo 429 shared cooldown timer to break retry death spiral on cold start 2026-03-11 04:32:32 +08:00
2569718930@qq.com 4b51e0dd15 feat: add 10min function-level cache to fetch_metar with stale fallback on error 2026-03-11 04:23:13 +08:00
2569718930@qq.com cc1a9a3b77 perf: increase Open-Meteo and Meteoblue cache TTL to 2 hours to reduce API rate limit bursts 2026-03-11 04:10:23 +08:00
2569718930@qq.com 46412511c8 fix: SyntaxError global declaration must be at function top not inside with-block 2026-03-11 03:59:55 +08:00
2569718930@qq.com c23020038e feat: add TTL cache and stale-cache fallback to Groq AI analyzer 2026-03-11 03:49:26 +08:00
2569718930@qq.com d4760dbcb2 fix: show MB/NWS/MGM as comp_parts when Open-Meteo daily data is missing 2026-03-11 03:44:10 +08:00
2569718930@qq.com ffb88ae61d feat: implement multi-source weather data collection including OpenWeatherMap, Visual Crossing, and METAR with caching. 2026-03-11 03:40:25 +08:00
2569718930@qq.com 4e6cb1071c feat: Implement Telegram push notifications for weather and market conditions and a Polymarket read-only data interface. 2026-03-11 02:13:38 +08:00
2569718930@qq.com d6434bf174 feat: Add weather alert engine, Polymarket data reader, and Telegram notification utility. 2026-03-11 01:56:22 +08:00
2569718930@qq.com 79b4708b7e revert: restore wallet activity watcher original closed-position behavior 2026-03-11 01:03:56 +08:00
2569718930@qq.com a6bef9a57f feat: wallet activity filter max_price to 0.10 and apply price filter to closed positions 2026-03-11 00:55:11 +08:00
2569718930@qq.com d08982a898 add logging for verifying price filter settings 2026-03-10 13:35:09 +08:00
2569718930@qq.com c53bfc629b feat: implement avg_price filtering for wallet activity watcher 2026-03-10 13:33:21 +08:00
2569718930@qq.com f638850048 feat: Add Polymarket wallet activity watcher and configure average price display thresholds. 2026-03-10 13:23:35 +08:00
2569718930@qq.com dd72e6a34b fix: resolve bot_listener encoding issues and syntax errors 2026-03-10 12:54:15 +08:00
2569718930@qq.com 5af63557df fix: Correct garbled Chinese characters in bot messages and logs. 2026-03-10 12:48:09 +08:00
2569718930@qq.com 35b846f1d6 feat: Add Polymarket and Polygon wallet activity watchers and integrate them into the bot. 2026-03-10 12:19:23 +08:00
2569718930@qq.com fb1c88463f feat: Add multi-source weather data collection (OpenWeatherMap, Meteoblue, Visual Crossing, METAR) and Polygon wallet watching functionality, introducing new configuration variables. 2026-03-10 11:51:43 +08:00
2569718930@qq.com d1b591106e feat: add dashboard panel components for hero summary and temperature chart 2026-03-10 10:37:14 +08:00
2569718930@qq.com 1afca8ecbb feat: Implement new dashboard panel sections, utilities, and a future forecast modal. 2026-03-10 10:17:59 +08:00
2569718930@qq.com e5011c5c8f feat: Introduce dashboard data types, utilities, market alert engine, and panel sections. 2026-03-10 10:07:18 +08:00
2569718930@qq.com 732b3c10a5 feat: Update README to detail React Quant Dashboard v2.0 features and refine Edge Analytics & Alerts descriptions. 2026-03-10 09:16:23 +08:00
2569718930@qq.com 396c373cba feat: implement PolyWeather dashboard with map UI, data collection, analysis, and comprehensive documentation. 2026-03-10 09:02:56 +08:00
2569718930@qq.com aab4477ab3 feat: Implement the PolyWeather dashboard including frontend components, data collection, analysis, and API endpoints. 2026-03-10 04:45:40 +08:00
274 changed files with 207209 additions and 5323 deletions
+174 -13
View File
@@ -1,21 +1,182 @@
# Telegram Bot
TELEGRAM_BOT_TOKEN=your_bot_token_here
TELEGRAM_CHAT_ID=your_chat_id_here
# PolyWeather backend/bot minimal reproducible config
# Full configuration guide:
# docs/CONFIGURATION_ZH.md
# Sensitive-only template:
# .env.secrets.example
########################################
# 1) Runtime paths and base behavior
########################################
ENV=production
LOG_LEVEL=INFO
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
# Optional: host user/group mapping for Docker on Linux.
# Windows / macOS can usually keep the defaults.
UID=1000
GID=1000
POLYWEATHER_STATE_STORAGE_MODE=dual
########################################
# 2) Telegram bot minimal
########################################
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
TELEGRAM_CHAT_IDS=
TELEGRAM_QUERY_TOPIC_CHAT_ID=
TELEGRAM_QUERY_TOPIC_ID=
TELEGRAM_QUERY_TOPIC_MAP=
POLYWEATHER_BOT_GROUP_INVITE_URL=
########################################
# 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_MULTI_MODEL_CACHE_VERSION=v2
OPEN_METEO_RATE_LIMIT_COOLDOWN_SEC=900
OPEN_METEO_RATE_CACHE_TTL_SEC=3600
OPEN_METEO_MIN_CALL_INTERVAL_SEC=3
METAR_CACHE_TTL_SEC=600
METEOBLUE_CACHE_TTL_SEC=7200
########################################
# 4) Auth / entitlement
########################################
POLYWEATHER_AUTH_ENABLED=false
POLYWEATHER_AUTH_REQUIRED=false
POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=false
POLYWEATHER_REQUIRE_ENTITLEMENT=false
SUPABASE_URL=
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
SUPABASE_HTTP_TIMEOUT_SEC=8
SUPABASE_AUTH_CACHE_TTL_SEC=30
SUPABASE_SUB_CACHE_TTL_SEC=60
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
########################################
# 5) Alerts / 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,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
TELEGRAM_ALERT_MISPRICING_MAX_YES_BUY=0.10
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
# AI
GROQ_API_KEY=your_groq_api_key_here
########################################
# 6) Frontend-facing shared values
########################################
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
# Proxy Setting (optional)
HTTPS_PROXY=http://127.0.0.1:7890
HTTP_PROXY=http://127.0.0.1:7890
########################################
# 7) Optional modules
########################################
# Other Settings
LOG_LEVEL=INFO
ENV=production
POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/
# Weekly reward / leaderboard
POLYWEATHER_WEEKLY_REWARD_ENABLED=true
POLYWEATHER_WEEKLY_REWARD_TIMEZONE=Asia/Shanghai
POLYWEATHER_WEEKLY_REWARD_SETTLE_WEEKDAY=1
POLYWEATHER_WEEKLY_REWARD_SETTLE_HOUR=0
POLYWEATHER_WEEKLY_REWARD_SETTLE_MINUTE=5
POLYWEATHER_WEEKLY_REWARD_CHECK_INTERVAL_SEC=300
POLYWEATHER_WEEKLY_REWARD_HTTP_TIMEOUT_SEC=10
POLYWEATHER_WEEKLY_REWARD_ANNOUNCE_ENABLED=true
# Group message points
POLYWEATHER_BOT_MESSAGE_POINTS=4
POLYWEATHER_BOT_MESSAGE_DAILY_CAP=40
POLYWEATHER_BOT_MESSAGE_MIN_LENGTH=3
POLYWEATHER_BOT_MESSAGE_COOLDOWN_SEC=30
POLYWEATHER_BOT_CITY_QUERY_COST=1
POLYWEATHER_BOT_DEB_QUERY_COST=1
# Payments
POLYWEATHER_PAYMENT_ENABLED=false
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_TOKEN_DECIMALS=6
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
POLYWEATHER_PAYMENT_HTTP_TIMEOUT_SEC=10
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
# Polymarket market scan
POLYMARKET_MARKET_SCAN_ENABLED=true
POLYMARKET_GAMMA_URL=https://gamma-api.polymarket.com
POLYMARKET_CLOB_URL=https://clob.polymarket.com
POLYMARKET_CHAIN_ID=137
POLYMARKET_HTTP_TIMEOUT_SEC=8
POLYMARKET_MARKET_CACHE_TTL_SEC=180
POLYMARKET_PRICE_CACHE_TTL_SEC=10
POLYMARKET_DISCOVERY_PAGES=6
POLYMARKET_DISCOVERY_LIMIT=200
POLYMARKET_SIGNAL_MIN_LIQUIDITY=500
POLYMARKET_SIGNAL_EDGE_PCT=2
# Polygon watcher
POLYGON_WALLET_WATCH_ENABLED=false
POLYGON_RPC_URL=https://polygon-rpc.com
POLYGON_WALLET_WATCH_ADDRESSES=
POLYGON_WALLET_WATCH_INTERVAL_SEC=8
POLYGON_WALLET_WATCH_CONFIRMATIONS=2
POLYGON_WALLET_WATCH_MAX_BLOCKS_PER_CYCLE=30
POLYGON_WALLET_WATCH_SEEN_TTL_SEC=604800
POLYGON_WALLET_WATCH_RPC_TIMEOUT_SEC=10
POLYGON_WALLET_WATCH_TX_BASE=https://polygonscan.com/tx
POLYGON_WALLET_WATCH_ADDR_BASE=https://polygonscan.com/address
POLYGON_WALLET_WATCH_POLYMARKET_ONLY=true
POLYGON_WALLET_WATCH_INCLUDE_DEFAULT_PM_CONTRACTS=true
POLYGON_WALLET_WATCH_POLYMARKET_CONTRACTS=
# Polymarket wallet activity
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=
+44
View File
@@ -0,0 +1,44 @@
# PolyWeather secrets-only template
# Copy the required lines into your real `.env` / platform secret manager.
# Never commit actual values.
########################################
# Telegram
########################################
TELEGRAM_BOT_TOKEN=
########################################
# Supabase
########################################
SUPABASE_URL=
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
########################################
# Entitlement / dashboard
########################################
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
########################################
# Meteoblue / third-party APIs
########################################
METEOBLUE_API_KEY=
########################################
# Wallet / payments
########################################
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
########################################
# Optional exchange / market secrets
########################################
POLYMARKET_API_KEY=
POLYMARKET_SECRET_KEY=
POLYMARKET_PASSPHRASE=
POLYMARKET_WALLET_ADDRESS=
+61
View File
@@ -0,0 +1,61 @@
name: CI
on:
push:
branches:
- main
pull_request:
jobs:
python-quality:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-dev.txt
- name: Ruff
run: python -m ruff check .
- name: Pytest
run: python -m pytest
frontend-quality:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
docker-build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t polyweather-ci .
+12
View File
@@ -2,6 +2,9 @@
.env
# Data and Logs
data/*.db
data/*.db-*
data/*.db.*
data/*.json
data/logs/
data/historical/
@@ -30,3 +33,12 @@ Thumbs.db
frontend/node_modules/
frontend/.next/
frontend/.vercel/
frontend/*.tsbuildinfo
.npm-cache/
.env.local
.vercel/
# Browser extension build artifacts
/extension.zip
/extension-*.zip
+5
View File
@@ -0,0 +1,5 @@
{
"css.validate": false,
"scss.validate": false,
"less.validate": false
}
+53
View File
@@ -0,0 +1,53 @@
# Changelog
## 1.5.1 - 2026-03-23
- `/ops` 页面增加管理员守卫,前后端双层限制管理员访问
- `/ops` 支持会员列表、支付异常单、用户查询、周榜和手动补分
- `/ops` 支付异常单支持按原因筛选、标记已处理,并补充支付异常审计视图
- 会员列表支持按 `user_id` 去重,并优先回补 Supabase Auth 邮箱/注册时间
- 新增按邮箱补跑订阅恢复脚本 `scripts/reconcile_subscription_by_email.py`
- 支付确认失败(如 `receiver_mismatch`)现在会明确落 `failed`,并写入 SQLite 审计事件
- 支付前强制重新拉取 `/api/payments/config`,并校验最新地址、允许域名和当前支付上下文
- 浏览器钱包选择补齐 EIP-6963 发现、稳定去重和绑定后账户状态即时刷新
- 城市详情页新增 `官方参考 / Official Sources` 区块,覆盖主要城市的官方机构/机场/METAR 链接
- “今日日内分析”结构解读改为后端同源动态短评,并统一网页与 Bot 解释口径
- 台北主结算源切换到 `NOAA RCTP`,按最终质控后的最高整度摄氏值展示和说明
- 浏览器插件同步台北 `NOAA RCTP` 结算参考标签和说明
- `/ops` 手机端收口为卡片化视图,保留桌面表格
- 账户中心补充本周积分显示,`weekly_points` 与周排行同屏展示
- Dashboard 历史对账补充“峰值前 12 小时 DEB 参考(近似)”卡片
- 历史图不再错误混入 `settlement_history` 实测,历史样本仅按可比较样本统计
- 新增 `scripts/backfill_recent_daily_actuals_from_metar.py`,支持为缺失 `daily_records` 的 METAR 城市补最近 14 天 `actual_high`
- 历史接口对新接入的 METAR 城市增加自动 bootstrap,避免新增城市历史页整块空白
- 香港历史/日内展示继续坚持 `HKO` 官方口径,不再 fallback 到 `VHHH METAR` 连续线
- 香港 HKO 当天官方点位不再落单独 JSON,统一写入 runtime state
- 今日日内结构信号按城市本地时间与峰值窗口分析,不再只看固定下午时段
- 新增高空结构信号:冲高环境、压温风险、午后扰动、冲高效率,并提供中英文说明
- 新增交易动作卡:结合高空结构、市场拥挤度与 `edge_percent` 输出 `偏暖侧 / 偏谨慎 / 先观察`
- 非香港机场城市新增 `TAF` 接入,支持 `FM / TEMPO / BECMG / PROB30/40` 时间片解析
- 温度走势图新增 `TAF 时段 / TAF Timing` 标记,并在 tooltip 中显示对应时段摘要
- `TAF` 信号与 `market_signal / edge_percent` 联动进入交易动作,提示更贴近交易语境
- `TAF` 展示词已改成普通用户可读版本:`基础时段 / 明确切换 / 临时波动 / 逐步转变`
- 日内结构总摘要补充“TAF 未新增压温不等于继续升温”的解释,避免误读
- 浏览器插件多日预报改为 `DEB` 优先,基础判断卡补充方向、置信度与原因,并统一引流到主站首页
## Unreleased
## 1.5.0 - 2026-03-21
- 运行态状态与缓存支持 SQLite 渐进迁移,新增 `POLYWEATHER_STATE_STORAGE_MODE=file|dual|sqlite`
- 新增 `/healthz``/api/system/status``/metrics`
- 新增支付运行态接口 `/api/payments/runtime`
- 支付侧新增 SQLite 审计事件、事件重放脚本与多 RPC 容灾支持
- 新增支付静态审计脚本与 V2 合约升级草案
- 统一周积分显示口径,`/top` 中“我的状态”改为累计发言/本周排名/本周积分
- 文档同步更新为 2026-03-20 当前状态
## 1.4.0 - 2026-03-14
- 统一收费阶段产品口径,发布 PolyWeather Pro `v1.4.0`
- 前端交付覆盖账户、支付、权限展示与缓存策略
- 支付链路支持 intent -> submit -> confirm 与自动补单
- 文档统一切换到单一版本源管理
+82
View File
@@ -0,0 +1,82 @@
# 前端交付与重构报告(v1.5.1)
最后更新:`2026-03-14`
## 1. 报告目的
说明当前线上前端(`frontend/`)在收费阶段的实际交付状态。
## 2. 当前前端架构
```mermaid
flowchart LR
B["Browser"] --> N["Next.js App Router (Vercel)"]
N --> RH["Route Handlers /api/*"]
RH --> F["FastAPI (VPS)"]
N --> STORE["Dashboard Store"]
STORE --> MAP["MapCanvas"]
STORE --> SIDEBAR["CitySidebar"]
STORE --> PANEL["DetailPanel + Modal"]
STORE --> ACCOUNT["Account Center + Pro Overlay"]
```
## 3. 已落地能力
### 3.1 信息架构与交互
- 风险分组侧栏折叠(持久化)。
- 选中城市状态持久化。
- 今日分析、历史对账、未来日期分析联动。
### 3.2 收费相关
- 账户中心(登录态、积分、订阅状态、钱包管理)。
- Pro 解锁浮层(套餐、积分抵扣、FAQ、社群入口)。
- 钱包绑定:浏览器扩展钱包 + WalletConnect 扫码。
- 支付流程:create intent -> submit -> confirm。
- `confirm pending` 时自动轮询 intent 状态,确认后自动刷新订阅态。
### 3.3 缓存与性能
- BFF `ETag/304``cities` / `summary` / `history`
- `summary?force_refresh=true` => `no-store`
- `sessionStorage` + in-flight 去重。
- `localStorage`:选中城市、侧栏折叠状态。
### 3.4 可访问性与稳定性
- 详情面板 `inert + blur` 焦点冲突修复。
- 关键支付错误文案标准化(用户取消、gas 不足、pending)。
## 4. 当前明确未做
- 离线能力(Service Worker / IndexedDB
- 前端级财务报表与退款后台(后端/运营侧)
## 5. 验收建议
### 5.1 前端构建
```bash
cd frontend
npm run build
```
### 5.2 缓存验收
```bash
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
```
### 5.3 支付验收
- 绑定钱包
- 创建 intent
- 发交易
- 验证 `intent` 状态从 `submitted -> confirmed`
- 校验账户页订阅状态更新
## 6. 结论
前端已具备收费阶段的核心能力(账户、支付、权限展示、状态回收),可支持持续商业迭代。
+149 -128
View File
@@ -1,168 +1,189 @@
# 🌡️ PolyWeather Pro
# PolyWeather Pro
> **Professional Weather Intelligence System** — Specialized in edge data collection, DEB smart blending, and real-time decision alerts.
Production weather-intelligence stack for temperature settlement markets.
---
Official dashboard: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
## 💎 Project Vision
Public docs center: `/docs/intro` on the main site (bilingual product documentation, including intraday signals, TAF, settlement sources, history, and extension).
PolyWeather is a specialized intelligence system built for **Polymarket** high-stakes participants. We aggregate top-tier meteorological sources, apply proprietary **DEB (Dynamic Error Balancing)** logic, and surface **actionable shift signals** at critical decision windows.
## Product Screenshots
---
### Global Dashboard
## 🏗️ Production Architecture
![PolyWeather global dashboard](docs/images/demo_map.png)
This project uses a decoupled production setup for reliability and iteration speed:
### City Analysis (Ankara)
- **Frontend**: A **Next.js** dashboard on **Vercel** with React component rendering.
- **Backend API**: A **FastAPI** service on VPS for low-latency weather aggregation and analysis.
- **Bot & Alert Heartbeat**: A **Telegram Bot** on VPS for minute-level scanning and push alerts.
![PolyWeather Ankara analysis](docs/images/demo_ankara.png)
🔗 **Official Visit**: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
## Product Status (2026-03-24)
---
- Subscription live: `Pro Monthly 5 USDC`.
- Points redemption live: `500 points = 1 USDC`, max `3 USDC` off.
- 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.
- Lightweight observability live: `/healthz`, `/api/system/status`, `/metrics`.
- Runtime state supports gradual SQLite migration (`file / dual / sqlite`).
- EMOS/CRPS pipeline is integrated in `shadow` mode with rollout gating.
- Intraday structural signal is now peak-window aware and bilingual (`zh-CN` / `en-US`).
- 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.
## 🖼️ Preview & Interaction
## Open-Core Boundary (Important)
<p align="center">
<img src="docs/images/demo_ankara.png" alt="PolyWeather Demo - Ankara Live Analysis" width="450">
<br>
<em>📊 <b>Deep Query View</b>: DEB blended forecast + settlement probability + AI analysis context</em>
</p>
This repository follows an **Open-Core** strategy:
<p align="center">
<img src="./docs/images/demo_map.png" alt="PolyWeather Web Map" width="850">
<br>
<em>🗺️ <b>Omni-Dashboard</b>: global station markers + nearby station context + right-side city intelligence panel</em>
</p>
- Public in repo: weather aggregation, core analysis, dashboard, bot baseline, standard payment flow.
- Private in production: commercial risk rules, operational thresholds, pricing strategy details, internal reconciliation policies, and growth operations tooling.
---
See: [Open-Core & Commercial Boundary](docs/OPEN_CORE_POLICY.md)
## 🚀 Core Features
## Core Capabilities
- **📡 Full-Spectrum Collection**
- **Major Models**: ECMWF, GFS, ICON, GEM, JMA, Open-Meteo, and city-level daily/hourly guidance.
- **Observed Data**: Aviation Weather / METAR as the primary observation source, plus Turkish MGM coverage for Ankara.
- **City Specialization**: `17130` (`Ankara (Bölge/Center)`) remains the Ankara lead station without replacing LTAC settlement observation.
- **⚖️ DEB Smart Blending**
- Dynamic weighting based on city-level performance and current model spread.
- **🧩 React Dashboard Runtime**
- Typed store + typed API client + Leaflet/Chart.js lifecycle wrappers.
- City click workflow: map focus + right panel open + nearby stations render.
- Today analysis workflow: open modal + freeze map motion.
- **🔔 Alert Engine**
- **Momentum Spike**: Captures rapid short-window temperature slope changes.
- **Forecast Breakthrough**: Fires when observations break model envelopes plus margin.
- **Advection Monitoring**: Tracks warm/cold advection using lead-station behavior and wind direction.
- Aggregates observations and forecasts for 30 monitored cities.
- Uses DEB (Dynamic Error Balancing) to blend multi-model highs.
- Generates settlement-oriented probability buckets (`mu` + bucket distribution).
- Maps weather view to Polymarket quotes for mispricing scan.
- 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 structure cards for surface + upper-air analysis.
- Adds airport-side `TAF` timing overlays and airport suppression/disruption interpretation for non-Hong Kong airport cities.
---
## 🔐 Alert Logic Details
| Trigger Name | Core Logic | Trading Value |
| :--------------- | :-------------------------------------------- | :-------------------------------------------- |
| **Center Hit** | Detects DEB trigger only at Ankara HQ `17130` | **Highest priority signal**, the "truth" |
| **Momentum** | 30min temperature slope exceed threshold | Captures sudden weather fronts |
| **Breakthrough** | Pierces all model highs + margin | Captures high-volatility outlier events |
| **Advection** | Lead station rise + Wind match | Gain 20-40 minutes of lead time for execution |
---
## 🧭 Current Data Logic
- **Primary observation source**: Aviation Weather / METAR
- **Ankara enhancement**:
- Settlement observation: `LTAC / Esenboğa`
- Official lead station: `Ankara (Bölge/Center)` / `17130`
- Nearby station layer: Turkish MGM network (Ankara-specific preferred station ordering)
- **Other cities nearby layer**:
- Production currently uses Aviation Weather METAR clusters
- U.S. cities may later receive Mesonet augmentation while METAR stays baseline
- **Frontend request optimization**:
- Initial map temperatures preload via `/api/city/{name}/summary`
- City detail cache TTL = 5 minutes, revision probe avoids unnecessary refetch
- Map movements, panel toggles, and modal open/close do not trigger redundant requests
- Manual refresh always bypasses cache (`force_refresh=true`)
---
## 🏗️ System Architecture
## Reference Architecture
```mermaid
graph TD
subgraph "Client / Terminals"
Web[Next.js React Web App]
TG[Telegram Client]
end
flowchart LR
U["Users (Web / Telegram)"] --> FE["Next.js Frontend (Vercel)"]
U --> BOT["Telegram Bot (VPS)"]
FE --> API["FastAPI /web/app.py"]
BOT --> API
subgraph "Edge Deployment (Vercel)"
Web --> |BFF Routes| Fast[FastAPI API]
end
API --> WX["Weather Collector"]
WX --> METAR["Aviation Weather (METAR)"]
WX --> TAF["Aviation Weather (TAF)"]
WX --> MGM["MGM (Turkey station network)"]
WX --> OM["Open-Meteo"]
WX --> HKO["HKO / NOAA / Official settlement sources"]
subgraph "Core Hub (VPS)"
Fast --- |Shared Logic| Worker[Alert Engine / Worker]
Bot[Telegram Bot] --- |Shared Logic| Worker
end
subgraph "External Sources"
Worker --> |Pull| MGM[MGM Weather]
Worker --> |Pull| METAR[Airport METAR]
Worker --> |Pull| OM[Open-Meteo]
Worker --> |Pull| MM[Multi-Model Integration]
end
Worker --> |Push Alert| TG
Bot --> |Query| Worker
API --> ANA["DEB + Trend + Probability + Market Scan"]
ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"]
ANA --> PM["Polymarket Read-only Layer"]
```
---
## Monitored Cities (30)
## 🛠️ Deployment
- Europe / Middle East: Ankara, London, Paris, Munich, Tel Aviv, Milan, Warsaw, Madrid
- APAC: Seoul, Hong Kong, Taipei, Shanghai, Singapore, Tokyo, Wellington
- Americas: Toronto, New York, Chicago, Dallas, Miami, Atlanta, Seattle, Buenos Aires, Sao Paulo
- South Asia: Lucknow
- China extension: Chengdu, Chongqing, Shenzhen, Beijing, Wuhan
### 1. Backend / Bot (VPS)
## Quick Start
### Backend + Bot (Docker)
```bash
# Pull source
git pull
# Environment
# Edit .env with TELEGRAM_BOT_TOKEN and other keys
# Launch
docker-compose up -d --build
docker compose up -d --build
```
### 2. Frontend (Vercel)
### Frontend (local)
Set `frontend` as the Vercel root directory for automatic CI/CD.
```bash
cd frontend
npm install
npm run dev
```
---
## Recent Highlights
## 💬 Bot Commands
- Taipei settlement is aligned to `NOAA RCTP` and rounded whole-degree Celsius logic.
- Hong Kong keeps `HKO` official readings in dashboard and history, without falling back to airport METAR lines.
- Intraday analysis now separates:
- `Surface Structure`
- `Upper-Air Structure`
- `Trade cue`
- `TAF` is used as an airport-side confirmation layer, not as the main temperature model.
- Browser extension remains a lightweight monitoring + basic-bias product, while the site holds the full analysis experience.
| Command | Description | Example |
| :-------- | :-------------------------------------- | :------------- |
| `/city` | Query real-time analysis for a city | `/city ankara` |
| `/deb` | View historical accuracy of DEB model | `/deb london` |
| `/top` | View activity leaderboard | `/top` |
| `/help` | Get detailed instructions | `/help` |
## Runtime Data (Recommended on VPS)
---
Use external runtime storage to avoid SQLite/git conflicts:
> [!NOTE]
> **Commercialization**: Current plans keep **Web Dashboard ($5/mo)** and **Telegram Signal Channel ($1/mo)** as the core entry offers.
> User entitlement and payment automation are tracked in `docs/COMMERCIALIZATION.md`.
```env
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
```
> [!NOTE]
> **Frontend Model**: Production rendering is now fully handled by React components under `frontend/components/dashboard` and hooks under `frontend/hooks`.
> Legacy static files are retained for reference, but no longer act as the main runtime path.
## Ops Verification
---
### Health / system status / metrics
---
```bash
curl http://127.0.0.1:8000/healthz
curl http://127.0.0.1:8000/api/system/status
curl http://127.0.0.1:8000/metrics
```
**📅 Last Updated**: 2026-03-09
**🚀 Status**: v1.1 Stable - React Dashboard Runtime in Production
### Frontend cache headers
> [!TIP]
> **Production Note**: The UI layout and visual contract remain unchanged while data flow, map lifecycle, and modal interaction are now managed by typed React modules.
```bash
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
```
### Payment auto-reconciliation logs
```bash
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
```
### Payment runtime
```bash
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 |
| :-- | :-- |
| `/city <name>` | City real-time analysis |
| `/deb <name>` | DEB historical reconciliation |
| `/top` | User leaderboard |
| `/id` | Show current chat ID |
| `/diag` | Startup diagnostics |
| `/help` | Help and usage |
## Documentation Index
- Chinese overview: [README_ZH.md](README_ZH.md)
- 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)
- Commercialization: [docs/COMMERCIALIZATION.md](docs/COMMERCIALIZATION.md)
- Open-Core 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)
- 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)
- 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)
- 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.1`
- Last Updated: `2026-03-24`
+142 -129
View File
@@ -1,168 +1,181 @@
# 🌡️ PolyWeather Pro
# PolyWeather Pro
> **专业级博弈情报系统** —— 专注边缘气象数据采集、DEB 智能融合与实时决策预警
面向温度结算市场的生产级气象情报系统
---
官方看板:[polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
## 💎 项目愿景
## 产品截图
PolyWeather 是一套专为 **Polymarket** 深度博弈者设计的实时情报系统。我们不只提供天气预报,而是通过聚合全球气象源、应用自研 **DEB (Dynamic Error Balancing)** 算法,并在关键时间节点输出**可执行的异动信号**。
### 全球看板
---
![PolyWeather 全球地图看板](docs/images/demo_map.png)
## 🏗️ 生产架构
### 城市分析(Ankara
本项目采用生产级解耦架构,确保高可用与迭代效率:
![PolyWeather Ankara 分析页](docs/images/demo_ankara.png)
- **前端**:部署在 **Vercel** 上的 **Next.js + React 组件化仪表盘**
- **后端 API**:运行在 VPS 上的 **FastAPI**,负责多源聚合与分析计算。
- **机器人与预警心跳**:运行在 VPS 上的 **Telegram Bot**,执行分钟级扫描与推送。
## 当前产品状态(2026-03-21
🔗 **官方访问地址**[polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
- 已上线订阅制:`Pro 月付 5 USDC`
- 已上线积分抵扣:`500 积分 = 1 USDC`,最多抵扣 `3 USDC`
- 已上线链上支付:Polygon 合约支付(USDC / USDC.e)。
- 已上线自动补单:事件监听 + 周期确认双链路。
- 已上线支付运行态与审计接口:`/api/payments/runtime`
- 已上线轻量运营后台:`/ops`(会员、周榜、补分、支付异常单)。
- 已上线轻量可观测性:`/healthz``/api/system/status``/metrics`
- 运行态状态与缓存已支持 SQLite 渐进迁移:`file / dual / sqlite`
- 已接入 EMOS/CRPS 校准链路,但当前仍保持 `emos_shadow`
---
## 开源边界(重要)
## 🖼️ 预览与交互
本项目采用 **Open-Core** 策略:
<p align="center">
<img src="docs/images/demo_ankara.png" alt="PolyWeather 效果展示 - 安卡拉实时分析" width="450">
<br>
<em>📊 <b>深度查询效果</b>DEB 融合预测 + 结算概率 + AI 分析上下文</em>
</p>
- 仓库公开部分:天气聚合、基础分析、前端看板、Bot 基础能力、支付标准流程示例。
- 生产私有部分:商业风控规则、运营阈值、收费策略细节、付费用户运营脚本、内部对账与审计策略。
<p align="center">
<img src="./docs/images/demo_map.png" alt="PolyWeather Web Map" width="850">
<br>
<em>🗺️ <b>全景仪表盘</b>:全球站点标记 + 周边站点联动 + 右侧城市详情卡片</em>
</p>
详细见:[Open-Core 与商用边界](docs/OPEN_CORE_POLICY.md)
---
## 核心能力
## 🚀 核心功能
- 聚合 30 个监控城市的实测与预报数据。
- DEBDynamic Error Balancing)融合多模型最高温。
- 输出结算导向概率分布(`mu` + 温度桶)。
- 将模型观点映射到 Polymarket 行情,做错价扫描。
- Web 仪表盘与 Telegram Bot 复用同一分析内核。
- 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。
- **📡 多源全量采集**
- **主流模型**ECMWF、GFS、ICON、GEM、JMA、Open-Meteo 的日/小时指导。
- **实测数据**Aviation Weather / METAR 为主观测源,安卡拉叠加 Turkish MGM 官方网络。
- **城市特化**:安卡拉保留 `17130``Ankara (Bölge/Center)`)领先站逻辑,不替代 LTAC 结算主站。
- **⚖️ DEB 智能融合**
- 基于城市历史表现与当前模型分歧动态调整权重。
- **🧩 React 仪表盘运行时**
- 类型化 Store + 类型化 Data Client + Leaflet/Chart.js 生命周期封装。
- 点击城市:地图聚焦 + 右侧卡片打开 + 周边站点展示。
- 点击“今日日内分析”:打开模态框并冻结地图动画。
- **🔔 异动预警系统**
- **动量突变**:捕捉短窗口温度斜率变化。
- **预测突破**:实测突破模型包络与安全边际时触发。
- **平流监测**:结合前导站和风向识别冷暖平流。
---
## 🔐 预警逻辑深度说明
| 触发器名称 | 核心逻辑 | 博弈价值 |
| :--------------- | :------------------------------------------- | :--------------------------------- |
| **Center Hit** | 仅识别安卡拉总部 `17130` 站点的 DEB 触发信号 | **最高级信号**,定盘星 |
| **Momentum** | 30min 温度斜率超过 | 捕捉突发天气系统(如锋面) |
| **Breakthrough** | 击穿所有预报上限 + 安全边际 | 捕捉市场极少数情况下的暴利点 |
| **Advection** | 前导站温升 + 风向匹配 | 获得 20-40 分钟的提前离场/建仓时间 |
---
## 🧭 当前数据逻辑
- **主观测源**Aviation Weather / METAR
- **安卡拉增强逻辑**
- 结算主观测:`LTAC / Esenboğa`
- 官方领先站:`Ankara (Bölge/Center)` / `17130`
- 周边站层:土耳其 MGM 网络(含安卡拉优先站筛选)
- **其他城市周边站层**
- 当前生产环境使用 Aviation Weather METAR cluster
- 美国城市后续可叠加 Mesonet,但 METAR 仍为基础层
- **前端请求优化口径**
- 首屏先走 `/api/city/{name}/summary` 预热地图温度
- 城市详情 5 分钟 TTL,revision 不变则跳过重拉
- 地图联动、侧卡开关、modal 开关不会重复请求
- 手动刷新强制绕过缓存(`force_refresh=true`
---
## 🏗️ 架构解析
## 参考架构
```mermaid
graph TD
subgraph "客户端 / 终端"
Web[Next.js React 网页端]
TG[Telegram 客户端]
end
flowchart LR
U["用户(Web / Telegram"] --> FE["Next.js 前端(Vercel"]
U --> BOT["Telegram BotVPS"]
FE --> API["FastAPI /web/app.py"]
BOT --> API
subgraph "云端部署 (Vercel)"
Web --> |BFF 路由| Fast[FastAPI API]
end
API --> WX["Weather Collector"]
WX --> METAR["Aviation WeatherMETAR"]
WX --> MGM["MGM(土耳其站网)"]
WX --> OM["Open-Meteo"]
subgraph "核心引擎 (VPS)"
Fast --- |Shared Logic| Worker[Alert Engine / Worker]
Bot[Telegram Bot] --- |Shared Logic| Worker
end
subgraph "外部数据源"
Worker --> |Pull| MGM[MGM 气象局]
Worker --> |Pull| METAR[机场实测]
Worker --> |Pull| OM[Open-Meteo]
Worker --> |Pull| MM[多模型集成]
end
Worker --> |Push Alert| TG
Bot --> |Query| Worker
API --> ANA["DEB + 趋势 + 概率 + 市场扫描"]
ANA --> PAY["支付状态(Intent + Event + Confirm Loop"]
ANA --> PM["Polymarket 只读层"]
API --> OBS["healthz / system status / metrics"]
ANA --> STATE["SQLite runtime state + dual fallback"]
```
---
## 监控城市(30
## 🛠️ 部署指南
- 欧洲/中东:Ankara、London、Paris、Munich、Tel Aviv、Milan、Warsaw、Madrid
- 亚太:Seoul、Hong Kong、Taipei、Shanghai、Singapore、Tokyo、Wellington
- 美洲:Toronto、New York、Chicago、Dallas、Miami、Atlanta、Seattle、Buenos Aires、Sao Paulo
- 南亚:Lucknow
- 中国扩展:Chengdu、Chongqing、Shenzhen、Beijing、Wuhan
### 1. 后端 / 机器人 (VPS)
## 快速启动
### 后端 + BotDocker
```bash
# 获取源码
git pull
# 环境配置
# 编辑 .env 文件,填入 TELEGRAM_BOT_TOKEN 等关键参数
# 一键启动
docker-compose up -d --build
docker compose up -d --build
```
### 2. 前端 (Vercel)
### 前端本地运行
关联本项目 `frontend` 目录作为根目录,启用自动 CI/CD。
```bash
cd frontend
npm install
npm run dev
```
---
## 运行数据目录(VPS 推荐)
## 💬 机器人指令
建议将运行态数据放到仓库外(避免 `git pull` 被 SQLite 卡住):
| 命令 | 说明 | 示例 |
| :-------- | :------------------------ | :------------- |
| `/city` | 查询指定城市实时分析 | `/city ankara` |
| `/deb` | 查看 DEB 模型的历史准确率 | `/deb london` |
| `/top` | 查看活跃积分排行榜 | `/top` |
| `/help` | 获取详细功能说明 | `/help` |
```env
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
POLYWEATHER_STATE_STORAGE_MODE=dual
```
---
## 运维验收
> [!NOTE]
> **商业化提示**:当前仍以 **Web 仪表盘 ($5/月)** 与 **Telegram 信号频道 ($1/月)** 为核心入口套餐。
> 自动化支付与订阅鉴权规划见 `docs/COMMERCIALIZATION.md`。
### 健康与系统状态
> [!NOTE]
> **前端现状**:生产环境页面已由 `frontend/components/dashboard` 与 `frontend/hooks` 完整接管渲染。
> legacy 静态文件仅保留为历史参考,不再作为主运行入口。
```bash
curl http://127.0.0.1:8000/healthz
curl http://127.0.0.1:8000/api/system/status
curl http://127.0.0.1:8000/metrics
```
---
### 前端缓存头
---
```bash
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
```
**📅 最后更新**2026-03-09
**🚀 状态**v1.1 稳定版 - React 运行时已上线
### 支付自动补单日志
> [!TIP]
> **生产提示**:在不改变既有 UI 布局与视觉层级的前提下,数据流、地图联动和模态行为已迁移到类型化 React 组件体系。
```bash
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
```
### 支付运行态
```bash
curl http://127.0.0.1:8000/api/payments/runtime
```
### 运营后台
- 前端入口:`https://polyweather-pro.vercel.app/ops`
- 后端需配置:
```env
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
```
### 钱包异动监听日志
```bash
docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed"
```
## Telegram 指令
| 指令 | 用途 |
| :-- | :-- |
| `/city <name>` | 城市实时分析 |
| `/deb <name>` | DEB 历史对账 |
| `/top` | 用户积分排行 |
| `/id` | 查看聊天 Chat ID |
| `/diag` | Bot 启动诊断 |
| `/help` | 帮助与用法 |
## 文档索引
- 英文总览:[README.md](README.md)
- API 文档(中文):[docs/API_ZH.md](docs/API_ZH.md)
- 商业化说明:[docs/COMMERCIALIZATION.md](docs/COMMERCIALIZATION.md)
- Open-Core 边界:[docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
- 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/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/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.1`
- 文档最后更新:`2026-03-21`
+86
View File
@@ -0,0 +1,86 @@
# 版本发布流程
本项目采用语义化版本号:`MAJOR.MINOR.PATCH`
当前单一版本源为根目录 [VERSION](E:/web/PolyWeather/VERSION) 文件,所有对外文档与前端版本号都从这里同步。
## 版本规则
- `PATCH`:修复缺陷、文档修正、兼容性不变的小改动
- `MINOR`:新增能力、接口扩展、向后兼容的功能迭代
- `MAJOR`:不兼容变更、核心架构升级、公开接口重大调整
示例:
- `1.4.0 -> 1.4.1`:告警逻辑修正、缓存修正、文档修正
- `1.4.0 -> 1.5.0`:新增支付能力、新增页面、新增 API
- `1.4.0 -> 2.0.0`:接口重构或数据结构不兼容
## 日常升版步骤
### 1. 升版本号
```bash
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
```
### 2. 检查同步结果
```bash
python scripts/sync_version.py
git diff
```
### 3. 补充 Changelog
在 [CHANGELOG.md](E:/web/PolyWeather/CHANGELOG.md) 对应版本下补齐:
- 新增能力
- 修复项
- 兼容性说明
### 4. 验证
建议至少执行:
```bash
cd frontend
npm run build
```
如涉及后端核心逻辑,补充执行:
```bash
python -m pytest
```
### 5. 提交与打标签
工作区干净后再打标签:
```bash
git add .
git commit -m "release: v1.4.1"
git tag v1.4.1
```
### 6. 推送
```bash
git push
git push origin v1.4.1
```
## 当前约束
- 不直接手改多份文档版本号
- 不在工作区脏状态下打 release tag
- `README.md`、前端 `package.json`、文档标题版本都通过脚本同步
+1
View File
@@ -0,0 +1 @@
1.5.1
View File
View File
@@ -0,0 +1,174 @@
{
"version": "emos-20260320132525",
"trained_at": "2026-03-20T13:25:25.836021+00:00",
"global": {
"mu": {
"intercept": -1.57406048,
"raw_mu_coef": 2.80583627,
"deb_coef": -0.06819634,
"ens_median_coef": -1.81560215,
"max_so_far_gap_coef": 0.0
},
"sigma": {
"intercept": 0.67509915,
"raw_sigma_coef": 0.14431833,
"spread_coef": 0.14431833,
"peak_flag_coef": 0.0,
"max_so_far_gap_coef": 0.0
}
},
"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.0,
"alpha_sigma": 0.0
},
"cities": {
"ankara": {
"samples": 7,
"mu_bias": 0.566273,
"sigma_scale": 2.0,
"confidence": 0.875
},
"london": {
"samples": 6,
"mu_bias": 0.489961,
"sigma_scale": 2.0,
"confidence": 0.75
},
"new york": {
"samples": 4,
"mu_bias": 1.852451,
"sigma_scale": 0.830444,
"confidence": 0.5
},
"paris": {
"samples": 7,
"mu_bias": 0.308599,
"sigma_scale": 2.0,
"confidence": 0.875
},
"seoul": {
"samples": 6,
"mu_bias": -1.486024,
"sigma_scale": 1.569585,
"confidence": 0.75
},
"toronto": {
"samples": 5,
"mu_bias": -0.734395,
"sigma_scale": 1.656751,
"confidence": 0.625
},
"buenos aires": {
"samples": 5,
"mu_bias": -1.753334,
"sigma_scale": 1.831739,
"confidence": 0.625
},
"wellington": {
"samples": 6,
"mu_bias": 0.350757,
"sigma_scale": 1.377974,
"confidence": 0.75
},
"chicago": {
"samples": 4,
"mu_bias": 3.01062,
"sigma_scale": 0.825575,
"confidence": 0.5
},
"sao paulo": {
"samples": 5,
"mu_bias": 1.632457,
"sigma_scale": 2.0,
"confidence": 0.625
},
"dallas": {
"samples": 4,
"mu_bias": 3.77714,
"sigma_scale": 0.796874,
"confidence": 0.5
},
"miami": {
"samples": 5,
"mu_bias": -4.868741,
"sigma_scale": 2.0,
"confidence": 0.625
},
"atlanta": {
"samples": 5,
"mu_bias": -7.648823,
"sigma_scale": 2.0,
"confidence": 0.625
},
"seattle": {
"samples": 4,
"mu_bias": 4.058619,
"sigma_scale": 2.0,
"confidence": 0.5
},
"lucknow": {
"samples": 4,
"mu_bias": 3.257609,
"sigma_scale": 2.0,
"confidence": 0.5
},
"munich": {
"samples": 6,
"mu_bias": -0.780811,
"sigma_scale": 2.0,
"confidence": 0.75
},
"hong kong": {
"samples": 3,
"mu_bias": -0.492675,
"sigma_scale": 2.0,
"confidence": 0.375
},
"taipei": {
"samples": 3,
"mu_bias": 1.462462,
"sigma_scale": 1.132834,
"confidence": 0.375
},
"milan": {
"samples": 3,
"mu_bias": -2.729203,
"sigma_scale": 2.0,
"confidence": 0.375
},
"warsaw": {
"samples": 3,
"mu_bias": 0.349319,
"sigma_scale": 1.337949,
"confidence": 0.375
}
},
"metrics": {
"sample_count": 105,
"mean_crps": 2.923823,
"legacy_mean_crps": 2.793938,
"legacy_mean_mae": 2.721143,
"legacy_bucket_hit_rate": 0.695238,
"legacy_bucket_brier": 0.775463,
"selected_mean_crps": 2.700275,
"selected_mean_mae": 2.721143,
"selected_bucket_hit_rate": 0.695238,
"selected_bucket_brier": 0.765459,
"selected_score": 4.003626,
"legacy_score": 4.104792,
"filled_actual_from_history": 2,
"settlement_history_city_count": 30
},
"source": "artifacts\\probability_calibration\\default.json"
}
@@ -0,0 +1,248 @@
{
"summary": {
"sample_count": 105,
"filled_actual_from_history": 2,
"legacy": {
"mean_crps": 2.793938,
"mean_mae": 2.721143,
"bucket_hit_rate": 0.695238
},
"emos": {
"mean_crps": 2.700275,
"mean_mae": 2.721143,
"bucket_hit_rate": 0.695238
},
"delta": {
"crps": -0.093663,
"mae": 0.0,
"bucket_hit_rate": 0.0
}
},
"by_city": {
"ankara": {
"samples": 7,
"legacy_mean_crps": 2.023242,
"emos_mean_crps": 2.023242,
"legacy_mean_mae": 1.984286,
"emos_mean_mae": 1.984286,
"legacy_bucket_hit_rate": 0.714286,
"emos_bucket_hit_rate": 0.714286
},
"atlanta": {
"samples": 5,
"legacy_mean_crps": 12.792034,
"emos_mean_crps": 12.694543,
"legacy_mean_mae": 12.806,
"emos_mean_mae": 12.806,
"legacy_bucket_hit_rate": 0.6,
"emos_bucket_hit_rate": 0.6
},
"buenos aires": {
"samples": 5,
"legacy_mean_crps": 3.846144,
"emos_mean_crps": 3.846144,
"legacy_mean_mae": 4.168,
"emos_mean_mae": 4.168,
"legacy_bucket_hit_rate": 0.6,
"emos_bucket_hit_rate": 0.6
},
"chicago": {
"samples": 4,
"legacy_mean_crps": 1.346667,
"emos_mean_crps": 0.601765,
"legacy_mean_mae": 0.0,
"emos_mean_mae": 0.0,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
},
"dallas": {
"samples": 4,
"legacy_mean_crps": 1.256111,
"emos_mean_crps": 0.651425,
"legacy_mean_mae": 0.0,
"emos_mean_mae": 0.0,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
},
"hong kong": {
"samples": 3,
"legacy_mean_crps": 0.261027,
"emos_mean_crps": 0.261027,
"legacy_mean_mae": 0.1,
"emos_mean_mae": 0.1,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 0.666667
},
"london": {
"samples": 6,
"legacy_mean_crps": 2.079624,
"emos_mean_crps": 2.079624,
"legacy_mean_mae": 2.451667,
"emos_mean_mae": 2.451667,
"legacy_bucket_hit_rate": 0.166667,
"emos_bucket_hit_rate": 0.166667
},
"lucknow": {
"samples": 4,
"legacy_mean_crps": 1.468528,
"emos_mean_crps": 1.468528,
"legacy_mean_mae": 1.6025,
"emos_mean_mae": 1.6025,
"legacy_bucket_hit_rate": 0.5,
"emos_bucket_hit_rate": 0.5
},
"madrid": {
"samples": 2,
"legacy_mean_crps": 6.27726,
"emos_mean_crps": 6.27726,
"legacy_mean_mae": 7.33,
"emos_mean_mae": 7.33,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"miami": {
"samples": 5,
"legacy_mean_crps": 11.665378,
"emos_mean_crps": 11.665378,
"legacy_mean_mae": 12.07,
"emos_mean_mae": 12.07,
"legacy_bucket_hit_rate": 0.6,
"emos_bucket_hit_rate": 0.6
},
"milan": {
"samples": 3,
"legacy_mean_crps": 4.401392,
"emos_mean_crps": 3.928883,
"legacy_mean_mae": 4.06,
"emos_mean_mae": 4.06,
"legacy_bucket_hit_rate": 0.666667,
"emos_bucket_hit_rate": 0.666667
},
"munich": {
"samples": 6,
"legacy_mean_crps": 2.988583,
"emos_mean_crps": 2.988583,
"legacy_mean_mae": 3.143333,
"emos_mean_mae": 3.143333,
"legacy_bucket_hit_rate": 0.5,
"emos_bucket_hit_rate": 0.5
},
"new york": {
"samples": 4,
"legacy_mean_crps": 1.861101,
"emos_mean_crps": 1.409393,
"legacy_mean_mae": 1.3725,
"emos_mean_mae": 1.3725,
"legacy_bucket_hit_rate": 0.75,
"emos_bucket_hit_rate": 0.75
},
"paris": {
"samples": 7,
"legacy_mean_crps": 2.430082,
"emos_mean_crps": 2.430082,
"legacy_mean_mae": 2.518571,
"emos_mean_mae": 2.518571,
"legacy_bucket_hit_rate": 0.571429,
"emos_bucket_hit_rate": 0.571429
},
"sao paulo": {
"samples": 5,
"legacy_mean_crps": 2.454756,
"emos_mean_crps": 2.454756,
"legacy_mean_mae": 2.628,
"emos_mean_mae": 2.628,
"legacy_bucket_hit_rate": 0.6,
"emos_bucket_hit_rate": 0.6
},
"seattle": {
"samples": 4,
"legacy_mean_crps": 0.531656,
"emos_mean_crps": 0.452784,
"legacy_mean_mae": 0.0,
"emos_mean_mae": 0.0,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
},
"seoul": {
"samples": 6,
"legacy_mean_crps": 0.328088,
"emos_mean_crps": 0.328088,
"legacy_mean_mae": 0.2,
"emos_mean_mae": 0.2,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
},
"shanghai": {
"samples": 2,
"legacy_mean_crps": 0.250034,
"emos_mean_crps": 0.250034,
"legacy_mean_mae": 0.15,
"emos_mean_mae": 0.15,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
},
"singapore": {
"samples": 2,
"legacy_mean_crps": 0.281993,
"emos_mean_crps": 0.281993,
"legacy_mean_mae": 0.15,
"emos_mean_mae": 0.15,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
},
"taipei": {
"samples": 3,
"legacy_mean_crps": 0.356996,
"emos_mean_crps": 0.356996,
"legacy_mean_mae": 0.1,
"emos_mean_mae": 0.1,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
},
"tel aviv": {
"samples": 2,
"legacy_mean_crps": 0.446758,
"emos_mean_crps": 0.446758,
"legacy_mean_mae": 0.3,
"emos_mean_mae": 0.3,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
},
"tokyo": {
"samples": 2,
"legacy_mean_crps": 0.450128,
"emos_mean_crps": 0.450128,
"legacy_mean_mae": 0.25,
"emos_mean_mae": 0.25,
"legacy_bucket_hit_rate": 0.5,
"emos_bucket_hit_rate": 1.0
},
"toronto": {
"samples": 5,
"legacy_mean_crps": 2.647861,
"emos_mean_crps": 2.566068,
"legacy_mean_mae": 2.532,
"emos_mean_mae": 2.532,
"legacy_bucket_hit_rate": 0.6,
"emos_bucket_hit_rate": 0.6
},
"warsaw": {
"samples": 3,
"legacy_mean_crps": 1.618875,
"emos_mean_crps": 1.618875,
"legacy_mean_mae": 2.056667,
"emos_mean_mae": 2.056667,
"legacy_bucket_hit_rate": 0.333333,
"emos_bucket_hit_rate": 0.333333
},
"wellington": {
"samples": 6,
"legacy_mean_crps": 0.266349,
"emos_mean_crps": 0.266349,
"legacy_mean_mae": 0.2,
"emos_mean_mae": 0.2,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
}
}
}
@@ -0,0 +1,72 @@
{
"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": 105,
"delta_crps": -0.093663,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0
},
"shadow": {
"sample_count": 103,
"delta_mae": 0.012708,
"delta_bucket_hit_rate": 0.009709,
"delta_bucket_brier": 0.293835
},
"blocking_reasons": [
"shadow bucket brier 退化超限:delta=0.293835"
],
"worst_shadow_regressions": [
{
"city": "dallas",
"samples": 4,
"delta_mae": 0.114807,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.778678
},
{
"city": "chicago",
"samples": 4,
"delta_mae": 0.075265,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.746156
},
{
"city": "seattle",
"samples": 4,
"delta_mae": 0.11262,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.692003
},
{
"city": "atlanta",
"samples": 4,
"delta_mae": 0.293028,
"delta_bucket_hit_rate": -0.25,
"delta_bucket_brier": 0.601425
},
{
"city": "miami",
"samples": 4,
"delta_mae": 0.241559,
"delta_bucket_hit_rate": -0.5,
"delta_bucket_brier": 0.478245
}
]
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -801
View File
@@ -1,811 +1,12 @@
import sys
import os
from typing import List
import telebot # type: ignore
from loguru import logger # type: ignore
import sys
# 确保项目根目录在 sys.path 中
project_root = os.path.dirname(os.path.abspath(__file__))
if project_root not in sys.path:
sys.path.insert(0, project_root)
from src.utils.config_loader import load_config # type: ignore # noqa: E402
from src.utils.telegram_push import start_trade_alert_push_loop # type: ignore # noqa: E402
from src.data_collection.weather_sources import WeatherDataCollector # type: ignore # noqa: E402
from src.data_collection.city_risk_profiles import get_city_risk_profile # type: ignore # noqa: E402
from src.analysis.deb_algorithm import calculate_dynamic_weights, update_daily_record # noqa: E402
from src.database.db_manager import DBManager
MESSAGE_POINTS = 4
MESSAGE_DAILY_CAP = 50
MESSAGE_MIN_LENGTH = 2
MESSAGE_COOLDOWN_SEC = 30
CITY_QUERY_COST = 1
DEB_QUERY_COST = 1
def analyze_weather_trend(weather_data, temp_symbol, city_name=None):
"""Thin wrapper — delegates to shared trend_engine module."""
from src.analysis.trend_engine import analyze_weather_trend as _analyze
display_str, ai_context, _structured = _analyze(weather_data, temp_symbol, city_name)
return display_str, ai_context
def start_bot():
config = load_config()
token = os.getenv("TELEGRAM_BOT_TOKEN")
if not token:
logger.error("未找到 TELEGRAM_BOT_TOKEN 环境变量")
return
bot = telebot.TeleBot(token)
db = DBManager()
weather = WeatherDataCollector(config)
start_trade_alert_push_loop(bot, config)
def _display_name(user) -> str:
return user.username or user.first_name or f"User_{user.id}"
def _ensure_query_points(message, cost: int, label: str) -> bool:
user = message.from_user
db.upsert_user(user.id, _display_name(user))
result = db.spend_points(user.id, cost)
if result.get("ok"):
return True
balance = int(result.get("balance") or 0)
required = int(result.get("required") or cost)
missing = max(0, required - balance)
bot.reply_to(
message,
(
f"❌ 积分不足,无法执行 <b>{label}</b>\n"
f"当前积分: <code>{balance}</code>\n"
f"需要积分: <code>{required}</code>\n"
f"还差积分: <code>{missing}</code>\n\n"
f"积分规则:每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
f"每日上限 {MESSAGE_DAILY_CAP} 分。"
),
parse_mode="HTML",
)
return False
@bot.message_handler(commands=["start", "help"])
def send_welcome(message):
welcome_text = (
"🌡️ <b>PolyWeather 天气查询机器人</b>\n\n"
"可用指令:\n"
f"/city [城市名] - 查询城市天气预测与实测 (消耗 {CITY_QUERY_COST} 积分)\n"
f"/deb [城市名] - 查看 DEB 融合预测准确率 (消耗 {DEB_QUERY_COST} 积分)\n"
"/top - 查看积分排行榜\n"
"/id - 获取当前聊天的 Chat ID\n\n"
"示例: <code>/city 伦敦</code>\n"
f"💡 <i>提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
f"每日上限 {MESSAGE_DAILY_CAP} 分。</i>"
)
bot.reply_to(message, welcome_text, parse_mode="HTML")
@bot.message_handler(commands=["id"])
def get_chat_id(message):
bot.reply_to(
message,
f"🎯 当前聊天的 Chat ID 是: <code>{message.chat.id}</code>",
parse_mode="HTML",
)
@bot.message_handler(commands=["top"])
def show_points(message):
"""显示当前用户的积分及排行榜"""
user = message.from_user
db.upsert_user(user.id, _display_name(user))
user_info = db.get_user(user.id)
leaderboard = db.get_leaderboard(limit=5)
rank_text = "🏆 <b>PolyWeather 活跃度排行榜</b>\n"
rank_text += "────────────────────\n"
for i, entry in enumerate(leaderboard):
medal = ["🥇", "🥈", "🥉", " ", " "][i] if i < 5 else " "
rank_text += f"{medal} {entry['username'][:12]}: <b>{entry['points']}</b> 点\n"
if user_info:
rank_text += "────────────────────\n"
rank_text += (
f"👤 <b>我的状态:</b>\n"
f"└ 积分: <code>{user_info['points']}</code>\n"
f"└ 发言: <code>{user_info['message_count']}</code> 次\n"
f"└ 今日发言积分: <code>{user_info.get('daily_points') or 0}/{MESSAGE_DAILY_CAP}</code>\n"
f"└ /city 消耗: <code>{CITY_QUERY_COST}</code> | /deb 消耗: <code>{DEB_QUERY_COST}</code>"
)
bot.send_message(message.chat.id, rank_text, parse_mode="HTML")
@bot.message_handler(commands=["deb"])
def deb_accuracy(message):
"""查询 DEB 融合预测的近 7 天准确率。"""
try:
parts = message.text.split(maxsplit=1)
if len(parts) < 2:
bot.reply_to(
message,
"❌ 用法: <code>/deb ankara</code>",
parse_mode="HTML",
)
return
from datetime import datetime as _dt, timedelta as _td
import os as _os
from src.analysis.deb_algorithm import load_history
from src.data_collection.city_registry import ALIASES
city_input = parts[1].strip().lower()
city_name = ALIASES.get(city_input, city_input)
project_root = _os.path.dirname(_os.path.abspath(__file__))
history_file = _os.path.join(project_root, "data", "daily_records.json")
data = load_history(history_file)
if city_name not in data or not data[city_name]:
bot.reply_to(
message,
f"❌ 暂无 {city_name} 的历史数据。",
parse_mode="HTML",
)
return
if not _ensure_query_points(message, DEB_QUERY_COST, "/deb"):
return
city_data = data[city_name]
today = _dt.now().date()
today_str = today.strftime("%Y-%m-%d")
cutoff_date = today - _td(days=6)
recent_items = []
for date_str, record in city_data.items():
try:
row_date = _dt.strptime(date_str, "%Y-%m-%d").date()
except Exception:
continue
if row_date >= cutoff_date:
recent_items.append((date_str, record, row_date))
recent_items.sort(key=lambda item: item[0])
lines = [
f"📊 <b>DEB 准确率报告 - {city_name.title()}</b>",
"",
"📅 <b>近7日记录:</b>",
]
total_days = 0
hits = 0
deb_errors = []
signed_errors = []
model_errors = {}
for date_str, record, _row_date in recent_items:
actual = record.get("actual_high")
deb_pred = record.get("deb_prediction")
forecasts = record.get("forecasts", {}) or {}
if actual is None:
continue
try:
actual = float(actual)
if deb_pred is not None:
deb_pred = float(deb_pred)
except Exception:
continue
if deb_pred is None and forecasts:
valid_preds = [float(v) for v in forecasts.values() if v is not None]
if valid_preds:
deb_pred = round(sum(valid_preds) / len(valid_preds), 1)
actual_wu = round(actual)
if date_str == today_str:
lines.append(f" {date_str}: 📍 今天进行中 (实测暂 {actual:.1f})")
elif deb_pred is not None:
total_days += 1
deb_wu = round(deb_pred)
hit = deb_wu == actual_wu
if hit:
hits += 1
err = deb_pred - actual
deb_errors.append(abs(err))
signed_errors.append(err)
if hit:
result_icon = ""
err_text = f"偏差{abs(err):.1f}°"
elif err < 0:
result_icon = ""
err_text = f"低估{abs(err):.1f}°"
else:
result_icon = ""
err_text = f"高估{abs(err):.1f}°"
retro = "" if "deb_prediction" not in record else ""
lines.append(
f" {date_str}: DEB {retro}{deb_pred:.1f}{deb_wu} vs 实测 {actual:.1f}{actual_wu} "
f"{result_icon} {err_text}"
)
if date_str != today_str and actual is not None:
for model, pred in forecasts.items():
if pred is None:
continue
try:
model_errors.setdefault(model, []).append(abs(float(pred) - actual))
except Exception:
continue
if total_days > 0:
hit_rate = hits / total_days * 100
deb_mae = sum(deb_errors) / len(deb_errors)
lines.append("")
lines.append(
f"🎯 <b>DEB 总战绩:</b>WU命中 {hits}/{total_days} (<b>{hit_rate:.0f}%</b>) | MAE: {deb_mae:.1f}°"
)
if model_errors:
lines.append("")
lines.append("📈 <b>模型 MAE 对比:</b>")
model_maes = {m: sum(e) / len(e) for m, e in model_errors.items() if e}
sorted_models = sorted(model_maes.items(), key=lambda item: item[1])
for model, mae in sorted_models:
tag = "" if mae <= deb_mae else ""
lines.append(f" {model}: {mae:.1f}°{tag}")
lines.append(f" <b>DEB融合: {deb_mae:.1f}°</b>")
mean_bias = sum(signed_errors) / len(signed_errors)
underest = sum(1 for e in signed_errors if e < -0.3)
overest = sum(1 for e in signed_errors if e > 0.3)
accurate = total_days - underest - overest
lines.append("")
lines.append("🔍 <b>偏差分析:</b>")
if abs(mean_bias) > 0.3:
bias_label = "系统性低估" if mean_bias < 0 else "系统性高估"
lines.append(f" ⚠️ {bias_label}:平均偏差 {mean_bias:+.1f}°")
else:
lines.append(f" ✅ 整体无明显系统偏差:平均偏差 {mean_bias:+.1f}°")
lines.append(f" 低估 {underest} 次 | 高估 {overest} 次 | 准确 {accurate}")
lines.append("")
lines.append("💡 <b>建议:</b>")
if underest > overest and abs(mean_bias) > 0.5:
lines.append(
f" 该城市模型集体低估趋势明显({mean_bias:+.1f}°),实际最高温可能比 DEB 融合值高 "
f"{abs(mean_bias):.0f}-{abs(mean_bias) + 0.5:.0f}°。交易时建议适当看高。"
)
elif overest > underest and abs(mean_bias) > 0.5:
lines.append(
f" 该城市模型集体高估趋势明显({mean_bias:+.1f}°),实际最高温可能低于 DEB 融合值。交易时注意追高风险。"
)
elif deb_mae > 1.5:
lines.append(f" 近期模型波动较大(MAE {deb_mae:.1f}°),建议降低对单一日预测的信任度。")
elif hit_rate >= 60:
lines.append(" DEB 近期表现稳定,可继续作为主要参考。")
else:
lines.append(" 近期准确率一般,建议结合主站实测与周边站点共同判断。")
lines.append("")
lines.append("📝 MAE = 平均绝对误差,越小越准。⭐ = 优于 DEB 融合。")
lines.append("🗓 统计窗口:近7天滚动样本。")
else:
lines.append("")
lines.append("⏳ 近7天尚无完整的 DEB 预测记录。")
lines.append("")
lines.append(f"💳 本次消耗 <code>{DEB_QUERY_COST}</code> 积分。")
bot.reply_to(message, "\n".join(lines), parse_mode="HTML")
except Exception as e:
bot.reply_to(message, f"❌ 查询失败: {e}")
@bot.message_handler(commands=["city"])
def get_city_info(message):
"""查询指定城市的天气详情"""
try:
parts = message.text.split(maxsplit=1)
if len(parts) < 2:
bot.reply_to(
message,
"❓ 请输入城市名称\n\n用法: <code>/city chicago</code>",
parse_mode="HTML",
)
return
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
city_input = parts[1].strip().lower()
# --- 使用统一注册表解析城市 ---
SUPPORTED_CITIES = list(CITY_REGISTRY.keys())
# 1. 第一优先级:全称或别名完全匹配
city_name = ALIASES.get(city_input)
if not city_name and city_input in SUPPORTED_CITIES:
city_name = city_input
# 2. 第二优先级:前缀模糊匹配
if not city_name and len(city_input) >= 2:
# 搜别名
for k, v in ALIASES.items():
if k.startswith(city_input):
city_name = v
break
# 搜城市全名
if not city_name:
for full_name in SUPPORTED_CITIES:
if full_name.startswith(city_input):
city_name = full_name
break
# 3. 未找到 → 报错
if not city_name:
city_list = ", ".join(sorted(SUPPORTED_CITIES))
bot.reply_to(
message,
f"❌ 未找到城市: <b>{city_input}</b>\n\n"
f"支持的城市: {city_list}",
parse_mode="HTML",
)
return
if not _ensure_query_points(message, CITY_QUERY_COST, "/city"):
return
bot.send_message(
message.chat.id, f"🔍 正在查询 {city_name.title()} 的天气数据..."
)
coords = weather.get_coordinates(city_name)
if not coords:
bot.reply_to(message, f"❌ 未找到城市坐标: {city_name}")
return
weather_data = weather.fetch_all_sources(
city_name, lat=coords["lat"], lon=coords["lon"]
)
open_meteo = weather_data.get("open-meteo", {})
metar = weather_data.get("metar", {})
mgm = weather_data.get("mgm") or {}
# 数值归一化
def _sf(v):
if v is None:
return None
try:
return float(v)
except Exception:
return None
temp_unit = open_meteo.get("unit", "celsius")
temp_symbol = "°F" if temp_unit == "fahrenheit" else "°C"
# --- 1. 紧凑 Header (城市 + 时间 + 风险状态) ---
local_time = open_meteo.get("current", {}).get("local_time", "")
time_str = local_time.split(" ")[1][:5] if " " in local_time else "N/A"
risk_profile = get_city_risk_profile(city_name)
risk_emoji = risk_profile.get("risk_level", "") if risk_profile else ""
msg_header = f"📍 <b>{city_name.title()}</b> ({time_str}) {risk_emoji}"
msg_lines = [msg_header]
# --- 2. 紧凑 风险提示 ---
if risk_profile:
bias = risk_profile.get("bias", "±0.0")
msg_lines.append(
f"⚠️ {risk_profile.get('airport_name', '')}: {bias}{temp_symbol} | {risk_profile.get('warning', '')}"
)
# --- 3. 紧凑 预测区 ---
daily = open_meteo.get("daily", {})
dates = daily.get("time", [])[:3]
max_temps = daily.get("temperature_2m_max", [])[:3]
nws_high = _sf(weather_data.get("nws", {}).get("today_high"))
mgm_high = _sf(mgm.get("today_high"))
mb_high = _sf(weather_data.get("meteoblue", {}).get("today_high"))
# 今天对比
today_t = max_temps[0] if max_temps else "N/A"
comp_parts = []
sources = ["Open-Meteo"]
if mb_high is not None:
sources.append("MB")
comp_parts.append(
f"MB: {mb_high:.1f}{temp_symbol}"
if isinstance(mb_high, (int, float))
else f"MB: {mb_high}"
)
if nws_high is not None:
sources.append("NWS")
comp_parts.append(
f"NWS: {nws_high:.1f}{temp_symbol}"
if isinstance(nws_high, (int, float))
else f"NWS: {nws_high}"
)
if mgm_high is not None:
sources.append("MGM")
comp_parts.append(
f"🇹🇷 MGM: {mgm_high:.1f}{temp_symbol}"
if isinstance(mgm_high, (int, float))
else f"🇹🇷 MGM: {mgm_high}"
)
# 检查是否有显著分歧 (超过 5°F 或 2.5°C)
divergence_warning = ""
if mb_high is not None and max_temps:
diff = abs(mb_high - (_sf(max_temps[0]) or 0))
threshold = 5.0 if temp_unit == "fahrenheit" else 2.5
if diff > threshold:
divergence_warning = (
f" ⚠️ <b>模型显著分歧 ({diff:.1f}{temp_symbol})</b>"
)
comp_str = f" ({' | '.join(comp_parts)})" if comp_parts else ""
sources_str = " | ".join(sources)
msg_lines.append(f"\n📊 <b>预报 ({sources_str})</b>")
msg_lines.append(
f"👉 <b>今天: {today_t}{temp_symbol}{comp_str}</b>{divergence_warning}"
)
# 明后天
if len(dates) > 1:
future_forecasts = []
mgm_daily = mgm.get("daily_forecasts", {}) or {}
for d, t in zip(dates[1:], max_temps[1:]):
# 检查 MGM 是否有该日期的预报
mgm_f = mgm_daily.get(d)
if mgm_f is not None:
future_forecasts.append(
f"{d[5:]}: {t}{temp_symbol} | 🇹🇷 <b>MGM: {mgm_f}{temp_symbol}</b>"
)
else:
future_forecasts.append(f"{d[5:]}: {t}{temp_symbol}")
msg_lines.append("📅 " + " | ".join(future_forecasts))
# --- 3.5 日出日落 + 日照时长 ---
sunrises = daily.get("sunrise", [])
sunsets = daily.get("sunset", [])
sunshine_durations = daily.get("sunshine_duration", [])
if sunrises and sunsets:
sunrise_t = (
sunrises[0].split("T")[1][:5]
if "T" in str(sunrises[0])
else sunrises[0]
)
sunset_t = (
sunsets[0].split("T")[1][:5]
if "T" in str(sunsets[0])
else sunsets[0]
)
sun_line = f"🌅 日出 {sunrise_t} | 🌇 日落 {sunset_t}"
if sunshine_durations:
sunshine_hours = sunshine_durations[0] / 3600 # 秒 -> 小时
sun_line += f" | ☀️ 日照 {sunshine_hours:.1f}h"
msg_lines.append(sun_line)
# --- 4. 核心 实测区 (合并 METAR 和 MGM) ---
# 基础数据优先用 METAR
cur_temp = _sf(
metar.get("current", {}).get("temp")
if metar
else mgm.get("current", {}).get("temp")
)
max_p = _sf(
metar.get("current", {}).get("max_temp_so_far") if metar else None
)
max_p_time = (
metar.get("current", {}).get("max_temp_time") if metar else None
)
obs_t_str = "N/A"
metar_age_min = None # METAR 数据年龄(分钟)
main_source = "METAR" if metar else "MGM"
if metar:
obs_t = metar.get("observation_time", "")
try:
if "T" in obs_t:
from datetime import datetime, timezone, timedelta
dt = datetime.fromisoformat(obs_t.replace("Z", "+00:00"))
utc_offset = open_meteo.get("utc_offset", 0)
local_dt = dt.astimezone(
timezone(timedelta(seconds=utc_offset))
)
obs_t_str = local_dt.strftime("%H:%M")
# 计算数据年龄
now_utc = datetime.now(timezone.utc)
metar_age_min = int((now_utc - dt).total_seconds() / 60)
elif " " in obs_t:
obs_t_str = obs_t.split(" ")[1][:5]
else:
obs_t_str = obs_t
except Exception:
obs_t_str = obs_t[:16]
elif mgm:
m_time = mgm.get("current", {}).get("time", "")
if "T" in m_time:
from datetime import datetime, timezone, timedelta
dt = datetime.fromisoformat(m_time.replace("Z", "+00:00"))
m_time = dt.astimezone(timezone(timedelta(hours=3))).strftime(
"%H:%M"
)
elif " " in m_time:
m_time = m_time.split(" ")[1][:5]
obs_t_str = m_time
# 数据年龄标注
age_tag = ""
if metar_age_min is not None:
if metar_age_min >= 60:
age_tag = f" ⚠️{metar_age_min}分钟前"
elif metar_age_min >= 30:
age_tag = f"{metar_age_min}分钟前"
max_str = ""
if max_p is not None:
import math
settled_val = math.floor(max_p + 0.5)
max_str = f" (最高: {max_p}{temp_symbol}"
if max_p_time:
max_str += f" @{max_p_time}"
max_str += f" → WU {settled_val}{temp_symbol})"
# --- 天气状况总结 ---
wx_summary = ""
# 优先使用 METAR 天气现象
metar_wx = metar.get("current", {}).get("wx_desc", "") if metar else ""
metar_clouds = metar.get("current", {}).get("clouds", []) if metar else []
mgm_cloud = mgm.get("current", {}).get("cloud_cover") if mgm else None
if metar_wx:
wx_upper = metar_wx.upper().strip()
wx_tokens = set(wx_upper.split())
rain_codes = {
"RA",
"DZ",
"-RA",
"+RA",
"-DZ",
"+DZ",
"TSRA",
"SHRA",
"FZRA",
}
snow_codes = {"SN", "GR", "GS", "-SN", "+SN", "BLSN"}
fog_codes = {"FG", "BR", "HZ", "FZFG"}
ts_codes = {"TS", "TSRA"}
if ts_codes & wx_tokens:
wx_summary = "⛈️ 雷暴"
elif {"+RA", "+SN"} & wx_tokens:
wx_summary = "🌧️ 大雨" if "+RA" in wx_tokens else "❄️ 大雪"
elif rain_codes & wx_tokens:
wx_summary = (
"🌧️ 小雨" if {"-RA", "-DZ", "DZ"} & wx_tokens else "🌧️ 下雨"
)
elif snow_codes & wx_tokens:
wx_summary = "❄️ 下雪"
elif fog_codes & wx_tokens:
wx_summary = "🌫️ 雾/霾"
# 如果 METAR 没有特殊现象,用云量推断
if not wx_summary:
# 优先 METAR 云层,回退 MGM
cover_code = ""
if metar_clouds:
cover_code = metar_clouds[-1].get("cover", "")
if cover_code in ("SKC", "CLR") or (
cover_code == "" and mgm_cloud is not None and mgm_cloud <= 1
):
wx_summary = "☀️ 晴"
elif cover_code == "FEW" or (
cover_code == "" and mgm_cloud is not None and mgm_cloud <= 2
):
wx_summary = "🌤️ 晴间少云"
elif cover_code == "SCT" or (
cover_code == "" and mgm_cloud is not None and mgm_cloud <= 4
):
wx_summary = "⛅ 晴间多云"
elif cover_code == "BKN" or (
cover_code == "" and mgm_cloud is not None and mgm_cloud <= 6
):
wx_summary = "🌥️ 多云"
elif cover_code == "OVC" or (
cover_code == "" and mgm_cloud is not None and mgm_cloud <= 8
):
wx_summary = "☁️ 阴天"
elif mgm_cloud is not None:
cloud_names = {
0: "☀️ 晴",
1: "🌤️ 晴",
2: "🌤️ 少云",
3: "⛅ 散云",
4: "⛅ 散云",
5: "🌥️ 多云",
6: "🌥️ 多云",
7: "☁️ 阴",
8: "☁️ 阴天",
}
wx_summary = cloud_names.get(mgm_cloud, "")
wx_display = f" {wx_summary}" if wx_summary else ""
msg_lines.append(
f"\n✈️ <b>实测 ({main_source}): {cur_temp}{temp_symbol}</b>{max_str} |{wx_display} | {obs_t_str}{age_tag}"
)
if mgm:
m_c = mgm.get("current", {})
# 翻译风向
wind_dir = m_c.get("wind_dir")
wind_speed_ms = m_c.get("wind_speed_ms")
dir_str = ""
if wind_dir is not None:
dirs = ["", "东北", "", "东南", "", "西南", "西", "西北"]
dir_str = dirs[int((float(wind_dir) + 22.5) % 360 / 45)] + ""
# 体感和湿度(跳过缺失数据)
feels_like = m_c.get("feels_like")
humidity = m_c.get("humidity")
if feels_like is not None or humidity is not None:
parts = []
if feels_like is not None:
parts.append(f"🌡️ 体感: {feels_like}°C")
# 针对安卡拉,补充市区(Center)实测值
ankara_center = next((s for s in weather_data.get("mgm_nearby", []) if "Bölge/Center" in s.get("name", "")), None)
if ankara_center:
parts.append(f"Ankara (Bölge/Center): <b>{ankara_center['temp']}°C</b>")
if humidity is not None:
parts.append(f"💧 {humidity}%")
msg_lines.append(f" [MGM] {' | '.join(parts)}")
# 风况(跳过缺失数据)
if wind_dir is not None and wind_speed_ms is not None:
msg_lines.append(
f" [MGM] 🌬️ {dir_str}{wind_dir}° ({wind_speed_ms} m/s) | 💧 降水: {m_c.get('rain_24h') or 0}mm"
)
# 新增:气压和云量
extra_parts = []
pressure = m_c.get("pressure")
if pressure is not None:
extra_parts.append(f"🌡 气压: {pressure}hPa")
cloud_cover = m_c.get("cloud_cover")
if cloud_cover is not None:
cloud_desc_map = {
0: "晴朗",
1: "少云",
2: "少云",
3: "散云",
4: "散云",
5: "多云",
6: "多云",
7: "很多云",
8: "阴天",
}
cloud_text = cloud_desc_map.get(cloud_cover, f"{cloud_cover}/8")
extra_parts.append(f"☁️ 云量: {cloud_text}({cloud_cover}/8)")
mgm_max = m_c.get("mgm_max_temp")
if mgm_max is not None:
extra_parts.append(f"🌡️ MGM最高: {mgm_max}°C")
if extra_parts:
msg_lines.append(f" [MGM] {' | '.join(extra_parts)}")
if metar:
m_c = metar.get("current", {})
wind = m_c.get("wind_speed_kt")
wind_dir = m_c.get("wind_dir")
vis = m_c.get("visibility_mi")
clouds = m_c.get("clouds", [])
cloud_desc = ""
if clouds:
c_map = {
"BKN": "多云",
"OVC": "阴天",
"FEW": "少云",
"SCT": "散云",
"SKC": "",
"CLR": "",
}
main = clouds[-1]
cloud_desc = f"☁️ {c_map.get(main.get('cover'), main.get('cover'))}"
prefix = "[METAR]" if mgm else " "
if not mgm:
msg_lines.append(
f" {prefix} 💨 {wind or 0}kt ({wind_dir or 0}°) | 👁️ {vis or 10}mi"
)
if cloud_desc:
msg_lines.append(
f" {prefix} {cloud_desc} | 👁️ {vis or 10}mi | 💨 {wind or 0}kt"
)
# --- 5. 态势特征提取 ---
feature_str, ai_context = analyze_weather_trend(
weather_data, temp_symbol, city_name
)
if feature_str:
# 仅将最核心的信息展示给用户作为"态势分析"
# 但后面会把更全的数据传给 AI
msg_lines.append("\n💡 <b>分析</b>:")
for line in feature_str.split("\n"):
if line.strip():
msg_lines.append(f"- {line.strip()}")
# --- 6. Groq AI 深度分析 ---
try:
from src.analysis.ai_analyzer import get_ai_analysis
# 构建更全的背景数据给 AI
# 补充多模型分歧
mm = weather_data.get("multi_model", {})
if mm.get("forecasts"):
mm_str = " | ".join(
[
f"{k}:{v}{temp_symbol}"
for k, v in mm["forecasts"].items()
if v
]
)
ai_context += f"\n模型分歧: {mm_str}"
ai_result = get_ai_analysis(ai_context, city_name, temp_symbol)
if ai_result:
msg_lines.append(f"\n{ai_result}")
except Exception as e:
logger.error(f"调用 Groq AI 分析失败: {e}")
msg_lines.append(f"\n💳 本次消耗 <b>{CITY_QUERY_COST}</b> 积分。")
bot.send_message(message.chat.id, "\n".join(msg_lines), parse_mode="HTML")
except Exception as e:
import traceback
logger.error(f"查询失败: {e}\n{traceback.format_exc()}")
bot.reply_to(message, f"❌ 查询失败: {e}")
@bot.message_handler(func=lambda message: True, content_types=['text'])
def track_activity(message):
"""全量监听消息,用于记录群内发言积分(非指令消息)"""
if message.text.startswith('/'):
return
if message.chat.type not in ("group", "supergroup"):
return
user = message.from_user
username = _display_name(user)
db.upsert_user(user.id, username)
result = db.add_message_activity(
user.id,
text=message.text,
points_to_add=MESSAGE_POINTS,
cooldown_sec=MESSAGE_COOLDOWN_SEC,
daily_cap=MESSAGE_DAILY_CAP,
min_text_length=MESSAGE_MIN_LENGTH,
)
if result.get("awarded"):
logger.info(
f"message points awarded user={user.id} points=+{MESSAGE_POINTS} "
f"daily_points={result.get('daily_points')}/{MESSAGE_DAILY_CAP}"
)
logger.info("🤖 Bot 启动中...")
bot.infinity_polling()
from src.bot.orchestrator import start_bot # noqa: E402
if __name__ == "__main__":
+93 -49
View File
@@ -1,53 +1,97 @@
# Weather API Configuration
weather:
meteoblue_api_key: null # Set via METEOBLUE_API_KEY env var
timeout: 30
# Target Cities
cities:
- id: "london"
city: "London"
country: "UK"
latitude: 51.5074
longitude: -0.1278
- id: "paris"
city: "Paris"
country: "France"
latitude: 48.8566
longitude: 2.3522
- id: "ankara"
city: "Ankara"
country: "Turkey"
latitude: 39.9334
longitude: 32.8597
- id: "new_york"
city: "New York"
country: "USA"
latitude: 40.7128
longitude: -74.0060
- id: "chicago"
city: "Chicago"
country: "USA"
latitude: 41.8781
longitude: -87.6298
- id: "lucknow"
city: "Lucknow"
country: "India"
latitude: 26.7606
longitude: 80.8893
- id: "sao paulo"
city: "São Paulo"
country: "Brazil"
latitude: -23.4356
longitude: -46.4731
- id: "munich"
city: "Munich"
country: "Germany"
latitude: 48.3538
longitude: 11.7861
# Logging
- id: london
city: London
country: UK
latitude: 51.5074
longitude: -0.1278
- id: paris
city: Paris
country: France
latitude: 48.8566
longitude: 2.3522
- id: ankara
city: Ankara
country: Turkey
latitude: 39.9334
longitude: 32.8597
- id: new_york
city: New York
country: USA
latitude: 40.7128
longitude: -74.006
- id: chicago
city: Chicago
country: USA
latitude: 41.8781
longitude: -87.6298
- id: lucknow
city: Lucknow
country: India
latitude: 26.7606
longitude: 80.8893
- id: sao paulo
city: São Paulo
country: Brazil
latitude: -23.4356
longitude: -46.4731
- id: munich
city: Munich
country: Germany
latitude: 48.3538
longitude: 11.7861
- id: hong_kong
city: Hong Kong
country: China
latitude: 22.3019
longitude: 114.1742
- id: shanghai
city: Shanghai
country: China
latitude: 31.1434
longitude: 121.8052
- id: singapore
city: Singapore
country: Singapore
latitude: 1.3644
longitude: 103.9915
- id: tokyo
city: Tokyo
country: Japan
latitude: 35.5523
longitude: 139.7798
- id: tel_aviv
city: Tel Aviv
country: Israel
latitude: 32.0114
longitude: 34.8867
- id: chengdu
city: Chengdu
country: China
latitude: 30.5785
longitude: 103.9471
- id: chongqing
city: Chongqing
country: China
latitude: 29.7196
longitude: 106.6416
- id: shenzhen
city: Shenzhen
country: China
latitude: 22.6393
longitude: 113.8107
- id: beijing
city: Beijing
country: China
latitude: 40.0801
longitude: 116.5846
- id: wuhan
city: Wuhan
country: China
latitude: 30.7838
longitude: 114.2081
logging:
level: "INFO"
rotation: "10 MB"
retention: "10 days"
level: INFO
rotation: 10 MB
retention: 10 days
+54
View File
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20 {
function transferFrom(address from, address to, uint256 value) external returns (bool);
}
contract PolyWeatherCheckout {
address public owner;
address public treasury;
mapping(address => bool) public allowedToken;
mapping(bytes32 => bool) public paidOrder;
event OrderPaid(
bytes32 indexed orderId,
address indexed payer,
uint256 indexed planId,
address token,
uint256 amount
);
modifier onlyOwner() {
require(msg.sender == owner, "ONLY_OWNER");
_;
}
constructor(address _token, address _treasury) {
require(_token != address(0) && _treasury != address(0), "ZERO_ADDR");
owner = msg.sender;
treasury = _treasury;
allowedToken[_token] = true;
}
function setTreasury(address _treasury) external onlyOwner {
require(_treasury != address(0), "ZERO_ADDR");
treasury = _treasury;
}
function setTokenAllowed(address token, bool allowed) external onlyOwner {
require(token != address(0), "ZERO_ADDR");
allowedToken[token] = allowed;
}
function pay(bytes32 orderId, uint256 planId, uint256 amount, address token) external {
require(allowedToken[token], "TOKEN_NOT_ALLOWED");
require(amount > 0, "AMOUNT_ZERO");
require(!paidOrder[orderId], "ORDER_PAID");
paidOrder[orderId] = true;
require(IERC20(token).transferFrom(msg.sender, treasury, amount), "TRANSFER_FAILED");
emit OrderPaid(orderId, msg.sender, planId, token, amount);
}
}
+267
View File
@@ -0,0 +1,267 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20 {
function transferFrom(address from, address to, uint256 value) external returns (bool);
function transfer(address to, uint256 value) external returns (bool);
}
library Address {
function functionCall(address target, bytes memory data, string memory errorMessage) internal returns (bytes memory) {
(bool success, bytes memory returndata) = target.call(data);
require(success, errorMessage);
return returndata;
}
}
library SafeERC20 {
using Address for address;
function safeTransferFrom(IERC20 token, address from, address to, uint256 value) internal {
bytes memory returndata = address(token).functionCall(
abi.encodeWithSelector(token.transferFrom.selector, from, to, value),
"SAFE_TRANSFER_FROM_FAILED"
);
if (returndata.length > 0) {
require(abi.decode(returndata, (bool)), "SAFE_TRANSFER_FROM_FALSE");
}
}
function safeTransfer(IERC20 token, address to, uint256 value) internal {
bytes memory returndata = address(token).functionCall(
abi.encodeWithSelector(token.transfer.selector, to, value),
"SAFE_TRANSFER_FAILED"
);
if (returndata.length > 0) {
require(abi.decode(returndata, (bool)), "SAFE_TRANSFER_FALSE");
}
}
}
abstract contract Ownable {
address public owner;
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
modifier onlyOwner() {
require(msg.sender == owner, "ONLY_OWNER");
_;
}
constructor(address initialOwner) {
require(initialOwner != address(0), "ZERO_OWNER");
owner = initialOwner;
emit OwnershipTransferred(address(0), initialOwner);
}
function transferOwnership(address newOwner) external onlyOwner {
require(newOwner != address(0), "ZERO_OWNER");
emit OwnershipTransferred(owner, newOwner);
owner = newOwner;
}
}
abstract contract Pausable {
bool public paused;
event Paused(address indexed account);
event Unpaused(address indexed account);
modifier whenNotPaused() {
require(!paused, "PAUSED");
_;
}
function _pause() internal {
require(!paused, "PAUSED");
paused = true;
emit Paused(msg.sender);
}
function _unpause() internal {
require(paused, "NOT_PAUSED");
paused = false;
emit Unpaused(msg.sender);
}
}
abstract contract ReentrancyGuard {
uint256 private _status = 1;
modifier nonReentrant() {
require(_status == 1, "REENTRANT");
_status = 2;
_;
_status = 1;
}
}
contract PolyWeatherCheckoutV2 is Ownable, Pausable, ReentrancyGuard {
using SafeERC20 for IERC20;
struct PlanConfig {
uint256 amount;
bool active;
}
bytes32 public constant AUTHORIZED_PAYMENT_TYPEHASH =
keccak256(
"AuthorizedPayment(bytes32 orderId,address payer,uint256 planId,address token,uint256 amount,uint256 nonce,uint256 deadline)"
);
bytes32 public immutable DOMAIN_SEPARATOR;
address public treasury;
address public signer;
mapping(address => bool) public allowedToken;
mapping(bytes32 => bool) public paidOrder;
mapping(uint256 => mapping(address => PlanConfig)) public planConfig;
mapping(address => uint256) public payerNonce;
event OrderPaid(
bytes32 indexed orderId,
address indexed payer,
uint256 indexed planId,
address token,
uint256 amount
);
event TreasuryUpdated(address indexed treasury);
event SignerUpdated(address indexed signer);
event TokenAllowedUpdated(address indexed token, bool allowed);
event PlanConfigured(uint256 indexed planId, address indexed token, uint256 amount, bool active);
constructor(address initialOwner, address initialTreasury, address initialSigner)
Ownable(initialOwner)
{
require(initialTreasury != address(0), "ZERO_TREASURY");
treasury = initialTreasury;
signer = initialSigner;
uint256 chainId;
assembly {
chainId := chainid()
}
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
),
keccak256(bytes("PolyWeatherCheckoutV2")),
keccak256(bytes("1")),
chainId,
address(this)
)
);
}
function setTreasury(address newTreasury) external onlyOwner {
require(newTreasury != address(0), "ZERO_ADDR");
treasury = newTreasury;
emit TreasuryUpdated(newTreasury);
}
function setSigner(address newSigner) external onlyOwner {
signer = newSigner;
emit SignerUpdated(newSigner);
}
function setTokenAllowed(address token, bool allowed) external onlyOwner {
require(token != address(0), "ZERO_ADDR");
allowedToken[token] = allowed;
emit TokenAllowedUpdated(token, allowed);
}
function setPlan(uint256 planId, address token, uint256 amount, bool active) external onlyOwner {
require(planId > 0, "PLAN_ZERO");
require(token != address(0), "ZERO_ADDR");
require(amount > 0 || !active, "AMOUNT_ZERO");
planConfig[planId][token] = PlanConfig({amount: amount, active: active});
emit PlanConfigured(planId, token, amount, active);
}
function pause() external onlyOwner {
_pause();
}
function unpause() external onlyOwner {
_unpause();
}
function payPlan(bytes32 orderId, uint256 planId, address token)
external
whenNotPaused
nonReentrant
{
require(allowedToken[token], "TOKEN_NOT_ALLOWED");
PlanConfig memory config = planConfig[planId][token];
require(config.active, "PLAN_NOT_ACTIVE");
require(config.amount > 0, "PLAN_AMOUNT_ZERO");
_collect(orderId, msg.sender, planId, token, config.amount);
}
function payAuthorized(
bytes32 orderId,
uint256 planId,
address token,
uint256 amount,
uint256 deadline,
bytes calldata signature
) external whenNotPaused nonReentrant {
require(allowedToken[token], "TOKEN_NOT_ALLOWED");
require(amount > 0, "AMOUNT_ZERO");
require(deadline >= block.timestamp, "AUTH_EXPIRED");
require(signer != address(0), "SIGNER_NOT_SET");
uint256 nonce = payerNonce[msg.sender];
bytes32 structHash = keccak256(
abi.encode(
AUTHORIZED_PAYMENT_TYPEHASH,
orderId,
msg.sender,
planId,
token,
amount,
nonce,
deadline
)
);
bytes32 digest = keccak256(
abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)
);
require(_recover(digest, signature) == signer, "BAD_SIGNATURE");
payerNonce[msg.sender] = nonce + 1;
_collect(orderId, msg.sender, planId, token, amount);
}
function rescueToken(address token, address to, uint256 amount) external onlyOwner nonReentrant {
require(token != address(0) && to != address(0), "ZERO_ADDR");
IERC20(token).safeTransfer(to, amount);
}
function _collect(bytes32 orderId, address payer, uint256 planId, address token, uint256 amount) internal {
require(!paidOrder[orderId], "ORDER_PAID");
paidOrder[orderId] = true;
IERC20(token).safeTransferFrom(payer, treasury, amount);
emit OrderPaid(orderId, payer, planId, token, amount);
}
function _recover(bytes32 digest, bytes calldata signature) internal pure returns (address) {
require(signature.length == 65, "BAD_SIG_LEN");
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := calldataload(signature.offset)
s := calldataload(add(signature.offset, 32))
v := byte(0, calldataload(add(signature.offset, 64)))
}
if (v < 27) {
v += 27;
}
require(v == 27 || v == 28, "BAD_SIG_V");
address recovered = ecrecover(digest, v, r, s);
require(recovered != address(0), "BAD_SIG");
return recovered;
}
}
Binary file not shown.
+123
View File
@@ -0,0 +1,123 @@
{"city": "ankara", "timestamp": "2026-03-20T12:00:00+03:00", "date": "2026-03-20", "temp_symbol": "°C", "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}, "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_version": "emos-20260320130245", "calibration_source": "artifacts/probability_calibration/default.json", "calibrated_mu": 15.1, "calibrated_sigma": 1.25}
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "hong kong", "timestamp": "2026-03-23T21:10:00+08:00", "date": "2026-03-23", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.6806250000000001, "deb_prediction": 25.2, "ensemble": {"p10": 26.3, "median": 26.4, "p90": 26.6}, "multi_model": {"Open-Meteo": 24.8, "HKO(港天文)": 27.0, "ECMWF": 25.4, "GFS": 25.1, "ICON": 24.8, "GEM": 25.3, "JMA": 23.6}, "max_so_far": 27.4, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "shenzhen", "timestamp": "2026-03-25T08:43:15.528748+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.18016764322916676, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 29.0, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "shenzhen", "timestamp": "2026-03-25T08:57:11.783182+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": 26.7, "raw_sigma": 0.18016764322916676, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 26.7, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 1.0}], "shadow_prob_snapshot": [{"v": 27, "p": 1.0}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 26.7, "calibrated_sigma": 0.24322631835937514}
{"city": "shenzhen", "timestamp": "2026-03-25T09:32:32+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.16637912326388898, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 28.9, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "shenzhen", "timestamp": "2026-03-25T10:02:35+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.15911458333333342, "deb_prediction": 28.2, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.5, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.5, "GEM": 30.7, "JMA": 26.2}, "max_so_far": 28.9, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
+11 -2
View File
@@ -6,8 +6,14 @@ services:
env_file:
- .env
volumes:
- ./data:/app/data # 挂载数据目录,确保历史数据持久化
# 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.
user: "${UID:-1000}:${GID:-1000}"
polyweather_web:
@@ -18,7 +24,10 @@ services:
env_file:
- .env
volumes:
- ./data:/app/data
# 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}"
+210 -209
View File
@@ -1,241 +1,242 @@
# PolyWeather API 接口文档 (v1.2)
# PolyWeather API 文档(v1.5.1
本文档说明当前 PolyWeather 后端实际提供的 HTTP API。后端由 `web/app.py` 提供,前端通过 Next.js BFF 路由代理访问这些接口。
最后更新:`2026-03-24`
---
本文档描述当前对外可用 API 口径(`web/app.py` + `web/routes.py` + `frontend/app/api/*`)。
## 1. 基础信息
- **本地 Base URL**: `http://127.0.0.1:8000`
- **生产 Base URL**: `http://<your-vps-ip>:8000` 或绑定后的 HTTPS API 域名
- **响应格式**: JSON
- **缓存策略**:
- 后端 `web/app.py` 内部分析缓存:默认 5 分钟(Ankara 为 60 秒)
- 前端城市详情缓存:5 分钟 TTL + revision 校验
- 前端手动刷新:强制 `force_refresh=true` 跳过缓存
- 后端直连:`http://127.0.0.1:8000`
- 前端 BFF`https://polyweather-pro.vercel.app/api/*`
- 返回格式:`application/json`
---
## 2. 请求链路
## 2. 接口列表
### 2.1 获取监控城市列表
- **URL**: `/api/cities`
- **Method**: `GET`
- **用途**: 返回首页左侧监控城市与地图 marker 的基础元数据。
**响应示例**
```json
{
"cities": [
{
"name": "ankara",
"display_name": "Ankara",
"lat": 40.1281,
"lon": 32.9951,
"risk_level": "medium",
"risk_emoji": "🟠",
"airport": "Esenboğa",
"icao": "LTAC",
"temp_unit": "celsius",
"is_major": true
}
]
}
```mermaid
flowchart LR
FE["Browser / Dashboard"] --> BFF["Next.js Route Handlers (/api/*)"]
BFF --> API["FastAPI (/web/app.py + /web/routes.py)"]
API --> WX["Weather Collector"]
API --> ANA["DEB + Trend + Probability + Market Scan"]
API --> PAY["Payment Intent + Event + Confirm Loops"]
API --> OBS["healthz / system status / metrics"]
```
### 2.2 获取城市实时分析
## 3. 天气分析接口
- **URL**: `/api/city/{name}`
- **Method**: `GET`
- **参数**:
- `name`: 城市名或别名,如 `ankara``new-york`
- `force_refresh` (可选): `true` 时跳过缓存
- **用途**: 右侧详情卡片、今日分析 modal、图表和周边站点的主数据接口。
| 接口 | 方法 | 用途 |
| :-- | :-- | :-- |
| `/api/cities` | GET | 监控城市列表 |
| `/api/city/{name}` | GET | 城市主分析 |
| `/api/city/{name}/summary` | GET | 轻量摘要 |
| `/api/city/{name}/detail` | GET | 聚合详情(含 market_scan |
| `/api/history/{name}` | GET | 历史对账 |
**当前核心字段**
### `GET /api/city/{name}/detail`
- `display_name`
- `local_time`
- `local_date`
- `temp_symbol`
- `risk`
- `current`
- `mgm`
- `mgm_nearby`
- `forecast`
- `multi_model`
- `deb`
- `ensemble`
- `probabilities`
- `trend`
- `metar_today_obs`
- `metar_recent_obs`
- `hourly`
- `hourly_next_48h`
- `source_forecasts`
- `multi_model_daily`
- `updated_at`
可选参数:
**说明**
- `force_refresh=true|false`
- `market_slug=<slug>`
- `target_date=YYYY-MM-DD`
- `current.raw_metar` 为 Aviation Weather 返回的原始报文字段
- `mgm` 仅在具备官方 MGM 覆盖的城市(如 Ankara)有效。
- `mgm_nearby` 为统一周边站点字段:
- AnkaraMGM 官方周边站
- 其他多数城市:METAR cluster
重点字段
### 2.3 获取历史对账数据
- `market_scan.available`
- `market_scan.signal_label`
- `market_scan.edge_percent`
- `market_scan.anchor_model / anchor_high / anchor_settlement`
- `market_scan.yes_buy / no_buy`
- `market_scan.primary_market.tradable`
- `peak.first_h / peak.last_h / peak.status`
- `vertical_profile_signal.heating_setup / suppression_risk / trigger_risk / mixing_strength`
- `taf.signal.peak_window / suppression_level / disruption_level / markers`
- **URL**: `/api/history/{name}`
- **Method**: `GET`
- **用途**: 历史对账弹窗与 `/deb` 指令的历史样本来源。
### `detail` 新增结构信号说明
**响应示例**
`/api/city/{name}/detail` 现在会返回一组更偏交易场景的结构字段:
```json
{
"history": [
{
"date": "2026-03-07",
"actual": 7.0,
"deb": 6.5,
"mu": 7.2,
"mgm": 8.0
}
]
}
#### 1. `peak`
- `first_h`:预计峰值窗口起始小时
- `last_h`:预计峰值窗口结束小时
- `status``before_peak | near_peak | after_peak`
这组字段用于让日内结构信号围绕真实峰值窗口分析,而不是固定只看下午。
#### 2. `vertical_profile_signal`
重点字段:
- `source`
- `window`
- `cape_max`
- `cin_min`
- `lifted_index_min`
- `boundary_layer_height_max`
- `shear_10m_180m_max`
- `suppression_risk`
- `trigger_risk`
- `mixing_strength`
- `shear_risk`
- `heating_setup`
- `heating_score`
- `summary_zh`
- `summary_en`
这组字段对应前端“高空结构信号 / Upper-Air Structure”卡片。
#### 3. `taf.signal`
仅对**非香港机场城市**启用。当前已支持解析:
- `FM`
- `TEMPO`
- `BECMG`
- `PROB30`
- `PROB40`
重点字段:
- `peak_window`
- `segments`
- `markers`
- `suppression_level`
- `disruption_level`
- `wind_shift`
- `summary_zh`
- `summary_en`
`markers` 会被前端温度走势图拿来做 `TAF 时段 / TAF Timing` 标记。
## 4. 鉴权与账户接口
| 接口 | 方法 | 用途 |
| :-- | :-- | :-- |
| `/api/auth/me` | GET | 当前登录态、积分、订阅状态 |
`/api/auth/me` 关键字段:
- `authenticated`
- `user_id`, `email`
- `points`, `weekly_points`, `weekly_rank`
- `subscription_active`, `subscription_plan_code`, `subscription_expires_at`
## 5. 支付接口
| 接口 | 方法 | 用途 |
| :-- | :-- | :-- |
| `/api/payments/config` | GET | 支付配置、代币列表、套餐、积分抵扣规则 |
| `/api/payments/runtime` | GET | 支付运行态、RPC 状态、event loop 状态、最近审计事件 |
| `/api/payments/wallets` | GET | 当前用户已绑定钱包 |
| `/api/payments/wallets/challenge` | POST | 获取绑定签名 challenge |
| `/api/payments/wallets/verify` | POST | 提交签名并绑定钱包 |
| `/api/payments/intents` | POST | 创建支付意图(intent |
| `/api/payments/intents/{intent_id}` | GET | 查询 intent 最新状态 |
| `/api/payments/intents/{intent_id}/submit` | POST | 提交交易哈希 |
| `/api/payments/intents/{intent_id}/confirm` | POST | 手动触发确认 |
| `/api/payments/reconcile-latest` | POST | 对当前登录用户最近一笔 intent 做恢复性确认 |
### 支付状态建议
前端流程建议:
1. `POST /intents`
2. 钱包发链上交易
3. `POST /submit`
4. `POST /confirm`
5. 若 pending,轮询 `GET /intents/{id}` 直到 `confirmed`
## 6. 运维与观测接口
| 接口 | 方法 | 用途 |
| :-- | :-- | :-- |
| `/healthz` | GET | 基础健康检查 |
| `/api/system/status` | GET | 系统状态、功能开关、rollout 状态、轻量指标摘要 |
| `/metrics` | GET | Prometheus 风格指标导出 |
`/api/system/status` 当前会包含:
- `features.state_storage_mode`
- `probability.decision`
- `probability.ready_for_primary`
- `metrics`
`/metrics` 当前会导出:
- `polyweather_http_requests_total`
- `polyweather_http_request_duration_ms_*`
- `polyweather_source_requests_total`
- `polyweather_source_request_duration_ms_*`
## 7. Ops 管理接口
这些接口主要给 `/ops` 管理后台使用,默认要求:
- 已登录
- 当前邮箱位于 `POLYWEATHER_OPS_ADMIN_EMAILS`
| 接口 | 方法 | 用途 |
| :-- | :-- | :-- |
| `/api/ops/users` | GET | 按 Telegram ID / 用户名 / 邮箱查询用户 |
| `/api/ops/leaderboard/weekly` | GET | 本周积分榜 |
| `/api/ops/memberships` | GET | 当前有效会员(已按用户去重,保留最晚到期) |
| `/api/ops/users/grant-points` | POST | 手动补分 |
| `/api/ops/payments/incidents` | GET | 支付异常单(仅 `payment_intent_failed` |
| `/api/ops/payments/incidents/{event_id}/resolve` | POST | 标记支付异常单已处理 |
`/api/ops/payments/incidents` 当前支持:
- `reason=<receiver_mismatch|sender_mismatch|event_mismatch|tx_reverted>`
- 默认不返回已标记处理的记录
- 重点用于排查“已付款未开通”“打到旧收款地址”等事故
## 8. 缓存策略(当前)
- `cities` / `summary` / `history`BFF 支持 `ETag + 304`
- `summary?force_refresh=true``Cache-Control: no-store`
- 详情接口与支付接口:`no-store`
- `METAR` / `TAF` / settlement current 由后端各自维护短 TTL 缓存
## 9. 调试示例
### 查询未来日期 market_scan
```bash
curl -s "http://127.0.0.1:8000/api/city/ankara/detail?force_refresh=true&target_date=2026-03-12"
```
**说明**
### 校验支付配置
- 网页端历史图默认展示近期样本,但统计口径只使用已结算日期。
- 当天未结算样本可用于可视化趋势,不计入胜率与 MAE。
```bash
curl -s http://127.0.0.1:8000/api/payments/config | python3 -m json.tool
```
### 2.4 获取城市摘要
### 查看支付运行态
- **URL**: `/api/city/{name}/summary`
- **Method**: `GET`
- **用途**: 轻量级温度摘要接口,用于首屏地图温度预热与低开销列表更新。
```bash
curl -s http://127.0.0.1:8000/api/payments/runtime | python3 -m json.tool
```
**字段**
### 查看支付异常单
- `name`
- `display_name`
- `icao`
- `local_time`
- `temp_symbol`
- `current.temp`
- `current.obs_time`
- `deb.prediction`
- `risk.level`
- `risk.warning`
- `updated_at`
```bash
curl -s "http://127.0.0.1:8000/api/ops/payments/incidents?reason=receiver_mismatch" | python3 -m json.tool
```
### 2.5 获取城市聚合详情
### 查看系统状态
- **URL**: `/api/city/{name}/detail`
- **Method**: `GET`
- **用途**: 面向后续商业化聚合视图的单请求聚合接口。
```bash
curl -s http://127.0.0.1:8000/api/system/status | python3 -m json.tool
```
**当前结构**
### 观察支付自动补单
- `overview`
- `official`
- `timeseries`
- `models`
- `probabilities`
- `market_scan`
- `risk`
- `ai_analysis`
```bash
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
```
**说明**
## 10. 开源口径说明
- 当前生产前端主链路仍以 `/api/city/{name}` + `/api/history/{name}` 为主
- `/api/city/{name}/detail` 已提供聚合结构,供后续产品层扩展接入。
对外公开文档仅覆盖通用 API 契约。生产商业策略参数不在公开文档披露
---
## 3. 核心对象定义
### 3.1 风险等级
- `low`: 低风险,模型与实测整体较一致
- `medium`: 中风险,存在一定分歧或站点偏置
- `high`: 高风险,模型冲突较大或盘面波动价值高
### 3.2 DEB
`DEB` 是 PolyWeather 的动态融合预测层,不是简单平均值。它会综合:
- 多模型预测值
- 近期表现
- 城市级偏差特征
- 实况修正上下文
### 3.3 μ
`μ` 表示当前结算概率分布中心(动态期望值),会随模型分歧与实况变化而更新。
它不应直接按固定 forecast 口径做静态历史对账。
---
## 4. 数据源与第三方 API
### 4.1 主观测源
- **Aviation Weather / METAR**
- 全球机场主观测源
- 同时提供结构化字段与原始 METAR 报文
### 4.2 Ankara 专属源
- **Turkish MGM**
- Ankara 官方增强层
- 含 `Ankara (Bölge/Center)` 与周边站点
### 4.3 预测源
- **Open-Meteo**
- **weather.gov**(美国城市)
- **Meteoblue**(部分城市)
- **多模型集成**: ECMWF / GFS / ICON / GEM / JMA
---
## 5. 当前口径说明
- 地图 marker 显示当前温度(首屏通过 `summary` 预热)。
- 点击城市后打开右侧详情卡片,保持当前布局与样式不变。
- “今日日内分析”在 modal 中展示:
- 今日温度走势(含 METAR 实测点)
- 结算概率分布
- 多模型预报
- 今日日内结构信号(规则引擎)
- AI 深度分析 + 0-2 小时临近判断
- modal 打开时地图停止动画;点击空白地图仅关闭右侧卡片,不重置视角。
---
## 6. 常见问题
- **接口 500**
- 先检查 `polyweather_web` 是否启动成功
- 再看 `docker-compose logs -f polyweather_web`
- **METAR 看起来慢几分钟**
- 常见原因是上游发布延迟,不一定是本地轮询问题
- 建议同时查看:
- `current.obs_time`
- `current.report_time`
- `current.receipt_time`
- **网页显示旧内容**
- 先确认 Vercel 已部署最新版本
- 再强刷浏览器缓存
- 如为详情数据,确认是否命中前端 5 分钟 TTL
---
**最后更新**: 2026-03-09
详见:[Open-Core 与商用边界](OPEN_CORE_POLICY.md)
+46 -75
View File
@@ -1,95 +1,66 @@
# 📈 Commercialization Roadmap
# 商业化说明(Production
> **Target**: Transforming PolyWeather for paid weather intelligence delivery.
最后更新:`2026-03-14`
---
## 1. 定位
## 🎯 Product Focus
PolyWeather 是面向温度结算场景的气象决策层,不是通用天气应用。
PolyWeather is positioned as a **premium intelligence service** for weather-driven prediction markets (**Polymarket**). The core differentiators remain **Ankara specialization**, **advection-aware signal logic**, and **DEB-weighted consensus**.
核心价值:
---
- 观测优先(METAR/MGM
- 结算导向(DEB + 概率桶)
- 市场映射(行情对照 + 错价雷达)
## 💰 Pricing & Monetization
## 2. 当前收费能力状态
| Tier | Price | Primary Value Proposition |
| :------------------- | :------------ | :------------------------------------------------------------ |
| **Telegram Channel** | **$1 / mo** | High-fidelity proactive alerts, low noise. |
| **Web Dashboard** | **$5 / mo** | Full multi-model context + historical DEB benchmarking. |
| **VIP Bundle** | **$5.5 / mo** | Unified access to dashboard + signal stream. |
| 能力 | 状态 | 备注 |
| :-- | :-- | :-- |
| 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 |
| 订阅套餐(Pro 月付) | 已上线 | `5 USDC / 30天` |
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U` |
| 合约支付 | 已上线 | PolygonUSDC + USDC.e |
| 支付自动确认 | 已上线 | Event Loop + Confirm Loop |
| 钱包绑定 | 已上线 | 浏览器钱包 + WalletConnect |
| 私有频道推送 | 已上线 | 可拆分业务频道 |
### 🛠️ Payment Infrastructure
## 3. 权限模型(当前)
- **Currency**: Polygon / USDC.
- **Method**: Phase-1 manual activation; Phase-2 automatic deposit detection and entitlement sync.
- 游客:可查看基础看板与简版信息。
- 登录用户:账户中心、钱包绑定、积分同步。
- Pro 用户:
- 今日日内深度分析(含高温时段)
- 历史对账 + 未来日期分析
- 全平台智能气象推送
---
## 4. 收费与积分规则(默认)
## 🗺️ Execution Roadmap
- 套餐:`pro_monthly`5 USDC / 30 天)
- 抵扣:500 积分抵 1 USDC,最高抵 3 USDC
- 实付下限:2 USDC(当积分满额时)
```mermaid
graph LR
P1[Phase 1: Manual Beta] --> P2[Phase 2: USDC Automation]
P2 --> P3[Phase 3: Scaling & Analytics]
> 说明:具体运营策略可按阶段调整,生产参数建议放私有仓库。
subgraph P1_Detail [Manual Operations]
P1 -->|DM Bot| Pay[Manual Payment]
Pay -->|Invite| Link[One-time Link]
end
## 5. 建议的开源边界
subgraph P2_Detail [Smart Automation]
P2 -->|Monitor| Chain[Polygon/USDC]
Chain -->|Auto| Access[JWT/Sub Activation]
end
```
请按 Open-Core 执行:
### 📦 Phase 1: Manual Beta
- 开源:基础能力与通用支付流程。
- 私有:商业风控、营销策略、关键运营参数、内部审计策略。
- **Goal**: Stabilize signal quality and convert initial paid users.
- **Actions**:
- Manual subscription activation via Telegram DM.
- Small paid Telegram channel for low-noise signal validation.
- Invite-based Web access while entitlement layer is being finalized.
- Keep Ankara as flagship strategy city for product credibility.
详见:[Open-Core 与商用边界](OPEN_CORE_POLICY.md)
### 🛠️ Phase 2: Automation (USDC)
## 6. 上线检查清单(收费前)
- **Goal**: Reduce operational friction and improve payment reliability.
- **Actions**:
- **On-chain monitoring**: Detect USDC deposits to dedicated addresses.
- **One-time Links**: Bot-generated invite links with strict member limits.
- **JWT Auth**: Subscriber-only access control for the Next.js frontend.
1. 支付链路:创建 intent、提交 tx、确认入账、订阅开通全链路可回放。
2. 权限链路:前端/后端/Bot 对 Pro 权限判定一致。
3. 审计能力:支付日志、订阅变更、异常重试可追溯。
4. 通知策略:支付成功私发、群内通知降噪。
5. 安全边界:敏感配置不进仓库。
### 🌐 Phase 3: Scaling & Analytics
## 7. 后续路线
- **Goal**: Improve retention and expand B2C/B2B utility.
- **Actions**:
- **Accuracy Leaderboard**: Monthly DEB vs settled-actual reports.
- **Self-Serve Portal**: Billing, subscription status, and alert preferences.
- **Usage Telemetry**: Feature-level analytics for conversion optimization.
### 📡 API Expansion Priority
- **P0-1 Market Layer**
- Polymarket Gamma discovery + `py-clob-client` pricing / order book
- **P0-2 Official Observation Layer**
- Aviation Weather / METAR
- weather.gov official forecast / observation / alert context
- **P1 Lead Layer**
- Ankara keeps Turkish MGM nearby network
- U.S. cities may later receive Mesonet enhancement without replacing METAR
- **P2 Product Layer**
- Stripe / Polygon-USDC automation
- Realtime entitlement sync and subscriber state management
---
## 🚧 Critical Constraints
- **Weather-First**: The product is built around physical weather shifts, not exchange-side execution tooling.
- **Quality > Quantity**: Alert fatigue directly harms retention; thresholds must favor actionable rarity.
- **UI Stability**: Commercial rollout assumes layout consistency; visual contract stays fixed while internals evolve.
---
**📅 Last Updated**: 2026-03-09
- 支持更多链和稳定币。
- 引入退款与工单后台。
- 建立周/月留存与付费转化看板。
- 打通渠道分销与邀请码返利系统。
+285
View File
@@ -0,0 +1,285 @@
# 配置与密钥管理(中文)
## 1. 目标
PolyWeather 的环境变量很多,但不是所有变量都属于同一层级。
当前推荐做法是把配置拆成三类:
1. 可复现基础配置
放在:[.env.example](/E:/web/PolyWeather/.env.example)
2. 敏感密钥模板
放在:[.env.secrets.example](/E:/web/PolyWeather/.env.secrets.example)
3. 平台侧真实密钥
放在:
- VPS / Docker `.env`
- Vercel Environment Variables
- GitHub Secrets(如需要)
## 2. 为什么要拆
如果把所有变量都平铺在一个 `.env` 里,会有三个问题:
1. 新环境很难知道“最小启动到底需要哪些变量”
2. 敏感密钥和普通开关混在一起,容易误泄露
3. 调优参数太多时,团队很难区分“必须填”和“保持默认即可”
所以正确做法不是“减少变量数量”,而是:
- 保留变量能力
- 按职责分层
- 给出最小启动路径
## 3. 文件职责
### 3.1 根 `.env.example`
文件:
- [.env.example](/E:/web/PolyWeather/.env.example)
用途:
- 后端 / Bot / Docker 的可复现配置模板
- 只放变量名、默认值、开关与非敏感示例
### 3.2 根 `.env.secrets.example`
文件:
- [.env.secrets.example](/E:/web/PolyWeather/.env.secrets.example)
用途:
- 只列敏感项
- 帮助运维明确哪些值必须从密钥系统注入
### 3.3 前端 `.env.example`
文件:
- [frontend/.env.example](/E:/web/PolyWeather/frontend/.env.example)
用途:
- 前端本地开发与 Vercel 环境变量模板
## 4. 配置分级
### 4.1 L1:最小启动必需项
这是“服务能跑起来”的最小集合。
后端 / Bot
- `TELEGRAM_BOT_TOKEN`
- `TELEGRAM_CHAT_ID`
- `POLYWEATHER_RUNTIME_DATA_DIR`
- `POLYWEATHER_DB_PATH`
- `POLYWEATHER_STATE_STORAGE_MODE`
前端:
- `POLYWEATHER_API_BASE_URL`
- `POLYWEATHER_OPS_ADMIN_EMAILS`(如果启用 `/ops` 页面级管理员守卫)
如果启用登录:
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- `SUPABASE_URL`
- `SUPABASE_ANON_KEY`
- `SUPABASE_SERVICE_ROLE_KEY`
### 4.2 L2:功能开关
这些变量一般不敏感,但会决定功能是否启用。
例如:
- `POLYWEATHER_AUTH_ENABLED`
- `POLYWEATHER_AUTH_REQUIRED`
- `POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION`
- `POLYWEATHER_OPS_ADMIN_EMAILS`
- `POLYWEATHER_STATE_STORAGE_MODE`
- `POLYWEATHER_PAYMENT_ENABLED`
- `POLYMARKET_MARKET_SCAN_ENABLED`
- `POLYGON_WALLET_WATCH_ENABLED`
- `POLYMARKET_WALLET_ACTIVITY_ENABLED`
### 4.3 L3:运行调优项
这些一般不需要在第一天就改。
例如:
- 各类 `*_TTL_SEC`
- 各类 `*_TIMEOUT_SEC`
- 各类 `*_COOLDOWN_SEC`
- 各类 `*_INTERVAL_SEC`
- `POLYWEATHER_PAYMENT_RPC_URLS`
- `TAF_CACHE_TTL_SEC`
策略:
- 先用默认值
- 出现性能或运维问题时再调
### 4.4 L4:敏感项
这些变量不应写进公开文档截图,也不应提交到仓库。
例如:
- `TELEGRAM_BOT_TOKEN`
- `SUPABASE_SERVICE_ROLE_KEY`
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
- `METEOBLUE_API_KEY`
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
- `POLYMARKET_SECRET_KEY`
## 5. 推荐部署矩阵
### 5.1 VPS / Docker(后端 + Bot
建议放这些:
- 根 `.env` 的后端项
- 所有 secrets
- Bot / 支付 / watcher 配置
### 5.2 Vercel(前端)
建议只放前端真正需要的变量:
- `POLYWEATHER_API_BASE_URL`
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- `POLYWEATHER_AUTH_ENABLED`
- `POLYWEATHER_AUTH_REQUIRED`
- `POLYWEATHER_OPS_ADMIN_EMAILS`
- `POLYWEATHER_DASHBOARD_ACCESS_TOKEN`
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`
- `NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL`
说明:
- `/ops` 现在是前后端双层限制:
- 前端页面入口读取 `POLYWEATHER_OPS_ADMIN_EMAILS`
- 后端写接口同样读取 `POLYWEATHER_OPS_ADMIN_EMAILS`
- 因此,Vercel 和 VPS / Docker 两侧都应配置相同的管理员邮箱白名单。
不要把后端专用密钥全搬进 Vercel。
### 5.3 GitHub Actions
当前 CI 不需要大规模 secrets。
如果未来要做自动部署,再考虑:
- `VERCEL_TOKEN`
- `VERCEL_ORG_ID`
- `VERCEL_PROJECT_ID`
## 6. 最小部署示例
### 6.1 前端最小变量
```env
POLYWEATHER_API_BASE_URL=https://your-backend.example.com
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
POLYWEATHER_AUTH_ENABLED=true
POLYWEATHER_AUTH_REQUIRED=true
```
### 6.2 后端最小变量
```env
TELEGRAM_BOT_TOKEN=...
TELEGRAM_CHAT_ID=...
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
POLYWEATHER_STATE_STORAGE_MODE=dual
UID=1000
GID=1000
POLYWEATHER_AUTH_ENABLED=true
POLYWEATHER_AUTH_REQUIRED=false
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
TAF_CACHE_TTL_SEC=900
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_ANON_KEY=...
SUPABASE_SERVICE_ROLE_KEY=...
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=...
```
说明:
- `UID` / `GID` 主要给 Linux Docker 主机用,避免容器把运行文件写成 root 所有。
- Windows / macOS 一般可以直接保留默认值。
- `POLYWEATHER_RUNTIME_DATA_DIR` 建议放在仓库外,例如 `/var/lib/polyweather`
- `docker-compose.yml` 会把这个目录同时挂载到容器内的 `/var/lib/polyweather``/app/data`,兼容现有缓存与 SQLite 路径。
- `POLYWEATHER_STATE_STORAGE_MODE` 当前推荐先用 `dual`,验证后再切 `sqlite`
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`
## 7. 当前建议的运维规则
### 7.1 仓库中允许存在
- `.env.example`
- `.env.secrets.example`
- `frontend/.env.example`
### 7.2 仓库中不应提交
- `.env`
- `.env.local`
- 任何带真实 token / key 的配置文件
### 7.3 截图与共享规则
以下值一旦出现在截图或聊天里,建议视为泄露并轮换:
- `SUPABASE_SERVICE_ROLE_KEY`
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN`
- `TELEGRAM_BOT_TOKEN`
- 第三方私有 API Key
## 8. 如何收口配置复杂度
如果你觉得变量仍然太多,正确的做法不是一刀删掉,而是:
1. 把“功能开关”和“调优参数”分开看
2. 保持 `.env.example` 中:
- 最小启动项
- 常用功能开关
- 默认调优值
3. 让不常改的高阶参数继续留默认
也就是说:
- 使用者只需要先关心 10-20 个关键变量
- 其余变量保持默认即可
## 9. 当前已经完成的配置治理
1. 根 `.env.example` 收口
2. `.env.secrets.example` 新增
3. 前端 `.env.example` 收口
4. 运行时配置校验脚本新增
5. `/ops` 管理员白名单与前后端职责边界已明确
5. 支付运行态与多 RPC 配置支持
6. 运行态 SQLite 迁移配置支持
## 10. 配置校验命令
在不启动服务的情况下,你可以直接检查配置:
```bash
python scripts/validate_runtime_env.py --component web
python scripts/validate_runtime_env.py --component bot
```
+311
View File
@@ -0,0 +1,311 @@
# EMOS 训练报告(2026-03-20
## 1. 报告目的
本文档用于记录当前 PolyWeather 概率校准引擎(EMOS)的训练结果、离线评估结果、线上 shadow 观测结果,以及是否具备切换为主路径的条件。
当前结论先写在前面:
- `EMOS` 已完成接入、训练、离线评估、shadow 落盘与滚动报表。
- 当前默认运行模式应继续保持 `emos_shadow`
- 现阶段 **不建议切换到 `emos_primary`**
## 2. 本次训练版本
- 校准版本:`emos-20260320130245`
- 训练时间:`2026-03-20T13:02:45.903772+00:00`
- 参数文件:[default.json](/E:/web/PolyWeather/artifacts/probability_calibration/default.json)
- 离线评估报告:[evaluation_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/evaluation_report.json)
- 线上 shadow 报表:[shadow_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/shadow_report.json)
## 3. 训练数据概况
### 3.1 数据来源
当前训练主要使用两类数据:
1. 项目历史日记录
文件:[daily_records.json](/E:/web/PolyWeather/data/daily_records.json)
2. 历史天气 CSV 构建出的结算标签
文件:[settlement_history.json](/E:/web/PolyWeather/artifacts/probability_calibration/settlement_history.json)
### 3.2 样本规模
- 总训练样本数:`105`
- 通过历史天气 CSV 补回的缺失 `actual_high``2`
- 历史结算标签覆盖城市数:`30`
说明:
- 当前样本已覆盖 30 个城市,但有效监督样本量仍偏小。
- 部分城市样本数只有 `2-7` 条,城市级参数容易波动。
## 4. 模型结构
### 4.1 当前实现
EMOS 属于统计后处理层,不是数值天气模型本身。当前结构位于:
- [probability_calibration.py](/E:/web/PolyWeather/src/analysis/probability_calibration.py)
当前目标是对原有概率引擎输出进行校准:
- 输入:`raw_mu``raw_sigma``DEB``ensemble median/spread``peak_status` 等特征
- 输出:校准后的 `mu / sigma / distribution`
### 4.2 当前运行模式
支持三种模式:
- `legacy`
- `emos_shadow`
- `emos_primary`
当前建议默认模式:
- `emos_shadow`
即:
- 对外仍展示 legacy 结果
- 后台并行计算 EMOS 结果
- 用于持续评估,不直接影响用户
## 5. 本次训练参数摘要
### 5.1 全局约束
本次训练已加入两类约束:
1. `sigma_constraints`
- `min_ratio = 0.85`
- `max_ratio = 1.35`
- `absolute_min = 0.25`
- `absolute_max = 3.0`
2. `selection_guardrails`
- `max_mae_increase = 0.02`
- `max_bucket_hit_drop = 0.01`
- `max_bucket_brier_increase = 0.05`
这两类约束的目的不是追求“更激进的拟合”,而是防止 EMOS 为了降低 CRPS 而把分布摊得过平,导致业务上更关键的顶桶命中和概率质量变差。
### 5.2 当前选中的 blending
本次训练产物中最终选择:
- `alpha_mu = 0.0`
- `alpha_sigma = 0.0`
含义是:
- 训练器在护栏约束下,没有找到足够安全的候选方案可以替代 legacy 主路径
- 因此当前正式选中的可用结果,本质上仍然锚定在 legacy
这是一种正确的保护行为,不是失败。说明门禁已经起作用,避免了坏校准进入主路径。
## 6. 离线评估结果
评估报告来源:
- [evaluation_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/evaluation_report.json)
### 6.1 总体结果
Legacy
- `mean_crps = 2.793938`
- `mean_mae = 2.721143`
- `bucket_hit_rate = 0.695238`
EMOS(强制 primary 评估):
- `mean_crps = 2.650216`
- `mean_mae = 2.722829`
- `bucket_hit_rate = 0.666667`
Delta
- `CRPS = -0.143722`
- `MAE = +0.001686`
- `bucket_hit_rate = -0.028571`
### 6.2 解读
这组结果说明:
1. `CRPS` 有改善
说明从“分布整体平滑度”角度看,EMOS 有一定价值。
2. `MAE` 基本持平但略差
不是大问题,但也不能算改善。
3. `bucket_hit_rate` 明显下降
这是当前最大阻塞项。对 PolyWeather 这种结算桶业务来说,顶桶命中率比单纯 CRPS 更关键。
因此,离线结论是:
- `EMOS` 有研究价值
- 但 **离线强切 primary 仍然不合格**
## 7. 线上 Shadow 观测结果
线上 shadow 报表来源:
- [shadow_report.json](/E:/web/PolyWeather/artifacts/probability_calibration/shadow_report.json)
### 7.1 总体结果
- `samples = 103`
- `legacy_mean_mae = 1.839223`
- `shadow_mean_mae = 1.851931`
- `delta_mae = +0.012708`
- `legacy_bucket_hit_rate = 0.669903`
- `shadow_bucket_hit_rate = 0.679612`
- `delta_bucket_hit_rate = +0.009709`
- `legacy_bucket_brier = 0.462814`
- `shadow_bucket_brier = 0.756649`
- `delta_bucket_brier = +0.293835`
### 7.2 解读
线上 shadow 结果和离线强制 primary 结果不完全相同,这是正常的。原因是:
- `shadow_report` 反映的是历史记录中实际落盘的 shadow 输出
- `evaluation_report` 反映的是离线脚本在强制 `emos_primary` 下重新计算的效果
当前线上 shadow 的含义是:
1. 顶桶命中率略有提升
`+0.97%`
2. 但 `MAE` 轻微变差
虽然幅度不大,但没有形成明确优势
3. `bucket_brier` 明显更差
说明 shadow 分布仍然偏“摊平”,概率质量不足
这是当前最重要的信号:
- EMOS 在“顶桶命中”上偶尔能赢
- 但在“概率质量”上还不够好
## 8. 城市级观察
从当前城市级结果看,EMOS 并不是“全城市统一改善”,而是明显分化:
### 8.1 相对改善较明显的城市
- `London`
- `Hong Kong`
- `Tokyo`
- `New York`
这些城市在部分指标上看到一定改善,说明当前校准特征在这些城市上更有效。
### 8.2 风险较高的城市
- `Atlanta`
- `Miami`
- `Chicago`
- `Dallas`
- `Seattle`
这些城市常见现象是:
- 顶桶命中没有显著提高
- 或 `bucket_brier` 明显恶化
- 或者 `MAE` 出现不必要抬升
这说明当前 EMOS 还没有形成稳定的全局校准能力,城市间异质性很强。
## 9. 当前判断
### 9.1 能不能上线为主路径
当前答案:
- **不能**
原因:
1. 离线强制 primary 时,`bucket_hit_rate` 下降
2. 线上 shadow 时,`bucket_brier` 明显变差
3. 样本量依然偏小,城市样本不均衡
4. 城市级表现分化明显
### 9.2 当前应该怎么运行
当前最合理的运行方式:
1. 保持 `emos_shadow`
2. 继续落盘 `shadow_prob_snapshot`
3. 继续维护滚动报表
4. 不修改机器人和网页的正式对外概率展示
## 10. 已完成的工程能力
目前已经具备以下能力:
1. 可离线训练
脚本:[fit_probability_calibration.py](/E:/web/PolyWeather/scripts/fit_probability_calibration.py)
2. 可离线评估
脚本:[evaluate_probability_calibration.py](/E:/web/PolyWeather/scripts/evaluate_probability_calibration.py)
3. 可导出训练样本
脚本:[export_probability_training_dataset.py](/E:/web/PolyWeather/scripts/export_probability_training_dataset.py)
4. 可历史回填 shadow 结果
脚本:[backfill_probability_shadow_history.py](/E:/web/PolyWeather/scripts/backfill_probability_shadow_history.py)
5. 可生成滚动 shadow 报表
脚本:[build_probability_shadow_report.py](/E:/web/PolyWeather/scripts/build_probability_shadow_report.py)
6. CI 已接入
包含 `ruff / pytest / frontend build / docker build workflow`
## 11. 下一步建议
### 11.1 必做
1. 扩大监督样本量
重点不是继续堆原始天气 CSV,而是补更多带 forecast snapshot 的历史样本。
2. 继续按版本沉淀训练报告
每次重训后都更新本报告或新增版本报告,避免只看单次结果。
3. 保持 `shadow` 连续观测
至少持续一段时间观察滚动指标是否稳定。
### 11.2 再做
1. 细分城市组建模
比如按气候区、结算规则、温度单位分组,而不是完全全局一套参数。
2. 优化训练目标
目前已经把 `bucket_brier` 纳入目标,但仍需进一步靠近 PolyWeather 的业务目标。
3. 补更严格的切换门槛
只有在同时满足以下条件时,才考虑切 `emos_primary`
- `CRPS` 下降
- `MAE` 不上升
- `bucket_hit_rate` 不下降
- `bucket_brier` 不上升
## 12. 结论
当前 EMOS 状态可以概括为:
- 工程上:已经完整接入,具备训练、评估、shadow 观测能力
- 模型上:有一定价值,但还不稳定
- 产品上:适合继续做 shadow,不适合切主路径
最终结论:
- **继续使用 `emos_shadow`**
- **暂不切 `emos_primary`**
- **继续积累样本并按版本跟踪训练结果**
+211
View File
@@ -0,0 +1,211 @@
# 前端部署配置(Vercel
本文只覆盖 `frontend` 目录对应的 Next.js 前端部署。
## 一、部署目标
推荐方案:
1. GitHub Actions 负责 `CI`
2. Vercel 负责前端 `CD`
3. FastAPI 后端单独部署在 VPS / Docker 主机
前端本身不直接访问天气源,而是通过 Next Route Handlers 转发到后端:
1. 浏览器 -> Vercel 上的 Next.js 前端
2. Next `/api/*` -> `POLYWEATHER_API_BASE_URL`
3. FastAPI 后端 -> 分析 / 支付 / 鉴权服务
## 二、Vercel 项目设置
在 Vercel 导入 GitHub 仓库后,使用下面的设置:
- Framework Preset: `Next.js`
- Root Directory: `frontend`
- Build Command: `npm run build`
- Install Command: `npm install`
如果仓库已经连接过 Vercel,通常只需要确认 `Root Directory` 仍然是 `frontend`
## 三、最小必填环境变量
只部署天气看板和基础登录时,先填下面 4 项:
```env
POLYWEATHER_API_BASE_URL=https://<your-fastapi-host>
NEXT_PUBLIC_SUPABASE_URL=https://<your-project>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<your-anon-key>
POLYWEATHER_AUTH_ENABLED=true
```
建议显式补:
```env
POLYWEATHER_AUTH_REQUIRED=true
```
说明:
- `POLYWEATHER_API_BASE_URL`:前端所有 `/api/*` Route Handler 转发时依赖它,没填会直接返回 500。
- `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_ANON_KEY`Supabase 客户端依赖它们。
- `POLYWEATHER_AUTH_ENABLED`:关闭时,前端不会启用登录能力。
- `POLYWEATHER_AUTH_REQUIRED`:控制 middleware 是否强制登录。
## 四、按功能启用的可选环境变量
### 1. 分享式看板
```env
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
```
设置后,可通过 `/?access_token=<token>` 打开带令牌的看板入口。
### 2. 前后端 entitlement 校验
```env
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
```
仅当后端开启 entitlement / 订阅校验时需要。
### 3. 钱包支付
```env
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
```
如果不启用钱包支付,可以留空。
### 4. `/ops` 管理员页面守卫
```env
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
```
说明:
- `/ops` 现在不是只有后端接口限制,前端页面入口也会读取管理员邮箱白名单。
- 因此前端部署到 Vercel 时,也应配置 `POLYWEATHER_OPS_ADMIN_EMAILS`
### 5. Telegram 入口
```env
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
```
只影响按钮跳转,不影响核心页面加载。
## 五、支付配置与旧部署治理
支付区现在有一层额外防护:
1. 用户点击支付前,前端会重新请求 `/api/payments/config`
2. 若发现 `receiver_contract` 与页面旧状态不一致,会自动切换到最新地址
3. 若后端返回的 `tx_payload.to` 与最新 `receiver_contract` 不一致,会直接阻断支付
这层防护的目的,是降低以下事故概率:
- 用户使用长期未刷新的旧标签页
- 命中旧 deployment URL
- 页面本地状态残留旧收款地址
如果你变更过支付收款地址,建议同步执行:
1. 在 Vercel 对当前 production 做一次 redeploy
2. 删除明显过期、可能还带旧支付配置的旧 deployment
3. 在 `Settings -> Security -> Deployment Retention Policy` 中收紧旧部署保留周期
## 六、推荐的三套配置口径
### 1. 公开游客模式
```env
POLYWEATHER_API_BASE_URL=https://api.example.com
POLYWEATHER_AUTH_ENABLED=false
POLYWEATHER_AUTH_REQUIRED=false
```
适合公开演示站。
### 2. 正常登录模式
```env
POLYWEATHER_API_BASE_URL=https://api.example.com
NEXT_PUBLIC_SUPABASE_URL=https://<project>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key>
POLYWEATHER_AUTH_ENABLED=true
POLYWEATHER_AUTH_REQUIRED=true
```
适合正式前端站点。
### 3. 登录 + entitlement 联动
```env
POLYWEATHER_API_BASE_URL=https://api.example.com
NEXT_PUBLIC_SUPABASE_URL=https://<project>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key>
POLYWEATHER_AUTH_ENABLED=true
POLYWEATHER_AUTH_REQUIRED=true
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=<shared-token>
```
适合前后端都启用了会员/订阅保护的生产环境。
## 七、不要放进 Vercel 的变量
这些属于后端私密配置,不应该放到前端项目:
- `SUPABASE_SERVICE_ROLE_KEY`
- `TELEGRAM_BOT_TOKEN`
- `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN` 以外的后端 secret
- 支付签名私钥 / 交易私钥 / 任何 bot 凭据
特别注意:
- `NEXT_PUBLIC_*` 会暴露给浏览器
- 只有明确允许前端公开使用的值,才应加 `NEXT_PUBLIC_`
## 八、上线前检查
Vercel 部署前至少确认:
1. `POLYWEATHER_API_BASE_URL` 指向可访问的后端生产地址
2. `frontend/.env.example` 和 Vercel Project Settings 中的实际值一致
3. GitHub Actions 中 `frontend-quality` 已通过
4. 如果启用鉴权,Supabase redirect URL 已包含前端域名
5. `GET /api/payments/config` 返回的是当前最新地址,而不是旧收款合约
6. 如果启用了 `/ops`,确认 `POLYWEATHER_OPS_ADMIN_EMAILS` 已在 Vercel 与后端同时配置
## 九、常见问题
### 1. 页面打开后 API 全部 500
先检查:
```env
POLYWEATHER_API_BASE_URL
```
这是最常见原因。
### 2. Vercel 构建通过,但登录失败
先检查:
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- Supabase 项目里的站点 URL / redirect URL
### 3. 钱包入口显示未配置
先检查:
```env
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
```
这是钱包连接的必需项。
+63
View File
@@ -0,0 +1,63 @@
# Open-Core 与商用边界
最后更新:`2026-03-14`
## 1. 目标
在保持社区可用性的前提下,保护商业化阶段的核心经营资产。
## 2. 仓库公开范围(可开源)
- 天气数据采集与标准化(METAR / Open-Meteo / MGM 接口层)。
- DEB 与基础趋势分析、概率桶计算。
- Dashboard 基础体验与 API/BFF 结构。
- Telegram Bot 基础命令与基础积分机制。
- 合约支付标准流程(钱包绑定、intent、提交、确认、补单)。
## 3. 生产私有范围(建议不公开)
- 商业风控参数与规则库:
- 错价信号阈值组合、推送阈值、异常检测策略。
- 运营策略资产:
- 用户分层规则、促销规则、留存策略、活动模板。
- 付费系统敏感细节:
- 实时对账容错阈值、退款审计策略、内部财务映射规则。
- 私有运维资产:
- 生产告警路由、内部频道映射、应急脚本与排障手册。
## 4. 配置与数据安全红线
- 不提交:`.env`、私钥、API key、机器人 token。
- 不提交:生产数据库、运行时状态文件、支付流水快照。
- 不提交:用户身份信息、钱包映射、订阅原始审计日志。
## 5. 推荐发布模式
### 5.1 Community Edition(开源)
- 提供基础分析与基础看板。
- 可选保留只读市场扫描。
- 默认关闭商业化运营规则。
### 5.2 Production Edition(私有)
- 启用收费、订阅、积分抵扣、风控、私有监控。
- 仅在私有仓库维护运营策略与敏感参数。
## 6. 文档口径规范
对外文档仅描述:
- 能力边界与使用方式。
- 可公开的技术架构。
- 不包含可被直接复刻的商业参数。
不对外文档描述:
- 具体策略阈值、用户分层细则、收益归因规则。
## 7. 许可证与法务建议(简版)
- 建议保持仓库代码许可证与商标/品牌授权分离。
- 若提供商业服务,建议在官网补充服务条款与隐私政策。
- 对“订阅权益”与“可用性”做明确 SLA 与免责边界。
+104
View File
@@ -0,0 +1,104 @@
# Ops 运营后台说明
最后更新:`2026-03-21`
## 1. 入口
前端入口:
- `https://polyweather-pro.vercel.app/ops`
## 2. 权限
`/ops` 的写接口由后端白名单控制:
```env
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
```
可配置多个邮箱,逗号分隔。
## 3. 当前能力
### 只读能力
- 系统健康
- SQLite / rollout / metrics 摘要
- 支付运行态
- 当前会员
- 周榜
- 支付异常单
### 写能力
- 手动补分
- 标记支付异常单“已处理”
## 4. 当前会员
会员列表来自:
1. `subscriptions` 中的有效订阅
2. 本地 `users` / `supabase_bindings`
3. 若本地缺邮箱或注册时间,再回补 Supabase Auth 用户信息
去重规则:
- 同一个 `user_id` 只保留最晚到期那条
## 5. 支付异常单
当前异常单来源:
- `payment_audit_events`
- 仅筛 `payment_intent_failed`
当前支持的典型失败原因:
- `receiver_mismatch`
- `sender_mismatch`
- `event_mismatch`
- `tx_reverted`
默认只显示未处理项。
## 6. 典型处理流程
### 6.1 钱已到账但没开订阅
先看 `/ops` 的支付异常单:
- 如果是 `receiver_mismatch`
- 优先判定为支付打到了旧收款地址
- 不是缓存问题
然后执行:
1. 查 `payment_intents`
2. 查 `payment_transactions`
3. 查 `subscriptions`
4. 跑恢复脚本:
```bash
python scripts/reconcile_subscription_by_email.py --email <user_email>
```
如果仍然失败,再人工补订阅。
### 6.2 已人工处理
`/ops` 里直接点:
- `标记已处理`
这不会删除审计事件,只会给原事件写:
- `resolved_at`
- `resolved_by`
## 7. 备注
`/ops` 是运营后台最小版,不是完整 Admin 平台。当前目标是:
- 让会员、积分、支付事故、系统状态可查
- 让常见人工操作不必再直接写 SQL
+324
View File
@@ -0,0 +1,324 @@
# 概率训练样本归档说明(中文)
## 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`
- 当天最终 `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_version": "emos-20260320130245"
}
```
当天结束后,再由后处理脚本回填:
- `actual_high`
- `settlement_bucket`
## 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
```bash
python scripts/fit_probability_calibration.py
```
作用:
- 生成新的 [default.json](/E:/web/PolyWeather/artifacts/probability_calibration/default.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. 推荐的一整套重训流程
如果过了十天、半个月,想重新训练一次,建议按这个顺序执行:
```bash
python scripts/build_settlement_history_from_csv.py
python scripts/export_probability_training_dataset.py
python scripts/fit_probability_calibration.py
python scripts/evaluate_probability_calibration.py
python scripts/backfill_probability_shadow_history.py
python scripts/build_probability_shadow_report.py
```
如果历史天气 CSV 还没补全,再先执行:
```bash
python scripts/backfill_historical_weather.py
```
## 9. 怎么判断这次训练有没有进步
重训后,不要只看一个指标。
至少看这 4 个:
1. `CRPS`
- 越低越好
2. `MAE`
- 越低越好
- 至少不要明显变差
3. `Bucket Hit Rate`
- 越高越好
- 这是业务上非常关键的指标
4. `Bucket Brier`
- 越低越好
- 反映概率分布质量
只有同时满足下面条件,才可以说训练效果真的进步:
- `CRPS` 下降
- `MAE` 不上升
- `Bucket Hit Rate` 不下降
- `Bucket Brier` 不上升
## 10. 当前最重要的现实判断
过去的“完整历史预测记录”通常没法完全补出来,除非:
1. 你之前就存过
2. 你接入了支持 forecast archive 的商业数据源
所以现实里最重要的不是“把过去全补齐”,而是:
- 从现在开始系统化归档
- 每天稳定沉淀可训练样本
- 定期离线重训
## 11. 推荐的下一步
最值得做的改造是:
1. 新增 `probability_training_snapshots.jsonl`
2. 每次分析时自动追加一条快照
3. 当天结束后自动回填 `actual_high`
4. 每 1-2 周重新训练一次
## 12. 总结
如果只记住一句话,就记这个:
**EMOS 要想越训越好,关键不是多下载一点历史天气,而是持续保存“当时系统看到的预测快照”。**
+122
View File
@@ -0,0 +1,122 @@
# Supabase + 登录 + 支付接入说明(v1.5.1)
最后更新:`2026-03-14`
## 1. 目标
- 前端支持 Google 一键登录 + 邮箱注册/登录。
- 后端支持 Supabase JWT 鉴权。
- 支持 Polygon 合约支付(USDC / USDC.e)并自动确认开通订阅。
## 2. Supabase 控制台配置
1. `Auth -> Providers` 打开 `Google``Email`
2. Google Cloud OAuth 回调配置:
- `https://<project-ref>.supabase.co/auth/v1/callback`
3. `Auth -> URL Configuration` 添加:
- 站点 URL(生产域名)
- 回调 URL(例如 `https://polyweather-pro.vercel.app/auth/callback`
## 3. 数据库脚本
在 Supabase SQL Editor 执行:
- `scripts/supabase/schema.sql`
会创建支付与订阅相关表:
- `subscriptions`
- `payments`
- `entitlement_events`
- `user_wallets`
- `wallet_link_challenges`
- `payment_intents`
- `payment_transactions`
## 4. 环境变量
### 4.1 前端(Vercel / frontend/.env.local
```env
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
POLYWEATHER_AUTH_ENABLED=true
POLYWEATHER_AUTH_REQUIRED=false
POLYWEATHER_API_BASE_URL=http://<backend-host>:8000
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
# WalletConnect(支持手机钱包扫码)
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
# Overlay 跳转
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/<your_group>
```
### 4.2 后端 / Bot.env
```env
POLYWEATHER_AUTH_ENABLED=true
POLYWEATHER_AUTH_REQUIRED=false
POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=false
SUPABASE_URL=
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
SUPABASE_HTTP_TIMEOUT_SEC=8
POLYWEATHER_PAYMENT_ENABLED=true
POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-bor-rpc.publicnode.com
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<receiver_contract>
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
# 支持双币种(示例)
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_e","symbol":"USDC.e","name":"USDC.e (PoS)","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0x<receiver>","is_default":true},{"code":"usdc","symbol":"USDC","name":"Native USDC","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x<receiver>"}]
# 套餐(当前只保留月付)
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON={"pro_monthly":{"plan_id":101,"amount_usdc":"5","duration_days":30}}
POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly
# 积分抵扣
POLYWEATHER_PAYMENT_POINTS_ENABLED=true
POLYWEATHER_PAYMENT_POINTS_PER_USDC=500
POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC=3
# 支付自动补单
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. 验证步骤
1. 登录后请求 `/api/auth/me`,确认 `authenticated=true`
2. 请求 `/api/payments/config`,确认 `enabled=true``configured=true`
3. 钱包绑定:
- `POST /api/payments/wallets/challenge`
- `POST /api/payments/wallets/verify`
4. 支付流程:
- `POST /api/payments/intents`
- 发链上交易
- `POST /api/payments/intents/{id}/submit`
- `POST /api/payments/intents/{id}/confirm`
5. 若前端显示 pending,轮询:
- `GET /api/payments/intents/{id}`
6. 确认订阅:`/api/auth/me` 返回 `subscription_active=true`
+286
View File
@@ -0,0 +1,286 @@
# PolyWeather TAF 信号说明(TAF_SIGNAL_ZH
本文档说明 PolyWeather 当前如何把 `TAF`(机场终端预报)接入“今日日内分析”,以及这些信号在交易判断里到底代表什么。
本文档只描述**当前实现**,不夸大、不脑补。
---
## 一、TAF 在项目中的定位
在 PolyWeather 里,`TAF` 不是主温度模型,也不是结算源。
它的定位是:
1. **机场侧确认层**
- 用来补充说明机场在峰值窗口附近会不会出现云、雨、雷暴或风向切换。
2. **压温 / 扰动风险提示层**
- 用来判断“机场最高温是否可能因为天气扰动被压低”。
3. **走势图时间轴联动层**
- 用来在温度走势图上标出 `FM / TEMPO / BECMG / PROB30/40` 对应的时段。
它**不负责**
1. 直接提供多模型最高温数值
2. 替代 `DEB`
3. 替代机场实况 `METAR`
4. 替代官方结算源
---
## 二、后端当前怎么解析 TAF
后端入口在:
- [web/analysis_service.py](/E:/web/PolyWeather/web/analysis_service.py)
核心函数:
- `_build_taf_signal(...)`
当前会解析这些时间片:
1. `BASE`
2. `FM`
3. `TEMPO`
4. `BECMG`
5. `PROB30`
6. `PROB40`
7. `PROB30 TEMPO`
8. `PROB40 TEMPO`
并且只聚焦于:
- **峰值窗口前后**
当前窗口定义是:
- `peak.first_h - 2h`
- 到
- `peak.last_h + 1h`
也就是说,TAF 不是整段报文全量平铺,而是会优先关注**和今天高温兑现最相关的时段**。
---
## 三、当前后端真正产出的核心字段
### 1. `suppression_level`
表示机场端的**压温风险等级**。
当前逻辑来自:
1. 降水 / 雷暴关键词
- `TSRA`
- `TS`
- `VCTS`
- `SHRA`
- `SHSN`
- `SHGS`
- `RA`
- `DZ`
- `SN`
2. 低云底
- 只对 `BKN / OVC` 生效
- 如果最低云底 `<= 4000 ft`
- 会把原本 `low` 的压温风险至少抬到 `medium`
注意:
- 不是所有 `FEW / SCT / BKN / OVC` 都会直接把风险打到 `high`
- 当前实现里,**低云主要是把风险从 `low` 抬到 `medium`**
- 真正更容易触发 `high` 的,还是阵雨 / 雷暴类关键词
### 2. `disruption_level`
表示峰值窗口附近的**扰动程度**。
当前逻辑:
1. 这些时间片会至少把扰动抬到 `medium`
- `TEMPO`
- `BECMG`
- `PROB30`
- `PROB40`
- `PROB30 TEMPO`
- `PROB40 TEMPO`
2. 这些情况会把扰动抬到 `high`
- `PROB30 TEMPO`
- `PROB40 TEMPO`
- 或者该时段本身就出现强降水/雷暴类关键词
注意:
- **`TEMPO` 本身不等于 `high`**
- **`PROB40` 也不等于“确定发生”**
---
## 四、前端怎么展示
前端主要在:
- [frontend/components/dashboard/FutureForecastModal.tsx](/E:/web/PolyWeather/frontend/components/dashboard/FutureForecastModal.tsx)
- [frontend/lib/dashboard-utils.ts](/E:/web/PolyWeather/frontend/lib/dashboard-utils.ts)
当前会通过三种方式展示 TAF
### 1. 图表时间轴标记
在日内温度走势图上,当前会显示:
- `TAF 时段 / TAF Timing`
tooltip 会显示该时段摘要,例如:
- `基础时段 13:00-19:00 以稳定为主`
- `明确切换 15:00-21:00 以稳定为主`
- `临时波动 14:00-17:00 有云雨扰动`
### 2. 今日日内结构信号里的 `机场预报`
会显示类似:
- `防压温`
- `看云雨`
- `暂稳`
### 3. 顶部摘要与交易动作
系统会把:
1. 近地面结构
2. 高空结构
3. `TAF`
4. `market_signal / edge_percent / bucket crowding`
合并成更贴近交易的提示,例如:
- `偏暖侧`
- `偏谨慎`
- `先观察`
---
## 五、TAF 关键词当前在项目里的真实含义
| TAF 关键词 | 当前展示词 | 当前项目含义 |
| :-- | :-- | :-- |
| `BASE` | 基础时段 | 在第一个显式变化组出现前的默认背景天气段 |
| `FM` | 明确切换 | 从某个明确时刻开始,机场预报进入一套新的天气阶段 |
| `TEMPO` | 临时波动 | 一段短时、非整段主导的扰动窗口 |
| `BECMG` | 逐步转变 | 天气在该窗口内逐步过渡,不是立刻硬切 |
| `PROB30/40` | 30% / 40% 风险窗 | 有概率触发的扰动窗口,不等于确定发生 |
注意:
- `FM` 不是由 `valid_match` 触发,它是按 `FMddhhmm` 独立解析出来的明确切换段
- `PROB40` 不是“确定性信号”,仍然只是概率窗口
---
## 六、怎么理解“机场端压温风险偏高”
这句话的意思不是:
- 城区一定更冷
- 一定会结算更低
真正意思是:
**在机场这个结算相关站点上,峰值窗口附近更容易因为云、阵雨或雷暴,导致最终最高温冲不上去。**
对于很多按机场报文或机场相关站点结算的市场,这一点很关键。
一句话:
- `TAF` 提示压温高
- 不代表一定下雨
- 但代表“机场高温可能被压低”的概率更值得防
---
## 七、怎么和图表一起看
### 情况 A:模型还偏热,但 TAF 给出压温高
这表示:
1. 数值模型仍给出较高高温
2. 但机场端预报提示云雨/雷暴会打断峰值兑现
这种情况下,更适合理解成:
- **机场侧高温兑现有风险**
- 追更高温区间要谨慎
### 情况 B:TAF 没有新增压温,但总判断仍偏降温
这表示:
1. `TAF` 没有提供新的云雨压温利空
2. 但近地面窗口本身已经在走弱
比如:
- 温度走弱
- 风场切换
- 气压回升
- 露点回落
所以:
- `TAF 无压温`
- **不等于**
- `一定继续升温`
当前系统已经会在摘要里把这层关系解释清楚。
---
## 八、当前实现边界
这套 `TAF` 逻辑当前是**交易导向的轻量解码**,不是完整航空专业解码器。
当前做得到:
1. 识别主要时间片
2. 找出峰值窗口附近的扰动
3. 识别云雨压温和风向切换
4. 联动图表时间轴
5. 联动交易动作
当前还没有做:
1. 对全部 TAF 语法做完整航空级严格解释
2. 对每个时段都做完整的逐字段人工预报学解释
3. 把 TAF 当成温度主预测模型
---
## 九、产品口径总结
最重要的一句:
**在 PolyWeather 里,TAF 是“机场侧扰动确认层”,不是主模型,也不是结算源。**
它最值钱的地方是:
- 帮你判断峰值窗口附近,机场高温会不会被云雨、雷暴、低云或风向切换打断。
对交易来说,更适合把它理解成:
- “机场这边有没有额外的压温风险”
而不是:
- “TAF 直接告诉我今天结算温度是多少”
---
_PolyWeather 文档中心_
+68 -53
View File
@@ -1,69 +1,84 @@
# 🛠️ Technical Debt & Engineering Backlog
# 技术债与工程待办(v1.5.1
> **Vision**: Moving from a research script to a production SaaS.
最后更新:`2026-03-20`
---
目标:在收费上线后,优先保证状态一致性、支付可靠性、可观测性和概率引擎发布可控。
## 🏛️ System Health: 82%
## 1. 债务快照
当前估计:**95% 稳定 / 5% 技术债**。
```mermaid
pie title System Health & Tech Debt
"Stable Engine" : 82
"Entitlement/Payments Debt" : 8
"Test/Replay Debt" : 6
"Observability Debt" : 4
flowchart TD
A["技术债"]
subgraph P["支付与订阅"]
P1["合约 V2 升级(SafeERC20 / Pausable"]
P2["退款与工单流程"]
P3["多 RPC 与链上对账面板"]
end
subgraph E["权限与运营"]
E1["前后端/Bot 权限矩阵回归"]
E2["积分来源明细与补分审计"]
end
subgraph O["可观测性"]
O1["外部监控抓取与告警阈值"]
O2["业务监控看板"]
end
subgraph S["状态与概率"]
S1["SQLite dual -> sqlite 切换验收"]
S2["EMOS shadow -> primary 门禁稳定化"]
end
A --> P
A --> E
A --> O
A --> S
```
The core weather engine and React dashboard runtime are now stable, but product-layer infrastructure debt is still material.
## 2. 近期已关闭
### Current Stable Modules
- 支付主链路已上线(intent -> submit -> confirm)。
- 支付自动补单已上线(Event Loop + Confirm Loop)。
- 支付事件重放脚本已补齐。
- 支付运行态 API 与 SQLite 审计事件已补齐。
- 钱包绑定支持浏览器钱包 + WalletConnect。
- 账户中心与 Pro 权限展示链路打通。
- 钱包异动支持独立频道路由。
- 运行态状态/缓存已支持 SQLite 渐进迁移。
- 轻量可观测性已上线(`/healthz``/api/system/status``/metrics`)。
- EMOS/CRPS 校准链路已上线 shadow 模式。
- [x] Multi-source Weather Aggregation
- [x] DEB Blending Algorithm
- [x] Proactive Telegram Alert Engine
- [x] Vercel Dashboard Infrastructure
- [x] React component-driven dashboard runtime (replacing legacy `public/static/app.js` rendering path)
## 3. 高优先级技术债
---
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| SQLite 主读切换验收 | 仍处于 dual 过渡期 | 线上跑满 24-48 小时后切到 `sqlite` |
| EMOS 上线门禁 | 当前 `hold`,不能切 primary | 继续积累样本,重点压 `bucket_brier` |
| 外部监控与告警 | 只有轻量指标,无外部抓取 | 接 Prometheus/Grafana 或最小巡检 |
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
## 🔴 High Priority: Immediate Focus
## 4. 中优先级技术债
| Debt Item | Impact | Suggested Fix |
| :--------------------- | :-------------------------------------------------- | :--------------------------------------------------------------- |
| **Monolithic Bot** | `bot_listener.py` is hard to test and evolve. | Isolate UI interaction from business logic into `src/analysis`. |
| **Subscription Store** | No persistent record of who has paid. | Migrate from in-memory user checks to **Supabase/PostgreSQL**. |
| **Alert Transparency** | Operators cannot easily audit "why" an alert fired. | Add an `Evidence` metadata block to all internal alert payloads. |
| **Entitlement Guard** | Dashboard routes are public by default. | Add JWT/session gating in Next.js middleware + backend checks. |
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/奖励/手动补分) |
| 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 |
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
---
## 5. 低优先级技术债
## 🟡 Medium Priority: Quality of Life
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 前端离线缓存能力 | 非核心 | 评估 Service Worker + IndexedDB |
| 冷启动波动 | 首屏抖动 | 热点城市预热 |
| Debt Item | Impact | Suggested Fix |
| :------------------------ | :-------------------------------------------------- | :--------------------------------------------------------------------------- |
| **Hard-coded Thresholds** | Modification requires code changes (e.g., 5s CD). | Extract all business constants into a structured `config.yaml`. |
| **Simulation Harness** | No way to "replay" a rainy day to test alert logic. | Build a `ReplayEngine` using `data/daily_records.json`. |
| **Backend Naming** | Artifacts of "market price" logic remain in naming. | Systematic refactor of variable names to reflect weather-intelligence focus. |
| **Chart Regression Tests**| UI relies on custom Chart.js lifecycles. | Add snapshot + interaction tests for chart datasets and legends. |
## 6. 下阶段里程碑
---
## 🟢 Low Priority: Optimization
| Debt Item | Impact | Suggested Fix |
| :------------------------- | :---------------------------------------------- | :------------------------------------------------------------- |
| **Serverless Cold Starts** | Initial Vercel API calls can be slow. | Implement edge-cache or warming cron for major city endpoints. |
| **Local SQLite Files** | Not compatible with Vercel's ephemeral storage. | Full transition to a remote DB (Supabase/Redis). |
---
## 🗓️ Next Milestones
1. **DB Integration**: Connect Supabase to `src/database/db_manager.py`.
2. **Entitlement Layer**: Enforce paid-access middleware on dashboard and API proxy routes.
3. **Alert Transparency**: Append logic metrics (slope, lead delta, advection factors) to push payloads.
4. **Replay & QA**: Add deterministic replay tests for map/panel/modal interaction regressions.
---
**📅 Last Updated**: 2026-03-09
1. 完成 SQLite 从 `dual``sqlite` 的主读切换。
2. 稳定 EMOS shadow,达到 rollout `observe/promote` 条件。
3. 补外部监控抓取与告警阈值。
4. 评估并推进支付合约 V2 升级。
+68 -53
View File
@@ -1,69 +1,84 @@
# 🛠️ 技术债与工程待办
# 技术债与工程待办v1.5.1
> **愿景**:从研究脚本演进为可持续的生产级 SaaS。
最后更新:`2026-03-20`
---
目标:在收费上线后,优先保证状态一致性、支付可靠性、可观测性和概率引擎发布可控。
## 🏛️ 系统健康度:82%
## 1. 债务快照
当前估计:**95% 稳定 / 5% 技术债**。
```mermaid
pie title 系统健康度与技术债
"稳定引擎" : 82
"权限与支付债务" : 8
"测试/回放债务" : 6
"可观测性债务" : 4
flowchart TD
A["技术债"]
subgraph P["支付与订阅"]
P1["合约 V2 升级(SafeERC20 / Pausable"]
P2["退款与工单流程"]
P3["多 RPC 与链上对账面板"]
end
subgraph E["权限与运营"]
E1["前后端/Bot 权限矩阵回归"]
E2["积分来源明细与补分审计"]
end
subgraph O["可观测性"]
O1["外部监控抓取与告警阈值"]
O2["业务监控看板"]
end
subgraph S["状态与概率"]
S1["SQLite dual -> sqlite 切换验收"]
S2["EMOS shadow -> primary 门禁稳定化"]
end
A --> P
A --> E
A --> O
A --> S
```
核心天气引擎与 React 仪表盘运行时已基本稳定,但产品层基础设施债务仍然明显。
## 2. 近期已关闭
### 当前稳定模块
- 支付主链路已上线(intent -> submit -> confirm)。
- 支付自动补单已上线(Event Loop + Confirm Loop)。
- 支付事件重放脚本已补齐。
- 支付运行态 API 与 SQLite 审计事件已补齐。
- 钱包绑定支持浏览器钱包 + WalletConnect。
- 账户中心与 Pro 权限展示链路打通。
- 钱包异动支持独立频道路由。
- 运行态状态/缓存已支持 SQLite 渐进迁移。
- 轻量可观测性已上线(`/healthz``/api/system/status``/metrics`)。
- EMOS/CRPS 校准链路已上线 shadow 模式。
- [x] 多源天气聚合
- [x] DEB 融合算法
- [x] 主动式 Telegram 预警引擎
- [x] Vercel 仪表盘基础设施
- [x] React 组件驱动仪表盘运行时(已替换 legacy `public/static/app.js` 渲染路径)
## 3. 高优先级技术债
---
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| SQLite 主读切换验收 | 仍处于 dual 过渡期 | 线上跑满 24-48 小时后切到 `sqlite` |
| EMOS 上线门禁 | 当前 `hold`,不能切 primary | 继续积累样本,重点压 `bucket_brier` |
| 外部监控与告警 | 只有轻量指标,无外部抓取 | 接 Prometheus/Grafana 或最小巡检 |
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
## 🔴 高优先级:立即处理
## 4. 中优先级技术债
| 债务项 | 影响 | 建议修复 |
| :-------------------- | :------------------------------------------------- | :--------------------------------------------------------------- |
| **Monolithic Bot** | `bot_listener.py` 可测试性差,演进成本高。 | 将 UI 交互与业务逻辑解耦,沉入 `src/analysis` |
| **Subscription Store**| 付费用户缺少持久化记录。 | 从内存校验迁移到 **Supabase/PostgreSQL** |
| **Alert Transparency**| 运维侧难以审计“告警为何触发”。 | 为所有内部告警载荷增加 `Evidence` 元数据块。 |
| **Entitlement Guard** | 仪表盘路由默认仍是公开可访问。 | 在 Next.js middleware 与后端校验中加入 JWT/会话权限守卫。 |
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/奖励/手动补分) |
| 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 |
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
---
## 5. 低优先级技术债
## 🟡 中优先级:体验与效率
| 项目 | 影响 | 建议动作 |
| :-- | :-- | :-- |
| 前端离线缓存能力 | 非核心 | 评估 Service Worker + IndexedDB |
| 冷启动波动 | 首屏抖动 | 热点城市预热 |
| 债务项 | 影响 | 建议修复 |
| :---------------------- | :------------------------------------------------- | :---------------------------------------------------------------------- |
| **Hard-coded Thresholds** | 阈值修改需要改代码(如 5s 冷却)。 | 将业务常量统一抽离到结构化 `config.yaml`。 |
| **Simulation Harness** | 无法“回放历史天气日”验证告警逻辑。 | 基于 `data/daily_records.json` 构建 `ReplayEngine`。 |
| **Backend Naming** | 仍有“市场价格时代”的命名残留。 | 系统化重命名,统一为 weather-intelligence 语义。 |
| **Chart Regression Tests**| 图表依赖自定义 Chart.js 生命周期,回归风险高。 | 增加图表数据集与图例的快照测试 + 交互测试。 |
## 6. 下阶段里程碑
---
## 🟢 低优先级:性能优化
| 债务项 | 影响 | 建议修复 |
| :----------------------- | :------------------------------------------------- | :--------------------------------------------------------------- |
| **Serverless Cold Starts** | Vercel 首次 API 调用可能偏慢。 | 为主要城市接口增加边缘缓存或预热任务。 |
| **Local SQLite Files** | 与 Vercel 短暂文件系统不兼容。 | 全面迁移到远程数据库(Supabase/Redis)。 |
---
## 🗓️ 下一阶段里程碑
1. **DB Integration**:将 Supabase 接入 `src/database/db_manager.py`
2. **Entitlement Layer**:在仪表盘与 API 代理路由上落实付费访问中间件。
3. **Alert Transparency**:在推送载荷中附加逻辑指标(斜率、领先差、平流因子)。
4. **Replay & QA**:为地图/侧卡/modal 联动补齐可复现回放测试。
---
**📅 最后更新**2026-03-09
1. 完成 SQLite 从 `dual``sqlite` 的主读切换。
2. 稳定 EMOS shadow,达到 rollout `observe/promote` 条件。
3. 补外部监控抓取与告警阈值。
4. 评估并推进支付合约 V2 升级。
+263
View File
@@ -0,0 +1,263 @@
# PolyWeather 深度评估与改进提案报告
## 执行摘要
PolyWeather(仓库:`yangyuan-zhen/PolyWeather`)定位为**面向温度类结算预测市场(如 Polymarket 的温度结算合约)**的“生产级气象情报系统”,核心在于把多源天气观测/预报转化为**结算导向的概率桶(μ + bucket distribution**,并进一步映射到市场报价完成**错价扫描**;同时提供 Web 仪表盘与 Telegram Bot 两套交互入口,并包含 Polygon 链上 USDC/USDC.e 支付、自动补单与订阅/积分体系。项目 README 明确其“Open-Core”边界:仓库公开天气聚合、基础分析、看板、Bot、标准支付流程;生产私有部分包含商业风控、阈值与运营工具等。
从工程实现看,截至 `2026-03-21`,项目已经完成一轮明确的工程化收口:多源天气采集仍保持现有业务能力,同时已完成采集层与 Web API 大文件拆分、CI 质量门禁、配置分级(`.env.example` / `.env.secrets.example` / 中文部署文档)、EMOS/CRPS 校准链路、运行态状态与缓存向 SQLite 的渐进迁移,以及基础可观测性接口(`/healthz``/api/system/status``/metrics`)。
这意味着报告里最初最突出的“工程地基缺失”问题,已经有一部分被关闭:`src/data_collection/weather_sources.py``web/app.py` 不再是原来的超大单文件;GitHub Actions 已覆盖 Python、前端和 Docker build;配置与密钥治理已成体系;运行态状态不再只能依赖 JSON/JSONL 文件;EMOS 也不再只是概念,而是进入了可训练、可评估、可 shadow、可门禁判断的阶段。
但项目仍处在“从可用走向稳态”的中段,而不是终局。当前真正的高优先级问题已收敛为三类:第一,**SQLite 迁移仍处于推荐的 dual 过渡模式**,线上真正切主读路径前仍需跑一段时间验证;第二,**可观测性只完成了轻量级指标层**,还没有形成完整的外部监控、阈值告警与趋势面板;第三,**EMOS 仍未达到生产切换标准**,当前门禁结论明确为 `hold`,阻塞原因是 shadow bucket brier 明显退化。支付链路方面,链下审计与容灾已明显增强:事件重放、SQLite 审计事件、RPC 多节点容灾、合约静态检查、`/ops` 支付异常单、按邮箱恢复脚本都已补齐;当前剩余风险主要集中在**链上合约本身仍是最小实现**,尚未升级到 SafeERC20、Pausable、链上套餐绑定等更强防护版本。
因此,当前阶段最正确的策略已经不是继续做“大范围基础重构”,而是围绕**迁移验收、可观测性补全、EMOS 上线门禁稳定化**这三条线持续收口。短中期内更高 ROI 的方向依然不是引入新的大模型,而是把现有“采集→后处理→市场映射→支付/订阅”的链路做成**状态一致、指标可见、发布可控、回退明确**的生产平台。
## 项目概览
PolyWeather 的目标与范围在 README/README_ZH 中定义得较清楚:为温度结算市场提供气象情报(多源采集→融合→概率→对照市场报价),并提供“官方看板(Vercel 前端)+ VPS 后端 + Telegram Bot”。
项目主功能可归纳为四层:
**天气层(数据源/采集)**:聚合 20 个城市的实测与预报;支持 AviationWeather METAR(机场观测)、土耳其 MGM 站网、Open-Meteo(含多模型与集合预报)、美国 NWS(仅美国城市)、以及部分城市使用官方结算源(香港 HKO、台北 CWA)等。
**分析层(DEB/趋势/概率/结算口径)**:
DEBDynamic Error Balancing)基于过去 N 天模型误差(MAE)倒数加权,输出融合预报;同时维护 `daily_records.json` 做历史对账、命中率/MAE 统计,并支持基于 WUWeather Underground 口径)四舍五入的结算命中评估。
趋势/概率引擎在 `trend_engine.py` 中实现:综合“集合预报区间→σ/μ→高温窗口→死盘判定→温度桶概率分布→边界提示”等,用于 bot 展示与 web 结构化数据输出。
**市场层(Polymarket 行情对照)**:只读模式从 Gamma API 发现市场、从 CLOB`py-clob-client` 或 REST 回退)读取价格/盘口并计算 edge(模型概率 − 市场概率)生成信号标签。
**商业化与支付**:订阅(`Pro Monthly 5 USDC`)、积分抵扣、Polygon 链上收款合约(USDC/USDC.e),并提供“事件监听 + 周期确认”的自动补单机制。
**支持的数据集/数据源**:项目不是传统“训练数据集+模型训练”的机器学习仓库;其“数据集”本质是外部实时/预报 API 与站点观测数据。对外部数据的使用需要遵守来源方的访问与速率限制,例如 AviationWeather Data API 明确限制请求频率(含每分钟请求上限/建议降低频率与使用缓存文件)。
**许可证**:仓库根目录 `LICENSE` 为 MIT。 同时 README 强调 Open-Core 策略与生产私有组件边界,意味着“可复现/可审计”的范围以公开部分为准。
(插图:项目 README 中包含产品截图,可用于快速理解信息架构与 UI 形态)
![PolyWeather demo map](https://raw.githubusercontent.com/yangyuan-zhen/PolyWeather/main/docs/images/demo_map.png)
## 架构与代码库分析
### 代码库模块地图
从 README、Docker/Compose、入口脚本与核心模块引用关系,可以抽象出如下模块地图(按“运行时组件”与“Python 域模块”两层描述):
| 层级 | 目录/文件 | 角色定位 | 关键说明 |
| ------------- | ------------------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 运行时组件 | `frontend/` | Next.js 前端(Vercel | 前端重构报告提到 App Router、Route HandlersBFF)、缓存策略、支付与账户中心等。 |
| 运行时组件 | `web/app.py` + `web/core.py` + `web/routes.py` + `web/analysis_service.py` | FastAPI 后端 API | 已从单文件入口拆为启动入口、核心上下文、路由层、分析服务层。 |
| 运行时组件 | `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/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` 持久缓存。 |
| 工程与运维 | `docker-compose.yml``Dockerfile``.github/workflows/ci.yml``scripts/*` | 部署/验证脚本 | 现已具备 CI 门禁、迁移脚本、状态校验脚本、配置校验脚本与 rollout 报告脚本。 |
### 参考架构与关键工作流
项目 README 给出了一版 mermaid 参考架构图(Web/Telegram→FastAPI→采集→分析→支付/市场层)。 在此基础上,结合 `StartupCoordinator` 的 loop 启动与支付监听逻辑,可补充一个更“运行时视角”的架构图:
```mermaid
flowchart TB
subgraph Clients
WEB[Next.js Frontend<br/>Vercel]
TG[Telegram Bot<br/>TeleBot + Handlers]
end
subgraph API
FAST[FastAPI<br/>web/app.py]
end
subgraph Data
WX[WeatherDataCollector]
CITY[CITY_REGISTRY]
HIST[(SQLite runtime state<br/>daily_records / cache / snapshots)]
JSON[Legacy JSON files<br/>dual-mode fallback]
end
subgraph ExternalAPIs
OM[Open-Meteo Forecast/Ensemble/Multi-model]
AW[AviationWeather Data API<br/>METAR]
MGM[MGM Turkey]
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
subgraph Payments
SOL[PolyWeatherCheckout.sol]
EVT[event_loop<br/>scan logs]
CF[confirm_loop<br/>confirm intents]
end
WEB --> FAST
TG --> FAST
TG -->|StartupCoordinator<br/>starts loops| EVT
TG --> CF
FAST --> WX
WX --> OM
WX --> AW
WX --> MGM
WX --> NWS
WX --> HKO
WX --> CWA
FAST --> PM_G
FAST --> PM_C
FAST --> SB
EVT --> RPC
CF --> RPC
RPC --> SOL
WX --> HIST
WX --> JSON
FAST --> HIST
WX --> CITY
```
### 依赖与运行环境
**Python 依赖**`requirements.txt` 包含 `requests``loguru``pyTelegramBotAPI``python-dotenv``numpy``web3``fastapi``uvicorn` 等,符合“采集+bot+api+链上交互”的需求。
**容器环境**`Dockerfile` 基于 `python:3.11-slim`,默认启动 bot`docker-compose.yml` 通过不同 command 分别启动 bot 与 web`python bot_listener.py` / `python web/app.py`),并挂载运行态数据目录。
**前端依赖**:前端 README 描述 Next.js、Leaflet、Chart.js、Supabase Auth、WalletConnect 等;`frontend/package.json` 是前端依赖来源。
### 数据预处理、模型与“训练/推理”管线
本项目的“模型”主要是统计融合与规则/启发式引擎,而非深度网络训练:
**天气数据预处理**`WeatherDataCollector` 内部做了大量“输入清洗+缓存+退避”的工程处理:
包含 Open-Meteo 三类缓存(forecast/ensemble/multi_model)、429 冷却期、最小调用间隔、磁盘持久化缓存文件(重启后避免冷启动打爆 API)、以及 METAR/结算源缓存。
**DEBDynamic Error Balancing**:以最近 N 天各模型的 MAE 计算倒数权重并做加权融合;同时将 `forecasts / actual_high / deb_prediction / mu / prob_snapshot` 写入 `data/daily_records.json`,并提供命中率/MAE/Brier 等统计口径。
**概率引擎**`trend_engine.py` 以集合预报的 p10/p90 推 σ(并考虑历史 MAE floor、风向/云量/压强的 shock_score、以及峰值窗口 time-decay),再用正态近似把连续分布映射为 WU 整数“温度桶概率”。
**推理流水线(在线)**
Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引擎输出结构化结果(μ、概率桶、趋势、死盘/窗口判定、DEB 预测、市场扫描)→ 前端渲染或 bot 消息格式化。
**检查点(checkpoints**:传统 ML checkpoint 不适用;但项目现已形成两类“业务状态 checkpoint”:
(a)SQLite 运行态存储(推荐主路径);(blegacy JSON/JSONL 文件(迁移期回退路径)。当前设计是 `POLYWEATHER_STATE_STORAGE_MODE=file|dual|sqlite`,建议先以 `dual` 运行,再切到 `sqlite`
### 测试、CI/CD 与运维验证
**测试**:仓库存在 `tests/test_trend_engine.py`,覆盖 μ 计算、死盘判定、预报崩盘提示、趋势方向等核心逻辑(通过 patch 隔离外部依赖)。
**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 清晰列出当前产品状态(订阅、积分抵扣、链上支付、自动补单等已上线)。
**复用一套分析内核服务多端**:趋势/概率/DEB 等核心逻辑被抽成分析模块,并被 web 与 bot 共用,避免“两套逻辑漂移”。
**面向外部 API 的工程防护意识较强**:Open-Meteo 429 冷却期、最小调用间隔、磁盘缓存、缓存 TTL 等措施表明作者已遭遇并处理速率限制与冷启动问题。 同时 AviationWeather 官方文档也明确建议控制频率并可使用 cache 文件降低负载,项目后续可进一步对齐最佳实践。
**支付侧有“事件监听 + 确认补单”的双通路**:支付链路天然存在“交易 pending / RPC 延迟 / 日志索引不完整”等问题,项目通过 event loop 与 confirm loop 双机制提升最终一致性。
### 薄弱点与风险
**核心文件过大问题已明显缓解,但边界仍需继续稳定**:`WeatherDataCollector``web/app.py` 的超大文件问题已完成第一阶段拆分;当前风险已从“文件过大”转为“跨模块兼容与边界稳定性”,例如旧调用路径、兼容导出、跨层 helper 仍需持续清理。
**可复现性已从“缺模板”进入“模板与生产对齐”的阶段**:`.env.example``.env.secrets.example`、中文配置文档、前端部署文档、运行时配置校验器都已存在;当前风险主要在于线上历史 `.env` 与新模板并存、旧变量命名残留、以及密钥轮换与分层是否真正落实。
**CI 已建立,但组织级质量门禁未必完全收口**:CI 现已覆盖 Python、前端与 Docker build。当前问题不再是“缺 CI”,而是是否把这些 status check 绑定到 `main` 保护策略,以及是否逐步引入更严格的 pre-merge 审查。
**运行态状态/缓存迁移仍处于过渡期**:`daily_records``telegram_alert_state``probability_training_snapshots``open_meteo` 缓存已经支持 SQLite,并有迁移/校验脚本;但在正式切到 `sqlite` 主读路径前,仍需经历一段 dual 双写验证期。这是当前最需要谨慎处理的工程性风险之一。
**第三方服务合规与稳定性风险**
项目强依赖外部 APIOpen-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase)。其中 AviationWeather Data API 有明确速率限制;Polymarket 官方说明 Gamma/Data/CLOB 三套 API 分属不同域,CLOB 交易端点需鉴权且策略可能变化;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。
**可观测性已起步,但仍不构成完整监控体系**:项目现在已有 `/healthz``/api/system/status``/metrics`,并为 HTTP 与关键第三方源增加了轻量指标;但仍缺少 Prometheus/Grafana 级别的外部抓取、告警阈值、趋势面板和运行日报。这部分现在属于“已开始,不算完成”。
**EMOS 已完成工程接入,但未完成生产发布**:EMOS/CRPS 校准、shadow 观测、rollout report、上线门禁都已实现;当前真实门禁结果为 `hold`,阻塞原因是 shadow bucket brier 明显退化。因此概率引擎标准化并非未做,而是“工程完成、发布未通过”。
**许可证/商业使用的潜在冲突点**:仓库自身是 MIT,但如果未来尝试引入外部 AI 预报模型,需要非常谨慎: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):用于评估市场层的工程选型。
### 关键对比表
| 项目/论文 | 解决的问题 | 输出形态 | 性能/效果(公开描述) | 易用性与依赖 | 许可证要点 |
| --------------------------------------------------------- | ---------------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| **PolyWeather**(本仓库) | 温度结算市场气象情报:多源→概率桶→错价扫描→订阅/支付 | 生产级应用(Web+Bot+API+支付) | 以工程能力为主;内置 DEB、概率桶、死盘判定、市场扫描;覆盖 20 城市。 | 主要依赖外部 APIDocker Compose 一键启动。 | 仓库 MIT;Open-Core(部分生产规则私有)。 |
| **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 与自定义缓存。 | 以仓库许可为准(此处建议上线前核验)。 |
**对标结论**PolyWeather 与这类“全球 AI 预报模型”不在同一层级:PolyWeather 是“面向结算市场的产品化情报系统”,其价值核心是**将预测转成可交易/可结算的决策信息**。短中期内更高 ROI 的方向不是“自训大模型”,而是把现有“采集+后处理+市场映射”的链路做成**可复现、可观测、可评测、可扩展**的工程平台;在许可合规前提下,再评估引入外部模型推理作为额外信号源。
## 优先级改进建议
下表按截至 `2026-03-21` 的真实状态重排优先级。已完成项不再继续列为“待做”,只保留当前仍需推进的事项。
| 优先级 | 改进项 | 预估工作量 | 主要收益 | 主要风险 | 可执行步骤(建议顺序) |
| ------ | --------------------------------------------------------------------------------------------------------------------------------- | -------------------: | ------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 高 | **完成 SQLite 迁移切换与验收**:从 `dual` 过渡到 `sqlite` 主读路径 | 25 天 | 真正关闭 JSON/JSONL 并发一致性风险;状态/缓存统一入库 | 迁移校验不充分会导致线上行为漂移 | 1) 线上部署新代码 → 2) 执行迁移与校验脚本 → 3) `dual` 运行至少 24–48 小时 → 4) 校验 `/api/history`、bot 告警、snapshot、缓存都正常 → 5) 再切 `POLYWEATHER_STATE_STORAGE_MODE=sqlite` |
| 高 | **把轻量可观测性接入外部监控与告警**:围绕 `/metrics` 建立抓取、阈值与巡检 | 3–7 天 | 不再只靠日志定位问题;可以监控第三方源错误率、缓存命中与 HTTP 延迟 | 指标不分层会导致噪音高、告警无用 | 1) 抓取 `/metrics` → 2) 先围绕 HTTP、Open-Meteo、MGM、METAR 建立最小仪表板 → 3) 为 429/403/error/stale_cache 设阈值 → 4) 增加巡检脚本或告警通道 |
| 高 | **稳定 EMOS shadow 并收紧上线门禁** | 1–2 周 | 让概率引擎升级具备明确发布条件,避免拍脑袋切换 | 当前 shadow bucket brier 退化明显,存在误上线风险 | 1) 持续积累 snapshot 样本 → 2) 定期重训与生成 `evaluation_report` / `shadow_report` / `rollout_report` → 3) 重点压 `bucket_brier` 退化 → 4) 只有门禁从 `hold` 进入 `observe/promote` 后才考虑上线 |
| 中 | **市场层升级为 async + 类型安全**:引入 `aiopolymarket` 或在现有层加重试/backoff/连接池 | 4–7 天 | 行情层更稳,减少短时网络抖动;更易扩展更多市场/分页 | 依赖升级带来的行为差异 | 1) 把 requests.Session 替换为 aiohttp/httpx → 2) 在 Gamma/CLOB 调用侧实现指数退避 → 3) 引入 typed models,减少解析失败 |
| 中 | **支付合约从“最小可用”升级到“更强合约防护”** | 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) 明确热修例外流程 |
| 低 | **引入外部 AI 预报模型作为附加信号**GraphCast/FourCastNet/Pangu-Weather 等) | 2–6 周(取决于范围) | 可能提升极端/中期预测能力与差异化 | **商业许可限制**(多为 CC BY-NC-SA/禁止商业)与算力成本 | 1) 先做合规评审(权重许可/数据条款)→ 2) 仅在研究/非商业环境评估 → 3) 若要商用,优先选择可商用权重或自研/购买授权 |
### 文档、测试与贡献流程的具体补强建议(落到仓库层面)
1)**文档体系**:保留现有中文 API/TechDebt 文档的同时,增加三份“高价值”文档:
(a)《运行与配置手册》:按环境(本地/测试/VPS/生产)列必需变量、默认值、敏感等级;(b)《数据源与合规说明》:列出 Open-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase 的使用条款要点、速率限制与降级策略(例如 AviationWeather 明确建议降低请求频率并提供 cache 文件)。 (c)《故障排查 Runbook》:429、支付 pending、市场扫描 miss、前端缓存异常等典型故障处理。
2**测试金字塔**:在现有 `trend_engine` 单测基础上,补齐:
(a)天气 provider 的“录制回放”测试(VCR 思路:固定响应→确保解析稳定);(b)市场层的契约测试(Gamma/CLOB schema 变更时提前失败);(c)支付链路的本地链集成测试(Hardhat/Anvil + 事件扫描回放)。这些测试能把“外部依赖漂移”尽量转成可控的回归失败。
3**贡献工作流**:引入 `CONTRIBUTING.md`(分支策略、PR 模板、变更日志、版本号策略)、`CODEOWNERS`(核心模块审查人)、`SECURITY.md`(漏洞披露与密钥处理),并把静态检查(ruff/eslint)作为 pre-commit + CI 必过项。
## 建议实验与基准
PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报所有变量。建议建立两类基准:**气象预测基准(结算导向)**与**市场信号基准(交易导向)**。
### 气象预测与概率校准基准
**数据集**(建议从现有生产数据演进)
1`daily_records.json` 的历史快照:已包含多模型预报、`actual_high``deb_prediction``mu` 与概率快照字段,天然可转成评测数据(建议迁移到 DB 后做版本化导出)。
2)观测“真值”统一口径:对 METAR 城市用 AviationWeather Data API;对香港/台北等按结算源(HKO/CWA)作为真值,和项目当前逻辑一致。
**指标**
1)确定性误差:MAE、RMSE(按城市、按季节、按风险等级分组);
2)结算命中率:`WU_round(pred) == WU_round(actual)`(项目已有统计口径);
3)概率质量:Brier Score(对离散温度桶),以及建议补充 CRPS(连续变量概率评分,EMOS 体系常用)。
4)校准曲线:预测概率分箱的可靠性图(reliability diagram)与 Sharpness(分布集中度)。
**基线**
- Baseline AOpen-Meteo 当日最高温(或 forecast median)作为点预测;
- Baseline B:等权平均(DEB 在历史少时也会回退此策略);
- Baseline C:当前 DEB
- Baseline DEMOS(以 ensemble 均值/方差为输入,拟合 μ 与 σ,优化 CRPS)。
**预期结果(定性)**
- 若历史样本足够,DEB 应在“系统性偏差明显”的城市提升 MAE;
- EMOS 类方法通常能在概率校准(可靠性与 CRPS)上更稳定,尤其当 ensemble 信息可用(项目已接入 Open-Meteo ensemble/p10/p90)。
**算力**:以上评测全部可在 CPU 上完成;数据量按“20 城市 × 180 天”级别,pandas/duckdb 即可。若引入更复杂拟合(如分层贝叶斯/分位数回归),也通常不需要 GPU。
### 错价信号与市场有效性基准
**数据集**
- 保存每次扫描输出:`date/city/bucket/model_prob/market_price/liquidity/edge`,并加上未来 `settled_bucket` 作为标签;Polymarket 市场发现与报价来自 Gamma/CLOB(官方文档说明三套 APIGamma/Data/CLOB)。
**指标**
- Signal 覆盖率:能否找到正确 market / bucket
- Edge 稳健性:不同流动性分位的 edge 分布;
- 交易模拟(如需):在考虑滑点/手续费/成交概率下的期望收益(即使项目当前只读,也可以离线评估“若执行”会怎样)。
**基线**
- 简单策略:仅用市场中间价(不做模型)作为概率;
- 当前策略:模型概率 vs 市场概率 edge 阈值;
- 改进策略:引入“流动性/盘口深度/波动”作为信号置信度(aiopolymarket/py-clob-client 提供更完整的盘口读取能力)。
**算力**:CPU 即可;关键在于数据采样与回放。
## 路线图与风险缓解
下面给出一个**12 周**(约 3 个月)的建议路线图,按“可稳定交付的工程里程碑”组织;人力以“1 名后端/数据工程 + 1 名前端(可兼职)+ 0.5 名链上工程(按需)”估算。
| 时间窗 | 里程碑 | 交付物 | 资源/备注 |
| ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| 第 1–2 周 | 工程地基:CI + 规范 + 配置可复现 | GitHub Actionsruff/eslintpytest 可一键跑;`.env.example`;敏感项分级说明(尤其 Supabase service role key 不可暴露)。 | 后端为主;前端补 eslint/typecheck |
| 第 3–5 周 | 核心模块解耦:采集 Provider 化 + API 分层 | provider 接口与实现;`web/app.py` 拆分路由与服务;核心 schema(Pydantic) | 风险:行为漂移;用回放测试压住 |
| 第 6–8 周 | 状态/缓存统一 + 可观测性 | `daily_records/open_meteo_cache` 迁移 DB;指标(请求量/429/延迟/命中率);告警阈值 | 可先用 SQLite/Redis,后续再上 Postgres |
| 第 9–10 周 | 评测体系上线 | 离线评测脚本(MAE/RMSE/WU-hit/Brier/CRPS);日报/周报自动生成 | 直接基于项目现有字段扩展 |
| 第 11–12 周 | 概率引擎升级(可选)+ 市场层健壮性增强 | EMOS/CRPS 拟合的 shadow 输出;Gamma/CLOB 客户端增强(async、重试、分页) | 以“小步可回滚”为原则,避免一次性替换 |
### 主要风险与缓解策略
**外部 API 速率限制/格式变更**AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用)。
**密钥泄露与权限滥用**:Supabase 明确强调 `service_role` 属高权限密钥,绝不可出现在前端或公开环境。缓解:密钥分级、CI secret scan、运行时最小权限、日志脱敏。
**支付链路最终一致性与链上不确定性**:链上事件索引延迟、RPC 不稳定、交易确认数不足都会导致误判。当前项目已经补齐“事件监听 + 确认补单”双路径、事件重放脚本、SQLite 审计事件与多 RPC fallback;现阶段的主要剩余风险不再是“没有防护”,而是链上合约仍为最小实现,owner 为单地址管理,且没有 pause 开关与 SafeERC20。
**引入外部 AI 预报模型的商业合规风险**GraphCast/Pangu-Weather 的权重许可均带非商业限制(CC BY-NC-SA/BY-NC-SA);若 PolyWeather 是付费产品,必须先做法务与授权评审。缓解:只在研究环境评估;商用优先选择可商用权重/购买授权/自研。
**Open-Core 边界导致的“公开仓库与生产行为不一致”**:README 明确生产存在私有风控与阈值。缓解:把“公开核心”的可复现与评测做扎实(接口/数据 schema/测试/评测),私有策略只作为可插拔 policy layer 接入。
## 参考链接
- PolyWeather 仓库(本次评估对象):https://github.com/yangyuan-zhen/PolyWeather
- Polymarket API 文档(Gamma/Data/CLOB):https://docs.polymarket.com/api-reference
- AviationWeather Data APIMETAR 等):https://aviationweather.gov/data/api/
- Open-Meteo DocsForecast):https://open-meteo.com/en/docs
- Open-Meteo DocsEnsemble):https://open-meteo.com/en/docs/ensemble-api
- Supabase REST APIhttps://supabase.com/docs/guides/api
- Supabase API keysservice_role 风险):https://supabase.com/docs/guides/api/api-keys
- GraphCast(代码 Apache-2.0;权重 CC BY-NC-SA):https://github.com/google-deepmind/graphcast
- FourCastNetBSD-3):https://github.com/NVlabs/FourCastNet
- Pangu-Weather(权重 BY-NC-SA,禁商用):https://github.com/198808xc/Pangu-Weather
- Polymarket 官方 Python CLOB SDKMIT):https://github.com/Polymarket/py-clob-client
- aiopolymarketasync、类型安全):https://github.com/the-odds-company/aiopolymarket
Binary file not shown.

Before

Width:  |  Height:  |  Size: 507 KiB

After

Width:  |  Height:  |  Size: 947 KiB

+244
View File
@@ -0,0 +1,244 @@
# PolyWeather 支付审计与防护说明
最后更新:`2026-03-21`
## 1. 当前已落地的防护
### 链下运行态
- 支付事件扫描与确认循环已把运行态写入 SQLite:
- `payment_runtime_state`
- `payment_audit_events`
- 关键循环现在会记录:
- `event_loop_started`
- `event_loop_cycle`
- `event_loop_error`
- `confirm_loop_started`
- `confirm_loop_cycle`
- `confirm_loop_error`
### 事件确认边界
- 后端只认链上 `OrderPaid` 事件。
- 前端提交 intent 不会直接视为支付完成。
- `confirm_loop` 会再次按链上交易与确认数校验 intent。
- 若确认失败,当前会明确把 intent / transaction 落为失败态,而不是长期停留在 `submitted`
当前已显式识别的失败原因包括:
- `receiver_mismatch`
- `sender_mismatch`
- `event_mismatch`
- `tx_reverted`
### RPC 多节点容灾
- 支持 `POLYWEATHER_PAYMENT_RPC_URLS`
- 格式示例:
```env
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com,https://polygon-bor-rpc.publicnode.com
```
- 启动时按顺序探活。
- 当前节点断连或收据查询失败时,会自动切换到下一个可用 RPC。
### 事件重放
- 已提供脚本:
- [replay_payment_events.py](/E:/web/PolyWeather/scripts/replay_payment_events.py)
用途:
- 审计某个区块范围内的 `OrderPaid`
- 事后补查漏单
- 排查 RPC 抖动导致的监听遗漏
命令示例:
```bash
python scripts/replay_payment_events.py --from-block 10000000 --to-block 10001000
```
### 运行态检查
- 已提供接口:
- `GET /api/payments/runtime`
可查看:
- checkout 配置摘要
- 当前活跃 RPC
- 候选 RPC 列表
- event loop 最新状态
- 最近审计事件
### Ops 事故单
现在 `/ops` 已提供单独的支付异常单列表,默认展示:
- `payment_intent_failed`
支持:
- 按 `reason` 过滤
- 标记已处理
这让下面这类事故不再需要翻日志定位:
- 已付款但未开通
- 打到旧收款地址
- 交易事件不匹配
## 2. 当前合约的授权边界
合约源码:
- [PolyWeatherCheckout.sol](/E:/web/PolyWeather/contracts/PolyWeatherCheckout.sol)
当前边界:
1. `owner`
- 可执行:
- `setTreasury`
- `setTokenAllowed`
2. 普通用户
- 只能调用:
- `pay(orderId, planId, amount, token)`
3. 代币边界
- 只有 `allowedToken[token] == true` 的 token 可支付
4. 订单边界
- 同一个 `orderId` 只能成功支付一次
## 3. 重入与重复支付判断
当前合约的 `pay` 逻辑顺序是:
1. 检查 token allowlist
2. 检查 `amount > 0`
3. 检查 `paidOrder[orderId] == false`
4. 先写入 `paidOrder[orderId] = true`
5. 再执行 `transferFrom`
6. 发出 `OrderPaid`
这意味着:
- 同一 `orderId` 的重复支付会被拦住
- 典型“转账外部调用后再回调重复执行同订单”的路径会被 `paidOrder` 状态挡住
但要注意:
- 当前合约没有 `Pausable`
- 当前合约没有 `SafeERC20`
- 当前合约没有在链上校验 `planId -> amount`
所以它属于:
- **最小可用支付合约**
- 不是“全功能强防护合约”
## 4. 当前静态审计结论
已提供脚本:
- [check_payment_contract_security.py](/E:/web/PolyWeather/scripts/check_payment_contract_security.py)
命令:
```bash
python scripts/check_payment_contract_security.py
```
输出会检查这些项目:
- 是否有 `onlyOwner`
- `setTreasury` / `setTokenAllowed` 是否受 owner 保护
- constructor / setter 是否检查零地址
- 是否校验 allowlist
- 是否校验 `amount > 0`
- 是否校验重复订单
- 是否在 `transferFrom` 前写入 `paidOrder`
- 是否有 pause 开关
- 是否使用 SafeERC20
- 是否在链上绑定套餐价格
## 5. 当前主要剩余风险
1. 单地址 owner
- 建议把 `owner` 迁移到多签钱包
2. 无暂停开关
- 发现紧急问题时,无法直接暂停 `pay`
3. 金额校验主要在链下
- 当前 `planId / amount / token` 绑定主要靠后端 intent 和确认逻辑
4. ERC20 兼容性假设
- 当前使用 `IERC20.transferFrom`
- 升级版合约更建议改为 OpenZeppelin `SafeERC20`
## 6. 推荐操作
### 每次支付配置变更后
执行:
```bash
python scripts/check_payment_contract_security.py
python scripts/replay_payment_events.py --from-block <from> --to-block <to>
```
### 线上巡检
执行:
```bash
curl http://127.0.0.1:8000/api/payments/runtime
```
重点看:
- `rpc.active_rpc_url`
- `rpc.configured_rpc_count`
- `event_loop_state.last_scanned_block`
- `recent_audit_events`
如果你在 `/ops` 或脚本里看到:
- `receiver_mismatch`
其含义通常不是“缓存没刷新”,而是:
- 用户这笔交易的 `to` 地址不是当前生产收款合约
- 常见原因是旧页面、旧 deployment、旧钱包会话,或历史收款地址仍被命中
此时应优先做:
1. 确认链上真实 `to` 地址
2. 确认当前 `/api/payments/config` 返回的 `receiver_contract`
3. 如确已收款,再走人工恢复或补开订阅
### 按邮箱恢复最近支付
已提供脚本:
- [reconcile_subscription_by_email.py](/E:/web/PolyWeather/scripts/reconcile_subscription_by_email.py)
命令:
```bash
docker compose exec polyweather_web python scripts/reconcile_subscription_by_email.py --email user@example.com
```
适用场景:
- 用户声称已付费但未开通
- 需要快速确认最近一笔 intent 是否能自动恢复
## 7. 下一版合约建议
如果后续升级合约,优先级建议:
1. `Ownable` -> 多签 owner
2. `SafeERC20`
3. `Pausable`
4. 链上 plan/amount/token 绑定
5. 必要时增加 rescue/sweep 能力
+197
View File
@@ -0,0 +1,197 @@
# PolyWeather 支付合约升级方案(V2)
最后更新:`2026-03-20`
## 1. 目标
本次 V2 方案对应三个明确目标:
1. 把 `owner` 迁到多签地址
2. 升级到 `SafeERC20 + Pausable + ReentrancyGuard`
3. 把“链上 plan 绑定”和“EIP-712 授权支付”都纳入设计,而不是只在链下校验
合约草案:
- [PolyWeatherCheckoutV2.sol](/E:/web/PolyWeather/contracts/PolyWeatherCheckoutV2.sol)
构造参数编码脚本:
- [encode_checkout_v2_constructor.py](/E:/web/PolyWeather/scripts/encode_checkout_v2_constructor.py)
## 2. V2 新增能力
### 多签 owner
V2 constructor 不再默认 `msg.sender` 作为唯一 owner,而是显式传入:
- `initialOwner`
- `initialTreasury`
- `initialSigner`
这意味着:
- 部署后可直接把多签地址设为 `owner`
- 不需要先单签部署再补 transfer
### SafeERC20
V2 内置最小 `SafeERC20` 封装:
- `safeTransferFrom`
- `safeTransfer`
相比直接依赖 `IERC20.transferFrom -> bool`
- 对非标准 ERC20 的兼容性更稳
- 出错边界更明确
### Pausable
V2 增加:
- `pause()`
- `unpause()`
支付入口:
- `payPlan(...)`
- `payAuthorized(...)`
都受 `whenNotPaused` 保护。
一旦发现:
- treasury 配置错误
- token allowlist 配置错误
- 签名器异常
- 链上风控问题
可以直接暂停支付入口。
### ReentrancyGuard
V2 增加 `nonReentrant`,保护:
- `payPlan`
- `payAuthorized`
- `rescueToken`
虽然当前订单去重已经能挡住典型重复支付路径,但 `ReentrancyGuard` 仍然是更稳的防线。
### 链上套餐绑定
V2 新增:
- `setPlan(planId, token, amount, active)`
- `planConfig[planId][token]`
正式支付入口 `payPlan` 会:
1. 校验 token 已 allowed
2. 校验 `planId + token` 的 plan 已 active
3. 从链上读取 amount
4. 按链上配置收款
这意味着:
- `planId / amount / token` 绑定不再完全依赖链下
### EIP-712 授权支付
V2 同时保留第二条入口:
- `payAuthorized(...)`
它适合:
- 临时折扣
- 特殊活动价
- 不想每次都上链改 `setPlan`
校验字段包括:
- `orderId`
- `payer`
- `planId`
- `token`
- `amount`
- `nonce`
- `deadline`
签名人地址由:
- `signer`
统一控制。
## 3. 两条支付路径怎么选
### 路线 A:链上套餐绑定优先
优点:
- 最直观
- 合约级约束最强
- 更容易审计
缺点:
- 套餐改价需要 owner 交易
适合:
- 月付/季付/年付这类稳定商品
### 路线 BEIP-712 授权优先
优点:
- 活动价灵活
- 不必每次改链上 plan
缺点:
- 需要管理 signer 密钥
- 风险从 owner 单点,部分转移到 signer 运维
适合:
- 促销
- 临时折扣
- 白名单价格
### 当前建议
生产建议不是二选一,而是:
1. **稳定套餐**`payPlan`
2. **特殊场景**`payAuthorized`
这样:
- 主流程更稳
- 特殊价仍保留灵活性
## 4. 推荐迁移步骤
1. 先部署 V2 到测试环境
2. `owner` 直接用多签地址
3. 配置 `treasury`
4. 配置 `allowedToken`
5. 配置 `planId/token/amount`
6. 仅在需要活动价时再配置 `signer`
7. 用事件重放脚本和运行态接口验证
8. 再切生产前端/后端配置到新 `receiver_contract`
## 5. 构造参数编码
示例:
```bash
python scripts/encode_checkout_v2_constructor.py \
--owner 0xYourMultiSig \
--treasury 0xYourTreasury \
--signer 0xYourBackendSigner
```
## 6. 当前判断
V2 已经把这三件事做成了明确方案:
1. 多签 owner
2. SafeERC20 + Pausable + ReentrancyGuard
3. 链上 plan 绑定 + EIP-712 授权
它现在是**升级草案**,不是现网已部署合约。
如果要真正上线,下一步就是:
1. 做一次测试网或本地链验证
2. 更新 PolygonScan 验证文档
3. 修改后端 `receiver_contract` 配置
+76
View File
@@ -0,0 +1,76 @@
# PolyWeatherCheckout PolygonScan 验证(v1.5.1
最后更新:`2026-03-20`
## 1. 目标
对生产收款合约完成源码验证,降低钱包风控误报并提升用户信任。
当前说明:
- **现网合约仍为 V1**`contracts/PolyWeatherCheckout.sol`
- **V2 只是升级草案**`contracts/PolyWeatherCheckoutV2.sol`
- 当前 PolygonScan 验证流程默认针对 V1
## 2. 当前部署参数(示例)
- 链:Polygon Mainnet`chainId=137`
- 合约:`PolyWeatherCheckout`
- 编译器:`v0.8.24+commit.e11b9ed9`
- 优化器:`Enabled``runs=200`
> 实际地址以线上配置为准:`POLYWEATHER_PAYMENT_RECEIVER_CONTRACT`
## 3. 构造参数编码
使用仓库脚本生成构造参数:
```bash
python scripts/encode_checkout_constructor.py \
--token 0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174 \
--treasury 0xe581D578EF101c80e3F32263e97E6eA28A0B170e
```
将输出填入 PolygonScan 的 `Constructor Arguments ABI-encoded`
## 4. PolygonScan 操作步骤
1. 打开合约页 -> `Contract` -> `Verify and Publish`
2. 选择 `Solidity (Single file)`
3. 粘贴 `contracts/PolyWeatherCheckout.sol` 源码。
4. 填写编译器/优化器参数。
5. 粘贴构造参数并提交。
## 5. 验证后检查
- `Read Contract`:可见 `owner / treasury / allowedToken / paidOrder`
- `Write Contract`:可见 `pay / setTreasury / setTokenAllowed`
- 标签显示 `Contract Source Code Verified`
## 6. 双币种开启(USDC + USDC.e
验证后可通过 `setTokenAllowed` 开启两种代币:
- USDC.e: `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`
- Native USDC: `0x3c499c542cef5e3811e1192ce70d8cc03d5c3359`
## 7. V2 说明(尚未部署)
如果后续升级到 V2,请改用:
```bash
python scripts/encode_checkout_v2_constructor.py \
--owner 0xYourMultiSig \
--treasury 0xYourTreasury \
--signer 0xYourBackendSigner
```
V2 相关文档:
- [PAYMENT_UPGRADE_V2_ZH.md](/E:/web/PolyWeather/docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
- [PAYMENT_AUDIT_ZH.md](/E:/web/PolyWeather/docs/payments/PAYMENT_AUDIT_ZH.md)
## 8. 说明
- 源码验证能显著降低“欺诈/不可信”误报,但钱包风险缓存更新存在延迟。
- 生产商用环境可使用私有升级版合约;公开仓库保留标准实现与验证流程。
+49
View File
@@ -0,0 +1,49 @@
# PolyWeather 侧边栏插件(MVP
这是一个 Chrome / Edge 侧边栏扩展的 MVP,用于把 PolyWeather 右侧城市卡片移植到浏览器侧边栏中。
## 功能
- 侧边栏展示:
- 城市选择
- 风险徽章
- 城市档案(结算源 / 距离 / 观测更新时间 / 周边站点)
- 今日日内走势(简版 Canvas)
- 多日预报(`DEB` 优先)
- 基础判断卡(方向 / 置信度 / 原因)
- 快捷按钮:
- 今日日内分析
- 历史对账
- 打开网站查看更多
- 自动识别城市:
- 监听当前激活标签页 URL(例如 Polymarket `.../event/highest-temperature-in-ankara-...`
- 自动将侧边栏城市切换为 URL 对应城市
- 设置页可配置:
- 网站基础地址
- API 基础地址
- Bearer Token(可选)
## 本地安装(开发者模式)
1. 打开 Chrome/Edge 扩展页面:
- Chrome`chrome://extensions`
- Edge`edge://extensions`
2. 打开“开发者模式”。
3. 选择“加载已解压的扩展程序”。
4. 选择目录:`extension/`
5. 点击扩展图标,侧边栏会打开。
## 设置
首次建议打开扩展“选项页”并确认:
- `网站基础地址`:你的前端域名(例如 `https://polyweather-pro.vercel.app`
- `API 基础地址`:你的后端 API 域名(若同域也可填前端域名)
- `Bearer Token`:后端开启鉴权时填写
## 说明
- 当前版本仍是轻量 MVP,重点是“监控 + 基础判断 + 导流回站”,未接入支付链路。
- 若你的 API 做了严格鉴权,请先在设置页填写 token 再使用。
- 台北现在按 `NOAA RCTP` 结算参考展示。
- 插件不会承载完整分析;完整结构判断、历史对账和更多信号仍以主站为准。
+6
View File
@@ -0,0 +1,6 @@
chrome.runtime.onInstalled.addListener(() => {
chrome.sidePanel
.setPanelBehavior({ openPanelOnActionClick: true })
.catch(() => {});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

+29
View File
@@ -0,0 +1,29 @@
{
"manifest_version": 3,
"name": "PolyWeather Side Panel",
"description": "PolyWeather 右侧城市卡片(浏览器侧边栏)",
"version": "0.1.5",
"icons": {
"16": "icon-16.png",
"32": "icon-32.png",
"48": "icon-48.png",
"128": "icon-128.png"
},
"permissions": ["sidePanel", "storage", "tabs"],
"host_permissions": ["https://*/*", "http://*/*"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_icon": {
"16": "icon-16.png",
"32": "icon-32.png",
"48": "icon-48.png"
},
"default_title": "Open PolyWeather"
},
"side_panel": {
"default_path": "sidepanel.html"
},
"options_page": "options.html"
}
+78
View File
@@ -0,0 +1,78 @@
body {
margin: 0;
background: #0b1225;
color: #e5eefb;
font-family: "Inter", "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
}
.wrap {
max-width: 840px;
margin: 24px auto;
padding: 0 16px;
}
h1 {
margin: 0 0 18px;
font-size: 24px;
}
.field {
display: grid;
gap: 6px;
margin-bottom: 14px;
}
.field span {
color: #9fb0c9;
font-size: 13px;
}
input,
textarea {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
background: #121c38;
color: #f1f5ff;
padding: 10px 12px;
font-size: 14px;
}
textarea {
resize: vertical;
}
.actions {
display: flex;
gap: 10px;
margin-top: 8px;
}
button {
border: 1px solid rgba(34, 211, 238, 0.4);
background: rgba(34, 211, 238, 0.12);
color: #ccf7ff;
border-radius: 9px;
padding: 10px 14px;
cursor: pointer;
font-weight: 700;
}
button.ghost {
border-color: rgba(99, 102, 241, 0.4);
background: rgba(99, 102, 241, 0.14);
color: #dbe4ff;
}
.result {
margin-top: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.03);
border-radius: 10px;
padding: 12px;
white-space: pre-wrap;
word-break: break-word;
min-height: 56px;
color: #9fb0c9;
}
+42
View File
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PolyWeather Extension Settings</title>
<link rel="stylesheet" href="./options.css" />
</head>
<body>
<main class="wrap">
<h1 id="settingsTitle">PolyWeather 侧边栏设置</h1>
<label class="field">
<span id="siteBaseLabel">Site Base URL</span>
<input id="siteBaseInput" type="text" placeholder="https://polyweather-pro.vercel.app" />
</label>
<label class="field">
<span id="apiBaseLabel">API Base URL</span>
<input id="apiBaseInput" type="text" placeholder="https://polyweather-pro.vercel.app" />
</label>
<label class="field">
<span id="tokenLabel">Bearer Token(可选)</span>
<textarea
id="tokenInput"
rows="3"
placeholder="公开模式留空即可;仅当后端返回 401 时再填写。"
></textarea>
</label>
<div class="actions">
<button id="saveBtn">保存</button>
<button id="testBtn" class="ghost">测试 /api/cities</button>
</div>
<pre id="resultBox" class="result"></pre>
</main>
<script src="./options.js"></script>
</body>
</html>
+145
View File
@@ -0,0 +1,145 @@
const DEFAULT_CONFIG = {
apiBase: "https://polyweather-pro.vercel.app",
siteBase: "https://polyweather-pro.vercel.app",
authToken: "",
selectedCity: ""
};
const locale = String(navigator.language || "en").toLowerCase().startsWith("zh")
? "zh"
: "en";
const I18N = {
zh: {
settingsTitle: "PolyWeather 侧边栏设置",
tokenLabel: "Bearer Token(可选)",
tokenPlaceholder: "公开模式留空即可;仅当后端返回 401 时再填写。",
save: "保存",
test: "测试 /api/cities",
saved: "已保存。公开模式下 Token 可留空。",
connectOk: "连接成功,返回城市数",
tokenOptional: "Token 可留空",
testFailed: "测试失败",
backendAuthHint: "说明后端仍要求鉴权;若你要公开插件,请先放开 /api/cities 与 /api/city/*/detail。"
},
en: {
settingsTitle: "PolyWeather Side Panel Settings",
tokenLabel: "Bearer Token (Optional)",
tokenPlaceholder: "Leave empty in public mode; only fill it if the backend returns 401.",
save: "Save",
test: "Test /api/cities",
saved: "Saved. Token can be empty in public mode.",
connectOk: "Connected successfully, city count",
tokenOptional: "Token can be empty",
testFailed: "Test failed",
backendAuthHint: "The backend still requires auth. If the extension should be public, allow /api/cities and /api/city/*/detail."
}
};
function t(key) {
return I18N[locale][key] || I18N.zh[key] || key;
}
const apiBaseInput = document.getElementById("apiBaseInput");
const siteBaseInput = document.getElementById("siteBaseInput");
const tokenInput = document.getElementById("tokenInput");
const resultBox = document.getElementById("resultBox");
const settingsTitle = document.getElementById("settingsTitle");
const siteBaseLabel = document.getElementById("siteBaseLabel");
const apiBaseLabel = document.getElementById("apiBaseLabel");
const tokenLabel = document.getElementById("tokenLabel");
const saveBtn = document.getElementById("saveBtn");
const testBtn = document.getElementById("testBtn");
function normalizeBase(url) {
return String(url || "").trim().replace(/\/+$/, "");
}
function writeResult(text) {
resultBox.textContent = text;
}
function getStorage() {
return new Promise((resolve) => {
chrome.storage.sync.get(DEFAULT_CONFIG, (items) => resolve(items));
});
}
function setStorage(values) {
return new Promise((resolve) => {
chrome.storage.sync.set(values, resolve);
});
}
async function loadForm() {
const cfg = await getStorage();
apiBaseInput.value = cfg.apiBase || DEFAULT_CONFIG.apiBase;
siteBaseInput.value = cfg.siteBase || cfg.apiBase || DEFAULT_CONFIG.siteBase;
tokenInput.value = cfg.authToken || "";
}
async function saveForm() {
const next = {
apiBase: normalizeBase(apiBaseInput.value),
siteBase: normalizeBase(siteBaseInput.value || apiBaseInput.value),
authToken: String(tokenInput.value || "").trim()
};
await setStorage(next);
writeResult(t("saved"));
}
async function testApi() {
const apiBase = normalizeBase(apiBaseInput.value);
const authToken = String(tokenInput.value || "").trim();
try {
const headers = { Accept: "application/json" };
if (authToken) headers.Authorization = `Bearer ${authToken}`;
const res = await fetch(`${apiBase}/api/cities`, {
headers,
cache: "no-store"
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (_e) {
data = text;
}
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${typeof data === "string" ? data : JSON.stringify(data)}`);
}
const count = Array.isArray(data)
? data.length
: Array.isArray(data?.cities)
? data.cities.length
: 0;
writeResult(`${t("connectOk")}: ${count} (${t("tokenOptional")})`);
} catch (err) {
const msg = String(err?.message || err || "");
if (msg.includes("HTTP 401")) {
writeResult(`${t("testFailed")}: ${msg}\n${t("backendAuthHint")}`);
return;
}
writeResult(`${t("testFailed")}: ${msg}`);
}
}
function applyStaticTranslations() {
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
if (settingsTitle) settingsTitle.textContent = t("settingsTitle");
if (tokenLabel) tokenLabel.textContent = t("tokenLabel");
if (tokenInput) tokenInput.placeholder = t("tokenPlaceholder");
if (saveBtn) saveBtn.textContent = t("save");
if (testBtn) testBtn.textContent = t("test");
if (siteBaseLabel) siteBaseLabel.textContent = "Site Base URL";
if (apiBaseLabel) apiBaseLabel.textContent = "API Base URL";
}
document.getElementById("saveBtn").addEventListener("click", () => {
void saveForm();
});
document.getElementById("testBtn").addEventListener("click", () => {
void testApi();
});
applyStaticTranslations();
void loadForm();
+410
View File
@@ -0,0 +1,410 @@
:root {
--bg: #070d1f;
--panel: #0d152b;
--card: rgba(255, 255, 255, 0.03);
--border: rgba(255, 255, 255, 0.08);
--text: #e5eefb;
--muted: #8ba0be;
--cyan: #22d3ee;
--blue: #3b82f6;
--green: #34d399;
--amber: #f59e0b;
--red: #f87171;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: radial-gradient(circle at top, #0c1735, var(--bg) 45%);
color: var(--text);
font-family: "Inter", "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
}
.panel {
position: relative;
min-height: 100vh;
padding: 14px;
}
.loading-overlay {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: 14px;
background: rgba(7, 13, 31, 0.7);
backdrop-filter: blur(2px);
}
.loading-spinner {
width: 28px;
height: 28px;
border-radius: 999px;
border: 3px solid rgba(34, 211, 238, 0.28);
border-top-color: #22d3ee;
animation: panel-loading-spin 0.75s linear infinite;
}
.loading-text {
color: #b7dcff;
font-size: 12px;
font-weight: 600;
}
.topbar {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 8px;
align-items: center;
margin-bottom: 12px;
}
.freshness-hint {
margin: -2px 0 12px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(245, 158, 11, 0.28);
background: rgba(245, 158, 11, 0.08);
color: #fcd34d;
font-size: 12px;
line-height: 1.4;
}
.freshness-hint.stale {
border-color: rgba(248, 113, 113, 0.38);
background: rgba(248, 113, 113, 0.1);
color: #fecaca;
}
.risk-badge {
padding: 5px 9px;
border-radius: 10px;
font-size: 12px;
font-weight: 700;
border: 1px solid transparent;
}
.risk-badge.low {
color: #86efac;
border-color: rgba(52, 211, 153, 0.5);
background: rgba(52, 211, 153, 0.14);
}
.risk-badge.medium {
color: #fcd34d;
border-color: rgba(245, 158, 11, 0.45);
background: rgba(245, 158, 11, 0.14);
}
.risk-badge.high {
color: #fca5a5;
border-color: rgba(248, 113, 113, 0.5);
background: rgba(248, 113, 113, 0.12);
}
.city-picker-wrap {
display: grid;
gap: 4px;
}
.city-picker-wrap label {
font-size: 11px;
color: var(--muted);
}
#citySelect {
width: 100%;
height: 34px;
border-radius: 9px;
border: 1px solid var(--border);
background: #0f1b35;
color: var(--text);
padding: 0 10px;
}
.refresh-btn {
width: 34px;
height: 34px;
border-radius: 9px;
border: 1px solid var(--border);
background: #0f1b35;
color: #9cecff;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
}
.refresh-btn:hover {
filter: brightness(1.08);
}
.refresh-btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.refresh-btn svg {
width: 16px;
height: 16px;
}
.refresh-btn.spinning svg {
animation: refresh-spin 0.8s linear infinite;
}
@keyframes refresh-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes panel-loading-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.btn {
height: 34px;
border-radius: 10px;
border: 1px solid rgba(34, 211, 238, 0.5);
color: #9cecff;
background: rgba(34, 211, 238, 0.07);
font-weight: 700;
cursor: pointer;
}
.btn:hover {
filter: brightness(1.08);
}
.section {
border: 1px solid var(--border);
border-radius: 14px;
background: var(--card);
padding: 12px;
margin-bottom: 12px;
}
.section h3 {
margin: 0 0 10px 0;
font-size: 15px;
}
.grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.mini-card {
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px;
background: rgba(255, 255, 255, 0.02);
}
.mini-label {
display: block;
color: var(--muted);
font-size: 11px;
margin-bottom: 6px;
}
.mini-card strong {
font-size: 16px;
line-height: 1.25;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.chart-wrap {
position: relative;
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(255, 255, 255, 0.01);
padding: 8px;
}
#trendCanvas {
width: 100%;
height: auto;
display: block;
}
.legend-text {
margin-top: 8px;
color: var(--muted);
font-size: 12px;
}
.decision-card {
display: grid;
gap: 10px;
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
padding: 10px;
}
.decision-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.decision-direction {
font-size: 16px;
font-weight: 800;
line-height: 1.2;
color: #f8fbff;
}
.decision-window {
margin-top: 4px;
color: var(--muted);
font-size: 11px;
}
.decision-confidence {
flex: 0 0 auto;
padding: 5px 9px;
border-radius: 999px;
border: 1px solid transparent;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
}
.decision-confidence.high {
color: #86efac;
border-color: rgba(52, 211, 153, 0.45);
background: rgba(52, 211, 153, 0.12);
}
.decision-confidence.medium {
color: #fcd34d;
border-color: rgba(245, 158, 11, 0.45);
background: rgba(245, 158, 11, 0.12);
}
.decision-confidence.low,
.decision-confidence.neutral {
color: #cbd5e1;
border-color: rgba(148, 163, 184, 0.28);
background: rgba(148, 163, 184, 0.1);
}
.decision-summary {
color: #dbeafe;
font-size: 12px;
line-height: 1.45;
}
.decision-reasons {
margin: 0;
padding-left: 18px;
display: grid;
gap: 6px;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.decision-reasons li {
margin: 0;
}
.forecast-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.forecast-card {
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
padding: 8px;
min-height: 68px;
}
.forecast-card.today {
border-color: rgba(34, 211, 238, 0.62);
background: rgba(34, 211, 238, 0.08);
}
.f-date {
color: var(--muted);
font-size: 11px;
}
.f-temp {
margin-top: 7px;
font-size: 16px;
font-weight: 800;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.btn-open-full {
width: 100%;
height: 40px;
border-color: rgba(59, 130, 246, 0.5);
color: #d9ebff;
background: rgba(59, 130, 246, 0.15);
}
.open-full-hint {
margin-bottom: 10px;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.error {
border: 1px solid rgba(248, 113, 113, 0.45);
border-radius: 12px;
background: rgba(248, 113, 113, 0.12);
color: #fecaca;
padding: 10px;
font-size: 12px;
line-height: 1.4;
}
.hidden {
display: none;
}
.chart-tooltip {
position: absolute;
z-index: 3;
pointer-events: none;
max-width: 180px;
padding: 6px 8px;
border-radius: 8px;
border: 1px solid rgba(34, 211, 238, 0.45);
background: rgba(9, 17, 36, 0.92);
color: #dff7ff;
font-size: 11px;
font-weight: 600;
line-height: 1.3;
white-space: nowrap;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
}
+92
View File
@@ -0,0 +1,92 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PolyWeather Panel</title>
<link rel="stylesheet" href="./sidepanel.css" />
</head>
<body>
<main class="panel">
<div id="loadingOverlay" class="loading-overlay hidden" aria-live="polite">
<div class="loading-spinner" aria-hidden="true"></div>
<div id="loadingText" class="loading-text">正在加载温度数据...</div>
</div>
<header class="topbar">
<div id="riskBadge" class="risk-badge medium">中风险</div>
<div class="city-picker-wrap">
<label id="cityLabel" for="citySelect">城市</label>
<select id="citySelect"></select>
</div>
<button id="refreshBtn" class="refresh-btn" title="刷新数据" aria-label="刷新数据">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
</button>
</header>
<div id="freshnessHint" class="freshness-hint hidden"></div>
<section class="section">
<h3 id="profileTitle">城市档案</h3>
<div class="grid2">
<article class="mini-card">
<span class="mini-label" id="settlementLabel">结算站点</span>
<strong id="settlementValue">--</strong>
</article>
<article class="mini-card">
<span id="distanceLabel" class="mini-label">站点距离</span>
<strong id="distanceValue">--</strong>
</article>
<article class="mini-card">
<span id="obsTimeLabel" class="mini-label">观测更新</span>
<strong id="obsTimeValue">--</strong>
</article>
<article class="mini-card">
<span id="nearbyLabel" class="mini-label">周边站点</span>
<strong id="nearbyValue">--</strong>
</article>
</div>
</section>
<section class="section">
<h3 id="trendTitle">今日日内走势(简版)</h3>
<div class="chart-wrap">
<canvas id="trendCanvas" width="560" height="220"></canvas>
<div id="chartTooltip" class="chart-tooltip hidden"></div>
</div>
<div id="chartLegend" class="legend-text">--</div>
</section>
<section class="section">
<h3 id="decisionTitle">方向判断</h3>
<div class="decision-card">
<div class="decision-top">
<div>
<div id="decisionDirection" class="decision-direction">--</div>
<div id="decisionWindow" class="decision-window">--</div>
</div>
<div id="decisionConfidence" class="decision-confidence neutral">--</div>
</div>
<div id="decisionSummary" class="decision-summary">--</div>
<ul id="decisionReasons" class="decision-reasons"></ul>
</div>
</section>
<section class="section">
<h3 id="forecastTitle">多日预报</h3>
<div id="forecastRow" class="forecast-row"></div>
</section>
<section class="section">
<div id="openFullHint" class="open-full-hint"></div>
<button id="openFullBtn" class="btn btn-open-full">打开完整网站分析</button>
</section>
<section id="errorBox" class="error hidden"></section>
</main>
<script src="./sidepanel.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+34
View File
@@ -1 +1,35 @@
# PolyWeather 前端最小配置(本地 / Vercel)
# 只部署天气看板时,先填下面 4 项即可。
# 必填:后端 FastAPI 基础地址
POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000
# 必填:Supabase 前端公钥(鉴权开启时必须)
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# 常用:前端鉴权开关
# 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/WeatherQuant_bot
+145 -43
View File
@@ -1,32 +1,39 @@
# PolyWeather Frontend
# PolyWeather 前端
This directory is the only web frontend in production.
PolyWeather Pro 的生产前端工程。
Production URL:
- https://polyweather-pro.vercel.app/
线上地址:
- [https://polyweather-pro.vercel.app/](https://polyweather-pro.vercel.app/)
## Stack
## 技术栈
- Next.js App Router
- React (component-driven dashboard)
- Tailwind CSS
- Leaflet (map runtime)
- Chart.js (charts with manual lifecycle wrapper)
- Typed store + typed data client
- React + Tailwind
- Leaflet + Chart.js
- Supabase Auth
- WalletConnect + 浏览器 EVM 钱包
## Production Model
## 运行模型
- Vercel serves the web UI and BFF route handlers
- FastAPI on VPS serves weather APIs only
- The old FastAPI static website has been removed
- The production page shell is React-driven (`components/dashboard/*`), with no runtime dependency on `public/static/app.js`
1. 浏览器 -> Next 应用(`frontend`
2. Next Route Handlers`/api/*`-> FastAPI 后端
3. FastAPI -> 分析服务 / 支付服务
Current request flow:
- Browser -> Vercel frontend
- React store/client -> Next route handlers
- Next route handlers -> FastAPI API
## 当前前端能力
## Local Development
- 主站 Dashboard 支持地图、城市详情、今日日内分析、历史准确率对账和账户中心
- `/docs` 已提供公开双语产品文档中心,解释日内结构信号、TAF、结算来源和历史对账
- 今日日内分析支持:
- 峰值窗口感知的近地面结构信号
- 高空结构信号
- 交易动作卡
- 非香港机场城市的 `TAF` 时段提示与走势图联动
- 历史对账支持:
- `DEB / 最佳单模型 / 实测最高温` 对比
- 峰值前 12 小时 `DEB` 参考(近似)
- `/ops` 已支持桌面表格 + 手机端卡片化视图
## 本地开发
```bash
cd frontend
@@ -35,44 +42,139 @@ npm install
npm run dev
```
Default local URL:
- http://localhost:3000
## Vercel 最小部署配置
## Required Environment Variable
只跑看板和基础鉴权时,先填这 4 项:
```env
POLYWEATHER_API_BASE_URL=https://<your-fastapi-host>
NEXT_PUBLIC_SUPABASE_URL=https://<your-supabase-project>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<your-anon-key>
POLYWEATHER_AUTH_ENABLED=true
```
Examples:
- `http://38.54.27.70:8000`
- `https://api.example.com`
建议显式补:
## Route Handlers
```env
POLYWEATHER_AUTH_REQUIRED=true
```
如果你只是开放游客浏览,可改成:
```env
POLYWEATHER_AUTH_ENABLED=false
POLYWEATHER_AUTH_REQUIRED=false
```
## 可选环境变量
仅在对应功能启用时填写:
```env
# 看板分享令牌
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
# 前端 API 转发到后端时使用的共享令牌
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
# 钱包支付
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/WeatherQuant_bot
```
更完整的 Vercel 配置说明见:
- [docs/FRONTEND_DEPLOYMENT_ZH.md](/E:/web/PolyWeather/docs/FRONTEND_DEPLOYMENT_ZH.md)
## 路由处理器
天气:
Thin BFF routes currently exposed by Next:
- `GET /api/cities`
- `GET /api/city/[name]`
- `GET /api/city/[name]/summary`
- `GET /api/city/[name]/detail`
- `GET /api/history/[name]`
Current frontend behavior:
- `/` keeps the world overview layout and initial city temperatures preload
- Marker click: focus map + open right city card + render nearby stations
- Right-card "今日日内分析": opens modal and freezes map motion
- Blank-map click: closes right card only, without resetting camera
鉴权:
## Vercel Deployment
- `GET /api/auth/me`
1. Import the repo into Vercel
2. Set Root Directory to `frontend`
3. Set `POLYWEATHER_API_BASE_URL`
4. Deploy
支付:
## Notes
- `GET /api/payments/config`
- `GET /api/payments/wallets`
- `POST /api/payments/wallets/challenge`
- `POST /api/payments/wallets/verify`
- `POST /api/payments/intents`
- `GET /api/payments/intents/[intentId]`
- `POST /api/payments/intents/[intentId]/submit`
- `POST /api/payments/intents/[intentId]/confirm`
- Backend CORS must allow `https://polyweather-pro.vercel.app`
- City detail cache TTL is 5 minutes with revision probe; manual refresh bypasses cache
- UI layout and sizing remain aligned with the legacy visual contract after React migration
Ops
Last updated: 2026-03-09
- `GET /ops`
- `GET /api/ops/users`
- `GET /api/ops/leaderboard/weekly`
- `GET /api/ops/memberships`
- `GET /api/ops/payments/incidents`
- `POST /api/ops/users/grant-points`
- `POST /api/ops/payments/incidents/[eventId]/resolve`
## Ops 管理后台
当前前端已内置轻量管理页:
- [https://polyweather-pro.vercel.app/ops](https://polyweather-pro.vercel.app/ops)
页面当前支持:
- 系统状态
- SQLite / rollout / 支付运行态
- 用户查询
- 当前会员
- 本周积分榜
- 手动补分
- 支付异常单筛选与标记已处理
- 手机端卡片化视图
注意:
- `/ops` 现在是前后端双层管理员限制
- Vercel 前端和后端都应配置相同的 `POLYWEATHER_OPS_ADMIN_EMAILS`
- 前端登录邮箱本身不会自动获得管理员权限
## 支付安全补充
为降低“旧页面/旧配置导致打到旧收款地址”的风险,支付区现在会:
1. 点击支付前重新请求 `/api/payments/config`
2. 若 `receiver_contract` 已更新,先切到最新地址
3. 若后端返回的 `tx_payload.to` 与最新地址不一致,直接阻断支付
4. 仅允许在 `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS` 白名单域名上创建 payment intent
5. 支付区会明确显示当前账号、付款钱包和收款合约,避免账号/钱包/地址混淆
这意味着:
- 旧标签页风险已明显降低
- 但支付地址变更后,仍建议在 Vercel 上 redeploy 当前 production,并清理明显过期 deployment
## 缓存行为
- `cities` / `summary` / `history``ETag + Cache-Control`
- `summary?force_refresh=true``no-store`
- 支付相关路由:`no-store`
## 开源边界说明
此前端仓库包含通用产品界面和标准支付体验。
商业策略调优、私有运营流程和敏感生产参数不在公开文档范围内。
详见根目录策略文档:`docs/OPEN_CORE_POLICY.md`
最后更新:`2026-03-24`
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { I18nProvider } from "@/hooks/useI18n";
import { AccountEntry } from "@/components/account/AccountEntry";
export const metadata: Metadata = {
title: "PolyWeather | Account Center",
description: "PolyWeather account center for identity and entitlement status.",
};
export default function AccountPage() {
return (
<I18nProvider>
<AccountEntry />
</I18nProvider>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 KiB

+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/auth/me`, {
headers: auth.headers,
cache: "no-store",
});
if (res.status === 401 || res.status === 403) {
const response = NextResponse.json({
authenticated: false,
subscription_active: false,
points: 0,
});
return applyAuthResponseCookies(response, auth.response);
}
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch auth profile", detail: String(error) },
{ status: 500 },
);
}
}
+23 -8
View File
@@ -1,33 +1,48 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildCachedJsonResponse } from "@/lib/http-cache";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export const dynamic = "force-dynamic";
export async function GET() {
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
const response = NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
return response;
}
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/cities`, {
headers: { Accept: "application/json" },
next: { revalidate: 120 },
headers: auth.headers,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
return NextResponse.json(
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: 502 },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
return NextResponse.json(data);
const response = buildCachedJsonResponse(
req,
data,
"public, max-age=0, s-maxage=300, stale-while-revalidate=1800",
);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
const response = NextResponse.json(
{ error: "Failed to fetch cities", detail: String(error) },
{ status: 500 },
);
return response;
}
}
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(
req: NextRequest,
context: { params: Promise<{ name: string }> },
) {
if (!API_BASE) {
const response = NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
return response;
}
const { name } = await context.params;
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const marketSlug = req.nextUrl.searchParams.get("market_slug");
const targetDate = req.nextUrl.searchParams.get("target_date");
const searchParams = new URLSearchParams({
force_refresh: forceRefresh,
});
if (marketSlug) {
searchParams.set("market_slug", marketSlug);
}
if (targetDate) {
searchParams.set("target_date", targetDate);
}
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
try {
const 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 = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: 502 },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
const response = NextResponse.json(
{ error: "Failed to fetch city detail aggregate", detail: String(error) },
{ status: 500 },
);
return response;
}
}
+14 -5
View File
@@ -1,4 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -7,10 +11,11 @@ export async function GET(
context: { params: Promise<{ name: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
const response = NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
return response;
}
const { name } = await context.params;
@@ -18,23 +23,27 @@ export async function GET(
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}`;
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(url, {
headers: { Accept: "application/json" },
headers: auth.headers,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
return NextResponse.json(
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: 502 },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
return NextResponse.json(data);
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
const response = NextResponse.json(
{ error: "Failed to fetch city detail", detail: String(error) },
{ status: 500 },
);
return response;
}
}
+28 -5
View File
@@ -1,4 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildCachedJsonResponse } from "@/lib/http-cache";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -7,34 +12,52 @@ export async function GET(
context: { params: Promise<{ name: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
const response = NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
return response;
}
const { name } = await context.params;
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const bypassCache = forceRefresh === "true";
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/summary?force_refresh=${forceRefresh}`;
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(url, {
headers: { Accept: "application/json" },
headers: auth.headers,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
return NextResponse.json(
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: 502 },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
return NextResponse.json(data);
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) {
return NextResponse.json(
const response = NextResponse.json(
{ error: "Failed to fetch city summary", detail: String(error) },
{ status: 500 },
);
return response;
}
}
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/healthz`, {
headers: auth.headers,
cache: "no-store",
});
const raw = await res.text();
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 (error) {
return NextResponse.json(
{ error: "Failed to fetch healthz", detail: String(error) },
{ status: 500 },
);
}
}
+21 -7
View File
@@ -1,39 +1,53 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildCachedJsonResponse } from "@/lib/http-cache";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(
_req: Request,
req: NextRequest,
context: { params: Promise<{ name: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
const response = 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 res = await fetch(url, {
headers: { Accept: "application/json" },
headers: auth.headers,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
return NextResponse.json(
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: 502 },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
return NextResponse.json(data);
const response = buildCachedJsonResponse(
req,
data,
"public, max-age=0, s-maxage=60, stale-while-revalidate=300",
);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
const response = NextResponse.json(
{ error: "Failed to fetch history", detail: String(error) },
{ status: 500 },
);
return response;
}
}
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const url = new URL(`${API_BASE}/api/ops/leaderboard/weekly`);
const limit = req.nextUrl.searchParams.get("limit");
if (limit) url.searchParams.set("limit", limit);
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": res.headers.get("content-type") || "application/json",
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch weekly leaderboard", detail: String(error) },
{ status: 500 },
);
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const url = new URL(`${API_BASE}/api/ops/memberships`);
const limit = req.nextUrl.searchParams.get("limit");
if (limit) url.searchParams.set("limit", limit);
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": res.headers.get("content-type") || "application/json",
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch memberships", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
type RouteContext = {
params: Promise<{ eventId: string }>;
};
export async function POST(req: NextRequest, context: RouteContext) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const { eventId } = await context.params;
const res = await fetch(`${API_BASE}/api/ops/payments/incidents/${eventId}/resolve`, {
method: "POST",
headers: auth.headers,
cache: "no-store",
});
const raw = await res.text();
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 (error) {
return NextResponse.json(
{ error: "Failed to resolve payment incident", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const url = new URL(`${API_BASE}/api/ops/payments/incidents`);
const limit = req.nextUrl.searchParams.get("limit");
if (limit) url.searchParams.set("limit", limit);
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": res.headers.get("content-type") || "application/json",
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch payment incidents", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
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 auth = await buildBackendRequestHeaders(req);
const body = await req.text();
const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, {
method: "POST",
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": res.headers.get("content-type") || "application/json",
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to grant points", detail: String(error) },
{ status: 500 },
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const url = new URL(`${API_BASE}/api/ops/users`);
const q = req.nextUrl.searchParams.get("q");
const limit = req.nextUrl.searchParams.get("limit");
if (q) url.searchParams.set("q", q);
if (limit) url.searchParams.set("limit", limit);
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": res.headers.get("content-type") || "application/json",
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch ops users", detail: String(error) },
{ status: 500 },
);
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/payments/config`, {
headers: auth.headers,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
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 NextResponse.json(
{ error: "Failed to fetch payment config", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
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 proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}/confirm`,
{
method: "POST",
headers: proxiedHeaders,
body: JSON.stringify(body ?? {}),
cache: "no-store",
},
);
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to confirm payment tx", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(
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 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 = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch payment intent", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
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 proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}/submit`,
{
method: "POST",
headers: proxiedHeaders,
body: JSON.stringify(body ?? {}),
cache: "no-store",
},
);
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to submit payment tx", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { isPaymentHostAllowed } from "@/lib/payment-host";
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 },
);
}
const requestHost =
req.headers.get("x-forwarded-host") ||
req.headers.get("host") ||
req.nextUrl.hostname;
if (!isPaymentHostAllowed(requestHost)) {
return NextResponse.json(
{
error:
"Payments are disabled on this host. Please return to the main production site and retry.",
host: requestHost,
},
{ status: 409 },
);
}
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/intents`, {
method: "POST",
headers: proxiedHeaders,
body: JSON.stringify(body ?? {}),
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to create payment intent", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
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 auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, {
method: "POST",
headers: auth.headers,
cache: "no-store",
});
const raw = await res.text();
const response = new NextResponse(raw, {
status: res.status,
headers: {
"content-type":
res.headers.get("content-type") || "application/json; charset=utf-8",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to reconcile latest payment", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/payments/runtime`, {
headers: auth.headers,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 500) },
{ status: res.status },
);
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 NextResponse.json(
{ error: "Failed to fetch payment runtime", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
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.json();
const auth = await buildBackendRequestHeaders(req);
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
method: "POST",
headers: proxiedHeaders,
body: JSON.stringify(body ?? {}),
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{
error: `Backend returned ${res.status}`,
detail: raw.slice(0, 350),
proxy_debug: {
has_authorization: proxiedHeaders.has("authorization"),
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
has_forwarded_user_id: proxiedHeaders.has(
"x-polyweather-auth-user-id",
),
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
},
},
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to create wallet challenge", detail: String(error) },
{ status: 500 },
);
}
}
+107
View File
@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
headers: auth.headers,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
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 NextResponse.json(
{ error: "Failed to fetch wallets", detail: String(error) },
{ status: 500 },
);
}
}
export async function DELETE(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
let payload: Record<string, unknown> = {};
try {
payload = (await req.json()) as Record<string, unknown>;
} catch {
payload = {};
}
try {
const auth = await buildBackendRequestHeaders(req);
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
method: "DELETE",
headers: proxiedHeaders,
body: JSON.stringify(payload),
cache: "no-store",
});
const raw = await res.text();
if (!res.ok) {
const response = NextResponse.json(
{
error: `Backend returned ${res.status}`,
detail: raw.slice(0, 350),
proxy_debug: {
incoming_has_authorization: Boolean(
String(req.headers.get("authorization") || "").trim(),
),
has_authorization: proxiedHeaders.has("authorization"),
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
has_forwarded_user_id: proxiedHeaders.has(
"x-polyweather-auth-user-id",
),
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
},
},
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
let data: unknown = { ok: true };
if (raw) {
try {
data = JSON.parse(raw);
} catch {
data = { ok: true, raw };
}
}
const response = NextResponse.json(data, {
headers: { "Cache-Control": "no-store" },
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to unbind wallet", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
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.json();
const auth = await buildBackendRequestHeaders(req);
const proxiedHeaders = new Headers(auth.headers);
proxiedHeaders.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/payments/wallets/verify`, {
method: "POST",
headers: proxiedHeaders,
body: JSON.stringify(body ?? {}),
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{
error: `Backend returned ${res.status}`,
detail: raw.slice(0, 350),
proxy_debug: {
has_authorization: proxiedHeaders.has("authorization"),
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
has_forwarded_user_id: proxiedHeaders.has(
"x-polyweather-auth-user-id",
),
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
},
},
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to verify wallet binding", detail: String(error) },
{ status: 500 },
);
}
}
+43
View File
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/system/status`, {
headers: auth.headers,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 500) },
{ status: res.status },
);
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 NextResponse.json(
{ error: "Failed to fetch system status", detail: String(error) },
{ status: 500 },
);
}
}
+73
View File
@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import {
getVitalsSummary,
normalizeMetricName,
recordVitalsSample,
} from "@/lib/vitals-store";
type VitalsPayload = {
id?: string;
metric?: string;
navigationType?: string;
pathname?: string;
rating?: string;
value?: number;
};
export async function POST(request: Request) {
try {
const payload = (await request.json()) as VitalsPayload;
const metric = normalizeMetricName(payload.metric);
if (!metric) {
return NextResponse.json({ ok: false, error: "metric is required" }, { status: 400 });
}
const pathname = String(payload.pathname || "/");
const rating = String(payload.rating || "unknown");
const value = Number(payload.value);
const navigationType = String(payload.navigationType || "unknown");
const id = String(payload.id || "");
const ts = Date.now();
if (!Number.isFinite(value)) {
return NextResponse.json({ ok: false, error: "value must be finite" }, { status: 400 });
}
recordVitalsSample({
id,
metric,
navigationType,
pathname,
rating,
timestamp: ts,
value,
});
// Keep this lightweight: log for now, can be wired to a persistent sink later.
console.info(
`[vitals] metric=${metric} path=${pathname} value=${Number.isFinite(value) ? value : "NaN"} rating=${rating} nav=${navigationType} id=${id}`,
);
return NextResponse.json({ ok: true });
} catch (error) {
console.warn("[vitals] failed to parse payload", error);
return NextResponse.json({ ok: false }, { status: 400 });
}
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const targetRoute = String(searchParams.get("route") || "").trim();
const summary = getVitalsSummary();
if (!targetRoute) {
return NextResponse.json({ ok: true, ...summary });
}
return NextResponse.json({
ok: true,
generatedAt: summary.generatedAt,
route: targetRoute,
metrics: summary.routes[targetRoute] || {},
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

+32
View File
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
function normalizeNextPath(input: string | null) {
const fallback = "/";
const raw = String(input || "").trim();
if (!raw) return fallback;
if (!raw.startsWith("/")) return fallback;
if (raw.startsWith("//")) return fallback;
return raw;
}
export async function GET(request: NextRequest) {
const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next"));
const redirectUrl = request.nextUrl.clone();
redirectUrl.pathname = nextPath;
redirectUrl.search = "";
if (!hasSupabaseServerEnv()) {
return NextResponse.redirect(redirectUrl);
}
const response = NextResponse.redirect(redirectUrl);
const supabase = createSupabaseRouteClient(request, response);
const code = request.nextUrl.searchParams.get("code");
if (code) {
await supabase.auth.exchangeCodeForSession(code);
}
return response;
}
+25
View File
@@ -0,0 +1,25 @@
import { LoginClient } from "@/components/auth/LoginClient";
import { I18nProvider } from "@/hooks/useI18n";
type PageProps = {
searchParams?: Promise<{ next?: string }>;
};
function normalizeNextPath(input: string | undefined) {
const fallback = "/";
const raw = String(input || "").trim();
if (!raw) return fallback;
if (!raw.startsWith("/")) return fallback;
if (raw.startsWith("//")) return fallback;
return raw;
}
export default async function LoginPage({ searchParams }: PageProps) {
const params = (await searchParams) || {};
const nextPath = normalizeNextPath(params.next);
return (
<I18nProvider>
<LoginClient nextPath={nextPath} />
</I18nProvider>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { notFound } from "next/navigation";
import { DocsScreen } from "@/components/docs/DocsScreen";
import { DOCS_PAGES, getDocsPage } from "@/content/docs/docs";
export function generateStaticParams() {
return DOCS_PAGES.map((page) => ({ slug: [page.slug] }));
}
export default async function DocsDetailPage({
params,
}: {
params: Promise<{ slug?: string[] }>;
}) {
const resolvedParams = await params;
if ((resolvedParams.slug?.length || 0) > 1) {
notFound();
}
const slug = resolvedParams.slug?.[0] || "intro";
const page = getDocsPage(slug);
if (!page) {
notFound();
}
return <DocsScreen page={page} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { I18nProvider } from "@/hooks/useI18n";
export default function DocsLayout({ children }: { children: React.ReactNode }) {
return <I18nProvider>{children}</I18nProvider>;
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function DocsIndexPage() {
redirect("/docs/intro");
}
@@ -0,0 +1,54 @@
type Props = {
searchParams?: Promise<{ next?: string }>;
};
export default async function EntitlementRequiredPage({ searchParams }: Props) {
const params = (await searchParams) || {};
const nextPath = params.next || "/";
return (
<main
style={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
background:
"radial-gradient(circle at 20% 20%, #13264f 0%, #071127 45%, #040812 100%)",
color: "#d6e2ff",
padding: "24px",
}}
>
<section
style={{
width: "100%",
maxWidth: 720,
border: "1px solid rgba(68, 92, 140, 0.45)",
borderRadius: 16,
padding: 24,
background: "rgba(9, 18, 36, 0.88)",
boxShadow: "0 20px 50px rgba(0, 0, 0, 0.35)",
}}
>
<h1 style={{ margin: 0, fontSize: 28, lineHeight: 1.2 }}>
Entitlement Required
</h1>
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
This dashboard is protected. If Supabase auth is enabled, please go to{" "}
<a
href={`/auth/login?next=${encodeURIComponent(nextPath)}`}
style={{ color: "#8fc5ff" }}
>
/auth/login
</a>{" "}
to sign in first.
</p>
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
Legacy mode still supports <code>?access_token=&lt;your-token&gt;</code>.
</p>
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
Requested path: <code>{nextPath}</code>
</p>
</section>
</main>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+10 -4
View File
@@ -6,6 +6,16 @@ export const metadata: Metadata = {
title: "PolyWeather | Weather Intelligence",
description:
"PolyWeather pro dashboard with global weather risk map and city analytics.",
manifest: "/site.webmanifest",
icons: {
icon: [
{ url: "/favicon.ico", type: "image/x-icon" },
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
],
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
shortcut: ["/favicon.ico"],
},
};
export default function RootLayout({
@@ -14,10 +24,6 @@ export default function RootLayout({
return (
<html lang="zh-CN" className="dark">
<head>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
+13
View File
@@ -0,0 +1,13 @@
import type { Metadata } from "next";
import { OpsDashboard } from "@/components/ops/OpsDashboard";
import { requireOpsAdmin } from "@/lib/ops-admin";
export const metadata: Metadata = {
title: "PolyWeather Ops",
description: "PolyWeather lightweight operations dashboard.",
};
export default async function OpsPage() {
await requireOpsAdmin("/ops");
return <OpsDashboard />;
}
+2 -2
View File
@@ -2,9 +2,9 @@ import type { Metadata } from "next";
import { DashboardEntry } from "@/components/dashboard/DashboardEntry";
export const metadata: Metadata = {
title: "PolyWeather - 天气衍生品智能地图",
title: "PolyWeather - Global Weather Intelligence Map",
description:
"PolyWeather 天气衍生品智能地图,聚合 METARMGMDEB、多模型预报与历史对账分析。",
"PolyWeather dashboard with METAR, MGM, DEB fusion forecast, multi-model comparison, and history reconciliation.",
};
export default function HomePage() {
+1
View File
@@ -0,0 +1 @@
{"background_color":"#ffffff","display":"standalone","icons":[{"sizes":"192x192","src":"/android-chrome-192x192.png","type":"image/png"},{"sizes":"512x512","src":"/android-chrome-512x512.png","type":"image/png"}],"name":"","short_name":"","theme_color":"#ffffff"}

Some files were not shown because too many files have changed in this diff Show More