下面是一份基于你提供的四篇文档整理出的、可直接指导项目实施的**ETF轮动策略技术方案文档**。内容覆盖：策略原理、数据与因子、回测实现、CAGR纠错、QMT实盘迁移、常见坑与工程规范。

---

# 一、项目目标与整体架构

**项目目标**：  
构建一套可从回测顺滑迁移到实盘的 ETF 轮动策略系统，实现：

- 风格/资产/行业轮动逻辑的灵活配置  
- 日频轮动（支持未来扩展到周频）  
- 有完整绩效评估（含修正后的 CAGR）  
- 可在 QMT 上稳定运行的实盘版本（含仓位隔离、委托跟踪等）

**整体架构分层**：

1. **数据层**：行情与指数数据获取（AkShare、本地QMT历史数据）。
2. **因子层**：动量/趋势得分计算（N日涨幅、斜率、斜率×R²）。
3. **信号层**：基于得分的强弱排序与轮动信号生成。
4. **回测层**：净值计算、quantstats 绩效评估（含 CAGR 修正）。
5. **实盘层（QMT）**：数据驱动、下单逻辑、委托跟踪、仓位隔离、异常处理。
6. **运维与风控**：日志、持仓文件、参数管理、错误监控。

---

# 二、策略原理与业务设计

## 2.1 轮动类型与本项目定位

文档中提到三类轮动：[1]

- 资产轮动：股、债、商品、现金之间切换；
- 行业/板块轮动：不同行业 ETF 之间切换；
- 风格轮动：大小盘、价值/成长等风格之间切换。

**本项目主线**：以**风格轮动**为核心案例（大盘/小盘 + 价值/成长），同时方案设计要做到：

- 候选池是可配置的（可替换为行业ETF、跨市场ETF组合）；
- 动量计算与信号生成模块，对“标的池”保持抽象，不绑定具体标的。

## 2.2 候选池设计

基础文档中的风格轮动候选池为四只 ETF：[1][2]

- 沪深300ETF：510300（大盘）
- 中证500ETF：510500（小盘）
- 红利ETF：510880（价值）
- 创业板ETF：159915（成长）

**方案要求**：

- 使用配置文件或策略参数维护 `CODE_LIST`：
  ```python
  CODE_LIST = ['510300', '510500', '510880', '159915']
  ```
- 候选池可轻松扩展（文档中有将候选池扩展到纳指ETF、黄金ETF等的例子[2]，可抽象为多资产轮动）。

**注意点**：

- 候选池选择带有“主观视角”和后视镜效应，属于策略建模假设，应在项目文档中明确：回测收益不能简单外推到未来[1]。
- 对于项目落地，可在候选池上设计多个版本（保守/均衡/激进），以便 AB 测试。

---

# 三、数据与因子计算方案

## 3.1 Python 环境与依赖

文档中使用的环境与库：[1][2]

- Python 3.x（文中示例 3.13）
- 关键库：`numpy`, `pandas`, `akshare`, `matplotlib`, `scikit-learn`, `quantstats`

方案建议：

- 统一到 3.10+ 的稳定版本；
- 通过 `requirements.txt` 管理：
  ```text
  numpy
  pandas
  akshare
  matplotlib
  scikit-learn
  quantstats
  ```

## 3.2 行情数据获取（离线回测）

示例代码结构如下：[2]

```python
code_list = ['510300', '510500', '510880', '159915']
start_date = '20150101'
end_date   = '20250828'

df_list = []
for code in code_list:
    df = ak.fund_etf_hist_em(
        symbol=code,
        period='daily',
        start_date=start_date,
        end_date=end_date,
        adjust='hfq'  # 后复权
    )
    df.insert(0, 'code', code)
    df_list.append(df)
    time.sleep(3)

all_df = pd.concat(df_list, ignore_index=True)
data = all_df.pivot(index='日期', columns='code', values='收盘')
data.index = pd.to_datetime(data.index)
data = data.sort_index()
```

**项目要求**：

1. 抽象出 `DataFetcher` 模块，支持：
   - AkShare 拉取 ETF 历史行情；
   - 可切换为本地 CSV/HDF5（防止外网依赖导致不稳定）。
2. 指数基准数据：
   - 必须同时获取沪深300指数（000300.SH）等基准，用于绩效评估和交易日推算（QMT 实盘中利用指数序列推前一交易日，避免停牌问题[3]）。

## 3.3 日收益率与动量（基础版）

基础动量使用“前 N 日涨幅”：[2]

```python
N = 10  # 动量窗口

for code in code_list:
    data[f'日收益率_{code}'] = data[code] / data[code].shift(1) - 1
    data[f'涨幅_{code}'] = data[code] / data[code].shift(N+1) - 1.0

data = data.dropna()
```

