diff --git a/backtesting.go b/backtesting.go index a8188fb..2789c1f 100644 --- a/backtesting.go +++ b/backtesting.go @@ -20,7 +20,8 @@ import ( var ( ErrEOF = errors.New("end of the input data") ErrNoData = errors.New("no data") - ErrPositionClosed = errors.New("position closed") + ErrPositionClosed = errors.New("position already closed") + ErrZeroUnits = errors.New("no amount of units specifed") ) var _ Broker = (*TestBroker)(nil) // Compile-time interface check. @@ -56,9 +57,11 @@ func Backtest(trader *Trader) { { w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0) fmt.Fprintln(w) + fmt.Fprintf(w, "Timespan:\t%s\t\n", stats.Dated.Date(-1).Sub(stats.Dated.Date(0)).Round(time.Second)) fmt.Fprintf(w, "Net Profit:\t$%.2f (%.2f%%)\t\n", profit, 100*profit/stats.Dated.Float("Equity", 0)) fmt.Fprintf(w, "Profit Factor:\t%.2f\t\n", profitFactor) fmt.Fprintf(w, "Max Drawdown:\t$%.2f (%.2f%%)\t\n", maxDrawdown, maxDrawdownPct) + fmt.Fprintf(w, "Spread collected:\t$%.2f\t\n", broker.spreadCollectedUSD) fmt.Fprintln(w) w.Flush() } @@ -328,9 +331,10 @@ type TestBroker struct { Spread float64 // Number of pips to add to the price when buying and subtract when selling. (Forex) Slippage float64 // A percentage of the price to add when buying and subtract when selling. - 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 + 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 + spreadCollectedUSD float64 // Total amount of spread collected from trades. } func NewTestBroker(dataBroker Broker, data *DataFrame, cash, leverage, spread float64, startCandles int) *TestBroker { @@ -340,25 +344,70 @@ func NewTestBroker(dataBroker Broker, data *DataFrame, cash, leverage, spread fl Cash: cash, Leverage: Max(leverage, 1), Spread: spread, - Slippage: 0.005, // Price +/- 0.5% + Slippage: 0.005, // Price +/- up to 0.5% by a random amount. candleCount: Max(startCandles, 1), } } +// SpreadCollected returns the total amount of spread collected from trades, in USD. +func (b *TestBroker) SpreadCollected() float64 { + return b.spreadCollectedUSD +} + // CandleIndex returns the index of the current candle. 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. +// strategy loop. This will also call Tick() to update orders and positions. func (b *TestBroker) Advance() { if b.candleCount < b.Data.Len() { b.candleCount++ } + b.Tick() } -// Bid returns the price a seller pays for the current candle. +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()) + for _, any_p := range b.positions { + if any_p.Closed() { + continue + } + p := any_p.(*TestPosition) + price := b.Price("", p.units < 0) // We want to buy if we are short, and vice versa. + + if p.trailingSLDist > 0 { + p.trailingSL = Max(p.trailingSL, price-p.trailingSLDist) + } + + // 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) + } + } else if p.stopLoss > 0 { + if (p.units > 0 && p.stopLoss >= low) || (p.units < 0 && p.stopLoss <= high) { + p.close(p.stopLoss, closeTypeStopLoss) + } + } else if p.trailingSL > 0 { + if (p.units > 0 && p.trailingSL >= low) || (p.units < 0 && p.trailingSL <= high) { + p.close(p.trailingSL, closeTypeTrailingStop) + } + } + } +} + +// Price returns the ask price if wantToBuy is true and the bid price if wantToBuy is false. +func (b *TestBroker) Price(symbol string, wantToBuy bool) float64 { + if wantToBuy { + return b.Ask(symbol) + } + return b.Bid(symbol) +} + +// Bid returns the price a seller receives for the current candle. func (b *TestBroker) Bid(_ string) float64 { return b.Data.Close(b.CandleIndex()) } @@ -390,6 +439,9 @@ func (b *TestBroker) Candles(symbol string, frequency string, count int) (*DataF } func (b *TestBroker) MarketOrder(symbol string, units float64, stopLoss, takeProfit float64) (Order, error) { + if units == 0 { + return nil, ErrZeroUnits + } if b.Data == nil { // The DataBroker could have data but nobody has fetched it, yet. if b.DataBroker == nil { return nil, ErrNoData @@ -400,28 +452,32 @@ func (b *TestBroker) MarketOrder(symbol string, units float64, stopLoss, takePro } } - var price float64 - if units < 0 { - price = b.Bid("") - } else { - price = b.Ask("") - } + 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 + } + order := &TestOrder{ id: strconv.Itoa(rand.Int()), leverage: b.Leverage, position: nil, price: price, symbol: symbol, - stopLoss: stopLoss, takeProfit: takeProfit, time: time.Now(), orderType: MarketOrder, units: units, } + if trailingSL > 0 { + order.trailingSL = trailingSL + } else { + order.stopLoss = stopLoss + } // Instantly fulfill the order. order.position = &TestPosition{ @@ -431,11 +487,15 @@ func (b *TestBroker) MarketOrder(symbol string, units float64, stopLoss, takePro id: strconv.Itoa(rand.Int()), leverage: b.Leverage, symbol: symbol, - stopLoss: stopLoss, takeProfit: takeProfit, time: time.Now(), units: units, } + if trailingSL > 0 { + order.position.trailingSLDist = trailingSL + } else { + order.position.stopLoss = stopLoss + } b.Cash -= order.position.EntryValue() b.orders = append(b.orders, order) @@ -493,38 +553,54 @@ func (b *TestBroker) Positions() []Position { } 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 - stopLoss float64 - takeProfit float64 - time time.Time - units float64 + broker *TestBroker + closed bool + entryPrice float64 + closePrice float64 // If zero, then position has not been closed. + closeType string // SL, TS, TP + id string + leverage float64 + symbol string + trailingSL float64 // the price of the trailing stop loss as assigned by broker Tick(). + trailingSLDist float64 // serves to calculate the trailing stop loss at the broker. + stopLoss float64 + takeProfit float64 + time time.Time + units float64 } func (p *TestPosition) Close() error { + p.close(p.broker.Price("", p.units < 0), closeTypeMarket) + return nil +} + +const ( + closeTypeMarket = "M" + closeTypeStopLoss = "SL" + closeTypeTrailingStop = "TS" + closeTypeTakeProfit = "TP" +) + +func (p *TestPosition) close(atPrice float64, closeType string) { if p.closed { - return ErrPositionClosed + return } p.closed = true - if p.units < 0 { - p.closePrice = p.broker.Ask("") // Ask because we are short so we have to buy. - } else { - p.closePrice = p.broker.Bid("") // Ask because we are long so we have to sell. - } + p.closePrice = atPrice + p.closeType = closeType p.broker.Cash += p.Value() // Return the value of the position to the broker. + p.broker.spreadCollectedUSD += p.broker.Spread * p.units p.broker.SignalEmit("PositionClosed", p) - return nil } func (p *TestPosition) Closed() bool { return p.closed } +func (p *TestPosition) CloseType() string { + return p.closeType +} + func (p *TestPosition) EntryPrice() float64 { return p.entryPrice } @@ -553,6 +629,10 @@ func (p *TestPosition) Symbol() string { return p.symbol } +func (p *TestPosition) TrailingStop() float64 { + return p.trailingSL +} + func (p *TestPosition) StopLoss() float64 { return p.stopLoss } @@ -573,13 +653,7 @@ func (p *TestPosition) Value() float64 { if p.closed { return p.closePrice * p.units } - var price float64 - if p.units < 0 { - price = p.broker.Ask("") - } else { - price = p.broker.Bid("") - } - return price * p.units + return p.broker.Price("", p.units > 0) * p.units } type TestOrder struct { @@ -588,6 +662,7 @@ type TestOrder struct { position *TestPosition price float64 symbol string + trailingSL float64 stopLoss float64 takeProfit float64 time time.Time @@ -623,6 +698,10 @@ func (o *TestOrder) Symbol() string { return o.symbol } +func (o *TestOrder) TrailingStop() float64 { + return o.trailingSL +} + func (o *TestOrder) StopLoss() float64 { return o.stopLoss } diff --git a/backtesting_test.go b/backtesting_test.go index 3c2a3fb..3b8c844 100644 --- a/backtesting_test.go +++ b/backtesting_test.go @@ -14,7 +14,7 @@ const testDataCSV = `date,open,high,low,close,volume 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 -2022-01-08,1.25,1.3,1.2,1.1,150 +2022-01-08,1.25,1.3,1.0,1.1,150 2022-01-09,1.1,1.4,1.0,1.3,220` func newTestingDataframe() *DataFrame { @@ -180,3 +180,98 @@ func TestBacktestingBrokerOrders(t *testing.T) { t.Errorf("Expected broker PL to be 2500, got %f", broker.PL()) } } + +func TestBacktestingBrokerStopLimitOrders(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) + if err != nil { + t.Fatal(err) + } + if order == nil { + t.Fatal("Order is nil") + } + if order.StopLoss() != 1.05 { + t.Errorf("Expected stop loss to be 1.1, got %f", order.StopLoss()) + } + if order.TakeProfit() != 1.25 { + t.Errorf("Expected take profit to be 1.25, got %f", order.TakeProfit()) + } + + position := order.Position() + if position == nil { + t.Fatal("Position is nil") + } + if position.StopLoss() != 1.05 { + t.Errorf("Expected stop loss to be 1.1, got %f", position.StopLoss()) + } + if position.TakeProfit() != 1.25 { + t.Errorf("Expected take profit to be 1.25, got %f", position.TakeProfit()) + } + + broker.Advance() + broker.Advance() // Now we're at the third candle which hits our take profit + + if position.Closed() != true { + t.Error("Expected position to be closed") + } + if position.ClosePrice() != 1.25 { + t.Errorf("Expected close price to be 1.25, got %f", position.ClosePrice()) + } + if !EqualApprox(position.PL(), 1000) { // (1.25-1.15) * 10_000 = 1000 + t.Errorf("Expected position PL to be 1000, got %f", position.PL()) + } + + broker.Advance() // 4th candle + + order, err = broker.MarketOrder("", 10_000, -0.2, 1.4) // Long position with trailing stop loss of 0.2. + if err != nil { + t.Fatal(err) + } + if order == nil { + t.Fatal("Order is nil") + } + if order.StopLoss() != 0 { + t.Errorf("Expected stop loss to be 0, got %f", order.StopLoss()) + } + if order.TakeProfit() != 1.4 { + t.Errorf("Expected take profit to be 1.4, got %f", order.TakeProfit()) + } + if !EqualApprox(order.TrailingStop(), 0.2) { // Orders return the distance to the trailing stop loss. + t.Errorf("Expected trailing stop to be 0.2, got %f", order.TrailingStop()) + } + + broker.Advance() // Cause the position to get updated. + position = order.Position() + if position == nil { + t.Fatal("Position is nil") + } + if position.Closed() { + t.Error("Expected position to be open") + } + if position.StopLoss() != 0 { + t.Errorf("Expected stop loss to be 0, got %f", position.StopLoss()) + } + if position.TakeProfit() != 1.4 { + t.Errorf("Expected take profit to be 1.4, got %f", position.TakeProfit()) + } + if !EqualApprox(position.TrailingStop(), 0.95) { // Positions return the actual trailing stop loss price. + t.Errorf("Expected trailing stop to be 0.95, got %f", position.TrailingStop()) + } + + for !position.Closed() { + broker.Advance() // Advance until position is closed. + } + + if !EqualApprox(position.ClosePrice(), 1.05) { + t.Errorf("Expected close price to be 1.05, got %f", position.ClosePrice()) + } + 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()) + } +} diff --git a/broker.go b/broker.go index b812177..603abe4 100644 --- a/broker.go +++ b/broker.go @@ -21,35 +21,38 @@ var ( ) type Order interface { - Cancel() error // Cancel attempts to cancel the order and returns an error if it fails. If the error is nil, the order was canceled. - 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. - 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. - TakeProfit() float64 // TakeProfit returns the take profit price of the order. - Time() time.Time // Time returns the time the order was placed. - Type() OrderType // Type returns the type of order. - Units() float64 // Units returns the number of units purchased or sold by the order. + Cancel() error // Cancel attempts to cancel the order and returns an error if it fails. If the error is nil, the order was canceled. + 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. + 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. + TrailingStop() float64 // TrailingStop returns the trailing stop loss distance of the order. + StopLoss() float64 // StopLoss returns the stop loss price of the order. + TakeProfit() float64 // TakeProfit returns the take profit price of the order. + Time() time.Time // Time returns the time the order was placed. + Type() OrderType // Type returns the type of order. + Units() float64 // Units returns the number of units purchased or sold by the order. } 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. - Symbol() string // Symbol returns the symbol name 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() 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. } // 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: @@ -57,10 +60,12 @@ type Position interface { // - PositionClosed(Position) - Emitted after a position is closed either manually or automatically. type Broker interface { Signaler - Bid(symbol string) float64 // Bid returns the sell price of the symbol. - Ask(symbol string) float64 // Ask returns the buy price of the symbol, which is typically higher than the sell price. + Price(symbol string, wantToBuy bool) float64 // Price returns the ask price if wantToBuy is true and the bid price if wantToBuy is false. + Bid(symbol string) float64 // Bid returns the sell price of the symbol. + 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) NAV() float64 // NAV returns the net asset value of the account. PL() float64 // PL returns the profit or loss of the account. diff --git a/oanda/oanda.go b/oanda/oanda.go index 407754a..4b34249 100644 --- a/oanda/oanda.go +++ b/oanda/oanda.go @@ -42,6 +42,14 @@ func NewOandaBroker(token, accountID string, practice bool) *OandaBroker { } } +// Price returns the ask price if wantToBuy is true and the bid price if wantToBuy is false. +func (b *OandaBroker) Price(symbol string, wantToBuy bool) float64 { + if wantToBuy { + return b.Ask(symbol) + } + return b.Bid(symbol) +} + func (b *OandaBroker) Bid(symbol string) float64 { return 0 }