订单跟踪严重 Bug 分析2

Bug 3 最优修复方案:限价单部分成交跟丢

关联文档:订单系统bug3_限价单跟丢与孤儿仓位误报.md


一、问题重述与根因校正

现象

限价单部分成交(72.09 ICP)后触发超时撤单,系统误报「开仓失败」,产生孤儿仓位告警。

原文档根因描述有误

原文档称:"totalSz 仅出现在 WS orderUpdates 推送消息中"。

实测不符:WS orderUpdates 消息字段为:

order.oid        订单 ID
order.limitPx    挂单价
order.sz         当前剩余委托量(非累计成交量)
order.origSz     原始委托量
item.status      订单状态

totalSz(累计成交量)和 avgPx(成交均价)在 WS 消息和 HTTP canceled 订单响应中均不存在

三频道数据价值分析

频道 包含的成交信息 精确度 时机
userFills 逐笔成交:qty、px、fee、tid ★★★ 最精确 实时推送,在整个 600s 追踪窗口持续累计
orderUpdates 订单状态:canceled 含 sz(剩余)、origSz(原始) ★★ 精确 撤单后推送,但到达时 tracking 已 TIMEOUT 被忽略
webData2 账户持仓快照 szi ★ 近似 有 TOCTOU 竞态,无法区分同币种多订单

核心发现userFills 数据已在代码中完整积累,问题在于超时路径丢弃了它。

数据丢失的精确位置

WebSocketOrderManager
  _on_order_filled_event()          ← userFills 每笔成交实时写入
    └─ _accumulate_fill(tracking)   ← tracking.filled_size / avg_price 持续更新
                                        600s 内任何成交都在这里

T=600s: _resolve(TIMEOUT)           ← tracking 从字典 pop,但对象仍在栈上
  └─ tracking.filled_size = 70.09   ← 数据在这里!

_track_limit_order()                ← 持有 tracking 对象的引用
  TIMEOUT branch:
    logger.warning("未成交")
    return False                    ← ❌ tracking.filled_size 被完全丢弃!

limit_open() timeout handling:
  → _check_order_after_cancel()     ← HTTP 查询
    → totalSz = None                ← 字段不存在
    → return 0.0, 0.0              ← ❌ 72.09 ICP 被误判为无成交

二、最优修复方案:三层数据源

设计原则

  1. WS userFills 累计数据优先:这是整个 600s 窗口内逐笔实时累计的结果,精度最高,无需额外 HTTP 请求
  2. HTTP origSz-sz 作为后备:应对 WS 事件延迟或极端时序边界(超时触发→撤单到达交易所之间的微量成交)
  3. 仓位查询作为最终兜底:仅当 HTTP 响应字段缺失时触发

数据流架构

600s 追踪窗口内:
  userFills WS events
    └─ tracking.filled_size(逐笔累计)
    └─ tracking.avg_price (加权均价)
    └─ tracking.fill_count(笔数)

T=timeout: TIMEOUT 解析
           tracking 对象仍在 _track_limit_order() 栈上

修复后的 _track_limit_order() TIMEOUT 分支:
  if tracking.filled_size > 0:
    order_result.size  = tracking.filled_size   ← WS fill 量(写回!)
    order_result.price = tracking.avg_price     ← WS 均价(写回!)

修复后的 limit_open() 超时处理:
  ┌─ [1] WS 路径(order_result.size < requested_size 且 > 0)
  │      actual_filled = order_result.size      ← 无 HTTP,最快
  │      fill_px       = order_result.price     ← 加权均价,最准
  │
  ├─ [2] HTTP 路径(WS 无数据)
  │      actual_filled = origSz - sz            ← 同一 HTTP 响应计算
  │      fill_px       = avgPx or 0             ← HTTP 均价(通常为 0)
  │
  └─ [3] 仓位降级(HTTP origSz 字段缺失)
         actual_filled = _get_actual_position_size()

三、具体代码改动

Fix 1 — _track_limit_order() in src/trading/executor.py

位置:第 606–615 行(TIMEOUT/CANCELED/REJECTED 分支)

改动:在 return False 前将 WS 成交数据写回 order_result

# 未成交(超时/取消/拒绝)
reason = tracking.status.value

# ── WS 成交数据回填 ──────────────────────────────────────────────
# tracking.filled_size / avg_price 由 userFills 事件在整个追踪窗口内逐笔累计。
# TIMEOUT 时 tracking 对象仍在此栈帧中,将数据写回 order_result,
# 使 limit_open() 超时处理可直接使用,避免 HTTP 查询和 totalSz 缺失问题。
if tracking.filled_size > 0:
    order_result.size = tracking.filled_size
    if tracking.avg_price > 0:
        order_result.price = tracking.avg_price
    logger.info(
        f"WS 累计部分成交: {coin} oid={oid} "
        f"filled={tracking.filled_size:.4f} "
        f"avg_px={tracking.avg_price:.6g} "
        f"fills={tracking.fill_count}"
    )
