4. 回测环境搭建与数据预处理

盘口微价格偏离策略的回测,成败往往不在信号公式本身,而在数据是否“干净、对齐、可信”。盘口数据(逐笔成交、委托挂单、快照行情)具有高频、异步、易缺失、易异常的特点:交易所推送的逐笔与快照时间戳不同源,不同合约的采样频率不一致,午休、集合竞价、涨跌停会造成大量空档。如果直接把这些原始数据喂给回测引擎,得到的收益曲线很可能来自数据缺陷而非策略逻辑。本节围绕“环境配置—数据清洗—时间对齐—缺失与异常处理”四个环节,给出可落地的实现路径。

一、回测环境:固定版本、隔离依赖

盘口策略回测对数值计算和数据结构要求较高,建议使用 Python 3.10 及以上版本,并用虚拟环境隔离依赖,避免不同项目间的 pandas/numpy 版本冲突。核心依赖包括:pandas 用于表格与时间序列处理,numpy 用于向量化计算,pyarrow 用于高效读写 Parquet 列式文件,以及回测框架(如 vectorbt、backtrader 或自研事件驱动引擎)。盘口数据体量大,建议统一以 Parquet 存储,按“合约—交易日”分区,读取时只加载所需列。

python -m venv venv
source venv/bin/activate        # Windows: venv\Scripts\activate
pip install pandas==2.2.* numpy==1.26.* pyarrow==16.*
pip freeze > requirements.txt

环境验证不能只看“装上了”,而要确认关键能力可用:时间戳能否正确解析到纳秒、时区能否显式指定、Parquet 分区读取是否正常。建议写一个最小自检脚本,读入一天样本数据,打印行数、时间范围、字段类型与空值统计,作为后续每次换数据源时的固定检查动作。

二、数据清洗:先统一口径,再谈计算

盘口原始数据常见的字段包括:时间戳、合约代码、最新价、买一价/量、卖一价/量、成交量、成交额等。清洗的第一步是统一字段口径:价格统一为浮点或定点整数(建议以“分”为单位的整数,避免浮点比较误差),数量统一为股/手并注明单位,方向字段统一为买/卖/未知三态。第二步是去重:同一时间戳、同一合约、同一笔成交可能因多路推送重复出现,按主键去重并保留首条。第三步是排序:按合约分组后按时间戳升序排列,确保后续对齐与滚动计算的前提成立。

需要特别注意的是“逻辑一致性”校验,而不只是格式校验。例如买一价应小于等于卖一价,买一量、卖一量应为非负,最新价应落在当日涨跌停区间内。对不满足的记录不应简单删除,而应先标记为可疑,统计其占比;若某交易日可疑记录超过阈值(如 0.5%),应回查数据源而非强行修补。

三、时间对齐:把异步数据放到同一时间轴

盘口微价格偏离策略通常需要同时使用逐笔成交与快照行情。两者时间戳不同源,直接按行合并会产生错位。正确做法是先确定一个“主时间轴”,再让其他数据向它对齐。常见方案有两种:其一是以固定频率(如 3 秒)重采样快照,逐笔成交按时间窗口聚合到同一栅格;其二是事件驱动对齐,以每一笔成交为触发点,取该时刻之前最近一条快照作为状态。前者适合向量化回测,后者更贴近实盘撮合逻辑。

对齐时必须显式处理时区与交易时段。建议统一转换为交易所本地时区,并显式剔除集合竞价、午休、收盘后时段,避免把跨时段的时间差误算为“持续偏离”。下图展示了从原始多源数据到对齐后回测输入的整体处理链路与关键判断点。

盘口数据预处理链路:从原始多源到回测输入 逐笔成交 时间戳 / 价格 / 量 / 方向 快照行情 买一/卖一价量、最新价 交易日历 交易时段 / 集合竞价 数据清洗 统一字段口径 主键去重 按合约时间排序 逻辑一致性校验 时间对齐 统一时区 剔除非交易时段 重采样 / 事件对齐 生成主时间轴 缺失与异常 前向填充 + 标记 价差异常剔除 涨跌停区间约束 可疑占比统计 回测输入:对齐后的盘口面板 时间戳 · 买一/卖一价量 · 成交价量 · 数据质量标记 质量门禁判断 可疑记录占比 ≤ 0.5%? 缺失连续段 ≤ 3 个栅格? 否则回查数据源

