Event-Driven vs Vectorized Backtesting
Event-driven vs vectorized backtesting, settled with data: what the same-bar fill costs in index points, and the three things only an event loop can model.
Event-driven and vectorized backtesting are two different claims about what a strategy knew and when it could act on it. A vectorized backtest computes signals and returns across the whole price history at once, which is fast, and which makes it easy to credit a trade with a price the strategy could never have reached. An event-driven backtest walks the history one bar at a time: the strategy sees bars up to t, and the order fills at t+1. This page prices the difference between those two conventions in index points, then names what only the event loop can express.
What is the difference between event-driven and vectorized backtesting?
First the vocabulary, since both forms use it. A bar is one interval of price data: open, high, low, close and volume over a minute, a day or a month. A fill is the price at which an order actually executes. Look-ahead bias is the use of information in a decision that the decision could not have had. Our primer on look-ahead bias in backtesting catalogues the common sources. This page is about the one that hides in the plumbing.
A vectorized backtest holds the price history as columns and does arithmetic on whole columns: one column of signals, one column of returns, multiply, accumulate. Nothing in that arithmetic knows about time ordering. The alignment between the two columns is the only thing standing between a forecast and a memory, and that alignment is a one-row index shift that nobody catches in a code review.
An event-driven backtest holds a clock. It replays bars in order, hands each one to the strategy, collects whatever orders come back, and passes them to a simulated broker that decides what the order fills at and how much of it fills at all. The strategy cannot read the next bar, since the next bar has not been delivered yet. The structure enforces that, rather than the author's discipline. If backtests are new ground, start with what is backtesting and come back here.
What the same-bar fill costs, in index points
Take the simplest trend filter there is: hold the index while its close sits above the average of the last ten closes, hold nothing otherwise. Run it on SPY, the S&P 500 ETF, across calendar 2024, under two fill conventions with nothing else different. The same-bar convention credits the position with the return that ended at the very close the signal read. The next-bar convention credits the return from that close to the following close, which is the earliest return an order placed on that signal could collect. Both curves start at 100.
| month | same_bar_equity | next_bar_equity | equity_spread |
|---|---|---|---|
| 2024-01 | 105.5 | 100.85 | 4.65 |
| 2024-02 | 113.07 | 102.55 | 10.52 |
| 2024-03 | 118.76 | 103.28 | 15.49 |
| 2024-04 | 121.01 | 101.87 | 19.14 |
| 2024-05 | 129.08 | 105.43 | 23.65 |
| 2024-06 | 134.06 | 108.17 | 25.89 |
| 2024-07 | 141.11 | 108.18 | 32.93 |
| 2024-08 | 150.09 | 110.92 | 39.17 |
| 2024-09 | 156.92 | 113.76 | 43.16 |
| 2024-10 | 162.94 | 113.34 | 49.6 |
| 2024-11 | 173.13 | 117.05 | 56.08 |
| 2024-12 | 177.8 | 114.6 | 63.21 |
The exact SQL behind every number
SELECT
mark.1 AS month,
round(mark.2, 2) AS same_bar_equity,
round(mark.3, 2) AS next_bar_equity,
round(mark.2 - mark.3, 2) AS equity_spread
FROM
(
SELECT arrayJoin(month_end_marks) AS mark
FROM
(
SELECT
arrayMap(x -> x.1, bars) AS dates,
arrayMap(x -> x.2, bars) AS closes,
length(bars) AS n,
arrayFilter(i -> (i >= 11)
AND (i <= n - 1)
AND (dates[i] >= toDate('2024-01-01'))
AND (dates[i] < toDate('2025-01-01')),
range(1, n + 1)) AS idx,
arrayMap(i -> formatDateTime(dates[i], '%Y-%m'), idx) AS months,
arrayMap(i -> if(closes[i] > arrayAvg(arraySlice(closes, toInt64(i) - 9, 10)),
log(closes[i] / closes[i - 1]), 0.0), idx) AS same_bar_logs,
arrayMap(i -> if(closes[i] > arrayAvg(arraySlice(closes, toInt64(i) - 9, 10)),
log(closes[i + 1] / closes[i]), 0.0), idx) AS next_bar_logs,
arrayMap(c -> 100.0 * exp(c), arrayCumSum(same_bar_logs)) AS same_bar_curve,
arrayMap(c -> 100.0 * exp(c), arrayCumSum(next_bar_logs)) AS next_bar_curve,
range(1, length(idx) + 1) AS jj,
arrayFilter((mo, j) -> (j = length(months)) OR (months[j + 1] != mo),
months, jj) AS end_months,
arrayFilter((eq, j) -> (j = length(months)) OR (months[j + 1] != months[j]),
same_bar_curve, jj) AS end_same,
arrayFilter((eq, j) -> (j = length(months)) OR (months[j + 1] != months[j]),
next_bar_curve, jj) AS end_next,
arrayZip(end_months, end_same, end_next) AS month_end_marks
FROM
(
SELECT arraySort(groupArray((date, close))) AS bars
FROM
(
SELECT
date,
toFloat64(any(close)) AS close
FROM global_markets.stocks_daily_aggs
WHERE ticker = 'SPY'
AND date >= '2023-12-01'
AND date < '2025-01-10'
GROUP BY date
)
)
)
)
ORDER BY monthThe two lines share every signal, every price and every date. At the final monthly mark, 2024-12, the same-bar curve stands at 177.8 against 114.6 for the next-bar curve, a spread of 63.21 index points on a base of 100. That spread column is the look-ahead, measured. Watch it widen month by month: the error is not a one-off mispricing, it is paid on every signal day and then compounded.
The fill price a live order can actually reach
Filling at the next close is already a courtesy to the strategy. An order decided on a close goes to market at the next session's open, and that open is a different number. Here is how different, across five household names over the same year.
| ticker | median_overnight_move_pct | p90_overnight_move_pct |
|---|---|---|
| NVDA | 1.084 | 2.664 |
| AAPL | 0.371 | 1.234 |
| MSFT | 0.337 | 1.079 |
| SPY | 0.27 | 0.767 |
| KO | 0.205 | 0.629 |
The exact SQL behind every number
WITH px AS
(
SELECT
ticker,
date,
toFloat64(any(close)) AS close,
toFloat64(any(open)) AS open
FROM global_markets.stocks_daily_aggs
WHERE ticker IN ('SPY', 'AAPL', 'MSFT', 'NVDA', 'KO')
AND date >= '2024-01-01'
AND date < '2025-01-10'
GROUP BY ticker, date
),
seq AS
(
SELECT
ticker,
date,
close,
leadInFrame(open, 1) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN CURRENT ROW AND 1 FOLLOWING) AS next_open
FROM px
)
SELECT
ticker,
round(quantileDeterministic(0.5)(abs(next_open / close - 1) * 100, toUInt32(toRelativeDayNum(date))), 3) AS median_overnight_move_pct,
round(quantileDeterministic(0.9)(abs(next_open / close - 1) * 100, toUInt32(toRelativeDayNum(date))), 3) AS p90_overnight_move_pct
FROM seq
WHERE next_open > 0
AND date < '2025-01-01'
GROUP BY ticker
ORDER BY median_overnight_move_pct DESCThe widest of the five was NVDA: a median absolute move of 1.084% from one close to the next open, and 2.664% on the worst tenth of days. A backtest that fills at the signal close is quoting a price that stopped existing before the order could be sent. The ordering discipline that avoids it is step one in how to backtest a trading strategy.
Run both forms yourself with python3 and nothing else
Two commands, standard library only, no pip and no network. The first builds a deterministic 250-bar price series from a seeded generator, computes the signal list and the return list for every bar, zips them index to index, and accumulates. That is the vectorized form:
python3 -c "import random,itertools,statistics as st;random.seed(7);p=[100.0];[p.append(round(p[-1]*(1+(random.random()-0.5)*0.03),4)) for _ in range(250)];T=list(range(10,len(p)-1));sig=[1 if p[t]>st.fmean(p[t-9:t+1]) else 0 for t in T];r=[p[t]/p[t-1]-1 for t in T];eq=list(itertools.accumulate([1+s*x for s,x in zip(sig,r)],lambda a,b:a*b,initial=100.0));print('same-bar equity',round(eq[-1],2),'curve',[round(eq[i],2) for i in range(0,len(eq),40)])"
The second rebuilds the identical series, then steps through it. At each t it reads the price list only up to and including t to make the decision, and settles that decision at t+1. That is the event-driven form:
python3 -c "import random,statistics as st;random.seed(7);p=[100.0];[p.append(round(p[-1]*(1+(random.random()-0.5)*0.03),4)) for _ in range(250)];T=list(range(10,len(p)-1));eq=[100.0];[eq.append(round(eq[-1]*(1+(1 if p[t]>st.fmean(p[t-9:t+1]) else 0)*(p[t+1]/p[t]-1)),4)) for t in T];print('next-bar equity',round(eq[-1],2),'curve',[round(eq[i],2) for i in range(0,len(eq),40)])"
Same seed, same series, same rule, two numbers that do not match. The first one has yesterday's answer folded into it, since the return it multiplies by the signal is the return that ended at the close the signal read. Change the lookback from ten to twenty, or the seed from seven to anything else, and the size of the gap moves. The mechanism producing it does not.
What only an event-driven backtest can express
Speed is the vectorized form's case, and it is a real one. Three properties sit outside its reach.
Order state
A vectorized position column holds one number per bar. A real order has a life: sent, partly filled, cancelled, rejected, replaced. Size is where that gap turns into money, and the unit of size is the print.
| ticker | median_shares_per_print |
|---|---|
| NVDA | 164 |
| SPY | 117 |
| KO | 94 |
| AAPL | 82 |
| MSFT | 51 |
The exact SQL behind every number
WITH daily AS
(
SELECT
ticker,
date,
toFloat64(any(volume)) AS volume,
toFloat64(any(transactions)) AS transactions
FROM global_markets.stocks_daily_aggs
WHERE ticker IN ('SPY', 'AAPL', 'MSFT', 'NVDA', 'KO')
AND date >= '2024-01-01'
AND date < '2025-01-01'
GROUP BY ticker, date
)
SELECT
ticker,
round(quantileDeterministic(0.5)(volume / transactions, toUInt32(toRelativeDayNum(date))), 0) AS median_shares_per_print
FROM daily
WHERE transactions > 0
GROUP BY ticker
ORDER BY median_shares_per_print DESCThe largest median print of the five belonged to NVDA at 164 shares. An order for fifty times that arrives as a run of fills at different prices, and the tail of it can go unfilled when the book thins. A vectorized backtest records the position it intended and moves on. An event-driven one records what came back, which is sometimes nothing.
Position-dependent sizing
Volatility targeting scales each position so every trade risks a similar amount, which makes today's size a function of a volatility estimate and of current equity. Current equity is a function of the fills that already happened. That is a loop by construction: size, fill, equity, next size. Any risk limit stated in dollars has the same shape. A whole-column multiply can only represent it by unrolling into the loop it was meant to avoid.
Latency
A decision taken on a bar's close cannot reach the exchange at that close. Computation, the network and the matching engine's queue all sit in between. The cost of the delay is measurable: below is the median absolute SPY move at five delays after a minute close, over regular-session minutes in June 2024.
| delay_minutes | median_move_bps | p95_move_bps |
|---|---|---|
| 1 | 1.47 | 4.99 |
| 2 | 2.02 | 7.18 |
| 5 | 3.11 | 11.49 |
| 15 | 5.24 | 19.64 |
| 30 | 7.66 | 26.73 |
The exact SQL behind every number
WITH minute_px AS
(
SELECT
toStartOfMinute(toTimeZone(window_start, 'America/New_York')) AS et_minute,
toFloat64(any(close)) AS close
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND window_start >= '2024-06-03 00:00:00'
AND window_start < '2024-06-29 00:00:00'
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) >= 570
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) < 960
GROUP BY et_minute
),
fills AS
(
SELECT
et_minute,
close,
arrayZip([1, 2, 5, 15, 30],
[leadInFrame(close, 1) OVER w,
leadInFrame(close, 2) OVER w,
leadInFrame(close, 5) OVER w,
leadInFrame(close, 15) OVER w,
leadInFrame(close, 30) OVER w]) AS fill_prices
FROM minute_px
WINDOW w AS (PARTITION BY toDate(et_minute) ORDER BY et_minute ASC ROWS BETWEEN CURRENT ROW AND 30 FOLLOWING)
),
pairs AS
(
SELECT
et_minute,
close,
arrayJoin(fill_prices) AS fill
FROM fills
)
SELECT
toUInt16(fill.1) AS delay_minutes,
round(quantileDeterministic(0.5)(abs(fill.2 / close - 1) * 10000, toUInt32(toUnixTimestamp(et_minute))), 2) AS median_move_bps,
round(quantileDeterministic(0.95)(abs(fill.2 / close - 1) * 10000, toUInt32(toUnixTimestamp(et_minute))), 2) AS p95_move_bps
FROM pairs
WHERE fill.2 > 0
GROUP BY delay_minutes
ORDER BY delay_minutesOne minute past the close that produced a decision, SPY had travelled a median 1.47 basis points, one basis point being a hundredth of one percent. At the longest delay in the panel the median is 7.66 bps, with the ninety-fifth percentile at 26.73. An event-driven engine can hold an order for a stated delay and fill it at the price that existed then. A vectorized one has nowhere to put the number. Latency models in HFT backtests goes through the components.
Why research-to-production engines are all event loops
The pitch of writing a strategy once and running the same code in backtest and in live trading is a pitch for exactly this property: one code path, one order lifecycle, one clock. QuantConnect's open-source LEAN engine states the architecture in its first line.
"LEAN is an event-driven, professional-caliber algorithmic trading platform built with a passion for elegant engineering." (QuantConnect LEAN README, read October 2026)
Live trading delivers events and waits for orders, so an engine that wants one code path in both places has to be an event loop in both places. LEAN runs on .NET, with strategies written in C# or Python, which is why the demonstration above uses plain Python and no dependencies. The architecture is the lesson, and the lesson is language-neutral.
Where a vectorized backtest is the right tool
For cross-sectional work the matrix form is correct and much faster than any loop: rank 500 names on a factor at each month end, measure what each earned over the following month, and read the spread between top and bottom groups. Factor screening and broad parameter sweeps have the same shape. One decision per period, one forward return per decision, no path dependence, nothing about the fill that feeds back into the next decision. In that setting the whole engineering requirement is the shift: a signal stamped at t joins the return earned from t+1 onward, and never the return that ends at t.
The moment position size, order state or timing starts depending on what the strategy already did, the array has stopped describing the strategy. That is the line, and it has nothing to do with taste.
How these panels are built
All four panels are pinned to fixed past windows, calendar 2024 and June 2024 for the minute panel, so the figures stay put as new data arrives. Daily rows are deduplicated by ticker and date. Session minutes come from the ET clock on each bar's own timestamp, with every stored timestamp in UTC. Percentiles use deterministic quantile functions, so two runs of the same query return the same statistic. The minute panel's forward prices are partitioned by trading day, which keeps a 30-minute delay from reaching across an overnight gap.
FAQ
Is a vectorized backtest always wrong?
No. For cross-sectional ranking and factor screening it is the correct tool and far quicker. It breaks when the strategy's own state, order lifecycle, position size or fill timing feeds into the next decision.
What is look-ahead bias in a backtest?
It is any use of information a decision could not have had at the moment it was taken. The same-bar fill is the quietest version: the signal reads a close, and the backtest pays it the return that ended at that same close.
Does an event-driven backtest need tick data?
No. A loop over daily bars is still an event loop, and it can already model next-bar fills, rejects and position-dependent sizing. Finer data buys a finer fill model, which matters when the edge lives inside the spread.
How do I test an existing backtest for a same-bar fill?
Shift the return series one period later and run it again. If the performance collapses, the original was paying the strategy for a bar it had already read.
Can one engine run both the backtest and the live strategy?
That is the design goal of event-driven platforms, and it follows from the shape of live trading. What remains different is the broker model, where a simulated fill stands in for a real queue. Estimating queue position is how that remaining gap gets narrowed.
Every panel here ships the SQL that produced it in the expander underneath. To re-run the fill-convention comparison on a different name, a different lookback or a different year, ask for it in plain English on the Strasmore terminal.