# ─────────────────────────────────────────────────────────────────

logger.warning(f"限价单未成交: {coin} oid={oid} | {reason}")
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,
)
return False

Fix 2 — limit_open() 超时处理 in src/trading/executor.py

位置:第 914–928 行(超时处理块)

改动:优先使用 WS 数据,无数据时才走 HTTP:

if not leg_a_filled:
    # 撤单
    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"

    # ── 三层成交量获取 ───────────────────────────────────────────────
    # [1] WS 路径:tracking.filled_size 已由 _track_limit_order 写入
    #     order_result.size < requested_size 说明是 WS 填写的成交量(非请求量)
    ws_partial = result.leg_a.size
    if 0 < ws_partial < result.leg_a.requested_size:
        actual_filled = ws_partial
        fill_px = result.leg_a.price  # WS 加权均价
        logger.info(
            f"使用 WS 累计成交: {alt_coin} "
            f"filled={actual_filled:.4f} px={fill_px:.6g}"
        )

    # [2] HTTP 路径:WS 无数据(极端时序或真无成交),origSz-sz 计算
    elif result.leg_a.order_id:
        actual_filled, fill_px = self._check_order_after_cancel(
            alt_coin, result.leg_a.order_id, alt_side
        )

    # [3] 兜底:无订单 ID
    else:
        actual_filled = self._get_actual_position_size(
            alt_coin, expected_side=alt_side, force_refresh=True
        )
        fill_px = 0.0
    # ─────────────────────────────────────────────────────────────────

    if actual_filled > 0:
        result.leg_a.size = actual_filled
        # ... 后续逻辑不变(fill_rate、微仓回滚、价格回填等)

Fix 3 — _check_order_after_cancel() in src/trading/executor.py

位置:第 767–802 行

改动:当 totalSz 缺失时,用 origSz - sz 从同一 HTTP 响应计算成交量:

def _check_order_after_cancel(self, coin: str, oid: int,
                               expected_side: str) -> tuple[float, float]:
    """撤单后查询订单状态确认实际成交量

    数据获取优先级:
      1. HTTP totalSz(存在时最直接)
      2. HTTP origSz - sz(canceled 订单通常仅有此字段,无竞态)
      3. 仓位降级查询(字段完全缺失时的最终兜底)

    Returns:
        (filled_size, avg_price) — filled_size=0 表示无成交
    """
    avg_px_hint = 0.0
    try:
        resp = self.query_order_status(oid)
        if isinstance(resp, dict):
            raw_status = (resp.get("status") or "").lower()
            total_sz = self._safe_pos_float(resp.get("totalSz") or resp.get("total_sz"))
            avg_px = self._safe_pos_float(resp.get("avgPx") or resp.get("avg_px"))
            avg_px_hint = avg_px

            # 路径 1:totalSz 存在(兼容未来 API 或特殊状态)
            if total_sz > 0:
                logger.info(
                    f"撤单后订单成交(totalSz): {coin} oid={oid} | "
                    f"status={raw_status} sz={total_sz} px={avg_px}"
                )
                return total_sz, avg_px

            # 路径 2:totalSz 缺失 → origSz - sz 计算
            # Hyperliquid canceled 订单 HTTP 响应结构:
            #   resp = {"order": {"sz": 剩余量, "origSz": 原始量, ...}, "status": "canceled"}
            # filled = origSz - sz(同一响应,无竞态,精确)
            inner = resp.get("order", {})
            orig_sz = self._safe_pos_float(inner.get("origSz"))
            remaining_sz = self._safe_pos_float(inner.get("sz"))

            if orig_sz > 0:
                computed_fill = max(0.0, orig_sz - remaining_sz)
                if computed_fill > 0:
                    logger.info(
                        f"撤单后通过 origSz-sz 计算成交: {coin} oid={oid} | "
                        f"origSz={orig_sz} remaining={remaining_sz} "
                        f"filled={computed_fill:.4f}"
                    )
                    return computed_fill, avg_px_hint
                else:
                    # origSz == sz,确实无成交
                    logger.info(f"撤单后订单未成交: {coin} oid={oid} status={raw_status}")
                    return 0.0, 0.0

            # 路径 3:origSz 也缺失,降级仓位查询
            logger.info(f"撤单后订单字段不完整,降级仓位查询: {coin} oid={oid}")

    except Exception as e:
        logger.warning(f"撤单后订单查询失败,降级到仓位查询: {coin} oid={oid} | {e}")

    # 兜底:查仓位
    actual = self._get_actual_position_size(
        coin, expected_side=expected_side, force_refresh=True
    )
    return actual, avg_px_hint

同步修正 query_order_status() docstring(第 1850 行):

