arbitrage-engine/docs/REVIEW.md
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

930 lines
33 KiB
Markdown
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.

# Arbitrage Engine V5.1 — 代码审阅报告
> 审阅时间2026-03-01
> 审阅范围backend/ 全部核心文件(跳过迁移脚本和已废弃文件)
> 审阅视角:资深量化交易系统架构师,以实盘资金安全为最高优先级
---
## 总体评价
系统架构设计合理关注点分离清晰PM2 多进程模型满足毫秒级 TP/SL 监控需求。但在接入真实资金前,有 **4 个 P0 级问题必须修复**——其中 pnl_r 计算错误和冷却期反向平仓失效会直接影响资金安全和策略可信度。P1 级的分区月份 Bug 在月底可能引发数据写入完全失败。
---
## P0 — 会直接导致资金损失的问题
### P0-1 冷却期阻断反向信号,持仓无法被对冲信号平仓
| 属性 | 内容 |
|------|------|
| **文件** | `signal_engine.py` |
| **行号** | 309COOLDOWN_MS 定义、305in_cooldown 判断、414-425signal 置 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 仓位继续亏损,直到 SL1.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,99signal_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_bepnl_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` |
| **行号** | 285price = vwap、499paper_open_trade 入参) |
| **严重性** | 🔴 P0实盘/ P1模拟盘 |
**问题描述**
`evaluate_signal``price = vwap if vwap > 0 else 0`,这里 `vwap` 是 30 分钟成交量加权均价。在评分触发信号的时刻,市场价格可能已经大幅偏离 30 分钟 VWAP
- 强烈单边行情中(这正是 CVD 信号触发的场景),价格可能在 30 分钟内上涨 1-3%
- 用 VWAP 作为 entry_priceTP1/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-650paper_close_by_signalpaper_monitor.py:44-130check_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
-- 方案APostgreSQL 行级锁(推荐)
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-162TradeWindow.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/ETHXRP/SOL 永久数据缺失
| 属性 | 内容 |
|------|------|
| **文件** | `market_data_collector.py` |
| **行号** | 112-118 |
`pair_map` 只含 BTC/ETHXRPUSDT/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和 UPDATErevoked=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,384signals/page.tsx:101,180,233page.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-78MiniKChart 组件)|
```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>}
// 方案Bdisable + 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 存储在 localStorageXSS 风险)
| 属性 | 内容 |
|------|------|
| **文件** | `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存于localStorageXSS风险|
| 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 文件对应行。*