chore(docs): reorganize documentation files into docs/ folder
Moved markdown documentation from root to docs/ directory to improve project structure.
This commit is contained in:
426
docs/ETF轮动策略方案.md
Normal file
426
docs/ETF轮动策略方案.md
Normal file
@@ -0,0 +1,426 @@
|
||||
下面是一份基于你提供的四篇文档整理出的、可直接指导项目实施的**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
|
||||
135
docs/data_logic_analysis.md
Normal file
135
docs/data_logic_analysis.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# 跨市场数据时间对齐逻辑分析
|
||||
|
||||
## 一、各市场交易时间(北京时间)
|
||||
|
||||
| 市场 | 交易时间 | 数据代码 | 数据源 | 数据标记 |
|
||||
|-----|---------|---------|--------|---------|
|
||||
| A股 | T日 09:30-15:00 | 000300.SH 等 | Tushare | T日 OHLC |
|
||||
| 港股 | T日 09:30-16:00 | HSTECH.HK (3033.HK) | YFinance | T日 OHLC |
|
||||
| 美股 | T日 21:30-次日04:00 | NDX | YFinance | T日 OHLC |
|
||||
| 加密货币 | T日 08:00-次日08:00 (UTC) | BTC/ETH | CCXT/OKX | T日 OHLC |
|
||||
| 黄金期货 | T日 09:00-15:30, 21:00-次日02:30 | AU.SHF | Tushare | T日 OHLC |
|
||||
|
||||
## 二、数据就绪时间点
|
||||
|
||||
假设当前是 **T+1日 09:00**(信号计算时间):
|
||||
|
||||
| 市场 | T日数据就绪时间 | T+1日09:00时最新数据 |
|
||||
|-----|---------------|-------------------|
|
||||
| A股 | T日 15:00 | T日数据 ✓ |
|
||||
| 港股 | T日 16:00 | T日数据 ✓ |
|
||||
| 美股 | T+1日 05:00 | T日数据 ✓ |
|
||||
| 加密货币 | T+1日 08:00 | T+1日数据 ✓ (UTC 00:00) |
|
||||
| 黄金期货 | T+1日 02:30 | T+1日数据 ✓ (夜盘结束) |
|
||||
|
||||
## 三、关键问题分析
|
||||
|
||||
### 问题1:数据标记与实际交易时间的不一致
|
||||
|
||||
**黄金期货 (AU.SHF)**:
|
||||
- 交易时间:T日 09:00-15:30(日盘)+ T日 21:00-T+1日 02:30(夜盘)
|
||||
- Tushare 数据标记:T+1日 OHLC(因为夜盘结束是T+1日凌晨)
|
||||
- 实际对应:T日日盘 + T日夜盘 = 标记为 T+1日
|
||||
|
||||
**加密货币 (BTC/ETH)**:
|
||||
- 交易时间:T日 08:00(UTC 00:00)- T+1日 08:00(UTC 00:00)
|
||||
- CCXT 数据标记:T+1日 OHLC(UTC 00:00 为日线分界)
|
||||
- 实际对应:T日08:00到T+1日08:00 = 标记为 T+1日
|
||||
|
||||
### 问题2:当前代码逻辑
|
||||
|
||||
```python
|
||||
# hybrid_source.py 中的数据对齐逻辑
|
||||
# 以A股交易日为基准,对齐所有数据
|
||||
a_share_dates = 获取A股交易日历(start_date, end_date)
|
||||
index_data = index_data.reindex(a_share_dates)
|
||||
|
||||
# 非A股标的处理
|
||||
# 港股/美股:ffill() - 使用T日数据
|
||||
# 加密货币/期货:bfill() - 使用T+1日数据
|
||||
```
|
||||
|
||||
### 问题3:当前存在的问题
|
||||
|
||||
**场景:3月26日 00:17 运行代码**
|
||||
|
||||
| 期望 | 实际 |
|
||||
|-----|------|
|
||||
| 获取3月25日所有市场数据 | 可能只获取到3月24日数据 |
|
||||
| 信号日期:3月26日 | 信号日期:3月25日 |
|
||||
| 数据基准:3月25日 | 数据基准:3月24日 |
|
||||
|
||||
**原因分析**:
|
||||
1. `end_date` 默认是 `datetime.now().strftime('%Y-%m-%d')` = "2026-03-26"
|
||||
2. 但 A股3月26日还没开盘,Tushare 获取不到3月26日数据
|
||||
3. 数据对齐时使用 `index_data.index.max()` 作为结束日期,变成3月25日
|
||||
4. 导致交易日历只到3月25日,3月26日被排除
|
||||
|
||||
## 四、正确的数据获取逻辑
|
||||
|
||||
### 4.1 运行时间点与数据获取
|
||||
|
||||
| 运行时间 | A股状态 | 应获取的最新数据 |
|
||||
|---------|--------|---------------|
|
||||
| T+1日 00:00-09:00 | 未开盘 | T日数据(所有市场) |
|
||||
| T+1日 09:30-15:00 | 交易中 | T日数据(不应获取盘中数据) |
|
||||
| T+1日 15:00后 | 已收盘 | T+1日数据(A股)+ T日数据(其他) |
|
||||
|
||||
### 4.2 数据对齐策略
|
||||
|
||||
**核心原则**:以A股交易日为基准,T+1日09:00计算信号时:
|
||||
|
||||
1. **A股/港股**:T日数据已收盘,使用 ffill() 填充
|
||||
2. **美股**:T日数据已收盘(T+1日05:00),使用 ffill() 填充
|
||||
3. **加密货币**:T+1日08:00已收盘,使用 T+1日数据(bfill())
|
||||
4. **黄金期货**:T+1日02:30已收盘,使用 T+1日数据(bfill())
|
||||
|
||||
### 4.3 代码修改建议
|
||||
|
||||
**修改1:使用配置的 end_date 获取交易日历**
|
||||
```python
|
||||
# 原代码(错误)
|
||||
end_str = index_data.index.max().strftime('%Y%m%d')
|
||||
|
||||
# 修改后(正确)
|
||||
end_str = pd.Timestamp(end_date).strftime('%Y%m%d')
|
||||
```
|
||||
|
||||
**修改2:区分不同市场的数据对齐方式**
|
||||
```python
|
||||
# 港股/美股:ffill() - T日数据
|
||||
# 加密货币/期货:bfill() - T+1日数据
|
||||
yf_codes = [港股, 美股]
|
||||
crypto_futures_codes = [BTC, ETH, AU.SHF]
|
||||
```
|
||||
|
||||
**修改3:信号日期计算**
|
||||
```python
|
||||
# 当前代码(错误)
|
||||
signal_date = backtest_result.index[-1] # 取数据最后一天
|
||||
data_base_date = signal_date - 1 # 假设是前一天
|
||||
|
||||
# 应该根据当前时间判断
|
||||
if 当前时间 < T+1日09:00:
|
||||
signal_date = T日
|
||||
data_base_date = T-1日
|
||||
else:
|
||||
signal_date = T+1日
|
||||
data_base_date = T日
|
||||
```
|
||||
|
||||
## 五、当前已做的修改
|
||||
|
||||
1. ✅ `hybrid_source.py` 使用配置的 `end_date` 获取交易日历
|
||||
2. ✅ `hybrid_source.py` 区分 yf_codes 和 crypto_futures_codes 的不同填充方式
|
||||
3. ✅ `hybrid_source.py` YFinance 添加 `auto_adjust=False` 和 `end_date+1天`
|
||||
4. ✅ `rotation.yaml` 黄金改为 AU.SHF,恒生科技改为 HSTECH.HK
|
||||
|
||||
## 六、仍需确认的问题
|
||||
|
||||
1. **信号日期显示**:当前显示 "2026-03-25 (基于 2026-03-24 收盘数据)" 是否正确?
|
||||
- 如果今天是3月26日00:17,应该显示 "2026-03-26 (基于 2026-03-25 收盘数据)"
|
||||
|
||||
2. **数据基准日期计算**:`report.py` 中的 `data_base_date = signal_date - 1` 逻辑是否需要修改?
|
||||
|
||||
3. **加密货币/黄金的数据标记**:CCXT/Tushare 返回的数据日期是否已经是 T+1日标记?
|
||||
597
docs/跨市场ETF映射方案_4467318e.md
Normal file
597
docs/跨市场ETF映射方案_4467318e.md
Normal file
@@ -0,0 +1,597 @@
|
||||
# 跨市场ETF映射方案
|
||||
|
||||
## 背景问题
|
||||
|
||||
当前系统存在以下问题:
|
||||
1. 配置中使用指数代码,但实际交易的是ETF
|
||||
2. 跨境ETF(恒生科技ETF、纳指ETF)在A股交易,交易时间与标的指数不同
|
||||
3. 回测收益使用指数价格,与实际ETF收益存在跟踪误差
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 核心思路
|
||||
- **信号层**:使用指数数据计算因子,生成交易信号
|
||||
- **执行层**:使用ETF数据计算收益,反映实际交易成本和跟踪误差
|
||||
- **加密货币**:保持原样,直接在交易所买卖
|
||||
|
||||
### 数据流程
|
||||
```
|
||||
指数数据 → 因子计算 → 信号生成 → 映射到ETF → ETF收益计算
|
||||
```
|
||||
|
||||
### Task 执行顺序
|
||||
|
||||
```
|
||||
Task 1 (配置结构) ─────┬─→ Task 6 (配置解析)
|
||||
│
|
||||
Task 2 (ETF数据获取) ──┼─→ Task 3 (因子计算) ─→ Task 4 (轮动引擎)
|
||||
│ │
|
||||
Task 7 (溢价控制) ─────┴───────────────────────────→─┴─→ Task 5 (报告)
|
||||
```
|
||||
|
||||
**推荐执行顺序**:Task 1 → Task 6 → Task 2 → Task 7 → Task 3 → Task 4 → Task 5
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 修改配置文件结构
|
||||
|
||||
修改 `config/strategies/rotation.yaml`,将简单的代码列表改为支持指数-ETF映射的结构:
|
||||
|
||||
> **ETF 代码格式说明**:Tushare 使用 `.SH`(上交所)和 `.SZ`(深交所)后缀
|
||||
|
||||
```yaml
|
||||
code_list:
|
||||
# A股指数 - index为指数代码(信号), etf为场内ETF代码(交易)
|
||||
"000300.SH":
|
||||
name: "沪深300"
|
||||
etf: "510300.SH" # 华泰柏瑞沪深300ETF(上交所)
|
||||
market: "A"
|
||||
"000905.SH":
|
||||
name: "中证500"
|
||||
etf: "510500.SH" # 南方中证500ETF(上交所)
|
||||
market: "A"
|
||||
|
||||
# 跨境ETF - 使用境外指数计算信号,但交易A股ETF
|
||||
"HSTECH":
|
||||
name: "恒生科技"
|
||||
etf: "513180.SH" # 华夏恒生科技ETF(上交所)
|
||||
market: "HK"
|
||||
"NDX":
|
||||
name: "纳指100"
|
||||
etf: "159501.SZ" # 嘉实纳指100ETF(深交所)- 流动性好
|
||||
market: "US"
|
||||
|
||||
# 黄金 - A股黄金ETF
|
||||
"GC=F":
|
||||
name: "黄金"
|
||||
etf: "518880.SH" # 华安黄金ETF(上交所)
|
||||
market: "COMMODITY"
|
||||
|
||||
# 加密货币 - 无ETF映射,直接交易
|
||||
"BTC":
|
||||
name: "比特币"
|
||||
etf: null # 无ETF,直接交易
|
||||
market: "CRYPTO"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 创建ETF数据获取模块
|
||||
|
||||
在 `core/datasource/hybrid_source.py` 中新增ETF数据获取能力:
|
||||
|
||||
1. 新增方法 `fetch_etf_data()` 获取A股ETF数据(通过Tushare)
|
||||
2. 修改 `fetch_all()` 返回值,同时返回指数数据和ETF数据
|
||||
|
||||
关键逻辑:
|
||||
- A股ETF(如510300.SH):使用 Tushare 的 `fund_daily` 接口
|
||||
- 所有ETF都在A股交易,统一使用A股交易日历
|
||||
|
||||
```python
|
||||
def fetch_all(...) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, list]:
|
||||
"""
|
||||
Returns:
|
||||
(index_data, etf_data, benchmark_data, valid_codes)
|
||||
- index_data: 指数数据,用于因子计算
|
||||
- etf_data: ETF数据,用于收益计算
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 修改因子计算逻辑
|
||||
|
||||
修改 `core/factors/momentum.py` 的 `compute_factors()` 函数:
|
||||
|
||||
1. 接收两套数据:指数数据(用于因子计算)+ ETF数据(用于收益计算)
|
||||
2. 因子基于指数价格计算
|
||||
3. 日收益率基于ETF价格计算(或指数价格,如果没有ETF映射)
|
||||
|
||||
```python
|
||||
def compute_factors(
|
||||
index_data: pd.DataFrame, # 指数数据 - 用于因子
|
||||
etf_data: pd.DataFrame, # ETF数据 - 用于收益
|
||||
code_list: list,
|
||||
...
|
||||
) -> tuple[pd.DataFrame, list]:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 修改轮动引擎
|
||||
|
||||
修改 `strategies/rotation/engine.py`:
|
||||
|
||||
1. `fetch_data()`: 同时获取指数和ETF数据
|
||||
2. `generate_signals()`: 基于指数因子生成信号,**动态检查 ETF 可用性**
|
||||
3. `run_backtest()`: 基于ETF价格计算收益
|
||||
|
||||
关键变更:
|
||||
- 存储 `self.index_data` 和 `self.etf_data` 两套数据
|
||||
- 信号基于 `index_data` 的因子得分
|
||||
- 收益率基于 `etf_data` 的价格变动
|
||||
- **新增**:ETF 上市日期检查,未上市的 ETF 不参与当日排名
|
||||
|
||||
```python
|
||||
def generate_signals(self) -> pd.DataFrame:
|
||||
# 对每个交易日,检查 ETF 数据可用性
|
||||
for date in trading_dates:
|
||||
# 获取当日有 ETF 数据的标的
|
||||
available_codes = [
|
||||
code for code in self.valid_codes
|
||||
if not pd.isna(self.etf_data.loc[date, code_etf_map[code]])
|
||||
]
|
||||
# 只在 available_codes 中选择信号
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 修改报告和持仓跟踪
|
||||
|
||||
修改 `strategies/rotation/report.py` 和 `portfolio.py`:
|
||||
|
||||
1. 报告中同时显示指数名称和ETF代码
|
||||
2. 持仓记录使用ETF代码(方便实际操作)
|
||||
3. 新增字段:实际交易标的、交易市场
|
||||
4. **新增**:跨境 ETF 溢价率显示(使用 Task 7 的真实净值计算)
|
||||
|
||||
输出示例:
|
||||
```
|
||||
当前持仓建议:
|
||||
1. 纳指100 (NDX) → 买入 513100.SH (国泰纳指100ETF) [溢价率: +3.2%] ⚠️
|
||||
2. 沪深300 (000300.SH) → 买入 510300.SH (华泰柏瑞沪深300ETF) [溢价率: +0.1%]
|
||||
3. 比特币 (BTC) → 买入 BTC/USDT (OKX交易所)
|
||||
|
||||
注: 溢价率 > 2% 显示警告标记
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 更新配置解析逻辑
|
||||
|
||||
修改 `scripts/run_rotation.py` 和 `config/settings.py`:
|
||||
|
||||
1. 解析新的配置结构,提取指数代码列表和ETF映射
|
||||
2. 构建 `code_name_map`、`code_etf_map`、`code_market_map`
|
||||
3. 向下传递映射关系
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 跨境 ETF 溢价控制(新增)
|
||||
|
||||
在 `strategies/rotation/engine.py` 中增加溢价过滤机制:
|
||||
|
||||
### 问题背景
|
||||
当美股/港股大涨时,A股跨境 ETF 往往高开甚至涨停,溢价率可达 3%-5%。如果在高溢价时买入,即使境外指数不跌,溢价回落也会造成亏损。
|
||||
|
||||
### 溢价率数据来源
|
||||
|
||||
**需要合并两个 Tushare 接口**(各需要 2000 积分):
|
||||
|
||||
1. `fund_daily`: 获取 ETF 交易价格 (close)
|
||||
2. `fund_nav`: 获取 ETF 单位净值 (unit_nav)
|
||||
|
||||
```python
|
||||
import tushare as ts
|
||||
pro = ts.pro_api()
|
||||
|
||||
# 1. 获取 ETF 价格
|
||||
price_df = pro.fund_daily(
|
||||
ts_code='159501.SZ', # 纳指ETF
|
||||
start_date='20200101',
|
||||
end_date='20250101',
|
||||
)
|
||||
|
||||
# 2. 获取 ETF 净值
|
||||
nav_df = pro.fund_nav(
|
||||
ts_code='159501.SZ',
|
||||
start_date='20200101',
|
||||
end_date='20250101',
|
||||
)
|
||||
|
||||
# 3. 合并计算溢价率
|
||||
price_df = price_df[['trade_date', 'close']].rename(columns={'trade_date': 'date'})
|
||||
nav_df = nav_df[['nav_date', 'unit_nav']].rename(columns={'nav_date': 'date', 'unit_nav': 'nav'})
|
||||
merged = price_df.merge(nav_df, on='date', how='inner')
|
||||
|
||||
# 溢价率 = 收盘价 / 净值 - 1
|
||||
merged['premium'] = merged['close'] / merged['nav'] - 1
|
||||
```
|
||||
|
||||
**实测结果**(159501.SZ 纳指 ETF,60 天数据):
|
||||
- 平均溢价率: +4.34%
|
||||
- 最大溢价率: +7.83%
|
||||
- 溢价率 > 2% 的天数: 93.9%
|
||||
|
||||
**结论**:跨境 ETF 的高溢价是真实存在的风险,必须在策略中增加溢价过滤!
|
||||
|
||||
### Tushare 积分说明
|
||||
|
||||
| 接口 | 所需积分 | 用途 |
|
||||
|------|---------|------|
|
||||
| `fund_daily` | 2000 | ETF 日线行情(价格) |
|
||||
| `fund_nav` | 2000 | ETF 单位净值 |
|
||||
| `index_daily` | 2000 | A股指数日线 |
|
||||
|
||||
当前 2000 积分满足所有需求,限制:每分钟 200 次,每天 10 万次/API。
|
||||
|
||||
### 不同市场类型的溢价率计算方式
|
||||
|
||||
**核心逻辑**:在 T+1 日 09:00 计算信号时,T 日净值是否已公布?
|
||||
|
||||
| ETF 类型 | 跟踪标的收盘时间 | 净值公布时间 | T+1日09:00可用净值 | 溢价率公式 | 溢价控制 |
|
||||
|---------|-----------------|-------------|-------------------|-----------|---------|
|
||||
| **A股 ETF** | T日 15:00 | T日 ~18:00 | T日净值 | `T日价格 / T日净值` | 可选(溢价通常 < 0.5%) |
|
||||
| **港股 ETF** | T日 16:00 | T日 ~18:00 | T日净值 | `T日价格 / T日净值` | **必须**(溢价可达 3%+) |
|
||||
| **黄金 ETF** | T日 15:00 | T日 ~18:00 | T日净值 | `T日价格 / T日净值` | 可选(流动性好,溢价小) |
|
||||
| **美股 ETF** | T+1日 05:00 | T+1日 晚间 | **T-1日净值** | `T日价格 / T-1日净值` | **必须**(溢价可达 5%+) |
|
||||
|
||||
> **注意**:A股 ETF(如沪深300 ETF)流动性好、套利机制完善,溢价率通常在 ±0.5% 以内,可不启用溢价过滤。跨境 ETF(港股/美股)因套利限制,溢价率波动大,**必须启用过滤**。
|
||||
|
||||
**时间线对比**:
|
||||
|
||||
```
|
||||
A股/港股/黄金 ETF:
|
||||
T日 T+1日
|
||||
├──────────────────────┼─────────────
|
||||
15:00 ~18:00 09:00
|
||||
收盘 净值公布 计算信号 → T日净值已可用 ✓
|
||||
|
||||
美股 ETF(跨境):
|
||||
T日 T+1日
|
||||
├────────────────────────────────┼────────────────────────────
|
||||
15:00 22:30 05:00 09:00 ~晚间
|
||||
ETF收盘 美股开盘 美股收盘 计算信号 T日净值公布
|
||||
↑
|
||||
T日净值未公布,只能用 T-1日净值
|
||||
```
|
||||
|
||||
**这与集思录的计算方式一致**:集思录对跨境 ETF 使用 `价格日期` 和 `净值日期` 两列,反映了实际数据可用性。
|
||||
|
||||
### 解决方案
|
||||
|
||||
1. **计算实盘溢价率**(按市场类型)
|
||||
```python
|
||||
def calculate_realtime_premium(
|
||||
etf_code: str,
|
||||
market_type: str,
|
||||
price_date: str,
|
||||
) -> float:
|
||||
"""
|
||||
计算实盘可用的溢价率(在 T+1 日 09:00 决策时点)
|
||||
|
||||
Args:
|
||||
etf_code: ETF 代码
|
||||
market_type: 市场类型 ('A', 'HK', 'US', 'COMMODITY')
|
||||
price_date: 价格日期(T日,即昨日)
|
||||
|
||||
Returns:
|
||||
溢价率(小数形式)
|
||||
"""
|
||||
pro = ts.pro_api()
|
||||
|
||||
# 获取 T 日 ETF 收盘价
|
||||
price_df = pro.fund_daily(ts_code=etf_code, trade_date=price_date)
|
||||
t_close = price_df['close'].iloc[0]
|
||||
|
||||
# 根据市场类型确定净值日期
|
||||
if market_type in ['A', 'HK', 'COMMODITY']:
|
||||
# A股/港股/黄金:T日净值在决策时点已公布
|
||||
nav_date = price_date # 同日
|
||||
else: # US 美股
|
||||
# 美股:T日净值未公布,使用 T-1 日净值
|
||||
nav_date = get_previous_trade_date(price_date) # 前一日
|
||||
|
||||
# 获取净值
|
||||
nav_df = pro.fund_nav(ts_code=etf_code, nav_date=nav_date)
|
||||
nav = nav_df['unit_nav'].iloc[0]
|
||||
|
||||
# 计算溢价率
|
||||
premium = t_close / nav - 1
|
||||
|
||||
return premium
|
||||
```
|
||||
|
||||
2. **计算历史溢价率**(回测阶段,同日对齐)
|
||||
```python
|
||||
def calculate_historical_premium(etf_code: str, start_date: str, end_date: str) -> pd.DataFrame:
|
||||
"""
|
||||
计算历史溢价率(回测用,同日对齐)
|
||||
"""
|
||||
pro = ts.pro_api()
|
||||
|
||||
# 获取价格和净值
|
||||
price_df = pro.fund_daily(ts_code=etf_code, start_date=start_date, end_date=end_date)
|
||||
nav_df = pro.fund_nav(ts_code=etf_code, start_date=start_date, end_date=end_date)
|
||||
|
||||
# 合并(同日对齐)
|
||||
price_df = price_df[['trade_date', 'close']].rename(columns={'trade_date': 'date'})
|
||||
nav_df = nav_df[['nav_date', 'unit_nav']].rename(columns={'nav_date': 'date', 'unit_nav': 'nav'})
|
||||
merged = price_df.merge(nav_df, on='date', how='inner')
|
||||
|
||||
# 溢价率 = 收盘价 / 净值 - 1
|
||||
merged['premium'] = merged['close'] / merged['nav'] - 1
|
||||
|
||||
return merged
|
||||
```
|
||||
|
||||
3. **信号过滤逻辑**
|
||||
```python
|
||||
# 配置参数
|
||||
premium_threshold = 0.02 # 溢价超过 2% 不买入
|
||||
|
||||
def filter_by_premium(scores, premium_rates, threshold):
|
||||
"""
|
||||
过滤高溢价标的
|
||||
- 溢价率超过阈值的标的,得分置为 -inf(不参与排名)
|
||||
- 或者:得分乘以惩罚因子 (1 - premium_rate)
|
||||
"""
|
||||
for code in scores.index:
|
||||
if premium_rates.get(code, 0) > threshold:
|
||||
scores[code] = float('-inf') # 或降权处理
|
||||
return scores
|
||||
```
|
||||
|
||||
4. **配置文件新增参数**
|
||||
```yaml
|
||||
# rotation.yaml
|
||||
premium_control:
|
||||
enabled: true
|
||||
default_threshold: 0.02 # 默认溢价阈值 2%
|
||||
mode: "filter" # "filter"(完全排除) 或 "penalize"(降权)
|
||||
penalty_factor: 0.5 # 降权模式下的惩罚系数
|
||||
|
||||
# 按市场类型覆盖配置
|
||||
market_overrides:
|
||||
A: # A股 ETF
|
||||
enabled: false # 不启用(溢价通常 < 0.5%)
|
||||
HK: # 港股 ETF
|
||||
enabled: true
|
||||
threshold: 0.03 # 阈值 3%
|
||||
US: # 美股 ETF
|
||||
enabled: true
|
||||
threshold: 0.02 # 阈值 2%
|
||||
COMMODITY: # 商品 ETF
|
||||
enabled: false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据对齐策略
|
||||
|
||||
### 信号计算基准时点
|
||||
|
||||
策略信号在 **A股 T+1 日早上 09:00** 计算,此时各市场数据可获取性:
|
||||
|
||||
| 市场 | 收盘时间(北京时间)| 09:00时可获取数据 |
|
||||
|------|------------------|------------------|
|
||||
| A股 | T日 15:00 | T日收盘价 |
|
||||
| 港股 | T日 16:00 | T日收盘价 |
|
||||
| 美股 | T+1日 05:00 | T日收盘价 |
|
||||
| 黄金期货 | T+1日 ~06:00 | T日结算价 |
|
||||
| 加密货币 | 24小时交易 | T+1日 08:00价格(UTC 00:00) |
|
||||
|
||||
**结论**:在 A股 T+1 日 09:00 信号计算时,所有市场的 T 日数据均已可获取,无需 shift 处理。
|
||||
|
||||
```
|
||||
时间线(北京时间):
|
||||
|
||||
T日 T+1日
|
||||
├─────────────────────────────────────┼─────────────────────────
|
||||
15:00 16:00 05:00 08:00 09:00 09:30
|
||||
A股收盘 港股收盘 美股收盘 UTC0 计算信号 A股开盘执行
|
||||
│ │ │ │ │ │
|
||||
└────────┴──────────────────┴──────┴──────┴─────────┘
|
||||
所有T日数据在T+1日09:00前均已可获取
|
||||
```
|
||||
|
||||
### 各市场数据对齐规则
|
||||
|
||||
```python
|
||||
# A股 T+1 日 09:00 计算信号时,使用的数据
|
||||
data_alignment = {
|
||||
"A股指数": {
|
||||
"price_date": "T日", # T日收盘价
|
||||
"source_time": "T日 15:00",
|
||||
},
|
||||
"港股指数": {
|
||||
"price_date": "T日", # T日收盘价
|
||||
"source_time": "T日 16:00",
|
||||
},
|
||||
"美股指数": {
|
||||
"price_date": "T日", # T日收盘价(美东时间)
|
||||
"source_time": "T+1日 05:00", # 北京时间
|
||||
},
|
||||
"黄金期货": {
|
||||
"price_date": "T日", # T日结算价
|
||||
"source_time": "T+1日 ~06:00",
|
||||
},
|
||||
"加密货币": {
|
||||
"price_date": "T日", # UTC T+1日 00:00 = 北京 T+1日 08:00
|
||||
"source_time": "T+1日 08:00", # 比决策时点早1小时
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 加密货币特殊处理
|
||||
|
||||
加密货币 24 小时交易,使用 **UTC 00:00(北京时间 08:00)** 作为"日收盘价":
|
||||
|
||||
- CCXT/OKX 返回的日线数据默认就是 UTC 00:00 切换
|
||||
- 北京时间 08:00 距离 09:00 信号计算只有 1 小时,数据足够新鲜
|
||||
- 与其他数据源保持一致的数据结构
|
||||
|
||||
**A股休市期间的处理**:
|
||||
|
||||
```
|
||||
场景:A股春节休市 5 天
|
||||
|
||||
1/20(五) 1/21-22(周末) 1/23-27(春节) 1/28(六) 1/29(日) 1/30(一)
|
||||
A股 交易 休市 休市 休市 休市 交易
|
||||
BTC 交易 交易 交易 交易 交易 交易
|
||||
│ │
|
||||
使用1/20价格 ────────────── ffill ──────────────────────→ 使用1/29价格
|
||||
```
|
||||
|
||||
- 因子计算:使用前向填充(休市期间认为价格不变)
|
||||
- 实际交易:加密货币可在A股休市期间随时买卖,但策略信号只在A股交易日生成
|
||||
|
||||
### 数据对齐代码实现
|
||||
|
||||
```python
|
||||
def align_to_a_share_calendar(
|
||||
market_data: dict[str, pd.DataFrame], # {code: df}
|
||||
market_types: dict[str, str], # {code: market_type}
|
||||
a_share_dates: pd.DatetimeIndex,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
将所有市场数据对齐到A股交易日历
|
||||
|
||||
由于信号在 T+1 日 09:00 计算,所有市场 T 日数据均已可获取,
|
||||
因此直接 reindex 到 A股交易日即可,无需 shift。
|
||||
"""
|
||||
aligned_data = {}
|
||||
|
||||
for code, df in market_data.items():
|
||||
market = market_types.get(code, "A")
|
||||
|
||||
# 统一处理:reindex 到 A股交易日,前向填充休市日
|
||||
aligned = df['close'].reindex(a_share_dates)
|
||||
|
||||
if market in ["HK", "US", "COMMODITY", "CRYPTO"]:
|
||||
# 非A股市场:休市日用前向填充
|
||||
aligned = aligned.ffill().bfill()
|
||||
|
||||
aligned_data[code] = aligned
|
||||
|
||||
return pd.DataFrame(aligned_data)
|
||||
```
|
||||
|
||||
### 跨境ETF信号与收益分离
|
||||
|
||||
以恒生科技为例:
|
||||
- **信号计算**:使用 HSTECH 港股指数(YFinance),反映真实市场走势
|
||||
- **收益计算**:使用 513180.SH A股ETF(Tushare),反映实际交易成本和跟踪误差
|
||||
|
||||
### 特殊情况处理
|
||||
|
||||
| 情况 | 处理方式 |
|
||||
|------|---------|
|
||||
| A股休市、境外交易 | 指数使用 ffill,ETF无数据不计收益 |
|
||||
| 境外休市、A股交易 | 指数使用 ffill,ETF正常交易(可能跳空) |
|
||||
| 两边都休市 | 该日不在回测范围内 |
|
||||
| ETF上市日期晚于指数 | 该标的从ETF上市日开始参与回测 |
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件清单
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|---------|
|
||||
| `config/strategies/rotation.yaml` | 配置结构重构,新增溢价控制参数 |
|
||||
| `core/datasource/hybrid_source.py` | 新增ETF数据获取 |
|
||||
| `core/factors/momentum.py` | 支持双轨数据输入 |
|
||||
| `strategies/rotation/engine.py` | 引擎逻辑重构,ETF可用性检查,溢价过滤 |
|
||||
| `strategies/rotation/portfolio.py` | 持仓显示ETF |
|
||||
| `strategies/rotation/report.py` | 报告显示ETF和溢价率 |
|
||||
| `scripts/run_rotation.py` | 配置解析适配 |
|
||||
| `config/settings.py` | 新增默认ETF映射和汇率配置 |
|
||||
|
||||
---
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
1. **跟踪误差**:ETF净值与指数存在偏差,这是预期行为
|
||||
2. **停牌风险**:ETF可能停牌,需要处理缺失数据
|
||||
3. **新ETF上市**:部分ETF成立时间晚于回测起始日,Task 4 已处理
|
||||
4. **QDII额度**:跨境ETF可能有申购限制,不影响回测
|
||||
5. **跨境ETF溢价**:美股/港股大涨时,ETF可能高溢价,Task 7 已增加过滤机制
|
||||
6. **Tushare 积分**:当前 2000 积分满足 fund_daily、fund_nav 和 index_daily 需求,建议缓存历史数据减少 API 调用
|
||||
7. **ETF 选择建议**:
|
||||
- 同一指数可能有多只 ETF(如纳指有 513100/159501/159632 等)
|
||||
- 优先选择:规模大(> 10 亿)、流动性好(日成交 > 1000 万)、跟踪误差小的 ETF
|
||||
- 本方案使用 159501.SZ(嘉实纳指100)是因为其流动性较好
|
||||
|
||||
---
|
||||
|
||||
## 关于加密货币数据精度的说明
|
||||
|
||||
建议文档中提到"BTC 应使用分钟级数据切片到 09:00",经评估:
|
||||
|
||||
| 方案 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| 日线数据(08:00 UTC) | 数据量小,易获取,与其他市场一致 | 与决策时点有 1 小时差距 |
|
||||
| 分钟级切片(09:00) | 数据最新 | 5年历史需 260 万条数据,获取/存储成本高 |
|
||||
|
||||
**决策**:
|
||||
- **回测阶段**:使用日线数据(UTC 00:00),08:00-09:00 的波动(平均 ~1%)对 25 天趋势因子影响极小,可通过交易成本参数覆盖
|
||||
- **实盘阶段**(未来增强):可在执行时获取实时价格,信号计算仍基于日线
|
||||
|
||||
---
|
||||
|
||||
## ffill 时机说明
|
||||
|
||||
建议文档担心"ffill 后立即计算因子会导致休市期间价格不变影响动量"。实际上:
|
||||
|
||||
- 我们使用**交易日对齐**(reindex 到 A股交易日)
|
||||
- 动量计算的是过去 N 个**交易日**的涨幅,非 N 个自然日
|
||||
- ffill 只填充非交易日,这些日期不进入因子计算序列
|
||||
- 当前逻辑正确,无需调整
|
||||
|
||||
### 详细说明:为什么 ffill 是安全的
|
||||
|
||||
**核心保障**:主循环 `for date in a_share_trading_dates` 严格只遍历 A 股交易日。
|
||||
|
||||
**实现方式**:`reindex(a_share_dates)` 直接索引到 A 股交易日历,结果只包含交易日行,不会产生中间填充行。
|
||||
|
||||
**示例:春节休市场景**
|
||||
|
||||
```
|
||||
A股交易日历: [1/20(周五), 1/30(周一)] (春节休市 1/21-1/29)
|
||||
美股原始数据: 1/20→200, 1/21→205, ..., 1/29→220
|
||||
|
||||
Step 1: reindex(a_share_dates)
|
||||
1/20: 200 (原始值)
|
||||
1/30: NaN (1/30 美股尚未收盘,无数据)
|
||||
|
||||
注意:结果只有 2 行,不会出现 [1/21, 1/22, ...] 这些行!
|
||||
|
||||
Step 2: ffill()
|
||||
1/20: 200
|
||||
1/30: 220 (向前找到 1/29 的值填充)
|
||||
|
||||
Step 3: 动量计算 pct_change(1)
|
||||
1/20: NaN (首行无前值)
|
||||
1/30: (220 - 200) / 200 = 10% ← 正确反映假期全部涨幅
|
||||
```
|
||||
|
||||
**关键区别**:
|
||||
|
||||
| 错误做法 | 正确做法(当前方案) |
|
||||
|---------|-------------------|
|
||||
| reindex 到自然日历 → ffill → 产生 `[D1,D1,D1,D1,D1,D2]` | reindex 到 A股交易日历 → ffill → 只有 `[D1, D2]` |
|
||||
| 中间填充日会干扰动量计算 | 根本不存在中间填充日 |
|
||||
|
||||
**结论**:当前方案直接 reindex 到 A 股交易日历,是最安全的实现方式。
|
||||
|
||||
160
docs/轮动策略核心逻辑.md
Normal file
160
docs/轮动策略核心逻辑.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# ETF轮动策略核心逻辑总结
|
||||
|
||||
## 📊 策略概览
|
||||
|
||||
基于**多因子动量**的跨市场ETF轮动策略,通过量化评分自动选择表现最优的ETF组合进行投资。
|
||||
|
||||
---
|
||||
|
||||
## 🎯 候选池配置
|
||||
|
||||
### 覆盖市场
|
||||
- **A股**(17个指数):
|
||||
- 宽基:沪深300(000300.SH)、中证500(000905.SH)、中证1000(000852.SH)、创业板指(399006.SZ)、上证红利(000015.SH)
|
||||
- 金融:中证银行(399986.SZ)
|
||||
- 消费:中证白酒(399997.SZ)
|
||||
- 医药:中证医疗(399989.SZ)
|
||||
- 科技:中证信息(000935.SH)
|
||||
- 新能源:新能源车(399976.SZ)
|
||||
- 周期资源:国证有色(399395.SZ)、中证煤炭(399998.SZ)、细分化工(399813.SZ)、中证能源(000937.SH)
|
||||
- 其他:中证军工(399967.SZ)、中证农业(000949.SH)、国债指数(399702.SZ)
|
||||
- **港股**(1个):恒生科技(HSTECH.HK)
|
||||
- **美股**(1个):纳指100(NDX)
|
||||
- **商品**(1个):黄金(AU.SHF)
|
||||
- **加密货币**(2个):BTC、ETH
|
||||
|
||||
### 指数-ETF映射
|
||||
每个指数对应一个可交易的ETF(或直接交易),例如:
|
||||
- `000300.SH`(沪深300指数) → `510300.SH`(华泰柏瑞沪深300ETF)
|
||||
- `NDX`(纳指100) → `159501.SZ`(嘉实纳指100ETF)
|
||||
- `BTC`(比特币) → 无ETF,直接交易
|
||||
|
||||
**总计:22个候选标的**
|
||||
|
||||
---
|
||||
|
||||
## ⏱️ 回看周期与调仓周期
|
||||
|
||||
### 回看周期
|
||||
- **窗口长度**:25个交易日
|
||||
- **因子类型**:`slope_r2`(斜率×R²趋势得分)
|
||||
- 对过去25日价格进行线性回归
|
||||
- 得分 = 斜率 × R² × 10000
|
||||
- 同时考虑趋势强度和稳定性
|
||||
|
||||
### 调仓周期
|
||||
- **最低持有期**:1天(`rebalance_days = 1`)
|
||||
- **调仓阈值**:0%(`rebalance_threshold = 0.0`)
|
||||
- 新组合总得分 > 当前组合总得分时即触发调仓
|
||||
- **交易成本**:0.1%(双边,含佣金+滑点)
|
||||
|
||||
**实际运行**:每个交易日评估是否调仓,无强制锁定期
|
||||
|
||||
---
|
||||
|
||||
## 🧮 得分计算逻辑
|
||||
|
||||
### 核心公式
|
||||
```
|
||||
得分 = 斜率(slope) × R² × 10000
|
||||
```
|
||||
|
||||
### 计算步骤
|
||||
1. **数据获取**:获取每个标的过去25+日的收盘价
|
||||
2. **价格归一化**:将价格序列除以起始价格(消除绝对价格影响)
|
||||
3. **线性回归**:对归一化价格进行一元线性回归
|
||||
- `x = [1, 2, 3, ..., 25]`
|
||||
- `y = 归一化价格序列`
|
||||
4. **提取指标**:
|
||||
- `slope`:回归斜率(代表趋势方向与强度)
|
||||
- `R²`:拟合优度(代表趋势稳定性)
|
||||
5. **计算得分**:`score = 10000 × slope × R²`
|
||||
|
||||
### 得分含义
|
||||
- **正值**:上升趋势,值越大趋势越强且越稳定
|
||||
- **负值**:下降趋势
|
||||
- **接近0**:无明显趋势或趋势不稳定
|
||||
|
||||
### 跨市场数据对齐
|
||||
- **基准日历**:以A股交易日为准
|
||||
- **非A股标的**:数据前向填充(ffill)到A股交易日
|
||||
- **T+1规则**:T日收盘计算信号,仅使用T日及之前数据
|
||||
|
||||
---
|
||||
|
||||
## 📦 持仓数量与仓位配置
|
||||
|
||||
### 持仓数量
|
||||
- **选中数量**:5个ETF(`select_num = 5`)
|
||||
- 每日从22个候选标的中选出得分最高的5个
|
||||
|
||||
### 仓位配置
|
||||
- **等权分配**:每个选中标的占总仓位的20%(1/5)
|
||||
- **日收益率计算**:`组合日收益 = mean(5个标的的日收益率)`
|
||||
|
||||
### 换仓逻辑
|
||||
1. 每日计算所有标的的最新得分
|
||||
2. 选出Top 5构成"目标组合"
|
||||
3. 对比"目标组合"与"当前持仓组合"的总得分
|
||||
4. 若 `目标总得分 > 当前总得分`,则触发调仓
|
||||
5. 调仓时卖出旧标的,等权买入新标的
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ 溢价率控制(跨境ETF)
|
||||
|
||||
### 控制机制
|
||||
- **A股ETF**:不启用(溢价通常 < 0.5%)
|
||||
- **港股ETF**:阈值3%,超过则排除
|
||||
- **美股ETF**:阈值2%,超过则排除
|
||||
- **商品ETF**:不启用
|
||||
|
||||
### 处理模式
|
||||
- **filter模式**:溢价超阈值的标的直接从候选池排除
|
||||
- **penalty模式**(可选):对高溢价标的得分乘以惩罚系数(0.5)
|
||||
|
||||
---
|
||||
|
||||
## 📅 运行时间安排
|
||||
|
||||
### 信号生成时间
|
||||
- **运行时间**:T+1日上午9:00(北京时间)
|
||||
- **数据基准**:基于T日(前一交易日)收盘数据
|
||||
- **原因**:美股和加密货币数据在T+1日凌晨才可用
|
||||
|
||||
### 数据源
|
||||
- **A股**:Tushare
|
||||
- **港股/美股/商品**:YFinance
|
||||
- **加密货币**:CCXT/OKX(通过SSH隧道访问)
|
||||
|
||||
---
|
||||
|
||||
## 📈 策略特点总结
|
||||
|
||||
| 维度 | 配置 |
|
||||
|------|------|
|
||||
| **策略类型** | 动量轮动(趋势跟踪) |
|
||||
| **候选池** | 22个标的(A股/港股/美股/商品/加密货币) |
|
||||
| **回看周期** | 25个交易日 |
|
||||
| **因子类型** | 斜率×R²(趋势强度×稳定性) |
|
||||
| **持仓数量** | 5个ETF |
|
||||
| **仓位配置** | 等权(各20%) |
|
||||
| **调仓频率** | 每日评估,无锁定期 |
|
||||
| **调仓条件** | 新组合得分 > 旧组合得分 |
|
||||
| **交易成本** | 0.1%(双边) |
|
||||
| **溢价控制** | 港股3%、美股2%阈值过滤 |
|
||||
| **基准指数** | 沪深300(000300.SH) |
|
||||
|
||||
---
|
||||
|
||||
## 💡 核心优势
|
||||
|
||||
1. **跨市场分散**:覆盖股票、商品、加密货币,降低单一市场风险
|
||||
2. **趋势+稳定性**:slope_r2因子同时捕捉趋势方向和可靠性
|
||||
3. **自动轮动**:量化评分,避免主观判断
|
||||
4. **溢价保护**:防止高价买入跨境ETF
|
||||
5. **灵活配置**:所有参数可通过YAML配置文件调整
|
||||
|
||||
---
|
||||
|
||||
*文档生成时间:2026-04-16*
|
||||
Reference in New Issue
Block a user