四、缺失值处理:区分“没有成交”与“没有数据”

盘口数据的缺失有两种性质完全不同的情况。第一种是“市场本身没有变化”,例如某段时间内买一价量未更新,此时前向填充(ffill)是合理的,但必须同时生成一个“填充标记”列,让回测引擎知道该行是推断值。第二种是“数据源断流”,例如快照缺失超过若干个采样周期,此时前向填充会制造虚假的稳定盘口,必须将该区间标记为不可交易,回测中跳过开仓。实践中建议设置两个阈值:短缺口(如 1~2 个栅格)用前向填充并标记;长缺口(如超过 3 个栅格或超过 10 秒)直接置为无效区间。

对于逐笔成交聚合到时间栅格后产生的空栅格,不应填充价格,而应保留“无成交”状态,成交量填 0,价格列置空并由后续逻辑决定是否沿用上一有效价。这样能避免把“无成交”误判为“价格不变”。

五、异常值处理:用盘口结构而非单一阈值判断

盘口异常值最典型的形态是“价格瞬时跳变后立即恢复”,常见于数据源错误或极端撮合瞬间。仅用固定百分比阈值(如价格变动超过 5% 即剔除)会误伤涨跌停附近的真实行情。更稳健的做法是结合盘口结构判断:若某条快照的买一价高于卖一价(交叉盘口),或买一价相对前一条快照跳变超过当日涨跌停幅度,则标记为异常;若最新价不在当日涨跌停区间内,同样标记。对标记记录,优先用相邻有效快照插值或直接剔除,并在日志中记录剔除数量与时间点,便于复盘。

建议在预处理阶段输出一份“数据质量日报”:每个交易日的记录数、去重数、缺失栅格数、长缺口区间、异常标记数及其占比。回测结果出现异常收益时,先对照这份日报排查数据,再怀疑策略。

六、可执行的最小预处理流程

把上述环节串成一个可复用的函数链,是本节最实用的交付物。下面给出一个结构示例(参数为教学示例,需按实际数据源调整):

import pandas as pd

def preprocess(raw: pd.DataFrame, freq="3s") -> pd.DataFrame:
    df = raw.copy()
    # 1. 统一口径
    df["ts"] = pd.to_datetime(df["ts"], unit="ns", utc=True).dt.tz_convert("Asia/Shanghai")
    df["price"] = (df["price"] * 100).round().astype("int64")   # 以分为单位
    # 2. 去重 + 排序
    df = df.drop_duplicates(subset=["ts", "symbol", "trade_id"])
    df = df.sort_values(["symbol", "ts"]).reset_index(drop=True)
    # 3. 逻辑校验
    bad = (df["bid1"] > df["ask1"]) | (df["bid1_vol"] < 0) | (df["ask1_vol"] < 0)
    df["suspect"] = bad
    # 4. 重采样到主时间轴
    df = (df.set_index("ts").groupby("symbol")
            .resample(freq).agg({"price": "last", "bid1": "last",
                                 "ask1": "last", "volume": "sum"}))
    # 5. 缺失处理:短缺口前向填充并标记
    df["filled"] = df["bid1"].isna()
    df[["bid1", "ask1"]] = df[["bid1", "ask1"]].ffill(limit=2)
    return df.reset_index()

这段代码的关键不在语法,而在每一步都可被检查:去重后行数是否合理、可疑记录占比是否在阈值内、填充标记数量是否与缺口统计一致。完成预处理后,建议抽取一个交易日,人工核对若干时间点的买一卖一价量与原始快照是否一致,确认对齐没有引入错位,再进入策略回测环节。

时间对齐的方向必须全文统一:本文约定“以成交事件时刻向前取最近一条快照”,即使用该时刻之前已发生的盘口状态,严禁使用未来快照,否则会引入前视偏差,使回测收益虚高。