feat: V3动态阈值实施方案落地

核心逻辑:
1. config.yaml新增bond_threshold配置块
2. selectors.py新增动态阈值逻辑:
   - _get_dynamic_threshold(): 阈值=短债动量×ratio
   - _grouped_selection(): BOND不参与竞争,空余仓位填充短债
3. strategy.py传入bond_threshold_config

回测验证:
- 最终净值: 292.56
- 累计收益: 29155.96%
- 持仓3只: 92.3%(满仓率提升)
- 短债填充: 27.7%时间启用(空余仓位)

信号特征:
- 短债可重复出现表示仓位占比
- 例如 "NDX,931862.CSI,931862.CSI" → NDX 33%, 短债 67%
This commit is contained in:
2026-05-18 23:58:10 +08:00
parent 3e6d9d1fdb
commit 74a664d4ff
3 changed files with 69 additions and 24 deletions

View File

@@ -125,6 +125,14 @@ diversified: true
# 设置为0表示过滤负动量标的更高阈值虽能改善回撤但可能错过正动量机会
min_score: 0.0
# 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只时用短债填充空余仓位
# ==================== 调仓控制 ====================
# 最低调仓周期(交易日):持仓至少持有 N 天后才允许换仓
rebalance_days: 1

View File

@@ -71,7 +71,8 @@ class RotationStrategy(StrategyBase):
group_mapping=self._group_mapping,
min_score=self.min_score, # 从配置读取,支持动态调整阈值
rebalance_days=self.rebalance_days,
rebalance_threshold=self.rebalance_threshold
rebalance_threshold=self.rebalance_threshold,
bond_threshold_config=self.config.get('bond_threshold', {}) # V3动态阈值配置
)
@classmethod

View File

@@ -18,6 +18,7 @@ class TopNSelector(SignalGenerator):
- 按因子值排序选出Top N标的
- 支持分组选股(先类内竞争,再跨类排序)
- 支持调仓阈值检查(新组合得分需超过当前组合一定比例才调仓)
- V3: 支持动态阈值(短债动量作为过滤阈值)
参数:
- select_num: 选中数量默认3
@@ -27,6 +28,7 @@ class TopNSelector(SignalGenerator):
- min_score: 最小得分阈值可选如0表示过滤负分
- rebalance_threshold: 调仓阈值可选新组合得分需超过当前组合X%才调仓)
- rebalance_days: 最低调仓周期可选持仓至少N天才能调仓
- bond_threshold_config: V3动态阈值配置
"""
mode = "top_n"
@@ -39,7 +41,8 @@ class TopNSelector(SignalGenerator):
top_per_group: int = 1,
min_score: Optional[float] = None,
rebalance_threshold: float = 0.0,
rebalance_days: int = 1
rebalance_days: int = 1,
bond_threshold_config: Optional[Dict] = None
):
super().__init__(
select_num=select_num,
@@ -57,6 +60,28 @@ class TopNSelector(SignalGenerator):
self.min_score = min_score
self.rebalance_threshold = rebalance_threshold
self.rebalance_days = rebalance_days
self.bond_threshold_config = bond_threshold_config or {}
def _get_dynamic_threshold(self, scores: Dict[str, float]) -> float:
"""获取动态阈值:短债动量 × ratio无数据时退化为 min_score
V3动态阈值逻辑
- 若bond_threshold.enabled=true阈值 = 短债动量 × ratio
- 若短债无数据或动量<0退化为固定min_score
- 若enabled=false退化为固定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
def generate(self, factor_data: pd.DataFrame) -> pd.DataFrame:
"""生成Top N选股信号支持调仓周期控制"""
@@ -80,9 +105,9 @@ class TopNSelector(SignalGenerator):
if pd.notna(score):
scores[col] = score
# 最小得分过滤(如过滤负分
if self.min_score is not None:
scores = {k: v for k, v in scores.items() if v >= self.min_score}
# 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:
@@ -191,37 +216,48 @@ class TopNSelector(SignalGenerator):
return new_total > 0
def _grouped_selection(self, scores: Dict[str, float]) -> List[str]:
"""分组选股:先类内竞争每大类选Top1再跨类排序
"""V3分组选股:BOND不参与竞争空余仓位填充短债
改进:大类冠军得分不足时跳过该大类,不强制持有弱正动量标的
V3逻辑
1. BOND大类标的不参与冠军竞争它是阈值不是候选
2. 选出不足 select_num 只时,用短债填充
3. V2退化若bond_threshold.enabled=falseBOND正常参与竞争
"""
if not scores:
return []
# 建立 group -> (code, score) 的映射
cfg = self.bond_threshold_config
bond_code = cfg.get('bond_code', '931862.CSI') if cfg.get('enabled') else None
# 建立 group -> (code, score) 映射
# V3: 排除 BOND 大类(它不参与竞争)
group_champions = {}
for code, score in scores.items():
# 从group_mapping获取分组
group = self.group_mapping.get(code, 'default')
# V3: BOND大类不参与竞争
if cfg.get('enabled') and group == 'BOND':
continue
if group not in group_champions or score > group_champions[group][1]:
group_champions[group] = (code, score)
# ⭐ 关键改进:大类冠军二次过滤
# 只保留得分足够显著的冠军,得分不足的大类跳过
# 这样组合中的每个标的动量都足够强
valid_champions = []
for group, (code, score) in group_champions.items():
# 大类冠军必须满足min_score已满足且得分足够显著
# min_score过滤负动量这里进一步过滤"弱正动量"
if score >= self.min_score:
valid_champions.append((code, score))
# 注意得分刚好等于min_score的冠军也会被保留
# 如果想更严格可以用更高的阈值如self.min_score + 0.02
# 跨类排序取 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]]
# 对有效冠军进行排序选出Top N
# 持仓数量动态调整最多select_num最少可以是0
sorted_champions = sorted(valid_champions, key=lambda x: x[1], reverse=True)
return [code for code, score in sorted_champions[:self.select_num]]
# V3: 空余仓位填充短债
if cfg.get('fill_bond', False) and bond_code:
n_bond_slots = self.select_num - len(selected)
if n_bond_slots > 0:
# 检查短债是否满足阈值条件需在原始scores中存在
bond_score = scores.get(bond_code, None)
if bond_score is not None:
# 用短债代码填充空余仓位(可能重复填充多次)
for _ in range(n_bond_slots):
selected.append(bond_code)
return selected
class TrendFollower(SignalGenerator):