diff --git a/backtesting.go b/backtesting.go index cea1881..db8c57b 100644 --- a/backtesting.go +++ b/backtesting.go @@ -15,7 +15,9 @@ var ( ) func Backtest(trader *Trader) { - trader.Tick() + for !trader.EOF { + trader.Tick() + } } // TestBroker is a broker that can be used for testing. It implements the Broker interface and fulfills orders @@ -41,10 +43,15 @@ type TestBroker struct { positions []Position } +// CandleIndex returns the index of the current candle. +func (b *TestBroker) candleIndex() int { + return Max(b.candleCount-1, 0) +} + func (b *TestBroker) Candles(symbol string, frequency string, count int) (*DataFrame, error) { // Check if we reached the end of the existing data. if b.Data != nil && b.candleCount >= b.Data.Len() { - return b.Data.Copy(0, -1), ErrEOF + return b.Data.Copy(0, -1).(*DataFrame), ErrEOF } // Catch up to the start candles. @@ -82,7 +89,7 @@ func (b *TestBroker) candles(symbol string, frequency string, count int) (*DataF end := Max(b.candleCount, 1) - 1 start := Max(Max(b.candleCount, 1)-count, 0) - return b.Data.Copy(start, end), nil + return b.Data.Copy(start, end).(*DataFrame), nil } func (b *TestBroker) MarketOrder(symbol string, units float64, stopLoss, takeProfit float64) (Order, error) { @@ -95,7 +102,7 @@ func (b *TestBroker) MarketOrder(symbol string, units float64, stopLoss, takePro return nil, err } } - price := b.Data.Close(Max(b.candleCount-1, 0)) // Get the last close price. + price := b.Data.Close(b.candleIndex()) // Get the last close price. // Instantly fulfill the order. b.Cash -= price * units * LeverageToMargin(b.Leverage) @@ -145,6 +152,7 @@ func NewTestBroker(dataBroker Broker, data *DataFrame, cash, leverage, spread fl } type TestPosition struct { + broker *TestBroker closed bool entryPrice float64 id string @@ -181,7 +189,10 @@ func (p *TestPosition) Leverage() float64 { } func (p *TestPosition) PL() float64 { - return 0 + price := p.broker.Data.Close(p.broker.candleIndex()) + p.broker.Spread + priceDiff := price - p.entryPrice + units := priceDiff * p.units * LeverageToMargin(p.leverage) + return units * price } func (p *TestPosition) Symbol() string { diff --git a/data.go b/data.go index 53c2e54..5cbfea9 100644 --- a/data.go +++ b/data.go @@ -23,14 +23,18 @@ func EasyIndex(i, n int) int { } type Series interface { - Copy() Series + Copy(start, end int) Series Len() int // Statistical functions. Rolling(period int) *RollingSeries + Push(val interface{}) Series + // Data access functions. Value(i int) interface{} + ValueRange(start, end int) []interface{} + Values() []interface{} // Values is the same as ValueRange(0, -1). Float(i int) float64 Int(i int) int64 String(i int) string @@ -38,7 +42,7 @@ type Series interface { } type Frame interface { - Copy() Frame + Copy(start, end int) Frame Len() int // Easy access functions. @@ -55,7 +59,9 @@ type Frame interface { Closes() Series Volumes() Series - // Custom data columns + PushCandle(date time.Time, open, high, low, close, volume float64) Frame + // AddSeries(name string, s Series) error + Series(name string) Series Value(column string, i int) interface{} Float(column string, i int) float64 @@ -253,7 +259,7 @@ type DataFrame struct { } // Copy copies the DataFrame from start to end (inclusive). If end is -1, it will copy to the end of the DataFrame. If start is out of bounds, nil is returned. -func (o *DataFrame) Copy(start, end int) *DataFrame { +func (o *DataFrame) Copy(start, end int) Frame { var _end *int if start < 0 || start >= o.Len() { return nil @@ -340,6 +346,22 @@ func (o *DataFrame) Volumes() Series { return o.Series("Volume") } +func (o *DataFrame) PushCandle(date time.Time, open, high, low, close, volume float64) Frame { + if o.data == nil { + o.data = df.NewDataFrame([]df.Series{ + df.NewSeriesTime("Date", nil, date), + df.NewSeriesFloat64("Open", nil, open), + df.NewSeriesFloat64("High", nil, high), + df.NewSeriesFloat64("Low", nil, low), + df.NewSeriesFloat64("Close", nil, close), + df.NewSeriesFloat64("Volume", nil, volume), + }...) + return o + } + o.data.Append(nil, date, open, high, low, close, volume) + return o +} + // Series returns a Series of the column with the given name. If the column does not exist, nil is returned. func (o *DataFrame) Series(name string) Series { if o.data == nil { @@ -380,13 +402,13 @@ func (o *DataFrame) Float(column string, i int) float64 { } // Int returns the value of the column at index i casted to int. The first value is at index 0. A negative value for i (-n) can be used to get n values from the latest, like Python's negative indexing. If i is out of bounds, 0 is returned. -func (o *DataFrame) Int(column string, i int) int { +func (o *DataFrame) Int(column string, i int) int64 { val := o.Value(column, i) if val == nil { return 0 } switch val := val.(type) { - case int: + case int64: return val default: return 0 @@ -425,8 +447,18 @@ func NewDataFrame(data *df.DataFrame) *DataFrame { return &DataFrame{data} } -func (s *DataSeries) Copy() Series { - return &DataSeries{s.data.Copy()} +// Copy copies the Series from start to end (inclusive). If end is -1, it will copy to the end of the Series. If start is out of bounds, nil is returned. +func (s *DataSeries) Copy(start, end int) Series { + var _end *int + if start < 0 || start >= s.Len() { + return nil + } else if end >= 0 { + if end < start { + return nil + } + _end = &end + } + return &DataSeries{s.data.Copy(df.Range{Start: &start, End: _end})} } func (s *DataSeries) Len() int { @@ -440,6 +472,13 @@ func (s *DataSeries) Rolling(period int) *RollingSeries { return &RollingSeries{s, period} } +func (s *DataSeries) Push(value interface{}) Series { + if s.data != nil { + s.data.Append(value) + } + return s +} + func (s *DataSeries) Value(i int) interface{} { if s.data == nil { return nil @@ -448,6 +487,34 @@ func (s *DataSeries) Value(i int) interface{} { return s.data.Value(i) } +// ValueRange returns a slice of values from start to end, including start and end. The first value is at index 0. A negative value for start or end can be used to get values from the latest, like Python's negative indexing. If end is less than zero, it will be sliced from start to the last item. If start or end is out of bounds, nil is returned. If start is greater than end, nil is returned. +func (s *DataSeries) ValueRange(start, end int) []interface{} { + if s.data == nil { + return nil + } + start = EasyIndex(start, s.Len()) + if start < 0 || start >= s.Len() || end >= s.Len() { + return nil + } else if end < 0 { + end = s.Len() - 1 + } else if start > end { + return nil + } + + items := make([]interface{}, end-start+1) + for i := start; i <= end; i++ { + items[i-start] = s.Value(i) + } + return items +} + +func (s *DataSeries) Values() []interface{} { + if s.data == nil { + return nil + } + return s.ValueRange(0, -1) +} + func (s *DataSeries) Float(i int) float64 { val := s.Value(i) if val == nil { diff --git a/data_test.go b/data_test.go index d3baa32..fa243dd 100644 --- a/data_test.go +++ b/data_test.go @@ -54,6 +54,14 @@ func TestDataFrame(t *testing.T) { if date.Year() != 2013 || date.Month() != 5 || date.Day() != 13 { t.Fatalf("Expected 2013-05-13, got %s", date.Format(time.DateOnly)) } + + data.PushCandle(time.Date(2023, 5, 14, 0, 0, 0, 0, time.UTC), 1.0, 1.0, 1.0, 1.0, 1) + if data.Len() != 2611 { + t.Fatalf("Expected 2611 rows, got %d", data.Len()) + } + if data.Close(-1) != 1.0 { + t.Fatalf("Expected latest close to be 1.0, got %f", data.Close(-1)) + } } func TestReadDataCSV(t *testing.T) { diff --git a/trader.go b/trader.go index 632436f..aa65094 100644 --- a/trader.go +++ b/trader.go @@ -21,16 +21,22 @@ type Trader struct { Frequency string CandlesToKeep int Log *log.Logger + EOF bool data *DataFrame sched *gocron.Scheduler idx int + stats *DataFrame // Performance (financial) reporting and statistics. } func (t *Trader) Data() *DataFrame { return t.data } +func (t *Trader) Stats() *DataFrame { + return t.stats +} + // Run starts the trader. This is a blocking call. func (t *Trader) Run() { t.sched = gocron.NewScheduler(time.UTC) @@ -71,18 +77,21 @@ func (t *Trader) Run() { // Tick updates the current state of the market and runs the strategy. func (t *Trader) Tick() { + t.Log.Println("Tick") if t.idx == 0 { t.Strategy.Init(t) } t.fetchData() t.Strategy.Next(t) - t.Log.Println("Tick") } func (t *Trader) fetchData() { var err error t.data, err = t.Broker.Candles(t.Symbol, t.Frequency, t.CandlesToKeep) - if err != nil { + if err == ErrEOF { + t.Log.Println("End of data") + t.sched.Clear() + } else if err != nil { panic(err) // TODO: implement safe shutdown procedure } } @@ -105,5 +114,6 @@ func NewTrader(config TraderConfig) *Trader { Frequency: config.Frequency, CandlesToKeep: config.CandlesToKeep, Log: logger, + stats: NewDataFrame(nil), } }