⚠ Trading involves significant risk of loss. Everything on this site is for education only and is not financial advice.
Lesson 8 of 13 · Free

An Event-Driven Backtest: A Loop Engine with ATR Stops, Position Sizing and a Trade List in R-Multiples

Build a bar-by-bar backtest loop in Python that sizes positions by ATR risk, honours stops including gaps, and records every trade in R-multiples.

Author: Moustafa HijaziContent reviewed: 2026-10-306 min readSelf-study course

What you will learn

  • Why a vectorised backtest cannot model stops, partial fills or risk-based sizing, and when a loop engine is needed.
  • The state an event-driven engine must carry from bar to bar: cash, position, entry price, stop level and open trade.
  • How to size each position from account risk and ATR, and how to record the result as an R-multiple.
  • How gaps through a stop are handled honestly in code.

Explanation

The vectorised backtest of lesson 7 computes every bar's position from the whole series at once. It is fast, but it cannot express rules that depend on path: a stop 2 ATR below the entry depends on where you entered, and the size depends on the account value at that moment. An event-driven backtest walks through the bars one at a time, uses only information available up to that bar, and updates its state exactly as a trader would.

The state and the order of events

The engine keeps a small set of variables: cash, position (number of shares, 0 when flat), entry, stop, risk_per_share and a list trades. On every bar the order of checks matters and must be written down once and kept:

  1. Exits first. If a position is open and today's low is at or below the stop, exit. The fill price is the stop, unless the open is already below the stop (a gap), in which case the fill is the open. This is where a planned 1R loss can become 1.4R or more.
  2. Update trailing stops using today's close and ATR.
  3. Entries last, and only on the next bar's open, because a signal computed from today's close cannot be traded at today's close. In code this means: a signal on bar t sets a flag, and the fill happens at open[t+1].

Position sizing in the loop

Risk-based sizing was taught in Risk & Trade Management (lesson 2) and Systematic Trading (lesson 9). In code it is one line: size = floor(equity * risk_pct / (entry - stop)), where the denominator is the risk per share (2 * ATR here). Add a cap such as size * entry <= 0.25 * equity so a tiny ATR cannot create a position the account cannot hold.

Recording trades in R

Each closed trade is appended as a dictionary with dates, prices, size, profit in euros and the R-multiple = profit / (size × risk per share). pd.DataFrame(trades) turns the list into a trade table for the metric functions of lesson 9 and the expectancy calculation from Risk & Trade Management (lesson 1).

