vectorbt's Portfolio.from_signals fills orders at the close of the bar that produced the signal by default, which silently leaks information you could not have acted on in real time. This article shows exactly where the leak enters, how to fix it with entries.shift(1) and explicit price/lag settings, and how to sanity-check the result. It is educational material about backtest correctness only, not trading or financial advice, and makes no claims about profitability or returns.
By default, Portfolio.from_signals(close, entries, exits) fills each order at the close of the same bar that generated the signal. If your entries array is computed from that same bar's close (say, a moving-average crossover evaluated on bar t), then vectorbt buys at close[t] using a signal you only knew because you already saw close[t]. In live trading you cannot observe a bar's close and simultaneously trade at that exact close on the same bar. The decision and the fill collapse into one instant that never exists in reality.
This is the same class of error described in [look-ahead bias](/learn/look-ahead-bias): the backtest consumes information from the future relative to the moment a decision is actually made. vectorbt is not doing anything wrong here. Same-bar close fills are a documented, deliberate default that is perfectly valid if your signals are already lagged. The bug is almost always in the caller's signal construction, not the library. The fix is to make the timing explicit rather than leaving it implicit.
Throughout this article, close is a pandas Series indexed by timestamp, and entries/exits are boolean Series aligned to it. We show the leaky version first, clearly framed as wrong, then the corrected version immediately after. Nothing here speaks to whether any strategy is profitable; the goal is only that the backtest measures a decision rule you could actually have executed.
Here is the pattern that leaks. The signal is derived from close on bar t, and with the default settings the fill also lands on bar t.
# ❌ WRONG: signal computed on bar t, filled on close of the same bar t
import vectorbt as vbt
fast = close.rolling(10).mean()
slow = close.rolling(30).mean()
# crossover evaluated on bar t
entries = (fast > slow) & (fast.shift(1) <= slow.shift(1))
exits = (fast < slow) & (fast.shift(1) >= slow.shift(1))
pf = vbt.Portfolio.from_signals(
close,
entries,
exits,
# default price is the bar's close -> fills on close[t]
)The crossover condition itself is fine: it correctly compares bar t to bar t-1. The problem is the fill. entries[t] is True because of close[t], and vectorbt then buys at close[t]. You are transacting at a price whose value was the very reason you decided to transact. Backtests built this way tend to look unrealistically clean, because every entry is priced at the exact moment the edge became visible, with zero delay.
The tell-tale symptom is a result that degrades sharply the moment you introduce any realistic delay. If shifting entries by a single bar materially changes your equity curve, same-bar leakage was doing heavy lifting. That fragility is itself diagnostic and is one of the recurring themes in [why most backtests fail](/learn/why-most-backtests-fail).
The correct model is: you observe bar t's close, and the earliest you can act is bar t+1. Shift the signals forward by one bar so the decision made on t is executed on t+1, and make the fill price explicit instead of relying on the default.
# ✅ CORRECT: decide on bar t, execute on bar t+1
import vectorbt as vbt
fast = close.rolling(10).mean()
slow = close.rolling(30).mean()
raw_entries = (fast > slow) & (fast.shift(1) <= slow.shift(1))
raw_exits = (fast < slow) & (fast.shift(1) >= slow.shift(1))
# bring the PAST decision forward to the next bar; fillna(False) for the first bar
entries = raw_entries.shift(1).fillna(False)
exits = raw_exits.shift(1).fillna(False)
pf = vbt.Portfolio.from_signals(
close,
entries,
exits,
price=close, # explicit: fill at the close of the (now t+1) execution bar
fees=0.0005, # model costs; frictionless backtests flatter themselves
slippage=0.0005,
freq="1D",
)Because entries is now raw_entries.shift(1), the True that was on bar t lands on bar t+1, and vectorbt fills it at close[t+1]. The decision uses only information available through bar t; the execution happens strictly afterward. .shift(1) here brings the past forward, which is the safe direction. If you ever find yourself reaching for .shift(-1) on a signal, stop: that pulls the future backward and reintroduces exactly the leak we are removing. That distinction is covered in depth in [pandas .shift() for trading signals](/learn/pandas-shift-trading-signals).
Adding fees and slippage is not strictly about look-ahead, but a same-bar, cost-free fill is doubly optimistic, so it is worth correcting both at once.
Filling at close[t+1] is defensible, but many desks argue it is still slightly optimistic: you decided at the close of t, and the first genuinely tradeable price is the open of t+1, not its close. vectorbt lets you model that by passing an open-price array as the fill price while keeping the shifted signals.
# ✅ CORRECT: decide on bar t's close, fill at the next bar's OPEN
import vectorbt as vbt
# open_ is a Series of bar opens aligned to close
entries = raw_entries.shift(1).fillna(False)
exits = raw_exits.shift(1).fillna(False)
pf = vbt.Portfolio.from_signals(
close=close,
entries=entries,
exits=exits,
price=open_, # fill at the execution bar's open
fees=0.0005,
slippage=0.0005,
freq="1D",
)Note the timing carefully. The signal is already shifted to bar t+1, and price=open_ fills at open[t+1]. That is the open after the decision bar, which is consistent and non-leaky. What you must never do is fill at open[t] using a signal derived from close[t], because on a single bar the open precedes the close, so you would be trading at a price that occurred before the information that triggered the trade existed. That is a subtle but real form of look-ahead.
If you prefer to keep signals unshifted and instead express the delay through the fill, some teams add a stop or a limit and rely on vectorbt's ordering semantics, but the shift-then-fill pattern above is the easiest to reason about and the hardest to get subtly wrong.
Do not trust that a fix worked because the code runs. Verify it. The cheapest check is a delay-sensitivity sweep: rerun the backtest with entries shifted by 0, 1, and 2 bars and compare the equity curves. A correctly-lagged strategy should degrade gracefully as delay increases; a leaky one collapses the instant you move off zero delay.
# sanity check: how much does one bar of delay change things?
for lag in (0, 1, 2):
e = raw_entries.shift(lag).fillna(False)
x = raw_exits.shift(lag).fillna(False)
pf = vbt.Portfolio.from_signals(
close, e, x, price=close, fees=0.0005, slippage=0.0005, freq="1D"
)
print(lag, pf.total_return())If the lag=0 line looks dramatically better than lag=1, treat that gap as the size of your look-ahead subsidy, not as edge. You can automate this and related timing checks with the free [Python Backtest Validator](/python), which statically flags same-bar from_signals usage and unshifted signals before you ever run a walk-forward.
Once timing is honest, the next questions are whether the result survives out-of-sample data and whether it is merely curve-fit. Those are separate failure modes covered in [in-sample vs out-of-sample](/learn/in-sample-vs-out-of-sample), [walk-forward analysis](/learn/walk-forward-analysis), and the [overfitting checker](/backtest/overfitting). A backtest with no look-ahead is a precondition for those checks to mean anything, not a guarantee of a good strategy. This article is educational and about correctness only; it is not financial advice and makes no claim about returns.
Educational only — not financial advice. Trading involves substantial risk of loss.