Making trade charting more reliable and informative

This commit is contained in:
Luke Wilson 2024-11-04 12:14:56 -06:00
parent d755f07d38
commit f1c45dbb74
7 changed files with 136 additions and 35 deletions

12
.vscode/launch.json vendored
View File

@ -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}"
}
]

View File

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

View File

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

View File

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

49
cmd/spread_example.go Normal file
View File

@ -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,
}))
}

View File

@ -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")

View File

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