AlgoPro UniversityCourse home
Part 5 · Lesson

Event-driven backtesting

Vectorised backtests break the moment your logic depends on the path. Learn why, then build a minimal but real event-driven engine — the same shape as a live trading bot.

The vectorised approach has a hard ceiling. Its power comes from computing every day at once — but that is exactly why it cannot handle logic where *today's decision depends on what happened along the way*. The instant you add a stop-loss, a trailing exit, position sizing that scales with current equity, or a rule like "only take the next signal if the last trade lost", the clean position * return identity falls apart.

Why the path matters

Consider a stop-loss at -5%. Whether it triggers on a given day depends on the entry price, which depends on when you entered, which depends on prior signals — a chain that only makes sense walked forward in time. You cannot express "exit if we are 5% below wherever we happened to get in" as a single column operation, because "wherever we got in" is itself an outcome of the simulation.

The event loop

An event-driven backtester walks through history one bar at a time. On each bar it runs the same cycle a live system runs — which is the entire point: the same code shape that backtests your strategy can trade it live.

Next barSignalRisk / stopsFill orderUpdate equity
The event loop. Each bar flows through the same four stages, then the clock advances. A live bot is this exact loop with a real broker and a real clock instead of a historical data feed.

A minimal event-driven engine

Here is a real, runnable engine — small enough to read in one sitting, complete enough to backtest a strategy with a stop-loss and proper per-trade accounting. It is built from three pieces: a Broker that holds cash and one position and fills orders with costs, a Strategy that decides what to do on each bar, and an Engine that drives the loop.

pythonThe broker: cash, position, and cost-aware fills
import numpy as np
import pandas as pd

class Broker:
    class="s">"""Holds cash and a single long/flat position. Fills at the given
    price plus a cost, and records every closed trade's P&L.class="s">"""

    def __init__(self, cash=10_000.class="n">0, cost_bps=class="n">6.0):
        self.cash = cash
        self.cost = cost_bps / 1e4     class="c"># fraction charged per fill
        self.units = class="n">0.0               class="c"># units of the asset held
        self.entry_price = None        class="c"># price we bought at (for trade P&L)
        self.trades = []               class="c"># list of realised P&L per closed trade
        self.equity_curve = []         class="c"># (timestamp, equity) snapshots

    def value(self, price):
        return self.cash + self.units * price

    def buy(self, price, cash_to_spend):
        fill = price * (class="n">1 + self.cost)          class="c"># pay up on the way in
        units = cash_to_spend / fill
        self.units += units
        self.cash  -= units * fill
        self.entry_price = price

    def sell_all(self, price):
        if self.units <= class="n">0:
            return
        fill = price * (class="n">1 - self.cost)          class="c"># receive less on the way out
        proceeds = self.units * fill
        class="c"># Record the round-trip return for trade stats
        self.trades.append(fill / self.entry_price - class="n">1)
        self.cash += proceeds
        self.units = class="n">0.0
        self.entry_price = None

    def mark(self, ts, price):
        self.equity_curve.append((ts, self.value(price)))
pythonThe strategy: an MA-cross with a hard stop-loss
class MACrossStop:
    class="s">"""Long when fast MA > slow MA. Exit on the opposite cross OR if price
    falls class="s">'stop' below the entry — the path-dependent rule vectorising can't do.class="s">"""

    def __init__(self, fast=class="n">20, slow=class="n">100, stop=class="n">0.05):
        self.fast, self.slow, self.stop = fast, slow, stop

    def warmup(self):
        return self.slow                     class="c"># bars needed before signals are valid

    def on_bar(self, i, prices, broker):
        fast = prices[i - self.fast:i].mean()
        slow = prices[i - self.slow:i].mean()
        price = prices[i]

        in_position = broker.units > class="n">0
        if in_position:
            class="c"># Stop-loss check — needs the entry price, i.e. the path
            if price <= broker.entry_price * (class="n">1 - self.stop):
                return class="s">"SELL"
            if fast < slow:                  class="c"># trend broke: exit
                return class="s">"SELL"
        else:
            if fast > slow:                  class="c"># trend turned up: enter
                return class="s">"BUY"
        return class="s">"HOLD"
pythonThe engine: walk the bars, one at a time
class Engine:
    def __init__(self, prices, index, strategy, broker):
        self.prices = np.asarray(prices, dtype=float)
        self.index = index
        self.strategy = strategy
        self.broker = broker

    def run(self):
        start = self.strategy.warmup()
        for i in range(start, len(self.prices)):
            price, ts = self.prices[i], self.index[i]

            action = self.strategy.on_bar(i, self.prices, self.broker)
            if action == class="s">"BUY" and self.broker.units == class="n">0:
                self.broker.buy(price, self.broker.cash)      class="c"># go all-in long
            elif action == class="s">"SELL" and self.broker.units > class="n">0:
                self.broker.sell_all(price)

            self.broker.mark(ts, price)                       class="c"># snapshot equity

        equity = pd.Series(dict(self.broker.equity_curve))
        return equity, self.broker.trades

And running it is three lines. Notice how the pieces snap together exactly like the five-component blueprint from Part 1 — data, signal, risk, execution, state.

pythonRunning the event-driven backtest
import yfinance as yf

df = yf.download(class="s">"SPY", start=class="s">"class="n">2015-class="n">01-class="n">01", end=class="s">"class="n">2024-class="n">12-class="n">31", auto_adjust=True)
prices = df[class="s">"Close"].dropna()

broker = Broker(cash=10_000, cost_bps=class="n">6)
engine = Engine(prices.values, prices.index, MACrossStop(class="n">20, class="n">100, stop=class="n">0.05), broker)
equity, trades = engine.run()

trades = pd.Series(trades)
print(fclass="s">"Final equity: {equity.iloc[-class="n">1]:,.0f}")
print(f"Trades: {len(trades)}  |  win rate: {(trades > class="n">0).mean():.class="n">1%}class="s">")
print(f"Profit factor: {trades[trades>class="n">0].sum() / -trades[trades<class="n">0].sum():.2f}")

You could extend this engine forever — multiple positions, short selling, limit orders, a portfolio of instruments. At some point, though, you are rebuilding a library that already exists and has been battle-tested by thousands of users. That is the final lesson.