Files
Chan/bsp_monitor/main.py
T
jackyu66gitandClaude Opus 4.7 881c9d5eac bsp_monitor: 支持全部20个币对 + 数据源切换至data_provider + Python 3.9兼容
- 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 <noreply@anthropic.com>
2026-05-26 11:55:23 +08:00

221 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
main.py - 缠论买卖点监控主程序。
每整分钟:
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, get_symbols
from engine import ChanEngine
from notify import send_bsp_alert, send_telegram_message, 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 ChanPivotMonitor import ChanPivotMonitor
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("bsp_monitor")
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):
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_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(symbol)
except Exception as e:
logger.error(f"[{name}] 拉取失败: {e}")
return
if df.empty:
logger.warning(f"[{name}] DataFrame 为空")
return
latest_ts = df.iloc[-1]["timestamp"]
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"[{name}] 缠论计算失败: {e}", exc_info=True)
return
# 3. 中枢特征监控 + 推送
pivot_state = st.pivot_monitor.update(engine.bi_zs_list)
# 4. BSP 检测 + 推送
current_bsps = engine.bsp_list
current_keys = {_bsp_stable_key(b, symbol) for b in current_bsps}
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"[{name}] 首次完成 — {len(confirmed)} 笔, "
f"{len(current_bsps)} BSP (不推送)"
)
return
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:
return
logger.info(f"[{name}] {len(new_keys)} 个新 BSP")
for bsp in current_bsps:
key = _bsp_stable_key(bsp, symbol)
if key not in new_keys:
continue
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}")
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"🏠 <b>中枢更新</b> — {name} 1m\n"
f"\n"
f"📐 笔数: <b>{state['bi_count']}</b> {dir_label} {phase}\n"
f"📏 收敛率: <b>{state['contraction']:.4f}</b> → {contraction_note}\n"
f"⚖️ 重心漂移: <b>{state['shift_norm']:+.4f}</b> → {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(f"bsp_monitor 启动 — {len(self._states)} 币对 1m 缠论监控")
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 _bsp_stable_key(bsp, symbol: str) -> str:
return f"{symbol}_{bsp.type}_{bsp.klc.end_time}"
def _escape_html(msg: str) -> str:
"""HTML 转义,保留已有的 <b>/<code> 标签。"""
msg = msg.replace("&", "&amp;")
msg = msg.replace("<b>", "\x00B\x00").replace("</b>", "\x00/B\x00")
msg = msg.replace("<code>", "\x00C\x00").replace("</code>", "\x00/C\x00")
msg = msg.replace("<", "&lt;").replace(">", "&gt;")
msg = msg.replace("\x00B\x00", "<b>").replace("\x00/B\x00", "</b>")
msg = msg.replace("\x00C\x00", "<code>").replace("\x00/C\x00", "</code>")
return msg
if __name__ == "__main__":
monitor = BSPMonitor()
try:
asyncio.run(monitor.run())
except KeyboardInterrupt:
logger.info("收到中断信号,退出")