zt p commited on
Commit
b04609c
·
1 Parent(s): cd60f2f

Tune market move alert cadence

Browse files
README.md CHANGED
@@ -115,12 +115,14 @@ DISABLE_WORKER=true .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 7860
115
  - `STRATEGY_SEC_8K_SCAN_LIMIT`:启动或最新 8-K 变化时最多扫描多少份 8-K,默认 `20`。
116
  - `SEC_USER_AGENT`:SEC API User-Agent,建议配置成包含联系方式的字符串。
117
  - `ENABLE_MARKET_MOVE`:是否启用全市场日/月涨跌监控,默认 `true`。
 
118
  - `MARKET_MOVE_INTERVAL_SEC`:全市场涨跌监控轮询间隔,默认 `300`。
119
- - `MARKET_MOVE_THRESHOLD_PCT`:24h/30d 涨跌提醒阈值百分比,默认 `30`。
120
  - `MARKET_MOVE_LOOKBACK_DAYS`:月线对比天数,默认 `30`。
121
  - `MARKET_MOVE_EXCHANGES`:启用的交易所列表,逗号分隔;默认启用 Binance、OKX、Bitget、Hyperliquid、Asterdex、Bybit、Gate、KuCoin、MEXC、Kraken、Upbit。
122
  - `MARKET_MOVE_COINS`:覆盖默认币种列表,格式如 `BTC,ETH,HYPE:Hyperliquid`。
123
  - `MARKET_MOVE_EXTRA_COINS`:在默认币种列表后追加币种,格式同上。
 
124
  - `BINANCE_API_KEY` / `BINANCE_API_SECRET`:币安私有接口凭证,可选。
125
  - `BINANCE_SPOT_BASE` / `BINANCE_FAPI_BASE` / `BINANCE_FSTREAM_BASE`:币安接口基址,可选。
126
  - `BINANCE_ALPHA_BASE`:Binance Alpha 公共行情基址,默认 `https://www.binance.com`。
 
115
  - `STRATEGY_SEC_8K_SCAN_LIMIT`:启动或最新 8-K 变化时最多扫描多少份 8-K,默认 `20`。
116
  - `SEC_USER_AGENT`:SEC API User-Agent,建议配置成包含联系方式的字符串。
117
  - `ENABLE_MARKET_MOVE`:是否启用全市场日/月涨跌监控,默认 `true`。
118
+ - `MARKET_MOVE_WEWORK_WEBHOOK`:全市场日/月涨跌监控专用企业微信机器人;未配置时使用代码内默认的“寻找牛市标的”机器人。
119
  - `MARKET_MOVE_INTERVAL_SEC`:全市场涨跌监控轮询间隔,默认 `300`。
120
+ - `MARKET_MOVE_THRESHOLD_PCT`:兼容旧配置;当前实际提醒规则为 30% 每 24 小时、50% 每 12 小时、60% 每 6 小时、70% 每 3 小时、80% 每 1 小时,首次触发立即通知。
121
  - `MARKET_MOVE_LOOKBACK_DAYS`:月线对比天数,默认 `30`。
122
  - `MARKET_MOVE_EXCHANGES`:启用的交易所列表,逗号分隔;默认启用 Binance、OKX、Bitget、Hyperliquid、Asterdex、Bybit、Gate、KuCoin、MEXC、Kraken、Upbit。
123
  - `MARKET_MOVE_COINS`:覆盖默认币种列表,格式如 `BTC,ETH,HYPE:Hyperliquid`。
124
  - `MARKET_MOVE_EXTRA_COINS`:在默认币种列表后追加币种,格式同上。
125
+ - `STRATEGY_TREASURY_WEWORK_WEBHOOK`:Strategy 财务监控专用企业微信机器人;未配置时使用代码内默认的 Strategy 财务机器人。
126
  - `BINANCE_API_KEY` / `BINANCE_API_SECRET`:币安私有接口凭证,可选。
127
  - `BINANCE_SPOT_BASE` / `BINANCE_FAPI_BASE` / `BINANCE_FSTREAM_BASE`:币安接口基址,可选。
128
  - `BINANCE_ALPHA_BASE`:Binance Alpha 公共行情基址,默认 `https://www.binance.com`。
app/monitors/market_move.py CHANGED
@@ -17,6 +17,18 @@ SERVICE_ID = "market_move:service"
17
  CONFIG_KEY = "market_move:config"
18
  ALERT_STATE_KEY = "market_move:alert_state"
