◆ New — Strategy Validator, Code Fixer & Prop Compliance Checker are live. Try one free →
Home / Learn / Common vectorbt Mistakes That Inflate Your Backtest
Python

Common vectorbt Mistakes That Inflate Your Backtest

vectorbt is fast, which means it computes wrong answers just as quickly as right ones. The four mistakes that most often inflate a vectorbt backtest are same-bar signals, zero fees and slippage, computing indicators on the full series across a split, and misreading the Portfolio stats. This article shows each one and its fix with real vectorbt API. It is educational material about backtest correctness, not financial advice, and implies nothing about profitability.

Why does vectorbt inflate backtests so easily?

vectorbt inflates backtests because its defaults are optimistic and its speed removes the friction that would make you notice. Portfolio.from_signals will happily fill on the same bar your signal appears, charge zero fees, and hand you a stats table whose numbers are easy to misread. None of that is a bug in vectorbt — it is a set of assumptions you are responsible for overriding.

The four mistakes below account for most inflated vectorbt results: same-bar signal execution, missing fees and slippage, computing indicators on the full series when you meant to respect a train/test split, and misreading what the stats actually report. Each is a one-line habit, and each has a one-line fix.

This is a Python-specific companion to [why most backtests fail](/learn/why-most-backtests-fail). If you want a machine to catch these for you, the free [Python Backtest Validator](/python) understands vectorbt's API and flags unshifted from_signals entries and missing-cost calls directly.

Mistake 1 — Same-bar signals (unshifted entries)

This is the vectorbt version of look-ahead bias, and it is the single most common way people inflate a vectorbt backtest. You compute a boolean entries series that is True on the bar where your condition becomes true — then pass it straight to from_signals. By default vectorbt executes that entry on the same bar's close, the very bar you used to decide. In live trading you cannot act on a bar until it has closed, so this buys you a fill you could never have gotten.

import vectorbt as vbt

entries = fast_ma.ma_crossed_above(slow_ma)   # True on the crossover bar
exits   = fast_ma.ma_crossed_below(slow_ma)

# ❌ WRONG — entries act on the SAME bar they fire, before it could be known
pf = vbt.Portfolio.from_signals(close, entries, exits)

The fix is to shift entries and exits forward by one bar so the trade lands on the next bar, which is the first bar you could actually have traded:

# ✅ Shift signals forward one bar — act on the bar AFTER the signal
entries_next = entries.vbt.signals.fshift(1)
exits_next   = exits.vbt.signals.fshift(1)

pf = vbt.Portfolio.from_signals(
    close, entries_next, exits_next,
    fees=0.0005, slippage=0.0005,          # never zero — see Mistake 2
)

fshift(1) (or a plain .shift(1) on the boolean series, filling NaN as False) moves execution off the signal bar. Conceptual background in [look-ahead bias](/learn/look-ahead-bias).

Mistake 2 — No fees, no slippage

from_signals defaults to fees=0 and slippage=0. A frictionless portfolio is a fantasy: every fill crosses a spread and pays commission, and vectorbt's speed makes it tempting to run high-turnover strategies that a frictionless model flatters the most. The more your strategy trades, the more a zero-cost run overstates it — sometimes turning a net loser into a net winner purely on paper.

# ✅ Always pass realistic costs; scale them to your instrument and broker
pf = vbt.Portfolio.from_signals(
    close,
    entries_next, exits_next,
    fees=0.0005,        # commission as a fraction of trade value
    slippage=0.0005,    # spread + market-impact estimate per fill
    init_cash=10_000,
)

There is no universally correct number — costs depend on your instrument, size, and venue, and this is not a recommendation of any particular value. The point is that zero is always wrong. A useful discipline is to run the same strategy at several cost levels and watch how fast the results decay; a strategy that only works at fees=0 does not work. If a modest, realistic cost erases the edge, the edge was the cost you were not paying.

Mistake 3 — Indicators on the full series across a split

When you validate with a train/test split, it is easy to compute indicators once on the entire price series and then slice. For most trailing indicators the values themselves are fine — a rolling mean at bar t only looks back. The leakage sneaks in when you fit a parameter or a threshold using the full series, or normalize against a whole-series statistic, and then evaluate on the test slice. The test slice's own future has now informed the decision.

# ❌ WRONG — threshold uses the WHOLE series, including the test period
threshold = close.mean()                  # peeks at the future
entries = (close > threshold)
# ✅ Derive any fitted quantity from the train slice only
split = int(len(close) * 0.7)
train = close.iloc[:split]
threshold = train.mean()                  # fit on train only

entries = (close > threshold).vbt.signals.fshift(1)   # then act next bar
pf = vbt.Portfolio.from_signals(
    close, entries, ~entries,
    fees=0.0005, slippage=0.0005,
)

The rule: any number your strategy learns — a threshold, a scaler, a percentile — must be learned from the training data only. Rolling and expanding indicators are safe because their window never reaches forward; whole-series .mean(), .max(), .quantile() are not. vectorbt's IndicatorFactory respects whatever windowing you give it, so the discipline is yours to impose. More in [in-sample vs out-of-sample](/learn/in-sample-vs-out-of-sample).

Mistake 4 — Misreading the stats

vectorbt's pf.stats() returns a rich table, and it is easy to read the wrong line or the wrong assumption into it. Three traps recur. First, Total Return is not annualized — a big number over ten years of minute bars is not a big annual number, and comparing it to anything requires matching the horizon. Second, the stats inherit your fees setting — if you forgot Mistake 2, every ratio in the table is computed on frictionless returns and is optimistic by construction. Third, `freq` matters: annualized metrics like the Sharpe ratio depend on the frequency you tell vectorbt the data is, and passing the wrong freq silently rescales them.

# ✅ Read stats knowing what they assume
pf = vbt.Portfolio.from_signals(
    close, entries_next, exits_next,
    fees=0.0005, slippage=0.0005,
    freq='1D',                   # set the true bar frequency
)
print(pf.stats())
print('trades:', pf.trades.count())     # a Sharpe on 8 trades means little

Always check the trade count next to any ratio: a flattering Sharpe on a handful of trades is noise, not evidence. And treat every number as descriptive of this backtest under these assumptions, never as a forecast — a clean, honest stats table still says nothing about whether the strategy will make money. This is educational material, not financial advice. When the assumptions are right and the signals are shifted, run the whole file through the [Python Backtest Validator](/python) to confirm no look-ahead survived, then interrogate overfitting with the [overfitting tool](/backtest/overfitting).

Key takeaways

Educational only — not financial advice. Trading involves substantial risk of loss.

Catch the bug that compiles.Run auditGet Pro