部分成交跟单失败+孤儿仓位错误识别bug修复

Bug 3 修复报告:限价单部分成交跟丢 & 孤儿仓位误报

修复日期:2026-02-23
涉及文件:4 个
修改性质:逻辑修复 + 旧代码彻底清理


一、问题描述

复现场景

以 ICP/USDC 为例:

  • 下限价单:origSz = 142.24 ICP,限价 2.1078
  • 追踪超时(默认 600s),触发撤单
  • 交易所实际成交:72.09 ICP(部分成交后撤单)
  • 系统报告:"开仓失败(超时未成交)"
  • 副作用:72.09 ICP 的真实仓位无人管理,触发"孤儿仓位"误报告警

影响范围

  • 任意限价单在追踪超时时,只要发生过部分成交,均会触发此 Bug
  • 轻则成交数据丢失(需手动核查),重则孤儿仓位触发不当平仓操作

二、根因分析

Bug 1:_track_limit_order() 丢弃 WS 成交数据

WS userFills 事件
    → _accumulate_fill()
    → tracking.filled_size / tracking.avg_price   ← 整个追踪窗口逐笔累计
    → 超时 → return False                         ← 累计数据被直接抛弃!
                                                     order_result 未写入

_track_limit_order() 在超时路径中直接 return False,未将 tracking.filled_size 写回给调用方(order_result),导致已累计的 WS 成交数据完全丢失。

Bug 2:limit_open() 超时处理无法获取成交量

由于 Bug 1,order_result.size 在超时时始终是初始下单量(alt_size),调用方无法区分"无成交"与"有部分成交",直接报告"超时未成交"。

Bug 3(次要):孤儿检测误判

限价单追踪窗口(600s)内,部分成交会在交易所产生真实仓位,但 _positions 中此时尚无对应的 PairPosition 记录(仓位记录在订单完成/超时处理后才写入)。_collect_orphan_candidates() 未检查是否存在活跃的 PENDING 限价单追踪,直接将该仓位标记为"孤儿"。


三、修复方案

核心思路

充分利用已有的 WS userFills 累计数据,消除不必要的 HTTP 查询。

Fix 1: _track_limit_order 超时路径
    → 将 tracking.filled_size 写回 order_result.size
    → 调用方可直接读取,无需额外 HTTP 查询

Fix 2: limit_open 超时处理
    → 双源对比:WS 累计量 vs 实际仓位量(取较大值)
    → 移除旧的 _check_order_after_cancel() 函数(彻底清理)

Fix 3: 孤儿检测
    → 新增 get_pending_coins(),让孤儿检测跳过活跃限价单币种

数据源优先级

优先级 来源 说明
1(主) WS userFills 累计 逐笔实时累计,含精确均价,无 HTTP 开销
2(辅) 实际仓位查询 捡回 WS 与撤单之间窗口期内的漏报成交

两者同时查询,取较大值作为最终成交量。


四、修改文件清单

文件 改动类型 关键函数
src/trading/executor.py 逻辑修复 + 旧代码清理 _track_limit_order, limit_open, query_order_status
src/trading/websocket_order_manager.py 新增方法 get_pending_coins
src/trading/position_manager.py 签名扩展 + 调用点更新 _collect_orphan_candidates, recover_from_exchange, sync_from_exchange
src/utils/websocket/enhanced_ws_manager.py 注释修正 _publish_order_status_events

五、各文件具体改动

5.1 src/trading/executor.py

Fix 1 — _track_limit_order() 超时路径(约 L606–627)

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

# 未成交(超时/取消/拒绝)
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 fill 数据写回:0.0 = 无成交(limit_open 可直接读取,消除调用方歧义)
# userFills 在整个追踪窗口逐笔累计,filled_size/avg_price 始终是最准确的来源
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"限价单{reason}(无 WS 成交记录): {coin} oid={oid}")
return False

关键点order_result.size 在 TIMEOUT 路径返回前被设置为 tracking.filled_size(可以是 0.0 也可以是累计成交量)。通知先发(用原始量),再覆盖(用成交量),语义清晰。


Fix 2 — limit_open() 超时处理(约 L888–967)

改动:移除旧的 _check_order_after_cancel() 调用,改为双源对比逻辑。

