回测太慢怎么办?我从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²)递归多标的多策略磁盘IO3000次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_dirPath(data_dir)self.cache_dirPath(cache_dir)self.cache_dir.mkdir(exist_okTrue)self._memory_cache:Dict[str,pd.DataFrame]{}defload(self,symbol:str,use_cacheTrue)-pd.DataFrame:# L1: 内存缓存ifsymbolinself._memory_cache:returnself._memory_cache[symbol]# L2: 磁盘缓存pickle比CSV快50倍cache_fileself.cache_dir/f{symbol}.pklifuse_cacheandcache_file.exists():dfpd.read_pickle(cache_file)else:# L3: 原始数据dfpd.read_csv(self.data_dir/f{symbol}.csv,parse_dates[date],index_coldate)# 预计算常用指标一起缓存dfself._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()returndfstaticmethoddef_calc_atr(df,period):high_lowdf[high]-df[low]high_close(df[high]-df[close].shift(1)).abs()low_close(df[low]-df[close].shift(1)).abs()true_rangepd.concat([high_low,high_close,low_close],axis1).max(axis1)returntrue_range.rolling(period).mean()效果3000只股票加载时间从32秒降到0.3秒100倍。预计算与Walk-Forward的关系这是一个容易搞混的点。我在第一版里犯过错——以为Walk-Forward意味着不能预计算指标于是每天重新算MA20。# 错误理解Walk-Forward每天重算foriinrange(len(df)):ma20df[close].iloc[:i1].rolling(20).mean().iloc[-1]# O(i) 每次重算# 正确理解先算好Walk-Forward只管可用性df[ma20]df[close].rolling(20).mean()# O(n) 一次算好foriinrange(len(df)):ifi20:valdf[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(i1,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 i1才能确认。 # 一次性标出所有顶底分型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_type0# 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_type1elifis_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)):prevfenxings[k-1]currfenxings[k]ifprev[type]!curr[type]:# 顶底交替# 检查独立K线条件ifcurr[idx]-prev[idx]4:# 至少4根K线含顶底各1bis.append(Bi(start_idxprev[idx],end_idxcurr[idx],start_priceprev[price],end_pricecurr[price],directionupifprev[type]-1elsedown))returnbis效果45秒 → 0.8秒56倍加速。但向量化的代价向量化把缠论的确认逻辑从递归确认改成了两遍遍历。这改变了笔的识别结果——在某些边界case下两者会算出不同的笔。这不是bug是缠论定义本身的歧义。不同的处理方式递归 vs 迭代对包含关系和笔延伸的处理不同。你需要做的是确保向量化版本和实盘逐日推进版本的结果一致。# 验证对比向量化 vs 逐日推进的笔识别结果defvalidate_vectorized(df,n_samples100):随机抽100个时间点对比两种实现的已确认笔vectorized_bisfind_bi_vectorized(df)for_inrange(n_samples):cutoffnp.random.randint(50,len(df)-50)sub_dfdf.iloc[:cutoff]v_bisfind_bi_vectorized(sub_df)w_bisfind_bi_walkforward(sub_df)# 只比较cutoff之前已确认的笔排除最后confirm_bars根confirmed_v[bforbinv_bisifb.end_idxcutoff-3]confirmed_w[bforbinw_bisifb.end_idxcutoff-3]iflen(confirmed_v)!len(confirmed_w):print(f⚠️ Mismatch at cutoff{cutoff}: vectorized{len(confirmed_v)}, wf{len(confirmed_w)})returnFalsereturnTrue第三轮消灭并发瓶颈多进程 vs 多线程Python有GILCPU密集型任务只能多进程。但多进程有坑frommultiprocessingimportPool,cpu_countimportsignaldefinit_worker():子进程初始化忽略SIGINT避免CtrlC时子进程全挂signal.signal(signal.SIGINT,signal.SIG_IGN)defparallel_backtest(symbols,strategy_cls,config,max_workersNone,chunksize10):多进程回测 设计细节 1. chunksize: 每个进程一次处理N只减少进程间通信开销 2. imap_unordered: 完成一个返回一个内存友好 3. 子进程独立创建策略对象避免共享状态 4. 优雅处理CtrlC ifmax_workersisNone:max_workersmin(cpu_count(),8)# 别占满所有核心def_backtest_one(symbol):strategystrategy_cls(config)# 每个进程独立创建dataDataProvider(data/).load(symbol)engineWalkForwardBacktester(strategy)returnengine.run(data,symbol)withPool(processesmax_workers,initializerinit_worker)aspool:results{}try:fori,(symbol,result)inenumerate(pool.imap_unordered(_backtest_one,symbols,chunksizechunksize)):results[symbol]resultif(i1)%1000:print(fProgress:{i1}/{len(symbols)})exceptKeyboardInterrupt:pool.terminate()print(Interrupted by user)returnresults效果10核MacBook Pro3000只股票从250小时降到35小时。7倍加速不是10倍因为进程间通信有开销。更大规模考虑Ray如果标的数量到了万级别比如全市场期货期权单机多进程不够了。阿里内部的离线计算用MaxCompute但交易回测需要更灵活的迭代——Ray是更好的选择importray ray.init(num_cpus16)ray.remotedefremote_backtest(symbol,strategy_config):Ray Remote — 跨机器分布式strategyChanLunStrategy(strategy_config)dataDataProvider(/shared/data/).load(symbol)engineWalkForwardBacktester(strategy)returnengine.run(data,symbol)# 提交全量任务futures[remote_backtest.remote(sym,config)forsyminall_symbols]resultsray.get(futures)# 阻塞等待全部完成Ray的优势跨机器扩展突破单机CPU上限ray.get按需取结果不用等全部完成内置对象存储避免重复序列化第四轮JIT加速热点Numba对纯数值计算效果极好但有限制——不能用Pandas不能用Python对象。fromnumbaimportnjitimportnumpyasnpnjit(cacheTrue,fastmathTrue)deffind_fenxing_numba(highs:np.ndarray,lows:np.ndarray):Numba编译的分型识别 — 纯数值无Python对象 性能比Pandas版本快50倍 代价只能用numpy array不能用DataFrame 适用缠论分型、ATR、均线等数值密集计算 不适用涉及日期索引、字符串操作、复杂对象 nlen(highs)topsnp.zeros(n,dtypenp.bool_)bottomsnp.zeros(n,dtypenp.bool_)foriinrange(1,n-1):# 顶分型高点比左右两根都高ifhighs[i]highs[i-1]andhighs[i]highs[i1]:tops[i]True# 底分型低点比左右两根都低iflows[i]lows[i-1]andlows[i]lows[i1]:bottoms[i]Truereturntops,bottomsnjit(cacheTrue)defcalc_atr_numba(highs,lows,closes,period):Numba版ATR计算nlen(highs)trnp.zeros(n)atrnp.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更快ifnperiod:atr[period]np.mean(tr[1:period1])foriinrange(period1,n):atr[i](atr[i-1]*(period-1)tr[i])/periodreturnatr效果分型识别和ATR计算各快50倍。四轮优化汇总轮次技术手段目标瓶颈加速倍数适用场景1数据预加载预计算Pickle缓存IO瓶颈100x所有场景2缠论算法向量化算法瓶颈56x缠论类非线性策略3多进程/Ray分布式并发瓶颈7x(10核)多标的批量回测4Numba 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— 优化的终极目标不是单次更快是从想法到验证更快这五条在交易系统和在搜索引擎里是一样的。技术会变元原则不会。