Portfolio¶
Asset weighting ¶
- Asset weighting enables you to fine-tune the influence of individual assets or strategies within your portfolio, providing enhanced control over the overall portfolio's performance. The key advantage: these weights aren't limited to just returns—they are consistently applied across all time series and metrics, including orders, cash flows, and more. This holistic approach ensures that every aspect of your portfolio is precisely aligned.
Maximize Sharpe of a random portfolio
>>> data = vbt.YFData.pull(["AAPL", "MSFT", "GOOG"], start="2020")
>>> pf = data.run("from_random_signals", n=vbt.Default(50), seed=42, group_by=True)
>>> pf.get_sharpe_ratio(group_by=False) # (1)!
symbol
AAPL 1.401012
MSFT 0.456162
GOOG 0.852490
Name: sharpe_ratio, dtype: float64
>>> pf.sharpe_ratio # (2)!
1.2132857343006869
>>> prices = pf.get_value(group_by=False)
>>> weights = vbt.pypfopt_optimize(prices=prices) # (3)!
>>> weights
{'AAPL': 0.85232, 'MSFT': 0.0, 'GOOG': 0.14768}
>>> weighted_pf = pf.apply_weights(weights, rescale=True) # (4)!
>>> weighted_pf.weights
symbol
AAPL 2.55696
MSFT 0.00000
GOOG 0.44304
dtype: float64
>>> weighted_pf.get_sharpe_ratio(group_by=True) # (5)!
1.426112580298898
- Sharpe of each individual asset
- Sharpe of the combined portfolio. We want to maximize it.
- Maximize Sharpe based on equity (i.e., portfolio value) development
- Rescale the weights for multiplication and create a new portfolio with the weights applied
- Sharpe of the optimized portfolio
Position views¶
- Position views enable the analysis of portfolios by focusing specifically on either long or short positions, providing a clear and distinct perspective on each investment strategy.
Separate long and short positions of a basic SMA crossover portfolio
>>> data = vbt.YFData.pull("BTC-USD")
>>> fast_sma = data.run("talib_func:sma", timeperiod=20)
>>> slow_sma = data.run("talib_func:sma", timeperiod=50)
>>> long_entries = fast_sma.vbt.crossed_above(slow_sma)
>>> short_entries = fast_sma.vbt.crossed_below(slow_sma)
>>> pf = vbt.PF.from_signals(
... data,
... long_entries=long_entries,
... short_entries=short_entries,
... fees=0.01,
... fixed_fees=1.0
... )
>>> long_pf = pf.long_view
>>> short_pf = pf.short_view
>>> fig = vbt.make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.01)
>>> fig = long_pf.assets.vbt.plot_against(
... 0,
... trace_kwargs=dict(name="Long position", line_shape="hv", line_color="mediumseagreen"),
... other_trace_kwargs=dict(visible=False),
... add_trace_kwargs=dict(row=1, col=1),
... fig=fig
... )
>>> fig = short_pf.assets.vbt.plot_against(
... 0,
... trace_kwargs=dict(name="Short position", line_shape="hv", line_color="coral"),
... other_trace_kwargs=dict(visible=False),
... add_trace_kwargs=dict(row=2, col=1),
... fig=fig
... )
>>> fig.show()
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
- 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!
- 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
- Return the result of the preparation
- Contains two attributes: target arguments as
target_args
and portfolio arguments aspf_args
- Replace the argument. Since it resides in another dictionary (
target_args
), we need to enablenested_
. The result is a new instance ofPFPrepResult
. - Pass the new preparation result as the first argument to the base simulation method
- 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
- 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)!
... )
- Path to the module the function resides in
- 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
- 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
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="M")
>>> 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()
- 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) andeager
(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
>>> 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()
- 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)
- 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-01", end="2020-01-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
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
- Iterate over the bars in the current period segment
- If a signal should be placed, return an index relative to the segment
- Place a signal if the current bar crosses Monday
- 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)
- 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
- 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()
- Keep dividends as cash
- Final cash balance
- Final number of shares in the portfolio
- 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)!
... )
>>> 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
- Tell portfolio class to wait until all arrays are broadcast and create a new floating array of the final shape
- Portfolio instance knows how to properly wrap a custom NumPy array into a pandas object
- 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
- Call the instance method to use the data stored in the portfolio
- Call the class method to provide all the data explicitly
- 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
- Pre-computed returns
- Actual returns