Python量化交易学习笔记(58)——backtrader多股回测的开始时间

在这里插入图片描述

在使用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线数据的时间

欢迎大家关注、点赞、转发、留言,感谢支持!
微信群用于学习交流,群1已满,群2已创建,感兴趣的读者请扫码加微信!
QQ群(676186743)用于资料共享,欢迎加入!

在这里插入图片描述
在这里插入图片描述

Guess you like

Origin blog.csdn.net/m0_46603114/article/details/119960374