From 18bc9b8c4461570d8cb2cfe9e4c55e006e7290a2 Mon Sep 17 00:00:00 2001 From: aszerW Date: Tue, 19 May 2026 01:06:20 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20V3=E5=8A=A8=E6=80=81=E9=98=88=E5=80=BC?= =?UTF-8?q?=E6=A0=B9=E5=9B=A02=E5=AD=90=E9=97=AE=E9=A2=98=E5=92=8C?= =?UTF-8?q?=E6=A0=B9=E5=9B=A03=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因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%) --- strategies/shared/signals/selectors.py | 28 +++++++++++++++++--------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/strategies/shared/signals/selectors.py b/strategies/shared/signals/selectors.py index 5f55cc6..2632908 100644 --- a/strategies/shared/signals/selectors.py +++ b/strategies/shared/signals/selectors.py @@ -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=false,BOND正常参与竞争 + 3. bond无数据时(2002-2007)不填充 + 4. V2退化:若bond_threshold.enabled=false,BOND正常参与竞争 """ 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)