diff --git a/.env.example b/.env.example index 1541a4ec..6216c409 100644 --- a/.env.example +++ b/.env.example @@ -234,34 +234,6 @@ POLYGON_WALLET_WATCH_POLYMARKET_ONLY=true POLYGON_WALLET_WATCH_INCLUDE_DEFAULT_PM_CONTRACTS=true POLYGON_WALLET_WATCH_POLYMARKET_CONTRACTS= -# Polymarket wallet activity (retired; replaced by market monitor digests + critical alerts) -POLYMARKET_WALLET_ACTIVITY_ENABLED=false -POLYMARKET_WALLET_ACTIVITY_USERS= -POLYMARKET_WALLET_ACTIVITY_CHAT_ID= -POLYMARKET_WALLET_ACTIVITY_CHAT_IDS= -POLYMARKET_WALLET_ACTIVITY_TOPIC_CHAT_ID= -POLYMARKET_WALLET_ACTIVITY_TOPIC_ID= -POLYMARKET_WALLET_ACTIVITY_USER_ALIASES= -POLYMARKET_WALLET_ACTIVITY_DATA_API_URL=https://data-api.polymarket.com -POLYMARKET_WALLET_ACTIVITY_INTERVAL_SEC=20 -POLYMARKET_WALLET_ACTIVITY_TIMEOUT_SEC=10 -POLYMARKET_WALLET_ACTIVITY_MIN_SIZE_ABS=0.001 -POLYMARKET_WALLET_ACTIVITY_MIN_SIZE_DELTA=0.001 -POLYMARKET_WALLET_ACTIVITY_MIN_AVG_PRICE_DELTA=0.002 -POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_ON_SIZE_DELTA=true -POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_SIZE_DELTA_MIN=0.001 -POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_COOLDOWN_SEC=20 -POLYMARKET_WALLET_ACTIVITY_MAX_CHANGES_PER_MSG=5 -POLYMARKET_WALLET_ACTIVITY_NOTIFY_CLOSED=false -POLYMARKET_WALLET_ACTIVITY_BOOTSTRAP_ALERT=false -POLYMARKET_WALLET_ACTIVITY_LINK_PREVIEW=true -POLYMARKET_WALLET_ACTIVITY_UPDATE_DEBOUNCE_SEC=30 -POLYMARKET_WALLET_ACTIVITY_UPDATE_MAX_HOLD_SEC=120 -POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MIN=0.01 -POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MAX=0.99 -POLYMARKET_WALLET_ACTIVITY_MIN_POSITION_VALUE_USD=0 -POLYMARKET_WALLET_ACTIVITY_MIN_VALUE_EXEMPT_USERS= - ######################################## # 8) Optional proxies ######################################## diff --git a/CLAUDE.md b/CLAUDE.md index 92e5a0ef..15e094ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -PolyWeather Pro — a production weather-intelligence stack for temperature settlement markets. Aggregates observations and forecasts for 52 monitored cities globally, blends multi-model highs using DEB (Dynamic Error Balancing), generates calibrated probability buckets for settlement, maps weather to Polymarket quotes for mispricing scans, and serves both a Next.js dashboard (Vercel) and a Telegram bot. +PolyWeather Pro — a production weather-intelligence stack for temperature settlement markets. Aggregates observations and forecasts for 52 monitored cities globally, blends multi-model highs using DEB (Dynamic Error Balancing), generates calibrated probability buckets for settlement, and serves both a Next.js dashboard (Vercel) and a Telegram bot. ## Environment & Preferences (ALWAYS follow) @@ -117,7 +117,7 @@ curl http://127.0.0.1:8000/metrics | `src/database/` | SQLite-based runtime state, DB manager, daily/truth/training feature repositories | | `src/data_collection/` | Weather sources (METAR, TAF, Open-Meteo, JMA, KMA, MGM, NMC, Russia stations, settlement sources), city registry (52 cities), Polymarket readonly layer. Also: `madis_sources.py` (NOAA 5-min NetCDF), `amos_station_sources.py` (Korean runway sensors), `country_networks.py` (per-country provider routing + `_airport_primary_from_raw`) | | `src/data_mining/` | Historical data fetch utilities | -| `src/onchain/` | Polygon wallet watcher, Polymarket wallet activity watcher | +| `src/onchain/` | Polygon wallet watcher | | `src/payments/` | Onchain checkout, event listener, confirm loop, contract audit | | `src/strategy/` | Trading strategy modules | | `src/trading/` | Trading execution modules | diff --git a/README.md b/README.md index 5299cf4b..cae41721 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat [![Star History Chart](https://api.star-history.com/svg?repos=yangyuan-zhen/PolyWeather&type=Date)](https://star-history.com/#yangyuan-zhen/PolyWeather&Date) -## Product Status (2026-05-11) +## Product Status (2026-05-23) - Subscription live: `Pro Monthly 10 USDC`. - Points system live: earn via group chat, welcome bonus (+20), first-message-of-day bonus (+2), weekly participation rewards. @@ -35,22 +35,18 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat - EMOS/CRPS calibration is wired and trainable, but production should stay on `legacy` or `emos_shadow`; `emos_primary` is only for candidates that pass local offline evaluation and manual rollout. - Intraday analysis is now positioned as a professional meteorology read: headline, confidence, base/upside/downside paths, next observation point, evidence chain, failure modes, and confirmation rules. - Intraday modal now blocks stale cached detail during refresh, so users do not briefly trade off old city/date data before full detail arrives. -- City decision cards now include the AI airport read: METAR, DEB, model cluster, and the AI expected-high center are resolved before mapping the result to Polymarket temperature buckets. +- City decision cards now include the AI airport read: METAR, DEB, model cluster, and the AI expected-high center are resolved before mapping the result to temperature buckets. - AI airport reads now use in-page memory cache, browser `localStorage`, and backend short-TTL cache; returning from another dashboard tab restores existing stream text or final results before any new request is needed. - Market bucket matching now uses the full `all_buckets` surface and strict exact / range / or-higher / or-lower direction checks, reducing bad matches to unreasonable tail buckets. - The card label “model-market difference” means `model probability - market-implied probability`; positive values indicate weather probability above market pricing, while negative values indicate the YES is already priced more fully. -- Calibrated model probability is now the primary probability panel. It shows the active production probability engine; EMOS/LGBM are surfaced only when evaluated or shadowed, while model consensus and market prices remain secondary references. +- Calibrated model probability is now the primary probability panel. It shows the active production probability engine (legacy Gaussian or EMOS), while model consensus remains a secondary reference. - Non-Hong Kong airport cities now ingest `TAF` and parse `FM / TEMPO / BECMG / PROB30/40`. - Temperature chart now overlays `TAF Timing` markers near the expected peak window. - Trade cue now combines upper-air structure, `TAF`, market crowding, and `edge_percent`. - Browser extension now uses `DEB` for multi-day forecast and stays positioned as a lightweight lead-in to the main site. - Official nearby-network layer now covers `MGM` (Turkey), `CMA/NMC` (Mainland China), `JMA AMeDAS` (Japan), `AMOS` (Korea, runway-level, Seoul/Busan), `HKO` (Hong Kong), and `CWA` (Taiwan). - Tokyo now ingests Haneda `JMA AMeDAS` 10-minute temperature as the official enhancement layer. -- Dashboard prewarm is now supported through a dedicated worker / cron path, with runtime status exposed in `/api/system/status` and `/ops`. -- `/ops` now exposes cache bucket counts, summary cache hit / miss rate, and prewarm runtime heartbeat. -- Intraday commentary can optionally use `Groq` as a bilingual rewrite layer, while rule-based commentary remains the fallback. -- Vercel frontend guidance now includes cost controls for analytics, eager fetches, and edge-side scanner blocking. -- Frontend design system overhauled: unified CSS token system, eliminated `!important` abuse (68→6 in light theme), consolidated breakpoints (18→10), migrated hardcoded colors to CSS variables, added ARIA attributes and focus-visible keyboard navigation. See `docs/frontend-ui-design-review.md` for the full audit trail. +- Frontend design system overhauled: unified CSS token system, eliminated `!important` abuse (134→49 in light theme), consolidated breakpoints (18→10), migrated hardcoded colors to CSS variables, added ARIA attributes and focus-visible keyboard navigation. See `docs/frontend-ui-design-review.md` for the full audit trail. ## License & Commercial Boundary @@ -66,15 +62,13 @@ See: [AGPL-3.0 & Commercial Boundary](docs/OPEN_CORE_POLICY.md) - Aggregates observations and forecasts for 51 monitored cities. - Uses DEB (Dynamic Error Balancing) to blend multi-model highs. -- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution), with `LGBM` metadata surfaced when the calibrated engine is active. -- Maps weather view to Polymarket quotes for mispricing scan. +- Generates settlement-oriented calibrated probability buckets (`mu` + bucket distribution) via legacy Gaussian or EMOS/CRPS calibration. - Adds city decision cards that combine AI airport reads, expected-high centers, full market-bucket mapping, and model-market difference in one view. - Reuses one analysis core across web dashboard and Telegram bot. - Adds payment audit trails, replay tooling, and incident visibility in ops. - Adds peak-window-oriented intraday analysis with meteorology headline, path buckets, evidence chain, invalidation rules, and confirmation rules. - Adds airport-side `TAF` timing overlays and airport suppression/disruption interpretation for non-Hong Kong airport cities. - Adds official nearby-network and runway-level enhancement layers for China, Japan, Korea (AMOS runway sensors for Seoul/Busan), Hong Kong, Taiwan, and Turkey without replacing airport settlement anchors. -- Adds optional dashboard prewarm worker so hot cities can be refreshed before user clicks. ## Reference Architecture @@ -96,9 +90,7 @@ flowchart LR API --> ANA["DEB + Trend + Probability + Market Scan"] ANA --> PAY["Payment State (Intent + Event + Confirm Loop)"] - ANA --> PM["Polymarket Read-only Layer"] - ANA --> LLM["Optional Groq Commentary Rewrite"] - API --> PREWARM["Dashboard Prewarm API / Worker"] + ANA --> STATE["SQLite runtime state"] ``` ## Monitored Cities (51) @@ -131,7 +123,7 @@ npm run dev - Hong Kong keeps `HKO` official readings in dashboard and history, without falling back to airport METAR lines. - Intraday analysis now separates meteorology conclusion, evidence chain, invalidation rules, confirmation rules, calibrated probability, and market reference. - `TAF` is used as an airport-side confirmation layer, not as the main temperature model. -- `LGBM` can power the calibrated probability panel; model vote counts remain an explanatory consensus line, not the final probability. +- Calibrated probability uses legacy Gaussian (default) or EMOS/CRPS when evaluated; model vote counts remain an explanatory consensus line, not the final probability. - Browser extension remains a lightweight monitoring + basic-bias product, while the site holds the full analysis experience. ## Runtime Data (Recommended on VPS) @@ -167,20 +159,6 @@ curl http://127.0.0.1:8000/api/system/status curl http://127.0.0.1:8000/metrics ``` -### Dashboard prewarm worker - -```bash -docker compose --profile workers up -d polyweather_prewarm -curl http://127.0.0.1:8000/api/system/status -``` - -Check: - -- `prewarm.thread_alive` -- `prewarm.runtime.cycle_count` -- `cache.analysis.hit_rate` -- `cache.open_meteo_forecast_entries` - ### Frontend cache headers ```bash @@ -199,12 +177,6 @@ docker compose logs -f polyweather | egrep "payment event loop started|payment c curl http://127.0.0.1:8000/api/payments/runtime ``` -### Wallet activity logs - -```bash -docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed" -``` - ## Telegram Commands | Command | Purpose | @@ -222,26 +194,25 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s - Chinese API guide: [docs/API_ZH.md](docs/API_ZH.md) - TAF signal guide (ZH): [docs/TAF_SIGNAL_ZH.md](docs/TAF_SIGNAL_ZH.md) - Model stack & DEB (ZH): [docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md) -- EMOS + LGBM system (ZH): [docs/EMOS_LGBM_SYSTEM_ZH.md](docs/EMOS_LGBM_SYSTEM_ZH.md) - Commercialization: [docs/COMMERCIALIZATION.md](docs/COMMERCIALIZATION.md) - AGPL-3.0 policy: [docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md) - Supabase setup (ZH): [docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md) - Configuration & secrets (ZH): [docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md) -- LightGBM daily-high model (ZH): [docs/LGBM_DAILY_HIGH_ZH.md](docs/LGBM_DAILY_HIGH_ZH.md) - Frontend deployment (ZH): [docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md) -- Tech debt (EN): [docs/TECH_DEBT.md](docs/TECH_DEBT.md) - Tech debt (ZH): [docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md) +- Airport realtime sources: [docs/AIRPORT_REALTIME_SOURCES.md](docs/AIRPORT_REALTIME_SOURCES.md) +- Airport market monitor (ZH): [docs/AIRPORT_MARKET_MONITOR_ZH.md](docs/AIRPORT_MARKET_MONITOR_ZH.md) +- Services overview (ZH): [docs/SERVICES_ZH.md](docs/SERVICES_ZH.md) - Payment verification: [docs/payments/POLYGONSCAN_VERIFY.md](docs/payments/POLYGONSCAN_VERIFY.md) - Payment audit: [docs/payments/PAYMENT_AUDIT_ZH.md](docs/payments/PAYMENT_AUDIT_ZH.md) - Payment V2 upgrade: [docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md) - Ops admin guide: [docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md) - Monitoring guide (ZH): [docs/MONITORING_ZH.md](docs/MONITORING_ZH.md) - Deep research report: [docs/deep-research-report.md](docs/deep-research-report.md) -- Frontend report: [FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md) - Release process: [RELEASE.md](RELEASE.md) - Changelog: [CHANGELOG.md](CHANGELOG.md) ## Version - Version: `v1.7.0` -- Last Updated: `2026-05-11` +- Last Updated: `2026-05-23` diff --git a/README_ZH.md b/README_ZH.md index 02061992..8aae3a08 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -14,7 +14,7 @@ ![PolyWeather Ankara 分析页](docs/images/demo_ankara.png) -## 当前产品状态(2026-05-11) +## 当前产品状态(2026-05-23) - 已上线订阅制:`Pro 月付 10 USDC`。 - 已上线积分体系:群内发言赚分 + 首次发言欢迎奖励 (+20) + 每日首条消息奖励 (+2) + 每周全员参与奖。 @@ -40,13 +40,12 @@ - `/ops` 现已展示缓存桶数量、summary cache hit/miss 与 prewarm heartbeat。 - 今日日内分析已改为“专业气象判断台”:顶部先给气象主判断、置信度、基准/上修/下修路径、下一观测点,再展示证据链、失效条件、确认条件和模型层。 - 日内分析弹窗在 full detail / market detail 同步完成前会锁住旧内容并显示刷新状态,避免用户短暂看到上一轮缓存数据后误判。 -- 城市决策卡已接入 AI 机场报文解读:先用 METAR、DEB、多模型集群和 AI 最高温中枢判断天气路径,再映射到 Polymarket 温度桶。 +- 城市决策卡已接入 AI 机场报文解读:先用 METAR、DEB、多模型集群和 AI 最高温中枢判断天气路径,再映射到温度桶。 - AI 机场报文解读现在同时使用页面内存缓存、浏览器 `localStorage` 和后端短 TTL 缓存;从其他选项卡切回决策卡时会优先恢复已有流式内容或最终结果。 - 市场温度桶匹配已改为完整 `all_buckets` 映射,按 exact / range / or higher / or lower 方向严格匹配,避免把天气中枢错配到不合理尾部桶。 - 决策卡中的“模型-市场差”口径为 `模型概率 - 市场隐含概率`,正值表示天气概率高于市场报价,负值表示市场已经更充分计价。 -- 概率区已改为“校准模型概率”;默认展示生产概率引擎输出,EMOS/LGBM 只在通过评估或作为 shadow 时进入解释层。 -- 今日日内结构解读已支持可选 `Groq` 改写层,失败时自动回退规则文案。 -- 前端部署文档已补充 Vercel 节流建议,包括 analytics 关闭、eager fetch 开关与扫描流量防火墙规则。 +- 概率区已改为”校准模型概率”;默认展示生产概率引擎输出(legacy 高斯或 EMOS),模型共识作为辅助参考。 +- 今日日内结构解读已支持可选 Groq 改写层,失败时自动回退规则文案。 - 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md`。 ## 许可证与商用边界(重要) @@ -63,13 +62,11 @@ - 聚合 51 个监控城市的实测与预报数据。 - DEB(Dynamic Error Balancing)融合多模型最高温。 -- 输出结算导向校准概率分布(`mu` + 温度桶),并在 LGBM 生效时展示校准引擎元数据。 -- 将模型观点映射到 Polymarket 行情,做错价扫描。 +- 输出结算导向校准概率分布(`mu` + 温度桶),通过 legacy 高斯或 EMOS/CRPS 校准引擎。 - 地图城市决策卡把 AI 机场报文解读、最高温中枢、完整市场温度桶和模型-市场差放在同一张卡中展示。 - Web 仪表盘与 Telegram Bot 复用同一分析内核。 - 支付链路具备事件重放、SQLite 审计事件与 RPC 容灾能力。 - 官方增强层与跑道级传感器支持按国家 provider 统一接入(含韩国 AMOS 首尔/釜山跑道实测),不替代机场主站、METAR 或明确官方结算站。 -- 支持后台预热热点城市,降低用户点击城市后的冷启动成本。 ## 参考架构 @@ -90,10 +87,6 @@ flowchart LR API --> ANA["DEB + 趋势 + 概率 + 市场扫描"] ANA --> PAY["支付状态(Intent + Event + Confirm Loop)"] - ANA --> PM["Polymarket 只读层"] - API --> OBS["healthz / system status / metrics"] - API --> PREWARM["Dashboard 预热接口 / Worker"] - ANA --> LLM["可选 Groq 文案改写层"] ANA --> STATE["SQLite runtime state
legacy files only for migration/export fallback"] ``` @@ -158,21 +151,7 @@ curl http://127.0.0.1:8000/api/system/status curl http://127.0.0.1:8000/metrics ``` -### Dashboard 预热 Worker - -```bash -docker compose --profile workers up -d polyweather_prewarm -curl http://127.0.0.1:8000/api/system/status -``` - -重点关注: - -- `prewarm.thread_alive` -- `prewarm.runtime.cycle_count` -- `prewarm.runtime.last_summary_ok` -- `cache.analysis.hit_rate` - -### 前端缓存头 +### 外部监控栈 ```bash ./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app" @@ -215,12 +194,6 @@ curl http://127.0.0.1:8000/api/payments/runtime POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com ``` -### 钱包异动监听日志 - -```bash -docker compose logs -f polyweather | egrep "polymarket wallet activity watcher started|wallet activity pushed" -``` - ## Telegram 指令 | 指令 | 用途 | @@ -241,24 +214,21 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s - Supabase 接入:[docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md) - 配置与密钥管理:[docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md) - 前端部署(Vercel):[docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md) -- EMOS 训练报告:[docs/EMOS_TRAINING_REPORT_ZH.md](docs/EMOS_TRAINING_REPORT_ZH.md) -- 概率快照归档:[docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md](docs/PROBABILITY_SNAPSHOT_ARCHIVE_ZH.md) -- 技术债(中文镜像):[docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md) -- 技术债(主文档):[docs/TECH_DEBT.md](docs/TECH_DEBT.md) +- 技术债:[docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md) +- 机场实时数据源:[docs/AIRPORT_REALTIME_SOURCES.md](docs/AIRPORT_REALTIME_SOURCES.md) +- 机场市场监控(中文):[docs/AIRPORT_MARKET_MONITOR_ZH.md](docs/AIRPORT_MARKET_MONITOR_ZH.md) +- 外部服务总览:[docs/SERVICES_ZH.md](docs/SERVICES_ZH.md) - 支付合约验证:[docs/payments/POLYGONSCAN_VERIFY.md](docs/payments/POLYGONSCAN_VERIFY.md) - 支付审计说明:[docs/payments/PAYMENT_AUDIT_ZH.md](docs/payments/PAYMENT_AUDIT_ZH.md) - 支付 V2 升级方案:[docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md) - 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md) - 外部监控说明:[docs/MONITORING_ZH.md](docs/MONITORING_ZH.md) - 模型栈与 DEB:[docs/MODEL_STACK_AND_DEB_ZH.md](docs/MODEL_STACK_AND_DEB_ZH.md) -- LightGBM 日最高温模型:[docs/LGBM_DAILY_HIGH_ZH.md](docs/LGBM_DAILY_HIGH_ZH.md) -- EMOS + LGBM 系统:[docs/EMOS_LGBM_SYSTEM_ZH.md](docs/EMOS_LGBM_SYSTEM_ZH.md) - 深度评估报告:[docs/deep-research-report.md](docs/deep-research-report.md) -- 前端报告:[FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md) - 发布流程:[RELEASE.md](RELEASE.md) - 变更记录:[CHANGELOG.md](CHANGELOG.md) ## 当前版本 - 版本:`v1.7.0` -- 文档最后更新:`2026-05-11` +- 文档最后更新:`2026-05-23` diff --git a/RELEASE.md b/RELEASE.md index 9b19ebf5..626d2fe9 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -12,9 +12,9 @@ 示例: -- `1.4.0 -> 1.4.1`:告警逻辑修正、缓存修正、文档修正 -- `1.4.0 -> 1.5.0`:新增支付能力、新增页面、新增 API -- `1.4.0 -> 2.0.0`:接口重构或数据结构不兼容 +- `1.7.0 -> 1.7.1`:告警逻辑修正、缓存修正、文档修正 +- `1.7.0 -> 1.8.0`:新增能力、接口扩展、向后兼容的功能迭代 +- `1.7.0 -> 2.0.0`:不兼容变更、核心架构升级 ## 日常升版步骤 @@ -29,7 +29,7 @@ python scripts/bump_version.py patch ```bash python scripts/bump_version.py minor python scripts/bump_version.py major -python scripts/bump_version.py 1.5.0 +python scripts/bump_version.py 1.8.0 ``` ### 2. 检查同步结果 @@ -68,15 +68,15 @@ python -m pytest ```bash git add . -git commit -m "release: v1.4.1" -git tag v1.4.1 +git commit -m "release: v1.7.1" +git tag v1.7.1 ``` ### 6. 推送 ```bash git push -git push origin v1.4.1 +git push origin v1.7.1 ``` ## 当前约束 diff --git a/docs/CONFIGURATION_ZH.md b/docs/CONFIGURATION_ZH.md index ff7b0d2c..a4248ecd 100644 --- a/docs/CONFIGURATION_ZH.md +++ b/docs/CONFIGURATION_ZH.md @@ -105,12 +105,9 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级 - `POLYWEATHER_OPS_ADMIN_EMAILS` - `POLYWEATHER_STATE_STORAGE_MODE` - `POLYWEATHER_PAYMENT_ENABLED` -- `POLYMARKET_MARKET_SCAN_ENABLED` - `POLYGON_WALLET_WATCH_ENABLED` - `TELEGRAM_ALERT_PUSH_ENABLED` - `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED` -- `POLYMARKET_WALLET_ACTIVITY_ENABLED`(已退役,建议保持 `false`) -- `POLYWEATHER_DASHBOARD_PREWARM_ENABLED` ### 4.3 L3:运行调优项 @@ -130,26 +127,12 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级 - `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N` - `POLYWEATHER_PAYMENT_RPC_URLS` - `TAF_CACHE_TTL_SEC` -- `POLYWEATHER_PREWARM_INTERVAL_SEC` -- `POLYWEATHER_PREWARM_JITTER_SEC` -- `POLYWEATHER_PREWARM_CITIES` -- `POLYWEATHER_PREWARM_INCLUDE_DETAIL` -- `POLYWEATHER_PREWARM_INCLUDE_MARKET` -- `POLYWEATHER_PREWARM_FORCE_REFRESH` 策略: - 先用默认值 - 出现性能或运维问题时再调 -当前默认预热名单优先覆盖: - -- 亚洲:Shanghai、Beijing、Shenzhen、Wuhan、Chengdu、Chongqing、Hong Kong、Taipei、Singapore、Tokyo、Seoul、Busan -- 中东:Ankara、Istanbul -- 欧洲:London、Paris、Madrid - -默认不再包含美国城市;如果线上 `.env` 已手动设置 `POLYWEATHER_PREWARM_CITIES`,则会以你的显式配置为准。 - ### 4.4 L4:敏感项 这些变量不应写进公开文档截图,也不应提交到仓库。 @@ -160,9 +143,7 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级 - `SUPABASE_SERVICE_ROLE_KEY` - `POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN` - `POLYWEATHER_DASHBOARD_ACCESS_TOKEN` -- `METEOBLUE_API_KEY` - `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` -- `POLYMARKET_SECRET_KEY` ## 5. 推荐部署矩阵 @@ -254,12 +235,6 @@ TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200 TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800 TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5 -POLYMARKET_WALLET_ACTIVITY_ENABLED=false -POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true -POLYWEATHER_PREWARM_INTERVAL_SEC=300 -POLYWEATHER_PREWARM_JITTER_SEC=20 -POLYWEATHER_PREWARM_INCLUDE_DETAIL=true -POLYWEATHER_PREWARM_INCLUDE_MARKET=true POLYWEATHER_BACKEND_URL=http://polyweather_web:8000 POLYWEATHER_SCAN_AI_ENABLED=false POLYWEATHER_SCAN_AI_API_KEY=... @@ -281,29 +256,6 @@ POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro - 机器人市场监控包含 `关键提醒` 与 `关注清单`:关键提醒逐城判断并受冷却控制,关注清单每轮先扫描完整城市列表,再按全局 Top N 推送;同一轮已经触发关键提醒的城市不会重复出现在关注清单里。 - `POLYWEATHER_SCAN_AI_*` 走 OpenAI-compatible `/chat/completions`;当前临时默认 MiMo,可用 `POLYWEATHER_SCAN_AI_BASE_URL` 与 `POLYWEATHER_SCAN_AI_MODEL` 随时切回其他兼容 provider。 - `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。 -- `POLYMARKET_WALLET_ACTIVITY_ENABLED` 已退役,保留为 `false` 即可,不建议再启用钱包异动监听。 -- `POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true` 时,建议同时启用独立 worker 或 bot 内嵌预热线程。 - -### 6.3 Dashboard 预热 worker 推荐变量 - -```env -POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true -POLYWEATHER_PREWARM_INTERVAL_SEC=300 -POLYWEATHER_PREWARM_JITTER_SEC=20 -POLYWEATHER_PREWARM_CITIES=ankara,istanbul,shanghai,beijing,shenzhen,wuhan,chengdu,chongqing,hong kong,taipei,singapore,tokyo,seoul,busan,london,paris,madrid -POLYWEATHER_PREWARM_INCLUDE_DETAIL=true -POLYWEATHER_PREWARM_INCLUDE_MARKET=true -POLYWEATHER_PREWARM_FORCE_REFRESH=false -POLYWEATHER_BACKEND_URL=http://polyweather_web:8000 -``` - -说明: - -- 这组变量用于后台定向预热热点城市,避免用户点击城市时才冷启动拉 detail。 - - -```env -``` 说明: @@ -311,7 +263,7 @@ POLYWEATHER_BACKEND_URL=http://polyweather_web:8000 ### 6.5 机器人市场监控建议配置 -这套配置用于替代旧的钱包异动监听,围绕市场本身做两类推送: +这套配置围绕市场本身做两类推送: - `关键提醒`:实时错价/触发条件满足时发送 - `关注清单`:按亚洲时区定时推送当日重点市场摘要 @@ -329,7 +281,6 @@ TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200 TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800 TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5 -POLYMARKET_WALLET_ACTIVITY_ENABLED=false ``` 说明: @@ -337,7 +288,6 @@ POLYMARKET_WALLET_ACTIVITY_ENABLED=false - `TELEGRAM_ALERT_MISPRICING_ONLY=true` 表示关键提醒优先围绕错价/市场触发,不把机器人做成泛通知器。 - `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800` 表示频道每 30 分钟主动推送一轮全局机会清单;每轮会先扫描完整 `TELEGRAM_ALERT_CITIES`,再选 Top N。 - `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5` 建议先保持较小,避免机器人一次推太多城市。 -- `POLYMARKET_WALLET_ACTIVITY_ENABLED=false` 表示停用旧的钱包异动监听,统一收敛到市场监控。 ## 7. 当前建议的运维规则 diff --git a/docs/MONITORING_ZH.md b/docs/MONITORING_ZH.md index 80407777..a94c3990 100644 --- a/docs/MONITORING_ZH.md +++ b/docs/MONITORING_ZH.md @@ -114,12 +114,6 @@ python scripts/check_ops_health.py --base-url http://127.0.0.1:8000 目前已覆盖: -- `prewarm` worker 是否启用、线程 / heartbeat 是否活着 -- 最近一轮 prewarm 的: - - `cycle_count` - - `success_count / failure_count` - - `last_started_at / last_finished_at` - - `last_summary_ok / last_detail_ok / last_market_ok` - 缓存桶条目数: - `api_cache` - `metar` diff --git a/docs/SERVICES_ZH.md b/docs/SERVICES_ZH.md index 23da9257..77216818 100644 --- a/docs/SERVICES_ZH.md +++ b/docs/SERVICES_ZH.md @@ -1,8 +1,8 @@ # 外部服务依赖总览 -最后更新:`2026-05-19` +最后更新:`2026-05-23` -项目调用了 24 个外部服务,按状态分为三类。 +项目调用了 20 个外部服务,按状态分为三类。 ## 核心(必须有,挂了服务不可用) @@ -13,10 +13,7 @@ | MADIS (NOAA) | 美国 5 分钟高频观测 | ✅ | | Supabase | 用户认证 + 订阅 | ✅ | | Telegram Bot API | Bot 消息 + 群成员检查 | ✅ | -| KNMI | Amsterdam 10 分钟观测 | ✅ | -| Polymarket Gamma/CLOB | 市场数据读取 | ✅ | - -## 国家气象源(特定城市必须) +| KNMI | Amsterdam 10 分钟观测 | ✅ |## 国家气象源(特定城市必须) | 服务 | 城市 | 状态 | | -------------------- | --------------------- | ----------- | @@ -30,7 +27,7 @@ | NMC (中国) | 国内城市 fallback | ✅ | | Singapore MSS | Singapore | ✅ | | IMGW (波兰) | Warsaw | ⚠️ 未配 key | -| Russia pogodaiklimat | Moscow | ✅ | +| Russia pogodaiklimat | Moscow | ❌ 已移除 | ## 可选 / 已禁用 @@ -38,7 +35,7 @@ | -------------- | ------------- | ----------- | | OpenWeatherMap | 天气 fallback | ⚠️ 未配 key | | VisualCrossing | 历史天气 | ⚠️ 未配 key | -| Meteoblue | 天气预报 | ⚠️ 未配 key | +| Meteoblue | 天气预报 | ❌ 已移除 | | SynopticData | 美国站点观测 | ⚠️ 未配 key | ## AI / 其他 @@ -47,10 +44,10 @@ | ----------------- | ---------------- | ----------- | | MiMo (xiaomimimo) | 城市分析 AI 评论 | ✅ 当前使用 | | DeepSeek | AI fallback | - 备用 | -| Groq | AI commentary | ⚠️ 未配 key | +| Groq | AI commentary | ❌ 已移除 | | Polygon RPC | 链上支付 | ✅ | | WalletConnect | 前端钱包连接 | ⚠️ 未配 key | ## 合计 -18 个在用,6 个可选/未配置。 +15 个在用,3 个可选/未配置,3 个已移除。 diff --git a/docs/SUPABASE_SETUP_ZH.md b/docs/SUPABASE_SETUP_ZH.md index 6f6e6143..66af9799 100644 --- a/docs/SUPABASE_SETUP_ZH.md +++ b/docs/SUPABASE_SETUP_ZH.md @@ -92,20 +92,7 @@ POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED=true POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true ``` -## 5. 钱包异动频道拆分(推荐) - -如果要把“钱包异动监控”发到独立频道: - -```env -POLYMARKET_WALLET_ACTIVITY_CHAT_ID=-1003821482461 -``` - -说明: - -- 设置了 `POLYMARKET_WALLET_ACTIVITY_CHAT_ID(S)` 后,钱包异动推送优先发该频道。 -- 未设置时,回退到全局 `TELEGRAM_CHAT_IDS/TELEGRAM_CHAT_ID`。 - -## 6. 验证步骤 +## 5. 验证步骤 1. 登录后请求 `/api/auth/me`,确认 `authenticated=true`。 2. 请求 `/api/payments/config`,确认 `enabled=true`、`configured=true`。 diff --git a/docs/TECH_DEBT_ZH.md b/docs/TECH_DEBT_ZH.md index 96020483..160a33dc 100644 --- a/docs/TECH_DEBT_ZH.md +++ b/docs/TECH_DEBT_ZH.md @@ -46,7 +46,6 @@ flowchart TD - 支付运行态 API 与 SQLite 审计事件已补齐。 - 钱包绑定支持浏览器钱包 + WalletConnect。 - 账户中心与 Pro 权限展示链路打通。 -- 钱包异动支持独立频道路由。 - 运行态状态/缓存与核心离线训练、评估、回填链路已完成 SQLite 主路径收口。 - 轻量可观测性已上线(`/healthz`、`/api/system/status`、`/metrics`)。 diff --git a/frontend/README.md b/frontend/README.md index da2462a5..6ce29413 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -40,8 +40,8 @@ PolyWeather Pro 的生产前端工程。 - 城市决策卡的 AI 机场报文解读包括最终判断、METAR 解读、推理说明、模型集群备注、风险提示和原始 METAR - AI 机场报文解读按 `city + local_date + locale + METAR signature` 做页面内存缓存和 `localStorage` 最终结果缓存;切换选项卡返回时会优先恢复已有内容 - 市场价格层使用完整 `all_buckets` 匹配温度桶,并把 `模型-市场差` 解释为 `模型概率 - 市场隐含概率` -- 概率区展示当前生产概率引擎输出;EMOS / LGBM 只在评估通过或 shadow 对照时进入解释层,模型共识和市场价格只作为辅助说明 -- `/ops` 现已展示 prewarm worker 运行态、缓存桶状态与 summary cache hit/miss +- 概率区展示当前生产概率引擎输出(legacy 高斯或 EMOS),模型共识只作为辅助参考 +- 缓存桶状态与 summary cache hit/miss ## 本地开发 @@ -150,7 +150,6 @@ Ops: - 系统状态 - SQLite / rollout / 支付运行态 -- prewarm worker 运行态 - 缓存桶状态与 summary cache hit/miss - 用户查询 - 当前会员 @@ -201,4 +200,4 @@ Ops: 详见根目录策略文档:`docs/OPEN_CORE_POLICY.md` -最后更新:`2026-04-19` +最后更新:`2026-05-23` diff --git a/src/bot/runtime_coordinator.py b/src/bot/runtime_coordinator.py index e10a1946..39966a60 100644 --- a/src/bot/runtime_coordinator.py +++ b/src/bot/runtime_coordinator.py @@ -85,7 +85,6 @@ class StartupCoordinator: loops = [ self._start_airport_high_freq_loop(), self._start_polygon_wallet_loop(), - self._start_polymarket_wallet_activity_loop(), self._start_weekly_reward_loop(), self._start_payment_event_loop(), self._start_payment_confirm_loop(), @@ -219,18 +218,6 @@ class StartupCoordinator: ).start_polygon_wallet_watch_loop(self.bot), ) - def _start_polymarket_wallet_activity_loop(self) -> LoopStatus: - return LoopStatus( - key="polymarket_wallet_activity", - label="Polymarket 钱包异动监听(已停用)", - configured_enabled=False, - started=False, - reason="retired_replaced_by_market_monitor", - details={ - "note": "wallet activity watcher retired", - }, - ) - def _start_weekly_reward_loop(self) -> LoopStatus: enabled = _env_bool("POLYWEATHER_WEEKLY_REWARD_ENABLED", True) chat_ids = get_telegram_chat_ids_from_env() diff --git a/src/onchain/polymarket_wallet_activity_watcher.py b/src/onchain/polymarket_wallet_activity_watcher.py deleted file mode 100644 index 9aae0908..00000000 --- a/src/onchain/polymarket_wallet_activity_watcher.py +++ /dev/null @@ -1,914 +0,0 @@ -import json -import os -import threading -import time -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -from typing import Any, Dict, List, Optional, Tuple - -import requests -from loguru import logger - -from src.utils.telegram_chat_ids import ( - get_polymarket_wallet_activity_chat_ids_from_env, -) - - -def _env_bool(name: str, default: bool) -> bool: - raw = os.getenv(name) - if raw is None: - return default - return raw.strip().lower() in {"1", "true", "yes", "on"} - - -def _env_int(name: str, default: int) -> int: - raw = os.getenv(name) - if raw is None: - return default - try: - return int(raw) - except Exception: - return default - - -def _env_float(name: str, default: float) -> float: - raw = os.getenv(name) - if raw is None: - return default - try: - return float(raw) - except Exception: - return default - - -def _safe_float(value: Any, default: float = 0.0) -> float: - try: - if value is None: - return default - return float(value) - except Exception: - return default - - -def _normalize_addr(value: Any) -> str: - if value is None: - return "" - text = str(value).strip().lower() - if text.startswith("0x") and len(text) == 42: - return text - return "" - - -def _short(addr: str, left: int = 6, right: int = 4) -> str: - if not addr: - return "unknown" - if len(addr) <= left + right + 2: - return addr - return f"{addr[:left + 2]}...{addr[-right:]}" - - -def _parse_addresses(raw: Optional[str]) -> List[str]: - out: List[str] = [] - if not raw: - return out - seen = set() - for part in raw.split(","): - addr = _normalize_addr(part) - if addr and addr not in seen: - out.append(addr) - seen.add(addr) - return out - - -def _parse_address_set(raw: Optional[str]) -> set[str]: - return set(_parse_addresses(raw)) - - -def _parse_address_aliases(raw: Optional[str]) -> Dict[str, str]: - """ - Parse wallet aliases from either: - - JSON object: {"0xabc...": "Whale A", "0xdef...": "Whale B"} - - CSV pairs: 0xabc...=Whale A,0xdef...=Whale B - """ - out: Dict[str, str] = {} - if not raw: - return out - - text = str(raw).strip() - if not text: - return out - - if text.startswith("{"): - try: - parsed = json.loads(text) - if isinstance(parsed, dict): - for k, v in parsed.items(): - addr = _normalize_addr(k) - alias = str(v or "").strip() - if addr and alias: - out[addr] = alias - return out - except Exception: - pass - - for part in text.split(","): - row = part.strip() - if not row: - continue - if "=" in row: - left, right = row.split("=", 1) - elif ":" in row: - left, right = row.split(":", 1) - else: - continue - addr = _normalize_addr(left) - alias = str(right or "").strip() - if addr and alias: - out[addr] = alias - - return out - - -def _state_file() -> str: - root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - return os.path.join(root, "data", "polymarket_wallet_activity_state.json") - - -def _load_state(path: str) -> Dict[str, Any]: - if not os.path.exists(path): - return {"users": {}} - try: - with open(path, "r", encoding="utf-8") as fh: - data = json.load(fh) - if isinstance(data, dict): - data.setdefault("users", {}) - return data - except Exception as exc: - logger.warning(f"failed to load wallet activity state: {exc}") - return {"users": {}} - - -def _save_state(path: str, state: Dict[str, Any]) -> None: - os.makedirs(os.path.dirname(path), exist_ok=True) - tmp = f"{path}.tmp" - with open(tmp, "w", encoding="utf-8") as fh: - json.dump(state, fh, ensure_ascii=False, indent=2) - os.replace(tmp, path) - - -def _market_url(position: Dict[str, Any]) -> str: - slug = str(position.get("slug") or "").strip() - event_slug = str(position.get("event_slug") or "").strip() - - # Prefer event-level URL first: Telegram preview is usually more stable. - if event_slug: - return f"https://polymarket.com/event/{event_slug}" - if slug: - return f"https://polymarket.com/market/{slug}" - return "" - - -def _position_key(position: Dict[str, Any]) -> str: - asset = str(position.get("asset") or "").strip().lower() - condition_id = str(position.get("condition_id") or "").strip().lower() - outcome = str(position.get("outcome") or "").strip().lower() - if asset: - return f"asset:{asset}" - return f"condition:{condition_id}|outcome:{outcome}" - - -def _normalize_position(row: Dict[str, Any]) -> Dict[str, Any]: - title = ( - str( - row.get("title") - or row.get("question") - or row.get("market") - or row.get("name") - or "" - ) - .strip() - ) - outcome = str(row.get("outcome") or row.get("side") or "").strip() - - return { - "proxy_wallet": _normalize_addr(row.get("proxyWallet") or row.get("proxy_wallet")), - "asset": str(row.get("asset") or row.get("assetId") or "").strip(), - "condition_id": str(row.get("conditionId") or row.get("condition_id") or "").strip(), - "title": title, - "slug": str(row.get("slug") or row.get("marketSlug") or "").strip(), - "event_slug": str(row.get("eventSlug") or row.get("event_slug") or "").strip(), - "outcome": outcome, - "size": _safe_float(row.get("size") or row.get("shares")), - "avg_price": _safe_float(row.get("avgPrice") or row.get("avg_price")), - "position_value": _safe_float(row.get("currentValue") or row.get("positionValue")), - "cash_pnl": _safe_float(row.get("cashPnl") or row.get("realizedPnl") or row.get("pnl")), - "percent_pnl": _safe_float( - row.get("percentPnl") - or row.get("pnlPercent") - or row.get("pnl_pct") - ), - "cur_price": _safe_float(row.get("curPrice") or row.get("lastPrice")), - "updated_at": int(time.time()), - } - - -def _fetch_positions( - session: requests.Session, - base_url: str, - user: str, - timeout_sec: int, -) -> List[Dict[str, Any]]: - url = f"{base_url.rstrip('/')}/positions" - resp = session.get(url, params={"user": user}, timeout=timeout_sec) - resp.raise_for_status() - data = resp.json() - - if isinstance(data, list): - return [row for row in data if isinstance(row, dict)] - - if isinstance(data, dict): - for key in ("positions", "data", "results", "items"): - maybe = data.get(key) - if isinstance(maybe, list): - return [row for row in maybe if isinstance(row, dict)] - - return [] - - -def _build_snapshot( - rows: List[Dict[str, Any]], - min_size_abs: float, -) -> Dict[str, Dict[str, Any]]: - snap: Dict[str, Dict[str, Any]] = {} - for row in rows: - pos = _normalize_position(row) - if abs(pos["size"]) < min_size_abs: - continue - key = _position_key(pos) - snap[key] = pos - return snap - - -def _diff_positions( - previous: Dict[str, Dict[str, Any]], - current: Dict[str, Dict[str, Any]], - min_size_delta: float, - notify_closed: bool, - min_price: float = 0.0, - max_price: float = 1.0, - min_avg_price_delta: float = 0.002, -) -> List[Tuple[str, Dict[str, Any]]]: - changes: List[Tuple[str, Dict[str, Any]]] = [] - min_avg_price_delta = max(0.0, min_avg_price_delta) - - for key, now_pos in current.items(): - # 价格过滤:如果设置了价格区间,不符合的直接跳过 - price = _safe_float(now_pos.get("avg_price")) - if price < min_price or price > max_price: - logger.debug(f"skipped position due to price range limit: market={now_pos.get('title')} price={price}") - continue - - old_pos = previous.get(key) - if old_pos is None: - changes.append(("new", now_pos)) - continue - - size_delta = now_pos["size"] - old_pos.get("size", 0.0) - avg_delta = now_pos["avg_price"] - old_pos.get("avg_price", 0.0) - - if abs(size_delta) >= min_size_delta or abs(avg_delta) >= min_avg_price_delta: - merged = {**now_pos} - merged["size_delta"] = size_delta - merged["old_size"] = old_pos.get("size", 0.0) - merged["old_avg_price"] = old_pos.get("avg_price", 0.0) - changes.append(("update", merged)) - - if notify_closed: - for key, old_pos in previous.items(): - if key not in current: - changes.append(("closed", old_pos)) - - return changes - - -def _merge_pending_update( - pending_updates: Dict[str, Dict[str, Any]], - pos_key: str, - pos: Dict[str, Any], - now_ts: int, -) -> None: - entry = pending_updates.get(pos_key) - size_delta = _safe_float(pos.get("size_delta")) - old_size = _safe_float(pos.get("old_size")) - new_size = _safe_float(pos.get("size")) - old_avg = _safe_float(pos.get("old_avg_price")) - new_avg = _safe_float(pos.get("avg_price")) - - if entry is None: - pending_updates[pos_key] = { - "count": 1, - "first_ts": now_ts, - "last_ts": now_ts, - "title": pos.get("title"), - "slug": pos.get("slug"), - "event_slug": pos.get("event_slug"), - "outcome": pos.get("outcome"), - "asset": pos.get("asset"), - "condition_id": pos.get("condition_id"), - "old_size": old_size, - "size": new_size, - "size_delta": size_delta, - "old_avg_price": old_avg, - "avg_price": new_avg, - "position_value": _safe_float(pos.get("position_value")), - "cash_pnl": _safe_float(pos.get("cash_pnl")), - "percent_pnl": _safe_float(pos.get("percent_pnl")), - } - return - - entry["count"] = int(entry.get("count", 1)) + 1 - entry["last_ts"] = now_ts - entry["size_delta"] = _safe_float(entry.get("size_delta")) + size_delta - entry["size"] = new_size - entry["avg_price"] = new_avg - entry["position_value"] = _safe_float(pos.get("position_value")) - entry["cash_pnl"] = _safe_float(pos.get("cash_pnl")) - entry["percent_pnl"] = _safe_float(pos.get("percent_pnl")) - pending_updates[pos_key] = entry - - -def _finalize_pending_update( - pending_entry: Dict[str, Any], - now_ts: int, -) -> Dict[str, Any]: - first_ts = int(pending_entry.get("first_ts") or now_ts) - last_ts = int(pending_entry.get("last_ts") or now_ts) - return { - "title": pending_entry.get("title") or "", - "slug": pending_entry.get("slug") or "", - "event_slug": pending_entry.get("event_slug") or "", - "outcome": pending_entry.get("outcome") or "", - "asset": pending_entry.get("asset") or "", - "condition_id": pending_entry.get("condition_id") or "", - "old_size": _safe_float(pending_entry.get("old_size")), - "size": _safe_float(pending_entry.get("size")), - "size_delta": _safe_float(pending_entry.get("size_delta")), - "old_avg_price": _safe_float(pending_entry.get("old_avg_price")), - "avg_price": _safe_float(pending_entry.get("avg_price")), - "position_value": _safe_float(pending_entry.get("position_value")), - "cash_pnl": _safe_float(pending_entry.get("cash_pnl")), - "percent_pnl": _safe_float(pending_entry.get("percent_pnl")), - "agg_count": int(pending_entry.get("count") or 1), - "agg_span_sec": max(0, last_ts - first_ts), - } - - -def _flush_ready_pending_updates( - pending_updates: Dict[str, Dict[str, Any]], - now_ts: int, - debounce_sec: int, - max_hold_sec: int, - force_keys: Optional[set] = None, - blocked_keys: Optional[set] = None, -) -> List[Tuple[str, Dict[str, Any]]]: - out: List[Tuple[str, Dict[str, Any]]] = [] - keys = list(pending_updates.keys()) - for key in keys: - entry = pending_updates.get(key) - if not isinstance(entry, dict): - pending_updates.pop(key, None) - continue - - if blocked_keys and key in blocked_keys: - continue - - if force_keys and key in force_keys: - out.append(("update", _finalize_pending_update(entry, now_ts))) - pending_updates.pop(key, None) - continue - - first_ts = int(entry.get("first_ts") or now_ts) - last_ts = int(entry.get("last_ts") or now_ts) - quiet_enough = (now_ts - last_ts) >= debounce_sec - held_too_long = (now_ts - first_ts) >= max_hold_sec - if quiet_enough or held_too_long: - out.append(("update", _finalize_pending_update(entry, now_ts))) - pending_updates.pop(key, None) - - return out - - -def _collect_suppressed_update_keys( - new_push_meta: Dict[str, Any], - now_ts: int, - suppress_sec: int, -) -> set[str]: - if suppress_sec <= 0: - return set() - out: set[str] = set() - stale: List[str] = [] - for key, ts_raw in new_push_meta.items(): - ts = int(ts_raw or 0) - if ts <= 0: - stale.append(str(key)) - continue - age = now_ts - ts - if age < suppress_sec: - out.add(str(key)) - elif age >= suppress_sec * 3: - stale.append(str(key)) - for key in stale: - new_push_meta.pop(key, None) - return out - - -def _fmt_pct(value: float) -> str: - # Data API may return either ratio (0.12) or percent (12.0). - display = value * 100.0 if abs(value) <= 1.5 else value - return f"{display:.1f}%" - - -def _fmt_usd(value: float) -> str: - return f"${value:.2f}" - - -def _fmt_price(value: float) -> str: - return f"{value:.3f}" - - -def _should_show_avg_price(avg_price: float) -> bool: - # Extreme prices near 0/1 are usually not informative for activity alerts. - min_show = max( - 0.0, - min(1.0, _env_float("POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MIN", 0.01)), - ) - max_show = max( - 0.0, - min(1.0, _env_float("POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MAX", 0.99)), - ) - if min_show > max_show: - min_show, max_show = max_show, min_show - return min_show <= avg_price <= max_show - - -def _format_change_block( - change_type: str, - wallet: str, - wallet_alias: Optional[str], - pos: Dict[str, Any], - now_utc: str, -) -> str: - title = pos.get("title") or "Unknown market" - outcome = pos.get("outcome") or "Unknown" - market_url = _market_url(pos) - - lines: List[str] = [] - if change_type == "new": - lines.append("🆕 新开仓位") - elif change_type == "closed": - lines.append("❌ 仓位关闭") - else: - agg_count = int(pos.get("agg_count") or 1) - if agg_count > 1: - lines.append("🔁 连续仓位变动汇总") - else: - lines.append("🔄 仓位更新") - - wallet_label = _short(wallet) - if wallet_alias: - wallet_label = f"{wallet_alias} ({wallet_label})" - lines.append(f"钱包: {wallet_label}") - lines.append(f"市场: {title}") - if market_url: - # Keep raw URL on its own line so Telegram can generate link preview. - lines.append(f"链接: {market_url}") - - lines.append(f"买入方向: {outcome}") - - if change_type == "update": - old_size = _safe_float(pos.get("old_size")) - now_size = _safe_float(pos.get("size")) - delta = _safe_float(pos.get("size_delta")) - lines.append(f"持有数量: {old_size:.3f} -> {now_size:.3f} (Δ {delta:+.3f})") - agg_count = int(pos.get("agg_count") or 1) - if agg_count > 1: - span_sec = int(_safe_float(pos.get("agg_span_sec"))) - lines.append(f"变动次数: {agg_count} 次 | 聚合窗口: {span_sec}s") - else: - lines.append(f"持有数量: {_safe_float(pos.get('size')):.3f}") - - avg_price = _safe_float(pos.get("avg_price")) - old_avg_price = _safe_float(pos.get("old_avg_price")) - agg_count = int(pos.get("agg_count") or 1) - if change_type == "update" and agg_count > 1: - if _should_show_avg_price(old_avg_price) or _should_show_avg_price(avg_price): - lines.append(f"建仓均价: {_fmt_price(old_avg_price)} -> {_fmt_price(avg_price)}") - elif _should_show_avg_price(avg_price): - lines.append(f"建仓均价: {_fmt_price(avg_price)}") - lines.append(f"当前价值: {_fmt_usd(_safe_float(pos.get('position_value')))}") - - pnl = _safe_float(pos.get("cash_pnl")) - pnl_pct = _safe_float(pos.get("percent_pnl")) - lines.append(f"盈亏: {_fmt_usd(pnl)} ({_fmt_pct(pnl_pct)})") - lines.append(f"时间: {now_utc}") - return "\n".join(lines) - - -def _build_message( - wallet: str, - changes: List[Tuple[str, Dict[str, Any]]], - max_changes: int, - wallet_alias: Optional[str] = None, -) -> str: - now_bj = ( - datetime.now(timezone.utc) - .astimezone(ZoneInfo("Asia/Shanghai")) - .strftime("%Y-%m-%d %H:%M:%S") - ) - shown = changes[:max_changes] - lines = [f"🚨 钱包异动监控 ({len(changes)} 个异动):", ""] - - for idx, (change_type, pos) in enumerate(shown): - lines.append( - _format_change_block( - change_type, - wallet, - wallet_alias, - pos, - f"{now_bj} 北京时间", - ) - ) - if idx != len(shown) - 1: - lines.append("") - - if len(changes) > max_changes: - lines.append("") - lines.append(f"... 以及其他 {len(changes) - max_changes} 个异动") - - return "\n".join(lines) - - -def _filter_changes_by_position_value( - *, - wallet: str, - changes: List[Tuple[str, Dict[str, Any]]], - min_position_value_usd: float, - exempt_wallets: set[str], -) -> List[Tuple[str, Dict[str, Any]]]: - if min_position_value_usd <= 0: - return changes - if wallet in exempt_wallets: - return changes - - out: List[Tuple[str, Dict[str, Any]]] = [] - for change_type, pos in changes: - value = _safe_float(pos.get("position_value")) - if value >= min_position_value_usd: - out.append((change_type, pos)) - else: - logger.info( - "wallet activity skipped by position value floor user={} change={} value={:.2f} floor={:.2f} market={}", - wallet, - change_type, - value, - min_position_value_usd, - str(pos.get("title") or ""), - ) - return out - - -def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread]: - enabled = _env_bool("POLYMARKET_WALLET_ACTIVITY_ENABLED", False) - chat_ids = get_polymarket_wallet_activity_chat_ids_from_env() - topic_chat_id = str( - os.getenv("POLYMARKET_WALLET_ACTIVITY_TOPIC_CHAT_ID") or "" - ).strip() - topic_thread_id = max( - 0, - _env_int("POLYMARKET_WALLET_ACTIVITY_TOPIC_ID", 0), - ) - if topic_chat_id and topic_chat_id not in chat_ids: - # Mirror wallet activity push to a forum topic without replacing existing channels. - chat_ids = [*chat_ids, topic_chat_id] - users = _parse_addresses(os.getenv("POLYMARKET_WALLET_ACTIVITY_USERS")) - user_aliases = _parse_address_aliases( - os.getenv("POLYMARKET_WALLET_ACTIVITY_USER_ALIASES") - or os.getenv("POLYMARKET_WALLET_ACTIVITY_USERS_ALIASES") - ) - exempt_wallets = _parse_address_set( - os.getenv("POLYMARKET_WALLET_ACTIVITY_MIN_VALUE_EXEMPT_USERS") - ) - - if not enabled: - logger.info("polymarket wallet activity watcher disabled") - return None - if not chat_ids: - logger.warning( - "polymarket wallet activity watcher skipped: " - "POLYMARKET_WALLET_ACTIVITY_CHAT_IDS/CHAT_ID and TELEGRAM_CHAT_IDS/CHAT_ID are empty" - ) - return None - if not users: - logger.warning("polymarket wallet activity watcher skipped: POLYMARKET_WALLET_ACTIVITY_USERS is empty") - return None - - data_api_url = str( - os.getenv("POLYMARKET_WALLET_ACTIVITY_DATA_API_URL", "https://data-api.polymarket.com") - ).strip() - poll_sec = max(5, _env_int("POLYMARKET_WALLET_ACTIVITY_INTERVAL_SEC", 20)) - timeout_sec = max(5, _env_int("POLYMARKET_WALLET_ACTIVITY_TIMEOUT_SEC", 10)) - min_size_abs = max(0.0, _env_float("POLYMARKET_WALLET_ACTIVITY_MIN_SIZE_ABS", 0.001)) - min_size_delta = max(0.0, _env_float("POLYMARKET_WALLET_ACTIVITY_MIN_SIZE_DELTA", 0.001)) - min_avg_price_delta = max( - 0.0, - _env_float("POLYMARKET_WALLET_ACTIVITY_MIN_AVG_PRICE_DELTA", 0.002), - ) - immediate_on_size_delta = _env_bool( - "POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_ON_SIZE_DELTA", - True, - ) - immediate_size_delta_min = max( - min_size_delta, - _env_float( - "POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_SIZE_DELTA_MIN", - min_size_delta, - ), - ) - immediate_cooldown_sec = max( - 0, - _env_int("POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_COOLDOWN_SEC", 20), - ) - max_changes = max(1, _env_int("POLYMARKET_WALLET_ACTIVITY_MAX_CHANGES_PER_MSG", 5)) - min_position_value_usd = max( - 0.0, _env_float("POLYMARKET_WALLET_ACTIVITY_MIN_POSITION_VALUE_USD", 0.0) - ) - notify_closed = _env_bool("POLYMARKET_WALLET_ACTIVITY_NOTIFY_CLOSED", False) - bootstrap_alert = _env_bool("POLYMARKET_WALLET_ACTIVITY_BOOTSTRAP_ALERT", False) - link_preview = _env_bool("POLYMARKET_WALLET_ACTIVITY_LINK_PREVIEW", True) - default_debounce_sec = max(poll_sec, 30) - update_debounce_sec = max( - poll_sec, - _env_int("POLYMARKET_WALLET_ACTIVITY_UPDATE_DEBOUNCE_SEC", default_debounce_sec), - ) - default_update_max_hold_sec = max(update_debounce_sec, 120) - update_max_hold_sec = max( - update_debounce_sec, - _env_int("POLYMARKET_WALLET_ACTIVITY_UPDATE_MAX_HOLD_SEC", default_update_max_hold_sec), - ) - new_update_suppress_sec = max( - 0, - _env_int( - "POLYMARKET_WALLET_ACTIVITY_NEW_POSITION_UPDATE_SUPPRESS_SEC", - 180, - ), - ) - - # 价格过滤范围配置 - min_price = _env_float("POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MIN", 0.0) - max_price = _env_float("POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MAX", 1.0) - - state_path = _state_file() - session = requests.Session() - - def _runner() -> None: - state = _load_state(state_path) - users_state = state.setdefault("users", {}) - - logger.info( - f"polymarket wallet activity watcher started users={len(users)} " - f"poll={poll_sec}s data_api={data_api_url} price_filter={min_price}-{max_price} " - f"min_position_value_usd={min_position_value_usd} " - f"min_value_exempt_users={len(exempt_wallets)} " - f"chat_targets={len(chat_ids)} " - f"topic_chat_id={topic_chat_id or '-'} topic_id={topic_thread_id} " - f"aliases={len(user_aliases)} link_preview={link_preview} " - f"min_avg_price_delta={min_avg_price_delta} " - f"immediate_on_size_delta={immediate_on_size_delta} " - f"immediate_size_delta_min={immediate_size_delta_min} " - f"immediate_cooldown={immediate_cooldown_sec}s " - f"update_debounce={update_debounce_sec}s update_max_hold={update_max_hold_sec}s " - f"new_update_suppress={new_update_suppress_sec}s" - ) - - while True: - cycle_started = time.time() - touched = False - for user in users: - try: - now_ts = int(time.time()) - rows = _fetch_positions( - session=session, - base_url=data_api_url, - user=user, - timeout_sec=timeout_sec, - ) - current = _build_snapshot(rows, min_size_abs=min_size_abs) - - user_state = users_state.get(user) if isinstance(users_state.get(user), dict) else {} - prev = (user_state.get("positions") if isinstance(user_state, dict) else {}) or {} - if not isinstance(prev, dict): - prev = {} - pending_updates = ( - user_state.get("pending_updates") if isinstance(user_state, dict) else {} - ) or {} - if not isinstance(pending_updates, dict): - pending_updates = {} - update_push_meta = ( - user_state.get("update_push_meta") if isinstance(user_state, dict) else {} - ) or {} - if not isinstance(update_push_meta, dict): - update_push_meta = {} - new_push_meta = ( - user_state.get("new_push_meta") if isinstance(user_state, dict) else {} - ) or {} - if not isinstance(new_push_meta, dict): - new_push_meta = {} - initialized = bool(user_state.get("initialized")) if isinstance(user_state, dict) else False - - # First cycle for each wallet only initializes baseline unless bootstrap alert is enabled. - if not initialized: - if not bootstrap_alert: - users_state[user] = { - "positions": current, - "pending_updates": {}, - "update_push_meta": {}, - "new_push_meta": {}, - "initialized": True, - "updated_at": now_ts, - } - touched = True - continue - prev = {} - - changes = _diff_positions( - previous=prev, - current=current, - min_size_delta=min_size_delta, - notify_closed=notify_closed, - min_price=min_price, - max_price=max_price, - min_avg_price_delta=min_avg_price_delta, - ) - suppressed_update_keys = _collect_suppressed_update_keys( - new_push_meta=new_push_meta, - now_ts=now_ts, - suppress_sec=new_update_suppress_sec, - ) - - outgoing: List[Tuple[str, Dict[str, Any]]] = [] - for change_type, pos in changes: - pos_key = _position_key(pos) - if change_type == "update": - if pos_key in suppressed_update_keys: - _merge_pending_update( - pending_updates=pending_updates, - pos_key=pos_key, - pos=pos, - now_ts=now_ts, - ) - continue - size_delta_abs = abs(_safe_float(pos.get("size_delta"))) - if ( - immediate_on_size_delta - and size_delta_abs >= immediate_size_delta_min - ): - last_push_ts = int(update_push_meta.get(pos_key) or 0) - if now_ts - last_push_ts >= immediate_cooldown_sec: - outgoing.extend( - _flush_ready_pending_updates( - pending_updates=pending_updates, - now_ts=now_ts, - debounce_sec=update_debounce_sec, - max_hold_sec=update_max_hold_sec, - force_keys={pos_key}, - blocked_keys=suppressed_update_keys, - ) - ) - outgoing.append((change_type, pos)) - update_push_meta[pos_key] = now_ts - continue - - _merge_pending_update( - pending_updates=pending_updates, - pos_key=pos_key, - pos=pos, - now_ts=now_ts, - ) - continue - - outgoing.extend( - _flush_ready_pending_updates( - pending_updates=pending_updates, - now_ts=now_ts, - debounce_sec=update_debounce_sec, - max_hold_sec=update_max_hold_sec, - force_keys={pos_key}, - blocked_keys=suppressed_update_keys, - ) - ) - outgoing.append((change_type, pos)) - - # If a key disappeared from snapshot, flush pending summary now. - missing_keys = {k for k in pending_updates.keys() if k not in current} - if missing_keys: - outgoing.extend( - _flush_ready_pending_updates( - pending_updates=pending_updates, - now_ts=now_ts, - debounce_sec=update_debounce_sec, - max_hold_sec=update_max_hold_sec, - force_keys=missing_keys, - blocked_keys=suppressed_update_keys, - ) - ) - for missing_key in missing_keys: - update_push_meta.pop(missing_key, None) - new_push_meta.pop(missing_key, None) - - outgoing.extend( - _flush_ready_pending_updates( - pending_updates=pending_updates, - now_ts=now_ts, - debounce_sec=update_debounce_sec, - max_hold_sec=update_max_hold_sec, - blocked_keys=suppressed_update_keys, - ) - ) - - outgoing = _filter_changes_by_position_value( - wallet=user, - changes=outgoing, - min_position_value_usd=min_position_value_usd, - exempt_wallets=exempt_wallets, - ) - - if outgoing: - msg = _build_message( - user, - outgoing, - max_changes=max_changes, - wallet_alias=user_aliases.get(user), - ) - sent_count = 0 - for chat_id in chat_ids: - try: - send_kwargs = { - "disable_web_page_preview": not link_preview, - } - if ( - topic_thread_id > 0 - and topic_chat_id - and str(chat_id).strip() == topic_chat_id - ): - send_kwargs["message_thread_id"] = topic_thread_id - bot.send_message( - chat_id, - msg, - **send_kwargs, - ) - sent_count += 1 - except Exception as exc: - logger.warning( - "wallet activity push failed user={} chat_id={} error={}", - user, - chat_id, - exc, - ) - if sent_count <= 0: - continue - for change_type, pos in outgoing: - if change_type == "new": - new_push_meta[_position_key(pos)] = now_ts - logger.info( - f"wallet activity pushed user={user} changes={len(outgoing)} chat_targets={sent_count}" - ) - - users_state[user] = { - "positions": current, - "pending_updates": pending_updates, - "update_push_meta": update_push_meta, - "new_push_meta": new_push_meta, - "initialized": True, - "updated_at": now_ts, - } - touched = True - except Exception: - logger.exception(f"wallet activity cycle failed user={user}") - - if touched: - try: - _save_state(state_path, state) - except Exception: - logger.exception("failed to save wallet activity state") - - elapsed = time.time() - cycle_started - sleep_sec = max(0.0, poll_sec - elapsed) - time.sleep(sleep_sec) - - thread = threading.Thread( - target=_runner, - name="polymarket-wallet-activity-watcher", - daemon=True, - ) - thread.start() - return thread diff --git a/tests/test_bot_runtime_coordinator.py b/tests/test_bot_runtime_coordinator.py index a3d05fb1..570e1e60 100644 --- a/tests/test_bot_runtime_coordinator.py +++ b/tests/test_bot_runtime_coordinator.py @@ -7,7 +7,6 @@ class DummyBot: def test_startup_coordinator_respects_disable_flags(monkeypatch): monkeypatch.setenv("POLYGON_WALLET_WATCH_ENABLED", "false") - monkeypatch.setenv("POLYMARKET_WALLET_ACTIVITY_ENABLED", "false") monkeypatch.delenv("TELEGRAM_CHAT_ID", raising=False) coordinator = StartupCoordinator( @@ -21,7 +20,6 @@ def test_startup_coordinator_respects_disable_flags(monkeypatch): loop_map = runtime.loop_map() assert loop_map["polygon_wallet_watch"].reason == "disabled_by_env" - assert loop_map["polymarket_wallet_activity"].reason == "retired_replaced_by_market_monitor" def test_render_runtime_status_html_contains_key_fields():