docs: add live trading v2 round-2 review report
This commit is contained in:
parent
8694e5cf3a
commit
6c7a6e6437
402
docs/LIVE_TRADING_REVIEW.md
Normal file
402
docs/LIVE_TRADING_REVIEW.md
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
# 实盘交易系统代码审阅报告(第二轮)
|
||||||
|
|
||||||
|
**审阅分支**:`review/live-trading-v2`
|
||||||
|
**审阅日期**:2026-03-02
|
||||||
|
**审阅范围**:
|
||||||
|
- `backend/live_executor.py`
|
||||||
|
- `backend/position_sync.py`
|
||||||
|
- `backend/risk_guard.py`
|
||||||
|
- `backend/signal_engine.py`
|
||||||
|
- `backend/main.py`
|
||||||
|
- `backend/trade_config.py`
|
||||||
|
- `frontend/app/live/page.tsx`
|
||||||
|
- `frontend/` 其余页面(重点检查权限、交易展示口径、调用链)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 审阅结论(摘要)
|
||||||
|
|
||||||
|
本轮已验证上一轮多数问题已修复(如 SL 三次失败补救、TP1 检测逻辑、实盘高危接口 admin 校验、funding 周期窗口、CORS、日志文件输出等)。
|
||||||
|
|
||||||
|
但当前仍存在 **P0 级问题 6 项**(含上轮未彻底闭环与新暴露问题),涉及:
|
||||||
|
- 鉴权密钥默认值导致的管理员权限伪造风险
|
||||||
|
- 数据库密码明文 fallback 仍存在
|
||||||
|
- 紧急平仓路径在特定分支仍可能失败或不够安全
|
||||||
|
- TP1 后 SL 重挂失败时状态机错误前进
|
||||||
|
- 双策略同币叠仓导致真实仓位与本地记录错配
|
||||||
|
|
||||||
|
**结论:当前版本不可以上实盘。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、上一轮问题复检
|
||||||
|
|
||||||
|
### 已确认修复
|
||||||
|
- P0-1:`live_executor.py` 已实现 SL 失败重试并在 3 次失败后紧急平仓。
|
||||||
|
- P0-2:`position_sync.py` 已改为基于 `qty` 的 TP1 触发检测。
|
||||||
|
- P0-3:`main.py` 实盘高危接口已接入 `_require_admin()`。
|
||||||
|
- P1-2:funding 追踪已改为按结算周期起始时间对齐。
|
||||||
|
- P1-3:收到 NOTIFY 后不再强制 sleep。
|
||||||
|
- P2-2:紧急指令文件删除时机已后移,竞争窗口收窄。
|
||||||
|
- P2-3:自动恢复增加了原因约束(避免日限亏损被 API 恢复误清)。
|
||||||
|
- P3-1:精度常量已抽取到 `trade_config.py`。
|
||||||
|
- P3-3:CORS 不再使用 `*`。
|
||||||
|
- P3-4:三个实盘进程已接入文件日志。
|
||||||
|
|
||||||
|
### 未彻底闭环或残留
|
||||||
|
- P0-4:DB 密码 fallback 仍存在(3 个实盘进程)。
|
||||||
|
- P0-5:`close_all` 路径已修,但超时自动平仓路径仍未做数量精度处理。
|
||||||
|
- P2-1:工作连接有重连,但 `LISTEN` 连接仍无重连。
|
||||||
|
- P3-2:部分 USDT 展示仍硬编码 `*2`,与 live_config 动态 1R 不一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、P0 — 资金安全(必须修复)
|
||||||
|
|
||||||
|
### P0-1 JWT_SECRET 存在默认值,可伪造管理员 Token
|
||||||
|
- **文件**:`backend/auth.py:15`
|
||||||
|
- **问题**:`JWT_SECRET` 使用硬编码默认值,攻击者可离线签发合法 JWT,伪造 `role=admin`。
|
||||||
|
- **风险**:可直接调用 `/api/live/emergency-close`、`/api/live/config` 等高危接口。
|
||||||
|
|
||||||
|
**修复代码**:
|
||||||
|
```python
|
||||||
|
# backend/auth.py
|
||||||
|
JWT_SECRET = os.getenv("JWT_SECRET")
|
||||||
|
if not JWT_SECRET or len(JWT_SECRET) < 32:
|
||||||
|
raise RuntimeError("JWT_SECRET 未配置或长度不足(>=32)")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P0-2 DB_PASSWORD 明文 fallback 仍在三处
|
||||||
|
- **文件**:
|
||||||
|
- `backend/live_executor.py:49`
|
||||||
|
- `backend/position_sync.py:42`
|
||||||
|
- `backend/risk_guard.py:47`
|
||||||
|
- **问题**:缺失环境变量时仍回退到固定密码。
|
||||||
|
- **风险**:环境误配即使用弱口令连接生产库,且密码已暴露于代码历史语义中。
|
||||||
|
|
||||||
|
**修复代码**(三文件同改):
|
||||||
|
```python
|
||||||
|
DB_PASSWORD = os.getenv("DB_PASSWORD")
|
||||||
|
if not DB_PASSWORD:
|
||||||
|
logger.error("DB_PASSWORD 未设置,拒绝启动")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
DB_CONFIG = {
|
||||||
|
"host": os.getenv("DB_HOST", "10.106.0.3"),
|
||||||
|
"port": int(os.getenv("DB_PORT", "5432")),
|
||||||
|
"dbname": os.getenv("DB_NAME", "arb_engine"),
|
||||||
|
"user": os.getenv("DB_USER", "arb"),
|
||||||
|
"password": DB_PASSWORD,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P0-3 SL 三次失败后的紧急平仓未显式 reduceOnly 且未校验结果
|
||||||
|
- **文件**:`backend/live_executor.py:423`(调用点),`backend/live_executor.py:190`(下单函数)
|
||||||
|
- **问题**:调用的是通用 `place_market_order`,未显式 `reduceOnly`,也未在失败后再升级处置。
|
||||||
|
- **风险**:极端市场/交易所拒单场景下,可能平仓失败后继续裸奔;在参数异常时也可能出现行为偏差。
|
||||||
|
|
||||||
|
**修复代码**:
|
||||||
|
```python
|
||||||
|
# backend/live_executor.py
|
||||||
|
async def place_reduce_only_close(session: aiohttp.ClientSession, symbol: str, side: str):
|
||||||
|
pos = await get_position(session, symbol)
|
||||||
|
if not pos:
|
||||||
|
return {"msg": "no_position"}, 200
|
||||||
|
|
||||||
|
amt = abs(float(pos.get("positionAmt", 0)))
|
||||||
|
if amt <= 0:
|
||||||
|
return {"msg": "no_position"}, 200
|
||||||
|
|
||||||
|
prec = SYMBOL_PRECISION.get(symbol, {"qty": 3})
|
||||||
|
qty_str = f"{amt:.{prec['qty']}f}"
|
||||||
|
|
||||||
|
return await binance_request(session, "POST", "/fapi/v1/order", {
|
||||||
|
"symbol": symbol,
|
||||||
|
"side": side,
|
||||||
|
"type": "MARKET",
|
||||||
|
"quantity": qty_str,
|
||||||
|
"reduceOnly": "true",
|
||||||
|
})
|
||||||
|
|
||||||
|
# 替换原 SL 失败分支
|
||||||
|
if sl_status != 200:
|
||||||
|
logger.error(f"[{symbol}] ❌ SL 3次全部失败,紧急reduceOnly平仓! data={sl_data}")
|
||||||
|
close_data, close_status = await place_reduce_only_close(session, symbol, close_side)
|
||||||
|
if close_status != 200:
|
||||||
|
_log_event(db_conn, "critical", "trade", "SL失败后紧急平仓也失败", symbol,
|
||||||
|
{"sl_data": str(sl_data), "close_data": str(close_data)})
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P0-4 TP1 触发后新 SL 挂单失败,代码仍推进到 `tp1_hit`
|
||||||
|
- **文件**:`backend/position_sync.py:373`、`backend/position_sync.py:385`
|
||||||
|
- **问题**:`rehang_sl()` 返回失败时未阻断,后续仍可能写 DB 为 `tp1_hit`。
|
||||||
|
- **风险**:旧单已取消、新 SL 未挂成功时,剩余仓位无保护且系统状态误报“安全”。
|
||||||
|
|
||||||
|
**修复代码**:
|
||||||
|
```python
|
||||||
|
# backend/position_sync.py (check_tp1_triggers 内)
|
||||||
|
ok, sl_resp = await rehang_sl(session, symbol, lp["direction"], new_sl, bp["amount"])
|
||||||
|
if not ok:
|
||||||
|
logger.error(f"[{symbol}] ❌ TP1后重挂SL失败: {sl_resp}")
|
||||||
|
_log_event(conn, "critical", "trade",
|
||||||
|
"TP1后重挂SL失败,需人工确认/应急平仓", symbol,
|
||||||
|
{"trade_id": lp["id"], "sl_resp": str(sl_resp)})
|
||||||
|
continue
|
||||||
|
|
||||||
|
tp2_data, tp2_status = await binance_request(session, "POST", "/fapi/v1/order", {
|
||||||
|
"symbol": symbol,
|
||||||
|
"side": close_side,
|
||||||
|
"type": "TAKE_PROFIT_MARKET",
|
||||||
|
"stopPrice": price_str,
|
||||||
|
"quantity": qty_str,
|
||||||
|
"reduceOnly": "true",
|
||||||
|
})
|
||||||
|
if tp2_status != 200:
|
||||||
|
logger.error(f"[{symbol}] ❌ TP2重挂失败: {tp2_data}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE live_trades SET tp1_hit=TRUE, sl_price=%s, status='tp1_hit'
|
||||||
|
WHERE id=%s
|
||||||
|
""", (new_sl, lp["id"]))
|
||||||
|
conn.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P0-5 超时自动平仓路径仍未做数量精度格式化
|
||||||
|
- **文件**:`backend/risk_guard.py:284`
|
||||||
|
- **问题**:`"quantity": str(amt)` 可能触发币安精度拒单。
|
||||||
|
- **风险**:超时强平本应兜底,实际可能静默失败。
|
||||||
|
|
||||||
|
**修复代码**:
|
||||||
|
```python
|
||||||
|
# backend/risk_guard.py (check_hold_timeout 内)
|
||||||
|
qty_prec = SYMBOL_QTY_PRECISION.get(symbol, 3)
|
||||||
|
qty_str = f"{amt:.{qty_prec}f}"
|
||||||
|
close_data, close_status = await binance_request(session, "POST", "/fapi/v1/order", {
|
||||||
|
"symbol": symbol,
|
||||||
|
"side": close_side,
|
||||||
|
"type": "MARKET",
|
||||||
|
"quantity": qty_str,
|
||||||
|
"reduceOnly": "true",
|
||||||
|
})
|
||||||
|
if close_status != 200:
|
||||||
|
logger.error(f"[{symbol}] ❌ 自动平仓失败: {close_data}")
|
||||||
|
else:
|
||||||
|
logger.info(f"[{symbol}] 🔴 自动平仓完成 qty={qty_str}")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P0-6 同币种仅按“同策略”去重,双策略并发时可能踩仓
|
||||||
|
- **文件**:`backend/live_executor.py:358`
|
||||||
|
- **问题**:检查条件是 `symbol + strategy`,而交易所实际是同一净仓(单向模式)。
|
||||||
|
- **风险**:V5.1 与 V5.2 可同时在同币开仓,导致本地两条 trade 共享一个交易所仓位,SL/TP/对账失真。
|
||||||
|
|
||||||
|
**修复代码**:
|
||||||
|
```python
|
||||||
|
# backend/live_executor.py
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, strategy, direction
|
||||||
|
FROM live_trades
|
||||||
|
WHERE symbol=%s AND status IN ('active', 'tp1_hit')
|
||||||
|
LIMIT 1
|
||||||
|
""", (symbol,))
|
||||||
|
existing = cur.fetchone()
|
||||||
|
if existing:
|
||||||
|
logger.warning(f"[{symbol}] ❌ 已有活跃仓位(策略隔离不足),拒绝开新仓")
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
**建议配套 DB 约束**:
|
||||||
|
```sql
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_live_active_symbol
|
||||||
|
ON live_trades(symbol)
|
||||||
|
WHERE status IN ('active', 'tp1_hit');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、P1 — 逻辑正确性(上线前应修)
|
||||||
|
|
||||||
|
### P1-1 手续费窗口条件仍写反(修复未生效)
|
||||||
|
- **文件**:`backend/position_sync.py:439`
|
||||||
|
- **问题**:注释写“开仓后200ms起算”,实际代码是 `entry_ts - 200`。
|
||||||
|
- **影响**:会纳入开仓前成交手续费,净值偏低。
|
||||||
|
|
||||||
|
**修复代码**:
|
||||||
|
```python
|
||||||
|
# 原: if t_time >= entry_ts - 200:
|
||||||
|
if t_time >= entry_ts + 200:
|
||||||
|
actual_fee_usdt += abs(float(t.get("commission", 0)))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P1-2 实盘未实现 USDT 换算仍硬编码 `*2`
|
||||||
|
- **文件**:`backend/main.py:1281`
|
||||||
|
- **问题**:与 live_config 的 `risk_per_trade_usd` 动态值不一致。
|
||||||
|
- **影响**:持仓页美元盈亏展示失真。
|
||||||
|
|
||||||
|
**修复代码**:
|
||||||
|
```python
|
||||||
|
risk_usd = await _get_risk_usd()
|
||||||
|
...
|
||||||
|
d["unrealized_pnl_usdt"] = round(d["unrealized_pnl_r"] * risk_usd, 2)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P1-3 前端持仓盈亏 USDT 仍硬编码 `*2`
|
||||||
|
- **文件**:`frontend/app/live/page.tsx:287`
|
||||||
|
- **问题**:与后端配置不一致。
|
||||||
|
- **影响**:交易员在盘中看到的风险金额错误。
|
||||||
|
|
||||||
|
**修复代码**:
|
||||||
|
```tsx
|
||||||
|
const [riskUsd, setRiskUsd] = useState(2);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const f = async () => {
|
||||||
|
try {
|
||||||
|
const r = await authFetch("/api/live/config");
|
||||||
|
if (r.ok) {
|
||||||
|
const cfg = await r.json();
|
||||||
|
setRiskUsd(parseFloat(cfg?.risk_per_trade_usd?.value ?? "2"));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
f();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unrealUsdt = unrealR * riskUsd;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P1-4 平仓成交兜底逻辑可能误取开仓成交
|
||||||
|
- **文件**:`backend/position_sync.py:433`
|
||||||
|
- **问题**:`close_trades` 为空时,fallback 到 `trades_data[-1]`,可能不是平仓单。
|
||||||
|
- **影响**:exit_price 偏差,pnl_r 分类错误(tp/sl/closed)。
|
||||||
|
|
||||||
|
**修复代码**:
|
||||||
|
```python
|
||||||
|
if close_trades:
|
||||||
|
total_qty = sum(float(t["qty"]) for t in close_trades)
|
||||||
|
if total_qty > 0:
|
||||||
|
exit_price = sum(float(t["price"]) * float(t["qty"]) for t in close_trades) / total_qty
|
||||||
|
else:
|
||||||
|
logger.warning(f"[{symbol}] 未找到明确平仓成交,延后本轮结算")
|
||||||
|
continue
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、P2 — 健壮性(建议修复)
|
||||||
|
|
||||||
|
### P2-1 LISTEN 连接无重连,通知链路可静默退化
|
||||||
|
- **文件**:`backend/live_executor.py:611`、`backend/live_executor.py:636`
|
||||||
|
- **问题**:仅 `work_conn` 有重连;`listen_conn` 断开后不会重建。
|
||||||
|
- **影响**:系统退化为轮询,信号延迟上升且不可观测。
|
||||||
|
|
||||||
|
**修复代码**:
|
||||||
|
```python
|
||||||
|
def ensure_listen_conn(conn):
|
||||||
|
try:
|
||||||
|
conn.poll()
|
||||||
|
return conn
|
||||||
|
except Exception:
|
||||||
|
logger.warning("LISTEN连接断开,重建中...")
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
c = get_db_connection()
|
||||||
|
cur = c.cursor()
|
||||||
|
cur.execute("LISTEN new_signal;")
|
||||||
|
return c
|
||||||
|
|
||||||
|
# 主循环内
|
||||||
|
listen_conn = ensure_listen_conn(listen_conn)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P2-2 余额风控仅“触发阻断”无“恢复解锁”
|
||||||
|
- **文件**:`backend/risk_guard.py:541`
|
||||||
|
- **问题**:余额低时会置 `block_new_entries=True`,余额恢复后不会自动清除。
|
||||||
|
- **影响**:系统可长期停摆(假阳性熔断残留)。
|
||||||
|
|
||||||
|
**修复代码**:
|
||||||
|
```python
|
||||||
|
threshold = RISK_PER_TRADE_USD * MIN_BALANCE_MULTIPLE
|
||||||
|
if balance < threshold:
|
||||||
|
if risk_state.circuit_break_reason != "LOW_BALANCE":
|
||||||
|
risk_state.status = "warning"
|
||||||
|
risk_state.block_new_entries = True
|
||||||
|
risk_state.circuit_break_reason = "LOW_BALANCE"
|
||||||
|
else:
|
||||||
|
if risk_state.circuit_break_reason == "LOW_BALANCE":
|
||||||
|
risk_state.block_new_entries = False
|
||||||
|
risk_state.status = "normal"
|
||||||
|
risk_state.circuit_break_reason = None
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P2-3 asyncio 事件循环内大量同步 DB 调用
|
||||||
|
- **文件**:`backend/live_executor.py:645`、`backend/position_sync.py:634`
|
||||||
|
- **问题**:`psycopg2` 同步查询在 async 主循环直接执行。
|
||||||
|
- **影响**:高负载时任务抖动,影响信号时效与风控响应。
|
||||||
|
|
||||||
|
**修复建议代码(渐进式)**:
|
||||||
|
```python
|
||||||
|
signals = await asyncio.to_thread(fetch_pending_signals, work_conn)
|
||||||
|
```
|
||||||
|
|
||||||
|
长期建议:实盘三进程统一迁移 `asyncpg` 或线程池封装 DB 访问。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、P3 — 代码质量与一致性
|
||||||
|
|
||||||
|
### P3-1 dashboard 页面仍走旧 token 与旧 API
|
||||||
|
- **文件**:`frontend/app/dashboard/page.tsx:21`、`:23`、`:31`
|
||||||
|
- **问题**:使用 `arb_token` + `/api/user/*`,而当前 auth 体系是 `access_token` + `/api/auth/*`。
|
||||||
|
- **影响**:该页功能不可用,且认证实现分叉。
|
||||||
|
|
||||||
|
**修复代码**:
|
||||||
|
```tsx
|
||||||
|
import { authFetch } from "@/lib/auth";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
authFetch("/api/auth/me")
|
||||||
|
.then(r => (r.ok ? r.json() : Promise.reject()))
|
||||||
|
.then(d => setUser(d))
|
||||||
|
.catch(() => router.push("/login"));
|
||||||
|
}, [router]);
|
||||||
|
```
|
||||||
|
|
||||||
|
如果暂不支持 Discord 绑定接口,建议临时隐藏按钮或补齐后端路由,避免前端死链。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、可上实盘结论
|
||||||
|
|
||||||
|
**是否可以上实盘:不可以。**
|
||||||
|
|
||||||
|
阻断上线原因:存在 P0 级风险(权限伪造、密钥管理、关键平仓路径安全性与状态一致性问题)。
|
||||||
|
|
||||||
|
建议顺序:
|
||||||
|
1. 先修全部 P0 并做单元/集成回归。
|
||||||
|
2. 再修 P1 的口径一致性,确保运营面板与实际风险一致。
|
||||||
|
3. 最后补 P2/P3,提升长期稳定性与维护性。
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user