docs: V3轮动策略核心逻辑文档

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

回测绩效:
- CAGR: 27.81%
- 最大回撤: -24.36%
- 夏普: 1.40
- Calmar: 1.14
This commit is contained in:
2026-05-19 01:13:43 +08:00
parent 18bc9b8c44
commit 61b6f0b0a3

View File

@@ -0,0 +1,415 @@
# 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`*