第一章: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就没了。
怎么压缩?我的做法是:
- 统一用int64时间戳:从1970-01-01开始的秒数或毫秒数。8字节搞定。
- 用基准时间+偏移量:如果数据是连续的,存一个基准时间,后面的只存偏移量。比如分钟级数据,偏移量用int32就够了。
- 按需转换:只在需要显示或计算时,才把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 知识体系总览
下面这张图,概括了本章的核心逻辑:
1.6 实战建议
说了这么多,总结几条我自己的经验:
- 别用Python原生对象存K线——那是给教学用的,不是给生产用的。
- NumPy结构化数组是标配——内存省、速度快、操作方便。
- float32够用就别上float64——省下来的内存可以多跑几个品种。
- 时间戳用int64+偏移量——简单有效,谁用谁知道。
最后说一句:内存优化不是炫技,是为了让你的回测引擎能跑更大的数据、更复杂的策略。我见过太多人,策略写得花里胡哨,结果数据一加载就OOM。先把数据层夯实了,后面的并行计算才有意义。