第四章:订单簿建模——从数据到特征的炼金术
做高频交易的朋友都知道,订单簿就是我们的战场地图。你想想看,每秒几千笔订单在眼前飞过,如果没有一套靠谱的建模方法,那跟闭着眼睛开车没啥区别。今天我就把这几年来折腾订单簿建模的经验,掰开了揉碎了讲给你听。
核心观点:订单簿建模不是简单的数据记录,而是从海量事件流中提取交易意图的过程。我个人习惯把这件事分成四步:事件驱动建模、快照重建、数据解析、特征工程。
4.1 订单簿事件驱动模型
先说事件驱动模型。说白了,就是记录「发生了什么」,而不是「现在长什么样」。交易所每时每刻都在推送两类消息:新增订单和撤单/成交。
我刚开始做的时候,犯过一个低级错误——直接用快照数据做回测。结果呢?策略在模拟里赚得盆满钵满,实盘一跑就亏。后来才发现,快照之间丢失了大量中间状态。嗯,这里要注意:事件流才是真相。
一个典型的事件模型包含以下字段:
| 字段 | 含义 | 示例 |
|---|---|---|
| EventType | 事件类型 | 0=新增, 1=撤单, 2=成交 |
| OrderID | 订单唯一标识 | 12345678 |
| Side | 买卖方向 | 0=买, 1=卖 |
| Price | 价格(单位:最小变动价位) | 50000 |
| Volume | 数量 | 100 |
| Timestamp | 纳秒级时间戳 | 1680000000123456789 |
事件驱动的核心逻辑其实就一句话:维护一个有序的限价单队列。每次收到新事件,更新对应价格档位的挂单量。我习惯用红黑树来实现,C++里直接用std::map,Python里用sortedcontainers。
// C++ 伪代码:事件驱动的订单簿更新
void onNewOrder(OrderEvent evt) {
auto &level = (evt.side == BUY) ? bidBook[evt.price] : askBook[evt.price];
level.totalVolume += evt.volume;
level.orderCount++;
// 维护队列顺序(FIFO)
level.orders.push_back(evt.orderId);
}
void onCancelOrder(OrderEvent evt) {
auto &level = (evt.side == BUY) ? bidBook[evt.price] : askBook[evt.price];
level.totalVolume -= evt.volume;
level.orderCount--;
// 从队列中移除(通常用哈希表加速)
level.orderMap.erase(evt.orderId);
}
避坑指南:我曾经在实盘中发现,某些交易所的撤单事件会晚到几微秒。如果你严格按照事件顺序更新,会导致订单簿短暂「失真」。解决方案是引入一个延迟容忍窗口——在窗口期内允许事件乱序到达,用时间戳做二次排序。
4.2 订单簿重建与快照
事件流虽然精确,但有个致命问题:如果中间断了一秒,你就完全不知道当前状态了。所以交易所会定期发送快照(Snapshot),比如每100毫秒一次。
快照重建,说白了就是「从零开始,用事件流把订单簿推到当前时刻」。我见过不少新手直接拿快照当订单簿用,结果策略的延迟特征完全不对。
正确的做法是:
- 收到快照后,清空当前订单簿
- 用快照数据初始化所有价格档位
- 然后继续处理后续事件流
这里有个细节:快照和事件流之间可能存在时间重叠。比如快照覆盖了T0到T1的状态,但你在T0之后已经处理了一些事件。怎么办?我的做法是丢弃快照之前的所有事件,只保留快照之后的部分。
def rebuild_from_snapshot(snapshot, event_stream):
orderbook = OrderBook()
# 第一步:加载快照
for level in snapshot.levels:
orderbook.set_level(level.price, level.side, level.volume)
# 第二步:找到快照时间戳
snap_time = snapshot.timestamp
# 第三步:只处理快照之后的事件
for event in event_stream:
if event.timestamp >= snap_time:
orderbook.apply_event(event)
return orderbook
注意:快照本身也可能有延迟。我遇到过某交易所的快照比实际状态晚了50微秒。如果你做的是纳秒级策略,建议用多个快照做交叉验证,或者自己维护一个「影子订单簿」来检测偏差。
4.3 LOBSTER数据解析
说到订单簿数据,就绕不开LOBSTER。这是来自瑞典皇家理工学院的一个数据集,提供了纳斯达克股票的完整订单簿事件流。我个人觉得,它是目前学术界和工业界最常用的标准数据集。
LOBSTER的数据格式其实很简单:
- 消息文件(message.csv):记录每个事件的时间、类型、订单ID、价格、数量
- 订单簿文件(orderbook.csv):记录每个事件发生后,前10档的买卖挂单量
但要注意,LOBSTER的数据是压缩过的。什么意思呢?它只记录了每个事件发生后的订单簿状态变化,而不是全量快照。所以解析的时候,你需要自己维护一个完整的订单簿。
我写过一个解析器,核心逻辑就三步:
def parse_lobster(message_file, orderbook_file):
# 第一步:读取消息文件
messages = pd.read_csv(message_file, header=None)
# 第二步:逐行处理事件
for idx, row in messages.iterrows():
event_type = row[1] # 事件类型
order_id = row[2] # 订单ID
price = row[3] # 价格
volume = row[4] # 数量
# 第三步:更新订单簿
if event_type == 1: # 新增
add_order(order_id, price, volume)
elif event_type == 2: # 撤单
cancel_order(order_id)
elif event_type == 3: # 成交
execute_order(order_id, volume)
# 记录当前快照
snapshot = get_top_k_levels(10)
yield snapshot
经验之谈:LOBSTER的数据是按天分文件的。我建议你一次性加载全天数据,然后按时间窗口切分。千万别逐行读取——Python的I/O开销会让你怀疑人生。用pandas.read_csv的chunksize参数,或者直接用C++写解析器。
4.4 订单簿特征工程
好了,数据有了,模型有了,接下来就是最关键的——特征工程。说白了,就是把原始数据变成能喂给机器学习模型的东西。
我个人把特征分成三类:
| 特征类别 | 具体特征 | 计算方式 |
|---|---|---|
| 价格特征 | 买卖价差、中间价、加权平均价 | 直接计算 |
| 量价特征 | 订单簿斜率、深度不平衡、价格冲击系数 | 回归/统计 |
| 时序特征 | 订单到达率、撤单率、流动性变化率 | 滑动窗口 |
举个例子,订单簿斜率这个特征,我特别喜欢用。它衡量的是价格每变动一个tick,挂单量变化多少。斜率陡峭说明流动性集中在当前价格附近,市场深度好;斜率平缓说明挂单分散,容易被大单击穿。
def orderbook_slope(orderbook, n_levels=5):
# 计算买盘斜率
bid_prices = [level.price for level in orderbook.bids[:n_levels]]
bid_volumes = [level.volume for level in orderbook.bids[:n_levels]]
bid_slope = np.polyfit(bid_prices, bid_volumes, 1)[0]
# 计算卖盘斜率
ask_prices = [level.price for level in orderbook.asks[:n_levels]]
ask_volumes = [level.volume for level in orderbook.asks[:n_levels]]
ask_slope = np.polyfit(ask_prices, ask_volumes, 1)[0]
return bid_slope, ask_slope
还有一个我踩过坑的特征——订单到达率。你以为算个每秒订单数就完事了?太天真了。不同时间段的到达率差异巨大,开盘和收盘前简直是两个世界。我后来改用相对到达率,即当前到达率除以过去N分钟的均值。这样特征就稳定多了。
核心原则:特征工程不是越多越好。我见过有人提取了200多个特征,结果模型过拟合得一塌糊涂。我的建议是:先理解市场微观结构,再设计特征。每个特征都要能解释一个具体的市场行为。
最后,送你一张我总结的订单簿建模知识图谱:
好了,这一章的内容就到这。订单簿建模是高频做市的基石,你把它吃透了,后面的策略设计才能站得住脚。下一章我们聊聊更实战的东西——如何用这些特征构建一个简单的做市策略。