{adamcoding}
Part V
22
Chapter 22

Building the Backtester

Everything now assembles. The pieces:

FromWhat it gives you
Ch 2money in integers, floats compared with tolerance
Ch 6errors as values, guard clauses
Ch 7-8slices, ring buffers, O(1) indicators
Ch 9maps for positions by symbol
Ch 10-11Bar, Trade, pointer receivers
Ch 12sorting trades, binary search by timestamp
Ch 14Strategy, CostModel, Sizer interfaces
Ch 15package layout
Ch 16CSV loading
Ch 17known-answer tests
Ch 18UTC everywhere
Ch 20parallel 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.

go
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:

go
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

go
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.