土法炼钢兴趣小组的算法知识备份

【金融科技工程】行情分发:MBP/MBO、快照+增量、组播/TCP、FIX/ITCH

文章导航

分类入口
architecturefintech
标签入口
#market-data#fix#itch#sbe#multicast#kdb#clickhouse#colocation#websocket#tick-data

目录

引言:行情是撮合的”出口”,也是整个市场的”公共视野”

上一篇《撮合引擎实现》讲清了订单簿(Order Book)内部如何以价格时间优先匹配买卖盘。但撮合引擎撮合出一笔成交,如果没人看见,它就只是一个私有状态的变更。只有当这个状态变更以某种协议、某种延迟、某种可靠性保证,推送给市场的每一个参与者时,这笔成交才真正成为”市场价格”。这个职责由行情系统(Market Data System)承担。

行情系统是交易所里最容易被低估的子系统。它看上去只是”把撮合结果广播出去”,但量化到数字上:

行情系统真正的复杂度不在”推”,而在:

  1. 多份副本,多种协议,多个消费者,还得一致。一笔成交要同时以 L1(Level-1,顶档)、L2(Level-2,多档)、L3(Level-3,逐笔/逐单)形式发给散户行情、机构行情、内部风控、监管报送、历史库 Tick 存储,任何一路错位都会引发套利漏洞或监管追责。
  2. UDP 必然丢包,序列号必须严格单调,恢复必须在微秒级完成。组播快但不可靠,TCP 可靠但慢,生产系统要两者兼备。
  3. 历史行情既要完全真实,又要能”快进”回放用于回测。事件时间(Event Time)与墙上时钟(Wall Clock)的分离是量化回测的核心抽象。

本篇的读者画像:

本篇与前后三篇联动:第 15 篇《交易所架构》给出了五大子系统的分层图,行情系统位于撮合引擎下游;第 16 篇《撮合引擎》把 Order Book 内部结构讲清楚了,本篇接过撮合引擎吐出的事件流;下一篇第 18 篇《证券登记结算》则承接行情 + 成交进入登记结算机构的链路。


一、行情分级:L1、L2、L3 到底在看什么

所有后续协议、分发链路、存储方案的设计都围绕一个问题展开:订阅者要看什么? 不同客户承受的延迟、需要的细节、愿意付的费用差距极大,因此交易所历史上形成了分级订阅体系。

1.1 三级行情的本质区别

级别 英文 内容 粒度 典型订阅者 典型费用
Level-1 Top of Book / BBO 最优买价、最优卖价、最新成交价/量、日高低开 每 tick 一条 散户行情、App、资讯门户 免费/低价
Level-2 MBP(Market by Price) 买卖各 N 档(5/10/20/全档)价格、挂单量、订单数 每 tick 一条聚合 机构、做市商、算法策略 月费几千到几万人民币/几百美元
Level-3 MBO(Market by Order) 逐笔订单:每一个挂单的订单号、价格、剩余量、队列位置 每个订单事件一条 高频做市、HFT、撮合模拟器 昂贵,部分市场监管才能看

MBP 和 MBO 是行情从业者最容易混淆的两个词:

为什么有了 MBP 还要 MBO?因为 MBP 丢失了时间优先(Time Priority)信息。做市商关心自己的单在队列里排第几位,这决定了”如果行情不动,我大概还要等多少成交才能轮到我”,这个信息只有 MBO 才能还原。

1.2 谁在同一时刻看到什么

一个 tick 事件(比如一笔撤单)在行情系统内部分发时,会按不同级别被”降级”输出:

flowchart TB
    ME[撮合引擎 Event Stream] --> MBO[L3 MBO:逐单事件]
    MBO -->|按价位聚合| MBP[L2 MBP:多档盘口]
    MBP -->|取首档| L1[L1 BBO:最优买卖价]
    MBO --> Trade[成交 Trade Tape]
    Trade --> L1
    Trade --> Recorder[历史 Tick 存储]
    MBO --> Recorder

L1 是从 L2 顶档派生的,L2 是从 L3 聚合的,三者同源,但分发路径、压缩策略、延迟 SLA 都不同。这也是行情系统内部工程最繁琐的一部分——同一个事件要沿多条路径出去。

1.3 国际主流市场的分级对照

市场 L1 产品 L2 产品 L3 产品 备注
Nasdaq(美股) Nasdaq Basic TotalView-ITCH 5 档 TotalView-ITCH 完整 MBO ITCH 协议同时承载 L2/L3
NYSE(美股) NYSE BBO NYSE Integrated NYSE OpenBook Pillar 平台
CME(期货) MDP 3.0 Top of Book MDP 3.0 Market by Price MDP 3.0 MBO 全基于 SBE + UDP 组播
上交所 Level-1 行情 Level-2 10 档 / 逐笔委托 无公开 L3 Level-2 本质是增强 L2 + 逐笔委托/成交
深交所 Level-1 Level-2 10 档 / 逐笔委托 无公开 L3 协议为 STEP(证券交易数据交换协议)
港交所 BMP(Basic Market Data) MMP(Market Data Plus) OMD(Orion Market Data)逐单 OMD-C/OMD-D
Binance @bookTicker @depth20@100ms @depth@100ms diff + REST 快照 WebSocket + REST

国内市场没有严格意义上的 L3,但”逐笔委托 + 逐笔成交”合起来等价于 L3,事后可以重建 MBO。这一点在国内量化做市、撤单率分析里被大量用到。


二、行情协议:从 FIX 到 ITCH 到 SBE