# ==================== 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)
        if not cancel_ok:
            logger.warning(f"撤单失败(可能已成交): {alt_coin} oid={result.leg_a.order_id}")

    alt_side = "buy" if alt_is_buy else "sell"

    # 成交量来源:WS 累计(Fix 1 写入)+ position 核查(捡回漏报)
    ws_filled = result.leg_a.size    # 0.0 = 无成交
    ws_avg_px = result.leg_a.price   # 0.0 = 无均价
    pos_filled = self._get_actual_position_size(
        alt_coin, expected_side=alt_side, force_refresh=True
    )
    if pos_filled > ws_filled:
        # position 发现 WS 漏报(_tracking.pop→cancel 窗口内的成交)
        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

关键点

  • ws_filled = result.leg_a.size — 读取 Fix 1 写入的 WS 累计量(默认 0.0)
  • pos_filled — 强制刷新后的实际仓位(捡回 WS 窗口期漏报的成交)
  • pos_filled > ws_filled 时取仓位数据(WS 有漏报)
  • ws_filled > 0 时取 WS 数据(更精确,含均价)
  • 旧的 _check_order_after_cancel() 函数被彻底移除,无死代码残留

Fix 3 — query_order_status() 注释修正(约 L1844–1846)

改动:修正旧注释中关于 totalSz/avgPx 字段的错误描述。

# 旧注释(错误):
# 本方法解包后返回内层 order dict(含 status/avgPx/totalSz 等字段)

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

5.2 src/trading/websocket_order_manager.py

Fix 4 — 新增 get_pending_coins() 方法(约 L586–598)

def get_pending_coins(self) -> set[str]:
    """返回当前正在追踪(PENDING 状态)的限价单币种集合

    供 PositionManager 孤儿检测使用:限价单追踪窗口内,部分成交会在交易所
    产生仓位,但内存中尚无 PairPosition 记录(订单未完成,仓位还未写入)。
    孤儿检测发现该币种在此集合中时应跳过,避免将部分成交仓位误判为外来孤儿。
    """
    with self._lock:
        return {
            t.coin
            for t in self._tracking.values()
            if t.status == OrderStatus.PENDING
        }

5.3 src/trading/position_manager.py

Fix 5A — _collect_orphan_candidates() 签名扩展(约 L1497–1527)

新增 pending_order_coins 参数,在主循环内增加跳过逻辑:

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
        # 限价单追踪窗口内(默认 600s),部分成交会在交易所产生仓位,
        # 但 _positions 中尚无该仓位记录(PairPosition 在订单完成后才写入)。
        # 若此时触发孤儿检测,会将该仓位误判为外来孤儿并错误收纳。
        # 通过 ws_order_manager.get_pending_coins() 排除活跃追踪的币种。
        if pending_order_coins and coin in pending_order_coins:
            logger.debug(
                f"孤儿检测跳过 {coin}:存在 PENDING 限价单追踪,"
                f"等待超时处理完成后再判定"
            )
            continue
        # ... 其余原有逻辑不变 ...

Fix 5B — 两处调用点更新

recover_from_exchange()(约 L1047–1076)sync_from_exchange()(约 L1152–1241) 均更新为:

# 锁外:获取 PENDING 限价单追踪币种(避免在 self._lock 内获取 ws._lock)
_ws_mgr = getattr(self._executor, "_ws_order_manager", None)
pending_order_coins = _ws_mgr.get_pending_coins() if _ws_mgr else set()

# ...(锁内操作)...

# 收集孤儿候选(纯内存)
memory_coins = {symbol_to_coin(k[0]) for k in self._positions}
orphan_candidates = self._collect_orphan_candidates(
    exchange_coins, memory_coins, pending_order_coins
)

注意get_pending_coins() 在锁外调用,避免在持有 self._lock 时再去获取 ws._lock(死锁风险)。


5.4 src/utils/websocket/enhanced_ws_manager.py

Fix 6 — _publish_order_status_events() 注释修正(约 L1424–1451)

澄清 WS orderUpdates 消息的字段语义:

"""将 orderUpdates 原始数据拆分为逐条 OrderStatusEvent

字段级联优先级:status = item.status → order.status(取先非空)
价格:order.limitPx(WS 消息仅含挂单价,avgPx/totalSz 是 HTTP 专有字段,WS 不推送)
数量:order.origSz(原始委托量,filled 时等于成交量)→ order.sz(剩余委托量,备用)
"""

六、关键数据结构验证

WS userFills 消息(实测)

{
  "isSnapshot": false,
  "fills": [{
    "oid": 48939226203,
    "tid": 987654,
    "sz": "12.19",
    "px": "2.1078",
    "coin": "ICP",
    "side": "B",
    "fee": "0.005",
    "closedPnl": "0"
  }]
}

