Implemented TP, SL, TSL

This commit is contained in:
Luke I. Wilson 2023-05-19 02:22:19 -05:00
parent 5a0a4d0c33
commit 56740b5a00
4 changed files with 256 additions and 69 deletions

View File

@ -20,7 +20,8 @@ import (
var ( var (
ErrEOF = errors.New("end of the input data") ErrEOF = errors.New("end of the input data")
ErrNoData = errors.New("no 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. 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) w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0)
fmt.Fprintln(w) 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, "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, "Profit Factor:\t%.2f\t\n", profitFactor)
fmt.Fprintf(w, "Max Drawdown:\t$%.2f (%.2f%%)\t\n", maxDrawdown, maxDrawdownPct) 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) fmt.Fprintln(w)
w.Flush() w.Flush()
} }
@ -331,6 +334,7 @@ type TestBroker struct {
candleCount int // The number of candles anyone outside this broker has seen. Also equal to the number of times Candles has been called. 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 orders []Order
positions []Position positions []Position
spreadCollectedUSD float64 // Total amount of spread collected from trades.
} }
func NewTestBroker(dataBroker Broker, data *DataFrame, cash, leverage, spread float64, startCandles int) *TestBroker { 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, Cash: cash,
Leverage: Max(leverage, 1), Leverage: Max(leverage, 1),
Spread: spread, Spread: spread,
Slippage: 0.005, // Price +/- 0.5% Slippage: 0.005, // Price +/- up to 0.5% by a random amount.
candleCount: Max(startCandles, 1), 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. // CandleIndex returns the index of the current candle.
func (b *TestBroker) CandleIndex() int { func (b *TestBroker) CandleIndex() int {
return Max(b.candleCount-1, 0) 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 // 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() { func (b *TestBroker) Advance() {
if b.candleCount < b.Data.Len() { if b.candleCount < b.Data.Len() {
b.candleCount++ 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 { func (b *TestBroker) Bid(_ string) float64 {
return b.Data.Close(b.CandleIndex()) 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) { 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.Data == nil { // The DataBroker could have data but nobody has fetched it, yet.
if b.DataBroker == nil { if b.DataBroker == nil {
return nil, ErrNoData return nil, ErrNoData
@ -400,28 +452,32 @@ func (b *TestBroker) MarketOrder(symbol string, units float64, stopLoss, takePro
} }
} }
var price float64 price := b.Price("", units > 0)
if units < 0 {
price = b.Bid("")
} else {
price = b.Ask("")
}
slippage := rand.Float64() * b.Slippage * price slippage := rand.Float64() * b.Slippage * price
price += slippage - slippage/2 // Get a slippage as +/- 50% of the slippage. price += slippage - slippage/2 // Get a slippage as +/- 50% of the slippage.
var trailingSL float64
if stopLoss < 0 {
trailingSL = -stopLoss
}
order := &TestOrder{ order := &TestOrder{
id: strconv.Itoa(rand.Int()), id: strconv.Itoa(rand.Int()),
leverage: b.Leverage, leverage: b.Leverage,
position: nil, position: nil,
price: price, price: price,
symbol: symbol, symbol: symbol,
stopLoss: stopLoss,
takeProfit: takeProfit, takeProfit: takeProfit,
time: time.Now(), time: time.Now(),
orderType: MarketOrder, orderType: MarketOrder,
units: units, units: units,
} }
if trailingSL > 0 {
order.trailingSL = trailingSL
} else {
order.stopLoss = stopLoss
}
// Instantly fulfill the order. // Instantly fulfill the order.
order.position = &TestPosition{ order.position = &TestPosition{
@ -431,11 +487,15 @@ func (b *TestBroker) MarketOrder(symbol string, units float64, stopLoss, takePro
id: strconv.Itoa(rand.Int()), id: strconv.Itoa(rand.Int()),
leverage: b.Leverage, leverage: b.Leverage,
symbol: symbol, symbol: symbol,
stopLoss: stopLoss,
takeProfit: takeProfit, takeProfit: takeProfit,
time: time.Now(), time: time.Now(),
units: units, units: units,
} }
if trailingSL > 0 {
order.position.trailingSLDist = trailingSL
} else {
order.position.stopLoss = stopLoss
}
b.Cash -= order.position.EntryValue() b.Cash -= order.position.EntryValue()
b.orders = append(b.orders, order) b.orders = append(b.orders, order)
@ -497,9 +557,12 @@ type TestPosition struct {
closed bool closed bool
entryPrice float64 entryPrice float64
closePrice float64 // If zero, then position has not been closed. closePrice float64 // If zero, then position has not been closed.
closeType string // SL, TS, TP
id string id string
leverage float64 leverage float64
symbol string 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 stopLoss float64
takeProfit float64 takeProfit float64
time time.Time time time.Time
@ -507,24 +570,37 @@ type TestPosition struct {
} }
func (p *TestPosition) Close() error { 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 { if p.closed {
return ErrPositionClosed return
} }
p.closed = true p.closed = true
if p.units < 0 { p.closePrice = atPrice
p.closePrice = p.broker.Ask("") // Ask because we are short so we have to buy. p.closeType = closeType
} else {
p.closePrice = p.broker.Bid("") // Ask because we are long so we have to sell.
}
p.broker.Cash += p.Value() // Return the value of the position to the broker. 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) p.broker.SignalEmit("PositionClosed", p)
return nil
} }
func (p *TestPosition) Closed() bool { func (p *TestPosition) Closed() bool {
return p.closed return p.closed
} }
func (p *TestPosition) CloseType() string {
return p.closeType
}
func (p *TestPosition) EntryPrice() float64 { func (p *TestPosition) EntryPrice() float64 {
return p.entryPrice return p.entryPrice
} }
@ -553,6 +629,10 @@ func (p *TestPosition) Symbol() string {
return p.symbol return p.symbol
} }
func (p *TestPosition) TrailingStop() float64 {
return p.trailingSL
}
func (p *TestPosition) StopLoss() float64 { func (p *TestPosition) StopLoss() float64 {
return p.stopLoss return p.stopLoss
} }
@ -573,13 +653,7 @@ func (p *TestPosition) Value() float64 {
if p.closed { if p.closed {
return p.closePrice * p.units return p.closePrice * p.units
} }
var price float64 return p.broker.Price("", p.units > 0) * p.units
if p.units < 0 {
price = p.broker.Ask("")
} else {
price = p.broker.Bid("")
}
return price * p.units
} }
type TestOrder struct { type TestOrder struct {
@ -588,6 +662,7 @@ type TestOrder struct {
position *TestPosition position *TestPosition
price float64 price float64
symbol string symbol string
trailingSL float64
stopLoss float64 stopLoss float64
takeProfit float64 takeProfit float64
time time.Time time time.Time
@ -623,6 +698,10 @@ func (o *TestOrder) Symbol() string {
return o.symbol return o.symbol
} }
func (o *TestOrder) TrailingStop() float64 {
return o.trailingSL
}
func (o *TestOrder) StopLoss() float64 { func (o *TestOrder) StopLoss() float64 {
return o.stopLoss return o.stopLoss
} }

View File

@ -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-05,1.1,1.2,1.0,1.15,110
2022-01-06,1.15,1.2,1.1,1.2,120 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-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` 2022-01-09,1.1,1.4,1.0,1.3,220`
func newTestingDataframe() *DataFrame { func newTestingDataframe() *DataFrame {
@ -180,3 +180,98 @@ func TestBacktestingBrokerOrders(t *testing.T) {
t.Errorf("Expected broker PL to be 2500, got %f", broker.PL()) 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())
}
}

View File

@ -28,6 +28,7 @@ type Order interface {
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. 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. 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. StopLoss() float64 // StopLoss returns the stop loss price of the order.
TakeProfit() float64 // TakeProfit returns the take profit 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. Time() time.Time // Time returns the time the order was placed.
@ -38,6 +39,7 @@ type Order interface {
type Position 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. 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. 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. 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. 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. EntryValue() float64 // EntryValue returns the value of the position at the time it was opened.
@ -45,6 +47,7 @@ type Position interface {
Leverage() float64 // Leverage returns the leverage of the position. Leverage() float64 // Leverage returns the leverage of the position.
PL() float64 // PL returns the profit or loss of the position. PL() float64 // PL returns the profit or loss of the position.
Symbol() string // Symbol returns the symbol name 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. StopLoss() float64 // StopLoss returns the stop loss price of the position.
TakeProfit() float64 // TakeProfit returns the take profit 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. Time() time.Time // Time returns the time the position was opened.
@ -57,10 +60,12 @@ type Position interface {
// - PositionClosed(Position) - Emitted after a position is closed either manually or automatically. // - PositionClosed(Position) - Emitted after a position is closed either manually or automatically.
type Broker interface { type Broker interface {
Signaler Signaler
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. 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. 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 returns a dataframe of candles for the given symbol, frequency, and count by querying the broker.
Candles(symbol, frequency string, count int) (*DataFrame, error) 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) MarketOrder(symbol string, units, stopLoss, takeProfit float64) (Order, error)
NAV() float64 // NAV returns the net asset value of the account. NAV() float64 // NAV returns the net asset value of the account.
PL() float64 // PL returns the profit or loss of the account. PL() float64 // PL returns the profit or loss of the account.

View File

@ -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 { func (b *OandaBroker) Bid(symbol string) float64 {
return 0 return 0
} }