部分成交跟单失败+孤儿仓位错误识别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、无 totalSz。sz 是剩余未成交量,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 行) |
旧函数存在多处设计缺陷:
- 误用
totalSz(该字段在 canceled 响应中不存在) total_sz == 0时在try块内return 0.0, 0.0,绕过except后的降级仓位查询- 独立 HTTP 调用带来额外延迟和 TOCTOU 竞态风险
新方案通过 WS 数据写回彻底规避了上述问题,不再需要该函数。