11、实战案例二:高频做市策略的Tick级回测
做市策略的回测,跟普通趋势策略完全是两码事。普通策略看日线、小时线就够了,但做市策略不行——你得看每一笔成交,也就是Tick级数据。说白了,做市商赚的是买卖价差和返佣,每一笔订单的成交时机、挂单顺序、撤单速度,都会直接影响盈亏。
我个人习惯把Tick级回测叫做「显微镜下的回测」。因为在这个粒度下,你能看到很多日线级别完全看不到的东西。比如订单簿的深度变化、盘口的瞬间失衡、大单的冲击痕迹。这些细节,才是做市策略真正的战场。
核心要点:高频做市策略的回测,必须使用Tick级数据。任何基于K线数据的回测,都会丢失关键的微观结构信息,导致回测结果严重失真。
11.1 为什么Tick级回测如此重要?
我在项目中遇到过不少团队,用1分钟K线来回测做市策略,结果回测曲线漂亮得不行,一上实盘就亏。为什么会这样?因为K线把订单簿的微观结构给抹平了。
你想想看,做市策略的核心逻辑是什么?是在买一价挂买单,在卖一价挂卖单,赚取价差。但K线数据只告诉你这一分钟的开高低收,它不会告诉你这一分钟里订单簿被冲击了多少次,你的挂单有没有被吃掉,撤单重挂的成本是多少。
嗯,这里要注意:Tick级数据通常包含以下关键字段:
- 时间戳:精确到毫秒甚至微秒
- 成交价格:每一笔成交的具体价格
- 成交量:该笔成交的数量
- 买卖方向:主动买还是主动卖
- 订单簿快照:当前买一到买五、卖一到卖五的挂单量和价格
我曾经接手过一个项目,团队用1分钟K线回测,年化收益做到80%,最大回撤只有5%。我当时就觉得不对劲——做市策略怎么可能有这么高的夏普比?后来换成Tick级数据一跑,年化直接变成-12%。原因很简单:K线回测忽略了滑点和挂单不成交的风险。
11.2 Tick级回测的核心架构
做Tick级回测,不能像普通回测那样一条K线一条K线地跑。你得模拟一个事件驱动的引擎。说白了,就是按时间顺序,一条一条地处理Tick数据,同时维护一个虚拟的订单簿。
下面这张图是我自己总结的Tick级回测流程,你可以参考一下:
这个流程看起来简单,但实际实现时坑很多。我重点讲三个最容易出问题的地方。
11.3 订单簿的维护与重建
做Tick级回测,第一步就是重建订单簿。很多交易所的Tick数据只提供成交记录,不提供完整的订单簿快照。这时候你就得自己维护一个虚拟订单簿。
我建议的做法是这样的:
class OrderBook:
def __init__(self):
self.bids = {} # 价格 -> 数量
self.asks = {} # 价格 -> 数量
self.last_update_time = 0
def apply_trade(self, trade):
# 根据成交记录更新订单簿
# 注意:这里需要处理吃单方向
if trade.side == 'buy':
# 主动买,吃掉卖单
self._remove_asks(trade.price, trade.volume)
else:
# 主动卖,吃掉买单
self._remove_bids(trade.price, trade.volume)
def apply_snapshot(self, snapshot):
# 定期用快照校准
self.bids = {level.price: level.volume
for level in snapshot.bids}
self.asks = {level.price: level.volume
for level in snapshot.asks}
注意:订单簿的维护有一个常见陷阱——数据漂移。长时间只靠成交记录更新订单簿,误差会越来越大。我建议每隔一段时间(比如每100笔成交)就用一次快照数据做校准。
11.4 挂单与撤单的模拟
做市策略的核心操作就是挂单和撤单。在Tick级回测中,你得模拟这个过程。这里有一个关键问题:你的挂单什么时候能成交?
我曾经在这个问题上栽过跟头。一开始我简单认为:只要对手方价格穿过你的挂单价,你的单子就成交了。但实际不是这样的——你得考虑订单簿的排队顺序。如果卖一价有100手挂单,你排在最后,那只有前面99手都被吃掉后,才轮到你。
嗯,这里我给出一个简化但实用的处理方式:
def simulate_order_fill(order, trade):
"""
判断订单是否被成交
order: 我们的挂单
trade: 当前Tick的成交记录
"""
# 买单被成交:对手方卖单价格 <= 我们的买单价格
if order.side == 'buy':
if trade.price <= order.price:
# 计算成交数量
fill_volume = min(order.remaining_volume, trade.volume)
order.fill(fill_volume)
return fill_volume
# 卖单被成交:对手方买单价格 >= 我们的卖单价格
else:
if trade.price >= order.price:
fill_volume = min(order.remaining_volume, trade.volume)
order.fill(fill_volume)
return fill_volume
return 0
小技巧:如果你做的是高频做市,建议在回测中加入「撤单延迟」的模拟。实盘中撤单不是瞬间完成的,会有几十到几百微秒的延迟。忽略这个延迟,回测结果会偏乐观。
11.5 一个完整的回测示例
说了这么多理论,我们来跑一个实际的例子。假设我们做一个最简单的价差做市策略:在买一价挂买单,在卖一价挂卖单,价差固定为2个tick。
def run_backtest(tick_data, spread=2, order_size=1):
"""
简单价差做市策略回测
tick_data: 按时间排序的Tick数据列表
spread: 目标价差(tick数)
order_size: 每笔挂单数量
"""
order_book = OrderBook()
position = 0
pnl = 0
trades = []
for tick in tick_data:
# 1. 更新订单簿
order_book.apply_trade(tick)
# 2. 检查现有挂单是否成交
for order in active_orders:
fill = simulate_order_fill(order, tick)
if fill > 0:
position += fill * (1 if order.side == 'buy' else -1)
pnl -= fill * tick.price * (1 if order.side == 'buy' else -1)
trades.append({
'time': tick.time,
'price': tick.price,
'volume': fill,
'side': order.side
})
# 3. 重新挂单(如果当前没有挂单)
if len(active_orders) == 0:
best_bid = order_book.get_best_bid()
best_ask = order_book.get_best_ask()
if best_bid and best_ask:
# 在买一价挂买单
place_order('buy', best_bid, order_size)
# 在卖一价挂卖单(价差为spread个tick)
place_order('sell', best_bid + spread * tick_size, order_size)
return pnl, trades
这个例子虽然简单,但包含了Tick级回测的核心要素:订单簿维护、挂单成交判断、仓位管理。实际项目中,你还需要加入手续费、滑点、撤单延迟等因素。
11.6 回测结果的评估指标
做市策略的评估,跟普通策略不太一样。除了常规的夏普比、最大回撤,我建议重点关注以下几个指标:
| 指标名称 | 计算公式 | 说明 |
|---|---|---|
| 成交率 | 成交笔数 / 挂单笔数 | 反映挂单被吃掉的概率,做市策略通常要求60%以上 |
| 平均持仓时间 | 所有持仓时间的平均值 | 高频做市通常以秒甚至毫秒计,过长说明策略失效 |
| 价差捕获率 | 实际成交价差 / 目标价差 | 衡量滑点对策略的影响,越接近1越好 |
| 订单簿冲击成本 | 挂单后订单簿变化导致的潜在损失 | 大单做市时尤其重要,小单可以忽略 |
我记得有一次帮朋友优化一个做市策略,回测的夏普比高达5.0,但成交率只有30%。我当时就觉得有问题——这么低的成交率,说明大部分挂单都没被吃掉,那策略的收益其实来自少数几次成交,稳定性很差。后来加了成交率这个指标,果然发现策略在流动性不足的时候表现极差。
总结一下:Tick级回测是做市策略的必修课。它让你看到K线背后真实的微观结构,但也带来了数据量大、计算复杂、坑多等挑战。我的建议是:先从简单的价差策略开始,跑通整个回测流程,再逐步加入更复杂的逻辑。别一上来就想做最优做市,先把基础打牢。
嗯,这一章的内容就到这里。代码示例你可以直接拿去用,但记得根据你自己的数据格式做调整。每个交易所的Tick数据格式都不一样,这是最让人头疼的地方。
交易系统化学习资料 微信Strategy888888