协议决定了行情系统的两个核心指标:序列化开销(编码/解码 CPU 时间)和传输带宽(每条消息字节数)。低延迟市场对两者都极端敏感。

2.1 FIX:行业通用语,但太”胖”

FIX(Financial Information eXchange)是 1992 年由 Fidelity 和所罗门兄弟发起的行业事实标准,核心是文本 Tag-Value 格式

8=FIX.4.4|9=178|35=W|34=2|49=NYSE|56=CLIENT|52=20260422-09:30:00.001|
55=AAPL|268=2|269=0|270=185.23|271=500|269=1|270=185.25|271=800|10=128|

每条消息用 |(实际是 SOH,0x01)分隔 Tag=Value 对,Tag 35=W 表示 Market Data Snapshot。优点是人类可读、版本容易演进、网关好调试;缺点显而易见:一条 10 档快照轻松上千字节,解析要做字符串扫描和整数转换,在纳秒级系统里不可接受。

FIX 在行情端几乎只剩两个位置还在用:

  1. 机构与经纪商之间的订单 + 低频行情:FIX Session 层提供 Heartbeat、Resend Request、Sequence Number 管理,是久经考验的双向连接协议;
  2. 加密市场的机构接入:不少 OTC 柜台和 Prime Broker 用 FIX 4.4 / 5.0 SP2 做订单 + 部分行情订阅。

2.2 FIX FAST:在 FIX 语义上做二进制压缩

FAST(FIX Adapted for STreaming)是 FIX 协议委员会 2005 年推出的压缩方案。核心思想:

同样的 10 档快照,FAST 可以压缩到原 FIX 的 10–20%,但解码需要完整的模板文件,任何模板变更都要客户端同步升级。上交所 Level-2 的 FAST 协议、CME 早期 MDP 2.5 都曾是 FAST 用户;国内部分券商直连方案至今仍在维护 FAST 解析器。

2.3 ITCH / OUCH:Nasdaq 的定长二进制

Nasdaq 1990 年代末自研 INET 撮合平台时顺手做了两套协议:

ITCH 的关键设计:

ITCH 之所以成为低延迟行情协议事实上的范式,是因为:整条消息放进一个 UDP datagram,接收端几乎零拷贝(Zero-Copy),用 C/C++ 可以 cast 成 struct。生产系统里完整解析一条 ITCH 消息在现代 CPU 上只需要几十纳秒。

2.4 SBE:CME 主导的新一代二进制

SBE(Simple Binary Encoding)是 Real Logic 公司(Martin Thompson 等 LMAX Disruptor 原班人马)主导,2014 年被 FIX Trading Community 标准化的二进制编码格式,现在是 CME MDP 3.0、IEX、Bloomberg B-PIPE、部分亚洲交易所的选择。SBE 的核心目标:

一个 SBE Message Header 恒定 8 字节:

┌──────────────┬──────────────┬──────────────┬──────────────┐
│ BlockLength  │ TemplateId   │ SchemaId     │ Version      │
│   2 bytes    │   2 bytes    │   2 bytes    │   2 bytes    │
└──────────────┴──────────────┴──────────────┴──────────────┘

后面紧跟固定长度主体 + 可重复组。CME 公开基准测试显示:同一条 MDP 增量消息,JSON ~400 字节、FIX ~200 字节、SBE ~40 字节,解码延迟 JSON ~3μs、FIX ~1μs、SBE ~50–100ns。

2.5 国内协议:STEP、FAST、二进制

上交所、深交所、期交所(上期所、大商所、郑商所、中金所)对外协议有较长的历史演进:

市场 行情协议 传输 备注
上交所 Level-1 STEP(FIX-like 文本) TCP 中国证券交易所数据交换协议
上交所 Level-2 FAST / 二进制 UDP 组播(内部)/ TCP(外发) 含逐笔委托、逐笔成交
深交所 BIN / STEP UDP 组播 + TCP 二进制协议由交易所定义
中金所 CTP + FEMAS 行情 TCP / UDP 组播 期货 CTP 协议广泛用于券商前置
上海国际能源中心(INE) CTP / Femas 同上 原油期货等

国内券商前置机典型部署:交易所机房的组播在券商核心机房被接收、落盘、解析、转成 CTP/STEP 格式再推给终端。每一跳都要小心序列号。

2.6 加密行情:WebSocket 为主,JSON / Protobuf 并存

加密货币交易所基本跳过了 FIX 和二进制组播,直接用 WebSocket + JSON:

交易所 订阅协议 格式 快照 增量
Binance WebSocket JSON REST /depth 快照 @depth@100ms diff
OKX WebSocket JSON 首包快照 后续增量,带 checksum
Coinbase Advanced WebSocket JSON level2 通道首条 snapshot 后续 update
Kraken WebSocket v2 JSON 订阅即推 snapshot 后续 update,带 checksum
Bybit WebSocket JSON 首包 snapshot delta 增量

少数交易所提供 Protobuf / FlatBuffers 版本(如 Binance Spot 的 market-data-only-stream 有实验性二进制),但主流仍是 JSON,原因是加密用户大多跑 Node.js / Python 脚本,JSON 足够。延迟敏感客户会付费走 Colocation + 专线 + 私有二进制通道。


三、UDP 组播 + 序列号 + 重传:交易所的经典分发模型

行情系统的分发从来不是一个简单的”广播”。工程上它必须同时满足三件事:低延迟、多订阅者、可恢复丢失。UDP 组播给出了前两件,序列号 + 重传通道补齐了第三件。

