2. 逐笔数据特征与校验挑战

逐笔数据(tick-by-tick)是交易所行情中最细粒度的一类数据:逐笔委托记录每一笔新订单的进入、撤单与交易所内部撮合产生的成交,逐笔成交则记录每一笔撮合成交的价格、数量与买卖方向。与快照行情相比,逐笔数据不是"某一时刻的截面",而是"一段时间内事件的完整序列"。这一本质差异决定了它的校验方式不能照搬快照的"逐字段比对",而必须围绕序列的完整性、顺序性和一致性来设计。

一、逐笔数据的四个固有特征

高频。以沪深交易所为例,单只活跃股票在开盘集合竞价与尾盘阶段,每秒可产生数十到数百条逐笔记录;全市场一天累计的逐笔委托与成交记录可达数亿条量级。高频带来的直接后果是:校验不能依赖人工抽样,必须全量流式处理;同时时间戳精度往往到毫秒甚至微秒,任何按秒聚合的校验都会掩盖乱序问题。

乱序。逐笔数据在传输链路上会经过交易所撮合系统、行情网关、专线、落地服务器、消息队列等多个环节。不同通道的延迟抖动、多线程发送、网络重传,都会导致记录到达顺序与交易所产生顺序不一致。乱序的典型表现是:同一只股票的两条记录,序号(如交易所的 SeqNum 或应用层序号)递增,但本地接收时间戳倒挂。校验时必须区分"交易所序号顺序"与"本地到达顺序",前者才是业务真序。

重复。重复来源有两类:一是传输层重传(如组播补包、TCP 重连后的重发),二是上游系统"至少一次"投递语义导致的重复写入。重复记录如果只按"记录内容相同"去重,会误删同一毫秒内真实发生的两笔同价同量成交。正确做法是结合交易所序号或唯一消息 ID 判重,而不是靠字段值比对。

缺失。缺失比重复更隐蔽。逐笔数据缺失可能发生在单只股票上(某段序号不连续),也可能发生在整个通道上(某台行情网关宕机导致一段时间无数据)。缺失的直接判据是序号断档,但序号断档也可能是"该股票本就没有事件"造成的假象——例如某冷门股长时间无委托,序号自然不增长。因此缺失校验必须结合"该股票是否处于交易状态""同通道其他股票是否有数据"等上下文来判断。

逐笔数据端到端链路与四类异常的发生位置 交易所撮合 产生 SeqNum 真序 行情网关 组播/专线发送 落地接收 打本地时间戳 消息队列 至少一次投递 入库/校验 去重、排序、补缺 乱序 多通道延迟抖动 缺失 补包失败/网关宕机 重复 重传/至少一次投递 高频压力 全量流式处理 端到端校验的三条核心判据 ① 序号连续性 判据:同一股票 SeqNum 严格递增 断档 → 疑似缺失,需结合上下文 回退 → 疑似重复或乱序 示例:Seq 1001→1002→1004 判定:1003 缺失,触发补包 补包后仍缺 → 标记数据缺口 ② 业务一致性 判据:委托与成交可相互印证 每笔成交须有对应委托或对手方 撤单量 ≤ 原委托量 示例:委托 500 股,成交 300 撤单 200,合计 = 500 ✓ 若合计 ≠ 500 → 数据异常 ③ 时序合理性 判据:交易所时间戳单调不减 本地到达时间可倒挂,不作真序 成交时间应落在交易时段内 示例:09:30:00.123 成交 若出现 09:29:59.999 → 异常 需核对时区与时钟同步

二、端到端校验的核心难点

把上述特征放到"端到端"的语境下,校验的难点不再是单点规则,而是判据之间的相互干扰。具体表现为三组矛盾。

第一组:乱序与缺失的混淆。当本地按到达顺序处理时,一条迟到的记录会让序号看起来"先断后补"。如果校验程序一看到断档就报警,会产生大量误报;如果等到收盘再统一判断,又失去了实时发现缺失的能力。工程上的折中做法是设置一个"乱序容忍窗口":例如允许记录在 500ms 内乱序到达,窗口内序号断档暂不判定为缺失,窗口滑过后仍缺才升级为缺失告警。窗口长度需要根据实测的链路抖动分布来定,不能拍脑袋。

