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 的视角做决策,你的回测结果才值得信赖。