量化投资之工具篇一:Backtrader从入门到精通(4)-Data相关类源代码解读

前面的文章通过源代码详解Cerebro是中心系统,负责控制各个部件(例如Strategy,Data feeds,Observers等)进行协同工作。

简而言之,量化最重要的就是两点:数据和策略,其他的都是辅助。下面我们从数据相关类入手,深入学习backtrader中数据的保存以及运作机制。

在系列文章3中,已经说明过加载数据的几种方式,在我们的示例中,采用的是加载Pandas数据方法:

    stock_hfq_df = pd.read_csv("../data/sh000300沪深300.csv",index_col='date',parse_dates=True)
    start_date = datetime(2021, 9, 1)  # 回测开始时间
    end_date = datetime(2021, 9, 30)  # 回测结束时间
    data = bt.feeds.PandasData(dataname=stock_hfq_df, fromdate=start_date, todate=end_date)  # 加载数据

为啥要使用Pandas方式输入数据呢?主要考虑到Pandas方便进行数据的预处理,而且目前常用量化数据python自动获取模块(例如akshare)返回的都是Pandas格式数据,为了后续直接获取数据方便处理,我们统一使用PandasData进行说明。

由于数据相关类众多,关系错综复杂,我们先从他们的关系入手。

数据家族关系图谱

在这里插入图片描述

牢牢记住这个图喔,这是咱们的家谱。

Note:图中类名后面加标号直接和未加标号同名类定义完全相同,比如LineRoot1和Lineroot是相同的。主要是为了图形清爽,不然太多交叉,看不清楚。
吐槽下csdn,居然不支持SVG,不太清晰,应该也能看清。

PandasData的实例化

在学习初始化代码之前,请大家回去看看系列文章3中关于元类的描述,其中两点这里重复一下:

  1. 所有类实例化的时候通过元类的__call__完成,不同的类可以在实例化的时候进行一些特殊化的处理(也就是可以控制类的生成)。
  2. 参数通过donew完成映射。

请看数据类关系图中PandasData继承关系,从AbstractDataBase开始就继承元类了。所以Pandas实例化的时候,首先就会调用MetaBase的__call__.MetaBase的__call__代码如下(比较重要,这里再贴一遍)

__call__(cls, *args, **kwargs):
        cls, args, kwargs = cls.doprenew(*args, **kwargs)
        _obj, args, kwargs = cls.donew(*args, **kwargs)
        _obj, args, kwargs = cls.dopreinit(_obj, *args, **kwargs)
        _obj, args, kwargs = cls.doinit(_obj, *args, **kwargs)
        _obj, args, kwargs = cls.dopostinit(_obj, *args, **kwargs)
        return _obj

可以看出,在__call__中会顺序执行doprenew,donew,dopreinit,doinit和dopostinit。为啥第一个返回cls,而其他返回obj?因为只有new的时候才会实例化,prenew的时候还没实例化呢,只能返回cls(class),在这里可以对类的定义进行一些处理。

好,PadasData实例化开始了,首先调用doprenew,到底调用哪个类的doprenew,根据面向对象原理,看MetaBase哪个子类会重写这个函数,请根据家谱从上往下看,没有任何类重新这个函数,那么只好调用MetaBase自己的doprenew,就是啥也没做:

def doprenew(cls, *args, **kwargs):
        return cls, args, kwargs    

继续往下走,就该donew了,一样的道理,看看哪个子类重写了这个函数,喔,是MetaLineSeries,那么就进入它的donew开始处理了:

def donew(cls, *args, **kwargs):
        '''
        Intercept instance creation, take over lines/plotinfo/plotlines
        class attributes by creating corresponding instance variables and add
        aliases for "lines" and the "lines" held within it
        '''
        # _obj.plotinfo shadows the plotinfo (class) definition in the class
        plotinfo = cls.plotinfo()

        for pname, pdef in cls.plotinfo._getitems():
            setattr(plotinfo, pname, kwargs.pop(pname, pdef))

        # Create the object and set the params in place
        _obj, args, kwargs = super(MetaLineSeries, cls).donew(*args, **kwargs)

        # set the plotinfo member in the class
        _obj.plotinfo = plotinfo

        # _obj.lines shadows the lines (class) definition in the class
        _obj.lines = cls.lines()

        # _obj.plotinfo shadows the plotinfo (class) definition in the class
        _obj.plotlines = cls.plotlines()

        # add aliases for lines and for the lines class itself
        _obj.l = _obj.lines
        if _obj.lines.fullsize():
            _obj.line = _obj.lines[0]

        for l, line in enumerate(_obj.lines):
            setattr(_obj, 'line_%s' % l, _obj._getlinealias(l))
            setattr(_obj, 'line_%d' % l, line)
            setattr(_obj, 'line%d' % l, line)

        # Parameter values have now been set before __init__
        return _obj, args, kwargs

要点:

  • 首先实例化一个AutoInfoClass并进行参数的设置,并记录到plotinfo属性。这个主要用于画图,先不说。
  • 下一步就是调用MetaLineSeries父类的donew进行实例化和参数映射。看图,MetaLineSeries父类是MetaLineRoot,然后MetaLineRoot的donew代码:
def donew(cls, *args, **kwargs):
        _obj, args, kwargs = super(MetaLineRoot, cls).donew(*args, **kwargs)

        # Find the owner and store it
        # startlevel = 4 ... to skip intermediate call stacks
        ownerskip = kwargs.pop('_ownerskip', None)
        _obj._owner = metabase.findowner(_obj,
                                         _obj._OwnerCls or LineMultiple,
                                         skip=ownerskip)

        # Parameter values have now been set before __init__
        return _obj, args, kwargs

干啥了?继续调用MetaLineRoot的父类的donew,他的父类是MetaParas,这个就不继续贴代码了(代码系列文章3讲过了),就是调用MetaBase的donew,donnew再对PandasData类进行实例化(走了这么大一圈,才到正主啊),并将参数映射到类属性。记住,这个时候的obj就是是PandasData类的实例了!

继续回到MetaLineRoot的donew了,实例化Pandas之后,然后就调用 metabase类findowner函数,这个比较重要,但是对于PandasData对象,没有owner,所以容后再讲。

再就回到MetaLineSeries到的donew了,继续:

  • 记录前面实例化的AutoInfoClass到属性plotinfo中。
  • 下面是是重头戏,看是实例化Lines了,这个太重要了,单独章节描述。
  • 后面就是实例化AutoInfoClass保存到plotlines(这里针对的是具体的line的画图)。这一块先不讲。
  • PandasData实例的lines别名为l,同时将实例各个line属性的别名记好(还记得之前文章说明的各种访问line的方法吗?都是这里处理的)。

