Features

Portfolio accounting

Track shared cash, deposits, dividends, positions, records, and exposure per bar

Where did the cash go, and what does the portfolio hold at every bar? VBT keeps a full account for every backtest: cash shared across assets, deposits and withdrawals, dividends and interest, fees, positions, and their value. Everything can be inspected as a time series or as records, so a multi-asset backtest with shared cash adds up to the cent.

Invest $500 a month in a 60/40 portfolio and collect dividends
data = vbt.YFData.pull(
    ["SPY", "TLT"],
    start="2020-01-01",
    end="2025-01-01",
    auto_adjust=False,  
)
month_start = ~data.index.tz_localize(None).to_period("M").duplicated()
deposits = pd.Series(0.0, index=data.index)
deposits[month_start] = 500
weights = data.symbol_wrapper.fill(np.nan)
weights[month_start] = [0.6, 0.4]

pf = vbt.PF.from_orders(
    data,
    size=weights,
    size_type="targetpercent",
    group_by=True,
    cash_sharing=True,  
    call_seq="auto",
    init_cash=0,
    cash_deposits=deposits,
    cash_dividends=data.get("Dividends"),  
    fees=0.0005,
)
pd.Series({
    "deposits": pf.cash_deposits.sum(),
    "dividends": pf.cash_earnings.sum(),
    "fees": pf.orders.fees.sum(),
    "cash": pf.cash.iloc[-1],
    "assets": pf.asset_value.iloc[-1],
    "value": pf.final_value,
}).round(2)
deposits     30000.00
dividends     1756.07
fees            16.96
cash           174.82
assets       34520.04
value        34694.86
dtype: float64
pd.DataFrame({
    "Deposited": pf.cash_deposits.cumsum(),
    "Portfolio value": pf.value,
}).vbt.plot().show()
Cumulative deposits and portfolio value of a monthly 60/40 SPY and TLT savings plan from 2020 to 2024 Figure data (JSON)

Sixty deposits of $500 and $1,756 of dividends went in, $16.96 of fees went out, and the dividends were reinvested at the next monthly rebalance. The final value equals the remaining cash plus the value of the shares.

Shared cash

By default, every column is its own account. Grouping columns with group_by and setting cash_sharing=True turns them into one account: positions stay per asset, while cash, value, and returns are tracked per group. init_cash is then the starting balance of each group, not of each asset. Within a bar, the assets of a group trade one after another, and call_seq="auto" puts sells first so their proceeds can fund the buys. Results can be read per group or per asset. For example, pf.get_cash_earnings(group_by=False) splits the dividends above into $719.56 from SPY and $1,036.51 from TLT.

Starting state

A simulation starts with init_cash, and optionally with positions from init_position and their entry prices from init_price, for example to continue from a live account. When the amount of capital should not limit the test, init_cash="auto" simulates with unlimited cash and then sets the starting balance to the most the strategy ever needed:

Find the cash a sequence of orders requires
index = pd.date_range("2026-01-01", periods=4)
close = pd.Series([10.0, 12.0, 8.0, 11.0], index=index)
pf = vbt.PF.from_orders(close, size=[5, 5, -10, 20], init_cash="auto")
print(pf.init_cash)
250.0
pf.cash.tolist()
[200.0, 140.0, 220.0, 0.0]

With several columns, "autoalign" gives all of them the largest requirement, so their returns share one capital base. Sizes relative to cash or value, such as "percent", "valuepercent", and target weights, have nothing finite to refer to during such a run and raise an error. Simulate them with real cash and, without leverage, call pf.replace(init_cash="auto") afterwards to learn how much of it the strategy actually used.

Existing holdings

You can test what happens next to an account that already holds assets. Here the account starts with $1,000 in cash and ten shares, then sells the shares in two stages:

Start with shares already in the account
held_close = pd.Series([100.0, 105.0, 110.0], index=pd.date_range("2026-01-01", periods=3))
held_pf = vbt.PF.from_orders(
    held_close,
    size=[np.nan, -4, -6],
    init_cash=1000,
    init_position=10,
    init_price=100,
)
pd.DataFrame({"cash": held_pf.cash, "shares": held_pf.assets, "value": held_pf.value})
              cash  shares   value
2026-01-01  1000.0    10.0  2000.0
2026-01-02  1420.0     6.0  2050.0
2026-01-03  2080.0     0.0  2080.0

The initial shares count toward the portfolio's value from the start. Selling four at $105 and the remaining six at $110 leaves $2,080 in cash. To keep an existing simulation running as new bars arrive, see Live simulation.

Cash flows

Money enters and leaves the account in four ways besides trading:

FlowArgumentNotes
Deposits and withdrawalscash_depositsNegative values withdraw, limited to the free cash
Dividendscash_dividendsPer share, multiplied by the position
Interest and other income or costscash_earningsCash amounts, credited at the end of the bar
Trading costsfees, fixed_feesCharged per order

Deposits are counted as invested capital, so they raise the input value without raising the return. Flows that depend on the account itself, such as a management fee on the current value, a performance fee at year end, or taxes on realized gains, are written into cash_earnings by a callback while the simulation runs.

Valuation

The reported portfolio value is its cash plus the value of its positions at closing prices. During simulation, val_price lets you choose the prices used to value positions for sizing decisions, such as how many shares make up a target allocation. Missing closing prices are filled by default, so a gap in the data does not zero out a position. The returns of a group come from its combined value, so they are not the sum or the average of the asset returns, which pf.get_returns(group_by=False) gives per asset. Profit splits into realized profit from closed trades and unrealized profit from open ones, and every series is available per bar: pf.cash, pf.assets, pf.asset_value, pf.value, and pf.returns.

Records

The account keeps four kinds of records, each with a readable table:

RecordsWhat one row is
pf.ordersA filled order, with signal, creation, and fill time for signal strategies
pf.logsA requested order with the state before and after it, filled or not
pf.tradesAn entry matched with an exit, including partial exits
pf.positionsA position from opening to flat, combining its trades

Set log=True when simulating to keep order logs, including requests that did not fill.

Read positions, including the one still open
pf = vbt.PF.from_orders(close, size=[5, 5, -10, 20], init_cash=1000)
cols = ["Entry Index", "Exit Index", "Size", "Avg Entry Price", "PnL", "Status"]
pf.positions.readable[cols]
  Entry Index Exit Index  Size  Avg Entry Price   PnL  Status
0  2026-01-01 2026-01-03  10.0             11.0 -30.0  Closed
1  2026-01-04 2026-01-04  20.0             11.0   0.0    Open

To attach your own information to orders, such as the reason for an exit, record it in a side array from a callback and join it to the order records by row and column.

Allocations and exposure

pf.allocations gives the weight of each asset in its group on every bar. Exposure summarizes the book: long and short exposure are the long and short asset values relative to portfolio value, gross exposure is their sum, and net exposure their difference. Here a book starts 60% long SPY and 40% short TLT and is left to drift:

Track gross and net exposure of a long/short book
weights = data.symbol_wrapper.fill(np.nan)
weights.iloc[0] = [0.6, -0.4]
book_pf = vbt.PF.from_orders(
    data,
    size=weights,
    size_type="targetpercent",
    group_by=True,
    cash_sharing=True,
    call_seq="auto",
    init_cash=10_000,
)
pd.DataFrame({
    "long": book_pf.get_gross_exposure(direction="longonly"),
    "short": book_pf.get_gross_exposure(direction="shortonly"),
    "gross": book_pf.gross_exposure,
    "net": book_pf.net_exposure,
}).iloc[[0, 500, 1000]].round(3)
                            long  short  gross    net
Date
2020-01-02 00:00:00-05:00  0.600  0.400  1.000  0.200
2021-12-27 00:00:00-05:00  0.707  0.349  1.056  0.358
2023-12-21 00:00:00-05:00  0.631  0.209  0.839  0.422

As SPY rose and TLT fell, the short shrank and the book became more net long. For time spent in the market, pf.position_coverage gives the share of bars with an open position.

Asset weighting

✅ Asset weighting lets you fine-tune the influence of individual assets or strategies within your portfolio, giving you enhanced control over your portfolio's overall performance. The key benefit is that these weights are not limited to returns. They are consistently applied to all time series and metrics, including orders, cash flows, and more. This comprehensive approach ensures that every aspect of your portfolio stays precisely aligned.

pf.apply_weights reweights completed results without rerunning the simulation. This is useful for comparing allocations across assets or strategies you have already tested. To test how assets compete for the same cash during trading, use shared cash. See Multi-strategy portfolios for combining several strategies.

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)  
symbol
AAPL    1.401012
MSFT    0.456162
GOOG    0.852490
Name: sharpe_ratio, dtype: float64
pf.sharpe_ratio  
1.2132857343006869
prices = pf.get_value(group_by=False)
weights = vbt.pypfopt_optimize(prices=prices)  
weights
{'AAPL': 0.85232, 'MSFT': 0.0, 'GOOG': 0.14768}
weighted_pf = pf.apply_weights(weights, rescale=True)  
weighted_pf.weights
symbol
AAPL    2.55696
MSFT    0.00000
GOOG    0.44304
dtype: float64
weighted_pf.get_sharpe_ratio(group_by=True)  
1.426112580298898

Position views

✅ Position views let you analyze your portfolio by focusing on either long or short positions, providing a clear and distinct perspective for 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()
BTC-USD SMA crossover portfolio with long and short position assets shown separately Figure data (JSON)
long_pf.sharpe_ratio
0.9185961894435091
short_pf.sharpe_ratio
0.2760864152147919

Position info

✅ Dynamic signal functions now have 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(ctx, entries, exits):
    is_entry = vbt.pf_nb.select_nb(ctx, entries)
    is_exit = vbt.pf_nb.select_nb(ctx, exits)
    if is_entry:
        return True, False, False, False
    if is_exit:
        pos_info = ctx.last_pos_info[ctx.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, seed=42, 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  
)
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

Cash deposits

✅ Cash can be deposited or 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  
1020.0
pf.final_value
20674.328828315127

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(  
    data.close,
    cash_dividends=data.get("Dividends")
)
pf_kept.cash.iloc[-1]  
93.9182408043298
pf_kept.assets.iloc[-1]  
15.37212731743495
pf_reinvested = vbt.PF.from_orders(  
    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()
AAPL portfolio value with dividends kept as cash versus reinvested from 2010 onward Figure data (JSON)

Copyright © 2021–2026 Oleg Polakow. All rights reserved.

Site content and documentation are provided for using and evaluating VectorBT PRO and for educational purposes. Any other use, including building or supporting competing products or services, requires prior written consent.