全系统key配对升级 bug19
配对维度优化后(全系统Key改为PairKey)缺陷与不足分析
分析日期:2026-02-20
分析范围:src/trading/所有核心模块
基准版本:main分支(最新提交40e8849)
一、严重缺陷
1. 启动SQL查询无时间边界
位置:src/trading/orchestrator.py:138
all_rows = db_client.execute_query("""
SELECT symbol, base_symbol, kline_time, zscore_4h
FROM (
SELECT ..., ROW_NUMBER() OVER (...) AS rn
FROM analysis_results
WHERE zscore_4h IS NOT NULL # ← 无时间过滤!
) sub WHERE rn = 1
ORDER BY symbol, base_symbol, kline_time DESC
""", params=())
问题描述:查询 analysis_results 全表,没有 kline_time >= NOW() - INTERVAL '...' 限制。随着运行时间增加,表中历史数据越积越多(数十万乃至百万行),每次重启都需要全表扫描,应用层再取前 144 条。
后果:生产环境运行一个月后,重启可能需要数十秒乃至数分钟才能完成初始化。
修复建议:
WHERE zscore_4h IS NOT NULL
AND kline_time >= NOW() - INTERVAL '30 days' -- 144条4h K线约24天,30天足够
2. KillSwitch 不平仓现有仓位
位置:src/trading/safety.py:28,src/trading/risk_manager.py:67-69
# safety.py
def is_killed(self) -> bool:
try:
with open(self._file_path, "rb"):
return True
except FileNotFoundError:
return False
# risk_manager.py - 只在开仓前检查
if self._kill_switch.is_killed():
return False, f"Kill Switch 已激活: {self._config.kill_switch_file}"
问题描述:is_killed() 只在 pre_trade_check 中被调用,仅阻止新建仓位,不触发平仓。激活 KillSwitch 后:
- 现有仓位继续持有,止损/超时机制仍在运行
- 若需紧急平仓,运维人员还要额外手动操作
后果:紧急情况下 KillSwitch 无法实现真正的"一键清仓",与运维预期不符。
修复建议:在止损监控线程 _stop_loss_monitor 中增加 KillSwitch 检查,触发时关闭所有仓位:
if self._kill_switch.is_killed():
for pos in self._position_manager.open_positions:
self._close_with_retry(pos, "kill_switch")
3. 止损平仓后冷却期结束仍无法自动再入场(逻辑陷阱)
位置:src/trading/strategy.py:310-320,src/trading/strategy.py:612-651
# on_position_closed 清除突破状态
def on_position_closed(self, symbol, base_symbol, timestamp=None):
key = (symbol, base_symbol)
with self._lock:
if key in self._positions:
self._last_trade_time[key] = timestamp # 冷却计时开始
self._positions.pop(key, None)
self._prev_above_threshold.pop(key, None) # ← 清除状态
问题描述:复现路径如下:
- 仓位止损平仓,
on_position_closed清除_prev_above_threshold[key],冷却计时开始 - 下一个tick:
prev_above = False(已清除),若 adaptive_z 仍超阈值 →breakout = True,但冷却期拦截 → 跳过,并在末尾写入_prev_above_threshold[key] = True - 冷却期内后续 tick:
prev_above = True,current_above = True→breakout = False(非首次突破)→ 不产生信号 - 冷却期结束后:
prev_above仍为True→breakout = False→ 永远不产生新信号!
只有等 adaptive_z 先跌破阈值、再重新上穿,才能产生新信号。若止损平仓后行情继续极端,即便冷却期过了也无法自动再入场,错失反弹机会。
后果:策略信号完整性损失,在止损后行情持续极端的场景下系统哑火。
修复建议:冷却期结束时主动重置突破检测状态:
# 在 _check_entry 中,冷却期结束时清除 _prev_above_threshold 使其可重新检测
if elapsed >= cooldown_seconds:
self._prev_above_threshold.pop(key, None) # 允许重新检测突破
return None # 本 tick 不产生信号,下 tick 可能触发
二、重要缺陷
4. __post_init__ 方法错误地插入在字段定义中间
位置:src/trading/models.py:81-84,models.py:139-142,models.py:189-191
@dataclass
class PairTradeSignal:
signal_id: str = ...
signal_type: SignalType = ...
symbol: str = ""
base_symbol: str = ""
def __post_init__(self): # ← 在字段中间定义
if self.base_symbol is None:
self.base_symbol = ""
direction: str = "" # ← 方法后面还有字段!
zscore_5m: float = 0.0
...
同样问题出现在 PairPosition(第139行)和 ReversionInfo(第189行)。
问题描述:虽然Python的dataclass机制能正确处理(所有字段在 __post_init__ 调用前完成初始化),但这种写法极易误导维护者误以为 __post_init__ 后面的字段不会被初始化,埋下理解隐患。
修复建议:将 __post_init__ 移到所有字段定义之后:
@dataclass
class PairTradeSignal:
signal_id: str = ...
...
# 所有字段定义完毕后
def __post_init__(self):
if self.base_symbol is None:
self.base_symbol = ""
5. 残留 base 腿大小不匹配时无修复措施
位置:src/trading/position_manager.py:699-708
with self._lock:
if orphan_key in self._positions:
existing = self._positions[orphan_key]
if abs(existing.alt_size - actual_size) > 1e-6:
logger.warning(
f"⚠️ 孤儿 key {orphan_key} 已被现有仓位占用且 size 不符..."
)
else:
logger.info(f"base 腿 {orphan_symbol} 已在管理中(size 一致),跳过收纳")
return # ← size不匹配时只警告然后return,没有更新size!
问题描述:当 orphan key 已存在但 size 不一致时,只打 warning,实际不做任何修正。这意味着系统内存中记录的持仓大小与交易所实际持仓大小不一致,止损计算将使用错误数值。
修复建议:size 不匹配时更新内存中的 size:
if abs(existing.alt_size - actual_size) > 1e-6:
logger.warning(f"⚠️ 孤儿 size 不符,强制同步: {orphan_key} | 现有={existing.alt_size} → 交易所={actual_size}")
existing.alt_size = actual_size # 以交易所实际为准
6. Pair 模式下 leg_b 平仓失败时 PnL 计算不完整
位置:src/trading/position_manager.py:633-648
base_pnl = 0.0
if position.pair_mode == "pair" and order_result.leg_b and order_result.leg_b.success:
...
base_pnl = ... # leg_b 失败时 base_pnl 永远为 0
问题描述:若 leg_b 经过3次重试后依然失败(leg_b_final_failed = True),base 腿通过 _adopt_residual_base_leg 转为独立孤儿仓位继续持有。但数据库记录的 realized_pnl 只包含 alt 腿的盈亏,缺失 base 腿部分,最终统计数字与实际不符。
修复建议:leg_b 转孤儿时,用当前市价估算 base 腿的未实现 PnL 并记录:
if leg_b_final_failed:
# 估算 base 腿 PnL(将在孤儿仓位关闭时最终结算)
base_mid = self._executor.get_all_mids().get(symbol_to_coin(position.base_symbol), 0.0)
if base_mid > 0 and position.base_entry_price > 0:
if position.base_side == "buy":
estimated_base_pnl = (base_mid - position.base_entry_price) * position.base_size
else:
estimated_base_pnl = (position.base_entry_price - base_mid) * position.base_size
# 记录备注说明 PnL 不完整
7. stop() 方法不主动平仓
位置:src/trading/orchestrator.py:236-259
def stop(self):
...
if self._position_manager:
open_count = self._position_manager.open_count
if open_count > 0:
logger.warning(f"交易模块停止时仍有 {open_count} 个活跃仓位")
# ← 只打日志,不平仓
问题描述:计划性停机时(如服务升级),现有仓位保留在交易所,依赖重启后恢复机制。若是永久停机或长时间不重启,仓位将无人监控,止损/超时保护完全失效。
修复建议:增加可配置的停机平仓选项:
# 配置项
close_positions_on_stop: bool = False # 停机时是否平仓
# stop() 中
if self._config.close_positions_on_stop:
for pos in self._position_manager.open_positions:
self._position_manager.close_position(pos.symbol, pos.base_symbol, reason="graceful_stop")
三、中等缺陷
8. RateLimiter 全局共享,多配对场景下过于保守
位置:src/trading/orchestrator.py:56-59
self._rate_limiter = RateLimiter(
max_requests=self._config.rate_limit_per_minute, # 默认30次/分钟
window_seconds=60,
)
问题描述:所有配对共享同一个频率限制器。若同时跟踪 PURR|HYPE、ETH|BTC、SOL|BTC 三个配对,信号频繁时30次/分钟的限制很快被打满,其他配对的合法信号会被误拒。
修复建议:采用两级限速:全局总限速 + 按 PairKey 单独限速。
9. CircuitBreaker 只受开仓操作影响,平仓成功不重置计数
位置:src/trading/orchestrator.py:557-572
if open_result:
...
self._circuit_breaker.record_success() # 仅开仓成功重置
else:
...
self._circuit_breaker.record_failure() # 仅开仓失败计数
问题描述:
- 平仓成功不重置熔断计数器
- 平仓失败不增加失败计数
- 网络瞬时抖动导致连续开仓失败 → 熔断 → 即使后续平仓成功,熔断状态也不会提前解除
10. _compute_adaptive_z 含隐藏副作用
位置:src/trading/strategy.py:364-375
def _compute_adaptive_z(self, bl: SymbolBaseline, realtime_z4h=None) -> float | None:
...
bl.last_std = std # ← 修改了传入对象,违反 Command-Query Separation
...
return (current_z4h - bl.ema) / std
问题描述:方法名暗示这是"计算"(查询),但实际修改了 bl.last_std(命令),违反 Command-Query Separation 原则,增加理解难度和隐患。
修复建议:将 bl.last_std = std 移到调用处,或将其作为返回值的一部分:
def _compute_adaptive_z(self, bl: SymbolBaseline, realtime_z4h=None) -> tuple[float | None, float]:
...
return (current_z4h - bl.ema) / std, std
11. 止损平仓不计入 exit_signals 统计
位置:src/trading/orchestrator.py 各平仓路径
| 平仓路径 | trades_closed | exit_signals |
|---|---|---|
on_exit_signal(策略信号) |
✅(position_manager内) | ✅ |
_stop_loss_monitor(止损/超时) |
✅(position_manager内) | ❌ 未计入 |
sync_with_exchange(幽灵清理) |
✅(position_manager内) | ❌ 未计入 |
问题描述:每日统计中 exit_signals 只反映策略信号引起的平仓,止损/超时/对账关闭的仓位不在统计之内,数据不完整。
12. 仓位关闭后部分诊断状态未清理,存在内存积累
位置:src/trading/strategy.py:310-320
on_position_closed 清理了 _positions, _exit_pending, _prev_above_threshold,但未清理:
_last_near_thresh_time[key]_last_status_time[key]_tick_count[key]_last_adaptive_z[key]
长期运行后(尤其是多配对场景),这些 dict 会积累大量已关闭配对的历史条目。
修复建议:在 on_position_closed 末尾补充清理:
self._last_near_thresh_time.pop(key, None)
self._last_status_time.pop(key, None)
self._tick_count.pop(key, None)
self._last_adaptive_z.pop(key, None)
四、轻微缺陷 / 设计局限
13. coin_to_symbol 硬编码 USDC,限制扩展性
位置:src/trading/models.py:210-218
def coin_to_symbol(coin: str) -> str:
if '/' in coin:
return coin
return f"{coin}/USDC:USDC" # ← 硬编码 USDC
若未来扩展至 USDT 或其他计价币种,此函数将返回错误的 symbol 格式,导致订单下单失败或数据查询错误。
14. PnL 计算缺失手续费和资金费
位置:src/trading/position_manager.py:620-648
alt_pnl = (alt_exit_price - alt_entry_price) * alt_size
仅计算价差盈亏,未包含:
- Maker/Taker 手续费(Hyperliquid 约 0.01%~0.035%/笔)
- 资金费(永续合约每8小时结算一次,最长持仓72小时可积累9次资金费)
对于长持仓,资金费累积可能显著影响实际盈亏,导致统计中的 realized_pnl 与账户实际变动不符。
15. save_position 将 exit_price 初始化为硬编码 0
位置:src/trading/trade_repository.py:144-145
0, # alt_exit_price 初始为0
0, # base_exit_price 初始为0
ON CONFLICT DO UPDATE 会将 EXCLUDED.alt_exit_price 写回。若因意外情况在平仓后再次调用 save_position(例如恢复流程中的边缘情况),exit_price 会被覆盖为 0,破坏数据完整性。
修复建议:ON CONFLICT 中对 exit_price 使用 COALESCE 保留非零值:
alt_exit_price = COALESCE(NULLIF(EXCLUDED.alt_exit_price, 0), pair_positions.alt_exit_price),
五、总结优先级矩阵
| 优先级 | # | 缺陷描述 | 影响范围 |
|---|---|---|---|
| 🔴 严重 | 1 | 启动SQL无时间限制,全表扫描 | 启动性能退化 |
| 🔴 严重 | 2 | KillSwitch不平仓现有仓位 | 运维紧急响应能力 |
| 🔴 严重 | 3 | 止损后冷却期结束仍无法再入场 | 策略信号完整性 |
| 🟠 重要 | 4 | __post_init__ 位置异常(3处) |
代码可维护性 |
| 🟠 重要 | 5 | 残留base腿size不匹配无修复 | 止损计算准确性 |
| 🟠 重要 | 6 | Pair模式leg_b失败时PnL不完整 | 盈亏统计准确性 |
| 🟠 重要 | 7 | stop() 不主动平仓 |
计划停机安全性 |
| 🟡 中等 | 8 | RateLimiter全局共享 | 多配对信号处理 |
| 🟡 中等 | 9 | CircuitBreaker只受开仓影响 | 风控有效性 |
| 🟡 中等 | 10 | _compute_adaptive_z 含副作用 |
代码质量 |
| 🟡 中等 | 11 | 止损平仓不计入exit_signals | 统计完整性 |
| 🟡 中等 | 12 | 诊断状态未在仓位关闭时清理 | 长期内存积累 |
| 🟢 轻微 | 13 | coin_to_symbol 硬编码USDC |
扩展性 |
| 🟢 轻微 | 14 | PnL缺失手续费和资金费 | 统计精度 |
| 🟢 轻微 | 15 | exit_price初始化为0的覆盖风险 | 数据完整性 |
最优先修复:缺陷 #3(冷却期逻辑陷阱)和 #1(启动查询),两者对系统实际运行效果影响最大且改动成本最低。