订单跟踪严重 Bug 分析1-3
订单部分成交 Bug — 根因分析与 WS 优先修复方案
测试网实战复盘,2026-02-22 22:30–22:40,ICP 限价单场景
一、现象还原
告警序列
| 时间 | 告警 | 说明 |
|---|---|---|
| 22:30:00 | 📤 限价单已挂出: ICP |
订单 48939226203,142.24 ICP @ 2.1078 |
| ~22:31:00 | 🎴 真孤儿仓位收纳: ICP/USDC:USDC |
来源: sync,大小: 12.19,数据库无记录 |
| ~22:40:00 | ❌ 限价单timeout: ICP |
错误: timeout,订单 ID: 48939226203 |
| ~22:40:00 | 🚨 开仓失败: ICP/USDC:USDC|APE/USDC:USDC |
订单状态: resting,Leg A 状态: resting |
交易所实际成交记录
22:30:03 ICP Open Long 12.19 ICP
22:33:00 ICP Open Long 19.89 ICP
22:33:11 ICP Open Long 8.11 ICP
22:33:15 ICP Open Long 7.88 ICP
22:33:51 ICP Open Long 8.36 ICP
22:35:59 ICP Open Long 7.92 ICP
22:35:59 ICP Open Long 7.74 ICP
─────────────────────────────────
合计成交:72.09 ICP(成交率 50.7%)
22:40:03 订单 Canceled,剩余 70.15 取消
矛盾:72.09 ICP 实际成交,系统却报「开仓失败」且产生「孤儿仓位」告警。
二、完整因果链
Bug A(次要)— 孤儿仓位误报
22:30:03 交易所出现 12.19 ICP 仓位(第一笔部分成交)
系统状态:
exchange_positions["ICP"] = {szi: 12.19}
ws_order_manager._tracking = {48939226203: OrderTracking(PENDING)}
position_manager._positions = {} ← 内存为空(订单未完成)
~22:31:00 sync_with_exchange() 定时触发:
memory_coins = {}
_collect_orphan_candidates(exchange_coins, memory_coins)
coin="ICP" 不在 memory_coins
❌ 未检查 ws_order_manager._tracking 是否有 ICP 的 PENDING 订单
candidates["ICP"] = {szi:12.19} ← 误判为孤儿
根因:PositionManager 不感知 WebSocketOrderManager 的追踪状态
Bug B(主要)— 开仓失败误判
22:40:00 timeout 到期(600s)
_resolve(oid, tracking, TIMEOUT)
tracking.status = TIMEOUT
tracking.result_event.set()
_tracking.pop(oid) ← 字典移除,本地引用仍有效
_track_limit_order():
wait_for_order() 返回 False
❌ tracking.filled_size = 72.09 被完全忽略
return False
limit_open():
_cancel_order(ICP, 48939226203)
_check_order_after_cancel(ICP, oid, "buy") ← 旧方案
HTTP 响应: {"sz": "70.15", "status": "canceled"}
↑ sz = 剩余量,totalSz 字段不存在!
total_sz = 0.0
❌ return 0.0, 0.0 ← try 块内提前返回,降级路径被绕过
actual_filled = 0.0 → 🚨 开仓失败
根因(三层叠加):
1. Hyperliquid HTTP:已撤销订单响应中 sz=剩余量,totalSz 字段缺失
2. _check_order_after_cancel:totalSz=0 时在 try 内 return,绕过第 801 行降级
3. tracking.filled_size=72.09 完整存在,却从未被读取
三、WS 数据完整性确认
userFills 在追踪期间完整累积
600s 等待窗口内,每笔成交经 userFills → OrderFilledEvent → _accumulate_fill() 写入 tracking:
TIMEOUT 时 tracking 的状态:
| 字段 | 值 | 来源 |
|---|---|---|
tracking.status |
TIMEOUT |
_resolve() 设置 |
tracking.filled_size |
72.09 ICP | userFills WS 累积 ✓ |
tracking.avg_price |
实际加权均价 | userFills WS 累积 ✓ |
tracking.fill_count |
7 | userFills 笔数 ✓ |
_resolve(..., TIMEOUT) 只设置 tracking.status = TIMEOUT,不清空 filled_size/avg_price。
数据完整,但 _track_limit_order 返回 False 时将其完全丢弃。
三个 WS 频道的角色
| 频道 | 修复中的角色 |
|---|---|
userFills |
主数据源:TIMEOUT 时直接读取 tracking.filled_size 和加权均价 |
webData2 |
对账数据源:_get_actual_position_size() 优先读取 webData2 缓存,无需 HTTP |
orderUpdates |
不变:提供订单生命周期终态 |
四、修复方案
核心思路
放弃 _check_order_after_cancel,彻底删除(仅有一处调用),改为:
TIMEOUT 后新数据流:
tracking.filled_size = 72.09 ← userFills WS 累积(主路径)
↓ Fix 1 写回 order_result
_cancel_order()
↓
_get_actual_position_size() ← webData2 缓存/HTTP(补捡窗口漏报)
↓
actual_filled = max(ws, pos) ✓ 正确
TIMEOUT→cancel 窗口说明:_tracking.pop(oid) 之后、_cancel_order() 之前,极短窗口内可能有新成交未被 tracking 捕获。_get_actual_position_size(force_refresh=True) 补捡这部分。
Fix 1 — src/trading/executor.py · _track_limit_order
位置:约第 606–615 行,非成交路径末尾
改动:发通知后,将 WS 累积数据写回 order_result,无论有无成交都写(0.0 = 无成交,消除调用方歧义)。
# ── 修改后 ──
reason = tracking.status.value
# 通知:使用原始挂单量/限价(语义:这是"哪个订单"的状态通知)
self._notify_limit_order(
f"❌ 限价单{reason}: {coin}", coin,
is_buy=(order_result.side == "buy"),
size=order_result.size, price=order_result.price,
order_id=oid, error=reason,
)
# 将 WS 累积数据写回,供 limit_open 读取
# 0.0 表示无成交;调用方不应再读取原始挂单量
order_result.size = tracking.filled_size
order_result.price = tracking.avg_price
if tracking.filled_size > 0:
logger.info(
f"限价单{reason},WS 部分成交: {coin} oid={oid} | "
f"ws_filled={tracking.filled_size:.4f} avg_px={tracking.avg_price:.4f} "
f"fills={tracking.fill_count}"
)
else:
logger.warning(f"限价单未成交(无 WS 成交记录): {coin} oid={oid} | {reason}")
return False
Fix 2 — src/trading/executor.py · limit_open + 删除 _check_order_after_cancel
删除:_check_order_after_cancel 整个函数(第 767–802 行)。
位置:约第 914–927 行,TIMEOUT 处理段
# ── 修改后 ──
if not leg_a_filled:
ws_filled = result.leg_a.size # Fix 1 写入:WS 累积成交量(0.0 = 无成交)
ws_avg_px = result.leg_a.price # Fix 1 写入:WS 加权均价(0.0 = 无均价)
if result.leg_a.order_id:
cancel_ok = self._cancel_order(alt_coin, result.leg_a.order_id)
if not cancel_ok:
logger.warning(f"撤单失败(可能已成交): {alt_coin} oid={result.leg_a.order_id}")
alt_side = "buy" if alt_is_buy else "sell"
# position 核查:补捡 _tracking.pop→cancel 窗口内的漏网成交
pos_filled = self._get_actual_position_size(
alt_coin, expected_side=alt_side, force_refresh=True
)
if pos_filled > ws_filled:
actual_filled = pos_filled
fill_px = 0.0
logger.info(
f"position 发现更多成交(WS 漏报): {alt_coin} | "
f"ws={ws_filled:.4f} pos={pos_filled:.4f}"
)
elif ws_filled > 0:
actual_filled = ws_filled
fill_px = ws_avg_px
logger.info(
f"使用 WS 累积成交: {alt_coin} | "
f"filled={actual_filled:.4f} avg_px={fill_px:.4f}"
)
else:
actual_filled = 0.0
fill_px = 0.0
# 以下 actual_filled 处理逻辑不变 ↓
Fix 3 — src/trading/websocket_order_manager.py
在公开接口区(cancel_all_open_orders 与 shutdown 之间)新增:
def get_pending_coins(self) -> set[str]:
"""返回当前有 PENDING 限价单追踪的币种集合(供孤儿检测排除用)"""
with self._lock:
return {t.coin for t in self._tracking.values()
if t.status == OrderStatus.PENDING}
Fix 4 — src/trading/position_manager.py
4a. _collect_orphan_candidates 签名(第 1485 行)
def _collect_orphan_candidates(
self,
exchange_coins: dict,
memory_coins: set[str],
pending_order_coins: set[str] | None = None,
) -> dict:
"""筛选交易所有持仓但内存中没有的币种(纯内存操作)"""
candidates = {}
for coin, ex_pos in exchange_coins.items():
if coin in memory_coins:
continue
if pending_order_coins and coin in pending_order_coins:
logger.debug(f"孤儿检测跳过 {coin}:存在活跃限价单追踪")
continue
if self._config.is_close_disabled(coin_to_symbol(coin)):
continue
szi = float(ex_pos.get("szi", 0))
if szi == 0:
continue
candidates[coin] = ex_pos
return candidates
4b. 调用点 1 — recover_positions_from_db(with self._lock: 第 1047 行之前)
# 锁外:获取待追踪币种(不可在 self._lock 内调用,避免锁竞争)
_ws_mgr = getattr(self._executor, "_ws_order_manager", None)
pending_order_coins = _ws_mgr.get_pending_coins() if _ws_mgr else set()
with self._lock:
...
orphan_candidates = self._collect_orphan_candidates(
exchange_coins, memory_coins, pending_order_coins
)
4c. 调用点 2 — sync_with_exchange(with self._lock: 第 1147 行之前,"锁外:获取交易所数据" 段末尾)
# 锁外:获取待追踪币种
_ws_mgr = getattr(self._executor, "_ws_order_manager", None)
pending_order_coins = _ws_mgr.get_pending_coins() if _ws_mgr else set()
# ── 锁内:纯内存对账 ──
with self._lock:
...
orphan_candidates = self._collect_orphan_candidates(
exchange_coins, memory_coins, pending_order_coins
)
五、修改文件清单
| 文件 | 操作 | 位置 | 说明 |
|---|---|---|---|
src/trading/executor.py |
删除 | 第 767–802 行 | 整体删除 _check_order_after_cancel 函数 |
src/trading/executor.py |
替换 | 第 606–615 行 | _track_limit_order 非成交路径:通知后写回 WS 数据 |
src/trading/executor.py |
替换 | 第 914–927 行 | limit_open TIMEOUT 段:WS+position 对账替换旧调用 |
src/trading/websocket_order_manager.py |
新增 | 约第 200 行 | 新增 get_pending_coins() 方法(5 行) |
src/trading/position_manager.py |
替换 | 第 1485–1502 行 | _collect_orphan_candidates 增加参数 |
src/trading/position_manager.py |
替换 | 第 1047 行前 | recover_positions_from_db 调用点 |
src/trading/position_manager.py |
替换 | 第 1147 行前 | sync_with_exchange 调用点 |
六、边界情况
| 场景 | 处理结果 |
|---|---|
| TIMEOUT 无 WS 成交(filled_size=0) | Fix 1 写 0.0 → ws_filled=0 → 仅靠 pos 查询 → 两者为 0 → 走无成交路径 ✅ |
| TIMEOUT→cancel 窗口有新成交 | pos_filled > ws_filled 分支捡回 ✅ |
| 撤单失败(订单已全部成交) | pos_filled = 全量,取 pos_filled ✅ |
_get_actual_position_size 失败返回 0 |
ws_filled > 0 时用 WS 数据兜底 ✅ |
| CANCELED(WS 发起)有部分成交 | Fix 1 同样写回 WS 数据,Fix 2 同路径处理 ✅ |
| REJECTED | tracking.filled_size=0,Fix 1 写 0.0,无成交路径 ✅ |
_ws_order_manager 未注入 |
pending_order_coins=set(),行为与修改前一致 ✅ |
七、验证方法
Bug B 验证
- 临时将
limit_order_timeout调低为 30s - 测试网下限价单(size ≥ $10),等待至少 1 笔部分成交后超时
- 观察日志:
- ✅
限价单timeout,WS 部分成交: ICP ws_filled=xxx - ✅
使用 WS 累积成交: ICP filled=xxx avg_px=xxx - ❌ 不出现
🚨 开仓失败
- ✅
Bug A 验证
- 下限价单,在追踪期间等待部分成交
- 观察
sync_with_exchange日志:- ✅
孤儿检测跳过 ICP:存在活跃限价单追踪 - ❌ 不出现
🎴 真孤儿仓位收纳: ICP
- ✅
回归验证
| 场景 | 预期 |
|---|---|
| 限价单正常全量成交 | Fix 1 不执行(走 FILLED 分支),行为不变 |
| 真孤儿仓位(无追踪订单) | 仍正确收纳,pending_order_coins 不含该币种 |
| 微仓位回滚路径 | actual_filled 来源更精确,逻辑不变 |