当前位置: 首页 > news >正文

回测太慢怎么办?我从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_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核)多标的批量回测
4Numba JIT编译数值计算瓶颈50x循环密集的数值代码

综合效果:250小时 → 0.8小时


过早优化是万恶之源

这句话在阿里被说烂了,但在交易系统里有更深的含义。

我犯过一个错——花了三天用Cython重写缠论算法。结果每次改策略逻辑要重新编译,改三遍编译三遍,效率反而更低。

后来想明白:优化分两种——优化代码和优化迭代速度。

代码优化让单次回测更快。但迭代速度优化让你跑更多次实验、试更多策略、更快发现哪些路走不通。在交易领域,后者比前者值钱得多

正确的优化顺序:

1. 先跑通逻辑(Pandas写第一版) → 确保正确 2. 验证策略有价值(至少正收益) → 确保方向对 3. 再做向量化优化 → 提速但不改逻辑 4. 最后考虑多进程/JIT → 确认是CPU瓶颈

如果策略本身不赚钱,优化得再快也是更快地亏钱。

还有一个更微妙的点——优化可能引入bug。向量化改了笔识别逻辑,Numba不支持Pandas意味着你需要维护两套数据结构,分布式引入了进程间同步问题。每引入一层优化,你的系统复杂度就增加一个维度,测试负担也增加一个维度。

所以阿里的做法是:优化必须可 reversible——如果优化后发现结果不一致,一键回退到慢版本。保留原始实现作为 ground truth,优化版本的结果要跟它对齐。


性能优化的元原则

最后总结几条在阿里踩过无数坑后总结的元原则:

  1. Profile before optimize— 不做profile就优化是盲人摸象
  2. Correctness first, speed second— 快的错误比慢的正确更危险
  3. Optimize the bottleneck, not the hotspot— cProfile告诉你哪里慢,但不告诉你优化哪里性价比最高
  4. Keep a ground truth— 保留未优化版本,优化后的结果必须对齐
  5. Measure the iteration cycle— 优化的终极目标不是"单次更快",是"从想法到验证更快"

这五条在交易系统和在搜索引擎里是一样的。技术会变,元原则不会。

http://www.jsqmd.com/news/1123924/

相关文章:

  • AI模型中规划与执行分离:开启智能应用新范式
  • SonicNote聆犀AI录音卡 × Obsidian:让每一次对话,自动成为你的知识资产
  • HCIP的OSPF的拓展配置
  • Java面试通关①:Java基础核心全集
  • 多层软硬结合板,电路板界的“变形金刚”
  • OpenClaw:微信扫码即用的轻量级AI工作流中枢
  • 数据分析师核心技能树:Excel、SQL、PowerBI与Python实战学习路径
  • JavaQuestPlayer:5分钟学会QSP游戏开发的终极指南 [特殊字符]
  • 5分钟永久解锁Office:零风险激活Microsoft 365的终极指南
  • E-Hentai漫画收藏难题:如何一键打包下载完整画廊?
  • H5支付实战:后端生成表单与支付宝客户端唤起的无缝衔接
  • 智能问题跟踪_agent-issue-tracker
  • 代码审查评估_agent-reviewer
  • Video2X 6.0.0 终极指南:如何免费让模糊视频秒变4K高清
  • 2026,大一寸证件照手机制作指南:尺寸底色规范与多款工具实操教程
  • 嵌入式 C++ 开发实战指南——OOP、模板、异常、STL 在 MCU 上的取舍
  • 复变函数:拉普拉斯逆变换、常见性质、解微分方程的一般通法
  • 速掌柜ERP-TemuTikTok Shop专精跨境ERP
  • ax-M3 开源实测:部署、推理与基准测试全记录
  • windows网络适配器驱动开发-泛型分段卸载(上)
  • 2026中小企业ERP选型指南:6大主流系统深度对比测评
  • 【关注可白嫖源码】--课程设计+毕业设计+django大学生健康信息可视化管理系统[编号:project35522](案例分析)
  • TPA3128D2与dsPIC33EP音频系统设计与优化
  • postgresql
  • windows网络适配器驱动开发-泛型分段卸载(下)
  • Ventoy启动界面个性化:3步打造专属启动盘视觉盛宴
  • 三步搞定E-Hentai漫画收藏:免费批量下载终极指南
  • openeuler/riscv-kernel:RISC-V架构在openEuler的统一内核解决方案
  • openEuler-lsb故障排除:常见LSB兼容性问题解决方案
  • Enigma Virtual Box解包终极指南:3分钟掌握专业脱壳技巧