3. 逐笔成交与盘口快照的合并回放流

为什么需要“合并回放流”

逐笔成交(Trade)和盘口快照(Snapshot)是两条独立的数据流:成交流记录“谁在什么价格成交了多少”,快照流记录“某一时刻买卖各档的挂单状态”。跳水回放要还原的是“盘口变化 → 成交触发 → 盘口再变化”的因果链,因此必须把两条流按时间戳合并成一条事件驱动的回放流,逐条推进。合并的核心难点不在排序本身,而在同一时间戳下成交与快照的先后顺序——顺序错了,回放中就会出现“先看到价格跌了、再看到卖单堆积”的倒因为果。

合并规则:同一时间戳的定序

交易所行情中,快照通常是按固定周期(如每 3 秒)切片生成的,其时间戳标记的是切片时刻;而逐笔成交的时间戳是成交撮合时刻。两者落在同一毫秒时,业务含义是:该快照反映的是切片时刻之前已发生的成交结果。因此合并时采用如下定序约定(本案例统一使用,正文与代码保持一致):

  • 同一时间戳:成交事件排在快照之前(Trade 先于 Snapshot),保证快照中的累计成交量、最新价已包含该笔成交。
  • 不同时间戳:严格按时间升序;时间戳相同且同为成交时,按原始序列号(seq)稳定排序,避免同毫秒多笔成交被打乱。
  • 快照与快照同时间戳属异常数据,保留首次出现、标记重复。
若数据源明确快照时间戳为“切片结束时刻”且成交时间戳精度更高,上述约定依然成立;若快照时间戳为“切片开始时刻”,则需把快照排在成交之后。定序约定必须与数据源语义一致,并在回放配置中显式记录,避免不同批次数据混用。
原始双流输入 逐笔成交流 Trade ts=10:00:00.120 seq=1 ts=10:00:00.120 seq=2 ts=10:00:00.450 seq=3 ts=10:00:03.000 seq=4 字段:时间戳/序号/价格/量/方向 盘口快照流 Snapshot ts=10:00:00.000 买一/卖一… ts=10:00:03.000 买一/卖一… ts=10:00:06.000 买一/卖一… 字段:时间戳/十档价量/累计量 合并器 ① 时间戳升序 ② 同 ts:Trade 先于 Snap ③ 同 ts 同类型:按 seq ④ 重复快照:保留首条 统一回放流(事件驱动,逐条推进) #1 Snap 10:00:00.000 初始盘口 #2 Trade 10:00:00.120 seq1 卖向 #3 Trade 10:00:00.120 seq2 卖向 #4 Trade 10:00:00.450 seq3 卖向 #5 Snap 10:00:03.000 卖压显现 #6 Trade 10:00:03.000 seq4 卖向 #7 Snap 10:00:06.000 跳水延续 虚线框:同 ts=10:00:03.000,Trade 先于 Snap 回放指针按 #1→#7 顺序推进;每推进一条,盘口状态与成交累计同步更新,保证因果不倒置。 若把 #6 排到 #5 之前,回放会先看到成交、后看到卖压,误判为“成交引发跳水”,实际是卖压先堆积。

合并实现:稳定排序 + 定序键

最稳妥的做法不是“先合并再排序”,而是给每条事件构造一个可比较的定序键,再对合并后的列表做一次稳定排序。定序键由三部分组成:时间戳、类型优先级、原始序号。类型优先级让同一时间戳下 Trade 排在 Snapshot 之前;原始序号保证同类型同时间戳的成交保持原始撮合顺序。

from dataclasses import dataclass, field
from typing import List, Literal

@dataclass
class Event:
    ts: int                      # 统一为毫秒整数,便于比较
    kind: Literal["trade", "snap"]
    seq: int                     # 成交用原始序号;快照用切片序号
    payload: dict = field(default_factory=dict)

# 定序键:时间戳升序 → 同 ts 下 trade(0) 先于 snap(1) → 同类型按 seq
def order_key(e: Event):
    kind_rank = 0 if e.kind == "trade" else 1
    return (e.ts, kind_rank, e.seq)