第二组:重复与真实同质事件的混淆。同一毫秒内出现两笔价格、数量、方向完全相同的成交,在活跃股上是完全可能的真实事件。若用"内容哈希"去重,就会误删。因此判重键必须包含交易所侧的唯一标识(序号或消息 ID),并且去重操作要放在"按序号排序"之后,否则重复记录可能因到达顺序不同而被误判为乱序。

第三组:高频下的状态维护成本。逐笔校验需要为每只股票维护"期望的下一个序号""已见序号集合""委托剩余量"等状态。全市场数千只股票、数亿条记录,状态内存与去重集合的规模会迅速膨胀。实践中常用"按股票分片 + 滑动窗口"的方式:每只股票只保留最近 N 条序号的状态,窗口外的历史序号不再参与判重,从而把内存控制在可接受范围。代价是极晚到达的重复记录可能漏判,这属于可接受的工程取舍,但必须在校验报告中注明。

三、一个可落地的校验判定示例

下面给出一个针对单只股票的逐笔校验伪代码片段,展示三条判据如何协同工作。示例参数(窗口 500ms、序号起点 1001)均为教学假设,实际取值应来自链路实测。

# 教学示例:单股票逐笔校验核心逻辑(伪代码)
WINDOW_MS = 500          # 乱序容忍窗口
expected_seq = 1001      # 期望的下一个交易所序号
seen = set()             # 窗口内已见序号,用于判重
pending = {}             # 序号 -> 记录,暂存窗口内乱序记录

def on_tick(rec):
    seq, ts_ex, ts_local = rec.seq, rec.exchange_ts, rec.local_ts

    # 判据③:时序合理性
    if not in_trading_session(ts_ex):
        report("时序异常", rec); return

    # 判据②:业务一致性(委托剩余量)
    if rec.type == "ORDER" and rec.remain < 0:
        report("委托剩余量为负", rec); return

    # 判重:以交易所序号为键,而非内容哈希
    if seq in seen:
        report("重复记录", rec); return
    seen.add(seq)

    # 判据①:序号连续性 + 乱序窗口
    if seq == expected_seq:
        emit(rec); expected_seq += 1
        # 尝试从 pending 中续接
        while expected_seq in pending:
            emit(pending.pop(expected_seq)); expected_seq += 1
    elif seq > expected_seq:
        pending[seq] = rec          # 暂存,等待窗口判定
        if ts_local - window_start > WINDOW_MS:
            report("疑似缺失", expected_seq, seq)
            expected_seq = seq + 1  # 跳过缺口,继续校验
    else:
        report("序号回退", rec)     # seq < expected_seq 且未在 seen 中

这段逻辑的关键点在于:判重先于判序,避免重复记录污染序号状态;乱序记录进入 pending 而非直接报错,给迟到记录一个续接机会;窗口超时后才判定缺失,并在报告中记录缺口区间,便于后续补包或标记。运行后应观察三类输出:正常 emit 的记录数、pending 中最终未续接的记录数(即真实乱序残留)、以及缺失缺口列表。若某只股票的缺失缺口在收盘后仍无法补齐,则该股票当日逐笔数据应标记为"不完整",下游回测或指标计算需据此降级处理。

校验报告建议至少包含四个字段:股票代码、缺口起止序号、缺口发生时段、补包结果(已补/未补/部分补)。这四个字段足以支撑后续的数据质量追溯,而不必记录每条记录的明细。

需要强调的是,逐笔校验的目标不是"零告警",而是"告警可解释、缺口可追溯"。高频、乱序、重复、缺失是逐笔数据的常态而非异常,校验系统的价值在于把不可见的链路问题转化为可定位、可量化的数据质量指标,让下游使用者清楚知道"这份逐笔数据在哪些时段、哪些标的上是可信的"。