diff --git a/backtesting.go b/backtesting.go index 3ce7678..63d56e1 100644 --- a/backtesting.go +++ b/backtesting.go @@ -35,12 +35,11 @@ func Backtest(trader *Trader) { // - PositionModified(Position) - Called when a position changes. type TestBroker struct { SignalManager - DataBroker Broker - Data *DataFrame - Cash float64 - Leverage float64 - Spread float64 // Number of pips to add to the price when buying and subtract when selling. (Forex) - StartCandles int + DataBroker Broker + Data *DataFrame + Cash float64 + Leverage float64 + Spread float64 // Number of pips to add to the price when buying and subtract when selling. (Forex) candleCount int // The number of candles anyone outside this broker has seen. Also equal to the number of times Candles has been called. orders []Order @@ -48,27 +47,26 @@ type TestBroker struct { } // CandleIndex returns the index of the current candle. -func (b *TestBroker) candleIndex() int { +func (b *TestBroker) CandleIndex() int { return Max(b.candleCount-1, 0) } +// Advance advances the test broker to the next candle in the input data. This should be done at the end of the +// strategy loop. +func (b *TestBroker) Advance() { + b.candleCount++ +} + func (b *TestBroker) Candles(symbol string, frequency string, count int) (*DataFrame, error) { // Check if we reached the end of the existing data. - if b.Data != nil && b.candleCount >= b.Data.Len() { + if b.Data != nil && b.candleCount > b.Data.Len() { return b.Data.Copy(0, -1).(*DataFrame), ErrEOF } - // Catch up to the start candles. - if b.candleCount < b.StartCandles { - b.candleCount = b.StartCandles - } else { - b.candleCount++ - } - return b.candles(symbol, frequency, count) + data, err := b.candles(symbol, frequency, count) + return data, err } -// candles does the same as the public Candles except it doesn't increment b.candleCount so that it can be used -// internally to fetch candles without incrementing the count. func (b *TestBroker) candles(symbol string, frequency string, count int) (*DataFrame, error) { if b.DataBroker != nil && b.Data == nil { // Fetch a lot of candles from the broker so we don't keep asking. @@ -83,14 +81,7 @@ func (b *TestBroker) candles(symbol string, frequency string, count int) (*DataF // TODO: check if count > our rows if we are using a data broker and then fetch more data if so. - // Catch up to the start candles. - if b.candleCount < b.StartCandles { - b.candleCount = b.StartCandles - } - - // We use a Max(b.candleCount, 1) because we want to return at least 1 candle (even if b.candleCount is 0), - // which may happen if we call this function before the first call to Candles. - end := Max(b.candleCount, 1) - 1 + end := b.candleCount - 1 start := Max(Max(b.candleCount, 1)-count, 0) return b.Data.Copy(start, end).(*DataFrame), nil @@ -106,16 +97,12 @@ func (b *TestBroker) MarketOrder(symbol string, units float64, stopLoss, takePro return nil, err } } - price := b.Data.Close(b.candleIndex()) // Get the last close price. - - // Instantly fulfill the order. - b.Cash -= price * units * LeverageToMargin(b.Leverage) - position := &TestPosition{} + price := b.Data.Close(b.CandleIndex()) // Get the last close price. order := &TestOrder{ id: strconv.Itoa(rand.Int()), leverage: b.Leverage, - position: position, + position: nil, price: price, symbol: symbol, stopLoss: stopLoss, @@ -125,15 +112,37 @@ func (b *TestBroker) MarketOrder(symbol string, units float64, stopLoss, takePro units: units, } + // Instantly fulfill the order. + order.position = &TestPosition{ + broker: b, + closed: false, + entryPrice: price, + id: strconv.Itoa(rand.Int()), + leverage: b.Leverage, + symbol: symbol, + stopLoss: stopLoss, + takeProfit: takeProfit, + time: time.Now(), + units: units, + } + b.Cash -= order.position.EntryValue() + b.orders = append(b.orders, order) - b.positions = append(b.positions, position) + b.positions = append(b.positions, order.position) b.SignalEmit("OrderPlaced", order) return order, nil } func (b *TestBroker) NAV() float64 { - return b.Cash + nav := b.Cash + // Add the value of open positions to our NAV. + for _, position := range b.positions { + if !position.Closed() { + nav += position.Value() + } + } + return nav } func (b *TestBroker) Orders() []Order { @@ -146,12 +155,12 @@ func (b *TestBroker) Positions() []Position { func NewTestBroker(dataBroker Broker, data *DataFrame, cash, leverage, spread float64, startCandles int) *TestBroker { return &TestBroker{ - DataBroker: dataBroker, - Data: data, - Cash: cash, - Leverage: Max(leverage, 1), - Spread: spread, - StartCandles: Max(startCandles-1, 0), + DataBroker: dataBroker, + Data: data, + Cash: cash, + Leverage: Max(leverage, 1), + Spread: spread, + candleCount: Max(startCandles, 1), } } @@ -159,6 +168,7 @@ type TestPosition struct { broker *TestBroker closed bool entryPrice float64 + closePrice float64 // If zero, then position has not been closed. id string leverage float64 symbol string @@ -173,6 +183,8 @@ func (p *TestPosition) Close() error { return ErrPositionClosed } p.closed = true + p.closePrice = p.broker.Data.Close(p.broker.CandleIndex()) - p.broker.Spread // Get the last close price. + p.broker.Cash += p.Value() // Return the value of the position to the broker. return nil } @@ -184,6 +196,14 @@ func (p *TestPosition) EntryPrice() float64 { return p.entryPrice } +func (p *TestPosition) ClosePrice() float64 { + return p.closePrice +} + +func (p *TestPosition) EntryValue() float64 { + return p.entryPrice * p.units +} + func (p *TestPosition) Id() string { return p.id } @@ -193,10 +213,7 @@ func (p *TestPosition) Leverage() float64 { } func (p *TestPosition) PL() float64 { - price := p.broker.Data.Close(p.broker.candleIndex()) + p.broker.Spread - priceDiff := price - p.entryPrice - units := priceDiff * p.units * LeverageToMargin(p.leverage) - return units * price + return p.Value() - p.EntryValue() } func (p *TestPosition) Symbol() string { @@ -219,6 +236,14 @@ func (p *TestPosition) Units() float64 { return p.units } +func (p *TestPosition) Value() float64 { + if p.closed { + return p.closePrice * p.units + } + bid := p.broker.Data.Close(p.broker.CandleIndex()) - p.broker.Spread + return bid * p.units +} + type TestOrder struct { id string leverage float64 diff --git a/backtesting_test.go b/backtesting_test.go index 255e192..22466f8 100644 --- a/backtesting_test.go +++ b/backtesting_test.go @@ -1,6 +1,7 @@ package autotrader import ( + "math" "strings" "testing" "time" @@ -49,6 +50,7 @@ func TestBacktestingBrokerCandles(t *testing.T) { t.Errorf("Expected first candle to be 2022-01-01, got %s", candles.Date(0)) } + broker.Advance() candles, err = broker.Candles("EUR_USD", "D", 3) if err != nil { t.Fatal(err) @@ -60,10 +62,11 @@ func TestBacktestingBrokerCandles(t *testing.T) { t.Errorf("Expected second candle to be 2022-01-02, got %s", candles.Date(1)) } - for i := 0; i < 7; i++ { // 7 because we want to call broker.Candles 9 times total + for i := 0; i < 7; i++ { // 6 because we want to call broker.Candles 9 times total + broker.Advance() candles, err = broker.Candles("EUR_USD", "D", 5) if err != nil { - t.Fatalf("Got an error on iteration %d: %v", i, err) + t.Fatalf("Got an error on iteration %d: %v (called Candles %d times)", i, err, 2+i+1) } if candles == nil { t.Errorf("Candles is nil on iteration %d", i+1) @@ -88,6 +91,7 @@ func TestBacktestingBrokerFunctions(t *testing.T) { func TestBacktestingBrokerOrders(t *testing.T) { data := newTestingDataframe() broker := NewTestBroker(nil, data, 100_000, 50, 0, 0) + timeBeforeOrder := time.Now() order, err := broker.MarketOrder("EUR_USD", 50_000, 0, 0) // Buy 50,000 USD for 1000 EUR with no stop loss or take profit if err != nil { @@ -124,4 +128,52 @@ func TestBacktestingBrokerOrders(t *testing.T) { if order.Type() != MarketOrder { t.Errorf("Expected order type to be MarketOrder, got %s", order.Type()) } + + position := order.Position() + if position == nil { + t.Fatal("Position is nil") + } + if position.Symbol() != "EUR_USD" { + t.Errorf("Expected symbol to be EUR_USD, got %s", position.Symbol()) + } + if position.Units() != 50_000 { + t.Errorf("Expected units to be 50_000, got %f", position.Units()) + } + if position.EntryPrice() != 1.15 { + t.Errorf("Expected entry price to be 1.15 (first close), got %f", position.EntryPrice()) + } + if position.Time().Before(timeBeforeOrder) { + t.Error("Expected position time to be after timeBeforeOrder") + } + if position.Leverage() != 50 { + t.Errorf("Expected leverage to be 50, got %f", position.Leverage()) + } + if position.StopLoss() != 0 { + t.Errorf("Expected stop loss to be 0, got %f", position.StopLoss()) + } + if position.TakeProfit() != 0 { + t.Errorf("Expected take profit to be 0, got %f", position.TakeProfit()) + } + + if broker.NAV() != 100_000 { // NAV should not change until the next candle + t.Errorf("Expected NAV to be 100_000, got %f", broker.NAV()) + } + + broker.Advance() // Advance broker to the next candle + if math.Round(position.PL()) != 2500 { // (1.2-1.15) * 50_000 = 2500 + t.Errorf("Expected position PL to be 2500, got %f", position.PL()) + } + if math.Round(broker.NAV()) != 102_500 { + t.Errorf("Expected NAV to be 102_500, got %f", broker.NAV()) + } + + // Test closing positions. + position.Close() + if position.Closed() != true { + t.Error("Expected position to be closed") + } + broker.Advance() + if broker.NAV() != 102_500 { + t.Errorf("Expected NAV to still be 102_500, got %f", broker.NAV()) + } } diff --git a/broker.go b/broker.go index cacc061..3b704f9 100644 --- a/broker.go +++ b/broker.go @@ -38,7 +38,9 @@ type Order interface { type Position interface { Close() error // Close attempts to close the position and returns an error if it fails. If the error is nil, the position was closed. Closed() bool // Closed returns true if the position has been closed with the broker. + ClosePrice() float64 // ClosePrice returns the price of the symbol at the time the position was closed. May be zero if the position is still open. EntryPrice() float64 // EntryPrice returns the price of the symbol at the time the position was opened. + EntryValue() float64 // EntryValue returns the value of the position at the time it was opened. Id() string // Id returns the unique identifier of the position by the broker. Leverage() float64 // Leverage returns the leverage of the position. PL() float64 // PL returns the profit or loss of the position. @@ -47,6 +49,7 @@ type Position interface { TakeProfit() float64 // TakeProfit returns the take profit price of the position. Time() time.Time // Time returns the time the position was opened. Units() float64 // Units returns the number of units purchased or sold by the position. + Value() float64 // Value returns the value of the position at the current price. } type Broker interface {