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.
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.053252pf.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: objectpf.plot(settings=dict(bm_returns=False)).show()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:
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 SellWithout 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:
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 SellIn 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:
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.748144With 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:
| Conflict | Argument | Default |
|---|---|---|
| Entry and exit in the same direction | upon_long_conflict, upon_short_conflict | "ignore" |
| Long entry and short entry together | upon_dir_conflict | "ignore" |
| Entry opposite to the open position | upon_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:
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.assetsupon_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.0Reversals 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:
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.assetsaccumulate 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.0accumulate 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:
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 SellFor 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:
@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 SellThe 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:
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 TrueCalling 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:
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.0At 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.
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()Related pages
- Backtesting engineSimulate orders, signals, and callbacks across many assets and parameters at once
- Event-driven backtestingWrite compiled callbacks for cooldowns, position limits, and custom simulators
- Orders and executionSimulate limit and stop-entry orders, fill prices, timing, rejections, and real fills
- Stop loss and take profitBacktest stop losses, trailing stops, take profits, time stops, and exit ladders
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.