diff --git a/examples/backtesting/main.go b/examples/backtesting/main.go index 19b6198a..b98c83fc 100644 --- a/examples/backtesting/main.go +++ b/examples/backtesting/main.go @@ -54,7 +54,10 @@ func main() { exchange.WithDataFeed(csvFeed), ) - chart := plot.NewChart() + chart := plot.NewChart(plot.WithIndicators( + plot.EMA(9, "red"), + plot.RSI(14, "purple"), + )) bot, err := ninjabot.NewBot( ctx, diff --git a/plot/assets/chart.html b/plot/assets/chart.html index 8ef8d473..f7bd5e23 100644 --- a/plot/assets/chart.html +++ b/plot/assets/chart.html @@ -10,9 +10,6 @@ Ninja Bot - Trade Results - diff --git a/plot/assets/chart.js b/plot/assets/chart.js index 1efe6944..31c2ce72 100644 --- a/plot/assets/chart.js +++ b/plot/assets/chart.js @@ -5,110 +5,116 @@ function unpack(rows, key) { } document.addEventListener("DOMContentLoaded", function () { - const candleStickData = { - name: "Candles", - x: unpack(candles, "time"), - close: unpack(candles, "close"), - open: unpack(candles, "open"), - low: unpack(candles, "low"), - high: unpack(candles, "high"), - type: "candlestick", - xaxis: "x", - yaxis: "y", - }; + const params = new URLSearchParams(window.location.search) + const pair = params.get("pair") || "" + fetch("/data?pair="+pair). + then(data => data.json()) + .then(data => { + const candleStickData = { + name: "Candles", + x: unpack(data.candles, "time"), + close: unpack(data.candles, "close"), + open: unpack(data.candles, "open"), + low: unpack(data.candles, "low"), + high: unpack(data.candles, "high"), + type: "candlestick", + xaxis: "x", + yaxis: "y", + }; - const points = []; - const annotations = []; - candles.forEach((candle) => { - candle.orders.forEach(order => { - const point = { - time: candle.time, - position: order.price, - side: order.side, - color: "green" - } - if (order.side === "SELL") { - point.color = "red" - } - points.push(point); + const points = []; + const annotations = []; + data.candles.forEach((candle) => { + candle.orders.forEach(order => { + const point = { + time: candle.time, + position: order.price, + side: order.side, + color: "green" + } + if (order.side === "SELL") { + point.color = "red" + } + points.push(point); - const annotation = { - x: candle.time, - y: candle.low, - xref: "x", - yref: "y", - text: "B", - hovertext: `${order.time} -
ID: ${order.id} -
Price: ${order.price.toLocaleString()} -
Size: ${order.quantity.toPrecision(4).toLocaleString()} -
Type: ${order.type} -
${(order.profit && "Profit: " + (order.profit * 100).toPrecision(2).toLocaleString() + "%") || ""}`, - showarrow: true, - arrowcolor: "green", - valign: "bottom", - borderpad: 4, - arrowhead: 2, - ax: 0, - ay: 20, - font: { - size: 12, - color: "green", - }, - }; + const annotation = { + x: candle.time, + y: candle.low, + xref: "x", + yref: "y", + text: "B", + hovertext: `${order.time} +
ID: ${order.id} +
Price: ${order.price.toLocaleString()} +
Size: ${order.quantity.toPrecision(4).toLocaleString()} +
Type: ${order.type} +
${(order.profit && "Profit: " + (order.profit * 100).toPrecision(2).toLocaleString() + "%") || ""}`, + showarrow: true, + arrowcolor: "green", + valign: "bottom", + borderpad: 4, + arrowhead: 2, + ax: 0, + ay: 20, + font: { + size: 12, + color: "green", + }, + }; - if (order.side === "SELL") { - annotation.font.color = "red"; - annotation.arrowcolor = "red"; - annotation.text = "S"; - annotation.y = candle.high; - annotation.ay = -20; - annotation.valign = "top"; - } - annotations.push(annotation); + if (order.side === "SELL") { + annotation.font.color = "red"; + annotation.arrowcolor = "red"; + annotation.text = "S"; + annotation.y = candle.high; + annotation.ay = -20; + annotation.valign = "top"; + } + annotations.push(annotation); + }); }); - }); - const sellPoints = points.filter(p => p.side === "SELL"); - const buyPoints = points.filter(p => p.side === "BUY"); - const buyData = { - name: "Buy Points", - x: unpack(buyPoints, "time"), - y: unpack(buyPoints, "position"), - mode: 'markers', - type: 'scatter', - marker: { - color: "green", - } - }; - const sellData = { - name: "Sell Points", - x: unpack(sellPoints, "time"), - y: unpack(sellPoints, "position"), - mode: 'markers', - type: 'scatter', - marker: { - color: "red", - } - }; + const sellPoints = points.filter(p => p.side === "SELL"); + const buyPoints = points.filter(p => p.side === "BUY"); + const buyData = { + name: "Buy Points", + x: unpack(buyPoints, "time"), + y: unpack(buyPoints, "position"), + mode: 'markers', + type: 'scatter', + marker: { + color: "green", + } + }; + const sellData = { + name: "Sell Points", + x: unpack(sellPoints, "time"), + y: unpack(sellPoints, "position"), + mode: 'markers', + type: 'scatter', + marker: { + color: "red", + } + }; - var layout = { - dragmode: "pan", - margin: { - r: 10, - t: 25, - b: 40, - l: 60, - }, - showlegend: true, - xaxis: { - autorange: true - }, - yaxis: { - autorange: true - }, - annotations: annotations, - }; + var layout = { + dragmode: "pan", + margin: { + r: 10, + t: 25, + b: 40, + l: 60, + }, + showlegend: true, + xaxis: { + autorange: true + }, + yaxis: { + autorange: true + }, + annotations: annotations, + }; - Plotly.newPlot("graph", [candleStickData, buyData, sellData], layout); + Plotly.newPlot("graph", [candleStickData, buyData, sellData], layout); + }) }); diff --git a/plot/chart.go b/plot/chart.go index 98751ffa..3199c2d7 100644 --- a/plot/chart.go +++ b/plot/chart.go @@ -2,6 +2,7 @@ package plot import ( "embed" + "encoding/json" "fmt" "html/template" "net/http" @@ -17,9 +18,11 @@ var staticFiles embed.FS type Chart struct { sync.Mutex - port int - candles map[string][]Candle - orders map[string][]*Order + port int + candles map[string][]Candle + dataframe map[string]*model.Dataframe + orders map[string][]*Order + indicators []Indicator } type Candle struct { @@ -42,6 +45,20 @@ type Order struct { Profit float64 `json:"profit"` } +type indicatorMetric struct { + Name string `json:"name"` + Time []time.Time `json:"time"` + Values []float64 `json:"value"` + Color string `json:"color"` + Style string `json:"style"` +} + +type plotIndicator struct { + Name string `json:"name"` + Overlay bool `json:"overlay"` + Metrics []indicatorMetric `json:"metrics"` +} + func (c *Chart) OnOrder(order model.Order) { c.Lock() defer c.Unlock() @@ -69,7 +86,9 @@ func (c *Chart) OnCandle(candle model.Candle) { c.Lock() defer c.Unlock() - if candle.Complete { + if candle.Complete && (len(c.candles[candle.Symbol]) == 0 || + candle.Time.After(c.candles[candle.Symbol][len(c.candles[candle.Symbol])-1].Time)) { + c.candles[candle.Symbol] = append(c.candles[candle.Symbol], Candle{ Time: candle.Time, Open: candle.Open, @@ -79,10 +98,50 @@ func (c *Chart) OnCandle(candle model.Candle) { Volume: candle.Volume, Orders: make([]Order, 0), }) + + if c.dataframe[candle.Symbol] == nil { + c.dataframe[candle.Symbol] = &model.Dataframe{ + Pair: candle.Symbol, + Metadata: make(map[string]model.Series), + } + } + + c.dataframe[candle.Symbol].Close = append(c.dataframe[candle.Symbol].Close, candle.Close) + c.dataframe[candle.Symbol].Open = append(c.dataframe[candle.Symbol].Open, candle.Open) + c.dataframe[candle.Symbol].High = append(c.dataframe[candle.Symbol].High, candle.High) + c.dataframe[candle.Symbol].Low = append(c.dataframe[candle.Symbol].Low, candle.Low) + c.dataframe[candle.Symbol].Volume = append(c.dataframe[candle.Symbol].Volume, candle.Volume) + c.dataframe[candle.Symbol].Time = append(c.dataframe[candle.Symbol].Time, candle.Time) + c.dataframe[candle.Symbol].LastUpdate = candle.Time } } -func (c *Chart) CandlesByPair(pair string) []Candle { +func (c *Chart) indicatorsByPair(pair string) []plotIndicator { + indicators := make([]plotIndicator, 0) + for _, i := range c.indicators { + i.Load(c.dataframe[pair]) + indicator := plotIndicator{ + Name: i.Name(), + Overlay: i.Overlay(), + Metrics: make([]indicatorMetric, 0), + } + + for _, metric := range i.Metrics() { + indicator.Metrics = append(indicator.Metrics, indicatorMetric{ + Name: metric.Name, + Values: metric.Values, + Time: metric.Time, + Color: metric.Color, + Style: metric.Style, + }) + } + + indicators = append(indicators, indicator) + } + return indicators +} + +func (c *Chart) candlesByPair(pair string) []Candle { for i := range c.candles[pair] { for j, order := range c.orders[pair] { if order == nil { @@ -124,21 +183,26 @@ func (c *Chart) Start() error { pairs = append(pairs, pair) } - http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + http.HandleFunc("/data", func(w http.ResponseWriter, req *http.Request) { pair := req.URL.Query().Get("pair") if pair == "" { pair = pairs[0] } - candles := c.CandlesByPair(pair) + w.Header().Set("Content-type", "text/json") + err := json.NewEncoder(w).Encode(map[string]interface{}{ + "candles": c.candlesByPair(pair), + "indicators": c.indicatorsByPair(pair), + }) + if err != nil { + log.Error(err) + } + }) + http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { w.Header().Add("Content-Type", "text/html") - err := t.Execute(w, struct { - Pairs []string - Candles []Candle - }{ - Pairs: pairs, - Candles: candles, + err := t.Execute(w, map[string][]string{ + "pairs": pairs, }) if err != nil { log.Error(err) @@ -156,11 +220,18 @@ func WithPort(port int) Option { } } +func WithIndicators(indicators ...Indicator) Option { + return func(chart *Chart) { + chart.indicators = indicators + } +} + func NewChart(options ...Option) *Chart { chart := &Chart{ - port: 8080, - candles: make(map[string][]Candle), - orders: make(map[string][]*Order), + port: 8080, + candles: make(map[string][]Candle), + dataframe: make(map[string]*model.Dataframe), + orders: make(map[string][]*Order), } for _, option := range options { option(chart) diff --git a/plot/indicator.go b/plot/indicator.go new file mode 100644 index 00000000..58c9e87b --- /dev/null +++ b/plot/indicator.go @@ -0,0 +1,103 @@ +package plot + +import ( + "fmt" + "time" + + "github.com/markcheno/go-talib" + + "github.com/rodrigo-brito/ninjabot/model" +) + +type Metric struct { + Name string + Color string + Style string + Values model.Series + Time []time.Time +} + +type Indicator interface { + Name() string + Overlay() bool + Metrics() []Metric + Load(dataframe *model.Dataframe) +} + +func EMA(period int, color string) Indicator { + return &ema{ + Period: period, + Color: color, + } +} + +type ema struct { + Period int + Color string + Values model.Series + Time []time.Time +} + +func (e ema) Name() string { + return fmt.Sprintf("EMA(%d)", e.Period) +} + +func (e ema) Overlay() bool { + return true +} + +func (e *ema) Load(dataframe *model.Dataframe) { + e.Values = talib.Ema(dataframe.Close, e.Period) + e.Time = dataframe.Time +} + +func (e ema) Metrics() []Metric { + return []Metric{ + { + Name: "value", + Style: "line", + Color: e.Color, + Values: e.Values, + Time: e.Time, + }, + } +} + +func RSI(period int, color string) Indicator { + return &ema{ + Period: period, + Color: color, + } +} + +type rsi struct { + Period int + Color string + Values model.Series + Time []time.Time +} + +func (e rsi) Name() string { + return fmt.Sprintf("RSI(%d)", e.Period) +} + +func (e rsi) Overlay() bool { + return false +} + +func (e *rsi) Load(dataframe *model.Dataframe) { + e.Values = talib.Rsi(dataframe.Close, e.Period) + e.Time = dataframe.Time +} + +func (e rsi) Metrics() []Metric { + return []Metric{ + { + Name: "value", + Color: e.Color, + Style: "line", + Values: e.Values, + Time: e.Time, + }, + } +}