Features

Backtesting engine

Build, customize, chain, and continue portfolio simulations in Python

Portfolio continuation

Recently added

✅ Pick up right where your simulation left off as new data arrives. VBT carries positions, order IDs, and stop state into each update, including entry timestamps and trailing highs. Your trailing stops remember their peaks, your time stops keep counting, and each update returns a new portfolio with the combined history.

Preserve a trailing stop across two updates
close = pd.Series(
    [100.0, 110.0, 108.0, 98.0, 103.0, 115.0],
    index=pd.date_range("2026-09-01", periods=6, freq="D")
)
entries = pd.Series([True, False, False, False, True, False], index=close.index)
pf_kwargs = dict(size=1, init_cash=1000, tsl_stop=0.05, freq="1D")

pf = vbt.PF.from_signals(
    close.iloc[:2],
    entries=entries.iloc[:2],
    attach_preparer=True,  
    **pf_kwargs,
)
for start in [2, 4]:
    pf = pf.update(  
        close.iloc[start:start + 2],
        entries=entries.iloc[start:start + 2],
    )

print(pf.orders.readable[["Order Id", "Fill Index", "Side", "Price", "Stop Type"]])
   Order Id Fill Index  Side  Price Stop Type
0         0 2026-09-01   Buy  100.0      None
1         1 2026-09-04  Sell   98.0       TSL
2         2 2026-09-05   Buy  103.0      None
full_pf = vbt.PF.from_signals(close, entries=entries, **pf_kwargs)  
pf.orders.records.equals(full_pf.orders.records) and pf.value.equals(full_pf.value)
True

Tutorial

Learn more in the Live simulation tutorial.

Chaining simulations

✅ Every built-in simulation function now returns the final simulation state after processing all data, including the last cash, position, pending order(s), and other relevant information. This state can be passed to the next simulation run, allowing you to continue from where the previous run ended. This enables seamless chaining of simulations across different time periods or datasets.

Simulate a SMA crossover strategy on a monthly basis
data = vbt.BinanceData.pull(  
    "BTCUSDT",
    start="one year ago",
    timeframe="5 minutes",
    cache=True
)

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)

single_pf = vbt.PF.from_signals(  
    data,
    long_entries=long_entries,
    short_entries=short_entries,
)

data_splits = data.split(by="month")  
long_entries_splits = long_entries.vbt.split(by="month", into=None)
short_entries_splits = short_entries.vbt.split(by="month", into=None)

pf_list = []
last_state = None
for i in range(len(data_splits)):  
    pf = vbt.PF.from_signals(
        data_splits.iloc[i],
        long_entries=long_entries_splits.iloc[i],
        short_entries=short_entries_splits.iloc[i],
        last_state=last_state,
    )
    pf_list.append(pf)
    last_state = pf.last_state

stacked_pf = vbt.PF.row_stack(*pf_list, chained=True)  
print(stacked_pf.returns.equals(single_pf.returns))
True

Full callback support

✅ Portfolio simulation method based on signals now fully supports callbacks at every step of the process. This includes pre-processing and post-processing callbacks for the simulation as a whole, as well as per-group and per-segment callbacks, and even an order modification callback. This allows you to customize the simulation behavior to a great extent.

DCA in $100 every month until 2x in profit, take out initial investment, and DCA out
DCAMode = namedtuple("DCAMode", ["In", "Out"])(0, 1)  

@njit
def pre_group_func_nb(ctx):  
    total_deposited = np.full(1, 0.0)
    dca_mode = np.full(1, DCAMode.In)
    return (total_deposited, dca_mode)

@njit
def pre_segment_func_nb(ctx, total_deposited, dca_mode, cash_deposits, dca_amount):  
    if ctx.i == 0 or vbt.dt_nb.month_nb(ctx.index[ctx.i - 1]) != vbt.dt_nb.month_nb(ctx.index[ctx.i]):
        dca_amount_now = vbt.pf_nb.select_from_group_nb(ctx, ctx.group, dca_amount)
        if dca_mode[0] == DCAMode.In and ctx.track_cash_deposits:
            cash_deposits[ctx.i, ctx.group] = dca_amount_now
            total_deposited[0] += dca_amount_now
    else:
        dca_amount_now = 0.0
    return (total_deposited, dca_mode, dca_amount_now)

@njit
def signal_func_nb(ctx, total_deposited, dca_mode, dca_amount_now, size):  
    if dca_amount_now > 0:
        size[ctx.i, ctx.col] = dca_amount_now
        if dca_mode[0] == DCAMode.In:
            return True, False, False, False
        return False, True, False, False
    return False, False, False, False

@njit
def post_order_func_nb(ctx, total_deposited, dca_mode, dca_amount_now):  
    if dca_mode[0] == DCAMode.In:
        if vbt.pf_nb.order_increased_position_nb(ctx):
            tp_info = ctx.last_tp_info[ctx.col]
            tp_info["stop"] = 1.0
            tp_info["init_price"] = ctx.last_pos_info[ctx.col]["entry_price"]
            tp_info["exit_size"] = total_deposited[0]
            tp_info["exit_size_type"] = vbt.pf_enums.SizeType.Value
        elif vbt.pf_nb.get_last_order_nb(ctx)["stop_type"] == vbt.pf_enums.StopType.TP:
            dca_mode[0] = DCAMode.Out

pf = vbt.PF.from_signals(
    vbt.YFData.pull("AAPL", start="2018"),
    pre_group_func_nb=pre_group_func_nb,
    pre_segment_func_nb=pre_segment_func_nb,
    pre_segment_args=(
        vbt.Rep("cash_deposits"),
        vbt.Rep("dca_amount")
    ),
    signal_func_nb=signal_func_nb,
    signal_args=(
        vbt.Rep("size"),
    ),
    post_order_func_nb=post_order_func_nb,
    broadcast_named_args=dict(dca_amount=100),
    arg_config=dict(
        cash_deposits=dict(full_shape=True),  
        size=dict(full_shape=True)
    ),
    accumulate=True,
    size_type="value",
    cash_sharing=True
)
pf.plot_orders().show()
AAPL OHLC with monthly DCA orders that recover the initial investment and then DCA out Figure data (JSON)

Documentation

Learn more in the Documentation → Portfolio → From signals → Callbacks.

✅ 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)

In-place outputs

✅ The portfolio can now accept and return any user-defined arrays filled during simulation, such as signals. In-place output arrays can broadcast together with regular arrays using templates and broadcastable named arguments. Additionally, VBT will (semi-)automatically determine 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)
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):
        col_debt = ctx.last_debt[col]
        ctx.in_outputs.debt[ctx.i, col] = col_debt
        if col_debt > ctx.in_outputs.max_debt[col]:
            ctx.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.)")
    )  
)
pf.get_in_output("debt")  
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")  
symbol
BTC-USD    75.890464
ETH-USD    25.926328
Name: max_debt, dtype: float64

✅ 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

Copyright © 20212026 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.