Added Round function and unit tests

This commit is contained in:
Luke I. Wilson 2023-05-17 14:20:13 -05:00
parent 34f98a1376
commit 494df7827b
6 changed files with 128 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

51
utils_test.go Normal file
View File

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