Added trade stats and candlestick chart

This commit is contained in:
Luke I. Wilson 2023-05-18 17:23:15 -05:00
parent 52260551e8
commit 565aa6c9fd
2 changed files with 177 additions and 62 deletions

View File

@ -6,6 +6,7 @@ import (
"log" "log"
"os" "os"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/go-echarts/go-echarts/v2/charts" "github.com/go-echarts/go-echarts/v2/charts"
@ -33,31 +34,65 @@ func Backtest(trader *Trader) {
trader.Tick() // Allow the trader to process the current candlesticks. trader.Tick() // Allow the trader to process the current candlesticks.
broker.Advance() // Give the trader access to the next candlestick. broker.Advance() // Give the trader access to the next candlestick.
} }
trader.closeOrdersAndPositions() // Close any outstanding trades now.
log.Printf("Backtest completed on %d candles. Opening report...\n", trader.Stats().Dated.Len()) log.Printf("Backtest completed on %d candles. Opening report...\n", trader.Stats().Dated.Len())
stats := trader.Stats() stats := trader.Stats()
// Pick a datetime layout based on the frequency.
dateLayout := time.DateTime
if strings.Contains(trader.Frequency, "S") { // Seconds
dateLayout = "15:04:05"
} else if strings.Contains(trader.Frequency, "H") { // Hours
dateLayout = "2006-01-02 15:04"
} else if strings.Contains(trader.Frequency, "D") || trader.Frequency == "W" { // Days or Weeks
dateLayout = time.DateOnly
} else if trader.Frequency == "M" { // Months
dateLayout = "2006-01"
} else if strings.Contains(trader.Frequency, "M") { // Minutes
dateLayout = "01-02 15:04"
}
page := components.NewPage() page := components.NewPage()
// Create a new line balChart based on account equity and add it to the page. // Create a new line balChart based on account equity and add it to the page.
balChart := charts.NewLine() balChart := charts.NewLine()
balChart.SetGlobalOptions(charts.WithTitleOpts(opts.Title{ balChart.SetGlobalOptions(
charts.WithTitleOpts(opts.Title{
Title: "Balance", Title: "Balance",
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()), 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{ }),
charts.WithTooltipOpts(opts.Tooltip{
Show: true, Show: true,
Trigger: "axis", Trigger: "axis",
TriggerOn: "mousemove|click", TriggerOn: "mousemove|click",
}), charts.WithYAxisOpts(opts.YAxis{ }),
charts.WithYAxisOpts(opts.YAxis{
AxisLabel: &opts.AxisLabel{ AxisLabel: &opts.AxisLabel{
Show: true, Show: true,
Formatter: "${value}", Formatter: "${value}",
}, },
}),
charts.WithLegendOpts(opts.Legend{
Show: true,
Selected: map[string]bool{"Equity": false, "Profit": true},
})) }))
balChart.SetXAxis(seriesStringArray(stats.Dated.Dates())). balChart.SetXAxis(seriesStringArray(stats.Dated.Dates(), dateLayout)).
AddSeries("Equity", lineDataFromSeries(stats.Dated.Series("Equity")), func(s *charts.SingleSeries) { AddSeries("Equity", lineDataFromSeries(stats.Dated.Series("Equity"))).
}). SetSeriesOptions(
AddSeries("Profit", lineDataFromSeries(stats.Dated.Series("Profit"))) charts.WithMarkPointNameTypeItemOpts(
// AddSeries("Drawdown", lineDataFromSeries(stats.Dated.Series("Drawdown"))) opts.MarkPointNameTypeItem{Name: "Peak", Type: "max", ItemStyle: &opts.ItemStyle{
Color: balChart.Colors[1],
}},
opts.MarkPointNameTypeItem{Name: "Drawdown", Type: "min", ItemStyle: &opts.ItemStyle{
Color: balChart.Colors[3],
}},
),
)
balChart.AddSeries("Profit", lineDataFromSeries(stats.Dated.Series("Profit")))
// Create a new kline chart based on the candlesticks and add it to the page.
kline := newKline(trader.data, stats.Dated.Series("Trades"), dateLayout)
// Sort Returns by value. // Sort Returns by value.
// Plot returns as a bar chart. // Plot returns as a bar chart.
@ -93,10 +128,12 @@ func Backtest(trader *Trader) {
} }
returnsChart := charts.NewBar() returnsChart := charts.NewBar()
returnsChart.SetGlobalOptions(charts.WithTitleOpts(opts.Title{ returnsChart.SetGlobalOptions(
charts.WithTitleOpts(opts.Title{
Title: "Returns", Title: "Returns",
Subtitle: fmt.Sprintf("Average: $%.2f", avg), Subtitle: fmt.Sprintf("Average: $%.2f", avg),
}), charts.WithYAxisOpts(opts.YAxis{ }),
charts.WithYAxisOpts(opts.YAxis{
AxisLabel: &opts.AxisLabel{ AxisLabel: &opts.AxisLabel{
Show: true, Show: true,
Formatter: "${value}", Formatter: "${value}",
@ -121,7 +158,7 @@ func Backtest(trader *Trader) {
// Add all the charts in the desired order. // Add all the charts in the desired order.
page.PageTitle = "Backtest Report" page.PageTitle = "Backtest Report"
page.AddCharts(balChart, returnsChart) page.AddCharts(balChart, kline, returnsChart)
// Draw the page to a file. // Draw the page to a file.
f, err := os.Create("backtest.html") f, err := os.Create("backtest.html")
@ -140,16 +177,85 @@ func Backtest(trader *Trader) {
} }
} }
// func barDataFromSeries(s Series) []opts.BarData { func newKline(dohlcv Frame, trades Series, dateLayout string) *charts.Kline {
// if s == nil || s.Len() == 0 { kline := charts.NewKLine()
// return []opts.BarData{}
// } x := make([]string, dohlcv.Len())
// data := make([]opts.BarData, s.Len()) y := make([]opts.KlineData, dohlcv.Len())
// for i := 0; i < s.Len(); i++ { for i := 0; i < dohlcv.Len(); i++ {
// data[i] = opts.BarData{Value: s.Value(i)} x[i] = dohlcv.Date(i).Format(dateLayout)
// } y[i] = opts.KlineData{Value: [4]float64{
// return data dohlcv.Open(i),
// } dohlcv.Close(i),
dohlcv.Low(i),
dohlcv.High(i),
}}
}
marks := make([]opts.MarkPointNameCoordItem, 0)
for i := 0; i < trades.Len(); i++ {
if slice := trades.Value(i); slice != nil {
for _, trade := range slice.([]TradeStat) {
color := "green"
rotation := float32(0)
if trade.Units < 0 {
color = "red"
rotation = 180
}
if trade.Exit {
color = "black"
}
marks = append(marks, opts.MarkPointNameCoordItem{
Name: "Trade",
Value: fmt.Sprintf("%v units", trade.Units),
Coordinate: []interface{}{x[i], y[i].Value.([4]float64)[1]},
Label: &opts.Label{
Show: true,
Position: "inside",
},
ItemStyle: &opts.ItemStyle{
Color: color,
},
Symbol: "arrow",
SymbolRotate: rotation,
SymbolSize: 25,
})
}
}
}
kline.SetGlobalOptions(
charts.WithTitleOpts(opts.Title{
Title: "Trades",
Subtitle: fmt.Sprintf("Showing %d candles", dohlcv.Len()),
}),
charts.WithXAxisOpts(opts.XAxis{
SplitNumber: 20,
}),
charts.WithYAxisOpts(opts.YAxis{
Scale: true,
}),
charts.WithTooltipOpts(opts.Tooltip{ // Enable seeing details on hover.
Show: true,
Trigger: "axis",
TriggerOn: "mousemove|click",
}),
charts.WithDataZoomOpts(opts.DataZoom{ // Support zooming with scroll wheel.
Type: "inside",
Start: 0,
End: 100,
XAxisIndex: []int{0},
}),
charts.WithDataZoomOpts(opts.DataZoom{ // Support zooming with bottom slider.
Type: "slider",
Start: 0,
End: 100,
XAxisIndex: []int{0},
}),
)
kline.SetXAxis(x).AddSeries("Price Action", y, charts.WithMarkPointNameCoordItemOpts(marks...))
return kline
}
func lineDataFromSeries(s Series) []opts.LineData { func lineDataFromSeries(s Series) []opts.LineData {
if s == nil || s.Len() == 0 { if s == nil || s.Len() == 0 {
@ -162,27 +268,14 @@ func lineDataFromSeries(s Series) []opts.LineData {
return data return data
} }
func seriesStringArray(s Series) []string { func seriesStringArray(s Series, dateLayout string) []string {
if s == nil || s.Len() == 0 { if s == nil || s.Len() == 0 {
return []string{} return []string{}
} }
first := true
data := make([]string, s.Len()) data := make([]string, s.Len())
var dateLayout string
for i := 0; i < s.Len(); i++ { for i := 0; i < s.Len(); i++ {
switch val := s.Value(i).(type) { switch val := s.Value(i).(type) {
case time.Time: case time.Time:
if first {
first = false
dateHead := s.Value(0).(time.Time)
dateTail := s.Value(-1).(time.Time)
diff := dateTail.Sub(dateHead)
if diff.Hours() > 24*365 {
dateLayout = time.DateOnly
} else {
dateLayout = time.DateTime
}
}
data[i] = val.Format(dateLayout) data[i] = val.Format(dateLayout)
case string: case string:
data[i] = fmt.Sprintf("%q", val) data[i] = fmt.Sprintf("%q", val)

View File

@ -11,12 +11,6 @@ import (
"github.com/go-co-op/gocron" "github.com/go-co-op/gocron"
) )
// 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 // 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 // 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. // requests for the current state of the portfolio.
@ -38,6 +32,18 @@ func (t *Trader) Data() *DataFrame {
return t.data return t.data
} }
type TradeStat struct {
Units float64 // Units is the signed number of units bought or sold.
Exit bool // Exit is true if the trade was to exit a previous position.
}
// Performance (financial) reporting and statistics.
type TraderStats struct {
Dated *DataFrame
returnsThisCandle float64
tradesThisCandle []TradeStat
}
func (t *Trader) Stats() *TraderStats { func (t *Trader) Stats() *TraderStats {
return t.stats return t.stats
} }
@ -90,8 +96,10 @@ func (t *Trader) Init() {
NewDataSeries("Profit"), NewDataSeries("Profit"),
NewDataSeries("Drawdown"), NewDataSeries("Drawdown"),
NewDataSeries("Returns"), NewDataSeries("Returns"),
NewDataSeries("Trades"), // []float64 representing the number of units traded positive for buy, negative for sell.
) )
t.Broker.SignalConnect("PositionClosed", t, func(args ...interface{}) { t.stats.tradesThisCandle = make([]TradeStat, 0, 2)
t.Broker.SignalConnect("PositionClosed", t, func(args ...any) {
position := args[0].(Position) position := args[0].(Position)
t.stats.returnsThisCandle += position.PL() t.stats.returnsThisCandle += position.PL()
}) })
@ -104,7 +112,7 @@ func (t *Trader) Tick() {
t.Strategy.Next(t) // Run the strategy. t.Strategy.Next(t) // Run the strategy.
// Update the stats. // Update the stats.
err := t.stats.Dated.PushValues(map[string]interface{}{ err := t.stats.Dated.PushValues(map[string]any{
"Date": t.data.Date(-1), "Date": t.data.Date(-1),
"Equity": t.Broker.NAV(), "Equity": t.Broker.NAV(),
"Profit": t.Broker.PL(), "Profit": t.Broker.PL(),
@ -117,13 +125,22 @@ func (t *Trader) Tick() {
} }
return Max(bal-t.Broker.NAV(), 0) return Max(bal-t.Broker.NAV(), 0)
}(), }(),
"Returns": func() interface{} { "Returns": func() any {
if t.stats.returnsThisCandle != 0 { if t.stats.returnsThisCandle != 0 {
return t.stats.returnsThisCandle return t.stats.returnsThisCandle
} else { } else {
return nil return nil
} }
}(), }(),
"Trades": func() any {
if len(t.stats.tradesThisCandle) == 0 {
return nil
}
trades := make([]TradeStat, len(t.stats.tradesThisCandle))
copy(trades, t.stats.tradesThisCandle)
t.stats.tradesThisCandle = t.stats.tradesThisCandle[:0]
return trades
}(),
}) })
if err != nil { if err != nil {
log.Printf("error pushing values to stats dataframe: %v\n", err.Error()) log.Printf("error pushing values to stats dataframe: %v\n", err.Error())
@ -146,26 +163,31 @@ func (t *Trader) fetchData() {
} }
func (t *Trader) Buy(units float64) { func (t *Trader) Buy(units float64) {
t.Log.Printf("Buy %f units", units)
t.closeOrdersAndPositions() t.closeOrdersAndPositions()
t.Log.Printf("Buy %f units", units)
t.Broker.MarketOrder(t.Symbol, units, 0.0, 0.0) t.Broker.MarketOrder(t.Symbol, units, 0.0, 0.0)
t.stats.tradesThisCandle = append(t.stats.tradesThisCandle, TradeStat{units, false})
} }
func (t *Trader) Sell(units float64) { func (t *Trader) Sell(units float64) {
t.Log.Printf("Sell %f units", units)
t.closeOrdersAndPositions() t.closeOrdersAndPositions()
t.Log.Printf("Sell %f units", units)
t.Broker.MarketOrder(t.Symbol, -units, 0.0, 0.0) t.Broker.MarketOrder(t.Symbol, -units, 0.0, 0.0)
t.stats.tradesThisCandle = append(t.stats.tradesThisCandle, TradeStat{-units, false})
} }
func (t *Trader) closeOrdersAndPositions() { func (t *Trader) closeOrdersAndPositions() {
for _, order := range t.Broker.OpenOrders() { for _, order := range t.Broker.OpenOrders() {
if order.Symbol() == t.Symbol { if order.Symbol() == t.Symbol {
t.Log.Printf("Cancelling order %s (%f units)", order.Id(), order.Units())
order.Cancel() order.Cancel()
} }
} }
for _, position := range t.Broker.OpenPositions() { for _, position := range t.Broker.OpenPositions() {
if position.Symbol() == t.Symbol { if position.Symbol() == t.Symbol {
t.Log.Printf("Closing position %s (%f units, %f PL)", position.Id(), position.Units(), position.PL())
position.Close() position.Close()
t.stats.tradesThisCandle = append(t.stats.tradesThisCandle, TradeStat{position.Units(), true})
} }
} }
} }