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 (
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
}

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-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())
}
}

View File

@ -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.

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 {
return 0
}