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.
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())111episodes = oversold.vbt.signals.partition_ranges()
print(episodes.count())33longest = 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 12print(round(longest.duration.values.sum() / oversold.sum(), 3))0.324print(oversold.vbt.signals.first().sum()) 33Seven 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:
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 12The 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.
@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 1The 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:
exits = rsi.vbt.crossed_above(70)
print(entries.sum(), exits.sum())12 60clean_entries, clean_exits = entries.vbt.signals.clean(exits)
print(clean_entries.sum(), clean_exits.sum())8 8Cleaning 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.
✅ 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.
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() ✅ 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.
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()✅ 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.
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()✅ Crossovers are now robust to NaNs.
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()✅ Signal generation functions have been redesigned to operate on contexts. This allows you to design more complex signal strategies with less namespace pollution.
@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 FalseTutorial
Learn more in the Signal development tutorial.
Related pages
- Backtesting › Backtesting engineSimulate orders, signals, and callbacks across many assets and parameters at once
- Backtesting › Event-driven backtestingWrite compiled callbacks for cooldowns, position limits, and custom simulators
- Technical indicatorsRun TA-Lib, pandas-ta, ta, SMC, WorldQuant Alphas, and native indicators at scale
- Backtesting › Signal backtestingTurn entry and exit signals into long, short, reversing, and pyramided positions
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.