diff --git a/.vscode/launch.json b/.vscode/launch.json index e9824e2..e09b495 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,6 +10,7 @@ "request": "launch", "mode": "test", "program": "${fileDirname}", + "envFile": "${workspaceFolder}/.env", "cwd": "${workspaceFolder}" }, { @@ -18,6 +19,7 @@ "request": "launch", "mode": "auto", "program": "${workspaceFolder}/cmd/sma_crossover.go", + "envFile": "${workspaceFolder}/.env", "cwd": "${workspaceFolder}" }, { @@ -26,6 +28,16 @@ "request": "launch", "mode": "auto", "program": "${workspaceFolder}/cmd/ichimoku.go", + "envFile": "${workspaceFolder}/.env", + "cwd": "${workspaceFolder}" + }, + { + "name": "Run Spread Example", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/cmd/spread_example.go", + "envFile": "${workspaceFolder}/.env", "cwd": "${workspaceFolder}" } ] diff --git a/backtesting.go b/backtesting.go index f470f0a..ba9420c 100644 --- a/backtesting.go +++ b/backtesting.go @@ -22,7 +22,7 @@ var ( ErrEOF = errors.New("end of the input data") ErrNoData = errors.New("no data") ErrPositionClosed = errors.New("position already closed") - ErrZeroUnits = errors.New("no amount of units specifed") + ErrInvalidUnits = errors.New("the units provided failed to meet the criteria") ) var _ Broker = (*TestBroker)(nil) // Compile-time interface check. @@ -452,14 +452,22 @@ func (b *TestBroker) Price(symbol string, wantToBuy bool) float64 { return b.Bid(symbol) } +func (b *TestBroker) lastClose() float64 { + if b.CandleIndex() < b.Data.Len() { + return b.Data.Close(b.CandleIndex()) + } else { + return b.Data.Close(-1) // If we are at end of data, then grab the last candlestick + } +} + // Bid returns the price a seller receives for the current candle. func (b *TestBroker) Bid(_ string) float64 { - return b.Data.Close(b.CandleIndex()) + return b.lastClose() } // Ask returns the price a buyer pays for the current candle. func (b *TestBroker) Ask(_ string) float64 { - return b.Data.Close(b.CandleIndex()) + b.Spread + return b.lastClose() + b.Spread } // Candles returns the last count candles for the given symbol and frequency. If count is greater than the number of candles, then a dataframe with zero rows is returned. @@ -485,7 +493,7 @@ func (b *TestBroker) Candles(symbol string, frequency string, count int) (*Index func (b *TestBroker) Order(orderType OrderType, symbol string, units, price, stopLoss, takeProfit float64) (Order, error) { if units == 0 { - return nil, ErrZeroUnits + return nil, ErrInvalidUnits } if b.Data == nil { // The DataBroker could have data but nobody has fetched it, yet. if b.DataBroker == nil { @@ -537,7 +545,7 @@ func (b *TestBroker) Order(orderType OrderType, symbol string, units, price, sto } b.orders = append(b.orders, order) - b.SignalEmit("OrderPlaced", order) + b.SignalEmit(OrderPlaced, order) return order, nil } @@ -598,16 +606,16 @@ type TestPosition struct { 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. + 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 + units float64 // Is negative if this is a short position or positive for long. } func (p *TestPosition) Close() error { - p.close(p.broker.Price("", p.units < 0), CloseMarket) + p.close(p.broker.Price(p.symbol, p.units < 0), CloseMarket) return nil } @@ -620,7 +628,7 @@ func (p *TestPosition) close(atPrice float64, closeType OrderCloseType) { p.closeType = closeType p.broker.Cash += p.Value() // Return the value of the position to the broker. p.broker.spreadCollectedUSD += p.broker.Spread * math.Abs(p.units) * p.closePrice - p.broker.SignalEmit("PositionClosed", p) + p.broker.SignalEmit(PositionClosed, p) } func (p *TestPosition) Closed() bool { @@ -707,7 +715,7 @@ func (o *TestOrder) Cancel() error { func (o *TestOrder) fulfill(atPrice float64) { slippage := rand.Float64() * o.broker.Slippage * atPrice - atPrice += slippage - slippage/2 // Adjust price as +/- 50% of the slippage. + atPrice += slippage / 2 // Adjust price as +/- 50% of the slippage. o.position = &TestPosition{ broker: o.broker, @@ -725,9 +733,11 @@ func (o *TestOrder) fulfill(atPrice float64) { } else { o.position.stopLoss = o.stopLoss } + // TODO: cash should be a function because position values change over time and you will pay for losses in realtime o.broker.Cash -= o.position.EntryValue() o.broker.positions = append(o.broker.positions, o.position) + o.broker.SignalEmit(OrderFulfilled, o) } func (o *TestOrder) Fulfilled() bool { diff --git a/broker.go b/broker.go index bb0db14..a6e202d 100644 --- a/broker.go +++ b/broker.go @@ -12,14 +12,20 @@ const ( CloseStopLoss OrderCloseType = "SL" CloseTrailingStop OrderCloseType = "TS" CloseTakeProfit OrderCloseType = "TP" + + OrderPlaced = "OrderPlaced" + OrderCancelled = "OrderCancelled" + OrderFulfilled = "OrderFulfilled" + + PositionClosed = "PositionClosed" ) type OrderType string const ( - Market OrderType = "MARKET" // Market means to buy or sell at the current market price, which may not be what you ask for. + Market OrderType = "MARKET" // Market means to buy or sell at the current market price, which may not always be what you expect. 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. + Stop OrderType = "STOP" // Stop means to buy or sell when the price reaches a specific price or ASAP. ) var ( diff --git a/cmd/sma_crossover.go b/cmd/sma_crossover.go index be8d016..b232cbe 100644 --- a/cmd/sma_crossover.go +++ b/cmd/sma_crossover.go @@ -20,10 +20,13 @@ func (s *SMAStrategy) Init(_ *auto.Trader) { func (s *SMAStrategy) Next(t *auto.Trader) { sma1 := t.Data().Closes().Copy().Rolling(s.period1).Mean() sma2 := t.Data().Closes().Copy().Rolling(s.period2).Mean() - // If the shorter SMA crosses above the longer SMA, buy. + + // If the shorter SMA (sma1) crosses above the longer SMA (sma2), buy. if auto.CrossoverIndex(*t.Data().Date(-1), sma1, sma2) { + t.CloseOrdersAndPositions() t.Buy(1000, 0, 0) } else if auto.CrossoverIndex(*t.Data().Date(-1), sma2, sma1) { + t.CloseOrdersAndPositions() t.Sell(1000, 0, 0) } } @@ -41,8 +44,8 @@ func main() { } auto.Backtest(auto.NewTrader(auto.TraderConfig{ - Broker: auto.NewTestBroker(broker /* data, */, nil, 10000, 50, 0.0002, 0), - Strategy: &SMAStrategy{period1: 10, period2: 25}, + Broker: auto.NewTestBroker(broker, nil, 10000, 50, 0.0002, 0), + Strategy: &SMAStrategy{period1: 7, period2: 20}, Symbol: "EUR_USD", Frequency: "M15", CandlesToKeep: 2500, diff --git a/cmd/spread_example.go b/cmd/spread_example.go new file mode 100644 index 0000000..a0307d9 --- /dev/null +++ b/cmd/spread_example.go @@ -0,0 +1,49 @@ +//go:build ignore + +package main + +import ( + "fmt" + "os" + + auto "github.com/fivemoreminix/autotrader" + "github.com/fivemoreminix/autotrader/oanda" +) + +type SpreadPayupStrategy struct { +} + +func (s *SpreadPayupStrategy) Init(t *auto.Trader) { + t.Broker.SignalConnect(auto.OrderFulfilled, s, func(a ...any) { + order := a[0].(auto.Order) + order.Position().Close() // Immediately close the position so we only pay spread. + }) +} + +func (s *SpreadPayupStrategy) Next(t *auto.Trader) { + _, err := t.Sell(1000, 0, 0) + if err != nil { + panic(err) + } +} + +func main() { + /* data, err := auto.EURUSD() + if err != nil { + panic(err) + } + */ + broker, err := oanda.NewOandaBroker(os.Getenv("OANDA_TOKEN"), os.Getenv("OANDA_ACCOUNT_ID"), true) + if err != nil { + fmt.Println("error:", err) + return + } + + auto.Backtest(auto.NewTrader(auto.TraderConfig{ + Broker: auto.NewTestBroker(broker, nil, 10000, 50, 0.0002, 0), + Strategy: &SpreadPayupStrategy{}, + Symbol: "EUR_USD", + Frequency: "M15", + CandlesToKeep: 1000, + })) +} diff --git a/trader.go b/trader.go index 2a0d1ab..f1df2be 100644 --- a/trader.go +++ b/trader.go @@ -100,8 +100,15 @@ func (t *Trader) Init() { NewSeries("Trades"), // []float64 representing the number of units traded positive for buy, negative for sell. ) t.stats.tradesThisCandle = make([]TradeStat, 0, 2) + t.Broker.SignalConnect(OrderFulfilled, t, func(a ...any) { + order := a[0].(Order) + tradeStat := TradeStat{order.Position().EntryPrice(), order.Units(), false} + t.stats.tradesThisCandle = append(t.stats.tradesThisCandle, tradeStat) + }) t.Broker.SignalConnect("PositionClosed", t, func(args ...any) { position := args[0].(Position) + tradeStat := TradeStat{position.ClosePrice(), position.Units(), true} + t.stats.tradesThisCandle = append(t.stats.tradesThisCandle, tradeStat) t.stats.returnsThisCandle += position.PL() }) } @@ -162,22 +169,38 @@ func (t *Trader) fetchData() { } } -func (t *Trader) Buy(units, stopLoss, takeProfit float64) { - t.CloseOrdersAndPositions() - t.Log.Printf("Buy %v units", units) - t.Broker.Order(Market, t.Symbol, units, 0, stopLoss, takeProfit) +func (t *Trader) Order(orderType OrderType, units, price, stopLoss, takeProfit float64) (Order, error) { + var priceStr string + if orderType != Market { // Price is ignored on market orders. + priceStr = fmt.Sprintf(" @ $%.2f", price) + } else { + priceStr = fmt.Sprintf(" @ ~$%.2f", t.Broker.Price(t.Symbol, units > 0)) + } + t.Log.Printf("%v %v units%v, stopLoss: %v, takeProfit: %v", orderType, units, priceStr, stopLoss, takeProfit) - tradeStat := TradeStat{t.Broker.Ask(t.Symbol), units, false} - t.stats.tradesThisCandle = append(t.stats.tradesThisCandle, tradeStat) + order, err := t.Broker.Order(orderType, t.Symbol, units, price, stopLoss, takeProfit) + if err != nil { + return order, err + } + + // NOTE: Trade stats get added by handling an event by the broker + return order, nil } -func (t *Trader) Sell(units, stopLoss, takeProfit float64) { - t.CloseOrdersAndPositions() - t.Log.Printf("Sell %v units", units) - t.Broker.Order(Market, t.Symbol, -units, 0, stopLoss, takeProfit) +// Buy creates a buy market order. Units must be greater than zero or ErrInvalidUnits is returned. +func (t *Trader) Buy(units, stopLoss, takeProfit float64) (Order, error) { + if units <= 0 { + return nil, ErrInvalidUnits + } + return t.Order(Market, units, 0, stopLoss, takeProfit) +} - tradeStat := TradeStat{t.Broker.Bid(t.Symbol), units, false} - t.stats.tradesThisCandle = append(t.stats.tradesThisCandle, tradeStat) +// Sell creates a sell market order. Units must be greater than zero or ErrInvalidUnits is returned. +func (t *Trader) Sell(units, stopLoss, takeProfit float64) (Order, error) { + if units <= 0 { + return nil, ErrInvalidUnits + } + return t.Order(Market, -units, 0, stopLoss, takeProfit) } func (t *Trader) CloseOrdersAndPositions() { @@ -189,18 +212,15 @@ func (t *Trader) CloseOrdersAndPositions() { } for _, position := range t.Broker.OpenPositions() { if position.Symbol() == t.Symbol { - t.Log.Printf("Closing position: %v units, $%.2f PL", position.Units(), position.PL()) - position.Close() - - tradeStat := TradeStat{t.Broker.Price(t.Symbol, position.Units() < 0), position.Units(), true} - t.stats.tradesThisCandle = append(t.stats.tradesThisCandle, tradeStat) + t.Log.Printf("Closing position: %v units, $%.2f PL, ($%.2f -> $%.2f)", position.Units(), position.PL(), position.EntryPrice(), position.ClosePrice()) + position.Close() // Event gets handled in the Init function } } } func (t *Trader) IsLong() bool { positions := t.Broker.OpenPositions() - if len(positions) <= 0 { + if len(positions) < 1 { return false } else if len(positions) > 1 { panic("cannot call IsLong with hedging enabled") @@ -210,7 +230,7 @@ func (t *Trader) IsLong() bool { func (t *Trader) IsShort() bool { positions := t.Broker.OpenPositions() - if len(positions) <= 0 { + if len(positions) < 1 { return false } else if len(positions) > 1 { panic("cannot call IsShort with hedging enabled") diff --git a/utils.go b/utils.go index 39ae4e8..a0f135b 100644 --- a/utils.go +++ b/utils.go @@ -18,6 +18,7 @@ func Crossover(a, b *Series) bool { return a.Float(-1) > b.Float(-1) && a.Float(-2) <= b.Float(-2) } +// CrossoverIndex is similar to Crossover, except that it works for IndexedSeries. func CrossoverIndex[I Index](index I, a, b *IndexedSeries[I]) bool { aRow, bRow := a.Row(index), b.Row(index) if aRow < 1 || bRow < 1 {