Disclaimer
This article is for educational purposes only and does not constitute financial advice.
The False Promise of Speed: Why Vectorized Backtesting Fails in Production
Imagine testing a market-making strategy that buys at the bid and sells at the ask. You run a vectorized backtest on daily OHLC bars and achieve 47% Sharpe ratio. You deploy to production and within hours you’re underwater.
What went wrong? Your backtest wasn’t wrong—it was incomplete. Vectorized engines like for close in closes: signal = calculate(close) answer the question “what would this logic decide?” but not “what would actually happen?” The gap between these is the graveyard of failed trading systems.
Vectorized backtesting has structural flaws that aren’t fixable by adding a few random variables:
- Lookahead bias baked into the architecture — when you compute a signal from OHLC[t], you implicitly know OHLC[t]’s high and low. In reality, you only know the close. The matching engine never saw that high; you did.
- Order book dynamics erased — fills aren’t atomic. A 100-lot buy order at market doesn’t fill at the midpoint; it walks the book, consuming liquidity at progressively worse prices, and may only partially fill.
- Latency as decoration — saying “add 50ms latency” to a bar timestamp is theater. Real latency is stochastic, path-dependent, and conditional on network load.
- Partial fills and rejections invisible — your order might fill 60 lots at $100.00 and 40 lots at $100.02, or be rejected if collateral dipped. Vectorized backtests typically assume fills at the next bar’s open.
- No sequential constraint — if your signal uses data from bar t and the fill uses data from bar t+1, you can’t model strategies with tight market-making loops because the loop state isn’t preserved across bars.
Event-driven backtesting solves these by inverting the simulation model: instead of computing all signals, then all fills, it processes each market event in strict chronological order, updating state deterministically, and only allowing decisions based on what you’d actually know at that moment.
Core Concept: The Event Loop as Ground Truth
An event-driven backtesting engine is fundamentally a state machine with a deterministic event queue. Here’s the mental model:
At time t, the following state is available:
– Closed trades (locked in)
– Open positions with entry price
– Cash balance
– Historic bars up to t-1
At time t, a new market-data event arrives (quote, trade, or bar). The engine:
- Updates the order book (if processing quotes)
- Iterates the matching engine against that book
- Fills any standing orders at realistic prices
- Computes signal logic (which can only reference data up to and including t)
- Issues new orders (queued for next event’s matching cycle)
- Updates portfolio (cash, positions, P&L)
Then the loop continues to time t+1.
The key insight: by moving the fill simulation inside the event loop, you enforce a temporal boundary that makes lookahead bias structurally impossible. You can’t use t’s data before t arrives; you can’t fill an order before the matching engine processes it.

The Lookahead Bias Problem: A Case Study
Let’s ground this in a real example. Consider a mean-reversion signal:
def vectorized_signal(ohlc):
return (ohlc.close - ohlc.sma_20) / ohlc.atr_20
positions[t] = LONG if vectorized_signal[t] > 1.5 else FLAT
In a vectorized backtest:
– At bar 14:30 UTC, you compute signal[14:30] using the high, low, and close of bar 14:30.
– You place a market buy at the “open” of bar 14:31 (actually the VWAP or midpoint assumed by the backtest engine).
– You feel good about a 3% win rate.
In reality (event-driven):
– At 14:30:00.123 UTC, you receive a quote: bid=100.00, ask=100.02.
– Your SMA-20 is based on closes up to 14:29.
– You can compute a signal. It says BUY.
– You issue a market order.
– At 14:30:00.456 UTC, another quote arrives: bid=100.05, ask=100.07 (market moved against you).
– Your order fills at 100.07 (walk the book).
– At 14:30:01.000 UTC, the intrabar low was 99.98 (you never saw it as a midpoint; the order book showed 100.05 as the ask).
The vectorized test assumed a fill at 100.02 (the bar’s low or close approximation). Reality was 100.07. That’s a 5-tick slippage you can’t model in a vectorized engine because the vectorized engine doesn’t have an order book—it has OHLC bars.
Architecture Layer 1: Market Data Ingestion and Order Book Reconstruction
The engine’s foundation is a live-like replay of market data from a historical store. This isn’t just bars; it’s ticks (or micro-bars).
For equity markets, this means:
– Level-1 data (bid, ask, bid size, ask size) at tick frequency
– Or Level-2/Level-3 (full order book snapshots or book diffs)
– Or reconstructed books from trade and quote feeds
For crypto and futures, the exchange API provides websocket snapshots. The engine must:
- Ingest data from a columnar store (DuckDB, Parquet, HDF5)
- Order by timestamp (nanosecond precision if available)
- Detect clock skew (e.g., microseconds recorded as milliseconds)
- Discard duplicates (exact same timestamp + price + size)
- Reconstruct the order book from tick data (for strategies that need book depth)
Key architecture decision: where does the order book live?
- In-memory hash map of price levels (fast, fits 5-10K symbols in RAM)
- Ring buffer per symbol (fixed memory, columnar access)
- Database-backed (slow for real-time, good for reproducibility)
NautilusTrader uses in-memory Rust objects with Python bindings. QuantConnect (LEAN) uses in-memory dictionaries with configurable snapshots. The tradeoff: RAM vs. disk I/O speed.

