2. 市场数据回放:构建高保真历史数据回放引擎
做高频交易,最怕什么?
怕策略在回测里赚得盆满钵满,一上实盘就亏得底裤都不剩。
这种惨案我见过太多次了。原因往往只有一个——回放引擎不够真实。说白了,你拿到的历史数据是「死」的,而真实市场是「活」的。今天我们就来聊聊,怎么造一个高保真的历史数据回放引擎,让策略在「模拟战场」里先挨一顿毒打。
2.1 为什么需要高保真回放?
普通的回测,就是把历史K线拉出来,算算指标,跑跑信号。嗯,这其实叫「复盘」,不叫「回放」。
真正的回放,要模拟出当时的市场微观结构。包括:
- 订单簿的逐笔变化:谁在买,谁在卖,挂单深度多少
- 成交的时序细节:每一笔成交是主动买还是主动卖
- 网络延迟与撮合排队:你的订单发出去,什么时候能被吃掉
核心观点:回放引擎的保真度,直接决定了策略在实盘中的存活率。我见过有人用1分钟K线回测,年化收益80%,实盘一周就爆仓。为什么?因为K线掩盖了盘口的瞬时波动。
2.2 回放引擎的核心架构
我个人习惯把回放引擎拆成三层。你想想看,就像剥洋葱一样,一层一层来。
2.3 数据接入层:Tick数据的「考古」工作
这一层说白了就是搞数据。但搞数据这件事,远比你想的复杂。
2.3.1 数据来源与格式
我建议你至少准备三个数据源:交易所直连数据、第三方数据商、以及自己录制的盘口快照。为什么?因为单一数据源总有缺失。我在项目中遇到过,某数据商在2015年股灾期间直接丢了30%的Tick数据,要不是有备份,整个回放引擎就废了。
常见的Tick数据格式:
// 标准Tick结构
{
"symbol": "BTCUSDT",
"timestamp": 1623456789123, // 毫秒级时间戳
"bid_price": 35000.12,
"bid_volume": 1.5,
"ask_price": 35000.15,
"ask_volume": 2.3,
"last_price": 35000.14,
"last_volume": 0.5,
"side": "buy" // 主动买卖方向
}
小技巧:时间戳一定要用纳秒级。很多回测引擎用毫秒,结果在高频场景下,同一毫秒内的订单顺序完全乱了。我吃过这个亏,后来全部改成纳秒。
2.3.2 数据清洗与对齐
原始数据有多脏?我举个例子:
- 重复的Tick记录(同一个时间戳出现两次)
- 价格异常(比如突然出现0.01的买单)
- 时间戳漂移(某段时间的数据整体偏移了几秒)
清洗规则我一般这样定:
| 异常类型 | 处理方式 | 备注 |
|---|---|---|
| 重复Tick | 保留第一条,删除后续 | 注意检查是否真的重复 |
| 价格跳变 | 超过阈值则标记为异常 | 阈值设为前一笔的±5% |
| 时间戳乱序 | 按时间戳重新排序 | 如果乱序超过100ms,直接丢弃 |
2.4 时序仿真层:让数据「活」过来
数据清洗好了,接下来就是回放的核心——时序仿真。
2.4.1 事件驱动引擎
我不会用「定时轮询」的方式去读数据。那样太慢了,而且不真实。我用的方法是事件驱动:
- 每个Tick数据包装成一个事件
- 事件按时间戳排序,放入优先队列
- 引擎按真实时间间隔触发事件
class ReplayEngine:
def __init__(self, tick_data):
self.event_queue = sorted(tick_data, key=lambda x: x['timestamp'])
self.current_index = 0
self.start_time = None
def run(self):
# 按真实时间回放
for tick in self.event_queue:
# 计算与上一笔的时间差
if self.start_time is None:
self.start_time = time.time()
sleep_time = 0
else:
elapsed = (tick['timestamp'] - self.event_queue[self.current_index-1]['timestamp']) / 1000.0
sleep_time = max(0, elapsed)
time.sleep(sleep_time)
self.process_tick(tick)
self.current_index += 1
注意:千万不要用「加速回放」模式去测试高频策略。我曾经为了省时间,把回放速度调到10倍,结果策略在实盘里完全跟不上节奏。高频交易,时间就是生命。
2.4.2 延迟模拟
真实市场里,你的订单从发出到成交,是有延迟的。这个延迟包括:
- 网络传输延迟(通常1-5ms)
- 交易所撮合排队(取决于订单量)
- 数据推送延迟(行情数据到你的机器)
我一般会在回放引擎里加一个「延迟抖动模型」:
import random
def simulate_latency(base_latency_ms=2):
# 基础延迟 + 随机抖动
jitter = random.gauss(0, 0.5) # 标准差0.5ms
total_latency = max(0.5, base_latency_ms + jitter)
time.sleep(total_latency / 1000.0)
return total_latency
2.5 订单执行层:模拟撮合与滑点
这一层最容易被忽视,但也最重要。
2.5.1 撮合引擎
回放引擎里的撮合,不能简单用「最新价成交」。你得模拟订单簿的深度:
- 你的买单进来,先吃卖一,卖一吃完了再吃卖二
- 如果卖单深度不够,订单只能部分成交
- 剩下的部分变成挂单,等待后续成交
2.5.2 滑点模型
滑点这东西,回测里不模拟,实盘里就会给你「惊喜」。我常用的滑点模型:
| 订单类型 | 滑点计算方式 | 典型值 |
|---|---|---|
| 市价单 | 按订单簿深度计算 | 0.5-2个tick |
| 限价单 | 取决于挂单位置 | 0-1个tick |
| 大额订单 | 按成交量加权 | 3-10个tick |
避坑指南:我曾经在回放引擎里用「固定滑点1个tick」,结果实盘里遇到流动性枯竭,滑点直接到了10个tick。从那以后,我改用动态滑点模型,根据当前订单簿深度实时计算。
2.6 性能优化:让回放跑得够快
高保真回放有个矛盾——数据越精细,回放越慢。怎么解决?
- 内存映射文件:把Tick数据预加载到内存,避免磁盘IO
- 零拷贝技术:数据在引擎内部传递时,用指针而不是复制
- 并行回放:不同品种的回放可以并行跑,最后汇总结果
// 使用内存映射读取Tick数据
#include <sys/mman.h>
#include <fcntl.h>
void* load_tick_data(const char* filename) {
int fd = open(filename, O_RDONLY);
off_t size = lseek(fd, 0, SEEK_END);
void* mapped = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
close(fd);
return mapped;
}
个人经验:我一般会把一天的Tick数据压缩到200MB以内。用内存映射加载,基本能做到秒级启动。如果数据量太大,可以考虑分片存储,按小时切分文件。
2.7 验证回放引擎的保真度
引擎造好了,怎么知道它够不够「真」?
我会做三件事:
- 统计特征对比:回放出来的成交价分布、成交量分布,是否和原始数据一致
- 订单簿重建:用回放数据重建订单簿,和原始快照对比
- 策略回放测试:拿一个已知的简单策略(比如盘口价差套利),看回放结果是否和实盘一致
嗯,这里要注意。第三点是最有说服力的。如果连最简单的策略都跑不对,那引擎肯定有问题。
好了,关于高保真历史数据回放引擎,核心内容就这些。记住一句话:回放引擎的每一行代码,都是在为你的实盘策略「买保险」。别在这上面省钱,也别偷懒。