<![CDATA[Qubit Quants]]>http://localhost:2368/http://localhost:2368/favicon.pngQubit Quantshttp://localhost:2368/Ghost 5.24Tue, 18 Apr 2023 14:09:07 GMT60<![CDATA[VectorBT Pro - Custom Simulator 3: CandleStick Strategy + StopLoss + TakeProfit + Partial Profits]]>

Importing The Dependencies

import vectorbtpro as vbt
import numpy as np
import pandas as pd
from numba import njit
import talib
import datetime as dt
import time
from collections import namedtuple
import itertools
import math
from vectorbtpro.records.nb import col_map_nb
from vectorbtpro.portfolio import nb as pf_
]]>
http://localhost:2368/customsim_3/643e998528c00f113e763a69Tue, 18 Apr 2023 13:29:37 GMT

Importing The Dependencies

import vectorbtpro as vbt
import numpy as np
import pandas as pd
from numba import njit
import talib
import datetime as dt
import time
from collections import namedtuple
import itertools
import math
from vectorbtpro.records.nb import col_map_nb
from vectorbtpro.portfolio import nb as pf_nb, enums as pf_enums
import plotly.io as pio
from numba import njit

Strategy Rules 📐

VectorBT Pro - Custom Simulator 3: CandleStick Strategy + StopLoss + TakeProfit + Partial Profits

In addition to the previous rules , the only additional rule here is to close 50 % of the position when the price hits the first take profit tp1 (RRR of 1) moving the stop loss to breakeven (entry_price)


The tracking dictionary here does exactly what it says , it will keep track of the entry_price , stop_loss , tp1 (take profit 1) , tp2 (take profit 2) for both long 🟩 and short 🟥 positions.


🐌 Indexing into a dictionary to update / retrieve values that will subsequently be used in the trading logic will be undoubtly be computationally slow however it is very easy to understand.


⚡The dictionary variables can be unpacked and be used independently to increase the speed.


tracking_dict = {'long' : {'entry_price': None,'stop_loss': None,'tp1': None,'tp2': None},
                'short': {'entry_price': None,'stop_loss': None,'tp1': None,'tp2': None}}

When tp1 (first take profit) the tracking dictionary (tracking_dict) must be updated to reflect this change (set to None).


In conjunction to this when tp1 is hit the stop_loss should be moved to breakeven (stop_loss price is set to entry_price)


When the stop_loss or tp2 is hit every variable in the tracking_dict is set to None to reflect that we are no longer in that position.

Custom Simulator

def custom_simulator_candlestick_partial_profs(open_ , high_ , low_ , close_ , IsBullArray, init_cash = 10000):
    
    order_records = np.empty((2663,1), dtype = vbt.pf_enums.order_dt)
    order_counts = np.full(1,0, dtype = np.int_)
    
    
    tracking_dict = {'long' : {'entry_price': None,'stop_loss': None,'tp1': None,'tp2': None},
                     'short': {'entry_price': None,'stop_loss': None,'tp1': None,'tp2': None}}

    exec_state = vbt.pf_enums.ExecState(
                        cash = float(init_cash),
                        position = 0.0,
                        debt = 0.0,
                        locked_cash = 0.0,
                        free_cash = float(init_cash),
                        val_price = np.nan,
                        value = np.nan)
     
    for i in range(len(close_)):
        price_area = vbt.pf_enums.PriceArea(open = open_[i], 
                                            high = high_[i], 
                                            low = low_[i], 
                                            close = close_[i])
        
        value_price = price_area.close
        value = exec_state.cash + (exec_state.position * value_price)
        
        current_price = price_area.close
        
        exec_state = vbt.pf_enums.ExecState(
                                            cash = exec_state.cash,
                                            position = exec_state.position,
                                            debt = exec_state.debt,
                                            locked_cash = exec_state.locked_cash,
                                            free_cash = exec_state.free_cash,
                                            val_price = value_price,
                                            value = value)
        
        if IsBullArray[i] and exec_state.position == 0:
            long_stop_loss = price_area.low #Long Stop loss
            long_entry_price = price_area.close #Long Entry Price
            long_tp1 = price_area.close + ( 1 * (price_area.close - long_stop_loss) ) #1RRR
            long_tp2 = price_area.close + ( 2 * (price_area.close - long_stop_loss) ) #2RRR
            
            tracking_dict['long']['entry_price'] = long_entry_price
            tracking_dict['long']['stop_loss'] = long_stop_loss
            tracking_dict['long']['tp1'] = long_tp1
            tracking_dict['long']['tp2'] = long_tp2
            
            
            
            order_result , exec_state = enter_position(
                                                        execution_state_ = exec_state,
                                                        price_area_ = price_area,
                                                        percent_risk_ = 1,
                                                        group_ = 0, column_= 0, bar_ = i,
                                                        direction = 'Buy',
                                                        order_records_= order_records,
                                                        order_counts_ = order_counts)
            
        elif not IsBullArray[i] and exec_state.position == 0:
            short_stop_loss = price_area.high #Short Stop Loss
            short_entry_price = price_area.close #Short Entry Price
            short_tp1 = price_area.close - ( 1 * (price_area.high - short_stop_loss) ) #1RRR
            short_tp2 = price_area.close - ( 2 * (price_area.high - short_stop_loss) ) #2RRR
            
            tracking_dict['short']['entry_price'] = short_entry_price
            tracking_dict['short']['stop_loss'] = short_stop_loss
            tracking_dict['short']['tp1'] = short_tp1
            tracking_dict['short']['tp2'] = short_tp2
            
            order_result , exec_state = enter_position(
                                                        execution_state_ = exec_state,
                                                        price_area_ = price_area,
                                                        percent_risk_ = 1,
                                                        group_ = 0, column_= 0, bar_ = i,
                                                        direction = 'Sell',
                                                        order_records_= order_records,
                                                        order_counts_ = order_counts)
        
        
        
        elif not IsBullArray[i] and exec_state.position > 0:
            
            tracking_dict['long']['entry_price'] = None
            tracking_dict['long']['stop_loss'] = None
            tracking_dict['long']['tp1'] = None
            tracking_dict['long']['tp2'] = None
            
            order_result , exec_state = close_full_position(
                                                            execution_state_ = exec_state,
                                                            price_area_ = price_area,
                                                            group_ = 0, column_= 0, bar_ = i,
                                                            order_records_= order_records,
                                                            order_counts_ = order_counts)
            
            
        elif IsBullArray[i] and exec_state.position < 0:
            
            tracking_dict['short']['entry_price'] = None
            tracking_dict['short']['stop_loss'] = None
            tracking_dict['short']['tp1'] = None
            tracking_dict['short']['tp2'] = None
            
            order_result , exec_state = close_full_position(
                                                            execution_state_ = exec_state,
                                                            price_area_ = price_area,
                                                            group_ = 0, column_= 0, bar_ = i,
                                                            order_records_= order_records,
                                                            order_counts_ = order_counts)
  
        else:
            
            if exec_state.position > 0: #In Long 
                entry_price = tracking_dict['long']['entry_price']
                
                if (price_area.low <= long_stop_loss):
                    #Long Stop Loss Has Been Hit
                    tracking_dict['long']['entry_price'] = None
                    tracking_dict['long']['stop_loss'] = None
                    tracking_dict['long']['tp1'] = None
                    tracking_dict['long']['tp2'] = None
                    
                    
                    order_result , exec_state = close_full_position(
                                                                execution_state_ = exec_state,
                                                                price_area_ = price_area,
                                                                group_ = 0, column_= 0, bar_ = i,
                                                                order_records_= order_records,
                                                                order_counts_ = order_counts)
                    
                    
                    
                
                elif (price_area.high >= tracking_dict['long']['tp1']):
                    # First Long Take Profit Has Been Hit
                    # Move The Stop Loss To Break Even (Entry Price) & Close 50% of The Position
                    tracking_dict['long']['stop_loss'] = entry_price #SL Moved To Entry Price (BreakEven)
                    tracking_dict['tp1'] = None #take profit 1 has been hit
                    
                    order_result, exec_state = close_partial_pos(
                                                        execution_state_ = exec_state,
                                                        price_area_ = price_area,
                                                        closing_percent = 50, 
                                                        group_ = 0 , column_ = 0, bar_ = None,
                                                        update_value_ = False, order_records_ = None, 
                                                        order_counts_ = None, log_records_ = None,
                                                        log_counts_ = None)
                    
                
                
                elif (price_area.high >= tracking_dict['long']['tp2']):
                    # Second Take Profit Has Been Hit
                    tracking_dict['long']['stop_loss'] = None
                    tracking_dict['long']['tp2'] = None
                    
                    order_result , exec_state = close_full_position(
                                                                execution_state_ = exec_state,
                                                                price_area_ = price_area,
                                                                group_ = 0, column_= 0, bar_ = i,
                                                                order_records_= order_records,
                                                                order_counts_ = order_counts)
                
                else:
                    continue
            
            elif exec_state.position < 0:
                
                entry_price = tracking_dict['short']['entry_price']
                
                if (price_area.high >= tracking_dict['short']['stop_loss']):
                    #Short Stop Loss Has Been Hit
                    tracking_dict['short']['entry_price'] = None
                    tracking_dict['short']['stop_loss'] = None
                    tracking_dict['short']['tp1'] = None
                    tracking_dict['short']['tp2'] = None
                    
                    
                    order_result , exec_state = close_full_position(
                                                                execution_state_ = exec_state,
                                                                price_area_ = price_area,
                                                                group_ = 0, column_= 0, bar_ = i,
                                                                order_records_= order_records,
                                                                order_counts_ = order_counts)
                
                elif (price_area.low <= tracking_dict['short']['tp1']):
                    # Frist Take Profit Has Been Hit
                    
                    tracking_dict['stop_loss'] = entry_price #SL Moved To Entry Price (BreakEven)
                    tracking_dict['tp1'] = None
                    
                    order_result, exec_state = close_partial_pos(
                                                            execution_state_ = exec_state,
                                                            price_area_ = price_area,
                                                            closing_percent = 50, 
                                                            group_ = 0 , column_ = 0, bar_ = None,
                                                            update_value_ = False, order_records_ = None, 
                                                            order_counts_ = None, log_records_ = None,
                                                            log_counts_ = None)
                
                elif (price_area.low <= tracking_dict['short']['tp2']):
                    # Second Take Profit Has Been Hit
                    tracking_dict['short']['entry_price'] = None
                    tracking_dict['short']['stop_loss'] = None
                    tracking_dict['short']['tp1'] = None
                    tracking_dict['short']['tp2'] = None
                    
                    order_result , exec_state = close_full_position(
                                                                execution_state_ = exec_state,
                                                                price_area_ = price_area,
                                                                group_ = 0, column_= 0, bar_ = i,
                                                                order_records_= order_records,
                                                                order_counts_ = order_counts)
                
                else:
                    continue
        
        
        
                
    return vbt.nb.repartition_nb(order_records, order_counts)         
                

Key Points / Summary 💡


As you can see there is not a whole lot of differences when looking at partial profits and moving stop loss to breakeven.


These are just simple examples to demonstrate how one can do these things that are quite common amongst most strategies.


You can use this code as a point of reference and get creative 🎨 when you are coding your own strategies ⚡

]]>
<![CDATA[VectorBT Pro - Custom Simulator 2: Candlestick Strategy + StopLoss + TakeProfit]]>

Importing the Dependencies

import vectorbtpro as vbt
import numpy as np
import pandas as pd
from numba import njit
import talib
import datetime as dt
import time
from collections import namedtuple
import itertools
import math
from vectorbtpro.records.nb import col_map_nb
from vectorbtpro.portfolio import nb as pf_
]]>
http://localhost:2368/customsim_2/643e928d28c00f113e7639d7Tue, 18 Apr 2023 13:18:45 GMT

Importing the Dependencies

import vectorbtpro as vbt
import numpy as np
import pandas as pd
from numba import njit
import talib
import datetime as dt
import time
from collections import namedtuple
import itertools
import math
from vectorbtpro.records.nb import col_map_nb
from vectorbtpro.portfolio import nb as pf_nb, enums as pf_enums
import plotly.io as pio
from numba import njit

Strategy Rules 📐

VectorBT Pro - Custom Simulator 2: Candlestick Strategy + StopLoss + TakeProfit

We will build upon the previous strategy

Stop Loss Below / Above The Entry Candle

Take Profit Level at a RRR (Risk and Reward Ratio of 2)

Long 🟩 Stop Loss ⬇ and Take Profit ⬆

The stop loss for the long position is placed below the low of the candle

    long_stop_loss = price_area.low

The take profit for the long position is placed at a RRR of 2.

We find the difference between the close and stoploss prices.

Multiply it by 2 and then add the the closing price.

This gives us the price for the take profit level corresponding to a RRR of 2.

long_take_profit = price_area.close + ( 2 * (price_area.close - long_stop_loss) )

Short 🟥 Stop Loss ⬆ and Take Profit ⬇

The stop loss for the short position is above the high of the candle.

short_stop_loss = price_area.high

The take profit for the short position is placed at a RRR of 2.

Similarly the short take profit is placed at a RRR of 2 below the closing price.

The calculation can be seen below.

short_take_profit = price_area.close - ( 2 * (price_area.high - short_stop_loss) )

Detecting When Stop Loss and Take Profit Is Hit

Long 🟩

The Long stop loss is hit when the low of the current candle is less than the stop loss price.

price_area.low <= long_stop_loss


The Long take profit is hit 🎯 when the high of the current candle is greater than the take profit price.

price_area.high >= long_take_profit


Short 🟥

The Short stop loss is hit when the high of the current candle is greater than the stop loss price

price_area.high >= short_stop_loss


The Short take profit is hit when the low of the current candle is less than the take profit price.

price_area.low <= short_take_profit


In the code below you can see the implmentation of the stop loss and take profit as part of the entire strategy.

Custom Simulator

def custom_simulator_candlestick_sl_tp(open_ , high_ , low_ , close_ , IsBullArray, init_cash = 10000):

    order_records = np.empty((2663,1), dtype = vbt.pf_enums.order_dt)
    order_counts = np.full(1, 0, dtype=np.int_)

    long_stop_loss = None
    long_take_profit = None
    
    short_stop_loss = None
    short_take_profit = None
    
    exec_state = vbt.pf_enums.ExecState(
                        cash = float(init_cash),
                        position = 0.0,
                        debt = 0.0,
                        locked_cash = 0.0,
                        free_cash = float(init_cash),
                        val_price = np.nan,
                        value = np.nan)
    
    
    
    for i in range(len(close_)):
        price_area = vbt.pf_enums.PriceArea(open = open_[i], 
                                            high = high_[i], 
                                            low = low_[i], 
                                            close = close_[i])
        
        value_price = price_area.close
        value = exec_state.cash + (exec_state.position * value_price)
        
        exec_state = vbt.pf_enums.ExecState(
                                            cash = exec_state.cash,
                                            position = exec_state.position,
                                            debt = exec_state.debt,
                                            locked_cash = exec_state.locked_cash,
                                            free_cash = exec_state.free_cash,
                                            val_price = value_price,
                                            value = value)


        if IsBullArray[i] and exec_state.position == 0:
            long_stop_loss = price_area.low #Stop Loss Placed Below The Low Of The Candle
            long_take_profit = price_area.close + ( 2 * (price_area.close - long_stop_loss) ) 
            
            order_result , exec_state = enter_position(
                                                        execution_state_ = exec_state,
                                                        price_area_ = price_area,
                                                        percent_risk_ = 1,
                                                        group_ = 0, column_= 0, bar_ = i,
                                                        direction = 'Buy',
                                                        order_records_= order_records,
                                                        order_counts_ = order_counts)

        elif not IsBullArray[i] and exec_state.position == 0:
            
            short_stop_loss = price_area.high #Stop Loss Placed Above The High Of The Candle
            short_take_profit = price_area.close - ( 2 * (price_area.high - short_stop_loss) ) #Stop Loss At 2RRR
            
            order_result , exec_state = enter_position(
                                                        execution_state_ = exec_state,
                                                        price_area_ = price_area,
                                                        percent_risk_ = 1,
                                                        group_ = 0, column_= 0, bar_ = i,
                                                        direction = 'Sell',
                                                        order_records_= order_records,
                                                        order_counts_ = order_counts)

        elif exec_state.position > 0 and not IsBullArray[i]:
            #Closing Long Position
            order_result , exec_state = close_full_position(
                                                            execution_state_ = exec_state,
                                                            price_area_ = price_area,
                                                            group_ = 0, column_= 0, bar_ = i,
                                                            order_records_ = order_records,
                                                            order_counts_  = order_counts)  
            
            
            
        
        elif exec_state.position < 0 and IsBullArray[i]:
            #Closing Short Position
            order_result , exec_state = close_full_position(
                                                            execution_state_ = exec_state,
                                                            price_area_ = price_area,
                                                            group_ = 0, column_= 0, bar_ = i,
                                                            order_records_= order_records,
                                                            order_counts_ = order_counts)
        
        else:
            
            if exec_state.position > 0: #In Long
                if (price_area.low <= long_stop_loss): 
                    #long stop loss has been hit
                    #Code To Close The Long Position
                    order_result , exec_state = close_full_position(
                                                                execution_state_ = exec_state,
                                                                price_area_ = price_area,
                                                                group_ = 0, column_= 0, bar_ = i,
                                                                order_records_= order_records,
                                                                order_counts_ = order_counts)
                    
                    
                    long_stop_loss = None
                    long_take_profit = None
                
                elif (price_area.high >= long_take_profit): 
                    #Long Take Profit Has Been Hit
                    #Code To Close Long Position Goes Here
                    
                    order_result , exec_state = close_full_position(
                                                                execution_state_ = exec_state,
                                                                price_area_ = price_area,
                                                                group_ = 0, column_= 0, bar_ = i,
                                                                order_records_= order_records,
                                                                order_counts_ = order_counts)
                    
                    
                    long_stop_loss = None
                    long_take_profit = None
                
                else:
                    continue
            
            elif exec_state.position < 0: #In Short
                if (price_area.high >= short_stop_loss): 
                    #Short Stop Loss Has Been Hit
                    #Code That Closes Out The Short Position Goes Here
                    order_result , exec_state = close_full_position(
                                                                execution_state_ = exec_state,
                                                                price_area_ = price_area,
                                                                group_ = 0, column_= 0, bar_ = i,
                                                                order_records_= order_records,
                                                                order_counts_ = order_counts)
          
                    short_stop_loss = None
                    short_take_profit = None
                
                elif (price_area.low <= short_take_profit): 
                    #Short Take Profit Has Been Hit
                    #Code That Closes Out The Short Position Goes Here
                    order_result , exec_state = close_full_position(
                                                                execution_state_ = exec_state,
                                                                price_area_ = price_area,
                                                                group_ = 0, column_= 0, bar_ = i,
                                                                order_records_= order_records,
                                                                order_counts_ = order_counts)
                    
                    short_stop_loss = None
                    short_take_profit = None
                
                else:
                    continue
        
    
    return vbt.nb.repartition_nb(order_records, order_counts)

Key Points / Summary 💡

  • When the stop loss / take profit is hit , both variables have to be set to None.

  • When we are in a position we must check at each bar (candle) whether the stop loss / take profit has been hit and then deploy the approporatiate function to fulfill the logic of our strategy.

]]>
<![CDATA[VectorBT Pro - Custom Simulator 1: Basic Candlestick Strategy]]>

Importing the Dependencies

import vectorbtpro as vbt
import numpy as np
import pandas as pd
from numba import njit
import talib
import datetime as dt
import time
from collections import namedtuple
import itertools
import math
from vectorbtpro.records.nb import col_map_nb
from vectorbtpro.portfolio import nb as pf_
]]>
http://localhost:2368/customsim_1/643e8de328c00f113e76395bTue, 18 Apr 2023 12:51:14 GMT

Importing the Dependencies

import vectorbtpro as vbt
import numpy as np
import pandas as pd
from numba import njit
import talib
import datetime as dt
import time
from collections import namedtuple
import itertools
import math
from vectorbtpro.records.nb import col_map_nb
from vectorbtpro.portfolio import nb as pf_nb, enums as pf_enums
import plotly.io as pio
from numba import njit

