Building the Backtester
Everything now assembles. The pieces:
| From | What it gives you |
|---|---|
| Ch 2 | money in integers, floats compared with tolerance |
| Ch 6 | errors as values, guard clauses |
| Ch 7-8 | slices, ring buffers, O(1) indicators |
| Ch 9 | maps for positions by symbol |
| Ch 10-11 | Bar, Trade, pointer receivers |
| Ch 12 | sorting trades, binary search by timestamp |
| Ch 14 | Strategy, CostModel, Sizer interfaces |
| Ch 15 | package layout |
| Ch 16 | CSV loading |
| Ch 17 | known-answer tests |
| Ch 18 | UTC everywhere |
| Ch 20 | parallel parameter sweeps |
The critical design decision
From Trading Systems for Software Engineers Chapter 7: the strategy must not be able to see the future. Not "we'll be careful" - structurally impossible.
package backtest
// MarketView is a window onto history that cannot return future data.
// The cursor is the index of the most recently CLOSED bar.
type MarketView struct {
bars []Bar
cursor int
}
func (v MarketView) Latest() Bar {
return v.bars[v.cursor]
}
func (v MarketView) Previous(back int) (Bar, bool) {
idx := v.cursor - back
if idx < 0 {
return Bar{}, false
}
return v.bars[idx], true
}
func (v MarketView) History(n int) []Bar {
start := v.cursor - n + 1
if start < 0 {
start = 0
}
return v.bars[start : v.cursor+1]
}
func (v MarketView) Index() int { return v.cursor }
// There is deliberately no method returning anything past cursor.The fields are lowercase, so code outside backtest cannot reach v.bars directly (Chapter 15). Lookahead now requires deliberately editing this file, not a one-character typo.
History returns a slice sharing memory with bars - Chapter 7's trap. A strategy could write through it and corrupt the data. If you want to be strict, return a copy; the cost is an allocation per call.
The engine
The bar ordering is the whole correctness story:
package backtest
import (
"math"
"sort"
)
type Engine struct {
Costs CostModel
Sizer Sizer
Equity float64
}
func (e *Engine) Run(s Strategy, bars []Bar) (*Result, error) {
if len(bars) == 0 {
return nil, ErrNoBars
}
result := &Result{InitialEquity: e.Equity}
equity := e.Equity
var open *Trade
var pending *Signal
for i := range bars {
bar := bars[i]
// 1. Fill any pending entry at THIS bar's open.
// The signal came from the previous bar's close - you
// cannot act on a close at that same close.
if pending != nil && open == nil {
entry := e.Costs.FillPrice(bar.Open, pending.Side)
qty := e.Sizer.Size(equity, pending.StopDistance)
if qty > 0 {
open = &Trade{
Side: pending.Side,
EntryTime: bar.Timestamp,
EntryPrice: entry,
Quantity: qty,
RiskPerUnit: pending.StopDistance,
Stop: entry - float64(pending.Side)*pending.StopDistance,
Target: entry + float64(pending.Side)*pending.TargetDistance,
}
equity -= e.Costs.Commission(entry, qty)
}
pending = nil
}
// 2. Check exits against this bar - including the fill bar.
if open != nil {
if price, reason, hit := e.checkExit(open, bar); hit {
filled := e.Costs.FillPrice(price, -open.Side)
open.ExitTime = bar.Timestamp
open.ExitPrice = filled
open.ExitReason = reason
equity += open.PnL() - e.Costs.Commission(filled, open.Quantity)
result.Trades = append(result.Trades, *open)
open = nil
}
}
// 3. Ask the strategy for a signal from this bar's close.
if open == nil && pending == nil {
pending = s.OnBar(MarketView{bars: bars, cursor: i})
}
result.EquityCurve = append(result.EquityCurve, equity)
}
result.FinalEquity = equity
return result, nil
}
// checkExit resolves stop and target. When a bar contains both, the
// stop is assumed to have hit first: the information needed to do
// better is not present in an OHLC bar.
func (e *Engine) checkExit(t *Trade, bar Bar) (float64, string, bool) {
var hitStop, hitTarget bool
if t.Side > 0 {
hitStop = bar.Low <= t.Stop
hitTarget = bar.High >= t.Target
} else {
hitStop = bar.High >= t.Stop
hitTarget = bar.Low <= t.Target
}
switch {
case hitStop && hitTarget:
return t.Stop, "stop (ambiguous bar)", true
case hitStop:
return t.Stop, "stop", true
case hitTarget:
return t.Target, "target", true
}
return 0, "", false
}Three comments in that code are doing more work than the code around them. The one about acting on a close, the one about the fill bar, and the one about ambiguous bars. Each marks a place where the obvious implementation is wrong.
Results in R
type Result struct {
Trades []Trade
EquityCurve []float64
InitialEquity float64
FinalEquity float64
}
func (r *Result) RMultiples() []float64 {
out := make([]float64, 0, len(r.Trades))
for _, t := range r.Trades {
out = append(out, t.RMultiple())
}
return out
}
func (r *Result) Summary() Summary {
rs := r.RMultiples()
if len(rs) == 0 {
return Summary{}
}
var wins, losses []float64
for _, v := range rs {
if v > 0 {
wins = append(wins, v)
} else {
losses = append(losses, v)
}
}
m := mean(rs)
sd := stdDev(rs)
stderr := sd / math.Sqrt(float64(len(rs)))
s := Summary{
Trades: len(rs),
Expectancy: m,
StdErr: stderr,
WinRate: float64(len(wins)) / float64(len(rs)),
MaxDrawdown: maxDrawdown(r.EquityCurve),
}
if stderr > 0 {
s.TStat = m / stderr
}
return s
}Reporting the standard error and t-statistic next to expectancy is not decoration. From the other book's Chapter 4: an expectancy of +0.1R needs roughly 400 trades before it's distinguishable from zero. Printing the t-statistic beside the headline number keeps that fact in front of you every time you look at a result.
Exercises
22.1 Assemble the full program across market, strategy and backtest packages with cmd/backtest/main.go. Run it on prices.csv.
22.2 Implement MarketView and verify with a test that a strategy cannot reach data past the cursor.
22.3 Implement three cost models - zero, realistic, pessimistic - and run all three. Report the spread between them.
22.4 Implement two sizers: fixed quantity and risk-based. Compare the equity curves.
22.5 Add the parameter sweep from Chapter 20 and find the best fast/slow combination. Then read the other book's Chapter 8 and work out why that sentence should worry you.
22.6 Print a summary table: trades, expectancy, standard error, t-statistic, win rate, profit factor, max drawdown.
22.7 Harder. Extend the engine to hold several positions across symbols simultaneously, using a map from symbol to open trade.