**工程要求**：

- N 作为可配置参数；
- 函数化封装，方便后续切换为斜率或斜率×R²得分。

## 3.4 趋势得分（改进版：斜率×R²）

文档中最终表现最好的是“斜率×决定系数 R²”的趋势得分[2][4]：

```python
def calculate_score(srs, N=25):
    if srs.shape[0] < N:
        return np.nan
    x = np.arange(1, N+1)
    y = srs.values / srs.values[0]  # 归一化
    lr = LinearRegression().fit(x.reshape(-1, 1), y)
    slope = lr.coef_[0]           # 斜率
    r_squared = lr.score(x.reshape(-1, 1), y)  # R²
    score = 10000 * slope * r_squared
    return score

N = 25  # 斜率计算长度

for code in code_list:
    data[f'日收益率_{code}'] = data[code] / data[code].shift(1) - 1
    data[f'得分_{code}'] = data[code].rolling(N).apply(lambda x: calculate_score(x, N))

data = data.dropna()
```

**项目选型**：

- 建议**默认采用斜率×R²得分**作为主策略动量因子：
  - 斜率：趋势方向与强度；
  - R²：趋势拟合优度，过滤剧烈波动导致的“假斜率”；
  - 实证上，从十年 7 倍提升到 19 倍收益[4]。
- 保留“简单 N 日涨幅”的实现作为对照策略，以支持对比研究与回测验证。

---

# 四、信号生成与回测实现

## 4.1 信号生成逻辑

核心思想：**每天选得分最高的 ETF，第二天按该信号进行持仓**。

文档中的实现要点：[2]

```python
# 取出每日得分最高的证券
data['信号'] = data[^f'得分_{v}' for v in code_list].idxmax(axis=1)
# 今日涨幅由昨日持仓产生，为避免未来函数：
data['信号'] = data['信号'].shift(1)
data = data.dropna()

data['轮动策略日收益率'] = data.apply(
    lambda x: x[f'日收益率_{x["信号"]}'], axis=1
)
```

**关键点（项目必须遵守）**：

- **信号使用 T-1 日数据**，在 T 日交易，避免“用收盘价算信号再按收盘价交易”的未来函数[5]。
- 第一日的交易日收益不记入策略收益（当日仅建仓）。

## 4.2 策略净值计算

策略净值曲线：以 1 为初始净值，日收益连乘：[2]

```python
data['轮动策略净值'] = (1 + data['轮动策略日收益率']).cumprod()
```

同时可计算各 ETF 的净值用于可视化对比。

## 4.3 回测指标与性能结论

文档给出的改进后策略表现（2015-02-10 至 2025-08-28）：[4]

| 指标          | 基准 沪深300 | 策略（斜率×R²） |
|---------------|--------------|-----------------|
| Cumulative Return | 516.53%    | 1,906.09%      |
| CAGR %        | 12.64%       | 21.67%*        |
| Sharpe        | 0.92         | 1.33           |
| Max Drawdown  | -28.57%      | -30.31%        |
| Time in Market| 98%          | 99%            |

> 注：文中后续通过修正 Quantstats CAGR Bug，得到的**正确年化在 32%+ 区间**[6]，见下一节。

**工程含义**：

- 策略在不加费用、以理想成交价（收盘价）回测下，显著跑赢基准；
- 最大回撤与基准相近，但收益远超，收益风险比良好；
- 实盘需考虑滑点与交易成本，实际收益会低于回测。

---

# 五、CAGR 计算错误及修正方案

## 5.1 问题来源

文档详细分析了 quantstats 中 CAGR 计算的历史 Bug[6][7]：

- quantstats 在 `cagr` 函数中使用：
  - 年数 = （自然日天数 / periods）
  - `periods` 默认值 = 252；
- 实际含义变成：**用自然日天数除以“交易日数 252”**，导致年数被拉长，年化收益被严重压低；
- 更糟糕的是，quantstats 在最终 metrics 中调用 `cagr` 时**没有传入 periods 参数**，导致用户在外部修改 `periods_per_year` 也不会影响 CAGR 结果[7]。

因此同一净值序列，QMT 与 quantstats 年化结果严重不一致：原版量化报告年化 21.69%，而 QMT 估算合理年化约 33%[6]。

## 5.2 修正方法（项目必须实现）

文档给出两种修复方式，任选一种即可，也可两种都做[7]：

**方式一：修改 stats.py 中 cagr 默认值**

```python
# 原
def cagr(returns, rf=0.0, compounded=True, periods=252):

# 改
def cagr(returns, rf=0.0, compounded=True, periods=365):
```

**方式二：修改 reports.py 中 metrics 调用**

