Features

Signal backtesting

Turn entry and exit signals into long, short, reversing, and pyramided positions

You have entry and exit signals, and you want to know how they become trades and what happens when they disagree. VBT lets you backtest buy and sell signals in Python by passing boolean arrays to vbt.PF.from_signals, which turns them into orders, positions, and a full portfolio you can inspect.

Use signals from technical indicators, your own pandas calculations, or model predictions converted to entry and exit conditions. You can trade long or short, scale into positions, and combine your signals with built-in stops and limit orders.

Backtest a moving average crossover on Bitcoin
data = vbt.YFData.pull("BTC-USD", start="2021-01-01", end="2024-01-01")
fast = data.run("talib:sma", timeperiod=20).real
slow = data.run("talib:sma", timeperiod=50).real
entries = fast.vbt.crossed_above(slow)
exits = fast.vbt.crossed_below(slow)

pf = vbt.PF.from_signals(data, entries, exits, price="nextopen", fees=0.001)  
pf.trades.readable[["Entry Index", "Exit Index", "Return"]].head(3)
                Entry Index                Exit Index    Return
0 2021-08-02 00:00:00+00:00 2021-09-24 00:00:00+00:00  0.122841
1 2021-10-12 00:00:00+00:00 2021-11-28 00:00:00+00:00 -0.049127
2 2022-02-20 00:00:00+00:00 2022-03-08 00:00:00+00:00 -0.053252
pf.stats(["total_return", "total_trades", "win_rate", "max_dd"])
Total Return [%]    -13.87894
Total Trades               12
Win Rate [%]        18.181818
Max Drawdown [%]      58.6206
dtype: object
pf.plot(settings=dict(bm_returns=False)).show()
Bitcoin price with moving average crossover entries and exits, trade returns, and cumulative returns from 2021 to 2023 Figure data (JSON)

Multiple assets and parameter combinations

The same method works with one signal Series or a DataFrame containing many assets and parameter combinations. Compare indicator settings, position sizes, and conflict rules in one simulation, then select each result by its column labels.

Each column has its own cash by default. To test signals as a multi-asset portfolio, group the asset columns and enable cash sharing. The Backtesting engine page shows both approaches, and Parameter optimization shows how to build larger searches.

From signals to orders

Each signal is a request, not an order. At every bar the simulator reads the signals, checks the current position, and converts the winning signal into an order with a size, a price, and a direction. By default, an entry while flat opens a position. A second entry while already in that position is ignored. An exit closes the position in full.

Every order argument broadcasts with the signals, so it can be a scalar, a row, a column, or a full array. To use a different value per signal, fill an array only where the signals are set:

Use a different size for each entry
close = pd.Series(
    [10.0, 11.0, 12.0, 11.0, 10.0, 12.0],
    index=pd.date_range("2026-01-01", periods=6)
)
entries = pd.Series([True, False, False, False, True, False], index=close.index)
exits = pd.Series([False, False, True, False, False, True], index=close.index)

size = close.vbt.wrapper.fill(np.nan)
size[entries] = [5, 10]

pf = vbt.PF.from_signals(close, entries, exits, size=size, init_cash=1000)
pf.orders.readable[["Fill Index", "Size", "Side"]]
  Fill Index  Size  Side
0 2026-01-01   5.0   Buy
1 2026-01-03   5.0  Sell
2 2026-01-05  10.0   Buy
3 2026-01-06  10.0  Sell

Without a size, each entry uses all available cash. Size types that target a position, such as "targetpercent", are rejected because a target can contradict the direction of a signal. To size each entry as a share of portfolio value, use "valuepercent", or pass targets as described in Targets as signals.

The per-signal pattern works for price, order_type, fees, and any other order argument. A price array, for example, lets exits fill at levels computed by your own logic:

Exit at computed prices and enter at the close
price = close.copy()  
price[exits] = [11.8, 11.7]

pf = vbt.PF.from_signals(close, entries, exits, price=price)
pf.orders.readable[["Fill Index", "Size", "Price", "Side"]]
  Fill Index  Size  Price  Side