Strategy Rules 📏

VectorBT Pro - Custom Simulator 1: Basic Candlestick Strategy

In this strategy we will go long 🟩 on a Bullish Candle and go short 🟥 on a Bearish Candle.

If already in a long position and a bearish candle is present the long position should be closed and vice versa.

Otherwise the position should be held.

Obtaining The Data

data = vbt.YFData.fetch("BTC-USD", end="2022-01-01")
Open = data.get("Open").to_numpy()
High = data.get("High").to_numpy()
Low = data.get("Low").to_numpy()
Close = data.get("Close").to_numpy()

Coding The Rules

A Bullish Candle 🟩 is where the close is greater the the open (The price increased)

df['IsBull'] = df['Close'] > df['Open']

Converting the above piece of code to a NumPY Array

IsBull = df['IsBull'].to_numpy()

Understanding the Custom Simulator

Bullish Candle 🟩 & Not In A Position ⚪

IsBullArray[i] and exec_state.position == 0


Bearish Candle 🟥 & Not In A Position ⚪

not IsBullArray[i] and exec_state.position == 0


Bullish Candle 🟩 & In A Short Position 🔴

IsBullArray[i] and exec_state.position < 0


Bearish Candle 🟥 & In A Long Position 🟢

not IsBullArray[i] and exec_state.position > 0


Custom Simulator Code

def custom_simulator(open_, high_ , low_ , close_ , IsBullArray,init_cash = 10000):
     
    order_records = np.empty((2663,1), dtype = vbt.pf_enums.order_dt)
    order_counts = np.full(1, 0, dtype=np.int_)

    exec_state = vbt.pf_enums.ExecState(
                        cash = float(init_cash),
                        position = 0.0,
                        debt = 0.0,
                        locked_cash = 0.0,
                        free_cash = float(init_cash),
                        val_price = np.nan,
                        value = np.nan)
    
    
    for i in range(len(close_)):
        
        price_area = vbt.pf_enums.PriceArea(open  = open_[i],
                                            high = high_[i], 
                                            low = low_[i], 
                                            close = close_[i])
        
        value_price = price_area.close
        value = exec_state.cash + (exec_state.position * value_price)
        
        exec_state = vbt.pf_enums.ExecState(
                                cash = exec_state.cash,
                                position = exec_state.position,
                                debt = exec_state.debt,
                                locked_cash = exec_state.locked_cash,
                                free_cash = exec_state.free_cash,
                                val_price = value_price,
                                value = value)
        
        if IsBullArray[i] and exec_state.position == 0:
            #CODE THAT WILL ENTER LONG POSITION

            order_result , exec_state = enter_position(
                                                                execution_state_ = exec_state,
                                                                price_area_ = price_area,
                                                                percent_risk_ = 1,
                                                                group_ = 0, column_= 0, bar_ = i,
                                                                direction = 'Buy',
                                                                order_records_= order_records,
                                                                order_counts_ = order_counts)

        elif not IsBullArray[i] and exec_state.position == 0:
            #CODE THAT WILL ENTER SHORT POSITION
            order_result , exec_state = enter_position(
                                                                execution_state_ = exec_state,
                                                                price_area_ = price_area,
                                                                percent_risk_ = 1,
                                                                group_ = 0, column_= 0, bar_ = i,
                                                                direction = 'Sell',
                                                                order_records_= order_records,
                                                                order_counts_ = order_counts)
               

        elif IsBullArray[i] and exec_state.position < 0:
            #CODE THAT WILL CLOSE THE SHORT POSITION HERE
            order_result , exec_state = close_full_position(
                                                                execution_state_ = exec_state,
                                                                price_area_ = price_area,
                                                                group_ = 0, column_= 0, bar_ = i,
                                                                order_records_ = order_records,
                                                                order_counts_  = order_counts)    
            

        elif not IsBullArray[i] and exec_state.position >0:
            order_result , exec_state = close_full_position(
                                                                execution_state_ = exec_state,
                                                                price_area_ = price_area,
                                                                group_ = 0, column_= 0, bar_ = i,
                                                                order_records_= order_records,
                                                                order_counts_ = order_counts)    
        else:
            continue
        
                
    return vbt.nb.repartition_nb(order_records, order_counts)

Running The Custom Simulator

custom_simulator(open_ = Open, 
                 high_ = High, 
                 low_  = Low, 
                 close_  = Close, 
                 IsBullArray = IsBull,
                 init_cash = 100000)

Output of Custom Simulation / Backtest

array([(   0, 0,    0, 2.18658566,   457.33401489, 0., 1),
       (   1, 0,    3, 2.18658566,   408.9039917 , 0., 0),
       (   2, 0,    4, 2.51004568,   398.8210144 , 0., 1), ...,
       (1818, 0, 2659, 0.0218352 , 47588.85546875, 0., 1),
       (1819, 0, 2661, 0.0218352 , 47178.125     , 0., 0),
       (1820, 0, 2662, 0.02244184, 46306.4453125 , 0., 1)],
      dtype={'names':['id','col','idx','size','price','fees','side'], 'formats':['<i4','<i4','<i4','<f8','<f8','<f8','<i4'], 'offsets':[0,4,8,16,24,32,40], 'itemsize':48, 'aligned':True})

Summary / Key Points 💡


The custom simulator is relatively simple to understand , all we do is iterate through the length of the closing_prices.


Upon each iteration we update the execution_state


We then index into the IsBullArray along with checking the value of the exec_state.position as shown above to match the logic of our strategy.


After we use our prebuilt functions enter_position , close_position to enter and close positions as per the strategy has defined.

]]>
<![CDATA[VectorBT Pro - Custom Simulator 0 : Main Functions]]>

Importing The Dependencies

import vectorbtpro as vbt
import numpy as np
import pandas as pd
from numba import njit
import talib
import datetime as dt
import time
from collections import namedtuple
import itertools
import math
from vectorbtpro.records.nb import col_map_nb
from vectorbtpro.portfolio import nb as pf_
]]>
http://localhost:2368/customsim_0/643d6375de286011d16d62edMon, 17 Apr 2023 15:41:52 GMT

Importing The Dependencies

import vectorbtpro as vbt
import numpy as np
import pandas as pd
from numba import njit
import talib
import datetime as dt
import time
from collections import namedtuple
import itertools
import math
from vectorbtpro.records.nb import col_map_nb
from vectorbtpro.portfolio import nb as pf_nb, enums as pf_enums
import plotly.io as pio
from numba import njit

Order Records , Order Counts

VectorBT Pro - Custom Simulator 0 : Main Functions

The order records keep track of the details of each trade executed by a trading strategy.

The purpose of order records is to provide traders with a complete record of all trades executed by the strategy, including the entry and exit prices ,order type , order size and other relevant details.

The order counts are the number of trades executed by a trading strategy over a certain period of time. They can be analysed to gain insights into trading activity and use this information to adjust their approach.

Process Order

The main purpose of using vbt.pf_nb.process_order_nb in the functions that will be introduced below is that it takes in the order_records and order_counts without having to manually construct them after an Order has been submitted.

Enter Position

The enter_position will enter either a Buy or Sell position depending on the logic and overall structure of your strategy.

Its primary purpose is to create the Order corresponding to the percentage_risk and the direction that has been specified.

It takes in a multitude of different arguments the main ones being ;

execution_state_

The execution_state feature keeps track of the
current position of a trading strategy (long, short, or neutral) at each time step in a trading simulation.

price_area_

The price_area_ feature can be used when constructing an order to help determine the entry and exit points for a trade since its compromised of the open , high , low and closing prices at the point in time the trade was taken.

percent_risk_

The percent_risk_ % will allow us to specify how much to risk on each trade.

direction

This is simply the direction of the trade that one wants to take whether that be Buy 🟩 or Sell 🟥.

group_ , col_ , bar_

These refer to the group , column and the current bar number of the iteration of the close prices that we are on.

@njit(nogil = True)
def enter_position(execution_state_,
                    price_area_,
                    percent_risk_ = 1, 
                    group_ = 0 , column = 0, bar_ = None,
                    direction = "Buy",
                    update_value_ = False, order_records_ = None, 
                    order_counts_ = None, log_records_ = None,
                    log_counts_ = None):
    
    
    if direction == "Buy":
        direction_to_take = vbt.pf_enums.Direction.LongOnly
    else:
        direction_to_take = vbt.pf_enums.Direction.ShortOnly
        
    Order = pf_nb.order_nb(size = percent_risk_, 
                           size_type = vbt.pf_enums.SizeType.ValuePercent100,
                           direction = direction_to_take,
                           price = execution_state_.val_price
                           )

    
    order_result , new_execution_state = vbt.pf_nb.process_order_nb(
                                                        group = group_ ,col = column , i = bar_,
                                                        exec_state = execution_state_,
                                                        order = Order,
                                                        price_area = price_area_,
                                                        update_value = update_value_,
                                                        order_records = order_records_,
                                                        order_counts = order_counts_,
                                                        log_records = log_records_,
                                                        log_counts = log_counts_)
    
    

    
                                                                
    return order_result, new_execution_state
    

close_full_position

There are minor differences between each function in the close_full_position the size is specified to be -np.inf (Infinite size) and the direction is determined via the passed execution_state.

If the execution_state.position is >0 (greater than zero) it indicates that we are invested in a a Buy (Long position) and if it is <0 (Less than zero) then we are invested in a Sell (Short Position).

close_partial_pos

The close_partial_pos intends to close the position only partially.

This is useful when taking partial profits which is the basis of many algorithmic trading strategies.

The Size when constructing the Order is set to be -closing_percent such that if 50 is specified only 50% of that particular position will be closed.

@njit(nogil = True)
def close_partial_pos(execution_state_,
                      price_area_,
                      closing_percent = 50, 
                      group_ = 0 , column = 0, bar_ = None,
                      update_value_ = False, order_records_ = None, 
                      order_counts_ = None, log_records_ = None,
                      log_counts_ = None):
    
    
    if execution_state.position > 0 :
        direction_to_take = vbt.pf_enums.Direction.LongOnly
    else:
        direction_to_take = vbt.pf_enums.Direction.ShortOnly
        
    Order = pf_nb.order_nb(size = -closing_percent, 
                           size_type = vbt.pf_enums.SizeType.ValuePercent100,
                           direction = direction_to_take,
                           price = execution_state_.val_price
                           )

    
    order_result , new_execution_state = vbt.pf_nb.process_order_nb(
                                                group = group_ ,col = column , i = bar_,
                                                exec_state = execution_state_,
                                                order = Order,
                                                price_area = price_area_,
                                                update_value = update_value_,
                                                order_records = order_records_,
                                                order_counts = order_counts_,
                                                log_records = log_records_,
                                                log_counts = log_counts_)
    
                                                         
    return order_result, new_execution_state



Key Points / Summary 💡

  • The functions streamline the process of creating the Order and allow all the trades to be tracked via the order_records and order_counts

  • The Size component of the Order is directly linked to the precentage risk that is being taken on the account and the size_type ensures that the amount on the account that is risked is exactly as what has been specified in the function argument.

]]>
<![CDATA[VectorBT Pro - Discretionary Signal Bactesting]]>In this tutorial we see how to backtest discretionary signals (eg: from a Telegram Channel) using VectorBT Pro. Primarily, we learn how to create our own order execution engine order_func_nb which deals with orders and position management in our signals data and finally our own custom simulator signal_

]]>
http://localhost:2368/discretionary-signals-bactesting/643c151fe770773c74f2e605Sat, 15 Apr 2023 09:00:00 GMT

In this tutorial we see how to backtest discretionary signals (eg: from a Telegram Channel) using VectorBT Pro. Primarily, we learn how to create our own order execution engine order_func_nb which deals with orders and position management in our signals data and finally our own custom simulator signal_simulator_nb which uses the order_func_nb function along with a host of other smaller helper functions to complete the simulation.

You can follow this tutorial in this YouTube video

YouTube - Discretionary Signals using VectorBT Pro

Many Thanks to Oleg Polakow for his gracious effort in building all the code in this project. Be sure to check out the jupyter notebook in the link below

]]>
<![CDATA[VectorBT Pro - Parameter Optimisation of a Strategy]]>

In this tutorial, we will see how to do Parameter Optimization on the Double Bollinger Band strategy we had seen in our earlier tutorials. The goal of parameter optimization is to find the optimal values for the parameters of an algorithmic trading strategy to maximize a performance metric like total_

]]>
http://localhost:2368/parameter-optimization/643c151fe770773c74f2e604Tue, 28 Feb 2023 09:00:00 GMTVectorBT Pro - Parameter Optimisation of a Strategy

In this tutorial, we will see how to do Parameter Optimization on the Double Bollinger Band strategy we had seen in our earlier tutorials. The goal of parameter optimization is to find the optimal values for the parameters of an algorithmic trading strategy to maximize a performance metric like total_returns, Sharpe Ratio, Sortino Ratio, Win Rate etc. or also to minimize certain metrics like Total Drawdown. Quintessentially, we will see how to:

  • Use the vbt.Parameterized() decorator to easily convert any strategy function into a parameter optimization simulation.
  • Use the vbt.Splitter object to create train and test splits for cross validation of the parameter optimization process.

Loading and resampling market data

As always, we begin by loading the market data, in this case BTCUSDT (one minute) data from Binance. We will then resample the M1 data to higher timeframes and store this resampled data in a dictionary for quick in memory access during the parameter optimization process.

import numpy as np
import pandas as pd
import vectorbtpro as vbt

## Acquire BTCUSDT 1m crypto data from Binance

data = vbt.BinanceData.fetch(
    ["BTCUSDT"], 
    start="2019-01-01 UTC", 
    end="2023-02-02 UTC",
    timeframe="1m"
    )

## Save acquired data locally for persistance
data.to_hdf("/Users/john.doe/vbt_pro_tutorials/data/Binance_BTCUSDT_OHLCV_3Y_m1.h5")

To read hdf files of market data downloaded from Binance, VectorBT Pro has this vbt.BinanceData.from_hdf() method which automatically deals with resampling issues for all the columns.

## Load m1 data - GBPUSD
m1_data = vbt.BinanceData.from_hdf('../data/Binance_BTCUSDT_OHLCV_3Y_m1.h5')

## Resample m1 data to higher timeframes
m5_data  = m1_data.resample('5T')   # Convert 1 minute to 5 mins
m15_data = m1_data.resample('15T')  # Convert 1 minute to 15 mins
m30_data = m1_data.resample('30T')  # Convert 1 minute to 30 mins
h1_data  = m1_data.resample("1H")   # Convert 1 minute to 1 hour
h2_data  = m1_data.resample("2H")   # Convert 1 minute to 2 hour
h4_data  = m1_data.resample('4H')   # Convert 1 minute to 4 hour
h12_data = m1_data.resample('12H')  # Convert 1 minute to 12 hour
d1_data  = m1_data.resample('1D')   # Convert 1 minute to Daily data

mtf_data = { "1T" : m1_data, "5T" : m5_data, "15T" : m15_data, "30T" : m30_data,
             "1H" : h1_data, "2H" : h2_data, "4H" : h4_data, "12H" : h12_data, "1D" : d1_data }

We will also create helper functions like:

  • remapped_tf - to retrive mapped key values from this freq_dict mapper dict
  • flatten_list - to flatten a 2D list into a 1D list
  • create_list_numbers - to generate a range list of numbers
freq_dict = { "1T" : 1, "5T" : 5, "15T" : 15, "30T" : 30,
              "1H" : 60, "2H" : 120, "4H" : 240, "8H" : 480,
              "12H" : 720, "1D": 1440 }  

def remapped_tf(input_value : int) -> str:
    """Map an integer to a string timeframe format"""
    tf_freq = {1 : "1T", 5 : "5T", 15 : "15T", 30 : "30T", 60 :"1H", 
                  120 : "2H", 240 : "4H", 720 : "12H", 1440 : "1D"}
    new_value = tf_freq.get(input_value)
    return new_value      

def flatten_list(list_2D : list):
    """Flatten a list of list of strings"""
    flat_list = list_2D if len(list_2D) == 0 else [item for sublist in list_2D for item in sublist]
    return flat_list

def create_list_numbers(r1, r2, step):
    """Create a list of numbers between two bounds (r1, r2 which can be float or int) and incrementing
       each number using the specified `step` value """
    if type(r1) == float and type(r2) == float:
        return list(np.round(np.arange(r1, r2+step, step), 2))
    return list(np.arange(r1, r2+step, step))                            

You might remember this create_resamplers function from our first tutorial which we used for upsampling.

def create_resamplers(result_dict_keys_list : list, source_indices : list,  
                      source_frequencies :list, target_index : pd.Series, target_freq : str):
    """
    Creates a dictionary of vbtpro resampler objects.

    Parameters
    ==========
    result_dict_keys_list : list, list of strings, which are keys of the output dictionary
    source_indices        : list, list of pd.time series objects of the higher timeframes
    source_frequencies    : list(str), which are short form representation of time series order. Eg:["1D", "4h"]
    target_index          : pd.Series, target time series for the resampler objects
    target_freq           : str, target time frequency for the resampler objects

    Returns
    ===========
    resamplers_dict       : dict, vbt pro resampler objects
    """
    
    
    resamplers = []
    for si, sf in zip(source_indices, source_frequencies):
        resamplers.append(vbt.Resampler(source_index = si,  target_index = target_index,
                                        source_freq = sf, target_freq = target_freq))
    return dict(zip(result_dict_keys_list, resamplers))

@vbt.parameterized decorator

The decorator @vbt.parameterized is engine-agnostic and parameterizes a strategy function and returns a new function with the same signature as the passed one. This decorator enhances our optimal_2BB strategy function (below) to take arguments wrapped with vbt.Param, and build the grid of parameter combinations, run the optimal_2BB function on each parameter combination, and merge the results using concatenation.

The following arguments are used in the @vbt.parameterized() decorator:

  • random_subset = 1000, randomly selects 1000 combinations out of millions of combinations, like we do in random selection
  • merge_func = "concat", concats the output of each output row-wise, to the same pd.Series, other values for merg_func are "column_stack"
  • show_progress = True, shows the tqdm progress bar of the simulation
