订单跟踪严重 Bug 分析1-3

订单部分成交 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_with_exchange() 定时触发:
  memory_coins = {}
  _collect_orphan_candidates(exchange_coins, memory_coins)
    coin="ICP" 不在 memory_coins
    ❌ 未检查 ws_order_manager._tracking 是否有 ICP 的 PENDING 订单
    candidates["ICP"] = {szi:12.19}   ← 误判为孤儿

根因:PositionManager 不感知 WebSocketOrderManager 的追踪状态

Bug B(主要)— 开仓失败误判

22:40:00  timeout 到期(600s)

  _resolve(oid, tracking, TIMEOUT)
    tracking.status = TIMEOUT
    tracking.result_event.set()
    _tracking.pop(oid)          ← 字典移除,本地引用仍有效

  _track_limit_order():
    wait_for_order() 返回 False
    ❌ tracking.filled_size = 72.09 被完全忽略
    return False

  limit_open():
    _cancel_order(ICP, 48939226203)

    _check_order_after_cancel(ICP, oid, "buy")   ← 旧方案
      HTTP 响应: {"sz": "70.15", "status": "canceled"}
                         ↑ sz = 剩余量,totalSz 字段不存在!
      total_sz = 0.0
      ❌ return 0.0, 0.0   ← try 块内提前返回,降级路径被绕过

    actual_filled = 0.0  →  🚨 开仓失败

根因(三层叠加):
  1. Hyperliquid HTTP:已撤销订单响应中 sz=剩余量,totalSz 字段缺失
  2. _check_order_after_cancel:totalSz=0 时在 try 内 return,绕过第 801 行降级
  3. tracking.filled_size=72.09 完整存在,却从未被读取

三、WS 数据完整性确认

userFills 在追踪期间完整累积

600s 等待窗口内,每笔成交经 userFillsOrderFilledEvent_accumulate_fill() 写入 tracking:

TIMEOUT 时 tracking 的状态:

字段 来源
tracking.status TIMEOUT _resolve() 设置
tracking.filled_size 72.09 ICP userFills WS 累积 ✓
tracking.avg_price 实际加权均价 userFills WS 累积 ✓
tracking.fill_count 7 userFills 笔数 ✓

_resolve(..., TIMEOUT) 只设置 tracking.status = TIMEOUT,不清空 filled_size/avg_price
数据完整,但 _track_limit_order 返回 False 时将其完全丢弃。

三个 WS 频道的角色

频道 修复中的角色
userFills 主数据源:TIMEOUT 时直接读取 tracking.filled_size 和加权均价
webData2 对账数据源_get_actual_position_size() 优先读取 webData2 缓存,无需 HTTP
orderUpdates 不变:提供订单生命周期终态

四、修复方案

核心思路

放弃 _check_order_after_cancel,彻底删除(仅有一处调用),改为:

TIMEOUT 后新数据流:

  tracking.filled_size = 72.09   ← userFills WS 累积(主路径)
           ↓ Fix 1 写回 order_result
  _cancel_order()
           ↓
  _get_actual_position_size()    ← webData2 缓存/HTTP(补捡窗口漏报)
           ↓
  actual_filled = max(ws, pos)   ✓ 正确

TIMEOUT→cancel 窗口说明_tracking.pop(oid) 之后、_cancel_order() 之前,极短窗口内可能有新成交未被 tracking 捕获。_get_actual_position_size(force_refresh=True) 补捡这部分。


Fix 1 — src/trading/executor.py · _track_limit_order

位置:约第 606–615 行,非成交路径末尾

改动:发通知后,将 WS 累积数据写回 order_result,无论有无成交都写(0.0 = 无成交,消除调用方歧义)。

# ── 修改后 ──
reason = tracking.status.value
# 通知:使用原始挂单量/限价(语义:这是"哪个订单"的状态通知)
self._notify_limit_order(
    f"❌ 限价单{reason}: {coin}", coin,
    is_buy=(order_result.side == "buy"),
    size=order_result.size, price=order_result.price,
    order_id=oid, error=reason,
)
# 将 WS 累积数据写回,供 limit_open 读取
# 0.0 表示无成交;调用方不应再读取原始挂单量
order_result.size  = tracking.filled_size
order_result.price = tracking.avg_price
if tracking.filled_size > 0:
    logger.info(
        f"限价单{reason},WS 部分成交: {coin} oid={oid} | "
        f"ws_filled={tracking.filled_size:.4f} avg_px={tracking.avg_price:.4f} "
        f"fills={tracking.fill_count}"
    )
else:
    logger.warning(f"限价单未成交(无 WS 成交记录): {coin} oid={oid} | {reason}")
return False

Fix 2 — src/trading/executor.py · limit_open + 删除 _check_order_after_cancel

删除_check_order_after_cancel 整个函数(第 767–802 行)。

位置:约第 914–927 行,TIMEOUT 处理段

