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.
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.110708pf[(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.
Three ways to simulate
Every simulation method returns the same Portfolio object. They differ in how you describe the
trading decisions:
| Method | You provide | Use it for |
|---|---|---|
vbt.PF.from_orders | Size, price, and direction per bar | Rebalancing, target weights, order lists |
vbt.PF.from_signals | Entry and exit signals, plus stops and limits | Most rule-based strategies |
vbt.PF.from_order_func | A compiled function that returns orders | Logic 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:
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:
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_returnfast_window slow_window
10 50 0.570373
100 0.186409
20 50 -0.055170
100 0.133235
Name: total_return, dtype: float64The 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.
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: objectThe 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:
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.441814pf.total_returnnp.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 passstaticized=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=Trueand related flags compute them during the simulation instead, trading memory for speed. - Mind the memory. Each full
float64array 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.
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_returntp_stop
tp_ladder_1 0.4
tp_ladder_2 0.6
Name: total_return, dtype: float64sim_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_returntp_stop
tp_ladder_1 0.4
tp_ladder_2 0.6
Name: total_return, dtype: float64✅ 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!
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, Falsedata = 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
)✅ 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! ⏩
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_ratio15 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_ratio855 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_ratio593 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.
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_exposureDate
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.
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: float64pf.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✅ 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 🔬
@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() ✅ 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.
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 NoneRelated pages
- Event-driven backtestingWrite compiled callbacks for cooldowns, position limits, and custom simulators
- Research toolkit › Multidimensional researchRun assets, parameters, and strategies as labeled columns, then index and stack them
- Signal backtestingTurn entry and exit signals into long, short, reversing, and pyramided positions
- Portfolio accountingTrack shared cash, deposits, dividends, positions, records, and exposure per bar
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.