系统配对维度重构后缺陷与不足分析
背景
项目在 f5543c3 提交中将全系统 Key 从单一 symbol 改为 PairKey = (symbol, base_symbol) 配对维度。此后经过 14 次调试提交(debug 1-13)进行修复。本分析基于当前 HEAD (2ab27b0) 状态,识别出重构后仍存在的缺陷和不足。
一、高优先级缺陷(影响正确性/资金安全)
1. sync_with_exchange 未更新 pair 模式下 base_current_price
- 文件:
src/trading/position_manager.py:960-970
- 问题:
sync_with_exchange 内的 all_mids 价格更新循环只更新了 alt_current_price,完全跳过了 base_current_price
- 影响: 在两次
_stop_loss_monitor 轮次之间,base_current_price 可能是过时值,导致同步线程中 base 腿消失判断后的 PnL 估算不准确
- 修复: 在
sync_with_exchange 的价格更新循环中增加 pair 模式 base 价格更新逻辑(参考 update_position_prices() 的实现)
2. 孤儿仓位 entry_adaptive_z=0 导致均值回归退出永久失效
- 文件:
src/trading/strategy.py:572-574
- 问题:
_check_exit 中 if abs(baseline) < 1e-9: return None — 当 entry_adaptive_z=0.0 时,均值回归退出信号永远不会触发
- 影响范围: 所有通过以下路径产生的孤儿仓位:
_adopt_residual_base_leg() 创建的残留 base 腿仓位
_build_orphan_positions() 中 B 路径(配对孤儿)和 C 路径(单腿孤儿)
- 仓位同步中收纳的、DB 无记录的交易所仓位
- 当前缓解: 这些仓位仅依赖止损/移动止损/超时退出(已在告警中注明),但缺少策略级退出能力
- 建议: 在
_position_sync 中尝试用当前 adaptive_z 回填孤儿的 entry_adaptive_z(orchestrator.py:763-766 已有部分实现,但仅在新收纳时执行一次)
3. _collect_orphan_candidates 缺少配对级 close_disabled 检查
- 文件:
src/trading/position_manager.py:1217
- 代码:
self._config.is_close_disabled(coin_to_symbol(coin))
- 问题: 只传了
symbol,缺少 base_symbol 参数,配对级 close_disabled(如 PURR|HYPE)检查被绕过
- 影响: 配对级禁止平仓的代币可能被错误收纳为孤儿仓位
- 修复: 此处无法确定 base_symbol(因为只有交易所 coin 信息),需在后续
_build_orphan_positions 中加入配对级过滤
4. 策略缓冲区灌入的 N+1 查询问题
- 文件:
src/trading/orchestrator.py:137-163
- 问题: 先查所有
DISTINCT (symbol, base_symbol) 对,再对每个配对单独执行历史 z4h 查询
- 影响:
- 启动时间随历史配对数线性增长(若有 100 个历史配对 = 100 次 DB 查询)
- 无黑名单过滤前置优化:先查 DB 再检查 is_symbol_allowed,浪费了被禁配对的查询
- 建议: 改用批量查询 + 应用层分组;或将
WHERE 中加入白名单/黑名单过滤
二、中优先级缺陷(影响健壮性/运维效率)
5. WebSocket 健康监控 warning/timeout 阈值相同
- 文件:
src/config.py:114-115
- 当前值:
WS_HEALTH_MONITOR_TIMEOUT = 15, WS_HEALTH_MONITOR_WARNING_THRESHOLD = 15
- 问题: 两个阈值完全相同,warning 日志在触发断连的同一时刻才输出,丧失了预警价值
- 修复: 将 warning 阈值设为 timeout 的 60-70%,如
WARNING=10, TIMEOUT=15
6. process_analysis 强制要求非空 base_symbol
- 文件:
src/trading/orchestrator.py:315-317
- 代码:
if not base_symbol: return False
- 问题: 当
pair_mode="single" 时,分析层可能传入空的 base_symbol(因为单币模式不关心基准币种),但 orchestrator 会将信号直接丢弃并记录 ERROR
- 影响: 单币模式下合法信号被静默拒绝
- 建议: 当
pair_mode="single" 时允许 base_symbol 为空,使用 DEFAULT_BASE_SYMBOL 或空字符串降级
7. get_positions_by_symbols 查询缺少 base_symbol 去重
- 文件:
src/trading/trade_repository.py:256-269
- 问题:
WHERE symbol = ANY(%s) 只按 symbol 查询,结果 dict 用 (symbol, base_symbol) 做 key
- 边界场景: 同一 symbol 存在多条不同 base_symbol 的活跃仓位时,只有最后一条被保留(dict 覆盖)
- 修复: 改为返回
list 或使用 defaultdict(list) 防止覆盖
8. 信号去重 cooldown 硬编码
- 文件:
src/trading/strategy.py:98
- 代码:
self._signal_cooldown = 60 # 秒
- 问题: 60 秒去重窗口无法通过配置调整,不同配对可能需要不同的去重策略
- 建议: 改为从
StrategyParams 读取,或至少提取为类级常量
9. 未使用的 Redis 依赖
- 文件:
pyproject.toml
- 问题:
redis>=7.1.0 被声明但代码中无任何 import redis
- 影响: 增加安装时间、依赖冲突风险、攻击面
- 修复: 移除或注释
10. _adopt_residual_base_leg 的 close_disabled 检查逻辑有误
- 文件:
src/trading/position_manager.py:668
- 代码:
if self._config.is_close_disabled(original_position.symbol, orphan_symbol)
- 问题: 检查的是"原始配对(alt, base)是否禁用平仓",但此处要判断的是"孤儿币种本身是否禁用平仓"
- 应改为: 同时检查
is_close_disabled(orphan_symbol, "") — 即孤儿作为单币仓位时是否被禁止平仓
三、低优先级 / 架构改进建议
11. 测试覆盖严重不足
- 现状: 整个交易模块(strategy/position_manager/orchestrator/risk_manager)无任何单元测试
.gitignore 排除了 test* 和 tests/ 目录
- 唯一测试文件为
src/utils/data_healing/test_basic.py(手动 print + assert 模式)
- 风险: 配对维度重构后的正确性完全依赖手动验证和线上调试(14 次 debug 提交为佐证)
- 建议: 优先为
AdaptiveBollingerStrategy 和 RiskManager 编写关键路径单元测试
12. 配对级 stop_loss_pct / max_hold_hours 已支持但移动止损参数未支持
- 文件:
src/trading/config.py vs src/trading/risk_manager.py
- 问题:
trailing_stop_activation_pct 和 trailing_stop_callback_pct 仅在 TradingConfig 全局层定义,不在 StrategyParams 中
- 影响: 所有配对共享相同的移动止损参数,无法按配对差异化
- 建议: 将移动止损参数加入
StrategyParams,与 stop_loss_pct 同级管理
13. 宽泛异常捕获
- 多处:
except Exception as e 用于捕获所有异常
orchestrator.py:886 — 仓位恢复的最外层
position_manager.py:793 — DB 行加载
trade_repository.py — 所有 DB 操作
- 问题: 吞没了具体异常类型,调试困难
- 建议: 逐步收窄为具体异常类型(
psycopg.Error, KeyError, ValueError 等)
14. pair_strategy_overrides key 格式不统一
- 配置端: 环境变量用
__(双下划线)分隔:PURR__HYPE
- 内部存储: dict key 用
|(竖线)分隔:PURR|HYPE
- 日志输出: 也用
|
- 这三种格式在整个系统中并存,虽然有转换逻辑,但增加了理解成本
15. 策略引擎内每个 tick 都输出 INFO 日志
- 文件:
src/trading/strategy.py:465-469
- 问题:
logger.info(f"📍 tick({tick_type})") 在每个 tick 都执行
- 影响: 高频运行时日志量极大(30 个分析线程 × 多配对 × 5 分钟 tick)
- 建议: 将常规 tick 日志改为 DEBUG 级别,仅在信号触发/接近阈值时使用 INFO
16. peak_equity 重启后丢失
- 文件:
src/trading/risk_manager.py:292-294, orchestrator.py:129
- 问题: 最大回撤检查依赖的
_peak_equity 仅在内存中维护,重启后以当前账户价值重新初始化
- 影响: 若账户在重启前已出现大幅回撤,重启后回撤计数器归零,可能导致回撤保护失效
- 建议: 将
peak_equity 持久化到 DB 或文件,重启后恢复
四、配对维度迁移完整性评估
已正确迁移的组件 ✅
| 组件 |
文件 |
PairKey 使用 |
| 策略引擎 |
strategy.py |
所有 7 个状态字典均使用 PairKey |
| 仓位管理器 |
position_manager.py |
_positions + _opening_pairs |
| 风控审查 |
risk_manager.py |
重复仓位检查按 (symbol, base_symbol) |
| 交易编排器 |
orchestrator.py |
信号处理、同步、止损 |
| 配置系统 |
config.py |
三级参数层级 (pair > symbol > global) |
| 黑名单/禁闭 |
config.py |
支持币种级和配对级 |
| 数据库 |
trade_repository.py |
所有查询含 base_symbol |
| DB 索引 |
migrations/ |
已迁移到 (symbol, base_symbol) 复合索引 |
仍有遗留的区域 ⚠️
| 区域 |
问题 |
严重度 |
| 孤儿收集 |
_collect_orphan_candidates 缺 base_symbol |
中 |
| 残留收纳 |
_adopt_residual_base_leg close_disabled 检查逻辑 |
中 |
| 价格同步 |
sync_with_exchange 缺 base 价格更新 |
高 |
| 仓位查询 |
get_positions_by_symbols 同 symbol 多 base 覆盖 |
中 |
| 单币模式 |
process_analysis 强制非空 base_symbol |
中 |
五、建议修复优先级
- 立即修复: #1 (sync 价格缺失), #3 (孤儿 close_disabled), #5 (WS 阈值)
- 短期优化: #2 (孤儿 adaptive_z), #4 (N+1 查询), #6 (单币模式兼容), #10 (close_disabled 逻辑)
- 中期建设: #7 (查询去重), #8 (cooldown 可配置), #11 (测试覆盖), #12 (移动止损配对级)
- 长期改进: #13 (异常收窄), #14 (格式统一), #15 (日志级别), #16 (peak_equity 持久化)
六、验证方式
- #1 修复验证: 在 pair 模式下运行,观察
sync_with_exchange 后 base_current_price 是否被正确更新
- #2 修复验证: 模拟孤儿仓位收纳,验证 entry_adaptive_z 非零时均值回归退出是否正常触发
- #5 修复验证: 断开 WS 连接,验证 warning 日志在 timeout 之前输出
- 单元测试: 为
AdaptiveBollingerStrategy.process_tick 编写测试,覆盖入场/退出/冷却/孤儿场景
- 集成测试: 使用 testnet 环境端到端验证配对开仓 → 均值回归平仓 → 孤儿收纳完整流程