# ── 修改后 ──
if not leg_a_filled:
    ws_filled = result.leg_a.size    # Fix 1 写入:WS 累积成交量(0.0 = 无成交)
    ws_avg_px = result.leg_a.price   # Fix 1 写入:WS 加权均价(0.0 = 无均价)

    if result.leg_a.order_id:
        cancel_ok = self._cancel_order(alt_coin, result.leg_a.order_id)
        if not cancel_ok:
            logger.warning(f"撤单失败(可能已成交): {alt_coin} oid={result.leg_a.order_id}")

    alt_side = "buy" if alt_is_buy else "sell"

    # position 核查:补捡 _tracking.pop→cancel 窗口内的漏网成交
    pos_filled = self._get_actual_position_size(
        alt_coin, expected_side=alt_side, force_refresh=True
    )

    if pos_filled > ws_filled:
        actual_filled = pos_filled
        fill_px = 0.0
        logger.info(
            f"position 发现更多成交(WS 漏报): {alt_coin} | "
            f"ws={ws_filled:.4f} pos={pos_filled:.4f}"
        )
    elif ws_filled > 0:
        actual_filled = ws_filled
        fill_px = ws_avg_px
        logger.info(
            f"使用 WS 累积成交: {alt_coin} | "
            f"filled={actual_filled:.4f} avg_px={fill_px:.4f}"
        )
    else:
        actual_filled = 0.0
        fill_px = 0.0
    # 以下 actual_filled 处理逻辑不变 ↓

Fix 3 — src/trading/websocket_order_manager.py

在公开接口区(cancel_all_open_ordersshutdown 之间)新增:

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 4 — src/trading/position_manager.py

4a. _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
        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

4b. 调用点 1 — recover_positions_from_dbwith self._lock: 第 1047 行之前)

# 锁外:获取待追踪币种(不可在 self._lock 内调用,避免锁竞争)
_ws_mgr = getattr(self._executor, "_ws_order_manager", None)
pending_order_coins = _ws_mgr.get_pending_coins() if _ws_mgr else set()

with self._lock:
    ...
    orphan_candidates = self._collect_orphan_candidates(
        exchange_coins, memory_coins, pending_order_coins
    )

4c. 调用点 2 — sync_with_exchangewith self._lock: 第 1147 行之前,"锁外:获取交易所数据" 段末尾)

# 锁外:获取待追踪币种
_ws_mgr = getattr(self._executor, "_ws_order_manager", None)
pending_order_coins = _ws_mgr.get_pending_coins() if _ws_mgr else set()

# ── 锁内:纯内存对账 ──
with self._lock:
    ...
    orphan_candidates = self._collect_orphan_candidates(
        exchange_coins, memory_coins, pending_order_coins
    )

五、修改文件清单

文件 操作 位置 说明
src/trading/executor.py 删除 第 767–802 行 整体删除 _check_order_after_cancel 函数
src/trading/executor.py 替换 第 606–615 行 _track_limit_order 非成交路径:通知后写回 WS 数据
src/trading/executor.py 替换 第 914–927 行 limit_open TIMEOUT 段:WS+position 对账替换旧调用
src/trading/websocket_order_manager.py 新增 约第 200 行 新增 get_pending_coins() 方法(5 行)
src/trading/position_manager.py 替换 第 1485–1502 行 _collect_orphan_candidates 增加参数
src/trading/position_manager.py 替换 第 1047 行前 recover_positions_from_db 调用点
src/trading/position_manager.py 替换 第 1147 行前 sync_with_exchange 调用点

六、边界情况

场景 处理结果
TIMEOUT 无 WS 成交(filled_size=0) Fix 1 写 0.0 → ws_filled=0 → 仅靠 pos 查询 → 两者为 0 → 走无成交路径 ✅
TIMEOUT→cancel 窗口有新成交 pos_filled > ws_filled 分支捡回 ✅
撤单失败(订单已全部成交) pos_filled = 全量,取 pos_filled
_get_actual_position_size 失败返回 0 ws_filled > 0 时用 WS 数据兜底 ✅
CANCELED(WS 发起)有部分成交 Fix 1 同样写回 WS 数据,Fix 2 同路径处理 ✅
REJECTED tracking.filled_size=0,Fix 1 写 0.0,无成交路径 ✅
_ws_order_manager 未注入 pending_order_coins=set(),行为与修改前一致 ✅

七、验证方法

Bug B 验证

  1. 临时将 limit_order_timeout 调低为 30s
  2. 测试网下限价单(size ≥ $10),等待至少 1 笔部分成交后超时
  3. 观察日志:
    • 限价单timeout,WS 部分成交: ICP ws_filled=xxx
    • 使用 WS 累积成交: ICP filled=xxx avg_px=xxx
    • ❌ 不出现 🚨 开仓失败

Bug A 验证

  1. 下限价单,在追踪期间等待部分成交
  2. 观察 sync_with_exchange 日志:
    • 孤儿检测跳过 ICP:存在活跃限价单追踪
    • ❌ 不出现 🎴 真孤儿仓位收纳: ICP

回归验证

场景 预期
限价单正常全量成交 Fix 1 不执行(走 FILLED 分支),行为不变
真孤儿仓位(无追踪订单) 仍正确收纳,pending_order_coins 不含该币种
微仓位回滚路径 actual_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