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

关键结论:前复权与后复权的首尾收益率相同,差异只体现在价格绝对水平——后复权首价保持原始价、末价被放大;前复权末价等于原始价、首价被缩小。因此轮动回测中若只用收益率和相对强弱信号,选哪种复权都不影响结果;但若涉及价格阈值、整手买入、保证金或名义本金,必须明确使用哪一种口径,并在整个回测中保持一致。

单ETF价格序列清洗与复权校验流程 原始日行情 date / close_raw amount / adj_factor 交易日历重建 reindex 到工作日历 区分结构性/停牌缺失 停牌日处理 amount=0 → is_suspend close/adj 前向填充 复权因子校验 除权日跳变检测 因子单调非减检查 后复权 close_hfq close_raw × adj_factor 首价=原始价,末价被放大 适合长期持有与绝对价格比较 前复权 close_qfq close_raw × adj_factor / adj_factor_latest 末价=原始价,首价被缩小 适合展示当前价格与近期信号 校验结论:两种复权口径的首尾收益率完全一致 r = close_hfq[末]/close_hfq[首] − 1 = close_qfq[末]/close_qfq[首] − 1 差异仅在价格绝对水平;涉及价格阈值/整手/保证金时须固定口径并全程一致

输出连续价格序列与验收条件

清洗完成后输出一张最小宽表:date, close_raw, adj_factor, close_hfq, close_qfq, is_suspend,并满足以下验收条件才算可用于轮动回测:

检查项通过条件不通过时的处理
索引连续性日期无重复、无缺口,覆盖回测区间重新 reindex 并补标记
价格缺失close_raw 无 NaN检查结构性缺失是否被误填充
复权因子单调非减,除权日跳变与真实涨跌幅一致回查数据源除权公告
停牌标记is_suspend 与成交额为 0 的日期一一对应核对成交量列交叉验证
收益一致性前复权与后复权首尾收益率差 < 1e-8检查因子是否被重复缩放
若回测信号只依赖收益率与相对强弱排序,前复权与后复权可互换;一旦策略含价格阈值(如“价格低于 1 元不买入”)或整手约束,必须在配置中显式声明复权口径,并在整个回测周期内不切换,否则会产生口径漂移导致的虚假信号。