订单跟踪严重 Bug 分析2
Bug 3 最优修复方案:限价单部分成交跟丢
一、问题重述与根因校正
现象
限价单部分成交(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 被误判为无成交
二、最优修复方案:三层数据源
设计原则
- WS userFills 累计数据优先:这是整个 600s 窗口内逐笔实时累计的结果,精度最高,无需额外 HTTP 请求
- HTTP origSz-sz 作为后备:应对 WS 事件延迟或极端时序边界(超时触发→撤单到达交易所之间的微量成交)
- 仓位查询作为最终兜底:仅当 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 | 更新调用 |
七、验证方法
-
Fix 1+2 主路径:测试网下限价单(价格设在盘口附近等待成交),临时调低
limit_order_timeout(如 60s),触发超时撤单:- 日志出现
"WS 累计部分成交"+ 成交量/均价 actual_filled正确,不再出现「开仓失败」- Leg B 正常基于 Leg A 实际成交量计算
- 日志出现
-
Fix 3 备用路径:模拟 WS 数据丢失(暂时在
_accumulate_fill加 return),验证 HTTP origSz-sz 路径:- 日志出现
"通过 origSz-sz 计算成交" - 结果与真实成交量一致
- 日志出现
-
Fix 4/5 孤儿检测:在 600s 窗口内观察
sync_from_exchange:- 部分成交产生仓位时,日志出现
"孤儿检测跳过 {coin}" - 不触发「孤儿仓位收纳」告警
- 部分成交产生仓位时,日志出现
-
回归:真实孤儿(无追踪、无 DB 记录)仍被正确收纳;
get_pending_coins()返回空集合时行为与修改前完全一致。