"开仓信号详细告警"模块 Bug 分析报告 main
"开仓信号详细告警"模块完整因果链分析
📋 分析目标
建立从输入到BUG的完整因果链:输入 → 状态变化 → 调用路径 → 出错点 → 根因
1️⃣ 输入(Input)
触发条件
# orchestrator.py:515-533
open_result = self._position_manager.open_position(signal, adaptive_z=adaptive_z)
if open_result:
position, order_result = open_result
# ...保存信号、更新统计...
# 发送开仓告警(触发点)
self._send_entry_alert(
signal, # 交易信号对象
multi_period_result, # 多周期分析结果 ← 关键输入
position, # 仓位信息
order_result, # 订单结果
adaptive_z, # 自适应Z值
)
关键输入数据结构
multi_period_result:
{
"zscore_list": [2.2, 2.5, 2.75], # 多周期Z-score
"details": {
("4h", "60d"): {
"correlation": 0.83,
"cointegration_old": {"passed": True, "adf_pvalue": 0.001},
"health_monitor": {
"long_window": {
"health_score": 77.0,
"hurst": 0.35 # ← Hurst指数(趋势性指标)
},
"short_window": {
"health_score": 80.0,
"hurst": 0.38
}
}
},
# ...其他周期数据...
}
}
Hurst 指数语义:
< 0.4: 均值回归特性(适合配对交易)0.4-0.6: 随机游走> 0.6: 趋势性强(破坏配对交易基础)
2️⃣ 状态变化(State Changes)
阶段1:风险评估入口
文件: orchestrator.py:892-934
def _send_entry_alert(self, signal, multi_period_result, ...):
"""发送开仓告警(详细告警 or 简短通知)"""
if ENABLE_SIGNAL_DETAIL_ALERT:
try:
# 调用格式化器
title, content = format_signal_alert(
signal=signal,
multi_period_result=multi_period_result, # ← 传递多周期数据
...
)
阶段2:格式化器调用风险评估
文件: signal_alert_formatter.py:67-128
def format_signal_alert(..., multi_period_result, ...):
try:
if level == AlertLevel.FULL:
return _format_full(
signal, multi_period_result, ... # ← 继续传递
)
文件: signal_alert_formatter.py (内部调用)
def _format_full(...):
# ...构建告警内容...
risk_assessment = evaluate_risk(multi_period_result) # ← 调用风险评估
阶段3:风险评估计算(关键状态变化)
文件: risk_evaluator.py:67-95
def evaluate_risk(multi_period_result: dict) -> RiskAssessment:
"""执行多维度风险评估"""
details = multi_period_result.get("details", {})
zscore_list = multi_period_result.get("zscore_list", [])
trend = _calc_trend_risk(details) # ← 维度1:趋势风险
coint = _calc_cointegration_score(details) # 维度2:协整质量
corr = _calc_correlation_score(details) # 维度3:相关性
zscore = _calc_zscore_consistency(zscore_list) # 维度4:Z-score一致性
health = _calc_health_score(details) # 维度5:协整健康度
return _calc_overall(trend, coint, corr, zscore, health) # ← 综合评分
状态变化3.1:趋势风险计算
文件: risk_evaluator.py:101-116
def _calc_trend_risk(details: dict) -> dict:
"""趋势风险评估 — score 越高风险越大""" # ← 关键注释!
hurst = _extract_hurst(details) # 从 health_monitor 提取 hurst 指数
# 状态转换:hurst → risk score
if hurst < 0.4: # 均值回归
score, level = 20.0, "低风险-均值回归"
detail = "均值回归特性,适合配对交易"
elif hurst < 0.6: # 随机游走
score, level = 50.0, "中风险-随机游走"
detail = "接近随机游走,回归不明显"
else: # 趋势性强
score = min(20 + (hurst - 0.6) * 200, 100.0) # 线性增长
level = "高风险-趋势性强"
detail = "趋势性强,可能破坏配对交易基础"
# 返回状态:score 越高 = 风险越大
return {"score": score, "level": level, "reason": f"Hurst={hurst:.3f}", "detail": detail}
状态示例:
- 输入:
hurst = 0.35 - 输出:
{"score": 20.0, "level": "低风险-均值回归", ...} - 语义: score=20 表示低风险
状态变化3.2:综合评分计算(第一次反转)
文件: risk_evaluator.py:300-357
def _calc_overall(trend, coint, corr, zscore, health) -> RiskAssessment:
"""加权计算综合评分并生成评级"""
# ⚠️ 关键状态转换:趋势风险反转为趋势质量
weighted = (
(100 - trend["score"]) * RISK_WEIGHTS["trend_risk"] # ← 第1次反转(仅用于加权计算)
+ coint["score"] * RISK_WEIGHTS["cointegration"] # 其他维度不反转
+ corr["score"] * RISK_WEIGHTS["correlation"]
+ zscore["score"] * RISK_WEIGHTS["zscore_consistency"]
+ health["score"] * RISK_WEIGHTS["health_monitor"]
)
# 计算综合评级
if weighted >= 75:
risk_level, rec, action = "低风险", "积极", "可按标准仓位执行"
elif weighted >= 55:
risk_level, rec, action = "中风险", "谨慎", "建议减半仓位或观望"
else:
risk_level, rec, action = "高风险", "警惕", "建议极小仓位试探或放弃"
# ⚠️ 关键:返回的 details 中保存的是原始 trend["score"](未反转)
return RiskAssessment(
overall_score=round(weighted, 1),
risk_level=risk_level,
# ...
details={
"trend_risk": trend, # ← 原始风险分(score=20表示低风险)
"cointegration": coint,
"correlation": corr,
"zscore_consistency": zscore,
"health_monitor": health,
},
)
状态变化示例:
# 输入
trend = {"score": 20.0, "level": "低风险-均值回归"}
coint = {"score": 75.0, "level": "优秀"}
# ...
# 计算
weighted = (100 - 20) * 0.25 + 75 * 0.25 + ... = 20 + 18.75 + ... ≈ 72.5
# weighted 中使用了反转后的值(80),但 details 中保存的是原始值(20)
# 输出
RiskAssessment(
overall_score=72.5,
risk_level="中风险",
details={
"trend_risk": {"score": 20.0, ...}, # ← 仍是原始风险分
...
}
)
阶段4:格式化展示(第二次反转,BUG触发点)
文件: signal_alert_formatter.py:370-394
def _section_risk_assessment(risk: RiskAssessment) -> str:
d = risk.details
rows: list[str] = []
if "trend_risk" in d:
# ⚠️ BUG触发点:第2次反转
rows.append(f"| 趋势风险 | {100 - d['trend_risk']['score']:.0f} | {d['trend_risk']['level']} | 25% |")
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# 100 - 20 = 80(第2次反转)
if "cointegration" in d:
rows.append(f"| 协整质量 | {d['cointegration']['score']:.0f} | ...") # 不反转
if "correlation" in d:
rows.append(f"| 相关性 | {d['correlation']['score']:.0f} | ...") # 不反转
# ...其他维度都不反转...
状态变化示例:
# 输入(从 risk.details 获取)
d['trend_risk']['score'] = 20.0 # 原始风险分(低风险)
# 计算
display_score = 100 - 20.0 = 80.0 # 第2次反转
# 输出(用户看到的)
"| 趋势风险 | 80 | 低风险-均值回归 | 25% |"
语义混淆:
- 列名: "趋势风险"
- 分数: 80(高分)
- 等级: "低风险-均值回归"
- 用户理解: "趋势风险得分80,风险很高?但等级说是低风险?"
- 实际含义: 80 = 趋势安全性分(100 - 20),不是风险分
3️⃣ 完整调用路径(Call Path)
【入口】orchestrator.py
↓
L530: self._send_entry_alert(signal, multi_period_result, ...)
↓
L905: title, content = format_signal_alert(..., multi_period_result, ...)
↓
【格式化】signal_alert_formatter.py
↓
L89: return _format_full(...)
↓
内部: risk = evaluate_risk(multi_period_result) # 调用风险评估
↓
【风险评估】risk_evaluator.py
↓
L80: trend = _calc_trend_risk(details)
↓
L102-116: 计算趋势风险分
└─ 输出: {"score": 20.0} ← score 越高风险越大
↓
L305-306: weighted = (100 - trend["score"]) * 0.25 ← 第1次反转(仅用于加权)
↓
L350-357: 返回 RiskAssessment
└─ details["trend_risk"] = {"score": 20.0} ← 仍保存原始风险分
↓
【格式化展示】signal_alert_formatter.py
↓
L370: _section_risk_assessment(risk)
↓
L375: f"趋势风险 | {100 - d['trend_risk']['score']}" ← 第2次反转
└─ 计算: 100 - 20 = 80
↓
【用户看到】
"| 趋势风险 | 80 | 低风险-均值回归 | 25% |"
^^^^^^^^^ ^^ ^^^^^^^^^^^^^^^^^
列名含义 分数 等级说明
风险? 很高 但是低风险? ← 语义混淆
4️⃣ 出错点(Error Point)
主要出错点
位置: signal_alert_formatter.py:375
rows.append(f"| 趋势风险 | {100 - d['trend_risk']['score']:.0f} | {d['trend_risk']['level']} | 25% |")
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# 双重反转的第2次,导致语义混淆
错误假设
代码作者在编写 _section_risk_assessment() 时的错误假设:
❌ 假设: risk.details["trend_risk"]["score"] 已经是反转后的"趋势质量分"(越高越好)
✅ 实际: risk.details["trend_risk"]["score"] 仍是原始"趋势风险分"(越高风险越大)
为什么会有这个假设?
设计不一致导致的认知偏差:
-
其他4个维度的定义都是"质量分"(越高越好):
- 协整质量: score 高 = 协整好
- 相关性: score 高 = 相关性强
- Z-score一致性: score 高 = 一致性好
- 协整健康: score 高 = 健康好
-
只有趋势风险是"风险分"(越高风险越大):
- 趋势风险: score 高 = 风险大
-
_calc_overall() 中的反转只用于加权计算:
weighted = (100 - trend["score"]) * 0.25 # 反转是为了统一方向但返回的
details["trend_risk"]仍是原始值 -
展示逻辑的作者认为应该把所有维度统一为"质量分"展示,所以又做了一次反转
5️⃣ 根因(Root Cause)
根本原因分类
1. 设计层面:语义不一致
问题: 风险评估体系中混用了两种相反的语义:
| 维度 | score 语义 | 展示处理 |
|---|---|---|
| 趋势风险 | 越高风险越大(风险分) | 反转展示 |
| 协整质量 | 越高质量越好(质量分) | 直接展示 |
| 相关性 | 越高质量越好(质量分) | 直接展示 |
| Z-score一致性 | 越高质量越好(质量分) | 直接展示 |
| 协整健康 | 越高质量越好(质量分) | 直接展示 |
根因: 5个维度中,4个用"质量分",1个用"风险分",造成设计不一致
2. 实现层面:双重反转错误
问题: 两处代码对 trend_risk["score"] 都做了反转:
-
第1次反转 (
risk_evaluator.py:305-306):weighted = (100 - trend["score"]) * 0.25- 目的: 统一方向用于加权计算
- 副作用: 给人错觉以为 details 中的 score 也被反转了
-
第2次反转 (
signal_alert_formatter.py:375):f"趋势风险 | {100 - d['trend_risk']['score']}"- 目的: 统一展示为"质量分"
- 问题: details 中的 score 其实是原始风险分,再反转就错了
根因: 第1次反转没有修改 details 中的值,第2次反转基于错误假设
3. 沟通层面:列名与语义不匹配
问题: 展示时的列名和分数语义不一致:
| 趋势风险 | 80 | 低风险-均值回归 | 25% |
^^^^^^^ ^^ ^^^^^^^^^^^^^^^
风险? 高分 但是低风险?
- 列名叫"趋势风险"(暗示分数越高风险越大)
- 分数是 80(高分)
- 等级是"低风险-均值回归"
- 结果: 用户困惑 — 到底是高风险还是低风险?
根因: 列名使用了"风险",但分数已经反转为"安全性"
6️⃣ 影响分析(Impact Analysis)
用户视角
场景1:均值回归信号(低风险)
输入: hurst = 0.35 → 原始风险分 = 20
展示: "| 趋势风险 | 80 | 低风险-均值回归 |"
用户: "趋势风险80分,很高啊?但等级说低风险?矛盾!"
场景2:趋势性强信号(高风险)
输入: hurst = 0.75 → 原始风险分 = 50
展示: "| 趋势风险 | 50 | 高风险-趋势性强 |"
用户: "趋势风险50分,中等?但等级说高风险?矛盾!"
决策影响
❌ 用户可能的误判:
- 看到"趋势风险 80"认为风险很高,放弃本来应该积极执行的均值回归信号
- 看到"趋势风险 50"认为风险中等,对高风险信号掉以轻心
✅ 实际不影响系统功能:
- 开仓执行逻辑不依赖这个展示值
- 综合评分(overall_score)计算是正确的(第1次反转是对的)
- 只是展示给用户的信息有误导性
7️⃣ 修复方案(Solution)
方案1:不反转,直接展示原始风险分 ✅ 推荐
修改: signal_alert_formatter.py:375
# 修改前
rows.append(f"| 趋势风险 | {100 - d['trend_risk']['score']:.0f} | {d['trend_risk']['level']} | 25% |")
# 修改后
rows.append(f"| 趋势风险 | {d['trend_risk']['score']:.0f} | {d['trend_risk']['level']} | 25% |")
效果:
| 趋势风险 | 20 | 低风险-均值回归 | 25% |
- 列名"趋势风险"+ 低分(20) + "低风险" = 语义一致 ✅
优点:
- ✅ 最小改动(只删除
100 -) - ✅ 语义一致(分数和列名匹配)
- ✅ 与 risk_evaluator.py 中的定义一致
缺点:
- ⚠️ 与其他4个维度的方向不一致(其他都是"越高越好")
方案2:改列名为"趋势安全性"
修改: signal_alert_formatter.py:375
# 修改前
rows.append(f"| 趋势风险 | {100 - d['trend_risk']['score']:.0f} | {d['trend_risk']['level']} | 25% |")
# 修改后
rows.append(f"| 趋势安全性 | {100 - d['trend_risk']['score']:.0f} | {d['trend_risk']['level']} | 25% |")
# ^^^^^^^^
效果:
| 趋势安全性 | 80 | 低风险-均值回归 | 25% |
- 列名"趋势安全性"+ 高分(80) + "低风险" = 语义一致 ✅
优点:
- ✅ 语义一致(分数和列名匹配)
- ✅ 与其他4个维度方向一致(都是"越高越好")
- ✅ 用户理解更直观(安全性80 = 很安全)
缺点:
- ⚠️ 需要修改列名(可能影响用户习惯)
- ⚠️ "低风险-均值回归"这个等级文本仍有小冲突(安全性高但叫"低风险"?)
方案3:在 details 中保存反转后的值(最彻底)
修改: risk_evaluator.py:350-357
def _calc_overall(...):
# 计算加权分数
weighted = (100 - trend["score"]) * RISK_WEIGHTS["trend_risk"] + ...
# ⚠️ 关键:修改 trend 字典中的 score 为反转后的值
trend_inverted = trend.copy()
trend_inverted["score"] = 100 - trend["score"] # 反转后保存
trend_inverted["level"] = trend["level"].replace("风险", "安全性") # 修改等级文本
return RiskAssessment(
overall_score=round(weighted, 1),
# ...
details={
"trend_risk": trend_inverted, # ← 保存反转后的值
# ...
},
)
效果:
# risk.details["trend_risk"]["score"] = 80(已反转)
# signal_alert_formatter.py:375 直接使用(不反转)
rows.append(f"| 趋势安全性 | {d['trend_risk']['score']:.0f} | ...")
优点:
- ✅ 彻底解决问题(统一语义)
- ✅ 展示代码不需要特殊处理
- ✅ 所有维度都是"质量分"(越高越好)
缺点:
- ⚠️ 改动较大(需要同时修改等级文本)
- ⚠️ 可能影响其他使用
risk.details的代码
方案对比
| 方案 | 改动量 | 语义一致性 | 用户体验 | 推荐度 |
|---|---|---|---|---|
| 方案1:不反转 | 最小 | 中等(风险分逆向) | 需要理解"风险越低越好" | ⭐⭐⭐⭐ |
| 方案2:改列名 | 小 | 高 | 最佳(安全性越高越好) | ⭐⭐⭐⭐⭐ |
| 方案3:details反转 | 大 | 最高 | 最佳 | ⭐⭐⭐ |
建议: 方案2(改列名)+ 方案1(不反转的简化版)
# 最终推荐代码
rows.append(f"| 趋势安全性 | {100 - d['trend_risk']['score']:.0f} | {d['trend_risk']['level']} | 25% |")
8️⃣ 测试验证(Test Verification)
测试场景
# 场景1:低风险信号(均值回归)
input_hurst = 0.35
expected_score = 20.0
expected_display = "| 趋势安全性 | 80 | 低风险-均值回归 | 25% |"
# 场景2:中风险信号(随机游走)
input_hurst = 0.50
expected_score = 50.0
expected_display = "| 趋势安全性 | 50 | 中风险-随机游走 | 25% |"
# 场景3:高风险信号(趋势性强)
input_hurst = 0.75
expected_score = 50.0 # min(20 + (0.75 - 0.6) * 200, 100) = 50
expected_display = "| 趋势安全性 | 50 | 高风险-趋势性强 | 25% |"
📊 总结
因果链全景图
【输入】
hurst = 0.35 (均值回归特性)
↓
【状态1】risk_evaluator._calc_trend_risk()
score = 20.0 (低风险)
↓
【状态2】risk_evaluator._calc_overall()
weighted = (100 - 20) * 0.25 = 20 (第1次反转,用于加权)
details["trend_risk"]["score"] = 20.0 (仍保存原始值)
↓
【状态3】signal_alert_formatter._section_risk_assessment()
display = 100 - 20 = 80 (第2次反转,语义错误)
↓
【输出】
"| 趋势风险 | 80 | 低风险-均值回归 | 25% |"
列名"风险" + 高分80 + "低风险" = 语义混淆 ❌
↓
【用户理解】
"趋势风险80分?很高啊?但为什么说低风险?矛盾!"
关键节点
- 输入:
hurst = 0.35→ 均值回归特性 - 计算:
score = 20.0→ 原始风险分(低风险) - 第1次反转:
(100 - 20) * 0.25→ 仅用于加权计算 - 第2次反转:
100 - 20 = 80→ BUG触发点 - 输出: "趋势风险 | 80" → 语义混淆
根因总结
| 层面 | 根因 | 解决方向 |
|---|---|---|
| 设计 | 5个维度中4个用"质量分",1个用"风险分",语义不统一 | 统一为"质量分" |
| 实现 | 双重反转:第1次用于加权但未修改details,第2次基于错误假设 | 删除第2次反转或修改列名 |
| 沟通 | 列名"趋势风险"与反转后的"趋势安全性"语义不符 | 改列名或改分数 |
影响评估
- 系统功能: ✅ 不影响(综合评分计算正确)
- 用户决策: ⚠️ 可能误导(分数与直觉相反)
- 严重性: 🟡 中高(不会崩溃,但影响判断)
推荐修复
方案2:改列名 + 保持反转
# signal_alert_formatter.py:375
rows.append(f"| 趋势安全性 | {100 - d['trend_risk']['score']:.0f} | {d['trend_risk']['level']} | 25% |")
# ^^^^^^^^ ← 列名改为"安全性",分数保持反转
理由:
- ✅ 改动最小(只改4个字)
- ✅ 语义一致(安全性越高越好)
- ✅ 与其他维度方向一致
- ✅ 用户体验最佳
报告完成时间: 2026-02-16
分析深度: 完整因果链(输入→状态→路径→出错→根因)
修复建议: 方案2(改列名为"趋势安全性")