```python
# 原
metrics["CAGR%%"] = _stats.cagr(df, rf, compounded) * pct

# 改
metrics["CAGR%%"] = _stats.cagr(df, rf, compounded, 365) * pct
```

修改后：

- 累计收益保持不变（仍为约 1906.09%）；
- 年化收益由 ~21.67% 修正到 ~32.86%，与 QMT 结果（33.46%）接近，差异来自自然日 vs 交易日、首尾日期含不含、净值精度等细节[8]。

**项目要求**：

- 在性能评估模块中集成“CAGR 自算函数”，不要完全依赖 quantstats 黑箱；
- 保留 QMT 计算口径的对比（通过自然日和交易日两种方式各计算一份年化），并输出到报告中，以减少跨平台困惑。

---

# 六、QMT 实盘迁移技术方案

文档中专门有两篇文章讨论轮动策略迁移到 QMT 实盘及踩坑经历[3][5][9]，项目需要按“回测逻辑 → 实盘逻辑”两层设计。

## 6.1 回测与实盘的关键差异

主要有五个维度的差异[5][9]：

1. 数据驱动方式（历史批量 vs 实时推送）；
2. 下单模式（假设全部成交 vs 部分成交、排队、撤单）；
3. 委托跟踪（回测通常忽略，实盘需要轮询订单状态）；
4. 仓位隔离（回测中账户只服务单一策略，实盘同一账户多策略并行）；
5. 异常处理（网络、交易所异常、盘中停牌等）。

项目中应为 QMT 实盘实现一个单独的“执行层模块”，逻辑大体如下：

- 定时任务在 `STRATEGY_TRADETIME`（如 14:58）触发；
- 拉取前一交易日日线收盘价，计算信号；
- 获取本策略持仓（见后面“仓位隔离”）；
- 对比目标持仓与当前持仓，生成买卖委托；
- 下单后轮询状态，`ORDER_TIMEOUT` 超时则自动撤单重下。

## 6.2 参数配置规范（QMT）

文档给出了比较完整的参数说明[5]：

- `ACCOUNT_ID`：股票账户资金账号（实盘必须为真实账号）。
- `ACCOUNT_TYPE`：固定 `"STOCK"`（ETF 走股票通道）。
- `ACCOUNT_MODE`：
  - `"MONEY"`：固定金额，如 `ACCOUNT_MONEY = 10000`；
  - `"RATIO"`：占总资产比例，如 `ACCOUNT_RATIO = 0.3`。
- `ACCOUNT_MONEY`：策略金额，建议 ≥ 能买得起候选池中任意一手 ETF，实践上建议不低于 \$1000 对应的人民币。
- `ACCOUNT_RATIO`：0–1 之间的小数。
- `STRATEGY_TRADETIME`：日内交易时间点（字符串），如 `"14:58"`。
- `ORDER_TIMEOUT`：订单超时（秒），超过时间未完全成交则撤单重下。
- `STRATEGY_PATH`：策略文件根路径，程序会在该路径下新建 `STRATEGY_NAME` 目录，用于：
  - 交易日志；
  - 本策略专用持仓文件。
- `STRATEGY_NAME`：策略名称，**实盘启用后不得随意修改**，否则会导致无法识别历史持仓文件，重新启动会把旧单子视为“外来持仓”[5]。
- `CODE_LIST`：实盘候选池，与回测保持一致。
- `N_DAYS`：斜率/得分窗口，默认 25。
- `SELECT_NUM`：一次轮动选中 ETF 数量（1 表示全仓单一品种，>1 表示多品种分仓）。

## 6.3 仓位隔离与本地文件

文档强调 QMT 自身只能区分“委托/成交归属哪个策略”，无法直接区分“当前总持仓中哪一部分是哪个策略开的”[9]。如果多个策略共用一个股票账户：

- 调用 `get_trade_detail_data` 得到的是**全账户持仓总和**；
- 若策略逻辑是 “if code not in selected_list then sell”，则会把其他策略和手工单的仓位一并卖掉。

**项目必须实现**：

- 为每个策略在 `STRATEGY_PATH/STRATEGY_NAME` 下维护一份**策略专属持仓文件**（如 JSON/CSV）：
  - 记录：证券代码、持仓数量、建仓时间、成本价等；
  - 实盘每次执行前，先读该文件，再结合实时查询来对账；
  - 下单成功后更新持仓文件；
- 禁止通过“全账户持仓”直接驱动策略卖出逻辑。

缺少本地读写权限时，策略无法创建这些文件，文档建议直接更换完整权限的 QMT 版本[9]。

## 6.4 常见实盘错误与防御

文档中列举了多类实盘报错及原因[3][9]：