@vbt.parameterized(merge_func = "concat", random_subset = 1000, show_progress=True)  
def optimal_2BB(lower_tf : int = 1, higher_tf: int = 5,
                ltf_rsi_timeperiod : int = 21, 
                bb_price_timeperiod : int = 14, bb_rsi_timeperiod : int = 14,
                bb_price_nbdevup : int = 2, bb_price_nbdevdn: int = 2,
                bb_rsi_nbdevup : int = 2, bb_rsi_nbdevdn : int = 2,
                output_metric : str | list = "total_return",
                index = None
                ):
    
    lower_tf  = remapped_tf(lower_tf)
    higher_tf = remapped_tf(higher_tf)
    # print("New Lower TF:", lower_tf, "New Higher TF:", higher_tf)
    
    if index is None:
        ltf_data = mtf_data[lower_tf]
        htf_data = mtf_data[higher_tf]
    else:
        # print(f"Start Index:{index[0]} || End Index: {index[-1]}")
        ltf_data = mtf_data[lower_tf].loc[index[0]:index[-1]]
        htf_data = mtf_data[higher_tf].loc[index[0]:index[-1]]

    ### Get OHLC prices for lower and higher timeframes
    ltf_open, ltf_high, ltf_low, ltf_close = ltf_data.get('Open'), ltf_data.get('High'), ltf_data.get('Low'), ltf_data.get('Close')
    htf_open, htf_high, htf_low, htf_close = htf_data.get('Open'), htf_data.get('High'), htf_data.get('Low'), htf_data.get('Close')

    ltf_rsi = vbt.talib("RSI", timeperiod = ltf_rsi_timeperiod).run(ltf_close, skipna=True).real.ffill()
    ltf_bbands_rsi = vbt.talib("BBANDS").run(ltf_rsi, timeperiod = bb_rsi_timeperiod, nbdevup = bb_rsi_nbdevup, nbdevdn = bb_rsi_nbdevdn, skipna=True)    
    htf_bbands_price = vbt.talib("BBANDS").run(htf_close, timeperiod = bb_price_timeperiod, nbdevup = bb_price_nbdevup, nbdevdn = bb_price_nbdevdn, skipna=True)

    ## Initialize  dictionary
    data = {}

    col_values = [ ltf_close, ltf_rsi,ltf_bbands_rsi.upperband, ltf_bbands_rsi.middleband, ltf_bbands_rsi.lowerband ]

    col_keys = [ "ltf_close", "ltf_rsi", "ltf_bbands_rsi_upper",  "ltf_bbands_rsi_middle", "ltf_bbands_rsi_lower" ]

    # Assign key, value pairs for method of time series data to store in data dict
    for key, time_series in zip(col_keys, col_values):
        data[key] = time_series.ffill()

    resampler_dict_keys = [higher_tf + "_" + lower_tf]

    list_resamplers = create_resamplers(result_dict_keys_list = resampler_dict_keys,
                                        source_indices = [htf_close.index], 
                                        source_frequencies = [higher_tf], 
                                        target_index = ltf_close.index,
                                        target_freq = lower_tf)

    # print(list_resamplers)
    
    ## Use along with  Manual indicator creation method for MTF
    series_to_resample = [
        [htf_open, htf_high, htf_low, htf_close, 
        htf_bbands_price.upperband, htf_bbands_price.middleband, htf_bbands_price.lowerband]
        ]


    resample_data_keys = [
        ["htf_open", "htf_high", "htf_low", "htf_close", 
        "htf_bbands_price_upper",  "htf_bbands_price_middle",  "htf_bbands_price_lower"]
            ]    

    df_cols_order = col_keys + flatten_list(resample_data_keys)
    ## Create resampled time series data aligned to base line frequency (15min)
    # print("COLUMNS ORDER:", df_cols_order)
    
    for lst_series, lst_keys, resampler in zip(series_to_resample, resample_data_keys, resampler_dict_keys):
        for key, time_series in zip(lst_keys, lst_series):
            if key.lower().endswith('open'):
                # print(f'Resampling {key} differently using vbt.resample_opening using "{resampler}" resampler')
                resampled_time_series = time_series.vbt.resample_opening(list_resamplers[resampler])
            else:
                resampled_time_series = time_series.vbt.resample_closing(list_resamplers[resampler])
            data[key] = resampled_time_series
    

    ## construct a multi-timeframe dataframe
    mtf_df = pd.DataFrame(data)[df_cols_order]

    # print("DataFrame Output:\n", mtf_df.head())

    ## Long Entry Conditions
    c1_long_entry = (mtf_df['htf_low'] <= mtf_df['htf_bbands_price_lower'])
    c2_long_entry = (mtf_df['ltf_rsi'] <= mtf_df['ltf_bbands_rsi_lower'] )

    ## Long Exit Conditions
    c1_long_exit =  (mtf_df['htf_high'] >= mtf_df['htf_bbands_price_upper'])
    c2_long_exit =  (mtf_df['ltf_rsi']  >= mtf_df['ltf_bbands_rsi_upper'])             

    ## Create entries and exit columns using the above conditions
    mtf_df['entry'] = c1_long_entry & c2_long_entry
    mtf_df['exit']  = c1_long_exit & c2_long_exit

    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'])

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

    pf = vbt.Portfolio.from_signals(
        close = ltf_close, 
        entries = entries, 
        exits = exits, 
        direction = "both", ## This setting trades both long and short signals
        freq = pd.Timedelta(minutes = freq_dict[lower_tf]), 
        init_cash = 100000
    )

    if type(output_metric) == str:
        return pf.deep_getattr(output_metric) ## When tuning a single metric
    elif type(output_metric) == list:
        return pd.Series({k: getattr(pf, k) for k in output_metric}) ## When you want to tune a list of metrics

Double Bollinger Band - Strategy

As a quick recap, the rules of the strategy coded in optimal_2BB are as follows:

  1. A long (buy) signal is generated whenever the higher timeframe (Low) price goes below its lower Bollinger band, and the lower timeframe RSI goes below its lower Bollinger band (RSI).

  2. A short (sell) signal is generated whenever the higher timeframe (High) price breaks its upper Bollinger band, and the lower timeframe RSI breaks above its upper Bollinger band (RSI).

We can invoke the parameter optimization process by passing the list of parameters we want for each argument wrapped in the vbt.Param class.
The vbt.Param class also accepts conditions, like in our case we don't want the lower_tf to have a value greater than the higher_tf so we specify that using the condition argument inside the vbt.Param class. In this particular case we are just specifying one metric (total_return) to tune our parameter optimization.

pf_results = optimal_2BB(
    lower_tf = vbt.Param([1, 5, 15, 30, 60, 120, 240, 720], condition = "x <= higher_tf"),
    higher_tf = vbt.Param([1, 5, 15, 30, 60, 120, 240, 720, 1440]),
    ltf_rsi_timeperiod = vbt.Param(create_list_numbers(18, 22, 1)),
    bb_price_timeperiod = vbt.Param(create_list_numbers(18, 22, 1)),
    bb_rsi_timeperiod = vbt.Param(create_list_numbers(18, 22, 1)),
    bb_price_nbdevup = vbt.Param(create_list_numbers(1.5, 2.5, step = 0.25)),
    bb_price_nbdevdn = vbt.Param(create_list_numbers(1.5, 2.5, step = 0.25)),
    bb_rsi_nbdevup = vbt.Param(create_list_numbers(1.5, 2.5, step = 0.25)),
    bb_rsi_nbdevdn = vbt.Param(create_list_numbers(1.5, 2.5, step = 0.25)),
    output_metric = "total_return"
 )

print(f"Best Total Returns: {round(pf_results.max(), 2)} %")
print(f"Parameter Combinations with Best Total Returns:{pf_results.idxmax()}")

print(f"Worst Total Returns: {round(pf_results.min(), 2)} %")
print(f"Parameter Combinations with Worst Total Returns:{pf_results.idxmin()}")

Output

Best Total Returns: 8917.26 %
Parameter Combinations with Best Total Returns:(1, 1, 19, 20, 22, 1.75, 2.25, 1.5, 2.0)
Worst Total Returns: -4.41 %
Parameter Combinations with Worst Total Returns:(120, 1440, 18, 19, 22, 2.5, 2.0, 2.25, 2.5)

Since for this simulation, we selected only a single output metric ( total_returns ), we get a multi-index pandas series which we can sort to quickly see the most promising results

pf_results.sort_values(ascending=False)
VectorBT Pro - Parameter Optimisation of a Strategy
Portfolio Simulation Results (Multi-index Pandas Series)

We can also pass multiple metrics as a list to the output_metric argument, and the output the entire portfolio simulation a multi-index pandas series. Of course, in this case, it is not like we are tuning multiple knobs and dials to get an optimal value for all the metrics passed, but we are just returning multiple metrics as part of the output of each simulation.

pf_results = optimal_2BB(
    lower_tf = vbt.Param([5, 30], condition = "x <= higher_tf"),
    higher_tf = vbt.Param([1, 5, 15]),
    ltf_rsi_timeperiod = vbt.Param(create_list_numbers(18, 22, 1)),
    bb_price_timeperiod = vbt.Param(create_list_numbers(18, 22, 1)),
    bb_rsi_timeperiod = vbt.Param(create_list_numbers(18, 22, 1)),
    bb_price_nbdevup = vbt.Param(create_list_numbers(1.5, 2.5, step = 0.25)),
    bb_price_nbdevdn = vbt.Param(create_list_numbers(1.5, 2.5, step = 0.25)),
    bb_rsi_nbdevup = vbt.Param(create_list_numbers(1.5, 2.5, step = 0.25)),
    bb_rsi_nbdevdn = vbt.Param(create_list_numbers(1.5, 2.5, step = 0.25)),
    output_metric = ["total_profit", "total_return", "max_drawdown", "sharpe_ratio"]
 )

To flatten the multi-index series and convert it into a pandas DataFrame, we can use this following code:

pf_results_df = pf_results.unstack(level = -1)
pf_results_df = pf_results_df[['total_return','max_drawdown','sharpe_ratio']].sort_values(
                by=['total_return', 'max_drawdown'], 
                ascending=False)
pf_results_df.reset_index(inplace=True)
pf_results_df

VectorBT Pro - Parameter Optimisation of a Strategy

## To check if our condition for `lower_tf` works
print("Length of DF:",len(pf_results_df[pf_results_df['lower_tf'] > pf_results_df['higher_tf']]))

Output

Length of DF: 0
print(f"Best Total Returns: {round(pf_results_df['total_return'].max(), 2)} %")
print(f"Parameter Combinations with Best Total Returns:")
pd.DataFrame(pf_results_df.iloc[pf_results_df['total_return'].idxmax()]).T

Output:

Best Total Returns: 504.49 %
Parameter Combinations with Best Total Returns:
lower_tf higher_tf ltf_rsi_timeperiod bb_price_timeperiod bb_rsi_timeperiod bb_price_nbdevup bb_price_nbdevdn bb_rsi_nbdevup bb_rsi_nbdevdn total_return max_drawdown sharpe_ratio
0 5 5 18.0 18.0 19.0 2.25 1.5 1.75 2.0 504.490043 -0.389288 2.328832
print(f"Worst Total Returns: {round(pf_results_df['total_return'].min(), 2)} %")
print(f"Parameter Combinations with Worst Total Returns:")
pd.DataFrame(pf_results_df.iloc[pf_results_df['total_return'].idxmin()]).T

Output:

Worst Total Returns: -0.98 %
Parameter Combinations with Worst Total Returns:
lower_tf higher_tf ltf_rsi_timeperiod bb_price_timeperiod bb_rsi_timeperiod bb_price_nbdevup bb_price_nbdevdn bb_rsi_nbdevup bb_rsi_nbdevdn total_return max_drawdown sharpe_ratio
999 5 15 20.0 22.0 20.0 2.25 2.5 2.25 2.5 -0.977365 -0.991565 -0.704983

Cross Validation

Cross-Validation (CV) is a technique used to curb overfitting, which involves partitioning a sample of data into complementary subsets, performing the analysis on one subset of data called the training or in-sample (IS) set, and validating the analysis on the other subset of data called the testing, validation or out-of-sample (OOS) set. This procedure is repeated until we have multiple OOS periods and can draw statistics from these results combined. CV is mainly done for the following reasons:

  • Improve robustness testing of the strategy
  • Mitigate the risk of running wrong predictions.
    • This is because, the input data, usually retrieved from a limited time frame slice of history, is highly biased and can not produce reliable forecasts. One way to mitigate the risk of running wrong predictions is to do CV, where we re-run backtests many times but each with slightly different data input

Splitter Class

At the heart of implementing CV functionality in vectorBT Pro is the class Splitter, whose main responsibility is to produce arbitrary splits and perform operations on those splits. The workings of this class are quite simple- the user calls one of the class methods with the prefix from_to generate splits; in return, a splitter instance is returned with splits and their labels being saved in a memory-efficient array format. This instance can be used to analyze the split distribution, to chunk array-like objects, and to run User Defined Functions (UDFs).
The splitter class has many methods like:

  • from_rolling andfrom_n_rolling
  • from_expandingand from_n_expanding
  • from_splits
  • from_ranges
  • from_grouper
  • from_random
  • from_sklearn
  • from_split_func

In this tutorial we will just go over the from_rolling splitter method as it is the typical requirement of creating data splits. It is beyond the scope of this tutorial, to go over all the splitter methods above, so it is recommended to read the vectorbtpro documentation to decide which method would best fit your use case.

Let's create a splitter schema using splitter.from_rolling() method for cross-validation of our optimal_2BB strategy.

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

splitter = vbt.Splitter.from_rolling(
    index  = d1_data.index, 
    length = 360, 
    split = 0.5,
    set_labels = ["train", "test"]
    )

The arguments we have used in the from_rolling method for the above schema are as follows:

  • index - datatime index series required to create the splits from. In this case, we used the the index series from the daily (d1_data) data.
  • length - can be int, float, or timedelta_like . Floating values between 0 and 1 are considered relative. Length can also be negative. We have used 360 days as the length of a single split.
  • split - Ranges to split the range into. If None, will produce the entire range as a single range. Here split = 0.5 means, we will be splitting the length of 360 days into equal splits for train and test sets.
  • set_labels - Labels corresponding to the selected row/column groups. In our case we are creating two sets called train and test
splitter.plot().show()

Output:
VectorBT Pro - Parameter Optimisation of a Strategy

The smallest unit of a splitter is a range, which is a period of time that can be mapped onto data. On the plot above, we can count a total of 14 ranges - 7 blue ones for train sets and 7 orange ones for test sets. Multiple ranges next to each other and representing a single test are called a split; there are 6 splits present in the chart, such that we expect one pipeline to be tested on 6 different data ranges. Different range types within each split are called sets. We have used the two sets - "training" and "test" (commonly used in backtesting). The number of sets is fixed throughout all splits.

splitter.splits

Output:

set train test
split
0 slice(0,180,None) slice(180,360,None)
1 slice(180,360,None) slice(360,540,None)
2 slice(360,540,None) slice(540,720,None)
3 slice(540,720,None) slice(720,900,None)
4 slice(720,900,None) slice(900,1080,None)
5 slice(900,1080,None) slice(1080,1260,None)
6 slice(1080,1260,None) slice(1260,1440,None)

Time is being tracked separately in Splitter.index while assets aren't being tracked at all since they have no implications on splitting.

splitter.index

Output:

DatetimeIndex(['2019-01-01 00:00:00+00:00', '2019-01-02 00:00:00+00:00',
               '2019-01-03 00:00:00+00:00', '2019-01-04 00:00:00+00:00',
               '2019-01-05 00:00:00+00:00', '2019-01-06 00:00:00+00:00',
               '2019-01-07 00:00:00+00:00', '2019-01-08 00:00:00+00:00',
               '2019-01-09 00:00:00+00:00', '2019-01-10 00:00:00+00:00',
               ...
               '2023-01-23 00:00:00+00:00', '2023-01-24 00:00:00+00:00',
               '2023-01-25 00:00:00+00:00', '2023-01-26 00:00:00+00:00',
               '2023-01-27 00:00:00+00:00', '2023-01-28 00:00:00+00:00',
               '2023-01-29 00:00:00+00:00', '2023-01-30 00:00:00+00:00',
               '2023-01-31 00:00:00+00:00', '2023-02-01 00:00:00+00:00'],
              dtype='datetime64[ns, UTC]', name='Open time', length=1493, freq='D')
print("Total Nr. of Splits:",len(close_slices.index))
df_splits = pd.DataFrame(close_slices.index.tolist(), columns=["split", "period"])
unique_splits = df_splits["split"].unique().tolist()
print("Unique Splits:", unique_splits)
df_splits

Output:

Total Nr. of Splits: 14
Unique Splits: [0, 1, 2, 3, 4, 5, 6]
split period
0 0 train
1 0 test
2 1 train
3 1 test
4 2 train
5 2 test
6 3 train
7 3 test
8 4 train
9 4 test
10 5 train
11 5 test
12 6 train
13 6 test

Compute Baseline Returns across splits

The baseline returns is just the buy and hold returns for buying and holding the asset for the period of the split.

def get_total_return(close_prices):
    return close_prices.vbt.to_returns().vbt.returns.total()

base_line_returns = close_slices.apply(get_total_return)
base_line_returns

Output:

split  set  
0      train    2.134762
       test    -0.336472
1      train   -0.336472
       test     0.326704
2      train    0.326704
       test     1.523051
3      train    1.523051
       test     0.576598
4      train    0.576598
       test     0.377110
5      train    0.377110
       test    -0.527897
6      train   -0.527897
       test    -0.226275
dtype: float64

Print upper and lower bound in each split

train_slices = [slice(close_slices[i, "train"].index[0], close_slices[i, "train"].index[-1]) for i in unique_splits]
train_slices

Output:

[slice(Timestamp('2019-01-01 00:00:00+0000', tz='UTC', freq='D'), Timestamp('2019-06-29 00:00:00+0000', tz='UTC', freq='D'), None),
 slice(Timestamp('2019-06-30 00:00:00+0000', tz='UTC', freq='D'), Timestamp('2019-12-26 00:00:00+0000', tz='UTC', freq='D'), None),
 slice(Timestamp('2019-12-27 00:00:00+0000', tz='UTC', freq='D'), Timestamp('2020-06-23 00:00:00+0000', tz='UTC', freq='D'), None),
 slice(Timestamp('2020-06-24 00:00:00+0000', tz='UTC', freq='D'), Timestamp('2020-12-20 00:00:00+0000', tz='UTC', freq='D'), None),
 slice(Timestamp('2020-12-21 00:00:00+0000', tz='UTC', freq='D'), Timestamp('2021-06-18 00:00:00+0000', tz='UTC', freq='D'), None),
 slice(Timestamp('2021-06-19 00:00:00+0000', tz='UTC', freq='D'), Timestamp('2021-12-15 00:00:00+0000', tz='UTC', freq='D'), None),
 slice(Timestamp('2021-12-16 00:00:00+0000', tz='UTC', freq='D'), Timestamp('2022-06-13 00:00:00+0000', tz='UTC', freq='D'), None)]
test_slices = [slice(close_slices[i, "test"].index[0], close_slices[i, "test"].index[-1]) for i in unique_splits]
test_slices

Output:

[slice(Timestamp('2019-06-30 00:00:00+0000', tz='UTC', freq='D'), Timestamp('2019-12-26 00:00:00+0000', tz='UTC', freq='D'), None),
 slice(Timestamp('2019-12-27 00:00:00+0000', tz='UTC', freq='D'), Timestamp('2020-06-23 00:00:00+0000', tz='UTC', freq='D'), None),
 slice(Timestamp('2020-06-24 00:00:00+0000', tz='UTC', freq='D'), Timestamp('2020-12-20 00:00:00+0000', tz='UTC', freq='D'), None),
 slice(Timestamp('2020-12-21 00:00:00+0000', tz='UTC', freq='D'), Timestamp('2021-06-18 00:00:00+0000', tz='UTC', freq='D'), None),
 slice(Timestamp('2021-06-19 00:00:00+0000', tz='UTC', freq='D'), Timestamp('2021-12-15 00:00:00+0000', tz='UTC', freq='D'), None),
 slice(Timestamp('2021-12-16 00:00:00+0000', tz='UTC', freq='D'), Timestamp('2022-06-13 00:00:00+0000', tz='UTC', freq='D'), None),
 slice(Timestamp('2022-06-14 00:00:00+0000', tz='UTC', freq='D'), Timestamp('2022-12-10 00:00:00+0000', tz='UTC', freq='D'), None)]

Splitter.apply - Applying our optimal_2BB function to the splitter sets

We can use the splitter.apply() to apply the different splits on our strategy optimal_2BB function, by selecting the splits by their set label (eg: train or test). We would pass our strategy function (optimal_2BB) to the apply_func argument of the splitter_apply method and the arguments of the optimal_2BB function typically follow after the apply_func argument.

The splitter.apply() method also has lots of arguments on its own and below we will see the significant ones used here:

  • index argument allows us to pass the splitter.index variable containing our timeseries index, wrapped with the class vbt.Takeable which represents an object from which a range can be taken. The vbt.Takeable method will select a slice from it and substitute the instruction with that slice.
  • set_ argument allows us to select the set_label from the splitter object, in our case either train or test
  • _random_subset argument over-rides the argument random_subset in vbt.parameterized and determines the nr. of simulations to run per split
  • merge_func argument with the value concat allows to append the result of each simulation in the train set row-wise (axis = 0)
  • execute_kwargs control the execution of each split/set/range
  • _execute_kwargs control the execution of your parameter combinations

When we use this optimal_2BB function during cross-validation by calling splitter.apply() method, we have to pass an index argument to our optimal_2BB function. We slice the dataframe using this index argument (eg: mtf_data[lower_tf]loc[index[0]:index[-1]]) inside the optimal_2BB function since we are doing vbt.Splitter.from_rolling()in this tutorial.

Generally, if the splitter produces date ranges that contain no gaps, such as in from_rolling method and most other cross-validation schemes for time-series data, we can use the first and the last date (also called "bounds") of each produced date range to select the subset of data.
For splitter versions that produce gaps, such as for k-fold cross-validation schemes (which are rarely used anyway), we must use the entire index to select the subset of data.

