Crossing over numbers

An oversight has been corrected with Release 1.9.27.105 of backtrader. It was an oversight because all pieces of the puzzle were in place, but the activation was not made in all corners.

The mechanism uses an attribute called _mindatas, so let’s call it: mindatas.

The community asked and the answer was not really right on spot. See the conversation here:

Even if the conversation was about something else, the question could have quickly answered with: “Hey, it should actually work!”. But who has time to consider a proper and thoughtful answer these days.

Let’s consider the use case of crossing over a plain old number argument. Something like this

mycrossover = bt.ind.CrossOver(bt.ind.RSI(), 50.0)

Which would break as in

Traceback (most recent call last):
  File "./cross-over-num.py", line 114, in <module>
    runstrat()
  File "./cross-over-num.py", line 70, in runstrat
    cerebro.run(**eval('dict(' + args.cerebro + ')'))
  File "d:\dro\01-docs\01-home\src\backtrader\backtrader\cerebro.py", line 810, in run
    runstrat = self.runstrategies(iterstrat)
  File "d:\dro\01-docs\01-home\src\backtrader\backtrader\cerebro.py", line 878, in runstrategies
    strat = stratcls(*sargs, **skwargs)
  File "d:\dro\01-docs\01-home\src\backtrader\backtrader\metabase.py", line 87, in __call__
    _obj, args, kwargs = cls.doinit(_obj, *args, **kwargs)
  File "d:\dro\01-docs\01-home\src\backtrader\backtrader\metabase.py", line 77, in doinit
    _obj.__init__(*args, **kwargs)
  File "./cross-over-num.py", line 35, in __init__
    bt.ind.CrossOver(bt.ind.RSI(), 50)
  File "d:\dro\01-docs\01-home\src\backtrader\backtrader\indicator.py", line 53, in __call__
    return super(MetaIndicator, cls).__call__(*args, **kwargs)
  File "d:\dro\01-docs\01-home\src\backtrader\backtrader\metabase.py", line 87, in __call__
    _obj, args, kwargs = cls.doinit(_obj, *args, **kwargs)
  File "d:\dro\01-docs\01-home\src\backtrader\backtrader\metabase.py", line 77, in doinit
    _obj.__init__(*args, **kwargs)
 Typeerror: __init__() takes exactly 1 argument (2 given)

Being the last line the most informative, because it is telling us that something got too many arguments. And this means that the 50.0 is hurting us.

To solve the problem at hand, a number wrapper was given as the answer.

class ConstantValue(bt.Indicator):
    lines = ('constant',)
    params = (('constant', float('NaN')),)

    def next(self):
        self.lines.constant[0] = self.p.constant

...

mycrossover = bt.ind.CrossOver(bt.ind.RSI(), ConstantValue(50.0))

Problem solved. But wait, the solution was already on board. There is an internal helper, to solve the problem and it was completely forgotten: LineNum. And it does what the name tries to imply: Takes a num and makes it a line. The problem solution was there and the solution could have looked like this:

mycrossover = bt.ind.CrossOver(bt.ind.RSI(), bt.LineNum(50.0))

The usual background thread kept anyhow ticking, telling something was still not 100% clear and the solution should be the obvious, without having the user specifying the wrapper.

And here comes the oversight. Even if the mindatas mechanism exists and is applied in some parts of the echosystem, it was not being applied to CrossOver. It was tried, but humans miserably fail sometimes, believing they have done something only to find out, they didn’t scroll further downwards. And this was the case. A one line addition like this:

class CrossOver(Indicator):
    ...
    _mindatas = 2
    ...

And now the solution to the question is straightforward:

mycrossover = bt.ind.CrossOver(bt.ind.RSI(), 50.0)

The way it should have always been in the 1st place (see the sample and chart below)

mindatas at work

This is a handy attribute which is meant to be used for specific situations and hence the leading _, to indicate it should be used with real caution. The default value for indicators is:

  • _mindatas = 1

    This tells the system that if NO data source has been passed to an indicator, the system should copy the 1st data source from the parent. Without this, instantiating for example the RelativeStrengthIndicator should be done like this:

    class Strategy(bt.Indicator):
        def __init__(self):
            rsi = bt.ind.RSI(self.data0)
    

    But with the default indication given by _mindatas, the following is possible:

    class Strategy(bt.Indicator):
        def __init__(self):
            rsi = bt.ind.RSI()
    

    And the result is exactly the same, because the 1st data source in the strategy, self.data0 is passed to the instantiation of RSI

An indicator like CrossOver needs 2 data feeds, because it’s checking that one thing is crossing over another. In this case and as seen above the default has been set to:

  • _mindatas = 2

