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.
This commit is contained in:
parent
d8ad87958a
commit
e518fba759
@ -74,6 +74,9 @@ def flush_buffer(symbol: str, trades: list) -> int:
|
||||
return 0
|
||||
try:
|
||||
# 确保分区存在
|
||||
# [REVIEW] P2 | flush_buffer 每次调用(每秒或每200条)都执行 ensure_partitions()
|
||||
# ensure_partitions 会发起数据库查询(CREATE TABLE IF NOT EXISTS),属冗余操作
|
||||
# 建议:将分区创建移到独立的定时任务(如每小时检查一次),不在热路径中执行
|
||||
ensure_partitions()
|
||||
|
||||
values = []
|
||||
@ -251,6 +254,11 @@ async def continuity_check():
|
||||
if gaps:
|
||||
logger.warning(f"[{symbol}] Found {len(gaps)} gaps: {gaps[:3]}")
|
||||
min_gap_id = min(g[0] for g in gaps)
|
||||
# [REVIEW] P2 | asyncio.create_task 在连接池已满时可能加剧连接压力
|
||||
# rest_catchup 内部调用 flush_buffer → get_sync_conn,可能与
|
||||
# 正在运行的 ws_collect 竞争有限的5个连接(maxconn=5)
|
||||
# 且若上一个 catchup task 未完成,新的又被创建,存在任务堆叠风险
|
||||
# 建议:用 asyncio.Lock 或 semaphore 限制并发的 catchup 任务数
|
||||
asyncio.create_task(rest_catchup(symbol, min_gap_id))
|
||||
else:
|
||||
logger.debug(f"[{symbol}] Continuity OK, last_agg_id={row[0]}")
|
||||
|
||||
@ -12,6 +12,10 @@ from pydantic import BaseModel, EmailStr
|
||||
|
||||
from db import get_sync_conn
|
||||
|
||||
# [REVIEW] P3 | JWT 密钥硬编码默认值,若未设置环境变量则使用明文密钥
|
||||
# 任何能读到此文件的人均可伪造合法的 JWT token,获取所有用户权限
|
||||
# 修复:移除默认值,改为 os.getenv("JWT_SECRET") 并在启动时校验非空
|
||||
# 部署:server 上必须设置 export JWT_SECRET=<随机256位密钥>
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "arb-engine-jwt-secret-v2-2026")
|
||||
ACCESS_TOKEN_HOURS = 24
|
||||
REFRESH_TOKEN_DAYS = 7
|
||||
@ -285,6 +289,8 @@ def register(body: RegisterReq):
|
||||
}
|
||||
|
||||
|
||||
# [REVIEW] P3 | 登录接口无频率限制,可被暴力破解
|
||||
# 建议:接入 slowapi 或 redis 计数器,同一IP 60秒内超过10次返回429
|
||||
@router.post("/auth/login")
|
||||
def login(body: LoginReq):
|
||||
ensure_tables()
|
||||
@ -306,6 +312,12 @@ def login(body: LoginReq):
|
||||
|
||||
@router.post("/auth/refresh")
|
||||
def refresh_token(body: RefreshReq):
|
||||
# [REVIEW] P3 | refresh token 刷新非原子操作,存在并发竞态
|
||||
# SELECT(revoked=0) 和 UPDATE(revoked=1) 之间有时间窗口
|
||||
# 两个并发请求可能同时通过 SELECT 校验,都获得新 token(token 复制攻击)
|
||||
# 修复:改用原子操作
|
||||
# UPDATE refresh_tokens SET revoked=1 WHERE token=%s AND revoked=0 RETURNING user_id
|
||||
# 若无返回行则 token 已失效
|
||||
row = _fetchone(
|
||||
"SELECT * FROM refresh_tokens WHERE token = %s AND revoked = 0", (body.refresh_token,)
|
||||
)
|
||||
|
||||
@ -34,6 +34,10 @@ _sync_pool = None
|
||||
|
||||
def get_sync_pool() -> psycopg2.pool.ThreadedConnectionPool:
|
||||
global _sync_pool
|
||||
# [REVIEW] P1 | 初始化非线程安全:两个线程可能同时发现 _sync_pool is None
|
||||
# 并各自创建一个连接池,第一个被第二个覆盖,造成连接泄漏
|
||||
# 修复:加锁 import threading; _pool_lock = threading.Lock()
|
||||
# with _pool_lock: if _sync_pool is None: ...
|
||||
if _sync_pool is None:
|
||||
_sync_pool = psycopg2.pool.ThreadedConnectionPool(
|
||||
minconn=1, maxconn=5,
|
||||
@ -311,6 +315,13 @@ def ensure_partitions():
|
||||
import datetime
|
||||
now = datetime.datetime.utcnow()
|
||||
months = []
|
||||
# [REVIEW] P1 | timedelta(days=30) 无法可靠地计算"下个月"
|
||||
# 反例:1月1日 +30天 = 1月31日(仍是1月),+60天 = 3月1日
|
||||
# 结果:2月分区不会被创建,2月的所有 INSERT INTO agg_trades 都将抛出
|
||||
# ERROR: no partition of relation "agg_trades" found for row
|
||||
# 修复:用 relativedelta 或手动月份加法:
|
||||
# from dateutil.relativedelta import relativedelta
|
||||
# for delta in range(3): d = now + relativedelta(months=delta); ...
|
||||
for delta in range(0, 3): # 当月+下2个月
|
||||
d = now + datetime.timedelta(days=delta * 30)
|
||||
months.append(d.strftime("%Y%m"))
|
||||
@ -326,6 +337,10 @@ def ensure_partitions():
|
||||
end = datetime.datetime(year + 1, 1, 1)
|
||||
else:
|
||||
end = datetime.datetime(year, month + 1, 1)
|
||||
# [REVIEW] P2 | naive datetime.timestamp() 使用本地时区
|
||||
# Binance time_ms 是 UTC 毫秒时间戳,若服务器时区非 UTC(如 JST UTC+9),
|
||||
# start_ms/end_ms 将偏移9小时,导致数据落入相邻分区或分区边界错误
|
||||
# 修复:start = datetime.datetime(year, month, 1, tzinfo=timezone.utc)
|
||||
start_ms = int(start.timestamp() * 1000)
|
||||
end_ms = int(end.timestamp() * 1000)
|
||||
part_name = f"agg_trades_{m}"
|
||||
|
||||
@ -123,6 +123,8 @@ async def run():
|
||||
if buf["count"] > 0:
|
||||
save_aggregated(sym, now * 1000, buf["long_usd"], buf["short_usd"], buf["count"])
|
||||
logger.info(f"[{sym}] 📊 5min agg: long=${buf['long_usd']:,.0f} short=${buf['short_usd']:,.0f} count={buf['count']}")
|
||||
# [REVIEW] P2 | elif 条件冗余:已在 if AGG_INTERVAL 内,elif 永远成立
|
||||
# 实际等价于 else,无需重复判断时间条件
|
||||
# 即使没清算也写一条0记录,保持连贯
|
||||
elif now - buf["window_start"] >= AGG_INTERVAL:
|
||||
save_aggregated(sym, now * 1000, 0, 0, 0)
|
||||
|
||||
@ -13,6 +13,9 @@ import datetime as _dt
|
||||
|
||||
app = FastAPI(title="Arbitrage Engine API")
|
||||
|
||||
# [REVIEW] P3 | CORS allow_origins=["*"] 允许任意域名跨域请求所有API
|
||||
# 对于交易系统,应限制为实际前端域名,防止CSRF类攻击
|
||||
# 修复:allow_origins=["https://arb.zhouyangclaw.com"]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@ -545,6 +548,9 @@ async def paper_summary(user: dict = Depends(get_current_user)):
|
||||
total = len(closed)
|
||||
wins = len([r for r in closed if r["pnl_r"] > 0])
|
||||
total_pnl = sum(r["pnl_r"] for r in closed)
|
||||
# [REVIEW] P3 | 1R=$200 和初始余额$10000 硬编码,不读取 paper_config 动态配置
|
||||
# 若通过API修改了 initial_balance 或 risk_per_trade,此处统计数据不随之更新
|
||||
# 修复:从 paper_config.json 读取参数,或从 paper_trades 的第一条记录推算
|
||||
total_pnl_usdt = total_pnl * 200 # 1R = $200
|
||||
balance = 10000 + total_pnl_usdt
|
||||
win_rate = (wins / total * 100) if total > 0 else 0
|
||||
|
||||
@ -48,6 +48,12 @@ logger = logging.getLogger("market_data_collector")
|
||||
|
||||
class MarketDataCollector:
|
||||
def __init__(self) -> None:
|
||||
# [REVIEW] P1 | 使用单个裸 psycopg2 连接,无连接池,无重连机制
|
||||
# 若数据库连接中断(网络抖动、DB重启、Cloud SQL故障切换),
|
||||
# 下次 save_indicator 调用会抛出 OperationalError,进程崩溃
|
||||
# PM2 会重启进程,但重启期间(约1~5分钟)market_indicators 将停止更新
|
||||
# 从而影响 signal_engine 的评分质量(使用过期的市场指标数据)
|
||||
# 修复:改用 db.get_sync_conn() 连接池,或在 save_indicator 中捕获并重连
|
||||
self.conn = psycopg2.connect(
|
||||
host=PG_HOST,
|
||||
port=PG_PORT,
|
||||
@ -113,6 +119,11 @@ class MarketDataCollector:
|
||||
"BTCUSDT": "BTC-USD",
|
||||
"ETHUSDT": "ETH-USD",
|
||||
}
|
||||
# [REVIEW] P2 | XRP/SOL 不在 pair_map 中,访问时抛出 KeyError
|
||||
# 虽然被 asyncio.gather(return_exceptions=True) 捕获不影响崩溃,
|
||||
# 但 XRPUSDT/SOLUSDT 永远不会有 coinbase_premium 数据
|
||||
# signal_engine 对这两个币种的辅助层会用默认值 2 分(中性),不影响正确性
|
||||
# 但长期是数据缺失,应添加:if symbol not in pair_map: return
|
||||
coinbase_pair = pair_map[symbol]
|
||||
|
||||
binance_url = "https://api.binance.com/api/v3/ticker/price"
|
||||
|
||||
@ -41,6 +41,11 @@ def load_config() -> bool:
|
||||
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)
|
||||
@ -66,6 +71,11 @@ def check_and_close(symbol_upper: str, price: float):
|
||||
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"
|
||||
@ -78,12 +88,17 @@ def check_and_close(symbol_upper: str, price: float):
|
||||
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"
|
||||
@ -96,6 +111,7 @@ def check_and_close(symbol_upper: str, price: float):
|
||||
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分钟
|
||||
@ -132,6 +148,10 @@ def check_and_close(symbol_upper: str, price: float):
|
||||
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}"
|
||||
|
||||
@ -172,6 +192,9 @@ async def ws_monitor():
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket异常: {e}, 5秒后重连...")
|
||||
# [REVIEW] P1 | 重连延迟固定5秒,无指数退避,也无最大重试次数限制
|
||||
# 若Binance服务长时间不可用,会产生大量频繁重连日志
|
||||
# 建议:实现指数退避,最大延迟如 min(delay*2, 60) 秒
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
|
||||
@ -76,6 +76,7 @@ ATR_PERIOD_MS = 5 * 60 * 1000
|
||||
ATR_LENGTH = 14
|
||||
|
||||
# 信号冷却
|
||||
# [REVIEW] P3 | 文档说冷却300秒(5分钟),代码是10分钟,与PROJECT.md不一致,请确认
|
||||
COOLDOWN_MS = 10 * 60 * 1000
|
||||
|
||||
|
||||
@ -146,6 +147,9 @@ class TradeWindow:
|
||||
cutoff = now_ms - self.window_ms
|
||||
while self.trades and self.trades[0][0] < cutoff:
|
||||
t_ms, qty, price, ibm = self.trades.popleft()
|
||||
# [REVIEW] P1 | 浮点精度漂移:buy_vol/sell_vol经历数千万次加减后会累积误差
|
||||
# 7000万条记录后 CVD 可能漂移数百至数千单位
|
||||
# 建议:定期用 sum(t[2] if t[3]==0 else 0 for t in self.trades) 重算
|
||||
self.pq_sum -= price * qty
|
||||
self.q_sum -= qty
|
||||
if ibm == 0:
|
||||
@ -205,6 +209,8 @@ class ATRCalculator:
|
||||
current = self.atr
|
||||
if current == 0:
|
||||
return 50.0
|
||||
# [REVIEW] P2 | @property 含副作用(append),若多次调用会重复追加相同ATR值
|
||||
# 建议将 append 移到显式的 update() 方法中,property只读不写
|
||||
self.atr_history.append(current)
|
||||
if len(self.atr_history) < 10:
|
||||
return 50.0
|
||||
@ -229,6 +235,9 @@ class SymbolState:
|
||||
self.market_indicators = fetch_market_indicators(symbol)
|
||||
self.last_signal_ts = 0
|
||||
self.last_signal_dir = ""
|
||||
# [REVIEW] P1 | recent_large_trades 无 maxlen 限制
|
||||
# 极端行情(如全市场P99涌现)期间,15分钟窗口内的大单数量可能非常多
|
||||
# 建议改为 deque(maxlen=500) 防止内存失控
|
||||
self.recent_large_trades: deque = deque()
|
||||
|
||||
def process_trade(self, agg_id: int, time_ms: int, price: float, qty: float, is_buyer_maker: int):
|
||||
@ -276,6 +285,10 @@ class SymbolState:
|
||||
atr_pct = self.atr_calc.atr_percentile
|
||||
p95, p99 = self.compute_p95_p99()
|
||||
self.update_large_trades(now_ms, p99)
|
||||
# [REVIEW] P0 | 开仓价使用30分钟VWAP而非当前实时价格
|
||||
# VWAP是过去30分钟的均价,在强趋势行情中可能与当前市价相差1%以上
|
||||
# 实盘接入时:必须改用最新aggTrade的price,或从Binance ticker获取mark price
|
||||
# 当前VWAP偏差导致TP1/TP2/SL价位均基于历史均价计算,不是真实的市价入场价
|
||||
price = vwap if vwap > 0 else 0
|
||||
cvd_fast_slope = cvd_fast - self.prev_cvd_fast
|
||||
cvd_fast_accel = cvd_fast_slope - self.prev_cvd_fast_slope
|
||||
@ -296,6 +309,12 @@ class SymbolState:
|
||||
|
||||
# 判断倾向方向(用于评分展示,即使冷却或方向不一致也计算)
|
||||
no_direction = False
|
||||
# [REVIEW] P0 | 冷却期阻断反向信号,导致反向平仓无法触发
|
||||
# 问题:COOLDOWN_MS内任何方向的信号都被置为None(见下方 result["signal"] = None)
|
||||
# 后果:LONG开仓后,冷却期内出现强烈SHORT信号 → result["signal"]=None
|
||||
# → 主循环 if result.get("signal") 为False → paper_close_by_signal 不执行
|
||||
# → LONG仓位在强反向信号面前继续亏损,直到SL或超时才关闭
|
||||
# 修复方案:反向信号应绕过冷却,或在主循环中单独基于方向判断触发反向平仓
|
||||
in_cooldown = (now_ms - self.last_signal_ts < COOLDOWN_MS)
|
||||
|
||||
if cvd_fast > 0 and cvd_mid > 0:
|
||||
@ -502,6 +521,14 @@ def paper_open_trade(symbol: str, direction: str, price: float, score: int, tier
|
||||
risk_atr = 0.7 * atr
|
||||
if risk_atr <= 0:
|
||||
return
|
||||
# [REVIEW] P0 | pnl_r 计算基准与TP/SL设置不一致,导致TP场景收益率虚高2倍
|
||||
# risk_distance (1R) = 2.0 * risk_atr = 1.4 * ATR
|
||||
# TP1 = 1.5 * risk_atr = 0.75 * risk_distance = 0.75R(而非1.5R)
|
||||
# TP2 = 3.0 * risk_atr = 1.5 * risk_distance = 1.5R(而非3.0R)
|
||||
# 但 paper_monitor.py 的 pnl_r 写死为 0.5*1.5=0.75R 和 0.5*1.5+0.5*3.0=2.25R
|
||||
# 正确值应为:TP1半仓=0.375R,全TP=1.125R
|
||||
# 导致:balance/equity_curve/统计指标全部虚高约2倍
|
||||
# 修复:pnl_r 统一用 (exit_price - entry_price) / risk_distance 计算(参考timeout逻辑)
|
||||
if direction == "LONG":
|
||||
sl = price - 2.0 * risk_atr
|
||||
tp1 = price + 1.5 * risk_atr
|
||||
@ -523,6 +550,9 @@ def paper_open_trade(symbol: str, direction: str, price: float, score: int, tier
|
||||
logger.info(f"[{symbol}] 📝 模拟开仓: {direction} @ {price:.2f} score={score} tier={tier} TP1={tp1:.2f} TP2={tp2:.2f} SL={sl:.2f}")
|
||||
|
||||
|
||||
# [REVIEW] P1 | paper_check_positions 当前已被注释掉(主循环717行),但代码仍存在
|
||||
# 该函数与 paper_monitor.py 的 check_and_close 逻辑重复,应删除或明确废弃
|
||||
# 若未来重新启用(例如paper_monitor挂掉时的fallback),需同步修复pnl_r计算错误
|
||||
def paper_check_positions(symbol: str, current_price: float, now_ms: int):
|
||||
"""检查模拟盘持仓的止盈止损"""
|
||||
with get_sync_conn() as conn:
|
||||
@ -594,7 +624,10 @@ def paper_check_positions(symbol: str, current_price: float, now_ms: int):
|
||||
risk_distance = 2.0 * 0.7 * atr_entry if atr_entry > 0 else 1
|
||||
fee_r = (2 * PAPER_FEE_RATE * entry_price) / risk_distance if risk_distance > 0 else 0
|
||||
pnl_r -= fee_r
|
||||
|
||||
# [REVIEW] P0 | 无 SELECT FOR UPDATE,与 paper_monitor.py 存在竞态
|
||||
# signal_engine 的 paper_close_by_signal 和 paper_monitor 的 check_and_close
|
||||
# 可能同时对同一行执行 UPDATE,后者覆盖前者,status/pnl_r 将不一致
|
||||
# 修复:使用 SELECT ... FOR UPDATE SKIP LOCKED 或在应用层加互斥锁
|
||||
cur.execute(
|
||||
"UPDATE paper_trades SET status=%s, exit_price=%s, exit_ts=%s, pnl_r=%s WHERE id=%s",
|
||||
(new_status, current_price, now_ms, round(pnl_r, 4), pid)
|
||||
@ -698,8 +731,13 @@ def main():
|
||||
existing_dir = paper_get_active_direction(sym)
|
||||
new_dir = result["signal"]
|
||||
|
||||
if existing_dir and existing_dir != new_dir:
|
||||
if existing_dir and existing_dir != new_dir:
|
||||
# 反向信号:先平掉现有仓位
|
||||
# [REVIEW] P0 | 此处永远不会被触发(冷却期内的反向信号)
|
||||
# 因为 in_cooldown=True 时 result["signal"]=None,
|
||||
# 外层 if result.get("signal") 为False,不会进入此代码块
|
||||
# 修复:在 evaluate_signal 中不因冷却屏蔽信号方向,
|
||||
# 或在主循环中基于 direction(而非signal)判断是否需要反向平仓
|
||||
paper_close_by_signal(sym, result["price"], now_ms)
|
||||
logger.info(f"[{sym}] 📝 反向信号平仓: {existing_dir} → {new_dir}")
|
||||
|
||||
|
||||
929
docs/REVIEW.md
Normal file
929
docs/REVIEW.md
Normal file
@ -0,0 +1,929 @@
|
||||
# Arbitrage Engine V5.1 — 代码审阅报告
|
||||
|
||||
> 审阅时间:2026-03-01
|
||||
> 审阅范围:backend/ 全部核心文件(跳过迁移脚本和已废弃文件)
|
||||
> 审阅视角:资深量化交易系统架构师,以实盘资金安全为最高优先级
|
||||
|
||||
---
|
||||
|
||||
## 总体评价
|
||||
|
||||
系统架构设计合理,关注点分离清晰,PM2 多进程模型满足毫秒级 TP/SL 监控需求。但在接入真实资金前,有 **4 个 P0 级问题必须修复**——其中 pnl_r 计算错误和冷却期反向平仓失效会直接影响资金安全和策略可信度。P1 级的分区月份 Bug 在月底可能引发数据写入完全失败。
|
||||
|
||||
---
|
||||
|
||||
## P0 — 会直接导致资金损失的问题
|
||||
|
||||
### P0-1 冷却期阻断反向信号,持仓无法被对冲信号平仓
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `signal_engine.py` |
|
||||
| **行号** | 309(COOLDOWN_MS 定义)、305(in_cooldown 判断)、414-425(signal 置 None)、702-720(主循环平仓触发) |
|
||||
| **严重性** | 🔴 P0 |
|
||||
|
||||
**问题描述**:
|
||||
|
||||
`evaluate_signal` 在 `in_cooldown=True` 时统一将 `result["signal"]` 置为 `None`。主循环的反向平仓逻辑(`paper_close_by_signal`)依赖 `if result.get("signal"):` 为真才执行。
|
||||
|
||||
**危险场景**:
|
||||
1. BTC LONG 信号触发,开仓。`last_signal_ts` 更新,冷却 10 分钟。
|
||||
2. 5 分钟后:市场急速反转,SHORT 评分达 90 分(强力信号)。
|
||||
3. `in_cooldown=True` → `result["signal"] = None` → 主循环不执行反向平仓。
|
||||
4. LONG 仓位继续亏损,直到 SL(1.4×ATR)被 `paper_monitor` 触发才关闭。
|
||||
5. **等价于:忽略了一个 90 分的强烈反向信号,仓位被迫吃满亏损。**
|
||||
|
||||
**实盘影响**:对于带杠杆的合约交易,这意味着无法在强反向行情中及时止损换仓。
|
||||
|
||||
**建议修复**:
|
||||
```python
|
||||
# 方案A(推荐):反向信号不受冷却限制
|
||||
# 在 evaluate_signal 中,即使 in_cooldown=True,也输出 direction 供主循环判断
|
||||
# 主循环中:
|
||||
existing_dir = paper_get_active_direction(sym)
|
||||
eval_direction = result.get("direction") # 始终有值
|
||||
if existing_dir and eval_direction and existing_dir != eval_direction:
|
||||
if result["score"] >= 60: # 反向信号评分够高才平仓
|
||||
paper_close_by_signal(sym, result["price"], now_ms)
|
||||
|
||||
# 方案B:在 evaluate_signal 中为反向信号单独处理冷却
|
||||
# 仅对同方向信号应用冷却,反向信号始终允许
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### P0-2 pnl_r 计算错误:TP 场景收益虚高 2 倍
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `paper_monitor.py`、`signal_engine.py` |
|
||||
| **行号** | paper_monitor.py:69,81,87,99;signal_engine.py:558-570 |
|
||||
| **严重性** | 🔴 P0 |
|
||||
|
||||
**问题描述**:
|
||||
|
||||
TP/SL_BE 的 pnl_r 使用了硬编码的错误倍数。
|
||||
|
||||
**核心数学**:
|
||||
```
|
||||
risk_atr = 0.7 × ATR
|
||||
risk_distance (1R 基准) = 2.0 × risk_atr = 1.4 × ATR
|
||||
|
||||
TP1 实际位置 = entry ± 1.5 × risk_atr = entry ± 1.05 ATR
|
||||
TP1 对应 R = 1.5 × risk_atr / (2.0 × risk_atr) = 0.75R ← 不是 1.5R
|
||||
TP2 实际位置 = entry ± 3.0 × risk_atr = entry ± 2.1 ATR
|
||||
TP2 对应 R = 3.0 × risk_atr / (2.0 × risk_atr) = 1.5R ← 不是 3.0R
|
||||
```
|
||||
|
||||
**当前错误**(paper_monitor.py):
|
||||
```python
|
||||
# TP1 sl_be:pnl_r = 0.5 * 1.5 = 0.75R (应为 0.375R,虚高 2×)
|
||||
# 全 TP: pnl_r = 0.5*1.5 + 0.5*3.0 = 2.25R (应为 1.125R,虚高 2×)
|
||||
```
|
||||
|
||||
**对比正确的超时计算**(同文件 line 110):
|
||||
```python
|
||||
pnl_r = move / risk_distance # ← 这个是对的!
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- `balance`(equity curve)虚增:完整 TP 一笔记 2.25R×200=$450,实际只有 1.125R×$225
|
||||
- 胜率不变,但 avg_win 虚高,PF 虚高,Sharpe 虚高
|
||||
- 若基于此回测参数标定实盘仓位,会严重高估策略收益
|
||||
|
||||
**建议修复**:
|
||||
```python
|
||||
# paper_monitor.py check_and_close 中,统一使用实际价差计算
|
||||
risk_distance = 2.0 * 0.7 * atr_entry if atr_entry > 0 else 1
|
||||
|
||||
if new_status == "tp":
|
||||
# 半仓 TP1 + 半仓 TP2 的加权 R
|
||||
tp1_r = (tp1 - entry_price) / risk_distance if direction == "LONG" else (entry_price - tp1) / risk_distance
|
||||
tp2_r = (tp2 - entry_price) / risk_distance if direction == "LONG" else (entry_price - tp2) / risk_distance
|
||||
pnl_r = 0.5 * tp1_r + 0.5 * tp2_r
|
||||
|
||||
elif new_status == "sl_be":
|
||||
tp1_r = (tp1 - entry_price) / risk_distance if direction == "LONG" else (entry_price - tp1) / risk_distance
|
||||
pnl_r = 0.5 * tp1_r # 已平半仓的 TP1 盈利
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### P0-3 开仓价格使用 30 分钟 VWAP 而非实时价格
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `signal_engine.py` |
|
||||
| **行号** | 285(price = vwap)、499(paper_open_trade 入参) |
|
||||
| **严重性** | 🔴 P0(实盘)/ P1(模拟盘) |
|
||||
|
||||
**问题描述**:
|
||||
|
||||
`evaluate_signal` 中 `price = vwap if vwap > 0 else 0`,这里 `vwap` 是 30 分钟成交量加权均价。在评分触发信号的时刻,市场价格可能已经大幅偏离 30 分钟 VWAP:
|
||||
|
||||
- 强烈单边行情中(这正是 CVD 信号触发的场景),价格可能在 30 分钟内上涨 1-3%
|
||||
- 用 VWAP 作为 entry_price,TP1/TP2/SL 的价位全部基于一个过去的"平均价"
|
||||
- 实际开仓后,TP1 可能已经被穿越(low entry),或 SL 过近(high entry)
|
||||
|
||||
**建议修复**:
|
||||
```python
|
||||
# 从 win_fast.trades 取最新的成交价
|
||||
if self.win_fast.trades:
|
||||
price = self.win_fast.trades[-1][2] # (time_ms, qty, price, ibm)
|
||||
else:
|
||||
price = vwap
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### P0-4 双进程并发写同一持仓行,无互斥机制
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `signal_engine.py` + `paper_monitor.py` |
|
||||
| **行号** | signal_engine.py:624-650(paper_close_by_signal);paper_monitor.py:44-130(check_and_close) |
|
||||
| **严重性** | 🔴 P0 |
|
||||
|
||||
**问题描述**:
|
||||
|
||||
两个独立 PM2 进程同时操作 `paper_trades` 表,均直接执行 `UPDATE paper_trades SET status=... WHERE id=?`,没有任何行级锁或应用层互斥。
|
||||
|
||||
**竞态场景**:
|
||||
```
|
||||
T1: paper_monitor SELECT → 返回 active 持仓,检查价格 → 即将触发 SL
|
||||
T2: signal_engine 出现反向信号 → paper_close_by_signal → UPDATE status='signal_flip'
|
||||
T3: paper_monitor UPDATE status='sl' → 覆盖 T2 的 signal_flip
|
||||
结果:一笔交易产生错误的 status 和 pnl_r(后者覆盖前者)
|
||||
```
|
||||
|
||||
**建议修复**:
|
||||
```sql
|
||||
-- 方案A:PostgreSQL 行级锁(推荐)
|
||||
SELECT ... FROM paper_trades
|
||||
WHERE symbol=%s AND status IN ('active','tp1_hit')
|
||||
FOR UPDATE SKIP LOCKED; -- 已被锁定的行跳过,避免死锁
|
||||
```
|
||||
```python
|
||||
# 方案B:将所有平仓逻辑集中在 paper_monitor 一个进程
|
||||
# signal_engine 仅标记"需要平仓"(写一个 pending_close 字段),
|
||||
# paper_monitor 读取后统一处理
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P1 — 长期运行稳定性问题
|
||||
|
||||
### P1-1 ensure_partitions 月份计算 Bug 导致分区缺失
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `db.py` |
|
||||
| **行号** | 320-332 |
|
||||
| **严重性** | 🟠 P1(月底跨月时触发) |
|
||||
|
||||
**问题描述**:
|
||||
|
||||
```python
|
||||
for delta in range(0, 3):
|
||||
d = now + datetime.timedelta(days=delta * 30) # ← 错误
|
||||
```
|
||||
|
||||
**具体反例**(1月1日运行):
|
||||
- delta=0: Jan 01 → `"202601"` ✓
|
||||
- delta=1: Jan 31 → `"202601"` ← 同月!
|
||||
- delta=2: Mar 02 → `"202603"` ← 跳过2月!
|
||||
|
||||
`set(months) = {"202601", "202603"}`,`agg_trades_202602` 分区**不会被创建**。
|
||||
|
||||
**后果**:1月31日到2月的 `flush_buffer` 将抛出:
|
||||
```
|
||||
ERROR: no partition of relation "agg_trades" found for row
|
||||
```
|
||||
所有 BTC/ETH/XRP/SOL 数据写入失败,交易信号引擎读不到新数据,评分僵死。
|
||||
|
||||
**建议修复**:
|
||||
```python
|
||||
import calendar
|
||||
for delta in range(3):
|
||||
month = now.month + delta
|
||||
year = now.year + (month - 1) // 12
|
||||
month = ((month - 1) % 12) + 1
|
||||
months.append(f"{year}{month:02d}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### P1-2 buy_vol/sell_vol 浮点精度漂移
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `signal_engine.py` |
|
||||
| **行号** | 143-162(TradeWindow.add/trim) |
|
||||
| **严重性** | 🟠 P1 |
|
||||
|
||||
**问题描述**:
|
||||
|
||||
`buy_vol` 和 `sell_vol` 是浮点数,通过数千万次加减操作累积误差。IEEE 754 双精度浮点数在大量加减后可能累积可测量的误差。BTC 每笔交易 qty 精度为 0.001,经过 7000 万次操作后误差可能达到数十至数百 BTC。
|
||||
|
||||
CVD = buy_vol - sell_vol 将产生系统性偏差,影响评分准确性。
|
||||
|
||||
**建议修复**:
|
||||
```python
|
||||
# 每隔 N 次 trim 操作后,从 deque 重算
|
||||
def rebuild_sums(self):
|
||||
self.buy_vol = sum(t[1] for t in self.trades if t[3] == 0)
|
||||
self.sell_vol = sum(t[1] for t in self.trades if t[3] == 1)
|
||||
self.pq_sum = sum(t[2] * t[1] for t in self.trades)
|
||||
self.q_sum = sum(t[1] for t in self.trades)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### P1-3 market_data_collector 单连接无重连
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `market_data_collector.py` |
|
||||
| **行号** | 51-57 |
|
||||
| **严重性** | 🟠 P1 |
|
||||
|
||||
使用单个裸 psycopg2 连接,无连接池,无断线重连逻辑。GCP Cloud SQL 网络抖动、实例重启时连接中断,进程崩溃,PM2 重启需要数秒到数分钟,期间市场指标停止更新,signal_engine 评分使用过期数据。
|
||||
|
||||
**建议修复**:改用 `db.get_sync_conn()` 连接池,每次 `save_indicator` 自动获取和归还连接。
|
||||
|
||||
---
|
||||
|
||||
### P1-4 get_sync_pool 初始化线程不安全
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `db.py` |
|
||||
| **行号** | 36-43 |
|
||||
| **严重性** | 🟠 P1 |
|
||||
|
||||
`_sync_pool is None` 判断和赋值之间没有锁,多线程同时调用可能创建多个连接池实例,造成连接泄漏。当前各进程是单线程,问题不会频繁触发,但 `ThreadedConnectionPool` 表明设计上考虑了多线程使用。
|
||||
|
||||
**建议修复**:
|
||||
```python
|
||||
import threading
|
||||
_pool_lock = threading.Lock()
|
||||
|
||||
def get_sync_pool():
|
||||
global _sync_pool
|
||||
if _sync_pool is None:
|
||||
with _pool_lock:
|
||||
if _sync_pool is None:
|
||||
_sync_pool = psycopg2.pool.ThreadedConnectionPool(...)
|
||||
return _sync_pool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### P1-5 recent_large_trades deque 无 maxlen 限制
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `signal_engine.py` |
|
||||
| **行号** | 232 |
|
||||
| **严重性** | 🟠 P1 |
|
||||
|
||||
`self.recent_large_trades: deque = deque()` 无上限。极端行情(挤压、崩盘)时 P99 大单频繁出现,15 分钟窗口内可能积累数万条记录,导致内存持续增长。
|
||||
|
||||
**建议修复**:`deque(maxlen=2000)`,超出自动丢弃最老的。
|
||||
|
||||
---
|
||||
|
||||
## P2 — 数据完整性问题
|
||||
|
||||
### P2-1 coinbase_premium 仅支持 BTC/ETH,XRP/SOL 永久数据缺失
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `market_data_collector.py` |
|
||||
| **行号** | 112-118 |
|
||||
|
||||
`pair_map` 只含 BTC/ETH,XRPUSDT/SOLUSDT 调用 `pair_map[symbol]` 会抛出 `KeyError`。被 `asyncio.gather(return_exceptions=True)` 捕获,不会崩溃,但这两个币种的 `coinbase_premium` 永远为 None。`signal_engine` 对它们的辅助层给默认 2 分(中性),不影响系统运行,但长期是数据空洞。
|
||||
|
||||
**建议修复**:
|
||||
```python
|
||||
if symbol not in pair_map:
|
||||
return # XRP/SOL 无 Coinbase 对应交易对,跳过
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### P2-2 ensure_partitions 使用本地时区
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `db.py` |
|
||||
| **行号** | 329-330 |
|
||||
|
||||
`datetime.datetime(year, month, 1).timestamp()` 使用服务器本地时区。GCP asia-northeast1(东京,JST UTC+9)若未设置 TZ=UTC,分区边界将偏移 9 小时,导致接近月末的数据可能写入错误分区。
|
||||
|
||||
**建议修复**:
|
||||
```python
|
||||
from datetime import timezone
|
||||
start = datetime.datetime(year, month, 1, tzinfo=timezone.utc)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### P2-3 ensure_partitions 在 flush_buffer 热路径中被频繁调用
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `agg_trades_collector.py` |
|
||||
| **行号** | 77 |
|
||||
|
||||
每 1 秒(或每 200 条交易)调用一次 `ensure_partitions()`,其内部执行 `CREATE TABLE IF NOT EXISTS`(3 次),属冗余 DDL 操作,增加每批写入的延迟。
|
||||
|
||||
**建议修复**:将分区创建移到独立的定时任务(每小时执行一次)。
|
||||
|
||||
---
|
||||
|
||||
### P2-4 liquidation_collector 聚合逻辑 elif 冗余条件
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `liquidation_collector.py` |
|
||||
| **行号** | 127 |
|
||||
|
||||
```python
|
||||
if now - buf["window_start"] >= AGG_INTERVAL: # 外层 if
|
||||
if buf["count"] > 0:
|
||||
save_aggregated(...)
|
||||
elif now - buf["window_start"] >= AGG_INTERVAL: # 永远成立!
|
||||
save_aggregated(sym, now * 1000, 0, 0, 0)
|
||||
```
|
||||
|
||||
`elif` 条件与外层 `if` 完全相同,在 else 分支中永远为 True,实际等价于 `else`。逻辑正确但代码有误导性。
|
||||
|
||||
---
|
||||
|
||||
### P2-5 atr_percentile property 含写副作用
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `signal_engine.py` |
|
||||
| **行号** | 209-219 |
|
||||
|
||||
`@property atr_percentile` 内部调用 `self.atr_history.append(current)`。若此属性被意外多次访问,会重复追加同一 ATR 值,使分布历史偏向当前 ATR,影响百分位计算准确性。
|
||||
|
||||
**建议修复**:将 `append` 移到显式调用的 `update_atr_history()` 方法中。
|
||||
|
||||
---
|
||||
|
||||
### P2-6 paper_summary 硬编码 1R = $200
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `main.py` |
|
||||
| **行号** | 554 |
|
||||
|
||||
```python
|
||||
total_pnl_usdt = total_pnl * 200 # 1R = $200(硬编码)
|
||||
```
|
||||
|
||||
若通过 API 修改了 `risk_per_trade` 或 `initial_balance`,此处计算不会随之更新,展示给用户的 USDT 余额将是错误的。
|
||||
|
||||
---
|
||||
|
||||
## P3 — 安全与 API 问题
|
||||
|
||||
### P3-1 JWT 密钥硬编码默认值
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `auth.py` |
|
||||
| **行号** | 15 |
|
||||
|
||||
```python
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "arb-engine-jwt-secret-v2-2026")
|
||||
```
|
||||
|
||||
默认密钥以明文存在源代码中。若服务器未设置环境变量(或被 git clone 后直接运行),任何人都可以用此密钥伪造合法 JWT,以任意身份访问所有需要认证的 API。
|
||||
|
||||
**建议修复**:移除默认值,启动时强制校验:
|
||||
```python
|
||||
JWT_SECRET = os.getenv("JWT_SECRET")
|
||||
if not JWT_SECRET:
|
||||
raise RuntimeError("JWT_SECRET environment variable is required")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### P3-2 CORS allow_origins=["*"]
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `main.py` |
|
||||
| **行号** | 17-21 |
|
||||
|
||||
允许任意域名发起跨域请求,浏览器的同源策略保护失效。若用户在访问恶意网站时处于登录状态,恶意网站可通过 JavaScript 向 API 发起请求。
|
||||
|
||||
**建议修复**:`allow_origins=["https://arb.zhouyangclaw.com"]`
|
||||
|
||||
---
|
||||
|
||||
### P3-3 refresh token 刷新非原子,存在并发竞态
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `auth.py` |
|
||||
| **行号** | 316-327 |
|
||||
|
||||
SELECT(检查 revoked=0)和 UPDATE(revoked=1)之间存在时间窗口,两个并发请求可能都通过校验,生成两个不同的新 access token。
|
||||
|
||||
**建议修复**:原子化操作:
|
||||
```sql
|
||||
UPDATE refresh_tokens SET revoked = 1
|
||||
WHERE token = %s AND revoked = 0
|
||||
RETURNING user_id, expires_at
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### P3-4 登录接口无频率限制
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `auth.py` |
|
||||
| **行号** | 292-308 |
|
||||
|
||||
登录端点无请求频率限制,可被自动化工具暴力破解密码。
|
||||
|
||||
**建议修复**:接入 `slowapi` 或 Redis 计数器,限制同一 IP 每分钟最多 10 次登录尝试。
|
||||
|
||||
---
|
||||
|
||||
## 汇总表
|
||||
|
||||
| ID | 优先级 | 文件 | 行号 | 标题 | 影响 |
|
||||
|----|--------|------|------|------|------|
|
||||
| P0-1 | 🔴 P0 | signal_engine.py | 305,414-425,702-720 | 冷却期阻断反向信号平仓 | 持仓无法被强烈反向信号关闭 |
|
||||
| P0-2 | 🔴 P0 | paper_monitor.py | 69,81,87,99 | pnl_r TP场景虚高2倍 | 统计数据失真,实盘参数标定偏差 |
|
||||
| P0-3 | 🔴 P0 | signal_engine.py | 285,499 | 开仓价用VWAP而非实时价 | TP/SL价位基于历史均价,偏离实际 |
|
||||
| P0-4 | 🔴 P0 | signal_engine.py + paper_monitor.py | 624,44 | 双进程并发写竞态 | 同一持仓被两个进程覆盖写 |
|
||||
| P1-1 | 🟠 P1 | db.py | 320-332 | 月份计算用timedelta(30天) | 月初跨月时漏建分区,数据写入失败 |
|
||||
| P1-2 | 🟠 P1 | signal_engine.py | 143-162 | 浮点精度漂移 | 长期运行后CVD偏差,影响评分 |
|
||||
| P1-3 | 🟠 P1 | market_data_collector.py | 51-57 | 单连接无重连 | DB断线后进程崩溃,指标停止更新 |
|
||||
| P1-4 | 🟠 P1 | db.py | 36-43 | 连接池初始化线程不安全 | 多线程下可能连接泄漏 |
|
||||
| P1-5 | 🟠 P1 | signal_engine.py | 232 | recent_large_trades无maxlen | 极端行情内存无限增长 |
|
||||
| P2-1 | 🟡 P2 | market_data_collector.py | 112-118 | XRP/SOL coinbase_premium KeyError | 两个币种辅助层永久为None |
|
||||
| P2-2 | 🟡 P2 | db.py | 329-330 | 分区边界用本地时区 | 非UTC服务器分区边界偏移 |
|
||||
| P2-3 | 🟡 P2 | agg_trades_collector.py | 77 | flush_buffer每次调用ensure_partitions | 热路径冗余DDL操作 |
|
||||
| P2-4 | 🟡 P2 | liquidation_collector.py | 127 | elif条件冗余 | 逻辑误导,代码质量问题 |
|
||||
| P2-5 | 🟡 P2 | signal_engine.py | 209-219 | atr_percentile有写副作用 | 多次调用时分布历史失真 |
|
||||
| P2-6 | 🟡 P2 | main.py | 554 | 1R=$200硬编码 | 参数变更时余额显示错误 |
|
||||
| P3-1 | 🔵 P3 | auth.py | 15 | JWT密钥硬编码默认值 | 可伪造任意用户JWT |
|
||||
| P3-2 | 🔵 P3 | main.py | 17-21 | CORS allow_origins=["*"] | 浏览器同源策略失效 |
|
||||
| P3-3 | 🔵 P3 | auth.py | 316-327 | refresh token刷新非原子 | 并发刷新可能双重成功 |
|
||||
| P3-4 | 🔵 P3 | auth.py | 292-308 | 登录无频率限制 | 可暴力破解密码 |
|
||||
|
||||
---
|
||||
|
||||
## 优先修复建议
|
||||
|
||||
### 接入实盘前必须完成(P0)
|
||||
|
||||
1. **P0-1**:重写冷却逻辑,确保反向信号不被冷却屏蔽,持仓可被对冲信号平仓
|
||||
2. **P0-2**:统一 pnl_r 计算,所有平仓场景改用 `(exit_price - entry_price) / risk_distance`
|
||||
3. **P0-3**:开仓价改用 `win_fast.trades[-1][2]`(最新成交价)而非 VWAP
|
||||
4. **P0-4**:为 paper_trades 更新操作加 `SELECT FOR UPDATE SKIP LOCKED`,或集中平仓逻辑到单一进程
|
||||
|
||||
### 持续运行必须完成(P1)
|
||||
|
||||
5. **P1-1**:`ensure_partitions` 用正确的月份加法,确保月末不遗漏下月分区
|
||||
6. **P1-3**:`market_data_collector` 改用连接池,加断线重连
|
||||
7. **P1-2**:定期从 deque 重算 buy_vol/sell_vol 防止浮点漂移
|
||||
|
||||
### V5.2 迭代完成(P2/P3)
|
||||
|
||||
8. **P2-1**:`collect_coinbase_premium` 跳过 XRP/SOL
|
||||
9. **P2-2**:分区边界改用 UTC
|
||||
10. **P3-1**:JWT_SECRET 强制环境变量,移除默认值
|
||||
11. **P3-2**:CORS 限制到前端域名
|
||||
12. **P3-4**:登录接口加频率限制
|
||||
|
||||
---
|
||||
|
||||
*本报告仅标注问题和建议,未修改任何业务逻辑。所有 inline 注释格式为 `# [REVIEW] P级 | 描述 | 建议`,已写入各后端文件对应行。*
|
||||
|
||||
---
|
||||
|
||||
## 前端审阅补充(frontend/)
|
||||
|
||||
> 审阅范围:lib/auth.tsx、lib/api.ts、app/paper/page.tsx、app/signals/page.tsx、app/page.tsx、app/trades/page.tsx、app/server/page.tsx
|
||||
|
||||
---
|
||||
|
||||
## FE-P1 — 认证与稳定性(会导致用户无声失联/看到过期数据)
|
||||
|
||||
### FE-P1-1 authFetch 并发刷新竞态:多个 401 同时触发多次 refresh
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `frontend/lib/auth.tsx` |
|
||||
| **行号** | 113-134 |
|
||||
| **严重性** | 🟠 P1 |
|
||||
|
||||
**问题描述**:
|
||||
|
||||
paper 页面同时运行 6 个独立的 `setInterval` 轮询组件。当 access token 恰好过期时,多个组件在同一时间触发 `authFetch`,都收到 401,全部进入刷新逻辑并并发调用 `/api/auth/refresh`。
|
||||
|
||||
后端 refresh token 是单次使用(用完即 revoke)。第一个到达的刷新请求成功,后续请求都得到 "invalid refresh token" → 进入 else 分支。
|
||||
|
||||
**后果**:
|
||||
- else 分支清除 localStorage 但不更新 React AuthContext(见 FE-P1-2)
|
||||
- 这些组件的请求静默失败,显示过期数据
|
||||
- 用户不知道自己已经"半登出"
|
||||
|
||||
**建议修复**:
|
||||
```typescript
|
||||
// lib/auth.tsx 模块顶层
|
||||
let _refreshPromise: Promise<string | null> | null = null;
|
||||
|
||||
async function refreshAccessToken(): Promise<string | null> {
|
||||
if (_refreshPromise) return _refreshPromise;
|
||||
_refreshPromise = (async () => {
|
||||
const rt = localStorage.getItem("refresh_token");
|
||||
if (!rt) return null;
|
||||
const res = await fetch(`${API_BASE}/api/auth/refresh`, { ... });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
localStorage.setItem("access_token", data.access_token);
|
||||
localStorage.setItem("refresh_token", data.refresh_token);
|
||||
return data.access_token;
|
||||
}
|
||||
// 清除 localStorage + 通知 AuthContext
|
||||
return null;
|
||||
})().finally(() => { _refreshPromise = null; });
|
||||
return _refreshPromise;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### FE-P1-2 刷新失败后 React AuthContext 状态未同步
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `frontend/lib/auth.tsx` |
|
||||
| **行号** | 127-133 |
|
||||
| **严重性** | 🟠 P1 |
|
||||
|
||||
**问题描述**:
|
||||
|
||||
当 refresh 失败时,代码清除了 localStorage 但无法直接调用 `AuthContext.logout()`(`authFetch` 是模块级函数,不在 Context 内):
|
||||
|
||||
```typescript
|
||||
// refresh failed, clear auth
|
||||
localStorage.removeItem("access_token"); // ✓ localStorage 清了
|
||||
localStorage.removeItem("refresh_token"); // ✓
|
||||
localStorage.removeItem("user"); // ✓
|
||||
// ← 但 AuthContext.user 和 accessToken state 仍然是旧值!
|
||||
```
|
||||
|
||||
**后果链**:
|
||||
1. `useAuth().isLoggedIn === true`(React state 未变)
|
||||
2. 页面继续显示已登录 UI,轮询仍然继续
|
||||
3. 所有后续请求因无 token(或过期 token)而失败,被 `catch {}` 静默吞掉
|
||||
4. **用户看到的是"运行中"状态但所有数据已冻结**——对交易监控极度危险
|
||||
|
||||
**建议修复**:
|
||||
```typescript
|
||||
// 方案A:全局事件总线
|
||||
window.dispatchEvent(new CustomEvent("auth:session-expired"));
|
||||
// AuthProvider 中监听并调用 logout()
|
||||
|
||||
// 方案B:将 logout 函数存到模块变量(不推荐,破坏封装)
|
||||
// 方案C:刷新失败时返回 res(状态码 401),调用方 useEffect 捕获后执行 logout()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### FE-P1-3 全局 `catch {}` 静默吞掉所有 API 错误
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | 所有页面组件 |
|
||||
| **行号** | paper/page.tsx:25,72,128,176,257,295,384;signals/page.tsx:101,180,233;page.tsx:137,143 |
|
||||
| **严重性** | 🟠 P1 |
|
||||
|
||||
**问题描述**:
|
||||
|
||||
整个项目的 API 调用模式为:
|
||||
```typescript
|
||||
try { const r = await authFetch(...); if (r.ok) setData(...); } catch {}
|
||||
```
|
||||
|
||||
当网络中断、服务器 5xx、或 token 失效时,数据状态不更新,也不给用户任何提示。用户面对的是静止的数字,无法判断是"市场没动"还是"系统断连"。
|
||||
|
||||
**尤其危险的场景**:实盘监控时,服务器崩溃,前端 signal page 显示的是上一个 15 秒前的评分,用户以为还在监控但实际上系统已宕机。
|
||||
|
||||
**建议修复**:至少在每个组件维护一个 `error: string | null` state:
|
||||
```typescript
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
try {
|
||||
const r = await authFetch(...);
|
||||
if (!r.ok) { setError(`API ${r.status}`); return; }
|
||||
setData(await r.json());
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError("网络连接失败");
|
||||
}
|
||||
// 渲染时显示 error && <div className="text-red-500">⚠ {error}</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### FE-P1-4 LatestSignals 串行发 4 个 API 请求,每轮耗时约 2 秒
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `frontend/app/paper/page.tsx` |
|
||||
| **行号** | 119-132 |
|
||||
| **严重性** | 🟠 P1 |
|
||||
|
||||
**问题描述**:
|
||||
|
||||
```typescript
|
||||
for (const sym of COINS) { // 串行!
|
||||
const r = await authFetch(`...?symbol=${sym}...`);
|
||||
setSignals(prev => ({ ...prev, [sym]: j.data[0] })); // 触发 4 次 re-render
|
||||
}
|
||||
```
|
||||
|
||||
4 个请求串行执行,每轮约 500ms × 4 = 2 秒。每次 `setSignals` 都触发组件重新渲染,共 4 次。每 15 秒一轮,共消耗约 2/15 = 13% 的时间在等待 API。
|
||||
|
||||
**建议修复**:改用并行请求 + 批量 state 更新:
|
||||
```typescript
|
||||
const results = await Promise.allSettled(
|
||||
COINS.map(sym => authFetch(`/api/signals/signal-history?symbol=${sym.replace("USDT","")}&limit=1`))
|
||||
);
|
||||
const newSignals: Record<string, any> = {};
|
||||
for (const [i, result] of results.entries()) {
|
||||
if (result.status === "fulfilled" && result.value.ok) {
|
||||
const j = await result.value.json();
|
||||
if (j.data?.[0]) newSignals[COINS[i]] = j.data[0];
|
||||
}
|
||||
}
|
||||
setSignals(prev => ({ ...prev, ...newSignals })); // 1 次 re-render
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FE-P2 — 数据展示与 UX 问题
|
||||
|
||||
### FE-P2-1 MiniKChart 每 30 秒销毁并重建图表,导致视觉闪烁
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `frontend/app/page.tsx` |
|
||||
| **行号** | 52-78(MiniKChart 组件)|
|
||||
|
||||
```typescript
|
||||
// 每次 render() 调用:
|
||||
chartRef.current?.remove(); // 销毁旧图表
|
||||
const chart = createChart(ref.current, baseChartOpts(220)); // 重建
|
||||
series.setData([...]); // 重新写入所有数据
|
||||
```
|
||||
|
||||
用户每 30 秒会看到 K 线图短暂消失再出现(destroy → create 有约 100ms 空白)。
|
||||
|
||||
**建议修复**:只在 symbol/interval 变化时重建,数据更新时只调用 `series.setData()`:
|
||||
```typescript
|
||||
const seriesRef = useRef<any>(null);
|
||||
// 初始化时: 创建 chart + series
|
||||
// 数据更新时: seriesRef.current?.setData(newBars);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### FE-P2-2 async render 未检查组件挂载状态(潜在内存泄漏)
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `frontend/app/page.tsx` |
|
||||
| **行号** | 52-78 |
|
||||
|
||||
cleanup 函数执行 `chartRef.current = null` 时,如果 `render()` 的 `await api.kline()` 仍 in-flight,后续代码继续运行,可能在已卸载的组件上调用 `setState` 或操作已销毁的 chart 对象。
|
||||
|
||||
**建议修复**:
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const doRender = async () => {
|
||||
const json = await api.kline(symbol, interval);
|
||||
if (!mounted || !ref.current) return; // ← 检查挂载状态
|
||||
// ... 渲染逻辑
|
||||
};
|
||||
doRender();
|
||||
const iv = window.setInterval(doRender, 30_000);
|
||||
return () => { mounted = false; window.clearInterval(iv); chartRef.current?.remove(); };
|
||||
}, [symbol, interval, mode]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### FE-P2-3 ControlPanel 对所有登录用户显示,非 admin 点击无任何反馈
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `frontend/app/paper/page.tsx` |
|
||||
| **行号** | 20-65 |
|
||||
|
||||
"启动/停止模拟盘"按钮对所有已登录用户显示。非 admin 用户点击后,后端返回 403,被 `catch {}` 吞掉,用户界面无任何反应(按钮状态不变,也无错误提示)。
|
||||
|
||||
**建议修复**:
|
||||
```typescript
|
||||
const { isAdmin } = useAuth();
|
||||
// 方案A:隐藏按钮
|
||||
{isAdmin && <button onClick={toggle}>...</button>}
|
||||
// 方案B:disable + tooltip
|
||||
<button disabled={!isAdmin} title={isAdmin ? undefined : "仅管理员可操作"}>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### FE-P2-4 ActivePositions WebSocket 无断线重连逻辑
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `frontend/app/paper/page.tsx` |
|
||||
| **行号** | 181-195 |
|
||||
|
||||
WebSocket 连接断开后(网络抖动、Binance 服务重启),`wsPrices` 停止更新,组件降级到 REST 价格(10 秒延迟),但没有任何视觉提示告诉用户"实时价格已断线"。对于监控持仓的页面,用户可能误以为价格在实时更新。
|
||||
|
||||
**建议修复**:
|
||||
```typescript
|
||||
ws.onclose = () => {
|
||||
setTimeout(() => { /* 重建 WebSocket */ }, 3000);
|
||||
setWsConnected(false); // 显示"价格延迟"警告
|
||||
};
|
||||
ws.onerror = () => ws.close();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### FE-P2-5 浮动盈亏 1R=$200 硬编码(前端 duplicate 了后端的同类问题)
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `frontend/app/paper/page.tsx` |
|
||||
| **行号** | 217 |
|
||||
|
||||
```typescript
|
||||
const unrealUsdt = unrealR * 200; // 1R = $200 硬编码
|
||||
```
|
||||
|
||||
与后端 `main.py:554` 同样问题。若 `initial_balance` 或 `risk_per_trade` 变化,此处不随之更新。
|
||||
|
||||
---
|
||||
|
||||
### FE-P2-6 market-indicators 每 5 秒轮询,但数据 5 分钟才更新一次
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `frontend/app/signals/page.tsx` |
|
||||
| **行号** | 101-112 |
|
||||
|
||||
```typescript
|
||||
const iv = setInterval(fetch, 5000); // 每5秒请求一次
|
||||
```
|
||||
|
||||
`market_data_collector` 每 300 秒采集一次,数据变化频率与轮询频率相差 60 倍,制造了 59/60 的冗余请求。
|
||||
|
||||
**建议修复**:将间隔改为 `300_000`(5 分钟),或从后端提供 `Last-Modified` / `ETag` 头支持 conditional GET。
|
||||
|
||||
---
|
||||
|
||||
### FE-P2-7 LayerScore factors 缺失时用比例估算,逻辑有误
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `frontend/app/signals/page.tsx` |
|
||||
| **行号** | 336-340 |
|
||||
|
||||
```typescript
|
||||
score={data.factors?.direction?.score ?? Math.min(Math.round(data.score * 0.45), 45)}
|
||||
```
|
||||
|
||||
当 `factors` 为 null(旧版记录)时,用 `总分 × 权重` 来估算各层分数。这假设各层得分比例与权重一致,但实际评分各层独立,可能出现方向层满分 45 而其他层 0 分但总分仍为 45 的情况,此时展示的进度条会严重失真。
|
||||
|
||||
---
|
||||
|
||||
### FE-P2-8 `any` 类型大量使用,绕过 TypeScript 类型安全
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `paper/page.tsx`, `signals/page.tsx` |
|
||||
| **行号** | paper:21,70,116,289,381 |
|
||||
|
||||
```typescript
|
||||
const [config, setConfig] = useState<any>(null);
|
||||
const [data, setData] = useState<any>(null);
|
||||
```
|
||||
|
||||
使用 `any` 意味着 API 返回异常结构时,运行时错误(`Cannot read property 'enabled' of undefined`)不会在编译期被发现。对于交易数据展示,`null?.toFixed()` 会显示 "undefined" 或 "NaN",误导用户。
|
||||
|
||||
**建议修复**:为每个 API 响应定义 TypeScript interface(参考 `lib/api.ts` 已有的模式)。
|
||||
|
||||
---
|
||||
|
||||
## FE-P3 — 安全问题
|
||||
|
||||
### FE-P3-1 Token 存储在 localStorage(XSS 风险)
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `frontend/lib/auth.tsx` |
|
||||
| **行号** | 33-34、44-48 |
|
||||
|
||||
`localStorage` 可被页面上运行的任意 JavaScript 访问。若某个 npm 依赖包含恶意代码(供应链攻击),或存在 XSS 漏洞,access_token 和 refresh_token 将被盗取,攻击者可以任意访问该用户的所有 API。
|
||||
|
||||
**建议修复**:将 token 改为 `httpOnly; Secure; SameSite=Strict` cookie,浏览器 JavaScript 无法读取:
|
||||
```python
|
||||
# 后端新增 /api/auth/session 端点,Set-Cookie 响应头设置 httpOnly cookie
|
||||
# 前端 fetch 请求加 credentials: "include",无需手动处理 token
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### FE-P3-2 密码强度校验仅要求 6 位,无复杂度要求
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `frontend/app/register/page.tsx` |
|
||||
|
||||
注册时仅校验密码长度 ≥ 6,无字符复杂度要求。对于访问交易数据的账号,建议至少要求大小写+数字+符号。
|
||||
|
||||
---
|
||||
|
||||
### FE-P3-3 `Promise.all` 中任一请求失败导致所有数据丢失
|
||||
|
||||
| 属性 | 内容 |
|
||||
|------|------|
|
||||
| **文件** | `frontend/app/page.tsx` |
|
||||
| **行号** | 144 |
|
||||
|
||||
```typescript
|
||||
const [s, h, sig, y] = await Promise.all([api.stats(), api.history(), api.signalsHistory(), api.statsYtd()]);
|
||||
```
|
||||
|
||||
若 `/api/stats/ytd` 因 Binance API 限速(1000条/次)超时,整个 `Promise.all` 失败,`stats`/`history`/`signals` 数据也全部丢失,用户看到页面数据全部空白。
|
||||
|
||||
**建议修复**:改用 `Promise.allSettled`:
|
||||
```typescript
|
||||
const [s, h, sig, y] = await Promise.allSettled([api.stats(), api.history(), ...]);
|
||||
if (s.status === "fulfilled") setStats(s.value);
|
||||
// 部分失败不影响其他数据展示
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端问题汇总表
|
||||
|
||||
| ID | 优先级 | 文件 | 行号 | 标题 |
|
||||
|----|--------|------|------|------|
|
||||
| FE-P1-1 | 🟠 P1 | lib/auth.tsx | 113-134 | 并发401时多个刷新请求竞态 |
|
||||
| FE-P1-2 | 🟠 P1 | lib/auth.tsx | 127-133 | 刷新失败后AuthContext状态未同步 |
|
||||
| FE-P1-3 | 🟠 P1 | 所有页面组件 | 广泛 | 全局catch{}静默吞掉所有API错误 |
|
||||
| FE-P1-4 | 🟠 P1 | paper/page.tsx | 119-132 | LatestSignals串行4请求耗时2秒 |
|
||||
| FE-P2-1 | 🟡 P2 | app/page.tsx | 52-78 | MiniKChart每30秒销毁重建,视觉闪烁 |
|
||||
| FE-P2-2 | 🟡 P2 | app/page.tsx | 52-78 | async render未检查组件挂载状态 |
|
||||
| FE-P2-3 | 🟡 P2 | paper/page.tsx | 20-65 | ControlPanel对非admin用户可见无反馈 |
|
||||
| FE-P2-4 | 🟡 P2 | paper/page.tsx | 181-195 | WebSocket无断线重连逻辑 |
|
||||
| FE-P2-5 | 🟡 P2 | paper/page.tsx | 217 | 浮动盈亏1R=$200前端硬编码 |
|
||||
| FE-P2-6 | 🟡 P2 | signals/page.tsx | 101-112 | market-indicators每5秒轮询但5分钟更新 |
|
||||
| FE-P2-7 | 🟡 P2 | signals/page.tsx | 336-340 | LayerScore factors估算逻辑失真 |
|
||||
| FE-P2-8 | 🟡 P2 | paper/page.tsx等 | 广泛 | 大量any类型绕过TypeScript安全 |
|
||||
| FE-P3-1 | 🔵 P3 | lib/auth.tsx | 33-48 | Token存于localStorage(XSS风险)|
|
||||
| FE-P3-2 | 🔵 P3 | register/page.tsx | — | 密码强度仅6位无复杂度要求 |
|
||||
| FE-P3-3 | 🔵 P3 | app/page.tsx | 144 | Promise.all任一失败导致全部数据丢失 |
|
||||
|
||||
---
|
||||
|
||||
## 前端优先修复建议
|
||||
|
||||
### 接入实盘前必须完成(P1)
|
||||
|
||||
1. **FE-P1-1 + FE-P1-2**:用单例 Promise 防止并发刷新竞态,刷新失败时通过事件总线同步 AuthContext 强制重新登录
|
||||
2. **FE-P1-3**:为所有轮询组件添加 error state,显示"连接失败"提示,让用户知道数据是否过期
|
||||
|
||||
### 近期迭代(P2)
|
||||
|
||||
3. **FE-P2-4**:ActivePositions WebSocket 添加重连逻辑和断线提示
|
||||
4. **FE-P2-3**:ControlPanel 校验 isAdmin,非 admin 隐藏或禁用按钮
|
||||
5. **FE-P1-4**:LatestSignals 改为 Promise.allSettled 并行请求
|
||||
6. **FE-P2-1**:MiniKChart 只在参数变化时重建图表,数据更新时复用
|
||||
|
||||
### 安全加固(P3)
|
||||
|
||||
7. **FE-P3-1**:评估是否将 token 迁移到 httpOnly cookie
|
||||
8. **FE-P3-3**:`Promise.all` 改为 `Promise.allSettled`,防止单个 API 失败影响整体
|
||||
|
||||
---
|
||||
|
||||
*前端 inline 注释格式为 `// [REVIEW] FE-P级 | 描述 | 建议`,已写入各 .tsx 文件对应行。*
|
||||
@ -54,6 +54,10 @@ function MiniKChart({ symbol, interval, mode, colors }: {
|
||||
const json = await api.kline(symbol, interval);
|
||||
const bars: KBar[] = json.data || [];
|
||||
if (!ref.current) return;
|
||||
// [REVIEW] FE-P2-1 | 每次调用都销毁并重建 lightweight-charts 实例
|
||||
// setInterval 每30秒触发一次,导致图表闪烁(destroy→create→setData 有可见延迟)
|
||||
// 修复:只在 symbol/interval 变化时重建图表,仅数据更新时调用 series.setData()
|
||||
// 可用 useRef 存储 series 引用:if (!chartRef.current) { 初始化 } else { series.setData() }
|
||||
chartRef.current?.remove();
|
||||
const chart = createChart(ref.current, baseChartOpts(220));
|
||||
chartRef.current = chart;
|
||||
@ -69,6 +73,12 @@ function MiniKChart({ symbol, interval, mode, colors }: {
|
||||
chart.timeScale().fitContent();
|
||||
ref.current.querySelectorAll("a").forEach(a => (a as HTMLElement).style.display = "none");
|
||||
} catch {}
|
||||
// [REVIEW] FE-P2-2 | async render 未检查组件挂载状态
|
||||
// cleanup 执行 chartRef.current = null 后,如果 fetch 仍在 in-flight,
|
||||
// render() 继续执行到 if (!ref.current) return 会安全退出
|
||||
// 但若 ref.current 在 cleanup 后仍不为 null(极短窗口),chart.remove 已调用
|
||||
// 则 series.setData 会访问已销毁的 chart 对象
|
||||
// 修复:在 useEffect 中用 let mounted = true; cleanup: mounted = false; 在 async 中判断
|
||||
}, [symbol, interval, mode, colors.up, colors.down]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -143,7 +153,10 @@ export default function Dashboard() {
|
||||
try {
|
||||
const [s, h, sig, y] = await Promise.all([api.stats(), api.history(), api.signalsHistory(), api.statsYtd()]);
|
||||
setStats(s); setHistory(h); setSignals(sig.items || []); setYtd(y);
|
||||
} catch {}
|
||||
} catch {
|
||||
// [REVIEW] FE-P1-3 | 4个并行API中任一失败,整批都被丢弃,用户无提示
|
||||
// Promise.all 失败则所有结果丢失,建议改用 Promise.allSettled
|
||||
}
|
||||
}, [isLoggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -17,11 +17,18 @@ function fmtPrice(p: number) {
|
||||
|
||||
// ─── 控制面板(开关+配置)──────────────────────────────────────
|
||||
|
||||
// [REVIEW] FE-P2-3 | ControlPanel 对所有已登录用户可见,不校验 isAdmin
|
||||
// 非admin用户看到"启动/停止模拟盘"按钮,点击后后端返回403,被 catch{} 静默吞掉
|
||||
// 用户看不到任何错误反馈,体验差
|
||||
// 修复:从 useAuth() 取 isAdmin,button 加 disabled={!isAdmin} 或条件渲染
|
||||
function ControlPanel() {
|
||||
const [config, setConfig] = useState<any>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// [REVIEW] FE-P1-3 | 全局 catch{} 吞掉所有错误,网络异常时用户无感知
|
||||
// 此模式在本项目中广泛存在(paper/signals/trades/server 所有组件)
|
||||
// 建议:至少在 catch 中设置一个 error state,显示"加载失败,请刷新"提示
|
||||
const f = async () => { try { const r = await authFetch("/api/paper/config"); if (r.ok) setConfig(await r.json()); } catch {} };
|
||||
f();
|
||||
}, []);
|
||||
@ -116,6 +123,11 @@ function LatestSignals() {
|
||||
const [signals, setSignals] = useState<Record<string, any>>({});
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
// [REVIEW] FE-P1-4 | 串行发4个 API 请求,每轮耗时约 2 秒(4 × ~500ms RTT)
|
||||
// 在此期间每次 setSignals 都触发一次 re-render,共4次
|
||||
// 修复:改用 Promise.all 并行请求 + 一次 setSignals 批量更新
|
||||
// const results = await Promise.allSettled(COINS.map(sym => authFetch(...)));
|
||||
// const newSignals = {...}; setSignals(newSignals);
|
||||
for (const sym of COINS) {
|
||||
try {
|
||||
const r = await authFetch(`/api/signals/signal-history?symbol=${sym.replace("USDT","")}&limit=1`);
|
||||
@ -191,6 +203,10 @@ function ActivePositions() {
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
// [REVIEW] FE-P2-4 | WebSocket 无断线重连逻辑,无 onerror/onclose 处理
|
||||
// 断线后 wsPrices 停止更新,组件静默降级到REST价格(10秒延迟)但无任何提示
|
||||
// 对于实时持仓监控页面,价格停止更新应有视觉警告
|
||||
// 修复:添加 ws.onclose = () => setTimeout(connectWS, 3000) 重连
|
||||
return () => ws.close();
|
||||
}, []);
|
||||
|
||||
@ -214,6 +230,8 @@ function ActivePositions() {
|
||||
const atr = p.atr_at_entry || 1;
|
||||
const riskDist = 2.0 * 0.7 * atr;
|
||||
const unrealR = riskDist > 0 ? (p.direction === "LONG" ? (currentPrice - entry) / riskDist : (entry - currentPrice) / riskDist) : 0;
|
||||
// [REVIEW] FE-P2-5 | unrealUsdt = unrealR * 200 硬编码 1R=$200
|
||||
// 同后端 main.py P3 问题,应读取 paper_config.initial_balance * risk_per_trade
|
||||
const unrealUsdt = unrealR * 200;
|
||||
return (
|
||||
<div key={p.id} className="px-3 py-2">
|
||||
|
||||
@ -107,6 +107,9 @@ function MarketIndicatorsCards({ symbol }: { symbol: Symbol }) {
|
||||
} catch {}
|
||||
};
|
||||
fetch();
|
||||
// [REVIEW] FE-P2-6 | 每5秒拉取一次 market-indicators,但该数据每5分钟才更新一次
|
||||
// 实际数据变化频率与轮询频率严重不匹配,造成60倍冗余请求
|
||||
// 建议:轮询间隔改为 300_000(5分钟),或后端推送WebSocket通知
|
||||
const iv = setInterval(fetch, 5000);
|
||||
return () => clearInterval(iv);
|
||||
}, [symbol]);
|
||||
@ -333,7 +336,11 @@ function IndicatorCards({ symbol }: { symbol: Symbol }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
<LayerScore label="方向" score={data.factors?.direction?.score ?? Math.min(Math.round(data.score * 0.45), 45)} max={45} colorClass="bg-blue-600" />
|
||||
{/* [REVIEW] FE-P2-7 | factors 缺失时用总分*权重估算各层分数,逻辑有误
|
||||
假设各层线性叠加,但实际评分并非线性(各层有最大值限制且独立计算)
|
||||
例:score=70时,direction估算=round(70*0.45)=32,但实际direction层可能已满45
|
||||
应直接从 signal_indicators 表存储 score_factors JSON 并返回给前端 */}
|
||||
<LayerScore label="方向" score={data.factors?.direction?.score ?? Math.min(Math.round(data.score * 0.45), 45)} max={45} colorClass="bg-blue-600" />
|
||||
<LayerScore label="拥挤" score={data.factors?.crowding?.score ?? Math.min(Math.round(data.score * 0.20), 20)} max={20} colorClass="bg-violet-600" />
|
||||
<LayerScore label="环境" score={data.factors?.environment?.score ?? Math.min(Math.round(data.score * 0.15), 15)} max={15} colorClass="bg-emerald-600" />
|
||||
<LayerScore label="确认" score={data.factors?.confirmation?.score ?? Math.min(Math.round(data.score * 0.15), 15)} max={15} colorClass="bg-amber-500" />
|
||||
|
||||
@ -30,6 +30,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// init from localStorage
|
||||
useEffect(() => {
|
||||
// [REVIEW] FE-P3-1 | Tokens 存储在 localStorage,存在 XSS 盗取风险
|
||||
// 任何能在页面执行的 JS(包括第三方依赖的供应链攻击)都能读取 localStorage
|
||||
// 对于交易系统建议改用 httpOnly Secure cookie(需后端配合 /api/auth/session 端点)
|
||||
const token = localStorage.getItem("access_token");
|
||||
const saved = localStorage.getItem("user");
|
||||
if (token && saved) {
|
||||
@ -113,6 +116,13 @@ export async function authFetch(path: string, options: RequestInit = {}): Promis
|
||||
if (res.status === 401) {
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
if (refreshToken) {
|
||||
// [REVIEW] FE-P1-1 | 并发刷新竞态:多个组件同时 401 时会并发调用此逻辑
|
||||
// 后端 refresh token 是单次使用(用完即 revoke),多个并发刷新请求中只有第一个成功
|
||||
// 其余请求的刷新会失败,进入 else 分支清除 localStorage,导致部分组件数据停止更新
|
||||
// 修复:用模块级 Promise 单例防止并发刷新
|
||||
// let _refreshPromise: Promise<string|null> | null = null;
|
||||
// if (!_refreshPromise) { _refreshPromise = doRefresh().finally(() => _refreshPromise = null); }
|
||||
// const newToken = await _refreshPromise;
|
||||
const refreshRes = await fetch(`${API_BASE}/api/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@ -125,6 +135,12 @@ export async function authFetch(path: string, options: RequestInit = {}): Promis
|
||||
headers.set("Authorization", `Bearer ${data.access_token}`);
|
||||
res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
} else {
|
||||
// [REVIEW] FE-P1-2 | 刷新失败后 React AuthContext 状态未同步
|
||||
// 这里清除了 localStorage 但没有调用 AuthContext.logout()(无法直接访问 Context)
|
||||
// 结果:React state 仍显示 isLoggedIn=true,user 对象仍存在
|
||||
// 用户 UI 看起来仍是登录状态,但所有后续 API 调用都会无声失败(catch{} 吞掉错误)
|
||||
// 修复方案A:抛出特定错误码,调用方 useEffect 中捕获并执行 logout()
|
||||
// 修复方案B:用 window.dispatchEvent(new CustomEvent("auth:logout")) 触发全局登出
|
||||
// refresh failed, clear auth
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user