A minimal engine

  • def run(df, risk_pct=0.01, atr_mult=2.0, start_cash=20_000):
  • cash, pos, entry, stop, rps = start_cash, 0, None, None, None
  • trades, equity, pending = [], [], False
  • for i in range(1, len(df)):
  • o, h, l, cl, atr = df.open[i], df.high[i], df.low[i], df.close[i], df.atr[i]
  • if pos > 0 and l <= stop: # exit: stop or gap
  • fill = min(o, stop)
  • pnl = pos * (fill - entry)
  • trades.append(dict(exit=df.index[i], pnl=pnl, R=pnl / (pos * rps)))
  • cash += pos * fill; pos = 0
  • if pending and pos == 0: # entry at this bar's open
  • entry, rps = o, atr_mult * df.atr[i-1]
  • pos = int(cash * risk_pct // rps)
  • stop = entry - rps; cash -= pos * entry; pending = False
  • if pos == 0 and df.signal[i]:
  • pending = True # trade on next open
  • equity.append(cash + pos * cl)
  • return pd.DataFrame(trades), pd.Series(equity, index=df.index[1:])

Two details: the ATR used for sizing is df.atr[i-1], the value known the evening before the entry, and the stop is checked against the low, not the close. A trailing stop is one extra line, stop = max(stop, cl - rps), after the exit block. Costs can be subtracted from pnl as in lesson 7; they are omitted here for clarity.

Worked example

Hypothetical example: start equity €20,000, risk 1 % per trade (€200), stop at 2 × ATR. Prices and ATR are invented round numbers; fees, spread and taxes are excluded.

  1. Trade 1. Signal on Monday; Tuesday open €50.00, previous ATR €2.50. Risk per share = 2 × 2.50 = €5.00; stop = €45.00. Size = floor(200 / 5) = 40 shares (€2,000 of exposure, 10 % of equity, inside a 25 % cap). Exit later on a signal at €58.00: profit = 40 × 8 = €320 = 320 / 200 = +1.6R.
  2. Trade 2. Same setup, entry €50.00, stop €45.00, 40 shares. Overnight news: the stock opens at €43.00. The engine fills at min(open, stop) = €43.00. Loss = 40 × 7 = €280 = −1.4R, not the planned −1.0R. A vectorised test would have shown −1.0R or nothing at all.
  3. Trade 3. Entry €50.00, trailing stop; the stop has risen to €47.50 when the low touches it. Loss = 40 × 2.50 = €100 = −0.5R.
  4. Trade list. R-multiples +1.6, −1.4, −0.5: sum −0.3R, average −0.1R. Three trades prove nothing, but the table has the right form for hundreds.

A second instrument at €120 with ATR €6 gives size floor(200 / 12) = 16 shares: same €200 risk, fewer shares because volatility is higher.

Common mistake

Filling at the stop price when the bar gapped through itCode such as if l <= stop: fill = stop silently assumes that the market traded at your stop. On a gap it did not. Always use min(open, stop) for long exits (and max(open, stop) for shorts). The same logic applies to entries with a stop order: a buy-stop at €52 fills at the open if the open is €54.

Practice activity

On paper, trace the engine for one trade: equity €15,000, risk 1.5 %, ATR the evening before entry €1.80, stop multiplier 2.5, entry at next open €36.00. (a) Compute risk per share, stop level and position size (floor). (b) The exit day opens at €37.00, trades down to a low of €31.00 and closes at €32.00. What is the fill, the euro profit and the R-multiple? (c) Would the result differ if you checked the stop against the close instead of the low?

Show solution

(a) Risk per trade = 0.015 × 15,000 = €225. Risk per share = 2.5 × 1.80 = €4.50. Stop = 36.00 − 4.50 = €31.50. Size = floor(225 / 4.50) = 50 shares.

(b) Open €37.00 is above the stop, so no gap; the low €31.00 is at or below €31.50, so the fill is min(37.00, 31.50) = €31.50. Loss = 50 × (31.50 − 36.00) = −€225 = −1.0R.

(c) Checking the close (€32.00) would also trigger the exit here, but at €32.00 instead of €31.50, so the loss would be €200 (−0.89R) and look better than reality. In other bars the low can hit the stop while the close recovers above it; a close-based check would then miss a real exit entirely.

Limitations: intraday order of high and low is unknown from daily bars; when both the stop and a target lie inside one bar, assume the worse outcome.

Key takeaways

  • Use a loop engine when rules depend on the path: stops, trailing stops, risk-based sizing and partial exits.
  • Fix the order of events per bar: exits, stop updates, then entries at the next open.
  • Size from equity × risk % divided by risk per share, with an exposure cap; store every trade with its R-multiple.
  • Fill gap exits at the open, not at the stop; that is where losses larger than 1R come from.

Next step

Lesson 9 turns the equity curve and trade list you now produce into reusable, tested performance functions: CAGR, drawdown, Sharpe, Sortino and profit factor.

Self-check

Answer the questions, then check your answers. You can retry as often as you like.

1. Equity €20,000, risk 1 % per trade, stop at 2 × ATR with ATR = €2.50, entry at €50. On the exit day the stock opens at €43 and the stop is €45. What loss does an honest engine record?

2. Why does the engine use df.atr[i-1] rather than df.atr[i] when sizing an entry at the open of bar i?

3. Which rule set genuinely requires an event-driven loop rather than the vectorised approach from lesson 7?

Automatic self-check for your own revision. Your answers are checked in your browser and are not seen or assessed by us. It is not an exam and not a qualification.

Your progress

“Complete” only means you marked the lesson as done. It is not a test of your understanding. Progress and notes are saved only in this browser on this device — not in an account.