4. 数据采集与预处理实战

连续小额订单冲击行为研究的第一道门槛不在模型,而在数据。逐笔委托(order-by-order)与逐笔成交(trade-by-trade)是两套独立记录:委托流记录“谁在什么时刻挂出/撤掉了多少量、价格是多少”,成交流记录“什么时刻发生了撮合、成交价与成交量是多少”。研究连续小额订单,需要把这两套流按时间与订单标识对齐,再从中筛出“单笔金额小、时间间隔短、方向一致”的订单簇。本节围绕一条可复现的处理链展开:字段理解 → 清洗 → 对齐 → 序列重构 → 缺失与异常处理 → 质量校验。

一、先明确字段与参照约定

以常见的逐笔数据为例,委托表至少包含:order_id(委托编号)、time(委托到达时间,精确到毫秒或微秒)、price、volume、side(B/S)、order_type(限价/市价/本方最优等)、action(A=新增,D=删除/撤单)。成交表包含:trade_id、time、price、volume、bid_order_id、ask_order_id(买卖双方委托编号)、side(主动方方向)。

本节统一约定:时间轴以交易所撮合时钟为准,方向以“主动方”定义——买方主动成交记为 B,卖方主动成交记为 S;金额一律用“价格×数量”计算,数量单位与价格单位在预处理阶段就固定下来,避免后续混用。所有图示与正文均遵循这一约定。

二、清洗:把“脏”挡在序列之外

原始逐笔数据常见四类问题:重复记录(同一 order_id 多次出现)、时间戳乱序或精度不一致(部分为秒级、部分为毫秒级)、价格或数量为 0/负值、以及撤单记录缺少对应新增记录。处理顺序建议为:先去重(按 order_id + time + action 组合键),再统一时间精度(全部转为微秒整数),再剔除 price<=0 或 volume<=0 的记录,最后用“新增-撤单配对”检查孤儿撤单。

import pandas as pd

def clean_orders(df):
    df = df.drop_duplicates(subset=["order_id", "time", "action"])
    df["ts"] = (df["time"] * 1_000_000).astype("int64")   # 统一为微秒
    df = df[(df["price"] > 0) & (df["volume"] > 0)]
    df = df.sort_values("ts").reset_index(drop=True)
    # 孤儿撤单:撤单存在但无对应新增
    added = set(df.loc[df["action"] == "A", "order_id"])
    orphan = df[(df["action"] == "D") & (~df["order_id"].isin(added))]
    return df, orphan

注意:去重不能简单按整行 drop_duplicates(),因为同一订单在不同毫秒可能有多条合法记录;必须带上 action 与时间戳。孤儿撤单不一定要删除,但应单独落表,作为数据质量指标记录。

三、对齐:委托与成交的“双向挂接”

对齐的核心是把每笔成交挂到买卖双方的委托上。做法是以成交表的 bid_order_id 与 ask_order_id 为外键,分别左连接委托表两次,得到买方委托的挂单时间、价格与卖方委托的挂单时间、价格。由此可计算两个关键量:被动方等待时长(成交时间 − 被动方挂单时间)与主动方到达即成交的延迟(成交时间 − 主动方挂单时间,通常接近 0)。

对齐时最容易出错的是“同一订单分多笔成交”。此时一个 order_id 会对应多条成交记录,连接后行数会膨胀,这是正常的,但必须在聚合时按 trade_id 去重,避免成交量被重复累计。另一个坑是时间戳相同但撮合顺序不同,需保留原始行号作为稳定排序键。

四、重构连续小额订单序列

“连续小额订单”没有唯一阈值,需要按标的流动性与研究目的设定。一个可操作的示例参数:单笔成交金额 ≤ 5 万元、相邻两笔同向成交时间间隔 ≤ 500 毫秒、连续笔数 ≥ 5。重构步骤为:先按主动方方向分组,再按时间排序,计算相邻成交的时间差 dt,用 dt 是否超阈值切分“簇”,最后过滤掉笔数不足或金额超限的簇。

