The vectorised backtest computes the whole answer with array operations: build a signal series, shift it, multiply it by the return series, take a cumulative product. It is fast (a 5,000-bar backtest in microseconds), short enough to hold in your head, and therefore ideal for research sweeps where you will run thousands of variants.
rets = prices.pct_change()
pos = signal.shift(1) # decide on t-1, act on t
strat = pos * rets
equity = (1 + strat).cumprod()
How does an event-driven backtest work?
The event-driven backtest walks bars in order, carrying state (position, cash, open orders, entry prices) and making one decision per bar with only the past in scope:
for t in range(1, n):
# 1. observe bar t-1 information, already complete
# 2. update the strategy's internal state
# 3. emit orders for bar t
# 4. fill them at bar t prices, pay costs
# 5. mark the book to bar t's close
The temptation is to treat these as beginner and professional versions of the same thing. They are not. They compute the same number whenever the strategy has no path dependence and no state, and they must be checked against each other precisely because of that. Exercise QD1.03-e05 makes this an assertion you have to pass: with stop_loss=None, your event loop's terminal NAV must equal the vectorised product to within 1e-12, on both a ₹ and a $ fixture. On the seeded demonstration path used in worked example 9, both architectures produce 1.331631792651527 and the difference is exactly 0.0. If your event loop disagrees with the vector in the frictionless case, your event loop has a bug, and this is the cheapest bug-finding trick in the node, because the vectorised version is too short to hide anything.
Advertisement
When is a vectorised backtest not enough?
What breaks the equality, and therefore what forces the event loop, is anything the vector cannot see:
- Rules that read inside a bar. A stop-loss, a take-profit, a trailing stop, an intraday limit. A vector of closes does not contain the path from open to close, so it cannot know whether your stop was hit before the close. On that same path, adding a 5% stop moves terminal NAV from
1.331632to1.318190and fires 5 stops, a 1.34 percentage-point gap that no amount of clever vectorisation recovers, because the information is not in the input. - State that persists. Position-dependent sizing, inventory limits, margin, "do not re-enter for five bars after a stop", cash that earns interest, a signal that reads its own P&L.
- Order lifecycle. Partial fills, cancellations, queue position, orders that rest across bars.
- Multiple instruments with interaction. Portfolio-level constraints (gross exposure, sector caps) evaluated before orders are sent.
The professional pattern is both. Research vectorised because you will run ten thousand variants; validate event-driven because the vector will not tell you that your stop is unfillable on a gap day; and keep a test that asserts they agree in the frictionless case so that a refactor of either cannot silently break the other. That test costs four lines and has caught more real bugs than any code review.
The event loop, and the ordering that makes look-ahead impossible
Most look-ahead is not a bug; it is an ordering. If your loop reads bar t's close and then decides bar t's position, no amount of care elsewhere saves you. So fix the ordering once, structurally, and stop relying on care:
for each bar t:
1. INFORMATION SET: everything with a timestamp strictly before the decision point
2. DECIDE: emit orders from the information set only
3. EXECUTE: fill at prices at or after the decision point (never before)
4. ACCOUNT: pay costs, update position, mark to market
Two rules follow, and they are worth memorising as sentences.
Decide on t−1, act on t. The position held over bar t is a function of information available at the close of bar t−1. In pandas this is signal.shift(1); in an event loop it is the discipline of computing the signal before the current bar's data is added to the buffer. Exercise QD1.03-e01 makes this the whole exercise: backtest_lagged(prices, signal, lag) where lag=1 is honest and lag=0 is the leak, and the function must reproduce both faithfully so that you can price the difference rather than discuss it.
Fill after you decide, never before. If the decision is made at the close of bar t−1, the earliest defensible fill is bar t's open. Filling at bar t−1's close means you traded on a price you were still using to decide. Filling at bar t's close means you had all of bar t to think about it. Both are common; both are wrong in the same direction.
Worked example: the same rule with and without a lag
Here is the smallest possible demonstration, small enough to check by hand and worth doing so. Four bars, closes [100, 110, 99, 108.9, 103.455], a signal [1, 1, −1, 1, 0]:
| lag | positions used | returns | terminal |
|---|---|---|---|
| 1 (honest) | 1, 1, −1, 1 | +10%, −10%, −10%, −5% | 0.84645× |
| 0 (leaky) | 1, −1, 1, 0 | +10%, +10%, +10%, 0% | 1.331× |
Identical data, identical rule, identical code path, one index. The leaky version turns a 15% loss into a 33% gain, and it does it by holding the short exactly on the day the price fell, which it knew because it had already seen the day.