Skip to content

Portfolio

Index records

  • How do you backtest time and asset-anchored queries such as "Order X units of asset Y on date Z"? Normally, you'd have to construct a full array and set information manually. But now there's a less stressful way: thanks to the preparers and redesigned smart indexing you can provide all information in a compressed record format! Under the hood, the record array will be translated into a set of index dictionaries - one per argument.
Define a basic signal strategy using records
>>> data = vbt.YFData.pull(["BTC-USD", "ETH-USD"], missing_index="drop")
>>> records = [
...     dict(date="2022", symbol="BTC-USD", long_entry=True),  # (1)!
...     dict(date="2022", symbol="ETH-USD", short_entry=True),
...     dict(row=-1, exit=True),
... ]
>>> pf = vbt.PF.from_signals(data, records=records)  # (2)!
>>> pf.orders.readable
   Order Id   Column              Signal Index            Creation Index   
0         0  BTC-USD 2022-01-01 00:00:00+00:00 2022-01-01 00:00:00+00:00  \
1         1  BTC-USD 2023-04-25 00:00:00+00:00 2023-04-25 00:00:00+00:00   
2         0  ETH-USD 2022-01-01 00:00:00+00:00 2022-01-01 00:00:00+00:00   
3         1  ETH-USD 2023-04-25 00:00:00+00:00 2023-04-25 00:00:00+00:00   

                 Fill Index      Size         Price  Fees  Side    Type   
0 2022-01-01 00:00:00+00:00  0.002097  47686.812500   0.0   Buy  Market  \
1 2023-04-25 00:00:00+00:00  0.002097  27534.675781   0.0  Sell  Market   
2 2022-01-01 00:00:00+00:00  0.026527   3769.697021   0.0  Sell  Market   
3 2023-04-25 00:00:00+00:00  0.026527   1834.759644   0.0   Buy  Market   

  Stop Type  
0      None  
1      None  
2      None  
3      None  
  1. Every broadcastable argument is supported. Rows can be provided via "row", "index", "date", or "datetime". Columns can be provided via "col", "column", or "symbol". If a row or column is not provided, will set the entire row or column respectively. If neither of them is provided, will set the entire array. Rows and columns can be provided as integer positions, labels, datetimes, or even complex indexers!
  2. Arguments not used in records can still be provided as usual. Arguments used in records can also be provided to be used as defaults.

Portfolio preparers

  • When you pass an argument to a simulation method such as Portfolio.from_signals, it must go through a complex preparation pipeline first to get a format suitable for Numba. Such a pipeline usually involves mapping of enums, broadcasting, data type checks, template substitution, and many other steps. To make VBT more transparent, this pipeline has been outsourced to a separate class so you now have the full control of arguments that are getting passed to the Numba functions! Moreover, you can extend the preparers to automatically prepare arguments for your own simulators.
Get a prepared argument, modify it, and use in a new simulation
>>> data = vbt.YFData.pull("BTC-USD", end="2017-01")
>>> prep_result = vbt.PF.from_holding(
...     data, 
...     stop_ladder="uniform",
...     tp_stop=vbt.Param([
...         [0.1, 0.2, 0.3, 0.4, 0.5],
...         [0.4, 0.5, 0.6],
...     ], keys=["tp_ladder_1", "tp_ladder_2"]),
...     return_prep_result=True  # (1)!
... )
>>> prep_result.target_args["tp_stop"]  # (2)!
array([[0.1, 0.4],
       [0.2, 0.5],
       [0.3, 0.6],
       [0.4, nan],
       [0.5, nan]])

>>> new_tp_stop = prep_result.target_args["tp_stop"] + 0.1
>>> new_prep_result = prep_result.replace(target_args=dict(tp_stop=new_tp_stop), nested_=True)  # (3)!
>>> new_prep_result.target_args["tp_stop"]
array([[0.2, 0.5],
       [0.3, 0.6],
       [0.4, 0.7],
       [0.5, nan],
       [0.6, nan]])

>>> pf = vbt.PF.from_signals(new_prep_result)  # (4)!
>>> pf.total_return
tp_stop
tp_ladder_1    0.4
tp_ladder_2    0.6
Name: total_return, dtype: float64