至此,pandas实例化完成了。

题外话:这里可以看出元类的好处了,普通类实例化很简单,而元类实例化的时候可以根据需要进行定制,将大量的初始化工作抽象到元类完成。

Lines的实例化和初始化

Datas中数据是保存到Line的,所以实例化的同时,会实例化Lines。

从家谱中可以看出,lines只是一个普通类。所以实例化的时候会用__new__实例化,并调用__init__方法初始化,初始化代码如下:

def __init__(self, initlines=None):
        '''
        Create the lines recording during "_derive" or else use the
        provided "initlines"
        '''
        self.lines = list()
        for line, linealias in enumerate(self._getlines()):
            kwargs = dict()
            self.lines.append(LineBuffer(**kwargs))

        # Add the required extralines
        for i in range(self._getlinesextra()):
            if not initlines:
                self.lines.append(LineBuffer())
            else:
                self.lines.append(initlines[i])

要点:

  • 首先就是初始化一个lines容器,然后针每一个的line实例化一个LineBuffer。初始lines包含close,low, high, open, volume, openinterest,还有一个datetime。
  • 还有一些额外的line也会实例化Linebuffer(应该就是后续可以定制的line,暂时不用)。

下面就是关键的LinebBuffer了,具体数据就存放在这里。

LineBuffer的实例化和初始化

看咱们的家谱,发现LineBuffer也继承了元类,那么他的实例化肯定也是要受到MetaBase的控制。元类实例化的时候首先会调用doprenew,LineBuffer继承路线上并没有doprenew,那还是调用MebaBase的doprenew,啥也没干。继续下一步就是donew了,继续看继承路线上,还是MetaLineRoot的donew,对了,刚刚pandas类也调用了这个。大家回头看下代码:

  • 继续调用MetaLineRoot的父类的donew,他的父类是MetaParas,在MetaParas中继续调用Metabase的donew,donnew再对LineBuffer类进行实例化,并将参数映射到类属性。记住,这个时候的obj是LineBuffer类的实例了!
  • 下一个步骤就是调用findowner找到谁是这个LineBuffer实例的主人,这个函数通过查找堆栈信息看看到底是谁实际创建了自己。也就是谁拥有这个LineBuffer实例。猜猜看,是谁?是PandasData实例。猜对了吗?你可以从头看,就是Pandas实例化的时候实例化了lines,然后lines实例化了LineBuffer。为啥要记录这个呢?你看看家谱错综复杂的关系,还有很多没画出来,不知道自己爸爸是谁不行啊。

下面就是doinit了(dopreinit在家谱中没有重写,所以实际没做啥,不提),调用的是LineBuffer的__init__了:

def __init__(self):
        self.lines = [self]
        self.mode = self.UnBounded
        self.bindings = list()
        self.reset()
        self._tz = None

就是几个属性的初始化,lines先把自己加进去。然后调用reset重置内存存储的结构和索引:

def reset(self):
        ''' Resets the internal buffer structure and the indices
        '''
        if self.mode == self.QBuffer:
            # add extrasize to ensure resample/replay work because they will
            # use backwards to erase the last bar/tick before delivering a new
            # bar The previous forward would have discarded the bar "period"
            # times ago and it will not come back. Having + 1 in the size
            # allows the forward without removing that bar
            self.array = collections.deque(maxlen=self.maxlen + self.extrasize)
            self.useislice = True
        else:
            self.array = array.array(str('d'))
            self.useislice = False

        self.lencount = 0
        self.idx = -1
        self.extension = 0

由于当前模式为UnBounded,所以初始化为一个array.array. array是python提供的一个array模块(python万物皆为类,你认为是array类就行了),用于提供基本数字,字符类型的数组,容纳字符号,整型,浮点等基本类型。这个array在初始化的时候可以指定类型(Type code),例如这里’d’指定存储float类型的数字(因为咱们股票价格是浮点数),最小8个字节。然后初始化一些基本信息,注意这里idx记为-1.

至此,PandasData实例化完成。想想看,通过元类,这个实例化过程干了多少事!

PandasData的初始化

PandasData的donew完成之后,下一步就是dopreinit了。顺着家谱去找,PandasData的父类中MetaAbstractDataBase重写了dopreinit,看看这里面做了啥?

def dopreinit(cls, _obj, *args, **kwargs):
        _obj, args, kwargs = \
            super(MetaAbstractDataBase, cls).dopreinit(_obj, *args, **kwargs)

        # Find the owner and store it
        _obj._feed = metabase.findowner(_obj, FeedBase)

        _obj.notifs = collections.deque()  # store notifications for cerebro

        _obj._dataname = _obj.p.dataname
        _obj._name = ''
        return _obj, args, kwargs
  • 首先是调用父类的dopreinint,不用看了,dopreinit都没有定义,直接到MetaBase走一圈啥也没做。
  • 第二步找Pandas的owner,这里返回为空,因为PandasData是实例化的初始发起者,他自己没有owner。
  • 初始化notifs用于存储发送给Cerebro的通知。
  • 名称name赋值为空串。

dopreinit之后,就是doinit了。看家谱,就是PandasData类本身的__init__函数了:

def __init__(self):
        super(PandasData, self).__init__()

        # these "colnames" can be strings or numeric types
        colnames = list(self.p.dataname.columns.values)
        if self.p.datetime is None:
            # datetime is expected as index col and hence not returned
            pass

        # try to autodetect if all columns are numeric
        cstrings = filter(lambda x: isinstance(x, string_types), colnames)
        colsnumeric = not len(list(cstrings))

        # Where each datafield find its value
        self._colmapping = dict()

        # Build the column mappings to internal fields in advance
        for datafield in self.getlinealiases():
            defmapping = getattr(self.params, datafield)

            if isinstance(defmapping, integer_types) and defmapping < 0:
                # autodetection requested
                for colname in colnames:
                    if isinstance(colname, string_types):
                        if self.p.nocase:
                            found = datafield.lower() == colname.lower()
                        else:
                            found = datafield == colname

                        if found:
                            self._colmapping[datafield] = colname
                            break

                if datafield not in self._colmapping:
                    # autodetection requested and not found
                    self._colmapping[datafield] = None
                    continue
            else:
                # all other cases -- used given index
                self._colmapping[datafield] = defmapping    
  • 第一步就是调用父类的__init__,顺着家谱往上走,爸爸们没做啥,忽略。
  • 下一步就是将输入参数dataname(pandas数据DataFrame)的列名记录到colnames中,对于我们的例子,对应的[‘Unnamed: 0’ ‘open’ ‘high’ ‘low’ ‘close’ ‘volume’]。
  • 参数datetime通常不用输入,用于指示datetime在哪一列,通常咱们都是放到第一列(索引为0)。
  • 再检查下有没有列名字是数字的,是数字的话,直接记录到_colmapping。什么时候是数字?就是参数直接输入line对应的列索引。如果参数中指定了列名称所在的列(数字),那么系统就不会通过名字来映射,直接使用数字。当然数字为-1<0的话,也会查找列并记录列名字。我们例子中,所有都是字符串形式。
  • 下面一步就是将Pandas.DataFrame的列名称和PandasData的数据字段做好映射。PandasData的缺省字段包括:[‘datetime’, ‘open’, ‘high’, ‘low’, ‘close’, ‘volume’, ‘openinterest’]。映射关系放到_colmapping字典中,结果:{‘close’: ‘close’, ‘low’: ‘low’, ‘high’: ‘high’, ‘open’: ‘open’, ‘volume’: ‘volume’} .

