""" paper_monitor.py — 模拟盘实时监控(独立PM2进程) 用币安WebSocket实时推送价格,毫秒级触发止盈止损。 """ import asyncio import json import logging import os import sys import time import websockets import psycopg2 sys.path.insert(0, os.path.dirname(__file__)) from db import get_sync_conn logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", handlers=[ logging.StreamHandler(), logging.FileHandler(os.path.join(os.path.dirname(__file__), "..", "paper-monitor.log")), ], ) logger = logging.getLogger("paper-monitor") SYMBOLS = ["btcusdt", "ethusdt", "xrpusdt", "solusdt"] FEE_RATE = 0.0005 # Taker 0.05% def load_config() -> bool: """读取模拟盘开关""" config_path = os.path.join(os.path.dirname(__file__), "paper_config.json") try: with open(config_path, "r") as f: cfg = json.load(f) return cfg.get("enabled", False) except FileNotFoundError: return False # [REVIEW] P0 | 双进程并发写竞态:signal_engine.paper_close_by_signal 与此函数均对 # paper_trades 执行 UPDATE,没有任何互斥机制(SELECT ... FOR UPDATE 或应用锁) # 场景:paper_monitor 正在 check_and_close → signal_engine 同时 paper_close_by_signal # 结果:同一行被 UPDATE 两次,后者覆盖前者的 status/exit_price/pnl_r # 修复建议:将平仓逻辑集中在一个进程,或用 SELECT FOR UPDATE SKIP LOCKED 加行级锁 def check_and_close(symbol_upper: str, price: float): """检查该币种的活跃持仓,价格到了就平仓""" now_ms = int(time.time() * 1000) with get_sync_conn() as conn: with conn.cursor() as cur: cur.execute( "SELECT id, direction, entry_price, tp1_price, tp2_price, sl_price, " "tp1_hit, entry_ts, atr_at_entry " "FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')", (symbol_upper,) ) positions = cur.fetchall() for pos in positions: pid, direction, entry_price, tp1, tp2, sl, tp1_hit, entry_ts, atr_entry = pos closed = False new_status = None pnl_r = 0.0 if direction == "LONG": if price <= sl: closed = True if tp1_hit: new_status = "sl_be" # [REVIEW] P0 | pnl_r 错误:TP1半仓已锁定的盈利计算有误 # TP1 = entry + 1.5*risk_atr, risk_distance = 2.0*risk_atr # 半仓TP1的实际R = 0.5 * (1.5/2.0) = 0.375R,而非 0.5*1.5=0.75R # 当前写法比实际值虚高2倍,导致balance/equity统计失真 # 修复:pnl_r = 0.5 * (tp1 - entry_price) / risk_distance pnl_r = 0.5 * 1.5 else: new_status = "sl" pnl_r = -1.0 elif not tp1_hit and price >= tp1: new_sl = entry_price * 1.0005 cur.execute("UPDATE paper_trades SET tp1_hit=TRUE, sl_price=%s, status='tp1_hit' WHERE id=%s", (new_sl, pid)) logger.info(f"[{symbol_upper}] ✅ TP1触发 LONG @ {price:.4f}, SL→{new_sl:.4f}") elif tp1_hit and price >= tp2: closed = True new_status = "tp" # [REVIEW] P0 | pnl_r 错误:全TP收益计算虚高2倍 # 正确值:0.5*(1.5/2.0) + 0.5*(3.0/2.0) = 0.375+0.75 = 1.125R # 当前 2.25R 是正确值的2倍 # 修复:pnl_r = 0.5*(tp1-entry)/risk_dist + 0.5*(tp2-entry)/risk_dist pnl_r = 2.25 else: # SHORT if price >= sl: closed = True if tp1_hit: new_status = "sl_be" # [REVIEW] P0 | 同LONG的sl_be:pnl_r 虚高2倍(应为0.375R) pnl_r = 0.5 * 1.5 else: new_status = "sl" pnl_r = -1.0 elif not tp1_hit and price <= tp1: new_sl = entry_price * 0.9995 cur.execute("UPDATE paper_trades SET tp1_hit=TRUE, sl_price=%s, status='tp1_hit' WHERE id=%s", (new_sl, pid)) logger.info(f"[{symbol_upper}] ✅ TP1触发 SHORT @ {price:.4f}, SL→{new_sl:.4f}") elif tp1_hit and price <= tp2: closed = True new_status = "tp" # [REVIEW] P0 | 同LONG的tp:pnl_r 虚高2倍(应为1.125R) pnl_r = 2.25 # 时间止损:60分钟 if not closed and (now_ms - entry_ts > 60 * 60 * 1000): closed = True new_status = "timeout" risk_distance = 2.0 * 0.7 * atr_entry if atr_entry > 0 else 1 if direction == "LONG": move = price - entry_price else: move = entry_price - price pnl_r = move / risk_distance if risk_distance > 0 else 0 if tp1_hit: pnl_r = max(pnl_r, 0.5 * 1.5) if closed: # 扣手续费 risk_distance = 2.0 * 0.7 * atr_entry if atr_entry > 0 else 1 fee_r = (2 * FEE_RATE * entry_price) / risk_distance if risk_distance > 0 else 0 pnl_r -= fee_r cur.execute( "UPDATE paper_trades SET status=%s, exit_price=%s, exit_ts=%s, pnl_r=%s WHERE id=%s", (new_status, price, now_ms, round(pnl_r, 4), pid) ) logger.info( f"[{symbol_upper}] 📝 平仓: {direction} @ {price:.4f} " f"status={new_status} pnl={pnl_r:+.2f}R (fee={fee_r:.3f}R)" ) conn.commit() async def ws_monitor(): """连接币安WebSocket,实时监控价格""" # 组合流:所有币种的markPrice@1s # [REVIEW] P1 | 使用 markPrice(标记价格)而非 aggTrade 成交价监控TP/SL # markPrice 与实际成交价存在偏差(尤其极端行情),可能导致TP/SL触发时机不准 # 且 markPrice@1s 最多1秒一次推送,aggTrade 是毫秒级实时推送 # 建议:改用 aggTrade stream(如 btcusdt@aggTrade)以获取真实成交价格和更高频率 streams = "/".join([f"{s}@markPrice@1s" for s in SYMBOLS]) url = f"wss://fstream.binance.com/stream?streams={streams}" logger.info(f"=== Paper Monitor 启动 ===") logger.info(f"监控币种: {[s.upper() for s in SYMBOLS]}") config_check_counter = 0 while True: enabled = load_config() if not enabled: logger.info("模拟盘未启用,等待30秒后重试...") await asyncio.sleep(30) continue try: async with websockets.connect(url, ping_interval=20) as ws: logger.info(f"WebSocket连接成功: {url[:80]}...") while True: msg = await asyncio.wait_for(ws.recv(), timeout=30) data = json.loads(msg) if "data" in data: d = data["data"] symbol = d.get("s", "") # e.g. "BTCUSDT" mark_price = float(d.get("p", 0)) if mark_price > 0: check_and_close(symbol, mark_price) # 每60秒检查一次开关 config_check_counter += 1 if config_check_counter >= 60: config_check_counter = 0 if not load_config(): logger.info("模拟盘已关闭,断开WebSocket") break except Exception as e: logger.error(f"WebSocket异常: {e}, 5秒后重连...") # [REVIEW] P1 | 重连延迟固定5秒,无指数退避,也无最大重试次数限制 # 若Binance服务长时间不可用,会产生大量频繁重连日志 # 建议:实现指数退避,最大延迟如 min(delay*2, 60) 秒 await asyncio.sleep(5) if __name__ == "__main__": asyncio.run(ws_monitor())