◆ New — Strategy Validator, Code Fixer & Prop Compliance Checker are live. Try one free →
Home / Learn / Same-Bar Execution: The Silent Python Backtest Bug
Python

Same-Bar Execution: The Silent Python Backtest Bug

Same-bar execution is when a backtest computes a signal from a bar's close and then fills an order at that same close — a trade you could not have placed, because the close was not known until the bar had already finished. This article shows how the bug appears across pandas, vectorbt, and backtrader, and the two standard fixes: shift the signal by one bar, or fill at the next bar's open. It is educational only and not financial advice; the fixes make results honest, not profitable, and all trading carries risk of loss.

What is same-bar execution?

Same-bar execution is when a backtest decides to trade using a bar's closing price and then executes the trade at that same closing price. It is a silent bug because the code runs, the equity curve renders, and nothing errors — but the trade is physically impossible. A bar's close is only known once the bar is complete. If your signal depends on the close, the earliest you could actually act is the next bar. Filling on the signal bar's close means the backtest traded on information it did not yet have.

This is the most common concrete form of [look-ahead bias](/learn/look-ahead-bias), and it is quietly one of the biggest reasons a strategy that looks excellent in testing does nothing live. The effect is not small. A strategy that buys at the close of every up-day and measures its return from that same close is booking part of a move that had already happened, systematically flattering every trade by roughly one bar of favourable drift.

The tell is a backtest that looks 'too clean' — smooth equity, few losers, entries that land suspiciously well. Before trusting any such result, it is worth mechanically checking where the signal is computed and where the fill lands. The rest of this article shows exactly how the bug looks in three common Python stacks and the two fixes that apply everywhere: shift the signal, or fill at the next open.

The bug in plain pandas

The vectorized-pandas version is the clearest illustration. You compute a boolean signal from the close, then multiply next-period... except you forget the 'next', and multiply the current bar's return by the current bar's signal.

# ❌ WRONG — signal from today's close earns today's return
df['signal'] = (df['close'] > df['sma']).astype(int)
df['ret'] = df['close'].pct_change()
df['strat_ret'] = df['signal'] * df['ret']   # same-bar: impossible fill

Row t's signal is computed from row t's close, but ret at row t is the return into the close at t — a move that finished before the signal existed. The position is being credited with a return that predates the decision.

The fix is to shift the signal forward by one bar, so a signal formed at the close of bar t governs the return earned over bar t+1:

# ✅ shift signal by 1 — act on the NEXT bar's return
df['signal'] = (df['close'] > df['sma']).astype(int)
df['ret'] = df['close'].pct_change()
df['position'] = df['signal'].shift(1)          # positive shift = past
df['strat_ret'] = df['position'] * df['ret']

The shift must be positive (.shift(1)), which moves data forward in time — yesterday's signal drives today's return. A negative shift (.shift(-1)) does the opposite: it pulls the future into the present and reintroduces the very bug you are fixing. A trailing .rolling() window for the SMA is already safe here as long as it does not use center=True; the leak in this example is purely the missing shift on the position.

The bug in vectorbt

vectorbt's Portfolio.from_signals takes boolean entries and exits arrays. The subtle point is that, in the default configuration, vectorbt fills a signal on the same bar's close. So if your entries array is computed from the close of each bar, you have same-bar execution — the classic look-ahead — even though vectorbt itself is behaving exactly as documented.

# ❌ WRONG — entries computed from close, filled on that same bar's close
import vectorbt as vbt

entries = fast_ma.ma_crossed_above(slow_ma)   # true on the signal bar
exits   = fast_ma.ma_crossed_below(slow_ma)
pf = vbt.Portfolio.from_signals(close, entries, exits)

The fix is to shift the signal arrays forward by one bar so the entry is acted on at the next bar:

# ✅ shift entries/exits by 1 bar so the fill happens after the signal
entries = fast_ma.ma_crossed_above(slow_ma).vbt.signals.fshift(1)
exits   = fast_ma.ma_crossed_below(slow_ma).vbt.signals.fshift(1)
pf = vbt.Portfolio.from_signals(close, entries, exits)

fshift(1) is vectorbt's forward shift for signal arrays and is the signal-safe equivalent of pandas' .shift(1). An alternative, if your data has a genuine next open, is to pass the next bar's open as the fill price rather than the close, which models a next-bar-open execution directly. Either way the principle is identical to the pandas case: the decision is made on a completed bar, and the fill happens strictly after it. Do not use a negative shift to 'align' the arrays — that silently restores the look-ahead.

The bug in backtrader

backtrader is event-driven, so the trap looks different but comes from the same source: line indexing. Inside a strategy, self.data.close[0] is the current bar, self.data.close[-1] is the previous bar, and — the dangerous one — self.data.close[1] is a future bar. Reading a positive index reaches into data that would not exist yet in live trading.

# ❌ WRONG — self.data.close[1] reads the NEXT (future) bar
class Strat(bt.Strategy):
    def next(self):
        if self.data.close[0] > self.sma[0] and self.data.close[1] > self.data.close[0]:
            self.buy()   # decision uses a bar that hasn't happened

Use only index [0] (current) and negative indices (past); never a positive index for a decision:

# ✅ current and past bars only — no positive index
class Strat(bt.Strategy):
    def next(self):
        if self.data.close[0] > self.sma[0] and self.data.close[-1] < self.sma[-1]:
            self.buy()   # a fresh cross above, using bar 0 and bar -1

There is good news about the fill itself: by default, a market order submitted in next() on the signal bar is executed at the next bar's open, so backtrader's execution model already avoids same-bar fills for plain market orders. The residual risk is in the signal, not the fill — a stray positive line index, or using cheat_on_close/cheat_on_open without understanding that those settings deliberately fill on the current bar and can reintroduce look-ahead if your signal also uses that bar's close. Adding a stop is orthogonal but wise; a bt.Order.StopTrail or an explicit stop level bounds the loss the backtest is allowed to book, keeping the accounting honest as well as look-ahead-free.

How to catch it before it fools you

Same-bar execution is dangerous precisely because it improves your metrics. Removing it will almost always make a backtest look worse — fewer clean entries, more losers, a flatter curve — and that is the point. The corrected numbers are the ones that stand a chance of surviving forward. A result that only looks good with the bug is not a strategy; it is an artefact, which is a large part of [why most backtests fail](/learn/why-most-backtests-fail).

A practical checklist: (1) Locate where every signal is computed and confirm it uses only closed-bar data. (2) Confirm the fill happens strictly after the signal bar — via a positive .shift(1)/fshift(1), a next-bar-open fill, or an execution model that fills on the next bar. (3) Search your code for the red flags: negative .shift(), rolling(center=True), positive backtrader line indices like close[1], and unshifted from_signals entries. (4) Re-run and expect the results to degrade; investigate if they do not.

Because these patterns are mechanical, they are detectable statically. The free [Python Backtest Validator](/python) scans for exactly this family of bugs — negative shifts, centered windows, future line indices, unshifted signal fills — and flags them before you build a thesis on a broken number. Pair it with the [overfitting tool](/backtest/overfitting) to check whether a clean, look-ahead-free result is also robust to parameter choices, or merely curve-fit.

Fixing same-bar execution makes a backtest honest, not profitable. It removes a specific illusion; it does not create edge, and a correctly-executed strategy can still lose money live. This is educational information only, not financial advice, and all trading carries risk of loss.

Key takeaways

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

Catch the bug that compiles.Run auditGet Pro