至此,PandasData初始化也完成了。

PandasData的数据加载

PandasData的reset

还记得Cerebro如何使用datas?第一件事情就是reset(在Cerebro的runstrategies函数里):

for data in self.datas:
    data.reset()
    if self._exactbars < 1:  # datas can be full length
        data.extend(size=self.params.lookahead)
    data._start()
    if self._dopreload:
        data.preload()

好,下面看看data如何reset。

看代码,PandasData类中没有reset啊?别忘了咱们是面向对象,拿出家谱,沿着类家族继续往上找,找到LineSeries有一个reset,而且你再瞅一眼,data的操作都在这里呢。看看代码:

def reset(self):
        '''
        Proxy line operation
        '''
        for line in self.lines:
            line.reset()

看看,其实就是就是一个代理,遍历调用line(对应LineBuffer实例)的reset。有多少个line?看看前面lines类的初始化代码,包括close,low, high, open, volume, openinterest,还有一个datetime,一共7个。每一个line都会进行reset。reset代码前面讲过了,就是初始化一个array.array用于存储数据。

PandasData的_start

_start函数在PandasData类中就有定义:

def _start(self):
        self.start()
        if not self._started:
            self._start_finish()

嗯,调用start,然后_strat_finish函数。

start函数PandasData类有定义:

def start(self):
        super(PandasData, self).start()

        # reset the length with each start
        self._idx = -1

        # Transform names (valid for .ix) into indices (good for .iloc)
        if self.p.nocase:
            colnames = [x.lower() for x in self.p.dataname.columns.values]
        else:
            colnames = [x for x in self.p.dataname.columns.values]

        for k, v in self._colmapping.items():
            if v is None:
                continue  # special marker for datetime
            if isinstance(v, string_types):
                try:
                    if self.p.nocase:
                        v = colnames.index(v.lower())
                    else:
                        v = colnames.index(v)
                except ValueError as e:
                    defmap = getattr(self.params, k)
                    if isinstance(defmap, integer_types) and defmap < 0:
                        v = None
                    else:
                        raise e  # let user now something failed

            self._colmapping[k] = v
  • 第一步就是调用父类的start函数,又得看家谱了,是咱们的AbstractDataBase爸爸,看看干了啥:

    def start(self):
            self._barstack = collections.deque()
            self._barstash = collections.deque()
            self._laststatus = self.CONNECTED
    

    初始化了两个双边队列_barstack和_barstash(deque类),并记录一个状态。为啥用deque?这个是类似于list的容器,可以在队列头部和尾部添加、删除元素,可以快速高效地进行数据的操作。这个主要用于暂时存储数据。

  • 初始化索引为-1.这个需要注意,方便后续+1都可以得到0的起始索引。

  • colnames保存Padas.DataFrame(也就是原始数据)的列名字。

  • _colmapping还记得吗?PandasData类初始化时候处理的,里面记录的是PandasData类对应的原始数据的列名字,这里统一修改为原始数据的列索引(当然如果已经是参数输入数字的话,就直接记录),现在变成了:{‘close’: 4, ‘low’: 3, ‘high’: 2, ‘open’: 1, ‘volume’: 5, ‘openinterest’: None, ‘datetime’: None} ,后面两个没有对应的列。datetime没有是因为原始数据中date直接作为索引了,colnames中就没有了。datatime直接从原始数据第一列(索引为0)提取。

start之后继续调用_start_finish了,但是PandasData类中没有这个定义,所以找家谱图,找到AbstractDataBase,提供了_start_finish函数:

def _start_finish(self):
        # A live feed (for example) may have learnt something about the
        # timezones after the start and that's why the date/time related
        # parameters are converted at this late stage
        # Get the output timezone (if any)
        self._tz = self._gettz()
        # Lines have already been create, set the tz
        self.lines.datetime._settz(self._tz)

        # This should probably be also called from an override-able method
        self._tzinput = bt.utils.date.Localizer(self._gettzinput())

        # Convert user input times to the output timezone (or min/max)
        if self.p.fromdate is None:
            self.fromdate = float('-inf')
        else:
            self.fromdate = self.date2num(self.p.fromdate)

        if self.p.todate is None:
            self.todate = float('inf')
        else:
            self.todate = self.date2num(self.p.todate)

        # FIXME: These two are never used and could be removed
        self.sessionstart = time2num(self.p.sessionstart)
        self.sessionend = time2num(self.p.sessionend)

        self._calendar = cal = self.p.calendar
        if cal is None:
            self._calendar = self._env._tradingcal
        elif isinstance(cal, string_types):
            self._calendar = PandasMarketCalendar(calendar=cal)

        self._started = True
  • 设置data的时区以及各个lines datatime的时区。
  • 将参数输入的时间变换为数字形式,就是以公元1年1月1日零时记为1,每过1天就增加1,不够一天按照按照比例记(比如中午12点,记为0.5)。通过这样的方法,把时间转化为一个独一无二的数字,方便对数据的快速处理。
  • 记录日历信息到_calendar,具体使用后续再说。
  • 标记start完成。

PandasData的preload

还是老规矩,在家谱图里面找:AbstractDataBase提供了preload函数,preload循环调用load函数,加载数据:

def preload(self):
        while self.load():
            pass    
