1. 性能瓶颈分析:用cProfile和line_profiler定位回测中的慢函数
做量化回测,最怕什么?
我最怕的就是策略逻辑写好了,一跑回测,等半天不出结果。你想想看,一个策略从想法到验证,中间要调参数、改逻辑、加条件,如果每次回测都要等上十几分钟甚至半小时,那这活儿根本没法干。
所以,性能优化这件事,不是锦上添花,是刚需。
但优化之前,你得先知道——到底慢在哪。
1.1 别靠猜,要拿数据说话
我见过不少同行,觉得回测慢就去优化数据读取,或者换数据结构。结果折腾半天,性能提升不到5%。
为什么?因为根本没找到真正的瓶颈。
性能优化有个铁律:先测量,再优化。你连慢在哪都不知道,优化就是瞎猫碰死耗子。
Python里有两个工具,我几乎天天用:cProfile 和 line_profiler。一个帮你从宏观上定位哪个函数最耗时,一个帮你从微观上看到底是哪一行代码在拖后腿。
核心原则:不要优化你猜测的瓶颈,要优化你测量出来的瓶颈。
1.2 cProfile:快速找到最耗时的函数
cProfile 是Python自带的性能分析器。说白了,它就是个计时器,能记录每个函数被调用了多少次、花了多少时间。
用法很简单。假设你的回测入口函数是 run_backtest(),你只需要这样跑:
import cProfile
import pstats
# 运行性能分析
cProfile.run('run_backtest()', 'profile_stats.prof')
# 读取分析结果
p = pstats.Stats('profile_stats.prof')
p.sort_stats('cumtime').print_stats(20) # 按累计耗时排序,显示前20个
跑完之后,你会看到一张表。我重点看两列:
- cumtime:累计耗时。这个函数本身加上它调用的所有子函数的总时间。
- ncalls:调用次数。有些函数本身不慢,但被调了几十万次,加起来就慢了。
举个例子。我之前优化一个双均线策略的回测,跑完cProfile发现,有个叫 calculate_sma() 的函数,累计耗时占了总时间的68%。
嗯,问题找到了。
小技巧:我习惯先用 sort_stats('cumtime') 看总耗时,再用 sort_stats('ncalls') 看调用次数。两个维度交叉对比,更容易发现问题。
1.3 line_profiler:精确到每一行代码
cProfile能告诉你哪个函数慢,但有时候还不够。比如你知道了 calculate_sma() 慢,但它是哪一行慢?是循环?是计算?还是数据访问?
这时候就该 line_profiler 上场了。
它需要额外安装:
pip install line_profiler
用法也很直接。在你怀疑的函数前面加个 @profile 装饰器:
@profile
def calculate_sma(prices, window):
sma = []
for i in range(len(prices)):
if i < window:
sma.append(None)
else:
sma.append(sum(prices[i-window:i]) / window)
return sma
然后这样运行:
kernprof -l -v backtest.py
输出结果会精确到每一行,告诉你:
- Line #:行号
- Hits:这行代码被执行了多少次
- Time:这行代码花了多少时间
- % Time:这行代码占函数总时间的百分比
我记得有一次,我优化一个因子计算函数。cProfile告诉我这个函数占了40%的时间,但代码看起来没什么问题。用line_profiler一查,发现有一行 df.loc[idx, 'factor'] = ... 占了其中80%的时间。
为什么?因为 loc 在循环里被反复调用,每次都要做索引查找。改成 df.iloc 或者直接用numpy数组,速度直接提升了5倍。
注意:line_profiler的装饰器 @profile 只在用 kernprof 运行时才生效。如果你直接 python backtest.py,它会报错。我一般会在文件开头加个条件判断:
try:
@profile
def dummy(): pass
except NameError:
def profile(func): return func
1.4 实战:一个回测慢函数的定位过程
咱们来个完整的例子。假设你有一个简单的回测函数:
def run_backtest(prices, signals):
capital = 100000
position = 0
for i in range(len(prices)):
if signals[i] == 1 and position == 0:
position = capital // prices[i]
capital -= position * prices[i]
elif signals[i] == -1 and position > 0:
capital += position * prices[i]
position = 0
return capital
跑cProfile之后,发现这个函数本身耗时最多。再用line_profiler一看:
| 行号 | Hits | Time (μs) | % Time | 代码 |
|---|---|---|---|---|
| 5 | 100000 | 4500 | 5% | if signals[i] == 1 and position == 0: |
| 6 | 30000 | 12000 | 13% | position = capital // prices[i] |
| 7 | 30000 | 65000 | 72% | capital -= position * prices[i] |
| 9 | 20000 | 9000 | 10% | capital += position * prices[i] |
看到了吗?第7行占了72%的时间。为什么?因为 position * prices[i] 这个乘法本身不慢,但 capital -= ... 这个赋值操作在Python层面有类型检查、内存分配等开销。
怎么优化?把 capital 和 position 改成Python的 float 类型?其实更狠的做法是——用numpy向量化操作,彻底干掉这个循环。
我的经验:回测中90%的性能瓶颈都在循环里。要么是循环次数太多,要么是循环内部做了不该做的事。line_profiler能帮你一眼看穿。
1.5 知识体系:性能分析的核心逻辑
说了这么多,咱们用一张图把整个流程串起来:
1.6 避坑指南
最后,分享几个我踩过的坑:
- 别在优化前加多进程。我曾经一上来就搞多进程,结果发现瓶颈在I/O,多进程反而增加了上下文切换的开销。先分析,再动手。
- cProfile本身有开销。它会让程序变慢一些,但不会影响相对耗时比例。放心用。
- line_profiler的结果要结合上下文看。有时候一行代码耗时高,是因为它被调了100万次,而不是它本身慢。别误杀了无辜的代码。
- 别忘了Python内置的
timeit。对于微小的代码片段,timeit比cProfile更精确。我一般用它来对比两种写法的性能差异。
好了,这一章就到这里。工具给你了,方法也讲了。下次回测跑得慢,别急着改代码,先跑一遍cProfile看看。