diff --git a/backtesting.go b/backtesting.go index 7592508..3ec9320 100644 --- a/backtesting.go +++ b/backtesting.go @@ -31,7 +31,7 @@ func Backtest(trader *Trader) { trader.Tick() // Allow the trader to process the current candlesticks. broker.Advance() // Give the trader access to the next candlestick. } - log.Println("Backtest complete. Opening report...") + log.Printf("Backtest completed on %d candles. Opening report...\n", trader.Stats().Dated.Len()) stats := trader.Stats() page := components.NewPage() @@ -40,11 +40,22 @@ func Backtest(trader *Trader) { 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)), + Subtitle: fmt.Sprintf("%s %s %T %s (took %.2f seconds)", trader.Symbol, trader.Frequency, trader.Strategy, time.Now().Format(time.DateTime), time.Since(start).Seconds()), + }), charts.WithTooltipOpts(opts.Tooltip{ + Show: true, + Trigger: "axis", + TriggerOn: "mousemove|click", + }), charts.WithYAxisOpts(opts.YAxis{ + AxisLabel: &opts.AxisLabel{ + Show: true, + Formatter: "${value}", + }, })) balChart.SetXAxis(seriesStringArray(stats.Dated.Dates())). - AddSeries("Equity", lineDataFromSeries(stats.Dated.Series("Equity"))). - AddSeries("Drawdown", lineDataFromSeries(stats.Dated.Series("Drawdown"))) + AddSeries("Equity", lineDataFromSeries(stats.Dated.Series("Equity")), func(s *charts.SingleSeries) { + }). + AddSeries("Profit", lineDataFromSeries(stats.Dated.Series("Profit"))) + // AddSeries("Drawdown", lineDataFromSeries(stats.Dated.Series("Drawdown"))) // Sort Returns by value. // Plot returns as a bar chart. @@ -83,6 +94,11 @@ func Backtest(trader *Trader) { returnsChart.SetGlobalOptions(charts.WithTitleOpts(opts.Title{ Title: "Returns", Subtitle: fmt.Sprintf("Average: $%.2f", avg), + }), charts.WithYAxisOpts(opts.YAxis{ + AxisLabel: &opts.AxisLabel{ + Show: true, + Formatter: "${value}", + }, })) returnsChart.SetXAxis(returnsLabels). AddSeries("Returns", returnsBars) @@ -122,16 +138,16 @@ 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 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 { @@ -139,7 +155,7 @@ func lineDataFromSeries(s Series) []opts.LineData { } data := make([]opts.LineData, s.Len()) for i := 0; i < s.Len(); i++ { - data[i] = opts.LineData{Value: s.Value(i)} + data[i] = opts.LineData{Value: Round(s.Value(i).(float64), 2)} } return data } @@ -294,6 +310,14 @@ func (b *TestBroker) NAV() float64 { return nav } +func (b *TestBroker) PL() float64 { + var pl float64 + for _, position := range b.positions { + pl += position.PL() + } + return pl +} + func (b *TestBroker) OpenOrders() []Order { orders := make([]Order, 0, len(b.orders)) for _, order := range b.orders { diff --git a/backtesting_test.go b/backtesting_test.go index f03cb46..eedd597 100644 --- a/backtesting_test.go +++ b/backtesting_test.go @@ -1,7 +1,6 @@ package autotrader import ( - "math" "strings" "testing" "time" @@ -83,7 +82,7 @@ func TestBacktestingBrokerCandles(t *testing.T) { func TestBacktestingBrokerFunctions(t *testing.T) { broker := NewTestBroker(nil, nil, 100_000, 20, 0, 0) - if broker.NAV() != 100_000 { + if !EqualApprox(broker.NAV(), 100_000) { t.Errorf("Expected NAV to be 100_000, got %f", broker.NAV()) } } @@ -155,15 +154,15 @@ func TestBacktestingBrokerOrders(t *testing.T) { t.Errorf("Expected take profit to be 0, got %f", position.TakeProfit()) } - if broker.NAV() != 100_000 { // NAV should not change until the next candle + if !EqualApprox(broker.NAV(), 100_000) { // NAV should not change until the next candle t.Errorf("Expected NAV to be 100_000, got %f", broker.NAV()) } broker.Advance() // Advance broker to the next candle - if math.Round(position.PL()) != 2500 { // (1.2-1.15) * 50_000 = 2500 + if !EqualApprox(position.PL(), 2500) { // (1.2-1.15) * 50_000 = 2500 t.Errorf("Expected position PL to be 2500, got %f", position.PL()) } - if math.Round(broker.NAV()) != 102_500 { + if !EqualApprox(broker.NAV(), 102_500) { t.Errorf("Expected NAV to be 102_500, got %f", broker.NAV()) } @@ -173,7 +172,10 @@ func TestBacktestingBrokerOrders(t *testing.T) { t.Error("Expected position to be closed") } broker.Advance() - if broker.NAV() != 102_500 { + if !EqualApprox(broker.NAV(), 102_500) { t.Errorf("Expected NAV to still be 102_500, got %f", broker.NAV()) } + if !EqualApprox(broker.PL(), 2500) { + t.Errorf("Expected broker PL to be 2500, got %f", broker.PL()) + } } diff --git a/broker.go b/broker.go index f60e1cc..1a10e29 100644 --- a/broker.go +++ b/broker.go @@ -61,6 +61,7 @@ type Broker interface { Candles(symbol string, frequency string, count int) (*DataFrame, error) MarketOrder(symbol string, units float64, 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. OpenOrders() []Order OpenPositions() []Position // Orders returns a slice of orders that have been placed with the broker. If an order has been canceled or diff --git a/trader.go b/trader.go index a34323d..353d75e 100644 --- a/trader.go +++ b/trader.go @@ -88,6 +88,7 @@ func (t *Trader) Init() { t.stats.Dated = NewDataFrame( NewDataSeries(dataframe.NewSeriesTime("Date", nil)), NewDataSeries(dataframe.NewSeriesFloat64("Equity", nil)), + NewDataSeries(dataframe.NewSeriesFloat64("Profit", nil)), NewDataSeries(dataframe.NewSeriesFloat64("Drawdown", nil)), NewDataSeries(dataframe.NewSeriesFloat64("Returns", nil)), ) @@ -104,9 +105,10 @@ func (t *Trader) Tick() { t.Strategy.Next(t) // Run the strategy. // Update the stats. - t.stats.Dated.PushValues(map[string]interface{}{ + err := t.stats.Dated.PushValues(map[string]interface{}{ "Date": t.data.Date(-1), "Equity": t.Broker.NAV(), + "Profit": t.Broker.PL(), "Drawdown": func() float64 { var bal float64 if t.stats.Dated.Len() > 0 { @@ -124,6 +126,9 @@ func (t *Trader) Tick() { } }(), }) + if err != nil { + log.Printf("error pushing values to stats dataframe: %v\n", err.Error()) + } t.stats.returnsThisCandle = 0 } diff --git a/utils.go b/utils.go index 01ff29a..e78026a 100644 --- a/utils.go +++ b/utils.go @@ -1,13 +1,14 @@ package autotrader import ( + "math" "os/exec" "runtime" "golang.org/x/exp/constraints" ) -const floatComparisonTolerance = float64(1e-6) +const float64Tolerance = 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 { @@ -22,8 +23,28 @@ func EasyIndex(i, n int) int { return i } +// EqualApprox returns true if a and b are approximately equal. NaN and Inf are handled correctly. The tolerance is 1e-6 or 0.0000001. func EqualApprox(a, b float64) bool { - return Abs(a-b) < floatComparisonTolerance + if math.IsNaN(a) || math.IsNaN(b) { + return math.IsNaN(a) && math.IsNaN(b) + } else if math.IsInf(a, 1) || math.IsInf(b, 1) { + return math.IsInf(a, 1) && math.IsInf(b, 1) + } else if math.IsInf(a, -1) || math.IsInf(b, -1) { + return math.IsInf(a, -1) && math.IsInf(b, -1) + } + return math.Abs(a-b) <= float64Tolerance +} + +// Round returns f rounded to d decimal places. d may be negative to round to the left of the decimal point. +// +// Examples: +// +// Round(123.456, 0) // 123.0 +// Round(123.456, 1) // 123.5 +// Round(123.456, -1) // 120.0 +func Round(f float64, d int) float64 { + ratio := math.Pow10(d) + return math.Round(f*ratio) / ratio } func Abs[T constraints.Integer | constraints.Float](a T) T { diff --git a/utils_test.go b/utils_test.go new file mode 100644 index 0000000..25f0817 --- /dev/null +++ b/utils_test.go @@ -0,0 +1,51 @@ +package autotrader + +import ( + "math" + "testing" +) + +func TestEqualApprox(t *testing.T) { + if !EqualApprox(0.0000000, float64Tolerance/10) { // 1e-6 + t.Error("Expected 0.0000000 to be approximately equal to 0.0000001") + } + if EqualApprox(0.0000000, float64Tolerance+float64Tolerance/10) { + t.Error("Expected 0.0000000 to not be approximately equal to 0.0000011") + } + if !EqualApprox(math.NaN(), math.NaN()) { + t.Error("Expected NaN to be approximately equal to NaN") + } + if EqualApprox(math.NaN(), 0) { + t.Error("Expected NaN to not be approximately equal to 0") + } + if !EqualApprox(math.Inf(1), math.Inf(1)) { + t.Error("Expected Inf to be approximately equal to Inf") + } + if EqualApprox(math.Inf(-1), math.Inf(1)) { + t.Error("Expected -Inf to not be approximately equal to Inf") + } + if EqualApprox(1, 2) { + t.Error("Expected 1 to not be approximately equal to 2") + } + if !EqualApprox(0.3, 0.6/2) { + t.Errorf("Expected 0.3 to be approximately equal to %f", 6.0/2) + } +} + +func TestRound(t *testing.T) { + if Round(0.1234567, 0) != 0 { + t.Error("Expected 0.1234567 to round to 0") + } + if Round(0.1234567, 1) != 0.1 { + t.Error("Expected 0.1234567 to round to 0.1") + } + if Round(0.1234567, 2) != 0.12 { + t.Error("Expected 0.1234567 to round to 0.12") + } + if Round(0.128, 2) != 0.13 { + t.Error("Expected 0.128 to round to 0.13") + } + if Round(12.34, -1) != 10 { + t.Error("Expected 12.34 to round to 10") + } +}