def load(self):
        while True:
            # move data pointer forward for new bar
            self.forward()

            if self._fromstack():  # bar is available
                return True

            if not self._fromstack(stash=True):
                _loadret = self._load()
                if not _loadret:  # no bar use force to make sure in exactbars
                    # the pointer is undone this covers especially (but not
                    # uniquely) the case in which the last bar has been seen
                    # and a backwards would ruin pointer accounting in the
                    # "stop" method of the strategy
                    self.backwards(force=True)  # undo data pointer

                    # return the actual returned value which may be None to
                    # signal no bar is available, but the data feed is not
                    # done. False means game over
                    return _loadret

            # Get a reference to current loaded time
            dt = self.lines.datetime[0]

            # A bar has been loaded, adapt the time
            if self._tzinput:
                # Input has been converted at face value but it's not UTC in
                # the input stream
                dtime = num2date(dt)  # get it in a naive datetime
                # localize it
                dtime = self._tzinput.localize(dtime)  # pytz compatible-ized
                self.lines.datetime[0] = dt = date2num(dtime)  # keep UTC val

            # Check standard date from/to filters
            if dt < self.fromdate:
                # discard loaded bar and carry on
                self.backwards()
                continue
            if dt > self.todate:
                # discard loaded bar and break out
                self.backwards(force=True)
                break

            # Pass through filters
            retff = False
            for ff, fargs, fkwargs in self._filters:
                # previous filter may have put things onto the stack
                if self._barstack:
                    for i in range(len(self._barstack)):
                        self._fromstack(forward=True)
                        retff = ff(self, *fargs, **fkwargs)
                else:
                    retff = ff(self, *fargs, **fkwargs)

                if retff:  # bar removed from systemn
                    break  # out of the inner loop

            if retff:  # bar removed from system - loop to get new bar
                continue  # in the greater loop

            # Checks let the bar through ... notify it
            return True

        # Out of the loop ... no more bars or past todate
        return False
  • 首先就是调用PandasData的forward函数,一样的,家谱图在父类LineSeries中提供forward函数:
def forward(self, value=NAN, size=1):
        self.lines.forward(value, size)

就是直接调用lines的forward,lines类函数直接传递到line(LineBuffer)的forward函数:

def forward(self, value=NAN, size=1):
        '''
        Proxy line operation
        '''
        for line in self.lines:
            line.forward(value, size=size)

LineBuffer的forward函数:

def forward(self, value=NAN, size=1):
        ''' Moves the logical index foward and enlarges the buffer as much as needed

        Keyword Args:
            value (variable): value to be set in new positins
            size (int): How many extra positions to enlarge the buffer
        '''
        self.idx += size
        self.lencount += size

        for i in range(size):
            self.array.append(value)

关键点在这里:

  1. 索引加1(缺省步幅)。还记得缺省idx是多少?-1,所以第一次调用这个函数就变成0了。
  2. 长度加1
  3. array中加入NAN无效值(初始化值)

继续load函数:

  • 调用_fromstack从_barstack或者_barstash中获取数据,现在无法获取,因为_start的时候是空的。
  • 没有获取到数据,那么就调用_load函数了,这里特别注意,由于pandasData类重写了_load函数,所以开始调用PandasData自己的加载函数了。
def _load(self):
        self._idx += 1

        if self._idx >= len(self.p.dataname):
            # exhausted all rows
            return False

        # Set the standard datafields
        for datafield in self.getlinealiases():
            if datafield == 'datetime':
                continue

            colindex = self._colmapping[datafield]
            if colindex is None:
                # datafield signaled as missing in the stream: skip it
                continue

            # get the line to be set
            line = getattr(self.lines, datafield)

            # indexing for pandas: 1st is colum, then row
            line[0] = self.p.dataname.iloc[self._idx, colindex]

        # datetime conversion
        coldtime = self._colmapping['datetime']

        if coldtime is None:
            # standard index in the datetime
            tstamp = self.p.dataname.index[self._idx]
        else:
            # it's in a different column ... use standard column index
            tstamp = self.p.dataname.iloc[self._idx, coldtime]

        # convert to float via datetime and store it
        dt = tstamp.to_pydatetime()
        dtnum = date2num(dt)
        self.lines.datetime[0] = dtnum

        # Done ... return
        return True

解析如下:

  1. 首先索引加1,从0开始。知道为啥初始化为-1的好处了吧。如果索引大于原始数据(注意dataname参数输入的是Pandas.DataFrame)的行数。表明加载完成(返回False,停止外层循环)。

  2. 根据data中每个line的别名(初始化的时候是close,low, high, open, volume, openinterest),在原始数据(Pandas.DataFrame)中找到对应的列编号(对应编号前面讲过,记录在_colmapping中)。然后将对应列的原始数据加入到line(例如close)的array.array中。这里就会有第一个数据了,例如array(‘d’, [1302.084])

  3. 然后就是找datetime,datetime通常作为索引放到第一列,所以从第一列取一个数据,并调用date2num转换为数字记录到所有line的datetime中,可以看出,所有line的时间必须一样。

    注意,_load函数一次只取一次数据,下面继续看load函数:

  • 加载数据之后,如果数据中输入了时区,那么就转化为本地时间,并更新datetime line的数据为新的日期数值。
  • 如果小于参数起始日期(fromdate)或者大于参数中终止日期(enddate),就会调用backwards函数。和forward一样,经过LineSeries的backforward直接到LineBuffer的backforward函数:
def backwards(self, size=1, force=False):
        ''' Moves the logical index backwards and reduces the buffer as much as needed

        Keyword Args:
            size (int): How many extra positions to rewind and reduce the
            buffer
        '''
        # Go directly to property setter to support force
        self.set_idx(self._idx - size, force=force)
        self.lencount -= size
        for i in range(size):
            self.array.pop()
  • 首先将idx回退,加第一个数据的时候是0,回退到-1了。长度也减去回退的步幅(这里是1)。然后就需要将之前加值删除掉(array的pop函数删除掉一个元素,缺省是最新加的一个数据)。那么array就变成array(‘d’)了,没数据。也就是不符合起始时间的数据不会被加载。注意,是每一个line(close,high,low等等)都会回退。

  • 回退之后,如果是小于起始日期,则继续循环。如果是大于终止日期,那么就停止,数据加载完成。

  • load函数就这样一直循环到日期等于起始日期,加入起始日期的数据,并对数据进行过滤处理(过滤器_filters后面再讲)。处理完毕,就到回到preload了。preolod没干啥,继续load了。

  • 首先forward,由于之前已经加了一个数据,所以这里idx变成1了,同时在array.array在加一个空值。

  • 然后_load了(看前面描述)再加一个数据到array.array中,这样循环下去,直到大于终结日期(注意大于终结日期的第一个数据会backward),或者取得的数据大于原始数据长度(也就是数据取完了),循环结束。

  • load循环结束,回到preload函数(AbstractDataBase),也终止循环,并且调用_last进行最后的处理(这里可以再次过滤),具体代码不贴了。

  • 再然后调用home函数,和forward路线一样,直接到LineBuffer的home,就是将每个line的idx设置为初始值(-1),长度计数(lencount)设置为0。这个主要用于加载数据的计数。如果要获取某个line的数据个数,可以使用buflen。这样状态恢复至加载前的状态。

