清理废弃代码并同步全部文档:删除钱包异动监控模块,移除已废弃功能的文档引用
- 删除 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:
@@ -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
|
||||
########################################
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -21,7 +21,7 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
||||
|
||||
[](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
@@ -14,7 +14,7 @@
|
||||
|
||||

|
||||
|
||||
## 当前产品状态(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<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
@@ -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
|
||||
```
|
||||
|
||||
## 当前约束
|
||||
|
||||
@@ -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. 当前建议的运维规则
|
||||
|
||||
|
||||
@@ -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
@@ -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 个已移除。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -46,7 +46,6 @@ flowchart TD
|
||||
- 支付运行态 API 与 SQLite 审计事件已补齐。
|
||||
- 钱包绑定支持浏览器钱包 + WalletConnect。
|
||||
- 账户中心与 Pro 权限展示链路打通。
|
||||
- 钱包异动支持独立频道路由。
|
||||
- 运行态状态/缓存与核心离线训练、评估、回填链路已完成 SQLite 主路径收口。
|
||||
- 轻量可观测性已上线(`/healthz`、`/api/system/status`、`/metrics`)。
|
||||
|
||||
|
||||
+3
-4
@@ -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`
|
||||
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user