Features

Trading signals

Generate, combine, clean, and analyze entry and exit signals before backtesting

How do you turn indicators into clean entry and exit signals, and how do you know what those signals actually capture? VBT generates trading signals in Python from crossovers, thresholds, pivots, and calendars, and treats signals as boolean arrays it can clean, rank, partition, and measure before any backtest runs.

Find out how many separate episodes 111 oversold days really are
btc = vbt.YFData.pull("BTC-USD", start="2018-01-01", end="2025-01-01")
rsi = vbt.RSI.run(btc.close, window=14).rsi
oversold = rsi < 30
print(oversold.sum())
111
episodes = oversold.vbt.signals.partition_ranges()  
print(episodes.count())
33
longest = episodes.apply_mask(episodes.duration.top_n_mask(3))
longest.readable[["Start Index", "End Index"]].assign(Days=longest.duration.values)
                Start Index                 End Index  Days
0 2018-11-14 00:00:00+00:00 2018-11-28 00:00:00+00:00    14
1 2022-06-13 00:00:00+00:00 2022-06-23 00:00:00+00:00    10
2 2023-08-17 00:00:00+00:00 2023-08-29 00:00:00+00:00    12
print(round(longest.duration.values.sum() / oversold.sum(), 3))
0.324
print(oversold.vbt.signals.first().sum())  
33

Seven years of daily data show 111 oversold days, but only 33 separate episodes, and the three longest ones hold a third of all those days. A strategy that buys oversold readings is mostly a bet on a handful of crashes, which is worth knowing before reading its backtest.

Crossovers and thresholds

crossed_above and crossed_below mark the bar where one series crosses another or a level, and above and below mark every bar on one side. A crossover can require the new side to hold for a number of bars with wait, and missing values can be skipped instead of breaking the comparison, as the Robust crossovers highlight below shows. Every indicator output has these methods too, such as rsi.rsi_crossed_below(30).

Combining conditions

Conditions combine with &, |, and ~ across assets and parameter combinations, because signals are aligned arrays like any other. Outputs of indicators run with different parameters carry different column labels, such as fast_window 5 and 10 against slow_window 20 and 25, and pandas refuses to compare them. Put .vbt in front, as in fast.vbt > slow, or use fast.vbt.crossed_above(slow): both align the columns and label the result with both parameters. A condition that should stay valid for a while, such as "RSI went below 30 in the last five days", is a rolling maximum of its mask:

Enter when price turns up within five days of an oversold cross
crossed_down = rsi.vbt.crossed_below(30)
recently_oversold = crossed_down.rolling(5, min_periods=1).max().astype(bool)
trigger = btc.close.vbt.crossed_above(btc.close.rolling(10).mean())
entries = recently_oversold & trigger
print(crossed_down.sum(), trigger.sum(), entries.sum())
33 215 12

The same approach covers streaks of consecutive bars, divergences between price and momentum, and signals from one asset used to trade another.

Pivots and calendars

vbt.PIVOTINFO detects swing highs and lows that reversed by at least a given fraction, and tells confirmed pivots from the latest candidate, which can still change. The Pivot detection highlight below plots both. Calendar signals are masks over the index, for example the first trading day of each week or a fixed time of day, and first keeps the first signal of each consecutive run so a rule fires once per episode.

One signal per day

A strategy may allow one opportunity per day even if its condition becomes true several times. Group the mask by day and keep its first event. This differs from keeping the first event of each consecutive run, because later events may be separated by False values.

Keep the first signal of each day, including days with no signals
@njit
def first_signal_nb(values):
    out = np.full(values.shape, False)
    for i in range(len(values)):
        if values[i]:
            out[i] = True
            break
    return out

mask = pd.Series(
    [True, False, True, False, False, False, False, True, True],
    index=pd.date_range("2025-01-01", periods=9, freq="8h"),
)
first_per_day = mask.vbt.groupby_transform("D", first_signal_nb)
pd.DataFrame({
    "Raw signals": mask.resample("D").sum(),
    "First per day": first_per_day.resample("D").sum(),
})
            Raw signals  First per day
2025-01-01            2              1
2025-01-02            0              0
2025-01-03            2              1

