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

598 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 跨市场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 股交易日历,是最安全的实现方式。