>>> sim_out = new_prep_result.target_func(**new_prep_result.target_args)  # (5)!
>>> pf = vbt.PF(order_records=sim_out, **new_prep_result.pf_args)
>>> pf.total_return
tp_stop
tp_ladder_1    0.4
tp_ladder_2    0.6
Name: total_return, dtype: float64
  1. Return the result of the preparation
  2. Contains two attributes: target arguments as target_args and portfolio arguments as pf_args
  3. Replace the argument. Since it resides in another dictionary (target_args), we need to enable nested_. The result is a new instance of PFPrepResult.
  4. Pass the new preparation result as the first argument to the base simulation method
  5. Or simulate manually!

Stop laddering

  • Stop laddering is a technique to incrementally move out of a position. Instead of providing a single stop value to close out the position, you can now provide an array of stop values, each removing a certain amount of the position when hit. This amount can be controlled by providing a differnet ladder mode. Furthermore, thanks to a new broadcasting feature that allows arrays to broadcast along just one axis, the stop values don't need to have the same shape as data. You can even provide stop arrays of different shapes as parameters!
Test two TP ladders
>>> data = vbt.YFData.pull("BTC-USD", end="2017-01")
>>> pf = vbt.PF.from_holding(
...     data, 
...     stop_ladder="uniform",
...     tp_stop=vbt.Param([
...         [0.1, 0.2, 0.3, 0.4, 0.5],
...         [0.4, 0.5, 0.6],
...     ], keys=["tp_ladder_1", "tp_ladder_2"])
... )
>>> pf.trades.plot(column="tp_ladder_1").show()

Staticization

  • One of the biggest limitations of Numba is that functions passed as arguments (i.e., callbacks) make the main function uncacheable, such that it has to be re-compiled in each new runtime, over and over again. Especially the performance of simulator functions takes a hit as they usually take up to a minute to compile. Gladly, we've got a new neat trick in our arsenal: "staticization". It works as follows. First, the source code of a function gets annotated with a special syntax. The annotated code then gets extracted (a.k.a. "cutting" ✂), modified into a cacheable version by removing any callbacks from the arguments, and saved to a Python file. Once the function gets called again, the cacheable version gets executed. Sounds complex? See below!
Define the signal function in a Python file, here signal_func_nb.py
from vectorbtpro import *

@njit
def signal_func_nb(c, fast_sma, slow_sma):  # (1)!
    long = vbt.pf_nb.iter_crossed_above_nb(c, fast_sma, slow_sma)
    short = vbt.pf_nb.iter_crossed_below_nb(c, fast_sma, slow_sma)
    return long, False, short, False
  1. Make sure that the function is named exactly as the callback argument
Run a staticized simulation. Running the script again won't re-compile.
>>> data = vbt.YFData.pull("BTC-USD")
>>> pf = vbt.PF.from_signals(
...     data,
...     signal_func_nb="signal_func_nb.py",  # (1)!
...     signal_args=(vbt.Rep("fast_sma"), vbt.Rep("slow_sma")),
...     broadcast_named_args=dict(
...         fast_sma=data.run("sma", 20, hide_params=True, unpack=True), 
...         slow_sma=data.run("sma", 50, hide_params=True, unpack=True)
...     ),
...     staticized=True  # (2)!
... )
  1. Path to the module the function resides in
  2. Does all the magic ✨

Position info

  • Dynamic signal functions have now access to the current position information such as (open) P&L.
Enter randomly, exit randomly but only if in profit
>>> @njit
... def signal_func_nb(c, entries, exits):
...     is_entry = vbt.pf_nb.select_nb(c, entries)
...     is_exit = vbt.pf_nb.select_nb(c, exits)
...     if is_entry:
...         return True, False, False, False
...     if is_exit:
...         pos_info = c.last_pos_info[c.col]
...         if pos_info["status"] == vbt.pf_enums.TradeStatus.Open:
...             if pos_info["pnl"] >= 0:
...                 return False, True, False, False
...     return False, False, False, False

