feat(rotation): 实现跨市场ETF映射与溢价控制方案

- 重新设计配置文件结构,支持指数到ETF的映射关系及市场类型区分
- 新增ETF数据获取模块,从Tushare获取A股ETF行情及净值
- 修改因子计算逻辑,基于指数数据计算因子,使用ETF数据计算收益率
- 重构轮动引擎,支持同时处理指数与ETF数据,动态检查ETF可用性
- 增加跨境ETF溢价控制机制,基于实时及历史溢价率过滤或降权持仓标的
- 持仓和报告模块显示ETF代码及跨境溢价率信息,提升实际操作参考价值
- 更新配置解析,构建代码-名称、代码-ETF和代码-市场映射关系
- 采用A股交易日历对齐多市场数据,确保因子计算和信号生成时序准确
- 详细设计溢价率计算与信号调整策略,解决跨境市场时差及数据可用性问题
- 明确加密货币数据处理方案及休市期间数据填充策略,保证逻辑一致性与安全性
This commit is contained in:
2026-03-25 22:01:07 +08:00
parent 6454e6823f
commit 61362b274b

View 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 纳指 ETF60 天数据):
- 平均溢价率: +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股ETFTushare反映实际交易成本和跟踪误差
### 特殊情况处理
| 情况 | 处理方式 |
|------|---------|
| A股休市境外交易 | 指数使用 ffillETF无数据不计收益 |
| 境外休市A股交易 | 指数使用 ffillETF正常交易可能跳空 |
| 两边都休市 | 该日不在回测范围内 |
| 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_dailyfund_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:0008: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 股交易日历,是最安全的实现方式。