This tells the system things like:

  • If no datas are passed, then copy 2 data feeds from the parent (if possible)

  • If only 1 data has been passed, try to convert the next incoming argument to a lines object to have 2 data feeds available. Usseful for the use case of a line crossing over a plain old float. Again for reference:

    mycrossover = bt.ind.CrossOver(bt.ind.RSI(), 50.0)
    
  • If 2 or more data feeds are passed to CrossOver, do nothing and proceed further

In the community, the mechanism has been lately applied to for example the 1st sketches to implement the KalmanFilter for pair trading. And when talking about pairs, one needs 2 data feeds and with it: _mindatas = 2

A small sample (although with a complete skeleton) to test the complete solution:

$ ./cross-over-num.py --plot

Which outputs this.

Sample usage

$ ./cross-over-num.py --help
usage: cross-over-num.py [-h] [--data0 DATA0] [--fromdate FROMDATE]
                         [--todate TODATE] [--cerebro kwargs]
                         [--broker kwargs] [--sizer kwargs] [--strat kwargs]
                         [--plot [kwargs]]

Sample Skeleton

optional arguments:
  -h, --help           show this help message and exit
  --data0 DATA0        Data to read in (default:
                       ../../datas/2005-2006-day-001.txt)
  --fromdate FROMDATE  Date[time] in YYYY-MM-DD[THH:MM:SS] format (default: )
  --todate TODATE      Date[time] in YYYY-MM-DD[THH:MM:SS] format (default: )
  --cerebro kwargs     kwargs in key=value format (default: )
  --broker kwargs      kwargs in key=value format (default: )
  --sizer kwargs       kwargs in key=value format (default: )
  --strat kwargs       kwargs in key=value format (default: )
  --plot [kwargs]      kwargs in key=value format (default: )

Sample Code

from __future__ import (absolute_import, division, print_function,
                        unicode_literals)


import argparse
import datetime

import backtrader as bt


class St(bt.Strategy):
    params = ()

    def __init__(self):
        bt.ind.CrossOver(bt.ind.RSI(), 50)

    def next(self):
        pass


def runstrat(args=None):
    args = parse_args(args)

    cerebro = bt.Cerebro()

    # Data feed kwargs
    kwargs = dict()

    # Parse from/to-date
    dtfmt, tmfmt = '%Y-%m-%d', 'T%H:%M:%S'
    for a, d in ((getattr(args, x), x) for x in ['fromdate', 'todate']):
        if a:
            strpfmt = dtfmt + tmfmt * ('T' in a)
            kwargs[d] = datetime.datetime.strptime(a, strpfmt)

    # Data feed
    data0 = bt.feeds.BacktraderCSVData(dataname=args.data0, **kwargs)
    cerebro.adddata(data0)

    # Broker
    cerebro.broker = bt.brokers.BackBroker(**eval('dict(' + args.broker + ')'))

    # Sizer
    cerebro.addsizer(bt.sizers.FixedSize, **eval('dict(' + args.sizer + ')'))

    # Strategy
    cerebro.addstrategy(St, **eval('dict(' + args.strat + ')'))

    # Execute
    cerebro.run(**eval('dict(' + args.cerebro + ')'))

    if args.plot:  # Plot if requested to
        cerebro.plot(**eval('dict(' + args.plot + ')'))


def parse_args(pargs=None):
    parser = argparse.ArgumentParser(
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        description=(
            'Sample Skeleton'
        )
    )

    parser.add_argument('--data0', default='../../datas/2005-2006-day-001.txt',
                        required=False, help='Data to read in')

    # Defaults for dates
    parser.add_argument('--fromdate', required=False, default='',
                        help='Date[time] in YYYY-MM-DD[THH:MM:SS] format')

    parser.add_argument('--todate', required=False, default='',
                        help='Date[time] in YYYY-MM-DD[THH:MM:SS] format')

    parser.add_argument('--cerebro', required=False, default='',
                        metavar='kwargs', help='kwargs in key=value format')

    parser.add_argument('--broker', required=False, default='',
                        metavar='kwargs', help='kwargs in key=value format')

    parser.add_argument('--sizer', required=False, default='',
                        metavar='kwargs', help='kwargs in key=value format')

    parser.add_argument('--strat', required=False, default='',
                        metavar='kwargs', help='kwargs in key=value format')

    parser.add_argument('--plot', required=False, default='',
                        nargs='?', const='{}',
                        metavar='kwargs', help='kwargs in key=value format')

    return parser.parse_args(pargs)


if __name__ == '__main__':
    runstrat()

Comments

Fork me on GitHub