Create a Custom Indicator in Vectorbt

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: