local commited on
Commit
a3d1a52
·
0 Parent(s):

虚拟货币价格通知 Streamlit 应用

Browse files
Files changed (4) hide show
  1. .gitignore +13 -0
  2. README.md +30 -0
  3. coinpush.py +1348 -0
  4. 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