Performance on train splits

Below we will see the splitter.apply used on our train splits

train_perf = splitter.apply(
    apply_func = optimal_2BB, ## apply your strategy function to the splitter object, followed by its arguments
    lower_tf = vbt.Param([1, 5, 15, 30, 60, 120, 240, 720], condition = "x <= higher_tf"),
    higher_tf = vbt.Param([1, 5, 15, 30, 60, 120, 240, 720, 1440]),    
    ltf_rsi_timeperiod = vbt.Param(create_list_numbers(18, 22, 1)),
    bb_price_timeperiod = vbt.Param(create_list_numbers(18, 22, 1)),
    bb_rsi_timeperiod = vbt.Param(create_list_numbers(18, 22, 1)),
    bb_price_nbdevup = vbt.Param(create_list_numbers(1.5, 2.5, step = 0.25)),
    bb_price_nbdevdn = vbt.Param(create_list_numbers(1.5, 2.5, step = 0.25)),
    bb_rsi_nbdevup = vbt.Param(create_list_numbers(1.5, 2.5, step = 0.25)),
    bb_rsi_nbdevdn = vbt.Param(create_list_numbers(1.5, 2.5, step = 0.25)),
    output_metric = "sharpe_ratio",
    #### Arguments of splitter.apply() not related to strategy
    index =  vbt.Takeable(splitter.index), ## DataTime index from the splitter object
    set_ = "train",  ## Specify the set to be used for this CV simulation - train or test
    _random_subset = 500, ## Specify the nr. of simulations to run per train split
    merge_func = "concat", ## concat the results
    execute_kwargs=dict(show_progress=True), ## execute_kwargs control the execution of each split/set/range - Show Progress bar of the simulation
    _execute_kwargs=dict(show_progress=False, clear_cache=50, collect_garbage=50) ## _execute_kwargs control the execution of your parameter combinations
    )
    
train_perf.sort_values(ascending=False)

An argument having underscore _ as the prefix (eg: _random_subset) here means that the argument overrides the default argument value passed to the vbt.parameterized() decorator. In this case, _random_subset = 500 means we will be doing only 500 simulations for each train split (7 train splits in total), as opposed to the default value of random_subset = 1000 set in the vbt.parameterized() decorator above the optimal_2BB function definition.

VectorBT Pro - Parameter Optimisation of a Strategy
Parameter Optimization Results on train splits

Statistics on train split

train_split_describe = pd.concat([train_perf[train_perf.index.get_level_values('split') == i].describe()\
                                for i in unique_splits], axis = 1, 
                                keys = [f"Train_Split_{i}" for i in unique_splits])
train_split_describe 

Output:
VectorBT Pro - Parameter Optimisation of a Strategy

## Compute baseline, best and worst returns for the overlaid line plots
train_split_best_returns = train_split_describe.loc['max'].reset_index(drop=True)
train_split_worst_returns = train_split_describe.loc['min'].reset_index(drop=True)
train_splits_baseline_returns = pd.Series([base_line_returns[i, "train"] for i in unique_splits])

## Create Box Plot for train_performance
train_split_fig = train_perf.vbt.boxplot(
    by_level="split",
    trace_kwargs=dict(
        line=dict(color="lightskyblue"),
        opacity=0.4,
        showlegend=False
        ),
        xaxis_title="Train Splits",
        yaxis_title="Sharpe Ratio"
        )

train_split_best_returns.vbt.plot(trace_kwargs=dict(name="Best Returns", line=dict(color="limegreen", dash="dash")), fig=train_split_fig)
train_split_worst_returns.vbt.plot(trace_kwargs=dict(name="Worst Returns", line=dict(color="tomato", dash="dash")), fig=train_split_fig)
train_splits_baseline_returns.vbt.plot(trace_kwargs=dict(name="Baseline", line=dict(color="yellow", dash="dash")), fig=train_split_fig)
train_split_fig.show()
VectorBT Pro - Parameter Optimisation of a Strategy
Box Plot to view simulation statistics on train splits

Performance Statistics on test splits

test_perf = splitter.apply(
    apply_func = optimal_2BB, ## apply your strategy function to the splitter object, followed by its arguments
    lower_tf = vbt.Param([1, 5, 15, 30, 60, 120, 240, 720], condition = "x <= higher_tf"),
    higher_tf = vbt.Param([1, 5, 15, 30, 60, 120, 240, 720, 1440]),    
    ltf_rsi_timeperiod = vbt.Param(create_list_numbers(18, 22, 1)),
    bb_price_timeperiod = vbt.Param(create_list_numbers(18, 22, 1)),
    bb_rsi_timeperiod = vbt.Param(create_list_numbers(18, 22, 1)),
    bb_price_nbdevup = vbt.Param(create_list_numbers(1.5, 2.5, step = 0.25)),
    bb_price_nbdevdn = vbt.Param(create_list_numbers(1.5, 2.5, step = 0.25)),
    bb_rsi_nbdevup = vbt.Param(create_list_numbers(1.5, 2.5, step = 0.25)),
    bb_rsi_nbdevdn = vbt.Param(create_list_numbers(1.5, 2.5, step = 0.25)),
    output_metric = "sharpe_ratio",
    #### Arguments of splitter.apply() not related to strategy
    index =  vbt.Takeable(splitter.index), ## DataTime index from the splitter object
    _random_subset = 500, ## Specify the nr. of simulations to run per test split
    set_ = "test",  ## Specify the set to be used for this CV simulation - train or test
    merge_func ="concat", ## concat the results
    execute_kwargs=dict(show_progress=True), ## execute_kwargs control the execution of each split/set/range - Show Progress bar of the simulation
    _execute_kwargs=dict(show_progress=False, clear_cache=50, collect_garbage=50) ## _execute_kwargs control the execution of your parameter combinations
    )
    
test_perf.sort_values(ascending=False)
VectorBT Pro - Parameter Optimisation of a Strategy
Parameter Optimization Results on test splits

Statistics on test split

test_split_describe = pd.concat([test_perf[test_perf.index.get_level_values('split') == i].describe()\
                                for i in unique_splits], axis = 1, 
                                keys = [f"Test_Split_{i}" for i in unique_splits])
test_split_describe  

VectorBT Pro - Parameter Optimisation of a Strategy

## Compute baseline, best and worst returns for the overlaid line plots
test_split_best_returns = test_split_describe.loc['max'].reset_index(drop=True)
test_split_worst_returns = test_split_describe.loc['min'].reset_index(drop=True)
test_splits_baseline_returns = pd.Series([base_line_returns[i, "test"] for i in unique_splits])

## Create Box Plot for test_performance statistics
test_split_fig = test_perf.vbt.boxplot(
    by_level="split",
    trace_kwargs=dict(
        line=dict(color="lightskyblue"),
        opacity=0.4,
        showlegend=False
        ),
        xaxis_title="Test Splits",
        yaxis_title="Sharpe Ratio"
        )

test_split_best_returns.vbt.plot(trace_kwargs=dict(name="Best Returns", line=dict(color="limegreen", dash="dash")), fig=test_split_fig)
test_split_worst_returns.vbt.plot(trace_kwargs=dict(name="Worst Returns", line=dict(color="tomato", dash="dash")), fig=test_split_fig)
test_splits_baseline_returns.vbt.plot(trace_kwargs=dict(name="Baseline", line=dict(color="yellow", dash="dash")), fig=test_split_fig)
test_split_fig.show()
VectorBT Pro - Parameter Optimisation of a Strategy
Box Plot to view simulation statistics on test splits

BONUS - Line-by-Line Profiling of a python function

It is sometimes important to optimize your strategy function to reduce the time taken for each simulation. We can do, line by line profiling of optimal_2BB function using this %load_ext line_profiler.
When applying the line_profiler you have to remove the vbt.parameterized() decorator, and apply the line_profiler on the unwrapped raw optimal_2BB strategy function. Below is a snapshot of the results you will get from the line_profiler which you can use to optimize aspects of your strategy function which have very higher Time per hit cost.

VectorBT Pro - Parameter Optimisation of a Strategy
Line Profiler Results on optimal_2BB function
]]>
<![CDATA[VectorBT Pro - MultiAsset Data Acquisition]]>

In this tutorial, we will talk about the acquisition of M1 (1 minute) data for various forex currency pairs from dukascopy (a free data provider).The acquired data will be saved to a .hdf file for use in a VectorBT Pro Backtesting project. We will use a nodeJS package called

]]>
http://localhost:2368/multi_asset_data_acquisition/643c151fe770773c74f2e603Sun, 22 Jan 2023 17:27:21 GMTVectorBT Pro - MultiAsset Data Acquisition

In this tutorial, we will talk about the acquisition of M1 (1 minute) data for various forex currency pairs from dukascopy (a free data provider).The acquired data will be saved to a .hdf file for use in a VectorBT Pro Backtesting project. We will use a nodeJS package called Dukascopy-node to download M1 (1 minute) historical data for the following currency pairs.

You can find the installation instructions and other details for this node package here: https://github.com/Leo4815162342/dukascopy-node

Multi Asset Market Data Acquistion with DukaScopy

npx dukascopy-node -i gbpaud -p ask -from 2019-01-01 to 2022-12-31 -t m1 -v true -f csv
npx dukascopy-node -i gbpaud -p bid -from 2019-01-01 to 2022-12-31 -t m1 -v true -f csv

npx dukascopy-node -i eurgbp -p ask -from 2019-01-01 to 2022-12-31 -t m1 -v true -f csv
npx dukascopy-node -i eurgbp -p bid -from 2019-01-01 to 2022-12-31 -t m1 -v true -f csv

npx dukascopy-node -i gbpjpy -p ask -from 2019-01-01 to 2022-12-31 -t m1 -v true -f csv
npx dukascopy-node -i gbpjpy -p bid -from 2019-01-01 to 2022-12-31 -t m1 -v true -f csv

npx dukascopy-node -i usdjpy -p ask -from 2019-01-01 to 2022-12-31 -t m1 -v true -f csv
npx dukascopy-node -i usdjpy -p bid -from 2019-01-01 to 2022-12-31 -t m1 -v true -f csv

npx dukascopy-node -i usdcad -p ask -from 2019-01-01 to 2022-12-31 -t m1 -v true -f csv
npx dukascopy-node -i usdcad -p bid -from 2019-01-01 to 2022-12-31 -t m1 -v true -f csv

npx dukascopy-node -i eurusd -p ask -from 2019-01-01 to 2022-12-31 -t m1 -v true -f csv
npx dukascopy-node -i eurusd -p bid -from 2019-01-01 to 2022-12-31 -t m1 -v true -f csv

npx dukascopy-node -i audusd -p ask -from 2019-01-01 to 2022-12-31 -t m1 -v true -f csv
npx dukascopy-node -i audusd -p bid -from 2019-01-01 to 2022-12-31 -t m1 -v true -f csv

npx dukascopy-node -i gbpusd -p ask -from 2019-01-01 to 2022-12-31 -t m1 -v true -f csv
npx dukascopy-node -i gbpusd -p bid -from 2019-01-01 to 2022-12-31 -t m1 -v true -f csv

The acquired bid and ask files need to be averaged to get normalized 1 min data and finally saved into a hdf (.h5) file. The code for these processes is as follows:

def read_bid_ask_data(ask_file : str, bid_file : str, set_time_index = False) -> pd.DataFrame:
    """Reads and combines the bid & ask csv files of duksascopy historical market data, into a single OHLCV dataframe."""
    df_ask = pd.read_csv(ask_file, infer_datetime_format = True)
    df_bid = pd.read_csv(bid_file, infer_datetime_format = True)
    merged_df = pd.merge(df_bid, df_ask, on='timestamp', suffixes=('_ask', '_bid'))
    merged_df['open'] = (merged_df['open_ask'] + merged_df['open_bid']) / 2.0
    merged_df['close']= (merged_df['close_ask'] + merged_df['close_bid']) / 2.0
    merged_df['high'] = merged_df[['high_ask','high_bid']].max(axis=1)
    merged_df['low'] = merged_df[['low_ask','low_bid']].max(axis=1)
    merged_df['volume'] = merged_df['volume_bid'] + merged_df['volume_ask']    

    merged_df = merged_df[merged_df["volume"] > 0.0].reset_index()
    ## Case when we downloaded Dukascopy historical market data from node package: dukascopy-node
    merged_df['time'] = pd.to_datetime(merged_df['timestamp'], unit = 'ms')
    merged_df.drop(columns = ["timestamp"], inplace = True)

    final_cols = ['time','open','high','low','close','volume','volume_bid','volume_ask']

    if set_time_index:
        merged_df["time"] = pd.to_datetime(merged_df["time"],format='%d.%m.%Y %H:%M:%S')
        merged_df = merged_df.set_index("time")
        return merged_df[final_cols[1:]]      
    return merged_df[final_cols].reset_index(drop=True)

## Specify FileNames of Bid / Ask data downloaded from DukaScopy
bid_ask_files = {
    "GBPUSD" : {"Bid": "gbpusd-m1-bid-2019-01-01-2023-01-13.csv",
                "Ask": "gbpusd-m1-ask-2019-01-01-2023-01-13.csv"},
    "EURUSD" : {"Bid": "eurusd-m1-bid-2019-01-01-2023-01-13.csv",
                "Ask": "eurusd-m1-ask-2019-01-01-2023-01-13.csv"},
    "AUDUSD" : {"Bid": "audusd-m1-bid-2019-01-01-2023-01-13.csv",
                "Ask": "audusd-m1-ask-2019-01-01-2023-01-13.csv"},
    "USDCAD" : {"Bid": "usdcad-m1-bid-2019-01-01-2023-01-13.csv",
                "Ask": "usdcad-m1-ask-2019-01-01-2023-01-13.csv"},
    "USDJPY" : {"Bid": "usdjpy-m1-bid-2019-01-01-2023-01-13.csv",
                "Ask": "usdjpy-m1-ask-2019-01-01-2023-01-13.csv"},
    "GBPJPY" : {"Bid": "gbpjpy-m1-bid-2019-01-01-2023-01-13.csv",
                "Ask": "gbpjpy-m1-ask-2019-01-01-2023-01-13.csv"},
    "EURGBP" : {"Bid": "eurgbp-m1-bid-2019-01-01-2023-01-16.csv",
                "Ask": "eurgbp-m1-ask-2019-01-01-2023-01-16.csv"},
    "GBPAUD" : {"Bid": "gbpaud-m1-bid-2019-01-01-2023-01-16.csv",
                "Ask": "gbpaud-m1-ask-2019-01-01-2023-01-16.csv"}                                                                           
}

## Write everything into one single HDF5 file indexed by keys for the various symbols
source_folder_path = "/Users/John.Doe/Documents/Dukascopy_Historical_Data/"
output_file_path = "/Users/John.Doe/Documents/qqblog_vbt_pro_tutorials/data/MultiAsset_OHLCV_3Y_m1.h5"

for symbol in bid_ask_files.keys():
    print(f'\n{symbol}')
    ask_csv_file = source_folder_path + bid_ask_files[symbol]["Ask"]
    bid_csv_file = source_folder_path + bid_ask_files[symbol]["Bid"]
    print("ASK File PATH:",ask_csv_file,'\nBID File PATH:',bid_csv_file)
    df = read_bid_ask_data(ask_csv_file, bid_csv_file, set_time_index = True)
    df.to_hdf(output_file_path, key=symbol)

Output

GBPUSD
ASK File PATH: /Users/john.doe/Documents/Dukascopy_Historical_Data/gbpusd-m1-ask-2019-01-01-2023-01-13.csv 
BID File PATH: /Users/john.doe/Documents/Dukascopy_Historical_Data/gbpusd-m1-bid-2019-01-01-2023-01-13.csv

EURUSD
ASK File PATH: /Users/john.doe/Documents/Dukascopy_Historical_Data/eurusd-m1-ask-2019-01-01-2023-01-13.csv 
BID File PATH: /Users/john.doe/Documents/Dukascopy_Historical_Data/eurusd-m1-bid-2019-01-01-2023-01-13.csv

AUDUSD
ASK File PATH: /Users/john.doe/Documents/Dukascopy_Historical_Data/audusd-m1-ask-2019-01-01-2023-01-13.csv 
BID File PATH: /Users/john.doe/Documents/Dukascopy_Historical_Data/audusd-m1-bid-2019-01-01-2023-01-13.csv

USDCAD
ASK File PATH: /Users/john.doe/Documents/Dukascopy_Historical_Data/usdcad-m1-ask-2019-01-01-2023-01-13.csv 
BID File PATH: /Users/john.doe/Documents/Dukascopy_Historical_Data/usdcad-m1-bid-2019-01-01-2023-01-13.csv

USDJPY
ASK File PATH: /Users/john.doe/Documents/Dukascopy_Historical_Data/usdjpy-m1-ask-2019-01-01-2023-01-13.csv 
BID File PATH: /Users/john.doe/Documents/Dukascopy_Historical_Data/usdjpy-m1-bid-2019-01-01-2023-01-13.csv

GBPJPY
ASK File PATH: /Users/john.doe/Documents/Dukascopy_Historical_Data/gbpjpy-m1-ask-2019-01-01-2023-01-13.csv 
BID File PATH: /Users/john.doe/Documents/Dukascopy_Historical_Data/gbpjpy-m1-bid-2019-01-01-2023-01-13.csv

EURGBP
ASK File PATH: /Users/john.doe/Documents/Dukascopy_Historical_Data/eurgbp-m1-ask-2019-01-01-2023-01-16.csv 
BID File PATH: /Users/john.doe/Documents/Dukascopy_Historical_Data/eurgbp-m1-bid-2019-01-01-2023-01-16.csv

GBPAUD
ASK File PATH: /Users/john.doe/Documents/Dukascopy_Historical_Data/gbpaud-m1-ask-2019-01-01-2023-01-16.csv 
BID File PATH: /Users/john.doe/Documents/Dukascopy_Historical_Data/gbpaud-m1-bid-2019-01-01-2023-01-16.csv
💡
Note: The free M1 data, provided by Dukascopy has some missing data and one needs to validate the data quality by comparing it with other preferable paid data sources.

Binance Crypto Data

For the crypto fans VectorBT directly provides a wrapper to fetch data from Binance

## Acquire multi-asset 1m crypto data from Binance

data = vbt.BinanceData.fetch(
    ["BTCUSDT", "ETHUSDT", "BNBUSDT", "XRPUSDT", "ADAUSDT"], 
    start="2019-01-01 UTC", 
    end="2022-12-01 UTC",
    timeframe="1m"
    )

## Save acquired data locally for persistance
data.to_hdf("/Users/john.doe/Documents/vbtpro_tuts_private/data/Binance_MultiAsset_OHLCV_3Y_m1.h5")
]]>
<![CDATA[VectorBT Pro - MultiAsset Portfolio Simulation]]>

In this tutorial, we will talk about verious topics pertaining to Multi Asset Portfolio Simulation, beginning with

  • Converting various forex (FX) pairs to the account currency, if the quote currency of the currency pair is not the same as the account currency.
  • Running different types of backtesting simulations like grouped
]]>
http://localhost:2368/multi_asset_portfolio_simulation/643c151fe770773c74f2e602Sun, 22 Jan 2023 16:50:48 GMTVectorBT Pro - MultiAsset Portfolio Simulation

In this tutorial, we will talk about verious topics pertaining to Multi Asset Portfolio Simulation, beginning with

  • Converting various forex (FX) pairs to the account currency, if the quote currency of the currency pair is not the same as the account currency.
  • Running different types of backtesting simulations like grouped, unified and discrete using vbt.Portfolio.from_signals() , and
  • Finally, exporting data to .pickle files for plotting and visualizing in an interactive plotly dashboard.

Before proceeding further, you would want to read this short helper tutorial about multi-asset data acquisition which explains how we created the below MultiAsset_OHLCV_3Y_m1.h5 file, which we load into hdf_data

Here, we will run the Double Bollinger Band Strategy from our earlier tutorial on multiple assets. But before we do that, we have to bring the quote value of all our forex currency pairs to the account currency (USD).

## Import Libraries
import numpy as np
import pandas as pd
import vectorbtpro as vbt