>>> data = vbt.YFData.pull("BTC-USD")
>>> entries, exits = data.run("RANDNX", n=10, unpack=True)
>>> pf = vbt.Portfolio.from_signals(
...     data,
...     signal_func_nb=signal_func_nb,
...     signal_args=(vbt.Rep("entries"), vbt.Rep("exits")),
...     broadcast_named_args=dict(entries=entries, exits=exits),
...     jitted=False  # (1)!
... )
>>> pf.trades.readable[["Entry Index", "Exit Index", "PnL"]]
                Entry Index                Exit Index           PnL
0 2014-11-01 00:00:00+00:00 2016-01-08 00:00:00+00:00     39.134739
1 2016-03-27 00:00:00+00:00 2016-09-07 00:00:00+00:00     61.220063
2 2016-12-24 00:00:00+00:00 2016-12-31 00:00:00+00:00     14.471414
3 2017-03-16 00:00:00+00:00 2017-08-05 00:00:00+00:00    373.492028
4 2017-09-12 00:00:00+00:00 2018-05-05 00:00:00+00:00    815.699284
5 2019-02-15 00:00:00+00:00 2019-11-10 00:00:00+00:00   2107.383227
6 2019-12-04 00:00:00+00:00 2019-12-10 00:00:00+00:00     12.630214
7 2020-07-12 00:00:00+00:00 2021-11-14 00:00:00+00:00  21346.035444
8 2022-01-15 00:00:00+00:00 2023-03-06 00:00:00+00:00 -11925.133817
  1. Disable Numba when testing to avoid compilation

Time stops

  • Joining the ranks of stop orders, time stop orders can close out a position after a period of time or also on a specific date.
Enter randomly, exit before the end of the month
>>> data = vbt.YFData.pull("BTC-USD", start="2022-01", end="2022-04")
>>> entries = vbt.pd_acc.signals.generate_random(data.symbol_wrapper, n=10)
>>> pf = vbt.PF.from_signals(data, entries, dt_stop="M")  # (1)!
>>> pf.orders.readable[["Fill Index", "Side", "Stop Type"]]
                 Fill Index  Side Stop Type
0 2022-01-19 00:00:00+00:00   Buy      None
1 2022-01-31 00:00:00+00:00  Sell        DT
2 2022-02-25 00:00:00+00:00   Buy      None
3 2022-02-28 00:00:00+00:00  Sell        DT
4 2022-03-11 00:00:00+00:00   Buy      None
5 2022-03-31 00:00:00+00:00  Sell        DT
  1. dt_stop for datetime-based stops, td_stop for timedelta-based stops. Datetime-based stops can be periods ("D"), timestamps ("2023-01-01"), and even times ("18:00").

Target size to signals

  • Target size can be translated to signals using a special signal function to gain access to the stop and limit order functionality, for example, when performing a portfolio optimization.
Perform the Mean-Variance Optimization with SL and TP
>>> data = vbt.YFData.pull(
...     ["SPY", "TLT", "XLF", "XLE", "XLU", "XLK", "XLB", "XLP", "XLY", "XLI", "XLV"],
...     start="2022",
...     end="2023",
...     missing_index="drop"
... )
>>> pfo = vbt.PFO.from_riskfolio(data.returns, every="MS")
>>> pf = pfo.simulate(
...     data, 
...     pf_method="from_signals", 
...     sl_stop=0.05,
...     tp_stop=0.1, 
...     stop_exit_price="close"  # (1)!
... )
>>> pf.plot_allocations().show()
  1. Otherwise user and stop signals will happen at different times during the same bar, which makes establishing a correctly ordered sequence of orders impossible

Target price

  • Limit and stop orders can also be defined using a target price rather than a delta.
