加密货币交易所九:市场数据系统

客户端拿到的订单簿是自己拼出来的:先拉一份快照,再把增量按序列号贴上去,中间漏一条就得整个重来。这篇讲这套快照加增量的对接协议、指数价格和标记价格为什么是两条链路,以及深度聚合把什么信息扔掉了。

位置
第 09 篇 / 共 12 篇
预计
12 分钟

交易所不会把整本订单簿一直推给你。它推的是变化,而完整的那本书在你自己的内存里 —— 客户端手上的订单簿是它自己拼出来的,交易所只负责保证「按顺序贴完这些补丁,你手上的和我这边一模一样」。

这句话是整个市场数据系统的形状:一份快照定基准,之后全是增量,中间靠序列号连起来。理解它之后,「为什么我的深度图和网页上的对不上」这类问题就有了唯一的答案 —— 你漏了一条增量,而且没发现。

快照加增量,服务端要担保的是三件事

客户端那一侧怎么把快照和增量拼起来,第四篇 已经把 Binance 合约深度流的五个步骤逐条走过了。这里换个方向:要让那五步成立,出数据的这一侧必须先保证什么。

第一,每个交易对一条严格递增、不跳号的更新序列。 客户端判断丢包靠的是「这条消息接不接得上上一条」,一旦序列号会跳号,「没变化」和「丢了包」就分不出来了。这条全序只能来自撮合引擎那条单线程 —— 序列号必须在撮合的那一刻分配,不能由后面的推送服务补发。推送服务是多实例的,多实例分配不出全序。

第二,快照必须是序列上某个真实存在的点的状态。 「大概是这个时候的」不行。所以深度快照通常是从撮合引擎那份内存订单簿上原子拷出来的,附带拷贝那一刻的序列号,而不是去数据库里查一遍拼出来 —— 数据库里那份是异步落的,它和序列号对不上。

第三,增量给绝对数量,不给增减量。 这个选择值得单独说:给增减量的话,客户端漏一条消息,那个价位就被永久污染,之后每一条增量都在错误的基数上继续加;给绝对数量,同一个价位下次再更新时会被直接覆盖成正确值,错误有机会自愈。代价是消息稍微大一点,换来的是错误不累积。

即便如此,自愈也只是「有机会」。漏掉的那条消息涉及的价位,要等它下次再变才会被纠正,而冷门价位可能几个小时都不变一次。程序不会崩,图表照常刷新,只是数字是错的,而且错得没有规律 —— 这类 bug 通常要等到有人拿它下单亏了钱才被发现。

所以整套协议的设计取向很明确:宁可整本重来,不要局部修补。 重拉一次快照的成本是几十 KB 和一次往返,猜错一条增量的成本是无限期的静默错误。服务端能做的是把重来这件事做得足够便宜 —— 快照接口要快、要能扛住一批客户端同时重连,因为丢包往往是相关的:一次网络抖动会让一整片客户端在同一秒里全来重拉快照。这个惊群才是深度快照接口真正的容量约束,不是稳态查询量。

指数价格和标记价格是两条链路,不是一个数的两种叫法

这两个价格经常被混用,但它们的来源、用途和更新节奏都不一样。

指数价格 标记价格
来自 多个现货交易所的成交价,加权 以指数价格为基准,再做平滑
反映 现货市场现在的价 这个合约「应该」值多少
用来 算标记价格、算资金费率 算未实现盈亏、判定强平
怕的是 某个成分交易所被操纵或掉线 跟得太紧(挡不住插针)或太慢(该平的没平)

指数价格的工程难点全在成分源的可靠性上。任何一家成分交易所掉线、停止提现导致价格脱锚、或者被人在薄盘上砸出一根针,都会顺着加权公式传进来。常见的挡法有几层:按成交量分配权重、单个源偏离中位数超过阈值就临时剔除、剩余源不足最低数量就冻结指数不再更新。这些规则本身是公开的,因为它们直接决定了用户的强平价。

标记价格在指数价格之上再加一层平滑,目的只有一个:让强平判定不跟着本交易所盘口上的瞬时波动走。如果直接用最新成交价判强平,一笔金额不大的市价单就能在薄的时段把价格打穿一截,顺带清掉一批本来健康的仓位。

Binance 把这件事写得很具体,值得照抄一遍结构(USDⓈ-M 永续):

标记价格 = 三个数的中位数
标记价格 = Median(价格一, 价格二, 合约最新成交价)
价格一 = 指数价格 × (1 + 上期资金费率 × 距下次结算的时间 ÷ 资金周期)
价格二 = 指数价格 + 最近 2.5 分钟基差的移动平均
基差每 5 秒采一个点,取 (买一 + 卖一) ÷ 2 − 指数价格,30 个点求平均

取中位数这一步是整个设计的关键。 三个输入里,价格一来自指数和资金费率,价格二来自本交易所的盘口,第三个直接就是最新成交价。想把标记价格推到某个位置,你得同时推动三个里的至少两个 —— 只砸盘口,中位数会落在另外两个上;只操纵某个成分交易所,另外两个也会把它挤掉。单点操纵在结构上被排除了,这比「加一层平滑」有力得多。

价格二里那个移动平均容易被记成「30 分钟的成交价均线」,两处都不对:它取的是基差(买一卖一中间价减指数价格)不是成交价,窗口是 2.5 分钟不是 30 分钟。窗口长度直接决定标记价格跟不跟得上行情,记错一个数量级,对强平时机的判断就整个偏了。这一条和链上永续面对的是同一个问题 —— Perpetual Protocol 三:ClearingHouse 里的做法是,当 vAMM 报告自己偏离预言机太多时,用预言机价格再算一遍保证金率,取对交易者更有利的那个。

