核心内容: - V2 vs V3核心差异对比表 - 动态阈值机制详解(三层守卫) - 短债双重角色:阈值参考 + 空余仓位填充 - 三步筛选流程:动态阈值 → 类内竞争 → 跨类排序+填充 - 具体示例:正常/危机/极端危机三种场景 - V3关键修复:重复代码计分 + 数据守卫 + 空信号状态保持 - 三次修复历程:74a664d →be8ca02→18bc9b8- V3配置示例与回滚方案 回测绩效: - CAGR: 27.81% - 最大回撤: -24.36% - 夏普: 1.40 - Calmar: 1.14
14 KiB
14 KiB
ETF轮动策略核心逻辑 V3
📊 策略概览
基于加权动量因子的跨市场ETF轮动策略,通过量化评分自动选择表现最优的ETF组合进行投资。V3版本核心升级:动态阈值机制,以短债动量作为过滤阈值,实现自适应风控——动量低于短债的标的自动排除,空余仓位用短债填充,构建"永不空仓"的防御体系。
🔄 V2 vs V3 核心差异
| 维度 | V2(固定阈值) | V3(动态阈值) |
|---|---|---|
| 阈值来源 | min_score=0.0 固定值 |
短债动量 × ratio(每日动态) |
| 阈值计算 | 常量 0 | scores['931862.CSI'] × 1.0 |
| BOND角色 | 普通候选,参与Top3竞争 | 双重角色:阈值参考 + 空余仓位填充 |
| 不足3只时 | 返回少于3只(仓位不满) | 用短债填充至3只(满仓保障) |
| 空信号处理 | 清仓(仓位归零) | 保持旧持仓(状态保持) |
| 信号格式 | "NDX,GC=F" (2只) |
"NDX,931862.CSI,931862.CSI" (3只,含填充) |
| 仓位效果 | 2只各50% | NDX 33%, 短债 67%(等权分配) |
| 防御机制 | 无(纯动量选股) | 短债作为"现金底仓"自动填充 |
🎯 候选池配置
覆盖市场(7大类,11只标的)
| 大类 (market) | 标的 | 信号源代码 | 交易ETF | 数据来源 |
|---|---|---|---|---|
| A股 (A) | 创业板指 | 399006.SZ | 159915.SZ | Tushare |
| A股 (A) | 中证红利低波 | H30269.CSI | 512890.SH | Tushare |
| 美股 (US) | 纳指100 | NDX | 513100.SH | YFinance |
| 日本 (JP) | 日经225 | N225 | 513520.SH | YFinance |
| 欧洲 (EU) | 德国DAX | GDAXI | 513030.SH | YFinance |
| 港股 (HK) | 恒生指数 | HSI | 159920.SZ | YFinance |
| 港股 (HK) | 恒生科技 | HSTECH.HK | 513130.SH | YFinance |
| 商品 (COMMODITY) | 黄金 | AU.SHF | 518880.SH | Tushare/期货 |
| 商品 (COMMODITY) | 原油 | CL.NYM | 160723.SZ | YFinance |
| 商品 (COMMODITY) | 有色金属(铜) | CU.SHF | 159980.SZ | Tushare/期货 |
| 固收 (BOND) | 短债指数 | 931862.CSI | 511090.SH | Tushare |
⚠️ V3特殊说明:BOND大类角色转变
在V3中,短债指数(931862.CSI)不再是普通候选标的,而是承担双重角色:
-
角色A:动态阈值参考
- 其动量得分决定当日过滤阈值
- 其他标的得分 < 短债动量 → 自动排除
-
角色B:空余仓位填充物
- 选出不足3只时,用短债代码填充剩余仓位
- 确保信号永远为3只标的(满仓保障)
关键:BOND大类不参与跨大类竞争,不会与其他大类冠军一起排名选Top3。
数据范围说明
- 短债指数数据:2007-12-31 起(Tushare限制)
- 2002-2007年策略行为:退化至V2模式(无动态阈值,无短债填充)
🧮 得分计算逻辑(与V2相同)
因子类型:加权线性回归动量 (weighted_momentum)
核心公式
得分 = 年化收益率 × R²
计算步骤
- 取对数收益:
y = ln(价格序列)(过去25个交易日) - 构造权重:
weights = linspace(1, 2, 25)(近期权重2.0,远期权重1.0,线性递增) - 加权线性回归:
slope, intercept = polyfit(x, y, 1, w=weights) - 年化收益率:
annualized_returns = exp(slope × 250) - 1 - 加权R²:
y_pred = slope × x + intercept ss_res = Σ(weights × (y - y_pred)²) ss_tot = Σ(weights × (y - weighted_mean(y))²) R² = 1 - ss_res / ss_tot - 最终得分:
score = annualized_returns × R²
得分含义
- 正值:上升趋势,值越大代表趋势越强且越稳定
- 负值:下降趋势(会被过滤,不参与选股)
- 接近0:无明显趋势或趋势不稳定
崩盘过滤机制
在得分计算后,对最近3个交易日进行崩盘检测:
- 条件1:最近3天中任一天跌幅 > 5%
- 条件2:连续3天下跌,且累计跌幅 > 5%
满足任一条件 → 得分强制清零,该标的当日不参与选股。
📦 选股逻辑:V3动态阈值分组选股
核心机制:三步筛选
第一步:动态阈值过滤
threshold = 短债动量得分 × ratio # ratio默认1.0
scores = {k: v for k, v in scores.items() if v >= threshold}
阈值退化机制:
- 短债无数据(2002-2007) →
threshold = min_score = 0.0 - 短债动量 < 0 →
threshold = min_score = 0.0 - 短债动量 ≥ 0 →
threshold = 短债动量 × 1.0
第二步:类内竞争(BOND不参与)
每个 market 大类只保留得分最高的1只标的(大类冠军),但BOND大类被排除:
A股: 创业板指 vs 红利低波 → 选1只冠军
港股: 恒生指数 vs 恒生科技 → 选1只冠军
商品: 黄金 vs 原油 vs 有色金属 → 选1只冠军
美股/日本/欧洲: 各1只,自动成为冠军
固收(BOND): ⚠️ 不参与竞争(作为阈值+填充)
第三步:跨类排序 + 短债填充
从最多6个大类冠军中(排除BOND),按得分从高到低选 Top 3:
选出标的 = Top 3 大类冠军
空余仓位 = 3 - len(选出标的)
if 空余仓位 > 0 AND 短债有数据:
用短债代码填充空余仓位(可能重复)
具体示例
示例1:正常行情(选出3只风险资产)
某日各标的得分:
| 大类 | 标的 | 得分 | 动态阈值过滤 | 类内排名 |
|---|---|---|---|---|
| BOND | 短债指数 | 0.05 | —(阈值参考) | ⚠️ 不参与 |
| A | 创业板指 | 2.5 | ✓ > 0.05 | ✓ 冠军 |
| US | 纳指100 | 4.7 | ✓ > 0.05 | ✓ 冠军 |
| JP | 日经225 | 3.5 | ✓ > 0.05 | ✓ 冠军 |
| COMMODITY | 黄金 | 3.1 | ✓ > 0.05 | ✓ 冠军 |
- 动态阈值 = 0.05(短债得分)
- 过滤后冠军:纳指100(4.7) > 日经225(3.5) > 黄金(3.1) > 创业板指(2.5)
- 选 Top 3:纳指100 + 日经225 + 黄金(各33%)
- 空余仓位 = 0 → 无需填充
- 信号:
"NDX,N225,GC=F"
示例2:危机行情(仅选出1只,短债填充2份)
| 大类 | 标的 | 得分 | 动态阈值过滤 | 类内排名 |
|---|---|---|---|---|
| BOND | 短债指数 | 0.02 | —(阈值参考) | ⚠️ 不参与 |
| A | 创业板指 | -0.5 | ✗ < 0.02 | 过滤 |
| US | 纳指100 | 0.03 | ✓ > 0.02 | ✓ 冠军 |
| JP | 日经225 | 0.01 | ✗ < 0.02 | 过滤 |
| COMMODITY | 黄金 | -0.2 | ✗ < 0.02 | 过滤 |
- 动态阈值 = 0.02(短债得分)
- 过滤后冠军:纳指100(0.03)
- 选出1只:纳指100
- 空余仓位 = 2 → 填充2份短债
- 信号:
"NDX,931862.CSI,931862.CSI" - 仓位:纳指100 33%,短债 67%(防御主导)
示例3:极端危机(选出0只,短债填充3份)
| 大类 | 标的 | 得分 | 动态阈值过滤 |
|---|---|---|---|
| BOND | 短债指数 | 0.01 | —(阈值参考) |
| A | 创业板指 | -0.5 | ✗ < 0.01 |
| US | 纳指100 | 0.005 | ✗ < 0.01 |
| JP | 日经225 | -0.3 | ✗ < 0.01 |
- 动态阈值 = 0.01
- 过滤后冠军:无(全部低于阈值)
- 选出0只
- 空余仓位 = 3 → 填充3份短债
- 信号:
"931862.CSI,931862.CSI,931862.CSI" - 仓位:100% 短债(全防御模式)
⏱️ 调仓控制(V3增强)
调仓触发条件
每日检查,同时满足以下条件才触发调仓:
- 最低持仓期:距上次调仓 ≥ 1个交易日(
rebalance_days = 1) - 得分改善检查:
# V3修复:按实际份数计算得分(支持重复代码) old_total = sum(row.get(c, 0) for c in old_codes) # 遍历持仓列表 new_total = sum(row.get(c, 0) for c in new_codes) # 含重复短债 if old_total > 0: return (new_total / old_total - 1) >= rebalance_threshold - 目标信号处理:
- V2:target为空 → 清仓
- V3:target为空 → 保持旧持仓(状态保持)
V3关键修复:重复代码计分
当信号中出现 "NDX,931862.CSI,931862.CSI" 时:
- 错误(V2逻辑):短债只计一次得分
- 正确(V3修复):短债计两次得分(按实际份数)
# 错误(遍历factor_cols做in判断)
new_total = sum(row[col] for col in factor_cols if col in new_codes)
# 短债出现2次,但只匹配1次 → 得分被低估
# 正确(直接遍历持仓列表)
new_total = sum(row[c] for c in new_codes)
# 短债出现2次 → 得分正确累加
T+1执行机制
- T日收盘后计算因子得分,生成信号(
signal_raw) - 信号向后移位1天(
shift(1))作为T+1日的执行信号 - 运行时间:T+1日上午9:00(北京时间),因美股/期货数据凌晨才可用
交易成本
- 成本率:0.1%(双边,含佣金+滑点)
- 扣除方式:按换手率比例扣除
换手率 = 新增品种数 / 旧持仓品种数成本 = 换手率 × 0.1%
🛡️ V3防御机制详解
动态阈值的三层守卫
def _get_dynamic_threshold(scores):
if not bond_threshold.enabled:
return min_score # 层1:V2退化
bond_score = scores.get('931862.CSI', None)
if bond_score is None or bond_score < 0:
return min_score # 层2:无数据或负动量
return bond_score × ratio # 层3:动态阈值
短债填充的数据守卫
def _grouped_selection(scores, bond_has_data):
# 类内竞争(排除BOND)
...
# 空余仓位填充
if fill_bond AND bond_code AND bond_has_data:
# 只在短债有数据时填充
# 2002-2007年无数据 → 不填充
for _ in range(n_bond_slots):
selected.append(bond_code)
空信号状态保持
def _apply_rebalance_control(daily_target):
for target in daily_target:
if not target:
# V3:保持当前持仓不变
# V2:清仓(current_held = '')
pass # 不更新current_held
为什么V3这样做?
- 有短债数据时,target永远不会为空(会被填充)
- target为空仅发生在2002-2007无短债数据期
- 保持旧持仓 = 用最近有效信号继续执行,比突然清仓更平滑
📈 回测绩效(2002-01 ~ 2026-05)
V2 vs V3 全周期对比
| 指标 | V2(固定阈值) | V3(动态阈值) | 差异 |
|---|---|---|---|
| 累计收益 | — | 39,380% | — |
| CAGR | — | 27.81% | — |
| 夏普比率 | — | 1.40 | — |
| 最大回撤 | — | -24.36% | — |
| Calmar | — | 1.14 | — |
| 最大回撤区间 | — | 2015-05~2016-06 | — |
V3持仓特征分析
| 持仓状态 | 占比 | 说明 |
|---|---|---|
| 3只风险资产 | 71.6% | 正常行情,短债不参与 |
| 1只风险+2只短债 | 12.2% | 部分防御,短债填充2份 |
| 2只风险+1只短债 | 12.0% | 轻度防御,短债填充1份 |
| 3只短债 | 4.5% | 全防御模式 |
| 不足3只 | <1% | 仅2002-2007无数据期 |
📋 策略特点总结
V1 → V2 → V3演进对比
| 维度 | V1配置 | V2配置 | V3配置 |
|---|---|---|---|
| 候选池 | 22只(A股为主) | 11只(全球7大类) | 11只(BOND角色转变) |
| 因子类型 | slope_r2 | weighted_momentum | weighted_momentum |
| 崩盘过滤 | 无 | 3天跌>5%清零 | 3天跌>5%清零 |
| 持仓数量 | 5只(等权20%) | 3只(等权33%) | 3只(含短债填充) |
| 选股模式 | 纯Top N | 跨大类分散化 | 动态阈值+短债填充 |
| 阈值类型 | 固定min_score=0 | 固定min_score=0 | 动态阈值(短债动量) |
| 空信号处理 | 清仓 | 清仓 | 保持旧持仓 |
| 防御机制 | 无 | 无 | 短债自动填充 |
| 数据源 | Tushare+YFinance+CCXT | Tushare+YFinance | Tushare+YFinance |
| 基准指数 | 沪深300 | 沪深300 | 沨深300 |
💡 V3核心优势
- 自适应风控:短债动量作为动态阈值,危机时刻自动收紧过滤标准
- 永不空仓:空余仓位用短债填充,避免"持币观望"的机会成本
- 平滑过渡:空信号保持旧持仓,避免突然清仓的冲击
- 满仓保障:信号永远为3只标的,仓位分配更稳定
- 防御主导:危机时刻短债占比可达67%~100%,有效控制回撤
- 数据退化:2002-2007无短债数据时正确退化为V2行为
📝 V3配置示例
# strategies/rotation/config.yaml
# ==================== 轮动参数 ====================
select_num: 3
diversified: true
# V3: 动态阈值配置(替代固定 min_score: 0.0)
bond_threshold:
enabled: true # true=V3动态阈值, false=退化为V2固定阈值
bond_code: "931862.CSI" # 阈值参考标的(短债指数)
ratio: 1.0 # 阈值 = 短债动量 × ratio
fill_bond: true # 选出不足select_num只时,用短债填充空余仓位
# 保留 min_score 作为 fallback
min_score: 0.0
# ==================== 调仓控制 ====================
rebalance_days: 1
rebalance_threshold: 0.0
trade_cost: 0.001
🔄 V3回滚方案
如需回退至V2行为,只需修改一行配置:
bond_threshold:
enabled: false # 关闭动态阈值
代码自动退化:
_get_dynamic_threshold返回min_score = 0.0_grouped_selection中BOND排除逻辑不生效_apply_rebalance_control空信号仍保持旧持仓(可额外修改)
🐛 V3修复历程
三次关键修复
| 版本 | Commit | 修复内容 | 效果 |
|---|---|---|---|
| V3落地 | 74a664d |
初始实现动态阈值 | 净值292,回撤-27% |
| 首次修复 | be8ca02 |
根因1+根因2(部分) | 净值334,回撤**-32%** |
| 最终修复 | 18bc9b8 |
根因2子问题+根因3 | 净值395,回撤**-24%** |
三个根因详解
| 根因 | 问题 | 修复 | 影响 |
|---|---|---|---|
| 根因1 | _check_rebalance重复代码只计一次得分 |
直接遍历持仓列表 | 278天应防御未切 |
| 根因2子问题 | bond无数据(2002-2007)时错误填充 | 添加bond_has_data守卫 |
2002年异常回撤 |
| 根因3 | 空target时清仓导致错过反弹 | 保持旧持仓(pass) |
2002年回撤加深 |
文档版本:V3.0
更新时间:2026-05-19
对应配置:strategies/rotation/config.yaml
关键Commit:18bc9b8