# 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 | null = null; async function refreshAccessToken(): Promise { 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(null); try { const r = await authFetch(...); if (!r.ok) { setError(`API ${r.status}`); return; } setData(await r.json()); setError(null); } catch (e) { setError("网络连接失败"); } // 渲染时显示 error &&
⚠ {error}
``` --- ### 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 = {}; 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(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 && } // 方案B:disable + tooltip