3. 订单簿快照与增量更新:Snapshot vs. Delta 机制

做市商系统里,订单簿的更新方式是个绕不开的话题。说白了,就是你怎么把交易所那一堆不断变化的挂单数据,高效地同步到你的本地系统里。

我个人习惯把这个问题拆成两个极端来看:全量快照(Snapshot)增量更新(Delta)。嗯,这俩就像“重新下载整张地图”和“只告诉你哪里修路了”的区别。

3.1 全量快照:简单粗暴,但代价高

全量快照,就是交易所每隔一段时间(比如每100毫秒),把当前订单簿上所有的买单和卖单,一股脑全发给你。

  • 优点:实现简单,你拿到数据直接覆盖本地订单簿就行,不用担心数据对不上。
  • 缺点:数据量大得吓人。一个深度稍好的币对,订单簿可能有几千甚至上万档价格。每次快照都传一遍,带宽和CPU都扛不住。

我在早期做回测系统时,就偷懒用过全量快照。结果呢?回测跑一次要等半天,光解析JSON就占了大半时间。后来我学乖了——全量快照只适合做“基准校准”,不适合做高频实时更新。

3.2 增量更新:高效,但容易“对不上账”

增量更新就聪明多了。交易所只告诉你:
“刚才有人撤单了,价格100.5的卖单数量从10变成5。”
“有人新挂了一个单,价格101.0,数量2。”
你只需要在本地订单簿上做对应的加减操作就行。

但这里有个坑——增量更新依赖一个正确的“初始状态”。如果你本地订单簿因为网络丢包、程序重启等原因,跟交易所对不上了,那后续所有增量更新都会“失之毫厘,谬以千里”。

⚠️ 我曾经踩过的坑:
有一次生产环境里,网络抖动导致我漏掉了一条增量消息。结果本地订单簿的卖一价跟交易所差了0.5个tick。我的做市策略以为价格要跌,疯狂挂低价卖单,差点被套利者吃掉。从那以后,我强制要求:每收到1000条增量,必须拉一次全量快照做校验

3.3 混合策略:Snapshot + Delta 的黄金组合

实际生产中,没人只用纯增量或纯快照。主流做法是:

  1. 启动时:先拉一次全量快照,建立基准。
  2. 运行中:只接收增量更新,维持本地订单簿。
  3. 定期校验:比如每5分钟或每1000条增量后,再拉一次快照,跟本地数据做比对。如果发现偏差,就丢弃本地数据,用新快照重建。

你想想看,这就像你开车时,导航每隔几分钟告诉你“前方路况有变”,但每半小时会重新规划一次全路径。既保证了实时性,又防止了累积误差。

3.4 代码实现:一个简单的订单簿类

下面是我自己写的一个简化版订单簿,支持快照和增量更新。代码不长,但核心逻辑都在里面了。

class OrderBook:
    def __init__(self):
        self.bids = {}  # 买单:价格 -> 数量
        self.asks = {}  # 卖单:价格 -> 数量
        self.sequence = 0  # 用于校验增量顺序

    def apply_snapshot(self, snapshot):
        """全量快照:直接覆盖"""
        self.bids = {item[0]: item[1] for item in snapshot['bids']}
        self.asks = {item[0]: item[1] for item in snapshot['asks']}
        self.sequence = snapshot['sequence']
        print(f"快照应用完成,买一:{self.get_best_bid()},卖一:{self.get_best_ask()}")

    def apply_delta(self, delta):
        """增量更新:逐条处理"""
        # 检查增量顺序,防止乱序
        if delta['sequence'] <= self.sequence:
            print(f"警告:收到过期增量,跳过 (seq {delta['sequence']} <= {self.sequence})")
            return

        # 处理买单增量
        for price, size in delta['bids']:
            if size == 0:
                # 数量为0表示删除该档位
                self.bids.pop(price, None)
            else:
                self.bids[price] = size

        # 处理卖单增量
        for price, size in delta['asks']:
            if size == 0:
                self.asks.pop(price, None)
            else:
                self.asks[price] = size

        self.sequence = delta['sequence']

    def get_best_bid(self):
        """获取最优买价"""
        if not self.bids:
            return None
        return max(self.bids.keys())

    def get_best_ask(self):
        """获取最优卖价"""
        if not self.asks:
            return None
        return min(self.asks.keys())

    def check_consistency(self, snapshot):
        """校验本地数据与快照是否一致"""
        local_bids = set(self.bids.items())
        snapshot_bids = set((item[0], item[1]) for item in snapshot['bids'])
        if local_bids != snapshot_bids:
            print("⚠️ 买单数据不一致!需要重建")
            return False
        return True
💡 个人经验:
代码里那个 sequence 字段很重要。我在对接某二线交易所时,他们的增量消息偶尔会乱序。如果不做序列号校验,本地订单簿会越跑越偏。后来我加了个“延迟队列”,把乱序的增量先缓存起来,等前面的序号补齐了再处理。这招帮我省了不少麻烦。

3.5 性能对比:什么时候该用哪种?

场景 推荐方式 原因
系统启动/重启 全量快照 快速建立基准,避免增量累积
高频交易(毫秒级) 增量更新 带宽和CPU开销小,延迟低
网络不稳定 混合模式 定期快照校验,防止数据漂移
回测/历史数据分析 全量快照 简化逻辑,避免增量重放带来的复杂度

3.6 知识体系图:Snapshot vs. Delta 的核心逻辑

下面这张图,是我自己梳理的决策流程。你一看就明白什么时候该用快照,什么时候该用增量。

订单簿更新策略决策流程 收到订单簿数据 是否首次? 全量快照覆盖 需校验? 拉快照校验 一致? 丢弃本地,重建 增量更新 订单簿就绪

这张图的核心逻辑就是:首次用快照,后续用增量,定期做校验。一旦发现数据对不上,别犹豫,直接拉快照重建。宁可花几毫秒重建,也别让错误数据跑一整天。

📌 核心要点:
  • 全量快照是“基准”,增量更新是“日常”。
  • 序列号校验是增量更新的生命线,别省。
  • 定期校验能防止“千里之堤,溃于蚁穴”。
  • 回测时用全量快照更省心,别跟自己过不去。

公众号:蓝海资料掘金营,微信deep3321