双WebSocket架构设计bug1
Bug 3:限价单跟丢 — 孤儿仓位误报 + 开仓失败误判
一、现象还原
测试网告警序列(2026-02-22 22:30–22:40):
| 时间 | 告警 | 说明 |
|---|---|---|
| 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 |
Hyperliquid 交易历史实际成交记录:
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 实际成交,但系统报告「开仓失败」且产生「孤儿仓位」告警。
二、完整因果链
因果链 A:孤儿仓位误报(次要 Bug)
输入
└─ 22:30:03 交易所出现 12.19 ICP 仓位(第一笔部分成交)
状态
├─ exchange_positions["ICP"] = {szi: 12.19}
├─ executor._ws_order_manager._tracking = {48939226203: OrderTracking(PENDING)}
└─ position_manager._positions = {} ← 内存为空(订单未完成)
position_manager._opening_pairs = {("ICP/USDC:USDC","")}
调用路径
sync_from_exchange() ← 定时任务触发(约每分钟一次)
└─ memory_coins = {} ← _positions 为空
└─ _collect_orphan_candidates(
exchange_coins, memory_coins={}
)
for coin="ICP":
"ICP" in memory_coins → False ← 不在内存,继续
# ❌ 没有检查 _ws_order_manager._tracking 是否有该币 PENDING 订单
candidates["ICP"] = {szi:12.19} ← 误判为孤儿
└─ _detect_and_adopt_orphans(candidates)
→ _positions[("ICP/USDC:USDC","")] = PairPosition(size=12.19)
→ 写 DB,发告警
出错点
position_manager.py:_collect_orphan_candidates()
只检查 memory_coins,不检查 ws_order_manager 的活跃追踪状态
根因
PositionManager 不感知 WebSocketOrderManager 的追踪状态。
限价单 600s 等待窗口内,部分成交产生的交易所仓位对
position_manager 完全不可见,被当作外部孤儿处理。
因果链 B:开仓失败误判(主要 Bug)
输入
└─ 22:40:00 tracking.timeout_seconds=600 到期
第一段:超时触发
_monitor_order_inner()
→ HTTP check → order 状态 "open"(订单还在挂)
→ _resolve(oid, tracking, TIMEOUT)
→ tracking.status = TIMEOUT,result_event.set()
_track_limit_order()
wait_for_order() 解阻塞 → 返回 False
order_result.status 仍为 "resting" ← 从未更新
发告警:❌ 限价单timeout: ICP
return False
第二段:limit_open 超时处理
leg_a_filled = False
→ _cancel_order("ICP", 48939226203) ← 撤单,70.15 剩余取消
→ _check_order_after_cancel(
coin="ICP", oid=48939226203, expected_side="buy"
)
# Hyperliquid query_order_by_oid 原始响应:
# raw = {
# "status": "order",
# "order": {
# "order": {"sz": "70.15", "limitPx": "2.1078", ...}, ← sz=剩余量
# "status": "canceled",
# "statusTimestamp": 1740232803000
# }
# }
# query_order_status() 解包后返回 raw["order"]:
# resp = {"order":{sz:"70.15",...}, "status":"canceled", ...}
raw_status = "canceled"
total_sz = _safe_pos_float(
resp.get("totalSz") → None(字段不存在)
or resp.get("total_sz") → None
) = 0.0
avg_px = 0.0
if total_sz > 0: ... ← False,跳过
if total_sz > 0: ... ← False,跳过
# ❌ 关键出错点(executor.py:797-798):
logger.info("撤单后订单未成交")
return 0.0, 0.0 ← try 块内提前 return,降级路径被绕过!
except: ← 无异常,不触发
...
# 降级路径(_get_actual_position_size)永远不执行
actual_filled = 0.0
→ result.leg_a.success = False
→ result.leg_a.error_message = "限价单超时未成交(600s)"
→ return result(leg_a.status 仍为 "resting")
第三段:_open_position_inner 报失败
order_result.leg_a.success = False
→ 发告警:🚨 开仓失败,订单状态: resting
→ 保存 ERROR 状态仓位到 DB,返回 None
出错点
executor.py:797-798,_check_order_after_cancel()
根因
Hyperliquid HTTP 订单查询(query_order_by_oid)对已撤销/部分成交订单的
响应结构中:
sz = 剩余未成交量(70.15),非累计成交量
totalSz = 字段不存在(totalSz 仅出现在 WS orderUpdates 推送消息中)
avgPx = 不存在(或 0)
代码将 totalSz=0 等价于"无成交",并在 try 块内 return 0.0,0.0,
绕过了 except 块后的降级路径 _get_actual_position_size()。
72.09 ICP 的真实成交被完全忽略。
两个 Bug 的关系
Hyperliquid HTTP 订单查询
canceled 响应 = {sz:剩余量} ≠ WS 推送 {totalSz:成交量}
│
┌───────────┴───────────────┐
│ Bug A(次要) │ Bug B(主要)
│ _collect_orphan_ │ _check_order_after_cancel
│ candidates() │ totalSz=0 → return 0.0,0.0
│ 不感知活跃限价单追踪 │ 降级路径被 try 块绕过
▼ ▼
部分成交 → 孤儿告警 72.09 成交 → 开仓失败告警
_positions 写入错误记录 leg_a.status 永久 resting
三、修复方案
Fix 1(主要)— src/trading/executor.py
位置:_check_order_after_cancel(),第 769–804 行
改动:移除 try 块内的 return 0.0, 0.0,改为 fall-through 到仓位降级查询。
# 修复前
if total_sz > 0: return total_sz, avg_px
if total_sz > 0: return total_sz, avg_px
logger.info(f"撤单后订单未成交: {coin} oid={oid} status={raw_status}")
return 0.0, 0.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"))
if total_sz > 0:
logger.info(
f"撤单后订单成交: {coin} oid={oid} | "
f"status={raw_status} sz={total_sz} px={avg_px}"
)
return total_sz, avg_px
# totalSz 缺失(Hyperliquid canceled 订单 HTTP 响应无此字段)
# 不能据此判断无成交,降级仓位查询
avg_px_hint = avg_px
logger.info(
f"撤单后订单 totalSz 缺失({raw_status}),降级仓位查询: "
f"{coin} oid={oid}"
)
except Exception as e:
logger.warning(f"撤单后订单查询失败,降级到仓位查询: {coin} oid={oid} | {e}")
# 降级:查仓位(totalSz 缺失 或 查询异常 均走此路径)
actual = self._get_actual_position_size(
coin, expected_side=expected_side, force_refresh=True
)
return actual, avg_px_hint
关键变化:
- 引入
avg_px_hint保留 HTTP 中可能存在的均价(供后续回填) - 移除
return 0.0, 0.0,totalSz=0时 fall-through 到仓位查询 - 降级路径(
_get_actual_position_size)现在对「totalSz 缺失」和「查询异常」均可达
Fix 2(次要)— src/trading/websocket_order_manager.py
位置:WebSocketOrderManager 类末尾新增方法
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 3(次要)— src/trading/position_manager.py
修改点 A:_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
# 跳过有活跃 PENDING 限价单的币种,防止部分成交误判为孤儿
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
修改点 B:recover_from_exchange() 和 sync_from_exchange() 中的两处调用,
分别在第 1069–1070 行和第 1229–1230 行,更新为:
# 获取活跃限价单追踪的币种(如果 ws_order_manager 已初始化)
_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
)
四、修改文件清单
| 文件 | 函数 | 行号(约) | 改动类型 |
|---|---|---|---|
src/trading/executor.py |
_check_order_after_cancel |
769–804 | 逻辑修改 |
src/trading/websocket_order_manager.py |
新增 get_pending_coins |
类末尾 | 新增方法 |
src/trading/position_manager.py |
_collect_orphan_candidates |
1485–1501 | 签名+逻辑 |
src/trading/position_manager.py |
recover_from_exchange 调用点 |
~1069 | 更新调用 |
src/trading/position_manager.py |
sync_from_exchange 调用点 |
~1229 | 更新调用 |
五、验证方法
-
Fix 1 验证:测试网下一笔大限价单,临时调低
limit_order_timeout(如 30s),
等待部分成交后触发超时撤单,确认日志出现「降级仓位查询」而非「订单未成交」,
且不再产生「开仓失败」告警。 -
Fix 2/3 验证:下一笔限价单并等待部分成交,在 600s 等待窗口内观察
sync_from_exchange是否还触发「孤儿仓位收纳」告警。 -
回归检查:确认真实孤儿仓位(无追踪订单、无 DB 记录)仍能被正确收纳,
get_pending_coins()返回空集合时行为与修改前完全一致。