Momentum Strategy
In another great post, Teddy Koker, has shown again a path for the development of algotrading strategies:
- Research first applying
pandas
- Backtesting then using
backtrader
Kudos!!!
The post can be found at:
Teddy Koker dropped me a message, asking if I could comment on the usage of backtrader. And my opinion can be seen below. It is only my personal humble opinion, because as the author of backtrader I am biased as to how the platform could be best used.
And my personal taste about how to formulate certain constructs, does not have to match how other people prefer to use the platform.
Note
Actually, letting the platform open to plug almost anything and with different ways to do the same thing, was a conscious decision, to let people use it however they see fit (within the constraints of what the platform aims to do, the language possibilities and the failed design decisions I made)
Here, we will just focus on things which could have been done in a different manner. Whether "different" is better or not is always a matter of opinion. And the author of backtrader does not always have to be right on what it is actually "better" for developing with "backtrader" (because the actual development has to suit the developer and not the author of "backtrader")
Params: dict
vs tuple of tuples
Many of the samples provided with backtrader
and also available in the
documentation and/or blog, use the tuple of tuples
pattern for the
parameters. For example from the code:
class Momentum(bt.Indicator): lines = ('trend',) params = (('period', 90),)
Together with this paradigm, one has always had the chance to use a
dict
.
class Momentum(bt.Indicator): lines = ('trend',) params = dict(period=90) # or params = {'period': 90}
Over time this has turned to be lighter to use and become the preferred pattern for the author.
Note
The author prefers the dict(period=90)
, being easier to type, not
needing quotes. But the curly braces notation, {'period': 90}
, is
preferred by many others.
The underlying difference between the dict
and tuple
approaches:
-
With a
tuple of tuples
parameters retain the order of declaration, which can be of importance when enumerating them.Tip
The declaration order should be no problem with default ordered dictionaries in Python
3.7
(and3.6
if using CPython even if it is an implementation detail)
In the examples modified by the author below, the dict
notation will be
used.
The Momentum
indicator
In the article, this is how the indicator is defined
class Momentum(bt.Indicator): lines = ('trend',) params = (('period', 90),) def __init__(self): self.addminperiod(self.params.period) def next(self): returns = np.log(self.data.get(size=self.p.period)) x = np.arange(len(returns)) slope, _, rvalue, _, _ = linregress(x, returns) annualized = (1 + slope) ** 252 self.lines.trend[0] = annualized * (rvalue ** 2)
Use the force, i.e.: use something which is already there like the
PeriodN
indicator, which:
- Already defines a
period
parameter and knows how to pass it to the system
As such, this could be better
class Momentum(bt.ind.PeriodN): lines = ('trend',) params = dict(period=50) def next(self): ...
We are already skipping the need to define __init__
for the only purpose of
using addminperiod
, which should only be used in exceptional cases.
To carry on, backtrader defines an OperationN
indicator which must have an
attribute func
defined, which will get period
bars passed as an argument
and which will put the return value into the defined line.
With that in mind, one can imagine the following as the potential code
def momentum_func(the_array): r = np.log(the_array) slope, _, rvalue, _, _ = linregress(np.arange(len(r)), r) annualized = (1 + slope) ** 252 return annualized * (rvalue ** 2) class Momentum(bt.ind.OperationN): lines = ('trend',) params = dict(period=50) func = momentum_func
Which means that we have taken the complexity of the indicator outside of the
indicator. We could even be importing momentum_func
from a external library
and the indicator would need no change to reflect a new behavior if the
underlying function changes. As a bonus we have purely declarative
indicator. No __init__
, no addminperiod
and no next
The Strategy
Let's look at the __init__
part.
class Strategy(bt.Strategy): def __init__(self): self.i = 0 self.inds = {} self.spy = self.datas[0] self.stocks = self.datas[1:] self.spy_sma200 = bt.indicators.SimpleMovingAverage(self.spy.close, period=200) for d in self.stocks: self.inds[d] = {} self.inds[d]["momentum"] = Momentum(d.close, period=90) self.inds[d]["sma100"] = bt.indicators.SimpleMovingAverage(d.close, period=100) self.inds[d]["atr20"] = bt.indicators.ATR(d, period=20)
Some things about the style:
-
Use parameters where possible rather than fixed values
-
Use shorter and the shorter names (for imports for example), it will in most cases increase readability
-
Use Python to its full extent
-
Don't use
close
for a data feed. Pass the data feed generically and it will use close. This may not seem relevant but it does help when trying to keep the code generic everywhere (like in indicators)
The first thing that one would/should consider: keep everything as a parameter if possible. Hence
class Strategy(bt.Strategy): params = dict( momentum=Momentum, # parametrize the momentum and its period momentum_period=90, movav=bt.ind.SMA, # parametrize the moving average and its periods idx_period=200, stock_period=100, volatr=bt.ind.ATR, # parametrize the volatility and its period vol_period=20, ) def __init__(self): # self.i = 0 # See below as to why the counter is commented out self.inds = collections.defaultdict(dict) # avoid per data dct in for # Use "self.data0" (or self.data) in the script to make the naming not # fixed on this being a "spy" strategy. Keep things generic # self.spy = self.datas[0] self.stocks = self.datas[1:] # Again ... remove the name "spy" self.idx_mav = self.p.movav(self.data0, period=self.p.idx_period) for d in self.stocks: self.inds[d]['mom'] = self.p.momentum(d, period=self.momentum_period) self.inds[d]['mav'] = self.p.movav(d, period=self.p.stock_period) self.inds[d]['vol'] = self.p.volatr(d, period=self.p.vol_period)
By using params
and changing a couple of the naming conventions, we have
made the __init__
(and with it the strategy) fully customizable and generic
(no spy
references anyhwere)
next
and its len
backtrader tries to use the Python paradigms where possible. It does for sure sometimes fail, but it tries.
Let us see what happens in next
def next(self): if self.i % 5 == 0: self.rebalance_portfolio() if self.i % 10 == 0: self.rebalance_positions() self.i += 1
Here is where the Python len
paradigm helps. Let's use it
def next(self): l = len(self) if l % 5 == 0: self.rebalance_portfolio() if l % 10 == 0: self.rebalance_positions()
As you may see, there is no need to keep the self.i
counter. The length of
the strategy and of most objects is provided, calculated and updated by the
system all along the way.
next
and prenext
The code contains this forwarding
def prenext(self): # call next() even when data is not available for all tickers self.next()
And there IS NO safeguard when entering next
def next(self): if self.i % 5 == 0: self.rebalance_portfolio() ...
Ok, we know that a survivorship bias-free data set is in use, but in general
not safeguarding the prenext => next
forwarding is not a good idea.
-
backtrader calls
next
when all buffers (indicators, data feeds) can deliver at least data point. A100-bar
moving average will obviously only deliver when it has 100 data points from the data feed.This means that when entering
next
, the data feed will have100 data points
to be examined and the moving average just1 data point
-
backtrader offers
prenext
as hook to let the developer access things before the aforementioned guarantee can be met. This is useful for example when several data feeds are in play and they start date is different. The developer may want some examination or action be taken, before all guarantees for all data feeds (and associated indicators) are met andnext
is called for the first time.
In a general case the prenext => next
forwarding should have a guard such as
this:
def prenext(self): # call next() even when data is not available for all tickers self.next() def next(self): d_with_len = [d for d in self.datas if len(d)] ...
Which means that only the subset d_with_len
from self.datas
can be used
with guarantees.
Note
A similar guard has to used for indicators.
Because it would seem pointless to do this calculation for the entire life of a strategy, an optimization is possible such as this
def __init__(self): ... self.d_with_len = [] def prenext(self): # Populate d_with_len self.d_with_len = [d for d in self.datas if len(d)] # call next() even when data is not available for all tickers self.next() def nextstart(self): # This is called exactly ONCE, when next is 1st called and defaults to # call `next` self.d_with_len = self.datas # all data sets fulfill the guarantees now self.next() # delegate the work to next def next(self): # we can now always work with self.d_with_len with no calculation ...
The guard calculation is moved to prenext
which will stopped being called
when the guarantees are met. nextstart
will be called then and by overriding
it we can reset the list
which holds the data set to work with, to be the
full data set, i.e.: self.datas
And with this, all guards have been removed from next
.
next
with timers
Although the intention of the author here is to rebalance (portfolio/positions) each 5/10 days, this is probably meant as a weekly/bi-weekly rebalancing.
The len(self) % period
approach will fail if:
-
The data set did not start on a Monday
-
During trading holidays, which will make the rebalancing move out of alignment
To overcome this, one can use the built-in functionalities in backtrader
- Using Docs - Timers
Using them will ensure that rebalancing happens when it is meant to happen. Let us imagine that the intention is to rebalance on Fridays
Let's add a bit of magic to the params
and __init__
in our strategy
class Strategy(bt.Strategy): params = dict( ... rebal_weekday=5, # rebalance 5 is Friday ) def __init__(self): ... self.add_timer( when=bt.Timer.SESSION_START, weekdays=[self.p.rebal_weekday], weekcarry=True, # if a day isn't there, execute on the next ) ...
And now we are ready to know when it is Friday. Even if a Friday happens to be
a trading holiday, adding weekcarry=True
ensures we will be notified on
Monday (or Tuesday if Monday is also a holiday or ...)
The notification of the timer is taken in notify_timer
def notify_timer(self, timer, when, *args, **kwargs): self.rebalance_portfolio()
Because there is also a rebalance_positions
which happens every 10
bars in
the original code, one could:
-
Add a 2nd timer, also for Fridays
-
Use a counter to only act on each 2nd call, which can even be in the timer itself using the
allow=callable
argument
Note
Timers could even be better used to achieve patterns like:
-
rebalance_portfolio
every on the 2nd and 4th Friday of the month -
rebalance_positions
only on the 4th Friday of each month
Some Extras
Some other things are probably and purely a matter of personal taste.
Personal Taste 1
Use always a pre-built comparison rather than compare things during
next
. For example from the code (used more than once)
if self.spy < self.spy_sma200: return
We could do the following. First during __init__
def __init__(self): ... self.spy_filter = self.spe < self.spy_sma200
And later
if self.spy_filter: return
With this in mind and if we wanted to alter the spy_filter
condition, we
would only have to do this once in __init__
and not in multiple positions in
the code.
The same could apply to this other comparison d < self.inds[d]["sma100"]
here:
# sell stocks based on criteria for i, d in enumerate(self.rankings): if self.getposition(self.data).size: if i > num_stocks * 0.2 or d < self.inds[d]["sma100"]: self.close(d)
Which could also be pre-built during __init__
and therefore changed to
something like this
# sell stocks based on criteria for i, d in enumerate(self.rankings): if self.getposition(self.data).size: if i > num_stocks * 0.2 or self.inds[d]['sma_signal']: self.close(d)
Personal Taste 2
Make everything a parameter. In the lines above we for example see a 0.2
which is used in several parts of the code: make it a parameter. The same
with other values like 0.001
and 100
(which was actually already suggested
as a parameter for the creation of moving averages)
Having everything as a parameter allows to pack the code and try different things by just changing the instantiation of the strategy and not the strategy itself.