news 2026/10/4 9:00:51

七日量化回测入门(四)Backtrader 双均线回测告别未来函数

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
七日量化回测入门(四)Backtrader 双均线回测告别未来函数

1. 引言

在量化回测中,未来函数(Look-ahead Bias)是导致回测结果虚高、实盘却亏损的头号杀手。它的本质是:在计算当天交易信号时,无意中使用了当天收盘后(甚至未来)才产生的数据。

正确做法是:所有交易信号必须基于 T-1 及之前的数据。在 Backtrader 中,通过负索引访问历史数据,例如self.data.close[-1]表示昨日收盘价。永远不要用[0]来决定当天的交易信号,因为[0]代表当前正在形成的 K 线,其收盘价在当天收盘前是未知的。

本文将以工业**(sh.601138)为例,演示如何用 Baostock 拉取数据、用 Backtrader 构建双均线策略,并重点修复代码中的未来函数隐患。

2. 数据获取与清洗

首先,使用 Baostock 拉取日线数据,并完成类型转换与索引设置。

importbaostockasbsimportpandasaspdimportbacktraderasbt# 登录print(">>>1.准备登录",flush=True)lg=bs.login()print(f">>>2.登录返回:{lg.error_code}{lg.error_msg}",flush=True)# 拉取数据print(">>>3.开始查询",flush=True)rs=bs.query_history_k_data_plus("sh.601138","date,code,open,high,low,close,volume,amount",start_date='2026-01-01',end_date='2026-09-30',frequency="d",adjustflag="2")print(">>>4.查询返回",flush=True)# 转 DataFramedata_list=[]while(rs.error_code=='0')&rs.next():data_list.append(rs.get_row_data())print(f">>>5.拿到{len(data_list)}",flush=True)df=pd.DataFrame(data_list,columns=rs.fields)# -------------------------- 关键修复1:全字段类型转换 --------------------------# 把所有空字符串替换成NaN,避免字符串残留df=df.replace('',pd.NA)# 把所有行情数值字段一次性转为float类型,覆盖所有Backtrader需要用到的列numeric_cols=['open','high','low','close','volume','amount']forcolinnumeric_cols:df[col]=pd.to_numeric(df[col],errors='coerce')# 日期字段转为datetime类型df['date']=pd.to_datetime(df['date'],errors='coerce')# -------------------------- 关键修复2:设置日期为索引 --------------------------# Backtrader的PandasData默认优先用索引作为datetime列,这是最不容易出错的配置df=df.sort_values('date').reset_index(drop=True)df.set_index('date',inplace=True)# 清洗掉所有带缺失值的行,避免后续计算异常df.dropna(subset=numeric_cols,inplace=True)print(df.tail())

3. 策略定义:信号只用 T-1 数据

这是本文的核心。在next()方法中,所有决策都基于self.data.close[-1](昨日收盘价)和均线指标,绝不使用self.data.close[0](当前未收盘的K线)。

