# 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)

#### 核心公式
```
得分 = 年化收益率 × R²
```

#### 计算步骤
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. **目标信号处理**：
   - V2：target为空 → 清仓
   - V3：target为空 → **保持旧持仓**（状态保持）

### 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  # 层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：动态阈值
```

### 短债填充的数据守卫

```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`*