The Reference Strategy
The strategy specified in Chapter 5, implemented twice. Implementing the same specification in two independent systems is one of the better bug-detection methods available (Chapter 5), and any discrepancy between these two implementations is a bug in one of them rather than a platform quirk.
Both versions are deliberately mediocre strategies. They exist to be measured correctly, not to make money.
A.1 - Pine Script (TradingView)
Use for fast iteration and visual inspection. Note the known limitation, marked in the code: the position is unprotected on the bar it fills, because exit orders cannot be placed until the fill is visible to the script.
//@version=5
strategy("Reference Strategy - Book Appendix A", overlay=true,
margin_long=100, margin_short=100, initial_capital=10000,
commission_type=strategy.commission.percent, commission_value=0.1,
slippage=2, pyramiding=0, calc_on_every_tick=false)
// ---------------- Inputs ----------------
grpMA = "Moving Averages"
fastLen = input.int(13, "Fast EMA", minval=1, group=grpMA)
slowLen = input.int(21, "Slow EMA", minval=1, group=grpMA)
trendLen = input.int(200, "Trend EMA", minval=1, group=grpMA)
useTrendFilter = input.bool(true, "Require price on trend-EMA side", group=grpMA)
grpRisk = "Risk"
atrLen = input.int(14, "ATR Length", minval=1, group=grpRisk)
atrMultSL = input.float(2.0, "ATR Stop Multiplier", minval=0.1, step=0.1, group=grpRisk)
atrMultTP = input.float(3.0, "ATR Target Multiplier", minval=0.1, step=0.1, group=grpRisk)
useRiskSizing = input.bool(true, "Size position by risk %", group=grpRisk)
riskPct = input.float(1.0, "Risk % of equity per trade", minval=0.01, maxval=100, step=0.1, group=grpRisk)
grpBE = "Breakeven"
useBreakeven = input.bool(false, "Enable breakeven stop", group=grpBE)
breakevenRR = input.float(1.0, "Arm breakeven after R multiple", minval=0.1, step=0.1, group=grpBE)
beOffsetR = input.float(0.05, "Breakeven offset in R (covers costs)", minval=0, step=0.01, group=grpBE)
grpDate = "Backtest Window"
useDateFilter = input.bool(false, "Limit backtest window", group=grpDate)
startDate = input.time(timestamp("01 Jan 2020 00:00 +0000"), "Start", group=grpDate)
endDate = input.time(timestamp("01 Jan 2024 00:00 +0000"), "End", group=grpDate)
// ---------------- Series ----------------
fastMA = ta.ema(close, fastLen)
slowMA = ta.ema(close, slowLen)
trendMA = ta.ema(close, trendLen)
atr = ta.atr(atrLen)
inWindow = not useDateFilter or (time >= startDate and time <= endDate)
warmedUp = not na(trendMA) and not na(atr)
trendOkLong = not useTrendFilter or close > trendMA
trendOkShort = not useTrendFilter or close < trendMA
longCond = warmedUp and inWindow and ta.crossover(fastMA, slowMA) and trendOkLong
shortCond = warmedUp and inWindow and ta.crossunder(fastMA, slowMA) and trendOkShort
// ---------------- Entries ----------------
riskPerUnit = atr * atrMultSL
qty = riskPerUnit > 0 ? (strategy.equity * riskPct / 100) / riskPerUnit : na
if longCond
if useRiskSizing and not na(qty)
strategy.entry("Long", strategy.long, qty=qty)
else
strategy.entry("Long", strategy.long)
if shortCond
if useRiskSizing and not na(qty)
strategy.entry("Short", strategy.short, qty=qty)
else
strategy.entry("Short", strategy.short)
// ---------------- Trade state ----------------
// ATR is frozen at fill so stop/target levels do not drift (Chapter 5).
var float entryAtr = na
var bool beArmed = false
var float curSL = na
var float curTP = na
prevPos = nz(strategy.position_size[1])
newTrade = strategy.position_size != 0 and
(prevPos == 0 or math.sign(strategy.position_size) != math.sign(prevPos))
if newTrade
entryAtr := nz(atr[1], atr) // ATR as of the signal bar
beArmed := false
if strategy.position_size == 0
entryAtr := na
beArmed := false
curSL := na
curTP := na
// ---------------- Exits ----------------
// One exit ID per side, re-issued each bar so the order is replaced, not duplicated.
if strategy.position_size != 0 and not na(entryAtr)
risk = entryAtr * atrMultSL
entry = strategy.position_avg_price
if strategy.position_size > 0
if useBreakeven and high >= entry + risk * breakevenRR
beArmed := true
initSL = entry - risk
curSL := beArmed ? math.max(initSL, entry + risk * beOffsetR) : initSL
curTP := entry + entryAtr * atrMultTP
strategy.exit("Exit Long", from_entry="Long", stop=curSL, limit=curTP)
else
if useBreakeven and low <= entry - risk * breakevenRR
beArmed := true
initSL = entry + risk
curSL := beArmed ? math.min(initSL, entry - risk * beOffsetR) : initSL
curTP := entry - entryAtr * atrMultTP
strategy.exit("Exit Short", from_entry="Short", stop=curSL, limit=curTP)
if useDateFilter and not inWindow and inWindow[1] and strategy.position_size != 0
strategy.close_all("Window End")
// ---------------- Plots ----------------
plot(fastMA, color=color.teal, title="Fast EMA")
plot(slowMA, color=color.orange, title="Slow EMA")
plot(trendMA, color=color.purple, title="Trend EMA")
plot(curSL, "Stop", color=color.new(color.red, 0), style=plot.style_linebr)
plot(curTP, "Target", color=color.new(color.green, 0), style=plot.style_linebr)
if barstate.islastconfirmedhistory
var label lbl = na
label.delete(lbl)
pf = strategy.grossloss > 0 ? strategy.grossprofit / strategy.grossloss : na
lbl := label.new(bar_index, high,
"Trades: " + str.tostring(strategy.closedtrades) +
"\nPF: " + str.tostring(pf, "#.##"),
style=label.style_label_left, color=color.new(color.blue, 20),
textcolor=color.white)A.2 - Python (event-driven)
The reference implementation. Slower than a vectorised version and correct by construction: the strategy receives a MarketView that cannot return future data (Chapter 7).
Two things this version does better than the Pine one. It checks the stop on the fill bar itself, closing the one-bar protection gap. And it resolves intrabar ambiguity pessimistically - when a bar contains both stop and target, the stop is assumed to have hit first.
"""
Reference event-driven backtester and strategy.
Companion to Trading Systems for Software Engineers, Chapters 5 and 7.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol
import numpy as np
import pandas as pd
# ---------------------------------------------------------------- data types
@dataclass(frozen=True)
class Signal:
side: int # +1 long, -1 short
stop_distance: float # in price units, frozen at signal time
@dataclass
class Trade:
side: int
entry_time: pd.Timestamp
entry_price: float
qty: float
risk_per_unit: float
stop: float
target: float
exit_time: pd.Timestamp | None = None
exit_price: float | None = None
exit_reason: str | None = None
@property
def pnl(self) -> float:
if self.exit_price is None:
return 0.0
return self.side * (self.exit_price - self.entry_price) * self.qty
@property
def r_multiple(self) -> float:
"""P&L expressed in units of initial risk. The unit of account (Ch 4)."""
risk = self.risk_per_unit * self.qty
return self.pnl / risk if risk else 0.0
@dataclass
class CostModel:
commission_pct: float = 0.001 # 0.1% per side
slippage_pct: float = 0.0002 # 0.02% per side, applied adversely
def fill_price(self, quoted: float, side: int) -> float:
"""Slippage always works against you. Never model it as symmetric noise."""
return quoted * (1 + side * self.slippage_pct)
def commission(self, price: float, qty: float) -> float:
return abs(price * qty) * self.commission_pct
# ---------------------------------------------------------------- market view
class MarketView:
"""
A window onto history that physically cannot see the future.
The cursor is the index of the most recently CLOSED bar. There is
deliberately no method returning data beyond it, so lookahead bias
requires reaching around the interface rather than a one-character typo.
"""
__slots__ = ("_frame", "_cursor")
def __init__(self, frame: pd.DataFrame, cursor: int):
self._frame = frame
self._cursor = cursor
def latest(self, column: str) -> float:
return float(self._frame[column].iat[self._cursor])
def previous(self, column: str, back: int = 1) -> float:
idx = self._cursor - back
if idx < 0:
return float("nan")
return float(self._frame[column].iat[idx])
def history(self, column: str, n: int) -> np.ndarray:
start = max(0, self._cursor - n + 1)
return self._frame[column].values[start : self._cursor + 1]
@property
def time(self) -> pd.Timestamp:
return self._frame.index[self._cursor]
class Strategy(Protocol):
def on_bar(self, view: MarketView) -> Signal | None: ...
# ---------------------------------------------------------------- indicators
def prepare_indicators(df: pd.DataFrame, fast: int = 13, slow: int = 21,
trend: int = 200, atr_len: int = 14) -> pd.DataFrame:
"""
Precomputed for speed. This is safe: an EMA or ATR at index i depends
only on data at or before i, so no future information leaks backwards.
Precomputing anything centred or forward-looking would NOT be safe.
"""
out = df.copy()
out["ema_fast"] = out["close"].ewm(span=fast, adjust=False).mean()
out["ema_slow"] = out["close"].ewm(span=slow, adjust=False).mean()
out["ema_trend"] = out["close"].ewm(span=trend, adjust=False).mean()
prev_close = out["close"].shift(1)
true_range = pd.concat([
out["high"] - out["low"],
(out["high"] - prev_close).abs(),
(out["low"] - prev_close).abs(),
], axis=1).max(axis=1)
out["atr"] = true_range.ewm(alpha=1 / atr_len, adjust=False).mean()
# Warm-up mask: do not trade before the slowest series is meaningful.
out["warmed_up"] = np.arange(len(out)) >= max(trend, atr_len)
return out
# ---------------------------------------------------------------- strategy
@dataclass
class EmaCrossStrategy:
atr_mult_sl: float = 2.0
use_trend_filter: bool = True
def on_bar(self, view: MarketView) -> Signal | None:
if not view.latest("warmed_up"):
return None
fast_now, fast_prev = view.latest("ema_fast"), view.previous("ema_fast")
slow_now, slow_prev = view.latest("ema_slow"), view.previous("ema_slow")
if np.isnan(fast_prev) or np.isnan(slow_prev):
return None
crossed_up = fast_prev <= slow_prev and fast_now > slow_now
crossed_down = fast_prev >= slow_prev and fast_now < slow_now
close = view.latest("close")
trend = view.latest("ema_trend")
atr = view.latest("atr")
if atr <= 0 or np.isnan(atr):
return None
stop_distance = atr * self.atr_mult_sl
if crossed_up and (not self.use_trend_filter or close > trend):
return Signal(side=1, stop_distance=stop_distance)
if crossed_down and (not self.use_trend_filter or close < trend):
return Signal(side=-1, stop_distance=stop_distance)
return None
# ---------------------------------------------------------------- backtester
@dataclass
class BacktestResult:
trades: list[Trade] = field(default_factory=list)
equity_curve: pd.Series | None = None
initial_equity: float = 10_000.0
final_equity: float = 10_000.0
@property
def r_multiples(self) -> np.ndarray:
return np.array([t.r_multiple for t in self.trades if t.exit_price is not None])
def summary(self) -> dict:
r = self.r_multiples
if len(r) == 0:
return {"trades": 0}
wins, losses = r[r > 0], r[r <= 0]
gross_profit, gross_loss = wins.sum(), -losses.sum()
# Standard error of expectancy - see the sample-size table in Chapter 4.
stderr = r.std(ddof=1) / np.sqrt(len(r)) if len(r) > 1 else float("nan")
return {
"trades": len(r),
"expectancy_R": r.mean(),
"expectancy_stderr": stderr,
"t_stat": r.mean() / stderr if stderr else float("nan"),
"win_rate": len(wins) / len(r),
"avg_win_R": wins.mean() if len(wins) else 0.0,
"avg_loss_R": losses.mean() if len(losses) else 0.0,
"profit_factor": gross_profit / gross_loss if gross_loss > 0 else float("inf"),
"total_R": r.sum(),
"return_pct": (self.final_equity / self.initial_equity - 1) * 100,
}
def run_backtest(
strategy: Strategy,
df: pd.DataFrame,
costs: CostModel = CostModel(),
initial_equity: float = 10_000.0,
risk_pct: float = 1.0,
atr_mult_tp: float = 3.0,
pessimistic_intrabar: bool = True,
) -> BacktestResult:
"""
Event-driven simulation.
Bar ordering, which is where correctness lives:
1. Fill any pending entry at this bar's OPEN (signal came from the
previous bar's close -- you cannot act on a close at that close).
2. Check exits against this bar's range, including on the fill bar.
3. At this bar's CLOSE, ask the strategy for a new signal.
Intrabar ambiguity (Chapter 7): when a bar contains both stop and target,
`pessimistic_intrabar` assumes the stop hit first. The information needed
to do better is not present in the bar.
"""
result = BacktestResult(initial_equity=initial_equity)
equity = initial_equity
equity_points: list[float] = []
open_trade: Trade | None = None
pending: Signal | None = None
highs = df["high"].values
lows = df["low"].values
opens = df["open"].values
for i in range(len(df)):
timestamp = df.index[i]
# --- 1. Fill pending entry at this bar's open -----------------------
if pending is not None and open_trade is None:
entry = costs.fill_price(opens[i], pending.side)
risk_per_unit = pending.stop_distance
qty = (equity * risk_pct / 100) / risk_per_unit if risk_per_unit > 0 else 0.0
if qty > 0:
target_distance = risk_per_unit * (atr_mult_tp / 2.0) # TP:SL ratio
open_trade = Trade(
side=pending.side,
entry_time=timestamp,
entry_price=entry,
qty=qty,
risk_per_unit=risk_per_unit,
stop=entry - pending.side * risk_per_unit,
target=entry + pending.side * target_distance,
)
equity -= costs.commission(entry, qty)
pending = None
# --- 2. Check exits, including on the fill bar ----------------------
if open_trade is not None:
hit_stop = (
lows[i] <= open_trade.stop if open_trade.side > 0
else highs[i] >= open_trade.stop
)
hit_target = (
highs[i] >= open_trade.target if open_trade.side > 0
else lows[i] <= open_trade.target
)
exit_price = exit_reason = None
if hit_stop and hit_target:
if pessimistic_intrabar:
exit_price, exit_reason = open_trade.stop, "stop (ambiguous bar)"
else:
exit_price, exit_reason = open_trade.target, "target (ambiguous bar)"
elif hit_stop:
exit_price, exit_reason = open_trade.stop, "stop"
elif hit_target:
exit_price, exit_reason = open_trade.target, "target"
if exit_price is not None:
filled = costs.fill_price(exit_price, -open_trade.side)
open_trade.exit_time = timestamp
open_trade.exit_price = filled
open_trade.exit_reason = exit_reason
equity += open_trade.pnl - costs.commission(filled, open_trade.qty)
result.trades.append(open_trade)
open_trade = None
# --- 3. Generate a signal from this bar's close ---------------------
if open_trade is None and pending is None:
pending = strategy.on_bar(MarketView(df, i))
equity_points.append(equity)
result.equity_curve = pd.Series(equity_points, index=df.index)
result.final_equity = equity
return resultUsage, including the null test from Chapter 7:
import numpy as np
df = prepare_indicators(load_ohlcv("data/eurusd_1h.parquet"))
# Baseline. Record it, then resist tuning (Chapter 5).
result = run_backtest(EmaCrossStrategy(), df)
print(result.summary())
# Null test: random entries with the same exits and costs must lose money.
class RandomEntry:
def __init__(self, seed: int, rate: float = 0.02, atr_mult_sl: float = 2.0):
self.rng = np.random.default_rng(seed)
self.rate = rate
self.atr_mult_sl = atr_mult_sl
def on_bar(self, view: MarketView) -> Signal | None:
if not view.latest("warmed_up") or self.rng.random() > self.rate:
return None
atr = view.latest("atr")
if atr <= 0 or np.isnan(atr):
return None
side = 1 if self.rng.random() < 0.5 else -1
return Signal(side=side, stop_distance=atr * self.atr_mult_sl)
null_expectancies = [
run_backtest(RandomEntry(seed=s), df).summary().get("expectancy_R", 0.0)
for s in range(200)
]
assert np.mean(null_expectancies) < 0, "Harness is broken: random entries profit"