1. 单ETF价格序列清洗与复权校验
任务边界与数据约定
本例只处理一只ETF的原始日行情,目标是得到一条可直接进入轮动回测的连续价格序列。原始数据通常包含四列:交易日、收盘价、成交额、复权因子(或累计净值)。在动手清洗前先固定三条约定,后续所有计算与判断都以此为准:
- 价格口径:原始收盘价
close_raw为未复权价,含分红送转造成的跳空;复权因子adj_factor为累计复权因子,随分红送转单调变化。 - 复权公式:后复权价
close_hfq = close_raw × adj_factor;前复权价close_qfq = close_raw × adj_factor / adj_factor_latest。两者只差一个常数缩放,收益率序列完全相同,但首尾价格水平不同。 - 停牌判定:某交易日无行情记录,或成交额与成交量同时为 0,视为停牌;停牌日不产生真实价格变动,回测中应以前一有效收盘价填充,而不是删除该行(删除会压缩时间轴,导致信号窗口错位)。
缺失值识别与停牌日处理
先按交易日历重建索引,再区分两类“缺失”:结构性缺失(上市前、退市后,不应填充)与停牌缺失(区间内应有行情但无记录,应填充)。实操中可用成交额判断:若某日存在于交易日历但 amount 为空或为 0,则标记为停牌并前向填充收盘价,同时保留一个 is_suspend 标记列,供后续回测剔除不可交易日的调仓动作。
import pandas as pd
import numpy as np
# 假设 raw 已按日期升序,含列: date, close_raw, amount, adj_factor
raw = raw.sort_values("date").reset_index(drop=True)
# 1) 用交易日历重建索引,识别结构性缺失与停牌缺失
cal = pd.date_range(raw["date"].min(), raw["date"].max(), freq="B") # 示例用工作日历
df = raw.set_index("date").reindex(cal)
df.index.name = "date"
# 2) 停牌标记:区间内 amount 缺失或为 0
in_range = df.index >= raw["date"].min()
df["is_suspend"] = in_range & (df["amount"].isna() | (df["amount"] == 0))
# 3) 停牌日以前一有效收盘价填充,复权因子同样前向填充
df["close_raw"] = df["close_raw"].ffill()
df["adj_factor"] = df["adj_factor"].ffill()
# 4) 结构性缺失(首行之前)仍为 NaN,直接丢弃
df = df.dropna(subset=["close_raw", "adj_factor"])
填充后必须做一次连续性校验:检查 close_raw 是否仍存在 NaN、复权因子是否单调非减(分红送转只会让累计因子上升或持平),以及填充段长度是否异常(例如连续停牌超过 20 个交易日应人工复核,可能是数据源断档而非真实停牌)。
复权因子校验与首尾收益对比
复权因子最容易出错的地方是除权日附近的跳变。校验方法是:在除权日 t,用 close_raw[t-1] × adj_factor[t-1] 与 close_raw[t] × adj_factor[t] 计算后复权收益,若该收益与当日真实涨跌幅偏离超过阈值(如 0.5%),说明因子未更新或更新错误。下面用一段示例数据演示前复权与后复权对首尾收益的影响:
# 示例:3 个交易日的原始价与复权因子(教学假设数据)
demo = pd.DataFrame({
"date": pd.to_datetime(["2024-01-02", "2024-01-03", "2024-01-04"]),
"close_raw": [1.000, 0.900, 0.918], # 1/3 除权,价格从 1.000 跳到 0.900
"adj_factor": [1.000, 1.111, 1.111], # 除权日因子由 1.000 升至 1.111
})
demo["close_hfq"] = demo["close_raw"] * demo["adj_factor"]
demo["close_qfq"] = demo["close_raw"] * demo["adj_factor"] / demo["adj_factor"].iloc[-1]
# 首尾收益:两种复权口径应完全一致
r_hfq = demo["close_hfq"].iloc[-1] / demo["close_hfq"].iloc[0] - 1
r_qfq = demo["close_qfq"].iloc[-1] / demo["close_qfq"].iloc[0] - 1
print(round(r_hfq, 6), round(r_qfq, 6)) # 两者相等,约 0.0200
关键结论:前复权与后复权的首尾收益率相同,差异只体现在价格绝对水平——后复权首价保持原始价、末价被放大;前复权末价等于原始价、首价被缩小。因此轮动回测中若只用收益率和相对强弱信号,选哪种复权都不影响结果;但若涉及价格阈值、整手买入、保证金或名义本金,必须明确使用哪一种口径,并在整个回测中保持一致。
输出连续价格序列与验收条件
清洗完成后输出一张最小宽表:date, close_raw, adj_factor, close_hfq, close_qfq, is_suspend,并满足以下验收条件才算可用于轮动回测:
| 检查项 | 通过条件 | 不通过时的处理 |
|---|---|---|
| 索引连续性 | 日期无重复、无缺口,覆盖回测区间 | 重新 reindex 并补标记 |
| 价格缺失 | close_raw 无 NaN | 检查结构性缺失是否被误填充 |
| 复权因子 | 单调非减,除权日跳变与真实涨跌幅一致 | 回查数据源除权公告 |
| 停牌标记 | is_suspend 与成交额为 0 的日期一一对应 | 核对成交量列交叉验证 |
| 收益一致性 | 前复权与后复权首尾收益率差 < 1e-8 | 检查因子是否被重复缩放 |