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)。
区分“未上市”与“停牌缺口”
两者在价格面板里都是 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,再在排序时按有效值处理。