def merge_streams(trades: List[Event], snaps: List[Event]) -> List[Event]:
    merged = trades + snaps
    # Python 的 sorted 是稳定排序,但这里键已含 seq,稳定性作为兜底
    merged.sort(key=order_key)
    # 去除同 ts 重复快照(保留首条)
    out, seen_snap_ts = [], set()
    for e in merged:
        if e.kind == "snap":
            if e.ts in seen_snap_ts:
                continue
            seen_snap_ts.add(e.ts)
        out.append(e)
    return out

注意 ts 统一为毫秒整数是合并的前提——若成交用微秒、快照用秒,直接比较会得到错误顺序。这一步应在数据接入阶段完成(时间戳统一),本案例只消费已对齐的整数时间戳。

逐条推进:事件驱动的回放指针

合并完成后,回放器持有一个指针 idx,每次 step() 取出当前事件并更新状态。成交事件更新“最新价、累计成交量、最近成交方向”;快照事件整体替换盘口十档。由于定序键已保证 Trade 先于同 ts 的 Snap,快照替换时累计量已包含该笔成交,不会出现“快照累计量落后于成交”的矛盾。

class ReplayEngine:
    def __init__(self, events: List[Event]):
        self.events = events
        self.idx = 0
        self.last_price = None
        self.cum_vol = 0
        self.orderbook = None

    def step(self) -> Event | None:
        if self.idx >= len(self.events):
            return None
        e = self.events[self.idx]
        self.idx += 1
        if e.kind == "trade":
            self.last_price = e.payload["price"]
            self.cum_vol += e.payload["vol"]
        else:
            self.orderbook = e.payload["levels"]
        return e

    def run_to(self, ts_ms: int):
        # 推进到指定时间戳(含),用于慢放/定位
        while self.idx < len(self.events) and self.events[self.idx].ts <= ts_ms:
            self.step()

验证:同时间戳顺序是否真的生效

用一组构造数据验证定序键。构造 2 笔成交与 1 条快照落在同一毫秒 ts=3000,期望输出顺序为 Trade(seq=4) → Trade(seq=5) → Snap。同时构造一条重复快照验证去重。

trades = [
    Event(120, "trade", 1, {"price": 10.02, "vol": 300}),
    Event(120, "trade", 2, {"price": 10.01, "vol": 500}),
    Event(450, "trade", 3, {"price": 10.00, "vol": 800}),
    Event(3000, "trade", 4, {"price": 9.98, "vol": 1200}),
    Event(3000, "trade", 5, {"price": 9.97, "vol": 900}),
]
snaps = [
    Event(0,    "snap", 0, {"levels": "初始"}),
    Event(3000, "snap", 1, {"levels": "卖压"}),
    Event(3000, "snap", 1, {"levels": "卖压-重复"}),
    Event(6000, "snap", 2, {"levels": "跳水"}),
]

merged = merge_streams(trades, snaps)
for e in merged:
    print(e.ts, e.kind, e.seq)

# 预期输出(ts, kind, seq):
# 0    snap  0
# 120  trade 1
# 120  trade 2
# 450  trade 3
# 3000 trade 4
# 3000 trade 5
# 3000 snap  1
# 6000 snap  2
验证时重点看两处:① ts=3000 的两笔成交是否排在快照之前;② 重复快照是否只保留一条。若输出中 3000 snap 出现在 3000 trade 之前,说明 kind_rank 写反或排序键漏了类型字段。

回放中值得观察的判读点

合并流跑通后,回放时重点观察“成交与快照的对应关系”是否自洽:

  • 累计量一致性:快照中的累计成交量应 ≥ 此前所有成交的累计量;若小于,说明定序或数据源有问题。
  • 最新价一致性:快照的最新价应等于此前最后一笔成交价(或在其附近),若快照最新价明显偏离,检查是否把快照排到了成交之前。
  • 卖压与成交的先后:跳水段中,若先出现卖一量骤增的快照、再出现大额卖向成交,属正常“卖压先行”;若顺序相反,需回查定序键。
本案例的交付物是一条可逐条推进的统一事件流:输入为已对齐的成交列表与快照列表,输出为按“时间戳升序 → Trade 先于 Snap → 同类型按 seq”排序、并去除重复快照的事件序列。后续的回放时钟、倍速控制与跳水识别都直接消费这条流,因此定序约定必须在此处固定下来并写入配置。