Features

Realistic backtests

Avoid look-ahead bias, impossible fills, and survivorship bias in your backtests

Why does a backtest look better than live trading? An order may have filled before its signal existed, a daily value arrived before the day ended, or trading costs ate the profit. VBT lets you control order timing, align data by when it became available, model costs, and inspect individual fills. Here's how to use those tools to avoid look-ahead bias and build more realistic backtests in Python.

Fill a limit order on the bar that created it, then on the next bar
data = vbt.BinanceData.pull(
    "BTCUSDT", start="2020-01-01", end="2025-01-01", timeframe="1h"
)
dip = data.close < data.close.vbt.rolling_mean(24)  
limit_price = data.close * 0.99
exits = dict(
    tp_stop=0.02,
    td_stop=24,
    time_delta_format="rows",
    stop_entry_price="fillprice",  
    fees=0.001,
)

same_bar = vbt.PF.from_signals(  
    data, entries=dip & (data.low <= limit_price), price=limit_price, **exits
)
next_bar = vbt.PF.from_signals(  
    data, entries=dip, price=limit_price, order_type="limit", limit_tif=2, **exits
)
metrics = ["total_trades", "win_rate", "total_return", "sharpe_ratio"]
stats = {"same bar": same_bar.stats(metrics), "next bar": next_bar.stats(metrics)}
pd.DataFrame(stats).astype(float).round(2)
                  same bar  next bar
Total Trades        802.00    663.00
Win Rate [%]         83.29     66.37
Total Return [%]  87489.57    -31.97
Sharpe Ratio          3.88      0.01

The same-bar version always buys the dip that already happened and then sells the rebound, so its trades reach the target sooner and it trades more often. It shows an 87,000% gain where the honest version loses 32%. The gap is a reason to inspect the order timing before spending time on a parameter search.

Causal order timing

A decision can use only what was known when it was made, and an order can fill only then or later. In vbt.PF.from_signals, the price argument says where in the bar each order executes:

SettingSignal usesOrder fills
price="close" (default)The bar's closeAt that close
price="nextopen"The bar's closeAt the next bar's open
from_ago=1The previous barAt this bar's price
order_type="limit" with price="close"The bar's closeFrom the next bar, when the limit is reached

Limit orders follow the same rule. Placed at the open, they can fill anywhere in the bar. Placed at the close, they wait for the next bar. Placed in between, they can only check the close. Stops are checked against each later bar's high and low, and limit_delay or order delays push fills further out. See Orders and execution for every option, Order delays for latency, and Realistic stop fills for what happens when both a stop and a target fall inside one bar.

Stop fills and data resolution

The high and the low have to be there to be checked. Passing only a close series, instead of the data object or open, high, and low, makes every stop wait for a close beyond its level:

Test a 3% trailing stop on closes only and on full bars
btc = vbt.YFData.pull("BTC-USD", start="2020-01-01", end="2025-01-01")
entries = btc.close.vbt.crossed_above(btc.close.vbt.rolling_mean(20))
pfs = {
    "close only": vbt.PF.from_signals(btc.close, entries, tsl_stop=0.03),
    "OHLC": vbt.PF.from_signals(btc, entries, tsl_stop=0.03),
}
stop_metrics = ["total_trades", "win_rate", "total_return", "max_dd"]
pd.DataFrame({k: pf.stats(stop_metrics) for k, pf in pfs.items()}).astype(float).round(2)
                  close only    OHLC
Total Trades           90.00   96.00
Win Rate [%]           38.89   41.67
Total Return [%]      530.06  213.53
Max Drawdown [%]       33.67   21.40

On closes alone, a trade survives every intraday dip that ends the day above the stop, so it rides more rebounds and the return more than doubles. When the stop finally fires, the fill is the close, below the level, which deepens the drawdown. Neither number is what a stop order at the exchange would have done.

Higher-timeframe alignment

Data from a longer timeframe has the same trap. A daily value is known only after the day closes, but aligning it to hourly bars with a plain forward fill hands each hour the close of its own day:

Align a daily trend filter to hourly bars, naively and safely
daily_close = data.close.resample("1D").last()
daily_trend = daily_close > daily_close.vbt.rolling_mean(20)
naive_trend = daily_trend.reindex(data.index, method="ffill")  
safe_trend = daily_trend.vbt.realign_closing(data.index, freq="1h")  
safe_trend = safe_trend.fillna(False).astype(bool)

pfs = {
    "naive": vbt.PF.from_signals(data, naive_trend, ~naive_trend, fees=0.001),
    "safe": vbt.PF.from_signals(data, safe_trend, ~safe_trend, fees=0.001),
}
pd.DataFrame({k: pf.stats(metrics) for k, pf in pfs.items()}).astype(float).round(2)
                      naive    safe
Total Trades         107.00  107.00
Win Rate [%]          93.46   25.23
Total Return [%]  854502.66  315.56
Sharpe Ratio           4.41    0.87
Equity of an hourly Bitcoin strategy using a daily trend filter aligned naively and safely, on a log scale from 2020 to 2024 Figure data (JSON)

The same 107 trades won 93% of the time with the naive alignment and 25% with the safe one. The Multi-timeframe analysis page covers alignment and resampling in detail.

Point-in-time universes

A strategy tested only on stocks that are listed today never holds the companies that failed or left the index. To avoid this survivorship bias, keep every symbol that was ever part of the universe, and mask each one with NaN outside the dates it belonged, so the backtest sees the universe that existed on each date. VBT ignores signals on bars without a price, including exits, so close positions on the last bar a symbol belongs to. The Data pipelines page shows how to build the membership mask.

Trading costs and available capital

A strategy can use the right prices and still spend money it would not have. VBT applies fees and slippage when processing orders, so costs affect how much the portfolio can buy as well as its final return. Use percentage commissions, fixed fees, and slippage to test whether the strategy still works after trading costs. The Fees, slippage, and leverage page covers these settings and borrowing costs.

When several assets compete for the same money, use a shared cash balance. Size limits, whole-share or lot rounding, and partial-fill settings let you model orders that fit the account. See Fills, limits, and rejections for an example of orders being capped, partially filled, and ignored.

Backtest-to-live parity

When live results differ from the backtest, compare them step by step. With the same inputs, settings, and random seed where randomness is used, you can reproduce a VBT simulation and find the first difference.

  • Data: check that the live bars match the historical ones, including the last, still-forming bar.
  • Indicators and signals: recompute them on the live data and compare values and timestamps. Platforms also define indicators differently, for example the smoothing and warm-up of RSI, so compare values bar by bar before comparing trades.
  • Orders: pf.orders.readable lists every order with its signal, creation, and fill bars, and stop type, which pinpoints where two runs diverge.
  • Costs: fees, slippage, and spreads that the backtest left out, covered on the Fees, slippage, and leverage page.

For orders that reached execution but did not fill as requested, enable log=True and inspect pf.logs.readable. The logs show the requested order, the execution result, and the account state before and after it. That helps distinguish an unaffordable order from one reduced by a size limit, instead of guessing from the equity curve.

The Live simulation page continues a backtest bar by bar on new data, and the Intraday and tick backtesting page tests fills on finer data inside each bar.

When a result looks too good

Look at a few trades on a chart first. For fills at the exact low or high of a bar, check that the order existed before that price was reached. Win rates above 80% or returns that dwarf the market deserve a closer look, but neither alone proves the strategy saw the future.

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.