指数价格和标记价格是两条链路:指数价格一路不经加工直接进广播层,另一路参与价格一(指数价格按资金费率摊销)和价格二(指数价格加基差移动平均)的计算,再和价格三(本所合约的最新成交价)一起取中位数得到标记价格,标记价格也进广播层 指数价格和标记价格是两条链路:指数价格一路不经加工直接进广播层,另一路参与价格一(指数价格按资金费率摊销)和价格二(指数价格加基差移动平均)的计算,再和价格三(本所合约的最新成交价)一起取中位数得到标记价格,标记价格也进广播层

图里值得注意的是两条链路都直接连到广播层。指数价格不是标记价格的中间产物,它自己就是用户要看的数据,也是外部做市商校准的基准,所以它单独推一份。

深度聚合扔掉的是价格的小数位

网页上的深度图不会把每一个价位都画出来。BTC 永续的报价精度细到一毛钱,一万美元的区间就是十万个价位,全画出来是一条毛线。所以要聚合:按 0.1 / 1 / 10 / 100 这样的档距把相邻价位并成一档,量相加。

有一件事值得先说清楚,因为它和很多人的印象相反:这个聚合是客户端自己做的。 交易所的深度接口只提供档位数量(5 档、20 档、1000 档)和推送频率的选择,没有任何一个参数叫「聚合精度」—— 网页上那个下拉框是前端在拿到原始档位之后自己合并的。所以你用 API 拿数据时,不要去找一个并不存在的参数,直接自己合。

聚合的方向不能搞反,这是个容易出错的细节:

档距 = 1,买卖两侧的归并方向相反
买单 50000.7 50000.3 50000.1 → 并进 50000 (向下取整)
卖单 50001.2 50001.6 50001.9 → 并进 50002 (向上取整)

买单向下、卖单向上,也就是都往对自己不利的方向归。反过来做的话,聚合后的最优买价会高于真实最优买价,用户看着这个价挂单会一直挂不上。同样的道理,聚合后的买一和卖一之间的价差只会被撑大,不会被缩小 —— 显示出来的市场比真实的市场更差一点,这是安全的方向。

聚合是有损的,扔掉的正是价格的低位小数。所以任何要精确计算的场景(下单前算滑点、做市商的报价决策、回测)都不能用聚合后的深度。这一条在回测里尤其容易翻车 —— 存下来的历史数据如果是从界面上抓的,它已经被合并过了,拿它算出来的成交价永远比真实的好看。

增量流之外,还有一条按时间切片的流

订单簿是「现在长什么样」,K 线是「过去一段时间发生了什么」,两者的数据结构和存储方式完全不同。

K 线的关键性质是可以从小周期合并出大周期:四根 15 分钟合成一根 1 小时,开盘价取第一根的开、收盘价取最后一根的收、最高取四根最高的最大值、最低取最小值、成交量求和。所以后端只需要落地最细的那个周期,其余全部由它聚出来。

四根 15 分钟合成一根 1 小时
open = 第一根的 open
close = 最后一根的 close
high = max(四根的 high)
low = min(四根的 low)
volume = sum(四根的 volume)

这个性质有一个前提经常被忽略:必须先对齐时间边界。1 小时线的起点是整点,不是「最近 60 分钟」。用滑动窗口去算 1 小时线,算出来的值和图表上的对不上,而且没法缓存 —— 每一秒都是一根新的线。

另一个陷阱是没有成交的那段时间。半夜的小币种可能十分钟一笔成交都没有,这时候要不要产出 K 线?产出的话,开高低收都等于上一根的收盘价、成交量为 0;不产出的话,图表上会出现一个洞,而且客户端按索引算时间会全部错位。两种做法都有交易所在用,但必须在文档里写死,因为它决定了对接方要不要自己补洞。

历史数据分层,依据是访问频率不是数据类型

最近几天的数据被反复查,几年前的数据一个月被查一次,用同一套存储服务这两种负载一定有一边不划算。所以按访问频率切:最热的一层放内存,中间一层放时序数据库(时序库对「按时间范围扫一段」这种查询做了专门的优化,也带自动降采样),最冷的一层压缩成文件扔进对象存储。

分层的成本在跨层查询上。用户拉一段横跨冷热边界的历史,后端要从两个地方取、拼起来、还要保证边界上不重不漏。多数交易所的做法是干脆不让跨 —— API 上限制单次查询的时间跨度,把拼接的活推给调用方。这是个很实际的取舍:接口变难用一点,换掉后端一整类边界 bug。

这一层的代价

推送节流是拿实时性换带宽和渲染。 订单簿的真实变化频率远高于人眼能分辨的频率,也高于浏览器能重绘的频率,所以服务端会把一段时间内的变化合并成一条再推。代价是你看到的深度永远滞后一点,而这一点对做市商是有意义的 —— 所以他们付费买更高频的通道,这是市场数据商业化的起点。

聚合过的数据不能用来算钱。 前面说过一次,值得再说一次:拿聚合深度算出来的滑点是错的,而且方向不固定。这件事在回测里尤其容易出错,因为历史数据往往只存了聚合后的版本。

指数价格的成分交易所是一个外部依赖。 它们停机、被攻击、或者干脆倒闭,你的合约就失去了定价基准。剔除规则能挡住一部分,但成分源同时出问题时(比如某条链拥堵导致多家交易所同时脱锚),只能冻结指数并暂停强平 —— 这是一个必须提前想好、并且写进规则的降级路径。

K 线补洞与否是个不可逆的决定。 一旦有客户端按「每根 K 线对应固定时长」的假设写了代码,你就再也不能改这个行为了。

这篇是 加密货币交易所的第 9 篇。前一篇是 加密货币交易所八:账户系统,后一篇是 加密货币交易所十:订单处理流程