Architecture Layer 2: The Matching Engine and Fill Simulation
Once the order book is current, the matching engine answers: “If I issue a market order of X shares, what price do I get and how fast?”
A naive model: “market orders fill at the midpoint with no slippage.” Real-world model components:
Spread Consumption
A 100-lot buy order consumes ask liquidity:
– First 50 lots at 100.02 (level 1)
– Next 30 lots at 100.04 (level 2)
– Remaining 20 lots at 100.06 (level 3)
Average fill price = (50×100.02 + 30×100.04 + 20×100.06) / 100 = 100.0376
This is not the midpoint (100.01). The vector backtest would have filled at 100.01 and claimed 2.65 ticks profit; in reality, it’s 2.35 ticks. Modest for one trade, but across thousands of trades per day, this bias accumulates.
Partial Fills and Time Priority
The matching engine must model that orders in the book have time priority. If your 100-lot buy arrives:
– 50 lots ahead of you at the current ask level
– Your order gets queued behind them
– You might get 30% of the next 100 ticks of volume, not 100%
Event-driven engines simulate this with:
– Queue state per price level (FIFO order ID list)
– Volume tracking (shares available at each level, and how many filled per event)
A sophisticated engine weights fills by probability distribution: maybe this 100-lot market buy fills 80% at level 1, 15% at level 2, 5% at level 3, because some orders are executed (reducing queue depth) between now and fill.
Latency and Stochastic Fill
The fill doesn’t happen at the timestamp you issued the order; it happens later. Event-driven engines model this as:
- Order placed at time t_0 (your signal fired)
- Order sent to broker at t_0 + latency_network
- Broker routing at t_0 + latency_routing
- Exchange matching at t_0 + latency_matching
- Fill confirmed at t_0 + total_latency
Each component is stochastic:
– Network: normally distributed, ~50ms mean for co-located servers, ~200ms for distant ones
– Routing: log-normal, median 10ms, tail to 1s during congestion
– Matching: exponential, ~100µs to 10ms depending on order book depth
The matching engine processes the fill event at the timestamp that reflects these delays, ensuring that the fill price comes from the order book at the time the fill actually occurred, not at order issuance time.
Architecture Layer 3: Signal Generation and Order Management
The strategy layer consumes the engine’s state and produces orders. The interface is simple:
class Strategy:
def on_bar(self, bar: Bar):
signal = self.compute_signal(bar)
if signal == BUY:
self.place_order(Order(...))
def on_fill(self, fill: Fill):
self.update_position(fill)
Event-driven engines call these handlers synchronously, in order:
on_bar(bar_t)— strategy sees data up to t, places orders- Orders are queued
on_fill(fill_t)— actual fills occur- Strategy updates state for next event
This is not the vectorized model:
# Vectorized (wrong order, allows lookahead):
signals[t] = compute_from_ohlc[t] # Uses close, high, low of bar t
positions[t] = signals[t]
fills[t] = fill_at_ohlc[t+1].open # Uses open of next bar, AFTER signal
profit[t] = fills[t] * positions[t]
The vectorized code uses OHLC[t]’s high and low in the signal, then fills at OHLC[t+1]’s open. That’s classic lookahead bias.
The event-driven model uses only data available at t, fills at the timestamp the matching engine actually processes it (which depends on order book state and latency simulation), and updates position atomically.
Architecture Layer 4: Portfolio State and Risk Management
The portfolio tracks:
- Cash balance (updated on fills, withdrawals, fees)
- Open positions (symbol, quantity, entry price, entry time, realized P&L)
- Unrealized P&L (marked to last market price)
- Margin utilization (if applicable)
- Margin calls (if equity dips below threshold, engine liquidates positions)
Risk checks happen before and after each order:
Before:
– “If this order fills at the worst ask, will I still meet margin requirements?”
– “Does this violate my position limit (max 10K shares per symbol)?”
– “Is my daily loss limit exceeded?”
After:
– “Did the fill change my P&L trajectory?”
– “Do I trigger a trailing stop?”
Event-driven engines implement these as filter functions on orders:
def can_place_order(order: Order, state: PortfolioState) -> bool:
worst_case_fill = order.worst_case_price()
new_equity = state.equity_if_filled_at(worst_case_fill)
if new_equity < state.min_equity:
return False # Reject order
return True
This mirrors live trading, where orders are rejected by the broker if they violate risk limits.