Set the SL to the previous low in a random portfolio
>>> data = vbt.YFData.pull("BTC-USD")
>>> pf = vbt.PF.from_random_signals(
...     data, 
...     n=100, 
...     sl_stop=data.low.vbt.ago(1),
...     delta_format="target"
... )
>>> sl_orders = pf.orders.stop_type_sl
>>> signal_index = pf.wrapper.index[sl_orders.signal_idx.values]
>>> hit_index = pf.wrapper.index[sl_orders.idx.values]
>>> hit_after = hit_index - signal_index
>>> hit_after
TimedeltaIndex([ '7 days',  '3 days',  '1 days',  '5 days',  '4 days',
                 '1 days', '28 days',  '1 days',  '1 days',  '1 days',
                 '1 days',  '1 days', '13 days', '10 days',  '5 days',
                 '1 days',  '3 days',  '4 days',  '1 days',  '9 days',
                 '5 days',  '1 days',  '1 days',  '2 days',  '1 days',
                 '1 days',  '1 days',  '3 days',  '1 days',  '1 days',
                 '1 days',  '1 days',  '1 days',  '2 days',  '2 days',
                 '1 days', '12 days',  '3 days',  '1 days',  '1 days',
                 '1 days',  '1 days',  '1 days',  '3 days',  '1 days',
                 '1 days',  '1 days',  '4 days',  '1 days',  '1 days',
                 '2 days',  '6 days', '11 days',  '1 days',  '2 days',
                 '1 days',  '1 days',  '1 days',  '1 days',  '1 days',
                 '4 days', '10 days',  '1 days',  '1 days',  '1 days',
                 '2 days',  '3 days',  '1 days'],
               dtype='timedelta64[ns]', name='Date', freq=None)

