在使用bt进行多股回测时,经常会出现回测开始的日期比预期日期要晚很多的情况,本文将结合案例,分析这一现象的原因。本文仅对实践中用到的日线回测进行分析,如要处理分时数据,可参考本文方法分析。
本文将先通过3个案例展示多股回测的开始时间的变化情况,然后通过分析源代码说明产生这种变化情况的原因。
案例
在以下3个案例中,分别使用[600035]、[600035,300412]、[600035,300412,300919]3组股票作为股票池,回测开始时间选定为1月8日,在策略的next函数中打印以下信息:
def next(self):print('next-------------------------------------------{}'.format(bt.num2date(self.lines.datetime[0])))
在回测过程中,cerebro的所有参数均使用默认值,所有的数据均通过pandas data数据导入,所需要的指标已前期计算完成,保存在pandas data中,无需backtrader在进行计算,即数据的最小周期数为1。
案例1
股票池:600035
回测开始时间:-01-08
打印结果:
next--------------------------------------------01-08 00:00:00next--------------------------------------------01-09 00:00:00...
案例2
股票池:600035,300412
回测开始时间:-01-08
打印结果:
next--------------------------------------------02-08 00:00:00next--------------------------------------------02-09 00:00:00...
案例3
股票池:600035,300412,300919
回测开始时间:-01-08
打印结果:
next--------------------------------------------12-23 00:00:00next--------------------------------------------12-24 00:00:00...
案例分析
3个案例中,除参与回测的股票池不同外,其余设置完全相同,但从打印结果可以看出,回测的开始时间相差非常大。
案例1的真实回测开始时间为-01-08,案例2的真实回测开始时间为-02-08,案例3的真实回测开始时间为-12-23。
来看一下参与回测的股票的情况:
600035,在回测开始时间-01-08有K线数据。300412,在回测开始时间-01-08没有K线数据,-01-08至-02-07停盘,-02-08恢复交易,开始有K线数据。300919,在回测开始时间-01-08没有K线数据,-12-23上市,开始有K线数据。
通过回顾个股的情况可以发现,600035在回测开始时间-01-08有K线数据,因此案例1从-01-08开始回测;300412在-02-08才开始有K线,因此案例1从-02-08开始回测;300919在-12-23才开始有K线,因此案例1从-12-23开始回测。也就是说,多股回测时,回测真实的开始时间是参与回测的所有股票,在设置的回测开始时间后,均具有最小周期个K线数据的时间(本文中的最小周期均为1)。
源码分析
这里结合案例2,即股票池为600035和300412,进行源码分析。
最小周期状态值
回测的核心代码都在strategy的next函数中,来看一下该函数的调用堆栈:
1. run, cerebro.py: 11272. runstrategies, cerebro.py: 12933. _runonce, cerebro.py: 16954. _oncepost, strategy.py: 305
_oncepost的部分源码如下:
def _oncepost(self, dt):...minperstatus = self._getminperstatus()if minperstatus < 0:self.next()elif minperstatus == 0:self.nextstart() # only called for the 1st valueelse:self.prenext()...
可以看到,_oncepost会根据最小周期状态值minperstatus来决定是调用next、nextstart还是prenext,下面展示了这3个函数默认的实现内容。
def prenext(self):'''This method will be called before the minimum period of alldatas/indicators have been meet for the strategy to start executing'''passdef nextstart(self):'''This method will be called once, exactly when the minimum period forall datas/indicators have been meet. The default behavior is to callnext'''# Called once for 1st full calculation - defaults to regular nextself.next()def next(self):'''This method will be called for all remaining data points when theminimum period for all datas/indicators have been meet.'''pass
其中,prenext在最小周期达到前别调用,默认实现为空;nextstart在最小周期达到时被调用一次,默认是调用next函数;当达到最小周期后,next被调用,进入回测逻辑,通常用户会根据自己的策略重写next函数。
了解了这3个函数的内容后,那么什么时候进入next,开始真正的策略回测,就取决于最小周期状态值minperstatus,来看一下_getminperstatus函数的代码:
def _getminperstatus(self):# check the min period status connected to datasdlens = map(operator.sub, self._minperiods, map(len, self.datas))self._minperstatus = minperstatus = max(dlens)return minperstatus
实现非常简洁,说明如下:
self._minperiods是一个列表,列表的长度为self.datas的长度,加载数据的个数,其中每个元素对应的是每个data的最小周期数(本文的案例中均为1),案例2中加载了2个数据,每个数据最小周期都为1,那么self._minperiods=[1, 1]。map(len, self.datas)使用map求取每个data已处理过的K线的数目,案例2共加载2个数据,第1个数据已处理1根K线,即len(self.datas[0])=1,第2个数据已处理0根K线,即len(self.datas[1])=0,那么map(len, self.datas)就返回由1和0两个元素组成的迭代器。使用operator.sub,对self._minperiods和map(len, self.datas)对应元素相减,返回1个迭代器,按上面的示例就会得到[1, 1] - [1, 0] = [0, 1](dlens)。最后使用max求取迭代器dlens中的最大值,示例中为1,并返回。
在回看_oncepost的源码,如果minperstatus > 0,就会调用prenext函数,默认就什么操作也没进行。
通过上面的分析可以看出,在最小周期确定的情况下,如果有部分数据K线一直未被处理(即len(self.data[x])=0,那么max(self._minperiods - map(len, self.datas)) > 0),则会使求得的最小周期状态值minperstatus一直大于0,就一直无法调用next函数进入真实回测阶段。
更新已处理K线长度
下面再来分析下bt中,依据K线数据时间更新len(self.data[x])的逻辑。
调用堆栈如下:
1. run, cerebro.py: 11272. runstrategies, cerebro.py: 12933. _runonce, cerebro.py: 1664
_runonce中相关代码如下:
def _runonce(self, runstrats):...while True:# Check next incoming date in the datasdts = [d.advance_peek() for d in datas]dt0 = min(dts)if dt0 == float('inf'):break # no data delivers anything# Timemaster if needed be# dmaster = datas[dts.index(dt0)] # and timemasterslen = len(runstrats[0])for i, dti in enumerate(dts):if dti <= dt0:datas[i].advance()# self._plotfillers2[i].append(slen) # mark as fillelse:# self._plotfillers[i].append(slen)pass...for strat in runstrats:strat._oncepost(dt0)...
dts = [d.advance_peek() for d in datas]返回的是1个日期的列表,每个元素是每个数据将要处理的日期。案例2中,加载了数据600035和300412,在首次循环时dts=[-01-08, -02-08](默认为时间戳,这里为了方面说明,转化为日期)。dt0取dts中的最小值,即-01-08。循环for i, dti in enumerate(dts)中,对待处理日期小于等于dt0的数据,进行 datas[i].advance(),而在advance函数中,进行了self.lencount += size,其中size默认为1。在len(self.datas[x])中,最底层也是访问的self.lencount。因此advance的调用就会改变len(self.datas[x])的值。len函数的底层方法实现如下:
def __len__(self):return self.lencount
对于案例2,600035的dti(-01-08) <= dt0(-01-08),因此会调用datas[i].advance();而300412的dti(-02-08)> dt0(-01-08),不会进行advance,因此没有改变len(self.datas[x]),这就导致上面提到的计算最小周期状态值minperstatus时,len(self.datas[x])一直为0,进而minperstatus=max(self._minperiods - map(len, self.datas)) > 0,进而无法进入next回测阶段。进入下一轮while循环 下一个待处理的日期列表dts = [-01-09, -02-08],即600035移动了1根K线数据,300412没有改变;dt0 = -01-09;循环for i, dti in enumerate(dts)中,600035的dti(-01-09) <= dt0(-01-09),因此会调用datas[i].advance();而300412的dti(-02-08)> dt0(-01-09),不会进行advance;最小周期状态值minperstatus > 0,未进入next。 进入下一轮while循环 下一个待处理的日期列表dts = [-01-10, -02-08],即600035移动了1根K线数据,300412没有改变;dt0 = -01-10;循环for i, dti in enumerate(dts)中,600035的dti(-01-10) <= dt0(-01-10),因此会调用datas[i].advance();而300412的dti(-02-08)> dt0(-01-10),不会进行advance;最小周期状态值minperstatus > 0,未进入next。 。。。。。。进入下一轮while循环 下一个待处理的日期列表dts = [-02-08, -02-08],即600035移动了1根K线数据,300412没有改变;dt0 = -02-08;循环for i, dti in enumerate(dts)中,600035的dti(-02-08) <= dt0(-02-08),因此会调用datas[i].advance();300412的dti(-02-08) <= dt0(-02-08),因此会调用datas[i].advance();最小周期状态值minperstatus = 0,进入next,开始回测。
通过上面的跟踪分析发现,虽然600035自回测开始时间-01-08就有K线数据,但是300412直到-02-08才有K线数据,回测直到两只股票都有K线数据时才会真正开始。也就是上面提到的,多股回测时,回测真实的开始时间是参与回测的所有股票,在设置的回测开始时间后,均具有最小周期个K线数据的时间。
欢迎大家关注、点赞、转发、留言,感谢支持!
微信群用于学习交流,群1已满,群2已创建,感兴趣的读者请扫码加微信!
QQ群(676186743)用于资料共享,欢迎加入!
如果觉得《Python量化交易学习笔记(58)——backtrader多股回测的开始时间》对你有帮助,请点赞、收藏,并留下你的观点哦!