解析 B3 交易所 Binary UMDF 行情:从 pcap 到订单簿
当你接入 B3 交易所(Brasil Bolsa Balcão)的实时行情时,数据以 Binary UMDF 格式通过 UDP 组播送达——这是一种基于 Simple Binary Encoding (SBE) 的二进制协议。没有 JSON,没有 FIX 标签,没有分隔符。只有线上的字节流,按照 XML schema 定义的结构排列。
本文完整讲解解码流水线:从抓取 pcap 文件到构建实时 MBO(Market By Order)订单簿。每个协议层都有解释,每个结构体映射都有展示,每个陷阱都有提醒。
协议栈
B3 Binary UMDF 数据包是分层结构。每个 UDP 载荷包含一个包头部,后面跟着一个或多个成帧消息。每个消息又包含 SBE 头部和消息体,消息体的布局由 template_id 决定。
┌──────────────────────────────────────────┐
│ UDP 载荷 │
│ ┌────────────────────────────────────┐ │
│ │ Packet Header (12B) │ │
│ ├────────────────────────────────────┤ │
│ │ Framing Header (4B) │ │
│ ├────────────────────────────────────┤ │
│ │ SBE Message Header (8B) │ │
│ ├────────────────────────────────────┤ │
│ │ Message Body (变长) │ │
│ ├────────────────────────────────────┤ │
│ │ Repeating Groups (变长) │ │
│ ├────────────────────────────────────┤ │
│ │ Variable-Length Data │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘
一个 UDP 包可以包含多个成帧消息。接收端循环遍历载荷,读取成帧头部并根据 template_id 分发,直到载荷耗尽。
第一层:Packet Header
每个 Binary UMDF 包以 12 字节的包头开始:
struct packet_header {
uint8_t channel_id; // 通道标识符(如 84、90)
uint8_t reserved; // 始终为 0
uint16_t sequence_version; // 通道重置时递增
uint32_t sequence_number; // 每个通道单调递增
uint64_t sending_time; // 纳秒级时间戳(PTP 同步)
};
关键字段:
channel_id:B3 为不同的合约组分配不同通道。行情处理器必须按通道 ID 过滤,避免处理无关数据。sequence_number:用于丢包检测。如果sequence_number跳跃,说明丢包,需要发起恢复。sending_time:这是 T11 时间戳——撮合引擎将包发布到 UDP 组播之前的瞬间,通过 PTP 同步到 B3 的三级原子钟,精度在亚微秒级。
sending_time 对延迟测量至关重要。自 2024 年 10 月起,其分辨率为纳秒,通过 PTP 同步到 B3 的三级原子主钟,偏差标准差在一微秒以内。
第二层:Framing Header
包头之后,每条消息被 4 字节的成帧头部包裹:
struct framing_header {
uint16_t message_length; // 总长度(含本头部)
uint16_t encoding_type; // SBE 必须为 0xEB50
};
encoding_type 是一个完整性校验。如果不是 0xEB50,说明该消息不是 SBE 编码的,应跳过。这可以防止损坏的包或协议版本不匹配。
message_length 告诉你在下一个成帧头部之前需要消耗多少字节——这是在同一包内遍历多条消息的方式。
第三层:SBE 消息头
每个消息体以 8 字节的 SBE 头开始:
struct sbe_message_header {
uint16_t block_length; // 消息体固定部分的长度
uint16_t template_id; // 消息类型标识符
uint16_t schema_id; // Schema 标识符(给定版本内为常量)
uint16_t schema_version; // Schema 版本号
};
template_id 是分发键,告诉你在后续字节上叠加哪个结构体。以下是订单簿构建最重要的模板 ID:
| Template ID | 名称 | 用途 |
|---|---|---|
| 2 | Sequence | 下一个期望的序列号 |
| 3 | SecurityStatus | 单个合约的交易状态 |
| 4 | SecurityDefinition(已废弃) | 合约定义(v1.6) |
| 9 | EmptyBook | 清空某合约的整个订单簿 |
| 10 | SecurityGroupPhase | 合约组的交易阶段 |
| 11 | ChannelReset | 重置通道的所有状态 |
| 12 | SecurityDefinition | 合约定义(v1.8+) |
| 30 | SnapshotFullRefresh_Header | 快照头部及订单簿状态 |
| 50 | Order_MBO | 新增或修改订单簿中的订单 |
| 51 | DeleteOrder_MBO | 删除订单簿中的单个订单 |
| 52 | MassDeleteOrder_MBO | 从某位置删除订单(DELETE_THRU/FROM) |
| 53 | Trade | 成交回报 |
| 54 | ForwardTrade | 远期成交回报 |
| 55 | ExecutionSummary | 主动方成交摘要 |
| 56 | ExecutionStatistics | VWAP、成交量统计 |
| 71 | SnapshotFullRefresh_Orders_MBO | 完整订单簿快照(MBO) |
block_length 告诉你固定部分的字节数。读完 block_length 字节后,可能遇到重复组或变长数据(由 XML schema 描述)。
三阶段数据恢复
B3 行情通过三个独立的 UDP 流发送,必须按顺序处理:
阶段 1:InstrumentDefinition → 获取所有合约元数据
阶段 2:SnapshotRecovery → 获取完整订单簿状态
阶段 3:Incremental → 应用实时增量更新
阶段 1:InstrumentDefinition
这个流发送 SecurityDefinition 消息(v1.8+ 中为 template 12),描述所有可交易合约:
struct SecurityDefinition_12 {
SecurityID securityID; // 唯一合约标识符
SecurityExchange securityExchange; // "BVMF"
Symbol symbol; // 如 "PETR4"
PriceOptional minPriceIncrement; // 最小变动价位(原始 int64)
Fixed8 contractMultiplier; // 合约乘数
uint8_t tickSizeDenominator; // 价格转换的指数
Currency currency; // "BRL"
// ... 还有 30+ 个字段
};
代码将这些收集到一个 map 中:
std::map<uint64_t, PH_SecurityDefinition_12*> map_ph_securityDefinition;
totNoRelatedSym 字段告诉你期望收到多少个合约。行情处理器会等到所有定义都到达后才进入阶段 2。
阶段 2:SnapshotRecovery
这个流通过 SnapshotFullRefresh_Header_30 及其重复组 noMDEntries(template 71)提供完整订单簿状态:
struct SnapshotFullRefresh_Header_30 {
uint64_t securityID;
uint32_t lastMsgSeqNumProcessed; // 丢包检测的关键字段
uint32_t totNumReports;
uint32_t totNumBids;
uint32_t totNumOffers;
uint16_t totNumStats;
uint32_t lastRptSeq;
};
struct noMDEntries {
PriceOptional mDEntryPx;
int64_t mDEntrySize;
uint32_t mDEntryPositionNo; // 在订单簿中的位置
uint32_t enteringFirm; // 经纪商 ID
UTCTimestampNanos mDInsertTimestamp;
uint64_t secondaryOrderID; // 订单 ID
char mDEntryType; // '0' = 买, '1' = 卖
};
lastMsgSeqNumProcessed 字段至关重要——它告诉你快照中反映了增量流的最后一个序列号。切换到增量流时,必须跳过所有 sequence_number <= lastMsgSeqNumProcessed 的消息。
阶段 3:Incremental
这是实时流。消息高速到达:
Order_MBO(template 50):新增或修改订单DeleteOrder_MBO(template 51):删除单个订单MassDeleteOrder_MBO(template 52):删除一个范围的订单Trade(template 53):成交回报ExecutionSummary(template 55):主动方信息
行情处理器必须验证 rptSeq 的连续性来检测丢包:
if (p_securityStatus_3->rptSeq - map_securityID_rptSeq[p_securityStatus_3->securityID] != 1) {
printf("丢包!securityID[%ld] rptSeq[%d]\n", ...);
}
SBE 编码:零拷贝解码
Simple Binary Encoding (SBE) 的设计目标是零拷贝解码。与 FIX 或 JSON 不同,你不需要解析——只需在原始字节上叠加结构体:
// 零拷贝:直接转换指针
p_order_MBO_50 = (Order_MBO_50*)malloc(sizeof(Order_MBO_50));
memcpy(p_order_MBO_50, (uint8_t*)pkt_data + offset, p_sbe_hd->block_length);
SBE 头中的 block_length 告诉你精确拷贝多少字节。没有字符串解析,没有标签-值查找,没有变长分隔符扫描。这就是为什么 SBE 是 CME、Eurex、B3 等低延迟交易所的首选编码。
大端字节序
SBE 对所有多字节字段使用大端序(网络字节序)。在 x86(小端序)上,必须交换字节:
static inline uint16_t read_uint16(uint8_t* buf) {
return (buf[0] << 8) + buf[1];
}
static inline uint32_t read_uint32(uint8_t* buf) {
return (buf[0] << 24) + (buf[1] << 16) + (buf[2] << 8) + buf[3];
}
但对于固定大小的 SBE 消息体,如果结构体声明使用了 #pragma pack(1),可以直接 memcpy——字节交换在字段访问时进行,而不是在拷贝时。在生产代码中,应使用 ntohs()/ntohl() 或编译器内建函数以获得更好性能。
重复组
固定长度消息体之后,SBE 使用重复组处理变长数据。每个组以 GroupSizeEncoding 开头:
struct GroupSizeEncoding {
uint16_t blockLength; // 每个组条目的大小
uint8_t numInGroup; // 条目数量
};
例如,SecurityDefinition_12 有三个重复组:noUnderlyings_12、noLegs_12 和 noInstrAttribs_12。解码器读取 GroupSizeEncoding,然后循环 numInGroup 次,每次读取 blockLength 字节。
变长数据
重复组之后可能有变长字段(如 SecurityDefinition 中的 securityDesc)。这些以长度前缀字节开头:
uint8_t securityDesc_len = *(uint8_t*)(pkt_data + offset);
offset += 1;
if (securityDesc_len > 0) {
char securityDesc[256];
memcpy(securityDesc, (uint8_t*)pkt_data + offset, securityDesc_len);
offset += securityDesc_len;
}
价格精度:指数模型
B3 将价格编码为原始 int64 值。实际价格通过除以一个取决于合约的指数来计算:
#define EXPONENT_4 10000 // 4 位小数
#define EXPONENT_7 10000000 // 7 位小数
#define EXPONENT_8 100000000 // 8 位小数
int64_t data_exponent(int64_t int_data, uint32_t exponent) {
if (int_data == 0x8000000000000000) return 0; // 空值
return int_data / exponent;
}
给定合约的指数由 SecurityDefinition_12 中的 tickSizeDenominator 字段决定。例如:
- 股票期权:
EXPONENT_4(价格 = BRL × 10000) - 迷你指数期货:
EXPONENT_8(价格 = 点数 × 100000000)
空值约定
SBE 使用哨兵值表示空/可选字段:
const int64_t null_value_int64 = 0x8000000000000000; // INT64_MIN
任何具有此值的字段应视为”不存在”。data_exponent() 函数对空值返回 0。
MBO 订单簿构建
B3 使用 Market By Order (MBO) 模式,订单簿中的每个订单通过 secondaryOrderID 和 mDEntryPositionNo 单独标识。这与许多亚洲交易所使用的 Market By Price (MBP) 模式有本质区别。
B3 与价格优先交易所的区别
一个关键区别:B3 按时间优先组织订单簿,而不是价格优先。 这意味着:
- B3:同一价格的订单按进入时间排序(FIFO)
- 中国交易所(如上交所、深交所):同一价格的订单按价格-时间优先排列,最优价格被聚合
这对 mDEntryPositionNo 有影响——它代表在时间排序簿中的绝对位置,而不是价格档位。
五种订单簿操作
sfr_order_book 类实现了对应于 MDUpdateAction 的五种操作:
1. NEW (0) — 插入订单
void insert_entries(char entryType, int64_t entryPx, int64_t entrySize,
uint32_t entryPositionNo) {
price_volume* pv = (price_volume*)malloc(sizeof(price_volume));
pv->PositionNo = entryPositionNo;
pv->Price = entryPx;
pv->Volume = entrySize;
// 在指定位置插入
auto iter = list_bid_orders.begin();
std::advance(iter, entryPositionNo - 1);
list_bid_orders.insert(iter, pv);
// 递增所有后续订单的位置号
for (iter; iter != list_bid_orders.end(); iter++) {
(*iter)->PositionNo += 1;
}
}
在位置 N 插入新订单时,所有位置 ≥ N 的订单的 PositionNo 必须加 1。这是与 MBP 的关键区别——在列表中间插入会移动后面所有元素。
2. CHANGE (1) — 修改订单
void change_entries(char entryType, int64_t entryPx, int64_t entrySize,
uint32_t entryPositionNo) {
// 找到该位置的订单并更新价格/数量
for (auto iter = list_bid_orders.begin(); iter != list_bid_orders.end(); iter++) {
if ((*iter)->PositionNo == entryPositionNo) {
(*iter)->Price = entryPx;
(*iter)->Volume = entrySize;
return;
}
}
}
修改不会改变位置号。但如果修改改变了订单优先级(如价格改善),B3 会发送 DELETE + NEW 对而不是 CHANGE。
3. DELETE (2) — 删除单个订单
void delete_entries(char entryType, uint32_t entryPositionNo) {
// 删除该位置的订单
auto iter = list_bid_orders.begin();
std::advance(iter, entryPositionNo - 1);
list_bid_orders.erase(iter);
// 递减所有后续订单的位置号
for (auto iter = list_bid_orders.begin(); iter != list_bid_orders.end(); iter++) {
if ((*iter)->PositionNo > entryPositionNo) {
(*iter)->PositionNo -= 1;
}
}
}
插入的镜像操作:删除位置 N 后,所有后续位置向下移动 1。
4. DELETE_THRU (3) — 清空一侧的所有订单
void delete_thru_entries(char entryType) {
// 清空整个买方或卖方
for (auto iter = list_bid_orders.begin(); iter != list_bid_orders.end(); iter++) {
free(*iter);
}
list_bid_orders.clear();
}
通常在通道重置或交易阶段切换时发送。
5. DELETE_FROM (4) — 从某位置删除到末尾
MassDeleteOrder_MBO_52 消息的 mDUpdateAction=4 表示删除从给定位置到订单簿末尾的所有订单。当合约进入某个使挂单失效的状态时使用。
快照初始化
处理 SnapshotFullRefresh_Orders_MBO_71 时,订单簿从 noMDEntries 重复组初始化:
void init_entries(noMDEntries* md) {
price_volume* pv = (price_volume*)malloc(sizeof(price_volume));
pv->PositionNo = md->mDEntryPositionNo;
pv->Price = md->mDEntryPx;
pv->Volume = md->mDEntrySize;
if (md->mDEntryType == '0') {
list_bid_orders.emplace_back(pv);
} else if (md->mDEntryType == '1') {
list_ask_orders.emplace_back(pv);
}
}
注意:'0' = 买,'1' = 卖。这些是 FIX tag 269 的值。
时间戳:PUMA 时间模型
B3 的 PUMA 交易系统提供多个时间戳字段,每个在交易生命周期的不同时刻捕获。理解这些对延迟分析至关重要。
时间戳测量点
| 测量点 | 字段 | 捕获者 | 描述 |
|---|---|---|---|
| T1 | InboundBusinessHeader.sendingTime | 客户端 | 客户端发送订单的时刻 |
| T2 | receivedTime | 网关 | 网关从 socket 收到订单的时刻 |
| T4 | marketSegmentReceivedTime | 撮合引擎 | 撮合引擎从内部总线收到的时刻 |
| T5 | transactTime / mDInsertTimestamp | 撮合引擎 | 事务发生的时刻 |
| T6 | OutboundBusinessHeader.sendingTime | 网关 | 网关将响应排入客户端 socket 的时刻 |
| T10 | mDEntryTimestamp | 撮合引擎 | 组装行情消息时(同包内所有消息相同) |
| T11 | packetHeader.sendingTime | 撮合引擎 | 即将发布到 UDP 组播之前的时刻 |
关键观察
-
T10 vs T11:
mDEntryTimestamp(T10)在撮合引擎组装行情消息时赋值,packetHeader.sendingTime(T11)在包即将发送到网络之前捕获。差值 T11 - T10 衡量了内部发布延迟。 -
T5 同时出现在私有和公有流中:私有 ExecutionReport 消息中的
transactTime和公有Order_MBO消息中的mDInsertTimestamp都携带 T5——撮合引擎处理事务的确切时刻。 -
aggressorTime:
ExecutionSummary消息(template 55)包含aggressorTime,等于主动方订单的 T4(撮合引擎收到主动订单的时刻)。这对于测量从下单到成交发布的往返延迟非常有价值。 -
PTP 同步:所有时间戳通过 PTP 同步到 B3 的三级原子钟,精度在亚微秒级,偏差标准差在一微秒以内。
pcap 处理流水线
示例代码分三个阶段处理离线 pcap 文件:
void binary_manager::run() {
// 阶段 1:解析 InstrumentDefinition pcap
pcap_t* fp_InstrumentDefinition = pcap_open_offline("MBO_090_InstrumentDefinition.pcap", errbuf);
pcap_loop(fp_InstrumentDefinition, 0, dispatcher_handler, (uint8_t*)this);
pcap_close(fp_InstrumentDefinition);
// 等待所有定义到达
while (!is_InstrumentDefinition_ready) { sleep(1); }
// 阶段 2:解析 SnapshotRecovery pcap
pcap_t* fp_SnapshotRecovery = pcap_open_offline("MBO_090_SnapshotRecovery.pcap", errbuf);
pcap_loop(fp_SnapshotRecovery, 0, dispatcher_handler, (uint8_t*)this);
pcap_close(fp_SnapshotRecovery);
// 等待快照完成
while (!is_snapshotFullRefreshHeader_ready) { sleep(1); }
// 阶段 3:解析 Incremental pcap
pcap_t* fp_Incremental = pcap_open_offline("MBO_090_Incremental.pcap", errbuf);
pcap_loop(fp_Incremental, 0, dispatcher_handler, (uint8_t*)this);
pcap_close(fp_Incremental);
}
网络层解析
调度回调逐层剥除网络头部:
auto dispatcher_handler = [](uint8_t* temp, const struct pcap_pkthdr* header,
const uint8_t* pkt_data) -> void {
binary_manager* pm = (binary_manager*)temp;
IPv4Header ipv4(pkt_data);
if (ipv4.Protocol == 0x11) { // UDP
UDPHeader udp(pkt_data, ipv4.IPv4Header_len);
pm->payload = udp.UDP_HeaderLen - 8; // 减去 UDP 头
pm->pkg_len = header->len;
pm->parse_package(pkt_data, udp.UDPHeader_len);
}
};
IPv4Header 和 UDPHeader 类处理 Ethernet → IP → UDP 解析,提取 Binary UMDF 数据开始的载荷偏移。
通道 ID 过滤
parse_package 方法立即检查通道 ID:
if (p_pkt_hd->channel_id != m_channel_id) {
printf("error channel[%d], ignore\n", p_pkt_hd->channel_id);
return;
}
这很关键,因为 B3 为不同合约组分配不同通道。在单线程上处理所有通道会混淆不相关的订单簿。
输出格式
示例程序产生三种输出:
1. SBE 消息 CSV
每个解析后的 SBE 消息写入模板特定的 CSV 文件。例如 ph_order_MBO_50.csv:
channel_id,sequence_version,sequence_number,securityID,matchEventIndicator,
mDUpdateAction,mDEntryType,padding,mDEntryPx,mDEntrySize,mDEntryPositionNo,
enteringFirm,mDInsertTimestamp,secondaryOrderID,rptSeq,mDEntryTimestamp
2. Tick 级行情数据
合并的 tick 文件,包含 10 档行情:
updateTime,updateNano,securityID,Symbol,lastPrice,tradeVolume,
bid_px1,bid_sz1,...,bid_px10,bid_sz10,
ask_px1,ask_sz1,...,ask_px10,ask_sz10
3. 订单簿快照
每个合约的完整 MBO 订单簿:
updateTime,updateNano,secondaryOrderID,enteringFirm,securityID,Symbol,
lastMsgSeqNumProcessed,totNumBids,totNumOffers,lastPrice,tradeVolume,
highLimitPrice,lowLimitPrice
常见陷阱
1. SBE 版本不匹配
B3 在协议版本 1.6 和 1.8 之间从 SecurityDefinition_4(template 4)升级到 SecurityDefinition_12(template 12)。解码器必须同时处理两者。SBE 头中的 schema_version 字段告诉你应期望哪个版本。readme 中也提到:“2024 年 2 月之前的 SBE 格式暂不支持。“
2. 位置号维护
mDEntryPositionNo 不是简单的数组索引。在位置 N 插入会将所有位置 ≥ N 的订单上移 1。删除位置 N 会将所有位置 > N 的订单下移 1。如果把它当作简单数组索引处理,订单簿会静默损坏。
3. 序列号间隙
检测到间隙(sequence_number 跳跃)后,不能简单地继续处理。必须:
- 记录间隙
- 请求快照恢复
- 从快照重建订单簿
- 从
lastMsgSeqNumProcessed + 1恢复增量处理
4. 大端序混淆
#pragma pack(1) 结构体与 memcpy 配合用于消息体,但包头部和成帧头部中的多字节字段是大端序。代码对这些使用 read_uint16()/read_uint32(),但对 SBE 消息体使用原始 memcpy。搞混这些是数据乱码的常见原因。
5. 空值处理
哨兵值 0x8000000000000000 是一个有效的 int64 位模式,代表”不存在”。如果在转换价格前忘记检查空值,会得到垃圾数字。
6. DELETE_THRU vs DELETE_FROM
MassDeleteOrder_MBO_52 的 mDUpdateAction=3(DELETE_THRU)清空一侧的所有订单。mDUpdateAction=4(DELETE_FROM)清空从某位置到末尾的订单。混淆这两者会清除订单簿的错误部分。
SBE 与其他金融编码格式对比
| 特性 | SBE | FIX/FAST | Protobuf | JSON |
|---|---|---|---|---|
| 解码 | 零拷贝(叠加结构体) | 基于 token | 需要解析 | 文本解析 |
| 延迟 | ~100 ns | ~1 μs | ~5 μs | ~50 μs |
| Schema | XML → 代码生成 | XML 字典 | .proto | 无 |
| 字段访问 | 直接偏移 | 标签查找 | 字段编号 | 键查找 |
| 线上大小 | 紧凑(无标签) | 紧凑(增量编码) | Varint 编码 | 冗长 |
| 使用者 | CME、Eurex、B3 | CME(旧版)、Euronext | 内部系统 | Web API |
SBE 的零拷贝特性是其对交易系统的杀手锏。XML schema(b3-market-data-messages-1.8.0.xml)可以与 real-logic SBE 工具配合自动生成 C++、Java 或 C# 编解码器。
快速参考:完整解码循环
void binary_manager::parse_package(const uint8_t* pkt_data, uint32_t offset) {
// 1. 读取包头
memcpy(p_pkt_hd, pkt_data + offset, sizeof(packet_header));
offset += sizeof(packet_header);
// 2. 按通道过滤
if (p_pkt_hd->channel_id != m_channel_id) return;
// 3. 循环处理包内所有消息
while (offset < pkg_len) {
// 3a. 读取成帧头部
memcpy(p_frm_hd, pkt_data + offset, sizeof(framing_header));
offset += sizeof(framing_header);
// 3b. 读取 SBE 头
memcpy(p_sbe_hd, pkt_data + offset, sizeof(sbe_message_header));
offset += sizeof(sbe_message_header);
// 3c. 验证编码类型
if (0xEB50 != p_frm_hd->encoding_type) return;
// 3d. 按 template_id 分发
switch (p_sbe_hd->template_id) {
case 50: // Order_MBO
memcpy(p_order_MBO_50, pkt_data + offset, p_sbe_hd->block_length);
// 处理插入/修改...
break;
case 51: // DeleteOrder_MBO
memcpy(p_deleteOrder_MBO_51, pkt_data + offset, p_sbe_hd->block_length);
// 处理删除...
break;
// ... 其他模板
}
offset += p_sbe_hd->block_length;
// ... 处理重复组和变长数据
}
}
总结
解析 B3 Binary UMDF 行情是一个系统化的过程:
- 捕获 UDP 组播包(pcap 或实时流)
- 解析 网络头部(Ethernet → IPv4 → UDP)
- 解码 Binary UMDF 包头(通道 ID、序列号、T11 时间戳)
- 迭代 使用成帧头部遍历消息
- 分发 按 SBE
template_id到正确的结构体 - 转换 使用指数模型和空值检查转换价格
- 构建 MBO 订单簿,使用五种操作(NEW/CHANGE/DELETE/DELETE_THRU/DELETE_FROM)
- 维护 正确的
mDEntryPositionNo(插入上移,删除下移) - 检测 通过
sequence_number和rptSeq连续性检测丢包 - 恢复 使用三阶段协议从丢包中恢复(InstrumentDefinition → SnapshotRecovery → Incremental)
SBE 编码使第 5 步几乎免费——你在原始字节上叠加 C 结构体,无需解析开销。真正的难点在于正确实现订单簿逻辑,特别是 PositionNo 维护和三阶段恢复序列。