# 修正前(误导):
# 本方法解包后返回内层 order dict(含 status/avgPx/totalSz 等字段)

# 修正后:
# 本方法解包后返回内层 order dict。
# canceled 订单响应通常不含 totalSz/avgPx,
# 成交量需通过 resp["order"]["origSz"] - resp["order"]["sz"] 计算。

Fix 4 — get_pending_coins() in src/trading/websocket_order_manager.py

位置:类末尾,cleanup() 之前

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 5 — _collect_orphan_candidates() in src/trading/position_manager.py

修改点 A:签名增加 pending_order_coins 参数(约第 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
        # ... 其余逻辑不变 ...

修改点 B:两处调用点(recover_from_exchange ~1070 行,sync_from_exchange ~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
)

四、时序与边界情况分析

正常部分成交路径(~99% 场景)

T=0          下单,track_order() 注册
T=30s        第一笔成交 → userFills WS 到达 → tracking.filled_size += 12.19
T=33s        第二笔成交 → tracking.filled_size += 19.89
...
T=600s       超时 → _resolve(TIMEOUT) → tracking popped
             tracking.filled_size = 70.09(WS 累计),tracking.avg_price = 2.1073
             _track_limit_order() TIMEOUT 分支:写入 order_result.size = 70.09
             limit_open() 检测到 ws_partial (70.09) < requested (142.24)
             → actual_filled = 70.09,fill_px = 2.1073  ✅ 无 HTTP 查询

极端时序边界(WS 无数据,~1% 场景)

T=599.9s     最后一笔成交发生
T=600s       _resolve(TIMEOUT) 先于 userFills WS 到达
             tracking.filled_size = 0(WS 事件尚在传输中)
             _track_limit_order() 不写入 order_result.size
             limit_open() ws_partial = requested_size → 进入 HTTP 路径
             _check_order_after_cancel() → origSz(142.24) - sz(70.15) = 72.09
             → actual_filled = 72.09  ✅ HTTP origSz-sz 正确兜底

WS 与 HTTP 数据可能存在的微量差异

场景 WS 成交量 HTTP origSz-sz 原因
正常 = HTTP = HTTP 所有成交均收到
WS 延迟 < HTTP = HTTP 超时触发和撤单间有新成交
WS 丢包 < HTTP = HTTP 极罕见,HTTP 覆盖

结论:WS 路径量可能略少于 HTTP,但 HTTP 路径(Fix 3)作为备选覆盖了所有边界。


五、方案对比

维度 原文档方案 上一版改进 本最优方案
成交量来源 HTTP totalSz(不存在)→ 0.0 ❌ HTTP origSz-sz WS userFills 优先 → HTTP origSz-sz 备用
额外 HTTP 请求 必须(且结果错误) 必须 有 WS 数据时零 HTTP
成交价精确度 无(avgPx=0) 无(avgPx=0) WS 逐笔加权均价
极端时序覆盖 origSz-sz 覆盖 origSz-sz 覆盖
代码改动量 1 处 1 处 4 处,各 5–15 行
孤儿检测修复

六、修改文件清单

文件 函数 行号(约) 改动类型
src/trading/executor.py _track_limit_order 606–615 新增 WS 数据回填(核心)
src/trading/executor.py limit_open 超时处理 914–928 三层数据源逻辑
src/trading/executor.py _check_order_after_cancel 767–802 origSz-sz 计算
src/trading/executor.py query_order_status docstring ~1850 注释修正
src/trading/websocket_order_manager.py 新增 get_pending_coins 类末尾 新增方法
src/trading/position_manager.py _collect_orphan_candidates ~1485 签名 + 逻辑
src/trading/position_manager.py recover_from_exchange 调用点 ~1070 更新调用
src/trading/position_manager.py sync_from_exchange 调用点 ~1230 更新调用

七、验证方法

  1. Fix 1+2 主路径:测试网下限价单(价格设在盘口附近等待成交),临时调低 limit_order_timeout(如 60s),触发超时撤单:

    • 日志出现 "WS 累计部分成交" + 成交量/均价
    • actual_filled 正确,不再出现「开仓失败」
    • Leg B 正常基于 Leg A 实际成交量计算
  2. Fix 3 备用路径:模拟 WS 数据丢失(暂时在 _accumulate_fill 加 return),验证 HTTP origSz-sz 路径:

    • 日志出现 "通过 origSz-sz 计算成交"
    • 结果与真实成交量一致
  3. Fix 4/5 孤儿检测:在 600s 窗口内观察 sync_from_exchange

    • 部分成交产生仓位时,日志出现 "孤儿检测跳过 {coin}"
    • 不触发「孤儿仓位收纳」告警
  4. 回归:真实孤儿(无追踪、无 DB 记录)仍被正确收纳;get_pending_coins() 返回空集合时行为与修改前完全一致。

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