classDualMAStrategy(bt.Strategy):params=(('fast_period',3),# 短期均线('slow_period',20),# 长期均线)def__init__(self):self.fast_ma=bt.indicators.SimpleMovingAverage(self.data.close(-1),period=self.params.fast_period)self.slow_ma=bt.indicators.SimpleMovingAverage(self.data.close(-1),period=self.params.slow_period)self.crossover=bt.indicators.CrossOver(self.fast_ma,self.slow_ma)#初始化订单引用,这里有个Backtrader常见坑,任何打算在next()里读写的自定义属性,都必须在___init___里先赋初值。self.order=Nonedefstart(self):# 这里的打印只会在第一次回测启动时执行一次print("-"*25)print(f"短线均线:{self.params.fast_period}")print(f"长线均线:{self.params.slow_period}")print("-"*25)defnext(self):#有未完成订单则跳过,避免重复下单ifself.order:returnifnotself.position:# 空仓ifself.crossover>0:# 金叉买入#用open[0]次日开盘假估算仓位,贴近实盘size=int(self.broker.getcash()*0.95/self.data.close[-1])ifsize>0:self.order=self.buy(size=size)else:# 持仓ifself.crossover<0:# 死叉卖出self.order=self.close()

为什么不能用[0]?

  • self.data.close[0]是当前正在形成的K线的收盘价。在实盘中,这根K线还没走完,收盘价是未知的。
  • 如果用它计算买入数量或触发信号,回测时相当于「偷看」了当天收盘数据,实盘时根本无法复现。
  • self.data.close[-1]是昨日收盘价,是实盘中当下就能拿到的确定数据,用它计算信号才真实可靠。建议尝试昨日收盘价和当日开盘价self.data.open[0],收益率能差出一些。

4. 回测引擎配置与运行

将清洗后的数据接入 Backtrader,配置好手续费、滑点与绩效分析器。

# 4.1 包装数据为 Backtrader 数据源data_feed=bt.feeds.PandasData(dataname=df,open='open',high='high',low='low',close='close',volume='volume',openinterest=None)# 4.2 配置回测引擎cerebro=bt.Cerebro()cerebro.broker.setcash(100000)# 初始资金 10 万cerebro.broker.setcommission(commission=0.0003)# 手续费万分之三cerebro.broker.set_slippage_perc(0.001)# 滑点千分之一cerebro.adddata(data_feed)cerebro.addstrategy(DualMAStrategy)# 添加分析器cerebro.addanalyzer(bt.analyzers.SharpeRatio,_name='sharpe')cerebro.addanalyzer(bt.analyzers.DrawDown,_name='drawdown')cerebro.addanalyzer(bt.analyzers.Returns,_name='returns')# 4.3 运行回测print("\n========== 双均线回测开始 ==========")print(f"初始资金:{cerebro.broker.getvalue():.2f}")results=cerebro.run()print(f"最终资金:{cerebro.broker.getvalue():.2f}")# 4.4 输出绩效指标strat=results[0]print("\n---------- 绩效指标 ----------")# 1. 夏普比率sharpe_res=strat.analyzers.sharpe.get_analysis()sharpe_val=sharpe_res.get('sharperatio')ifsharpe_valisnotNone:print(f"夏普比率:{sharpe_val:.2f}")else:print("夏普比率: 无有效数据")# 2. 最大回撤drawdown_res=strat.analyzers.drawdown.get_analysis()print(f"最大回撤:{drawdown_res.max.drawdown:.2f}%")# 3. 年化收益率returns_res=strat.analyzers.returns.get_analysis()print(f"年化收益率:{returns_res.rnorm100:.2f}%")

5.完整代码

importbaostockasbsimportpandasaspdimportmatplotlib.pyplotaspltimportbacktraderasbt#==================== 1. 登录 ====================print(">>>1.准备登录",flush=True)lg=bs.login()print(f">>>2.登录返回:{lg.error_code}{lg.error_msg}",flush=True)# ==================== 2. 拉取数据 ====================print(">>>3.开始查询",flush=True)rs=bs.query_history_k_data_plus("sh.601138","date,code,open,high,low,close,volume,amount",start_date='2026-01-01',end_date='2026-09-30',frequency="d",adjustflag="2")print(">>>4.查询返回",flush=True)#==================== 3. 转 DataFrame ====================data_list=[]while(rs.error_code=='0')&rs.next():data_list.append(rs.get_row_data())print(f">>>5.拿到{len(data_list)}",flush=True)df=pd.DataFrame(data_list,columns=rs.fields)#==================== 4. 清洗 ====================# -------------------------- 关键修复1:全字段类型转换 --------------------------# 把所有空字符串替换成NaN,避免字符串残留df=df.replace('',pd.NA)# 把所有行情数值字段一次性转为float类型,覆盖所有Backtrader需要用到的列numeric_cols=['open','high','low','close','volume','amount']forcolinnumeric_cols:df[col]=pd.to_numeric(df[col],errors='coerce')# 日期字段转为datetime类型df['date']=pd.to_datetime(df['date'],errors='coerce')# -------------------------- 关键修复2:设置日期为索引 --------------------------# Backtrader的PandasData默认优先用索引作为datetime列,这是最不容易出错的配置df=df.sort_values('date').reset_index(drop=True)df.set_index('date',inplace=True)# 清洗掉所有带缺失值的行,避免后续计算异常df.dropna(subset=numeric_cols,inplace=True)print(df.tail())# ==================== 5. 双均线回测 ====================# 5.1 定义策略classDualMAStrategy(bt.Strategy):params=(('fast_period',5),# 短期均线('slow_period',10),# 长期均线)def__init__(self):#用close(-1)昨日收盘价计算指票,避免未来函数self.fast_ma=bt.indicators.SimpleMovingAverage(self.data.close(-1),period=self.params.fast_period)self.slow_ma=bt.indicators.SimpleMovingAverage(self.data.close(-1),period=self.params.slow_period)self.crossover=bt.indicators.CrossOver(self.fast_ma,self.slow_ma)#初始化订单引用self.order=Nonedefstart(self):# 这里的打印只会在第一次回测启动时执行一次print("-"*25)print(f"短线均线:{self.params.fast_period}")print(f"长线均线:{self.params.slow_period}")print("-"*25)defnext(self):#有未完成订单则跳过,避免重复下单ifself.order:returnifnotself.position:# 空仓ifself.crossover>0:# 金叉买入#用open[0]次日开盘假估算仓位,贴近实盘size=int(self.broker.getcash()*0.95/self.data.close[-1])ifsize>0:self.order=self.buy(size=size)else:# 持仓ifself.crossover<0:# 死叉卖出self.order=self.close()defnotify_order(self,order):#订单状态回调:成交或失败后清空order引用iforder.statusin[order.Completed,order.Canceled,order.Margin,order.Rejected]:self.order=None# 5.2 包装数据为 Backtrader 数据源data_feed=bt.feeds.PandasData(dataname=df,open='open',high='high',low='low',close='close',volume='volume',openinterest=None)# 5.3 配置回测引擎cerebro=bt.Cerebro()cerebro.broker.setcash(100000)# 初始资金 10 万cerebro.broker.setcommission(commission=0.0003)# 手续费万分之三cerebro.broker.set_slippage_perc(0.001)# 滑点千分之一cerebro.adddata(data_feed)cerebro.addstrategy(DualMAStrategy)# 添加分析器cerebro.addanalyzer(bt.analyzers.SharpeRatio,_name='sharpe')cerebro.addanalyzer(bt.analyzers.DrawDown,_name='drawdown')cerebro.addanalyzer(bt.analyzers.Returns,_name='returns')# 5.4 运行回测print("\n========== 双均线回测开始 ==========")print(f"初始资金:{cerebro.broker.getvalue():.2f}")results=cerebro.run()print(f"最终资金:{cerebro.broker.getvalue():.2f}")# 5.5 输出绩效指标strat=results[0]print("\n---------- 绩效指标 ----------")# 1. 夏普比率sharpe_res=strat.analyzers.sharpe.get_analysis()sharpe_val=sharpe_res.get('sharperatio')ifsharpe_valisnotNone:print(f"夏普比率:{sharpe_val:.2f}")else:print("夏普比率: 无有效数据")# 2. 最大回撤dd_res=strat.analyzers.drawdown.get_analysis()max_dd=dd_res.get('max',{}).get('drawdown')ifmax_ddisnotNone:print(f"最大回撤:{max_dd:.2f}%")else:print("最大回撤: 无有效数据")# 3. 收益率ret_res=strat.analyzers.returns.get_analysis()rtot=ret_res.get('rtot')rnorm=ret_res.get('rnorm')ifrtotisnotNone:print(f"总收益率:{rtot:.2%}")# 使用 :.2% 自动转换为百分比格式ifrnormisnotNone:print(f"年化收益率:{rnorm:.2%}")# 登出bs.logout()

6. 总结

常见错误正确做法
用self.data.close[0]触发信号用self.data.close[-1]或均线指标判断
用self.data.close[0]计算下单数量用self.data.close[-1]计算下单数量
无self.order管理,可能重复下单加self.order+notify_order回调
忽略数据清洗,字符串残留全字段pd.to_numeric+dropna
未设置日期索引df.set_index('date', inplace=True)

核心心法:回测中每一个信号,都必须是在实盘当下「已经发生」的数据。[0]是未来,[-1]才是过去。永远站在 T-1 的视角做决策,你的回测结果才值得信赖。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/4 8:58:19

GitHub热榜深度解析:从项目复现到技术趋势判断

GitHub 热榜&#xff08;Trending&#xff09;一直是我每周必刷的固定栏目&#xff0c;看它不是为了凑热闹&#xff0c;而是想搞清楚当下开发者到底在为什么东西兴奋、什么技术真正落到了能用甚至好用的阶段。这一期周榜扫下来&#xff0c;我最直观的感受是&#xff1a;榜单比前…

作者头像 李华
网站建设 2026/10/4 8:55:21

为什么AI也要算命考试?MingLi-Bench八字命理评测基准深度解析

为什么AI也要算命考试&#xff1f;MingLi-Bench八字命理评测基准深度解析 【免费下载链接】MingLi-Bench A benchmark for evaluating LLMs on Chinese traditional fortune telling — Bazi (八字) and Ziwei Doushu (紫微斗数). 项目地址: https://gitcode.com/gh_mirrors/…

作者头像 李华
网站建设 2026/10/4 8:53:32

架构不是堆层次:判断该不该加一层的实用标准

一次评审会上&#xff0c;年轻同事指着一份设计文档问我&#xff1a;“这个Manager层&#xff0c;是不是有点多余了&#xff1f;我数了一下&#xff0c;一个查询从Controller进来&#xff0c;要经过Service、Manager、Handler&#xff0c;最后才到Mapper&#xff0c;每一层代码…

作者头像 李华
网站建设 2026/10/4 8:47:45

工业数据存储不掉电:PIC18搭配SPI MRAM的实战方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华