46 KiB
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 [ ]: