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.
203 lines
8.7 KiB
Python
203 lines
8.7 KiB
Python
"""
|
||
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())
|