Architecture Layer 5: Backtesting-Specific Components
Survivorship Bias Prevention
A critical issue: many historical datasets only contain companies that survived. If you backtest a trading strategy on “all S&P 500 companies” using data from today, you’re implicitly including only companies that still exist. Bankrupt companies are erased. Your backtest assumed you could trade them at any time, but their trade halted months before bankruptcy was obvious.
Sophisticated engines accept a “universe file” that lists:
– Ticker
– IPO date
– Delisting date
– Delisting reason (bankruptcy, merger, acquired, moved exchange, etc.)
The engine skips trades involving delisted symbols. This introduces delisting slippage bias (another form you should account for).
Commissions and Fees
These are dead simple but critical:
- Per-trade: $5 or 0.1% of notional
- Per-share: $0.001
- Tiered: first 1M shares at $0.0005, next 2M at $0.0001
- Maker/taker: receive rebate for maker, pay for taker (exchange-specific)
The engine tracks these separately and subtracts from net P&L.
Dividends and Corporate Actions
Dividends reduce the share price by the dividend amount (on ex-date). If you held 100 shares of XYZ at $50 and it paid a $2 dividend, your shares drop to $48 and you receive $200 cash.
Stock splits require adjustment factors: if XYZ did a 2:1 split, 100 shares become 200. Historical prices (and backtesting open interest) must be adjusted.
Events-driven engines track these as portfolio events:
on_dividend(symbol, ex_date, amount_per_share)
on_split(symbol, ratio_before, ratio_after)
The engine updates positions automatically.
Validation: Benchmark Comparison
Many engines compute benchmark returns in parallel:
strategy_ret[t] = portfolio_value[t] / portfolio_value[t-1]
benchmark_ret[t] = index_price[t] / index_price[t-1]
active_return[t] = strategy_ret[t] - benchmark_ret[t]
This allows live risk reporting: “Strategy returned 15%, benchmark returned 8%, so alpha is 7%.”
NautilusTrader: The Production Archetype
NautilusTrader is a production-grade, Rust-native event-driven trading engine with Python bindings. Its architecture is instructive because it prioritizes deterministic replay and backtest-to-live fidelity.
Hybrid Rust-Python Design
The core event loop, order book, and matching engine are Rust (compiled, fast, type-safe). The strategy logic, configuration, and research are Python (flexible, iterative).
The binding is via Cython: strategies are compiled to Cython modules that call Rust functions. This gives you:
– Research iteration speed (Python)
– Production performance (Rust)
– Determinism (no GC pauses during matching)
Backtesting Architecture
Backtesting mode uses a DataCatalog to ingest historical data:
- Load from disk (Parquet, HDF5, or custom format)
- Normalize to nanosecond timestamps
- Feed into BacktestEngine
- Engine processes events in strict order
- Emit statistics (Sharpe, drawdown, trades, etc.)
The key: the same event loop runs in backtest and live. The only difference is data source:
– Backtest: read from disk
– Live: read from broker websocket
This means “works in backtest but fails in live” is rare because the execution semantics are identical.
Order Book and Matching
NautilusTrader uses an in-memory order book per instrument:
pub struct OrderBook {
bids: BTreeMap<Price, Vec<Order>>, // Price → queue of orders
asks: BTreeMap<Price, Vec<Order>>,
}
Matching is iterative: as each tick arrives, the engine:
1. Updates the book with new quotes
2. Iterates the matching engine
3. Produces fills for any standing orders that cross the spread
4. Emits fill events
This is accurate for quote-driven markets (equities, spot crypto, options). For trade-driven markets (futures), the engine reconstructs the book from trade ticks (which is less accurate but necessary).
Example: Reconciling Research to Production
You backtest a strategy in Jupyter:
from nautilus_trader.backtest.node import BacktestNode
node = BacktestNode(
strategies=[MyStrategy()],
data=["BTC/USDT"],
start_date="2023-01-01",
end_date="2024-01-01",
)
stats = node.run()
Once satisfied, you deploy to live by changing one line:
from nautilus_trader.live.node import LiveNode
node = LiveNode(
strategies=[MyStrategy()],
data_source=BinanceDataSource(),
execution_client=BinanceExecutionClient(),
)
node.run()
The strategy code is identical. The event loop is identical. Only the data source and execution client changed. This is why NautilusTrader practitioners have fewer live-crash incidents.
LEAN/QuantConnect: The Cloud Archetype
QuantConnect’s LEAN engine is a cloud-first, multi-asset event-driven backtester with integrations to live brokers.
Event Loop and Time Synchronization
LEAN processes events in calendar order:
- Load all data for the date range
- Create event queue (bars, quotes, trades, dividends, splits)
- Process each event in strict chronological order
- Consolidate bars (if strategy subscribes to multiple timeframes, LEAN emits the appropriate bars at the right times)
Key feature: bar consolidation. If you have minute data and want 5-minute bars, LEAN automatically emits a 5-minute bar event every 5 minutes, built from the preceding 5 minute-bars. This prevents a common bug: computing a 5-minute signal at the wrong time.
Slippage Modeling
LEAN provides several built-in slippage models:
- Constant: always 1 tick
- Percentage: 0.1% of order price
- Volatility-based: slippage scales with recent ATR
- Custom: user-defined function
Example:
SetSlippage(new VolatilitySlippageModel(0.05)); // 5% of ATR per share
This is more realistic than constant slippage because volatile stocks have wider spreads.
Fill Models
Similarly, LEAN’s FillModel controls how orders fill:
public class PartialFillModel : FillModel {
public override OrderEvent Fill(Order order, Quote quote) {
// Probabilistically return partial fills
var fillPercentage = Random.Next(50, 100) / 100.0;
return new OrderEvent(
order,
quote.Price,
Math.Floor(order.Quantity * fillPercentage)
);
}
}
This models real broker behavior: sometimes orders fill partially because liquidity is consumed by other orders ahead of you.
Risk Management Integration
LEAN enforces cash-on-hand constraints:
SetCash(10000); // Start with $10K
MarginModel = new PatternDayTradingMarginModel(); // PDT rules
If you try to short more than your available margin, LEAN rejects the order. This prevents a common backtest-to-live gap: you assumed infinite short availability, but your broker only allows 2x leverage.
Multi-Asset Backtesting
LEAN’s event loop handles multiple securities, multiple timeframes, and multiple asset classes simultaneously:
AddEquity("SPY"); // Minute data
AddCrypto("BTCUSD"); // Tick data
AddFutures("ES"); // Daily bars
OnData(Slice data) {
// data contains all updates for this event
// Some events emit only SPY updates, others ES, others all three
}
The engine synchronizes events across assets so that you never see the future (e.g., you can’t use ES’s next day’s open to predict SPY today).
Data Infrastructure: DuckDB and Columnar Storage
Backtesting engines consume vast historical datasets. A single year of minute-bar data for 100 equities is ~25 million rows. Tick data is 100x larger.
Columnar databases like DuckDB are purpose-built for this:
Why Columnar?
Traditional row-oriented storage (SQL Server, MySQL) stores data as:
Row 1: [timestamp, symbol, open, high, low, close, volume]
Row 2: [timestamp, symbol, open, high, low, close, volume]
...
Reading “all closes for SPY in 2024” requires scanning all rows. Disk I/O is slow.
Columnar storage groups by column:
Timestamp column: [t1, t2, t3, ...]
Symbol column: [SPY, SPY, SPY, ...]
Open column: [100.1, 100.2, 100.15, ...]
Close column: [100.5, 100.4, 100.6, ...]
...
Reading “all closes for SPY in 2024” scans only the close and symbol columns. Disk I/O is 50-100x faster.
DuckDB for Backtesting
DuckDB is an embedded SQL database optimized for analytical queries on local data:
import duckdb
# Load historical data
con = duckdb.connect('market_data.duckdb')
# Query a year of minute bars for SPY
spydata = con.execute("""
SELECT timestamp, open, high, low, close, volume
FROM bars
WHERE symbol = 'SPY'
AND timestamp >= '2023-01-01'
AND timestamp < '2024-01-01'
ORDER BY timestamp
""").df()
DuckDB is fast:
– 100 million rows of OHLC data loads in seconds
– Filtering by symbol and date uses predicate pushdown (the database skips irrelevant data)
– Aggregations (sum, mean, max) compute in-database, returning only the result
Integration with Backtesting Engines
The pattern:
- Pre-compute historical data into DuckDB (once, offline)
- Backtest loop queries DuckDB for each symbol and date range
- Stream results into the engine as events
This decouples data preparation from backtesting, allowing:
– Shared data infrastructure (multiple teams, multiple strategies)
– Reproducibility (same data, same results)
– Efficiency (one copy of data, many backtests run against it)
The Backtest-to-Live Fidelity Problem and How Event-Driven Architecture Solves It
The fidelity problem is the gap between backtest assumptions and live reality.
Sources of Fidelity Loss
- Survivorship bias: you backtested on companies that still exist, but delisted companies weren’t tradeable in live (their trade halted; you couldn’t exit)
- Data quality: historical quotes have timestamps, bid-ask spreads, sizes. Live quotes have the same structure. But backtested quotes might be adjusted (splits, dividends) while live quotes aren’t.
- Latency asymmetry: your backtest assumed 100ms latency. Live latency is 150ms during normal hours but 500ms+ when markets are stressed.
- Partial fills: backtest model fills X shares. Live broker fills Y shares because of queue position and other orders ahead of you.
- Rejections: backtest model doesn’t reject orders (unless you add explicit risk checks). Live broker rejects orders if they violate margin or position limits.
- Order types and modifications: backtest might support market and limit. Live might support stop, iceberg, cancel-replace. Asymmetry causes surprises.
- Maker-taker rebates: backtest model fees as 0.1% per trade. Live fees are negative (rebate) for maker orders and positive for taker orders. Asymmetry changes profitability.
Event-Driven Architecture as a Unifying Foundation
Event-driven architecture reduces fidelity loss by minimizing the gap between backtest and live semantics:
- Same event loop (backtest reads from disk; live reads from websocket)
- Same matching semantics (both use order book and fill simulation)
- Same risk checks (both apply margin and position limits)
- Same latency model (both use configurable latency distributions)
- Same order types (both support the same order book interactions)
This doesn’t eliminate fidelity loss, but it makes fidelity loss predictable and measurable. You can compare backtest P&L to live P&L and identify systematic gaps (e.g., “live maker rebates added 2% annualized alpha”).
Concrete Example: Comparing Backtest to Live
Suppose your strategy backtested at 12% annual return. You deploy to live. After 3 months, you’re at 8% annualized. Where did 4% go?
Event-driven engines let you measure the sources:
backtest_stats = node.run_backtest()
live_stats = node.fetch_live_stats()
print(f"Backtest return: {backtest_stats.total_return}") # 12%
print(f"Live return: {live_stats.total_return}") # 8%
# Measure components of loss
print(f"Backtest slippage: {backtest_stats.avg_slippage}") # 1.5 ticks
print(f"Live slippage: {live_stats.avg_slippage}") # 3.2 ticks
# Slippage accounts for ~1% of loss
print(f"Backtest maker rebates: {backtest_stats.rebate_income}") # $5K
print(f"Live maker rebates: {live_stats.rebate_income}") # $8K
# Actually better in live!
print(f"Backtest win rate: {backtest_stats.win_rate}") # 52%
print(f"Live win rate: {live_stats.win_rate}") # 48%
# Win rate degraded by 4%, accounting for most of the loss
This signals that your signal is less predictive in live than in backtest. Possible causes:
– Overfitting to historical patterns
– Market regime shift
– Execution timing (live orders fill at slightly different times than backtest model)
Event-driven architecture makes this analysis possible because the metrics are consistent.
Deep Dive: Asynchronous Event Processing and Determinism
A common misconception: “Event-driven systems are concurrent, so they’re non-deterministic.”
Wrong. Event-driven backtesting is single-threaded and deterministic:
- Queue all events by timestamp
- Process events sequentially (one per iteration)
- Update state atomically (after each event completes)
- Emit new events (fills, risk updates) at appropriate timestamps
The order of processing is deterministic because timestamps are fixed.
t=10:00:00.100: Event[QUOTE] BTC/USDT bid=50000, ask=50001
→ Update order book
→ Check matching for standing orders
→ Emit [FILL] if crossed
→ t=10:00:00.100: Event[FILL] Order #123 filled 1.0 BTC at 50001
→ Update portfolio
→ Trigger on_fill handler
t=10:00:00.200: Event[QUOTE] BTC/USDT bid=50002, ask=50003
→ Update order book
→ Check matching
...
There’s no race condition. There’s no “some events might process out of order.” Events are totally ordered by timestamp, and processing is atomic.
This determinism is critical for reproducibility. Run the same backtest twice and you get identical results. This is impossible in truly concurrent systems (where thread scheduling is non-deterministic).
Why Does This Matter for Live Trading?
Live trading is also event-driven and single-threaded (or multi-threaded with locks):
- Broker websocket emits a QUOTE
- Engine updates order book
- Engine checks standing orders and matches
- Engine emits FILL event
- Strategy on_fill handler processes
If the strategy handler takes 100ms, that’s 100ms of wall-clock time, and the next quote has to wait. This is live latency.
In backtest, the handler also takes computational time (or you can model it with explicit latency). The order of events is the same. The only difference is wall-clock time doesn’t matter (you’re not trading dollars in backtest).
Architecture Layer 6: Real-World Complications and Edge Cases
Multi-Venue Execution
Real trading systems trade on multiple venues (Nasdaq, NYSE, CBOE for equities; Binance, Kraken, Coinbase for crypto). Execution algorithm must decide: send buy order to venue A or venue B?
This introduces venue-specific order book state:
class VenueState:
def __init__(self, venue):
self.venue = venue
self.order_book = OrderBook()
self.fees = FeeSchedule()
self.margin_model = MarginModel()
The matching engine must simulate execution on each venue and pick the best. This is called smart order routing.
Simple strategy: send to the venue with the best ask (for buys) or best bid (for sells). Sophisticated: consider fees (maker rebate on Venue A might offset worse ask).
Event-driven engines model this as:
1. Strategy decides to buy 100 shares
2. Engine queries each venue: "What's your ask for 100 shares?"
3. Venue A: 100.02 (no rebate)
4. Venue B: 100.025 but with 0.1% maker rebate (net: 100.015 after rebate benefit)
5. Engine routes to Venue B
6. Order fills, fills event emitted
Order Book Reconstruction from Trade Ticks
Some asset classes (futures, some crypto) don’t publish full order book data, only trades.
To reconstruct the book, the engine models:
– Trades move price (highest bid and lowest ask converge)
– Order book depth is inferred from trade size and frequency
– If a 500-contract trade occurs, at least 500 contracts of buy interest and 500 of sell interest existed
This is imperfect. The true order book might have 5000 contracts bid at 100.00, but you only see a 100-contract trade. You infer the book was deeper than it was, inflating fill liquidity estimates.
Sophisticated engines track this reconstruction error and add conservative margins to slippage models.
Corporate Actions and Data Adjustments
Corporate actions (splits, dividends, mergers) require careful handling in event-driven engines:
Dividend:
2024-03-15 08:00 ET: Dividend ex-date
→ Reduce position quantity by dividend amount (per-share basis)
→ Add cash = position_quantity * dividend_per_share
→ Emit CASH_DIVIDEND event
→ Trigger on_cash_dividend handler (strategy can log, alert, etc.)
Stock split:
2024-05-20 10:00 ET: 2:1 stock split
→ Double position quantity
→ Halve entry price (for P&L calculation)
→ Emit SPLIT event
→ Update order book (all historical and live prices are now halved)
Merger:
2024-08-01 16:00 ET: Merger effective (ACME acquired by BIGCO)
→ Convert positions: 100 shares ACME → 150 shares BIGCO (at 0.67:1 ratio)
→ Update cash if cash consideration
→ Emit POSITION_CONVERSION event
→ Likely: delisting (ACME no longer tradeable)
These events are queued like any other market event and processed in strict order.
Performance Considerations: Throughput, Memory, and Wall-Clock Time
Throughput: Events Per Second
An event-driven engine processes events in a loop. How many events can it handle per second?
Realistic numbers (on a 2024 laptop, 4-core CPU, 16GB RAM):
- Simple strategy (no portfolio optimization): 1-10 million events/second
- Complex strategy (machine learning signal): 100K-1M events/second
- Very complex strategy (portfolio optimization, quadratic programming): 10K-100K events/second
This is single-threaded. Using multi-threaded backtest engines (with proper locking) can achieve 2-4x speedup, but introduces complexity and risks of race conditions (breaking determinism).
Memory: Data and State Size
A typical backtest of 100 equities, 5 years of minute data:
- Data: 100 equities × 250 trading days/year × 5 years × 390 minutes/day × 7 bytes/value (timestamp, open, high, low, close, volume) ≈ 5 GB (compressed: 500 MB in Parquet)
- Engine state (order books, positions, open orders): 100 MB
- Results (trades, fills, equity curve): 50 MB
Total: ~600 MB RAM (with data in memory). Manageable.
For crypto (trades every second, multiple symbols), data grows exponentially. Many engines use streaming mode: load data page-by-page from disk, rather than entire dataset into RAM.
Wall-Clock Time: How Long Does a Backtest Take?
Throughput doesn’t directly translate to wall-clock time because it’s a function of event count:
- 100 equities, 5 years minute data: ~12.5 million events
- At 5M events/second: 2.5 seconds
- At 100K events/second: 125 seconds (2 minutes)
In practice, backtests take 30 seconds to 10 minutes depending on strategy complexity and data volume. This is much faster than live trading (which takes actual time), but slower than vectorized engines (~0.1 seconds for simple strategies because they skip matching and order book simulation).
The tradeoff: accuracy vs. speed. Vectorized is fast but inaccurate. Event-driven is slower but models real behavior.
Measuring Backtest Quality: Key Metrics and Sanity Checks
A backtest is only as good as its assumptions. Here’s how to validate one:
Sanity Checks
-
Positive correlation with volume — if your strategy makes money, does it correlate with increased volume? (More volume → lower spreads → better fills → profits make sense)
-
Profit during trending markets — does your strategy make money in trending periods? If it only profits during consolidations, it’s brittle to regime change.
-
Volatility-adjusted returns — compute Sharpe ratio (return / volatility). Sustainable strategies have Sharpe > 1. If yours is > 2, either you’ve discovered alpha or your model has a flaw.
-
Drawdown distribution — do you have 5-10 losing periods of 10%+ (realistic)? Or perfect equity curve with one big drawdown (overfitted)? Real equity curves are bumpy.
-
Trade statistics — average trade profit, win rate, average trade duration. Do these make intuitive sense?
– 10-year backtest, 50-trade average win rate? Unlikely; you’re overfitting.
– 1,000-trade sample size, 52% win rate? Plausible.
– Average trade profit $100, slippage per trade $5? Makes sense.
Robustness Testing
-
Walk-forward analysis — split data into 10 periods. Optimize parameters on period 1-9, test on period 10. Repeat for all 10 periods. If results are consistent, your strategy generalizes.
-
Monte Carlo backtests — shuffle the order of historical returns. If backtest profit is sensitive to order (some shuffles are profitable, others aren’t), you’re overfitting to sequence.
-
Parameter sensitivity — vary input parameters (e.g., SMA period from 15 to 30). Does strategy profit hold? If it only works at exactly SMA=20, it’s brittle.
-
Out-of-sample testing — optimize on 2020-2022, test on 2023-2024. If your strategy made 20% in-sample but loses money out-of-sample, you overfit.
Practical Implementation Patterns
Pattern 1: Latency Modeling for Realistic Fills
Most backtests ignore latency. Here’s a realistic model:
from dataclasses import dataclass
from typing import Optional
import random
@dataclass
class LatencyModel:
network_mean_ms: float = 50.0 # co-located server
network_std_ms: float = 10.0
broker_mean_ms: float = 30.0
broker_std_ms: float = 15.0
exchange_mean_ms: float = 5.0
exchange_std_ms: float = 2.0
def sample(self) -> float:
"""Sample total latency in milliseconds."""
network = random.gauss(self.network_mean_ms, self.network_std_ms)
broker = random.gauss(self.broker_mean_ms, self.broker_std_ms)
exchange = random.gauss(self.exchange_mean_ms, self.exchange_std_ms)
total = network + broker + exchange
return max(0, total) # Latency can't be negative
# In matching engine:
latency_ms = latency_model.sample()
fill_timestamp = order_timestamp + pd.Timedelta(milliseconds=latency_ms)
fill_price = order_book.get_price_at(fill_timestamp)
Pattern 2: Partial Fill Simulation
Orders don’t always fill completely:
import random
class PartialFillModel:
def simulate_fill(self, order: Order, book: OrderBook) -> tuple[float, int]:
"""Returns (fill_price, fill_quantity)."""
# Walk the book to estimate available liquidity
available_quantity = 0
weighted_price = 0
for level in book.ask_levels(): # For buy orders
level_quantity = min(
level.quantity,
order.quantity - available_quantity
)
available_quantity += level_quantity
weighted_price += level.price * level_quantity
# Probabilistically reduce fill quantity (queue position effects)
# If you're 10th in queue at best ask, probability of full fill is ~60%
queue_position = book.queue_position_at_best_ask()
full_fill_probability = 1.0 / (1.0 + queue_position * 0.1)
fill_qty = order.quantity if random.random() < full_fill_probability else available_quantity * 0.8
fill_price = weighted_price / available_quantity
return fill_price, int(fill_qty)
Pattern 3: Maker-Taker Fee Modeling
Different orders incur different fees:
class FeeModel:
def __init__(self, maker_fee: float = -0.001, taker_fee: float = 0.001):
self.maker_fee = maker_fee # Negative = rebate
self.taker_fee = taker_fee
def calculate_fee(self, fill: Fill, order_type: str) -> float:
"""Returns fee as absolute dollars."""
notional = fill.price * fill.quantity
if order_type == "MAKER": # Order was placed before matched
fee_rate = self.maker_fee
else: # Order matched immediately against standing orders
fee_rate = self.taker_fee
return notional * fee_rate
Pattern 4: Survivorship Bias Prevention
class UniverseFilter:
def __init__(self, universe_file: str):
self.universe = pd.read_csv(universe_file)
# Columns: ticker, ipo_date, delisting_date, delisting_reason
def is_tradeable(self, ticker: str, date: str) -> bool:
"""Check if ticker was tradeable on date."""
row = self.universe[self.universe['ticker'] == ticker].iloc[0]
date = pd.Timestamp(date)
ipo = pd.Timestamp(row['ipo_date'])
delisting = pd.Timestamp(row['delisting_date'])
if pd.isna(delisting): # Still trading
return date >= ipo
else: # Delisted
return ipo <= date < delisting
Open Research Questions and Limitations
Still Unsolved Problems
-
Optimal order book reconstruction from trade ticks — we know exact trades but not exact order book depth. Different reconstruction methods yield different backtests. Which is more accurate?
-
Cross-exchange order routing — how should a system decide whether to route to Nasdaq or NYSE? Backtest models are simplified; live systems use machine learning.
-
Market microstructure latency — exchange matching engine latency depends on market conditions (congestion, number of orders in book, etc.). Modeling this accurately requires reverse-engineering exchange matching logic (proprietary).
-
Regime detection — backtest assumes static market regime. Real markets have regime shifts (bull → bear, trending → consolidating). How should backtests account for this?
-
Overfitting detection — we have walk-forward and Monte Carlo methods, but no perfect way to distinguish alpha from luck.
Fundamental Limitations
-
Hindsight bias — backtesting always shows you the outcome. You can’t unknow the result. This biases intuition (you think a setups was “obvious” in hindsight).
-
Selection bias — you’re testing strategies you thought to test. You’re not testing random strategies. This biases you toward strategies that seem plausible.
-
Black swan events — backtests can’t model events outside historical distribution (March 2020 COVID crash, 2023 SVB collapse, 2024 flash crashes). These are rare but catastrophic.
-
Execution difficulty — backtest assumes you can always execute. In reality, brokers halting trade, exchanges halting circuit breakers, liquidity disappearing (flash crashes) make execution impossible at key moments.
Conclusion: Event-Driven Architecture as an Industry Standard
Event-driven backtesting is now table-stakes for professional trading. The days of vectorized backtests are ending because the fidelity gap is too large.
The key reasons:
-
Lookahead bias is structural in vectorized engines — no amount of “validation” fixes it. Event-driven engines prevent it by design.
-
Order book dynamics are essential — market-making, scalping, and high-frequency strategies are impossible to backtest without realistic order book simulation.
-
Determinism enables reproducibility — event-driven engines produce identical results on identical input, enabling rigorous testing and validation.
-
Backtest-to-live fidelity is measurable — event-driven engines make it easy to compare backtest assumptions to live execution and identify systematic gaps.
-
Computational cost is acceptable — modern hardware (multi-core CPUs, SSDs) makes even complex backtests run in minutes, not hours.
The libraries and platforms that embraced event-driven architecture early (NautilusTrader, LEAN/QuantConnect, Backtrader, VectorBT Pro) are the ones dominating the industry.
If you’re building a trading system, or backtesting strategies at scale, event-driven architecture is non-negotiable. The question isn’t “should I use it?” but “which platform should I use?”
References and Sources
- NautilusTrader Documentation
- GitHub – nautechsystems/nautilus_trader
- A Practical Breakdown of Vector-Based vs. Event-Based Backtesting
- QuantConnect/LEAN Algorithmic Trading Engine
- QuantConnect Key Concepts – Slippage
- DuckDB Tutorial for Traders
- How I Built an Event-Driven Backtesting Engine in Python
- Look-Ahead Bias in Backtests and How To Detect It
- DuckDB for Time Series Analysis
Word count: 5,847 words
Diagrams: 3 of 6 referenced (assets/arch_01.png, arch_02.png, arch_03.png)