19
  HEALTH_ALERT_COOLDOWN_SEC = 3600
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  SUPPORTED_EXCHANGES = [
22
  "binance",
@@ -219,6 +231,14 @@ def format_pct(value):
219
  return f"{value:+.2f}%"
220
 
221
 
 
 
 
 
 
 
 
 
222
  def market_type_label(value):
223
  return "现货" if value == "spot" else "合约"
224
 
@@ -1179,7 +1199,7 @@ class MarketMoveMonitor(BaseMonitor):
1179
  self._failure_counts = {}
1180
  self._health_alert_at = {}
1181
  self.client = PublicMarketDataClient(self.log, self.config)
1182
- self.pusher = legacy.WeWorkBotPusher(legacy.WEWORK_BOT_WEBHOOK)
1183
  self._register_monitors()
1184
 
1185
  def start(self):
@@ -1390,8 +1410,6 @@ class MarketMoveMonitor(BaseMonitor):
1390
  return candles[0].close
1391
 
1392
  def _handle_snapshot_alerts(self, snapshot, cfg):
1393
- threshold = float(cfg.get("threshold_pct", 30.0))
1394
- reset_threshold = threshold * float(cfg.get("reset_ratio", 0.8))
1395
  state = self.store.get_kv(ALERT_STATE_KEY, {})
1396
  sent = 0
1397
  changed = False
@@ -1400,39 +1418,57 @@ class MarketMoveMonitor(BaseMonitor):
1400
  for window, change_info in snapshot.get("changes", {}).items():
1401
  change_pct = change_info.get("change_pct")
1402
  key = f"{snapshot['symbol']}:{window}"
1403
- if change_pct is None:
 
 
 
 
1404
  continue
1405
- if abs(change_pct) >= threshold:
1406
- direction = "涨" if change_pct > 0 else "跌"
1407
- previous = state.get(key) or {}
1408
- if previous.get("direction") != direction or not previous.get("active"):
1409
- delivered = self._send_move_alert(snapshot, window, change_info, direction, threshold)
1410
- sent += 1
1411
- self.stats["alerts"] += 1
1412
- if not delivered:
1413
- self.stats["notify_fails"] += 1
1414
- state[key] = {
1415
- "active": True,
1416
- "direction": direction,
1417
- "change_pct": change_pct,
1418
- "last_seen_at": now,
1419
- "last_alert_at": previous.get("last_alert_at") or now,
1420
- }
1421
- changed = True
1422
- elif abs(change_pct) <= reset_threshold and key in state:
1423
- state.pop(key, None)
1424
- changed = True
 
 
 
 
 
 
 
 
 
 
 
 
 
1425
  if changed:
1426
  self.store.set_kv(ALERT_STATE_KEY, state)
1427
  return sent
1428
 
1429
- def _send_move_alert(self, snapshot, window, change_info, direction, threshold):
1430
- title = f"{snapshot['symbol']} {change_info.get('label') or window}涨跌超过 {threshold:g}%"
1431
  text = (
1432
- "币种价格异动\n\n"
1433
  f"币种:{snapshot.get('name')} ({snapshot.get('symbol')})\n"
1434
  f"方向:{direction}\n"
1435
  f"周期:{change_info.get('label') or window}\n"
 
1436
  f"涨跌幅:{format_pct(change_info.get('change_pct'))}\n"
1437
  f"当前价格:{fmt_price(snapshot.get('price'))}\n"
1438
  f"参考价格:{fmt_price(change_info.get('reference_price'))}\n"
 
17
  CONFIG_KEY = "market_move:config"
18
  ALERT_STATE_KEY = "market_move:alert_state"
19
  HEALTH_ALERT_COOLDOWN_SEC = 3600
20
+ MARKET_MOVE_WEWORK_WEBHOOK = os.getenv(
21
+ "MARKET_MOVE_WEWORK_WEBHOOK",
22
+ "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=1b44a22e-5d3d-4336-86a0-4662a3b9460f",
23
+ )
24
+
25
+ MOVEMENT_ALERT_RULES = [
26
+ {"threshold": 80.0, "cooldown": 3600, "label": "80%+", "frequency": "1小时"},
27
+ {"threshold": 70.0, "cooldown": 3 * 3600, "label": "70%+", "frequency": "3小时"},
28
+ {"threshold": 60.0, "cooldown": 6 * 3600, "label": "60%+", "frequency": "6小时"},
29
+ {"threshold": 50.0, "cooldown": 12 * 3600, "label": "50%+", "frequency": "12小时"},
30
+ {"threshold": 30.0, "cooldown": 24 * 3600, "label": "30%+", "frequency": "24小时"},
31
+ ]
32
 
33
  SUPPORTED_EXCHANGES = [
34
  "binance",
 
231
  return f"{value:+.2f}%"
232
 
233
 
234
+ def movement_alert_rule(change_pct):
235
+ magnitude = abs(num(change_pct, 0) or 0)
236
+ for rule in MOVEMENT_ALERT_RULES:
237
+ if magnitude >= rule["threshold"]:
238
+ return rule
239
+ return None
240
+
241
+
242
  def market_type_label(value):
243
  return "现货" if value == "spot" else "合约"
244
 
 
1199
  self._failure_counts = {}
1200
  self._health_alert_at = {}
1201
  self.client = PublicMarketDataClient(self.log, self.config)
1202
+ self.pusher = legacy.WeWorkBotPusher(MARKET_MOVE_WEWORK_WEBHOOK)
1203
  self._register_monitors()
1204
 
1205
  def start(self):
 
1410
  return candles[0].close
1411
 
1412
  def _handle_snapshot_alerts(self, snapshot, cfg):
 
 
1413
  state = self.store.get_kv(ALERT_STATE_KEY, {})
1414
  sent = 0
1415
  changed = False
 
1418
  for window, change_info in snapshot.get("changes", {}).items():
1419
  change_pct = change_info.get("change_pct")
1420
  key = f"{snapshot['symbol']}:{window}"
1421
+ rule = movement_alert_rule(change_pct)
1422
+ if change_pct is None or rule is None:
1423
+ if key in state:
1424
+ state.pop(key, None)
1425
+ changed = True
1426
  continue
1427
+
1428
+ direction = "涨" if change_pct > 0 else "跌"
1429
+ previous = state.get(key) or {}
1430
+ previous_threshold = float(
1431
+ previous.get("band_threshold")
1432
+ or (30.0 if previous.get("active") else 0.0)
1433
+ )
1434
+ last_alert_at = float(previous.get("last_alert_at") or 0)
1435
+ is_first_alert = last_alert_at <= 0
1436
+ direction_changed = previous.get("direction") and previous.get("direction") != direction
1437
+ escalated = rule["threshold"] > previous_threshold
1438
+ cooldown_elapsed = now - last_alert_at >= rule["cooldown"]
1439
+ should_send = is_first_alert or direction_changed or escalated or cooldown_elapsed
1440
+
1441
+ if should_send:
1442
+ delivered = self._send_move_alert(snapshot, window, change_info, direction, rule)
1443
+ sent += 1
1444
+ self.stats["alerts"] += 1
1445
+ if not delivered:
1446
+ self.stats["notify_fails"] += 1
1447
+ last_alert_at = now
1448
+
1449
+ state[key] = {
1450
+ "active": True,
1451
+ "direction": direction,
1452
+ "band_threshold": rule["threshold"],
1453
+ "band_label": rule["label"],
1454
+ "cooldown_seconds": rule["cooldown"],
1455
+ "change_pct": change_pct,
1456
+ "last_seen_at": now,
1457
+ "last_alert_at": last_alert_at,
1458
+ }
1459
+ changed = True
1460
  if changed:
1461
  self.store.set_kv(ALERT_STATE_KEY, state)
1462
  return sent
1463
 
1464
+ def _send_move_alert(self, snapshot, window, change_info, direction, rule):
1465
+ title = f"{snapshot['symbol']} {change_info.get('label') or window}涨跌超过 {rule['threshold']:g}%"
1466
  text = (
1467
+ "寻找牛市标的\n\n"
1468
  f"币种:{snapshot.get('name')} ({snapshot.get('symbol')})\n"
1469
  f"方向:{direction}\n"
1470
  f"周期:{change_info.get('label') or window}\n"
1471
+ f"触发档位:{rule['label']}({rule['frequency']}内最多通知一次)\n"
1472
  f"涨跌幅:{format_pct(change_info.get('change_pct'))}\n"
1473
  f"当前价格:{fmt_price(snapshot.get('price'))}\n"
1474
  f"参考价格:{fmt_price(change_info.get('reference_price'))}\n"
app/monitors/strategy_treasury.py CHANGED
@@ -13,6 +13,10 @@ from app.monitors.base import BaseMonitor
13
  SERVICE_ID = "strategy:service"
14
  BTC_MONITOR_ID = "strategy:btc_holdings"
15
  CASH_MONITOR_ID = "strategy:cash_reserve"
 
 
 
 
16
 
17
 
18
  def utc_now_iso():
@@ -248,7 +252,7 @@ class StrategyTreasuryMonitor(BaseMonitor):
248
  self._stop_event = threading.Event()
249
  self._thread = None
250
  self.client = StrategyTreasuryClient(self.log)
251
- self.pusher = legacy.WeWorkBotPusher(legacy.WEWORK_BOT_WEBHOOK)
252
  self.stats = {"loops": 0, "alerts": 0, "notify_fails": 0, "api_fails": 0}
253
  self._register_monitors()
254
 
 
13
  SERVICE_ID = "strategy:service"
14
  BTC_MONITOR_ID = "strategy:btc_holdings"
15
  CASH_MONITOR_ID = "strategy:cash_reserve"
16
+ STRATEGY_TREASURY_WEWORK_WEBHOOK = os.getenv(
17
+ "STRATEGY_TREASURY_WEWORK_WEBHOOK",
18
+ "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=39502f69-f08e-43f8-afb2-6546ec722dfa",
19
+ )
20
 
21
 
22
  def utc_now_iso():
 
252
  self._stop_event = threading.Event()
253
  self._thread = None
254
  self.client = StrategyTreasuryClient(self.log)
255
+ self.pusher = legacy.WeWorkBotPusher(STRATEGY_TREASURY_WEWORK_WEBHOOK)
256
  self.stats = {"loops": 0, "alerts": 0, "notify_fails": 0, "api_fails": 0}
257
  self._register_monitors()
258