3.1 为什么是组播而不是单播

假设 CME 有 1000 个订阅者,每秒 100 万条 tick,每条 100 字节:

组播的代价是需要底层网络支持,因此几乎只在交易所内部 LAN、机构 Colo 机房、运营商专线里可用。普通互联网路由器不转发组播,加密交易所对公网用户只能 TCP/WebSocket。

3.2 A/B 双线冗余:丢包第一道防线

UDP 不保证投递,中间交换机一次掉电、CPU 中断风暴都能丢包。主流交易所采用 A/B 双线(也叫 Line A / Line B):

CME MDP 3.0、Nasdaq ITCH 都提供 A/B 双组播。接收端典型实现:

on_packet(pkt):
    if pkt.seq > expected:
        enqueue_gap(expected, pkt.seq)    # 记录缺口
        request_retransmit(expected, pkt.seq)  # 触发 TCP 恢复通道
    if pkt.seq == expected:
        deliver(pkt)
        expected = pkt.seq + 1
    # pkt.seq < expected:来自另一条线的重复,丢弃

3.3 Snapshot + Incremental Recovery

双线丢包仍不足以满足监管要求(任何参与者都必须能重建完整订单簿)。行情系统因此普遍采用快照 + 增量恢复

客户端启动或大段丢包后的恢复流程:

sequenceDiagram
    participant C as 客户端
    participant I as Incremental UDP
    participant S as Snapshot UDP
    participant R as Retransmission TCP

    C->>I: 加入组播,开始缓冲增量
    C->>S: 加入组播,等一个完整快照
    S-->>C: Snapshot (seq=N, book=...)
    C->>C: 用快照初始化本地簿
    C->>C: 应用缓冲区里 seq > N 的增量
    Note right of C: 若缓冲里出现缺口 (N, M)
    C->>R: TCP Replay Request (N..M)
    R-->>C: 补齐丢失消息
    C->>C: 按序列号重新应用,本地簿与交易所一致

三个通道的职责严格单一:Incremental 只管推、不管补;Snapshot 只管”全量快照”、不管增量;Retransmission 只管按序列号范围回放。这种分离设计源于 FIX Trading Community 的 “Market Data - Incremental Refresh + Snapshot” 模型,如今在 CME MDP、Nasdaq ITCH、上交所 Level-2 里都能看到。

3.4 序列号的”单调性神圣”

行情系统里最容易出事的是序列号回退或跳跃。只要客户端观察到:

因此生产端的要求:

  1. 全局单调:整个 feed(或分区后的每个 channel)序列号单调递增,即便进程重启;
  2. 落盘持久化:计数器必须写到持久存储(NVM / SSD),否则重启后必须跳过一个”安全间隔”或直接抛下一档序号让客户端走 snapshot;
  3. 严禁复用:同一序号的消息内容必须一致,否则下游幂等失败。

生产事故的典型教训:2015 年 8 月纽交所因行情系统配置错误停摆 3.5 小时,根因之一就是升级后 feed 序列号管理与客户端预期不一致,大量消费者触发 resync 风暴,拖垮快照通道。

3.5 Conflation vs Full Tick

当订阅者能力有限(比如 Web App、手机端、海外跨洋链路),行情系统提供聚合(Conflation)模式:

Binance 的 @depth20@100ms 就是典型 Conflation:100ms 内的多笔深度变化合并成一条 20 档快照。代价是中间状态消失了,比如一个 1 手挂单 ms 级出现又被吃掉,Conflation 模式下订阅者完全看不到。

选型原则:


四、物理层:把延迟压到物理极限

在撮合引擎之外,行情分发是交易所内部对延迟最敏感的一层。所有”快一毫秒”的优化都会落到这里。

4.1 内核旁路(Kernel Bypass)

传统 Linux 网络栈从网卡到应用态要经过:中断 → softirq → skb → socket buffer → recvmsg 系统调用 → 用户态。即便打开 NAPI、调优 NIC offload,一个收包路径也要几微秒。

内核旁路直接绕过内核:

方案 厂商 / 来源 特点
Solarflare OpenOnload AMD Xilinx(原 Solarflare) LD_PRELOAD 劫持 socket API,用户态协议栈,延迟 ~1μs
Mellanox VMA / libvma NVIDIA(原 Mellanox) 同理,Infiniband / RoCE 场景常用
DPDK Intel / 开源 Poll Mode Driver,100% CPU 轮询,纯用户态
ef_vi Solarflare 原生 API 比 OpenOnload 更底层,几百纳秒
XDP / AF_XDP Linux 内核 eBPF 在驱动层截获包,开源免费替代

行情分发侧典型做法:

  1. 生产端用 DPDK / ef_vi 直接构造 UDP 包送到网卡,跳过内核;
  2. 接收端关键策略(做市、套利)用 OpenOnload / VMA,不改应用代码;
  3. 非关键消费者(合规、存档)用普通 Linux 网络栈,降低维护成本。

4.2 Colocation(共址)

交易所对外提供”机柜租赁”服务,让客户的服务器摆在撮合引擎”隔壁”:

公平接入原则要求所有 Colo 客户到撮合引擎的物理线缆长度完全一致(到米级甚至厘米级),因此交易所会刻意”盘线”让每个机柜绕等长路径。

4.3 微波 / 激光 / 毫米波:城际级物理优化