def build_bursts(trades, amt_max=50000, dt_max=500_000, min_n=5):
    t = trades.sort_values(["side", "ts"]).copy()
    t["amount"] = t["price"] * t["volume"]
    t["dt"] = t.groupby("side")["ts"].diff().fillna(10**12)
    t["new_burst"] = (t["dt"] > dt_max) | (t["amount"] > amt_max)
    t["burst_id"] = t.groupby("side")["new_burst"].cumsum()
    g = t.groupby(["side", "burst_id"])
    bursts = g.agg(n=("trade_id", "size"), amt=("amount", "sum"),
                   t0=("ts", "min"), t1=("ts", "max")).reset_index()
    return bursts[bursts["n"] >= min_n]

这段逻辑的关键在于 new_burst 同时承担“时间断裂”和“金额超限”两种切分条件——任一条件触发即开新簇。这样得到的每个 burst_id 就是一条候选连续小额订单序列,后续可计算其持续时长、总金额、平均单笔金额与方向一致性。

逐笔委托与成交的对齐及连续小额订单簇重构 逐笔委托流 order_id / time / price volume / side / action A=新增 D=撤单 去重→统一微秒→剔非正 逐笔成交流 trade_id / time / price volume / bid_order_id ask_order_id / side 按 trade_id 去重防膨胀 双向挂接对齐 成交 ↔ 买方委托 成交 ↔ 卖方委托 被动方等待时长 主动方成交延迟 保留原始行号排序 连续小额订单簇重构 ① 按主动方方向分组 ② 计算相邻成交 dt ③ 金额≤5万 且 dt≤500ms ④ 任一超限即切新簇 ⑤ 保留笔数≥5 的簇 输出字段 burst_id / n / 总金额 t0 / t1 / 持续时长 平均单笔金额 / 方向 质量校验指标 孤儿撤单率 · 重复率 · 时间乱序率 · 对齐失败率 · 簇内方向一致率

五、缺失值与异常值:分类处理而非一刀切

缺失值分两类。第一类是结构性缺失,例如某笔成交的 bid_order_id 在委托表中查不到——这通常意味着委托数据被截断或跨日边界问题,应标记为“对齐失败”并统计比例,比例低于 0.1% 时可剔除该成交,高于 1% 则要回查数据源。第二类是字段缺失,如 order_type 为空,可用同标的同方向同时段众数填充,但必须新增一列 is_imputed 标记,避免污染后续统计。

异常值方面,价格类异常用“同标的当日成交价中位数 ± 10%”作粗筛,超出者多为数据错位;数量类异常用分位数法,例如超过 99.9 分位且金额超过 100 万元的单笔成交,先标记为“大单”而非直接删除——因为大单恰恰是研究小额订单冲击时的重要对照样本。时间类异常(dt 为负)说明排序键不稳定,应回到原始行号重新排序,而不是取绝对值。

不要用均值填充价格或成交量。逐笔数据的分布高度偏斜,均值填充会系统性抬高小额订单的金额,直接改变“连续小额”的筛选结果。缺失值优先剔除或标记,填充只用于辅助字段。

六、结果如何看:一份可执行的校验清单

处理完成后,至少核对以下五项,任一项异常都应回到对应环节排查:

  • 行数守恒:清洗后成交笔数 + 剔除笔数 = 原始笔数,差额可解释。
  • 对齐失败率:成交挂不到委托的比例,正常应低于 0.1%。
  • 时间单调性:按 ts 排序后 dt 不应为负。
  • 簇内方向一致率:同一 burst_id 内主动方方向应完全一致,否则说明分组键有误。
  • 金额分布:簇内平均单笔金额应显著低于全样本均值,这是“小额”筛选生效的直接证据。

以某示例标的的假设数据为例:原始成交 12 万笔,去重剔除 320 笔,对齐失败 47 笔(0.04%),重构得到 186 个连续小额订单簇,簇内平均单笔金额约 1.8 万元,而全样本平均单笔金额约 6.5 万元。这组数字仅用于说明校验口径,实际数值需以真实数据跑出。至此,一条可追溯、可复核的连续小额订单序列才算真正准备好,可以进入后续的冲击行为度量环节。