订单跟踪严重 Bug 分析1-2
订单部分成交 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_from_exchange() 定时触发:
memory_coins = {}
_collect_orphan_candidates(exchange_coins, memory_coins={})
for coin="ICP":
"ICP" in memory_coins → False
❌ 没检查 ws_order_manager._tracking 是否有 ICP 的 PENDING 订单
candidates["ICP"] = {szi:12.19} ← 误判为孤儿
_detect_and_adopt_orphans(candidates)
→ _positions[("ICP/USDC:USDC","")] = PairPosition(12.19)
→ 写 DB + 发告警
根因:PositionManager 不感知 WebSocketOrderManager 的追踪状态
Bug B(主要)— 开仓失败误判
22:40:00 timeout 到期(600s)
_monitor_order_inner()
HTTP 查询 → 订单状态 "open"(仍在挂)
_resolve(oid, tracking, TIMEOUT)
tracking.status = TIMEOUT
tracking.result_event.set() ← 解除阻塞
_tracking.pop(oid) ← 从字典移除(引用仍有效)
_track_limit_order():
wait_for_order(tracking) 返回 False
reason = tracking.status.value → "timeout"
❌ 不访问 tracking.filled_size / tracking.avg_price
return False
limit_open():
leg_a_filled = False
_cancel_order(ICP, 48939226203) ← 撤单,剩余 70.15 取消
_check_order_after_cancel(ICP, oid, "buy")
HTTP query_order_status() 返回:
resp = {"order":{sz:"70.15",...}, "status":"canceled", ...}
total_sz = resp.get("totalSz") → None(字段不存在!)
= 0.0
avg_px = 0.0
if total_sz > 0: ... → False
if total_sz > 0: ... → False
❌ return 0.0, 0.0 ← try 块内提前返回,降级路径被绕过
actual_filled = 0.0
result.leg_a.success = False
发告警:🚨 开仓失败
根因:
1. Hyperliquid HTTP 已撤销订单响应不含 totalSz(sz = 剩余量,非成交量)
2. 代码将 totalSz=0 等同于"无成交",在 try 块内 return,绕过降级路径
3. tracking.filled_size 里已有 72.09 ICP 的累积成交量,但被完全抛弃
三、WS 数据流完整性分析
userFills 已在追踪期间完整累积部分成交
在整个 600s 等待窗口内,每笔成交都通过 userFills → OrderFilledEvent → _accumulate_fill() 写入 tracking:
# websocket_order_manager.py
def _on_order_filled_event(self, event: OrderFilledEvent):
tracking = self._tracking.get(oid)
if tracking and tracking.status == OrderStatus.PENDING:
self._accumulate_fill(tracking, event.filled_price, event.filled_qty)
tracking.has_fill_price = True
tracking.fill_count += 1
# → tracking.filled_size 累加为 72.09 ICP
# → tracking.avg_price 加权平均为实际成交均价
TIMEOUT 时 tracking 的状态:
| 字段 | 值 | 来源 |
|---|---|---|
tracking.status |
TIMEOUT |
_resolve() 设置 |
tracking.filled_size |
72.09 ICP | userFills WS 累积 ✓ |
tracking.avg_price |
实际加权均价 | userFills WS 累积 ✓ |
tracking.fill_count |
7 | userFills 笔数 ✓ |
tracking.has_fill_price |
True | userFills 标记 ✓ |
_resolve(..., TIMEOUT) 执行时,只设置 tracking.status = TIMEOUT,不清空 filled_size 和 avg_price。数据是完整的,但无人使用。
数据在何处被丢弃
_on_order_filled_event × 7 次
→ tracking.filled_size = 72.09 ✓ 累积完整
_resolve(..., TIMEOUT)
→ tracking.status = TIMEOUT
→ _tracking.pop(oid) ← 字典引用删除,本地引用仍有效
→ tracking.filled_size 保留不变 ✓
wait_for_order() 返回 False
_track_limit_order(): ❌ 数据丢弃点
reason = tracking.status.value
return False
# tracking 局部变量超出作用域
# tracking.filled_size = 72.09 永远不被读取
limit_open():
leg_a_filled = False
→ 调用 HTTP(二次查询,且 totalSz 字段不存在)
→ actual_filled = 0.0 ❌ 误判无成交
三个 WS 频道在修复中的角色
| 频道 | 当前作用 | 修复中的作用 |
|---|---|---|
userFills |
累积部分成交到 tracking.filled_size | 主数据源:TIMEOUT 时直接读取已累积的成交量和均价 |
orderUpdates |
检测 filled/canceled 终态 | 不变:提供订单生命周期状态 |
webData2 |
推送持仓快照到 executor 缓存 | 兜底数据源:通过 executor 缓存的 _cached_positions 给 _get_actual_position_size() 提供无 HTTP 的仓位查询 |
核心洞察:Bug 的本质是 tracking.filled_size(已由 userFills 正确累积)在 TIMEOUT 路径上被丢弃,系统转而依赖返回格式不一致的 HTTP API,最终误判。
四、WS 优先修复方案(推荐)
方案概述
放弃依赖 HTTP totalSz 字段,改为:
- 主路径:从
tracking.filled_size读取 WS 已累积的成交量(准确、有均价) - 验证路径:调用
_get_actual_position_size()(使用webData2缓存或 HTTP 降级)对账,取最大值 - 完全跳过
_check_order_after_cancel()的 HTTP 订单状态查询(或仅保留为最终异常兜底)
TIMEOUT 后新数据流:
tracking.filled_size = 72.09 (userFills WS 累积,主路径)
↓
_cancel_order()
↓
_get_actual_position_size() (webData2 缓存/HTTP,验证捡漏)
= max(tracking.filled_size, actual_pos)
↓
actual_filled = 72.09 ICP ✓ 正确
为什么 position 查询作为验证而非主路径:
- TIMEOUT 到 cancel 之间有一个小窗口,期间可能有新成交
- 这些成交在
_tracking.pop()之后到达,不被tracking.filled_size捕获 - position 查询是地面真相,可以捡回这个窗口内的成交
Fix 1 — src/trading/executor.py(主要修改)
修改 _track_limit_order:TIMEOUT 时将累积的 WS fill 数据写回 order_result
位置:第 563–615 行,TIMEOUT 处理分支(约第 606–615 行)
# 修改前(行 606-615)
reason = tracking.status.value
logger.warning(f"限价单未成交: {coin} oid={oid} | {reason}")
# ... 通知
return False
# 修改后
reason = tracking.status.value
if tracking.status == OrderStatus.TIMEOUT and tracking.filled_size > 0:
# WS 已累积部分成交:写回 order_result,供 limit_open 后续使用
# (cancel 后 position 查询会进一步对账捡漏)
order_result.size = tracking.filled_size
order_result.price = tracking.avg_price
logger.info(
f"限价单超时但有 WS 部分成交记录: {coin} oid={oid} | "
f"ws_filled={tracking.filled_size} ws_avg_px={tracking.avg_price} "
f"fills={tracking.fill_count}"
)
else:
logger.warning(f"限价单未成交: {coin} oid={oid} | {reason}")
# ... 通知
return False
修改 limit_open:TIMEOUT 后用 position 查询验证并取最大值,跳过 HTTP 订单查询
位置:约第 914–951 行,TIMEOUT 处理段
# 修改前
if not leg_a_filled:
if result.leg_a.order_id:
cancel_ok = self._cancel_order(alt_coin, result.leg_a.order_id)
actual_filled, fill_px = self._check_order_after_cancel(
alt_coin, result.leg_a.order_id, alt_side
)
# ... actual_filled 来自 HTTP,可能为 0
# 修改后
if not leg_a_filled:
ws_filled = result.leg_a.size # _track_limit_order 已写入 WS 累积量(可能为 0)
ws_avg_px = result.leg_a.price # WS 累积均价
if result.leg_a.order_id:
self._cancel_order(alt_coin, result.leg_a.order_id)
# 用 position 查询对账(捡回 TIMEOUT→cancel 窗口内的漏网成交)
pos_filled = self._get_actual_position_size(
alt_coin, expected_side=alt_side, force_refresh=True
)
# 取最大值:WS 数据更精确(有均价),position 数据更完整(捡漏)
if pos_filled > ws_filled:
actual_filled = pos_filled
fill_px = 0.0 # position 查询无成交价,由后续 fallback_px 补
logger.info(
f"position 查询发现更多成交(WS 漏报): {alt_coin} | "
f"ws={ws_filled} pos={pos_filled}"
)
elif ws_filled > 0:
actual_filled = ws_filled
fill_px = ws_avg_px
logger.info(
f"使用 WS 累积成交: {alt_coin} | "
f"filled={actual_filled} avg_px={fill_px}"
)
else:
actual_filled = 0.0
fill_px = 0.0
# ... 后续 actual_filled > 0 / == 0 的处理逻辑不变
同时,_check_order_after_cancel 保持现有逻辑不变,不再被主流程调用,但保留作为其他场景(非 TIMEOUT 超时)的兜底。
Fix 2 — src/trading/websocket_order_manager.py(次要,Bug A 修复)
新增 get_pending_coins() 方法:
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 3 — src/trading/position_manager.py(次要,Bug A 修复)
修改点 A:_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
# 跳过有活跃 PENDING 限价单的币种,防止部分成交被误判为孤儿
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
修改点 B:两处调用点(第 1070、1230 行)
_ws_mgr = getattr(self._executor, "_ws_order_manager", None)
pending_order_coins = _ws_mgr.get_pending_coins() if _ws_mgr else set()
orphan_candidates = self._collect_orphan_candidates(
exchange_coins, memory_coins, pending_order_coins
)
五、修改文件清单
| 文件 | 函数 | 行号(约) | 改动 |
|---|---|---|---|
src/trading/executor.py |
_track_limit_order |
~606–615 | TIMEOUT 时写回 WS fill 数据到 order_result |
src/trading/executor.py |
limit_open |
~914–951 | 替换 HTTP 订单查询为 WS fill + position 对账 |
src/trading/websocket_order_manager.py |
新增 get_pending_coins |
类末尾 | 新增 5 行方法 |
src/trading/position_manager.py |
_collect_orphan_candidates |
1485–1502 | 新增参数 + 跳过有追踪订单的币种 |
src/trading/position_manager.py |
recover_from_exchange 调用点 |
~1070 | 传入 pending_order_coins |
src/trading/position_manager.py |
sync_from_exchange 调用点 |
~1230 | 传入 pending_order_coins |
六、两种修复方案对比
| 维度 | 原方案(Fix 1 HTTP fallthrough) | 本方案(WS 优先) |
|---|---|---|
| Bug B 主路径 | 修复 try 块 return → 降级到 position 查询 | 直接读 tracking.filled_size(WS 已累积) |
| 成交均价来源 | HTTP totalSz/avgPx(通常不含均价) | WS userFills 加权平均(精确) |
| HTTP 依赖 | 仍依赖(改为 fallthrough) | 仅 position 查询一次(更少 HTTP) |
| TIMEOUT→cancel 窗口 | position 查询兜底 | position 查询兜底(相同) |
| 代码改动范围 | 仅 _check_order_after_cancel | _track_limit_order + limit_open |
| Bug A 修复 | 相同(Fix 2/3) | 相同(Fix 2/3) |
| 数据准确性 | 中(HTTP 响应格式不稳定) | 高(WS 实时累积,均价精确) |
推荐采用本方案(WS 优先),原因:
- 根本上解决了依赖 HTTP
totalSz字段的问题 - 利用已有的 WS 数据(
tracking.filled_size在 TIMEOUT 时已有值),改动逻辑直观 - 成交均价更精确(来自真实成交,非挂单价)
- 减少 HTTP 查询次数(只查 position,不查 order 状态)
七、验证方法
Fix 1+2 验证(主要 Bug B)
- 测试网下限价单,将
limit_order_timeout临时调低为 30s - 等待至少 1 笔部分成交,让
tracking.filled_size累积非零 - 等待超时后观察日志:
- 应出现:
限价单超时但有 WS 部分成交记录: ICP ws_filled=xxx - 应出现:
使用 WS 累积成交: ICP filled=xxx avg_px=xxx - 不应出现:
🚨 开仓失败
- 应出现:
- 确认数据库中 leg_a.size = WS 累积成交量,而非 0
Fix 3 验证(次要 Bug A)
- 下限价单等待部分成交(600s 窗口内)
- 观察
sync_from_exchange日志:- 应出现:
孤儿检测跳过 ICP:存在活跃限价单追踪 - 不应出现:
🎴 真孤儿仓位收纳: ICP
- 应出现:
回归验证
- 真孤儿仓位(无追踪订单)仍能被正确收纳
get_pending_coins()返回空集时,行为与修改前完全一致- 订单正常成交(FILLED)路径不受影响