芝加哥(CME)到纽约(NYSE/Nasdaq)约 1200 公里。光纤在玻璃里的折射率约 1.47,光速 20.4 万公里/秒,双向单程约 6ms,加上交换延迟约 8ms。微波在大气中接近真空光速,同距离约 3.9ms,节省 4ms

链路 媒介 单程延迟(芝加哥↔︎纽约)
金融专线光纤(最短路径) 单模光纤 ~8 ms
Spread Networks(2010,专修短路径光纤) 单模光纤 ~6.65 ms
McKay Brothers / Tradeworx 微波 11 GHz / 18 GHz 微波 ~4.1 ms
Jump Trading 毫米波 / 激光 更高频段 ~3.95 ms

微波的问题是雨衰:一场暴雨能让链路丢包到不可用。HFT 公司因此同时运营微波 + 光纤备份,恶劣天气自动切换。这些优化单独看只值几毫秒,但在跨所套利(NYSE 和 Nasdaq 之间、CME 和 NYSE 之间)里,快几毫秒就意味着每次套利多赚一整轮

4.4 时钟同步:PTP 而不是 NTP

行情消息必须带交易所时间戳,且精度要高到可以判断 tick 先后。NTP(网络时间协议)精度在毫秒级,对行情远远不够。交易所和券商普遍用 PTP(Precision Time Protocol,IEEE 1588v2) + GPS 钟源:


五、Tick 数据存储:一天 TB 级的流水账

行情系统不仅要发,还要存。一家中等规模交易所的完整 tick 一天能写 500 GB – 5 TB 原始数据,业界的存储方案围绕”高写入 + 范围扫描 + 时间维度聚合”展开。

5.1 kdb+:Wall Street 的历史默认

kdb+ 是 Kx Systems 1993 年开始开发的列式时序库,搭配 q 语言。在顶级投行(Morgan Stanley、GS、JPM、Citadel、Two Sigma)、主流交易所(LSE、ICE)几乎无处不在。关键特性:

/ 取 AAPL 当天 09:3010:00 所有 tick 的 VWAP
select vwap:size wavg price from trade where date=.z.d, sym=`AAPL,
  time within 09:30:00.000 10:00:00.000

5.2 ClickHouse:近几年最流行的开源替代

ClickHouse 从 Yandex 内部搜索日志库演化而来,2016 开源。在金融 tick 存储场景被大量采用(Cloudflare、Bloomberg 内部工具、许多加密量化基金):

典型 tick 表:

CREATE TABLE market.trade
(
    symbol       LowCardinality(String),
    ts           DateTime64(9, 'UTC'),     -- 纳秒时间戳
    price        Decimal(18, 8),
    size         Decimal(18, 8),
    side         Enum8('B'=1,'S'=2),
    trade_id     UInt64,
    seq          UInt64
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(ts)
ORDER BY (symbol, ts)
SETTINGS index_granularity = 8192;
-- 1 分钟 K 线(即席查询,不建物化视图)
SELECT
    symbol,
    toStartOfMinute(ts) AS bar_ts,
    argMin(price, ts)   AS open,
    max(price)          AS high,
    min(price)          AS low,
    argMax(price, ts)   AS close,
    sum(size)           AS volume,
    sum(price * size) / sum(size) AS vwap
FROM market.trade
WHERE symbol = 'BTCUSDT'
  AND ts >= '2026-04-22 00:00:00'
  AND ts <  '2026-04-23 00:00:00'
GROUP BY symbol, bar_ts
ORDER BY bar_ts;

5.3 QuestDB / TimescaleDB / InfluxDB

5.4 Parquet + Arrow:长期归档与回测

实时热库用 kdb+/ClickHouse,历史冷数据普遍落到 Parquet + S3 / OSS


六、行情回放与回测:事件时间才是”真时间”

量化策略研发离不开回测(Backtest),回测的第一原则是重放事件时间而不是墙上时钟

6.1 两类时间戳的分离

时间 含义 来源
事件时间(Event Time) 行情本身发生/交易所发出的时间戳 交易所 feed 内嵌的 TransactTime
处理时间(Processing Time) 回放系统当前挂钟时间 time.Now()
发送时间(Send Time) 生产端发包时间 Wire Header
接收时间(Receive Time) 消费端网卡 HW 时间戳 NIC timestamp

回测必须按事件时间排序、按事件时间触发策略计算。用挂钟一步步等会导致:

6.2 基本回放引擎伪代码

def replay(events, handler, speed=0.0):
    """
    events: 按 event_time 升序的迭代器
    speed:  0 表示尽快重放;1.0 表示实时;0.5 表示慢速
    """
    wall_start = time.time()
    event_start = None
    for ev in events:
        if event_start is None:
            event_start = ev.ts
        if speed > 0:
            elapsed_event = (ev.ts - event_start) / speed
            elapsed_wall = time.time() - wall_start
            sleep_for = elapsed_event - elapsed_wall
            if sleep_for > 0:
                time.sleep(sleep_for)
        handler.on_event(ev)  # 策略看到的是 ev.ts

6.3 Look-ahead Bias:回测最致命的陷阱

前视偏差是指策略在某时刻看到了该时刻之后才应该发生的信息。典型错误:

生产级回测框架必备:

  1. 单调推进的事件时钟:handler 之外没有任何数据来源能”看未来”;
  2. 撮合模拟器:策略下单后必须走模拟撮合,按当时 Order Book 真实可成交量给出成交价;
  3. Latency Model:下单、撤单必须加一个可配置的延迟(几十 μs 到几毫秒),模拟真实到达交易所的延迟。

七、从逐笔到盘口:K 线、VWAP、TWAP 的合成

L3 事件流是所有衍生行情指标的”原子数据”。常见的合成:

7.1 K 线(OHLCV Bar)

K 线(Candlestick Bar)的定义:在一个时间窗口 [t, t+Δt) 内:

Open   = 窗口内第一笔成交价
High   = 窗口内最高成交价
Low    = 窗口内最低成交价
Close  = 窗口内最后一笔成交价
Volume = 窗口内成交量之和

对 1 分钟 K 线,标准实现是一个增量聚合器:

type BarAgg struct {
    Open, High, Low, Close float64
    Volume                 float64
    Start                  time.Time
    HasData                bool
}

func (b *BarAgg) OnTrade(ts time.Time, price, size float64) {
    if !b.HasData {
        b.Start = ts.Truncate(time.Minute)
        b.Open, b.High, b.Low, b.Close = price, price, price, price
        b.Volume = size
        b.HasData = true
        return
    }
    if price > b.High { b.High = price }
    if price < b.Low  { b.Low  = price }
    b.Close = price
    b.Volume += size
}

边界处理是坑点:一根分钟 K 线在当分钟没有一笔成交时应当沿用上一根 Close 形成十字线,还是跳空?不同数据源约定不一,量化回测里要统一。

7.2 VWAP(成交量加权平均价)

VWAP(t) = Σ(price_i × size_i) / Σ(size_i),i 为 [0, t] 所有成交

VWAP 两种用法:

7.3 TWAP(时间加权平均价)

TWAP(t) = ∫(price ds) / (t - t₀),在无连续价的离散场景下常用分钟 close 等间隔采样

TWAP 在非连续交易(场外、低流动性)或滑点控制里比 VWAP 更合理,因为它不被大单拖偏。

7.4 MBP 盘口合成:从 L3 到 L2

从逐笔委托 + 逐笔成交(国内 Level-2 的原料)重建 MBP 盘口是国内量化最常做的预处理:

  1. 维护一个 map<price, size> 的买/卖侧;
  2. AddOrder(side, price, size)map[price] += size
  3. Cancel(order_id, remain_size)map[price] -= remain_size,若为 0 删除;
  4. Trade(order_id, size):根据 order_id 查到价位,map[price] -= size

生产级实现会额外维护每个价位的订单列表以支持从 MBP 反推 MBO。


八、代码示例:订阅 Binance 深度并本地维护订单簿

Binance 现货深度 WebSocket 是加密市场最常见的 Diff Depth 模式。官方文档给出的“How to manage a local order book correctly”流程是:

  1. 开启 WebSocket @depth@100ms 流(即 bnbbtc@depth@100ms),把增量先缓冲
  2. 调 REST GET /api/v3/depth?symbol=BNBBTC&limit=1000 拿一次 snapshot,记下 lastUpdateId;
  3. 丢弃缓冲中 u < lastUpdateId + 1 的事件;
  4. 第一条应用的事件必须满足 U <= lastUpdateId + 1 <= u
  5. 此后每条事件必须满足 U == prev_u + 1,否则重来第 2 步。

8.1 Python 实现

import asyncio
import json
import heapq
import httpx
import websockets
from collections import OrderedDict

SYMBOL = "bnbbtc"
WS_URL = f"wss://stream.binance.com:9443/ws/{SYMBOL}@depth@100ms"
REST_URL = f"https://api.binance.com/api/v3/depth?symbol={SYMBOL.upper()}&limit=1000"

class OrderBook:
    def __init__(self):
        self.bids: dict[float, float] = {}
        self.asks: dict[float, float] = {}
        self.last_update_id: int = 0

    def apply(self, side: dict[float, float], updates: list[list[str]]):
        for price_s, qty_s in updates:
            price, qty = float(price_s), float(qty_s)
            if qty == 0.0:
                side.pop(price, None)
            else:
                side[price] = qty

    def best(self):
        bb = max(self.bids) if self.bids else None
        ba = min(self.asks) if self.asks else None
        return bb, ba

async def run():
    async with websockets.connect(WS_URL) as ws:
        buffer = []
        # 1) 先缓冲增量
        async def buffer_task():
            async for raw in ws:
                buffer.append(json.loads(raw))
        buf = asyncio.create_task(buffer_task())
        await asyncio.sleep(0.5)  # 给 WS 一点时间积累事件

        # 2) 拉快照
        async with httpx.AsyncClient() as c:
            snap = (await c.get(REST_URL)).json()
        book = OrderBook()
        book.last_update_id = snap["lastUpdateId"]
        book.apply(book.bids, snap["bids"])
        book.apply(book.asks, snap["asks"])

        # 3) 丢弃旧事件
        fresh = [e for e in buffer if e["u"] > book.last_update_id]
        if not fresh or not (fresh[0]["U"] <= book.last_update_id + 1 <= fresh[0]["u"]):
            raise RuntimeError("快照与增量不连续,重来")

        prev_u = book.last_update_id
        for ev in fresh:
            if ev["U"] != prev_u + 1:
                raise RuntimeError("增量序列号断裂,重来")
            book.apply(book.bids, ev["b"])
            book.apply(book.asks, ev["a"])
            prev_u = ev["u"]
        buffer.clear()

        # 4) 进入稳态循环
        async for raw in ws:
            ev = json.loads(raw)
            if ev["U"] != prev_u + 1:
                # 真实实现:重启整个流程
                buf.cancel()
                return await run()
            book.apply(book.bids, ev["b"])
            book.apply(book.asks, ev["a"])
            prev_u = ev["u"]
            bb, ba = book.best()
            print(f"BBO bid={bb} ask={ba} spread={ba-bb:.8f}")

asyncio.run(run())

8.2 Go 实现(核心片段)

type Book struct {
    Bids, Asks   map[float64]float64
    LastUpdateID int64
}

func (b *Book) apply(side map[float64]float64, updates [][]string) {
    for _, pq := range updates {
        price, _ := strconv.ParseFloat(pq[0], 64)
        qty, _ := strconv.ParseFloat(pq[1], 64)
        if qty == 0 {
            delete(side, price)
        } else {
            side[price] = qty
        }
    }
}

type DepthEvent struct {
    E int64      `json:"E"`
    U int64      `json:"U"`   // first update id
    Uu int64     `json:"u"`   // last update id
    B [][]string `json:"b"`
    A [][]string `json:"a"`
}

func maintainBook(ctx context.Context) error {
    ws, _, err := websocket.DefaultDialer.DialContext(ctx,
        "wss://stream.binance.com:9443/ws/bnbbtc@depth@100ms", nil)
    if err != nil { return err }
    defer ws.Close()

    buf := make([]DepthEvent, 0, 1024)
    bufDone := make(chan struct{})
    go func() {
        for {
            var ev DepthEvent
            if err := ws.ReadJSON(&ev); err != nil { close(bufDone); return }
            buf = append(buf, ev)
        }
    }()
    time.Sleep(500 * time.Millisecond)

    // 拉快照
    resp, err := http.Get("https://api.binance.com/api/v3/depth?symbol=BNBBTC&limit=1000")
    if err != nil { return err }
    defer resp.Body.Close()
    var snap struct {
        LastUpdateID int64      `json:"lastUpdateId"`
        Bids         [][]string `json:"bids"`
        Asks         [][]string `json:"asks"`
    }
    json.NewDecoder(resp.Body).Decode(&snap)

    book := &Book{Bids: map[float64]float64{}, Asks: map[float64]float64{}}
    book.LastUpdateID = snap.LastUpdateID
    book.apply(book.Bids, snap.Bids)
    book.apply(book.Asks, snap.Asks)

    // 应用缓冲(跳过过期事件、校验首条跨接)
    prevU := book.LastUpdateID
    started := false
    for _, ev := range buf {
        if ev.Uu <= prevU { continue }
        if !started {
            if !(ev.U <= prevU+1 && prevU+1 <= ev.Uu) {
                return fmt.Errorf("snapshot/incremental gap")
            }
            started = true
        } else if ev.U != prevU+1 {
            return fmt.Errorf("gap in buffered incrementals")
        }
        book.apply(book.Bids, ev.B)
        book.apply(book.Asks, ev.A)
        prevU = ev.Uu
    }

    // 稳态
    for {
        var ev DepthEvent
        if err := ws.ReadJSON(&ev); err != nil { return err }
        if ev.U != prevU+1 { return fmt.Errorf("gap, need resync") }
        book.apply(book.Bids, ev.B)
        book.apply(book.Asks, ev.A)
        prevU = ev.Uu
    }
}

8.3 生产级细节

上面的示例代码只到”能跑”,离生产还差几步:

  1. gap 触发全量 resync:不要 panic,要像 CME 客户端一样退回 snapshot 状态机;
  2. Bid/Ask 用排序结构map 取 BBO 是 O(N),生产用 B+树 / skip list / TreeMap;
  3. Price 用整数:浮点比较最终会坑你,币安的价格精度是定义好的 tick size,用 Int64 × 10⁸;
  4. 时间戳用交易所 E 字段:不要用本地 time.Now() 做策略时钟;
  5. 断线自动重连 + 指数退避,并把 resync 计数作为监控指标;
  6. Checksum 校验(Kraken、OKX 提供):用收到的 checksum 对本地簿 top N 档哈希比对,不一致则重来。

九、行情分发链路与多路径重传(SVG)

撮合引擎 Matching Engine 事件流 / 序列号生成

行情生产端 Feed Publisher MBO→MBP→L1 · 序列号 · 编码 (ITCH/SBE)

快照生成 Snapshot Publisher 周期全量盘口 · 带 LastMsgSeqNumProcessed

增量组播 A UDP Multicast 主路径 · 低延迟 · 可能丢包

增量组播 B UDP Multicast 冗余路径 · 不同交换机 · 去重合并

快照组播 Snapshot UDP 周期重播全量簿 · 新客户端/大段丢包恢复

重传通道 Retransmission TCP 按序列号区间 pull · 补小段缺口

做市 / HFT Kernel Bypass 本地 MBO 簿 队列位置估算 μs 级决策

算法策略 MBP 10 档 VWAP / TWAP Signal 生成 ms 级反应

风控 / 清算 最新成交 保证金重算 强平触发 100ms 窗口

Tick 存储 / 监管 kdb+ / ClickHouse Parquet 归档 监管报送 CAT/TR 回测数据源

图中实线代表主推流(UDP 组播),橙色代表快照恢复路径,红色虚线代表按需 TCP 重传。做市商和算法同时订阅 A、B 两条增量组播并做去重,风控与存档由于对延迟不敏感可以共用快照通道。


十、消费者:同一份行情,五种不同的用法

行情数据在交易所下游至少有五类消费者,对应五种 SLA 与五种失败模式

10.1 交易策略(Strategy)

  • 需求:最低延迟、Full Tick、MBO;
  • 失败模式:延迟跳变(Latency Spike)→ 信号基于过期价 → 亏损;
  • 工程策略:Colocation + Kernel Bypass + 热备策略自动切换。

10.2 风控(Risk)

  • 需求:最新成交价用于 PnL 与保证金;时序上允许 10–100ms 延迟;
  • 失败模式:行情中断但仍放新单 → 风险敞口失控(骑士资本类事故);
  • 工程策略:Stale Price 检测,行情停滞超阈值自动进入只减仓(Reduce-Only)模式。

10.3 清算(Clearing)

  • 需求:日终结算价(Settlement Price),一般是收盘集合竞价均价或 VWAP;
  • 工程策略:事后批处理计算,容忍几分钟延迟但要求可审计。

10.4 监管报送

  • 美国:CAT(Consolidated Audit Trail)要求所有订单与成交上报 SRO,时钟同步 100μs;
  • 欧盟:MiFID II RTS 25 时钟同步、RTS 22 交易报告;
  • 中国:沪深交易所要求券商异常交易、频繁报撤单监控并上报证监会;
  • 加密:多数管辖区要求合规 VASP 上报可疑交易(SAR / STR)。

这条路径的关键不是延迟,而是完整性与不可篡改。行情存档必须是 WORM(Write-Once-Read-Many)介质或经审计的云 Object Lock。

10.5 普通用户 / 资讯

  • App、K 线图、门户;
  • Conflation + Full Snapshot + CDN;
  • 最大容忍延迟:秒级。

十一、工程坑点

11.1 序列号不是业务 ID。业务上的 trade_id、order_id 由撮合引擎分配;feed 的 MsgSeqNum 由行情生产端分配。两个命名空间独立,重启/切换时一定要分开持久化。

11.2 组播 IGMP 泄漏。客户端没有 IGMP leave 就崩溃,交换机仍会向该端口投递流量,下一个新程序上来可能收到陈旧副本。生产端要设置 Source-Specific Multicast(SSM)+ 客户端显式 IP_DROP_MEMBERSHIP

11.3 快照”太小”或”太稀”都出问题。快照频率太低,新客户端启动时需要等下一个周期,冷启动慢;太高则吃网络带宽。典型做法是分片:整个订单簿按 symbol 切成 N 片,每片 1–5 秒轮发一次。

11.4 盘口深度用定点数而不是浮点price = 12.30 在 IEEE 754 里不是精确的 12.3,累加几万次会漂。所有价格按 tick size 换成 Int64。

11.5 WebSocket 背压(Back-pressure)。订阅端消费慢时服务端缓冲积累,典型故障:Binance 在极端行情下会对慢客户端主动断开。客户端要统计 WS 读取到处理的时延,超过阈值主动降级(减少订阅对或开多进程分担)。

11.6 时钟回跳。NTP 小步校时可能让进程级 time.Now() 回跳几毫秒,导致”新消息时间早于旧消息”。用 单调时钟(Monotonic Clock)做内部排序,UTC 时钟只做展示和报送。

11.7 跨集群序列号合并。为吞吐分片后,每个 shard 有自己的序列号。下游全局存储时要用 (shard_id, seq) 复合键,或在 Kafka/消息总线侧重新分配全局 offset。

11.8 回测”完美”陷阱。历史 tick 重放时策略的成交假设往往过于乐观——“挂单马上在最优价成交”——实际在真实盘口里同价位前面可能排着几百手队列。合理的成交模拟要结合 MBO 的 Queue Position。

11.9 行情停发的优雅处理。交易所技术故障会停发行情(2015 NYSE、2020 TSE),下游不能假设”没有消息 = 价格不动”,要做 heartbeat 超时告警并冻结策略。

11.10 协议版本迁移。FAST / SBE 模板升级是典型的”鸡生蛋”问题:客户端升级慢,交易所升级必须保留老版本并行发布 6–12 个月。上线前必须提供模板差异工具和对比测试集。


十二、选型建议与落地清单

12.1 选型建议

场景 建议
证券 / 期货交易所自研行情 ITCH 风格定长二进制 或 SBE;A/B 双 UDP 组播;独立 snapshot 与 TCP replay
券商前置 / 行情中台 接入交易所原生协议 + 对外统一归一化(FIX/WebSocket),按业务分层转发
加密现货 / 衍生品交易所 WebSocket JSON 主通道 + REST snapshot + 机构 FIX 通道,checksum 必备
量化策略消费端 本地维护 MBP + MBO;序列号严格校验;gap → 全量 resync,不容忍”容忍”
历史 tick 存储 热:kdb+(预算充足)或 ClickHouse(开源优先);冷:Parquet + S3/OSS
回测平台 事件时间驱动 + 撮合模拟 + Latency Model,三者缺一不可
跨地域分发 核心市场 Colo 接入 + 微波/光纤跨城 + CDN 分发给终端

12.2 落地清单

生产环境上线行情系统前,至少核对以下 20 项:

  1. 序列号全局单调,跨进程重启可恢复,NVM/SSD 持久化;
  2. 增量通道 A/B 双组播,默认开启去重;
  3. 快照通道周期 ≤ 5 秒,按 symbol 分片轮发;
  4. TCP 重传通道鉴权与流控,单客户端限速;
  5. 客户端 IGMP 退订逻辑经过故障演练;
  6. 端到端延迟(发布→订阅)监控 P50/P99/P999,告警阈值分档;
  7. 价格字段全部定点整数;
  8. 时间戳统一纳秒 + 单调时钟双轨;
  9. 服务端 PTP 同步,所有机器时钟偏差 < 1μs;
  10. 生产端丢包率监控,策略客户端 gap 次数告警;
  11. Conflation 与 Full Tick 两套频道,按订阅权限控制;
  12. 压力测试覆盖:日内峰值 ×3、长时间(4 小时)满载、突发 burst;
  13. 失败演练:交换机端口 down、冷启动 resync、时钟跳变、网卡丢包注入;
  14. 协议版本兼容性:老版本至少并行发布 6 个月;
  15. 合规存档:WORM 存储、保留年限符合监管要求;
  16. 监管报送链路独立部署,不影响主分发;
  17. 历史 tick 库按日分区,每日自动归档到冷存储;
  18. 行情停发检测 heartbeat + 自动冻结下游策略;
  19. 客户端 SDK 覆盖 Java / C++ / Go / Python,所有 SDK 共享同一套协议测试集;
  20. 文档(协议、订阅方法、示例代码、故障自诊断清单)对公,版本一致。

十三、真实事件与教训

13.1 2015-07-08 NYSE 停摆 3.5 小时

纽交所在当天上午因前一晚发布的行情软件升级配置与客户端协议不一致,导致大面积断连。Pillar 平台上线后的消费者反复触发 snapshot resync 使压力级联到内部总线,最终交易所宣布停止全部股票交易接近 3.5 小时。教训:协议升级必须有”开关”可以回退到上一版本;客户端必须限制 resync 频率防止风暴。

13.2 2020-10-01 东京证交所(TSE)全天停牌

原因是共享硬盘某个存储板的自动切换失败,行情系统启动时无法拉到前一天收盘状态,所有上市股票交易中断整整一天。这是日本自 1999 年全电子化以来最严重事故。教训:存储冗余的failover 本身必须演练,行情生产端的”冷启动对初态的依赖”是隐藏风险源。

13.3 国内 Level-2 “逐笔委托泄漏”争议

2020–2021 年间国内多家券商因 Level-2 数据重分发权限模糊,出现 “散户用户看到专业级 MBO”引发的合规核查。核心问题是行情权限的技术层与合规层没有对齐:协议上能分发,许可上不允许。教训:行情系统必须在生产端(而不是客户端)根据订阅合同裁剪字段,客户端不可被信任。

13.4 2021-05-19 Binance 极端行情 WS 断流

加密市场当日多头大规模爆仓,Binance 现货 / 合约 WebSocket 因后端 Kafka 队列堆积对大量慢消费者主动断开,部分做市商被迫只能 REST 轮询 /depth 导致滑点放大。教训:公开 API 的背压策略必须写进 SLA 文档;机构级客户应走独立专线而非公共 WebSocket。

13.5 2010-05-06 美股 Flash Crash

虽然根因在大单 + 算法 + 流动性撤退的共振,但 SEC 调查报告指出,多个做市商在事故期间停订阅主行情转用备用慢行情,进一步抽走了流动性。教训:行情系统的可用性和资本市场稳定性直接挂钩,不是单个交易所的内部问题。


十四、与本系列其他文章的交叉引用

  • 第 15 篇《交易所核心系统架构》:行情系统在五大子系统中的位置,以及与订单网关、清算的边界;
  • 第 16 篇《撮合引擎实现》:行情的源头是撮合引擎发出的事件流,理解 MBO 必须先理解 Order Book 内部;
  • 第 18 篇《证券登记结算》:行情里的成交最终进入登记结算机构完成 DvP;
  • 第 19 篇《实时风控引擎》:风控对行情的消费模式和 stale price 处理;
  • 第 24 篇《金融级可靠性》:行情系统的两地三中心、切换演练与 RPO/RTO 目标。

参考资料


上一篇《撮合引擎实现:撮合算法、价格优先时间优先、状态机、低延迟工程》

下一篇《证券登记结算:中证登、DTCC、Euroclear、T+1、DvP》

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-22 · architecture / fintech

【金融科技工程】交易所核心系统架构:撮合、行情、做市、风控、清算

从订单网关到撮合引擎、从 L1/L2/L3 行情到清算与结算,系统梳理证券、期货、加密货币交易所的五大核心子系统;给出低延迟工程技术栈(Disruptor、Kernel Bypass、FPGA)、订单生命周期状态机、主流交易所(NYSE Pillar、Nasdaq INET、上交所新一代、CME Globex、Binance、dYdX v4)对比、以及 Flash Crash 与 Knight Capital 的工程教训。

2026-04-22 · architecture / fintech

【金融科技工程】金融科技工程全景:从支付到交易所的系统分类与读图

金融科技(FinTech)不是普通后端加一张账户表。钱的原子性、监管的硬边界、一个小数点的代价,把这个领域推进到工程强度最高的那一档。本文是【金融科技工程】25 篇的总目录与阅读地图:先交代为什么它比一般业务系统更难,再给出对账体、支付体、交易体、风控合规体四维分类,把后续 24 篇挂到骨架上,最后给出一份绿地项目的落地顺序建议。

2026-04-22 · architecture / fintech

【金融科技工程】复式记账工程化:科目、分录、余额、对账

把 500 年历史的复式记账翻译成工程师可以落地的数据模型、SQL 表结构与余额计算策略,覆盖充值、下单、退款、分润、红包、多币种与冲销的真实场景,并对比 TigerBeetle、beancount、Ledger CLI、Square LedgerDB、Stripe Ledger 等开源与工业实现。


By .