字段 sz 为单笔成交量,px 为成交价。多笔通过 _accumulate_fill() 累计到 tracking.filled_size / avg_price

WS orderUpdates 消息(实测)

[{
  "status": "canceled",
  "order": {
    "oid": 48939226203,
    "limitPx": "2.1078",
    "sz": "70.15",
    "origSz": "142.24"
  }
}]

avgPx、无 totalSzsz 是剩余未成交量,origSz 是原始委托量,limitPx 是挂单价(非成交均价)。

HTTP query_order_by_oid 响应(实测)

{
  "status": "order",
  "order": {
    "order": {
      "sz": "70.15",
      "origSz": "142.24",
      "limitPx": "2.1078"
    },
    "status": "canceled",
    "statusTimestamp": 1740232803000
  }
}

query_order_status() 解包后(返回 raw["order"]):

{
  "order": { "sz": "70.15", "origSz": "142.24", "limitPx": "2.1078" },
  "status": "canceled"
}

同样无 totalSz/avgPx。成交量需计算:origSz - sz = 142.24 - 70.15 = 72.09

字段来源总结

字段 WS userFills WS orderUpdates HTTP query_order_by_oid
成交量(单笔) sz
成交量(累计) 逐笔累计 ✅ origSz - sz 计算 origSz - sz 计算
成交均价 px ❌ 无 ❌ 无(canceled 时)
挂单价 limitPx order.limitPx
原始委托量 origSz order.origSz
剩余未成交量 sz order.sz
totalSz ❌ 不存在 ❌ 不存在 ❌ canceled 时不存在

七、修复前后对比

修复前(Bug 状态)

[超时] _track_limit_order
  → tracking.filled_size = 72.09  (WS 累计,正确)
  → return False                   (数据丢弃)

[超时] limit_open
  → result.leg_a.size = 142.24    (仍是原始下单量)
  → _check_order_after_cancel()
      → query_order_status()
      → totalSz = 0               (canceled 响应无此字段)
      → return 0.0, 0.0           (在 try 内直接返回,绕过降级路径)
  → actual_filled = 0.0
  → 报告:"开仓失败(超时未成交)"  ← 错误!
  → 产生孤儿仓位误报

修复后

[超时] _track_limit_order
  → tracking.filled_size = 72.09  (WS 累计,正确)
  → order_result.size = 72.09     (写回调用方)  ← Fix 1
  → return False

[超时] limit_open
  → ws_filled = 72.09             (读取 Fix 1 写入的值)
  → pos_filled = 72.09            (实际仓位核查)
  → pos_filled == ws_filled → 使用 WS 数据
  → actual_filled = 72.09
  → 报告:"接受 Leg A 部分成交: 成交率 50.7% (72.09/142.24)"  ← 正确!

[孤儿检测] sync_from_exchange
  → get_pending_coins() = {"ICP"}  ← Fix 4
  → _collect_orphan_candidates: 跳过 ICP   ← Fix 5
  → 不触发孤儿告警

八、验证方法

Fix 1 & 2 验证

在测试网下大限价单,调低 limit_order_timeout(如 30s),等待部分成交触发超时撤单:

  • 日志应出现 "限价单超时,WS 部分成交: ... ws_filled=XX.XX""使用 WS 累积成交: ..."
  • actual_filled 应等于真实成交量,不再误报"超时无成交"
  • Leg B 数量按实际成交量计算,不再用原始下单量

Fix 5 验证

下限价单后在 600s 追踪窗口内观察 sync_from_exchange

  • 部分成交产生仓位时,日志应出现 "孤儿检测跳过 {coin}:存在 PENDING 限价单追踪"
  • 不再触发 "孤儿仓位收纳" 告警

回归验证

  • 无追踪订单时(get_pending_coins() 返回空集合),真实孤儿仓位应仍被正确收纳
  • 已全量成交的限价单(tracking.filled_size == alt_size)应走正常完成路径,不进入超时处理

九、移除的旧代码

函数 文件 操作
_check_order_after_cancel() executor.py 完整删除(约 70 行)

旧函数存在多处设计缺陷:

  1. 误用 totalSz(该字段在 canceled 响应中不存在)
  2. total_sz == 0 时在 try 块内 return 0.0, 0.0,绕过 except 后的降级仓位查询
  3. 独立 HTTP 调用带来额外延迟和 TOCTOU 竞态风险

新方案通过 WS 数据写回彻底规避了上述问题,不再需要该函数。

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