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.

Block re-entries for 10 bars after a stop loss
@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.897484
pf[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      None

The 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 doUse
Keep signal arrays and adjust sizes or stops as the account changesAn adjustment callback with from_signals
Decide whether to enter or exit using current positions, fills, or PnLA signal callback with from_signals
Choose each order directly, including several orders on one barAn order function with from_order_func, using flexible mode for multiple orders
Manage your own account states and simulation loopA 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:

StateContext field or helper
Position, cash, and debtc.last_position, c.last_cash, c.last_debt
Portfolio valuec.last_value, vbt.pf_nb.get_group_value_nb(c, c.group)
Open position: entry bar, price, PnL, returnc.last_pos_info[c.col]
Pending stops and limit ordersc.last_sl_info, c.last_tsl_info, c.last_tp_info, c.last_limit_info
Your own arrays at the current barvbt.pf_nb.select_nb(c, array)
What the last order didvbt.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:

Hold at most three positions across 20 assets
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())
3

Without 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:

RuleState it reads
Cooldown after a stop, a loss, or any exitLast order record and its stop type, closed position PnL
Maximum concurrent positionsOpen positions in the group plus entries approved this bar
Minimum holding periodEntry bar of the open position in c.last_pos_info
Daily trade or loss limitCounters and a reference value reset on the first bar of each day
Equity curve filterPortfolio value history kept in your own array
Portfolio-level stopGroup 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:

Buy the open and sell the close on gap-down days
@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.324127

Order 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:

Write a dip-buying simulator from execution primitives
@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.234375

The 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.

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

The members-only callbacks guide covers every callback that from_signals accepts.

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

Signal callbacks

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

Backtest SMA crossover iteratively
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()
BTC-USD iterative 50-day and 200-day SMA crossover with buy and sell orders Figure data (JSON)

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.