一、订单簿结构:限价订单簿(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分钟)再拉一次快照做同步,防止误差累积。
下面这张图展示了订单簿的结构和更新流程:
说到代码实现,我习惯用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分钟拉一次快照做校验。如果发现偏差超过阈值,就触发重连和全量同步。这样既保证了实时性,又防止了误差累积。
你想想看,如果只依赖快照,每秒拉一次,带宽压力大不说,还容易错过中间的变化。如果只依赖增量,一旦断连或丢包,本地数据就废了。所以,两者结合才是最佳实践。