ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

七日量化回测入门(四)Backtrader 双均线回测告别未来函数 1. 引言在量化回测中未来函数Look-ahead Bias是导致回测结果虚高、实盘却亏损的头号杀手。它的本质是在计算当天交易信号时无意中使用了当天收盘后甚至未来才产生的数据。正确做法是所有交易信号必须基于 T-1 及之前的数据。在 Backtrader 中通过负索引访问历史数据例如self.data.close[-1]表示昨日收盘价。永远不要用[0]来决定当天的交易信号因为[0]代表当前正在形成的 K 线其收盘价在当天收盘前是未知的。本文将以工业**sh.601138为例演示如何用 Baostock 拉取数据、用 Backtrader 构建双均线策略并重点修复代码中的未来函数隐患。2. 数据获取与清洗首先使用 Baostock 拉取日线数据并完成类型转换与索引设置。importbaostockasbsimportpandasaspdimportbacktraderasbt# 登录print(1.准备登录,flushTrue)lgbs.login()print(f2.登录返回:{lg.error_code}{lg.error_msg},flushTrue)# 拉取数据print(3.开始查询,flushTrue)rsbs.query_history_k_data_plus(sh.601138,date,code,open,high,low,close,volume,amount,start_date2026-01-01,end_date2026-09-30,frequencyd,adjustflag2)print(4.查询返回,flushTrue)# 转 DataFramedata_list[]while(rs.error_code0)rs.next():data_list.append(rs.get_row_data())print(f5.拿到{len(data_list)},flushTrue)dfpd.DataFrame(data_list,columnsrs.fields)# -------------------------- 关键修复1全字段类型转换 --------------------------# 把所有空字符串替换成NaN避免字符串残留dfdf.replace(,pd.NA)# 把所有行情数值字段一次性转为float类型覆盖所有Backtrader需要用到的列numeric_cols[open,high,low,close,volume,amount]forcolinnumeric_cols:df[col]pd.to_numeric(df[col],errorscoerce)# 日期字段转为datetime类型df[date]pd.to_datetime(df[date],errorscoerce)# -------------------------- 关键修复2设置日期为索引 --------------------------# Backtrader的PandasData默认优先用索引作为datetime列这是最不容易出错的配置dfdf.sort_values(date).reset_index(dropTrue)df.set_index(date,inplaceTrue)# 清洗掉所有带缺失值的行避免后续计算异常df.dropna(subsetnumeric_cols,inplaceTrue)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_mabt.indicators.SimpleMovingAverage(self.data.close-1,periodself.params.fast_period)self.slow_mabt.indicators.SimpleMovingAverage(self.data.close-1,periodself.params.slow_period)self.crossoverbt.indicators.CrossOver(self.fast_ma,self.slow_ma)#初始化订单引用这里有个Backtrader常见坑任何打算在next()里读写的自定义属性都必须在___init___里先赋初值。self.orderNonedefstart(self):# 这里的打印只会在第一次回测启动时执行一次print(-*25)print(f短线均线{self.params.fast_period})print(f长线均线{self.params.slow_period})print(-*25)defnext(self):#有未完成订单则跳过避免重复下单ifself.order:returnifnotself.position:# 空仓ifself.crossover0:# 金叉买入#用open[0]次日开盘假估算仓位贴近实盘sizeint(self.broker.getcash()*0.95/self.data.close[-1])ifsize0:self.orderself.buy(sizesize)else:# 持仓ifself.crossover0:# 死叉卖出self.orderself.close()为什么不能用[0]self.data.close[0]是当前正在形成的K线的收盘价。在实盘中这根K线还没走完收盘价是未知的。如果用它计算买入数量或触发信号回测时相当于「偷看」了当天收盘数据实盘时根本无法复现。self.data.close[-1]是昨日收盘价是实盘中当下就能拿到的确定数据用它计算信号才真实可靠。建议尝试昨日收盘价和当日开盘价self.data.open[0],收益率能差出一些。4. 回测引擎配置与运行将清洗后的数据接入 Backtrader配置好手续费、滑点与绩效分析器。# 4.1 包装数据为 Backtrader 数据源data_feedbt.feeds.PandasData(datanamedf,openopen,highhigh,lowlow,closeclose,volumevolume,openinterestNone)# 4.2 配置回测引擎cerebrobt.Cerebro()cerebro.broker.setcash(100000)# 初始资金 10 万cerebro.broker.setcommission(commission0.0003)# 手续费万分之三cerebro.broker.set_slippage_perc(0.001)# 滑点千分之一cerebro.adddata(data_feed)cerebro.addstrategy(DualMAStrategy)# 添加分析器cerebro.addanalyzer(bt.analyzers.SharpeRatio,_namesharpe)cerebro.addanalyzer(bt.analyzers.DrawDown,_namedrawdown)cerebro.addanalyzer(bt.analyzers.Returns,_namereturns)# 4.3 运行回测print(\n 双均线回测开始 )print(f初始资金:{cerebro.broker.getvalue():.2f})resultscerebro.run()print(f最终资金:{cerebro.broker.getvalue():.2f})# 4.4 输出绩效指标stratresults[0]print(\n---------- 绩效指标 ----------)# 1. 夏普比率sharpe_resstrat.analyzers.sharpe.get_analysis()sharpe_valsharpe_res.get(sharperatio)ifsharpe_valisnotNone:print(f夏普比率:{sharpe_val:.2f})else:print(夏普比率: 无有效数据)# 2. 最大回撤drawdown_resstrat.analyzers.drawdown.get_analysis()print(f最大回撤:{drawdown_res.max.drawdown:.2f}%)# 3. 年化收益率returns_resstrat.analyzers.returns.get_analysis()print(f年化收益率:{returns_res.rnorm100:.2f}%)5.完整代码importbaostockasbsimportpandasaspdimportmatplotlib.pyplotaspltimportbacktraderasbt# 1. 登录 print(1.准备登录,flushTrue)lgbs.login()print(f2.登录返回:{lg.error_code}{lg.error_msg},flushTrue)# 2. 拉取数据 print(3.开始查询,flushTrue)rsbs.query_history_k_data_plus(sh.601138,date,code,open,high,low,close,volume,amount,start_date2026-01-01,end_date2026-09-30,frequencyd,adjustflag2)print(4.查询返回,flushTrue)# 3. 转 DataFrame data_list[]while(rs.error_code0)rs.next():data_list.append(rs.get_row_data())print(f5.拿到{len(data_list)},flushTrue)dfpd.DataFrame(data_list,columnsrs.fields)# 4. 清洗 # -------------------------- 关键修复1全字段类型转换 --------------------------# 把所有空字符串替换成NaN避免字符串残留dfdf.replace(,pd.NA)# 把所有行情数值字段一次性转为float类型覆盖所有Backtrader需要用到的列numeric_cols[open,high,low,close,volume,amount]forcolinnumeric_cols:df[col]pd.to_numeric(df[col],errorscoerce)# 日期字段转为datetime类型df[date]pd.to_datetime(df[date],errorscoerce)# -------------------------- 关键修复2设置日期为索引 --------------------------# Backtrader的PandasData默认优先用索引作为datetime列这是最不容易出错的配置dfdf.sort_values(date).reset_index(dropTrue)df.set_index(date,inplaceTrue)# 清洗掉所有带缺失值的行避免后续计算异常df.dropna(subsetnumeric_cols,inplaceTrue)print(df.tail())# 5. 双均线回测 # 5.1 定义策略classDualMAStrategy(bt.Strategy):params((fast_period,5),# 短期均线(slow_period,10),# 长期均线)def__init__(self):#用close(-1)昨日收盘价计算指票避免未来函数self.fast_mabt.indicators.SimpleMovingAverage(self.data.close(-1),periodself.params.fast_period)self.slow_mabt.indicators.SimpleMovingAverage(self.data.close(-1),periodself.params.slow_period)self.crossoverbt.indicators.CrossOver(self.fast_ma,self.slow_ma)#初始化订单引用self.orderNonedefstart(self):# 这里的打印只会在第一次回测启动时执行一次print(-*25)print(f短线均线{self.params.fast_period})print(f长线均线{self.params.slow_period})print(-*25)defnext(self):#有未完成订单则跳过避免重复下单ifself.order:returnifnotself.position:# 空仓ifself.crossover0:# 金叉买入#用open[0]次日开盘假估算仓位贴近实盘sizeint(self.broker.getcash()*0.95/self.data.close[-1])ifsize0:self.orderself.buy(sizesize)else:# 持仓ifself.crossover0:# 死叉卖出self.orderself.close()defnotify_order(self,order):#订单状态回调成交或失败后清空order引用iforder.statusin[order.Completed,order.Canceled,order.Margin,order.Rejected]:self.orderNone# 5.2 包装数据为 Backtrader 数据源data_feedbt.feeds.PandasData(datanamedf,openopen,highhigh,lowlow,closeclose,volumevolume,openinterestNone)# 5.3 配置回测引擎cerebrobt.Cerebro()cerebro.broker.setcash(100000)# 初始资金 10 万cerebro.broker.setcommission(commission0.0003)# 手续费万分之三cerebro.broker.set_slippage_perc(0.001)# 滑点千分之一cerebro.adddata(data_feed)cerebro.addstrategy(DualMAStrategy)# 添加分析器cerebro.addanalyzer(bt.analyzers.SharpeRatio,_namesharpe)cerebro.addanalyzer(bt.analyzers.DrawDown,_namedrawdown)cerebro.addanalyzer(bt.analyzers.Returns,_namereturns)# 5.4 运行回测print(\n 双均线回测开始 )print(f初始资金:{cerebro.broker.getvalue():.2f})resultscerebro.run()print(f最终资金:{cerebro.broker.getvalue():.2f})# 5.5 输出绩效指标stratresults[0]print(\n---------- 绩效指标 ----------)# 1. 夏普比率sharpe_resstrat.analyzers.sharpe.get_analysis()sharpe_valsharpe_res.get(sharperatio)ifsharpe_valisnotNone:print(f夏普比率:{sharpe_val:.2f})else:print(夏普比率: 无有效数据)# 2. 最大回撤dd_resstrat.analyzers.drawdown.get_analysis()max_dddd_res.get(max,{}).get(drawdown)ifmax_ddisnotNone:print(f最大回撤:{max_dd:.2f}%)else:print(最大回撤: 无有效数据)# 3. 收益率ret_resstrat.analyzers.returns.get_analysis()rtotret_res.get(rtot)rnormret_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.ordernotify_order回调忽略数据清洗字符串残留全字段pd.to_numericdropna未设置日期索引df.set_index(date, inplaceTrue)核心心法回测中每一个信号都必须是在实盘当下「已经发生」的数据。[0]是未来[-1]才是过去。永远站在 T-1 的视角做决策你的回测结果才值得信赖。
返回列表