3. 逐笔数据预处理实战

逐笔成交数据(Tick/Trade)是订单流特征工程最原始的原料,但它几乎从不“开箱即用”。交易所推送的逐笔流往往存在多路数据时间基准不一致、个别记录价格或数量明显失真、成交记录本身不携带主动买卖方向等问题。如果直接把这些原始记录送进因子计算,得到的 OFI、主动买卖差、大单净流入等指标会被系统性污染。本节围绕四个关键处理环节展开:时间戳对齐、异常值处理、买卖方向推断(Lee-Ready 及其变体)、数据重采样与聚合,并给出可落地的实现思路与判断依据。

一、时间戳对齐:先统一时间基准,再谈顺序

逐笔数据通常包含两类时间:交易所撮合时间(如沪深市场的成交时间戳,精度到毫秒或十毫秒)和本地接收时间(行情落地时间)。两者含义不同:撮合时间反映事件真实发生顺序,接收时间反映你“看到”的顺序。做订单流特征时,应以撮合时间为事件排序主键,接收时间仅用于估计延迟和做数据质量监控。

实际对齐要处理三种情况。第一,多路数据合并:成交表与委托表(逐笔委托)来自不同频道,需要按“撮合时间 + 交易所序号”联合排序,单靠时间戳在同一毫秒内无法定序。第二,时区与精度统一:把全部时间转换为同一时区的毫秒整数(如 epoch_ms),避免字符串比较。第三,乱序与重复:按主键去重后,用稳定排序恢复顺序;对跨频道时间差超过阈值(示例取 500ms)的记录打上“疑似错序”标记而非直接丢弃。

# 示例:统一时间基准并按主键排序去重(pandas)
df["ts"] = pd.to_datetime(df["trade_time"], format="%Y%m%d%H%M%S%f")
df["epoch_ms"] = df["ts"].astype("int64") // 10**6
df = (df.sort_values(["epoch_ms", "seq_no"])
        .drop_duplicates(subset=["epoch_ms", "seq_no"], keep="first")
        .reset_index(drop=True))

二、异常值处理:区分“脏数据”与“真实极端事件”

异常值处理最容易犯的错误是把真实的大单或涨跌停瞬间当成噪声删掉。正确做法是先定义规则、再分级处置。常用检查包括:价格是否落在当日涨跌停区间内;成交量是否为 0 或负;成交价相对前一 tick 的跳变是否超过合理阈值(例如超过 10% 且无对应涨跌停状态);同一主键是否重复。对确认的脏数据(价格为 0、数量为负、明显越界)直接剔除;对“可疑但可能真实”的记录(如巨量成交)保留但打标签,供后续因子做稳健化处理(如缩尾 winsorize)。

判断原则:能由交易规则解释的极端值(涨跌停价成交、集合竞价大单)应保留;违反市场规则的记录(负成交量、价格越界)才剔除。处置动作要留痕,便于回溯。

三、买卖方向推断:Lee-Ready 的核心逻辑与修正

逐笔成交只告诉你“成交了”,不告诉你“谁主动”。买卖方向推断的目标是给每笔成交打上主动买(+1)或主动卖(-1)标签。Lee-Ready 算法的经典三步是:

  1. 报价规则(Quote Rule):成交价高于买卖中间价,判为主动买;低于中间价,判为主动卖。
  2. 中间价成交的 tick 规则(Tick Rule):成交价等于中间价时,比较本次成交价与上一次不同成交价:上升判买,下降判卖,持平沿用上一笔方向。
  3. 开盘/无前值时:用当日首笔相对开盘价或集合竞价结果做初始方向。

实现时要注意:中间价需要同一时刻的最优买一/卖一,若成交表与盘口快照时间不完全对齐,应取“不晚于成交时间的最近一笔盘口”,而不是简单按行号拼接。此外,A 股等市场存在涨跌停时买一或卖一为空,此时报价规则失效,应退化为 tick 规则并标记“报价缺失”。下面给出一个可运行的推断函数骨架:

def infer_side(trade_px, bid, ask, prev_px, prev_side):
    mid = (bid + ask) / 2 if bid and ask else None
    if mid is not None and trade_px > mid:  return 1
    if mid is not None and trade_px < mid:  return -1
    # 中间价成交或报价缺失:tick 规则
    if prev_px is None:            return prev_side or 1
    if trade_px > prev_px:         return 1
    if trade_px < prev_px:         return -1
    return prev_side or 1

