From ef9659b450e92377376c9db683e930c33b762bba Mon Sep 17 00:00:00 2001 From: "Luke I. Wilson" Date: Wed, 17 May 2023 12:02:16 -0500 Subject: [PATCH] Display stats on returns --- backtesting.go | 96 +++++++++++++++++++++++++++++++++++++++----- broker.go | 4 ++ cmd/sma_crossover.go | 9 +---- data.go | 13 +++++- trader.go | 35 ++++++++++++---- utils.go | 5 +++ 6 files changed, 136 insertions(+), 26 deletions(-) diff --git a/backtesting.go b/backtesting.go index 13688f2..5894e6e 100644 --- a/backtesting.go +++ b/backtesting.go @@ -12,6 +12,7 @@ import ( "github.com/go-echarts/go-echarts/v2/components" "github.com/go-echarts/go-echarts/v2/opts" "golang.org/x/exp/rand" + "golang.org/x/exp/slices" ) var ( @@ -23,6 +24,7 @@ var ( func Backtest(trader *Trader) { switch broker := trader.Broker.(type) { case *TestBroker: + rand.Seed(uint64(time.Now().UnixNano())) trader.Init() // Initialize the trader and strategy. start := time.Now() for !trader.EOF { @@ -30,20 +32,81 @@ func Backtest(trader *Trader) { broker.Advance() // Give the trader access to the next candlestick. } log.Println("Backtest complete. Opening report...") + stats := trader.Stats() page := components.NewPage() - // Create a new line chart based on account equity and add it to the page. - chart := charts.NewLine() - chart.SetGlobalOptions(charts.WithTitleOpts(opts.Title{ - Title: fmt.Sprintf("Backtest (%s)", time.Now().Format(time.DateTime)), - Subtitle: fmt.Sprintf("%s %s %T (took %.2f seconds)", trader.Symbol, trader.Frequency, trader.Strategy, time.Since(start).Seconds()), + // Create a new line balChart based on account equity and add it to the page. + balChart := charts.NewLine() + balChart.SetGlobalOptions(charts.WithTitleOpts(opts.Title{ + Title: "Balance", + Subtitle: fmt.Sprintf("%s %s %T (took %.2f seconds) %s", trader.Symbol, trader.Frequency, trader.Strategy, time.Since(start).Seconds(), time.Now().Format(time.DateTime)), })) - chart.SetXAxis(seriesStringArray(trader.Stats().Dates())). - AddSeries("Equity", lineDataFromSeries(trader.Stats().Series("Equity"))). - AddSeries("Drawdown", lineDataFromSeries(trader.Stats().Series("Drawdown"))) + balChart.SetXAxis(seriesStringArray(stats.Dated.Dates())). + AddSeries("Equity", lineDataFromSeries(stats.Dated.Series("Equity"))). + AddSeries("Drawdown", lineDataFromSeries(stats.Dated.Series("Drawdown"))) - page.AddCharts(chart) + // Sort Returns by value. + // Plot returns as a bar chart. + returnsSeries := stats.Dated.Series("Returns") + returns := make([]float64, 0, returnsSeries.Len()) + // returns := stats.Dated.Series("Returns").Values() + // Remove nil values. + for i := 0; i < returnsSeries.Len(); i++ { + r := returnsSeries.Value(i) + if r != nil { + returns = append(returns, r.(float64)) + } + } + // Sort the returns. + slices.Sort(returns) + // Create the X axis labels for the returns chart based on length of the returns slice. + returnsLabels := make([]int, len(returns)) + for i := range returns { + returnsLabels[i] = i + 1 + } + returnsBars := make([]opts.BarData, len(returns)) + for i, r := range returns { + returnsBars[i] = opts.BarData{Value: r} + if r < 0 { + log.Println("Negative return:", r, "at index", i) + } + } + var avg float64 + for _, r := range returns { + avg += r + } + avg /= float64(len(returns)) + returnsAverage := make([]opts.LineData, len(returns)) + for i := range returnsAverage { + returnsAverage[i] = opts.LineData{Value: avg} + } + + returnsChart := charts.NewBar() + returnsChart.SetGlobalOptions(charts.WithTitleOpts(opts.Title{ + Title: "Returns", + Subtitle: fmt.Sprintf("Average: $%.2f", avg), + })) + returnsChart.SetXAxis(returnsLabels). + AddSeries("Returns", returnsBars) + + returnsChartAvg := charts.NewLine() + returnsChartAvg.SetGlobalOptions(charts.WithTitleOpts(opts.Title{ + Title: "Average Returns", + })) + returnsChartAvg.SetXAxis(returnsLabels). + AddSeries("Average", returnsAverage, func(s *charts.SingleSeries) { + s.LineStyle = &opts.LineStyle{ + Width: 2, + } + }) + returnsChart.Overlap(returnsChartAvg) + + // TODO: Use Radar to display performance metrics. + + // Add all the charts in the desired order. + page.PageTitle = "Backtest Report" + page.AddCharts(balChart, returnsChart) // Draw the page to a file. f, err := os.Create("backtest.html") @@ -62,7 +125,21 @@ func Backtest(trader *Trader) { } } +func barDataFromSeries(s Series) []opts.BarData { + if s == nil || s.Len() == 0 { + return []opts.BarData{} + } + data := make([]opts.BarData, s.Len()) + for i := 0; i < s.Len(); i++ { + data[i] = opts.BarData{Value: s.Value(i)} + } + return data +} + func lineDataFromSeries(s Series) []opts.LineData { + if s == nil || s.Len() == 0 { + return []opts.LineData{} + } data := make([]opts.LineData, s.Len()) for i := 0; i < s.Len(); i++ { data[i] = opts.LineData{Value: s.Value(i)} @@ -280,6 +357,7 @@ func (p *TestPosition) Close() error { p.closed = true p.closePrice = p.broker.Data.Close(p.broker.CandleIndex()) - p.broker.Spread // Get the last close price. p.broker.Cash += p.Value() // Return the value of the position to the broker. + p.broker.SignalEmit("PositionClosed", p) return nil } diff --git a/broker.go b/broker.go index 2ff7f99..f60e1cc 100644 --- a/broker.go +++ b/broker.go @@ -52,7 +52,11 @@ type Position interface { 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: +// +// - PositionClosed(Position) - Emitted after a position is closed either manually or automatically. type Broker interface { + Signaler // Candles returns a dataframe of candles for the given symbol, frequency, and count by querying the broker. Candles(symbol string, frequency string, count int) (*DataFrame, error) MarketOrder(symbol string, units float64, stopLoss, takeProfit float64) (Order, error) diff --git a/cmd/sma_crossover.go b/cmd/sma_crossover.go index bf2bf2b..b3125ad 100644 --- a/cmd/sma_crossover.go +++ b/cmd/sma_crossover.go @@ -15,18 +15,13 @@ func (s *SMAStrategy) Next(t *auto.Trader) { sma1 := t.Data().Closes().Rolling(s.period1).Mean() sma2 := t.Data().Closes().Rolling(s.period2).Mean() // If the shorter SMA crosses above the longer SMA, buy. - if crossover(sma1, sma2) { + if auto.Crossover(sma1, sma2) { t.Buy(1000) - } else if crossover(sma2, sma1) { + } else if auto.Crossover(sma2, sma1) { t.Sell(1000) } } -// crossover returns true if s1 crosses above s2 at the latest float. -func crossover(s1, s2 auto.Series) bool { - return s1.Float(-1) > s2.Float(-1) && s1.Float(-2) <= s2.Float(-2) -} - func main() { data, err := auto.EURUSD() if err != nil { diff --git a/data.go b/data.go index 20dea8b..8ed7ad7 100644 --- a/data.go +++ b/data.go @@ -35,6 +35,7 @@ type Series interface { // Writing data. SetName(name string) Series + SetValue(i int, val interface{}) Series Push(val interface{}) Series // Statistical functions. @@ -321,6 +322,13 @@ func (s *DataSeries) Push(value interface{}) Series { return s } +func (s *DataSeries) SetValue(i int, val interface{}) Series { + if s.data != nil { + s.data.Update(EasyIndex(i, s.Len()), val) + } + return s +} + func (s *DataSeries) Value(i int) interface{} { if s.data == nil { return nil @@ -335,10 +343,11 @@ func (s *DataSeries) ValueRange(start, end int) []interface{} { return nil } start = EasyIndex(start, s.Len()) + if end < 0 { + end = s.Len() - 1 + } if start < 0 || start >= s.Len() || end >= s.Len() || start > end { return nil - } else if end < 0 { - end = s.Len() - 1 } items := make([]interface{}, end-start+1) diff --git a/trader.go b/trader.go index d13aad0..a34323d 100644 --- a/trader.go +++ b/trader.go @@ -12,6 +12,12 @@ import ( "github.com/rocketlaunchr/dataframe-go" ) +// Performance (financial) reporting and statistics. +type TraderStats struct { + Dated *DataFrame + returnsThisCandle float64 +} + // Trader acts as the primary interface to the broker and strategy. To the strategy, it provides all the information // about the current state of the market and the portfolio. To the broker, it provides the orders to be executed and // requests for the current state of the portfolio. @@ -26,14 +32,14 @@ type Trader struct { data *DataFrame sched *gocron.Scheduler - stats *DataFrame // Performance (financial) reporting and statistics. + stats *TraderStats } func (t *Trader) Data() *DataFrame { return t.data } -func (t *Trader) Stats() *DataFrame { +func (t *Trader) Stats() *TraderStats { return t.stats } @@ -79,11 +85,16 @@ func (t *Trader) Run() { func (t *Trader) Init() { t.Strategy.Init(t) - t.stats = NewDataFrame( + t.stats.Dated = NewDataFrame( NewDataSeries(dataframe.NewSeriesTime("Date", nil)), NewDataSeries(dataframe.NewSeriesFloat64("Equity", nil)), NewDataSeries(dataframe.NewSeriesFloat64("Drawdown", nil)), + NewDataSeries(dataframe.NewSeriesFloat64("Returns", nil)), ) + t.Broker.SignalConnect("PositionClosed", func(args ...interface{}) { + position := args[0].(Position) + t.stats.returnsThisCandle += position.PL() + }) } // Tick updates the current state of the market and runs the strategy. @@ -93,19 +104,27 @@ func (t *Trader) Tick() { t.Strategy.Next(t) // Run the strategy. // Update the stats. - t.stats.PushValues(map[string]interface{}{ + t.stats.Dated.PushValues(map[string]interface{}{ "Date": t.data.Date(-1), "Equity": t.Broker.NAV(), "Drawdown": func() float64 { var bal float64 - if t.stats.Len() > 0 { - bal = t.stats.Float("Equity", 0) // Take starting balance + if t.stats.Dated.Len() > 0 { + bal = t.stats.Dated.Float("Equity", 0) // Take starting balance } else { - bal = t.Broker.NAV() + bal = t.Broker.NAV() // Take current balance for first value } return Max(bal-t.Broker.NAV(), 0) }(), + "Returns": func() interface{} { + if t.stats.returnsThisCandle != 0 { + return t.stats.returnsThisCandle + } else { + return nil + } + }(), }) + t.stats.returnsThisCandle = 0 } func (t *Trader) fetchData() { @@ -165,6 +184,6 @@ func NewTrader(config TraderConfig) *Trader { Frequency: config.Frequency, CandlesToKeep: config.CandlesToKeep, Log: logger, - stats: NewDataFrame(), + stats: &TraderStats{}, } } diff --git a/utils.go b/utils.go index 591de1c..01ff29a 100644 --- a/utils.go +++ b/utils.go @@ -9,6 +9,11 @@ import ( const floatComparisonTolerance = float64(1e-6) +// Crossover returns true if the latest a value crosses above the latest b value, but only if it just happened. For example, if a series is [1, 2, 3, 4, 5] and b series is [1, 2, 3, 4, 3], then Crossover(a, b) returns false because the latest a value is 5 and the latest b value is 3. However, if a series is [1, 2, 3, 4, 5] and b series is [1, 2, 3, 4, 6], then Crossover(a, b) returns true because the latest a value is 5 and the latest b value is 6 +func Crossover(a, b Series) bool { + return a.Float(-1) > b.Float(-1) && a.Float(-2) <= b.Float(-2) +} + // EasyIndex returns an index to the `n` -length object that allows for negative indexing. For example, EasyIndex(-1, 5) returns 4. This is similar to Python's negative indexing. The return value may be less than zero if (-i) > n. func EasyIndex(i, n int) int { if i < 0 {