Features

Backtesting engine

Simulate orders, signals, and callbacks across many assets and parameters at once

Can you backtest your own trading logic in Python without giving up speed or control? VBT is a Python backtesting engine that simulates orders bar by bar in compiled code, runs every asset and parameter combination as a column of one vectorized simulation, and returns a portfolio where every order, trade, and position can be inspected. Start with entry and exit signals, replay an existing order list, or build custom logic that reacts to cash and positions as the simulation runs. Each approach gives you the same portfolio analysis tools.

Backtest four moving average pairs on two assets in one call
data = vbt.YFData.pull(["BTC-USD", "ETH-USD"], start="2021-01-01", end="2024-01-01")
fast = vbt.MA.run(data.close, window=[10, 10, 20, 20], short_name="fast")
slow = vbt.MA.run(data.close, window=[50, 100, 50, 100], short_name="slow")
entries = fast.ma_crossed_above(slow)
exits = fast.ma_crossed_below(slow)

pf = vbt.PF.from_signals(data, entries, exits, fees=0.001)  
pf.total_return.unstack("symbol")
symbol                    BTC-USD   ETH-USD
fast_window slow_window
10          50           0.346309  0.720064
            100          0.577382 -0.151935
20          50          -0.139554  0.017887
            100          0.365233 -0.110708
pf[(10, 50, "ETH-USD")].plot(settings=dict(bm_returns=False)).show()  

Each signal fills at the close of the bar it appears on, and every order pays a 0.1% fee. To act on the next bar instead, see Causal order timing.

ETH-USD 10 and 50 day moving average crossover orders, trade returns, and cumulative returns from 2021 to 2023 Figure data (JSON)

Three ways to simulate

Every simulation method returns the same Portfolio object. They differ in how you describe the trading decisions:

MethodYou provideUse it for
vbt.PF.from_ordersSize, price, and direction per barRebalancing, target weights, order lists
vbt.PF.from_signalsEntry and exit signals, plus stops and limitsMost rule-based strategies
vbt.PF.from_order_funcA compiled function that returns ordersLogic that depends on the portfolio state

from_orders is the most direct: you supply order sizes and prices, and the engine handles cash and execution. For target weights combined with stops and limits, from_signals accepts them with order_mode=True, as shown in Targets as signals. Both methods fill at most one order per column and bar. To fill several, for example a buy at the open and a sell at the close, use a flexible order function, as shown in Order functions.

from_orders and from_signals accept arrays prepared before the simulation starts, while execution still responds to the current account. Target weights, for example, translate into order sizes using the portfolio value and existing positions. from_signals and from_order_func also accept callbacks, which decide during the simulation, as shown on the Event-driven backtesting page. Static or dynamic, the methods share order execution rules. Here, the same long-only strategy expressed two ways gives the same orders:

Run one strategy through two simulation methods
close = data.close["BTC-USD"]
entries = close.vbt.crossed_above(close.rolling(50).mean())
exits = close.vbt.crossed_below(close.rolling(50).mean())
pf_signals = vbt.PF.from_signals(close, entries, exits)

size = pd.Series(np.nan, index=close.index)  
size[entries] = np.inf
size[exits] = -np.inf
pf_orders = vbt.PF.from_orders(close, size, direction="longonly")

cols = ["Index", "Size", "Price", "Side"]
a = pf_signals.orders.readable.rename(columns={"Fill Index": "Index"})[cols]
b = pf_orders.orders.readable[cols]
a.equals(b), len(a)
(True, 51)

Under the high-level methods sit compiled functions that take plain NumPy arrays. You can call them from your own Numba code, as shown on the Signal backtesting page.

Many backtests in one call

Inputs broadcast against each other. A signal array with eight columns, a price array with two, and a scalar fee produce eight backtests, and parameter values become column levels you can select, group, and plot by. Each column is an independent account by default.

Grouping changes that. group_by combines columns into one portfolio for reporting, and cash_sharing=True makes them draw from one cash balance, which is how multi-asset portfolios are simulated. Grouping the first grid by its parameter levels turns eight single-asset backtests into four two-asset portfolios:

Turn the grid into four two-asset portfolios with shared cash
pf = vbt.PF.from_signals(
    data,
    fast.ma_crossed_above(slow),
    fast.ma_crossed_below(slow),
    size=0.5,  
    size_type="valuepercent",
    fees=0.001,
    group_by=["fast_window", "slow_window"],  
    cash_sharing=True,
)
pf.total_return
fast_window  slow_window
10           50             0.570373
             100            0.186409
20           50            -0.055170
             100            0.133235
Name: total_return, dtype: float64

The simulator carries cash and positions forward from one bar to the next within each group. Speed comes from compiled loops and from running many independent groups side by side. You can scale parameter sweeps with the options on the Parallel execution and caching page.

One position per column

A column holds one net position. You can add to it with accumulation, or run several independently managed trades on one instrument by putting each trade in its own column and grouping the columns, optionally with shared cash. Signal unraveling builds those columns from one signal array.

Simulation windows

Indicators need history before the first signal. Instead of slicing the data, pass sim_start and sim_end: indicators see the full history, while the simulation starts flat at the given bar and stops before the end bar.

Simulate only 2022 and 2023 while the indicator uses 2021
pf = vbt.PF.from_signals(close, entries, exits, sim_start="2022-01-01")
pf.stats(["start_index", "end_index", "total_return", "total_trades"])
Start Index         2022-01-01 00:00:00+00:00
End Index           2023-12-31 00:00:00+00:00
Total Return [%]                    25.991572
Total Trades                               18
Name: BTC-USD, dtype: object

The window can differ per column, sim_start="auto" starts each column at its first signal, and a callback can end a group early. Metrics, records, and plots all respect the window, as the Simulation ranges highlight below shows. You can also analyze different date ranges of an existing portfolio without rerunning the backtest.

Inputs as records

Bring prices and signals straight from pandas Series or DataFrames with matching timestamps and asset columns. You can use your existing data pipeline or VBT's market data integrations.

Some strategies come as a list of events: buy this symbol on that date. Building a dense array for them is easy with the data's wrapper, for example data.symbol_wrapper.fill(False) followed by assignments with .loc. You can also skip the array entirely and pass the events as records, as shown in the Index records highlight below.

Records also turn a fill log from a broker, a spreadsheet, or another backtester into a portfolio. Each column of the log names an argument of the simulation method:

Replay a year of manual stock trades from a fill log
data = vbt.YFData.pull(["AAPL", "NVDA"], start="2024-01-01", end="2025-01-01")
fills = pd.DataFrame(
    [
        ("2024-02-05", "AAPL", 50, 188.20, 1.0),
        ("2024-03-11", "NVDA", 100, 86.40, 1.0),
        ("2024-04-22", "AAPL", -50, 166.90, 1.0),
        ("2024-06-18", "NVDA", -60, 131.70, 1.0),
        ("2024-08-05", "NVDA", 60, 101.20, 1.0),
        ("2024-11-25", "NVDA", -100, 137.40, 1.0),
    ],
    columns=["date", "symbol", "size", "price", "fixed_fees"],  
)
pf = vbt.PF.from_orders(
    data,
    records=fills,
    init_cash=20_000,
    group_by=True,
    cash_sharing=True,
)
pf.trades.readable[["Column", "Size", "Avg Entry Price", "Avg Exit Price", "PnL", "Return"]]
  Column   Size  Avg Entry Price  Avg Exit Price     PnL    Return
0   AAPL   50.0           188.20           166.9 -1067.0 -0.113390
1   NVDA   60.0            86.40           131.7  2716.4  0.523997
2   NVDA  100.0            95.28           137.4  4209.6  0.441814
pf.total_return
np.float64(0.29294999999999993)

The partial NVDA sale closes 60 shares as one trade, and the remaining 40 shares join the August buy at an average of 95.28. Once the log is a portfolio, you can inspect its metrics and charts, or replay the orders with different fees and exit dates to explore what-if scenarios.

A timestamp that falls inside a bar maps to the next bar in the data, so floor intraday fill times to the bar start first, for example with fills["date"].dt.floor("1D") for daily bars. A later record for the same symbol and bar overwrites an earlier one, so combine fills that share a bar before passing them.

Inspect before running

Before anything is simulated, a preparer converts your arguments: strings become enums, inputs broadcast to their final shape, templates are filled, and defaults are applied. Pass return_prep_result=True to get that result instead of a portfolio, check or change any argument, and run it afterwards. The Portfolio preparers highlight below walks through it.

Compile once, rerun fast

Research means running the same simulation many times. A few settings keep that fast:

  • Compile once. Simulators with callbacks are compiled for each new Python session. With staticized=True, VBT writes a cacheable copy of the simulator for your callback, so later sessions skip compilation. The copy is regenerated automatically when the callback source, the simulation settings, or the VBT version changes. Edits to other modules that the callback imports are not detected, so pass staticized=dict(override=True) after changing them. Simulations without callbacks use simulators that are already cached, so they compile only once per installation.
  • Save what you will read. By default, the simulation stores orders and reconstructs balances and returns when you ask for them. save_returns=True and related flags compute them during the simulation instead, trading memory for speed.
  • Mind the memory. Each full float64 array takes rows × columns × 8 bytes. Twenty years of daily crypto bars across 10,000 columns is about 580 MB per array, and a simulation keeps several. Split large grids into chunks, as described on the Parallel execution and caching page.

Results as attributes

Every result is an attribute of the portfolio. pf.sharpe_ratio returns the value with default settings and caches it, while pf.get_sharpe_ratio(risk_free=0.02) takes arguments. Attributes can also be computed from arrays you supply, so you can test a different benchmark or override a series without rerunning the simulation.

To add your own metrics or behavior, subclass vbt.Portfolio. Class methods such as from_signals return an instance of your subclass. To relabel results, create a new portfolio with pf.replace(wrapper=dict(columns=...), nested_=True).

Documentation

The members-only Portfolio documentation covers the simulation lifecycle, from order execution to records and wrapping.

✅ When you pass an argument to a simulation method such as Portfolio.from_signals, it goes through a complex preparation pipeline to convert it into a format suitable for Numba. This pipeline usually involves enum mapping, broadcasting, data type checks, template substitution, and many other steps. To make VBT more transparent, this pipeline has been moved to a separate class, giving you full control over the arguments that reach the Numba functions! You can even 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  
)
prep_result.target_args["tp_stop"]  
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)  
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)  
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)  
pf = vbt.PF(sim_out=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

Staticization

✅ One major limitation of Numba is that functions passed as arguments (that is, callbacks) make the main function uncacheable, forcing it to be recompiled in every new runtime, again and again. This especially affects the performance of simulator functions, as they can take up to a minute to compile. Thankfully, there is a new trick available: "staticization". Here is how it works. First, the source code of a function is annotated with a special syntax. The annotated code is then extracted (also called "cutting" ✂️), modified into a cacheable version by removing any callbacks from the arguments, and saved to a Python file. Once the function is called again, the cacheable version is executed. Sound complicated? Take a look below!

Define the signal function in a Python file, here signal_func_nb.py
from vectorbtpro import *

@njit
def signal_func_nb(ctx, fast_sma, slow_sma):  
    long = vbt.pf_nb.iter_crossed_above_nb(ctx, fast_sma, slow_sma)
    short = vbt.pf_nb.iter_crossed_below_nb(ctx, fast_sma, slow_sma)
    return long, False, short, False
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",  
    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  
)

Pre-computation

✅ There is a tradeoff between memory usage and execution speed: a dataset with 1000 columns is usually processed much faster than processing a 1-column dataset 1000 times. However, the first dataset also requires 1000 times more memory than the second. That's why, during the simulation phase, VBT primarily generates orders, while other portfolio attributes such as balances, equity, and returns are reconstructed later during the analysis phase if the user needs them. For cases where performance is the main concern, arguments are now available that let you pre-compute these attributes during simulation! ⏩

Benchmark a random portfolio with 1000 columns without and with pre-computation
data = vbt.YFData.pull("BTC-USD")
%%timeit  
for n in range(1000):
    pf = vbt.PF.from_random_signals(data, n=n, seed=42, save_returns=False)
    pf.sharpe_ratio
15 s ± 829 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(), seed=42, 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(), seed=42, save_returns=True)
pf.sharpe_ratio
593 ms ± 7.07 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

✅ Portfolio attributes can now be partially or even fully computed from user-defined arrays. This gives you greater control over post-simulation analysis, such as overriding some simulation data, testing hyperparameters without re-simulating the entire portfolio, or avoiding 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, seed=42)
value = pf.get_value()  
long_exposure = vbt.PF.get_gross_exposure(  
    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  
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

✅ In-place output arrays can be used to override regular portfolio attributes. The portfolio will automatically pick the pre-computed array and perform all future calculations using this array, avoiding redundant reconstruction.

Modify the returns from within the simulation
data = vbt.YFData.pull("BTC-USD")
size = data.symbol_wrapper.fill(np.nan)
np.random.seed(42)
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(ctx):
    for col in range(ctx.from_col, ctx.to_col):
        return_now = ctx.last_return[col]
        return_now = 0.5 * return_now if return_now > 0 else return_now
        ctx.in_outputs.returns[ctx.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  
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()  
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

Simulation ranges

✅ Each simulation starts and ends at specific points, usually matching the first and last rows of your data. You can set a different simulation range beforehand, and now, you can also adjust this range during the simulation. This flexibility lets you stop the simulation when further processing is unnecessary. Additionally, the date range is saved in the portfolio object, so all metrics and subplots recognize it. Processing only the relevant dates speeds up execution and adds a new dimension to your analysis: isolated time windows 🔬

Simulate a quick liquidation scenario
@njit
def post_segment_func_nb(ctx):
    value = vbt.pf_nb.get_group_value_nb(ctx, ctx.group)
    if value <= 0:
        vbt.pf_nb.stop_group_sim_nb(ctx, ctx.group)  

pf = vbt.PF.from_random_signals(
    "BTC-USD",
    n=10,
    seed=42,
    sim_start="auto",  
    post_segment_func_nb=post_segment_func_nb,
    leverage=10,
)
pf.plot_value()  
Seeded leveraged BTC-USD portfolio value through liquidation Figure data (JSON)

Index records

✅ How can you backtest time- and asset-anchored queries such as "Order X units of asset Y on date Z"? Typically, you would need to build a full array and set each detail manually. Now, there is a simpler way: with preparers and redesigned smart indexing, you can provide all information in a compressed record format! Behind the scenes, the record array is translated into a set of index dictionaries, one for each 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),  
    dict(date="2022", symbol="ETH-USD", short_entry=True),
    dict(row=-1, exit=True),
]
pf = vbt.PF.from_signals(data, records=records)  
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

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.