- ChanPivotClassifier: 提取 calc_duration/contraction/shift 为 @staticmethod,新增 compute_features() - ChanPivotMonitor: 实时追踪当前中枢,bi_count 增长时重新计算 shift/contraction/duration - bsp_monitor/fetcher: 改用 data_provider HTTP API 替代直连 CCXT - bsp_monitor/notify: 新增 send_telegram_message() 通用推送 - bsp_monitor/main: 集成 ChanPivotMonitor,有新笔或 BSP 时推送到 Telegram Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
206 lines
7.5 KiB
Python
206 lines
7.5 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
main.py - 缠论买卖点监控主程序。
|
||
|
||
每整分钟:
|
||
1. 从 Binance 拉取最新 1m K 线
|
||
2. 跑完整缠论管线
|
||
3. 检测新出现的买卖点(BSP)
|
||
4. 推送到 Telegram(同一 BSP 只推一次)
|
||
"""
|
||
import asyncio
|
||
import logging
|
||
import sys
|
||
import os
|
||
import time
|
||
from datetime import datetime, timezone, timedelta
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
|
||
from fetcher import fetch_ohlcv
|
||
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 _bsp_stable_key(bsp) -> str:
|
||
"""生成 BSP 的稳定唯一键(不依赖笔边界的微小变化)。"""
|
||
return f"{bsp.type}_{bsp.klc.end_time}"
|
||
|
||
|
||
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)
|
||
|
||
async def tick(self):
|
||
"""单次 tick。"""
|
||
tick_start = time.monotonic()
|
||
logger.info("── tick 开始 ──")
|
||
|
||
# 1. 拉取 K 线
|
||
try:
|
||
df = fetch_ohlcv()
|
||
except Exception as e:
|
||
logger.error(f"拉取 K 线失败: {e}")
|
||
return
|
||
|
||
if df.empty:
|
||
logger.warning("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
|
||
|
||
# 2. 运行缠论管线
|
||
try:
|
||
engine = ChanEngine(df)
|
||
except Exception as e:
|
||
logger.error(f"缠论计算失败: {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 "⬇️ 向下"
|
||
|
||
# 白话解释
|
||
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"🏠 <b>中枢更新</b> — BTC/USDT 1m\n"
|
||
f"\n"
|
||
f"📐 笔数: <b>{pivot_state['bi_count']}</b> {dir_label} {phase}\n"
|
||
f"📏 收敛率: <b>{pivot_state['contraction']:.4f}</b> → {contraction_note}\n"
|
||
f"⚖️ 重心漂移: <b>{pivot_state['shift_norm']:+.4f}</b> → {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 去重)
|
||
current_bsps = engine.bsp_list
|
||
current_keys = {_bsp_stable_key(b) for b in current_bsps}
|
||
|
||
if self._first_run:
|
||
self._first_run = False
|
||
self._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)} 个买卖点 (不推送历史)"
|
||
)
|
||
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 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
|
||
for bsp in current_bsps:
|
||
key = _bsp_stable_key(bsp)
|
||
if key not in new_keys:
|
||
continue
|
||
msg = engine.format_bsp_detail(bsp)
|
||
# HTML 转义(Telegram parse_mode=HTML 要求)
|
||
msg = msg.replace("&", "&")
|
||
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("<", "<").replace(">", ">")
|
||
msg = msg.replace("\x00B\x00", "<b>").replace("\x00/B\x00", "</b>")
|
||
msg = msg.replace("\x00C\x00", "<code>").replace("\x00/C\x00", "</code>")
|
||
ok = send_bsp_alert(msg, bsp_key=key)
|
||
if ok:
|
||
logger.info(f"✅ 推送: {key}")
|
||
else:
|
||
logger.warning(f"❌ 推送失败: {key}")
|
||
|
||
elapsed = (time.monotonic() - tick_start) * 1000
|
||
logger.info(f"── tick 结束 ({elapsed:.0f}ms) ──")
|
||
|
||
async def run(self):
|
||
logger.info("=" * 50)
|
||
logger.info("bsp_monitor 启动 — BTC/USDT 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)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
monitor = BSPMonitor()
|
||
try:
|
||
asyncio.run(monitor.run())
|
||
except KeyboardInterrupt:
|
||
logger.info("收到中断信号,退出")
|