## Forex Data
hdf_data = vbt.HDFData.fetch('/Users/dilip.rajkumar/Documents/vbtpro_tuts_private/data/MultiAsset_OHLCV_3Y_m1.h5') 
symbols = hdf_data.symbols
print('Multi-Asset DataFrame Symbols:',symbols)

Output

Multi-Asset DataFrame Symbols: ['AUDUSD', 'EURGBP', 'EURUSD', 'GBPAUD', 'GBPJPY', 'GBPUSD', 'USDCAD', 'USDJPY']

Convert FX pairs where quote_currency != account currency ( US$ )

We will be converting OHLC price columns for the following currency pairs to the account currency (USD), as in these pairs either the quote currency or both the base currency & quote currency are not the same as the account currency which in our requirement is USD.

price_cols = ["Open", "High", "Low", "Close"]
symbols_to_convert = ["USDJPY", "USDCAD", "GBPJPY", "EURGBP", "GBPAUD"]

For this currency conversion of price data, we will use this convert_to_account_currency function, which handles the following scenarios, where the quote currency is not the same as account currency:

1.) Base currency == Account currency :

In this case, we simply inverse the price of the instrument. For eg: in the case of USDJPY the quote currency is JPY, but the base currency is the same as the account currency (USD). So in order to get the price of USDJPY in USD all we have to do is compute 1 / USDJPY.

2.) Both (Base Currency & Quote Currency ) != Account Currency :

This scenario occurs when we basically don't see the account currency characters in the source forex currency pair symbol (Eg: GBPJPY) and in order to convert this kind of currency pair to the account currency we require a bridge currency pair. Now depending on how the bridge pair symbol is presented in the market data provided by the exchange, we would be either dividing or multiplying the source currency pair by the bridge pair. For eg:

a.) In the case of converting GBPJPY to USD, we would be dividing GBPJPY / USDJPY

b.) In the case of converting GBPAUD to USD, the exchange typically provides the bridge currency pair data required as AUDUSD and not USDAUD and so in this case, we would be multiplying GBPAUD * AUDUSD.

def convert_to_account_currency(price_data : pd.Series, account_currency : str = "USD",
                                bridge_pair_price_data: pd.Series = None) -> pd.Series:
    """
    Convert prices of different FX pairs to account currency.

    Parameters
    ==========
    price_data      :   pd.Series, Price data from (OHLC) columns of the pair to be converted
    account_currency:   str, default = 'USD'
    bridge_pair_price_data: pd.Series, price data to be used when neither,
                            the base or quote currency is = account currency
    
    Returns
    =======
    new_instrument_price : pd.Series, converted price data

    """
    symbol = price_data.name
    base_currency  = symbol[0:3].upper()
    quote_currency = symbol[3:6].upper() ## a.k.a Counter_currency

    if base_currency == account_currency: ## case 1  - Eg: USDJPY
        print(f"BaseCurrency: {base_currency} is same as AccountCurrency: {account_currency} for Symbol:- {symbol}."+ \
              "Performing price inversion")
        new_instrument_price = (1/price_data)

    elif (quote_currency != account_currency) and (base_currency != account_currency): ## Case 2 - Eg: GBPJPY  
        bridge_pair_symbol =  account_currency + quote_currency  ## Bridge Pair symbol is : USDJPY
        print(f"Applying currency conversion for {symbol} with {bridge_pair_symbol} price data")
        if (bridge_pair_price_data is None):
            raise Exception(f"Price data for {bridge_pair_symbol} is missing. Please provide the same")
        elif (bridge_pair_symbol != bridge_pair_price_data.name.upper()):
            message = f"Mismatched data. Price data for {bridge_pair_symbol} is expected, but" + \
                      f"{bridge_pair_price_data.name.upper()} price data is provided"
            print(message) ## Eg: When AUDUSD is required, but instead USDAUD is provided
            new_instrument_price = price_data * bridge_pair_price_data
            # raise Exception(message)
        else:
            new_instrument_price = price_data/ bridge_pair_price_data ## Divide GBPJPY / USDJPY
    
    else:
        print(f"No currency conversion needed for {symbol} as QuoteCurreny: {quote_currency} == Account Currency")
        new_instrument_price = price_data
    return new_instrument_price

We copy the data from the original hdf_data file and store them in a dictionary of dataframes. For symbols whose price columns are to be converted we create an empty pd.DataFrame which we will be filling with the converted price values

new_data = {}
for symbol, df in hdf_data.data.items():
    if symbol in symbols_to_convert: ## symbols whose price columns needs to be converted to account currency
        new_data[symbol] = pd.DataFrame(columns=['Open','High','Low','Close','Volume'])
    else: ## for other symbols store the data as it is
        new_data[symbol] = df

Here we call our convert_to_account_currency() function to convert the price data to account cuurency. For pairs like USDJPY and USDCAD a simple price inversion (Eg: 1 / USDJPY ) alone is sufficient, so for these cases we will be setting bridge_pair == None.

bridge_pairs = [None, None, "USDJPY", "GBPUSD", "AUDUSD"]

for ticker_source, ticker_bridge  in zip(symbols_to_convert, bridge_pairs):
    new_data[ticker_source]["Volume"] = hdf_data.get("Volume")[ticker_source]
    for col in price_cols:
        print("Source Symbol:", ticker_source, "|| Bridge Pair:", ticker_bridge, "|| Column:", col)
        new_data[ticker_source][col] = convert_to_account_currency( 
                            price_data =  hdf_data.get(col)[ticker_source],
                            bridge_pair_price_data = None  if ticker_bridge is None else hdf_data.get(col)[ticker_bridge]
                            )

Ensuring Correct data for High and Low columns

Once we have the converted OHLC price columns for a particular symbol (ticker_source), we recalculate the High and Low by getting the max and min of each row in the OHLC columns respectively using df.max(axis=1) and df.min(axis=1)

## Converts this `new_data` (dict of dataframes) into a vbt.Data object
m1_data = vbt.Data.from_data(new_data)    

for ticker_source in symbols:
    m1_data.data[ticker_source]['High'] = m1_data.data[ticker_source][price_cols].max(axis=1)
    m1_data.data[ticker_source]['Low'] = m1_data.data[ticker_source][price_cols].min(axis=1)

What need is there for above step?

Lets assume for a symbol X if low is 10 and high is 20, then when we do a simple price inversion ( 1/X ) new high would become 1/10 = 0.1 and new low would become 1/20 = 0.05 which will result in complications and thus arises the need for the above step

## Sanity check to see if empty pd.DataFrame got filled now
m1_data.data['EURGBP'].dropna()
VectorBT Pro - MultiAsset Portfolio Simulation
M1 data of EURGBP after dropping NaN rows

Double Bollinger Band Strategy over Multi-Asset portfolio

The following steps are very similar we already saw in the Alignment and Resampling and Strategy Development tutorials, except now they are applied over multiple symbols (assets) in a portfolio. So I will just put the code here and won't be explaining anything here in detail, when in doubt refer back to the above two tutorials.

m15_data = m1_data.resample('15T')  # Convert 1 minute to 15 mins
h1_data = m1_data.resample("1h")    # Convert 1 minute to 1 hour
h4_data = m1_data.resample('4h')    # Convert 1 minute to 4 hour

# Obtain all the required prices using the .get() method
m15_close = m15_data.get('Close')

## h1 data
h1_open  = h1_data.get('Open')
h1_close = h1_data.get('Close')
h1_high  = h1_data.get('High')
h1_low   = h1_data.get('Low')

## h4 data
h4_open  = h4_data.get('Open')
h4_close = h4_data.get('Close')
h4_high  = h4_data.get('High')
h4_low   = h4_data.get('Low')

### Create (manually) the indicators for Multi-Time Frames
rsi_period = 21

## 15m indicators
m15_rsi = vbt.talib("RSI", timeperiod = rsi_period).run(m15_close, skipna=True).real.ffill()
m15_bbands = vbt.talib("BBANDS").run(m15_close, skipna=True)
m15_bbands_rsi = vbt.talib("BBANDS").run(m15_rsi, skipna=True)

## h1 indicators
h1_rsi = vbt.talib("RSI", timeperiod = rsi_period).run(h1_close, skipna=True).real.ffill()
h1_bbands = vbt.talib("BBANDS").run(h1_close, skipna=True)
h1_bbands_rsi = vbt.talib("BBANDS").run(h1_rsi, skipna=True)

## h4 indicators
h4_rsi = vbt.talib("RSI", timeperiod = rsi_period).run(h4_close, skipna=True).real.ffill()
h4_bbands = vbt.talib("BBANDS").run(h4_close, skipna=True)
h4_bbands_rsi = vbt.talib("BBANDS").run(h4_rsi, skipna=True)
def create_resamplers(result_dict_keys_list : list, source_indices : list,  
                      source_frequencies :list, target_index : pd.Series, target_freq : str):
    """
    Creates a dictionary of vbtpro resampler objects.

    Parameters
    ==========
    result_dict_keys_list : list, list of strings, which are keys of the output dictionary
    source_indices        : list, list of pd.time series objects of the higher timeframes
    source_frequencies    : list(str), which are short form representation of time series order. Eg:["1D", "4h"]
    target_index          : pd.Series, target time series for the resampler objects
    target_freq           : str, target time frequency for the resampler objects

    Returns
    ===========
    resamplers_dict       : dict, vbt pro resampler objects
    """
    
    
    resamplers = []
    for si, sf in zip(source_indices, source_frequencies):
        resamplers.append(vbt.Resampler(source_index = si,  target_index = target_index,
                                        source_freq = sf, target_freq = target_freq))
    return dict(zip(result_dict_keys_list, resamplers))
## Initialize  dictionary
mtf_data = {}

col_values = [
    m15_close, m15_rsi, m15_bbands.upperband, m15_bbands.middleband, m15_bbands.lowerband, 
    m15_bbands_rsi.upperband, m15_bbands_rsi.middleband, m15_bbands_rsi.lowerband
    ]

col_keys = [
    "m15_close", "m15_rsi", "m15_bband_price_upper",  "m15_bband_price_middle", "m15_bband_price_lower", 
    "m15_bband_rsi_upper",  "m15_bband_rsi_middle", "m15_bband_rsi_lower"
         ]

# Assign key, value pairs for method of time series data to store in data dict
for key, time_series in zip(col_keys, col_values):
    mtf_data[key] = time_series.ffill()

## Create Resampler Objects for upsampling
src_indices = [h1_close.index, h4_close.index]
src_frequencies = ["1H","4H"] 
resampler_dict_keys = ["h1_m15","h4_m15"]

list_resamplers = create_resamplers(resampler_dict_keys, src_indices, src_frequencies, m15_close.index, "15T")

## Use along with  Manual indicator creation method for MTF
series_to_resample = [
    [h1_open, h1_high, h1_low, h1_close, h1_rsi, h1_bbands.upperband, h1_bbands.middleband, h1_bbands.lowerband,
     h1_bbands_rsi.upperband, h1_bbands_rsi.middleband, h1_bbands_rsi.lowerband], 
    [h4_high, h4_low, h4_close, h4_rsi, h4_bbands.upperband, h4_bbands.middleband, h4_bbands.lowerband, 
    h4_bbands_rsi.upperband, h4_bbands_rsi.middleband, h4_bbands_rsi.lowerband]
    ]


data_keys = [
    ["h1_open","h1_high", "h1_low", "h1_close", "h1_rsi", "h1_bband_price_upper",  "h1_bband_price_middle",  "h1_bband_price_lower", 
     "h1_bband_rsi_upper",  "h1_bband_rsi_middle", "h1_bband_rsi_lower"],
    ["h4_open","h4_high", "h4_low", "h4_close", "h4_rsi", "h4_bband_price_upper",  "h4_bband_price_middle",  "h4_bband_price_lower", 
     "h4_bband_rsi_upper",  "h4_bband_rsi_middle", "h4_bband_rsi_lower"]
         ]

for lst_series, lst_keys, resampler in zip(series_to_resample, data_keys, resampler_dict_keys):
    for key, time_series in zip(lst_keys, lst_series):
        if key.lower().endswith('open'):
            print(f'Resampling {key} differently using vbt.resample_opening using "{resampler}" resampler')
            resampled_time_series = time_series.vbt.resample_opening(list_resamplers[resampler])
        else:
            resampled_time_series = time_series.vbt.resample_closing(list_resamplers[resampler])
        mtf_data[key] = resampled_time_series

cols_order = ['m15_close', 'm15_rsi', 'm15_bband_price_upper','m15_bband_price_middle', 'm15_bband_price_lower',
              'm15_bband_rsi_upper','m15_bband_rsi_middle', 'm15_bband_rsi_lower',
              'h1_open', 'h1_high', 'h1_low', 'h1_close', 'h1_rsi',
              'h1_bband_price_upper', 'h1_bband_price_middle', 'h1_bband_price_lower', 
              'h1_bband_rsi_upper', 'h1_bband_rsi_middle', 'h1_bband_rsi_lower',              
              'h4_open', 'h4_high', 'h4_low', 'h4_close', 'h4_rsi',
              'h4_bband_price_upper', 'h4_bband_price_middle', 'h4_bband_price_lower', 
              'h4_bband_rsi_upper', 'h4_bband_rsi_middle', 'h4_bband_rsi_lower'
              ]                 

Double Bollinger Band - Strategy Conditions

required_cols = ['m15_close','m15_rsi','m15_bband_rsi_lower', 'm15_bband_rsi_upper',
                 'h4_low', "h4_rsi", "h4_bband_price_lower", "h4_bband_price_upper" ]

## Higher values greater than 1.0 are like moving up the lower RSI b-band, 
## signifying if the lowerband rsi is anywhere around 1% of the lower b-band validate that case as True
bb_upper_fract = 0.99
bb_lower_fract = 1.01

## Long Entry Conditions
# c1_long_entry = (mtf_data['h1_low'] <= mtf_data['h1_bband_price_lower'])
c1_long_entry = (mtf_data['h4_low'] <= mtf_data['h4_bband_price_lower'])
c2_long_entry = (mtf_data['m15_rsi'] <= (bb_lower_fract * mtf_data['m15_bband_rsi_lower']) )


## Long Exit Conditions
# c1_long_exit =  (mtf_data['h1_high'] >= mtf_data['h1_bband_price_upper'])
c1_long_exit = (mtf_data['h4_high'] >= mtf_data['h4_bband_price_upper'])
c2_long_exit = (mtf_data['m15_rsi'] >= (bb_upper_fract * mtf_data['m15_bband_rsi_upper']))       

## Strategy conditions check - Using m15 and h4 data 
mtf_data['entries'] = c1_long_entry & c2_long_entry
mtf_data['exits']  = c1_long_exit & c2_long_exit

mtf_data['signal'] = 0   
mtf_data['signal'] = np.where( mtf_data['entries'], 1, 0)
mtf_data['signal'] = np.where( mtf_data['exits'] , -1, mtf_data['signal'])

After the above np.where, we can use this pd.df.where to return a pandas object

mtf_data['signal'] = mtf_data['entries'].vbt.wrapper.wrap(mtf_data['signal'])
mtf_data['signal'] = mtf_data['exits'].vbt.wrapper.wrap(mtf_data['signal'])
print(mtf_data['signal'])
VectorBT Pro - MultiAsset Portfolio Simulation
Signal Column for Multiple Forex Pair Symbols

Cleaning and Resampling entries and exits

entries = mtf_data['signal'] == 1.0
exits = mtf_data['signal'] == -1.0

## Clean redundant and duplicate signals
clean_entries, clean_exits = entries.vbt.signals.clean(exits)
print(f"Total nr. of Signals in Clean_Entries and Clean_Exits")
pd.DataFrame(data = {"Entries":clean_entries.vbt.signals.total(),
                    "Exits": clean_exits.vbt.signals.total()})
VectorBT Pro - MultiAsset Portfolio Simulation
Symbol-wise Number of Entries and Exits on M15 timeframe

We can resample the entries and exits for plotting purposes on H4 chart, but this always produces some loss in the nr. of signals as the entries / exits in our strategy is based on M15 timeframe. So just be aware of this.

## Resample clean entries to H4 timeframe
clean_h4_entries = clean_entries.vbt.resample_apply("4h", "any", wrap_kwargs=dict(dtype=bool))
clean_h4_exits = clean_exits.vbt.resample_apply("4h", "any", wrap_kwargs=dict(dtype=bool))

print(f"Total nr. of H4_Entry Signals:\n {clean_h4_entries.vbt.signals.total()}\n")
print(f"Total nr. of H4_Exit Signals:\n {clean_h4_exits.vbt.signals.total()}")
VectorBT Pro - MultiAsset Portfolio Simulation
Symbol-wise Number of Entries and Exits on H4 timeframe

Saving Data to .pickle file

For the purposes of plotting, we will be saving various data like:

  • price data across various timeframes
  • indicator data across various timeframes
  • entries & exits
  • finally, the vectorbt.portfolio objects after running each type of portfolio simulation
## Save Specific Data to pickle file for plotting purposes
price_data = {"h4_data": h4_data, "m15_data" : m15_data}
vbt_indicators = {'m15_rsi': m15_rsi,'m15_price_bbands': m15_bbands, 'm15_rsi_bbands' : m15_bbands_rsi,
                  'h4_rsi': h4_rsi, 'h4_price_bbands':h4_bbands, 'h4_rsi_bbands' : h4_bbands_rsi}

entries_exits_data = {'clean_entries' : clean_entries, 'clean_exits' : clean_exits}

print(type(h4_data), '||' ,type(m15_data))
print(type(h4_bbands), '||', type(h4_bbands_rsi), '||', type(h1_rsi))
print(type(m15_bbands), '||', type(m15_bbands_rsi), '||', type(m15_rsi))

file_path1 = '../vbt_dashboard/data/price_data'
file_path2 = '../vbt_dashboard/data/indicators_data'
file_path3 = '../vbt_dashboard/data/entries_exits_data'


vbt.save(price_data, file_path1)
vbt.save(vbt_indicators, file_path2)
vbt.save(entries_exits_data, file_path3)

Multi-asset Portfolio Backtesting simulation using vbt.Portfolio.from_signals()

In this section, we will see different ways to run this portfolio.from_signals() simulation and save the results as .pickle files to be used in a plotly-dash data visualization dashboard later (in another tutorial).

1.) Asset-wise Discrete Portfolio Simulation

In this section we will see how to run the portfolio simulation for each asset in the portfolio independently. If we start with the default from_signals() function as we had from the previous tutorial, the simulation is run for each symbol independently, which means the account balance is not connected between the various trades executed across symbols

pf_from_signals_v1 = vbt.Portfolio.from_signals(
    close = mtf_data['m15_close'], 
    entries = mtf_data['entries'], 
    exits = mtf_data['exits'], 
    direction = "both", ## This setting trades both long and short signals
    freq = pd.Timedelta(minutes=15), 
    init_cash = 100000
)

## Save portfolio simulation as a pickle file
pf_from_signals_v1.save("../vbt_dashboard/data/pf_sim_discrete")

## Load saved portfolio simulation from pickle file
pf = vbt.Portfolio.load('../vbt_dashboard/data/pf_sim_discrete')

## View Trading History of pf.simulation 
pf_trade_history = pf.trade_history
print("Unique Symbols:", list(pf_trade_history['Column'].unique()) )
pf_trade_history
VectorBT Pro - MultiAsset Portfolio Simulation
Trade History for Portfolio Simulation Object

We can view the portfolio simulation statistics as a dataframe by running the following code snippet

## View Portfolio Stats as a dataframe for pf_from_signals_v1 case
## pd.concat() operation concates the stats information acosss all assets
stats_df = pd.concat([pf.stats()] + [pf[symbol].stats() for symbol in symbols], axis = 1)
## Remove microsend level granularity information in TimeDelta Object
stats_df.loc['Avg Winning Trade Duration'] = [x.floor('s') for x in stats_df.iloc[21]] 
stats_df.loc['Avg Losing Trade Duration'] = [x.floor('s') for x in stats_df.iloc[22]]
stats_df = stats_df.reset_index() 
stats_df.rename(inplace = True, columns = {'agg_stats':'Agg_Stats', 'index' : 'Metrics' })  
stats_df
VectorBT Pro - MultiAsset Portfolio Simulation
Symbol-wise Simulation Statistics 

