清理废弃代码并同步全部文档:删除钱包异动监控模块,移除已废弃功能的文档引用

- 删除 polymarket_wallet_activity_watcher.py(914行)及 runtime_coordinator 调度
- 移除 LGBM / Polymarket 价格层 / Groq / prewarm 等 v1.7.0 已废弃功能的文档引用
- 清理 6 个死文档链接,更新文档索引和版本日期
- 移除 POLYMARKET_WALLET_ACTIVITY_* / PREWARM_* 等废弃环境变量

Directive: 文档与代码同步清理,无功能变更
Tested: pytest tests/test_bot_runtime_coordinator.py pass
Confidence: high
This commit is contained in:
2569718930@qq.com
2026-05-23 21:42:46 +08:00
parent 864b1fa1f9
commit e79bc2d291
14 changed files with 43 additions and 1133 deletions
-28
View File
@@ -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
########################################
+2 -2
View File
@@ -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 |
+11 -40
View File
@@ -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`
+11 -41
View File
@@ -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 个监控城市的实测与预报数据。
- DEBDynamic 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<br/>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`
+7 -7
View File
@@ -12,9 +12,9 @@
示例:
- `1.4.0 -> 1.4.1`:告警逻辑修正、缓存修正、文档修正
- `1.4.0 -> 1.5.0`:新增支付能力、新增页面、新增 API
- `1.4.0 -> 2.0.0`接口重构或数据结构不兼容
- `1.7.0 -> 1.7.1`:告警逻辑修正、缓存修正、文档修正
- `1.7.0 -> 1.8.0`:新增能力、接口扩展、向后兼容的功能迭代
- `1.7.0 -> 2.0.0`:不兼容变更、核心架构升级
## 日常升版步骤
@@ -29,7 +29,7 @@ python scripts/bump_version.py patch
```bash
python scripts/bump_version.py minor
python scripts/bump_version.py major
python scripts/bump_version.py 1.5.0
python scripts/bump_version.py 1.8.0
```
### 2. 检查同步结果
@@ -68,15 +68,15 @@ python -m pytest
```bash
git add .
git commit -m "release: v1.4.1"
git tag v1.4.1
git commit -m "release: v1.7.1"
git tag v1.7.1
```
### 6. 推送
```bash
git push
git push origin v1.4.1
git push origin v1.7.1
```
## 当前约束
+1 -51
View File
@@ -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. 当前建议的运维规则
-6
View File
@@ -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`
+7 -10
View File
@@ -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 个已移除
+1 -14
View File
@@ -92,20 +92,7 @@ POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED=true
POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED=true
```
## 5. 钱包异动频道拆分(推荐)
如果要把“钱包异动监控”发到独立频道:
```env
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=-1003821482461
```
说明:
- 设置了 `POLYMARKET_WALLET_ACTIVITY_CHAT_ID(S)` 后,钱包异动推送优先发该频道。
- 未设置时,回退到全局 `TELEGRAM_CHAT_IDS/TELEGRAM_CHAT_ID`
## 6. 验证步骤
## 5. 验证步骤
1. 登录后请求 `/api/auth/me`,确认 `authenticated=true`
2. 请求 `/api/payments/config`,确认 `enabled=true``configured=true`
-1
View File
@@ -46,7 +46,6 @@ flowchart TD
- 支付运行态 API 与 SQLite 审计事件已补齐。
- 钱包绑定支持浏览器钱包 + WalletConnect。
- 账户中心与 Pro 权限展示链路打通。
- 钱包异动支持独立频道路由。
- 运行态状态/缓存与核心离线训练、评估、回填链路已完成 SQLite 主路径收口。
- 轻量可观测性已上线(`/healthz``/api/system/status``/metrics`)。
+3 -4
View File
@@ -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`
-13
View File
@@ -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()
@@ -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
-2
View File
@@ -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():