def home(self):
        ''' Rewinds the logical index to the beginning

        The underlying buffer remains untouched and the actual len can be found
        out with buflen
        '''
        self.idx = -1
        self.lencount = 0

至此,preload完毕,总体上就是将原始数据(来自Pandas.Dataframe)填写到Linebuffer对象的array.array.

PandasData的next

前面看代码Cerebro在runstrategies函数中调用preload的代码,前面是有一个条件的:

if self._dopreload:
        data.preload()

就是要满足_dopreload的时候才能预加载,实际上,如下几种情况不会预加载:

  1. 数据源datas包含实时(live)数据。
  2. 数据源包含resample和replay数据。

那么这些情况下如何加载数据呢?那就是在next过程中加载。

看Cerebro的_runnext函数中一段(整体在下一章使用中说明)

            for d in datas:
                qlapse = datetime.datetime.utcnow() - qstart
                d.do_qcheck(newqcheck, qlapse.total_seconds())
                drets.append(d.next(ticks=False))

直接调用数据的next函数,我们来找next函数。家谱图,发现了next在AbstractDataBase,看看是如何处理的:

def next(self, datamaster=None, ticks=True):

        if len(self) >= self.buflen():
            if ticks:
                self._tick_nullify()

            # not preloaded - request next bar
            ret = self.load()
            if not ret:
                # if load cannot produce bars - forward the result
                return ret

            if datamaster is None:
                # bar is there and no master ... return load's result
                if ticks:
                    self._tick_fill()
                return ret
        else:
            self.advance(ticks=ticks)

        # a bar is "loaded" or was preloaded - index has been moved to it
        if datamaster is not None:
            # there is a time reference to check against
            if self.lines.datetime[0] > datamaster.lines.datetime[0]:
                # can't deliver new bar, too early, go back
                self.rewind()
            else:
                if ticks:
                    self._tick_fill()

        else:
            if ticks:
                self._tick_fill()

        # tell the world there is a bar (either the new or the previous
        return True

可以看出,主要也是调用load函数加载数据,load函数前面讲过,这里不再讨论了。

PandasData的过滤和调整

前面介绍原始数据,有时候咱们还需要进行 一定的处理才能使用。backtrader提供了Resample,Replay,下面分别讨论。

虽然没有数据类关系图谱那么复杂,但是为了方便解读,还是提供下resampler以及Replayer的关系图谱。

Resample和Replay关系图谱

在这里插入图片描述

Resampling

有时候咱们的策略中,同时要看日线和周线,咱们原始数据是每天的(甚至还有每小时每分钟的),那咋办?backtrader给你提供了resampledata函数。

Resampling主要用于将粒度小的数据重新抽样为粒度大的数据,例如日线转为周线。

backtrader内置了过滤器对象,可以进行对原始数据进行处理。通过如下代码可以简单实现:

    stock_hfq_df = pd.read_csv("../data/sh000001上证指数.csv",index_col='date',parse_dates=True)
    start_date = datetime(2021, 9,1 )  # 回测开始时间
    end_date = datetime(2021, 9, 10)  # 回测结束时间
    data = bt.feeds.PandasData(dataname=stock_hfq_df, fromdate=start_date)  # 加载数据
    
    cerebro.adddata(data)  # 将数据传入回测系统
    
    
    cerebro.resampledata(data,timeframe=bt.TimeFrame.Weeks)
  • 首先要实例化一个原始数据。
  • 然后更改时间长度。通常两个方法:一个是直接输入时间粒度(timeframe),比如一周;另外一个压缩比(compression),比如compression为2,表示2行压缩为1行。

resampledata函数上一篇文章在Cerebro解读的时候讲过,这里再明确下:

def resampledata(self, dataname, name=None, **kwargs):
        '''
        Adds a ``Data Feed`` to be resample by the system

        If ``name`` is not None it will be put into ``data._name`` which is
        meant for decoration/plotting purposes.

        Any other kwargs like ``timeframe``, ``compression``, ``todate`` which
        are supported by the resample filter will be passed transparently
        '''
        if any(dataname is x for x in self.datas):
            dataname = dataname.clone()

        dataname.resample(**kwargs)
        self.adddata(dataname, name=name)
        self._doreplay = True

        return dataname
  • 首先从已经加入的data中找到参数中指定的需要resample的数据,所以一定要注意,这个函数只能针对已有数据。然后从克隆一个完全一样的数据。注意,克隆后完全是新的地址空间,但是内容是完全一样。CloneDatabase在数据家族中的地位参见第一章家谱图右下角,可以看出,他继承了AbastractDatabase,也就是它和普通数据类操作差不多,只是重写了start/preload/_load/Advace函数,也就是这几个处理有不同。
  • 然后调用data的resample函数,这里又涉及到元类了,顺着家谱,找到AbstractDataBase(好眼熟啊,又是它):
    def resample(self, **kwargs):
        self.addfilter(Resampler, **kwargs)
    def addfilter(self, p, *args, **kwargs):
        if inspect.isclass(p):
            pobj = p(self, *args, **kwargs)
            self._filters.append((pobj, [], {}))

            if hasattr(pobj, 'last'):
                self._ffilters.append((pobj, [], {}))

        else:
            self._filters.append((p, args, kwargs))

直接就调用addfilter加载滤器类(Resampler)了。注意这个函数输入的既可以是类,也可以是实例。当前使用的是类,先实例化得到具体对象,然后加入到数据的_filters. 眼熟吧?在数据load的时候会调用(往前搜索下)。

看到这里,你可以看到,Resample加入的数据,和原数据完全一样,就是增加一个Resampler的对象,在加载数据的时候进行针对性处理。

下面看看Resampler类了。

Resampler的实例化和初始化

根据家谱来看,Resampler父类有元类,所以实例化还得到MetaBase走一圈,套路一样,就不详细描述,只解析过程波及的函数。

根据家谱图,只有MetaParams重写donew函数,这个函数解析过,重点是进行参数到属性的映射,并完成实例化。

关键参数包括(从家谱中找,所有祖先的属性都会继承到Resampler实例中):