The empty day stays empty. The same grouping approach can express one opportunity per week or another calendar period, while signal ranking lets you select later events in a sequence.

Custom generators

When a signal depends on earlier signals, such as an exit that must come a minimum number of bars after its entry, a vectorized mask is not enough. The signal factory builds generators from compiled placement functions that search forward from each entry for its exit, and generate_random and generate_random_both create reproducible random signals for baselines. Placement functions receive a context with the search window and the column, as described in the Signal contexts highlight.

Cleaning and analysis

Raw entry and exit masks often repeat themselves. clean keeps the first entry after each exit and the first exit after each entry, which turns them into alternating pairs:

Clean entries and exits into alternating pairs
exits = rsi.vbt.crossed_above(70)
print(entries.sum(), exits.sum())
12 60
clean_entries, clean_exits = entries.vbt.signals.clean(exits)
print(clean_entries.sum(), clean_exits.sum())
8 8

Cleaning is for analysis. vbt.PF.from_signals resolves repeated signals itself, and cleaning before an accumulating backtest would remove the extra entries that accumulation relies on.

Signals can also be ranked, counted per period, measured by the distance between them, unraveled into one column per signal, and summarized with stats(). Shift a signal forward with fshift when it is computed from the close and executed on the next bar. The Signal unraveling and Signal detection highlights below show two of these tools.

From a signal to a trade

Use signal analysis to check what your rule captures: how often it fires, how long conditions persist, and whether events cluster in a few periods. Random signals provide a baseline for comparing your entry rule, while unraveling lets you inspect each opportunity separately. Patterns and event studies takes this further by examining the price paths after events.

A signal is a request to trade. Pass it to Signal backtesting to account for position size, fees, stops, and whether orders can fill. If the next signal depends on an actual fill, available cash, or the current position, generate it inside a simulation callback as described in Event-driven backtesting. This also lets signals from one asset influence decisions in another.

Signal unraveling

✅ To backtest each signal individually, you can now "unravel" each signal, or each pair of entry and exit signals, into its own column. This creates a wide, two-dimensional mask that, when backtested, returns performance metrics for each signal rather than for the entire column.

For each signal, create a separate position with own stop orders
data = vbt.YFData.pull("BTC-USD")
fast_sma = data.run("talib_func:sma", timeperiod=20)  
slow_sma = data.run("talib_func:sma", timeperiod=50)
entries = fast_sma.vbt.crossed_above(slow_sma)
exits = fast_sma.vbt.crossed_below(slow_sma)
entries, exits = entries.vbt.signals.unravel_between(exits, relation="anychain")  
pf = vbt.PF.from_signals(
    data,
    long_entries=entries,
    short_entries=exits,
    size=100,  
    size_type="value",
    init_cash="auto",  
    tp_stop=0.2,
    sl_stop=0.1,
    group_by=vbt.ExceptLevel("signal"),  
    cash_sharing=True
)
pf.positions.returns.to_pd(ignore_index=True).vbt.barplot(
    trace_kwargs=dict(marker=dict(colorscale="Spectral"))
).show()  
Position returns for individually unraveled BTC-USD signals Figure data (JSON)

Signal detection

✅ VBT includes an indicator that uses a robust peak detection algorithm based on z-scores. This indicator can be used to identify outbreaks and outliers in any time series data.

Detect sudden changes in the bandwidth of a Bollinger Bands indicator
data = vbt.YFData.pull("BTC-USD")
fig = vbt.make_subplots(rows=2, cols=1, shared_xaxes=True)
bbands = data.run("bbands")
bbands.loc["2022"].plot(add_trace_kwargs=dict(row=1, col=1), fig=fig)
sigdet = vbt.SIGDET.run(bbands.bandwidth, factor=5)
sigdet.loc["2022"].plot(add_trace_kwargs=dict(row=2, col=1), fig=fig)
fig.show()
BTC-USD Bollinger Bands and detected bandwidth changes during 2022 Figure data (JSON)

Pivot detection

