在使用bt进行多股回测时,经常会出现回测开始的日期比预期日期要晚很多的情况,本文将结合案例,分析这一现象的原因。本文仅对实践中用到的日线回测进行分析,如要处理分时数据,可参考本文方法分析。
本文将先通过3个案例展示多股回测的开始时间的变化情况,然后通过分析源代码说明产生这种变化情况的原因。
案例
在以下3个案例中,分别使用[600035]、[600035,300412]、[600035,300412,300919]3组股票作为股票池,回测开始时间选定为2018年1月8日,在策略的next函数中打印以下信息:
def next(self):
print('next-------------------------------------------{}'.format(bt.num2date(self.lines.datetime[0])))
在回测过程中,cerebro的所有参数均使用默认值,所有的数据均通过pandas data数据导入,所需要的指标已前期计算完成,保存在pandas data中,无需backtrader在进行计算,即数据的最小周期数为1。
案例1
股票池:600035
回测开始时间:2018-01-08
打印结果:
next-------------------------------------------2018-01-08 00:00:00
next-------------------------------------------2018-01-09 00:00:00
...
案例2
股票池:600035,300412
回测开始时间:2018-01-08
打印结果:
next-------------------------------------------2018-02-08 00:00:00
next-------------------------------------------2018-02-09 00:00:00
...
案例3
股票池:600035,300412,300919
回测开始时间:2018-01-08
打印结果:
next-------------------------------------------2020-12-23 00:00:00
next-------------------------------------------2020-12-24 00:00:00
...
案例分析
3个案例中,除参与回测的股票池不同外,其余设置完全相同,但从打印结果可以看出,回测的开始时间相差非常大。
案例1的真实回测开始时间为2018-01-08,案例2的真实回测开始时间为2018-02-08,案例3的真实回测开始时间为2020-12-23。
来看一下参与回测的股票的情况:
- 600035,在回测开始时间2018-01-08有K线数据。
- 300412,在回测开始时间2018-01-08没有K线数据,2018-01-08至2018-02-07停盘,2018-02-08恢复交易,开始有K线数据。
- 300919,在回测开始时间2018-01-08没有K线数据,2020-12-23上市,开始有K线数据。
通过回顾个股的情况可以发现,600035在回测开始时间2018-01-08有K线数据,因此案例1从2018-01-08开始回测;300412在2018-02-08才开始有K线,因此案例1从2018-02-08开始回测;300919在2020-12-23才开始有K线,因此案例1从2020-12-23开始回测。也就是说,多股回测时,回测真实的开始时间是参与回测的所有股票,在设置的回测开始时间后,均具有最小周期个K线数据的时间(本文中的最小周期均为1)。
源码分析
这里结合案例2,即股票池为600035和300412,进行源码分析。
最小周期状态值
回测的核心代码都在strategy的next函数中,来看一下该函数的调用堆栈:
1. run, cerebro.py: 1127
2. runstrategies, cerebro.py: 1293
3. _runonce, cerebro.py: 1695
4. _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 value
else:
self.prenext()
...
可以看到,_oncepost会根据最小周期状态值minperstatus来决定是调用next、nextstart还是prenext,下面展示了这3个函数默认的实现内容。
def prenext(self):
'''
This method will be called before the minimum period of all
datas/indicators have been meet for the strategy to start executing
'''
pass
def nextstart(self):
'''
This method will be called once, exactly when the minimum period for
all datas/indicators have been meet. The default behavior is to call
next
'''
# Called once for 1st full calculation - defaults to regular next
self.next()
def next(self):
'''
This method will be called for all remaining data points when the
minimum 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 datas
dlens = 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: 1127
2. runstrategies, cerebro.py: 1293
3. _runonce, cerebro.py: 1664
_runonce中相关代码如下:
def _runonce(self, runstrats):
...
while True:
# Check next incoming date in the datas
dts = [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 timemaster
slen = len(runstrats[0])
for i, dti in enumerate(dts):
if dti <= dt0:
datas[i].advance()
# self._plotfillers2[i].append(slen) # mark as fill
else:
# 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=[2018-01-08, 2018-02-08](默认为时间戳,这里为了方面说明,转化为日期)。
- dt0取dts中的最小值,即2018-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(2018-01-08) <= dt0(2018-01-08),因此会调用datas[i].advance();而300412的dti(2018-02-08)> dt0(2018-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 = [2018-01-09, 2018-02-08],即600035移动了1根K线数据,300412没有改变;
- dt0 = 2018-01-09;
- 循环for i, dti in enumerate(dts)中,600035的dti(2018-01-09) <= dt0(2018-01-09),因此会调用datas[i].advance();
- 而300412的dti(2018-02-08)> dt0(2018-01-09),不会进行advance;
- 最小周期状态值minperstatus > 0,未进入next。
- 进入下一轮while循环
- 下一个待处理的日期列表dts = [2018-01-10, 2018-02-08],即600035移动了1根K线数据,300412没有改变;
- dt0 = 2018-01-10;
- 循环for i, dti in enumerate(dts)中,600035的dti(2018-01-10) <= dt0(2018-01-10),因此会调用datas[i].advance();
- 而300412的dti(2018-02-08)> dt0(2018-01-10),不会进行advance;
- 最小周期状态值minperstatus > 0,未进入next。
- 。。。
- 。。。
- 进入下一轮while循环
- 下一个待处理的日期列表dts = [2018-02-08, 2018-02-08],即600035移动了1根K线数据,300412没有改变;
- dt0 = 2018-02-08;
- 循环for i, dti in enumerate(dts)中,600035的dti(2018-02-08) <= dt0(2018-02-08),因此会调用datas[i].advance();
- 300412的dti(2018-02-08) <= dt0(2018-02-08),因此会调用datas[i].advance();
- 最小周期状态值minperstatus = 0,进入next,开始回测。
通过上面的跟踪分析发现,虽然600035自回测开始时间2018-01-08就有K线数据,但是300412直到2018-02-08才有K线数据,回测直到两只股票都有K线数据时才会真正开始。也就是上面提到的,多股回测时,回测真实的开始时间是参与回测的所有股票,在设置的回测开始时间后,均具有最小周期个K线数据的时间。
博客内容只用于交流学习,不构成投资建议,盈亏自负!
个人博客:http://coderx.com.cn/(优先更新)
项目最新代码:https://gitee.com/sl/quant_from_scratch
欢迎大家转发、留言。有微信群用于学习交流,感兴趣的读者请扫码加微信!
如果认为博客对您有帮助,可以扫码进行捐赠,感谢!
微信二维码 | 微信捐赠二维码 |
---|---|