From 957769b501b99b980ec954602268f1c31c95b9b2 Mon Sep 17 00:00:00 2001 From: aszerW Date: Tue, 19 May 2026 00:01:25 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0V3=E5=8A=A8=E6=80=81?= =?UTF-8?q?=E9=98=88=E5=80=BC=E5=AE=9E=E6=96=BD=E6=96=B9=E6=A1=88=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 详细说明了: 1. 当前代码流程分析 2. 需要修改的文件及具体代码 3. V2 vs V3逻辑对照表 4. 关键注意事项(BOND角色转变、信号格式兼容等) 5. 验证方式与预期结果 6. 回滚方案 --- docs/V3动态阈值实施方案.md | 259 +++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 docs/V3动态阈值实施方案.md diff --git a/docs/V3动态阈值实施方案.md b/docs/V3动态阈值实施方案.md new file mode 100644 index 0000000..2bdbda0 --- /dev/null +++ b/docs/V3动态阈值实施方案.md @@ -0,0 +1,259 @@ +# V3 动态阈值实施方案 + +## 目标 + +将 `generate_bond_threshold_report.py` 中验证的动态阈值逻辑落地到正式策略代码,使 `RotationStrategy.run_backtest()` 直接产出 V3 效果(CAGR 28.17%, 回撤 -24.35%)。 + +--- + +## 当前代码流程 + +``` +config.yaml (min_score: 0.0) + ↓ +strategy.py:96 → self.min_score = config.get('min_score', 0.0) + ↓ +strategy.py:72 → TopNSelector(..., min_score=self.min_score) + ↓ +selectors.py:84 → scores = {k: v for k, v in scores.items() if v >= self.min_score} + ↓ +selectors.py:216 → if score >= self.min_score: valid_champions.append(...) + ↓ +selectors.py:224 → return sorted_champions[:self.select_num] (不足N只时返回少于N只) +``` + +**问题**:当前 `min_score=0.0` 是固定值,且选出不足 `select_num` 只时直接返回少数标的(没有用短债填充空余仓位)。 + +--- + +## 需要修改的文件(3个) + +### 文件1:`strategies/rotation/config.yaml` + +**改动**:新增 `bond_threshold` 配置块,替代固定 `min_score` + +```yaml +# ==================== 轮动参数 ==================== +select_num: 3 +diversified: true + +# V3: 动态阈值配置(替代固定 min_score: 0.0) +# 使用短债动量作为动态 min_score:标的动量 < 短债动量 → 不持有 +bond_threshold: + enabled: true # true=V3动态阈值, false=退化为V2固定阈值 + bond_code: "931862.CSI" # 阈值参考标的 + ratio: 1.0 # 阈值 = 短债动量 × ratio + fill_bond: true # 选出不足select_num只时,用短债填充空余仓位 + +# 保留 min_score 作为 fallback(bond_threshold.enabled=false 或短债无数据时使用) +min_score: 0.0 +``` + +**位置**:第 120~132 行区域 + +--- + +### 文件2:`strategies/shared/signals/selectors.py` + +**改动点1**:`TopNSelector.__init__` 新增参数(第 34~59 行) + +```python +def __init__( + self, + select_num: int = 3, + group_by: Optional[str] = None, + group_mapping: Optional[Dict[str, str]] = None, + top_per_group: int = 1, + min_score: Optional[float] = None, + rebalance_threshold: float = 0.0, + rebalance_days: int = 1, + # V3 新增 + bond_threshold_config: Optional[Dict] = None, +): + ... + self.bond_threshold_config = bond_threshold_config or {} +``` + +**改动点2**:`generate()` 方法中替换固定 min_score 过滤(第 83~85 行) + +当前: +```python +# 最小得分过滤(如过滤负分) +if self.min_score is not None: + scores = {k: v for k, v in scores.items() if v >= self.min_score} +``` + +改为: +```python +# V3: 动态阈值 = 短债动量; V2 fallback: 固定 min_score +threshold = self._get_dynamic_threshold(scores) +scores = {k: v for k, v in scores.items() if v >= threshold} +``` + +**改动点3**:新增 `_get_dynamic_threshold` 方法 + +```python +def _get_dynamic_threshold(self, scores: Dict[str, float]) -> float: + """获取动态阈值:短债动量 × ratio,无数据时退化为 min_score""" + cfg = self.bond_threshold_config + if not cfg.get('enabled', False): + return self.min_score if self.min_score is not None else 0.0 + + bond_code = cfg.get('bond_code', '931862.CSI') + ratio = cfg.get('ratio', 1.0) + + bond_score = scores.get(bond_code, None) + if bond_score is None or bond_score < 0: + return self.min_score if self.min_score is not None else 0.0 + + return bond_score * ratio +``` + +**改动点4**:`_grouped_selection()` 方法(第 193~224 行) + +当前逻辑已排除 BOND 大类的标的与其他类竞争吗?**没有**——当前代码所有通过 min_score 的标的都参与分组选股,BOND 冠军和其他大类冠军一起排名。 + +需要改为: +1. BOND 大类标的不参与冠军竞争(它是阈值,不是候选) +2. 选出不足 `select_num` 只时,用短债填充 + +```python +def _grouped_selection(self, scores: Dict[str, float]) -> List[str]: + """V3分组选股:BOND不参与竞争,空余仓位填充短债""" + if not scores: + return [] + + cfg = self.bond_threshold_config + bond_code = cfg.get('bond_code', '931862.CSI') if cfg.get('enabled') else None + + # 建立 group -> (code, score) 映射,排除 BOND 大类 + group_champions = {} + for code, score in scores.items(): + group = self.group_mapping.get(code, 'default') + if group == 'BOND': + continue # BOND 不参与竞争 + if group not in group_champions or score > group_champions[group][1]: + group_champions[group] = (code, score) + + # 跨类排序取 Top N + sorted_champions = sorted(group_champions.values(), key=lambda x: x[1], reverse=True) + selected = [code for code, score in sorted_champions[:self.select_num]] + + # 空余仓位填充短债 + if cfg.get('fill_bond', False) and bond_code: + n_bond_slots = self.select_num - len(selected) + for _ in range(n_bond_slots): + selected.append(bond_code) + + return selected +``` + +--- + +### 文件3:`strategies/rotation/strategy.py` + +**改动点**:初始化 `TopNSelector` 时传入 `bond_threshold_config`(第 69~75 行) + +当前: +```python +self._selector = TopNSelector( + select_num=self.select_num, + group_mapping=self._group_mapping, + min_score=self.min_score, + rebalance_days=self.rebalance_days, + rebalance_threshold=self.rebalance_threshold +) +``` + +改为: +```python +self._selector = TopNSelector( + select_num=self.select_num, + group_mapping=self._group_mapping, + min_score=self.min_score, + rebalance_days=self.rebalance_days, + rebalance_threshold=self.rebalance_threshold, + bond_threshold_config=self.config.get('bond_threshold', {}), +) +``` + +--- + +## 逻辑对照表 + +| 步骤 | V2(当前) | V3(目标) | +|------|-----------|-----------| +| 阈值来源 | `config.yaml` 固定 `min_score=0.0` | 每日从因子中读取短债动量 | +| 阈值计算 | 常量 0 | `scores['931862.CSI'] × ratio` | +| BOND 竞争 | BOND冠军与其他大类一起排名 | BOND 不参与竞争(仅作阈值+填充) | +| 不足N只时 | 返回少于N只(BacktestExecutor等权分配) | 用短债代码填充至N只 | +| 信号格式 | `"NDX,GC=F"` (2只) | `"NDX,GC=F,931862.CSI"` (3只) | +| 仓位效果 | 2只各50% | NDX 33%, GC=F 33%, 短债 33% | + +--- + +## 关键注意事项 + +### 1. BOND 大类从"候选"变为"工具" + +V2 中 931862.CSI 是普通候选标的,与其他大类一样参与 Top3 排名。V3 中它变为双重角色: +- **角色A**:阈值(它的动量决定其他标的是否值得持有) +- **角色B**:填充物(空余仓位用它填充) + +它不再参与 `_grouped_selection` 的冠军竞争。 + +### 2. 信号格式兼容 + +V3 信号中短债出现方式:`"NDX,931862.CSI,931862.CSI"` 表示 NDX 1/3 + 短债 2/3。`BacktestExecutor` 已支持重复代码等权分配(`generate_bond_threshold_report.py` 已验证)。 + +### 3. 短债无数据时的退化 + +2002-2007年 931862.CSI 无数据,`scores.get('931862.CSI')` 返回 None → 阈值退化为 `min_score=0.0` → 策略行为与 V2 完全一致。无需特殊处理。 + +### 4. `min_score` 过滤的时机 + +动态阈值替代了第84行的全局过滤。但第216行的大类冠军二次过滤也需要用动态阈值,否则存在不一致: + +```python +# 第216行当前逻辑 +if score >= self.min_score: + valid_champions.append((code, score)) +``` + +V3 中这行逻辑被合并进新的 `_grouped_selection`:在 `scores` 已经过动态阈值过滤后,所有剩余标的天然满足 `>= threshold`,不需要二次检查。 + +--- + +## 验证方式 + +修改完成后运行: + +```bash +# 用正式策略流程跑回测 +python -c " +from strategies.rotation.strategy import RotationStrategy +strategy = RotationStrategy.from_yaml('strategies/rotation/config.yaml') +strategy.run_backtest() +" +``` + +预期结果应与 `generate_bond_threshold_report.py --ratio 1.0` 一致: +- CAGR ≈ 28% +- 最大回撤 ≈ -24% +- 夏普 ≈ 1.40 + +如果数值有差异,检查: +1. 调仓控制逻辑是否一致(`_apply_rebalance_control` vs `_apply_rebalance`) +2. 溢价率过滤是否影响了部分标的的入选 + +--- + +## 回滚方案 + +```yaml +# config.yaml 一行改动即可回退到V2 +bond_threshold: + enabled: false # 关闭动态阈值,退化为 min_score=0.0 +``` + +代码中 `_get_dynamic_threshold` 会返回 `self.min_score`,`_grouped_selection` 中 BOND 排除逻辑不生效(因为 `cfg.get('enabled')` 为 false),行为与 V2 完全一致。