Features
Event-driven backtesting
Write compiled callbacks for cooldowns, position limits, and custom simulators
Some trading rules depend on what already happened in the portfolio: a cooldown after a stop, a cap on open positions, a daily loss limit. VBT supports event-driven backtesting in Python through compiled callbacks that run inside the simulation loop, see cash, positions, and fills on every bar, and still run at Numba speed.
@njit
def cooldown_signal_nb(c, entries, exits, cooldown):
is_exit = vbt.pf_nb.select_nb(c, exits)
if c.i < c.in_outputs.blocked_until[c.col]:
return False, is_exit, False, False
return vbt.pf_nb.select_nb(c, entries), is_exit, False, False
@njit
def cooldown_post_order_nb(c, cooldown):
if vbt.pf_nb.order_closed_position_nb(c):
if vbt.pf_nb.get_last_order_nb(c)["stop_type"] >= 0:
wait = vbt.pf_nb.select_nb(c, cooldown)
c.in_outputs.blocked_until[c.col] = c.i + 1 + wait
data = vbt.YFData.pull("BTC-USD", start="2022-01-01", end="2024-01-01")
sma = data.close.rolling(20).mean()
pf = vbt.PF.from_signals(
data,
signal_func_nb=cooldown_signal_nb,
signal_args=(vbt.Rep("entries"), vbt.Rep("exits"), vbt.Rep("cooldown")),
post_order_func_nb=cooldown_post_order_nb,
post_order_args=(vbt.Rep("cooldown"),),
broadcast_named_args=dict(
entries=data.close > sma,
exits=data.close < sma,
cooldown=vbt.Param([0, 10]),
),
in_outputs=dict(
blocked_until=vbt.RepEval("np.zeros(wrapper.shape_2d[1], dtype=np.int_)")
),
sl_stop=0.05,
)
pf.stats(["total_trades", "win_rate", "total_return", "max_dd"], agg_func=None) Total Trades Win Rate [%] Total Return [%] Max Drawdown [%]
cooldown
0 44 22.727273 4.394750 38.506912
10 34 29.411765 34.063625 31.897484pf[10].orders.readable[["Fill Index", "Side", "Stop Type"]].iloc[2:6] Fill Index Side Stop Type
2 2022-02-28 00:00:00+00:00 Buy None
3 2022-03-04 00:00:00+00:00 Sell SL
4 2022-03-16 00:00:00+00:00 Buy None
5 2022-04-06 00:00:00+00:00 Sell NoneThe 5% stop fired on March 4, 2022. Without the cooldown, the trend filter bought again on March 9 and was stopped out the next day. With it, the next entry waits until March 16. In this sample, the cooldown removed 10 trades and reduced the drawdown. The point is not the result but that the rule needs the simulation's own output, so it cannot be precomputed as a signal array.
Choose how much to customize
You can add a rule to an existing signal strategy or take control of order generation. Choose the level that matches what your strategy needs:
| What you want to do | Use |
|---|---|
| Keep signal arrays and adjust sizes or stops as the account changes | An adjustment callback with from_signals |
| Decide whether to enter or exit using current positions, fills, or PnL | A signal callback with from_signals |
| Choose each order directly, including several orders on one bar | An order function with from_order_func, using flexible mode for multiple orders |
| Manage your own account states and simulation loop | A custom simulator built from VBT's execution primitives |
Signal callbacks retain the signal simulator's sizing, stops, limits, and conflict handling. Order functions give you direct control over the orders. Both produce a portfolio you can inspect with the same trade records, metrics, and charts.
These callbacks can process a full historical backtest in one call. To continue a simulation as new bars arrive, see Live simulation.
Callbacks that see the portfolio
vbt.PF.from_signals accepts a signal function, signal_func_nb, that replaces the four signal
arrays. It receives a context c with the current bar c.i, the column c.col, and the live
simulation state, then returns the four signals for that bar. Everything else, from order sizing to
stops and fees, works as usual.
The context exposes the state you usually need:
| State | Context field or helper |
|---|---|
| Position, cash, and debt | c.last_position, c.last_cash, c.last_debt |
| Portfolio value | c.last_value, vbt.pf_nb.get_group_value_nb(c, c.group) |
| Open position: entry bar, price, PnL, return | c.last_pos_info[c.col] |
| Pending stops and limit orders | c.last_sl_info, c.last_tsl_info, c.last_tp_info, c.last_limit_info |
| Your own arrays at the current bar | vbt.pf_nb.select_nb(c, array) |
| What the last order did | vbt.pf_nb.order_closed_position_nb(c), vbt.pf_nb.get_last_order_nb(c) |
Compute indicators before the simulation and select their current value inside the callback to reuse
the work across bars. If the indicator itself needs to update during simulation, you can use
streaming indicators. For calendar rules,
c.index[c.i] holds the bar's timestamp in nanoseconds.
The simulator values the portfolio at the bar's open before it calls the signal function, so
c.last_value and the PnL in c.last_pos_info reflect the open, not the close. To judge a position
on the close, compute it from vbt.pf_nb.select_nb(c, c.close). Both fields are recomputed by the
simulator, so writing to them changes nothing. To change the outcome, return different signals or
edit the pending stop and limit records.
Timing inside callbacks
A signal function decides on the bar it is called for. To act on the previous bar's data at this
bar's open, read your inputs at c.i - 1 and pass price="open". With price="nextopen", the
simulator labels each returned signal as the previous bar's and fills it at the current open, so a
callback that reads the current bar's close trades before that close exists.
Debugging a callback
Mistakes in compiled callbacks surface as Numba typing errors. The usual causes are a function
without @njit, a branch that returns nothing instead of four flags, and signal_args that is
not a tuple, such as (0.1) instead of (0.1,). To step through the logic, remove @njit and
pass jitted=False: the simulator then runs in Python, so print and breakpoints work.
Signal function or adjustment function
adjust_func_nb runs before the built-in signal functions to change stops or sizes while signals
still come from arrays. Once you pass your own signal_func_nb, that call no longer happens
automatically, so call the adjustment logic from inside your signal function.
Stateful trading rules
Most stateful rules follow the same pattern: keep a little state, read it in the signal function, and update it after orders fill. Here is a limit of three concurrent positions across a 20-asset universe that shares one cash balance:
symbols = [f"S{i:02d}" for i in range(20)]
data = vbt.GBMOHLCData.pull(symbols, start="2024-01-01", end="2025-01-01", seed=42)
sma = data.close.rolling(20).mean()
@njit
def max_positions_nb(c, entries, exits, max_positions):
if c.in_outputs.bar_entries[0] != c.i:
c.in_outputs.bar_entries[:] = (c.i, 0)
if c.last_position[c.col] > 0:
return False, vbt.pf_nb.select_nb(c, exits), False, False
if vbt.pf_nb.select_nb(c, entries):
n_open = vbt.pf_nb.get_n_active_positions_nb(c)
if n_open + c.in_outputs.bar_entries[1] < max_positions:
c.in_outputs.bar_entries[1] += 1
return True, False, False, False
return False, False, False, False
pf = vbt.PF.from_signals(
data,
signal_func_nb=max_positions_nb,
signal_args=(vbt.Rep("entries"), vbt.Rep("exits"), 3),
broadcast_named_args=dict(
entries=data.close.vbt.crossed_above(sma),
exits=data.close.vbt.crossed_below(sma),
),
in_outputs=dict(bar_entries=vbt.RepEval("np.full(2, -1, dtype=np.int_)")),
size=0.3,
size_type="valuepercent",
group_by=True,
cash_sharing=True,
)
print((pf.assets > 0).sum(axis=1).max())3Without the limit, the same signals hold up to seven positions at once. Without the same-bar counter, the limit leaks and reaches five. Rules built this way include:
| Rule | State it reads |
|---|---|
| Cooldown after a stop, a loss, or any exit | Last order record and its stop type, closed position PnL |
| Maximum concurrent positions | Open positions in the group plus entries approved this bar |
| Minimum holding period | Entry bar of the open position in c.last_pos_info |
| Daily trade or loss limit | Counters and a reference value reset on the first bar of each day |
| Equity curve filter | Portfolio value history kept in your own array |
| Portfolio-level stop | Group value compared with a threshold, then exits for every column |
The signal function and post_segment_func_nb run on every bar. post_order_func_nb runs only when
the simulator tries to execute an order, including one that is rejected, and not when a signal is
ignored or only places a limit order. Bookkeeping that must advance on quiet bars, such as a daily
counter or a settlement delay, belongs in the segment callback, or pass skip_empty=False to run
the order callback on every bar too.
Tutorial
The members-only From Python to Rust tutorial builds the stop cooldown on a four-asset portfolio and carries it into Numba kernels and native Rust.
Order functions
Signal callbacks still produce signals, and the signal simulator places at most one order per column
and bar. vbt.PF.from_order_func removes that layer: your callback returns orders directly. With a
flexible order function, it also picks the column and can return several orders on one bar. This one
buys at the open after a gap down of more than 0.5% and sells at the close of the same day:
@njit
def open_to_close_nb(c, gap_down):
col = c.from_col
if not gap_down[c.i, col]:
return -1, vbt.pf_enums.NoOrder
if c.call_idx == 0:
return col, vbt.pf_nb.order_nb(size=1, price=c.open[c.i, col])
if c.call_idx == 1:
return col, vbt.pf_nb.close_position_nb(price=c.close[c.i, col])
return -1, vbt.pf_enums.NoOrder
data = vbt.YFData.pull("SPY", start="2024-01-01", end="2024-02-01")
gap_down = data.open < data.close.shift(1) * 0.995
pf = vbt.PF.from_order_func(
data,
flex_order_func_nb=open_to_close_nb,
flex_order_args=(vbt.Rep("gap_down"),),
broadcast_named_args=dict(gap_down=gap_down),
max_order_records=len(data.index) * 2,
)
pf.orders.readable[["Index", "Side", "Price"]] Index Side Price
0 2024-01-09 00:00:00-05:00 Buy 456.917453
1 2024-01-09 00:00:00-05:00 Sell 458.863770
2 2024-01-17 00:00:00-05:00 Buy 456.869019
3 2024-01-17 00:00:00-05:00 Sell 457.324127Order functions come with a full set of lifecycle hooks. pre_sim_func_nb and post_sim_func_nb
wrap the whole run, the group and segment hooks run around each asset group and each bar, and
post_order_func_nb sees every execution result. With row_wise=True, the simulator finishes one
timestamp across all groups before moving to the next, so callbacks can coordinate groups.
vbt.pf_nb.stop_group_sim_nb ends a group early once a strategy has nothing left to do.
Order within a bar
A bar is a black box. Only its open and close have a known order in time, so a callback that trades on the high and the low of the same bar has to decide which came first.
Memory and custom outputs
Callbacks share state through arrays you pass in. Named tuples keep several arrays in one argument,
and in_outputs goes one step further: arrays created there are available as c.in_outputs in
every callback and come back with the portfolio, wrapped with its index and columns. The examples
above use them for the cooldown and the position counter, and the
In-place outputs section below records debt on every bar.
You can also record the decisions behind a result: which entries a risk rule blocked, when a cooldown was active, or what exposure the strategy saw before an order. Keeping those arrays with the portfolio lets you inspect the rule alongside its trades instead of only seeing the final return.
Inside compiled callbacks, preallocate arrays and keep an explicit count instead of growing them. For state with an unknown size, pass a Numba typed list as a callback argument.
Custom simulators
When even order functions are too rigid, for example for several account states per asset or many pending limit orders, write the simulation loop yourself. VBT exposes the same execution primitives its own simulators use, so the result is a regular portfolio with every metric and chart:
@njit
def dip_buyer_nb(close, init_cash, dip, rebound):
order_records = np.empty(close.shape, dtype=vbt.pf_enums.order_dt)
order_counts = np.zeros(close.shape[1], dtype=np.int_)
for col in range(close.shape[1]):
state = vbt.pf_enums.ExecState(
init_cash, 0.0, 0.0, 0.0, init_cash, np.nan, np.nan
)
peak = close[0, col]
for i in range(close.shape[0]):
price = close[i, col]
peak = max(peak, price)
if state.position == 0 and price <= peak * (1 - dip):
order = vbt.pf_nb.order_nb(size=np.inf, price=price)
elif state.position > 0 and price >= peak * (1 - rebound):
order = vbt.pf_nb.close_position_nb(price=price)
peak = price
else:
continue
_, state = vbt.pf_nb.process_order_nb(
col, col, i, state, order,
order_records=order_records, order_counts=order_counts
)
return vbt.nb.repartition_nb(order_records, order_counts)
data = vbt.YFData.pull("BTC-USD", start="2023-01-01", end="2024-01-01")
records = dip_buyer_nb(data.close.values[:, None], 100.0, 0.1, 0.02)
pf = vbt.PF(
data.symbol_wrapper,
close=data.close,
order_records=records,
init_cash=100.0
)
pf.orders.readable[["Index", "Side", "Price"]] Index Side Price
0 2023-03-07 00:00:00+00:00 Buy 22219.769531
1 2023-03-14 00:00:00+00:00 Sell 24746.074219
2 2023-04-21 00:00:00+00:00 Buy 27276.910156
3 2023-06-21 00:00:00+00:00 Sell 30027.296875
4 2023-08-17 00:00:00+00:00 Buy 26664.550781
5 2023-10-23 00:00:00+00:00 Sell 33086.234375The simulator buys after a 10% drop from the running peak and sells once the price is back within 2% of it. The same building blocks support limit orders you manage yourself, multiple orders per bar, and accounts that share cash across positions.
Documentation
The members-only Simulation guide builds a simulator from these primitives step by step.
✅ 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
The members-only callbacks guide
covers every callback that from_signals accepts.
✅ 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✅ Want to customize your simulation based on signals, or even generate signals dynamically according to the current backtesting environment? Two new callbacks now bring simulator flexibility to the next level: one lets you generate or override signals for each asset at every bar, and another allows you to compute user-defined metrics for the entire group at the end of each bar. Both accept a "context" that contains information about the current simulation state, enabling trading decisions to be made in a way similar to event-driven backtesters.
InOutputs = namedtuple("InOutputs", ["fast_sma", "slow_sma"])
def initialize_in_outputs(target_shape):
return InOutputs(
fast_sma=np.full(target_shape, np.nan),
slow_sma=np.full(target_shape, np.nan)
)
@njit
def signal_func_nb(ctx, fast_window, slow_window):
fast_sma = ctx.in_outputs.fast_sma
slow_sma = ctx.in_outputs.slow_sma
fast_start_i = ctx.i - fast_window + 1
slow_start_i = ctx.i - slow_window + 1
if fast_start_i >= 0 and slow_start_i >= 0:
fast_sma[ctx.i, ctx.col] = np.nanmean(ctx.close[fast_start_i : ctx.i + 1])
slow_sma[ctx.i, ctx.col] = np.nanmean(ctx.close[slow_start_i : ctx.i + 1])
is_entry = vbt.pf_nb.iter_crossed_above_nb(ctx, fast_sma, slow_sma)
is_exit = vbt.pf_nb.iter_crossed_below_nb(ctx, fast_sma, slow_sma)
return is_entry, is_exit, False, False
return False, False, False, False
pf = vbt.PF.from_signals(
vbt.YFData.pull("BTC-USD"),
signal_func_nb=signal_func_nb,
signal_args=(50, 200),
in_outputs=vbt.RepFunc(initialize_in_outputs),
)
fig = pf.get_in_output("fast_sma").vbt.plot()
pf.get_in_output("slow_sma").vbt.plot(fig=fig)
pf.orders.plot(plot_ohlc=False, plot_close=False, fig=fig)
fig.show()Related pages
- Backtesting engineSimulate orders, signals, and callbacks across many assets and parameters at once
- Signal backtestingTurn entry and exit signals into long, short, reversing, and pyramided positions
- Orders and executionSimulate limit and stop-entry orders, fill prices, timing, rejections, and real fills
- Live simulationContinue backtests on new bars, chain runs, and feed an external trading system
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.