From 4a12b939925579fb636a821c297f408e64c907cf Mon Sep 17 00:00:00 2001 From: "Luke I. Wilson" Date: Sat, 13 May 2023 09:09:11 -0500 Subject: [PATCH] Implemented backtesting orders --- backtesting.go | 161 +++++++++++++++++++++++++++++++++++++++---- backtesting_test.go | 33 ++++----- broker.go | 5 +- cmd/sma_crossover.go | 2 +- utils.go | 8 +++ 5 files changed, 177 insertions(+), 32 deletions(-) diff --git a/backtesting.go b/backtesting.go index f547a67..cb9f71b 100644 --- a/backtesting.go +++ b/backtesting.go @@ -2,11 +2,17 @@ package autotrader import ( "errors" + "strconv" + "time" df "github.com/rocketlaunchr/dataframe-go" + "golang.org/x/exp/rand" ) -var ErrNoData = errors.New("no data") +var ( + ErrEOF = errors.New("end of the input data") + ErrNoData = errors.New("no data") +) func Backtest(trader *Trader) { trader.Tick() @@ -17,11 +23,32 @@ type TestBroker struct { Data *df.DataFrame Cash float64 Leverage float64 + Spread float64 // Number of pips to add to the price when buying and subtract when selling. (Forex) StartCandles int - candles int + + 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 + positions []Position } func (b *TestBroker) Candles(symbol string, frequency string, count int) (*df.DataFrame, error) { + // Check if we reached the end of the existing data. + if b.Data != nil && b.candleCount >= b.Data.NRows() { + return b.Data.Copy(), 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) +} + +// 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) (*df.DataFrame, error) { if b.DataBroker != nil && b.Data == nil { // Fetch a lot of candles from the broker so we don't keep asking. candles, err := b.DataBroker.Candles(symbol, frequency, Max(count, 1000)) @@ -33,37 +60,143 @@ func (b *TestBroker) Candles(symbol string, frequency string, count int) (*df.Da return nil, ErrNoData } - // Check if we reached the end of the existing data. - if b.candles >= b.Data.NRows() { - return nil, nil - } + // 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.candles < b.StartCandles { - b.candles = b.StartCandles - } else { - b.candles++ + if b.candleCount < b.StartCandles { + b.candleCount = b.StartCandles } - end := b.candles - 1 - start := Max(b.candles-count, 0) + + // 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 + start := Max(Max(b.candleCount, 1)-count, 0) return b.Data.Copy(df.Range{Start: &start, End: &end}), nil } func (b *TestBroker) MarketOrder(symbol string, units float64, stopLoss, takeProfit float64) (Order, error) { - return nil, nil + if b.Data == nil { // The dataBroker could have data but nobody has called Candles, yet. + if b.DataBroker == nil { + return nil, ErrNoData + } + _, err := b.candles("", "", 1) // Fetch 1 candle. + if err != nil { + return nil, err + } + } + closeIdx, err := b.Data.NameToColumn("Close") + if err != nil { + return nil, err + } + price := b.Data.Series[closeIdx].Value(Max(b.candleCount-1, 0)).(float64) // Get the last close price. + + // Instantly fulfill the order. + b.Cash -= price * units * LeverageToMargin(b.Leverage) + position := &TestPosition{} + + order := &TestOrder{ + id: strconv.Itoa(rand.Int()), + leverage: b.Leverage, + position: position, + price: price, + symbol: symbol, + stopLoss: stopLoss, + takeProfit: takeProfit, + time: time.Now(), + orderType: MarketOrder, + units: units, + } + + b.orders = append(b.orders, order) + b.positions = append(b.positions, position) + + return order, nil } func (b *TestBroker) NAV() float64 { return b.Cash } -func NewTestBroker(dataBroker Broker, data *df.DataFrame, cash, leverage float64, startCandles int) *TestBroker { +func (b *TestBroker) Orders() []Order { + return b.orders +} + +func (b *TestBroker) Positions() []Position { + return b.positions +} + +func NewTestBroker(dataBroker Broker, data *df.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), } } + +type TestPosition struct { +} + +type TestOrder struct { + id string + leverage float64 + position *TestPosition + price float64 + symbol string + stopLoss float64 + takeProfit float64 + time time.Time + orderType OrderType + units float64 +} + +func (o *TestOrder) Cancel() error { + return ErrCancelFailed +} + +func (o *TestOrder) Fulfilled() bool { + return o.position != nil +} + +func (o *TestOrder) Id() string { + return o.id +} + +func (o *TestOrder) Leverage() float64 { + return o.leverage +} + +func (o *TestOrder) Position() Position { + return o.position +} + +func (o *TestOrder) Price() float64 { + return o.price +} + +func (o *TestOrder) Symbol() string { + return o.symbol +} + +func (o *TestOrder) StopLoss() float64 { + return o.stopLoss +} + +func (o *TestOrder) TakeProfit() float64 { + return o.takeProfit +} + +func (o *TestOrder) Time() time.Time { + return o.time +} + +func (o *TestOrder) Type() OrderType { + return o.orderType +} + +func (o *TestOrder) Units() float64 { + return o.units +} diff --git a/backtesting_test.go b/backtesting_test.go index 425f1df..468090e 100644 --- a/backtesting_test.go +++ b/backtesting_test.go @@ -21,13 +21,14 @@ const testDataCSV = `date,open,high,low,close,volume func newTestingDataframe() *df.DataFrame { data, err := ReadDataCSVFromReader(strings.NewReader(testDataCSV), DataCSVLayout{ - DateFormat: "2006-01-02", - Date: "date", - Open: "open", - High: "high", - Low: "low", - Close: "close", - Volume: "volume", + LatestFirst: false, + DateFormat: "2006-01-02", + Date: "date", + Open: "open", + High: "high", + Low: "low", + Close: "close", + Volume: "volume", }) if err != nil { panic(err) @@ -37,7 +38,7 @@ func newTestingDataframe() *df.DataFrame { func TestBacktestingBrokerCandles(t *testing.T) { data := newTestingDataframe() - broker := NewTestBroker(nil, data, 0, 0, 0) + broker := NewTestBroker(nil, data, 0, 0, 0, 0) candles, err := broker.Candles("EUR_USD", "D", 3) if err != nil { @@ -79,17 +80,17 @@ func TestBacktestingBrokerCandles(t *testing.T) { } func TestBacktestingBrokerFunctions(t *testing.T) { - broker := NewTestBroker(nil, nil, 100000, 20, 0) + broker := NewTestBroker(nil, nil, 100_000, 20, 0, 0) - if broker.NAV() != 100000 { - t.Errorf("Expected NAV to be 100000, got %f", broker.NAV()) + if broker.NAV() != 100_000 { + t.Errorf("Expected NAV to be 100_000, got %f", broker.NAV()) } } func TestBacktestingBrokerOrders(t *testing.T) { - broker := NewTestBroker(nil, newTestingDataframe(), 100000, 50, 0) + broker := NewTestBroker(nil, newTestingDataframe(), 100_000, 50, 0, 0) timeBeforeOrder := time.Now() - order, err := broker.MarketOrder("EUR_USD", 1000, 0, 0) // Buy 50,000 USD for 1000 EUR with no stop loss or take profit + 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 { t.Fatal(err) } @@ -100,11 +101,11 @@ func TestBacktestingBrokerOrders(t *testing.T) { if order.Symbol() != "EUR_USD" { t.Errorf("Expected symbol to be EUR_USD, got %s", order.Symbol()) } - if order.Units() != 1000 { - t.Errorf("Expected units to be 1000, got %f", order.Units()) + if order.Units() != 50_000 { + t.Errorf("Expected units to be 50_000, got %f", order.Units()) } if order.Price() != 1.15 { - t.Errorf("Expected open price to be 1.15 (first close), got %f", order.Price()) + t.Errorf("Expected order price to be 1.15 (first close), got %f", order.Price()) } if order.Fulfilled() != true { t.Error("Expected order to be fulfilled") diff --git a/broker.go b/broker.go index d64ba8a..36c3fb9 100644 --- a/broker.go +++ b/broker.go @@ -16,6 +16,7 @@ const ( ) var ( + ErrCancelFailed = errors.New("cancel failed") ErrSymbolNotFound = errors.New("symbol not found") ErrInvalidStopLoss = errors.New("invalid stop loss") ErrInvalidTakeProfit = errors.New("invalid take profit") @@ -26,7 +27,7 @@ type Order interface { Fulfilled() bool // Fulfilled returns true if the order has been filled with the broker and a position is active. Id() string // Id returns the unique identifier of the order by the broker. Leverage() float64 // Leverage returns the leverage of the order. - Position() *Position // Position returns the position of the order. If the order has not been filled, nil is returned. + Position() Position // Position returns the position of the order. If the order has not been filled, nil is returned. Price() float64 // Price returns the price of the symbol at the time the order was placed. Symbol() string // Symbol returns the symbol name of the order. StopLoss() float64 // StopLoss returns the stop loss price of the order. @@ -44,4 +45,6 @@ type Broker interface { Candles(symbol string, frequency string, count int) (*df.DataFrame, error) MarketOrder(symbol string, units float64, stopLoss, takeProfit float64) (Order, error) NAV() float64 // NAV returns the net asset value of the account. + Orders() []Order + Positions() []Position } diff --git a/cmd/sma_crossover.go b/cmd/sma_crossover.go index fc01eaf..3ce884f 100644 --- a/cmd/sma_crossover.go +++ b/cmd/sma_crossover.go @@ -49,7 +49,7 @@ func main() { // AccountID: "101-001-14983263-001", // DemoAccount: true, // }), - Broker: auto.NewTestBroker(nil, data, 10000, 50, 0), + Broker: auto.NewTestBroker(nil, data, 10000, 50, 0.0002, 0), Strategy: &SMAStrategy{}, Symbol: "EUR_USD", Frequency: "D", diff --git a/utils.go b/utils.go index 45fd1db..e47214e 100644 --- a/utils.go +++ b/utils.go @@ -15,3 +15,11 @@ func Max[T constraints.Ordered](a, b T) T { } return b } + +func LeverageToMargin(leverage float64) float64 { + return 1 / leverage +} + +func MarginToLeverage(margin float64) float64 { + return 1 / margin +}