第六章:回测引擎设计:事件驱动架构、订单管理、成交逻辑
做市策略写好了,怎么知道它能不能赚钱?
你得跑回测。但回测引擎不是随便写个循环就行的。我见过太多人把回测写成「K线遍历器」——遍历一遍历史数据,算算盈亏,完事。这种回测,说实话,跟猜硬币差不多。
真正的回测引擎,核心是三个东西:事件驱动架构、订单管理、成交逻辑。今天我们就一个一个拆开讲。
6.1 事件驱动架构:让引擎「活」起来
先问个问题:你的回测引擎是怎么推进时间的?
很多人写回测,就是按时间顺序遍历K线。每来一根K线,算一下指标,决定要不要下单。这有什么问题?
问题大了。真实市场里,订单不是在K线收盘时成交的。订单可能在盘中任何时刻被吃掉。你按K线收盘价算成交,误差会大到离谱。尤其是做市策略,吃的是盘口差价,按收盘价算,你根本算不出真实的买卖价差。
所以,我们需要事件驱动架构。
核心思想:把回测过程拆成一个个「事件」。每个事件带一个时间戳。引擎按时间顺序处理事件。事件可以是:
- Tick数据到达
- 订单状态变化
- 定时器触发
- 外部信号
我个人习惯用「事件队列」来实现。所有事件按时间排序,塞进一个优先队列。引擎每次从队列头部取一个事件,处理它,然后可能产生新事件,再塞回队列。
class EventEngine:
def __init__(self):
self.queue = [] # 优先队列,按时间排序
self.current_time = None
def push_event(self, event):
heapq.heappush(self.queue, (event.timestamp, event))
def run(self):
while self.queue:
_, event = heapq.heappop(self.queue)
self.current_time = event.timestamp
self.process_event(event)
def process_event(self, event):
if event.type == 'TICK':
self.on_tick(event.data)
elif event.type == 'ORDER':
self.on_order(event.data)
elif event.type == 'TIMER':
self.on_timer(event.data)
你看,这样引擎就不是「被动遍历」了。它是「主动响应」的。每个事件来了,该干嘛干嘛。这才是模拟真实市场的正确姿势。
避坑指南:我曾经在事件队列里直接用Python的list+sort,结果回测跑10万笔订单就卡死了。后来换成heapq,速度快了不止一个量级。事件队列的性能,直接影响回测速度。
6.2 订单管理:别让订单「丢了」
订单管理,说白了就是跟踪每个订单的「一生」。从创建、提交、部分成交、完全成交、到取消或过期。
我刚开始写回测引擎时,订单管理就用了几个变量。结果跑完回测,发现有些订单「凭空消失」了——既没成交也没取消,就是找不到了。查了半天,原来是某个分支逻辑忘了更新订单状态。
所以,我后来设计了一个订单状态机。每个订单必须严格按照状态机流转。
| 状态 | 说明 | 可转换到 |
|---|---|---|
| PENDING | 已创建,未提交 | SUBMITTED, CANCELLED |
| SUBMITTED | 已提交到市场 | PARTIAL, FILLED, CANCELLED |
| PARTIAL | 部分成交 | PARTIAL, FILLED, CANCELLED |
| FILLED | 完全成交 | (终态) |
| CANCELLED | 已取消 | (终态) |
每个订单有一个唯一的order_id。我建议用uuid或者时间戳+自增序号。别用简单的自增ID,多个策略同时跑时会冲突。
class Order:
def __init__(self, order_id, symbol, side, price, quantity):
self.order_id = order_id
self.symbol = symbol
self.side = side # 'BUY' or 'SELL'
self.price = price
self.quantity = quantity
self.filled_quantity = 0
self.status = 'PENDING'
self.create_time = None
self.update_time = None
def fill(self, fill_quantity, fill_price):
self.filled_quantity += fill_quantity
if self.filled_quantity >= self.quantity:
self.status = 'FILLED'
else:
self.status = 'PARTIAL'
self.update_time = datetime.now()
注意:订单管理里最容易出bug的地方是「部分成交」。一个订单可能被拆成几十笔小单成交。如果你只记录最终状态,中间的价格信息就丢了。做市策略对成交价格极其敏感,必须记录每一笔成交明细。
6.3 成交逻辑:模拟「撮合」的过程
成交逻辑是回测引擎最核心的部分。它决定了你的订单到底能不能成交、以什么价格成交。
真实市场里,成交靠的是撮合引擎。你的买单挂在盘口上,等卖单来吃。回测里,我们得模拟这个过程。
我常用的方法是订单簿模拟。维护一个本地的订单簿,包含所有挂单。当新的Tick数据到来时,更新订单簿,然后检查是否有订单可以成交。
class OrderBook:
def __init__(self):
self.bids = [] # 买单队列,按价格降序
self.asks = [] # 卖单队列,按价格升序
def update(self, tick):
# 更新盘口数据
self.bids = tick.bids
self.asks = tick.asks
def match_order(self, order):
if order.side == 'BUY':
# 买单:看卖一价是否 <= 买单价格
while self.asks and self.asks[0].price <= order.price:
ask = self.asks[0]
fill_qty = min(order.quantity - order.filled_quantity, ask.quantity)
fill_price = ask.price
# 记录成交
order.fill(fill_qty, fill_price)
ask.quantity -= fill_qty
if ask.quantity == 0:
heapq.heappop(self.asks)
if order.status == 'FILLED':
break
else:
# 卖单逻辑类似
pass
这里有个关键点:成交价格怎么取?
很多人直接用对手盘的价格。但做市策略里,你的订单可能是「挂单」而不是「吃单」。挂单成交时,价格是你自己挂的价格,不是对手盘的价格。这个区别,直接影响回测的盈亏计算。
我的经验:做市策略的回测,一定要区分「被动成交」和「主动成交」。被动成交(挂单被吃)用你的挂单价,主动成交(吃别人的单)用对手盘价。混在一起算,误差能到10%以上。
6.4 整体架构:一张图说清楚
说了这么多,我们画张图把整个流程串起来。
这张图里,数据从历史数据源流入事件引擎。事件引擎把数据包装成事件,分发给策略和订单管理器。策略生成订单后,交给订单管理器。订单管理器维护订单状态,并调用成交逻辑。成交逻辑模拟撮合,返回成交结果。结果再反馈给策略,更新策略的内部状态。
整个流程是闭环的。这也是事件驱动架构的精髓——每个模块只关心自己收到的事件,模块之间通过事件解耦。
6.5 几个容易踩的坑
最后,分享几个我实际踩过的坑。
- 时间精度问题:Tick数据的时间戳精度到毫秒甚至微秒。如果你用秒级精度,多个事件可能被当成同时发生,顺序就乱了。我建议统一用微秒级时间戳。
- 订单簿深度:回测时订单簿只维护前几档就够了。全量维护太慢,而且大部分成交都发生在头几档。我一般只维护前5档。
- 滑点模拟:做市策略的滑点跟普通策略不一样。普通策略滑点是「吃单滑点」,做市策略是「挂单被吃」,滑点方向相反。别搞混了。
- 回测速度:事件驱动架构比遍历K线慢。这是正常的。但如果你发现慢到无法接受,检查一下事件队列是不是用了低效的数据结构。
一个小技巧:回测时先跑一小段数据(比如1天),验证订单管理和成交逻辑是否正确。确认无误后再跑全量数据。我曾经直接跑全量,跑了3小时发现有个bug,气得我差点砸电脑。
好了,回测引擎的核心架构就这些。下一章我们聊聊具体的回测指标——怎么评估你的做市策略到底赚不赚钱。
无相订单流研究社 微信Lucian808555