订单跟踪系统严重 Bug 分析 — 完整因果链
订单跟踪系统严重 Bug 分析 — 完整因果链
本文档基于当前代码分析,建立「输入 → 状态变化 → 调用路径 → 出错点 → 根因」的因果链,聚焦订单跟踪(WebSocketOrderManager + EventBus + Executor)中的严重问题。
一、系统概览与正常因果链
1.1 输入来源
| 输入 | 来源 | 形态 |
|---|---|---|
| 限价单下单结果 | Executor 下单 API 响应 | OrderResult.order_id (int)、coin、timeout |
| 订单状态流 | 交易 WS orderUpdates |
→ OrderStatusEvent(order_id=str, status, price, size) |
| 成交流 | 交易 WS userFills |
→ OrderFilledEvent(order_id=str, filled_qty, filled_price, fill_id) |
| 补查/超时 | 监控线程 + 重连回调 | HTTP query_order_by_oid 响应 |
1.2 状态与调用路径(正常)
Executor._track_limit_order(order_result, coin, timeout)
→ order_result.order_id 存在且非 FILLED
→ WebSocketOrderManager.track_order(oid, coin, timeout)
状态: _tracking[oid] = OrderTracking(PENDING), 启动 _monitor_order 线程
→ WebSocketOrderManager.wait_for_order(tracking) 阻塞在 result_event.wait()
并行:
- EventBus: OrderStatusEvent → _on_order_status_event
- EventBus: OrderFilledEvent → _on_order_filled_event
- 监控线程: 2s 早期 HTTP → 等待剩余超时 → 超时后 HTTP 验证(两轮)
终态由唯一路径 _resolve(oid, tracking, status?, px?, sz?) 完成:
- 在 _lock 内做 identity 检查、设 status、填 avg_price/filled_size、pop、cancel grace timer
- 在 _lock 外 result_event.set() → wait_for_order 返回,调用方根据 tracking.status 判断 FILLED 与否
二、严重 Bug 因果链
Bug 1:WS 事件中 order_id 为浮点或非数字字符串时,订单永远不被 WS 解析
| 环节 | 内容 |
|---|---|
| 输入 | 交易所或中间层下发的 orderUpdates / userFills 中,oid 为浮点(如 1.0)或非数字字符串。 |
| 状态变化 | EnhancedWebSocketManager._publish_order_status_events / _publish_fill_events 用 order_id=str(oid) 发布;WebSocketOrderManager._on_order_status_event / _on_order_filled_event 用 _safe_int_oid(event.order_id) 解析。 |
| 调用路径 | WS 消息 → _publish_* → EventBus.publish(OrderStatusEvent/OrderFilledEvent) → _on_order_status_event / _on_order_filled_event → oid = _safe_int_oid(event.order_id)。 |
| 出错点 | _safe_int_oid("1.0") 或 _safe_int_oid("abc") 会 ValueError/TypeError,返回 None,后续 if oid is None: return,事件被静默丢弃。 |
| 根因 | 只接受可被 int() 直接转换的字符串(整数形式);未对浮点字符串(如 "1.0")或异常格式做兼容(如先 float 再 int,或容错回退)。 |
| 后果 | 该订单在 WS 侧永远不会被 resolve,只能依赖监控线程超时后的 HTTP 兜底;若 HTTP 也失败或延迟,wait_for_order 会拖到超时或两轮重试后才返回。 |
代码位置:
websocket_order_manager.py 第 55–62 行 _safe_int_oid;第 294–296、355–357 行两处 if oid is None: return。
Bug 2:orderUpdates 仅处理 filled/canceled/rejected,中间状态导致长期 PENDING 直至超时
| 环节 | 内容 |
|---|---|
| 输入 | Hyperliquid 推送的 orderUpdates 中 item.status / order.status 为未显式处理的中间或别名状态(如 "partial"、"open" 变体、或未来新增状态)。 |
| 状态变化 | _on_order_status_event 中仅对 "filled"、"canceled"/"margincanceled"、以及 "rejected" in status_str 做分支处理并可能设 _ws_status 或 resolve;其它 status 不写 _ws_status。 |
| 调用路径 | orderUpdates 消息 → _publish_order_status_events → OrderStatusEvent(status=status_str) → _on_order_status_event → 不进入任一已知分支。 |
| 出错点 | 订单的 _ws_status 始终为 None,WS 路径永远不会触发 resolve;监控线程只会在「早期 HTTP + 超时后两轮 HTTP」后按 TIMEOUT 或 HTTP 结果 resolve。 |
| 根因 | 状态机只对终态(filled/canceled/rejected)做映射,未定义「部分成交」或其它中间态的策略(例如视为仍 PENDING、或按交易所文档映射到某一终态)。 |
| 后果 | 订单在 WS 上已是部分成交或其它状态,但本地一直 PENDING,必须等到超时或 HTTP 查到终态才结束,延迟和用户感知差。 |
代码位置:
websocket_order_manager.py 第 309–344 行(仅 filled / canceled / rejected 分支);enhanced_ws_manager.py 第 1426–1430 行 status 来源。
Bug 3:重连补查二次重试对「已替换」的 tracking 调用 _resolve,导致旧 tracking 永远不 set(理论边界)
| 环节 | 内容 |
|---|---|
| 输入 | 交易 WS 重连 → _on_websocket_reconnected(source="trading")→ 启动线程执行 verify_pending_orders() 两次(中间 sleep WS_RECONNECT_VERIFY_DELAY)。 |
| 状态变化 | 第一次循环中,部分 oid 的 _http_check(oid) 返回 None(查询失败),被加入 retry_list;在 sleep 期间,同一 oid 被再次 track_order 替换,_tracking[oid] 指向新 tracking。 |
| 调用路径 | 第二次循环:for (oid, tracking) in retry_list: self._http_check(oid) is None → self._resolve(oid, tracking, OrderStatus.TIMEOUT)。此时 tracking 是第一次快照里的旧对象。 |
| 出错点 | _resolve(oid, old_tracking, TIMEOUT) 内 current = self._tracking.get(oid) 为新 tracking,current is not tracking → 返回 False,不会对 old_tracking 执行 result_event.set()。若旧 tracking 之前从未被 resolve(例如从未收到 WS、且第一次 HTTP 也失败),则旧 tracking 的 wait 永远阻塞。 |
| 根因 | verify_pending_orders 的 retry 阶段使用的是「第一次构建的 (oid, tracking) 快照」,未在二次重试前重新从 _tracking 取当前 tracking;在「同一 oid 在补查期间被重复 track」的边界下,会对着已替换掉的旧对象做 resolve。 |
| 后果 | 在「重复追踪 + 重连补查」叠加的罕见场景下,旧调用方的 wait_for_order(old_tracking) 可能永远不返回(若该 old 从未被 set)。当前实现中重复追踪时会在 track_order 里对 old 做 result_event.set(),因此多数情况下旧线程已返回;仅当替换发生在 verify_pending_orders 第一次失败到第二次重试之间、且旧 tracking 从未被 set 时才会暴露。 |
代码位置:
websocket_order_manager.py 第 134–161 行 verify_pending_orders(retry_list 与第二次 for 循环);第 231–235 行 _resolve 的 identity 检查。
Bug 4:HTTP 响应结构假设过强,嵌套变化会导致成交价/量为 0
| 环节 | 内容 |
|---|---|
| 输入 | Executor.query_order_status(oid) 返回的 dict 来自 raw["order"](Hyperliquid 内层);当前假设该内层为 {"status": "...", "order": { "limitPx", "origSz", "sz", ... }}。 |
| 状态变化 | WebSocketOrderManager._http_check → _parse_order_response(resp) 从 resp.get("order", {}) 取 limitPx/origSz/sz,计算 total_sz 和 avg_px;若 API 改为扁平或嵌套层级变化,inner 为空或字段不在预期位置。 |
| 调用路径 | 监控线程 / 重连补查 → _http_check(oid) → query_order_status(oid) → _parse_order_response(resp) → _resolve(oid, tracking, status, avg_px, total_sz)。 |
| 出错点 | 若 resp["order"] 缺失或结构变化,inner = {},则 avg_px=0、total_sz=0;对 FILLED 订单 resolve 时 tracking.filled_size 若原本为 0(未收齐 userFills),则成交价和成交量写回为 0,上游 OrderResult.price/size 错误。 |
| 根因 | 解析逻辑与「当前」Hyperliquid 响应格式强绑定,未做防御性解析或对缺失字段的明确降级(如从 resp 顶层再尝试取 limitPx/origSz/sz)。 |
| 后果 | 下游依赖 order_result.price / order_result.size 的仓位、风控、通知会得到错误数值;在 API 升级或不同账户/环境返回不同结构时易触发。 |
代码位置:
websocket_order_manager.py 第 536–572 行 _parse_order_response(尤其 550–556 行);executor.py 第 1744–1747 行 query_order_status 解包逻辑。
Bug 5:宽限期定时器使用 orderUpdates 的 size 作为 fallback,语义与文档不一致
| 环节 | 内容 |
|---|---|
| 输入 | orderUpdates 中 order.origSz / order.sz;文档写「order.sz / order.origSz → size(sz=当前剩余量,origSz=原始委托量)」;发布时 size=float(order.get("origSz") or order.get("sz") or 0)。 |
| 状态变化 | _on_order_status_event 在 status= filled 且 tracking.filled_size == 0 时启动 5s 宽限期 Timer,回调 _resolve(oid, tracking, None, event.price, event.size)。 |
| 调用路径 | OrderStatusEvent(price=limitPx, size=origSz or sz) → 宽限期到 → _resolve(..., px=event.price, sz=event.size);在 _resolve 中若 final_status==FILLED and tracking.filled_size==0,则用 px/sz 写 avg_price、filled_size。 |
| 出错点 | 对 filled 订单,WS 侧通常 sz=0、origSz=委托量,故 event.size 多为 origSz(总成交量),用作 fallback 合理;若某端实现或将来改为「仅推送 sz」且 filled 时未送 origSz,则 event.size 可能为 0 或剩余量,fallback filled_size 错误。 |
| 根因 | 依赖「orderUpdates 在 filled 时一定提供 origSz 或可推导的 size」的隐含约定,未在代码或文档中明确「宽限期 fallback 仅当 size 表示成交量时有效」,也未对 event.size<=0 做防护。 |
| 后果 | 在边缘实现或协议演变下,可能出现「WS 显示成交但本地 filled_size=0、仅用 limitPx 作价」或错误成交量,影响统计与风控。 |
代码位置:
websocket_order_manager.py 第 321–328 行(宽限期 Timer 与 resolve 参数);第 250–255 行(fallback 填充);enhanced_ws_manager.py 第 1436–1438 行 OrderStatusEvent 的 price/size 来源。
Bug 6:userFills 快照被整体跳过,重连后可能漏掉断连期间的成交
| 环节 | 内容 |
|---|---|
| 输入 | 交易 WS 重连后,Hyperliquid 可能先发 userFills 的 isSnapshot: true(历史成交列表),再发增量。 |
| 状态变化 | _publish_fill_events 中 if data.get("isSnapshot"): return,整条 snapshot 不发布任何 OrderFilledEvent。 |
| 调用路径 | 重连 → userFills 消息 → _publish_fill_events → isSnapshot 为 True → 直接 return,不发布事件。 |
| 出错点 | 若订单在断连期间已成交,重连后只有 snapshot 包含这笔成交、且无后续增量,则 WebSocketOrderManager 永远收不到该笔 fill;仅当 orderUpdates 也推送了 filled 时,会走宽限期 5s 后用 limitPx/origSz 作 fallback resolve,真实成交价依赖 HTTP 或 fallback。若 orderUpdates 也因重连未补发,则只能靠 verify_pending_orders 的 HTTP 补查拿到终态。 |
| 根因 | 为避免「连接建立时历史成交重复触发」而跳过 snapshot,但未区分「连接建立时全量快照」与「重连后含断连期间成交的快照」,导致重连后的 snapshot 也被跳过。 |
| 后果 | 断连期间成交的订单,可能缺少 userFills 的精确成交价,只能依赖 orderUpdates 的 fallback 或 HTTP;在极端情况下若两者都缺失或延迟,会拖到超时或补查才结束。 |
代码位置:
enhanced_ws_manager.py 第 1449–1450 行 if data or data.get("isSnapshot"): return;第 1442–1448 行注释说明 isSnapshot 用途。
三、因果链汇总表
| Bug | 输入 | 状态/路径 | 出错点 | 根因 |
|---|---|---|---|---|
| 1 | WS oid 浮点/非数字 | EventBus → _safe_int_oid → None | 事件静默丢弃 | 只接受 int 可解析的 order_id |
| 2 | orderUpdates 未知 status | 不进入 filled/canceled/rejected | _ws_status 永不设 | 未处理中间/未知状态 |
| 3 | 重连补查 + 同 oid 重复 track | retry_list 存旧 tracking,第二次 resolve(oid, old) | identity 失败,old 不 set | 重试未用当前 _tracking 引用 |
| 4 | HTTP 响应结构变化 | _parse_order_response 取 resp["order"] | inner 空 → avg_px/total_sz=0 | 强依赖当前 API 嵌套结构 |
| 5 | orderUpdates size 语义 | 宽限期 resolve(..., event.size) | event.size 非成交量时错误 | 未明确并防护 size 语义 |
| 6 | userFills isSnapshot | 整条 snapshot return | 断连期间成交可能无 fill 事件 | 快照一律跳过,未区分重连快照 |
四、建议修复优先级
- P0:Bug 1 — 在
_safe_int_oid或事件处理侧兼容浮点/异常 order_id,避免整单 WS 路径失效。 - P0:Bug 4 — 对
_parse_order_response做防御性解析(如兼容 resp 顶层字段),并对缺失 avg_px/total_sz 做明确降级或告警。 - P1:Bug 2 — 明确中间状态策略(如 partial → 仍 PENDING,或按交易所文档映射)。
- P1:Bug 6 — 区分「首次连接全量快照」与「重连快照」,或对重连后的 userFills 做一次按 oid 的补全而非整条跳过。
- P2:Bug 5 — 在宽限期 fallback 处校验 event.size 语义,或仅在 size>0 时写入 filled_size。
- P2:Bug 3 — 二次重试前从
_tracking重新取当前 tracking 再 resolve,避免对已替换对象操作。
以上为当前系统订单跟踪的严重 bug 与完整因果链(输入 → 状态变化 → 调用路径 → 出错点 → 根因)。