Files
etf/docs/跨市场ETF映射方案_4467318e.md
aszerW 4df3ac4e31 chore(docs): reorganize documentation files into docs/ folder
Moved markdown documentation from root to docs/ directory to improve project structure.
2026-04-30 01:06:25 +08:00

21 KiB
Raw Permalink Blame History

跨市场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(深交所)后缀

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股交易日历
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.pycompute_factors() 函数:

  1. 接收两套数据:指数数据(用于因子计算)+ ETF数据用于收益计算
  2. 因子基于指数价格计算
  3. 日收益率基于ETF价格计算或指数价格如果没有ETF映射
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_dataself.etf_data 两套数据
  • 信号基于 index_data 的因子得分
  • 收益率基于 etf_data 的价格变动
  • 新增ETF 上市日期检查,未上市的 ETF 不参与当日排名
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.pyportfolio.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.pyconfig/settings.py

  1. 解析新的配置结构提取指数代码列表和ETF映射
  2. 构建 code_name_mapcode_etf_mapcode_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)
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. 计算实盘溢价率(按市场类型)
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
  1. 计算历史溢价率(回测阶段,同日对齐)
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
  1. 信号过滤逻辑
# 配置参数
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
  1. 配置文件新增参数
# 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前均已可获取

各市场数据对齐规则

# 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股交易日生成

数据对齐代码实现

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