Leverage

  • Leverage has become an integral part of the portfolio simulation. Supports two different leverage modes: lazy (enables leverage only if there's not enough cash available) and eager (enables leverage while using only a fraction of the available cash). Allows setting leverage per order. Can also determine an optimal leverage value automatically to satisfy any order requirement! 🏋
Explore how leverage affects the equity curve in a random portfolio
>>> data = vbt.YFData.pull("BTC-USD", start="2020", end="2022")
>>> pf = vbt.PF.from_random_signals(
...     data, 
...     n=100,  
...     leverage=vbt.Param([0.5, 1, 2, 3]),
... )
>>> pf.value.vbt.plot().show()

Order delays

  • By default, VBT executes every order at the end of the current bar. To delay the execution to the next bar, one had to manually shift all the order-related arrays by one bar, which makes the operation prone to user mistakes. To avoid them, you can now simply specify the number of bars in the past you want the order information to be taken from. Moreover, the price argument has got the new options "nextopen" and "nextclose" for a one-line solution.
Compare orders without and with a delay in a random portfolio
>>> pf = vbt.PF.from_random_signals(
...     vbt.YFData.pull("BTC-USD", start="2021-01", end="2021-02"), 
...     n=3, 
...     price=vbt.Param(["close", "nextopen"])
... )
>>> fig = pf.orders["close"].plot(
...     buy_trace_kwargs=dict(name="Buy (close)", marker=dict(symbol="triangle-up-open")),
...     sell_trace_kwargs=dict(name="Buy (close)", marker=dict(symbol="triangle-down-open"))
... )
>>> pf.orders["nextopen"].plot(
...     plot_ohlc=False,
...     plot_close=False,
...     buy_trace_kwargs=dict(name="Buy (nextopen)"),
...     sell_trace_kwargs=dict(name="Sell (nextopen)"),
...     fig=fig
... )
>>> fig.show()

Signal callbacks

  • Want to hack into the simulation based on signals, or even generate signals dynamically based on the current backtesting environment? There are two new callbacks that push flexibility of the simulator to the limits: one that lets the user generate or override signals for each asset at the current bar, and another one that lets the user compute any user-defined metrics for the entire group at the end of the bar. Both are taking a so-called "context" that contains the information on the current simulation state based on which trading decisions can be made - very similar to how event-driven backtesters work.
Backtest SMA crossover iteratively
>>> from collections import namedtuple

>>> InOutputs = namedtuple("InOutputs", ["fast_sma", "slow_sma"])

>>> def initialize_in_outputs(target_shape):
...     return InOutputs(
...         fast_sma=np.full(target_shape, np.nan),
...         slow_sma=np.full(target_shape, np.nan)
...     )

>>> @njit
... def signal_func_nb(c, fast_window, slow_window):
...     fast_sma = c.in_outputs.fast_sma
...     slow_sma = c.in_outputs.slow_sma
...     fast_start_i = c.i - fast_window + 1
...     slow_start_i = c.i - slow_window + 1
...     if fast_start_i >= 0 and slow_start_i >= 0:
...         fast_sma[c.i, c.col] = np.nanmean(c.close[fast_start_i : c.i + 1])
...         slow_sma[c.i, c.col] = np.nanmean(c.close[slow_start_i : c.i + 1])
...         is_entry = vbt.pf_nb.iter_crossed_above_nb(c, fast_sma, slow_sma)
...         is_exit = vbt.pf_nb.iter_crossed_below_nb(c, fast_sma, slow_sma)
...         return is_entry, is_exit, False, False
...     return False, False, False, False

>>> pf = vbt.PF.from_signals(
...     vbt.YFData.pull("BTC-USD"),
...     signal_func_nb=signal_func_nb,
...     signal_args=(50, 200),
...     in_outputs=vbt.RepFunc(initialize_in_outputs),
... )
>>> fig = pf.get_in_output("fast_sma").vbt.plot()
>>> pf.get_in_output("slow_sma").vbt.plot(fig=fig)
>>> pf.orders.plot(plot_ohlc=False, plot_close=False, fig=fig)
>>> fig.show()

Limit orders

  • Long-awaited support for limit orders in simulation based on signals! Features time-in-force (TIF) orders, such as DAY, GTC, GTD, LOO, and FOK orders ⏰ You can also reverse a limit order as well as create it using a delta for easier testing.
Explore how limit delta affects number of orders in a random portfolio
>>> pf = vbt.PF.from_random_signals(
...     vbt.YFData.pull("BTC-USD"), 
...     n=100, 
...     order_type="limit", 
...     limit_delta=vbt.Param(np.arange(0.001, 0.1, 0.001)),  # (1)!
... )
>>> pf.orders.count().vbt.plot(
...     xaxis_title="Limit delta", 
...     yaxis_title="Order count"
... ).show()
  1. Limit delta is the distance between the close (or any other price) and the target limit price in percentage terms. The highest the delta, the less is the chance that it will be eventually hit.

Delta formats

  • Previously, stop orders had to be provided strictly as percentages, which works well for single values but may require some transformations for arrays. For example, to set SL to ATR, one would need to know the entry price. Generally, to lock in a specific dollar amount of a trade, you may prefer to utilize a fixed price trailing stop. To address this, VBT has introduced multiple stop value formats (so called "delta formats") to choose from.
Use ATR as SL
>>> data = vbt.YFData.pull("BTC-USD")
>>> atr = vbt.talib("ATR").run(data.high, data.low, data.close).real
>>> pf = vbt.PF.from_holding(
...     data.loc["2022-01-01":"2022-01-07"], 
...     sl_stop=atr.loc["2022-01-01":"2022-01-07"], 
...     delta_format="absolute"
... )
>>> pf.orders.plot().show()

Bar skipping

  • Simulation based on orders and signals can (partially) skip bars that do not define any orders - a change that often results in noticeable speedups for sparsely distributed orders.
Benchmark bar skipping in a buy-and-hold portfolio
>>> data = vbt.BinanceData.pull("BTCUSDT", start="one month ago UTC", timeframe="minute")
>>> size = data.symbol_wrapper.fill(np.nan)
>>> size[0] = np.inf
>>> %%timeit
>>> vbt.PF.from_orders(data, size, ffill_val_price=True)  # (1)!
5.92 ms ± 300 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)
  1. When enabled (default), this argument forces the simulation to process each bar
>>> %%timeit
>>> vbt.PF.from_orders(data, size, ffill_val_price=False)
2.75 ms ± 16 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Signal contexts

  • Signal generation functions have been redesigned from the ground up to operate on contexts. This allows for designing more complex signal strategies with less namespace polution.

Tutorial

Learn more in the Signal development tutorial.

Entry at the first bar of the week, exit at the last bar of the week
>>> @njit
... def entry_place_func_nb(c, index):  
...     for i in range(c.from_i, c.to_i):  # (1)!
...         if i == 0:
...             return i - c.from_i  # (2)!
...         else:
...             index_before = index[i - 1]
...             index_now = index[i]
...             index_next_week = vbt.dt_nb.future_weekday_nb(index_before, 0)
...             if index_now >= index_next_week:  # (3)!
...                 return i - c.from_i
...     return -1

>>> @njit
... def exit_place_func_nb(c, index):  
...     for i in range(c.from_i, c.to_i):
...         if i == len(index) - 1:
...             return i - c.from_i
...         else:
...             index_now = index[i]
...             index_after = index[i + 1]
...             index_next_week = vbt.dt_nb.future_weekday_nb(index_now, 0)
...             if index_after >= index_next_week:  # (4)!
...                 return i - c.from_i
...     return -1

>>> data = vbt.YFData.pull("BTC-USD", start="2020-01", end="2020-14")
>>> entries, exits = vbt.pd_acc.signals.generate_both(
...     data.symbol_wrapper.shape,
...     entry_place_func_nb=entry_place_func_nb,
...     entry_place_args=(data.index.vbt.to_ns(),),
...     exit_place_func_nb=exit_place_func_nb,
...     exit_place_args=(data.index.vbt.to_ns(),),
...     wrapper=data.symbol_wrapper
... )
>>> pd.concat((
...     entries.rename("Entries"), 
...     exits.rename("Exits")
... ), axis=1).to_period("W")
                       Entries  Exits
Date                                 
2019-12-30/2020-01-05     True  False
2019-12-30/2020-01-05    False  False
2019-12-30/2020-01-05    False  False
2019-12-30/2020-01-05    False  False
2019-12-30/2020-01-05    False   True
2020-01-06/2020-01-12     True  False
2020-01-06/2020-01-12    False  False
2020-01-06/2020-01-12    False  False
2020-01-06/2020-01-12    False  False
2020-01-06/2020-01-12    False  False
2020-01-06/2020-01-12    False  False
2020-01-06/2020-01-12    False   True
2020-01-13/2020-01-19     True  False
  1. Iterate over the bars in the current period segment
  2. If a signal should be placed, return an index relative to the segment
  3. Place a signal if the current bar crosses Monday
  4. Place a signal if the next bar crosses Monday

Pre-computation

  • There is a tradeoff between memory consumption and execution speed: a 1000-column dataset is usually processed much faster than a 1-column dataset 1000 times. But the first dataset also requires 1000 times more memory than the second one. That's why during the simulation phase, VBT mainly generates orders while other portfolio attributes such as various balances, equity, and returns are then later reconstructed during the analysis phase if it's needed by the user. For use cases where performance is the main criteria, there are now arguments that allow pre-computing these attributes at the simulation time! ⏩
Benchmark a random portfolio with 1000 columns without and with pre-computation
>>> data = vbt.YFData.pull("BTC-USD")
>>> %%timeit  # (1)!
>>> for n in range(1000):
...     pf = vbt.PF.from_random_signals(data, n=n, save_returns=False)
...     pf.sharpe_ratio
15 s ± 829 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
  1. Good for RAM, bad for performance
>>> %%timeit
>>> pf = vbt.PF.from_random_signals(data, n=np.arange(1000).tolist(), save_returns=False)
>>> pf.sharpe_ratio
855 ms ± 6.26 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
>>> %%timeit
>>> pf = vbt.PF.from_random_signals(data, n=np.arange(1000).tolist(), save_returns=True)
>>> pf.sharpe_ratio
593 ms ± 7.07 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

Cash deposits

  • Cash can be deposited and withdrawn at any time.
DCA $10 into Bitcoin each month
>>> data = vbt.YFData.pull("BTC-USD")
>>> cash_deposits = data.symbol_wrapper.fill(0.0)
>>> month_start_mask = ~data.index.tz_convert(None).to_period("M").duplicated()
>>> cash_deposits[month_start_mask] = 10
>>> pf = vbt.PF.from_orders(
...     data.close, 
...     init_cash=0, 
...     cash_deposits=cash_deposits
... )

>>> pf.input_value  # (1)!
1020.0

>>> pf.final_value
20674.328828315127
  1. Total invested

Cash earnings

  • Cash can be continuously earned or spent depending on the current position.
Backtest Apple without and with dividend reinvestment
>>> data = vbt.YFData.pull("AAPL", start="2010")

>>> pf_kept = vbt.PF.from_holding(  # (1)!
...     data.close,
...     cash_dividends=data.get("Dividends")
... )
>>> pf_kept.cash.iloc[-1]  # (2)!
93.9182408043298

>>> pf_kept.assets.iloc[-1]  # (3)!
15.37212731743495

>>> pf_reinvested = vbt.PF.from_orders(  # (4)!
...     data.close,
...     cash_dividends=data.get("Dividends")
... )
>>> pf_reinvested.cash.iloc[-1]
0.0

>>> pf_reinvested.assets.iloc[-1]
18.203284859405468

>>> fig = pf_kept.value.rename("Value (kept)").vbt.plot()
>>> pf_reinvested.value.rename("Value (reinvested)").vbt.plot(fig=fig)
>>> fig.show()
  1. Keep dividends as cash
  2. Final cash balance
  3. Final number of shares in the portfolio
  4. Reinvest dividends at the next bar

In-outputs

  • Portfolio can now take and return any user-defined arrays filled during the simulation, such as signals. In-output arrays can broadcast together with regular arrays using templates and broadcastable named arguments. Additionally, VBT will (semi-)automatically figure out how to correctly wrap and index each array, for example, whenever you select a column from the entire portfolio.
Track the debt of a random portfolio during the simulation
>>> data = vbt.YFData.pull(["BTC-USD", "ETH-USD"], missing_index="drop")
>>> size = data.symbol_wrapper.fill(np.nan)
>>> rand_indices = np.random.choice(np.arange(len(size)), 10)
>>> size.iloc[rand_indices[0::2]] = -np.inf
>>> size.iloc[rand_indices[1::2]] = np.inf

>>> @njit
... def post_segment_func_nb(c):
...     for col in range(c.from_col, c.to_col):
...         col_debt = c.last_debt[col]
...         c.in_outputs.debt[c.i, col] = col_debt
...         if col_debt > c.in_outputs.max_debt[col]:
...             c.in_outputs.max_debt[col] = col_debt

>>> pf = vbt.PF.from_def_order_func(
...     data.close, 
...     size=size,
...     post_segment_func_nb=post_segment_func_nb,
...     in_outputs=dict(
...         debt=vbt.RepEval("np.empty_like(close)"),
...         max_debt=vbt.RepEval("np.full(close.shape[1], 0.)")
...     )  # (1)!
... )
>>> print(pf.get_in_output("debt"))  # (2)!
symbol                       BTC-USD    ETH-USD
Date                                           
2017-11-09 00:00:00+00:00   0.000000   0.000000
2017-11-10 00:00:00+00:00   0.000000   0.000000
2017-11-11 00:00:00+00:00   0.000000   0.000000
2017-11-12 00:00:00+00:00   0.000000   0.000000
2017-11-13 00:00:00+00:00   0.000000   0.000000
...                              ...        ...
2023-02-08 00:00:00+00:00  43.746892  25.054571
2023-02-09 00:00:00+00:00  43.746892  25.054571
2023-02-10 00:00:00+00:00  43.746892  25.054571
2023-02-11 00:00:00+00:00  43.746892  25.054571
2023-02-12 00:00:00+00:00  43.746892  25.054571

[1922 rows x 2 columns]

>>> pf.get_in_output("max_debt")  # (3)!
symbol
BTC-USD    75.890464
ETH-USD    25.926328
Name: max_debt, dtype: float64
  1. Tell portfolio class to wait until all arrays are broadcast and create a new floating array of the final shape
  2. Portfolio instance knows how to properly wrap a custom NumPy array into a pandas object
  3. The same holds for reduced NumPy arrays

Flexible attributes

  • Portfolio attributes can now be partly or even entirely computed from user-defined arrays. This allows great control of post-simulation analysis, for example, to override some simulation data, to test hyperparameters without having to re-simulate the entire portfolio, or to avoid repeated reconstruction when caching is disabled.
Compute the net exposure by caching its components
>>> data = vbt.YFData.pull("BTC-USD")
>>> pf = vbt.PF.from_random_signals(data.close, n=100)
>>> value = pf.get_value()  # (1)!
>>> long_exposure = vbt.PF.get_gross_exposure(  # (2)!
...     asset_value=pf.get_asset_value(direction="longonly"),
...     value=value,
...     wrapper=pf.wrapper
... )
>>> short_exposure = vbt.PF.get_gross_exposure(
...     asset_value=pf.get_asset_value(direction="shortonly"),
...     value=value,
...     wrapper=pf.wrapper
... )
>>> del value  # (3)!
>>> net_exposure = vbt.PF.get_net_exposure(
...     long_exposure=long_exposure,
...     short_exposure=short_exposure,
...     wrapper=pf.wrapper
... )
>>> del long_exposure
>>> del short_exposure
>>> net_exposure
Date
2014-09-17 00:00:00+00:00    1.0
2014-09-18 00:00:00+00:00    1.0
2014-09-19 00:00:00+00:00    1.0
2014-09-20 00:00:00+00:00    1.0
2014-09-21 00:00:00+00:00    1.0
                             ... 
2023-02-08 00:00:00+00:00    0.0
2023-02-09 00:00:00+00:00    0.0
2023-02-10 00:00:00+00:00    0.0
2023-02-11 00:00:00+00:00    0.0
2023-02-12 00:00:00+00:00    0.0
Freq: D, Length: 3071, dtype: float64
  1. Call the instance method to use the data stored in the portfolio
  2. Call the class method to provide all the data explicitly
  3. Delete the object as soon as it's no needed, to release memory

Shortcut properties

  • In-output arrays can be used to override regular portfolio attributes. Portfolio will automatically pick the pre-computed array and perform all future calculations using this array, without wasting time on its reconstruction.
Modify the returns from within the simulation
>>> data = vbt.YFData.pull("BTC-USD")
>>> size = data.symbol_wrapper.fill(np.nan)
>>> rand_indices = np.random.choice(np.arange(len(size)), 10)
>>> size.iloc[rand_indices[0::2]] = -np.inf
>>> size.iloc[rand_indices[1::2]] = np.inf

>>> @njit
... def post_segment_func_nb(c):
...     for col in range(c.from_col, c.to_col):
...         return_now = c.last_return[col]
...         return_now = 0.5 * return_now if return_now > 0 else return_now
...         c.in_outputs.returns[c.i, col] = return_now

>>> pf = vbt.PF.from_def_order_func(
...     data.close,
...     size=size,
...     size_type="targetpercent",
...     post_segment_func_nb=post_segment_func_nb,
...     in_outputs=dict(
...         returns=vbt.RepEval("np.empty_like(close)")
...     )
... )

>>> pf.returns  # (1)!
Date
2014-09-17 00:00:00+00:00    0.000000
2014-09-18 00:00:00+00:00    0.000000
2014-09-19 00:00:00+00:00    0.000000
2014-09-20 00:00:00+00:00    0.000000
2014-09-21 00:00:00+00:00    0.000000
                                  ...   
2023-02-08 00:00:00+00:00   -0.015227
2023-02-09 00:00:00+00:00   -0.053320
2023-02-10 00:00:00+00:00   -0.008439
2023-02-11 00:00:00+00:00    0.005569  << modified
2023-02-12 00:00:00+00:00    0.001849  << modified
Freq: D, Length: 3071, dtype: float64

>>> pf.get_returns()  # (2)!
Date
2014-09-17 00:00:00+00:00    0.000000
2014-09-18 00:00:00+00:00    0.000000
2014-09-19 00:00:00+00:00    0.000000
2014-09-20 00:00:00+00:00    0.000000
2014-09-21 00:00:00+00:00    0.000000
                                  ...   
2023-02-08 00:00:00+00:00   -0.015227
2023-02-09 00:00:00+00:00   -0.053320
2023-02-10 00:00:00+00:00   -0.008439
2023-02-11 00:00:00+00:00    0.011138
2023-02-12 00:00:00+00:00    0.003697
Freq: D, Length: 3071, dtype: float64
  1. Pre-computed returns
  2. Actual returns