221 lines
6.8 KiB
Python
221 lines
6.8 KiB
Python
#!/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)
|
||
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 转义,保留已有的 <b>/<code> 标签。"""
|
||
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>")
|
||
return msg
|
||
|
||
|
||
if __name__ == "__main__":
|
||
monitor = BSPMonitor()
|
||
try:
|
||
asyncio.run(monitor.run())
|
||
except KeyboardInterrupt:
|
||
logger.info("收到中断信号,退出")
|