Features

Intraday and tick backtesting

Backtest minute bars, ticks, and sessions, and resolve stops on finer data

A bar only tells you its open, high, low, and close. For intraday strategies with tight stops, what happened inside the bar decides the result. VBT runs intraday and tick backtesting in Python on bars of any size, on irregular timestamps, and within trading sessions, and lets you resolve stops on finer data than the signals use.

Resolve the same stops on hourly and on minute bars
minute_data = vbt.GBMOHLCData.pull(  
    "SYN",
    start="2024-01-01",
    end="2024-02-01",
    timeframe="1min",
    std=0.0001,
    seed=42,
)
hourly_data = minute_data.resample("1h")
hourly_entries = hourly_data.symbol_wrapper.fill(False)
hourly_entries.iloc[::4] = True  

minute_entries = minute_data.symbol_wrapper.fill(False)
entry_minutes = hourly_entries.index[hourly_entries] + pd.Timedelta(minutes=59)
minute_entries[entry_minutes] = True  

kwargs = dict(sl_stop=0.002, tp_stop=0.002)
hourly_pf = vbt.PF.from_signals(hourly_data, hourly_entries, **kwargs)
minute_pf = vbt.PF.from_signals(minute_data, minute_entries, **kwargs)

def summary(pf):
    stop_types = pf.orders.readable["Stop Type"].value_counts()
    return pd.Series({
        "trades": pf.trades.count(),
        "stop losses": stop_types.get("SL", 0),
        "take profits": stop_types.get("TP", 0),
        "win rate": round(pf.trades.win_rate, 3),
        "total return": round(pf.total_return, 3),
    })

pd.DataFrame({"hourly bars": summary(hourly_pf), "minute bars": summary(minute_pf)})
              hourly bars  minute bars
trades            186.000      186.000
stop losses       134.000      102.000
take profits       52.000       84.000
win rate            0.280        0.452
total return       -0.152       -0.036

Both runs take the same 186 trades with the same 0.2% stop loss and take profit. On hourly bars, many bars reach both levels, and with no way to know which came first the simulator assumes the stop loss. Minute bars resolve more of those cases, and the win rate moves from 28% to 45%, much closer to the 50% that symmetric stops on a random walk should produce.

Ticks and irregular bars

Nothing in the engine assumes a fixed bar size. Tick prices go in as close, each row is processed as its own moment, and the open, high, and low are not needed. Volume bars, range bars, dollar bars, and trade-count bars built elsewhere work the same way, because the index only needs to be sorted:

Trade on ticks with irregular timestamps
index = pd.date_range("2024-01-01 09:30", periods=8, freq="1s").vbt.randomize(
    step="100ms",
    seed=42,
)
prices = [100.0, 100.2, 100.1, 100.4, 100.3, 100.6, 100.5, 100.7]
ticks = pd.Series(prices, index=index)
ticks.index.to_series().diff().dt.total_seconds().tolist()
[nan, 0.8, 1.6, 1.2, 0.8, 0.6, 1.0, 1.0]
entries = pd.Series([True] + [False] * 7, index=index)
exits = pd.Series([False] * 6 + [True, False], index=index)
pf = vbt.PF.from_signals(ticks, entries, exits)
pf.orders.readable[["Fill Index", "Side", "Price"]]
           Fill Index  Side  Price
0 2024-01-01 09:30:00   Buy  100.0
1 2024-01-01 09:30:06  Sell  100.5

Metrics that annualize, such as the Sharpe ratio, need a frequency, which an irregular index does not have. Pass freq explicitly, use freq="index_mean" to take the average spacing of the timestamps, or resample the portfolio's returns to a regular grid for analysis. Durations are counted as bars times this frequency, so with the average spacing they are estimates. How long a year is matters as well, as explained under Returns.

To build synthetic tick data for tests, vbt.RandomOHLCData and vbt.GBMOHLCData generate prices, and .vbt.randomize() turns a regular index into irregular timestamps as above.

Windows on ticks count rows, not time

Most indicators take a window in rows. On ticks, a 20-row window can span a second or an hour depending on activity. Compute indicators on time bars, or use time-based windows such as ticks.rolling("5min"), and align the result back to the tick index.

Inside the bar

There are three ways to see inside a bar. The first is the one above: compute signals on the bars you trade, and run the simulation on finer bars so stops and limits are checked at finer resolution. Results can be resampled back to the original timeframe for reporting.

An hourly strategy can therefore use minute or tick prices for execution without changing its indicator windows to minutes or ticks. VBT's alignment tools let you place an hourly closing signal where that hour ends and make a daily trend filter available only after the day closes. See Multi-timeframe analysis for combining signals, filters, and prices from different timeframes.

The second is to place several orders inside one bar. The signal simulator processes at most one order per column and bar. A flexible order function returns as many orders as you need, for example a buy at the open and a sell at the close of the same bar, as shown on the Event-driven backtesting page.

The third keeps the signal simulator and splits each bar into sub-bars: one for the open, one for the range in between, and one for the close. Stops then fill in the first two, your own signals execute in the last, and a position can close and reopen within one original bar. pf.resample maps the result back to the original index.

Cookbook

The members-only multiple actions per bar recipe builds the three sub-bars and resamples the portfolio back.

What a bar cannot tell

Only the open and the close of a bar have a known order in time. When a strategy depends on whether the high came before the low, either use finer data or accept the pessimistic assumption. See Realistic stop fills for the exact rules.

Trading sessions

A session is a mask over the index. Restrict entries to the session, add an exit on the last bar of each session, and the strategy is flat every night:

Trade a New York session and be flat at every close
data = minute_data.loc["2024-01-08":"2024-01-12"]
local_index = data.index.tz_convert("America/New_York")
local_time = local_index.time
in_session = (local_time >= pd.Timestamp("09:30").time()) & (
    local_time < pd.Timestamp("16:00").time()
)
session_end = local_time == pd.Timestamp("15:59").time()

sma = data.close.rolling(30).mean()
entries = data.close.vbt.crossed_above(sma) & in_session & ~session_end
exits = data.close.vbt.crossed_below(sma) | session_end
pf = vbt.PF.from_signals(data, entries, exits)

print(pf.trades.count())
91
pf.assets.groupby(local_index.date).last().tolist()  
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0]

Rows without a price place no order. With price="nextopen", a Friday signal whose next row is an empty weekend row never fills. Drop the rows where the market is closed, for example with missing_index="drop" when pulling several symbols, so the next row is the next session's open.

Signals that appear while the market is closed need a rule. Either drop them, or carry them to the first bar of the next session so the live position matches the backtest. Rules such as "no exit on the day of entry" are masks as well, comparing each exit with the date of the entry before it.

To build bars that only cover the session, for example a daily bar from 09:30 to 16:00 out of 24-hour data, filter the data to the session first or resample between custom bounds.

You can also test sessions independently. Put equal-length sessions in separate columns to compare a strategy across days, with a fresh cash balance for each session. This is useful for strategies that close every day and for finding whether a few sessions account for most of the result.

Tutorial

The members-only MTF analysis tutorial aggregates intraday data into session bars with custom bounds.

Longer intraday histories

Simulate on ticks, then zoom out to the bigger picture. Resample the finished portfolio to minute, hourly, or daily bars for analysis. VBT maps its order records to the new index and reconstructs portfolio values and returns at that frequency, so you can inspect the broader result with less data to process and plot. Keep the original portfolio for inspecting individual fills.

For a history processed in successive batches, a portfolio can also continue on the next batch with its cash, positions, and stops carried forward. The same mechanism used for Live simulation lets a historical run cross batch boundaries without closing positions at each boundary.

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.