diff --git a/跨市场ETF映射方案_4467318e.md b/跨市场ETF映射方案_4467318e.md new file mode 100644 index 0000000..106f2f9 --- /dev/null +++ b/跨市场ETF映射方案_4467318e.md @@ -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 股交易日历,是最安全的实现方式。 +