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,索引设计都直接影响查询性能。以下是三种常用索引策略的对比:

Tick 数据索引策略对比 B-tree 索引 按时间戳建立平衡树 范围查询 O(log n) 适合 HDF5 单表 写入时维护成本高 分区索引 按日期/品种物理分区 查询时跳过无关分区 适合 Parquet 列存 分区数过多反效果 混合索引 分区内建 B-tree 先分区裁剪,再索引定位 兼顾范围与随机查询 推荐生产环境使用 生产环境推荐:混合索引 HDF5:按品种分组 → 组内按日期建表 → 每表 timestamp 列建 B-tree 索引 Parquet:按 symbol+date 分区 → 每个 Parquet 文件内 timestamp 列排序存储 实测:混合索引比纯 B-tree 快 3-5 倍,比纯分区索引快 1.5-2 倍

4.6 存储方案架构总览

将上述技术整合到一套完整的 Tick 数据存储流水线中,整体架构如下:

Tick 数据存储流水线架构 原始 Tick 流 清洗 & 校验 存储引擎 查询接口 存储引擎内部策略 HDF5 分层存储 Parquet 分区存储 ZSTD 压缩 混合索引 数据流说明 1. 原始 Tick 流:从行情网关接收逐笔数据 2. 清洗校验:去重、时间戳对齐、异常值过滤 3. 存储引擎:按品种/日期分层或分区写入 4. 压缩策略:热数据 Snappy,冷数据 ZSTD 5. 索引优化:混合索引加速回测查询 6. 查询接口:统一 API 屏蔽底层格式差异

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 数据查询性能的基石。