Files
etf/docs/轮动策略核心逻辑_v3.md
aszerW 61b6f0b0a3 docs: V3轮动策略核心逻辑文档
核心内容:
- V2 vs V3核心差异对比表
- 动态阈值机制详解(三层守卫)
- 短债双重角色:阈值参考 + 空余仓位填充
- 三步筛选流程:动态阈值 → 类内竞争 → 跨类排序+填充
- 具体示例:正常/危机/极端危机三种场景
- V3关键修复:重复代码计分 + 数据守卫 + 空信号状态保持
- 三次修复历程:74a664d → be8ca0218bc9b8
- V3配置示例与回滚方案

回测绩效:
- CAGR: 27.81%
- 最大回撤: -24.36%
- 夏普: 1.40
- Calmar: 1.14
2026-05-19 01:13:43 +08:00

415 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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不再是普通候选标的而是承担双重角色
1. **角色A动态阈值参考**
- 其动量得分决定当日过滤阈值
- 其他标的得分 < 短债动量 自动排除
2. **角色B空余仓位填充物**
- 选出不足3只时用短债代码填充剩余仓位
- 确保信号永远为3只标的满仓保障
**关键BOND大类不参与跨大类竞争**不会与其他大类冠军一起排名选Top3
### 数据范围说明
- **短债指数数据**2007-12-31 Tushare限制
- **2002-2007年策略行为**退化至V2模式无动态阈值无短债填充
---
## 🧮 得分计算逻辑与V2相同
### 因子类型:加权线性回归动量 (weighted_momentum)
#### 核心公式
```
得分 = 年化收益率 ×
```
#### 计算步骤
1. **取对数收益**`y = ln(价格序列)`过去25个交易日
2. **构造权重**`weights = linspace(1, 2, 25)`近期权重2.0远期权重1.0线性递增
3. **加权线性回归**`slope, intercept = polyfit(x, y, 1, w=weights)`
4. **年化收益率**`annualized_returns = exp(slope × 250) - 1`
5. **加权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
```
6. **最终得分**`score = annualized_returns × R²`
#### 得分含义
- **正值**:上升趋势,值越大代表趋势越强且越稳定
- **负值**:下降趋势(会被过滤,不参与选股)
- **接近0**:无明显趋势或趋势不稳定
### 崩盘过滤机制
在得分计算后对最近3个交易日进行崩盘检测
- **条件1**最近3天中任一天跌幅 > 5%
- **条件2**连续3天下跌且累计跌幅 > 5%
满足任一条件 → **得分强制清零**,该标的当日不参与选股。
---
## 📦 选股逻辑V3动态阈值分组选股
### 核心机制:三步筛选
#### 第一步:动态阈值过滤
```python
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. **最低持仓期**:距上次调仓 ≥ 1个交易日`rebalance_days = 1`
2. **得分改善检查**
```python
# 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
```
3. **目标信号处理**
- V2target为空 → 清仓
- V3target为空 → **保持旧持仓**(状态保持)
### V3关键修复重复代码计分
当信号中出现 `"NDX,931862.CSI,931862.CSI"` 时:
- **错误V2逻辑**:短债只计一次得分
- **正确V3修复**:短债计两次得分(按实际份数)
```python
# 错误遍历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防御机制详解
### 动态阈值的三层守卫
```python
def _get_dynamic_threshold(scores):
if not bond_threshold.enabled:
return min_score # 层1V2退化
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动态阈值
```
### 短债填充的数据守卫
```python
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)
```
### 空信号状态保持
```python
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核心优势
1. **自适应风控**:短债动量作为动态阈值,危机时刻自动收紧过滤标准
2. **永不空仓**:空余仓位用短债填充,避免"持币观望"的机会成本
3. **平滑过渡**:空信号保持旧持仓,避免突然清仓的冲击
4. **满仓保障**信号永远为3只标的仓位分配更稳定
5. **防御主导**危机时刻短债占比可达67%~100%,有效控制回撤
6. **数据退化**2002-2007无短债数据时正确退化为V2行为
---
## 📝 V3配置示例
```yaml
# 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行为只需修改一行配置
```yaml
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`*