回测太慢怎么办?我从250小时优化到1小时的经历
回测太慢怎么办?我从250小时优化到1小时的经历
一个让P8也崩溃的数字
全市场3000多只股票,单策略每只回测5分钟:
3000 × 5分钟 = 15000分钟 =250小时= 超过10天
而我需要对比6个策略 × 多只股票。按这个速度,项目要以"季度"为单位了。
在阿里做搜索排序的时候,我处理过PB级数据的离线任务,优化过MapReduce的shuffle阶段。但那些是"离线的",跑慢了明天出结果就是了。交易回测不一样——你需要反复调参、反复验证,一个"10天后出结果"的系统,意味着你一次实验要等10天才能看到对错。
这不是性能问题,是迭代速度问题。迭代速度决定策略迭代质量,策略迭代质量决定你能不能赚钱。
诊断:瓶颈在哪
先不要猜,用数据说话。
cProfile 火焰图
$ python -m cProfile -s cumulative backtest.py ncalls tottime percall filename:lineno(function) 584 45.23% 0.077 chanlun.py:89(find_bi) ← 缠论笔识别 584000 38.12% 0.000 {method 'iloc' of DataFrame} ← Pandas切片 3000 32.45% 0.011 data.py:42(load_csv) ← 磁盘IO 12 8.3% 0.692 backtest.py:55(execute) ← 交易执行三个瓶颈清晰可见。但更有意思的是不同场景的瓶颈不一样:
| 场景 | 瓶颈 | 根因 |
|---|---|---|
| 少标的多策略 | 缠论算法 | O(n²)递归 |
| 多标的多策略 | 磁盘IO | 3000次read_csv |
| 海龟/均线类策略 | iloc切片 | 每次创建新DataFrame |
| 全市场扫描 | 全部 | 叠加效应 |
优化策略取决于瓶颈在哪里。不做profile就优化,等于蒙着眼打靶。
第一轮:消灭IO瓶颈
数据预加载
3000只股票每只读一次CSV,磁盘IO占了32%的时间。但这些数据在回测期间不会变。
importpicklefrompathlibimportPathfromfunctoolsimportlru_cachefromtypingimportDictclassDataProvider:"""数据提供层 —— 统一管理数据加载和缓存 设计原则: 1. 一次加载,全程复用 2. 按需加载,不全量塞内存 3. 格式统一,不管原始数据是CSV/Parquet/数据库 """def__init__(self,data_dir:str,cache_dir:str='.cache'):self.data_dir=Path(data_dir)self.cache_dir=Path(cache_dir)self.cache_dir.mkdir(exist_ok=True)self._memory_cache:Dict[str,pd.DataFrame]={}defload(self,symbol:str,use_cache=True)->pd.DataFrame:# L1: 内存缓存ifsymbolinself._memory_cache:returnself._memory_cache[symbol]# L2: 磁盘缓存(pickle比CSV快50倍)cache_file=self.cache_dir/f'{symbol}.pkl'ifuse_cacheandcache_file.exists():df=pd.read_pickle(cache_file)else:# L3: 原始数据df=pd.read_csv(self.data_dir/f'{symbol}.csv',parse_dates=['date'],index_col='date')# 预计算常用指标,一起缓存df=self._precompute_indicators(df)ifuse_cache:df.to_pickle(cache_file)self._memory_cache[symbol]=dfreturndfdef_precompute_indicators(self,df:pd.DataFrame)->pd.DataFrame:"""预计算指标 —— 与Walk-Forward不矛盾 关键认知:Walk-Forward约束的是'什么时候可以用', 不是'什么时候可以算'。你可以先全量算好MA20, Walk-Forward只管第i天能不能取df['ma20'].iloc[i]。 但注意:缠论类非线性指标不能预计算, 因为它们的定义依赖逐日推进的确认逻辑。 """df['ma5']=df['close'].rolling(5).mean()df['ma20']=df['close'].rolling(20).mean()df['ma60']=df['close'].rolling(60).mean()df['atr20']=self._calc_atr(df,20)df['vol20']=df['close'].pct_change().rolling(20).std()returndf@staticmethoddef_calc_atr(df,period):high_low=df['high']-df['low']high_close=(df['high']-df['close'].shift(1)).abs()low_close=(df['low']-df['close'].shift(1)).abs()true_range=pd.concat([high_low,high_close,low_close],axis=1).max(axis=1)returntrue_range.rolling(period).mean()效果:3000只股票加载时间从32秒降到0.3秒,100倍。
预计算与Walk-Forward的关系
这是一个容易搞混的点。我在第一版里犯过错——以为Walk-Forward意味着"不能预计算指标",于是每天重新算MA20。
# 错误理解Walk-Forward:每天重算foriinrange(len(df)):ma20=df['close'].iloc[:i+1].rolling(20).mean().iloc[-1]# O(i) 每次重算# 正确理解:先算好,Walk-Forward只管可用性df['ma20']=df['close'].rolling(20).mean()# O(n) 一次算好foriinrange(len(df)):ifi>=20:val=df['ma20'].iloc[i]# 当天收盘后当天MA可用# 如需更保守:用 iloc[i-1] 取昨天的MA原则:线性指标(MA/ATR/STD)可以全量预计算,非线性指标(缠论笔/中枢)必须逐日推进。区分标准——指标的计算是否改变了历史数据的解释。MA不改历史形态,缠论笔改了(因为后续K线可能使前面的笔消失)。
第二轮:消灭算法瓶颈
缠论笔识别:从O(n²)到O(n)
缠论笔识别是初版代码里最大的瓶颈——递归+双重循环,O(n²)复杂度。
# 原始实现:O(n²) — 每个分型都要遍历后续所有K线到下一个分型deffind_bi_recursive(klines):bis=[]foriinrange(len(klines)):ifis_top_fenxing(klines,i):forjinrange(i+1,len(klines)):ifis_bottom_fenxing(klines,j):ifhas_independent_k(klines,i,j):bis.append(Bi(i,j))breakreturnbis向量化重写:
deffind_bi_vectorized(df):"""向量化笔识别 O(n) — 两次遍历,递归改为迭代 注意:shift(-1)使用了下一根K线数据来确认分型。 这与第一篇的"前视偏差"并不矛盾—— 分型的定义本身要求"高点两侧各有一根更低的K线", 这个确认过程天然需要后续K线。 Walk-Forward中,我们在day i只能使用day i-1已确认的分型, 不使用day i当天的分型(因为day i的分型要到day i+1才能确认)。 """# 一次性标出所有顶底分型is_top=((df['high']>df['high'].shift(1))&(df['high']>df['high'].shift(-1)))is_bottom=((df['low']<df['low'].shift(1))&(df['low']<df['low'].shift(-1)))# 交替连接:顶→底→顶→底...fenxings=[]prev_type=0# 0=无, 1=顶, -1=底foriinrange(len(df)):ifis_top.iloc[i]andprev_type!=1:fenxings.append({'idx':i,'type':1,'price':df['high'].iloc[i]})prev_type=1elifis_bottom.iloc[i]andprev_type!=-1:fenxings.append({'idx':i,'type':-1,'price':df['low'].iloc[i]})prev_type=-1# 构建笔:相邻顶底分型连接bis=[]forkinrange(1,len(fenxings)):prev=fenxings[k-1]curr=fenxings[k]ifprev['type']!=curr['type']:# 顶底交替# 检查独立K线条件ifcurr['idx']-prev['idx']>=4:# 至少4根K线(含顶底各1)bis.append(Bi(start_idx=prev['idx'],end_idx=curr['idx'],start_price=prev['price'],end_price=curr['price'],direction='up'ifprev['type']==-1else'down'))returnbis效果:45秒 → 0.8秒,56倍加速。
但向量化的代价
向量化把缠论的确认逻辑从"递归确认"改成了"两遍遍历"。这改变了笔的识别结果——在某些边界case下,两者会算出不同的笔。
这不是bug,是缠论定义本身的歧义。不同的处理方式(递归 vs 迭代)对"包含关系"和"笔延伸"的处理不同。你需要做的是:确保向量化版本和实盘逐日推进版本的结果一致。
# 验证:对比向量化 vs 逐日推进的笔识别结果defvalidate_vectorized(df,n_samples=100):"""随机抽100个时间点,对比两种实现的已确认笔"""vectorized_bis=find_bi_vectorized(df)for_inrange(n_samples):cutoff=np.random.randint(50,len(df)-50)sub_df=df.iloc[:cutoff]v_bis=find_bi_vectorized(sub_df)w_bis=find_bi_walkforward(sub_df)# 只比较cutoff之前已确认的笔(排除最后confirm_bars根)confirmed_v=[bforbinv_bisifb.end_idx<cutoff-3]confirmed_w=[bforbinw_bisifb.end_idx<cutoff-3]iflen(confirmed_v)!=len(confirmed_w):print(f"⚠️ Mismatch at cutoff={cutoff}: vectorized={len(confirmed_v)}, wf={len(confirmed_w)}")returnFalsereturnTrue第三轮:消灭并发瓶颈
多进程 vs 多线程
Python有GIL,CPU密集型任务只能多进程。但多进程有坑:
frommultiprocessingimportPool,cpu_countimportsignaldefinit_worker():"""子进程初始化:忽略SIGINT,避免Ctrl+C时子进程全挂"""signal.signal(signal.SIGINT,signal.SIG_IGN)defparallel_backtest(symbols,strategy_cls,config,max_workers=None,chunksize=10):"""多进程回测 设计细节: 1. chunksize: 每个进程一次处理N只,减少进程间通信开销 2. imap_unordered: 完成一个返回一个,内存友好 3. 子进程独立创建策略对象,避免共享状态 4. 优雅处理Ctrl+C """ifmax_workersisNone:max_workers=min(cpu_count(),8)# 别占满所有核心def_backtest_one(symbol):strategy=strategy_cls(config)# 每个进程独立创建data=DataProvider('data/').load(symbol)engine=WalkForwardBacktester(strategy)returnengine.run(data,symbol)withPool(processes=max_workers,initializer=init_worker)aspool:results={}try:fori,(symbol,result)inenumerate(pool.imap_unordered(_backtest_one,symbols,chunksize=chunksize)):results[symbol]=resultif(i+1)%100==0:print(f"Progress:{i+1}/{len(symbols)}")exceptKeyboardInterrupt:pool.terminate()print("Interrupted by user")returnresults效果:10核MacBook Pro,3000只股票从250小时降到35小时。7倍加速(不是10倍,因为进程间通信有开销)。
更大规模:考虑Ray
如果标的数量到了万级别(比如全市场+期货+期权),单机多进程不够了。阿里内部的离线计算用MaxCompute,但交易回测需要更灵活的迭代——Ray是更好的选择:
importray ray.init(num_cpus=16)@ray.remotedefremote_backtest(symbol,strategy_config):"""Ray Remote — 跨机器分布式"""strategy=ChanLunStrategy(strategy_config)data=DataProvider('/shared/data/').load(symbol)engine=WalkForwardBacktester(strategy)returnengine.run(data,symbol)# 提交全量任务futures=[remote_backtest.remote(sym,config)forsyminall_symbols]results=ray.get(futures)# 阻塞等待全部完成Ray的优势:
- 跨机器扩展,突破单机CPU上限
ray.get按需取结果,不用等全部完成- 内置对象存储,避免重复序列化
第四轮:JIT加速热点
Numba对纯数值计算效果极好,但有限制——不能用Pandas,不能用Python对象。
fromnumbaimportnjitimportnumpyasnp@njit(cache=True,fastmath=True)deffind_fenxing_numba(highs:np.ndarray,lows:np.ndarray):"""Numba编译的分型识别 — 纯数值,无Python对象 性能:比Pandas版本快50倍 代价:只能用numpy array,不能用DataFrame 适用:缠论分型、ATR、均线等数值密集计算 不适用:涉及日期索引、字符串操作、复杂对象 """n=len(highs)tops=np.zeros(n,dtype=np.bool_)bottoms=np.zeros(n,dtype=np.bool_)foriinrange(1,n-1):# 顶分型:高点比左右两根都高ifhighs[i]>highs[i-1]andhighs[i]>highs[i+1]:tops[i]=True# 底分型:低点比左右两根都低iflows[i]<lows[i-1]andlows[i]<lows[i+1]:bottoms[i]=Truereturntops,bottoms@njit(cache=True)defcalc_atr_numba(highs,lows,closes,period):"""Numba版ATR计算"""n=len(highs)tr=np.zeros(n)atr=np.zeros(n)foriinrange(1,n):tr[i]=max(highs[i]-lows[i],abs(highs[i]-closes[i-1]),abs(lows[i]-closes[i-1]))# 累积平均(比rolling更快)ifn>period:atr[period]=np.mean(tr[1:period+1])foriinrange(period+1,n):atr[i]=(atr[i-1]*(period-1)+tr[i])/periodreturnatr效果:分型识别和ATR计算各快50倍。
四轮优化汇总
| 轮次 | 技术手段 | 目标瓶颈 | 加速倍数 | 适用场景 |
|---|---|---|---|---|
| 1 | 数据预加载+预计算+Pickle缓存 | IO瓶颈 | 100x | 所有场景 |
| 2 | 缠论算法向量化 | 算法瓶颈 | 56x | 缠论类非线性策略 |
| 3 | 多进程/Ray分布式 | 并发瓶颈 | 7x(10核) | 多标的批量回测 |
| 4 | Numba JIT编译 | 数值计算瓶颈 | 50x | 循环密集的数值代码 |
综合效果:250小时 → 0.8小时。
过早优化是万恶之源
这句话在阿里被说烂了,但在交易系统里有更深的含义。
我犯过一个错——花了三天用Cython重写缠论算法。结果每次改策略逻辑要重新编译,改三遍编译三遍,效率反而更低。
后来想明白:优化分两种——优化代码和优化迭代速度。
代码优化让单次回测更快。但迭代速度优化让你跑更多次实验、试更多策略、更快发现哪些路走不通。在交易领域,后者比前者值钱得多。
正确的优化顺序:
1. 先跑通逻辑(Pandas写第一版) → 确保正确 2. 验证策略有价值(至少正收益) → 确保方向对 3. 再做向量化优化 → 提速但不改逻辑 4. 最后考虑多进程/JIT → 确认是CPU瓶颈如果策略本身不赚钱,优化得再快也是更快地亏钱。
还有一个更微妙的点——优化可能引入bug。向量化改了笔识别逻辑,Numba不支持Pandas意味着你需要维护两套数据结构,分布式引入了进程间同步问题。每引入一层优化,你的系统复杂度就增加一个维度,测试负担也增加一个维度。
所以阿里的做法是:优化必须可 reversible——如果优化后发现结果不一致,一键回退到慢版本。保留原始实现作为 ground truth,优化版本的结果要跟它对齐。
性能优化的元原则
最后总结几条在阿里踩过无数坑后总结的元原则:
- Profile before optimize— 不做profile就优化是盲人摸象
- Correctness first, speed second— 快的错误比慢的正确更危险
- Optimize the bottleneck, not the hotspot— cProfile告诉你哪里慢,但不告诉你优化哪里性价比最高
- Keep a ground truth— 保留未优化版本,优化后的结果必须对齐
- Measure the iteration cycle— 优化的终极目标不是"单次更快",是"从想法到验证更快"
这五条在交易系统和在搜索引擎里是一样的。技术会变,元原则不会。