1. **没有下载 ETF 历史数据**：回测前必须在 QMT 中先下载策略相关 ETF 历史数据，否则出现 `IndexError: single positional indexer is out-of-bounds`。
2. **没有下载指数数据**：基准指数（默认沪深300）同样需要下载，否则：
   - 回测绩效计算缺少 benchmark；
   - `get_previous_nth_trading_date` 使用指数作为“完整交易日序列”的参照时会出错。
3. **候选池增加新标的后报错**：
   - 新加入的 ETF 没有历史数据；
   - 或数据缺口导致 rolling 计算不满 N 天。
4. **global 变量作用域错误**：
   - 在函数中修改 `CODE_LIST` 未声明 `global CODE_LIST`，导致只改了局部变量，轮动逻辑仍基于旧池子；
   - 文档明确通过 `global CODE_LIST` 解决[9]。
5. **无读写权限**：
   - 无法写入策略持仓或日志，建议换 QMT 安装路径或版本。

项目中应：

- 在策略启动时加入一套“自检流程”：
  - 检查历史数据完整性（ETF + 基准指数）；
  - 核验文件系统读写权限；
  - 打印当前 `CODE_LIST` 和 `N_DAYS` 配置；
- 对常见异常设置明确的错误类型与日志输出。

---

# 七、项目落地建议与版本规划

## 7.1 最小可用版本（MVP）

包含：

1. AkShare 数据获取 + 本地缓存；
2. 简单 N 日涨幅轮动 + 斜率×R²轮动两个版本；
3. 回测模块（含 quantstats + 自算 CAGR 修正）；
4. 策略报告（净值曲线、相对基准、关键指标表）。

用途：内部论证与参数调优。

## 7.2 QMT 实盘版本（V1）

在 MVP 基础上增加：

1. QMT 适配：数据获取、下单接口封装；
2. 日线定时任务（按 `STRATEGY_TRADETIME` 触发）；
3. 仓位隔离实现（本地持仓文件）；
4. 完整日志与错误处理（包括数据缺失、下单失败、超时重试）。

## 7.3 强化版本（V2+）

未来可增加：

- 交易成本与滑点模拟；
- 多频率（周维度）轮动；
- 按波动率调节仓位（风险平价思想）；
- 多策略组合框架（资产轮动 + 行业轮动 + 风格轮动）。

---

# 八、结语（给团队的执行要点）

1. **策略层**：落地时默认优先实现“斜率×R²趋势得分”的 ETF 轮动，并保留 N 日涨幅版用作对照。
2. **评估层**：不能直接信任 quantstats 原始 CAGR 输出来做决策，必须按文档方式修正或自算年化。
3. **工程层**：QMT 实盘时，仓位隔离、本地持仓文件、读写权限是“生死线”，没做好会直接导致策略乱卖持仓。
4. **产品层**：对外宣传时，明确回测假设（收盘价成交、无费用、主观候选池），避免“十年10倍/19倍”被误解为可复制的实盘收益。
5. **团队协作**：建议先按本方案拆分为“数据&回测”、“QMT接入”、“风控&监控”三个子任务并行推进。  

如果你愿意，我可以在下一步帮你把上述方案拆解成更细的任务列表和代码模块设计草图（按你现在团队的技术栈来适配）。  

---

**References**

[1] 一、说在前面的话 / 轮动类型与候选池说明. 手把手教你构建与改进ETF轮动策略（十年19倍，附源码）.pdf  
[2] 轮动策略构建：数据获取与N日涨幅动量计算. 手把手教你构建与改进ETF轮动策略（十年19倍，附源码）.pdf  
[3] ETF轮动策略实盘QMT报错及数据下载问题解析. 完了，ETF轮动策略实盘当中，到底会遇到多少挫折_.pdf  
[4] 改进策略2：斜率×R²趋势得分与绩效对比. 手把手教你构建与改进ETF轮动策略（十年19倍，附源码）.pdf  
[5] ETF轮动策略迁移到实盘后，幸好守住了十年10倍+（实盘参数与未来函数讨论）. ETF轮动策略迁移到实盘后，幸好守住了十年10倍+.pdf  
[6] 关于量化策略中CAGR计算公式及年数 n 确定方法讨论. 其实，上期的ETF轮动策略中，隐藏着一个错误.pdf  
[7] quantstats 计算CAGR时periods默认值错误与cagr函数未传参Bug说明. 其实，上期的ETF轮动策略中，隐藏着一个错误.pdf  
[8] ETF轮动策略累计收益与年化收益计算及其误差分析. 其实，上期的ETF轮动策略中，隐藏着一个错误.pdf  
[9] ETF轮动策略实盘中成份股代码设置与global关键字问题、仓位隔离说明. 完了，ETF轮动策略实盘当中，到底会遇到多少挫折_.pdf