订单跟踪严重 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 等待窗口内,每笔成交都通过 userFillsOrderFilledEvent_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_sizeavg_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 优先),原因:

  1. 根本上解决了依赖 HTTP totalSz 字段的问题
  2. 利用已有的 WS 数据(tracking.filled_size 在 TIMEOUT 时已有值),改动逻辑直观
  3. 成交均价更精确(来自真实成交,非挂单价)
  4. 减少 HTTP 查询次数(只查 position,不查 order 状态)

七、验证方法

Fix 1+2 验证(主要 Bug B)

  1. 测试网下限价单,将 limit_order_timeout 临时调低为 30s
  2. 等待至少 1 笔部分成交,让 tracking.filled_size 累积非零
  3. 等待超时后观察日志:
    • 应出现:限价单超时但有 WS 部分成交记录: ICP ws_filled=xxx
    • 应出现:使用 WS 累积成交: ICP filled=xxx avg_px=xxx
    • 不应出现:🚨 开仓失败
  4. 确认数据库中 leg_a.size = WS 累积成交量,而非 0

Fix 3 验证(次要 Bug A)

  1. 下限价单等待部分成交(600s 窗口内)
  2. 观察 sync_from_exchange 日志:
    • 应出现:孤儿检测跳过 ICP:存在活跃限价单追踪
    • 不应出现:🎴 真孤儿仓位收纳: ICP

回归验证

  • 真孤儿仓位(无追踪订单)仍能被正确收纳
  • get_pending_coins() 返回空集时,行为与修改前完全一致
  • 订单正常成交(FILLED)路径不受影响

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