From d851061d1f77e9bb31cbb4037406385b8934c26b Mon Sep 17 00:00:00 2001 From: "Luke I. Wilson" Date: Fri, 19 May 2023 13:17:08 -0500 Subject: [PATCH] Implemented stop and limit orders --- backtesting.go | 118 ++++++++++++++++++++++++++--------------- backtesting_test.go | 125 ++++++++++++++++++++++++++++++++++++++------ broker.go | 51 ++++++++++-------- oanda/oanda.go | 2 +- trader.go | 4 +- 5 files changed, 218 insertions(+), 82 deletions(-) diff --git a/backtesting.go b/backtesting.go index 2789c1f..0b58e5b 100644 --- a/backtesting.go +++ b/backtesting.go @@ -371,6 +371,28 @@ func (b *TestBroker) Advance() { func (b *TestBroker) Tick() { // Check if the current candle's high and lows contain any take profits or stop losses. high, low := b.Data.High(b.CandleIndex()), b.Data.Low(b.CandleIndex()) + + // Update orders. + for _, any_o := range b.orders { + if any_o.Fulfilled() { + continue + } + o := any_o.(*TestOrder) + + if o.orderType == Limit { + if o.price >= low && o.price <= high { + o.fulfill(o.price) + } + } else if o.orderType == Stop { + if o.price <= high && o.price >= low { + o.fulfill(o.price) + } + } else { + panic("the order type is either unknown or otherwise should not be market because those are fulfilled immediately") + } + } + + // Update positions. for _, any_p := range b.positions { if any_p.Closed() { continue @@ -385,15 +407,18 @@ func (b *TestBroker) Tick() { // Check if the position should be closed. if p.takeProfit > 0 { if (p.units > 0 && p.takeProfit <= high) || (p.units < 0 && p.takeProfit >= low) { - p.close(p.takeProfit, closeTypeTakeProfit) + p.close(p.takeProfit, CloseTakeProfit) + continue } - } else if p.stopLoss > 0 { + } + // stopLoss won't be set if trailingSL is set, and vice versa. + if p.stopLoss > 0 { if (p.units > 0 && p.stopLoss >= low) || (p.units < 0 && p.stopLoss <= high) { - p.close(p.stopLoss, closeTypeStopLoss) + p.close(p.stopLoss, CloseStopLoss) } } else if p.trailingSL > 0 { if (p.units > 0 && p.trailingSL >= low) || (p.units < 0 && p.trailingSL <= high) { - p.close(p.trailingSL, closeTypeTrailingStop) + p.close(p.trailingSL, CloseTrailingStop) } } } @@ -438,7 +463,7 @@ func (b *TestBroker) Candles(symbol string, frequency string, count int) (*DataF return b.Data.Copy(start, adjCount).(*DataFrame), nil } -func (b *TestBroker) MarketOrder(symbol string, units float64, stopLoss, takeProfit float64) (Order, error) { +func (b *TestBroker) Order(orderType OrderType, symbol string, units, price, stopLoss, takeProfit float64) (Order, error) { if units == 0 { return nil, ErrZeroUnits } @@ -452,17 +477,18 @@ func (b *TestBroker) MarketOrder(symbol string, units float64, stopLoss, takePro } } - price := b.Price("", units > 0) - - slippage := rand.Float64() * b.Slippage * price - price += slippage - slippage/2 // Get a slippage as +/- 50% of the slippage. - var trailingSL float64 if stopLoss < 0 { trailingSL = -stopLoss } + marketPrice := b.Price("", units > 0) + if orderType == Market { + price = marketPrice + } + order := &TestOrder{ + broker: b, id: strconv.Itoa(rand.Int()), leverage: b.Leverage, position: nil, @@ -470,7 +496,7 @@ func (b *TestBroker) MarketOrder(symbol string, units float64, stopLoss, takePro symbol: symbol, takeProfit: takeProfit, time: time.Now(), - orderType: MarketOrder, + orderType: orderType, units: units, } if trailingSL > 0 { @@ -479,27 +505,18 @@ func (b *TestBroker) MarketOrder(symbol string, units float64, stopLoss, takePro order.stopLoss = stopLoss } - // Instantly fulfill the order. - order.position = &TestPosition{ - broker: b, - closed: false, - entryPrice: price, - id: strconv.Itoa(rand.Int()), - leverage: b.Leverage, - symbol: symbol, - takeProfit: takeProfit, - time: time.Now(), - units: units, + // TODO: only instantly fulfill market orders or sometimes limit orders when requirements are met. + if orderType == Market { + order.fulfill(price) + } else if orderType == Limit { + if units > 0 && marketPrice <= order.price { + order.fulfill(price) + } else if units < 0 && marketPrice >= order.price { + order.fulfill(price) + } } - if trailingSL > 0 { - order.position.trailingSLDist = trailingSL - } else { - order.position.stopLoss = stopLoss - } - b.Cash -= order.position.EntryValue() b.orders = append(b.orders, order) - b.positions = append(b.positions, order.position) b.SignalEmit("OrderPlaced", order) return order, nil @@ -556,8 +573,8 @@ type TestPosition struct { broker *TestBroker closed bool entryPrice float64 - closePrice float64 // If zero, then position has not been closed. - closeType string // SL, TS, TP + closePrice float64 // If zero, then position has not been closed. + closeType OrderCloseType // SL, TS, TP id string leverage float64 symbol string @@ -570,18 +587,11 @@ type TestPosition struct { } func (p *TestPosition) Close() error { - p.close(p.broker.Price("", p.units < 0), closeTypeMarket) + p.close(p.broker.Price("", p.units < 0), CloseMarket) return nil } -const ( - closeTypeMarket = "M" - closeTypeStopLoss = "SL" - closeTypeTrailingStop = "TS" - closeTypeTakeProfit = "TP" -) - -func (p *TestPosition) close(atPrice float64, closeType string) { +func (p *TestPosition) close(atPrice float64, closeType OrderCloseType) { if p.closed { return } @@ -597,7 +607,7 @@ func (p *TestPosition) Closed() bool { return p.closed } -func (p *TestPosition) CloseType() string { +func (p *TestPosition) CloseType() OrderCloseType { return p.closeType } @@ -657,6 +667,7 @@ func (p *TestPosition) Value() float64 { } type TestOrder struct { + broker *TestBroker id string leverage float64 position *TestPosition @@ -674,6 +685,31 @@ func (o *TestOrder) Cancel() error { return ErrCancelFailed } +func (o *TestOrder) fulfill(atPrice float64) { + slippage := rand.Float64() * o.broker.Slippage * atPrice + atPrice += slippage - slippage/2 // Adjust price as +/- 50% of the slippage. + + o.position = &TestPosition{ + broker: o.broker, + closed: false, + entryPrice: atPrice, + id: strconv.Itoa(rand.Int()), + leverage: o.leverage, + symbol: o.symbol, + takeProfit: o.takeProfit, + time: time.Now(), + units: o.units, + } + if o.trailingSL > 0 { + o.position.trailingSLDist = o.trailingSL + } else { + o.position.stopLoss = o.stopLoss + } + o.broker.Cash -= o.position.EntryValue() + + o.broker.positions = append(o.broker.positions, o.position) +} + func (o *TestOrder) Fulfilled() bool { return o.position != nil } diff --git a/backtesting_test.go b/backtesting_test.go index 3b8c844..2db4f88 100644 --- a/backtesting_test.go +++ b/backtesting_test.go @@ -10,7 +10,7 @@ const testDataCSV = `date,open,high,low,close,volume 2022-01-01,1.1,1.2,1.0,1.15,100 2022-01-02,1.15,1.2,1.1,1.2,110 2022-01-03,1.2,1.3,1.15,1.25,120 -2022-01-04,1.25,1.3,1.2,1.1,130 +2022-01-04,1.25,1.3,1.0,1.1,130 2022-01-05,1.1,1.2,1.0,1.15,110 2022-01-06,1.15,1.2,1.1,1.2,120 2022-01-07,1.2,1.3,1.15,1.25,140 @@ -79,21 +79,13 @@ func TestBacktestingBrokerCandles(t *testing.T) { } } -func TestBacktestingBrokerFunctions(t *testing.T) { - broker := NewTestBroker(nil, nil, 100_000, 20, 0, 0) - - if !EqualApprox(broker.NAV(), 100_000) { - t.Errorf("Expected NAV to be 100_000, got %f", broker.NAV()) - } -} - -func TestBacktestingBrokerOrders(t *testing.T) { +func TestBacktestingBrokerMarketOrders(t *testing.T) { data := newTestingDataframe() broker := NewTestBroker(nil, data, 100_000, 50, 0, 0) broker.Slippage = 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 + order, err := broker.Order(Market, "EUR_USD", 50_000, 0, 0, 0) // Buy 50,000 USD for 1000 EUR with no stop loss or take profit if err != nil { t.Fatal(err) } @@ -125,7 +117,7 @@ func TestBacktestingBrokerOrders(t *testing.T) { if order.TakeProfit() != 0 { t.Errorf("Expected take profit to be 0, got %f", order.TakeProfit()) } - if order.Type() != MarketOrder { + if order.Type() != Market { t.Errorf("Expected order type to be MarketOrder, got %s", order.Type()) } @@ -181,12 +173,111 @@ func TestBacktestingBrokerOrders(t *testing.T) { } } -func TestBacktestingBrokerStopLimitOrders(t *testing.T) { +func TestBacktestingBrokerLimitOrders(t *testing.T) { data := newTestingDataframe() broker := NewTestBroker(nil, data, 100_000, 50, 0, 0) broker.Slippage = 0 - order, err := broker.MarketOrder("", 10_000, 1.05, 1.25) + order, err := broker.Order(Limit, "EUR_USD", -50_000, 1.3, 1.35, 1.1) // Sell limit 50,000 USD for 1000 EUR + if err != nil { + t.Fatal(err) + } + if order == nil { + t.Fatal("Order is nil") + } + if order.Price() != 1.3 { + t.Errorf("Expected order price to be 1.3, got %f", order.Price()) + } + if order.Fulfilled() != false { + t.Error("Expected order to not be fulfilled") + } + + broker.Advance() + broker.Advance() // Advance to the third candle where the order should be fulfilled + + if order.Fulfilled() != true { + t.Error("Expected order to be fulfilled") + } + + position := order.Position() + if position == nil { + t.Fatal("Position is nil") + } + if position.Closed() != false { + t.Fatal("Expected position to not be closed") + } + + broker.Advance() // Advance to the fourth candle which should hit our take profit + + if position.Closed() != true { + t.Fatal("Expected position to be closed") + } + if position.ClosePrice() != 1.1 { + t.Errorf("Expected position close price to be 1.1, got %f", position.ClosePrice()) + } + if position.CloseType() != CloseTakeProfit { + t.Errorf("Expected position close type to be TP, got %s", position.CloseType()) + } + if !EqualApprox(position.PL(), 10_000) { // abs(1.1-1.3) * 50_000 = 10,000 + t.Errorf("Expected position PL to be 10000, got %f", position.PL()) + } +} + +func TestBacktestingBrokerStopOrders(t *testing.T) { + data := newTestingDataframe() + broker := NewTestBroker(nil, data, 100_000, 50, 0, 0) + broker.Slippage = 0 + + order, err := broker.Order(Stop, "EUR_USD", 50_000, 1.2, 1, 1.3) // Buy stop 50,000 EUR for 1000 USD + if err != nil { + t.Fatal(err) + } + if order == nil { + t.Fatal("Order is nil") + } + if order.Price() != 1.2 { + t.Errorf("Expected order price to be 1.2, got %f", order.Price()) + } + if order.Fulfilled() != false { + t.Error("Expected order to not be fulfilled") + } + + broker.Advance() // Advance to the second candle where the order should be fulfilled + + if order.Fulfilled() != true { + t.Error("Expected order to be fulfilled") + } + + position := order.Position() + if position == nil { + t.Fatal("Position is nil") + } + if position.Closed() != false { + t.Fatal("Expected position to not be closed") + } + + broker.Advance() // Advance to the third candle which should hit our take profit + + if position.Closed() != true { + t.Fatal("Expected position to be closed") + } + if position.ClosePrice() != 1.3 { + t.Errorf("Expected position close price to be 1.3, got %f", position.ClosePrice()) + } + if position.CloseType() != CloseTakeProfit { + t.Errorf("Expected position close type to be TP, got %s", position.CloseType()) + } + if !EqualApprox(position.PL(), 5000) { // (1.3-1.2) * 50_000 = 5000 + t.Errorf("Expected position PL to be 5000, got %f", position.PL()) + } +} + +func TestBacktestingBrokerStopLossTakeProfit(t *testing.T) { + data := newTestingDataframe() + broker := NewTestBroker(nil, data, 100_000, 50, 0, 0) + broker.Slippage = 0 + + order, err := broker.Order(Market, "", 10_000, 0, 1.05, 1.25) if err != nil { t.Fatal(err) } @@ -226,7 +317,7 @@ func TestBacktestingBrokerStopLimitOrders(t *testing.T) { broker.Advance() // 4th candle - order, err = broker.MarketOrder("", 10_000, -0.2, 1.4) // Long position with trailing stop loss of 0.2. + order, err = broker.Order(Market, "", 10_000, 0, -0.2, 1.4) // Long position with trailing stop loss of 0.2. if err != nil { t.Fatal(err) } @@ -271,7 +362,7 @@ func TestBacktestingBrokerStopLimitOrders(t *testing.T) { if !EqualApprox(position.PL(), -500) { // (1.05-1.1) * 10_000 = -500 t.Errorf("Expected position PL to be 1000, got %f", position.PL()) } - if position.CloseType() != closeTypeTrailingStop { - t.Errorf("Expected close type to be %q, got %q", closeTypeTrailingStop, position.CloseType()) + if position.CloseType() != CloseTrailingStop { + t.Errorf("Expected close type to be %q, got %q", CloseTrailingStop, position.CloseType()) } } diff --git a/broker.go b/broker.go index 603abe4..82e703d 100644 --- a/broker.go +++ b/broker.go @@ -5,12 +5,21 @@ import ( "time" ) +type OrderCloseType string + +const ( + CloseMarket OrderCloseType = "M" + CloseStopLoss OrderCloseType = "SL" + CloseTrailingStop OrderCloseType = "TS" + CloseTakeProfit OrderCloseType = "TP" +) + type OrderType string const ( - MarketOrder OrderType = "MARKET" - LimitOrder OrderType = "LIMIT" - StopOrder OrderType = "STOP" + Market OrderType = "MARKET" // Market means to buy or sell at the current market price, which may not be what you ask for. + Limit OrderType = "LIMIT" // Limit means to buy or sell at a specific price or better. + Stop OrderType = "STOP" // Stop means to buy or sell when the price reaches a specific price or worse. ) var ( @@ -37,22 +46,22 @@ 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. - CloseType() string // CloseType returns the type of order used to close the position. - 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. - Symbol() string // Symbol returns the symbol name of the position. - TrailingStop() float64 // TrailingStop returns the trailing stop loss price of the position. - StopLoss() float64 // StopLoss returns the stop loss price of the position. - 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. + 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. + CloseType() OrderCloseType // CloseType returns the type of order used to close the position. + 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. + Symbol() string // Symbol returns the symbol name of the position. + TrailingStop() float64 // TrailingStop returns the trailing stop loss price of the position. + StopLoss() float64 // StopLoss returns the stop loss price of the position. + 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. } // Broker is an interface that defines the methods that a broker must implement to report symbol data and place orders, etc. All Broker implementations must also implement the Signaler interface and emit the following functions when necessary: @@ -65,8 +74,8 @@ type Broker interface { Ask(symbol string) float64 // Ask returns the buy price of the symbol, which is typically higher than the sell price. // Candles returns a dataframe of candles for the given symbol, frequency, and count by querying the broker. Candles(symbol, frequency string, count int) (*DataFrame, error) - // MarketOrder places a market order for the given symbol and returns an error if it fails. A short position has negative units. If stopLoss or takeProfit are zero, they will not be set. If stopLoss is greater than the current price for a long position or less than the current price for a short position, the order will fail. Likewise for takeProfit. If the stopLoss is a negative number, it is used as a trailing stop loss to represent how many price points away the stop loss should be from the current price. - MarketOrder(symbol string, units, stopLoss, takeProfit float64) (Order, error) + // Order places an order with orderType for the given symbol and returns an error if it fails. A short position has negative units. If the orderType is Market, the price argument will be ignored and the order will be fulfilled at current price. Otherwise, price is used to set the target price for Stop and Limit orders. If stopLoss or takeProfit are zero, they will not be set. If the stopLoss is greater than the current price for a long position or less than the current price for a short position, the order will fail. Likewise for takeProfit. If the stopLoss is a negative number, it is used as a trailing stop loss to represent how many price points away the stop loss should be from the current price. + Order(orderType OrderType, symbol string, units, price, stopLoss, takeProfit float64) (Order, error) NAV() float64 // NAV returns the net asset value of the account. PL() float64 // PL returns the profit or loss of the account. OpenOrders() []Order diff --git a/oanda/oanda.go b/oanda/oanda.go index 4b34249..252b891 100644 --- a/oanda/oanda.go +++ b/oanda/oanda.go @@ -82,7 +82,7 @@ func (b *OandaBroker) Candles(symbol, frequency string, count int) (*auto.DataFr return newDataframe(candlestickResponse) } -func (b *OandaBroker) MarketOrder(symbol string, units, stopLoss, takeProfit float64) (auto.Order, error) { +func (b *OandaBroker) Order(orderType auto.OrderType, symbol string, units, price, stopLoss, takeProfit float64) (auto.Order, error) { return nil, nil } diff --git a/trader.go b/trader.go index 77b6345..57d39ad 100644 --- a/trader.go +++ b/trader.go @@ -165,14 +165,14 @@ func (t *Trader) fetchData() { func (t *Trader) Buy(units float64) { t.closeOrdersAndPositions() t.Log.Printf("Buy %f units", units) - t.Broker.MarketOrder(t.Symbol, units, 0.0, 0.0) + t.Broker.Order(Market, t.Symbol, units, 0, 0, 0) t.stats.tradesThisCandle = append(t.stats.tradesThisCandle, TradeStat{units, false}) } func (t *Trader) Sell(units float64) { t.closeOrdersAndPositions() t.Log.Printf("Sell %f units", units) - t.Broker.MarketOrder(t.Symbol, -units, 0.0, 0.0) + t.Broker.Order(Market, t.Symbol, -units, 0, 0, 0) t.stats.tradesThisCandle = append(t.stats.tradesThisCandle, TradeStat{-units, false}) }