Files
Chan/bsp_monitor/main.py
T
jackyu66gitandClaude Opus 4.7 78d02cf2ef refactor: BSP检测改为新笔驱动,不再逐tick对比BSP列表
- 用 last_bi_id (start_klc.start_time) 跟踪最后一笔
- 新笔确认时检查上一笔终点是否为 BSP → 推送
- 中枢更新同样在新笔产生时触发
- 移除时间过滤、BSP列表diff、持久化去重等冗余逻辑
- 无新笔时快速跳过,tick从40s降到15s

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:52:36 +08:00

224 lines
7.4 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. 检测中枢特征变化 → 推送
"""
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, 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("/", "")
def _bi_id(bi) -> tuple:
"""笔的稳定标识,基于首K线时间戳。"""
return (bi.start_klc.start_time,)
@dataclass
class SymbolState:
symbol: str
pivot_monitor: ChanPivotMonitor = field(default_factory=ChanPivotMonitor)
last_bi_id: Optional[tuple] = None # 上次检查过的最后一笔 ID
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
# 2. 运行缠论管线
try:
engine = ChanEngine(df)
except Exception as e:
logger.error(f"[{name}] 缠论计算失败: {e}", exc_info=True)
return
# 3. 获取已确认的笔
confirmed = [b for b in engine.bi_list if b.is_sure]
if len(confirmed) < 2:
return
last_bi = confirmed[-1]
current_bi_id = _bi_id(last_bi)
# 4. 首轮:记录状态,不推送
if st.first_run:
st.first_run = False
st.last_bi_id = current_bi_id
st.pivot_monitor.update(engine.bi_zs_list) # 初始化中枢状态
logger.info(
f"[{name}] 首次完成 — {len(confirmed)} 笔, "
f"{len(engine.bsp_list)} BSP (不推送)"
)
return
# 5. 检测新笔
if current_bi_id == st.last_bi_id:
return # 无新笔,跳过
st.last_bi_id = current_bi_id
logger.info(f"[{name}] 新笔确认 — #{len(confirmed)} "
f"{'⬆️' if last_bi.dir.value == 1 else '⬇️'} "
f"高度: ${last_bi.height:.2f}")
# 6. 检查上一笔终点是否为 BSP
prev_bi = confirmed[-2]
bsp = engine.get_bsp_for_bi(prev_bi)
if bsp:
key = f"{symbol}_{bsp.type}_{bsp.klc.end_time}"
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}")
# 7. 中枢特征更新
pivot_state = st.pivot_monitor.update(engine.bi_zs_list)
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)
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 _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("收到中断信号,退出")