订单跟踪BUG22
订单跟踪系统 Bug 分析报告
分析时间: 2026-02-21
涉及文件:executor.py,position_manager.py,websocket_order_manager.py,realtime_kline_service_base.py
Bug 1: limit_open 中 result.leg_b 已被置为 None 后丢失错误信息
严重度: 中高
文件: executor.py:946-951
result.leg_b = None # ← 先置为 None
result.leg_a.error_message = (
f"Leg B 市价单失败且回滚失败,Leg A 仓位已转为单腿跟踪: "
f"{result.leg_b_error}" # ← 访问不存在的属性 leg_b_error
if hasattr(result, 'leg_b_error') # ← PairOrderResult 没有 leg_b_error 字段
else f"Leg B 市价单失败且回滚失败,Leg A 仓位已转为单腿跟踪"
)
问题分析:
PairOrderResult没有leg_b_error属性,hasattr永远返回False- 虽然当前不会崩溃(走
else分支),但原始的result.leg_b.error_message在leg_b = None之后已经丢失 - 回滚前应该先保存
leg_b的错误信息再置None,这是一个逻辑遗漏
影响: 不会崩溃但会丢失关键的错误诊断信息,排查问题时无法得知 Leg B 失败的具体原因。
修复建议:
leg_b_err = result.leg_b.error_message if result.leg_b else "未知错误"
result.leg_b = None
result.leg_a.error_message = f"Leg B 市价单失败且回滚失败,Leg A 仓位已转为单腿跟踪: {leg_b_err}"
Bug 2: 缓冲区回放的线程安全问题(竞态条件)
严重度: 高
文件: realtime_kline_service_base.py:618-626
if self._order_msg_buffer: # 无锁检查
mgr = self._get_ws_order_manager()
if mgr is not None:
while self._order_msg_buffer:
buffered = self._order_msg_buffer.popleft() # deque.popleft 虽然原子但...
问题分析:
self._order_msg_buffer 是一个普通 deque(第 193 行),没有任何锁保护。on_message 可能从多线程调用(WebSocket 回调线程),同时:
- 线程 A 正在回放缓冲区(
popleft) - 线程 B 正在追加新消息(
append,第 637 行) - 线程 C 也在回放
虽然 CPython 的 GIL 让 deque.popleft() 和 append() 各自原子,但 while self._order_msg_buffer 判空 + popleft() 组合不是原子的。如果两个线程同时进入 while 循环,可能导致 popleft 抛 IndexError(空 deque 上调用)。
影响: 会导致消息处理线程崩溃,订单状态更新丢失。
修复建议:
# 方案 1: 使用 try/except 保护
if self._order_msg_buffer:
mgr = self._get_ws_order_manager()
if mgr is not None:
while True:
try:
buffered = self._order_msg_buffer.popleft()
except IndexError:
break
try:
mgr.handle_message(buffered)
except Exception as e:
self.logger.error(f"回放缓冲订单消息失败: {e}", exc_info=True)
# 方案 2: 使用 threading.Lock 保护
Bug 3: _close_ops 类型标注与实际 tuple 长度不匹配
严重度: 低
文件: position_manager.py:1134 vs 1163-1167
收集阶段 append 了 8 个元素的 tuple:
_close_ops.append((
pos.position_id, now, symbol, pos.base_symbol,
pos.direction, pos.alt_entry_price, pos.alt_size,
ghost_pnl, # 第8个
))
但类型声明只有 7 个类型:
_close_ops: list[tuple[str, datetime, str, str, str, float, float]] = []
消费阶段用 *rest 解包然后取 rest[-1]:
for pos_id, close_time, *rest in _close_ops:
ghost_pnl = rest[-1] if rest else 0.0
影响: 当前运行正确(rest[-1] 确实是 ghost_pnl),但类型标注与实际数据不符,是维护隐患。
修复建议: 更新类型标注为 8 元素 tuple,或使用 @dataclass 替代 tuple。
Bug 4: open_time 使用本地时间而非 UTC
严重度: 中
文件: position_manager.py:154
open_time=datetime.now().astimezone(),
问题分析:
datetime.now().astimezone() 获取的是本地时区时间。如果系统部署在不同时区(如 Docker 容器默认 UTC vs 开发机 UTC+8),会导致:
- 持仓时长计算错误
- 止损超时判断偏差
- DB 中时间不一致
同样问题出现在 close_time, _adopt_residual_base_leg 等多处。
影响: 在单机部署下不影响,但跨时区/容器部署时会引发持仓时长误判。
修复建议:
open_time=datetime.now(timezone.utc),
Bug 5: _get_actual_position_size 在限价单超时后使用缓存数据
严重度: 高
文件: executor.py:664-676
def _get_actual_position_size(self, coin: str, expected_side: str | None = None) -> float:
try:
positions = self.get_positions() # ← 可能返回缓存(5s TTL)
在 limit_open 超时处理(第 826 行)中调用 _get_actual_position_size,但 get_positions() 默认使用缓存。限价单超时撤单后需要查询实时持仓量来判断部分成交量,使用缓存可能返回过时数据。
后果:
- 缓存显示有持仓(但实际撤单已清零)→ 创建了错误的仓位
- 缓存显示无持仓(但实际已部分成交)→ 丢失了已成交的部分仓位,交易所有残留仓位无人管理
影响: 直接导致资金损失风险。
修复建议:
def _get_actual_position_size(self, coin: str, expected_side: str | None = None,
force_refresh: bool = False) -> float:
try:
positions = self.get_positions(force_refresh=force_refresh)
调用处改为:
actual_filled = self._get_actual_position_size(alt_coin, expected_side=alt_side, force_refresh=True)
Bug 6: limit_close 撤单后 _get_actual_position_size 同样使用缓存
严重度: 高
文件: executor.py:1023, 1064
与 Bug 5 相同的问题,在平仓超时撤单后也使用了缓存数据:
remaining_size = self._get_actual_position_size(alt_coin, expected_side=position.alt_side)
# ...
remaining_base = self._get_actual_position_size(base_coin, expected_side=position.base_side)
后果:
- 缓存显示仍有残余持仓 → 执行市价平仓更大数量 → 可能导致仓位翻转(虽然有 reduceOnly 保护,但如果 size > 实际持仓,reduceOnly 会被拒绝)
- 缓存显示无残余 → 视为已成交 → 但实际仍有仓位残留
影响: 平仓时使用过期数据,可能导致仓位残留或平仓失败。
修复建议: 同 Bug 5,对 _get_actual_position_size 增加 force_refresh 参数,在撤单后强制刷新。
Bug 7: sync_with_exchange 中 closed_pairs 可能重复追加
严重度: 低
文件: position_manager.py:1259-1263
close_result = self.close_position(
lost_symbol, lost_base, reason="base_leg_lost", force_market=True
)
if close_result:
closed_pairs.append(lost_key) # ← 已在 1168 行通过幽灵清理追加过?
问题分析:
如果同一个 key 既因为 alt 腿在交易所消失被幽灵清理(第 1168 行 closed_pairs.append(key)),又因为 base 腿消失触发强制平仓,会导致 closed_pairs 中出现重复的 key。
影响: 目前返回值仅用于日志,不影响核心逻辑。但如果上层依赖这个列表做去重操作,会有问题。
Bug 8: _monitor_order 早期 HTTP 检查与 WS 消息处理的竞态窗口
严重度: 低
文件: websocket_order_manager.py:404-421
# 检查是否已有 WS 终态
with self._lock:
if tracking._ws_status is not None:
return
current = self._tracking.get(oid)
if current is not tracking:
return
if tracking.status != OrderStatus.PENDING:
return
# ← 释放锁
# ← 此处 WS 消息到达,_on_order_update 设置 _ws_status 并启动 grace timer
logger.info(f"早期 HTTP 检查: {tracking.coin} oid={oid}")
result = self._http_check(oid) # ← HTTP 查询期间 grace timer 触发 _finish()
问题分析:
在释放锁和 _http_check 之间,如果 WS orderUpdates 消息到达并设置了 _ws_status = FILLED + 启动了 grace timer,然后 grace timer 5 秒后触发 _finish() 完成订单。同时 _http_check 也查到了 filled 并调用 _finish_direct()。
由于 _finish 和 _finish_direct 都在锁内检查 tracking.status != OrderStatus.PENDING 才操作,所以不会重复结算。但如果 HTTP 查到 filled 的价格与 userFills 不同,先完成的路径会使用其价格,可能导致价格记录不是最准确的 userFills 价格。
影响: identity check 保证了不会重复结算,但价格精度可能受影响。
总结
| 严重度 | Bug | 文件 | 影响 |
|---|---|---|---|
| 高 | Bug 5 | executor.py:664 |
限价单超时后用缓存判断部分成交量,可能创建错误仓位或丢失仓位 |
| 高 | Bug 6 | executor.py:1023,1064 |
平仓撤单后用缓存查残余持仓,可能残留无人管理仓位 |
| 高 | Bug 2 | realtime_kline_service_base.py:618 |
缓冲区回放无锁保护,多线程下可能 IndexError 导致消息处理崩溃 |
| 中高 | Bug 1 | executor.py:946 |
leg_b=None 后丢失错误信息,诊断困难 |
| 中 | Bug 4 | position_manager.py:154 |
跨时区部署时持仓时长/超时计算偏差 |
| 低 | Bug 3 | position_manager.py:1134 |
类型标注与实际 tuple 长度不匹配 |
| 低 | Bug 7 | position_manager.py:1259 |
closed_pairs 列表可能重复追加 |
| 低 | Bug 8 | websocket_order_manager.py:404 |
HTTP 与 WS 竞态可能影响价格精度 |
最需要立即修复的是 Bug 5 和 Bug 6:在限价单超时撤单后,应使用 self.get_positions(force_refresh=True) 替代 self.get_positions() 来获取实时持仓数据,避免缓存导致的仓位判断错误。这在真实交易中可能直接导致资金损失。