VectorBT Pro - Strategy Development and Signal Generation
7 min read

VectorBT Pro - Strategy Development and Signal Generation

VectorBT Pro - Strategy Development and Signal Generation

Strategy Development and Signal Generation

Strategy Development usually involves multiple trial and error research, where we check for various combinations of technical indicators and price levels to generate entry and exit signals. It is very easy to generate entry and exit signals in VectorBT which is just a bool array for entries and exits. If you have different criteria for your LONGS and SHORTS, you can create separate boolean arrays for long_entries, long_exits, short_entries and short_exits.

Double Bollinger Band Strategy Conditions

For the rest of this tutorial we will be using the indicators and other elements created in the previous tutorial - Aligning MTF time series Data with Resampling, for our customized MTF adaptation of this Double Bollinger Band Strategy on the H4 and 15m timeframes. In our implementation we will be using the same conditions for a short entry as a long exit and a short exit signal will use the same conditions as a long entry.

Long Condition

In our adaptation of this Double Bollinger Band Strategy, a long (buy) signal is generated whenever the H4 market (Low) price goes below its lower Bollinger band, and the 15m RSI goes below its lower Bollinger band.

Here are the two conditions ( c1_long_entry and c2_long_entry ) that would qualify a long entry

## The two variables `bb_upper_fract` and `bb_lower_fract` are simply some adjustment parameters for the RSI bollinger bands and they are explained at the end of this article.

bb_upper_fract = 0.99
bb_lower_fract = 1.01

c1_long_entry = (mtf_df['h4_low'] <= mtf_df['h4_bband_price_lower'])
c2_long_entry = (mtf_df['m15_rsi'] <= (bb_lower_fract * mtf_df['m15_bband_rsi_lower']))
Short Condition

Likewise, a long exit ( and also the short (sell) signal ) is generated whenever the H4 market (High) price breaks its upper Bollinger band, and the 15m RSI breaks above its upper Bollinger band.

Here are the two conditions ( c1_long_exit and c2_long_exit ) that would qualify as a long exit (and also as a short entry )

c1_long_exit =  (mtf_df['h4_high'] >= mtf_df['h4_bband_price_upper'])
c2_long_exit = (mtf_df['m15_rsi'] >= (bb_upper_fract * mtf_df['m15_bband_rsi_upper']))

As seen below the entry and exit criteria can be added as two seperate columns in the mtf_df pandas dataframe, simply by chaining multiple conditions using the bitwise & operator.

## Strategy conditions check - Using m15 and h4 data 
mtf_df['entry'] = c1_long_entry & c2_long_entry
mtf_df['exit']  = c1_long_exit & c2_long_exit

The entries and exits are boolean arrays and in order to represent them in a numerical format we will create a new column called signal in mtf_df that will read 1 where the entry conditions have been satisfied and -1 where the exit conditions have been satisfied, or 0 otherwise.

mtf_df['signal'] = 0   
mtf_df['signal'] = np.where( mtf_df['entry'] ,1, 0)
mtf_df['signal'] = np.where( mtf_df['exit'] ,-1, mtf_df['signal'])

The entries and exits series can be extracted from mtf_df['signal'] which will then be used to run the simulation/backtest

entries = mtf_df.signal == 1.0
exits = mtf_df.signal == -1.0

To run the backtest we use the following code , when the direction is set to both, the long exit is categorised as a short entry, as to close out the long position a short must be initiated and likewise, the short exit is treated as a long entry

pf = vbt.Portfolio.from_signals(
    close = mtf_df['m15_close'], 
    entries = entries, 
    exits = exits, 
    direction = "both", ## This setting trades both long and short signals
    freq = pd.Timedelta(minutes=15), 
    init_cash = 100000
)
pf_stats = pf.stats()
print("Total Returns    [%]:", round(pf_stats['Total Return [%]'], 2))
print("Maximum Drawdown [%]: ", round(pf_stats['Max Drawdown [%]'],2))
print(pf_stats)

Output

Total Returns    [%]: 33.27
Maximum Drawdown [%]:  11.57
Start                         2019-08-27 00:00:00+00:00
End                           2022-08-26 16:45:00+00:00
Period                                365 days 05:40:00
Start Value                                    100000.0
Min Value                                  92520.879497
Max Value                                  133314.96609
End Value                                 133266.736753
Total Return [%]                              33.266737
Benchmark Return [%]                          -3.833225
Total Time Exposure [%]                       99.856448
Max Gross Exposure [%]                       107.104183
Max Drawdown [%]                              11.572446
Max Drawdown Duration                  94 days 06:30:00
Total Orders                                        535
Total Fees Paid                                     0.0
Total Trades                                        535
Win Rate [%]                                  59.550562
Best Trade [%]                                 2.797026
Worst Trade [%]                               -3.578342
Avg Winning Trade [%]                          0.384536
Avg Losing Trade [%]                           -0.42807
Avg Winning Trade Duration    0 days 13:10:31.132075471
Avg Losing Trade Duration     0 days 21:07:20.277777777
Profit Factor                                  1.320807
Expectancy                                    62.387577
Sharpe Ratio                                   1.868003
Calmar Ratio                                   0.867506
Omega Ratio                                    1.021671
Sortino Ratio                                  2.691066
dtype: object
💡
In the pf.stats printout, one might wonder why Period is 365 days, when the dataset start and end period is for 3 years and there are atleast (262 * 3) 786 tradings days in the 3 year calendar?

