数据结构选型:列表 vs 数组 vs 元组 vs 字典、collections模块、内存布局与缓存友好性
做回测系统,说白了就是在跟时间赛跑。你想想看,一次回测可能要遍历几百万条 tick 数据,选错数据结构,性能直接腰斩。我早年吃过这个亏,一个简单的 OHLC 回测,用列表硬扛,跑了整整四十分钟。后来换成数组,十分钟搞定。嗯,今天我们就来聊聊这个。
一、Python 原生数据结构:谁适合做回测?
先看最常用的几个:列表、元组、字典。它们各有脾气,用错了地方,代价不小。
1. 列表(list)
列表是动态数组,支持随机访问。我习惯用它来存订单队列或者中间计算结果。但要注意,列表的 append 操作是 O(1) 均摊,可如果在头部插入或删除,那就是 O(n) 了。
核心结论:列表适合尾部追加、随机读取的场景。不适合频繁插入删除。
# 回测中常见的用法:存储成交记录
trades = []
for tick in ticks:
if tick.price > threshold:
trades.append(tick) # 尾部追加,没问题
2. 元组(tuple)
元组是不可变的。我经常用它来打包一组固定字段,比如 (时间戳, 开盘价, 最高价, 最低价, 收盘价)。为什么?因为不可变意味着哈希,可以当字典的键。
我的习惯:如果数据在生成后不会修改,优先用元组。它比列表更省内存,访问也更快。
# 用元组表示一根K线
kline = (timestamp, open_p, high_p, low_p, close_p)
kline_dict[kline] = volume # 元组可哈希,没问题
3. 字典(dict)
字典是哈希表,查找 O(1)。在回测里,我常用它来维护持仓字典:{'AAPL': 100, 'GOOG': 200}。但要注意,字典的内存开销比列表大得多。如果你有上百万个键值对,内存会爆炸。
避坑指南:我曾经在回测里用字典存逐笔成交数据,结果内存飙到 8GB。后来换成数组,降到 1.2GB。字典虽好,别贪杯。
4. 数组(array vs numpy.ndarray)
Python 自带的 array 模块,说实话我用得不多。它比列表省内存,但功能有限。真正的大杀器是 NumPy 的 ndarray。
为什么?因为 NumPy 数组在内存里是连续存储的。你想想看,CPU 读取数据时,会一次性把相邻的数据都加载到缓存里。连续的内存布局,缓存命中率极高。这就是所谓的「缓存友好性」。
性能对比(我实测过):遍历 1000 万条价格数据,列表耗时 0.8 秒,NumPy 数组耗时 0.05 秒。差了 16 倍。
import numpy as np
# 用 NumPy 数组存价格序列
prices = np.array([100.0, 101.5, 102.3, ...], dtype=np.float64)
# 向量化计算,比循环快一个数量级
returns = np.diff(prices) / prices[:-1]
二、collections 模块:回测中的瑞士军刀
Python 标准库的 collections 模块,我几乎每个回测项目都会用到。它提供了几个非常实用的数据结构。
1. deque:双端队列
deque 是双向链表实现的,两端插入和删除都是 O(1)。在回测里,我常用它来实现滑动窗口,比如计算过去 N 笔交易的移动平均。
我的经验:用列表做滑动窗口,每次 pop(0) 都是 O(n),数据量大了根本扛不住。deque 才是正解。
from collections import deque
# 维护一个长度为 100 的滑动窗口
window = deque(maxlen=100)
for price in price_stream:
window.append(price)
if len(window) == 100:
ma = sum(window) / 100
# 处理信号...
2. defaultdict:带默认值的字典
回测里经常要按股票代码分组统计。用普通字典,每次都要先检查键是否存在。用 defaultdict,省心多了。
from collections import defaultdict
# 按股票代码统计成交量
volume_by_symbol = defaultdict(int)
for trade in trades:
volume_by_symbol[trade.symbol] += trade.volume
# 直接访问,不存在就返回 0
print(volume_by_symbol['AAPL']) # 不会报 KeyError
注意:defaultdict 的默认值工厂函数只会在键不存在时调用。如果你传了一个可变对象(比如 list),所有新键会共享同一个列表。我曾经踩过这个坑,排查了半天。
三、内存布局与缓存友好性
这部分有点底层,但理解了它,你就能写出真正高性能的回测代码。
1. 什么是缓存友好?
CPU 读取内存时,不是只读一个字节,而是读一整块(缓存行,通常 64 字节)。如果你的数据在内存里是连续存放的,CPU 一次加载就能用到后续好几个数据。这就是缓存友好。
反过来,如果你的数据是散乱存放的(比如链表、Python 对象列表),CPU 每次都要重新加载缓存行,性能就差了。
2. Python 对象的内存布局
Python 的列表里存的是指针,每个指针指向一个 PyObject。这些 PyObject 在内存里是散乱分布的。所以遍历 Python 列表,缓存命中率很低。
而 NumPy 数组里存的是真正的数值,连续排列。遍历时,CPU 可以预取,速度自然快。
一句话总结:能用 NumPy 数组,就别用 Python 列表。能用元组,就别用列表。能用简单类型,就别用复杂对象。
3. 实战中的选择策略
我一般这样选型:
- 存储价格序列:NumPy 数组,float64
- 存储订单簿:deque,因为要频繁插入删除
- 存储持仓:字典,因为要按股票代码快速查找
- 存储成交记录:列表,尾部追加,偶尔遍历
- 存储固定字段的 K 线:元组,省内存且可哈希
四、知识体系总览
下面这张图,是我自己梳理的数据结构选型决策流程。你可以照着它来选,基本不会出错。
五、总结
数据结构选型,没有银弹。你得根据数据的访问模式、修改频率、内存要求来权衡。我个人习惯是:
- 能静态的,就别动态(元组优于列表)
- 能连续的,就别散乱(NumPy 优于 Python 列表)
- 能简单的,就别复杂(int 优于对象)
记住,回测系统的瓶颈往往不在算法逻辑,而在数据结构的选型上。选对了,事半功倍;选错了,事倍功半。
最后一个小建议:写回测代码时,先用简单数据结构把逻辑跑通。然后做性能分析,找到热点,再针对性优化。别一开始就追求极致性能,那样容易把代码写死。
公众号:蓝海资料掘金营,微信deep3321