Create a Custom Indicator in Vectorbt
The indicator factory is one of the most important parts of the VectorBt ecosystem. Once you've unlocked its powers you'll be able to implement any indicator you like. You can use it to combine indicators, test different time frames at the same time, etc.
What is the indicator factory?
Its namesake suggests a helpful metaphor. The indicator factory takes in raw materials. That being a python function representing your indicator, as well as metadata about it like what the parameter names are, what you'd like the output to be called, etc. Then the "factory" produces a VectorBt-compatible function, indistinguishable from the in-built indicators you've been using already like the RSI, moving average, etc.
It can take pretty much any python function you throw at it. As we'll see later you benefit greatly from using numba to compile your functions, or using the already-optimized TA-lib functions.
Getting our Factory up and running
Let's quickly grab some data using yfinance with vbt's fancy adapter
1>>> import vectorbt as vbt
2>>> btc_price_1d = vbt.YFData.download("BTC-USD",
3... missing_index='drop',
4... interval="1D").get('Close')
5>>> btc_price_1d
6Date
72014-09-17 00:00:00+00:00 457.334015
82014-09-18 00:00:00+00:00 424.440002
92014-09-19 00:00:00+00:00 394.795990
102014-09-20 00:00:00+00:00 408.903992
112014-09-21 00:00:00+00:00 398.821014
12 ...
132022-02-11 00:00:00+00:00 42407.937500
142022-02-12 00:00:00+00:00 42244.468750
152022-02-13 00:00:00+00:00 42197.515625
162022-02-14 00:00:00+00:00 42586.917969
172022-02-15 00:00:00+00:00 44261.191406
18Freq: D, Name: Close, Length: 2709, dtype: float64
You're more than welcome to pull your own data in here from a .csv
or equivalent. Make sure you've got a simple pandas series at the end of it, indexed by a DateTime column.
Now that that's sorted, let's go ahead and write our strategy as a basic python function using numpy
1>>> import numpy as np
2>>> def ma_strategy(close, window = 730, lower_multiplier=1, upper_multiplier=4):
3... signal = np.full(close.shape, np.nan)
4... for x in range(len(close)):
5... if x >= window:
6... mavg = np.mean( close[x-window:x])
7... if close[x] < mavg*lower_multiplier:
8... signal[x] = 1
9... elif close[x] > mavg*upper_multiplier:
10... signal[x] = -1
11... else:
12... signal[x] = 0
13...
14... return signal
The strategy that I'm using here is a variant of the 2-year-ma strategy, where we buy when the price falls below the 2-year moving average, and sell when it moves above 5x the 2-year MA.
In my version of this function, I've parameterized the window length, the lower_multiplier
, and the upper_multipler
. That way we can test out different scenarios like if we use the 1 year average instead of the two year. Or we only sell when we get to 6x the 2-year MA. The possibilities are endless.
Now it's time to create the indicator using the factory:
1>>> my_indicator = vbt.IndicatorFactory(
2... class_name="ma_strategy",
3... short_name="ma",
4... input_names=["close"],
5... param_names=["window","lower_multiplier","upper_multiplier"],
6... output_names=["signal"]
7... ).from_apply_func(
8... ma_strategy,
9... window=730,
10... lower_multiplier=1,
11... upper_multiplier=4
12... )
Most of the parameters there should be straightforward. Make sure that you give the full list of all the parameters in param_names
. You'll also want to provide default values in from_apply_func
.
Running our indicator
At this point we can actually run our indicator with our data. Just like any other vectorbt indicator we can call my_indicator.run
with the appropriate parameters. In our case we just provide the closing daily prices for Bitcoin that we scraped earlier.
1>>> results = my_indicator.run(btc_price_1d)
2>>> results.signal
3Date
42014-09-17 00:00:00+00:00 NaN
52014-09-18 00:00:00+00:00 NaN
62014-09-19 00:00:00+00:00 NaN
72014-09-20 00:00:00+00:00 NaN
82014-09-21 00:00:00+00:00 NaN
9 ...
102022-02-11 00:00:00+00:00 0.0
112022-02-12 00:00:00+00:00 0.0
122022-02-13 00:00:00+00:00 0.0
132022-02-14 00:00:00+00:00 0.0
142022-02-15 00:00:00+00:00 0.0
15Freq: D, Name: Close, Length: 2709, dtype: float64
Now that we've got our signal indicator, we need to turn this into something that vectorbt can understand and trade with. The format used is a pandas series with True
or False
values depending on whether a position should be entered into or not. We can therefore create our entries
and exits
series like so:
1>>> entries = results.signal == 1.0
2>>> exits = results.signal == -1.0
3>>> exits
4Date
52014-09-17 00:00:00+00:00 False
62014-09-18 00:00:00+00:00 False
72014-09-19 00:00:00+00:00 False
82014-09-20 00:00:00+00:00 False
92014-09-21 00:00:00+00:00 False
10 ...
112022-02-11 00:00:00+00:00 False
122022-02-12 00:00:00+00:00 False
132022-02-13 00:00:00+00:00 False
142022-02-14 00:00:00+00:00 False
152022-02-15 00:00:00+00:00 False
16Freq: D, Name: Close, Length: 2709, dtype: bool
So for entries a value of True
means enter a position there, and False
means do nothing. For exits a value of True
means we should exit the position. The default behaviour is that we go long-only, only entering one position at a time.
It's pretty straightforward at this point to run the portfolio with out entries and exits. I've also set a fee there of 1% to make things more realistic. Printing out pf.stats()
gives us a peak into the performance of the strategy.
1>>> pf = vbt.Portfolio.from_signals(btc_price_1d, entries, exits, freq = "1D", fees=0.01)
2>>> pf.stats()
3Start 2014-09-17 00:00:00+00:00
4End 2022-02-15 00:00:00+00:00
5Period 2709 days 00:00:00
6Start Value 100.0
7End Value 692.154491
8Total Return [%] 592.154491
9Benchmark Return [%] 9578.088654
10Max Gross Exposure [%] 100.0
11Total Fees Paid 7.981559
12Max Drawdown [%] 61.81085
13Max Drawdown Duration 485 days 00:00:00
14 .....
Making Tweaks
You may have noticed that it took a few seconds for the pf.stats()
to run. That's to be expected really, as we're just using plain numpy to calculate our indicator. One thing we can do to massively speed this up is to re-define our original indicator function using the @njit
decorator from numba.
This compiles our function to lightning fast machine code. This'll be helpful when we're doing parameter optimization and want to run 100s of different combinations.
1>>> from numba import njit
2>>> @njit
3... def ma_strategy(close, window = 730, lower_multiplier=1, upper_multiplier=4):
4... signal = np.full(close.shape, np.nan)
5... for x in range(len(close)):
6... if x >= window:
7... mavg = np.mean( close[x-window:x])
8... if close[x] < mavg*lower_multiplier:
9... signal[x] = 1
10... elif close[x] > mavg*upper_multiplier:
11... signal[x] = -1
12... else:
13... signal[x] = 0
14...
15... return signal
If you're following along in the interpreter, you may want to re-define the indicator factory to make sure it's tracking the correct function.
Parameter Optimization
Here we can define a range of window values using np.linspace
. Note that I've set dtype to be int. Otherwise it'd confuse our indicator function. You could do the same for lower_multiplier
or upper_multiplier
, or both at the same time. If you set param_product=True
in indicator.run
, it'll take the cartesian product of your parameter and evaluate all possible combinations.
In our case I'm just interested in the window length so I'll just set that.
1>>> windows = np.linspace(100,1000, num=100, dtype=int)
2>>> results = my_indicator.run(btc_price_1d,
3... window=windows)
4>>> exits = results.signal == -1.0
5>>> entries = results.signal == 1.0
6>>> entries
7ma_window 100 109 118 127 ... 972 981 990 1000
8Date ...
92014-09-17 00:00:00+00:00 False False False False ... False False False False
102014-09-18 00:00:00+00:00 False False False False ... False False False False
112014-09-19 00:00:00+00:00 False False False False ... False False False False
122014-09-20 00:00:00+00:00 False False False False ... False False False False
132014-09-21 00:00:00+00:00 False False False False ... False False False False
14... ... ... ... ... ... ... ... ... ...
152022-02-11 00:00:00+00:00 True True True True ... False False False False
162022-02-12 00:00:00+00:00 True True True True ... False False False False
172022-02-13 00:00:00+00:00 True True True True ... False False False False
182022-02-14 00:00:00+00:00 True True True True ... False False False False
192022-02-15 00:00:00+00:00 True True True True ... False False False False
As you can see our entries now look a little different as it keeps one column for every unique combination of parameters.
The power of vectorbt is that our Portfolio.from_signals
can ingest this and give us stats about every single portfolio we simulated. And thanks to our numba optimzation it should be done in less than a couple of seconds.
1>>> pf = vbt.Portfolio.from_signals(btc_price_1d, entries, exits, freq = "1D", fees=0.01)
2>>> pf.total_return()
3ma_window
4100 132.364564
5109 164.534706
6118 192.629907
7127 186.370684
8136 200.106583
9 ...
10963 7.267445
11972 7.267445
12981 8.301056
13990 8.301056
141000 8.301056
You can find the full list of metrics on the docs. We can then find the maximal portfolio return, and the window length that generated it using simply max and idxmax, common Pandas functions.
1Name: total_return, Length: 100, dtype: float64
2>>> pf.total_return().max()
3502.38459909336217
4>>> pf.total_return().idxmax()
5209
Video Tutorial
If you'd prefer a video tutorial, you can check out this free course on my youtube channel: