Features
Backtesting engine
Build, customize, chain, and continue portfolio simulations in Python
✅ 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.
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 Nonefull_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)TrueTutorial
Learn more in the Live simulation tutorial.
✅ 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.
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✅ 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.
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()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.
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)✅ 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.
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.
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: float64Copyright © 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.