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 tuplesparameters 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.6if 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
periodparameter 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
closefor 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
nextwhen all buffers (indicators, data feeds) can deliver at least data point. A100-barmoving 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 pointsto be examined and the moving average just1 data point -
backtrader offers
prenextas 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 andnextis 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=callableargument
Note
Timers could even be better used to achieve patterns like:
-
rebalance_portfolioevery on the 2nd and 4th Friday of the month -
rebalance_positionsonly 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.