2. 数据加载优化:用bcolz替代CSV,实现列式存储与快速读取
做回测的朋友都知道,数据加载往往是第一个瓶颈。我早期用Zipline跑策略,数据量一大,光是读CSV就能等上几分钟。后来我换成了bcolz,读取速度直接提升了10倍以上。今天咱们就聊聊这个优化点。
2.1 为什么CSV会成为瓶颈?
CSV是文本格式,存的是字符串。每次读取都要做类型转换,把"2023-01-01"转成日期,把"123.45"转成浮点数。这个过程非常慢。而且CSV是行式存储,你想取某一列的数据,得把整行都读进来。
举个例子,你有一个100万行、10列的数据集。用CSV存,文件大小可能200MB。每次回测都要从头解析一遍。我见过有人回测一次,光数据加载就占了总时间的40%。
核心问题:CSV的I/O和解析开销,在数据量增大时呈线性增长。对于高频策略或长周期回测,这个开销不可忽视。
3.2 bcolz是什么?
bcolz是一种列式压缩存储格式。它把数据按列存储,每列独立压缩。读取时只需要加载你需要的列,不需要的列直接跳过。这正好符合量化回测的场景——你通常只需要open、high、low、close、volume这几列。
我个人习惯把bcolz看作"带压缩的NumPy数组"。它底层用的是Blosc压缩算法,专门针对数值型数据做了优化。压缩比通常在2-5倍,读取速度比CSV快一个数量级。
小提示:bcolz支持内存映射(memory-mapped),这意味着你可以读取比物理内存还大的数据集。这在处理多年分钟级数据时特别有用。
2.3 如何将CSV转换为bcolz?
转换过程很简单。我一般写一个脚本,一次性把历史数据全部转成bcolz格式。之后回测就直接读bcolz,不再碰CSV。
import bcolz
import pandas as pd
# 读取CSV
df = pd.read_csv('data/AAPL.csv', parse_dates=['date'])
# 转换为bcolz
# 注意:bcolz要求数据按日期排序
df = df.sort_values('date')
# 创建bcolz carray
carrays = {}
for col in df.columns:
if col == 'date':
# 日期列单独处理,转为int64
carrays[col] = bcolz.carray(
df[col].values.astype('int64'),
chunklen=10000
)
else:
carrays[col] = bcolz.carray(
df[col].values,
chunklen=10000
)
# 保存到磁盘
bcolz.ctable(
rootdir='data/bcolz/AAPL.bcolz',
**carrays
).flush()
嗯,这里要注意一点:bcolz的chunklen参数会影响压缩率和读取速度。我试过不同值,发现10000左右效果最好。太小了压缩率低,太大了内存占用高。
2.4 在Zipline中集成bcolz
Zipline默认使用CSV数据源。要换成bcolz,需要自定义一个数据加载器。我封装了一个简单的类,直接替换原来的CSV加载逻辑。
import bcolz
import pandas as pd
from zipline.data.bundles.core import register
class BcolzDataLoader:
def __init__(self, data_dir):
self.data_dir = data_dir
def load_equity(self, sid, columns=None):
"""加载单只股票的数据"""
path = f"{self.data_dir}/{sid}.bcolz"
ct = bcolz.open(rootdir=path)
if columns is None:
columns = ['open', 'high', 'low', 'close', 'volume']
# 只读取需要的列
data = {}
for col in columns:
data[col] = ct[col][:]
# 日期列单独处理
dates = pd.to_datetime(ct['date'][:])
return pd.DataFrame(data, index=dates)
def load_all_equities(self, sids, columns=None):
"""批量加载,支持并行"""
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=4) as executor:
futures = {
executor.submit(self.load_equity, sid, columns): sid
for sid in sids
}
result = {}
for future in futures:
sid = futures[future]
result[sid] = future.result()
return result
避坑指南:我曾经在批量加载时没注意线程安全,导致数据错乱。bcolz的ctable对象不是线程安全的,每个线程必须独立打开文件句柄。上面的代码中,每个load_equity调用都会重新打开bcolz文件,这样就避免了共享状态的问题。
2.5 性能对比:CSV vs bcolz
我用真实数据做过对比测试。数据集是标普500成分股5年的日线数据,约120万行。结果如下:
| 指标 | CSV | bcolz | 提升倍数 |
|---|---|---|---|
| 文件大小 | 850 MB | 210 MB | 4.0x |
| 单次全量加载 | 12.3 秒 | 1.1 秒 | 11.2x |
| 单列读取(close) | 12.3 秒 | 0.3 秒 | 41.0x |
| 内存占用 | 2.1 GB | 0.5 GB | 4.2x |
你想想看,单列读取快了41倍。这意味着如果你的策略只用到close和volume两列,bcolz可以几乎瞬间完成数据加载。而CSV还得老老实实把整行都解析一遍。
2.6 核心逻辑流程图
下面这张图展示了bcolz替代CSV后的数据流变化。左边是传统CSV流程,右边是bcolz优化后的流程。关键区别在于:bcolz跳过了解析步骤,直接读取压缩后的二进制数据。
2.7 实际使用中的注意事项
bcolz虽然好,但也不是银弹。我总结了几点经验:
- 数据更新:bcolz不支持原地追加数据。如果你每天要更新行情,需要重新创建ctable。我一般用定时任务,每天收盘后重建一次。
- 多进程兼容:bcolz的ctable在fork后可能出问题。如果你用multiprocessing做并行回测,记得在每个子进程中独立打开文件。
- 版本兼容:bcolz的版本更新有时会改变文件格式。我建议固定版本号,或者在升级后重新生成所有数据文件。
我的习惯:我会在数据目录里放一个version.txt文件,记录bcolz的版本和生成时间。这样万一出问题,能快速定位是数据问题还是代码问题。
说白了,bcolz就是给量化回测量身定做的存储格式。它牺牲了写入性能(写入比CSV慢),换来了读取性能的飞跃。而回测场景恰恰是"一次写入,多次读取",这个取舍非常划算。
我个人建议,如果你的回测数据超过100万行,或者策略需要频繁读取不同列组合,bcolz是性价比最高的优化方案。没有之一。