✅ The pivot detection indicator is a tool for finding when the price trend is reversing. By identifying support and resistance areas, it helps spot significant price changes while filtering out short-term fluctuations and reducing noise. A valley is confirmed after the price rises by the chosen threshold, and a peak after it falls by the chosen threshold. The indicator exposes both the last confirmed pivot and the current candidate. Use the confirmed information from the bar where the reversal becomes known when building a trading rule. A regular Zig Zag indicator plots the turning points back at their original locations, which serves a different purpose from deciding when a strategy could act.

Plot the last pivot value
data = vbt.YFData.pull("BTC-USD", start="2020", end="2023")
fig = data.plot(plot_volume=False)
pivot_info = data.run("pivotinfo", up_th=1.0, down_th=0.5)
pivot_info.plot(fig=fig, conf_value_trace_kwargs=dict(visible=False))
fig.show()
BTC-USD OHLC with confirmed pivot values from 2020 through 2022 Figure data (JSON)

✅ Crossovers are now robust to NaNs.

Remove a bunch of data points and plot the crossovers
data = vbt.YFData.pull("BTC-USD", start="2022-01", end="2022-03")
fast_sma = vbt.talib("SMA").run(data.close, vbt.Default(5)).real
slow_sma = vbt.talib("SMA").run(data.close, vbt.Default(10)).real
np.random.seed(42)
fast_sma.iloc[np.random.choice(np.arange(len(fast_sma)), 5)] = np.nan
slow_sma.iloc[np.random.choice(np.arange(len(slow_sma)), 5)] = np.nan
crossed_above = fast_sma.vbt.crossed_above(slow_sma, skipna=True)
crossed_below = fast_sma.vbt.crossed_below(slow_sma, skipna=True)

fig = fast_sma.rename("Fast SMA").vbt.lineplot()
slow_sma.rename("Slow SMA").vbt.lineplot(fig=fig)
crossed_above.vbt.signals.plot_as_entries(fast_sma, fig=fig)
crossed_below.vbt.signals.plot_as_exits(fast_sma, fig=fig)
fig.show()
Robust fast and slow BTC-USD moving-average crossovers with missing data Figure data (JSON)

Signal contexts

✅ Signal generation functions have been redesigned to operate on contexts. This allows you to design more complex signal strategies with less namespace pollution.

Entry at the first bar of the week, exit at the last bar of the week
@njit
def entry_place_func_nb(ctx, index):
    for i in range(ctx.from_i, ctx.to_i):  
        if i == 0:
            return i - ctx.from_i  
        else:
            index_before = index[i - 1]
            index_now = index[i]
            index_next_week = vbt.dt_nb.future_weekday_nb(index_before, 0)
            if index_now >= index_next_week:  
                return i - ctx.from_i
    return -1

@njit
def exit_place_func_nb(ctx, index):
    for i in range(ctx.from_i, ctx.to_i):
        if i == len(index) - 1:
            return i - ctx.from_i
        else:
            index_now = index[i]
            index_after = index[i + 1]
            index_next_week = vbt.dt_nb.future_weekday_nb(index_now, 0)
            if index_after >= index_next_week:  
                return i - ctx.from_i
    return -1

data = vbt.YFData.pull("BTC-USD", start="2020-01-01", end="2020-01-14")
entries, exits = vbt.pd_acc.signals.generate_both(
    data.symbol_wrapper.shape,
    entry_place_func_nb=entry_place_func_nb,
    entry_place_args=(data.index.vbt.to_ns(),),
    exit_place_func_nb=exit_place_func_nb,
    exit_place_args=(data.index.vbt.to_ns(),),
    wrapper=data.symbol_wrapper
)
pd.concat((
    entries.rename("Entries"),
    exits.rename("Exits")
), axis=1).to_period("W")
                       Entries  Exits
Date
2020-01-06/2020-01-12     True  False
2020-01-06/2020-01-12    False  False
2020-01-06/2020-01-12    False  False
2020-01-06/2020-01-12    False  False
2020-01-06/2020-01-12    False  False
2020-01-06/2020-01-12    False  False
2020-01-06/2020-01-12    False   True
2020-01-13/2020-01-19     True  False

Tutorial

Learn more in the Signal development tutorial.

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.