0 2026-01-01  10.0   10.0   Buy
1 2026-01-03  10.0   11.8  Sell
2 2026-01-05  11.8   10.0   Buy
3 2026-01-06  11.8   11.7  Sell

In a price array, -np.inf stands for the bar's open and np.inf for its close. Setting price[entries] = -np.inf on an array filled with np.inf enters at the open and exits at the close.

Execution timing

By default, an order fills at the close of the bar where its signal appears. If your signals are computed from that same close, use price="nextopen" or price="nextclose", or shift the signals by one bar, so the backtest does not trade on a price it could not have known in time.

Stops and limit orders

Your entry signals can work with stop losses, trailing stops, take-profit targets, and time exits. The simulator tracks these alongside your signals, so you can test an exit rule without writing the position-tracking logic yourself. Stop levels can differ across assets and parameter combinations. See Stop loss and take profit.

Signals can also place limit orders that wait for a price instead of filling immediately. You can use different order types for entries and exits, and set how long an unfilled limit order stays active. See Orders and execution for limit prices, expiry, and fill behavior.

Long, short, and reversals

There are two ways to describe direction. With two masks, entries and exits, a direction argument decides what they mean. "longonly" is the default, "shortonly" flips it, and "both" makes each exit reverse into a short position. With four masks, long_entries, long_exits, short_entries, and short_exits, the signals set the direction themselves, so a position can also go flat between trades, and direction is ignored.

The difference matters. Below, a Bollinger Bands strategy buys below the lower band, shorts above the upper band, and exits at the middle band. Collapsing it into two masks with direction="both" removes the exits, so the strategy is always in the market:

Compare four direction-aware masks with two reversing masks
data = vbt.YFData.pull("BTC-USD", start="2023-01-01", end="2024-01-01")
bb = data.run("bbands", window=20, alpha=2)
long_entries = data.close.vbt.crossed_below(bb.lower)
long_exits = data.close.vbt.crossed_above(bb.middle)
short_entries = data.close.vbt.crossed_above(bb.upper)
short_exits = data.close.vbt.crossed_below(bb.middle)

pf_four = vbt.PF.from_signals(
    data,
    long_entries=long_entries,
    long_exits=long_exits,
    short_entries=short_entries,
    short_exits=short_exits,
)
pf_two = vbt.PF.from_signals(
    data,
    entries=long_entries,
    exits=short_entries,
    direction="both"
)
metrics = ["total_trades", "total_time_exposure", "total_return"]
pd.DataFrame({
    "four masks": pf_four.stats(metrics),
    "two masks": pf_two.stats(metrics),
})
                      four masks  two masks
Total Trades                  15          6
Position Coverage [%]  44.931507  89.315068
Total Return [%]       14.886316 -40.748144

With four masks, the 15 trades split into 6 longs and 9 shorts, and the strategy waits in cash for more than half the year. Use two masks when one direction rules a whole column, and four masks when positions must close without reversing.

Conflicting signals

Signals generated by different rules often fire on the same bar. The simulator resolves them in a fixed order, and each step has its own argument:

ConflictArgumentDefault
Entry and exit in the same directionupon_long_conflict, upon_short_conflict"ignore"
Long entry and short entry togetherupon_dir_conflict"ignore"
Entry opposite to the open positionupon_opposite_entry"reversereduce"

Same-direction conflicts pick "entry", "exit", or the signal "adjacent" or "opposite" to the current position. Direction conflicts pick "long", "short", "adjacent", or "opposite". Opposite entries can be ignored, close the position, or reverse it. Like any other argument, each mode can vary by bar and column, and vbt.Param tests several at once:

Resolve an entry and an exit on the same bar
close = pd.Series(
    [10.0, 11.0, 12.0, 11.0],
    index=pd.date_range("2026-01-01", periods=4)
)
entries = pd.Series([True, False, True, False], index=close.index)
exits = pd.Series([False, False, True, False], index=close.index)

