fix: V3动态阈值根因2子问题和根因3修复

根因2子问题: bond无数据时不填充
- 问题: be8ca02无条件填充导致2002-2007年信号中出现931862.CSI
- 但执行器找不到收益数据,2/3仓位收益为0,引发-31.92%回撤
- 修复: generate()中过滤前检查bond_has_data,传入_grouped_selection
- 填充条件改为: bond_code存在 AND bond_has_data
- bond有数据(含负值)→填充 ✓; bond无数据(NaN)→不填充 ✓

根因3: 空target时保持旧持仓而非清仓
- 问题: _apply_rebalance_control中target为空时清仓
- 但独立脚本的行为是保持旧持仓
- 修复: else分支改为pass,保持current_held不变
- 在有bond数据期target不会为空,空target仅发生在2002-2007

修复后验证结果:
- CAGR: 27.81% (预期28.03%, 差异-0.22%)
- 最大回撤: -24.36% (预期-24.35%, 差异≈0) ✓
- 夏普: 1.40 (预期1.40) ✓
- 回撤区间: 2015-05~2016-06 (与预期一致) ✓
- Calmar: 1.14 (预期1.15)

净值: 334.46 → 394.80 (+18%)
This commit is contained in:
2026-05-19 01:06:20 +08:00
parent be8ca023f7
commit 18bc9b8c44

View File

@@ -105,13 +105,18 @@ class TopNSelector(SignalGenerator):
if pd.notna(score):
scores[col] = score
# V3: 过滤前检查bond是否有因子数据用于填充守卫
cfg = self.bond_threshold_config
bond_code = cfg.get('bond_code', '931862.CSI') if cfg.get('enabled') else None
bond_has_data = bond_code in scores # scores此时是过滤前的完整字典
# V3: 动态阈值过滤(替代固定 min_score
threshold = self._get_dynamic_threshold(scores)
scores = {k: v for k, v in scores.items() if v >= threshold}
# 分组选股或全局选股
if self.group_mapping:
selected = self._grouped_selection(scores)
selected = self._grouped_selection(scores, bond_has_data)
else:
selected = self._global_top_n(scores)
@@ -177,10 +182,11 @@ class TopNSelector(SignalGenerator):
current_held = target
last_rebalance_idx = i
else:
# 目标信号为空所有标的动量得分低于min_score清仓
# 不继续持有负动量标的,转为空仓
current_held = ''
last_rebalance_idx = i
# V3: target为空时保持当前持仓不变与独立脚本行为一致
# 在有bond数据的时期target不会为空会被bond填充
# target为空仅发生在2002-2007无bond数据期
# 保持旧持仓比突然清仓更平滑
pass
signals.append(current_held)
@@ -216,13 +222,14 @@ class TopNSelector(SignalGenerator):
return new_total > 0
def _grouped_selection(self, scores: Dict[str, float]) -> List[str]:
def _grouped_selection(self, scores: Dict[str, float], bond_has_data: bool = True) -> List[str]:
"""V3分组选股BOND不参与竞争空余仓位填充短债
V3逻辑
1. BOND大类标的不参与冠军竞争它是阈值不是候选
2. 选出不足 select_num 只时,用短债填充
3. V2退化若bond_threshold.enabled=falseBOND正常参与竞争
3. bond无数据时2002-2007不填充
4. V2退化若bond_threshold.enabled=falseBOND正常参与竞争
"""
if not scores:
return []
@@ -248,11 +255,12 @@ class TopNSelector(SignalGenerator):
selected = [code for code, score in sorted_champions[:self.select_num]]
# V3: 空余仓位填充短债
# 短债填充是防御机制,不应受阈值过滤影响
if cfg.get('fill_bond', False) and bond_code:
# 短债填充是防御机制,但需要有数据才能填充
# bond有数据含负值→ 填充 ✓(防御机制不受动量影响)
# bond无数据NaN→ 不填充 ✓2002-2007正常退化
if cfg.get('fill_bond', False) and bond_code and bond_has_data:
n_bond_slots = self.select_num - len(selected)
if n_bond_slots > 0:
# 修复: 无条件填充短债(防御仓位不应依赖动量阈值)
for _ in range(n_bond_slots):
selected.append(bond_code)