4. Tick 数据存储:高效存储 Tick 数据(HDF5、Parquet)、数据压缩与索引优化
Tick 数据具有高频、海量、时间序列密集的特点。以国内沪深两市为例,单只股票每日产生的 Tick 数据可达数万至数十万条,全市场每日数据量轻松超过 10 GB。选择错误的存储方案会导致回测速度缓慢、磁盘空间爆炸、甚至数据损坏。本章将聚焦于两种主流的高效存储格式——HDF5 与 Parquet,并深入讲解数据压缩策略与索引优化方法。
4.1 存储格式选型:HDF5 vs. Parquet
在量化回测场景中,HDF5 和 Parquet 是最常用的两种列式/分层存储格式。下表从多个维度对比两者的适用性:
| 对比维度 | HDF5 | Parquet |
|---|---|---|
| 数据模型 | 分层组(Group)+ 数据集(Dataset),类似文件系统 | 扁平列式存储,嵌套结构通过 schema 定义 |
| 压缩效率 | 支持 GZIP、LZF、SZIP 等,压缩比高但写速度略慢 | 内置 Snappy、GZIP、ZSTD,压缩比与速度均衡 |
| 随机读取 | 按行索引切片极快,适合按时间窗口切片 | 按列裁剪极快,适合只读取部分字段 |
| 跨语言支持 | Python(h5py/pytables)、C/C++、MATLAB | Python(pyarrow/fastparquet)、Spark、R、Julia |
| 元数据管理 | 内置属性(Attributes)可存储合约信息、时间范围 | 通过 schema 和统计信息(min/max)实现谓词下推 |
| 典型场景 | 单机回测、高频因子计算、时间序列切片 | 分布式计算、数据仓库、跨平台数据交换 |
选型建议: 如果你的回测框架运行在单机环境,且需要频繁按时间范围切片读取全字段数据,优先选择 HDF5。如果数据需要被 Spark 或大数据平台消费,或者你只关心部分字段(如仅读取 price 和 volume),Parquet 是更优选择。
4.2 HDF5 存储实战:分层结构与索引设计
HDF5 通过 Group 组织数据,天然适合按 品种 → 日期 → 表名 的层级存储 Tick 数据。以下是一个典型的分层结构:
tick_data.h5
├── rb2401/ # 品种组
│ ├── 2024-01-15/ # 日期组
│ │ ├── trades # 逐笔成交数据集
│ │ └── quotes # 逐笔报价数据集
│ └── 2024-01-16/
│ ├── trades
│ └── quotes
└── ag2406/
├── 2024-01-15/
│ ├── trades
│ └── quotes
└── 2024-01-16/
├── trades
└── quotes
写入 HDF5 时,必须为时间列创建索引以加速切片查询。推荐使用 pytables 的 create_table 并指定 expectedrows 来预分配存储空间:
import tables as tb
import numpy as np
class TickTrade(tb.IsDescription):
timestamp = tb.Int64Col(pos=0) # 纳秒时间戳
price = tb.Float64Col(pos=1)
volume = tb.Int32Col(pos=2)
side = tb.Int8Col(pos=3) # 1=买, -1=卖
with tb.open_file('tick_data.h5', 'w') as h5:
group = h5.create_group('/', 'rb2401', '螺纹钢2401')
table = h5.create_table(
group, 'trades', TickTrade,
expectedrows=500000, # 预分配行数
createparents=True
)
# 写入数据后创建索引
table.cols.timestamp.create_index()
h5.flush()
索引优化要点:
- 始终对
timestamp列创建索引,HDF5 的 B-tree 索引可将时间范围查询从 O(n) 降至 O(log n)。 - 使用
expectedrows参数预分配存储空间,避免频繁扩展导致文件碎片。 - 写入完成后调用
h5.flush()确保数据落盘,防止因异常中断导致文件损坏。
4.3 Parquet 存储实战:分区策略与谓词下推
Parquet 不依赖分层 Group,而是通过 分区列 实现物理隔离。将 symbol 和 date 作为分区键,查询时只扫描相关分区,大幅减少 I/O:
import pandas as pd
import pyarrow.parquet as pq
import pyarrow as pa
# 假设 df 包含 tick 数据,列:symbol, date, timestamp, price, volume, side
df['date'] = pd.to_datetime(df['timestamp']).dt.date
# 写入分区 Parquet
df.to_parquet(
'tick_data_parquet/',
partition_cols=['symbol', 'date'],
compression='zstd',
index=False
)
# 读取时只查询特定品种和日期
filtered = pq.read_table(
'tick_data_parquet/',
filters=[
('symbol', '==', 'rb2401'),
('date', '==', pd.Timestamp('2024-01-15').date())
],
columns=['timestamp', 'price', 'volume'] # 列裁剪
).to_pandas()
Parquet 的 谓词下推 机制会在读取文件前先检查统计信息(min/max),跳过不满足条件的数据块。配合 列裁剪,在只读取少量字段时性能优势尤为明显。
避坑提示: Parquet 分区列不宜过多,建议控制在 2-3 个以内。过多的分区列会产生大量小文件,导致文件系统元数据膨胀,反而降低读取性能。对于 Tick 数据,推荐使用
symbol + date 两级分区。
4.4 数据压缩策略对比
不同压缩算法在压缩比、读写速度上差异显著。以下是在 1 亿条 Tick 数据上的实测对比:
| 压缩算法 | 压缩后大小 | 压缩比 | 写入速度 | 读取速度 | 适用场景 |
|---|---|---|---|---|---|
| 无压缩 | 4.2 GB | 1.0x | 最快 | 最快 | 临时中间数据 |
| Snappy | 1.8 GB | 2.3x | 快 | 快 | 追求读写平衡 |
| ZSTD (level=3) | 1.2 GB | 3.5x | 中等 | 中等 | 归档存储、长期保存 |
| GZIP (level=6) | 1.0 GB | 4.2x | 慢 | 慢 | 磁盘空间极度紧张 |
推荐组合: 日常回测使用 Snappy 压缩,兼顾速度与空间;历史数据归档使用 ZSTD level=3,在压缩比和读写性能之间取得最佳平衡。避免在回测热数据上使用 GZIP,其解压开销会拖慢策略迭代速度。
4.5 索引优化:从 B-tree 到时间分区索引
无论使用 HDF5 还是 Parquet,索引设计都直接影响查询性能。以下是三种常用索引策略的对比:
4.6 存储方案架构总览
将上述技术整合到一套完整的 Tick 数据存储流水线中,整体架构如下:
4.7 实战建议与避坑清单
- 不要混用压缩算法: 同一份数据在不同存储层使用不同压缩算法会导致重复解压,增加 CPU 开销。建议全链路统一使用 Snappy 或 ZSTD。
- 定期重建索引: 高频写入 HDF5 会导致 B-tree 索引退化,建议每周或每月执行一次
table.cols.timestamp.reindex()。 - 控制 Parquet 文件大小: 每个 Parquet 文件建议在 128 MB ~ 512 MB 之间。过小的文件会导致大量小文件 I/O,过大的文件则不利于并行读取。
- 使用内存映射: HDF5 支持
driver='core'将文件加载到内存,适合回测时频繁读取同一份数据。但需注意内存容量,避免 OOM。 - 备份元数据: HDF5 文件损坏后恢复困难,建议定期备份
.h5文件,或使用h5repack工具整理文件碎片。
本章核心结论: 对于单机回测场景,推荐使用 HDF5 + 混合索引 + Snappy 压缩 的组合方案。对于需要跨平台共享或分布式计算的数据,选择 Parquet + 分区索引 + ZSTD 压缩。无论选择哪种方案,始终将时间戳索引作为第一优先级,这是 Tick 数据查询性能的基石。