参数缺省值含义
adjbartimeTRUE使用边界时间调整采样后的时间,而不是最后看到的时间戳。如果重新采样时间粒度为5s。那么时间调整为hh:mm:05
即使在这个时间宽度范围内最后一个bar的时间戳是hh:mm:04.33。
bar2edgeTRUE以时间边界为目标的重采样。比如说,将ticks(时间戳)按照5秒粒度重新采样,则生成的5秒粒度将对齐如下:
xx:00,xx:05,xx:10…
boundoff0就是向前移动一定数量数据(bar)用来resample。比如说,现在是1分钟粒度抽样为15分钟粒度,系统缺省是从 00:01:00 到00:15:00 15个1分钟粒度的数据产生1个15分钟粒度。如果这个值设置为1,那么向前移动一位,从 00:00:00 到00:14:00也是15个1分钟粒度的数据产生一个15分钟粒度的数据。
compression1压缩比(compression),比如compression为2,表示2个小粒度数据压缩为1个目标粒度数据。
rightedgeTRUE使用边界时间的右边缘作为采用后的时间,一样,如果采用目标长度是5s:
设置为False:hh:mm:00到hh:mm:04之间的秒数抽样为为hh:mm:00(边界的开始时间)
设置为True,那么抽样后为hh:mm:05(边界结束时间)

实例化之后,就开始初始化了。看家谱图,找到_BaseResampler的__init__,里面主要就是参数的初始化,关键是将自己和data对象绑定。代码就不贴了。

Resmapler的数据加载

Resampler的数据加载,加载过程和普通data差不多,只是加载的是DataClone。首先是在runstrategis函数中调用_start函数,这个函数DataClone在重写:

def _start(self):
        # redefine to copy data bits from guest data
        self.start()

        # Copy tz infos
        self._tz = self.data._tz
        self.lines.datetime._settz(self._tz)

        self._calendar = self.data._calendar

        # input has already been converted by guest data
        self._tzinput = None  # no need to further converr

        # Copy dates/session infos
        self.fromdate = self.data.fromdate
        self.todate = self.data.todate

        # FIXME: if removed from guest, remove here too
        self.sessionstart = self.data.sessionstart
        self.sessionend = self.data.sessionend

    def start(self):
        super(DataClone, self).start()
        self._dlen = 0
        self._preloading = False

  • 首先调用DataClone父类的start,父类就是AbstractDataBase,如何start和普通Data一样,请反向搜索查看。然后各条line的设定时区。记录起始结束日期。特别注意的是,这里的_preloading的时候都是False。

  • 然后就是调用dataclone的next(参见4.4节,也许你说了,也没看到4.4节啊,主要是MarkDown不支持自动加编号,顺着标题看第4个一级标题,第4个二级标题)。再调用DataClone的_load函数了,代码如下:

    def _load(self):
            # assumption: the data is in the system
            # simply copy the lines
            if self._preloading:
                # data is preloaded, we are preloading too, can move
                # forward until have full bar or data source is exhausted
                self.data.advance()
                if len(self.data) > self.data.buflen():
                    return False
    
                for line, dline in zip(self.lines, self.data.lines):
                    line[0] = dline[0]
    
                return True
    
            # Not preloading
            if not (len(self.data) > self._dlen):
                # Data not beyond last seen bar
                return False
    
            self._dlen += 1
    
            for line, dline in zip(self.lines, self.data.lines):
                line[0] = dline[0]
    
            return True
    

    如果不是预加载,直接从DataClone的原始数据(本例这两个就是Data0,在初始化的时候已绑定)中将所有Line拷贝到CloneData的lines中。

Resampler的使用

我们已经知道,数据加载数据的时候会调用过滤器对数据进行处理。在AbstractDataBase的load函数中,在加载数据完成之前(请看前述PandasData的preload章节),最后会经过filters进行处理,代码如下:

# Pass through filters
            retff = False
            for ff, fargs, fkwargs in self._filters:
                # previous filter may have put things onto the stack
                if self._barstack:
                    for i in range(len(self._barstack)):
                        self._fromstack(forward=True)
                        retff = ff(self, *fargs, **fkwargs)
                else:
                    retff = ff(self, *fargs, **fkwargs)

                if retff:  # bar removed from systemn
                    break  # out of the inner loop

            if retff:  # bar removed from system - loop to get new bar
                continue  # in the greater loop
  • 代码先看看之前是否有filter处理过,这就要看_barstack(为啥加stack,就是为了回溯之前的处理)。咱们这里是第一个filter,所以直接到 ff(self, *fargs, **fkwargs)。ff是Resampler对象,加个括号和参数啥意思?这个是显式调用Resampler类的__call__。这一段代码过于晦涩难懂,考虑到这个对我们理解Backtrader作用不大,我们只需要知道如何使用即可,后续就不深入解读这一块代码(说不定有时间再仔细看看)。

Resampler作用的结果

我们重点看看经过Resampler处理之后数据的呈现,这样我们可以根据需要使用它来达到我们的期望。首先我们有这样一个原始数据。将每天粒度(日线)改为每周粒度(周线)。

日期星期原始closeResample后的close
2021/9/133567.101-
2021/9/243597.043-
2021/9/353581.734-
2021/9/613621.8593581.734
2021/9/723676.5873581.734
2021/9/833675.1873581.734
2021/9/943693.133581.734
2021/9/1053703.113581.734
2021/9/1313715.3723703.11
2021/9/1423662.6023703.11
2021/9/1533656.2233703.11
2021/9/1643607.0923703.11
2021/9/1753613.9663703.11
2021/9/2233628.493613.966
2021/9/2343642.223613.966
2021/9/2453613.0673613.966
2021/9/2713582.8313613.067
2021/9/2823602.2183613.067
2021/9/2933536.2943613.067
2021/9/3043568.1673613.067

这个就是原始的的数据,下面看看Resampler过滤后的数据:

  • 首先,第一周的数据,因为需要根据第一周的数据生成周线;
  • 下一周开始,周线记录的是上一周周五的数据。

从以上图标,可以清晰地看出Resampler是如何处理的。

Replaying

Replay对应的过滤器是Replayer,从关系图谱可以看出,基本上和Resampler差不多。从名字来看,数据就是重新播放一下,关键是数据的生成或者说使用过程,这个需要在next过程中查看,后续统一描述。

自定义过滤器

如果我们要定制自己的过滤规则怎么办?可以继承Resampler类值处理,但是我觉得这个完全没必要。如果有更复杂的数据定制需求,咱们可以在Backtrader加载之前使用Pandas进行预处理啊,Pandas多强大,还需要自己编码处理数据?

PandasData的使用

Pandas的数据被使用,在两种情况:

  1. 数据已经预加载并且参数设置runonce为True(缺省值)
  2. 其他情况下都是_runnext

在Cerebro的_runonce中使用

数据加载之后,系统如何使用这些数据呢?这个使用主要还是看Cerebro如何调度,这里还是以Cerebro的_runonce函数对data的处理为线索:

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

在_runonce函数中,循环进行数据(所有数据indicator等)的协调处理,首先就是数据时间的对齐,如何做到的呢?为了更容易说明,我们示例中加入了两个数据:第一个数据起始(索引为0)时间2021/9/3(对应数字为738036),第二个数据(索引为1)的起始时间2021/9/3(对应数字为738034)。看看是如何处理的:

  • 首先调用data的advance_peek,这个函数在哪里,在家谱中找,喔,在AbstractDataBase中:

        def advance_peek(self):
            if len(self) < self.buflen():
                return self.lines.datetime[1]  # return the future
    
            return float('inf')  # max date else
    

    逻辑很简单,取得data的下一个时间,如果是第一次取数据,下一个就是0(也就是第一个),因为索引初始化为-1。

  • 将两个data的第一个日期值取回放到dts数组中,这个dts是[738036.0, 738034.0]. 并取其最小值(对应最早日期9月1日)到dt0.

  • 下一步就是循环dts,走到dts中小于等于dt0的data,说白了,就是找到初始日期最早的data,然后对该data调用advance。

  • advance函数在家谱中,一样追溯到AbstractDataBase中:

def advance(self, size=1, datamaster=None, ticks=True):
        if ticks:
            self._tick_nullify()

        # Need intercepting this call to support datas with
        # different lengths (timeframes)
        self.lines.advance(size)

直接调用lines的advance,lines只是的代理,再调用所有该data的所有lines的,到LineBuffer的advance:

  def advance(self, size=1):
        ''' Advances the logical index without touching the underlying buffer

        Keyword Args:
            size (int): How many extra positions to move forward
        '''
        self.idx += size
        self.lencount += size

就是所有line索引后移,长度计数增加。这里idx变为0了。注意,这里只改索引,不涉及底层数据。再看下一轮循环:

  • 取下一个日期值放到dts数组中。取得是哪个值呢,请注意,data0的idx没动,还是-1,data1已经advance中idx变为0了。所以advance_peek返回的时间:data0还是第一个数据,data1是第二个数据了。dts=[738036.0, 738035.0],看data1的时间已经后退到9月2日了。
  • 然后继续找到最早的数据,还是data1,然后继续advace。索引变为1了。
  • 在继续循环,一样的原理,dts就是[738036.0, 738036.0]了,两个时间终于相同了。这种情况下,data0和data1都需要advance,idx后移。data0的idx为0,data1的idx=2了。也就是data0和data1基于时间线对齐了。
  • 后续就一同后移(next),数据提供给Strategies使用了,具体如何用,咱们后续在Strategies类中再详细解读。

在Cerebro的_runnext中使用

由于_runnext函数中大量对数据的处理,虽然上一篇文章已经描述过,但是不够细节,这里再以数据处理的视角重点解析,代码如下:

def _runnext(self, runstrats):
        '''
        Actual implementation of run in full next mode. All objects have its
        ``next`` method invoke on each data arrival
        '''
        datas = sorted(self.datas,
                       key=lambda x: (x._timeframe, x._compression))
        datas1 = datas[1:]
        data0 = datas[0]
        d0ret = True

        rs = [i for i, x in enumerate(datas) if x.resampling]
        rp = [i for i, x in enumerate(datas) if x.replaying]
        rsonly = [i for i, x in enumerate(datas)
                  if x.resampling and not x.replaying]
        onlyresample = len(datas) == len(rsonly)
        noresample = not rsonly

        clonecount = sum(d._clone for d in datas)
        ldatas = len(datas)
        ldatas_noclones = ldatas - clonecount
        lastqcheck = False
        dt0 = date2num(datetime.datetime.max) - 2  # default at max
        while d0ret or d0ret is None:
            # if any has live data in the buffer, no data will wait anything
            newqcheck = not any(d.haslivedata() for d in datas)
            if not newqcheck:
                # If no data has reached the live status or all, wait for
                # the next incoming data
                livecount = sum(d._laststatus == d.LIVE for d in datas)
                newqcheck = not livecount or livecount == ldatas_noclones

            lastret = False
            # Notify anything from the store even before moving datas
            # because datas may not move due to an error reported by the store
            self._storenotify()
            if self._event_stop:  # stop if requested
                return
            self._datanotify()
            if self._event_stop:  # stop if requested
                return

            # record starting time and tell feeds to discount the elapsed time
            # from the qcheck value
            drets = []
            qstart = datetime.datetime.utcnow()
            for d in datas:
                qlapse = datetime.datetime.utcnow() - qstart
                d.do_qcheck(newqcheck, qlapse.total_seconds())
                drets.append(d.next(ticks=False))

            d0ret = any((dret for dret in drets))
            if not d0ret and any((dret is None for dret in drets)):
                d0ret = None

            if d0ret:
                dts = []
                for i, ret in enumerate(drets):
                    dts.append(datas[i].datetime[0] if ret else None)

                # Get index to minimum datetime
                if onlyresample or noresample:
                    dt0 = min((d for d in dts if d is not None))
                else:
                    dt0 = min((d for i, d in enumerate(dts)
                               if d is not None and i not in rsonly))

                dmaster = datas[dts.index(dt0)]  # and timemaster
                self._dtmaster = dmaster.num2date(dt0)
                self._udtmaster = num2date(dt0)

                # slen = len(runstrats[0])
                # Try to get something for those that didn't return
                for i, ret in enumerate(drets):
                    if ret:  # dts already contains a valid datetime for this i
                        continue

                    # try to get a data by checking with a master
                    d = datas[i]
                    d._check(forcedata=dmaster)  # check to force output
                    if d.next(datamaster=dmaster, ticks=False):  # retry
                        dts[i] = d.datetime[0]  # good -> store
                        # self._plotfillers2[i].append(slen)  # mark as fill
                    else:
                        # self._plotfillers[i].append(slen)  # mark as empty
                        pass

                # make sure only those at dmaster level end up delivering
                for i, dti in enumerate(dts):
                    if dti is not None:
                        di = datas[i]
                        rpi = False and di.replaying   # to check behavior
                        if dti > dt0:
                            if not rpi:  # must see all ticks ...
                                di.rewind()  # cannot deliver yet
                            # self._plotfillers[i].append(slen)
                        elif not di.replaying:
                            # Replay forces tick fill, else force here
                            di._tick_fill(force=True)

                        # self._plotfillers2[i].append(slen)  # mark as fill

            elif d0ret is None:
                # meant for things like live feeds which may not produce a bar
                # at the moment but need the loop to run for notifications and
                # getting resample and others to produce timely bars
                for data in datas:
                    data._check()
            else:
                lastret = data0._last()
                for data in datas1:
                    lastret += data._last(datamaster=data0)

                if not lastret:
                    # Only go extra round if something was changed by "lasts"
                    break

            # Datas may have generated a new notification after next
            self._datanotify()
            if self._event_stop:  # stop if requested
                return

            if d0ret or lastret:  # if any bar, check timers before broker
                self._check_timers(runstrats, dt0, cheat=True)
                if self.p.cheat_on_open:
                    for strat in runstrats:
                        strat._next_open()
                        if self._event_stop:  # stop if requested
                            return

            self._brokernotify()
            if self._event_stop:  # stop if requested
                return

            if d0ret or lastret:  # bars produced by data or filters
                self._check_timers(runstrats, dt0, cheat=False)
                for strat in runstrats:
                    strat._next()
                    if self._event_stop:  # stop if requested
                        return

                    self._next_writers(runstrats)

        # Last notification chance before stopping
        self._datanotify()
        if self._event_stop:  # stop if requested
            return
        self._storenotify()
        if self._event_stop:  # stop if requested
            return    

