订单跟踪系统BUG28

订单跟踪系统 Bug 深度分析报告

共发现 12 个 Bug,其中 2 个 Critical4 个 High6 个 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 条目:

  1. 旧 tracking 被 track_order 标记为 CANCELEDresult_event.set() -- 实际已成交的订单被报告为"取消"
  2. _resolve(oid) 查表得到新 tracking(_ws_status 为 None),条件不满足返回 False
  3. 新 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_bufferdeque(maxlen=500),被多个线程并发访问:

  1. on_message 线程(WS 消息回调)执行 popleft()append()
  2. _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 中,itemorder 是不同层级的数据结构,它们的 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=Falsecompare=False,但这个锁对象:

  1. 不可被 pickle 序列化(如果未来需要将仓位对象发送到其他进程)
  2. 只保护 peak_pnl_pct 的更新(update_peak_pnl),但 unrealized_pnlalt_current_price 等相关字段在 position_manager.py 的 sync 逻辑中直接写入,不使用此锁
  3. peak_pnl_pctrisk_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

如果撤单请求和限价单成交几乎同时发生:

  1. _cancel_order 发送撤单请求
  2. 在撤单生效前,限价单恰好成交
  3. _get_actual_position_size 查询时仓位已清零(限价单刚成交)
  4. remaining_size 为 0,走 else 分支
  5. result.leg_a 保留的是限价单下单时的信息,_backfill_order_price 未被调用
  6. 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/超时路径保持一致。

Read more

跑步的技巧(滚动落地)

“滚动落地(rolling contact / rolling foot strike)”不是一种教条式的“脚法”,而是一种 让冲击沿着整只脚、整条后链逐级传递的落地机制。 它的核心不是“你先用哪儿着地”,而是: 你的脚落地之后,冲击是不是像轮子一样滚过去,而不是像锤子一样砸下去。 这就是滚动落地的本质。 一、什么叫“滚动落地”? 你可以把它理解成两种完全不同的落地方式: 1. 砸地(撞击式) 脚像锤子一样拍到地上: * 要么后跟先砸 * 要么前掌先戳 * 冲击集中在一个点 * 一个结构瞬间吃掉大部分载荷 结果就是: * 后跟砸 → 膝盖难受 * 前掌戳 → 前脚掌磨烂 * 都不是长跑友好模式 这叫 撞击式着地(impact strike)。 2. 滚地(滚动式) 脚像轮胎一样“滚”过地面: * 不是某一点硬砸 * 而是外侧中足先轻触 * 再向前滚到前掌 * 最后从大脚趾蹬离

By SHI XIAOLONG

AMI的优越性

世界模型(World Models)的具体例子 如下,我按类型分类,便于理解。每类都附带实际实现、演示效果和应用场景。 1. Yann LeCun / Meta 的 JEPA 系列(最直接对应“世界模型”概念) 这些是 LeCun 主张的非生成式抽象预测世界模型代表。 * I-JEPA(Image JEPA,2023) 输入一张图像,模型把不同区域(context 和 target)编码成抽象表示,然后预测 target 的表示(不在像素级别重建)。 例子:给定一张遮挡了部分物体的图片,模型能预测“被遮挡物体的大致位置和属性”,构建对物体持久性和空间关系的理解。 这是一个“原始世界模型”,能学习物理常识(如物体不会凭空消失)。 * V-JEPA / V-JEPA 2(Video JEPA,

By SHI XIAOLONG

什么是:“世界模型(World Models)”

世界模型(World Models) 是人工智能领域的一个核心概念,尤其在 Yann LeCun 等研究者推动的下一代 AI 架构中占据中心位置。它指的是 AI 系统在内部构建的对现实世界的抽象模拟或内部表示,让机器能够像人类或动物一样“理解”物理世界、预测未来、规划行动。 简单比喻 想象你闭上眼睛也能“看到”房间里的物体会如何移动、碰撞或掉落——这就是你大脑里的世界模型。AI 的世界模型就是类似的“数字孪生”(digital twin)或“内部模拟器”:它不是简单记住数据,而是学习世界的动态、因果关系和物理直觉(如重力、物体持久性、遮挡、因果等)。 为什么需要世界模型? 当前主流的大型语言模型(LLM) 擅长处理文本(统计模式预测),但存在根本局限: * 缺乏对物理世界的真正理解 → 容易“幻觉”、无法可靠规划。 * 样本效率低 → 人类/

By SHI XIAOLONG

K线周期可配置化设计方案

K线周期可配置化设计方案 1. 背景与目标 当前 Beta 套利策略的 K 线周期硬编码为 "1h",分散在多个文件中。需要: 1. 将 K 线周期从 1h 改为 2h 2. 提取为环境变量 BETA_ARB_KLINE_INTERVAL,使其可在 .env 中配置 2. 影响范围分析 2.1 需要修改的文件(共 6 个) 文件 硬编码位置 修改内容 src/trading/config.py BetaArbConfig dataclass 新增 kline_interval 字段,

By SHI XIAOLONG