The Agg_Stats column is basically the metrics aggregated across the various symbols which you can validate by running the following code and comparing the output with the above dataframe print out

print("Mean Total Return [%] (across cols):", np.round(np.mean(stats_df.iloc[[7]].values.tolist()[0][1:]), 4) )
print("Mean Total Orders (across cols):", np.round(np.mean(stats_df.iloc[[13]].values.tolist()[0][1:]), 4) )
print("Mean Sortino Ratio (across cols):", np.round(np.mean(stats_df.iloc[[28]].values.tolist()[0][1:]), 4) )

Output:

Mean Total Return [%] (across cols): 0.3675
Mean Total Orders (across cols): 479.125
Mean Sortino Ratio (across cols): 0.1084

Description of a few Parameter settings for pf.from_signals()

We will see a short description of the new parameters of vbt.Portfolio.from_signals() function which we will be using henceforth in the rest of this tutorial.
But I would like to point out that the from_signals() function in VectorBT Pro is very exhaustive in its capabilities and feature set, thus it is beyond the scope of this blog post to cover every parameter of this function along with multitude of settings. So please refer the documentation for this.


a.) size : Specifies the position size in units. For any fixed size, you can set to any number to buy/sell some fixed amount or value. For any target size, you can set to any number to buy/sell an amount relative to the current position or value. If you set this to np.nan or 0 it will get skipped (or close the current position in the case of setting 0 for any target size). Set to np.inf to buy for all cash, or -np.inf to sell for all free cash. A point to remember setting to np.inf may cause the scenario for the portfolio simulation to become heavily weighted to one single instrument. So use a sensible size related.


b.) init_cash : Initial capital per column (or per group with cash sharing). By setting it to auto the initial capital is automatically decided based on the position size you specify in the above size parameter.


c.) cash_sharing : Accepts a boolean (True or False) value to specify whether cash sharing is to be disabled or if enabled then cash is shared across all the assets in the portfolio or cash is shared within the same group.
If group_by is None and cash_sharing is True, group_by becomes True to form a single group with cash sharing. Example:
Consider three columns (3 assets), each having $100 of starting capital. If we built one group of two columns and one group of one column, the init_cash would be np.array([200, 100]) with cash sharing enabled and np.array([100, 100, 100]) without cash sharing.

d.) call_seq : Default sequence of calls per row and group. Controls the sequence in which order_func_nb is executed within each segment. For more details of this function kindly refer the documentation.

e.) group_by : can be boolean, integer, string, or sequence to call multi-level indexing and can accept both level names and level positions. In this tutorial I will be setting group_by = True to treat the entire portfolio simulation in a unified manner for all assets in congruence with cash_sharing = True. When I want to create custom groups with specific symbols in each group then I will be setting group_by = 0 to specify the level position (in multi-index levels) as the first in the hierarchy.

2.) Unified Portfolio Simulation

In this section, we run the portfolio simulation treating the entire portfolio as a singular asset by enabling the following parameters in the pf.from_signals():

  • cash_sharing = True
  • group_by = True
  • call_seq = "auto"
  • size = 100000
pf_from_signals_v2 = vbt.Portfolio.from_signals(
    close = mtf_data['m15_close'], 
    entries = mtf_data['entries'], 
    exits = mtf_data['exits'],    
    direction = "both", ## This setting trades both long and short signals
    freq = pd.Timedelta(minutes=15), 
    init_cash = "auto",
    size = 100000,
    group_by = True,
    cash_sharing = True,
    call_seq = "auto"
)

## Save portfolio simulation as a pickle file
pf_from_signals_v2.save("../vbt_dashboard/data/pf_sim_single")

## Load portfolio simulation from pickle file
pf = vbt.Portfolio.load('../vbt_dashboard/data/pf_sim_single')
pf.stats()

Now in this case since the entire portfolio is simulated in a unified manner for all symbols with cash sharing set to True, we get only one pd.Series object for the portfolio simulation stats.

Output

Start                         2019-01-01 22:00:00+00:00
End                           2023-01-16 06:45:00+00:00
Period                               1475 days 09:00:00
Start Value                               781099.026861
Min Value                                  751459.25085
Max Value                                 808290.908182
End Value                                 778580.017067
Total Return [%]                              -0.322496
Benchmark Return [%]                           0.055682
Total Time Exposure [%]                       99.883504
Max Gross Exposure [%]                        99.851773
Max Drawdown [%]                               4.745308
Max Drawdown Duration                 740 days 04:15:00
Total Orders                                       3833
Total Fees Paid                                     0.0
Total Trades                                       3833
Win Rate [%]                                  63.006536
Best Trade [%]                                  4.06637
Worst Trade [%]                               -7.984396
Avg Winning Trade [%]                          0.200416
Avg Losing Trade [%]                          -0.336912
Avg Winning Trade Duration    1 days 12:07:11.327800829
Avg Losing Trade Duration     3 days 22:03:11.201716738
Profit Factor                                  1.007163
Expectancy                                     0.858292
...
Sharpe Ratio                                  -0.015093
Calmar Ratio                                  -0.016834
Omega Ratio                                    0.999318
Sortino Ratio                                 -0.021298
Name: group, dtype: object

3.) Grouped Portfolio Simulation

In this section, we run the portfolio simulation by combining the 8 currency pairs into two groups USDPairs and NonUSDPairs respectively, along with the following parameter settings in the pf.from_signals():

  • cash_sharing = True
  • group_by = True
  • call_seq = "auto"
  • size = 100000
print("Symbols:",list(pf_from_signals_v2.wrapper.columns))
grp_type = ['USDPairs', 'NonUSDPairs', 'USDPairs', 'NonUSDPairs', 'NonUSDPairs', 'USDPairs', 'USDPairs', 'USDPairs']
unique_grp_types = list(set(grp_type))
print("Group Types:", grp_type)
print("Nr. of Unique Groups:", unique_grp_types)

Output:

Symbols: ['AUDUSD', 'EURGBP', 'EURUSD', 'GBPAUD', 'GBPJPY', 'GBPUSD', 'USDCAD', 'USDJPY']
Group Types: ['USDPairs', 'NonUSDPairs', 'USDPairs', 'NonUSDPairs', 'NonUSDPairs', 'USDPairs', 'USDPairs', 'USDPairs']
Nr. of Unique Groups: ['USDPairs', 'NonUSDPairs']

VectorBT expects the group labels to be in a monolithic, sorted array, that is our group must be in a monolithic sorted order like:
[USDPairs, USDPairs, USDPairs, USDPairs, USDPairs, NonUSDPairs, NonUSDPairs, NonUSDPairs] not a random order like:
['USDPairs', 'NonUSDPairs', 'USDPairs', 'NonUSDPairs', 'NonUSDPairs', 'USDPairs', 'USDPairs', 'USDPairs']. So
we create a small method reorder_columns that takes a pandas object and reorders it by sorting columns levels by the level you want to group-by, as a way of preparing the dataframe before it's getting passed to the from_signals() method..

def reorder_columns(df, group_by):
    return df.vbt.stack_index(group_by).sort_index(axis=1, level=0)

Thereafter, we pass group_by=0 (first level) to the pf.from_signals() method before we're running the simulation, since we appended grp_type list of level names, as the top-most level to the columns of each dataframe, thus making it the first in the hierarchy.

pf_from_signals_v3 = vbt.Portfolio.from_signals(
    close = reorder_columns(mtf_data["m15_close"], group_by = grp_type),
    entries = reorder_columns(mtf_data['entries'], group_by = grp_type),
    exits = reorder_columns(mtf_data['exits'], group_by = grp_type),
    direction = "both", ## This setting trades both long and short signals
    freq = pd.Timedelta(minutes=15), 
    init_cash = "auto",
    size = 100000,
    group_by = 0,
    cash_sharing=True,
    call_seq="auto"
)

## Save portfolio simulation as a pickle file
pf_from_signals_v3.save("../vbt_dashboard/data/pf_sim_grouped")

## Load portfolio simulation from a pickle file
pf = vbt.Portfolio.load('../vbt_dashboard/data/pf_sim_grouped')

## View Trading History of pf.simulation 
pf_trade_history = pf.trade_history
print("Unique Symbols:", list(pf_trade_history['Column'].unique()) )
pf_trade_history

Output:

Unique Symbols: [('NonUSDPairs', 'EURGBP'), ('NonUSDPairs', 'GBPAUD'), ('NonUSDPairs', 'GBPJPY'), ('USDPairs', 'AUDUSD'), ('USDPairs', 'EURUSD'), ('USDPairs', 'GBPUSD'), ('USDPairs', 'USDCAD'), ('USDPairs', 'USDJPY')]
VectorBT Pro - MultiAsset Portfolio Simulation
Trading History for Grouped Portfolio Simulation
# For pf_from_signals_v3 case
stats_df = pd.concat([pf[grp].stats() for grp in unique_grp_types], axis = 1) 
stats_df.loc['Avg Winning Trade Duration'] = [x.floor('s') for x in stats_df.iloc[21]]
stats_df.loc['Avg Losing Trade Duration'] = [x.floor('s') for x in stats_df.iloc[22]]
stats_df = stats_df.reset_index() 
stats_df.rename(inplace = True, columns = {'agg_stats':'Agg_Stats', 'index' : 'Metrics' })  
stats_df
VectorBT Pro - MultiAsset Portfolio Simulation
Group-wise Statistics for Grouped Portfolio Simulation

This concludes the tutorial for multi-asset portfolio simulation. I hope this is useful in your backtesting studies and workflow. If there are any issues or fixes, please leave a git issue in the link below to the jupyter notebook.

]]>
<![CDATA[VectorBT Pro - Custom Dashboard for Portfolio Simulation and Strategy Visualisation]]>

In this tutorial, we will see how to create a customized dashboard using dash and plotly to visualize the portfolio simulation and strategy development in separate tabs.

To generate the data for this tutorial, you need to follow the steps in the Multi-Asset Portfolio Simulation tutorial OR, for quick reference,

]]>
http://localhost:2368/vbt_dashboard/643c151fe770773c74f2e601Sun, 22 Jan 2023 15:55:43 GMTVectorBT Pro - Custom Dashboard for Portfolio Simulation and Strategy Visualisation

In this tutorial, we will see how to create a customized dashboard using dash and plotly to visualize the portfolio simulation and strategy development in separate tabs.

To generate the data for this tutorial, you need to follow the steps in the Multi-Asset Portfolio Simulation tutorial OR, for quick reference, you can look up this ReadMe file.

Please watch this YouTube video below that explains the details about this dashboard

Custom plotly dashboard for vectorBT Portfolio Simulation and Strategy Visualisation

To try out this dashboard at your end, please checkout the Git Repo link below. Feel free to contribute (by forking and creating a pull request) to this dashboard if you want to add more features or share your vectorBT study with the community.

]]>
<![CDATA[VectorBT Pro - Plotting Indicators and Visualising Strategy with Cleaned Signals]]>

In this blog we will see how to visualize our Double Bollinger Band strategy along with the indicators and the cleaned entries/exits from the simulation. You will master your VectorBT Pro plotting skills by creating your own plot_strategy() function and learn how to go from a basic plot

]]>
http://localhost:2368/vbt_plot_strategy/643c151fe770773c74f2e600Wed, 30 Nov 2022 17:49:00 GMT

In this blog we will see how to visualize our Double Bollinger Band strategy along with the indicators and the cleaned entries/exits from the simulation. You will master your VectorBT Pro plotting skills by creating your own plot_strategy() function and learn how to go from a basic plot like this ⬇

VectorBT Pro - Plotting Indicators and Visualising Strategy with Cleaned Signals

TO this Fancy Advanced Plot 🎉 😎 with stacked figures, entries/exits and layered indicators

VectorBT Pro - Plotting Indicators and Visualising Strategy with Cleaned Signals

Plotting - Basics 👶

As before we will start with the global settings we will use everywhere for our plotting like this dark theme and figure / width.

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

OHLCV Plot

The code to get a basic OHLCV plot is shown below, to get custom plot titles and other attributes you pass it in the kwargs. To make some sensible visualization and not get a super condensed plot we will use .iloc to slice a small sample of the dataframe.

## Plot OHLCV data first
kwargs1 = {"title_text" : "OHLCV Plot", "title_font_size" : 18}
h4_ohlc_sample = h4_df[["Open", "High", "Low", "Close"]].iloc[100:200]#.dropna()
f = h4_ohlc_sample.vbt.ohlcv.plot(**kwargs1)
f.show()

VectorBT Pro - Plotting Indicators and Visualising Strategy with Cleaned Signals

This gives the plot you saw earlier, and yes we see some ugly gaps in the candlestick data, which we will see how to fix later. Let's try to add the Bollinger Bands indicator on top of this basic candlestick plot

OHLCV Plot with Bollinger Bands

h4_bbands.iloc[100:200].plot(fig = f,
                            lowerband_trace_kwargs=dict(fill=None, name = 'BB_Price_Lower'), 
                            upperband_trace_kwargs=dict(fill=None, name = 'BB_Price_Upper'),
                            middleband_trace_kwargs=dict(fill=None, name = 'BB_Price_Middle')).show()

VectorBT Pro - Plotting Indicators and Visualising Strategy with Cleaned Signals

The fundamental concept you have to understand in layering elements on a figure is to reference the parent figure, notice how the fig = f in the above Bollinger Band plot is referencing the parent figure object f we first created. Also notice that child objects inherit the styling and other attributes kwargs1 you passed to the parent object. You can ofcourse over-ride them by placing another **kwargs in the plot() function call.

Adding RSI on Stacked SubPlots

So we learnt how to add an indicator to the figure, but what if we want to create stacked subplots, like adding an RSI Indicator below the previous plot. This is essentially done using the vbt.make_subplots() function and the use of the add_trace_kwargs argument inside the plot() function.

kwargs1 = {"title_text" : "H4 OHLCV with BBands on Price and RSI", "title_font_size" : 18, 
           "legend" : dict(yanchor="top",y=0.99, xanchor="right",x= 0.25)}

fig = vbt.make_subplots(rows=2,cols=1, shared_xaxes=True, vertical_spacing=0.1)

## Sliced Data
h4_price = h4_df[["Open", "High", "Low", "Close"]]
indices = slice(100,200)
h4_price.iloc[indices].vbt.ohlcv.plot(add_trace_kwargs=dict(row=1, col=1),  fig=fig, **kwargs1) 
h4_bbands.iloc[indices].plot(add_trace_kwargs=dict(row=1, col=1),fig=fig,
                            lowerband_trace_kwargs=dict(fill=None, name = 'BB_Price_Lower'), 
                            upperband_trace_kwargs=dict(fill=None, name = 'BB_Price_Upper'),
                            middleband_trace_kwargs=dict(fill=None, name = 'BB_Price_Middle'))

h4_rsi.iloc[indices].rename("RSI").vbt.plot(add_trace_kwargs=dict(row=2, col=1),fig=fig, **kwargs1 )

h4_bbands_rsi.iloc[indices].plot(add_trace_kwargs=dict(row=2, col=1),limits=(25, 75),fig=fig,
                            lowerband_trace_kwargs=dict(fill=None, name = 'BB_RSI_Lower'), 
                            upperband_trace_kwargs=dict(fill=None, name = 'BB_RSI_Upper'),
                            middleband_trace_kwargs=dict(fill=None, name = 'BB_RSI_Middle'),
                            # xaxis=dict(rangeslider_visible=True) ## Without Range Slider
                            )

fig.update_xaxes(rangebreaks=[dict(values=dt_breaks)])
fig.layout.showlegend = False
fig.show()

VectorBT Pro - Plotting Indicators and Visualising Strategy with Cleaned Signals


Plotting - Advanced 💪

In this section, we will see how to create your own plot_strategy with all the customizations you would want. Please read the inline comments in the code block below to understand the inner workings and details of the various commands

def plot_strategy(slice_lower : str, slice_upper: str, df : pd.DataFrame , rsi : pd.Series,
                  bb_price : vbt.indicators.factory, bb_rsi : vbt.indicators.factory,  
                  pf: vbt.portfolio.base.Portfolio, entries: pd.Series = None, 
                  exits: pd.Series = None,
                  show_legend : bool = True):
    """Creates a stacked indicator plot for the 2BB strategy.
    Parameters
    ===========
    slice_lower : str, start date of dataframe slice in yyyy.mm.dd format
    slice_upper : str, start date of dataframe slice in yyyy.mm.dd format
    df          : pd.DataFrame, containing the OHLCV data
    rsi         : pd.Series, rsi indicator time series in same freq as df
    bb_price    : vbt.indicators.factory.talib('BBANDS'), computed on df['close'] price
    bb_rsi      : vbt.indicators.factory.talib('BBANDS') computer on RSI
    pf          : vbt.portfolio.base.Portfolio, portfolio simulation object from VBT Pro
    entries     : pd.Series, time series data of long entries
    exits       : pd.Series, time series data of long exits
    show_legend : bool, switch to show or completely hide the legend box on the plot
    
    Returns
    =======
    fig         : plotly figure object
    """
    kwargs1 = {"title_text" : "H4 OHLCV with BBands on Price and RSI", 
               "title_font_size" : 18,
               "height" : 960,
               "legend" : dict(yanchor="top",y=0.99, xanchor="left",x= 0.1)}
    fig = vbt.make_subplots(rows=2,cols=1, shared_xaxes=True, vertical_spacing=0.1)
    ## Filter Data according to date slice
    df_slice = df[["Open", "High", "Low", "Close"]][slice_lower : slice_upper]
    bb_price = bb_price[slice_lower : slice_upper]
    rsi = rsi[slice_lower : slice_upper]
    bb_rsi = bb_rsi[slice_lower : slice_upper]

    ## Retrieve datetime index of rows where price data is NULL
    # retrieve the dates that are in the original datset
    dt_obs = df_slice.index.to_list()
    # Drop rows with missing values
    dt_obs_dropped = df_slice['Close'].dropna().index.to_list()
    # store  dates with missing values
    dt_breaks = [d for d in dt_obs if d not in dt_obs_dropped]

    ## Plot Figures
    df_slice.vbt.ohlcv.plot(add_trace_kwargs=dict(row=1, col=1),  fig=fig, **kwargs1) ## Without Range Slider
    rsi.rename("RSI").vbt.plot(add_trace_kwargs=dict(row=2, col=1), trace_kwargs = dict(connectgaps=True), fig=fig) 

    bb_line_style = dict(color="white",width=1, dash="dot")
    bb_price.plot(add_trace_kwargs=dict(row=1, col=1),fig=fig, **kwargs1,
                lowerband_trace_kwargs=dict(fill=None, name = 'BB_Price_Lower', connectgaps=True, line = bb_line_style), 
                upperband_trace_kwargs=dict(fill=None, name = 'BB_Price_Upper', connectgaps=True, line = bb_line_style),
                middleband_trace_kwargs=dict(fill=None, name = 'BB_Price_Middle', connectgaps=True) )

    bb_rsi.plot(add_trace_kwargs=dict(row=2, col=1),limits=(25, 75),fig=fig,
                lowerband_trace_kwargs=dict(fill=None, name = 'BB_RSI_Lower', connectgaps=True,line = bb_line_style), 
                upperband_trace_kwargs=dict(fill=None, name = 'BB_RSI_Upper', connectgaps=True,line = bb_line_style),
                middleband_trace_kwargs=dict(fill=None, name = 'BB_RSI_Middle', connectgaps=True, visible = False))
    
    ## Plots Long Entries / Exits and Short Entries / Exits
    pf[slice_lower:slice_upper].plot_trade_signals(add_trace_kwargs=dict(row=1, col=1),fig=fig,
                                                   plot_close=False, plot_positions="lines")

    ## Plot Trade Profit or Loss Boxes
    pf.trades.direction_long[slice_lower : slice_upper].plot(
                                        add_trace_kwargs=dict(row=1, col=1),fig=fig,
                                        plot_close = False,
                                        plot_markers = False
                                        )
                                        

    pf.trades.direction_short[slice_lower : slice_upper].plot(
                                            add_trace_kwargs=dict(row=1, col=1),fig=fig,
                                            plot_close = False,
                                            plot_markers = False
                                            )

    if (entries is not None) & (exits is not None):
        ## Slice Entries and Exits
        entries = entries[slice_lower : slice_upper]
        exits = exits[slice_lower : slice_upper]
        ## Add Entries and Long Exits on RSI in lower subplot
        entries.vbt.signals.plot_as_entries(rsi, fig = fig,
                                                add_trace_kwargs=dict(row=2, col=1),
                                                trace_kwargs=dict(name = "Long Entry", 
                                                                  marker=dict(color="limegreen")
                                                                  ))  

        exits.vbt.signals.plot_as_exits(rsi, fig = fig, 
                                            add_trace_kwargs=dict(row=2, col=1),
                                            trace_kwargs=dict(name = "Short Entry", 
                                                              marker=dict(color="red"),
                                                            #   showlegend = False ## To hide this from the legend
                                                              )
                                            )

    fig.update_xaxes(rangebreaks=[dict(values=dt_breaks)])
    fig.layout.showlegend = show_legend  
    # fig.write_html(f"2BB_Strategy_{slice_lower}_to_{slice_upper}.html")
    
    return fig
slice_lower = '2019.11.01'
slice_higher = '2019.12.31'

fig = plot_strategy(slice_lower, slice_higher, h4_data.get(), h4_rsi, 
                           h4_bbands, h4_bbands_rsi, pf,
                           clean_h4_entries, clean_h4_exits,
                           show_legend = True)

# fig.show_svg()
fig.show()

VectorBT Pro - Plotting Indicators and Visualising Strategy with Cleaned Signals

Notes:

  • We are using the H4 timeframe data for our plotting, as the 15T baseline data series results in a very dense looking plot which slows down the interactive plot.
  • We are passing a slice of the time series dataframe in order to avoid a dense, highly cluttered plot. You can make this interactive also if you were to use plotly-dash (a topic for another blog post perhaps 😃 )
  • The gaps in the OHLCV candlesticks on weekends are fixed by storing the dates with missing values in the dt_breaks variable and passing this in the rangebreaks=[dict(values=dt_breaks)] argument in the fig.update_xaxes() method to filter our the missing dates in the x-axes.
  • We fixed the gaps occuring in the bollinger bands (due to weekend market closures) by using the connectgaps = True argument in each _trace_kwargs of the bollinger bands.
  • Note, that you may wonder why we are naming the legends entries and exits in the RSI subplot below as Long Entries and Short Entries and not four distinct labels as in the first subplot.
    • This is because since we set direction = both in the pf.simulation the Short Entry is basically an Exit for the previous Long position and similarly, a Long Entry is basically an exit for the previous short position. This type of nomenclature setting makes the plot also usable for other settings like when you would want to set direction = longonly in the pf.simulation in which case you can just call these legend items Long Entries and Long Exits on the RSI subplot.

Cleaning entries and exits

You might have noticed in the last code block we were using clean_h4_entries and clean_h4_exits. Lets understand why we did that? Before we plot any entries and exits for visual analysis, it is imperative to clean them. We will continue with the entries and exits at the end of our previous tutorial Strategy Development and Signal Generation in order to explain how we can go about cleaning and resampling entry & exit signals.

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

The total nr. of actual signals in the entries and exits array is computed by the vbt.signals.total() accessor method.

print(f"Length of Entries (array): {len(entries)} || Length of Exits (array): {len(exits)}" )
print(f"Total Nr. of Entry Signals: {entries.vbt.signals.total()} || \
        Total Nr. of Exit Signals: {exits.vbt.signals.total()}")

Output:

Length of Entries (array): 105188 || Length of Exits (array): 105188
Total Nr. of Entry Signals: 2135 || Total Nr. of Exit Signals: 1568

Why do you want to clean the entries and exits?

From the print statement output we can see that in the entire length of the dataframe 105188 on 15T frequency, we can see that the total number of raw entry and raw exit signals is 2135 and 1568 respectively, which is prior to any cleaning. Currently there are many duplicate signals in the entries and exits, and this discrepancy between entries and exits need to be resolved by cleaning.

Cleaning entry and exit signals is easily done by vbt.signals.clean() accessor and after cleaning each entry signal has a corresponding exit signal. When we validate the total number of clean entry and clean exit signals using the vbt.signals.total() method now, we can now see the cleaned entry and exit signals total to be 268 and 267 respectively.

## Clean redundant and duplicate signals
clean_entries, clean_exits = entries.vbt.signals.clean(exits)
print(f"Length of Clean_Entries Array: {len(clean_entries)} || Length of Clean_Exits Array: {len(clean_exits)}" )
print(f"Total nr. of Entry Signals in Clean_Entry Array: {clean_entries.vbt.signals.total()} || \
        Total nr. of Exit Signals in Clean_Exit Array: {clean_exits.vbt.signals.total()}")

Output

Length of Clean_Entries Array: 105188 || Length of Clean_Exits Array: 105188
Total nr. of Entry Signals in Clean_Entry Array: 268 || Total nr. of Exit Signals in Clean_Exit Array: 267

Could you think of a reason why there is a difference of one signal between the clean_entry and clean_exit signals ?

  • This is happening because the most recent (entry) position that was last opened, has not been closed out.

Cleaning Signals - Visual Difference

The below plots will make it easier when understanding what happens if we use uncleaned signals
Uncleaned Entries & Exits on our RSI Plot
VectorBT Pro - Plotting Indicators and Visualising Strategy with Cleaned Signals

Cleaned Entries & Exits on our RSI plot
VectorBT Pro - Plotting Indicators and Visualising Strategy with Cleaned Signals

Resampling entries and exits to H4 timeframe

Resampling any entries/exits is only required when we are concerned with analyzing (plotting, counting etc.) and visualizing our strategy and entries/exits on a timeframe different from the baseline frequency of our strategy. It is always recommended to first clean the raw entries/exits array and then do the resampling.

When do we need to upsample and downsample entries & exits?

  • Downsampling of entries/exists is required if we want to do any visual analysis on a timeframe higher than that our baseline frequency of our strategy.
    • For eg, in the below code we show a case of Downsampling our entries/exits on the 15m timeframe to H4 timeframe. Downsampling comes with the risk of information loss.
  • Upsampling of entries/exits is required, if we want to do any visual analysis on a timeframe lower than that our baseline frequency of our strategy.
    • For example, you have some crossover on 4h frequency and you want to combine the signals with some other crossover on 15 min frequency, then you would need to upsample signals from 4h->15min, which would cause no problems because there is no information loss in upsampling
clean_h4_entries = clean_entries.vbt.resample_apply("4h", "any", wrap_kwargs=dict(dtype=bool))
clean_h4_exits = clean_exits.vbt.resample_apply("4h", "any", wrap_kwargs=dict(dtype=bool))

print(f"Length of H4_Entries (array): {len(clean_h4_entries)} || Length of H4_Exits (array): {len(clean_h4_exits)}" )
print(f"Total nr. of H4_Entry Signals: {clean_h4_entries.vbt.signals.total()} || \
        Total nr. of H4_Exit Signals: {clean_h4_exits.vbt.signals.total()}")

Output

Length of H4_Entries (array): 6575 || Length of H4_Exits (array): 6575
Total nr. of H4_Entry Signals: 263 || Total nr. of H4_Exit Signals: 263

Information Loss during downsampling

From the above output we see the nr. of signals on the clean_h4_entries and clean_h4_exits as 263 respectively, this shows a loss of information during this Downsampling process. This information loss occurs because by aggregating signals during downsampling, our data becomes less granular.

Key Points

  • How information loss occurs during downsampling of entries/exits?
    • Let's say we have [entry, entry, exit, exit, entry, entry] in our signal array, after cleaning we'll get [entry, exit, entry], but after aggregating the original signal sequence you'll get just {entry:4 ; exit:2}, which clearly cannot after cleaning produce the same sequence as on the smaller (more granular) timeframe.
    • The problem here is that the information loss occured during downsampling the cleaned entries and exits, ignores any exit that could have closed the position if you back tested on the original timeframe (15T in our example), that is why we end up seeing 263 || 263 in the above print statement.
  • After downsampling if we want to retrace the order of signals and we want to investigate "Which signal came first in the original timeframe ?", we encounter a irresolvible problem. We can't resample 1m (minutely) data to one year timeframe and then expect the signal count to be the same even though our new data is all fitting in only one bar (1D candle).

By changing the method to sum and removing the wrap_kwargs argument in the vbt.resample_apply() method we can aggregate the signals and show the aggregated nr. of signals.

## sum() will aggregate the signals
h4_entries_agg = clean_entries.vbt.resample_apply("4h", "sum")
h4_exits_agg = clean_exits.vbt.resample_apply("4h", "sum")

## h4_extries_agg is not a vbt object so vbt.signals.total() accessor will not work 
## and thus we use the pandas sum() method in the print statement below
print(f"Length of H4_Entries (array): {len(h4_entries_agg)} || Length of H4_Exits (array): {len(h4_exits_agg)}" )
print(f"Aggregated H4_Entry Signals: {int(h4_entries_agg.sum())} || Aggregated H4_Exit Signals: {int(h4_exits_agg.sum())}")

Output

Length of H4_Entries (array): 6575 || Length of H4_Exits (array): 6575
Aggregated H4_Entry Signals: 268   || Aggregated H4_Exit Signals: 267

Though the result of this aggregation shows the same result we got above when printing the clean_entries.vbt.signals.total() and clean_exits.vbt.signals.total(), this does not help us in inferring the true order of the signals and the information loss created by downsampling cannot be compensated by aggregation.

Do we have to resample entries/exits for running the backtesting simulation?

When you are doing portfolio simulation (backtesting), using the from_signals() which we see in the previous blog post - Strategy Development and Signal Generation, the from_signals method automatically cleans the entries and exits , therefore there is no reason to do resampling or cleaning of any entries and exits.
Irrespective of whether we use the clean entries and exits or just the regular entries and exits in the simulation/backtest when running from_signals() it will make no difference. You can try this for yourself and see the results will be the same.

]]>
<![CDATA[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

]]>
http://localhost:2368/strategydev/643c151fe770773c74f2e5ffMon, 28 Nov 2022 11:09:00 GMT

Strategy Development and Signal Generation

VectorBT Pro - 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()

VectorBT Pro - Strategy Development and Signal Generation

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()

VectorBT Pro - Strategy Development and Signal Generation

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()

VectorBT Pro - Strategy Development and Signal Generation

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

VectorBT Pro - Strategy Development and Signal Generation

  • 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()

VectorBT Pro - Strategy Development and Signal Generation

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

]]>
<![CDATA[VectorBT Pro - Aligning MTF time series Data with Resampling]]>Software Requirements : vectorbtpro, python3

Resampling of the market data is needed for strategies that involve multiple time frames, often referred to as “top down analysis” or “Multi Time Frame (MTF) analysis”. There are two types of resampling, called upsampling and downsampling.

Before thinking of upsampling and

]]>
http://localhost:2368/aligning-mtf-data/643c151fe770773c74f2e5fcSat, 26 Nov 2022 08:30:00 GMTSoftware Requirements : vectorbtpro, python3VectorBT Pro - Aligning MTF time series Data with Resampling

Resampling of the market data is needed for strategies that involve multiple time frames, often referred to as “top down analysis” or “Multi Time Frame (MTF) analysis”. There are two types of resampling, called upsampling and downsampling.

Before thinking of upsampling and downsampling time-series data, let's use the analogy of an UltraHD (4K)  television to better understand these terms intuitively. When you feed a 1080p video source to a 4K TV it will Upsample the pixels, giving you a high resolution image. Essentially, you will get a high granularity (finer, hi-res image) from a low granularity (coarse, low-res image). The opposite ( downsampling ) happens when you play a 4K video file on an old 📺 HD TV.

In the context of time series data,

💡
Downsampling means going from high frequency time-series data (holds more information about the price within a time interval) to low frequency time-series data (holds less information about the price within the same time interval)
Example: 15 Minute Data → 1 Hour Data
💡
Upsampling means going from a low frequency time-series data (holds less information about the price within a time interval) to a higher frequency (holds more information about the price within a time interval)
Example : 1 Hour Data → 15 Minute Data

In multi-time frame strategy analysis, we have to deal with the problem of integrating time series data (eg: close price) from multiple time frames.

VectorBT Pro - Aligning MTF time series Data with Resampling
How to merge timeseries data with different frequencies?

To make our data-analytics and back-testing simulation process easier, we usually like to have a single time-series dataframe ( mtf_df ) which contains all the values from whatever time-frames we require (eg: 5m, 15min, 1h. 4h, 1D, 1W etc.) . This MTF dataframe will have a base-line frequency which will typically be the highest frequency (i.e highest granularity or the lowest timeframe time-series data, eg: 5m ) with which you want to do your signal generation for the strategy. The process of creating this MTF dataframe with resampled data is called Alignment.

VectorBT Pro - Aligning MTF time series Data with Resampling
Aligned and upsampled data frame

In alignment, we basically merge the MTF time-series  resampled data into a single dataframe using ffill() and shift() operations. This is very easily done using vbt.resampler() objects and using those resampler objects as an argument in vbt.resample_opening() function for open price and vbt.resample_closing() when dealing with close, high, low prices and indicators.


Loading and Resampling Data

Loading the data using vbt.HDF functionality of the 1-minute granularity

## Import Required Libaries
import vectorbtpro as vbt
import pandas as pd

## Load m1 data
m1_data = vbt.HDFData.fetch('../data/GU_OHLCV_3Y.h5')
m1_data.wrapper.index #pandas doaesn't recognise the frequency because of missing timestamps

Output:

DatetimeIndex(['2019-08-27 00:00:00+00:00', '2019-08-27 00:01:00+00:00',
               '2019-08-27 00:02:00+00:00', '2019-08-27 00:03:00+00:00',
               '2019-08-27 00:04:00+00:00', '2019-08-27 00:05:00+00:00',
               '2019-08-27 00:06:00+00:00', '2019-08-27 00:07:00+00:00',
               '2019-08-27 00:08:00+00:00', '2019-08-27 00:09:00+00:00',
               ...
               '2022-08-26 16:50:00+00:00', '2022-08-26 16:51:00+00:00',
               '2022-08-26 16:52:00+00:00', '2022-08-26 16:53:00+00:00',
               '2022-08-26 16:54:00+00:00', '2022-08-26 16:55:00+00:00',
               '2022-08-26 16:56:00+00:00', '2022-08-26 16:57:00+00:00',
               '2022-08-26 16:58:00+00:00', '2022-08-26 16:59:00+00:00'],
              dtype='datetime64[ns, UTC]', name='time', length=1122468, freq=None)

Resampling (Downsampling) the Data from 1 Minute Timeframe / Granularity to other Timeframes/Granularities.

  • Converting 1 Minute (M1) to 15 Minute (M15)
  • Converting 1 Minute (M1) to 1 Hour (H1)
  • Converting 1 Minute (M1) to 4 Hours (H4)

This resampling uses the vbt.resample() method for the downsampling operations, after which we see the frequency is identified correctly as 15T (15 mins)

m15_data = m1_data.resample('15T')
h1_data = m1_data.resample("1h")
h4_data = m1_data.resample('4h')
print(m15_data.wrapper.index)

Output:

DatetimeIndex(['2019-08-27 00:00:00+00:00', '2019-08-27 00:15:00+00:00',
               '2019-08-27 00:30:00+00:00', '2019-08-27 00:45:00+00:00',
               '2019-08-27 01:00:00+00:00', '2019-08-27 01:15:00+00:00',
               '2019-08-27 01:30:00+00:00', '2019-08-27 01:45:00+00:00',
               '2019-08-27 02:00:00+00:00', '2019-08-27 02:15:00+00:00',
               ...
               '2022-08-26 14:30:00+00:00', '2022-08-26 14:45:00+00:00',
               '2022-08-26 15:00:00+00:00', '2022-08-26 15:15:00+00:00',
               '2022-08-26 15:30:00+00:00', '2022-08-26 15:45:00+00:00',
               '2022-08-26 16:00:00+00:00', '2022-08-26 16:15:00+00:00',
               '2022-08-26 16:30:00+00:00', '2022-08-26 16:45:00+00:00'],
              dtype='datetime64[ns, UTC]', name='time', length=105188, freq='15T')
💡
How can the user tell which resample() method was used in the above operation, is it pandas or vbt.resample() ?
If the object you are resampling is of class vbt then the numba-compiled resample() function of VectorBT will be used automatically. If the resampled object is a pandas.Series or pandas.DataFrame then the pandas resample() method will be used automatically.

As seen in the code below the respective (OHLC) can be obtained using the .get() method.

# Obtain all the closing  prices using the .get() method
m15_close = m15_data.get()['Close']

## h1 data
h1_open  = h1_data.get()['Open']
h1_close = h1_data.get()['Close']
h1_high  = h1_data.get()['High']
h1_low   = h1_data.get()['Low']

## h4 data
h4_open  = h4_data.get()['Open']
h4_close = h4_data.get()['Close']
h4_high  = h4_data.get()['High']
h4_low   = h4_data.get()['Low']

OR, you can can also simply follow the pandas convention like resampled_data.column_name to retrieve the column data

# Obtain all the closing  prices using the .get() method
m15_close = m15_data.close

## h1 data
h1_open  = h1_data.open
h1_close = h1_data.close
h1_high  = h1_data.high
h1_low   = h1_data.low

## h4 data
h4_open  = h4_data.open
h4_close = h4_data.close
h4_high  = h4_data.high
h4_low   = h4_data.low

The OHLC for both the H4 4-Hourly Candle Data as well as the closing price for the 15m Candle Data was obtained.


Multi-Time Frame Indicator Creation

VectorBT has a built-in method called vbt.talib() which calls the required indicator from talib library and runs it on the specified time-series data (Eg: Close Price or another indicator). We will now create the following indicators (manually) on the M15, H1 and H4 timeframes using the :

  • RSI of 21 period
  • BBANDS Bolllinger Bands
  • BBANDS_RSI Bollinger Bands on the RSI
rsi_period = 21

## 15m indicators
m15_rsi = vbt.talib("RSI", timeperiod = rsi_period).run(m15_close, skipna=True).real.ffill()
m15_bbands = vbt.talib("BBANDS").run(m15_close, skipna=True)
m15_bbands_rsi = vbt.talib("BBANDS").run(m15_rsi, skipna=True)

## h4 indicators
h1_rsi = vbt.talib("RSI", timeperiod = rsi_period).run(h1_close, skipna=True).real.ffill()
h1_bbands = vbt.talib("BBANDS").run(h1_close, skipna=True)
h1_bbands_rsi = vbt.talib("BBANDS").run(h1_rsi, skipna=True)

## h4 indicators
h4_rsi = vbt.talib("RSI", timeperiod = rsi_period).run(h4_close, skipna=True).real.ffill()
h4_bbands = vbt.talib("BBANDS").run(h4_close, skipna=True)
h4_bbands_rsi = vbt.talib("BBANDS").run(h4_rsi, skipna=True)

When talib() creates the RSI indicator time-series, it is known to create it with NaNs (null-values), so it is a good idea to run ffill(), forward filling operation to fill the missing values. On this note, it is also a good idea in general, to investigate the talib results and compare it with the original time-series data (Close Price) for abnormal number of NaN values and then decide on ffill() operation

We will now initialize the empty dict called data and fill it with key - value pairs of the 15m time-series data.

## Initialize  dictionary
data = {}

col_values = [
    m15_close, m15_rsi, m15_bbands.upperband, m15_bbands.middleband, m15_bbands.lowerband, 
    m15_bbands_rsi.upperband, m15_bbands_rsi.middleband, m15_bbands_rsi.lowerband
    ]

col_keys = [
    "m15_close", "m15_rsi", "m15_bband_price_upper",  "m15_bband_price_middle", "m15_bband_price_lower", 
    "m15_bband_rsi_upper",  "m15_bband_rsi_middle", "m15_bband_rsi_lower"
         ]

# Assign key, value pairs for method of time series data to store in data dict
for key, time_series in zip(col_keys, col_values):
    data[key] = time_series.ffill()

Alternative (One-Liner) Method of Indicator Creation

VectorBT also offers a more convenient one-liner method of creating this multi-time frame indicators

rsi_period = 21

rsi = vbt.talib("RSI", timeperiod=rsi_period).run(
    m15_data.get("Close"), 
    timeframe=["15T", "1H" , "4H"], 
    skipna=True, 
    broadcast_kwargs=dict(wrapper_kwargs=dict(freq="15T"))
).real

bbands_price = vbt.talib("BBANDS").run(
    m15_data.get("Close"), 
    timeframe=["15T", "1H", "4H"], 
    skipna=True,
    broadcast_kwargs=dict(wrapper_kwargs=dict(freq="15T"))
)

bbands_rsi = vbt.talib("BBANDS").run(
    rsi,
    timeframe=vbt.Default(["15T", "1H" ,"4H"]),
    skipna=True,
    per_column=True,
    broadcast_kwargs=dict(wrapper_kwargs=dict(freq="15T"))
)

Note : The method of indicator creation shown above using talib('IndicatorName').run with broadcast_kwargs argument automatically does the ffill() operation. This one liner method doesn't resample to 15T only because of broadcast_kwargs argument, in fact, using broadcast_kwargs we just provide vbt with the true frequency of your data in case this frequency cannot be inferred from data. Without specifying it the method will still work (we will just get a warning if frequency cannot be inferred)
So here we we specify the broadcast_kwargs argument, because m15_data.get("Close") contains gaps and pandas cannot infer its frequency as 15T, this approach works only because of the timeframe argument and because indicators always return outputs of the same index as their inputs, such that we're forced to resample it back to the original frequency. If pandas can infer the frequency of the input series, we don't need to specify broadcast_kwargs argument at all.

## Initialize  dictionary
data = {}

## Assign key, value pairs for method 2 of Automated One-liner MTF indicator creation method
col_values = [
    [m15_close.ffill(), rsi['15T'], bbands_price['15T'].upperband, bbands_price['15T'].middleband, bbands_price['15T'].lowerband, bbands_rsi['15T'].upperband, bbands_rsi['15T'].middleband, bbands_rsi['15T'].lowerband],
    [rsi['1H'], bbands_price['1H'].upperband, bbands_price['1H'].middleband, bbands_price['1H'].lowerband, bbands_rsi['1H'].upperband, bbands_rsi['1H'].middleband, bbands_rsi['1H'].lowerband],
    [rsi['4H'], bbands_price['4H'].upperband, bbands_price['4H'].middleband, bbands_price['4H'].lowerband, bbands_rsi['4H'].upperband, bbands_rsi['4H'].middleband, bbands_rsi['4H'].lowerband]
    ]

col_keys = [
    ["m15_close", "m15_rsi", "m15_bband_price_upper",  "m15_bband_price_middle", "m15_bband_price_lower",  "m15_bband_rsi_upper",  "m15_bband_rsi_middle", "m15_bband_rsi_lower"], 
    ["h1_rsi", "h1_bband_price_upper",  "h1_bband_price_middle",  "h1_bband_price_lower",  "h1_bband_rsi_upper",  "h1_bband_rsi_middle", "h1_bband_rsi_lower"],
    ["h4_rsi", "h4_bband_price_upper",  "h4_bband_price_middle",  "h4_bband_price_lower",  "h4_bband_rsi_upper",  "h4_bband_rsi_middle", "h4_bband_rsi_lower" ],
         ]

## Assign key, value pairs for method 2 of Automated One-liner MTF indicator creation method
for lst_series, lst_keys in zip(col_values, col_keys):
    for key, time_series in zip(lst_keys, lst_series):
        data[key] = time_series

Alignment & Up-sampling

Let's now see what is resampler in VectorBT. Resampler is an instance of the Resampler class, which simply stores a source index and frequency, and a target index and frequency. The vbt.resampler() method can just work with the source index and target index and can automatically infer the source and target frequency. In contrast to Pandas, vectorbt can also accept an arbitrary target index for resampling

Resampler(
    source_index,
    target_index,
    source_freq=None,
    target_freq=None,
    silence_warnings=None
)

where the arguments, are

source_index : is index_like, Index being resampled.
target_index : is index_like ,Index resulted from resampling.
source_freq : frequency_like or bool, Frequency or date offset of the source index. Set to False to force-set the frequency to None.
target_freq : frequency_like or bool, Frequency or date offset of the target index. Set to False to force-set the frequency to None.
silence_warnings : bool, Whether to silence all warnings.

We will now create a custom function called create_resamplers() using this vbt.Resampler() function to create a resampler object to convert H4 time-series

def create_resamplers(result_dict_keys_list : list, source_indices : list,  
                      source_frequencies :list, target_index : pd.Series, target_freq : str):
    """
    Creates a dictionary of vbtpro resampler objects.

    Parameters
    ==========
    result_dict_keys_list : list, list of strings, which are keys of the output dictionary
    source_indices        : list, list of pd.time series objects of the higher timeframes
    source_frequencies    : list(str), which are short form representation of time series order. Eg:["1D", "4h"]
    target_index          : pd.Series, target time series for the resampler objects
    target_freq           : str, target time frequency for the resampler objects

    Returns
    ===========
    resamplers_dict       : dict, vbt pro resampler objects
    """
    
    
    resamplers = []
    for si, sf in zip(source_indices, source_frequencies):
        resamplers.append(vbt.Resampler(source_index = si,  target_index = target_index,
                                        source_freq = sf, target_freq = target_freq))
    return dict(zip(result_dict_keys_list, resamplers))

Using this function we can create a dictionary of vbt.Resampler objecters stored by appropriately named keys.

## Create Resampler Objects for upsampling
src_indices = [h1_close.index, h4_close.index]
src_frequencies = ["1H","4H"] 
resampler_dict_keys = ["h1_m15","h4_m15"]

list_resamplers = create_resamplers(resampler_dict_keys, src_indices, src_frequencies, m15_close.index, "15T")

print(list_resamplers)

Output:

{'h1_m15': <vectorbtpro.base.resampling.base.Resampler at 0x16c83de70>,
 'h4_m15': <vectorbtpro.base.resampling.base.Resampler at 0x16c5478e0>}

The output shows that two vbt.Resampler class objects have been created in memory.

The resample_closing() and resample_opening() operations don't require any ffill() operations and they automatically align the source time-series data to the target frequency, which in our case is 15T (15 mins)

## Add H1 OLH data - No need to do ffill() on resample_closing as it already does that by default
data["h1_open"] = h4_open.vbt.resample_opening(list_resamplers['h1_m15'])

## Add H4 OLH data - No need to do ffill() on resample_closing as it already does that by default
data["h4_open"] = h4_open.vbt.resample_opening(list_resamplers['h4_m15'])

We use resample_opening only if information in the array happens exactly at the beginning of the bar (such as open price), and resample_closing if information happens after that (such as high, low, and close price). You can see the effect of this resample_opening operation with the print() statements below:

print(h4_open.info()) ## Before resampling pandas series

<class 'pandas.core.series.Series'>
DatetimeIndex: 6575 entries, 2019-08-27 00:00:00+00:00 to 2022-08-26 16:00:00+00:00
Freq: 4H
Series name: Open
Non-Null Count  Dtype  
--------------  -----  
4841 non-null   float64
dtypes: float64(1)
memory usage: 102.7 KB
None

print(data["h4_open"].info()) ## After resampling pandas series

<class 'pandas.core.series.Series'>
DatetimeIndex: 105188 entries, 2019-08-27 00:00:00+00:00 to 2022-08-26 16:45:00+00:00
Freq: 15T
Series name: Open
Non-Null Count   Dtype  
--------------   -----  
105188 non-null  float64
dtypes: float64(1)
memory usage: 1.6 MB
None
## Use along with  Manual indicator creation method for MTF
series_to_resample = [
    [h1_high, h1_low, h1_close, h1_rsi, 
    h1_bbands.upperband, h1_bbands.middleband, h1_bbands.lowerband,
    h1_bbands_rsi.upperband, h1_bbands_rsi.middleband, h1_bbands_rsi.lowerband], 
    [h4_high, h4_low, h4_close, h4_rsi,
    h4_bbands.upperband, h4_bbands.middleband, h4_bbands.lowerband, 
    h4_bbands_rsi.upperband, h4_bbands_rsi.middleband, h4_bbands_rsi.lowerband]
    ]

data_keys = [
    ["h1_high", "h1_low", "h1_close", "h1_rsi", 
    "h1_bband_price_upper", "h1_bband_price_middle","h1_bband_price_lower", 
     "h1_bband_rsi_upper",  "h1_bband_rsi_middle", "h1_bband_rsi_lower"],
    ["h4_high", "h4_low", "h4_close", "h4_rsi", 
    "h4_bband_price_upper", "h4_bband_price_middle", "h4_bband_price_lower", 
     "h4_bband_rsi_upper",  "h4_bband_rsi_middle", "h4_bband_rsi_lower"]
     ]

## Create resampled time series data aligned to base line frequency (15min)

for lst_series, lst_keys, resampler in zip(series_to_resample, data_keys, resampler_dict_keys):
    for key, time_series in zip(lst_keys, lst_series):
        resampled_time_series = time_series.vbt.resample_closing(list_resamplers[resampler])
        data[key] = resampled_time_series

Alignment and Resampling when using one-liner method of indicator creation

In this method, we have already dealt with resampling and aligning the indicators, so all we have to do is just resample the open and closing prices of the respective timeframes required.

## Resample prices to match base_line frequency (`15T`)

series_to_resample = [
    [h1_open, h1_high, h1_low, h1_close],
    [h4_open, h4_high, h4_low, h4_close]
    ]

data_keys = [
    ["h1_open", "h1_high", "h1_low", "h1_close"],
    ["h4_open", "h4_high", "h4_low" ,"h4_close"]
    ]

## Create resampled time series data aligned to base line frequency (15min)

for lst_series, lst_keys, resampler in zip(series_to_resample, data_keys, resampler_dict_keys):
    for key, time_series in zip(lst_keys, lst_series):
        if key.lower().endswith('open'):
            print(f'Resampling {key} differently using vbt.resample_opening using "{resampler}" resampler')
            resampled_time_series = time_series.vbt.resample_opening(list_resamplers[resampler])
        else:
            resampled_time_series = time_series.vbt.resample_closing(list_resamplers[resampler])
        data[key] = resampled_time_series

Creating The Master DataFrame

Now that we have resampled the various time series to the different timeframes, created and run our indicators, we can finally create the composite mtf_df dataframe from this data which is properly aligned to the baseline frequency (in our case 15T) that will allow us to properly create the Buy/Long and Sell/Short conditions for whichever MTF (Multi Time Frame) Strategy that we indend to backtest.

cols_order = ['m15_close', 'm15_rsi', 'm15_bband_price_upper','m15_bband_price_middle', 'm15_bband_price_lower',
              'm15_bband_rsi_upper','m15_bband_rsi_middle', 'm15_bband_rsi_lower',
              'h1_open', 'h1_high', 'h1_low', 'h1_close', 'h1_rsi',
              'h1_bband_price_upper', 'h1_bband_price_middle', 'h1_bband_price_lower', 
              'h1_bband_rsi_upper', 'h1_bband_rsi_middle', 'h1_bband_rsi_lower',              
              'h4_open', 'h4_high', 'h4_low', 'h4_close', 'h4_rsi',
              'h4_bband_price_upper', 'h4_bband_price_middle', 'h4_bband_price_lower', 
              'h4_bband_rsi_upper', 'h4_bband_rsi_middle', 'h4_bband_rsi_lower'
              ]

## construct a multi-timeframe dataframe
mtf_df = pd.DataFrame(data)[cols_order]
display(mtf_df)            
VectorBT Pro - Aligning MTF time series Data with Resampling
Final aligned and resampled, pandas MultiTimeFrame DataFrame

The mtf_df multi-time frame master dataframe will have the following columns each of which will help us define the logic of the strategy.

  1. m15_close : 15 Minute Closing Price
  2. m15_rsi : RSI values on the m15closing price of period 21
  3. m15_bband_price_upper: The upper bollinger band on m15 closing price
  4. m15_bband_price_middle: The middle bollinger band on m15 closing price
  5. m15_bband_price_lower : The lower bollinger band on m15 closing price
  6. m15_bband_rsi_upper : The Upper Bollinger Band on the M15 RSI Values
  7. m15_bband_rsi_middle : The Middle Bollinger Band on the M15 RSI Values
  8. m15_bband_rsi_lower: The Lower Bollinger band on the M15 RSI Values
  9. h1_open: The opening price of the H1 candle
  10. h1_high: The High Price of the H1 Candle
  11. h1_low: The Low Price of the H1 Candle
  12. h1_close: The Closing Price of the H1 Candle
  13. h1_rsi : RSI Values on the H1 closing price of period 21
  14. h1_bband_price_upper: The Upper Bollinger Band On H1 Closing Price
  15. h1_bband_price_middle: The Middle Bollinger Band On H1 Closing Price
  16. h1_bband_price_lower:The Lower Bollinger Band On H1 Closing Price
  17. h1_bband_rsi_upper: The Upper Bollinger Band on the H1 RSI Value
  18. h1_bband_rsi_middle: The Middle Bollinger Band on the H1 RSI Value
  19. h1_bband_rsi_lower: The Lower Bollinger Band on the H1 RSI Value
  20. h4_open: The opening price of the H4 candle
  21. h4_high: The High Price of the H4 Candle
  22. h4_low: The Low Price of the H4 Candle
  23. h4_close: The Closing Price of the H4 Candle
  24. h4_rsi : RSI Values on the H4 closing price of period 21
  25. h4_bband_price_upper: The Upper Bollinger Band On H4 Closing Price
  26. h4_bband_price_middle: The Middle Bollinger Band On H4 Closing Price
  27. h4_bband_price_lower:The Lower Bollinger Band On H4 Closing Price
  28. h4_bband_rsi_upper: The Upper Bollinger Band on the H4 RSI Value
  29. h4_bband_rsi_middle: The Middle Bollinger Band on the H4 RSI Value
  30. h4_bband_rsi_lower: The Lower Bollinger Band on the H4 RSI Value
print(mtf_df.info())

<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 105188 entries, 2019-08-27 00:00:00+00:00 to 2022-08-26 16:45:00+00:00
Freq: 15T
Data columns (total 33 columns):
 #   Column                  Non-Null Count   Dtype  
---  ------                  --------------   -----  
 0   m15_close               105188 non-null  float64
 1   m15_rsi                 105167 non-null  float64
 2   m15_bband_price_upper   105184 non-null  float64
 3   m15_bband_price_middle  105184 non-null  float64
 4   m15_bband_price_lower   105184 non-null  float64
 5   m15_bband_rsi_upper     105163 non-null  float64
 6   m15_bband_rsi_middle    105163 non-null  float64
 7   m15_bband_rsi_lower     105163 non-null  float64
 8   h1_open                 105188 non-null  float64
 9   h1_high                 105185 non-null  float64
 10  h1_low                  105185 non-null  float64
 11  h1_close                105185 non-null  float64
 12  h1_rsi                  105101 non-null  float64
 13  h1_bband_price_upper    105169 non-null  float64
 14  h1_bband_price_middle   105169 non-null  float64
 15  h1_bband_price_lower    105169 non-null  float64
 16  h1_bband_rsi_upper      105085 non-null  float64
 17  h1_bband_rsi_middle     105085 non-null  float64
 18  h1_bband_rsi_lower      105085 non-null  float64
 19  h4_open                 105188 non-null  float64
 20  h4_high                 105173 non-null  float64
 21  h4_low                  105173 non-null  float64
 22  h4_close                105173 non-null  float64
 23  h4_rsi                  104837 non-null  float64
 24  h4_bband_price_upper    105109 non-null  float64
 25  h4_bband_price_middle   105109 non-null  float64
 26  h4_bband_price_lower    105109 non-null  float64
 27  h4_bband_rsi_upper      104773 non-null  float64
 28  h4_bband_rsi_middle     104773 non-null  float64
...
 32  signal                  105188 non-null  int64  
dtypes: bool(2), float64(30), int64(1)
memory usage: 29.9 MB

Summary

In general, the resampling and alignment steps for creating a multi-time frame (MTF) dataframe can be summarized in the below diagram.

  1. We start with the highest granularity of OHLCV data possible (1m) and then downsample the data to higher timeframes (5m, 15m, 1h, 4h etc.)
  2. We then create the indicators on the multiple time frames required but at this juncture we don't forward fill the price data. After the indicator is created we can ffill() the resulting series if we are going with the manual method of indicator creation.
  3. In order to create the composite, merged MTF dataframe we employ resample_opening() on the open price or resample_closing() on every other time series, with the appropriate vbt.Resampler() objects, so that all the time-series are aligned to the base-line frequency time series.
VectorBT Pro - Aligning MTF time series Data with Resampling
Steps for MultiTimeFrame DataFrame creation
]]>
<![CDATA[VectorBT Pro - Tutorial Series]]>This article is an introduction to a series of tutorials on VectorBT Pro and will serve as a Table of Contents to the entire series of our VectorBT tutorials and will be regularly updated whenever a new blog post is published in our VectorBT tutorial series. To run the code

]]>
http://localhost:2368/vbt-tuts-toc/643c151fe770773c74f2e5fbMon, 14 Nov 2022 07:00:00 GMT

This article is an introduction to a series of tutorials on VectorBT Pro and will serve as a Table of Contents to the entire series of our VectorBT tutorials and will be regularly updated whenever a new blog post is published in our VectorBT tutorial series. To run the code in these tutorials, you would need to buy access to vectorBT pro from Oleg Polakow and import vectorbtpro as vbt in your code.

TABLE OF CONTENTS

1. Comprehensive Basics

The tutorials in this chapter will use a toy strategy called the Double Bollinger Band Strategy to illustrate the vectorBT concepts


2. Advanced

  • Create Customized dashboard for Simulation and Strategy
    Create your own customized dashboard using dash (from plotly) to visualize the vectorBT portfolio simulation and strategy development with entries and exits.
  • Parameter Optimization
    Learn how to run a parameter optimization process on a strategy with multiple parameter combinations and also do cross validation of the parameter optimization across multiple train & test splits of the market data.
  • Discretionary Signals Backtesting
    Learn how to run a backtest simulation on extracted discretionary signals, by creating your own custom simulator and order management function.

Want to Contribute?
If you would like to contribute any articles to this blog reach out to us on Linkedin
Have suggestions for tutorial topics, leave them in the comments below!

Many thanks to Oleg Polakow, for his continuing support to VectorBT Pro

]]>
<![CDATA[MT5PyBot 🤖 - Account Monitor & Data Visualisation Dashboard]]>

In this presentation we will see the details of the Account Monitor and the required infrastructure for the same. Thereafter we will also see the visualization of the trading strategy’s performance metrics in a data visualization dashboard and finally a demo of the dashboard.

MongoDB ChangeStreams and WebSockets

]]>
http://localhost:2368/plotly-dashboard/643c151fe770773c74f2e5feMon, 05 Sep 2022 09:00:00 GMTMT5PyBot 🤖 - Account Monitor & Data Visualisation Dashboard

In this presentation we will see the details of the Account Monitor and the required infrastructure for the same. Thereafter we will also see the visualization of the trading strategy’s performance metrics in a data visualization dashboard and finally a demo of the dashboard.

MongoDB ChangeStreams and WebSockets - https://community.plotly.com/t/mongodb-websocket-and-dash-plot/64808

]]>
<![CDATA[MT5PyBot 🤖- Strategy and AlgoBot in Action]]>In this presentation we will see a description of the Double Bollinger Band Strategy and a code walkthrough of the strategy module. In the second half we will also see the Algorithmic Trading Bot in action on an AWS Windows EC2 instance trading the Double Bollinger Band Strategy.

]]>
http://localhost:2368/strategy_algobot_action/643c151fe770773c74f2e5fdWed, 31 Aug 2022 14:14:00 GMT

In this presentation we will see a description of the Double Bollinger Band Strategy and a code walkthrough of the strategy module. In the second half we will also see the Algorithmic Trading Bot in action on an AWS Windows EC2 instance trading the Double Bollinger Band Strategy.

]]>