4. 回测数据模型设计:OHLCV数据结构、事件驱动模型、Bar数据与Tick数据的Spark表示
回测系统的核心是数据模型。一个设计良好的数据模型,决定了回测引擎的计算效率、扩展性和准确性。本章将深入剖析OHLCV数据结构、事件驱动模型,并讲解如何在Spark中高效表示Bar数据与Tick数据。
4.1 OHLCV数据结构:金融数据的基石
OHLCV(Open, High, Low, Close, Volume)是金融时间序列数据最通用的表示形式。每一根K线(Bar)都包含以下五个核心字段:
| 字段 | 类型 | 说明 | 典型Spark类型 |
|---|---|---|---|
| Open | Double | 时间区间内的第一笔成交价 | DoubleType |
| High | Double | 时间区间内的最高成交价 | DoubleType |
| Low | Double | 时间区间内的最低成交价 | DoubleType |
| Close | Double | 时间区间内的最后一笔成交价 | DoubleType |
| Volume | Long | 时间区间内的总成交量(股/手) | LongType |
在Spark中,OHLCV数据通常以宽表形式存储,每行代表一个时间片(如1分钟、1天)的汇总数据。这种结构非常适合进行向量化计算和批量回测。
设计要点: 在Spark Schema中,建议将时间戳(Timestamp)设为分区键,股票代码(Symbol)设为分桶键,以加速按时间范围和特定标的的查询。
4.2 事件驱动模型:回测引擎的脉搏
事件驱动模型是回测引擎的另一种主流架构。它将市场数据、订单状态、定时任务等抽象为“事件”,引擎通过事件循环(Event Loop)按时间顺序处理这些事件。
核心事件类型包括:
- 市场数据事件(MarketDataEvent): 新的Tick或Bar到达。
- 订单事件(OrderEvent): 策略发出买入/卖出指令。
- 成交事件(FillEvent): 订单被交易所撮合。
- 定时事件(TimerEvent): 用于定期执行的操作(如每日调仓)。
下图展示了事件驱动模型在Spark中的核心处理流程:
Spark实现提示: 在分布式环境中,事件队列可以使用Spark的
RDD.queue或基于Kafka的DStream实现。事件循环则通过mapPartitions在每个分区内按时间戳排序处理,避免全局排序带来的Shuffle开销。
4.3 Bar数据与Tick数据的Spark表示
Bar数据和Tick数据在数据密度、更新频率和存储方式上有显著差异。Spark需要针对这两种数据采用不同的表示和优化策略。
4.3.1 Bar数据的Spark表示
Bar数据是聚合后的数据,数据量相对较小,适合使用Spark SQL的DataFrame进行存储和分析。典型的Schema如下:
// Bar数据Schema (Scala)
val barSchema = StructType(Array(
StructField("symbol", StringType, nullable = false),
StructField("ts", TimestampType, nullable = false),
StructField("open", DoubleType, nullable = false),
StructField("high", DoubleType, nullable = false),
StructField("low", DoubleType, nullable = false),
StructField("close", DoubleType, nullable = false),
StructField("volume", LongType, nullable = false),
StructField("vwap", DoubleType, nullable = true) // 成交量加权平均价
))
Bar数据通常按时间分区(如dt=2024-01-01)存储为Parquet列式格式,以支持谓词下推和高效的列裁剪。
4.3.2 Tick数据的Spark表示
Tick数据是逐笔成交数据,数据量极大(单日可达数亿条)。在Spark中,Tick数据通常以原始行存或列存(如Parquet)存储,但需要特别注意数据倾斜和内存占用。
// Tick数据Schema (Scala)
val tickSchema = StructType(Array(
StructField("symbol", StringType, nullable = false),
StructField("ts", TimestampType, nullable = false),
StructField("price", DoubleType, nullable = false),
StructField("volume", LongType, nullable = false),
StructField("side", StringType, nullable = true), // 'B' 或 'S'
StructField("trade_id", StringType, nullable = true)
))
性能陷阱: 处理Tick数据时,避免使用
groupBy(symbol, ts)进行全局聚合,这会导致严重的数据倾斜。应优先使用repartition按Symbol分区,再在每个分区内进行局部聚合或窗口计算。
下图对比了Bar数据与Tick数据在Spark中的存储与处理差异:
4.4 数据模型选择策略
在实际回测项目中,数据模型的选择取决于回测的精度和性能要求:
| 回测类型 | 推荐数据模型 | Spark优化要点 |
|---|---|---|
| 日频/低频回测 | Bar数据(日线) | 使用DataFrame API,开启AQE和动态分区裁剪 |
| 分钟/小时频回测 | Bar数据(分钟线) | 预聚合为Parquet,使用Z-order排序加速过滤 |
| 高频回测(Tick级) | Tick数据 | 使用RDD + mapPartitions,避免Shuffle;考虑使用Delta Lake进行增量处理 |
| 混合精度回测 | Bar + Tick 双轨 | Bar用于策略信号生成,Tick用于滑点/冲击成本模拟 |
实战建议: 在课程后续的实验中,我们将使用Spark的
Window函数在Bar数据上计算技术指标,并使用mapGroupsWithState在Tick数据上实现事件驱动的订单簿重建。请确保你已理解本节的数据模型差异。