From 881c9d5eac05ea6d328f6fd9bea67e9982425059 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Tue, 26 May 2026 11:55:23 +0800 Subject: [PATCH] =?UTF-8?q?bsp=5Fmonitor:=20=E6=94=AF=E6=8C=81=E5=85=A8?= =?UTF-8?q?=E9=83=A820=E4=B8=AA=E5=B8=81=E5=AF=B9=20+=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=BA=90=E5=88=87=E6=8D=A2=E8=87=B3data=5Fprovider=20+=20Pytho?= =?UTF-8?q?n=203.9=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetcher.py: 数据源从CCXT改为data_provider HTTP API,新增get_symbols()自动获取所有币对 - main.py: 重构为多币对架构,每个币对独立SymbolState(pivot_monitor/BSP去重/首轮抑制) - engine.py: format_bsp_detail()支持动态币对名 - ChanPivotMonitor/Classifier: 修复Python 3.9类型注解兼容(X|None → Optional[X]) - 首轮初始化时不推送中枢和BSP,避免启动时20条消息轰炸 Co-Authored-By: Claude Opus 4.7 --- ChanPivotClassifier.py | 3 +- ChanPivotMonitor.py | 9 +- bsp_monitor/engine.py | 5 +- bsp_monitor/fetcher.py | 30 ++++-- bsp_monitor/main.py | 223 ++++++++++++++++++++++------------------- 5 files changed, 153 insertions(+), 117 deletions(-) diff --git a/ChanPivotClassifier.py b/ChanPivotClassifier.py index 8718d4c..52b1af0 100644 --- a/ChanPivotClassifier.py +++ b/ChanPivotClassifier.py @@ -8,6 +8,7 @@ Feature 描述中枢内部结构,Label 记录中枢后实际演化。 import math import json +from typing import Optional from ChanEnum import Chan_BI_DIR @@ -90,7 +91,7 @@ class ChanPivotClassifier: return duration_raw / avg @staticmethod - def compute_features(zs, historical_durations: list | None = None): + def compute_features(zs, historical_durations: Optional[list] = None): """计算单个中枢的全部结构特征(实时友好)""" duration_raw = ChanPivotClassifier.calc_duration(zs) contraction = ChanPivotClassifier.calc_contraction(zs) diff --git a/ChanPivotMonitor.py b/ChanPivotMonitor.py index 69b8e70..d6e29f7 100644 --- a/ChanPivotMonitor.py +++ b/ChanPivotMonitor.py @@ -8,6 +8,7 @@ shift / contraction / duration。 """ from collections import deque +from typing import Optional from ChanPivotClassifier import ChanPivotClassifier @@ -22,17 +23,17 @@ class ChanPivotMonitor: def __init__(self, window_size: int = 10): self._window_size = window_size self._duration_history: deque[int] = deque(maxlen=window_size) - self._current_zs_id: tuple | None = None + self._current_zs_id: Optional[tuple] = None self._current_bi_count: int = 0 self._current_is_sure: bool = False - self._current_state: dict | None = None + self._current_state: Optional[dict] = None self._duration_added_for_zs: set = set() # 已加入窗口的中枢 ID # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ - def update(self, bi_zs_list: list) -> dict | None: + def update(self, bi_zs_list: list) -> Optional[dict]: """ 主入口:检测当前中枢特征变化。 @@ -96,7 +97,7 @@ class ChanPivotMonitor: return self._current_state - def get_current(self) -> dict | None: + def get_current(self) -> Optional[dict]: """返回当前中枢的最新特征""" return self._current_state diff --git a/bsp_monitor/engine.py b/bsp_monitor/engine.py index be01e6a..f3ed73d 100644 --- a/bsp_monitor/engine.py +++ b/bsp_monitor/engine.py @@ -127,7 +127,7 @@ class ChanEngine: cst = dt.astimezone(timezone(timedelta(hours=8))) return cst.strftime("%Y-%m-%d %H:%M:%S CST") - def format_bsp_detail(self, bsp: ChanBSP) -> str: + def format_bsp_detail(self, bsp: ChanBSP, symbol: str = "BTC/USDT:USDT") -> str: bi = bsp.bi klc = bsp.klc bsp_type = bsp.type @@ -136,8 +136,9 @@ class ChanEngine: emoji = "🟢" if bsp_dir == Chan_BSP_DIR.BUY else "🔴" dir_label = "买点" if bsp_dir == Chan_BSP_DIR.BUY else "卖点" + symbol_short = symbol.split(":")[0].replace("/", "") lines = [ - f"{emoji} [{dir_label}] {self._bsp_type_name(bsp_type)} — BTC/USDT 1m", + f"{emoji} [{dir_label}] {self._bsp_type_name(bsp_type)} — {symbol_short} 1m", "", f"⏰ 确认: {self._utc_to_cst(klc.end_time)}", f"💰 价格: {klc.close:.2f}", diff --git a/bsp_monitor/fetcher.py b/bsp_monitor/fetcher.py index 76ea0e9..9f3bd2f 100644 --- a/bsp_monitor/fetcher.py +++ b/bsp_monitor/fetcher.py @@ -2,23 +2,42 @@ fetcher.py - 从 data_provider HTTP API 拉取 K 线数据。 """ +from typing import List, Optional + import requests import pandas as pd import logging logger = logging.getLogger(__name__) -SYMBOL = "BTC/USDT:USDT" TIMEFRAME = "1m" PROVIDER_URL = "http://103.179.242.166" FETCH_LIMIT = 1000 +_symbols_cache: Optional[List[str]] = None -def fetch_ohlcv() -> pd.DataFrame: - """从 data_provider API 拉取最近 FETCH_LIMIT 根 1m K 线。""" + +def get_symbols() -> list[str]: + """从 data_provider /health 获取所有可用币对(缓存)。""" + global _symbols_cache + if _symbols_cache is not None: + return _symbols_cache + try: + resp = requests.get(f"{PROVIDER_URL}/health", timeout=10) + resp.raise_for_status() + _symbols_cache = resp.json().get("symbols", []) + logger.info(f"获取到 {len(_symbols_cache)} 个币对") + except Exception as e: + logger.error(f"获取币对列表失败: {e}") + _symbols_cache = ["BTC/USDT:USDT"] + return _symbols_cache + + +def fetch_ohlcv(symbol: str) -> pd.DataFrame: + """从 data_provider API 拉取某个币对最近 FETCH_LIMIT 根 1m K 线。""" url = f"{PROVIDER_URL}/api/candles" params = { - "symbol": SYMBOL, + "symbol": symbol, "tf": TIMEFRAME, "limit": FETCH_LIMIT, } @@ -27,7 +46,7 @@ def fetch_ohlcv() -> pd.DataFrame: data = resp.json() if not data: - logger.warning("API 返回空数据") + logger.warning(f"{symbol}: API 返回空数据") return pd.DataFrame() df = pd.DataFrame(data) @@ -35,5 +54,4 @@ def fetch_ohlcv() -> pd.DataFrame: df["date"] = df["timestamp"] df = df.drop_duplicates(subset="timestamp").sort_values("timestamp").reset_index(drop=True) - logger.info(f"拉取 {len(df)} 根 {TIMEFRAME} K 线 from {PROVIDER_URL}") return df diff --git a/bsp_monitor/main.py b/bsp_monitor/main.py index 35e4dbc..a3ae592 100644 --- a/bsp_monitor/main.py +++ b/bsp_monitor/main.py @@ -3,21 +3,22 @@ main.py - 缠论买卖点监控主程序。 每整分钟: - 1. 从 Binance 拉取最新 1m K 线 - 2. 跑完整缠论管线 - 3. 检测新出现的买卖点(BSP) - 4. 推送到 Telegram(同一 BSP 只推一次) + 1. 从 data_provider 拉取所有币对最新 1m K 线 + 2. 每个币对独立跑缠论管线 + 3. 检测新出现的买卖点(BSP)+ 中枢特征变化 + 4. 推送到 Telegram """ import asyncio import logging import sys import os import time +from dataclasses import dataclass, field from datetime import datetime, timezone, timedelta sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from fetcher import fetch_ohlcv +from fetcher import fetch_ohlcv, get_symbols from engine import ChanEngine from notify import send_bsp_alert, send_telegram_message, BOT_TOKEN, CHAT_ID @@ -33,149 +34,148 @@ logging.basicConfig( logger = logging.getLogger("bsp_monitor") -def _bsp_stable_key(bsp) -> str: - """生成 BSP 的稳定唯一键(不依赖笔边界的微小变化)。""" - return f"{bsp.type}_{bsp.klc.end_time}" +def _short(symbol: str) -> str: + """BTC/USDT:USDT → BTCUSDT""" + return symbol.split(":")[0].replace("/", "") + + +@dataclass +class SymbolState: + symbol: str + pivot_monitor: ChanPivotMonitor = field(default_factory=ChanPivotMonitor) + known_bsp_keys: set = field(default_factory=set) + last_df_ts: object = None + first_run: bool = True class BSPMonitor: def __init__(self): - self._first_run = True - self._last_df_ts = None - self._known_bsp_keys: set = set() # 已见过的 BSP 键(含已推送和历史的) - self.pivot_monitor = ChanPivotMonitor(window_size=10) + symbols = get_symbols() + self._states: dict[str, SymbolState] = { + s: SymbolState(symbol=s) for s in symbols + } + logger.info(f"监控 {len(symbols)} 个币对: {', '.join(_short(s) for s in symbols)}") async def tick(self): - """单次 tick。""" tick_start = time.monotonic() logger.info("── tick 开始 ──") + for symbol, st in self._states.items(): + await self._tick_symbol(symbol, st) + + elapsed = (time.monotonic() - tick_start) * 1000 + logger.info(f"── tick 结束 ({elapsed:.0f}ms) ──") + + async def _tick_symbol(self, symbol: str, st: SymbolState): + name = _short(symbol) + # 1. 拉取 K 线 try: - df = fetch_ohlcv() + df = fetch_ohlcv(symbol) except Exception as e: - logger.error(f"拉取 K 线失败: {e}") + logger.error(f"[{name}] 拉取失败: {e}") return if df.empty: - logger.warning("DataFrame 为空,跳过") + logger.warning(f"[{name}] DataFrame 为空") return latest_ts = df.iloc[-1]["timestamp"] - if self._last_df_ts and latest_ts <= self._last_df_ts: - logger.info("无新 K 线,跳过") - return - self._last_df_ts = latest_ts + if st.last_df_ts and latest_ts <= st.last_df_ts: + return # 无新K线 + st.last_df_ts = latest_ts # 2. 运行缠论管线 try: engine = ChanEngine(df) except Exception as e: - logger.error(f"缠论计算失败: {e}", exc_info=True) + logger.error(f"[{name}] 缠论计算失败: {e}", exc_info=True) return - # 2.5 更新中枢特征监控 + 推送 - pivot_state = self.pivot_monitor.update(engine.bi_zs_list) - if pivot_state: - logger.info( - f"中枢特征更新: bi_count={pivot_state['bi_count']} " - f"is_sure={pivot_state['is_sure']} " - f"contraction={pivot_state['contraction']:.4f} " - f"shift_norm={pivot_state['shift_norm']:+.4f} " - f"duration_norm={pivot_state['duration_norm']:.4f}" - ) - # Telegram 推送 - if pivot_state["is_sure"]: - phase = "✅ 已确认" - elif pivot_state["bi_count"] > 3: - phase = "🔄 延伸中" - else: - phase = "🆕 刚形成" - zs_dir = pivot_state["zs_dir"] - dir_label = "⬆️ 向上" if "UP" in zs_dir else "⬇️ 向下" + # 3. 中枢特征监控 + 推送 + pivot_state = st.pivot_monitor.update(engine.bi_zs_list) - # 白话解释 - c = pivot_state["contraction"] - if c < 0.85: - contraction_note = "收敛(振幅缩小,可能快出方向)" - elif c > 1.15: - contraction_note = "扩张(振幅放大,波动加剧)" - else: - contraction_note = "稳定" - - s = pivot_state["shift_norm"] - if s > 0.3: - shift_note = "重心上移(偏多)" - elif s < -0.3: - shift_note = "重心下移(偏空)" - else: - shift_note = "重心居中" - - msg = ( - f"🏠 中枢更新 — BTC/USDT 1m\n" - f"\n" - f"📐 笔数: {pivot_state['bi_count']} {dir_label} {phase}\n" - f"📏 收敛率: {pivot_state['contraction']:.4f} → {contraction_note}\n" - f"⚖️ 重心漂移: {pivot_state['shift_norm']:+.4f} → {shift_note}\n" - f"⏱️ 持续: {pivot_state['duration_raw']}K " - f"(norm: {pivot_state['duration_norm']:.2f})\n" - f"📦 区间: {pivot_state['zd']:.2f} – {pivot_state['zg']:.2f} " - f"(gg/dd: {pivot_state['gg']:.2f}/{pivot_state['dd']:.2f})" - ) - send_telegram_message(msg) - - # 3. 检测新 BSP(用 stable key 去重) + # 4. BSP 检测 + 推送 current_bsps = engine.bsp_list - current_keys = {_bsp_stable_key(b) for b in current_bsps} + current_keys = {_bsp_stable_key(b, symbol) for b in current_bsps} - if self._first_run: - self._first_run = False - self._known_bsp_keys = current_keys + if st.first_run: + st.first_run = False + st.known_bsp_keys = current_keys confirmed = [b for b in engine.bi_list if b.is_sure] logger.info( - f"首次运行完成 — {len(confirmed)} 笔已确认, " - f"{len(current_bsps)} 个买卖点 (不推送历史)" + f"[{name}] 首次完成 — {len(confirmed)} 笔, " + f"{len(current_bsps)} BSP (不推送)" ) - elapsed = (time.monotonic() - tick_start) * 1000 - logger.info(f"── tick 结束 ({elapsed:.0f}ms) ──") return - new_keys = current_keys - self._known_bsp_keys - self._known_bsp_keys |= current_keys # 永增,BSP 一旦见过就不会再"新" + if pivot_state: + logger.info( + f"[{name}] 中枢更新: bi_count={pivot_state['bi_count']} " + f"contraction={pivot_state['contraction']:.4f} " + f"shift={pivot_state['shift_norm']:+.4f}" + ) + self._push_pivot(pivot_state, name) + + new_keys = current_keys - st.known_bsp_keys + st.known_bsp_keys |= current_keys if not new_keys: - logger.debug("无新买卖点") - elapsed = (time.monotonic() - tick_start) * 1000 - logger.info(f"── tick 结束 ({elapsed:.0f}ms) ──") return - logger.info(f"检测到 {len(new_keys)} 个新买卖点") - - # 4. 推送新 BSP + logger.info(f"[{name}] {len(new_keys)} 个新 BSP") for bsp in current_bsps: - key = _bsp_stable_key(bsp) + key = _bsp_stable_key(bsp, symbol) if key not in new_keys: continue - msg = engine.format_bsp_detail(bsp) - # HTML 转义(Telegram parse_mode=HTML 要求) - msg = msg.replace("&", "&") - msg = msg.replace("", "\x00B\x00").replace("", "\x00/B\x00") - msg = msg.replace("", "\x00C\x00").replace("", "\x00/C\x00") - msg = msg.replace("<", "<").replace(">", ">") - msg = msg.replace("\x00B\x00", "").replace("\x00/B\x00", "") - msg = msg.replace("\x00C\x00", "").replace("\x00/C\x00", "") - ok = send_bsp_alert(msg, bsp_key=key) - if ok: - logger.info(f"✅ 推送: {key}") - else: - logger.warning(f"❌ 推送失败: {key}") + msg = engine.format_bsp_detail(bsp, symbol) + msg = _escape_html(msg) + if send_bsp_alert(msg, bsp_key=key): + logger.info(f"[{name}] ✅ BSP: {key}") - elapsed = (time.monotonic() - tick_start) * 1000 - logger.info(f"── tick 结束 ({elapsed:.0f}ms) ──") + def _push_pivot(self, state: dict, name: str): + if state["is_sure"]: + phase = "✅ 已确认" + elif state["bi_count"] > 3: + phase = "🔄 延伸中" + else: + phase = "🆕 刚形成" + + zs_dir = state["zs_dir"] + dir_label = "⬆️ 向上" if "UP" in zs_dir else "⬇️ 向下" + + c = state["contraction"] + if c < 0.85: + contraction_note = "收敛(振幅缩小,可能快出方向)" + elif c > 1.15: + contraction_note = "扩张(振幅放大,波动加剧)" + else: + contraction_note = "稳定" + + s = state["shift_norm"] + if s > 0.3: + shift_note = "重心上移(偏多)" + elif s < -0.3: + shift_note = "重心下移(偏空)" + else: + shift_note = "重心居中" + + msg = ( + f"🏠 中枢更新 — {name} 1m\n" + f"\n" + f"📐 笔数: {state['bi_count']} {dir_label} {phase}\n" + f"📏 收敛率: {state['contraction']:.4f} → {contraction_note}\n" + f"⚖️ 重心漂移: {state['shift_norm']:+.4f} → {shift_note}\n" + f"⏱️ 持续: {state['duration_raw']}K " + f"(norm: {state['duration_norm']:.2f})\n" + f"📦 区间: {state['zd']:.2f} – {state['zg']:.2f} " + f"(gg/dd: {state['gg']:.2f}/{state['dd']:.2f})" + ) + send_telegram_message(msg) async def run(self): logger.info("=" * 50) - logger.info("bsp_monitor 启动 — BTC/USDT 1m 缠论买卖点监控") + logger.info(f"bsp_monitor 启动 — {len(self._states)} 币对 1m 缠论监控") logger.info(f"Telegram: {'已配置' if BOT_TOKEN and CHAT_ID else '⚠️ 未配置'}") logger.info("=" * 50) @@ -197,6 +197,21 @@ class BSPMonitor: await asyncio.sleep(5) +def _bsp_stable_key(bsp, symbol: str) -> str: + return f"{symbol}_{bsp.type}_{bsp.klc.end_time}" + + +def _escape_html(msg: str) -> str: + """HTML 转义,保留已有的 / 标签。""" + msg = msg.replace("&", "&") + msg = msg.replace("", "\x00B\x00").replace("", "\x00/B\x00") + msg = msg.replace("", "\x00C\x00").replace("", "\x00/C\x00") + msg = msg.replace("<", "<").replace(">", ">") + msg = msg.replace("\x00B\x00", "").replace("\x00/B\x00", "") + msg = msg.replace("\x00C\x00", "").replace("\x00/C\x00", "") + return msg + + if __name__ == "__main__": monitor = BSPMonitor() try: