Files
strategy-lab/to_explore/notebooks/QQ_TelegramSignals.ipynb

46 KiB

In [ ]:
from vectorbtpro import *
# whats_imported()

vbt.settings.set_theme("dark")
In [ ]:
# Pull data

def date_parser(timestamps):
    # First column are integer timestamps, parse them into DatetimeIndex
    return pd.to_datetime(timestamps, utc=True, unit="ms")

data = vbt.CSVData.pull("download/xauusd-m1-bid-2021-09-01-2023-03-14.csv", date_parser=date_parser)

print(data.wrapper.shape)
In [ ]:
# Pull signals
signal_data = vbt.CSVData.pull("download/TG_Extracted_Signals.csv", index_col=1)

print(signal_data.wrapper.shape)
In [ ]:
# Numba doesn't understand strings, thus create an enumerated type for stop types

# Create a type first
OrderTypeT = namedtuple("OrderTypeT", ["BUY", "SELL", "BUYSTOP", "SELLSTOP"])

# Then create a tuple
OrderType = OrderTypeT(*range(len(OrderTypeT._fields)))

print(OrderType)
In [ ]:
# Prepare signals

def transform_signal_data(df):
    # Select only one symbol, the one we pulled the data for
    df = df[df["Symbol"] == "XAUUSD"]
    
    # Select columns of interest
    df = df.iloc[:, -7:]
    
    # Map order types using OrderType
    df["OrderType"] = df["OrderType"].map(lambda x: OrderType._fields.index(x.replace(" ", "")))
    
    # Some entry prices are zero
    df = df[df["EntryPrice"] > 0]
    
    return df

signal_data = signal_data.transform(transform_signal_data)

print(signal_data.wrapper.shape)
In [ ]:
# Create named tuples which will act as containers for various arrays

# SignalInfo will contain signal information in a vbt-friendly format
# Rows in each array correspond to signals
SignalInfo = namedtuple("SignalInfo", [
    "timestamp",  # 1d array with timestamps in nanosecond format (int64)
    "order_type",  # 1d array with order types in integer format (int64, see order_type_map)
    "entry_price",  # 1d array with entry price (float64)
    "sl",  # 2d array where columns are SL levels (float64)
    "tp",  # 2d array where columns are TP levels (float64)
])

# TempInfo will contain temporary information that will be written during backtesting
# You can imagine being buffer that we write and then access at a later time
# Rows in each array correspond to signals
TempInfo = namedtuple("TempInfo", [
    "ts_bar",  # 1d array with row indices where signal was hit (int64)
    "entry_price_bar",  # 1d array with row indices where entry price was hit (int64)
    "sl_bar",  # 2d array with row indices where each SL level was hit, same shape as SignalInfo.sl (int64)
    "tp_bar",  # 2d array with row indices where each TP level was hit, same shape as SignalInfo.tp (int64)
])
In [ ]:
# Here's what we will do:
# Represent each signal as a separate column with its own starting capital
# Run an order function using Portfolio.from_order_func
# The order function is executed at each bar and column (signal in our case)
# If the current bar contains a signal, execute the signal logic
# Order functions can issue only one order at bar, thus we if multiple stops were hit, we will aggregate them
# We will go all in and then gradually reduce the position based on the number of stops

@njit
def has_data_nb(c):
    # Numba function to check whether OHLC is not NaN
    if np.isnan(vbt.pf_nb.select_nb(c, c.open)):
        return False
    if np.isnan(vbt.pf_nb.select_nb(c, c.high)):
        return False
    if np.isnan(vbt.pf_nb.select_nb(c, c.low)):
        return False
    if np.isnan(vbt.pf_nb.select_nb(c, c.close)):
        return False
    return True

@njit
def check_price_hit_nb(c, price, hit_below, can_use_ohlc):
    # Numba function to check whether a price level was hit during this bar
    # Use hit_below=True to check against low and hit_below=False to check against high
    # If can_use_ohlc is False, will check only against the close price
    
    order_price, hit_on_open, hit = vbt.pf_nb.check_price_hit_nb(
        open=vbt.pf_nb.select_nb(c, c.open),  # OHLC are flexible arrays, always use select_nb!
        high=vbt.pf_nb.select_nb(c, c.high),
        low=vbt.pf_nb.select_nb(c, c.low),
        close=vbt.pf_nb.select_nb(c, c.close),
        price=price,
        hit_below=hit_below,
        can_use_ohlc=can_use_ohlc
    )
    # Order price here isn't necessarily the price that has been hit
    # For example, if the price was hit before open, order price is set to the open price
    return order_price, hit

@njit(boundscheck=True)
def order_func_nb(c, signal_info, temp_info):  # first argument is context, other are our containers
    if not has_data_nb(c):
        # If this bar contains no data, skip it
        return vbt.pf_nb.order_nothing_nb()
    
    # Each column corresponds to a signal
    signal = c.col
    
    # Each row corresponds to a bar
    bar = c.i
    
    # Define various flags for pure convenience
    buy_market = signal_info.order_type[signal] == OrderType.BUY
    sell_market = signal_info.order_type[signal] == OrderType.SELL
    buy_stop = signal_info.order_type[signal] == OrderType.BUYSTOP
    sell_stop = signal_info.order_type[signal] == OrderType.SELLSTOP
    buy = buy_market or buy_stop
    
    # First, we need to check whether the current bar contains a signal
    can_use_ohlc = True
    if temp_info.ts_bar[signal] == -1:
        if c.index[bar] == signal_info.timestamp[signal]:
            # If so, store the current row index in a temporary array
            # such that later we know that we already discovered a signal
            temp_info.ts_bar[signal] = bar

            # The signal has the granularity of seconds, thus it belongs somewhere in the bar
            # We need to notify the functions below that they cannot use full OHLC information, only close
            # This is to avoid using prices that technically happened before the signal
            can_use_ohlc = False
        
    # Here comes the entry order
    # Check whether the signal has been discovered
    # -1 means hasn't been discovered yet
    if temp_info.ts_bar[signal] != -1:
        
        # Then, check whether the entry order hasn't been executed
        if temp_info.entry_price_bar[signal] == -1:
            
            # If so, execute the entry order
            if buy_market:
                # Buy market order (using closing price)
                
                # Store the current row index in a temporary array such that future bars know
                # that the order has already been executed
                temp_info.entry_price_bar[signal] = bar
                order_price = signal_info.entry_price[signal]
                return vbt.pf_nb.order_nb(np.inf, np.inf)  # size, price
            
            if sell_market:
                # Sell market order (using closing price)
                temp_info.entry_price_bar[signal] = bar
                order_price = signal_info.entry_price[signal]
                return vbt.pf_nb.order_nb(-np.inf, np.inf)
            
            if buy_stop:
                # Buy stop order
                # A buy stop order is entered at a stop price above the current market price
                
                # Since it's a pending order, we first need to check whether the entry price has been hit
                order_price, hit = check_price_hit_nb(
                    c,
                    price=signal_info.entry_price[signal],
                    hit_below=False,
                    can_use_ohlc=can_use_ohlc,
                )
                if hit:
                    # If so, execute the order
                    temp_info.entry_price_bar[signal] = bar
                    return vbt.pf_nb.order_nb(np.inf, order_price)
                
            if sell_stop:
                # Sell stop order
                # A sell stop order is entered at a stop price below the current market price
                order_price, hit = check_price_hit_nb(
                    c,
                    price=signal_info.entry_price[signal],
                    hit_below=True,
                    can_use_ohlc=can_use_ohlc,
                )
                if hit:
                    temp_info.entry_price_bar[signal] = bar
                    return vbt.pf_nb.order_nb(-np.inf, order_price)
               
        # Here comes the stop order
        # Check whether the entry order has been executed
        if temp_info.entry_price_bar[signal] != -1:
            
            # We also need to check whether we're still in a position
            # in case stops have already closed out the position
            if c.last_position[signal] != 0:
                
                # If so, start with checking for potential SL orders
                # (remember that SL pessimistically comes before TP)
                # First, we need to know the number of potential and already executed SL levels
                # since we want to gradually reduce the position proportially to the number of levels
                # For example, one signal may define [12.35, 12.29] and another [17.53, nan]
                n_sl_levels = 0
                n_sl_hits = 0
                sl_levels = signal_info.sl[signal]  # select 1d array from 2d array
                sl_bar = temp_info.sl_bar[signal]  # same here
                for k in range(len(sl_levels)):
                    if not np.isnan(sl_levels[k]):
                        n_sl_levels += 1
                    if sl_bar[k] != -1:
                        n_sl_hits += 1
                
                # We can execute only one order at the current bar
                # Thus, if the price crossed multiple SL levels, we need to pack them into one order
                # Since SL levels are guaranteed to be sorted, we will check the most distant levels first
                # because if a distant stop has been hit, the closer stops are automatically hit too
                for k in range(n_sl_levels - 1, n_sl_hits - 1, -1):
                    if not np.isnan(sl_levels[k]) and sl_bar[k] == -1:
                        # Check against low for buy orders and against high for sell orders
                        order_price, hit = check_price_hit_nb(
                            c,
                            price=sl_levels[k],
                            hit_below=buy,
                            can_use_ohlc=can_use_ohlc,
                        )
                        if hit:
                            sl_bar[k] = bar
                            # The further away the stop is, the more of the position needs to be closed
                            # We will specify a target percentage
                            # For example, for two stops it would be 0.5 (SL1) and 0.0 (SL2)
                            # while for three stops it would be 0.66 (SL1), 0.33 (SL2), and 0.0 (SL3)
                            # This works only if we went all in before (size=np.inf)!
                            size = 1 - (k + 1) / n_sl_levels
                            size_type = vbt.pf_enums.SizeType.TargetPercent
                            if buy:
                                return vbt.pf_nb.order_nb(size, order_price, size_type)
                            else:
                                # Size must be negative for short positions
                                return vbt.pf_nb.order_nb(-size, order_price, size_type)
                        
                # Same for potential TP orders
                n_tp_levels = 0
                n_tp_hits = 0
                tp_levels = signal_info.tp[signal]
                tp_bar = temp_info.tp_bar[signal]
                for k in range(len(tp_levels)):
                    if not np.isnan(tp_levels[k]):
                        n_tp_levels += 1
                    if tp_bar[k] != -1:
                        n_tp_hits += 1
                
                for k in range(n_tp_levels - 1, n_tp_hits - 1, -1):
                    if not np.isnan(tp_levels[k]) and tp_bar[k] == -1:
                        # Check against high for buy orders and against low for sell orders
                        order_price, hit = check_price_hit_nb(
                            c,
                            price=tp_levels[k],
                            hit_below=not buy,
                            can_use_ohlc=can_use_ohlc,
                        )
                        if hit:
                            tp_bar[k] = bar
                            size = 1 - (k + 1) / n_tp_levels
                            size_type = vbt.pf_enums.SizeType.TargetPercent
                            if buy:
                                return vbt.pf_nb.order_nb(size, order_price, size_type)
                            else:
                                return vbt.pf_nb.order_nb(-size, order_price, size_type)
                    
    # If neither of orders has been executed, order nothing
    return vbt.pf_nb.order_nothing_nb()
In [ ]:
# Prepare signal information

timestamp = vbt.dt.to_ns(signal_data.index)  # nanoseconds
order_type = signal_data.get("OrderType").values
entry_price = signal_data.get("EntryPrice").values
sl = signal_data.get("SL").values
tp1 = signal_data.get("TP1").values
tp2 = signal_data.get("TP2").values
tp3 = signal_data.get("TP3").values
tp4 = signal_data.get("TP4").values

n_signals = len(timestamp)
print(n_signals)
In [ ]:
# Since the signals are of the second granularity while the data is of the minute granularity,
# we need to round the timestamp of the signal to the nearest minute
# Timestamps represent the opening time, thus the second "19:28:59" belongs to the minute "19:28:00"

timestamp = timestamp - timestamp % vbt.dt_nb.m_ns
In [ ]:
# Create a named tuple for signal information

signal_info = SignalInfo(
    timestamp=timestamp,
    order_type=order_type,
    entry_price=entry_price,
    sl=np.column_stack((sl,)),
    tp=np.column_stack((tp1, tp2, tp3, tp4))
)

n_sl_levels = signal_info.sl.shape[1]
print(n_sl_levels)

n_tp_levels = signal_info.tp.shape[1]
print(n_tp_levels)
In [ ]:
# Important: re-run this cell every time you're running the simulation!
# Create a named tuple for temporary information
# All arrays below hold row indices, thus the default value is -1

def build_temp_info(signal_info):
    return TempInfo(
        ts_bar=np.full(len(signal_info.timestamp), -1),
        entry_price_bar=np.full(len(signal_info.timestamp), -1),
        sl_bar=np.full(signal_info.sl.shape, -1),
        tp_bar=np.full(signal_info.tp.shape, -1)
    )

temp_info = build_temp_info(signal_info)
In [ ]:
# By default, vectorbt initializes an empty order array of the same shape as data
# But since our data is highly granular, it would take a lot of RAM
# Let's limit the number of records to one entry order and the maximum number of SL and TP orders
# It will be applied per column

max_order_records = 1 + n_sl_levels + n_tp_levels

print(max_order_records)
In [ ]:
# Perform the actual simulation
# Since we don't broadcast data against any other array, vectorbt doesn't know anything about
# our signal arrays and will simulate only the one column in our data
# Thus, we need to tell it to expand the number of columns by the number of signals using tiling
# But don't worry: thanks to flexible indexing vectorbt won't actually tile the data - good for RAM!
# (it would tile the data if it had multiple columns though!)

pf = vbt.Portfolio.from_order_func(
    data,
    order_func_nb=order_func_nb,
    order_args=(signal_info, temp_info),
    broadcast_kwargs=dict(tile=n_signals),  # tiling here
    max_order_records=max_order_records,
    freq="minute"  # we have an irregular one-minute frequency
)
# (may take a minute...)
In [ ]:
# Let's print out the order records in a human-readable format

print(pf.orders.records_readable)
In [ ]:
# We can notice above that there's no information whether an order is an SL or TP order
# What we can do is to create our own order records with custom fields, copy the old ones over,
# and tell the portfolio to use them instead of the default ones

# First, we need to create an enumerated field for stop types
# SL levels will come first, TP levels second, in an incremental fashion
StopTypeT = namedtuple("StopTypeT", [
    *[f"SL{i + 1}" for i in range(n_sl_levels)],
    *[f"TP{i + 1}" for i in range(n_tp_levels)]
])
StopType = StopTypeT(*range(len(StopTypeT._fields)))

print(StopType)
In [ ]:
# To extend order records, we just need to append new fields and construct a new data type

custom_order_dt = np.dtype(vbt.pf_enums.order_fields + [("order_type", np.int_), ("stop_type", np.int_)])

def fix_order_records(order_records, signal_info, temp_info):
    # This is a function that will "fix" our default records and return the fixed ones
    
    # Create a new empty record array with the new data type
    # Empty here means that the array isn't initialized yet and contains junk data
    # Thus, make sure to override each single element
    custom_order_records = np.empty(order_records.shape, dtype=custom_order_dt)
    
    # Copy over the information from our default records
    for field, _ in vbt.pf_enums.order_fields:
        custom_order_records[field] = order_records[field]
        
    # Iterate over the new records and fill the stop type
    for i in range(len(custom_order_records)):
        record = custom_order_records[i]
        signal = record["col"]  # each column corresponds to a signal
        
        # Fill the order type
        record["order_type"] = signal_info.order_type[signal]
        
        # Concatenate SL and TP row indices of this signal into a new list
        # We must do it the same way as we did in StopTypeT
        bar = [
            *temp_info.sl_bar[signal],
            *temp_info.tp_bar[signal]
        ]
        
        # Check whether the row index of this order is in this list
        # (which means that this order is a stop order)
        if record["idx"] in bar:
            # If so, get the matching position in this list and use it as order type
            # It will correspond to a field in StopType
            record["stop_type"] = bar.index(record["idx"])
        else:
            record["stop_type"] = -1
    return custom_order_records
            
custom_order_records = fix_order_records(pf.order_records, signal_info, temp_info)
print(custom_order_records[:10])
In [ ]:
# Having raw order records is not enough as vbt.Orders doesn't know what to do with the new field
# (remember that vbt.Orders is used to analyze the records)
# Let's create our custom class that subclasses vbt.Orders
# and override the field config to also include the information on the new field

from vectorbtpro.records.decorators import attach_fields, override_field_config

@attach_fields(dict(stop_type=dict(attach_filters=True)))
@override_field_config(dict(
    dtype=custom_order_dt,  # specify the new data type
    settings=dict(
        order_type=dict(
            title="Order Type",  # specify a human-readable title for the field
            mapping=OrderType,  # specify the mapper for the field
        ),
        stop_type=dict(
            title="Stop Type",  # specify a human-readable title for the field
            mapping=StopType,  # specify the mapper for the field
        ),
    )
))
class CustomOrders(vbt.Orders):
    pass
In [ ]:
# Finally, let's replace the order records and the class in the portfolio

pf = pf.replace(order_records=custom_order_records, orders_cls=CustomOrders)
In [ ]:
# We can now effortlessly analyze the stop type

print(pf.orders.records_readable)
In [ ]:
# And here are the signals that correspond to these records for verification

print(signal_data.get())
In [ ]:
# We can see that some signals were skipped, let's remove them from the portfolio

pf = pf.loc[:, pf.orders.count() >= 1]

print(len(pf.wrapper.columns))
In [ ]:
# There are various ways to analyze the data
# For example, we can count how many times each stop type was triggered
# Since we want to combine all trades in each statistic, we need to provide grouping

print(pf.orders.stop_type.stats(group_by=True))
In [ ]:
# We can also get the position stats for P&L information

print(pf.positions.stats(group_by=True))
In [ ]:
# Let's plot a random trade
# The only issue: we have too much data for that (thanks to Plotly)
# Thus, crop it before plotting to remove irrelevant data

signal = np.random.choice(len(pf.wrapper.columns))
pf.trades.iloc[:, signal].crop().plot().show_svg()
In [ ]:
# Let's verify that the entry price stays within each candle

print(pd.concat((
    pf.orders.records_readable[["Column", "Order Type", "Stop Type", "Price"]],
    pf.orders.bar_high.to_readable(title="High", only_values=True),
    pf.orders.bar_low.to_readable(title="Low", only_values=True),
    pf.orders.price_status.to_readable(title="Price Status", only_values=True),
), axis=1))

print(pf.orders.price_status.stats(group_by=True))
In [ ]:
# Now, what if we're interested in portfolio metrics, such as the Sharpe ratio?
# The problem is that most metrics are producing multiple (intermediate) time series 
# of the full shape, which is disastrous for RAM since our data will have to be tiled 
# by the number of columns. But here's a trick: merge order records of all columns into one, 
# as if we did the simulation on just one column!

def merge_order_records(order_records):
    merged_order_records = order_records.copy()
    
    # New records should have only one column
    merged_order_records["col"][:] = 0
    
    # Sort the records by the timestamp
    merged_order_records = merged_order_records[np.argsort(merged_order_records["idx"])]
    
    # Reset the order ids
    merged_order_records["id"][:] = np.arange(len(merged_order_records))
    return merged_order_records

merged_order_records = merge_order_records(custom_order_records)
print(merged_order_records[:10])
In [ ]:
# We also need to change the wrapper because it holds the information on our columns

merged_wrapper = pf.wrapper.replace(columns=[0], ndim=1)
In [ ]:
# Is there any other array that requires merging?
# Let's introspect the portfolio instance and search for arrays of the full shape

print(pf)
In [ ]:
# There are none, thus replace only the records and the wrapper
# Also, the previous individual portfolios were each using the starting capital of $100
# Which was used by 100%, but since we merge columns together, we now may require less starting capital
# Thus, we will determine it automatically

merged_pf = pf.replace(
    order_records=merged_order_records, 
    wrapper=merged_wrapper,
    init_cash="auto"
)
In [ ]:
# We can now get any portfolio statistic

print(merged_pf.stats())
In [ ]:
# You may wonder why the win rate and other trade metrics are different here
# There are two reasons: 
# 1) portfolio stats uses exit trades (previously we used positions), 
#     that is, each stop order is a trade
# 2) after merging, there's no more information which order belongs to which trade, 
#     thus positions are built in a sequential order

# But to verify that both portfolio match, we can compare to the total profit to the previous trade P&L
print(merged_pf.total_profit)
print(pf.trades.pnl.sum(group_by=True))
In [ ]:
# We can now plot the entire portfolio

merged_pf.resample("daily").plot().show_svg()
In [ ]:
# The main issue with using from_order_func is that we need to go over the entire data 
# as many times as there are signals because the order function is run on single each element
# A far more time-efficient approach would be processing trades in a sequential order
# This is easily possible because our trades are perfectly sorted - we don't need
# to process a signal if the previous signal hasn't been processed yet
# Also, because the scope of this notebook assumes that signals are independent, 
# we can simulate them independently and stop each signal's simulation once its position has been closed out
# This is only possible by writing an own simulator (which isn't as scary as it sounds!)

# To avoid duplicating our signal logic, we will re-use order_func_nb by passing our own limited context
# It will consist only of the fields that are required by our order_func_nb

OrderContext = namedtuple("OrderContext", [
    "i",
    "col",
    "index",
    "open",  
    "high",
    "low",
    "close",
    "last_position"
])
In [ ]:
# Let's build the simulator
# Technically, it's just a regular Numba function that does whatever we want
# What's special about it is that it calls the vectorbt's low-level API to place orders and 
# updates the simulation state such as cash balances and positions
# We'll first determine the bars where the signals happen, and then run a smaller simulation
# on the first signal. Once the signal's position has been closed out, we'll terminate the simulation
# and continue with the next signal, until all signals are processed.

@njit(boundscheck=True)
def signal_simulator_nb(
    index, 
    open, 
    high, 
    low, 
    close, 
    signal_info,
    temp_info
):
    # Determine the number of signals, levels, and potential orders
    n_signals = len(signal_info.timestamp)
    n_sl_levels = signal_info.sl.shape[1]
    n_tp_levels = signal_info.tp.shape[1]
    max_order_records = 1 + n_sl_levels + n_tp_levels
    
    # Temporary arrays
    
    # This array will hold the bar where each signal happens
    signal_bars = np.full(n_signals, -1, dtype=np.int_)
    
    # This array will hold order records
    # Initially, order records are uninitialized (junk data) but we will fill them gradually
    # Notice how we use our own data type custom_order_dt - we can fill order type and stop type 
    # fields right during the simulation
    order_records = np.empty((max_order_records, n_signals), dtype=custom_order_dt)
    
    # To be able to distinguish between uninitialized and initialized (filled) orders,
    # we'll create another array holding the number of filled orders for each signal
    # For example, if order_records has a maximum of 6 rows and only one record is filled,
    # order_counts will be 1 for this signal, so vectorbt can remove 5 unfilled orders later
    order_counts = np.full(n_signals, 0, dtype=np.int_)
    
    # order_func_nb requires last_position, which holds the position of each signal
    last_position = np.full(n_signals, 0.0, dtype=np.float_)
    
    # First, we need to determine the bars where the signals happen
    # Even though we know their timestamps, we need to translate them into absolute indices
    signal = 0
    bar = 0
    while signal < n_signals and bar < len(index):
        if index[bar] == signal_info.timestamp[signal]:
            # If there's a match, save the bar and continue with the next signal on the next bar
            signal_bars[signal] = bar
            signal += 1
            bar += 1
        elif index[bar] > signal_info.timestamp[signal]:
            # If we're past the signal, continue with the next signal on the same bar
            signal += 1
        else:
            # If we haven't hit the signal yet, continue on the next bar
            bar += 1

    # Once we know the bars, we can iterate over signals in a loop and simulate them independently
    for signal in range(n_signals):
        
        # If there was no match in the previous level, skip the simulation
        from_bar = signal_bars[signal]
        if from_bar == -1:
            continue
            
        # This is our initial execution state, which holds the most important balances
        # We'll start with a starting capital of $100
        exec_state = vbt.pf_enums.ExecState(
            cash=100.0,
            position=0.0,
            debt=0.0,
            locked_cash=0.0,
            free_cash=100.0,
            val_price=np.nan,
            value=np.nan
        )
            
        # Here comes the actual simulation that starts from the signal's bar and
        # ends either once we processed all bars or once the position has been closed out (see below)
        for bar in range(from_bar, len(index)):
            
            # Create a named tuple holding the current context (this is "c" in order_func_nb)
            c = OrderContext(  
                i=bar,
                col=signal,
                index=index,
                open=open,
                high=high,
                low=low,
                close=close,
                last_position=last_position,
            )
            
            # If the first bar has no data, skip the simulation
            if bar == from_bar and not has_data_nb(c):
                break

            # Price area holds the OHLC of the current bar
            price_area = vbt.pf_enums.PriceArea(
                vbt.flex_select_nb(open, bar, signal), 
                vbt.flex_select_nb(high, bar, signal), 
                vbt.flex_select_nb(low, bar, signal), 
                vbt.flex_select_nb(close, bar, signal)
            )
            
            # Why do we need to redefine the execution state?
            # Because we need to manually update the valuation price and the value of the column
            # to be able to use complex size types such as target percentages
            # As in order_func_nb, we will use the opening price as the valuation price
            # Why doesn't vectorbt do it on its own? Because it doesn't know anything
            # about other columns. For example, imagine having a grouped simulation with 100 columns sharing
            # the same cash: using the formula below wouldn't consider the positions of other 99 columns.
            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=price_area.open,
                value=exec_state.cash + price_area.open * exec_state.position
            )
            
            # Let's run the order function, which returns an order
            # Remember when we used order_nothing_nb()? It also returns an order but with filled with nans
            order = order_func_nb(c, signal_info, temp_info)
            
            # Here's the main function in the entire simulation, which 1) executes the order,
            # 2) updates the execution state, and 3) updates the order_records and order_counts
            order_result, exec_state = vbt.pf_nb.process_order_nb(
                signal, 
                signal, 
                bar,
                exec_state=exec_state,
                order=order,
                price_area=price_area,
                order_records=order_records,
                order_counts=order_counts
            )
                    
            # If the order was successful (i.e., it's now in order_records),
            # we need to manually set the order type and stop type
            if order_result.status == vbt.pf_enums.OrderStatus.Filled:
                
                # Use this line to get the last order of any signal
                filled_order = order_records[order_counts[signal] - 1, signal]
                
                # Fill the order type
                filled_order["order_type"] = signal_info.order_type[signal]
                
                # Fill the stop type by going through the SL and TP levels and checking whether 
                # the order bar matches the level bar
                order_is_stop = False
                for k in range(n_sl_levels):
                    if filled_order["idx"] == temp_info.sl_bar[signal, k]:
                        filled_order["stop_type"] = k
                        order_is_stop = True
                        break
                for k in range(n_tp_levels):
                    if filled_order["idx"] == temp_info.tp_bar[signal, k]:
                        filled_order["stop_type"] = n_sl_levels + k  # TP indices come after SL indices
                        order_is_stop = True
                        break
                
                # If order bar hasn't been matched, it's not a stop order
                if not order_is_stop:
                    filled_order["stop_type"] = -1
                    
            # If we're not in position after an entry anymore, terminate the simulation
            if temp_info.entry_price_bar[signal] != -1:
                if exec_state.position == 0:
                    break
                    
            # Don't forget to update the position array
            last_position[signal] = exec_state.position
        
    # Remove uninitialized order records and flatten 2d array into a 1d array
    return vbt.nb.repartition_nb(order_records, order_counts)
In [ ]:
# Numba requires arrays in a NumPy format, and to avoid preparing them each time,
# let's create a function that only takes the data and signal information, and does everything else for us

def signal_simulator(data, signal_info):
    temp_info = build_temp_info(signal_info)
    
    custom_order_records = signal_simulator_nb(
        index=data.index.vbt.to_ns(),  # convert to nanoseconds
        open=vbt.to_2d_array(data.open),  # flexible indexing requires inputs to be 2d
        high=vbt.to_2d_array(data.high),
        low=vbt.to_2d_array(data.low),
        close=vbt.to_2d_array(data.close),
        signal_info=signal_info,
        temp_info=temp_info
    )
    
    # We have order records, what's left is wrapping them with a Portfolio
    # Required are three things: 1) array wrapper with index and columns, 2) order records, and 3) prices
    # We also need to specify the starting capital that we used during the simulation
    return vbt.Portfolio(
        wrapper=vbt.ArrayWrapper(
            index=data.index, 
            columns=range(len(signal_info.timestamp)),  # one column per signal
            freq="minute"
        ),
        order_records=custom_order_records,
        open=data.open,
        high=data.high,
        low=data.low,
        close=data.close,
        init_cash=100.0,
        orders_cls=CustomOrders
    )

# That's it!
pf = signal_simulator(data, signal_info)

print(pf.trades.pnl.sum(group_by=True))
In [ ]: