第一章:K线数据压缩——回测引擎的“瘦身”必修课

做量化回测,最头疼的是什么?

我个人的经验是——数据量太大,内存撑不住。

你想想看,一个简单的日线回测,沪深300跑个10年,也就几千根K线。但一旦切到分钟级,甚至Tick级,数据量直接爆炸。我见过不少团队,回测引擎写得很漂亮,结果一加载数据,内存直接飙到几十个G,还没开始算就崩了。

所以,K线数据压缩,是回测引擎优化的第一道坎。今天我们就来聊聊,怎么把OHLC数据“塞”进更小的内存里。

1.1 OHLC数据存储的“原罪”

先看一个典型的OHLC结构:

# 传统Python对象存储(最占内存)
class Bar:
    def __init__(self, open, high, low, close, volume, timestamp):
        self.open = open
        self.high = high
        self.low = low
        self.close = close
        self.volume = volume
        self.timestamp = timestamp

每个Python对象都有额外的内存开销(对象头、引用计数等)。100万根K线,光对象头就能吃掉几百MB。这还不算数据本身。

核心问题:Python对象的内存开销远大于数据本身。一个float64在Python里可能占24字节,但实际数据只需要8字节。

我曾经接手过一个回测项目,对方用列表存了200万根1分钟K线,每个K线是一个字典。你猜内存用了多少?接近3个G。而实际数据量,算下来不到200MB。剩下的全是Python的“包装费”。

1.2 用NumPy结构化数组“瘦身”

怎么解决?我的建议是——用NumPy结构化数组。

它把数据连续地存在内存里,没有对象头,没有引用计数。说白了,就是C语言级别的紧凑存储。

import numpy as np

# 定义结构化数据类型
dtype = np.dtype([
    ('open', 'f4'),      # float32
    ('high', 'f4'),
    ('low', 'f4'),
    ('close', 'f4'),
    ('volume', 'i4'),    # int32
    ('timestamp', 'i8')  # int64 时间戳
])

# 创建结构化数组
bars = np.zeros(1000000, dtype=dtype)

# 赋值示例
bars['open'] = np.random.rand(1000000) * 100
bars['high'] = bars['open'] + np.random.rand(1000000) * 5
bars['low'] = bars['open'] - np.random.rand(1000000) * 5
bars['close'] = bars['open'] + np.random.rand(1000000) * 2 - 1
bars['volume'] = np.random.randint(1000, 100000, 1000000)
bars['timestamp'] = np.arange(1000000) * 60  # 假设每分钟一根

看到没?数据是连续存储的。100万根K线,内存占用大概在:

  • 4个float32 × 4字节 = 16字节
  • 1个int32 × 4字节 = 4字节
  • 1个int64 × 8字节 = 8字节
  • 合计:28字节每根K线
  • 100万根:约28MB

对比之前的3个G,压缩了100倍以上。嗯,这才是回测引擎该有的样子。

小技巧:如果你不需要超高精度,OHLC用float32就够了。股票价格一般小数点后两位,float32的精度(约7位有效数字)完全够用。我实测过,float32和float64的回测结果差异在万分之0.1以内,几乎可以忽略。

1.3 数据精度取舍:float32 vs float64

这里有个常见的争论:用float32会不会损失精度?

我的看法是——看场景。

场景 推荐精度 原因
日线/周线回测 float32 价格范围小,精度足够
分钟级回测 float32 波动不大,float32够用
Tick级回测 float64 价格跳动频繁,累积误差可能放大
跨品种价差计算 float64 价差可能很小,需要高精度

我曾经在回测一个高频策略时,用了float32,结果发现累计收益和float64差了0.3%。查了半天,原来是价差计算时,两个接近的float32相减,有效位数丢失了。从那以后,涉及价差或比率的计算,我统一用float64。

注意:不要盲目用float64。100万根K线,float64比float32多占一倍内存(56MB vs 28MB)。如果你的回测数据量上亿,这个差距就是几个G。我的原则是:能用float32的地方绝不用float64,但关键计算节点必须double。

1.4 时间戳压缩技巧

时间戳是另一个内存大户。Python的datetime对象,一个就占40多字节。100万个datetime,40MB就没了。

怎么压缩?我的做法是:

  1. 统一用int64时间戳:从1970-01-01开始的秒数或毫秒数。8字节搞定。
  2. 用基准时间+偏移量:如果数据是连续的,存一个基准时间,后面的只存偏移量。比如分钟级数据,偏移量用int32就够了。
  3. 按需转换:只在需要显示或计算时,才把int64转成datetime。平时全用整数。
# 基准时间+偏移量压缩
base_timestamp = 946684800  # 2000-01-01 00:00:00 UTC
offsets = np.arange(1000000, dtype='i4')  # 只占4字节
timestamps = base_timestamp + offsets * 60  # 每分钟

# 实际存储时,只存offsets和base_timestamp
# 100万个时间戳从8MB降到4MB

这个技巧我在一个Tick级回测项目中用过。原始数据有5000万条Tick,时间戳用datetime存,占了2个G。改成int64后降到400MB,再用偏移量压缩,最终只用了200MB。内存占用直接砍半。

1.5 知识体系总览

下面这张图,概括了本章的核心逻辑:

K线数据压缩知识体系 OHLC数据压缩 NumPy结构化数组 float32 vs float64 时间戳压缩技巧 连续内存存储 无对象头开销 日线用float32 价差用float64 int64时间戳 偏移量压缩 目标:内存占用降低80%以上,回测速度提升5-10倍

1.6 实战建议

说了这么多,总结几条我自己的经验:

  • 别用Python原生对象存K线——那是给教学用的,不是给生产用的。
  • NumPy结构化数组是标配——内存省、速度快、操作方便。
  • float32够用就别上float64——省下来的内存可以多跑几个品种。
  • 时间戳用int64+偏移量——简单有效,谁用谁知道。

最后说一句:内存优化不是炫技,是为了让你的回测引擎能跑更大的数据、更复杂的策略。我见过太多人,策略写得花里胡哨,结果数据一加载就OOM。先把数据层夯实了,后面的并行计算才有意义。

专注资料整理