推断完成后应做一致性抽检:统计主动买占比是否长期偏离 50% 过多、涨跌停时段方向是否异常集中。若某标的主动买占比持续高于 80%,多半是盘口对齐错位或 tick 规则初值设置不当,需要回查。

四、重采样与聚合:从逐笔到可计算的时间序列

逐笔数据是非等间隔事件流,因子计算通常需要等间隔序列。重采样的关键是选对“桶”的边界与聚合口径。常用方式有三种:按固定时间桶(如 1 秒、1 分钟)聚合;按成交量桶(Volume Bar,每成交 N 手切一根);按成交笔数桶(Tick Bar)。时间桶实现简单、便于与行情快照对齐,是入门首选;量桶能自适应活跃度,适合做微观结构因子。

聚合时要区分“可加量”和“状态量”:成交量、成交笔数、主动买量、主动卖量可直接求和;最新价、买一卖一应取桶内最后一笔(last);最高最低取桶内极值。一个常见的 1 分钟聚合示例如下:

g = df.set_index("ts").resample("1min")
bar = pd.DataFrame({
    "volume":   g["volume"].sum(),
    "trades":   g["volume"].count(),
    "buy_vol":  g.apply(lambda x: x.loc[x.side == 1, "volume"].sum()),
    "sell_vol": g.apply(lambda x: x.loc[x.side == -1, "volume"].sum()),
    "last_px":  g["price"].last(),
    "high":     g["price"].max(),
    "low":      g["price"].min(),
})
bar["ofi_proxy"] = bar["buy_vol"] - bar["sell_vol"]

聚合后要检查空桶:无成交的分钟应保留为 0 成交量还是前值填充,取决于因子定义;对价格类字段用前值填充(ffill),对成交量类用 0 填充,二者不可混用。最后用“聚合前后总成交量守恒”做一次校验:各桶成交量之和应等于清洗后逐笔成交量总和,差额为 0 才说明没有丢单或重复计数。

逐笔数据预处理流水线:从原始成交到可计算因子 原始逐笔成交 成交时间 / 价格 / 数量 交易所序号 seq_no 本地接收时间 (无买卖方向) ① 时间戳对齐 统一 epoch_ms 按 时间+序号 排序 去重 / 标记错序 阈值示例 500ms ② 异常值处理 价格越界 / 量≤0 → 剔除 巨量成交 → 保留打标 涨跌停成交 → 保留 处置留痕可回溯 ③ 买卖方向推断 报价规则:价 vs 中间价 tick 规则:价 vs 前一笔 报价缺失 → 退化 tick 输出 side = +1 / -1 盘口快照(买一/卖一) 取不晚于成交时间的最近快照 ④ 重采样与聚合(示例:1 分钟桶) 可加量:volume / trades / buy_vol / sell_vol → sum 状态量:last_px → last;high / low → max / min 空桶:价格 ffill,成交量填 0 校验:各桶成交量之和 = 清洗后逐笔总量 输出:订单流因子 主动买卖差 ofi_proxy 大单净流入 / 成交笔数 质量监控点 主动买占比是否≈50% 错序记录占比 剔除记录数 / 原因 成交量守恒差额 注:图中阈值、桶宽与字段均为教学示例,实际取值需结合市场规则与数据频率调整

五、把四步串成可复现的处理闭环

上述四步不是孤立的:时间戳对齐决定了盘口快照能否正确匹配,异常值处理决定了方向推断的输入是否干净,方向推断的结果又直接进入重采样的聚合口径。建议把处理流程固化为一个函数或脚本,输入原始逐笔与盘口文件,输出带 side 标签的清洗后逐笔表和聚合后的 bar 表,并在每一步输出统计摘要(记录数、剔除数、错序数、主动买占比、守恒差额)。这样当因子表现异常时,可以快速定位是数据层还是因子层的问题。

常见坑:用本地接收时间排序会导致同一毫秒内成交顺序错乱,进而使 tick 规则方向判断系统性偏移;用行号直接拼接盘口快照会在停牌、集合竞价等时段错配;聚合时对价格字段用 sum 会得到无意义数值。这三类错误都不会报错,但会悄悄污染因子。

完成本节处理后,你应当得到一张“每笔成交都有明确时间顺序、通过质量检查、带有主动买卖方向标签”的逐笔表,以及一张口径清晰、可校验的聚合 bar 表。它们是后续构建订单流不平衡、大单识别、微观结构因子等工作的共同基础。