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事件驱动回测核心流程 事件源 事件队列 事件循环 策略模块 风控模块 执行模块 反馈(新事件) Tick / Bar / 订单
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中的存储与处理差异:

Bar数据 vs Tick数据:Spark表示对比 Bar数据 (DataFrame) • 数据量:小(每日数百万行) • 存储格式:Parquet列存 • 分区键:dt (日期) • 计算模式:批量SQL / 向量化 • 典型操作:JOIN, AGG, WINDOW Tick数据 (DataFrame/RDD) • 数据量:极大(每日数亿行) • 存储格式:Parquet / 压缩行存 • 分区键:symbol + dt • 计算模式:mapPartitions / 流式 • 典型操作:FILTER, MAP, REDUCE 选择依据:回测精度要求 vs 计算资源成本

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数据上实现事件驱动的订单簿重建。请确保你已理解本节的数据模型差异。