订单跟踪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=...) 被调用两次:

  1. position_manager._execute_close() 内部调用一次(line 574-578)
  2. 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="🚨 平仓重试全部失败: ...")  # 误报告警

影响:

  1. 策略引擎收到错误的 on_exit_failed 通知,可能在下次 tick 产生不必要的重试退场信号
  2. 发送虚假的"平仓重试全部失败"高优先级告警,造成运维噪音
  3. 如果仓位已被另一线程成功平仓并清除策略状态,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 无持仓(下单失败)

后续连锁问题:

  1. 平仓时尝试平仓不存在的 base 腿: _execute_close 同步交易所持仓时,发现 base_coin 无持仓但 position.base_size 仍为非零值(预计算值),代码会尝试平仓不存在的 base 腿(pair_mode == "pair" and base_size > 0),浪费 API 调用并可能产生误操作
  2. PnL 计算包含虚假的 base 腿盈亏: _calculate_realized_pnl 使用 pair_mode == "pair" 分支,计算包含不存在的 base 腿
  3. 止损监控计算不准确: _calculate_pnl_pct 使用加权平均,但 base 腿的 base_current_pricebase_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_2timer_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|BTCETH|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()

在重连和补查完成之间,如果策略引擎产生新的入场信号并下单:

  1. 新订单注册到 _tracking
  2. WS 订阅可能尚未完全恢复
  3. verify_pending_orders 只查重连前已有的 PENDING 订单,不包含新订单
  4. 新订单的 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 内部修改为交易所实际值

后续影响:

  1. 止损监控线程检测到仓位仍为 OPEN,再次尝试平仓
  2. 交易所上 alt 腿已不存在,触发 alt_gone_from_exchange 分支
  3. 以估算 PnL 关闭仓位(使用 mid_priceentry_price 而非实际成交价)
  4. 过程中产生不必要的"交易所已无持仓"告警

虽然系统最终能自愈(通过仓位同步),但中间过程的 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

建议修复顺序

  1. Bug #1 (一行代码修复): 删除 orchestrator._close_with_retry 中的 update_daily_stats 调用
  2. Bug #2 (简单逻辑修复): 重试前检查仓位是否仍存在
  3. Bug #4 (一行代码修复): 创建新 timer 前取消旧 timer
  4. Bug #3 (需设计评审): 回滚失败时降级 pair_mode 的完整方案
  5. Bug #5-#8 (按需修复): 根据实际运行中的影响频率决定优先级

Read more

跑步的技巧(滚动落地)

“滚动落地(rolling contact / rolling foot strike)”不是一种教条式的“脚法”,而是一种 让冲击沿着整只脚、整条后链逐级传递的落地机制。 它的核心不是“你先用哪儿着地”,而是: 你的脚落地之后,冲击是不是像轮子一样滚过去,而不是像锤子一样砸下去。 这就是滚动落地的本质。 一、什么叫“滚动落地”? 你可以把它理解成两种完全不同的落地方式: 1. 砸地(撞击式) 脚像锤子一样拍到地上: * 要么后跟先砸 * 要么前掌先戳 * 冲击集中在一个点 * 一个结构瞬间吃掉大部分载荷 结果就是: * 后跟砸 → 膝盖难受 * 前掌戳 → 前脚掌磨烂 * 都不是长跑友好模式 这叫 撞击式着地(impact strike)。 2. 滚地(滚动式) 脚像轮胎一样“滚”过地面: * 不是某一点硬砸 * 而是外侧中足先轻触 * 再向前滚到前掌 * 最后从大脚趾蹬离

By SHI XIAOLONG

AMI的优越性

世界模型(World Models)的具体例子 如下,我按类型分类,便于理解。每类都附带实际实现、演示效果和应用场景。 1. Yann LeCun / Meta 的 JEPA 系列(最直接对应“世界模型”概念) 这些是 LeCun 主张的非生成式抽象预测世界模型代表。 * I-JEPA(Image JEPA,2023) 输入一张图像,模型把不同区域(context 和 target)编码成抽象表示,然后预测 target 的表示(不在像素级别重建)。 例子:给定一张遮挡了部分物体的图片,模型能预测“被遮挡物体的大致位置和属性”,构建对物体持久性和空间关系的理解。 这是一个“原始世界模型”,能学习物理常识(如物体不会凭空消失)。 * V-JEPA / V-JEPA 2(Video JEPA,

By SHI XIAOLONG

什么是:“世界模型(World Models)”

世界模型(World Models) 是人工智能领域的一个核心概念,尤其在 Yann LeCun 等研究者推动的下一代 AI 架构中占据中心位置。它指的是 AI 系统在内部构建的对现实世界的抽象模拟或内部表示,让机器能够像人类或动物一样“理解”物理世界、预测未来、规划行动。 简单比喻 想象你闭上眼睛也能“看到”房间里的物体会如何移动、碰撞或掉落——这就是你大脑里的世界模型。AI 的世界模型就是类似的“数字孪生”(digital twin)或“内部模拟器”:它不是简单记住数据,而是学习世界的动态、因果关系和物理直觉(如重力、物体持久性、遮挡、因果等)。 为什么需要世界模型? 当前主流的大型语言模型(LLM) 擅长处理文本(统计模式预测),但存在根本局限: * 缺乏对物理世界的真正理解 → 容易“幻觉”、无法可靠规划。 * 样本效率低 → 人类/

By SHI XIAOLONG

K线周期可配置化设计方案

K线周期可配置化设计方案 1. 背景与目标 当前 Beta 套利策略的 K 线周期硬编码为 "1h",分散在多个文件中。需要: 1. 将 K 线周期从 1h 改为 2h 2. 提取为环境变量 BETA_ARB_KLINE_INTERVAL,使其可在 .env 中配置 2. 影响范围分析 2.1 需要修改的文件(共 6 个) 文件 硬编码位置 修改内容 src/trading/config.py BetaArbConfig dataclass 新增 kline_interval 字段,

By SHI XIAOLONG