Period in stats measures the number of bars multiplied by frequency, that is, the real duration of backtest and is not to be confused with calendar duration

We can see all the trades that were executed during this backtest simulation by using

pf.trade_history()

pf_trade_history

Plotting - Portfolio Simulations

It is a good practise to set the theme and plot dimensions that we would like to commonly use for all our plotting in the beginning. VectorBT uses plotly (a python package) for all its plotting capabilities.

## Global Plot Settings for vectorBT
vbt.settings.set_theme("dark")
vbt.settings['plotting']['layout']['width'] = 1280

Since our backtest simulation was run on 15m timeframe (as it was our baseline frequency) we resamply the pf.plot() to save time and also avoid seeing a dense plot. The below SVG static plot was generated using the show_svg() function, but you can also show_png() to render the plot as a static rasterized image or use show() to show an interactive toolbar along with a dynamic interactive plot with hovertools etc.

The important thing to remember is to use one of the show() method after the plot() in order to render the figure correctly.

# pf.plot().show() ## This takes slightly long (10 secs) as it uses 15m timeframe index
pf.resample("1d").plot().show_svg()

pf_plot_resampled_1d

The plot includes three sub-plots that captures at a glance most of the trading performance stats we would like to see like

  • Open and closed positions at various price points
  • The PnL of each order
  • The cummulative returns of our strategy compared to the benchmark returns of just holding the instrument

We can also isolate pf.orders from the above pf.plot to just show the orders and pass a custom kwargs argument to give a custom title for the plot.

kwargs = {"title_text" : "Orders - Daily chart", "title_font_size" : 18}
pf.orders.resample("1d").plot(xaxis=dict(rangeslider_visible=False),**kwargs).show()

Orders_1D_Isolated

Max Drawdown

Understanding and Visualization of Drawdown is a very important part of any strategy development and fortunately, for us VectorBT has a very convenient method to visualize DrawDown

print(f"Max Drawdown [%]: {pf.stats()['Max Drawdown [%]']}")
print(f"Max Drawdown Duration: {pf.stats()['Max Drawdown Duration']}")
pf.drawdowns.plot(**{"title_text" : "Drawdowns Plot"}).show()

Output:

Max Drawdown [%]: 11.572445827440356
Max Drawdown Duration: 94 days 06:30:00

DrawDownPlot_Interactive

  • The above Drawdown plot below shows only the top 5 drawdowns.
  • The max drawdown duration of 94 days includes, 73 days for the declination phase and 21 days for the recovery phase in the max. peak drawdown. If you use the .show() method to get an interactive plot you can see this in the hover information when you hover over the plotted figure.

UnderWater Plot:

VectorBT also allows us to plot an underwater plot, which is basically just an alternative way of visualizing drawdown and shows the relative drawdown ( time-to-time ) from the previous peak balance

kwargs = {"title_text" : "Underwater Plot",'title_x': 0.5}
pf.plot_underwater(**kwargs).show()

UnderWaterPlot

To adjust various aspects and parameters of the plot (eg: Title, position etc.) , one should always refer the Plotly Documentation Reference : https://plotly.com/python/reference/layout/

Summary - Strategy Exploration

💡 Why we use bb_upper_fract and bb_lower_fract?

To readily understand what these two adjustment variables are you can try running the code see what happens when you see both of these variables equal to 1 , you will essentially get fewer number of signals.

The bb_upper_fract and bb_lower_fract can simply be thought of as parameters that reduce the gap between the upper bound and the high price and the lower bound and the low price.

Reducing this gap results in price more frequently touching the new upper bound and the new lower bound thus resulting in more signals generated.

💡 What will happen if we try other timeframe combinations?

Well in the strategy explained above we used H4 and m15 but you may have the idea 💭 to use H1 instead of H4, thinking that it would result in more signals and perhaps also more profit 💵 🤑 ?

## Long Entry Conditions
c1_long_entry = (mtf_df['h1_low'] <= mtf_df['h1_bband_price_lower'])
c2_long_entry = (mtf_df['m15_rsi'] <= (bb_lower_fract * mtf_df['m15_bband_rsi_lower']) )
 
 
## Long Exit Conditions
c1_long_exit =  (mtf_df['h1_high'] >= mtf_df['h1_bband_price_upper'])
c2_long_exit = (mtf_df['m15_rsi'] >= (bb_upper_fract * mtf_df['m15_bband_rsi_upper']))

Yes ✅ that is true we get more signals ,take a look at the results obtained when we execute the above ☝ code. Would you be happy with these results ?

Total Returns    [%]: -16.21
Maximum Drawdown [%]:  28.17

Key Takeaways ⚡

  • Ultimately strategy development and signal generation is an art, that is dependent on variety of different elements like:

    • Timeframes
    • Indicators and their parameter values
    • Fundamental and Economic data etc.
  • Its imperative that you study 📚 the rules and parameters of your strategy carefully before backtesting.

Good Luck  ✌️ on your strategy development journey, let us know in the comments below if you happen to discover any profitable variations of this strategy