pf = vbt.PF.from_signals(
    close,
    entries,
    exits,
    upon_long_conflict=vbt.Param(["ignore", "exit"]),
    size=1,
)
pf.assets
upon_long_conflict  ignore  exit
2026-01-01             1.0   1.0
2026-01-02             1.0   1.0
2026-01-03             1.0   0.0
2026-01-04             1.0   0.0

Reversals are one order

A reversal closes the old position and opens the new one in a single order, so both legs share one price and order type. To close with a market order and reopen with a limit order, issue the exit on one bar and the opposite entry on the next, and set order_type per signal. Trade records still show two trades, one closing and one opening, and a max_size smaller than both legs together can block the reversal.

Accumulation and entry ladders

Without accumulation, the simulator keeps at most one position per column and ignores repeated entries. Set accumulate=True to treat every signal as an order, so entries add to the position and exits with a size reduce it:

Add one unit on each entry signal
close = pd.Series(
    [10.0, 11.0, 12.0, 13.0, 12.0, 14.0],
    index=pd.date_range("2026-01-01", periods=6)
)
entries = pd.Series([True, True, True, True, False, False], index=close.index)
exits = pd.Series([False, False, False, False, False, True], index=close.index)

pf = vbt.PF.from_signals(
    close,
    entries,
    exits,
    size=1,
    accumulate=vbt.Param([False, True])
)
pf.assets
accumulate  False  True
2026-01-01    1.0    1.0
2026-01-02    1.0    2.0
2026-01-03    1.0    3.0
2026-01-04    1.0    4.0
2026-01-05    1.0    4.0
2026-01-06    0.0    3.0

accumulate also takes "addonly" to scale in while each exit closes the whole position, and "removeonly" to scale out of a single entry. With size_type="percent", an exit size is a share of the current position, so taking half off and then closing the rest is two exits:

Buy with all cash, sell half, then sell the rest
close = pd.Series(
    [10.0, 11.0, 12.0, 11.5, 13.0, 12.0],
    index=pd.date_range("2026-01-01", periods=6)
)
entries = pd.Series([True, False, False, False, False, False], index=close.index)
exits = pd.Series([False, False, True, False, True, False], index=close.index)
size = close.vbt.wrapper.fill(np.nan)
size[entries] = 1.0  
size[exits] = [0.5, 1.0]

pf = vbt.PF.from_signals(
    close,
    entries,
    exits,
    size=size,
    size_type="percent",
    accumulate="removeonly",
    init_cash=1000,
)
pf.orders.readable[["Fill Index", "Size", "Price", "Side"]]
  Fill Index   Size  Price  Side
0 2026-01-01  100.0   10.0   Buy
1 2026-01-03   50.0   12.0  Sell
2 2026-01-05   50.0   13.0  Sell

For pyramiding with a cap, the decision depends on the position you already hold, so it cannot be precomputed as a mask. A signal function written with Numba sees the simulation state at every bar. Here it adds one unit on each new 20-day high, up to three units, and exits below the 20-day average:

Pyramid into new highs with a three-unit cap
@njit
def pyramid_signal_nb(c, new_high, exits, max_units):
    position = c.last_position[c.col]  
    if position > 0 and vbt.pf_nb.select_nb(c, exits):
        return False, True, False, False  
    if position < max_units and vbt.pf_nb.select_nb(c, new_high):
        return True, False, False, False
    return False, False, False, False

new_high = data.close >= data.close.rolling(20).max()
exits = data.close.vbt.crossed_below(data.close.rolling(20).mean())
pf = vbt.PF.from_signals(
    data,
    signal_func_nb=pyramid_signal_nb,
    signal_args=(vbt.Rep("new_high"), vbt.Rep("exits"), 3),
    broadcast_named_args=dict(new_high=new_high, exits=exits),  
    size=1,
    init_cash=100_000,
    accumulate="addonly",
)
pf.orders.readable[["Fill Index", "Size", "Price", "Side"]].head(4)
                 Fill Index  Size         Price  Side
