#!/usr/bin/env python3 """ main.py - 缠论多周期买卖点监控。 每整分钟: 1. 从 data_provider 拉取所有币对多周期 K 线 2. 每个币对 × 每个周期独立跑缠论管线 3. 检测新笔确认 → BSP 推送 """ import asyncio import logging import sys import os import time from dataclasses import dataclass, field from datetime import datetime, timezone, timedelta from typing import Optional sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from fetcher import fetch_ohlcv, get_symbols from engine import ChanEngine from notify import send_bsp_alert, BOT_TOKEN, CHAT_ID _PARENT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if _PARENT not in sys.path: sys.path.insert(0, _PARENT) from ChanEnum import Chan_BI_DIR # from ChanPivotMonitor import ChanPivotMonitor # 暂停中枢监控 logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) logger = logging.getLogger("bsp_monitor") TIMEFRAMES = ["1m", "5m", "15m", "1h"] def _short(symbol: str) -> str: """BTC/USDT:USDT → BTCUSDT""" return symbol.split(":")[0].replace("/", "") def _bi_id(bi) -> Optional[tuple]: """笔的稳定标识,基于首K线时间戳。""" if bi.start_klc is None: return None return (bi.start_klc.start_time,) def _push_bsp(engine: ChanEngine, bsp, symbol: str, tf: str) -> bool: """推送 BSP 到 Telegram(带去重)。""" if bsp.klc is None: return False key = f"{symbol}_{bsp.type}_{bsp.klc.end_time}_{tf}" msg = engine.format_bsp_detail(bsp, symbol, tf) msg = _escape_html(msg) if send_bsp_alert(msg, bsp_key=key): logger.info(f"[{_short(symbol)} {tf}] ✅ BSP: {key}") return True return False @dataclass class TfState: """单个周期的状态。""" last_bi_id: Optional[tuple] = None last_df_ts: object = None first_run: bool = True # pivot_monitor: ChanPivotMonitor = None # 暂停中枢监控 # def __post_init__(self): # if self.pivot_monitor is None: # self.pivot_monitor = ChanPivotMonitor() @dataclass class SymbolState: symbol: str tfs: dict = field(default_factory=dict) def __post_init__(self): self.tfs = {tf: TfState() for tf in TIMEFRAMES} class BSPMonitor: def __init__(self): symbols = get_symbols() self._states: dict[str, SymbolState] = { s: SymbolState(symbol=s) for s in symbols } logger.info(f"监控 {len(symbols)}×{len(TIMEFRAMES)} 币对×周期: " f"{', '.join(_short(s) for s in symbols)}") async def tick(self): 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) for tf in TIMEFRAMES: await self._check_tf(symbol, tf, st.tfs[tf], name) async def _check_tf(self, symbol: str, tf: str, ts: TfState, name: str): # 1. 拉取 K 线 try: df = fetch_ohlcv(symbol, tf) except Exception as e: logger.error(f"[{name} {tf}] 拉取失败: {e}") return if df.empty: return # 2. 检查是否有新 K 线 latest_ts = df.iloc[-1]["timestamp"] if ts.last_df_ts and latest_ts <= ts.last_df_ts: return ts.last_df_ts = latest_ts # 3. 运行缠论管线 try: engine = ChanEngine(df) except Exception as e: logger.error(f"[{name} {tf}] 缠论计算失败: {e}", exc_info=True) return # 4. 中枢特征更新(暂停) # try: # ts.pivot_monitor.update(engine.bi_zs_list) # except Exception as e: # logger.debug(f"[{name} {tf}] 中枢特征更新失败: {e}") # 5. BSP 检测 confirmed = [b for b in engine.bi_list if b.is_sure] if len(confirmed) < 2: return last_confirmed = confirmed[-1] current_bi_id = _bi_id(last_confirmed) if current_bi_id is None: return if ts.first_run: ts.first_run = False ts.last_bi_id = current_bi_id bsp = engine.get_bsp_for_bi(last_confirmed) if bsp: _push_bsp(engine, bsp, symbol, tf) logger.info( f"[{name} {tf}] 首次完成 — " f"{len(confirmed)} 笔, {len(engine.bsp_list)} BSP" ) return if current_bi_id == ts.last_bi_id: return ts.last_bi_id = current_bi_id bi_dir = "⬆️" if last_confirmed.dir == Chan_BI_DIR.UP else "⬇️" logger.info(f"[{name} {tf}] 新笔确认 — #{len(confirmed)} " f"{bi_dir} 高度: ${last_confirmed.height:.2f}") bsp = engine.get_bsp_for_bi(last_confirmed) if bsp: _push_bsp(engine, bsp, symbol, tf) async def run(self): logger.info("=" * 50) logger.info(f"bsp_monitor 启动 — {len(self._states)} 币对 " f"× {len(TIMEFRAMES)} 周期 ({', '.join(TIMEFRAMES)})") logger.info(f"Telegram: {'已配置' if BOT_TOKEN and CHAT_ID else '⚠️ 未配置'}") logger.info("=" * 50) logger.info("首次运行(初始化)...") await self.tick() while True: now = datetime.now(timezone.utc) next_minute = now.replace(second=0, microsecond=0) + timedelta(minutes=1) wait_seconds = max(0.1, (next_minute - now).total_seconds()) logger.info(f"等待 {wait_seconds:.0f}s 到 {next_minute.strftime('%H:%M:%S')}UTC") await asyncio.sleep(wait_seconds) try: await self.tick() except Exception as e: logger.error(f"tick 异常: {e}", exc_info=True) await asyncio.sleep(5) 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: asyncio.run(monitor.run()) except KeyboardInterrupt: logger.info("收到中断信号,退出")