Indicator Development
If anything (besides one or more winning Strategies) must ever be developed, this something is a custom Indicator.
Such development within the platform is, according to the author, easy.
The following is needed:
-
A class derived from Indicator (either directly or from an already existing subclass)
-
Define the lines it will hold
An indicator must at least have 1 line. If deriving from an existing one, the line(s) may have already be defined
-
Optionally define parameters which can alter the behavior
-
Optionally provided/customize some of the elements which enable sensible plotting of the indicators
-
Provide a fully defined operation in
__init__
with a binding (assignment) to the line(s) of the indicator or else providenext
and (optionally)once
methodsIf an indicator can be fully defined with logic/arithmetic operations during initialization and the result is assigned to the line: done
Be it not the case, at least a
next
has to be provided where the indicator must assign a value to the line(s) at index 0Optimization of the calculation for the runonce mode (batch operation) can be achieved by providing a once method.
Important note: Idempotence
Indicators produce an output for each bar they receive. No assumption has to be made about how many times the same bar will be sent. Operations have to be idempotent.
The rationale behind this:
- The same bar (index-wise) can be sent many times with changing values (namely the changing value is the closing price)
This enables, for example, “replaying” a daily session but using intraday data which could be made of 5 minutes bars.
It could also allow the platform to get values from a live feed.
A dummy (but functional) indicator
So can it be:
class DummyInd(bt.Indicator): lines = ('dummyline',) params = (('value', 5),) def __init__(self): self.lines.dummyline = bt.Max(0.0, self.params.value)
Done! The indicator will output always the same value: either 0.0 or self.params.value if it happens to be greater than 0.0.
The same indicator but using the next method:
class DummyInd(bt.Indicator): lines = ('dummyline',) params = (('value', 5),) def next(self): self.lines.dummyline[0] = max(0.0, self.params.value)
Done! Same behavior.
Note
Notice how in the __init__
version bt.Max
is used to assign to
the Line object self.lines.dummyline
.
bt.Max
returns an lines object that is automatically iterated for
each bar passed to the indicator.
Had max
been used instead, the assigment would have been
pointless, because instead of a line, the indicator would have a
member variable with a fixed value.
During next
the work is done directly with floating point values
and the standard max
built-in can be used
Let’s recall that self.lines.dummyline
is the long notation and that it can
be shortened to:
self.l.dummyline
and even to:
self.dummyline
The latter being only possible if the code has not obscured this with a member attribute.
The 3rd and last version provides an additional once
method to optimize the
calculation:
class DummyInd(bt.Indicator): lines = ('dummyline',) params = (('value', 5),) def next(self): self.lines.dummyline[0] = max(0.0, self.params.value) def once(self, start, end): dummy_array = self.lines.dummyline.array for i in xrange(start, end): dummy_array[i] = max(0.0, self.params.value)
A lot more effective but developing the once
method has forced to scratch beyond
the surface. Actually the guts have been looked into.
The __init__
version is in any case the best:
-
Everything is confined to the initialization
-
next
andonce
(both optimized, becausebt.Max
already has them) are provided automatically with no need to play with indices and/or formulas
Be it needed for development, the indicator can also override the methods
associated to next
and once
:
-
prenext
andnexstart
-
preonce
andoncestart
Manual/Automatic Minimum Period
If possible the platform will calculate it, but manual action may be needed.
Here is a potential implementation of a Simple Moving Average:
class SimpleMovingAverage1(Indicator): lines = ('sma',) params = (('period', 20),) def next(self): datasum = math.fsum(self.data.get(size=self.p.period)) self.lines.sma[0] = datasum / self.p.period
Although it seems sound, the platform doesn’t know what the minimum period is, even if the parameter is named “period” (the name could be misleading and some indicators receive several “period”s which have different usages)
In this case next
would be called already for the 1st bar and everthing
would explode because get cannot return the needed self.p.period
.
Before solving the situation something has to be taken into account:
- The data feeds passed to the indicators may already carry a minimum period
The sample SimpleMovingAverage may be done on for example:
-
A regular data feed
This has a default mininum period of 1 (just wait for the 1st bar that enters the system)
-
Another Moving Average … and this in turn already has a period
If this is 20 and again our sample moving average has also 20, we end up with a minimum period of 40 bars
Actually the internal calculation says 39 … because as soon as the first moving average has produced a bar this counts for the next moving average, which creates an overlapping bar, thus 39 are needed.
-
Other indicators/objects which also carry periods
Alleviating the situation is done as follows:
class SimpleMovingAverage1(Indicator): lines = ('sma',) params = (('period', 20),) def __init__(self): self.addminperiod(self.params.period) def next(self): datasum = math.fsum(self.data.get(size=self.p.period)) self.lines.sma[0] = datasum / self.p.period
The addminperiod
method is telling the system to take into account the extra
period bars needed by this indicator to whatever minimum period there may be
in existence.
Sometimes this is absolutely not needed, if all calculations are done with objects which already communicate its period needs to the system.
A quick MACD implementation with Histogram:
from backtrader.indicators import EMA class MACD(Indicator): lines = ('macd', 'signal', 'histo',) params = (('period_me1', 12), ('period_me2', 26), ('period_signal', 9),) def __init__(self): me1 = EMA(self.data, period=self.p.period_me1) me2 = EMA(self.data, period=self.p.period_me2) self.l.macd = me1 - me2 self.l.signal = EMA(self.l.macd, period=self.p.period_signal) self.l.histo = self.l.macd - self.l.signal
Done! No need to think about mininum periods.
-
EMA
stands for Exponential Moving Average (a platform built-in alias)And this one (already in the platform) already states what it needs
-
The named lines of the indicator “macd” and “signal” are being assigned objects which already carry declared (behind the scenes) periods
-
macd takes the period from the operation “me1 - me2” which has in turn take the maximum from the periods of me1 and me2 (which are both exponential moving averages with different periods)
-
signal takes directly the period of the Exponential Moving Average over macd. This EMA also takes into account the already existing macd period and the needed amount of samples (period_signal) to calculate itself
-
histo takes the maximum of the two operands “signal - macd”. Once both are ready can histo also produce a value
-
A full custom indicator
Let’s develop a simple custom indicator which “indicates” if a moving average (which can be modified with a parameter) is above the given data:
import backtrader as bt import backtrader.indicators as btind class OverUnderMovAv(bt.Indicator): lines = ('overunder',) params = dict(period=20, movav=btind.MovAv.Simple) def __init__(self): movav = self.p.movav(self.data, period=self.p.period) self.l.overunder = bt.Cmp(movav, self.data)
Done! The indicator will have a value of “1” if the average is above the data and “-1” if below.
Be the data a regular data feed the 1s and -1s would be produced comparing with the close price.
Although more can be seen in the Plotting section and to have a behaved and nice citizen in the plotting world, a couple of things can be added:
import backtrader as bt import backtrader.indicators as btind class OverUnderMovAv(bt.Indicator): lines = ('overunder',) params = dict(period=20, movav=bt.ind.MovAv.Simple) plotinfo = dict( # Add extra margins above and below the 1s and -1s plotymargin=0.15, # Plot a reference horizontal line at 1.0 and -1.0 plothlines=[1.0, -1.0], # Simplify the y scale to 1.0 and -1.0 plotyticks=[1.0, -1.0]) # Plot the line "overunder" (the only one) with dash style # ls stands for linestyle and is directly passed to matplotlib plotlines = dict(overunder=dict(ls='--')) def _plotlabel(self): # This method returns a list of labels that will be displayed # behind the name of the indicator on the plot # The period must always be there plabels = [self.p.period] # Put only the moving average if it's not the default one plabels += [self.p.movav] * self.p.notdefault('movav') return plabels def __init__(self): movav = self.p.movav(self.data, period=self.p.period) self.l.overunder = bt.Cmp(movav, self.data)