2. 多ETF交易日历对齐与面板构建

为什么“对齐”是轮动回测的第一道硬约束

5 只 ETF 的上市日、停牌日、节假日休市安排并不一致。若直接按各自原始行数拼接,会出现三类问题:行数不同无法成表、某列在早期为 NaN 导致信号窗口不足、停牌日被误当作“价格不变”参与收益率计算。本节的做法是:先取 5 只 ETF 交易日的并集作为主日历,再按主日历左连接各标的收盘价,最后用“有效样本数 ≥ 回看窗口 + 1”来判定每只 ETF 最早可参与轮动的日期。

构造示例数据:5 只 ETF 的原始日行情

下面用可复现的合成数据模拟真实差异:510300 与 510500 上市最早,159915 晚 40 个交易日,512880 晚 90 个交易日,518880 在区间中段有 3 天停牌。所有日期均为工作日,便于聚焦“对齐逻辑”本身。

import numpy as np
import pandas as pd

rng = np.random.default_rng(20240607)
cal = pd.bdate_range("2023-01-02", periods=260)   # 260 个工作日

spec = {
    "510300": dict(start=0,   halt=[],        drift=0.0004),
    "510500": dict(start=0,   halt=[],        drift=0.0006),
    "159915": dict(start=40,  halt=[],        drift=0.0008),
    "512880": dict(start=90,  halt=[],        drift=0.0003),
    "518880": dict(start=0,   halt=[120,121,122], drift=0.0002),
}

raw = {}
for code, s in spec.items():
    idx = cal[s["start"]:]
    px = 1.0 * np.exp(np.cumsum(rng.normal(s["drift"], 0.012, len(idx))))
    ser = pd.Series(px, index=idx, name=code)
    if s["halt"]:
        ser.iloc[s["halt"]] = np.nan          # 停牌日:有交易日但无成交价
    raw[code] = ser

for code, ser in raw.items():
    print(code, "行数=", len(ser), "首个交易日=", ser.index[0].date(),
          "缺失=", int(ser.isna().sum()))

预期输出(示例数据可直接推出):510300/510500 各 260 行、159915 为 220 行、512880 为 170 行、518880 为 260 行但含 3 个 NaN。此时五列长度互不相同,无法直接构成宽表。

统一交易日历与宽表价格面板

主日历取所有标的索引的并集并排序;随后逐列 reindex 到主日历。关键点:上市前与停牌日都表现为 NaN,但语义不同,因此要额外记录“该日是否在原始索引中”,用于后续区分“未上市”与“停牌缺口”。

master = pd.DatetimeIndex(sorted(set().union(*[s.index for s in raw.values()])))
panel = pd.DataFrame(index=master)
listed = pd.DataFrame(index=master, dtype=bool)

for code, ser in raw.items():
    panel[code] = ser.reindex(master)
    listed[code] = master.isin(ser.index)

print("主日历长度:", len(master))
print(panel.head(3).round(4))
print("各列有效样本数:\n", panel.notna().sum())

对齐后 panel 是 260 行 × 5 列的宽表,索引为统一交易日历,列名为 ETF 代码。此时 panel.notna().sum() 给出各列有效样本数:510300=260、510500=260、159915=220、512880=170、518880=257(260 减 3 个停牌 NaN)。

原始序列(长度不一)→ 主日历并集 → 宽表面板 → 窗口匹配判定 原始日行情 510300:260 行 510500:260 行 159915:220 行(晚 40 日) 512880:170 行(晚 90 日) 518880:260 行,含 3 停牌 统一交易日历 并集 + 升序排序 长度 = 260 索引:DatetimeIndex 每列 reindex 对齐 上市前 / 停牌 → NaN 宽表价格面板 260 行 × 5 列 列 = ETF 代码 值 = 收盘价 listed 掩码区分 未上市 / 停牌 窗口匹配 有效样本 ≥ W+1 W = 20 回看 首个可算信号日 = 第 W+1 个 有效样本日 对齐后各列有效样本数(示例数据) 510300 260 510500 260 159915 220 512880 170 518880 257(3 停牌) W+1 = 21 个有效样本门槛(W=20) 512880 需等到第 21 个有效样本日才可首次计算 20 日动量

区分“未上市”与“停牌缺口”

两者在价格面板里都是 NaN,但处理策略不同:未上市期间该 ETF 不应进入候选池;停牌日则应保留 NaN,让收益率计算自然跳过,而不是用前值填充(前值填充会人为制造 0 收益,污染动量排序)。用 listed 掩码即可分离:

not_listed = panel.isna() & ~listed      # 未上市
halted     = panel.isna() & listed       # 停牌缺口

print("未上市 NaN 数:\n", not_listed.sum())
print("停牌 NaN 数:\n", halted.sum())
# 518880 停牌 3 天,其余标的停牌数为 0

验证:有效样本数与回看窗口的匹配性

设回看窗口 W=20,则计算 20 日收益率需要 21 个有效价格点。对每列求“第 21 个有效样本所在日期”,即为该 ETF 最早可产生轮动信号的日期;再与主日历起点比较,得到“预热期长度”。

W = 20
need = W + 1

first_signal = {}
for code in panel.columns:
    valid_dates = panel[code].dropna().index
    first_signal[code] = valid_dates[need - 1] if len(valid_dates) >= need else pd.NaT

summary = pd.DataFrame({
    "有效样本数": panel.notna().sum(),
    "首个可算信号日": pd.Series(first_signal),
})
summary["预热交易日"] = (summary["首个可算信号日"] - master[0]).dt.days
print(summary)

# 断言:所有列有效样本数均 ≥ need,否则该标的无法参与回测
assert (summary["有效样本数"] >= need).all(), "存在样本不足的标的"

示例数据下的预期结论:510300、510500 从主日历第 21 个交易日起可算信号;159915 因晚 40 日上市,首个信号日约在第 61 个交易日;512880 晚 90 日,约在第 111 个交易日;518880 因 3 天停牌,首个信号日顺延 3 个交易日。若某列有效样本数小于 21,则该标的在当前区间内根本无法产生动量信号,应在进入轮动前剔除或缩短窗口。

对齐完成后建议固化两条检查:① panel.index.is_monotonic_increasing and panel.index.is_unique 为真;② 每列 notna().sum() >= W+1。前者保证日历无重复无乱序,后者保证信号窗口可算,二者共同构成后续排名与调仓的输入前提。
不要用 ffill 填补停牌日再计算收益率:停牌期间价格被复制为常数,会得到 0 收益并可能把该 ETF 错误排到中位,扭曲轮动结果。正确做法是保留 NaN,让 pct_change 在缺口处自然产生 NaN,再在排序时按有效值处理。