0 2023-01-20 00:00:00+00:00   1.0  22676.552734   Buy
1 2023-01-21 00:00:00+00:00   1.0  22777.625000   Buy
2 2023-01-23 00:00:00+00:00   1.0  22934.431641   Buy
3 2023-02-06 00:00:00+00:00   3.0  22760.109375  Sell

The same technique builds entry ladders and DCA plans. The signal function records the initial entry, then releases staged entries after a set number of bars or at set price levels, each with its own size.

You can also add to a winning position after it reaches a profit threshold. Signal callbacks can read the open position's return and issue another entry with accumulation enabled. See Position info for the running position metrics available to your logic.

Documentation

The members-only Portfolio from signals guide builds a parameterized entry ladder step by step.

Targets as signals

Sometimes you know the position you want, not the signals. With order_mode=True, the size array is read as a list of orders, and the simulator derives the matching signals so stop and limit orders still apply. pf.get_signals() returns the four signal masks that were actually executed:

Derive signals from target positions
close = pd.Series(
    [10.0, 11.0, 12.0, 11.0, 13.0],
    index=pd.date_range("2026-01-01", periods=5)
)
target = pd.Series([5, np.nan, 10, 0, -5], index=close.index)  

pf = vbt.PF.from_signals(
    close,
    size=target,
    size_type="targetamount",
    order_mode=True,
    accumulate=True,
    direction="both",
    init_cash=1000,
)
long_entries, long_exits, short_entries, short_exits = pf.get_signals()
pd.DataFrame({
    "long entry": long_entries,
    "long exit": long_exits,
    "short entry": short_entries,
})
            long entry  long exit  short entry
2026-01-01        True      False        False
2026-01-02       False      False        False
2026-01-03        True      False        False
2026-01-04       False       True        False
2026-01-05       False      False         True

Calling the kernel directly

vbt.PF.from_signals prepares arguments, broadcasts them, and wraps the results. The engine underneath is a compiled function that takes plain NumPy arrays. You can call it directly, for example inside your own Numba code or a large optimization loop:

Run the compiled signal simulator on NumPy arrays
close_arr = np.array([[10.0], [11.0], [12.0], [11.0], [13.0]])
long_entries = np.array([[True], [False], [False], [True], [False]])
long_exits = np.array([[False], [False], [True], [False], [True]])

sim_out = vbt.pf_nb.from_signals_nb(
    target_shape=close_arr.shape,
    group_lens=np.array([1]),  
    close=close_arr,
    init_cash=100.0,
    long_entries=long_entries,
    long_exits=long_exits,
)
pd.DataFrame(sim_out.order_records)[["idx", "size", "price"]]
   idx       size  price
0    0  10.000000   10.0
1    2  10.000000   12.0
2    3  10.909091   11.0
3    4  10.909091   13.0

At this level, argument preparation is up to you: string options become their enum integers, and arrays must already have the target shape or broadcast to it. The callback version, from_signal_func_nb, accepts the same signal functions as from_signals, and trade records and metrics can be rebuilt from the order records with compiled functions as well.

Tutorial

The members-only From Python to Rust tutorial takes one strategy from vbt.PF.from_signals down to the Numba kernel and then to Rust.

✅ Target size can be converted to signals using a special signal function, giving access to stop and limit order functionality. This is especially useful, for example, in portfolio optimization.

Perform the Mean-Variance Optimization with SL and TP
data = vbt.YFData.pull(
    ["SPY", "TLT", "XLF", "XLE", "XLU", "XLK", "XLB", "XLP", "XLY", "XLI", "XLV"],
    start="2022",
    end="2023",
    missing_index="drop"
)
pfo = vbt.PFO.from_riskfolio(data.returns, every="M")
pf = pfo.simulate(
    data,
    pf_method="from_signals",
    sl_stop=0.05,
    tp_stop=0.1,
    stop_exit_price="close"  
)
pf.plot_allocations().show()
Monthly Mean-Variance portfolio allocations with stop-loss and take-profit protection during 2022 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.