Spaces:
Paused
Paused
local commited on
Commit ·
a3d1a52
0
Parent(s):
虚拟货币价格通知 Streamlit 应用
Browse files- .gitignore +13 -0
- README.md +30 -0
- coinpush.py +1348 -0
- requirements.txt +5 -0
.gitignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 密钥与本地环境,切勿上传
|
| 2 |
+
.env
|
| 3 |
+
.venv/
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.pyc
|
| 6 |
+
|
| 7 |
+
# 运行时生成的配置(首次启动会自动校准重建)
|
| 8 |
+
crypto_config.json
|
| 9 |
+
|
| 10 |
+
# 其他本地脚本
|
| 11 |
+
💸 空场防空转监控.py
|
| 12 |
+
📢 突发购票监控.py
|
| 13 |
+
token_data.json
|
README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: 虚拟货币价格通知
|
| 3 |
+
emoji: 🚀
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: streamlit
|
| 7 |
+
sdk_version: 1.58.0
|
| 8 |
+
app_file: coinpush.py
|
| 9 |
+
pinned: false
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# 🚀 虚拟货币价格通知
|
| 13 |
+
|
| 14 |
+
基于币安公开行情,对 BTC / TON 等币种做多维度监控,并通过企业微信机器人推送关键信号。
|
| 15 |
+
|
| 16 |
+
## 信号颜色
|
| 17 |
+
- 🟢 涨 🔴 跌 🟡 值得关注(不涨不跌但需留意)
|
| 18 |
+
|
| 19 |
+
## 监控维度
|
| 20 |
+
快速涨跌、关键价位、突破24h/7日高低、成交量异常、振幅、单笔/连续大额成交、
|
| 21 |
+
买卖价差、盘口失衡、买卖墙、偏离均线、连续K线、RSI、资金费率、持仓量、强平。
|
| 22 |
+
|
| 23 |
+
## 阈值校准
|
| 24 |
+
基于约 30 天历史行情,按“理想每日触发次数”反推阈值,突出关键转折点;
|
| 25 |
+
可在页面用「通知灵敏度」一键调节松紧,新增币种自动校准。
|
| 26 |
+
|
| 27 |
+
## 环境变量(在 Space 的 Settings → Variables and secrets 配置)
|
| 28 |
+
- `WEWORK_BOT_WEBHOOK`(必填):企业微信机器人 Webhook
|
| 29 |
+
- `BINANCE_API_KEY` / `BINANCE_API_SECRET`(可选,公开行情无需)
|
| 30 |
+
- `BINANCE_SPOT_BASE` / `BINANCE_FAPI_BASE` / `BINANCE_FSTREAM_BASE`(可选,换镜像用)
|
coinpush.py
ADDED
|
@@ -0,0 +1,1348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import requests
|
| 3 |
+
import time
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import copy
|
| 7 |
+
import threading
|
| 8 |
+
import hmac
|
| 9 |
+
import hashlib
|
| 10 |
+
import urllib3
|
| 11 |
+
from datetime import datetime, timedelta, timezone, time as dt_time
|
| 12 |
+
from collections import deque, defaultdict
|
| 13 |
+
from dotenv import load_dotenv
|
| 14 |
+
|
| 15 |
+
# --- 0. 基础配置 ---
|
| 16 |
+
st.set_page_config(page_title="虚拟货币价格通知", page_icon="🚀", layout="wide")
|
| 17 |
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
from streamlit_autorefresh import st_autorefresh
|
| 21 |
+
except ImportError:
|
| 22 |
+
st_autorefresh = None
|
| 23 |
+
|
| 24 |
+
# 可选:实时强平依赖 websocket-client,缺失则自动禁用强平监控
|
| 25 |
+
try:
|
| 26 |
+
import websocket as ws_client # websocket-client
|
| 27 |
+
except ImportError:
|
| 28 |
+
ws_client = None
|
| 29 |
+
|
| 30 |
+
# --- 1. 环境变量与常量 ---
|
| 31 |
+
load_dotenv()
|
| 32 |
+
|
| 33 |
+
# 企业微信机器人 Webhook
|
| 34 |
+
WEWORK_BOT_WEBHOOK = os.getenv("WEWORK_BOT_WEBHOOK")
|
| 35 |
+
|
| 36 |
+
# 币安 API Key(公共行情接口无需密钥,仅在需要私有接口时使用)
|
| 37 |
+
BINANCE_API_KEY = os.getenv("BINANCE_API_KEY")
|
| 38 |
+
BINANCE_API_SECRET = os.getenv("BINANCE_API_SECRET")
|
| 39 |
+
|
| 40 |
+
# 行情接口基址(部分地区需要换成 data-api.binance.vision 等镜像)
|
| 41 |
+
SPOT_BASE = os.getenv("BINANCE_SPOT_BASE", "https://api.binance.com")
|
| 42 |
+
FAPI_BASE = os.getenv("BINANCE_FAPI_BASE", "https://fapi.binance.com")
|
| 43 |
+
FSTREAM_BASE = os.getenv("BINANCE_FSTREAM_BASE", "wss://fstream.binance.com/ws")
|
| 44 |
+
|
| 45 |
+
# 配置持久化文件
|
| 46 |
+
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 47 |
+
CONFIG_FILE = os.path.join(CURRENT_DIR, "crypto_config.json")
|
| 48 |
+
|
| 49 |
+
REQ_HEADERS = {"User-Agent": "Mozilla/5.0"}
|
| 50 |
+
|
| 51 |
+
# K线周期对应的毫秒数(用于分页拉取长周期历史)
|
| 52 |
+
INTERVAL_MS = {
|
| 53 |
+
"1m": 60_000, "5m": 300_000, "15m": 900_000,
|
| 54 |
+
"1h": 3_600_000, "1d": 86_400_000,
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
# --- 2. 默认阈值配置 ---
|
| 58 |
+
# 价格变动:每个时间窗口 [关注, 重要, 紧急](百分比,绝对值)
|
| 59 |
+
def _btc_defaults():
|
| 60 |
+
return {
|
| 61 |
+
"symbol": "BTCUSDT",
|
| 62 |
+
"futures_symbol": "BTCUSDT",
|
| 63 |
+
"enable_futures": True,
|
| 64 |
+
# 个人关键价位(0 表示未设置,不监控)
|
| 65 |
+
"cost_price": 0.0,
|
| 66 |
+
"target_price": 0.0,
|
| 67 |
+
"stop_price": 0.0,
|
| 68 |
+
# 快速涨跌触发阈值(百分比绝对值),方向由涨/跌自动判断
|
| 69 |
+
"price_change": {
|
| 70 |
+
"1m": 0.35,
|
| 71 |
+
"5m": 0.80,
|
| 72 |
+
"15m": 1.50,
|
| 73 |
+
"1h": 2.50,
|
| 74 |
+
},
|
| 75 |
+
# 关键价位
|
| 76 |
+
"near_cost_pct": 0.5,
|
| 77 |
+
"near_key_pct": 0.4,
|
| 78 |
+
# 突破
|
| 79 |
+
"break_24h_pct": 0.2, "break_24h_hold_sec": 120,
|
| 80 |
+
"break_7d_pct": 0.5,
|
| 81 |
+
# 成交量
|
| 82 |
+
"vol_anomaly_mult": 2.5, "vol_severe_mult": 5.0,
|
| 83 |
+
# 振幅
|
| 84 |
+
"amp_5m": 1.0, "amp_15m": 1.8, "amp_1h": 3.0,
|
| 85 |
+
# 大额成交
|
| 86 |
+
"big_trade_usdt": 1_000_000, "cum_trade_5m_usdt": 10_000_000,
|
| 87 |
+
# 盘口
|
| 88 |
+
"spread_pct": 0.03, "spread_severe_pct": 0.08,
|
| 89 |
+
"imbalance_depth_pct": 0.5, "imbalance_ratio": 2.5,
|
| 90 |
+
"wall_depth_pct": 1.0, "wall_mult": 3.0, "wall_vanish_pct": 60.0,
|
| 91 |
+
# 技术指标
|
| 92 |
+
"ma_deviation_pct": 2.5,
|
| 93 |
+
"consecutive_klines": 5,
|
| 94 |
+
"rsi_high": 75.0, "rsi_low": 25.0,
|
| 95 |
+
# 合约(enable_futures 为 False 时整体跳过)
|
| 96 |
+
"funding_high": 0.03, "funding_hot": 0.07, "funding_neg": -0.03,
|
| 97 |
+
"oi_15m": 2.0, "oi_1h": 5.0, "oi_4h": 10.0,
|
| 98 |
+
"liq_5m_usdt": 5_000_000, "liq_severe_usdt": 20_000_000,
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _ton_defaults():
|
| 103 |
+
d = _btc_defaults()
|
| 104 |
+
d.update({
|
| 105 |
+
"symbol": "TONUSDT",
|
| 106 |
+
"futures_symbol": "TONUSDT",
|
| 107 |
+
"enable_futures": False, # TONUSDT 合约已下线,待 GRAMUSDT 重新上线后启用
|
| 108 |
+
"price_change": {
|
| 109 |
+
"1m": 0.80,
|
| 110 |
+
"5m": 1.80,
|
| 111 |
+
"15m": 3.00,
|
| 112 |
+
"1h": 5.00,
|
| 113 |
+
},
|
| 114 |
+
"near_cost_pct": 1.0,
|
| 115 |
+
"near_key_pct": 1.0,
|
| 116 |
+
"break_24h_pct": 0.5, "break_24h_hold_sec": 180,
|
| 117 |
+
"break_7d_pct": 1.2,
|
| 118 |
+
"vol_anomaly_mult": 3.5, "vol_severe_mult": 8.0,
|
| 119 |
+
"amp_5m": 2.5, "amp_15m": 5.0, "amp_1h": 8.0,
|
| 120 |
+
"big_trade_usdt": 100_000, "cum_trade_5m_usdt": 500_000,
|
| 121 |
+
"spread_pct": 0.12, "spread_severe_pct": 0.35,
|
| 122 |
+
"imbalance_depth_pct": 1.0, "imbalance_ratio": 3.0,
|
| 123 |
+
"wall_depth_pct": 2.0, "wall_mult": 4.0, "wall_vanish_pct": 70.0,
|
| 124 |
+
"ma_deviation_pct": 5.0,
|
| 125 |
+
"consecutive_klines": 4,
|
| 126 |
+
"rsi_high": 80.0, "rsi_low": 20.0,
|
| 127 |
+
"funding_high": 0.05, "funding_hot": 0.12, "funding_neg": -0.05,
|
| 128 |
+
"oi_15m": 4.0, "oi_1h": 8.0, "oi_4h": 15.0,
|
| 129 |
+
"liq_5m_usdt": 300_000, "liq_severe_usdt": 1_000_000,
|
| 130 |
+
})
|
| 131 |
+
return d
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
DEFAULT_CONFIG = {
|
| 135 |
+
"coins": {
|
| 136 |
+
"BTC": _btc_defaults(),
|
| 137 |
+
"TON": _ton_defaults(),
|
| 138 |
+
},
|
| 139 |
+
"global": {
|
| 140 |
+
"poll_interval_sec": 30,
|
| 141 |
+
"daily_report_hour": 9,
|
| 142 |
+
"down_priority": True,
|
| 143 |
+
"alert_sensitivity": 1.0,
|
| 144 |
+
},
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
# 各类告警冷却时间(分钟),避免重复刷屏
|
| 148 |
+
# 微观结构类(盘口/大单/价差/振幅)天然高频,冷却拉长,突出关键事件
|
| 149 |
+
COOLDOWN_MIN = {
|
| 150 |
+
"price_change": 10, "near_cost": 30, "near_key": 30,
|
| 151 |
+
"break_24h": 30, "break_7d": 120, "volume": 20, "amplitude": 20,
|
| 152 |
+
"big_trade": 15, "cum_trade": 20, "spread": 30, "imbalance": 30,
|
| 153 |
+
"wall": 90, "wall_vanish": 30, "ma_deviation": 30,
|
| 154 |
+
"consecutive": 60, "rsi": 60, "funding": 120, "oi": 30, "liquidation": 10,
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# --- 3. 配置持久化 ---
|
| 159 |
+
def _deep_merge(base, override):
|
| 160 |
+
"""用 override 覆盖 base,缺失项以 base 补全(保证新增阈值字段有默认值)"""
|
| 161 |
+
result = copy.deepcopy(base)
|
| 162 |
+
for k, v in (override or {}).items():
|
| 163 |
+
if isinstance(v, dict) and isinstance(result.get(k), dict):
|
| 164 |
+
result[k] = _deep_merge(result[k], v)
|
| 165 |
+
else:
|
| 166 |
+
result[k] = v
|
| 167 |
+
return result
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def load_config():
|
| 171 |
+
"""读取配置文件,并以默认值补全缺失字段"""
|
| 172 |
+
cfg = copy.deepcopy(DEFAULT_CONFIG)
|
| 173 |
+
if os.path.exists(CONFIG_FILE):
|
| 174 |
+
try:
|
| 175 |
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
| 176 |
+
saved = json.load(f)
|
| 177 |
+
# global 直接合并
|
| 178 |
+
cfg["global"] = _deep_merge(cfg["global"], saved.get("global", {}))
|
| 179 |
+
# coins:保留用户自定义币种,并以 BTC 默认结构补全字段
|
| 180 |
+
saved_coins = saved.get("coins", {})
|
| 181 |
+
merged_coins = {}
|
| 182 |
+
for name, coin_cfg in saved_coins.items():
|
| 183 |
+
template = _btc_defaults()
|
| 184 |
+
merged_coins[name] = _deep_merge(template, coin_cfg)
|
| 185 |
+
if merged_coins:
|
| 186 |
+
cfg["coins"] = merged_coins
|
| 187 |
+
except Exception as e:
|
| 188 |
+
print(f"⚠️ 配置读取失败,使用默认值: {e}")
|
| 189 |
+
return cfg
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def save_config(cfg):
|
| 193 |
+
try:
|
| 194 |
+
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
| 195 |
+
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
| 196 |
+
return True
|
| 197 |
+
except Exception as e:
|
| 198 |
+
print(f"❌ 配置保存失败: {e}")
|
| 199 |
+
return False
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
# --- 4. 工具函数 ---
|
| 203 |
+
def get_beijing_now():
|
| 204 |
+
utc_now = datetime.now(timezone.utc)
|
| 205 |
+
return utc_now.astimezone(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def fmt_price(p):
|
| 209 |
+
"""根据量级自适应小数位"""
|
| 210 |
+
try:
|
| 211 |
+
p = float(p)
|
| 212 |
+
except (TypeError, ValueError):
|
| 213 |
+
return str(p)
|
| 214 |
+
if p >= 100:
|
| 215 |
+
return f"{p:,.2f}"
|
| 216 |
+
if p >= 1:
|
| 217 |
+
return f"{p:.4f}"
|
| 218 |
+
return f"{p:.6f}"
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def fmt_usdt(v):
|
| 222 |
+
"""金额转人类可读(万/亿)"""
|
| 223 |
+
try:
|
| 224 |
+
v = float(v)
|
| 225 |
+
except (TypeError, ValueError):
|
| 226 |
+
return str(v)
|
| 227 |
+
if v >= 1e8:
|
| 228 |
+
return f"{v / 1e8:.2f}亿USDT"
|
| 229 |
+
if v >= 1e4:
|
| 230 |
+
return f"{v / 1e4:.2f}万USDT"
|
| 231 |
+
return f"{v:.0f}USDT"
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def compute_rsi(closes, period=14):
|
| 235 |
+
"""标准 RSI"""
|
| 236 |
+
if len(closes) < period + 1:
|
| 237 |
+
return None
|
| 238 |
+
gains, losses = 0.0, 0.0
|
| 239 |
+
for i in range(1, period + 1):
|
| 240 |
+
diff = closes[i] - closes[i - 1]
|
| 241 |
+
if diff >= 0:
|
| 242 |
+
gains += diff
|
| 243 |
+
else:
|
| 244 |
+
losses -= diff
|
| 245 |
+
avg_gain = gains / period
|
| 246 |
+
avg_loss = losses / period
|
| 247 |
+
for i in range(period + 1, len(closes)):
|
| 248 |
+
diff = closes[i] - closes[i - 1]
|
| 249 |
+
gain = diff if diff > 0 else 0.0
|
| 250 |
+
loss = -diff if diff < 0 else 0.0
|
| 251 |
+
avg_gain = (avg_gain * (period - 1) + gain) / period
|
| 252 |
+
avg_loss = (avg_loss * (period - 1) + loss) / period
|
| 253 |
+
if avg_loss == 0:
|
| 254 |
+
return 100.0
|
| 255 |
+
rs = avg_gain / avg_loss
|
| 256 |
+
return 100.0 - (100.0 / (1.0 + rs))
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
# --- 5. 企业微信机器人推送 ---
|
| 260 |
+
class WeWorkBotPusher:
|
| 261 |
+
def __init__(self, webhook_url):
|
| 262 |
+
self.webhook_url = webhook_url
|
| 263 |
+
|
| 264 |
+
def send_text(self, content):
|
| 265 |
+
if not self.webhook_url:
|
| 266 |
+
print("⚠️ 未配置 WEWORK_BOT_WEBHOOK,消息未发送:\n" + content)
|
| 267 |
+
return False
|
| 268 |
+
try:
|
| 269 |
+
resp = requests.post(
|
| 270 |
+
self.webhook_url,
|
| 271 |
+
json={"msgtype": "text", "text": {"content": content}},
|
| 272 |
+
headers={"Content-Type": "application/json"},
|
| 273 |
+
timeout=10,
|
| 274 |
+
)
|
| 275 |
+
result = resp.json()
|
| 276 |
+
if result.get("errcode") == 0:
|
| 277 |
+
return True
|
| 278 |
+
print(f"❌ 企业微信机器人发送失败: {result}")
|
| 279 |
+
return False
|
| 280 |
+
except Exception as e:
|
| 281 |
+
print(f"❌ 企业微信机器人发送异常: {e}")
|
| 282 |
+
return False
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
# --- 6. 币安行情接口 ---
|
| 286 |
+
class BinanceAPI:
|
| 287 |
+
def __init__(self, logger):
|
| 288 |
+
self.logger = logger
|
| 289 |
+
self.session = requests.Session()
|
| 290 |
+
self.session.headers.update(REQ_HEADERS)
|
| 291 |
+
if BINANCE_API_KEY:
|
| 292 |
+
self.session.headers.update({"X-MBX-APIKEY": BINANCE_API_KEY})
|
| 293 |
+
|
| 294 |
+
def _get(self, base, path, params=None, timeout=10):
|
| 295 |
+
try:
|
| 296 |
+
r = self.session.get(base + path, params=params, timeout=timeout)
|
| 297 |
+
if r.status_code != 200:
|
| 298 |
+
self.logger(f"⚠️ 接口 {path} 状态码 {r.status_code}")
|
| 299 |
+
return None
|
| 300 |
+
return r.json()
|
| 301 |
+
except Exception as e:
|
| 302 |
+
self.logger(f"⚠️ 接口 {path} 异常: {e}")
|
| 303 |
+
return None
|
| 304 |
+
|
| 305 |
+
# 现货行情
|
| 306 |
+
def ticker_24h(self, symbol):
|
| 307 |
+
return self._get(SPOT_BASE, "/api/v3/ticker/24hr", {"symbol": symbol})
|
| 308 |
+
|
| 309 |
+
def klines(self, symbol, interval, limit):
|
| 310 |
+
return self._get(SPOT_BASE, "/api/v3/klines",
|
| 311 |
+
{"symbol": symbol, "interval": interval, "limit": limit})
|
| 312 |
+
|
| 313 |
+
def klines_history(self, symbol, interval, days, max_calls=40):
|
| 314 |
+
"""分页拉取约 days 天的历史K线(用于稳定的长周期校准)"""
|
| 315 |
+
ms = INTERVAL_MS.get(interval)
|
| 316 |
+
if not ms:
|
| 317 |
+
return self.klines(symbol, interval, 1000)
|
| 318 |
+
end = int(time.time() * 1000)
|
| 319 |
+
cursor = end - int(days * 86_400_000)
|
| 320 |
+
out, calls = [], 0
|
| 321 |
+
while cursor < end and calls < max_calls:
|
| 322 |
+
data = self._get(SPOT_BASE, "/api/v3/klines",
|
| 323 |
+
{"symbol": symbol, "interval": interval,
|
| 324 |
+
"startTime": cursor, "limit": 1000})
|
| 325 |
+
calls += 1
|
| 326 |
+
if not data:
|
| 327 |
+
break
|
| 328 |
+
out.extend(data)
|
| 329 |
+
nxt = data[-1][0] + ms
|
| 330 |
+
if nxt <= cursor:
|
| 331 |
+
break
|
| 332 |
+
cursor = nxt
|
| 333 |
+
if len(data) < 1000:
|
| 334 |
+
break
|
| 335 |
+
return out
|
| 336 |
+
|
| 337 |
+
def depth(self, symbol, limit=500):
|
| 338 |
+
return self._get(SPOT_BASE, "/api/v3/depth", {"symbol": symbol, "limit": limit})
|
| 339 |
+
|
| 340 |
+
def agg_trades(self, symbol, limit=1000):
|
| 341 |
+
return self._get(SPOT_BASE, "/api/v3/aggTrades", {"symbol": symbol, "limit": limit})
|
| 342 |
+
|
| 343 |
+
# 合约行情
|
| 344 |
+
def premium_index(self, symbol):
|
| 345 |
+
return self._get(FAPI_BASE, "/fapi/v1/premiumIndex", {"symbol": symbol})
|
| 346 |
+
|
| 347 |
+
def open_interest(self, symbol):
|
| 348 |
+
return self._get(FAPI_BASE, "/fapi/v1/openInterest", {"symbol": symbol})
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
# --- 7. 实时强平监控(websocket,可选)---
|
| 352 |
+
class LiquidationTracker:
|
| 353 |
+
"""
|
| 354 |
+
订阅币安合约 !forceOrder@arr 全市场强平流,按 symbol 维护近 5 分钟滚动金额。
|
| 355 |
+
websocket-client 未安装时整体禁用。
|
| 356 |
+
"""
|
| 357 |
+
def __init__(self, symbols, logger):
|
| 358 |
+
self.symbols = set(symbols)
|
| 359 |
+
self.logger = logger
|
| 360 |
+
self.enabled = ws_client is not None and len(self.symbols) > 0
|
| 361 |
+
# symbol -> deque[(ts, notional)]
|
| 362 |
+
self.events = defaultdict(lambda: deque(maxlen=5000))
|
| 363 |
+
self.lock = threading.Lock()
|
| 364 |
+
if self.enabled:
|
| 365 |
+
threading.Thread(target=self._run, daemon=True).start()
|
| 366 |
+
|
| 367 |
+
def update_symbols(self, symbols):
|
| 368 |
+
self.symbols = set(symbols)
|
| 369 |
+
|
| 370 |
+
def _on_message(self, _ws, message):
|
| 371 |
+
try:
|
| 372 |
+
data = json.loads(message)
|
| 373 |
+
o = data.get("o", {})
|
| 374 |
+
sym = o.get("s")
|
| 375 |
+
if sym not in self.symbols:
|
| 376 |
+
return
|
| 377 |
+
price = float(o.get("ap") or o.get("p") or 0)
|
| 378 |
+
qty = float(o.get("q") or 0)
|
| 379 |
+
notional = price * qty
|
| 380 |
+
with self.lock:
|
| 381 |
+
self.events[sym].append((time.time(), notional))
|
| 382 |
+
except Exception:
|
| 383 |
+
pass
|
| 384 |
+
|
| 385 |
+
def _run(self):
|
| 386 |
+
url = f"{FSTREAM_BASE}/!forceOrder@arr"
|
| 387 |
+
while True:
|
| 388 |
+
try:
|
| 389 |
+
self.logger("🔌 连接强平 websocket...")
|
| 390 |
+
app = ws_client.WebSocketApp(
|
| 391 |
+
url,
|
| 392 |
+
on_message=self._on_message,
|
| 393 |
+
on_error=lambda _w, e: self.logger(f"⚠️ 强平流错误: {e}"),
|
| 394 |
+
)
|
| 395 |
+
app.run_forever(ping_interval=180, ping_timeout=10)
|
| 396 |
+
except Exception as e:
|
| 397 |
+
self.logger(f"⚠️ 强平流断开,5秒后重连: {e}")
|
| 398 |
+
time.sleep(5)
|
| 399 |
+
|
| 400 |
+
def get_5m_total(self, symbol):
|
| 401 |
+
if not self.enabled:
|
| 402 |
+
return None
|
| 403 |
+
cutoff = time.time() - 300
|
| 404 |
+
with self.lock:
|
| 405 |
+
dq = self.events.get(symbol)
|
| 406 |
+
if dq is None:
|
| 407 |
+
return 0.0
|
| 408 |
+
return sum(n for ts, n in dq if ts >= cutoff)
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
# --- 8. 告警冷却管理 ---
|
| 412 |
+
class AlertManager:
|
| 413 |
+
def __init__(self, pusher, logger, stats):
|
| 414 |
+
self.pusher = pusher
|
| 415 |
+
self.logger = logger
|
| 416 |
+
self.stats = stats
|
| 417 |
+
self.last_sent = {} # key -> timestamp
|
| 418 |
+
|
| 419 |
+
def emit(self, alerts):
|
| 420 |
+
"""alerts: list[dict(category,key,severity,sev_emoji,text,direction)]"""
|
| 421 |
+
# 下跌优先:跌 排前面先发
|
| 422 |
+
alerts = sorted(alerts, key=lambda a: (a.get("direction") != "跌",))
|
| 423 |
+
now = time.time()
|
| 424 |
+
for a in alerts:
|
| 425 |
+
key = a["key"]
|
| 426 |
+
cd = COOLDOWN_MIN.get(a["category"], 5) * 60
|
| 427 |
+
if now - self.last_sent.get(key, 0) < cd:
|
| 428 |
+
continue
|
| 429 |
+
ok = self.pusher.send_text(a["text"])
|
| 430 |
+
self.last_sent[key] = now
|
| 431 |
+
self.stats["alerts"] += 1
|
| 432 |
+
if not ok:
|
| 433 |
+
self.stats["notify_fails"] += 1
|
| 434 |
+
self.logger(f"{a['sev_emoji']} ���送[{a['category']}] {a.get('title', key)}")
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
# 方向信号 -> emoji / 文案:🟢涨 / 🔴跌 / 🟡值得关注
|
| 438 |
+
SIGNAL_EMOJI = {"涨": "🟢", "跌": "🔴", "关注": "🟡"}
|
| 439 |
+
SIGNAL_LABEL = {"涨": "涨", "跌": "跌", "关注": "值得关注"}
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
def _floats(klines, idx):
|
| 443 |
+
return [float(k[idx]) for k in klines]
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def _percentile(sorted_vals, p):
|
| 447 |
+
"""线性插值分位数;sorted_vals 必须已升序"""
|
| 448 |
+
if not sorted_vals:
|
| 449 |
+
return None
|
| 450 |
+
if len(sorted_vals) == 1:
|
| 451 |
+
return sorted_vals[0]
|
| 452 |
+
k = (len(sorted_vals) - 1) * p / 100.0
|
| 453 |
+
f = int(k)
|
| 454 |
+
c = min(f + 1, len(sorted_vals) - 1)
|
| 455 |
+
if f == c:
|
| 456 |
+
return sorted_vals[f]
|
| 457 |
+
return sorted_vals[f] * (c - k) + sorted_vals[c] * (k - f)
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
# 校准目标:各事件“理想的每日触发次数”(再乘以全局灵敏度)
|
| 461 |
+
# 数值越小 -> 阈值越高 -> 通知越少(只剩关键转折点)
|
| 462 |
+
TARGET_PER_DAY = {
|
| 463 |
+
"1m": 3.0, "5m": 3.0, "15m": 2.0, "1h": 1.5,
|
| 464 |
+
"amp_5m": 2.0, "amp_15m": 1.5, "amp_1h": 1.0,
|
| 465 |
+
"vol_anomaly": 3.0, "vol_severe": 0.5,
|
| 466 |
+
"ma_dev": 2.0,
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
def _threshold_for_rate(sorted_vals, window_min, target_per_day, floor=0.0):
|
| 471 |
+
"""
|
| 472 |
+
给定该周期下的历史样本(升序),返回一个阈值:
|
| 473 |
+
使得历史上“超过该阈值”的次数 ≈ target_per_day 次/天。
|
| 474 |
+
样本为相邻周期收盘价变动/振幅,互不重叠,故覆盖天数 = n*window/1440。
|
| 475 |
+
"""
|
| 476 |
+
n = len(sorted_vals)
|
| 477 |
+
if n == 0:
|
| 478 |
+
return None
|
| 479 |
+
days_span = n * window_min / 1440.0
|
| 480 |
+
exceed = max(1, int(round(target_per_day * days_span)))
|
| 481 |
+
idx = min(max(n - exceed, 0), n - 1)
|
| 482 |
+
return max(sorted_vals[idx], floor)
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
def calibrate_thresholds(api, coin_cfg, logger=print, sensitivity=1.0):
|
| 486 |
+
"""
|
| 487 |
+
基于约30天历史行情,按“理想每日触发次数”反推阈值,突出关键转折点。
|
| 488 |
+
sensitivity 越大 -> 目标次数越多 -> 阈值越低 -> 通知越多。
|
| 489 |
+
任一环节失败均跳过,保留原默认值。
|
| 490 |
+
"""
|
| 491 |
+
sym = coin_cfg["symbol"]
|
| 492 |
+
out = {}
|
| 493 |
+
s = max(0.2, float(sensitivity))
|
| 494 |
+
logger(f"🧪 {sym} 拉取约30天历史校准(灵敏度 x{s:g})...")
|
| 495 |
+
|
| 496 |
+
hist_1m = api.klines_history(sym, "1m", 7) # 1m 噪声平稳,7天已足够稳定
|
| 497 |
+
hist_5m = api.klines_history(sym, "5m", 30)
|
| 498 |
+
hist_15m = api.klines_history(sym, "15m", 30)
|
| 499 |
+
hist_1h = api.klines_history(sym, "1h", 30)
|
| 500 |
+
|
| 501 |
+
def cc_returns(kl):
|
| 502 |
+
closes = [float(k[4]) for k in kl]
|
| 503 |
+
return sorted(abs(closes[i] - closes[i - 1]) / closes[i - 1] * 100
|
| 504 |
+
for i in range(1, len(closes)) if closes[i - 1] > 0)
|
| 505 |
+
|
| 506 |
+
def amps(kl):
|
| 507 |
+
v = []
|
| 508 |
+
for k in kl:
|
| 509 |
+
hi, lo = float(k[2]), float(k[3])
|
| 510 |
+
if lo > 0:
|
| 511 |
+
v.append((hi - lo) / lo * 100)
|
| 512 |
+
return sorted(v)
|
| 513 |
+
|
| 514 |
+
# 1) 快速涨跌:按每日触发次数目标反推
|
| 515 |
+
pc = {}
|
| 516 |
+
src = {"1m": hist_1m, "5m": hist_5m, "15m": hist_15m, "1h": hist_1h}
|
| 517 |
+
wmin = {"1m": 1, "5m": 5, "15m": 15, "1h": 60}
|
| 518 |
+
for w in ("1m", "5m", "15m", "1h"):
|
| 519 |
+
kl = src[w]
|
| 520 |
+
if kl and len(kl) > 50:
|
| 521 |
+
t = _threshold_for_rate(cc_returns(kl), wmin[w],
|
| 522 |
+
TARGET_PER_DAY[w] * s, floor=0.05)
|
| 523 |
+
if t:
|
| 524 |
+
pc[w] = round(t, 2)
|
| 525 |
+
if pc:
|
| 526 |
+
base = coin_cfg.get("price_change", {})
|
| 527 |
+
for w in ("1m", "5m", "15m", "1h"):
|
| 528 |
+
if w not in pc:
|
| 529 |
+
bv = base.get(w, 1.0)
|
| 530 |
+
pc[w] = float(bv[0]) if isinstance(bv, list) else float(bv)
|
| 531 |
+
out["price_change"] = pc
|
| 532 |
+
|
| 533 |
+
# 2) 振幅
|
| 534 |
+
for kl, win, key, floor in ((hist_5m, 5, "amp_5m", 0.1),
|
| 535 |
+
(hist_15m, 15, "amp_15m", 0.2),
|
| 536 |
+
(hist_1h, 60, "amp_1h", 0.3)):
|
| 537 |
+
if kl and len(kl) > 50:
|
| 538 |
+
t = _threshold_for_rate(amps(kl), win, TARGET_PER_DAY[key] * s, floor)
|
| 539 |
+
if t:
|
| 540 |
+
out[key] = round(t, 2)
|
| 541 |
+
|
| 542 |
+
# 3) 成交量倍数 + 5m 累计大额 + 单笔大额:基于 5m 成交额分布
|
| 543 |
+
if hist_5m and len(hist_5m) > 50:
|
| 544 |
+
qv = [float(k[7]) for k in hist_5m]
|
| 545 |
+
avg5_qvol = sum(qv) / len(qv)
|
| 546 |
+
if avg5_qvol > 0:
|
| 547 |
+
ratios = sorted(x / avg5_qvol for x in qv)
|
| 548 |
+
an = _threshold_for_rate(ratios, 5, TARGET_PER_DAY["vol_anomaly"] * s, 1.5)
|
| 549 |
+
sv = _threshold_for_rate(ratios, 5, TARGET_PER_DAY["vol_severe"] * s,
|
| 550 |
+
(an or 1.5) + 0.5)
|
| 551 |
+
if an:
|
| 552 |
+
out["vol_anomaly_mult"] = round(an, 1)
|
| 553 |
+
if sv:
|
| 554 |
+
out["vol_severe_mult"] = round(max(sv, (an or 1.5) + 0.5), 1)
|
| 555 |
+
out["cum_trade_5m_usdt"] = round(avg5_qvol * (an or 2.5))
|
| 556 |
+
# 单笔大额:一笔成交达到约 3 分钟的平均成交额才算“巨单”
|
| 557 |
+
avg1m_qvol = avg5_qvol / 5.0
|
| 558 |
+
out["big_trade_usdt"] = round(max(avg1m_qvol * 3.0 / s, 5000))
|
| 559 |
+
|
| 560 |
+
# 4) 偏离 MA20:1h 历史偏离
|
| 561 |
+
if hist_1h and len(hist_1h) > 40:
|
| 562 |
+
closes = [float(k[4]) for k in hist_1h]
|
| 563 |
+
devs = []
|
| 564 |
+
for i in range(20, len(closes)):
|
| 565 |
+
ma = sum(closes[i - 20:i]) / 20
|
| 566 |
+
if ma > 0:
|
| 567 |
+
devs.append(abs(closes[i] - ma) / ma * 100)
|
| 568 |
+
t = _threshold_for_rate(sorted(devs), 60, TARGET_PER_DAY["ma_dev"] * s, 0.5)
|
| 569 |
+
if t:
|
| 570 |
+
out["ma_deviation_pct"] = round(t, 2)
|
| 571 |
+
|
| 572 |
+
# 5) 价差:以当前盘口价差为基准放大
|
| 573 |
+
ob = api.depth(sym, 100)
|
| 574 |
+
if ob and ob.get("bids") and ob.get("asks"):
|
| 575 |
+
bb, ba = float(ob["bids"][0][0]), float(ob["asks"][0][0])
|
| 576 |
+
mid = (bb + ba) / 2
|
| 577 |
+
if mid > 0:
|
| 578 |
+
sp = (ba - bb) / mid * 100
|
| 579 |
+
out["spread_pct"] = round(max(sp * 3, 0.02), 3)
|
| 580 |
+
out["spread_severe_pct"] = round(max(sp * 6, out["spread_pct"] * 2), 3)
|
| 581 |
+
|
| 582 |
+
# 6) 强平:按 24h 成交额量级缩放
|
| 583 |
+
t24 = api.ticker_24h(sym)
|
| 584 |
+
if t24:
|
| 585 |
+
qvol24 = float(t24.get("quoteVolume") or 0)
|
| 586 |
+
if qvol24 > 0:
|
| 587 |
+
avg5 = qvol24 / 288.0
|
| 588 |
+
out["liq_5m_usdt"] = round(max(avg5 * 0.5, 10000))
|
| 589 |
+
out["liq_severe_usdt"] = round(out["liq_5m_usdt"] * 4)
|
| 590 |
+
|
| 591 |
+
# 7) 资金费率:历史费率分位(仅合约启用时)
|
| 592 |
+
if coin_cfg.get("enable_futures"):
|
| 593 |
+
fr = api._get(FAPI_BASE, "/fapi/v1/fundingRate",
|
| 594 |
+
{"symbol": coin_cfg["futures_symbol"], "limit": 500})
|
| 595 |
+
if fr:
|
| 596 |
+
try:
|
| 597 |
+
rates = [float(x["fundingRate"]) * 100 for x in fr]
|
| 598 |
+
pos = sorted(r for r in rates if r > 0)
|
| 599 |
+
neg = sorted(r for r in rates if r < 0)
|
| 600 |
+
if pos:
|
| 601 |
+
high = round(max(_percentile(pos, 80), 0.005), 4)
|
| 602 |
+
hot = round(max(_percentile(pos, 97), high * 1.5), 4)
|
| 603 |
+
out["funding_high"] = high
|
| 604 |
+
out["funding_hot"] = hot
|
| 605 |
+
if neg:
|
| 606 |
+
out["funding_neg"] = round(min(_percentile(neg, 20), -0.005), 4)
|
| 607 |
+
except (KeyError, ValueError, TypeError):
|
| 608 |
+
pass
|
| 609 |
+
|
| 610 |
+
logger(f"✅ {sym} 校准完成,覆盖 {len(out)} 组参数")
|
| 611 |
+
return out
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
# --- 9. 单币种评估器 ---
|
| 615 |
+
class CoinEvaluator:
|
| 616 |
+
WINDOW_BACK = {"1m": 2, "5m": 6, "15m": 16, "1h": 61}
|
| 617 |
+
WINDOW_CN = {"1m": "1分钟", "5m": "5分钟", "15m": "15分钟", "1h": "1小时"}
|
| 618 |
+
|
| 619 |
+
def __init__(self, name, api, liq_tracker, logger):
|
| 620 |
+
self.name = name
|
| 621 |
+
self.api = api
|
| 622 |
+
self.liq = liq_tracker
|
| 623 |
+
self.logger = logger
|
| 624 |
+
# 状态
|
| 625 |
+
self.break_24h_since = {} # 'high'/'low' -> ts
|
| 626 |
+
self.break_7d_alerted = {}
|
| 627 |
+
self.prev_walls = {} # 'bid'/'ask' -> notional
|
| 628 |
+
self.oi_history = deque(maxlen=2000) # (ts, oi)
|
| 629 |
+
self.last_price = None
|
| 630 |
+
self.last_funding_pct = None
|
| 631 |
+
self.last_rsi = None
|
| 632 |
+
|
| 633 |
+
def evaluate(self, cfg):
|
| 634 |
+
sym = cfg["symbol"]
|
| 635 |
+
alerts = []
|
| 636 |
+
|
| 637 |
+
ticker = self.api.ticker_24h(sym)
|
| 638 |
+
k1 = self.api.klines(sym, "1m", 61)
|
| 639 |
+
if not ticker or not k1 or len(k1) < 17:
|
| 640 |
+
return alerts, None # 数据不足
|
| 641 |
+
|
| 642 |
+
closes = _floats(k1, 4)
|
| 643 |
+
highs = _floats(k1, 2)
|
| 644 |
+
lows = _floats(k1, 3)
|
| 645 |
+
qvols = _floats(k1, 7) # quoteAssetVolume
|
| 646 |
+
|
| 647 |
+
price = float(ticker.get("lastPrice") or closes[-1])
|
| 648 |
+
high24 = float(ticker.get("highPrice") or 0)
|
| 649 |
+
low24 = float(ticker.get("lowPrice") or 0)
|
| 650 |
+
qvol24 = float(ticker.get("quoteVolume") or 0)
|
| 651 |
+
self.last_price = price
|
| 652 |
+
|
| 653 |
+
def add(category, key, direction, headline, body):
|
| 654 |
+
emoji = SIGNAL_EMOJI[direction]
|
| 655 |
+
text = f"{emoji} {self.name} {headline}\n{head}\n{body}"
|
| 656 |
+
alerts.append({"category": category, "key": f"{self.name}_{key}",
|
| 657 |
+
"direction": direction, "sev_emoji": emoji,
|
| 658 |
+
"title": headline, "text": text})
|
| 659 |
+
|
| 660 |
+
head = f"【{self.name}】现价 {fmt_price(price)} USDT"
|
| 661 |
+
|
| 662 |
+
# 1) 快速涨跌(方向决定颜色)
|
| 663 |
+
for win, back in self.WINDOW_BACK.items():
|
| 664 |
+
if len(closes) <= back:
|
| 665 |
+
continue
|
| 666 |
+
then = closes[-back]
|
| 667 |
+
if then == 0:
|
| 668 |
+
continue
|
| 669 |
+
pct = (price - then) / then * 100
|
| 670 |
+
thr = cfg["price_change"][win]
|
| 671 |
+
if isinstance(thr, list):
|
| 672 |
+
thr = thr[0]
|
| 673 |
+
if abs(pct) >= thr:
|
| 674 |
+
direction = "涨" if pct >= 0 else "跌"
|
| 675 |
+
arrow = "📈" if pct >= 0 else "📉"
|
| 676 |
+
add("price_change", f"pc_{win}_{direction}", direction,
|
| 677 |
+
f"{self.WINDOW_CN[win]}快速{direction} {arrow}",
|
| 678 |
+
f"{self.WINDOW_CN[win]}内{direction}幅 {pct:+.2f}%")
|
| 679 |
+
|
| 680 |
+
# 2) 关键价位
|
| 681 |
+
if cfg.get("cost_price", 0) > 0:
|
| 682 |
+
diff = (price - cfg["cost_price"]) / cfg["cost_price"] * 100
|
| 683 |
+
if abs(diff) <= cfg["near_cost_pct"]:
|
| 684 |
+
add("near_cost", "near_cost", "关注", "接近成本价",
|
| 685 |
+
f"成本价 {fmt_price(cfg['cost_price'])},当前偏离 {diff:+.2f}%")
|
| 686 |
+
|
| 687 |
+
for label, pkey in (("目标价", "target_price"), ("止损价", "stop_price")):
|
| 688 |
+
kp = cfg.get(pkey, 0)
|
| 689 |
+
if kp > 0:
|
| 690 |
+
diff = (price - kp) / kp * 100
|
| 691 |
+
if abs(diff) <= cfg["near_key_pct"]:
|
| 692 |
+
add("near_key", f"near_{pkey}", "关注", f"接近{label}",
|
| 693 |
+
f"{label} {fmt_price(kp)},当前偏离 {diff:+.2f}%")
|
| 694 |
+
|
| 695 |
+
# 3) 突破 24h 高/低(突破后维持一段时间)
|
| 696 |
+
self._check_break_24h(cfg, price, high24, low24, head, add)
|
| 697 |
+
|
| 698 |
+
# 4) 突破 7 日高/低
|
| 699 |
+
self._check_break_7d(cfg, sym, price, head, add)
|
| 700 |
+
|
| 701 |
+
# 5) 成交量
|
| 702 |
+
if qvol24 > 0 and len(qvols) >= 5:
|
| 703 |
+
cur5 = sum(qvols[-5:])
|
| 704 |
+
avg5 = qvol24 / 288.0
|
| 705 |
+
if avg5 > 0:
|
| 706 |
+
ratio = cur5 / avg5
|
| 707 |
+
if ratio >= cfg["vol_severe_mult"]:
|
| 708 |
+
add("volume", "vol_severe", "关注", f"严重放量 x{ratio:.1f}",
|
| 709 |
+
f"近5分钟成交 {fmt_usdt(cur5)},为24h均量的 {ratio:.1f} 倍")
|
| 710 |
+
elif ratio >= cfg["vol_anomaly_mult"]:
|
| 711 |
+
add("volume", "vol_anomaly", "关注", f"成交量异常 x{ratio:.1f}",
|
| 712 |
+
f"近5分钟成交 {fmt_usdt(cur5)},为24h均量的 {ratio:.1f} 倍")
|
| 713 |
+
|
| 714 |
+
# 6) 振幅
|
| 715 |
+
for win, n, thr_key in (("5分钟", 5, "amp_5m"), ("15分钟", 15, "amp_15m"), ("1小时", 60, "amp_1h")):
|
| 716 |
+
if len(highs) >= n:
|
| 717 |
+
hi = max(highs[-n:])
|
| 718 |
+
lo = min(lows[-n:])
|
| 719 |
+
if lo > 0:
|
| 720 |
+
amp = (hi - lo) / lo * 100
|
| 721 |
+
if amp >= cfg[thr_key]:
|
| 722 |
+
add("amplitude", f"amp_{n}", "关注", f"{win}振幅过大 {amp:.2f}%",
|
| 723 |
+
f"{win}高低差 {amp:.2f}%({fmt_price(lo)} ~ {fmt_price(hi)})")
|
| 724 |
+
|
| 725 |
+
# 7) 大额成交
|
| 726 |
+
self._check_trades(cfg, sym, head, add)
|
| 727 |
+
|
| 728 |
+
# 8) 盘口(价差 / 失衡 / 买卖墙)
|
| 729 |
+
self._check_orderbook(cfg, sym, price, head, add)
|
| 730 |
+
|
| 731 |
+
# 9) 价格偏离均线
|
| 732 |
+
self._check_ma(cfg, sym, price, head, add)
|
| 733 |
+
|
| 734 |
+
# 10) 连续 K 线
|
| 735 |
+
self._check_consecutive(cfg, sym, head, add)
|
| 736 |
+
|
| 737 |
+
# 11) RSI
|
| 738 |
+
self._check_rsi(cfg, sym, head, add)
|
| 739 |
+
|
| 740 |
+
# 12) 合约类
|
| 741 |
+
if cfg.get("enable_futures"):
|
| 742 |
+
self._check_futures(cfg, head, add)
|
| 743 |
+
|
| 744 |
+
snapshot = {
|
| 745 |
+
"price": price, "high24": high24, "low24": low24,
|
| 746 |
+
"rsi": self.last_rsi, "funding": self.last_funding_pct,
|
| 747 |
+
}
|
| 748 |
+
return alerts, snapshot
|
| 749 |
+
|
| 750 |
+
|
| 751 |
+
# ---- 子检查 ----
|
| 752 |
+
def _check_break_24h(self, cfg, price, high24, low24, head, add):
|
| 753 |
+
now = time.time()
|
| 754 |
+
hold = cfg["break_24h_hold_sec"]
|
| 755 |
+
thr = cfg["break_24h_pct"]
|
| 756 |
+
# 突破高点
|
| 757 |
+
if high24 > 0 and price >= high24 * (1 + thr / 100):
|
| 758 |
+
self.break_24h_since.setdefault("high", now)
|
| 759 |
+
if now - self.break_24h_since["high"] >= hold:
|
| 760 |
+
add("break_24h", "break_24h_high", "涨", "突破24h高点 📈",
|
| 761 |
+
f"已突破24h高 {fmt_price(high24)} 超 {thr}% 并维持 {hold // 60} 分钟以上")
|
| 762 |
+
else:
|
| 763 |
+
self.break_24h_since.pop("high", None)
|
| 764 |
+
# 突破低点
|
| 765 |
+
if low24 > 0 and price <= low24 * (1 - thr / 100):
|
| 766 |
+
self.break_24h_since.setdefault("low", now)
|
| 767 |
+
if now - self.break_24h_since["low"] >= hold:
|
| 768 |
+
add("break_24h", "break_24h_low", "跌", "突破24h低点 📉",
|
| 769 |
+
f"已跌破24h低 {fmt_price(low24)} 超 {thr}% 并维持 {hold // 60} 分钟以上")
|
| 770 |
+
else:
|
| 771 |
+
self.break_24h_since.pop("low", None)
|
| 772 |
+
|
| 773 |
+
def _check_break_7d(self, cfg, sym, price, head, add):
|
| 774 |
+
kd = self.api.klines(sym, "1d", 8)
|
| 775 |
+
if not kd or len(kd) < 2:
|
| 776 |
+
return
|
| 777 |
+
# 取已收盘的前 7 日(排除当前未收盘日)
|
| 778 |
+
closed = kd[:-1][-7:]
|
| 779 |
+
hi7 = max(float(k[2]) for k in closed)
|
| 780 |
+
lo7 = min(float(k[3]) for k in closed)
|
| 781 |
+
thr = cfg["break_7d_pct"]
|
| 782 |
+
if hi7 > 0 and price >= hi7 * (1 + thr / 100):
|
| 783 |
+
add("break_7d", "break_7d_high", "涨", "突破7日高点 📈",
|
| 784 |
+
f"已突破7日高 {fmt_price(hi7)} 超 {thr}%")
|
| 785 |
+
if lo7 > 0 and price <= lo7 * (1 - thr / 100):
|
| 786 |
+
add("break_7d", "break_7d_low", "跌", "跌破7日低点 📉",
|
| 787 |
+
f"已跌破7日低 {fmt_price(lo7)} 超 {thr}%")
|
| 788 |
+
|
| 789 |
+
def _check_trades(self, cfg, sym, head, add):
|
| 790 |
+
trades = self.api.agg_trades(sym, 1000)
|
| 791 |
+
if not trades:
|
| 792 |
+
return
|
| 793 |
+
cutoff_ms = (time.time() - 300) * 1000
|
| 794 |
+
big_thr = cfg["big_trade_usdt"]
|
| 795 |
+
cum = 0.0
|
| 796 |
+
max_single = 0.0
|
| 797 |
+
for t in trades:
|
| 798 |
+
try:
|
| 799 |
+
notional = float(t["p"]) * float(t["q"])
|
| 800 |
+
ts = t.get("T", 0)
|
| 801 |
+
except (KeyError, ValueError):
|
| 802 |
+
continue
|
| 803 |
+
if ts >= cutoff_ms:
|
| 804 |
+
cum += notional
|
| 805 |
+
if notional > max_single:
|
| 806 |
+
max_single = notional
|
| 807 |
+
if max_single >= big_thr:
|
| 808 |
+
add("big_trade", "big_trade", "关注", f"单笔大额成交 {fmt_usdt(max_single)}",
|
| 809 |
+
f"出现单笔成交 {fmt_usdt(max_single)}(阈值 {fmt_usdt(big_thr)})")
|
| 810 |
+
if cum >= cfg["cum_trade_5m_usdt"]:
|
| 811 |
+
add("cum_trade", "cum_trade", "关注", f"连续大额成交 {fmt_usdt(cum)}",
|
| 812 |
+
f"近5分钟累计成交 {fmt_usdt(cum)}(阈值 {fmt_usdt(cfg['cum_trade_5m_usdt'])})")
|
| 813 |
+
|
| 814 |
+
def _check_orderbook(self, cfg, sym, price, head, add):
|
| 815 |
+
ob = self.api.depth(sym, 1000)
|
| 816 |
+
if not ob or not ob.get("bids") or not ob.get("asks"):
|
| 817 |
+
return
|
| 818 |
+
bids = [(float(p), float(q)) for p, q in ob["bids"]]
|
| 819 |
+
asks = [(float(p), float(q)) for p, q in ob["asks"]]
|
| 820 |
+
best_bid = bids[0][0]
|
| 821 |
+
best_ask = asks[0][0]
|
| 822 |
+
mid = (best_bid + best_ask) / 2
|
| 823 |
+
if mid <= 0:
|
| 824 |
+
return
|
| 825 |
+
|
| 826 |
+
# 价差
|
| 827 |
+
spread = (best_ask - best_bid) / mid * 100
|
| 828 |
+
if spread >= cfg["spread_severe_pct"]:
|
| 829 |
+
add("spread", "spread", "关注", f"买卖价差异常 {spread:.3f}%",
|
| 830 |
+
f"当前价差 {spread:.3f}%(严重阈值 {cfg['spread_severe_pct']}%)")
|
| 831 |
+
elif spread >= cfg["spread_pct"]:
|
| 832 |
+
add("spread", "spread", "关注", f"买卖价差偏大 {spread:.3f}%",
|
| 833 |
+
f"当前价差 {spread:.3f}%(阈值 {cfg['spread_pct']}%)")
|
| 834 |
+
|
| 835 |
+
# 盘口失衡
|
| 836 |
+
dp = cfg["imbalance_depth_pct"] / 100
|
| 837 |
+
bid_usdt = sum(p * q for p, q in bids if p >= mid * (1 - dp))
|
| 838 |
+
ask_usdt = sum(p * q for p, q in asks if p <= mid * (1 + dp))
|
| 839 |
+
if bid_usdt > 0 and ask_usdt > 0:
|
| 840 |
+
if bid_usdt / ask_usdt >= cfg["imbalance_ratio"]:
|
| 841 |
+
add("imbalance", "imbalance_buy", "涨", "买盘失衡(买强)",
|
| 842 |
+
f"±{cfg['imbalance_depth_pct']}%深度内 买/卖 = {bid_usdt / ask_usdt:.1f} 倍")
|
| 843 |
+
elif ask_usdt / bid_usdt >= cfg["imbalance_ratio"]:
|
| 844 |
+
add("imbalance", "imbalance_sell", "跌", "卖盘失衡(卖强)",
|
| 845 |
+
f"±{cfg['imbalance_depth_pct']}%深度内 卖/买 = {ask_usdt / bid_usdt:.1f} 倍")
|
| 846 |
+
|
| 847 |
+
# 买卖墙 + 墙消失
|
| 848 |
+
wp = cfg["wall_depth_pct"] / 100
|
| 849 |
+
self._wall_side(cfg, "bid", "买墙", bids, [p for p, q in bids if p >= mid * (1 - wp)],
|
| 850 |
+
bids, mid, wp, head, add, "涨")
|
| 851 |
+
self._wall_side(cfg, "ask", "卖墙", asks, [p for p, q in asks if p <= mid * (1 + wp)],
|
| 852 |
+
asks, mid, wp, head, add, "跌")
|
| 853 |
+
|
| 854 |
+
def _wall_side(self, cfg, side, label, _all, _prices, orders, mid, wp, head, add, direction):
|
| 855 |
+
if side == "bid":
|
| 856 |
+
near = [(p, q) for p, q in orders if p >= mid * (1 - wp)]
|
| 857 |
+
else:
|
| 858 |
+
near = [(p, q) for p, q in orders if p <= mid * (1 + wp)]
|
| 859 |
+
if len(near) < 3:
|
| 860 |
+
self.prev_walls[side] = 0.0
|
| 861 |
+
return
|
| 862 |
+
notionals = [p * q for p, q in near]
|
| 863 |
+
max_n = max(notionals)
|
| 864 |
+
avg_n = sum(notionals) / len(notionals)
|
| 865 |
+
wall_price = near[notionals.index(max_n)][0]
|
| 866 |
+
prev = self.prev_walls.get(side, 0.0)
|
| 867 |
+
|
| 868 |
+
if avg_n > 0 and max_n >= avg_n * cfg["wall_mult"]:
|
| 869 |
+
add("wall", f"wall_{side}", "关注", f"出现{label} {fmt_usdt(max_n)}",
|
| 870 |
+
f"{fmt_price(wall_price)} 附近挂单 {fmt_usdt(max_n)},为附近均值的 {max_n / avg_n:.1f} 倍")
|
| 871 |
+
self.prev_walls[side] = max_n
|
| 872 |
+
else:
|
| 873 |
+
# 墙消失检测
|
| 874 |
+
if prev > 0 and max_n <= prev * (1 - cfg["wall_vanish_pct"] / 100):
|
| 875 |
+
add("wall_vanish", f"wall_vanish_{side}",
|
| 876 |
+
"跌" if side == "bid" else "涨", f"{label}快速撤离",
|
| 877 |
+
f"原 {label} {fmt_usdt(prev)} 已减少超 {cfg['wall_vanish_pct']}%")
|
| 878 |
+
self.prev_walls[side] = max_n
|
| 879 |
+
|
| 880 |
+
def _check_ma(self, cfg, sym, price, head, add):
|
| 881 |
+
kh = self.api.klines(sym, "1h", 21)
|
| 882 |
+
if not kh or len(kh) < 20:
|
| 883 |
+
return
|
| 884 |
+
closes = _floats(kh[-20:], 4)
|
| 885 |
+
ma20 = sum(closes) / 20
|
| 886 |
+
if ma20 <= 0:
|
| 887 |
+
return
|
| 888 |
+
dev = (price - ma20) / ma20 * 100
|
| 889 |
+
if abs(dev) >= cfg["ma_deviation_pct"]:
|
| 890 |
+
direction = "涨" if dev > 0 else "跌"
|
| 891 |
+
add("ma_deviation", "ma_deviation", direction, f"价格偏离均线 {dev:+.2f}%",
|
| 892 |
+
f"偏离1小时MA20({fmt_price(ma20)}) {dev:+.2f}%")
|
| 893 |
+
|
| 894 |
+
def _check_consecutive(self, cfg, sym, head, add):
|
| 895 |
+
n = int(cfg["consecutive_klines"])
|
| 896 |
+
kl = self.api.klines(sym, "15m", n + 1)
|
| 897 |
+
if not kl or len(kl) < n:
|
| 898 |
+
return
|
| 899 |
+
recent = kl[-n:]
|
| 900 |
+
ups = all(float(k[4]) > float(k[1]) for k in recent)
|
| 901 |
+
downs = all(float(k[4]) < float(k[1]) for k in recent)
|
| 902 |
+
if ups:
|
| 903 |
+
add("consecutive", "consecutive_up", "涨", "连续上涨 �",
|
| 904 |
+
f"连续 {n} 根15分钟阳��")
|
| 905 |
+
elif downs:
|
| 906 |
+
add("consecutive", "consecutive_down", "跌", "连续下跌 📉",
|
| 907 |
+
f"连续 {n} 根15分钟阴线")
|
| 908 |
+
|
| 909 |
+
def _check_rsi(self, cfg, sym, head, add):
|
| 910 |
+
kl = self.api.klines(sym, "15m", 100)
|
| 911 |
+
if not kl or len(kl) < 20:
|
| 912 |
+
return
|
| 913 |
+
closes = _floats(kl, 4)
|
| 914 |
+
rsi = compute_rsi(closes, 14)
|
| 915 |
+
self.last_rsi = rsi
|
| 916 |
+
if rsi is None:
|
| 917 |
+
return
|
| 918 |
+
if rsi >= cfg["rsi_high"]:
|
| 919 |
+
add("rsi", "rsi_high", "关注", f"RSI 超买 {rsi:.0f}",
|
| 920 |
+
f"15分钟 RSI = {rsi:.1f}(超买阈值 {cfg['rsi_high']}),警惕回调")
|
| 921 |
+
elif rsi <= cfg["rsi_low"]:
|
| 922 |
+
add("rsi", "rsi_low", "关注", f"RSI 超卖 {rsi:.0f}",
|
| 923 |
+
f"15分钟 RSI = {rsi:.1f}(超卖阈值 {cfg['rsi_low']}),警惕反弹")
|
| 924 |
+
|
| 925 |
+
def _check_futures(self, cfg, head, add):
|
| 926 |
+
fsym = cfg["futures_symbol"]
|
| 927 |
+
# 资金费率
|
| 928 |
+
pi = self.api.premium_index(fsym)
|
| 929 |
+
if pi and pi.get("lastFundingRate") is not None:
|
| 930 |
+
rate = float(pi["lastFundingRate"]) * 100 # 转百分比
|
| 931 |
+
self.last_funding_pct = rate
|
| 932 |
+
if rate >= cfg["funding_hot"]:
|
| 933 |
+
add("funding", "funding_hot", "关注", f"资金费率过热 {rate:+.4f}%",
|
| 934 |
+
f"当前资金费率 {rate:+.4f}%/期(过热阈值 {cfg['funding_hot']}%),多头拥挤")
|
| 935 |
+
elif rate >= cfg["funding_high"]:
|
| 936 |
+
add("funding", "funding_high", "关注", f"资金费率偏高 {rate:+.4f}%",
|
| 937 |
+
f"当前资金费率 {rate:+.4f}%/期(偏高阈值 {cfg['funding_high']}%)")
|
| 938 |
+
elif rate <= cfg["funding_neg"]:
|
| 939 |
+
add("funding", "funding_neg", "关注", f"资金费率偏负 {rate:+.4f}%",
|
| 940 |
+
f"当前资金费率 {rate:+.4f}%/期(偏负阈值 {cfg['funding_neg']}%),空头拥挤")
|
| 941 |
+
|
| 942 |
+
# 持仓量变化
|
| 943 |
+
oi_data = self.api.open_interest(fsym)
|
| 944 |
+
if oi_data and oi_data.get("openInterest") is not None:
|
| 945 |
+
oi = float(oi_data["openInterest"])
|
| 946 |
+
now = time.time()
|
| 947 |
+
self.oi_history.append((now, oi))
|
| 948 |
+
for win_min, thr_key, cn in ((15, "oi_15m", "15分钟"), (60, "oi_1h", "1小时"), (240, "oi_4h", "4小时")):
|
| 949 |
+
past = self._oi_at(now - win_min * 60)
|
| 950 |
+
if past and past > 0:
|
| 951 |
+
chg = (oi - past) / past * 100
|
| 952 |
+
if abs(chg) >= cfg[thr_key]:
|
| 953 |
+
direction = "增" if chg > 0 else "减"
|
| 954 |
+
add("oi", f"oi_{win_min}", "关注", f"持仓量{cn}{direction} {chg:+.1f}%",
|
| 955 |
+
f"持仓量{cn}内{direction} {chg:+.1f}%(阈值 {cfg[thr_key]}%)")
|
| 956 |
+
|
| 957 |
+
# 强平
|
| 958 |
+
liq5 = self.liq.get_5m_total(fsym) if self.liq else None
|
| 959 |
+
if liq5 is not None:
|
| 960 |
+
if liq5 >= cfg["liq_severe_usdt"]:
|
| 961 |
+
add("liquidation", "liq_severe", "关注", f"严重强平 {fmt_usdt(liq5)}",
|
| 962 |
+
f"近5分钟强平 {fmt_usdt(liq5)}(严重阈值 {fmt_usdt(cfg['liq_severe_usdt'])})")
|
| 963 |
+
elif liq5 >= cfg["liq_5m_usdt"]:
|
| 964 |
+
add("liquidation", "liq_5m", "关注", f"强平金额异常 {fmt_usdt(liq5)}",
|
| 965 |
+
f"近5分钟强平 {fmt_usdt(liq5)}(阈值 {fmt_usdt(cfg['liq_5m_usdt'])})")
|
| 966 |
+
|
| 967 |
+
def _oi_at(self, target_ts):
|
| 968 |
+
"""返回最接近 target_ts 的历史持仓量(要求有足够久的样本)"""
|
| 969 |
+
best = None
|
| 970 |
+
best_diff = None
|
| 971 |
+
for ts, oi in self.oi_history:
|
| 972 |
+
diff = abs(ts - target_ts)
|
| 973 |
+
if best_diff is None or diff < best_diff:
|
| 974 |
+
best_diff = diff
|
| 975 |
+
best = oi
|
| 976 |
+
# 样本与目标时间差太大(超过窗口一半)则视为无效
|
| 977 |
+
if best_diff is not None and best_diff <= 600:
|
| 978 |
+
return best
|
| 979 |
+
return None
|
| 980 |
+
|
| 981 |
+
|
| 982 |
+
# --- 10. 监控主逻辑 ---
|
| 983 |
+
class CryptoMonitor:
|
| 984 |
+
def __init__(self):
|
| 985 |
+
self.logs = deque(maxlen=80)
|
| 986 |
+
self.status_text = "初始化中..."
|
| 987 |
+
self.next_wakeup = None
|
| 988 |
+
self.lock = threading.Lock()
|
| 989 |
+
|
| 990 |
+
self.config = load_config()
|
| 991 |
+
self.snapshots = {} # coin -> 最新行情快照
|
| 992 |
+
self.stats = {"alerts": 0, "notify_fails": 0, "api_fails": 0, "loops": 0}
|
| 993 |
+
self.last_daily_report_date = None
|
| 994 |
+
# 配置文件不存在时,首次启动自动校准默认阈值
|
| 995 |
+
self._auto_calibrate_needed = not os.path.exists(CONFIG_FILE)
|
| 996 |
+
|
| 997 |
+
self.api = BinanceAPI(self.log)
|
| 998 |
+
self.pusher = WeWorkBotPusher(WEWORK_BOT_WEBHOOK)
|
| 999 |
+
self.alert_mgr = AlertManager(self.pusher, self.log, self.stats)
|
| 1000 |
+
self.liq = LiquidationTracker(self._futures_symbols(), self.log)
|
| 1001 |
+
|
| 1002 |
+
self.evaluators = {}
|
| 1003 |
+
self._rebuild_evaluators()
|
| 1004 |
+
|
| 1005 |
+
self.thread = threading.Thread(target=self._run_loop, daemon=True)
|
| 1006 |
+
self.thread.start()
|
| 1007 |
+
|
| 1008 |
+
def log(self, msg):
|
| 1009 |
+
ts = get_beijing_now().strftime("%H:%M:%S")
|
| 1010 |
+
entry = f"[{ts}] {msg}"
|
| 1011 |
+
print(entry)
|
| 1012 |
+
self.logs.appendleft(entry)
|
| 1013 |
+
|
| 1014 |
+
def _futures_symbols(self):
|
| 1015 |
+
return [c["futures_symbol"] for c in self.config["coins"].values()
|
| 1016 |
+
if c.get("enable_futures")]
|
| 1017 |
+
|
| 1018 |
+
def _rebuild_evaluators(self):
|
| 1019 |
+
with self.lock:
|
| 1020 |
+
for name in list(self.evaluators.keys()):
|
| 1021 |
+
if name not in self.config["coins"]:
|
| 1022 |
+
self.evaluators.pop(name, None)
|
| 1023 |
+
for name in self.config["coins"]:
|
| 1024 |
+
if name not in self.evaluators:
|
| 1025 |
+
self.evaluators[name] = CoinEvaluator(name, self.api, self.liq, self.log)
|
| 1026 |
+
|
| 1027 |
+
def update_config(self, new_cfg):
|
| 1028 |
+
"""由前端调用:保存并热更新配置"""
|
| 1029 |
+
with self.lock:
|
| 1030 |
+
self.config = new_cfg
|
| 1031 |
+
save_config(new_cfg)
|
| 1032 |
+
self.liq.update_symbols(self._futures_symbols())
|
| 1033 |
+
self._rebuild_evaluators()
|
| 1034 |
+
self.log("⚙️ 配置已更新并保存")
|
| 1035 |
+
|
| 1036 |
+
def calibrate_all(self):
|
| 1037 |
+
"""对所有币种执行历史校准并保存(后台首次启动调用)"""
|
| 1038 |
+
with self.lock:
|
| 1039 |
+
cfg = copy.deepcopy(self.config)
|
| 1040 |
+
scale = float(cfg["global"].get("alert_sensitivity", 1.0))
|
| 1041 |
+
for name, coin in cfg["coins"].items():
|
| 1042 |
+
try:
|
| 1043 |
+
overrides = calibrate_thresholds(self.api, coin, self.log, scale)
|
| 1044 |
+
cfg["coins"][name] = _deep_merge(coin, overrides)
|
| 1045 |
+
except Exception as e:
|
| 1046 |
+
self.log(f"⚠️ {name} 校准失败: {e}")
|
| 1047 |
+
with self.lock:
|
| 1048 |
+
self.config = cfg
|
| 1049 |
+
save_config(cfg)
|
| 1050 |
+
self._rebuild_evaluators()
|
| 1051 |
+
|
| 1052 |
+
def _maybe_daily_report(self):
|
| 1053 |
+
now = get_beijing_now()
|
| 1054 |
+
today = now.strftime("%Y-%m-%d")
|
| 1055 |
+
hour = int(self.config["global"].get("daily_report_hour", 9))
|
| 1056 |
+
if now.time() >= dt_time(hour, 0) and self.last_daily_report_date != today:
|
| 1057 |
+
if self.last_daily_report_date is None:
|
| 1058 |
+
# 首次启动当天不发,避免重启刷屏
|
| 1059 |
+
self.last_daily_report_date = today
|
| 1060 |
+
return
|
| 1061 |
+
coins = "、".join(self.config["coins"].keys())
|
| 1062 |
+
msg = (
|
| 1063 |
+
f"🚀 虚拟货币价格监控日报\n\n"
|
| 1064 |
+
f"{now.strftime('%Y年%m月%d日')} 服务正常运行中。\n"
|
| 1065 |
+
f"监控币种:{coins}\n\n"
|
| 1066 |
+
f"昨日统计:\n"
|
| 1067 |
+
f"推送告警:{self.stats['alerts']} 次\n"
|
| 1068 |
+
f"发送失败:{self.stats['notify_fails']} 次\n"
|
| 1069 |
+
f"接口失败:{self.stats['api_fails']} 次\n"
|
| 1070 |
+
f"轮询次数:{self.stats['loops']} 次"
|
| 1071 |
+
)
|
| 1072 |
+
self.pusher.send_text(msg)
|
| 1073 |
+
self.log(f"🔔 已发送每日报告: {today}")
|
| 1074 |
+
self.last_daily_report_date = today
|
| 1075 |
+
self.stats.update({"alerts": 0, "notify_fails": 0, "api_fails": 0, "loops": 0})
|
| 1076 |
+
|
| 1077 |
+
def _run_loop(self):
|
| 1078 |
+
self.log("🚀 虚拟货币价格监控服务已启动")
|
| 1079 |
+
if not WEWORK_BOT_WEBHOOK:
|
| 1080 |
+
self.log("⚠️ 未配置 WEWORK_BOT_WEBHOOK,仅记录日志不推送")
|
| 1081 |
+
if ws_client is None:
|
| 1082 |
+
self.log("⚠️ 未安装 websocket-client,强平监控已禁用")
|
| 1083 |
+
|
| 1084 |
+
if self._auto_calibrate_needed:
|
| 1085 |
+
self.log("🧪 首次启动:根据历史行情自动校准各币种阈值...")
|
| 1086 |
+
try:
|
| 1087 |
+
self.calibrate_all()
|
| 1088 |
+
self.log("✅ 首次自动校准完成并已保存")
|
| 1089 |
+
except Exception as e:
|
| 1090 |
+
self.log(f"⚠️ 自动校准异常: {e}")
|
| 1091 |
+
self._auto_calibrate_needed = False
|
| 1092 |
+
|
| 1093 |
+
while True:
|
| 1094 |
+
try:
|
| 1095 |
+
with self.lock:
|
| 1096 |
+
cfg = copy.deepcopy(self.config)
|
| 1097 |
+
interval = int(cfg["global"].get("poll_interval_sec", 30))
|
| 1098 |
+
|
| 1099 |
+
self._maybe_daily_report()
|
| 1100 |
+
|
| 1101 |
+
self.status_text = "🔥 监控中"
|
| 1102 |
+
total_alerts = []
|
| 1103 |
+
for name, coin_cfg in cfg["coins"].items():
|
| 1104 |
+
evaluator = self.evaluators.get(name)
|
| 1105 |
+
if not evaluator:
|
| 1106 |
+
continue
|
| 1107 |
+
try:
|
| 1108 |
+
alerts, snap = evaluator.evaluate(coin_cfg)
|
| 1109 |
+
if snap:
|
| 1110 |
+
self.snapshots[name] = snap
|
| 1111 |
+
else:
|
| 1112 |
+
self.stats["api_fails"] += 1
|
| 1113 |
+
total_alerts.extend(alerts)
|
| 1114 |
+
except Exception as e:
|
| 1115 |
+
self.log(f"❌ {name} 评估异常: {e}")
|
| 1116 |
+
|
| 1117 |
+
if total_alerts:
|
| 1118 |
+
self.alert_mgr.emit(total_alerts)
|
| 1119 |
+
|
| 1120 |
+
self.stats["loops"] += 1
|
| 1121 |
+
now = get_beijing_now()
|
| 1122 |
+
self.next_wakeup = now + timedelta(seconds=interval)
|
| 1123 |
+
time.sleep(interval)
|
| 1124 |
+
|
| 1125 |
+
except Exception as e:
|
| 1126 |
+
self.log(f"❌ 主循环异常: {e}")
|
| 1127 |
+
time.sleep(30)
|
| 1128 |
+
|
| 1129 |
+
|
| 1130 |
+
# --- 11. Streamlit 前端 ---
|
| 1131 |
+
@st.cache_resource
|
| 1132 |
+
def get_monitor():
|
| 1133 |
+
return CryptoMonitor()
|
| 1134 |
+
|
| 1135 |
+
|
| 1136 |
+
# 阈值字段的中文标签与分组(用于动态生成编辑器)
|
| 1137 |
+
FIELD_GROUPS = [
|
| 1138 |
+
("关键价位", [
|
| 1139 |
+
("cost_price", "成本价 (0=不监控)"),
|
| 1140 |
+
("target_price", "目标价 (0=不监控)"),
|
| 1141 |
+
("stop_price", "止损价 (0=不监控)"),
|
| 1142 |
+
("near_cost_pct", "接近成本价阈值 %"),
|
| 1143 |
+
("near_key_pct", "接近目标/止损阈值 %"),
|
| 1144 |
+
]),
|
| 1145 |
+
("突破", [
|
| 1146 |
+
("break_24h_pct", "突破24h高低 %"),
|
| 1147 |
+
("break_24h_hold_sec", "突破维持秒数"),
|
| 1148 |
+
("break_7d_pct", "突破7日高低 %"),
|
| 1149 |
+
]),
|
| 1150 |
+
("成交量与振幅", [
|
| 1151 |
+
("vol_anomaly_mult", "5m放量倍数"),
|
| 1152 |
+
("vol_severe_mult", "5m严重放量倍数"),
|
| 1153 |
+
("amp_5m", "5m振幅 %"),
|
| 1154 |
+
("amp_15m", "15m振幅 %"),
|
| 1155 |
+
("amp_1h", "1h振幅 %"),
|
| 1156 |
+
]),
|
| 1157 |
+
("大额成交", [
|
| 1158 |
+
("big_trade_usdt", "单笔大额 USDT"),
|
| 1159 |
+
("cum_trade_5m_usdt", "5m累计大额 USDT"),
|
| 1160 |
+
]),
|
| 1161 |
+
("盘口", [
|
| 1162 |
+
("spread_pct", "价差 %"),
|
| 1163 |
+
("spread_severe_pct", "严重价差 %"),
|
| 1164 |
+
("imbalance_depth_pct", "失衡统计深度 %"),
|
| 1165 |
+
("imbalance_ratio", "买卖失衡倍数"),
|
| 1166 |
+
("wall_depth_pct", "买卖墙深度 %"),
|
| 1167 |
+
("wall_mult", "墙厚度倍数"),
|
| 1168 |
+
("wall_vanish_pct", "墙消失减少 %"),
|
| 1169 |
+
]),
|
| 1170 |
+
("技术指标", [
|
| 1171 |
+
("ma_deviation_pct", "偏离1h MA20 %"),
|
| 1172 |
+
("consecutive_klines", "连续15m K线根数"),
|
| 1173 |
+
("rsi_high", "RSI 超买"),
|
| 1174 |
+
("rsi_low", "RSI 超卖"),
|
| 1175 |
+
]),
|
| 1176 |
+
("合约 (需启用)", [
|
| 1177 |
+
("funding_high", "资金费率偏高 %"),
|
| 1178 |
+
("funding_hot", "资金费率过热 %"),
|
| 1179 |
+
("funding_neg", "资金费率偏负 %"),
|
| 1180 |
+
("oi_15m", "持仓15m变化 %"),
|
| 1181 |
+
("oi_1h", "持仓1h变化 %"),
|
| 1182 |
+
("oi_4h", "持仓4h变化 %"),
|
| 1183 |
+
("liq_5m_usdt", "5m强平 USDT"),
|
| 1184 |
+
("liq_severe_usdt", "严重强平 USDT"),
|
| 1185 |
+
]),
|
| 1186 |
+
]
|
| 1187 |
+
|
| 1188 |
+
INT_FIELDS = {"break_24h_hold_sec", "consecutive_klines"}
|
| 1189 |
+
|
| 1190 |
+
|
| 1191 |
+
def render_config_editor(monitor):
|
| 1192 |
+
cfg = copy.deepcopy(monitor.config)
|
| 1193 |
+
st.subheader("⚙️ 监控参数设置")
|
| 1194 |
+
|
| 1195 |
+
with st.expander("全局设置", expanded=False):
|
| 1196 |
+
g = cfg["global"]
|
| 1197 |
+
g["poll_interval_sec"] = st.number_input(
|
| 1198 |
+
"轮询间隔(秒)", min_value=5, max_value=600,
|
| 1199 |
+
value=int(g.get("poll_interval_sec", 30)), step=5)
|
| 1200 |
+
g["daily_report_hour"] = st.number_input(
|
| 1201 |
+
"每日报告时间(小时)", min_value=0, max_value=23,
|
| 1202 |
+
value=int(g.get("daily_report_hour", 9)))
|
| 1203 |
+
g["alert_sensitivity"] = st.number_input(
|
| 1204 |
+
"通知灵敏度(越大通知越多,建议 0.5~2.0)", min_value=0.2, max_value=5.0,
|
| 1205 |
+
value=float(g.get("alert_sensitivity", 1.0)), step=0.1, format="%.1f",
|
| 1206 |
+
help="校准时按“理想每日触发次数 × 灵敏度”反推阈值;调整后点币种页的“历史自动校准”生效")
|
| 1207 |
+
|
| 1208 |
+
coin_names = list(cfg["coins"].keys())
|
| 1209 |
+
tabs = st.tabs(coin_names + ["➕ 新增币种"])
|
| 1210 |
+
|
| 1211 |
+
for i, name in enumerate(coin_names):
|
| 1212 |
+
with tabs[i]:
|
| 1213 |
+
coin = cfg["coins"][name]
|
| 1214 |
+
cc1, cc2, cc3 = st.columns(3)
|
| 1215 |
+
with cc1:
|
| 1216 |
+
coin["symbol"] = st.text_input(
|
| 1217 |
+
"现货交易对", value=coin["symbol"], key=f"{name}_symbol")
|
| 1218 |
+
with cc2:
|
| 1219 |
+
coin["futures_symbol"] = st.text_input(
|
| 1220 |
+
"合约交易对", value=coin["futures_symbol"], key=f"{name}_fsym")
|
| 1221 |
+
with cc3:
|
| 1222 |
+
coin["enable_futures"] = st.checkbox(
|
| 1223 |
+
"启用合约监控", value=coin.get("enable_futures", False), key=f"{name}_futenable")
|
| 1224 |
+
|
| 1225 |
+
cal_col, _ = st.columns([1, 3])
|
| 1226 |
+
if cal_col.button("📈 历史自动校准", key=f"cal_{name}",
|
| 1227 |
+
help="拉取该币种历史行情,自动推算更贴合的阈值"):
|
| 1228 |
+
with st.spinner(f"正在根据历史行情校准 {name} ..."):
|
| 1229 |
+
scale = float(cfg["global"].get("alert_sensitivity", 1.0))
|
| 1230 |
+
overrides = calibrate_thresholds(monitor.api, coin, monitor.log, scale)
|
| 1231 |
+
cfg["coins"][name] = _deep_merge(coin, overrides)
|
| 1232 |
+
monitor.update_config(cfg)
|
| 1233 |
+
st.success(f"{name} 已根据历史校准并保存")
|
| 1234 |
+
st.rerun()
|
| 1235 |
+
|
| 1236 |
+
st.markdown("**快速涨跌触发阈值 %(绝对值,方向🟢涨/🔴跌自动判断)**")
|
| 1237 |
+
pc = coin["price_change"]
|
| 1238 |
+
pcols = st.columns(4)
|
| 1239 |
+
for idx, win in enumerate(["1m", "5m", "15m", "1h"]):
|
| 1240 |
+
val = pc.get(win, 1.0)
|
| 1241 |
+
if isinstance(val, list):
|
| 1242 |
+
val = val[0] if val else 1.0
|
| 1243 |
+
pc[win] = pcols[idx].number_input(
|
| 1244 |
+
win, value=float(val), step=0.05,
|
| 1245 |
+
key=f"{name}_{win}_pc", format="%.2f")
|
| 1246 |
+
|
| 1247 |
+
for group_name, fields in FIELD_GROUPS:
|
| 1248 |
+
with st.expander(group_name, expanded=False):
|
| 1249 |
+
cols = st.columns(2)
|
| 1250 |
+
for j, (fkey, flabel) in enumerate(fields):
|
| 1251 |
+
with cols[j % 2]:
|
| 1252 |
+
cur = coin.get(fkey, 0)
|
| 1253 |
+
if fkey in INT_FIELDS:
|
| 1254 |
+
coin[fkey] = st.number_input(
|
| 1255 |
+
flabel, value=int(cur), step=1, key=f"{name}_{fkey}")
|
| 1256 |
+
else:
|
| 1257 |
+
coin[fkey] = st.number_input(
|
| 1258 |
+
flabel, value=float(cur),
|
| 1259 |
+
step=1000.0 if "usdt" in fkey else 0.1,
|
| 1260 |
+
key=f"{name}_{fkey}", format="%.4f")
|
| 1261 |
+
|
| 1262 |
+
if st.button(f"🗑️ 删除 {name}", key=f"del_{name}"):
|
| 1263 |
+
if len(coin_names) > 1:
|
| 1264 |
+
cfg["coins"].pop(name)
|
| 1265 |
+
monitor.update_config(cfg)
|
| 1266 |
+
st.rerun()
|
| 1267 |
+
else:
|
| 1268 |
+
st.warning("至少保留一个币种")
|
| 1269 |
+
|
| 1270 |
+
# 新增币种
|
| 1271 |
+
with tabs[-1]:
|
| 1272 |
+
st.markdown("基于 BTC 默认阈值创建新币种,创建后可在对应标签页微调。")
|
| 1273 |
+
nc1, nc2 = st.columns(2)
|
| 1274 |
+
new_name = nc1.text_input("币种代号(如 ETH)", key="new_coin_name")
|
| 1275 |
+
new_symbol = nc2.text_input("现货交易对(如 ETHUSDT)", key="new_coin_symbol")
|
| 1276 |
+
if st.button("➕ 创建币种"):
|
| 1277 |
+
new_name = (new_name or "").strip().upper()
|
| 1278 |
+
new_symbol = (new_symbol or "").strip().upper()
|
| 1279 |
+
if not new_name or not new_symbol:
|
| 1280 |
+
st.warning("请填写币种代号和交易对")
|
| 1281 |
+
elif new_name in cfg["coins"]:
|
| 1282 |
+
st.warning("该币种已存在")
|
| 1283 |
+
else:
|
| 1284 |
+
template = _btc_defaults()
|
| 1285 |
+
template["symbol"] = new_symbol
|
| 1286 |
+
template["futures_symbol"] = new_symbol
|
| 1287 |
+
template["enable_futures"] = False
|
| 1288 |
+
with st.spinner(f"正在根据历史行情自动校准 {new_name} 的默认值..."):
|
| 1289 |
+
scale = float(cfg["global"].get("alert_sensitivity", 1.0))
|
| 1290 |
+
overrides = calibrate_thresholds(monitor.api, template, monitor.log, scale)
|
| 1291 |
+
template = _deep_merge(template, overrides)
|
| 1292 |
+
cfg["coins"][new_name] = template
|
| 1293 |
+
monitor.update_config(cfg)
|
| 1294 |
+
st.success(f"已创建并校准 {new_name}")
|
| 1295 |
+
st.rerun()
|
| 1296 |
+
|
| 1297 |
+
st.divider()
|
| 1298 |
+
if st.button("💾 保存全部设置", type="primary"):
|
| 1299 |
+
monitor.update_config(cfg)
|
| 1300 |
+
st.success("设置已保存并热更新")
|
| 1301 |
+
|
| 1302 |
+
|
| 1303 |
+
def main():
|
| 1304 |
+
monitor = get_monitor()
|
| 1305 |
+
if st_autorefresh:
|
| 1306 |
+
st_autorefresh(interval=30 * 1000, key="crypto_refresh")
|
| 1307 |
+
|
| 1308 |
+
st.title("🚀 虚拟货币价格通知")
|
| 1309 |
+
|
| 1310 |
+
c1, c2, c3, c4 = st.columns(4)
|
| 1311 |
+
c1.metric("运行状态", monitor.status_text)
|
| 1312 |
+
c2.metric("下次唤醒",
|
| 1313 |
+
monitor.next_wakeup.strftime("%H:%M:%S") if monitor.next_wakeup else "--")
|
| 1314 |
+
c3.metric("今日累计告警", monitor.stats["alerts"])
|
| 1315 |
+
c4.metric("监控币种", len(monitor.config["coins"]))
|
| 1316 |
+
|
| 1317 |
+
st.divider()
|
| 1318 |
+
|
| 1319 |
+
# 实时行情快照
|
| 1320 |
+
st.subheader("📊 实时行情")
|
| 1321 |
+
snap_cols = st.columns(max(1, len(monitor.snapshots)))
|
| 1322 |
+
if monitor.snapshots:
|
| 1323 |
+
for idx, (name, snap) in enumerate(monitor.snapshots.items()):
|
| 1324 |
+
with snap_cols[idx]:
|
| 1325 |
+
rsi = snap.get("rsi")
|
| 1326 |
+
funding = snap.get("funding")
|
| 1327 |
+
extra = []
|
| 1328 |
+
if rsi is not None:
|
| 1329 |
+
extra.append(f"RSI {rsi:.0f}")
|
| 1330 |
+
if funding is not None:
|
| 1331 |
+
extra.append(f"费率 {funding:+.3f}%")
|
| 1332 |
+
st.metric(name, f"{fmt_price(snap['price'])}",
|
| 1333 |
+
" | ".join(extra) if extra else None)
|
| 1334 |
+
else:
|
| 1335 |
+
st.caption("等待首次行情拉取...")
|
| 1336 |
+
|
| 1337 |
+
st.divider()
|
| 1338 |
+
|
| 1339 |
+
col_log, col_cfg = st.columns([2, 3])
|
| 1340 |
+
with col_log:
|
| 1341 |
+
st.subheader("📜 运行日志")
|
| 1342 |
+
st.text_area("Logs", "\n".join(list(monitor.logs)), height=600, disabled=True)
|
| 1343 |
+
with col_cfg:
|
| 1344 |
+
render_config_editor(monitor)
|
| 1345 |
+
|
| 1346 |
+
|
| 1347 |
+
if __name__ == "__main__":
|
| 1348 |
+
main()
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit
|
| 2 |
+
requests
|
| 3 |
+
python-dotenv
|
| 4 |
+
streamlit-autorefresh
|
| 5 |
+
websocket-client
|