首先说明下,这次咱们使用的是两个数据作为例子:

  1. 一个是按照日期粒度的原始数据。
  2. 一个是Resample按照周粒度的重新抽样数据。

再看代码:

  • 首先按照粒度对数据进行排序,原始数据粒度为天,所以保存到data0中,其他数据保存到datas1(注意,这个是列表,可以保存多个数据)。
  • 下一段就是统计有多少数据是resample和replay的数据,以及是clone的数据,记好标记。
  • dt0初始化为最大时间(9999年12月31日)的日期数值。
  • 开始循环,对实时数据以及通知的处理忽略,快进到数据的处理,记录好数据处理开始的时间。
  • 开始每个数据的处理,首先记录下从时间差(当前data处理减去上一步记录的数据处理时间)。然后调用data的do_qcheck函数。根据家谱图,找到AbastractDataBase中:
def do_qcheck(self, onoff, qlapse):
        # if onoff is True the data will wait p.qcheck for incoming live data
        # on its queue.
        qwait = self.p.qcheck if onoff else 0.0
        qwait = max(0.0, qwait - qlapse)
        self._qcheck = qwait

​ 将时间差记住,后续处理数据的时候要等待对应的是时间。

然后就是调用data的next函数(4.4节已有描述),并记录返回值到drets。next中会加载符合时间要求的数据(注意普通数据和resample数据加载方法不一样)。同时,对于Resample的data(DataClone)返回值是False。

根据返回值,提取普通数据的date值最小的data,也就是时间最早的data,记为主数据(dmaster)。分别记录具体时间和日期值。

后续检查drets返回值为False的数据(也就是Resample的DataClone数据),首先调用_check函数进行过滤(Resample)处理。然后就是调用next函数。在家谱图中咱们看到DataClone继承AbstractDataBase,因此调用的是它的next。AbastractDataBase的next函数参见4.4节描述,这里就不再一次贴代码了。先调用AbstractDataBase的load,再调用CloneDataBase的_load函数(因为被重写了,参见家谱图),这个DataClone的处理和普通Data的处理有不同,就是直接从clone的原始数据获取,不过这里,并没有获取到数据(过滤策略),所以dts[1]并没有记录到时间。只有获取到一个数据,才会记录这个时间。还记得dts[0]的时间是什么时候?原始数据的起始时间2021年9月1日。

后面的处理就和data关系不大了,就到了Strategies的next驱动。

总体上,和_runonce相比,_runnext由于数据并没有预加载,所以在next过程中驱动加载。

这里只是描述了数据在Cerebro的使用,而关键在Strategies中如何使用,这一块下一篇文章中专题讲。

如何增加自定义的数据

前面我们说过,lines包含close,low, high, open, volume, openinterest,还有一个datetime。如果我选股需要更多的数据怎么办?比如PE(市盈率)、ROE(净资产收益率)和turnover(换手率)等等。如果你有基本的面向对象知识,通过我们上述代码解析,就可以很容易知道如何办了。咱们自定义一个数据类,从PandasData继承:

class MyCustomdata(PandasData):
    lines = ('turnover',)
    params = (('turnover',-1),)

简单两行代码:

  • 首先增加一个line(也可以加多个,这里只是一个示例)。那其他的lines呢?从PandasData继承啊。这里有一点注意下,如果只增加一个line,别忘了元组里面逗号不能省,为啥?前面文章讲过。
  • 添加一个参数,指示该Line(turnover)对应的原始数据PandasFrame的列号。如果是-1的话,就是让系统从原始数据Pandas.DataFrame列名称中匹配查找。

看看,是如何的简单!甚至不用重写任何函数。这就是面向对象程序的伟大,所有的处理细节都抽象到父类完成。突然想到有个介绍backtrader的专家,说面向对象没必要,还是过程编程比较好,呵呵。Backtrader的目标就是易于使用,为啥易于使用?那是因为复杂的事情框架都做了啊。

好,继续,那这个自定义类怎么用呢?用法就是和PandasData一样:

    stock_hfq_df = pd.read_csv("../data/sh600000.csv",index_col='date',parse_dates=True)
    start_date = datetime(2021, 9,1 )  # 回测开始时间
    end_date = datetime(2021, 9, 30)  # 回测结束时间
    data=MyCustomdata(dataname=stock_hfq_df, fromdate=start_date,todate=end_date)

看看咱们数据加载好没有,可以在strategies的next函数中增加打印看看:

def next(self):
        self.log('Close:%.3f' % self.data0.close[0])
        self.log('turnover, %.8f' % self.data0.turnover[0])

结果:

…(省略若干)
2021-09-27, Close:127.110
2021-09-27, turnover, 0.00142096
2021-09-28, Close:127.260
2021-09-28, turnover, 0.00113589
2021-09-29, Close:127.110
2021-09-29, turnover, 0.00136304
2021-09-30, Close:126.830
2021-09-30, turnover, 0.00099448

嗯,数据加载成功,咱们在策略中就可以使用换手率了。

总结

本文我们详细讲解了Backtrader中数据相关类的关系以及初始化、数据加载等过程,并提供了自定义数据类的方法。其中元类的使用,有志于编制大型框架的同学可以参考。

当然,我们是以使用者的角度来进行解读,还有一些数据类并未波及。没关系,如果后续在使用过程中有波及,我们会针对性解读。

除了PandasData,还有直接从CSV读取数据的类GenericCSVData,这个只要看看类继承关系,基于我们前面的代码解读,就很容易理解和使用,不再描述。

至此,咱们的数据已经ready,那么下一步就是看Strategies如何使用这些数据进行回测。

  • 11
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 10
    评论
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值