arbitrage-engine/backend/paper_monitor.py
fanziqi ad60a53262 review: add code audit annotations and REVIEW.md for v5.1
P0 issues annotated (critical, must fix before live trading):
- signal_engine.py: cooldown blocks reverse-signal position close
- paper_monitor.py + signal_engine.py: pnl_r 2x inflated for TP scenarios
- signal_engine.py: entry price uses 30min VWAP instead of real-time price
- paper_monitor.py + signal_engine.py: concurrent write race on paper_trades

P1 issues annotated (long-term stability):
- db.py: ensure_partitions uses timedelta(30d) causing missed monthly partitions
- signal_engine.py: float precision drift in buy_vol/sell_vol accumulation
- market_data_collector.py: single bare connection with no reconnect logic
- db.py: get_sync_pool initialization not thread-safe
- signal_engine.py: recent_large_trades deque has no maxlen

P2/P3 issues annotated across backend and frontend:
- coinbase_premium KeyError for XRP/SOL symbols
- liquidation_collector: redundant elif condition in aggregation logic
- auth.py: JWT secret hardcoded default, login rate-limit absent
- Frontend: concurrent refresh token race, AuthContext not synced on failure
- Frontend: universal catch{} swallows all API errors silently
- Frontend: serial API requests in LatestSignals, market-indicators over-polling

docs/REVIEW.md: comprehensive audit report with all 34 issues (P0×4, P1×5,
P2×6, P3×4 backend + FE-P1×4, FE-P2×8, FE-P3×3 frontend), fix suggestions
and prioritized remediation roadmap.
2026-03-01 17:14:52 +08:00

203 lines
8.7 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.

"""
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_bepnl_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的tppnl_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())