一、订单簿结构:限价订单簿(LOB)的构成

做量化交易这些年,我接触最多的数据结构就是限价订单簿(LOB)。说白了,它就是交易所用来记录所有未成交订单的电子账本。你想想看,每一笔买卖委托都在这里排队,等着被撮合。

LOB的核心由两部分组成:买盘(Bid)卖盘(Ask)。买盘按价格从高到低排列,卖盘按价格从低到高排列。中间那个价格差,就是我们常说的「价差」(Spread)。

关键点:LOB不是静态的,它每时每刻都在变化。新订单进来、旧订单撤单、成交发生,都会改变它的状态。

我在项目中遇到过一个问题:刚开始做高频策略时,我以为LOB只是简单的价格队列。后来发现,每个价格档位背后还藏着订单数量、订单类型、时间戳等信息。忽略这些细节,策略很容易出问题。

1.1 订单簿的层级与价格档位

订单簿的层级,其实就是价格档位。每个价格档位对应一个价格水平,上面挂着所有愿意以这个价格买卖的订单。

举个例子,假设某股票当前最优买价是100.00元,挂了1000股;最优卖价是100.01元,挂了800股。那么:

  • 第一档买盘:价格100.00,数量1000
  • 第一档卖盘:价格100.01,数量800
  • 价差:0.01元

为什么要有多个层级?因为市场深度很重要。我记得有一次做回测,只看最优报价,结果实盘时发现大单进来直接把价格打穿了。嗯,从那以后我养成了看至少5档深度的习惯。

我的建议:做流动性分析时,别只看最优价。把前10档的挂单量加起来,才能判断真正的市场深度。

价格档位的粒度,不同市场不一样。A股最小变动单位是0.01元,美股是0.01美元(部分股票可以到0.0001)。这个细节会影响你的订单簿建模精度。

1.2 订单簿的快照与增量更新

订单簿的数据有两种获取方式:快照和增量更新。

快照(Snapshot)就是某一时刻订单簿的完整状态。它包含所有价格档位的买卖挂单。快照的优点是信息完整,缺点是数据量大。如果你每秒拉一次快照,带宽和存储都扛不住。

增量更新(Incremental Update)只记录变化的部分。比如「价格100.00的买盘增加了200股」,或者「价格100.01的卖盘全部撤单」。增量更新数据量小,但需要维护一个本地订单簿,把增量一条条应用上去。

注意:增量更新依赖顺序。如果漏掉一条,或者顺序乱了,本地订单簿就会和交易所不一致。我曾经因为网络丢包,导致本地订单簿差了1000股,策略直接开仓错误。后来我加了序列号校验和重连机制,才解决这个问题。

实际应用中,通常是两者结合:先拉一次快照作为基准,然后持续接收增量更新。每隔一段时间(比如每10分钟)再拉一次快照做同步,防止误差累积。

下面这张图展示了订单簿的结构和更新流程:

限价订单簿(LOB)结构图 买盘(Bid) 卖盘(Ask) 第1档:99.99元 1,000股 第2档:99.98元 2,500股 第3档:99.97元 1,800股 第4档:99.96元 3,200股 第5档:99.95元 1,500股 第1档:100.01元 800股 第2档:100.02元 1,200股 第3档:100.03元 2,000股 第4档:100.04元 1,600股 第5档:100.05元 2,800股 价差 0.02元 增量更新 → 实时维护本地订单簿

说到代码实现,我习惯用Python来模拟订单簿。下面是一个简单的订单簿类,支持快照和增量更新:

class OrderBook:
    def __init__(self):
        self.bids = {}  # 买盘:价格 -> 数量
        self.asks = {}  # 卖盘:价格 -> 数量
        self.sequence = 0  # 序列号,用于校验
    
    def apply_snapshot(self, snapshot):
        """应用快照,重置订单簿"""
        self.bids = {level['price']: level['size'] 
                     for level in snapshot['bids']}
        self.asks = {level['price']: level['size'] 
                     for level in snapshot['asks']}
        self.sequence = snapshot['sequence']
    
    def apply_update(self, update):
        """应用增量更新"""
        # 检查序列号连续性
        if update['sequence'] != self.sequence + 1:
            print(f"警告:序列号不连续,期望{self.sequence+1},收到{update['sequence']}")
            return False
        
        # 处理买盘更新
        for change in update['bid_changes']:
            price, size = change['price'], change['size']
            if size == 0:
                self.bids.pop(price, None)  # 撤单
            else:
                self.bids[price] = size  # 新增或修改
        
        # 处理卖盘更新
        for change in update['ask_changes']:
            price, size = change['price'], change['size']
            if size == 0:
                self.asks.pop(price, None)
            else:
                self.asks[price] = size
        
        self.sequence = update['sequence']
        return True
    
    def get_top_n(self, n=5):
        """获取前N档买卖盘"""
        sorted_bids = sorted(self.bids.items(), reverse=True)[:n]
        sorted_asks = sorted(self.asks.items())[:n]
        return sorted_bids, sorted_asks

避坑指南:我曾经在增量更新时忘了处理size=0的情况,结果撤单的订单还留在订单簿里,导致策略误判市场深度。后来我加了个断言,每次更新后检查总订单数是否合理。

订单簿的快照和增量更新,说白了就是「全量同步」和「增量同步」的区别。全量同步保证一致性,增量同步保证效率。两者配合使用,才能既快又准。

我个人习惯在本地维护一个订单簿缓存,每收到一条增量就更新一次。同时每隔5分钟拉一次快照做校验。如果发现偏差超过阈值,就触发重连和全量同步。这样既保证了实时性,又防止了误差累积。

你想想看,如果只依赖快照,每秒拉一次,带宽压力大不说,还容易错过中间的变化。如果只依赖增量,一旦断连或丢包,本地数据就废了。所以,两者结合才是最佳实践。

专注资料整理