订单跟踪bug18
订单跟踪系统严重 Bug 分析报告
分析日期: 2026-02-21
分析范围:websocket_order_manager.py,executor.py,position_manager.py,risk_manager.py,orchestrator.py
Bug 1: 止损/同步触发平仓时,每日统计双重计数 (严重)
位置: orchestrator.py:814-818 + position_manager.py:572-578
问题: 当止损监控线程或仓位同步线程触发平仓时,update_daily_stats(trades_closed=1, realized_pnl=...) 被调用两次:
position_manager._execute_close()内部调用一次(line 574-578)orchestrator._close_with_retry()成功后又调用一次(line 814-818)
# position_manager.py:572-578 (第一次)
self._risk_manager.update_daily_pnl(realized_pnl)
self._repo.update_daily_stats(
network=self._config.network.value,
trades_closed=1,
realized_pnl=realized_pnl,
)
# orchestrator.py:814-818 (第二次 - 重复!)
self._trade_repo.update_daily_stats(
network=self._config.network.value,
trades_closed=1,
realized_pnl=closed.realized_pnl,
)
影响: 所有通过止损、移动止损、持仓超时、KillSwitch、base腿消失触发的平仓,trades_closed 计数翻倍,realized_pnl 翻倍。导致每日统计报表数据严重失真,可能触发错误的每日亏损限制判断。
注意: 信号驱动的平仓路径(on_exit_signal)没有这个问题,因为它只更新 exit_signals=1,不重复 trades_closed。
修复建议: 移除 orchestrator._close_with_retry() 中的 update_daily_stats 调用,统一由 position_manager._execute_close() 负责统计更新。
Bug 2: _close_with_retry 误报平仓失败告警 (严重)
位置: orchestrator.py:793-862
问题: 当多个线程并发尝试平仓同一仓位时(例如止损监控线程和均值回归退场信号同时触发),第一个线程成功平仓后,仓位从 _positions 中移除。_close_with_retry 的重试循环中,后续重试调用 close_position() 会返回 None(因为仓位已不存在),被误判为"失败":
for attempt in range(1, max_retries + 1):
close_result = self._position_manager.close_position(...)
if close_result:
return # 成功
# close_result 为 None → 视为失败,继续重试
...
else:
# 所有重试"失败"
self._strategy.on_exit_failed(...) # 错误!仓位实际已平仓
sender_colourful(title="🚨 平仓重试全部失败: ...") # 误报告警
影响:
- 策略引擎收到错误的
on_exit_failed通知,可能在下次 tick 产生不必要的重试退场信号 - 发送虚假的"平仓重试全部失败"高优先级告警,造成运维噪音
- 如果仓位已被另一线程成功平仓并清除策略状态,
on_exit_failed可能导致状态不一致
修复建议: 在重试循环中增加"仓位已不存在"的检测逻辑:
for attempt in range(1, max_retries + 1):
# 先检查仓位是否仍存在
if not self._position_manager.has_position(pos.symbol, pos.base_symbol):
logger.info(f"仓位已被其他路径平仓,无需重试: {pair_label}")
# 同步策略引擎清除状态
self._strategy.on_position_closed(pos.symbol, pos.base_symbol)
self._pair_rate_limiters.pop((pos.symbol, pos.base_symbol), None)
return
close_result = self._position_manager.close_position(...)
...
Bug 3: Leg B 下单失败且回滚也失败时,仓位状态不一致 (严重)
位置: executor.py:916-917 + position_manager.py:163-175
问题: 在 limit_open 中,当 Leg B 市价单失败后回滚 Leg A 也失败时:
# executor.py:916-917
else:
# 回滚失败:result.leg_a.success 仍为 True!
result.leg_a.error_message = "...回滚失败,Leg A 仓位需人工处理..."
result.leg_a.success 没有被设为 False。回到 position_manager._open_position_inner:
# position_manager.py:163
if order_result.leg_a and order_result.leg_a.success: # True!
...
position.status = PositionStatus.OPEN # 标记为 OPEN
self._positions[_pair_key(position)] = position # 加入内存
仓位以 pair_mode="pair" 但实际无 base 腿的状态被记录。交易所上:
- Leg A 有持仓(回滚失败)
- Leg B 无持仓(下单失败)
后续连锁问题:
- 平仓时尝试平仓不存在的 base 腿:
_execute_close同步交易所持仓时,发现 base_coin 无持仓但position.base_size仍为非零值(预计算值),代码会尝试平仓不存在的 base 腿(pair_mode == "pair" and base_size > 0),浪费 API 调用并可能产生误操作 - PnL 计算包含虚假的 base 腿盈亏:
_calculate_realized_pnl使用pair_mode == "pair"分支,计算包含不存在的 base 腿 - 止损监控计算不准确:
_calculate_pnl_pct使用加权平均,但 base 腿的base_current_price和base_entry_price可能不一致
修复建议: 回滚失败时,将仓位降级为 pair_mode="single" 并清零 base 相关字段:
# executor.py - 回滚失败分支
else:
result.leg_a.error_message = "..."
# 不设 success=False(保留 Leg A 追踪),但通知上层降级
result.leg_b_market_order = False # 标记 Leg B 未执行
# position_manager.py - 检测回滚失败场景
if order_result.leg_a and order_result.leg_a.success:
...
if self._config.pair_mode == "pair" and (not order_result.leg_b or not order_result.leg_b.success):
# Leg B 失败:降级为 single 模式
position.pair_mode = "single"
position.base_size = 0.0
position.base_symbol = ""
Bug 4: 重复 "filled" orderUpdate 消息导致 Grace Timer 泄漏 (中等)
位置: websocket_order_manager.py:312-328
问题: 当 WebSocket 收到同一订单的重复 "filled" 消息时(网络抖动或交易所重发),如果 has_fill_price 仍为 False:
# 第一条 "filled" 消息: 创建 timer_1,赋值给 tracking._grace_timer
timer = threading.Timer(self._FILL_GRACE_SEC, self._finish, [oid])
tracking._grace_timer = timer # timer_1
timer.start()
# 第二条 "filled" 消息: 创建 timer_2,覆盖 tracking._grace_timer
timer = threading.Timer(self._FILL_GRACE_SEC, self._finish, [oid])
tracking._grace_timer = timer # timer_2(timer_1 引用丢失!)
timer.start()
timer_1 的线程仍在运行,但其引用已丢失,无法被取消。当 _finish(oid) 在 line 213-215 取消 tracking._grace_timer 时,只取消了 timer_2,timer_1 仍会触发:
# _finish() 只取消 tracking._grace_timer (timer_2)
if tracking._grace_timer:
tracking._grace_timer.cancel() # 只取消 timer_2
影响:
- 线程资源泄漏(每次重复消息泄漏一个 Timer 线程)
- 宽限期实际被缩短(timer_1 先于 timer_2 触发),可能在 userFills 到达前就以 fallback 价格完成解析
修复建议: 在创建新 timer 前取消旧 timer:
if status_str == "filled":
tracking._ws_status = OrderStatus.FILLED
tracking._fallback_px = ...
tracking._fallback_sz = ...
if tracking.has_fill_price:
should_finish = True
else:
# 先取消已有的 timer
if tracking._grace_timer:
tracking._grace_timer.cancel()
timer = threading.Timer(self._FILL_GRACE_SEC, self._finish, [oid])
timer.daemon = True
tracking._grace_timer = timer
timer.start()
Bug 5: _get_actual_position_size 无法区分同一币种的不同配对仓位 (中等)
位置: executor.py:651-666
问题: _get_actual_position_size(coin) 查询交易所返回该币种的总持仓量。如果系统允许同一币种参与多个不同的配对(例如 ETH|BTC 和 ETH|SOL),此方法返回的是所有 ETH 持仓的合计,而非某个特定配对的持仓:
def _get_actual_position_size(self, coin: str) -> float:
positions = self.get_positions()
for pos in positions:
if pos.get("coin") == coin:
return abs(float(pos.get("szi", 0))) # 返回该 coin 的总持仓
return 0.0
影响:
- 限价单超时后的部分成交计算中,
actual_filled可能包含其他配对的持仓 - Leg B 数量计算基于错误的 Leg A 成交价值,导致两腿名义价值不匹配
- 平仓时
remaining_size可能包含其他配对的持仓,导致超额平仓
当前系统的 _positions 使用 dict[PairKey, PairPosition] 设计为单仓模式,实际中同一币种参与多个配对的概率取决于策略配置。但当孤儿仓位收纳(_adopt_residual_base_leg)将 base 腿转为独立仓位后,同一币种可能同时出现在配对仓位和独立仓位中。
修复建议: 在调用 _get_actual_position_size 时,传入预期的持仓方向和已知的其他配对持仓量,进行差值计算:
def _get_actual_position_size(self, coin: str, expected_side: str = None) -> float:
positions = self.get_positions()
for pos in positions:
if pos.get("coin") == coin:
szi = float(pos.get("szi", 0))
if expected_side and (
(expected_side == "buy" and szi < 0)
or (expected_side == "sell" and szi > 0)
):
return 0.0 # 方向不符
return abs(szi)
return 0.0
Bug 6: peak_pnl_pct 多线程读写无锁保护 (中等)
位置: risk_manager.py:246 vs position_manager.py:1136
问题: position.peak_pnl_pct 在多个线程中被读写,但没有统一的锁保护:
# risk_manager.py:246 (止损监控线程,无 position_manager._lock)
position.peak_pnl_pct = max(position.peak_pnl_pct, current_pnl_pct)
# position_manager.py:1136 (同步线程,在 _lock 内)
if pos.peak_pnl_pct > 0:
_runtime_ops.append((pos.position_id, pos.peak_pnl_pct))
_update_trailing_peak 中的 max() 操作不是原子的:读取 peak_pnl_pct → 计算 max() → 写入 peak_pnl_pct 之间可能被另一线程打断,导致写入的值覆盖了另一线程刚更新的更高峰值。
影响:
- 移动止损的峰值追踪可能丢失真实峰值
- 在极端行情中,峰值被错误降低可能导致移动止损提前触发,造成不必要的平仓
修复建议: 在 _update_trailing_peak 中使用 position_manager._lock,或使用 threading.Lock 保护每个 position 的 peak_pnl_pct。鉴于 CPython GIL 的存在,简单属性赋值在实践中是原子的,但 max() 的读-计算-写序列不是。可考虑:
# 方案一:在止损监控中复用 position_manager 的锁
def _stop_loss_monitor(self):
...
with self._position_manager._lock:
for pos in ...:
self._risk_manager._update_trailing_peak(pos)
# 同时做止损检查
Bug 7: WebSocket 重连期间订单跟踪窗口期 (中等)
位置: executor.py:1554-1573
问题: WebSocket 重连事件处理中,verify_pending_orders 在独立线程中执行:
def _on_websocket_reconnected(self, event):
# 清空缓存
with self._cache_lock:
self._cached_positions = []
...
# 独立线程补查(非阻塞)
if self._ws_order_manager:
threading.Thread(
target=self._ws_order_manager.verify_pending_orders,
daemon=True,
).start()
在重连和补查完成之间,如果策略引擎产生新的入场信号并下单:
- 新订单注册到
_tracking - WS 订阅可能尚未完全恢复
verify_pending_orders只查重连前已有的 PENDING 订单,不包含新订单- 新订单的 WS 消息可能丢失
虽然 _timeout_loop 最终会通过 HTTP 兜底,但这意味着新订单的成交确认会延迟至 timeout_seconds(默认 600s),限价单追踪严重滞后。
修复建议: 在 WS 未完全恢复前,暂停新订单的下单:
def _on_websocket_reconnected(self, event):
self._ws_recovering = True # 新增恢复中标志
...
def _verify_and_resume():
self._ws_order_manager.verify_pending_orders()
self._ws_recovering = False # 恢复完成
threading.Thread(target=_verify_and_resume, daemon=True).start()
在下单前检查:
def _place_limit_order(self, ...):
if self._ws_recovering:
logger.warning("WS 恢复中,延迟下单")
# 等待恢复或降级为市价单
Bug 8: _execute_close 异常后仓位状态恢复不完整 (中等)
位置: position_manager.py:302-332
问题: 如果 _execute_close 在 Leg A 成功平仓后、但在更新 DB 或处理 Leg B 时抛出异常:
try:
return self._execute_close(key, position, signal, reason, force_market)
except Exception as e:
with self._lock:
if (key in self._positions
and self._positions[key] is position
and position.status == PositionStatus.CLOSING):
position.status = PositionStatus.OPEN # 恢复为 OPEN
此时:
- 交易所上 Leg A 已经平仓(不可逆)
- 内存中仓位恢复为 OPEN
alt_size可能已被_execute_close内部修改为交易所实际值
后续影响:
- 止损监控线程检测到仓位仍为 OPEN,再次尝试平仓
- 交易所上 alt 腿已不存在,触发
alt_gone_from_exchange分支 - 以估算 PnL 关闭仓位(使用
mid_price或entry_price而非实际成交价) - 过程中产生不必要的"交易所已无持仓"告警
虽然系统最终能自愈(通过仓位同步),但中间过程的 PnL 计算不准确且产生噪音告警。
修复建议: 在 _execute_close 内部,Leg A 平仓成功后立即标记,异常恢复时据此判断:
# 方案:在 position 上增加 _leg_a_closed 临时标记
try:
return self._execute_close(key, position, signal, reason, force_market)
except Exception as e:
with self._lock:
if hasattr(position, '_leg_a_closed') and position._leg_a_closed:
# Leg A 已执行,不应恢复为 OPEN
logger.error(f"Leg A 已平仓但后续处理异常,保持 CLOSING 状态待人工处理")
elif (...):
position.status = PositionStatus.OPEN
总结
| 严重级别 | Bug | 影响范围 | 修复优先级 |
|---|---|---|---|
| 严重 | #1 每日统计双重计数 | 所有止损/同步平仓,统计数据失真 | P0 |
| 严重 | #2 误报平仓失败告警 | 并发平仓场景,虚假告警+策略状态错误 | P0 |
| 严重 | #3 回滚失败后仓位状态不一致 | Pair模式 Leg B 失败,连锁错误 | P1 |
| 中等 | #4 Grace Timer 泄漏 | 重复 WS 消息,资源泄漏+宽限期缩短 | P1 |
| 中等 | #5 同币种多配对持仓计算错误 | 多配对共用同一币种,金额计算偏差 | P2 |
| 中等 | #6 peak_pnl_pct 竞态 | 移动止损精度,可能提前触发 | P2 |
| 中等 | #7 重连窗口期订单延迟 | WS 断连期间新订单,追踪延迟 | P2 |
| 中等 | #8 异常恢复后状态不完整 | 平仓过程异常,PnL 不准确 | P2 |
建议修复顺序
- Bug #1 (一行代码修复): 删除
orchestrator._close_with_retry中的update_daily_stats调用 - Bug #2 (简单逻辑修复): 重试前检查仓位是否仍存在
- Bug #4 (一行代码修复): 创建新 timer 前取消旧 timer
- Bug #3 (需设计评审): 回滚失败时降级 pair_mode 的完整方案
- Bug #5-#8 (按需修复): 根据实际运行中的影响频率决定优先级