订单跟踪系统BUG28
订单跟踪系统 Bug 深度分析报告
共发现 12 个 Bug,其中 2 个 Critical、4 个 High、6 个 Medium。
Bug 1 [Critical]: WS 解析路径的 TOCTOU 竞态 -- 已成交订单被报成"取消"或"超时"
位置: src/trading/websocket_order_manager.py:341-375 (_on_order_update) 和 :402-417 (_on_user_fill)
严重程度: Critical
问题描述:
_on_order_update 和 _on_user_fill 在锁内设置 tracking._ws_status = OrderStatus.FILLED,然后释放锁,再调用 self._resolve(oid) -- 只传 oid 而不传 tracking 引用。_resolve(oid) 内部会重新加锁并通过 self._tracking.get(oid) 查找 tracking 对象。
在"释放锁"到"_resolve 再次加锁查表"之间存在竞态窗口。如果另一个线程在此窗口内调用 track_order(oid) 替换了 tracking 条目:
- 旧 tracking 被
track_order标记为CANCELED并result_event.set()-- 实际已成交的订单被报告为"取消" _resolve(oid)查表得到新 tracking(_ws_status为 None),条件不满足返回 False- 新 tracking 只能等到超时后被 HTTP 路径或超时路径解析为
TIMEOUT
影响范围:
- 已成交订单的实际成交信息丢失
- 仓位管理器收到错误的订单状态,可能导致仓位状态不一致
- 在限价开仓的 Leg A 追踪场景下,可能导致本应成功的开仓被判定为失败
根本原因: WS 路径仅使用 oid 查表,不像 HTTP/超时路径那样传入 tracking 引用并做 identity check (current is tracking)。这是经典的 TOCTOU (Time-Of-Check-To-Time-Of-Use) 竞态。
关键代码:
# _on_order_update 第341-375行
with self._lock:
tracking = self._tracking.get(oid)
# ... 设置 tracking._ws_status = OrderStatus.FILLED
if tracking.has_fill_price:
should_resolve = True
# ... 释放锁
if should_resolve:
self._resolve(oid) # BUG: 只传 oid,不传 tracking 引用
Bug 2 [Critical]: 宽限期定时器同样存在 TOCTOU 竞态
位置: src/trading/websocket_order_manager.py:359-364
严重程度: Critical
问题描述:
当 orderUpdates 报告 "filled" 但尚无 userFills 真实成交价时,代码启动一个 5 秒宽限期定时器:
timer = threading.Timer(
self._FILL_GRACE_SEC, self._resolve, [oid]
)
定时器回调仅传递 oid。5 秒后定时器触发时,如果同一 oid 已被 track_order 替换,_resolve(oid) 将拿到新的 tracking(_ws_status 为 None),解析无效。
影响范围: 与 Bug 1 相同 -- 已成交订单被误报。此外定时器的旧 tracking 引用丢失,该订单的真实成交状态永远不会被正确解析。
根本原因: 与 Bug 1 相同 -- 定时器回调缺少 tracking 引用的绑定。
Bug 3 [High]: _order_msg_buffer 非线程安全的 deque 操作
位置: src/services/realtime_kline_service_base.py:193 (初始化), :625-660 (on_message) 和 :1877-1900 (_periodic_buffer_flush)
严重程度: High
问题描述:
_order_msg_buffer 是 deque(maxlen=500),被多个线程并发访问:
on_message线程(WS 消息回调)执行popleft()和append()_periodic_buffer_flush线程每 2 秒执行popleft()
虽然 CPython 的 GIL 让单个 popleft() 或 append() 操作是原子的,但 on_message 中的逻辑涉及先检查 if self._order_msg_buffer:,再 while True: popleft() 的复合操作,两个线程可能同时进入 while 循环消费 buffer。更重要的是:
# on_message 第625-636行
if self._order_msg_buffer: # 检查非空
mgr = self._get_ws_order_manager()
if mgr is not None:
while True:
try:
buffered = self._order_msg_buffer.popleft() # 消费
except IndexError:
break
这段代码同时存在于 on_message 和 _periodic_buffer_flush 两个执行路径中,虽然 IndexError 异常被捕获防止了崩溃,但可能导致:
- 同一消息被两个线程各取走一半,导致部分订单消息丢失
- 更关键的是:两个线程都在对同一个 buffer 做 drain 操作,存在消息被处理两次的风险(如果
mgr.handle_message在两个线程中被并发调用)
影响范围: 订单状态消息可能丢失或被重复处理,导致订单跟踪状态不一致。
Bug 4 [High]: 仓位管理器中 _close_cooldown 字典无锁保护
位置: src/trading/position_manager.py:64 (定义), :296-306 (读取), :335 (写入), :477 (删除), :603 (删除), :645 (写入)
严重程度: High
问题描述:
_close_cooldown 字典在多个线程中被读写,但没有使用锁保护:
- 止损监控线程通过
_close_with_retry调用close_position - 仓位同步线程通过
sync_with_exchange间接调用 - 策略信号处理线程通过
on_exit_signal调用
读取和写入 _close_cooldown 的代码有些在 self._lock 内,有些在锁外:
# close_position 第296-306行 -- 在锁外读取
cooldown_until = self._close_cooldown.get(key)
if cooldown_until is not None:
if time.time() < cooldown_until:
# ...
return None
else:
self._close_cooldown.pop(key, None)
# 第335行 -- 在锁内写入
self._close_cooldown[key] = time.time() + 30
# 第477行 -- 在锁外删除
self._close_cooldown.pop(key, None)
影响范围: 在极端情况下,冷却期检查可能产生不一致的结果,导致在冷却期内重复尝试平仓(浪费 API 调用)或在冷却期过后仍然跳过平仓。
根本原因: _close_cooldown 的读写操作应统一在 self._lock 保护下。
Bug 5 [High]: _pair_rate_limiters 字典无锁保护的并发读写
位置: src/trading/orchestrator.py:588-603 (读写), :430 (删除), :446 (删除), :839 (删除), :853 (删除), :905 (删除)
严重程度: High
问题描述:
_pair_rate_limiters 字典被多个线程并发读写,且没有任何锁保护:
on_entry_signal(信号处理线程)中创建和检查限速器_close_with_retry(止损监控线程)中删除限速器process_analysis(分析工作线程)中删除限速器_position_sync(仓位同步线程)中删除限速器
虽然 CPython dict 的单个操作在 GIL 下是原子的,但 on_entry_signal 中的 check-then-create 模式不是原子的:
# orchestrator.py 第588-594行
pair_key: PairKey = (symbol, base_symbol)
if pair_key not in self._pair_rate_limiters: # check
pair_limit = max(1, ...)
self._pair_rate_limiters[pair_key] = RateLimiter(...) # create
if not self._pair_rate_limiters[pair_key].allow(): # use
影响范围: 多个分析工作线程可能为同一 pair_key 并发创建多个 RateLimiter,导致限速失效。
Bug 6 [High]: 止损监控线程中仓位列表的迭代安全问题
位置: src/trading/orchestrator.py:764
严重程度: High
问题描述:
for pos in self._position_manager.open_positions:
open_positions 属性返回 list(self._positions.values()) -- 这是一个线程安全的副本。但在第一轮收集阶段和第二轮执行阶段之间,仓位状态可能已经改变。虽然第二轮有 if pos.status == PositionStatus.OPEN 检查,但 pos 对象本身是共享的引用(不是深拷贝),其 status 字段可能在止损检查和平仓执行之间被其他线程修改为 CLOSING,然后止损线程又将其设回 OPEN(通过 _close_with_retry 失败路径),造成状态跳跃。
代码在 _execute_close 异常路径中已有恢复逻辑,但"收集-执行"两阶段之间的窗口仍可能导致重复平仓尝试(同一仓位被止损线程和策略退场信号同时触发平仓)。
影响范围: 可能产生冗余的平仓操作,增加交易所 API 调用负担,并可能在日志中产生混淆的状态变化记录。
Bug 7 [Medium]: verify_pending_orders 存在与 Bug 1 相同的 TOCTOU 风险
位置: src/trading/websocket_order_manager.py:154-183
严重程度: Medium
问题描述:
verify_pending_orders 在锁内收集 pending 列表后释放锁,然后逐个调用 _http_check(oid)。在释放锁到 _http_check 内再次加锁之间,tracking 可能已被替换。虽然 _http_check 最终调用 _resolve(oid, tracking, status, ...) 时会做 identity check,但中间的查询浪费了 API 调用。
第二轮重试的 _resolve(oid, tracking, OrderStatus.TIMEOUT) 中的 tracking 来自第一轮收集,如果在收集后被替换,identity check 会正确拦截,但强制 TIMEOUT 的意图落空。
影响范围: WebSocket 重连后的订单补查可能不完整,部分订单可能遗漏补查。
Bug 8 [Medium]: on_message 中订单消息的 early return 导致 K 线处理被跳过
位置: src/services/realtime_kline_service_base.py:639-661
严重程度: Medium
问题描述:
# 路由订单消息到订单管理器
if channel in ("orderUpdates", "userFills"):
mgr = self._get_ws_order_manager()
if mgr is not None:
try:
mgr.handle_message(msg)
except Exception as e:
self.logger.error(...)
else:
self._order_msg_buffer.append(msg)
# ...
return # <-- 这里 early return
这个逻辑本身是正确的(订单消息不是 K 线消息,不需要走 K 线处理)。但是,如果 channel 判断逻辑出错(例如消息同时包含 "channel" 和 "type" 字段且值不同),可能导致本应处理的 K 线消息被误判为订单消息并 early return,从而丢失 K 线数据。
虽然当前代码在第621行使用了 channel = msg.get("channel") or msg.get("type", "") 作为防御性处理,这在 Hyperliquid 当前 API 格式下是安全的,但依赖对第三方 API 消息格式的假设。
影响范围: 正常情况下不触发,但在 API 格式变化时可能导致 K 线数据丢失。
Bug 9 [Medium]: _on_order_update 中 status 字段解析的脆弱性
位置: src/trading/websocket_order_manager.py:336-338
严重程度: Medium
问题描述:
status_str = (
item.get("status") or order.get("status") or ""
).lower()
这里使用 or 链来提取 status,但 or 运算符将空字符串 "" 视为 falsy。如果 item.get("status") 返回空字符串 ""(而非 None),会继续尝试 order.get("status"),这可能意外地获取到不同层级的 status 值。
在 Hyperliquid API 中,item 和 order 是不同层级的数据结构,它们的 status 字段含义可能不同(item.status 是事件状态,order.status 是订单状态)。如果 API 某些边界情况下 item.status 为空字符串而 order.status 为 "open",逻辑会提取到不相关的 status。
影响范围: 可能导致订单状态被错误解析,在极端 API 响应场景下触发错误终态。
Bug 10 [Medium]: track_order 替换旧追踪时取消宽限期定时器的竞态
位置: src/trading/websocket_order_manager.py:118-126
严重程度: Medium
问题描述:
with self._lock:
old = self._tracking.get(oid)
if old:
old.status = OrderStatus.CANCELED
if old._grace_timer:
old._grace_timer.cancel()
old.result_event.set()
self._tracking[oid] = tracking
这里在锁内取消旧的宽限期定时器,但 threading.Timer.cancel() 只能阻止尚未启动执行的定时器。如果定时器回调函数 _resolve(oid) 已经开始执行但尚未获取锁,cancel() 无效。定时器回调将继续执行并尝试解析 -- 此时查表得到的是新 tracking(_ws_status 为 None),解析失败,但浪费了一次无效的 _resolve 调用。
虽然不会产生错误终态(因为新 tracking 的 _ws_status 为 None,_resolve 会直接返回 False),但说明了 Bug 1/Bug 2 的竞态窗口确实存在。
影响范围: 性能浪费,且是 Bug 1/Bug 2 竞态窗口的佐证。
Bug 11 [Medium]: PairPosition._pnl_lock 在 dataclass 序列化时的问题
位置: src/trading/models.py:166
严重程度: Medium
问题描述:
_pnl_lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)
PairPosition 是一个 dataclass,包含一个 threading.Lock 实例。虽然设置了 repr=False 和 compare=False,但这个锁对象:
- 不可被 pickle 序列化(如果未来需要将仓位对象发送到其他进程)
- 只保护
peak_pnl_pct的更新(update_peak_pnl),但unrealized_pnl、alt_current_price等相关字段在position_manager.py的 sync 逻辑中直接写入,不使用此锁 peak_pnl_pct在risk_manager.py:_calculate_pnl_pct中被读取时也不持有此锁
影响范围: peak_pnl_pct 的读写可能出现不一致(虽然在单个 float 赋值层面 CPython GIL 保证原子性,但 update_peak_pnl 中的 compare-then-update 模式在理论上仍有竞态)。
Bug 12 [Medium]: 限价平仓撤单后回退市价时可能导致成交价不准确
位置: src/trading/executor.py:1137-1160 (limit_close 方法)
严重程度: Medium
问题描述:
在 limit_close 中,Leg A 限价平仓超时后:
if not leg_a_filled:
if result.leg_a.order_id:
cancel_ok = self._cancel_order(alt_coin, result.leg_a.order_id)
remaining_size = self._get_actual_position_size(
alt_coin, expected_side=position.alt_side, force_refresh=True
)
if remaining_size > 0:
result.leg_a = self._place_close_order(alt_coin, remaining_size, alt_close_side)
else:
result.leg_a.success = True
result.leg_a.status = ORDER_STATUS_FILLED
如果撤单请求和限价单成交几乎同时发生:
_cancel_order发送撤单请求- 在撤单生效前,限价单恰好成交
_get_actual_position_size查询时仓位已清零(限价单刚成交)remaining_size为 0,走 else 分支- 但
result.leg_a保留的是限价单下单时的信息,_backfill_order_price未被调用 result.leg_a.price可能仍是限价而非实际成交均价
影响范围: 平仓后 PnL 计算可能使用限价而非实际成交均价,导致盈亏记录不准确。
总结
| 序号 | 严重程度 | 问题简述 | 位置 |
|---|---|---|---|
| 1 | Critical | WS 解析路径 TOCTOU 竞态 -- 已成交订单被误报 | websocket_order_manager.py:341-375, 402-417 |
| 2 | Critical | 宽限期定时器 TOCTOU 竞态 -- 同 Bug 1 | websocket_order_manager.py:359-364 |
| 3 | High | _order_msg_buffer deque 并发访问无锁保护 |
realtime_kline_service_base.py:625-660, 1877-1900 |
| 4 | High | _close_cooldown 字典并发读写无锁保护 |
position_manager.py:296-306, 335, 477 |
| 5 | High | _pair_rate_limiters 字典并发读写无锁保护 |
orchestrator.py:588-603 |
| 6 | High | 止损监控与策略退场的并发平仓竞争 | orchestrator.py:764 |
| 7 | Medium | verify_pending_orders TOCTOU 风险 |
websocket_order_manager.py:154-183 |
| 8 | Medium | 订单消息 channel 判断依赖 API 格式假设 | realtime_kline_service_base.py:639-661 |
| 9 | Medium | status 字段解析的 or 链脆弱性 |
websocket_order_manager.py:336-338 |
| 10 | Medium | track_order 取消定时器的竞态窗口 |
websocket_order_manager.py:118-126 |
| 11 | Medium | PairPosition._pnl_lock 保护不完整 |
models.py:166 |
| 12 | Medium | 限价平仓撤单后成交价信息可能不准确 | executor.py:1137-1160 |
最高优先修复建议
Bug 1 和 Bug 2 属于同一根因,可通过一次修改统一解决 -- 扩展 _resolve 方法支持"WS 按引用解析"模式,使所有调用路径统一使用 identity check(current is tracking),与 HTTP/超时路径保持一致。