# Signal backtesting (/features/backtesting/signal-backtesting)

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.

```python title="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)  # (1)
>>> 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()
```

1.  Signals are computed on each bar's close and filled at the next bar's open, with 0.1% fees per
    order.

Bitcoin price with moving average crossover entries and exits, trade returns, and cumulative returns from 2021 to 2023. [Figure data (JSON)](/assets/figures/features/backtesting/signal-backtesting-sma-crossover.c0a5967bf35b.json)

## Multiple assets and parameter combinations \[#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](/features/backtesting/backtesting-engine/#many-backtests-in-one-call) page
shows both approaches, and [Parameter optimization](/features/optimization/strategy-optimization/)
shows how to build larger searches.

## From signals to orders \[#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:

```python title="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](#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:

```python title="Exit at computed prices and enter at the close"
>>> price = close.copy()  # (1)
>>> 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
```

1.  Start from the close so that entries keep a valid price. A bar whose price is `NaN` places no
    order, so a price array filled only at exits would skip every entry without an error.

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.

!!! warning "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 \[#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](/features/backtesting/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](/features/backtesting/orders-and-execution/) for limit prices,
expiry, and fill behavior.

## Long, short, and reversals \[#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:

```python title="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 \[#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:

=== "Entry and exit together"
    ```python title="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
    ```

=== "Opposite entry"
    ```python title="Close or reverse a long position on a short entry"
    >>> long_entries = pd.Series([True, False, False, False], index=close.index)
    >>> short_entries = pd.Series([False, False, True, False], index=close.index)

    >>> pf = vbt.PF.from_signals(
    ...     close,
    ...     long_entries=long_entries,
    ...     short_entries=short_entries,
    ...     upon_opposite_entry=vbt.Param(["ignore", "close", "reverse"]),
    ...     size=1,
    ... )
    >>> pf.assets
    upon_opposite_entry  ignore  close  reverse
    2026-01-01              1.0    1.0      1.0
    2026-01-02              1.0    1.0      1.0
    2026-01-03              1.0    0.0     -1.0
    2026-01-04              1.0    0.0     -1.0
    ```

!!! info "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 \[#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:

```python title="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:

```python title="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  # (1)
>>> 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
```

1.  For entries, the percentage applies to available cash, so 1.0 invests all of it.

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:

```python title="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]  # (1)
...     if position > 0 and vbt.pf_nb.select_nb(c, exits):
...         return False, True, False, False  # (2)
...     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),  # (3)
...     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
```

1.  The position in the current column before this bar's order.
2.  The function returns four flags: long entry, long exit, short entry, and short exit.
3.  Arrays passed here are broadcast to the data's shape and substituted for `vbt.Rep` placeholders.

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](/features/backtesting/portfolio-accounting/#position-info) for the running position
metrics available to your logic.

!!! info "Documentation"
    The members-only [Portfolio from signals](https://members.vectorbt.pro/documentation/portfolio/from-signals/)
    guide builds a parameterized entry ladder step by step.

## Targets as signals \[#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:

```python title="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)  # (1)

>>> 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
```

1.  Hold 5 units, keep the position, raise it to 10, go flat, then hold a 5-unit short. `NaN` means
    no order.

## Calling the kernel directly \[#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:

```python title="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]),  # (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
```

1.  One group with one column. Columns in the same group share cash.

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.

!!! info "Tutorial"
    The members-only [From Python to Rust](https://members.vectorbt.pro/tutorials/from-python-to-rust/static/)
    tutorial takes one strategy from `vbt.PF.from_signals` down to the Numba kernel and then to Rust.

## Target size to signals \[#target-size-to-signals]

New in 1.10.0

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

```python title="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"  # (1)
... )
>>> pf.plot_allocations().show()
```

1.  Otherwise, user and stop signals will occur at different times within the same bar, making it
    impossible to establish the correct order of execution.

Monthly Mean-Variance portfolio allocations with stop-loss and take-profit protection during 2022. [Figure data (JSON)](/assets/figures/features/portfolio/target-size-to-signals.8f527b79be15.json)


## Related pages

*   [Backtesting engine](/features/backtesting/backtesting-engine/): Simulate orders, signals, and callbacks across many assets and parameters at once
*   [Event-driven backtesting](/features/backtesting/event-driven-backtesting/): Write compiled callbacks for cooldowns, position limits, and custom simulators
*   [Orders and execution](/features/backtesting/orders-and-execution/): Simulate limit and stop-entry orders, fill prices, timing, rejections, and real fills
*   [Stop loss and take profit](/features/backtesting/stop-loss-and-take-profit/): Backtest stop losses, trailing stops, take profits, time stops, and exit ladders