diff --git a/go.mod b/go.mod index 3247e489f8..8b88b6f9ed 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/gertd/go-pluralize v0.2.1 github.com/gin-contrib/cors v1.7.2 github.com/gin-gonic/gin v1.10.0 + github.com/gizak/termui/v3 v3.1.0 github.com/go-redis/redis/v8 v8.11.5 github.com/go-sql-driver/mysql v1.10.0 github.com/gofrs/flock v0.8.1 @@ -117,10 +118,12 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/common v0.55.0 // indirect diff --git a/go.sum b/go.sum index 47133eba46..7af95f114e 100644 --- a/go.sum +++ b/go.sum @@ -143,6 +143,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/gizak/termui/v3 v3.1.0 h1:ZZmVDgwHl7gR7elfKf1xc4IudXZ5qqfDh4wExk4Iajc= +github.com/gizak/termui/v3 v3.1.0/go.mod h1:bXQEBkJpzxUAKf0+xq9MSWAvWZlE7c+aidmyFlkYTrY= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gota/gota v0.10.1/go.mod h1:NZLQccXn0rABmkXjsaugRY6l+UH2dDZSgIgF8E2ipmA= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -395,6 +397,7 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= @@ -414,6 +417,8 @@ github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceT github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= @@ -433,6 +438,8 @@ github.com/muesli/kmeans v0.3.0/go.mod h1:eNyybq0tX9/iBEP6EMU4Y7dpmGK0uEhODdZpnG github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d h1:x3S6kxmy49zXVVyhcnrFqxvNVCBPb2KZ9hV2RBdS840= +github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= diff --git a/pkg/cmd/dashboard.go b/pkg/cmd/dashboard.go new file mode 100644 index 0000000000..8b554f533b --- /dev/null +++ b/pkg/cmd/dashboard.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "context" + "errors" + "log" + "strings" + "time" + + "github.com/c9s/bbgo/pkg/cmd/widget" + "github.com/c9s/bbgo/pkg/exchange" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" + ui "github.com/gizak/termui/v3" + "github.com/spf13/cobra" +) + +func init() { + orderFlowCmd.Flags().String("exchange", "", "exchange name") + orderFlowCmd.MarkFlagRequired("exchange") + orderFlowCmd.Flags().String("symbol", "", "trading pair symbol") + orderFlowCmd.MarkFlagRequired("symbol") + orderFlowCmd.Flags().Float64("aggregate", 0, "aggregate price unit; 0 disables aggregation") + dashboardCmd.AddCommand(orderFlowCmd) + RootCmd.AddCommand(dashboardCmd) +} + +var dashboardCmd = &cobra.Command{ + Use: "dashboard", + Short: "start the dashboard", + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} + +var orderFlowCmd = &cobra.Command{ + Use: "orderflow", + Short: "start the order flow dashboard", + RunE: func(cmd *cobra.Command, args []string) error { + if err := ui.Init(); err != nil { + log.Fatalf("failed to initialize termui: %v", err) + } + defer ui.Close() + + exName, err := cmd.Flags().GetString("exchange") + if err != nil { + return err + } + + symbol, err := cmd.Flags().GetString("symbol") + if err != nil { + return err + } + if len(exName) == 0 || len(symbol) == 0 { + return errors.New("--exchange and --symbol are required") + } + aggregate, err := cmd.Flags().GetFloat64("aggregate") + if err != nil { + return err + } + aggregateUnit := fixedpoint.NewFromFloat(aggregate) + + // create a new public trade stream + exMin, err := exchange.NewWithEnvVarPrefix(types.ExchangeName(exName), strings.ToUpper(exName)) + ex, ok := exMin.(types.Exchange) + if !ok { + return errors.New("exchange does not implement types.Exchange") + } + stream := ex.NewStream() + stream.Subscribe(types.MarketTradeChannel, symbol, types.SubscribeOptions{}) + + w := widget.NewOrderFlowWidget() + w.Title = " Order Flow Delta (Total Volume Normalization) " + stream.OnMarketTrade(types.TradeWith(symbol, func(trade types.Trade) { + trade.Price = aggregatePrice(trade.Price, aggregateUnit) + w.UpdateTrade(trade) + })) + stream.SetPublicOnly() + // start receiving market trades + if err := stream.Connect(context.Background()); err != nil { + return err + } + + w.BorderStyle = ui.NewStyle(ui.ColorCyan) + w.TitleStyle = ui.NewStyle(ui.ColorWhite, ui.ColorClear, ui.ModifierBold) + + termWidth, termHeight := ui.TerminalDimensions() + w.SetRect(0, 0, termWidth, termHeight) + ui.Render(w) + + // refresh ticker + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + uiEvents := ui.PollEvents() + + for { + select { + case <-ticker.C: + ui.Render(w) + case e := <-uiEvents: + switch e.Type { + case ui.KeyboardEvent: + if e.ID == "q" || e.ID == "" { + return nil + } + w.HandleKeyboardEvent(e) + case ui.ResizeEvent: + payload := e.Payload.(ui.Resize) + w.SetRect(0, 0, payload.Width, payload.Height) + ui.Render(w) + } + } + } + }, +} + +func aggregatePrice(price, aggregateUnit fixedpoint.Value) fixedpoint.Value { + if aggregateUnit.Sign() <= 0 { + return price + } + return price.Div(aggregateUnit).Floor().Mul(aggregateUnit) +} diff --git a/pkg/cmd/dashboard_test.go b/pkg/cmd/dashboard_test.go new file mode 100644 index 0000000000..b5f7074249 --- /dev/null +++ b/pkg/cmd/dashboard_test.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "testing" + + "github.com/c9s/bbgo/pkg/fixedpoint" + testifyassert "github.com/stretchr/testify/assert" +) + +func TestAggregatePrice(t *testing.T) { + price := fixedpoint.MustNewFromString("63773.91") + + tests := []struct { + name string + unit fixedpoint.Value + want fixedpoint.Value + }{ + { + name: "zero disables aggregation", + unit: fixedpoint.Zero, + want: fixedpoint.MustNewFromString("63773.91"), + }, + { + name: "negative disables aggregation", + unit: fixedpoint.MustNewFromString("-1"), + want: fixedpoint.MustNewFromString("63773.91"), + }, + { + name: "one tenth", + unit: fixedpoint.MustNewFromString("0.1"), + want: fixedpoint.MustNewFromString("63773.9"), + }, + { + name: "cent", + unit: fixedpoint.MustNewFromString("0.01"), + want: fixedpoint.MustNewFromString("63773.91"), + }, + { + name: "one", + unit: fixedpoint.One, + want: fixedpoint.MustNewFromString("63773"), + }, + { + name: "five", + unit: fixedpoint.NewFromInt(5), + want: fixedpoint.MustNewFromString("63770"), + }, + { + name: "ten", + unit: fixedpoint.NewFromInt(10), + want: fixedpoint.MustNewFromString("63770"), + }, + { + name: "larger fractional step", + unit: fixedpoint.MustNewFromString("2.5"), + want: fixedpoint.MustNewFromString("63772.5"), + }, + { + name: "sub-unit floors down, not to nearest", + unit: fixedpoint.MustNewFromString("0.5"), + want: fixedpoint.MustNewFromString("63773.5"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testifyassert.Equal(t, tt.want, aggregatePrice(price, tt.unit)) + }) + } +} diff --git a/pkg/cmd/widget/orderflow.go b/pkg/cmd/widget/orderflow.go new file mode 100644 index 0000000000..192b3d3542 --- /dev/null +++ b/pkg/cmd/widget/orderflow.go @@ -0,0 +1,346 @@ +package widget + +import ( + "fmt" + "image" + "math" + "sort" + "sync" + "time" + + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" + ui "github.com/gizak/termui/v3" +) + +// maxOrderFlowSideLevels caps how many price levels are shown above and below +// the POC. All traded prices are retained; only the display is windowed. +const maxOrderFlowSideLevels = 10 + +const ( + priceColWidth = 13 + deltaColWidth = 10 + volColWidth = 16 +) + +type PriceLevel struct { + Price fixedpoint.Value + AskVol fixedpoint.Value + BidVol fixedpoint.Value +} + +func (p *PriceLevel) Delta() fixedpoint.Value { + return p.AskVol.Sub(p.BidVol) +} + +func (p *PriceLevel) TotalVol() fixedpoint.Value { + return p.AskVol.Add(p.BidVol) +} + +type OrderFlowWidget struct { + ui.Block + mu sync.Mutex + levels []PriceLevel + lastTrade *types.Trade + startTime time.Time + visibleLevelsOffset int +} + +func (w *OrderFlowWidget) UpdateTrade(trade types.Trade) { + level := PriceLevel{ + Price: trade.Price, + } + if trade.Side == types.SideTypeBuy { + level.BidVol = trade.Quantity + } else { + level.AskVol = trade.Quantity + } + + w.mu.Lock() + defer w.mu.Unlock() + w.upsertLevel(level) + t := trade + w.lastTrade = &t +} + +func (w *OrderFlowWidget) upsertLevel(level PriceLevel) { + for idx := range w.levels { + if !w.levels[idx].Price.Eq(level.Price) { + continue + } + w.levels[idx].BidVol = w.levels[idx].BidVol.Add(level.BidVol) + w.levels[idx].AskVol = w.levels[idx].AskVol.Add(level.AskVol) + return + } + + w.levels = append(w.levels, level) +} + +func (w *OrderFlowWidget) sortedLevelsSnapshot() ([]PriceLevel, *types.Trade) { + w.mu.Lock() + levels := append([]PriceLevel(nil), w.levels...) + lastTrade := w.lastTrade + w.mu.Unlock() + + sort.Slice(levels, func(i, j int) bool { + return levels[i].Price.Compare(levels[j].Price) > 0 + }) + + return levels, lastTrade +} + +func NewOrderFlowWidget() *OrderFlowWidget { + return &OrderFlowWidget{ + Block: *ui.NewBlock(), + + startTime: time.Now(), + } +} + +func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { + w.Block.Draw(buf) + + // levels: high price → low price + levels, lastTrade := w.sortedLevelsSnapshot() + if len(levels) == 0 { + return + } + + // Identify the POC and total delta + totalDelta := fixedpoint.Zero + pocVol := fixedpoint.Zero + pocPrice := fixedpoint.Zero + pocIdx := 0 + for idx, lvl := range levels { + if v := lvl.TotalVol(); v.Compare(pocVol) > 0 { + pocVol = v + pocPrice = lvl.Price + pocIdx = idx + } + totalDelta = totalDelta.Add(lvl.Delta()) + } + if pocVol.IsZero() { + return + } + + // Calculate the canvas area + inner := w.Inner + + infoColWidth := deltaColWidth + volColWidth + barStart := inner.Min.X + priceColWidth + barEnd := inner.Max.X - infoColWidth + barWidth := barEnd - barStart + if barWidth < 4 { + return + } + barSideGap := w.Inner.Dx() / 8 + barCenterX := barStart + barWidth/2 + maxBarHalfWidth := barWidth/2 - barSideGap + if maxBarHalfWidth < 1 { + maxBarHalfWidth = 1 + } + + availableHeight := inner.Dy() - 2 + + // Show at most maxOrderFlowSideLevels above and below the POC, shrinking + // further if the terminal cannot fit that many rows. + const minRowHeight = 2 + maxVisible := availableHeight / minRowHeight + if maxVisible < 1 { + return + } + + side := maxOrderFlowSideLevels + if half := (maxVisible - 1) / 2; half < side { + side = half + } + + start := pocIdx - side + end := pocIdx + side + 1 + if start < 0 { + start = 0 + } + if end > len(levels) { + end = len(levels) + } + if w.visibleLevelsOffset != 0 { + // adjust the start and end by the offset + start += w.visibleLevelsOffset + end += w.visibleLevelsOffset + if start < 0 { + start = 0 + end = start + 2*side + 1 + if end > len(levels) { + end = len(levels) + } + } + if end > len(levels) { + start = len(levels) - 2*side + if start < 0 { + start = 0 + } + end = len(levels) + } + } + visibleLevels := levels[start:end] + hiddenBelow := len(levels) - end + + numLevels := len(visibleLevels) + rowHeight := availableHeight / numLevels + if rowHeight < minRowHeight { + rowHeight = minRowHeight + } + + // Price range of the visible levels, used to position rows proportionally. + minPrice := visibleLevels[0].Price + maxPrice := visibleLevels[0].Price + for _, lvl := range visibleLevels { + if lvl.Price.Compare(minPrice) < 0 { + minPrice = lvl.Price + } + if lvl.Price.Compare(maxPrice) > 0 { + maxPrice = lvl.Price + } + } + priceRange := maxPrice.Sub(minPrice) + + // Band the rows may occupy: half a row of margin top and bottom and three + // rows reserved at the bottom for the summary. + drawMinY := inner.Min.Y + rowHeight/2 + drawMaxY := inner.Max.Y - 3 - rowHeight/2 + if drawMaxY < drawMinY { + drawMinY = inner.Min.Y + drawMaxY = inner.Max.Y - 3 + } + if drawMaxY < drawMinY { + return + } + + // Place every row at its proportional ideal, then de-overlap so clustered + // prices that round to the same Y keep a one-row gap. maxVisible caps the + // count so the gaps always fit: no level (the POC included) is ever dropped. + const minGap = 1 + rowCenterYs := make([]int, numLevels) + for i, lvl := range visibleLevels { + y := (drawMinY + drawMaxY) / 2 + if !priceRange.IsZero() { + priceOffset := maxPrice.Sub(lvl.Price).Div(priceRange).Float64() + y = drawMinY + int(math.Round(priceOffset*float64(drawMaxY-drawMinY))) + } + rowCenterYs[i] = y + } + // Pass 1 (top→bottom): push colliding rows down. + for i := 1; i < numLevels; i++ { + if lo := rowCenterYs[i-1] + minGap; rowCenterYs[i] < lo { + rowCenterYs[i] = lo + } + } + // Pass 2 (bottom→top): if the tail overran the band, pin it and pull back up. + if rowCenterYs[numLevels-1] > drawMaxY { + rowCenterYs[numLevels-1] = drawMaxY + } + for i := numLevels - 2; i >= 0; i-- { + if hi := rowCenterYs[i+1] - minGap; rowCenterYs[i] > hi { + rowCenterYs[i] = hi + } + } + + maxAbsDelta := fixedpoint.Zero + for _, lvl := range visibleLevels { + if d := lvl.Delta().Abs(); d.Compare(maxAbsDelta) > 0 { + maxAbsDelta = d + } + } + + for i, lvl := range visibleLevels { + rowCenterY := rowCenterYs[i] + + delta := lvl.Delta() + totalVol := lvl.TotalVol() + + color := ui.ColorGreen + if delta.Sign() < 0 { + color = ui.ColorRed + } else if delta.IsZero() { + color = ui.ColorWhite + } + + halfWidth := 0 + if !maxAbsDelta.IsZero() { + halfWidth = int(math.Round(delta.Abs().Div(maxAbsDelta).Float64() * float64(maxBarHalfWidth))) + } + if halfWidth < 1 && !delta.IsZero() { + halfWidth = 1 + } + if halfWidth > 0 { + DrawHorizontalBar(buf, barCenterX-halfWidth, barCenterX+halfWidth, rowCenterY, ui.NewStyle(color)) + } + + priceStyle := ui.NewStyle(ui.ColorWhite) + if lvl.Price.Eq(pocPrice) { + priceStyle = ui.NewStyle(ui.ColorYellow, ui.ColorClear, ui.ModifierBold) + } + setPaddedString(buf, fmt.Sprintf("%.5f", lvl.Price.Float64()), priceStyle, image.Pt(inner.Min.X, rowCenterY), priceColWidth) + + deltaStr := fmt.Sprintf("% .4f", delta.Float64()) + setPaddedString(buf, deltaStr, ui.NewStyle(color), image.Pt(barEnd, rowCenterY), deltaColWidth) + + volStr := fmt.Sprintf(" V:%.5f", totalVol.Float64()) + setPaddedString(buf, volStr, ui.NewStyle(ui.ColorCyan), image.Pt(barEnd+deltaColWidth, rowCenterY), volColWidth) + } + + summaryY := inner.Max.Y - 1 + deltaColor := ui.ColorGreen + if totalDelta.Sign() < 0 { + deltaColor = ui.ColorRed + } + summary := fmt.Sprintf( + " Total Δ: %.4f | POC: %.5f (Vol %.5f)", + totalDelta.Float64(), pocPrice.Float64(), pocVol.Float64()) + if lastTrade != nil { + pocRel := "= POC" + switch lastTrade.Price.Compare(pocPrice) { + case 1: + pocRel = "↑ POC" + case -1: + pocRel = "↓ POC" + } + summary += fmt.Sprintf(" | Last Trade: %s %s @ %.5f (%s)", + lastTrade.Side, + lastTrade.Quantity, + lastTrade.Price.Float64(), + pocRel, + ) + } + if start > 0 || hiddenBelow > 0 { + summary += fmt.Sprintf(" | hidden ↑%d ↓%d", start, hiddenBelow) + } + elapsedTime := time.Since(w.startTime).Truncate(time.Second) + buf.SetString(fmt.Sprintf( + " Elapsed: %s (since %s)", + elapsedTime, w.startTime.Format(time.RFC3339)), + ui.NewStyle(deltaColor), + image.Pt(inner.Min.X, summaryY-1), + ) + buf.SetString(summary, ui.NewStyle(deltaColor), image.Pt(inner.Min.X, summaryY)) + cmdString := " [↑/↓] scroll [r] reset [q] quit" + buf.SetString(cmdString, ui.NewStyle(ui.ColorWhite), image.Pt(inner.Max.X-len(cmdString), summaryY)) +} + +func (w *OrderFlowWidget) HandleKeyboardEvent(e ui.Event) { + switch e.ID { + case "": + w.visibleLevelsOffset-- + case "": + w.visibleLevelsOffset++ + case "r": + w.visibleLevelsOffset = 0 + } +} + +func setPaddedString(buf *ui.Buffer, s string, style ui.Style, point image.Point, width int) { + if len(s) > width { + s = s[:width] + } + buf.SetString(fmt.Sprintf("%-*s", width, s), style, point) +} diff --git a/pkg/cmd/widget/orderflow_test.go b/pkg/cmd/widget/orderflow_test.go new file mode 100644 index 0000000000..6e5a5fe661 --- /dev/null +++ b/pkg/cmd/widget/orderflow_test.go @@ -0,0 +1,201 @@ +package widget + +import ( + "fmt" + "image" + "strings" + "sync" + "testing" + + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" + ui "github.com/gizak/termui/v3" + "github.com/stretchr/testify/assert" +) + +func TestOrderFlowWidget_UpdateTradeAggregatesByPriceAndSide(t *testing.T) { + w := NewOrderFlowWidget() + + const n = 50 + for price := int64(1); price <= n; price++ { + w.UpdateTrade(types.Trade{ + Price: fixedpoint.NewFromInt(price), + Quantity: fixedpoint.One, + Side: types.SideTypeBuy, + }) + } + + // A repeated price merges into the existing level instead of adding one. + w.UpdateTrade(types.Trade{ + Price: fixedpoint.NewFromInt(15), + Quantity: fixedpoint.One, + Side: types.SideTypeBuy, + }) + w.UpdateTrade(types.Trade{ + Price: fixedpoint.NewFromInt(15), + Quantity: fixedpoint.NewFromInt(3), + Side: types.SideTypeSell, + }) + + levels, _ := w.sortedLevelsSnapshot() + assert.Len(t, levels, n) + assert.True(t, hasPriceLevel(levels, fixedpoint.NewFromInt(1))) + assert.True(t, hasPriceLevel(levels, fixedpoint.NewFromInt(n))) + + level := requirePriceLevel(t, levels, fixedpoint.NewFromInt(15)) + assert.Equal(t, fixedpoint.NewFromInt(2), level.BidVol) + assert.Equal(t, fixedpoint.NewFromInt(3), level.AskVol) + + // Sorting is a render-time concern; the sorted snapshot is ordered by + // descending price without mutating the collected levels. + for idx := 1; idx < len(levels); idx++ { + assert.GreaterOrEqual(t, levels[idx-1].Price.Compare(levels[idx].Price), 0) + } +} + +// At most maxOrderFlowSideLevels levels are shown above and below the POC. +func TestOrderFlowWidget_DrawWindowsAroundPOC(t *testing.T) { + w := NewOrderFlowWidget() + + const pocIdx = 20 + for i := 0; i < 41; i++ { + qty := fixedpoint.One + if i == pocIdx { + qty = fixedpoint.NewFromInt(50) // unambiguous POC + } + w.UpdateTrade(types.Trade{ + Price: priceFromIndex(i), + Quantity: qty, + Side: types.SideTypeBuy, + }) + } + + // Tall enough that height is not the limiter (21 rows × 2). Width must be + // wide enough for the summary line, which now also includes last-trade and + // elapsed info before the trailing "hidden" counters. + w.SetRect(0, 0, 200, 46) + buf := ui.NewBuffer(image.Rect(0, 0, 200, 46)) + w.Draw(buf) + + text := bufferText(buf) + assert.Contains(t, text, priceLabelFromIndex(pocIdx-maxOrderFlowSideLevels)) + assert.Contains(t, text, priceLabelFromIndex(pocIdx+maxOrderFlowSideLevels)) + assert.NotContains(t, text, priceLabelFromIndex(pocIdx-maxOrderFlowSideLevels-1)) + assert.NotContains(t, text, priceLabelFromIndex(pocIdx+maxOrderFlowSideLevels+1)) + + // 41 levels, POC centered: 10 windowed off each side of the visible range. + assert.Contains(t, text, "hidden ↑10 ↓10") +} + +func TestOrderFlowWidget_DrawEmptyDoesNotPanic(t *testing.T) { + w := NewOrderFlowWidget() + w.SetRect(0, 0, 80, 40) + + buf := ui.NewBuffer(image.Rect(0, 0, 80, 40)) + assert.NotPanics(t, func() { w.Draw(buf) }) +} + +// UpdateTrade is fed from a stream callback while Draw runs on the render loop; +// the mutex exists to make that safe. Run under -race to exercise it. +func TestOrderFlowWidget_ConcurrentUpdateAndDraw(t *testing.T) { + w := NewOrderFlowWidget() + w.SetRect(0, 0, 80, 40) + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + for i := 0; i < 1000; i++ { + w.UpdateTrade(types.Trade{ + Price: priceFromIndex(i % 40), + Quantity: fixedpoint.One, + Side: types.SideTypeBuy, + }) + } + }() + + go func() { + defer wg.Done() + buf := ui.NewBuffer(image.Rect(0, 0, 80, 40)) + for i := 0; i < 1000; i++ { + w.Draw(buf) + } + }() + + wg.Wait() +} + +// The POC must stay on screen when the terminal cannot fit every level and +// prices are clustered. +func TestOrderFlowWidget_DrawKeepsPOCVisible(t *testing.T) { + w := NewOrderFlowWidget() + + pocPrice := priceFromIndex(15) + for i := 0; i < 30; i++ { + price := priceFromIndex(i) + qty := fixedpoint.One + if price.Eq(pocPrice) { + qty = fixedpoint.NewFromInt(50) // unambiguous POC + } + w.UpdateTrade(types.Trade{Price: price, Quantity: qty, Side: types.SideTypeBuy}) + } + + // Too short for 30 levels at 2 rows each. + w.SetRect(0, 0, 80, 14) + + buf := ui.NewBuffer(image.Rect(0, 0, 80, 14)) + w.Draw(buf) + + assert.Contains(t, bufferText(buf), priceLabelFromIndex(15), + "POC price must be rendered even when the terminal cannot fit every level") +} + +func priceFromIndex(i int) fixedpoint.Value { + return fixedpoint.MustNewFromString(fmt.Sprintf("100.%02d", i)) +} + +func priceLabelFromIndex(i int) string { + return fmt.Sprintf("100.%02d000", i) +} + +// bufferText flattens a termui buffer into one line per row. +func bufferText(buf *ui.Buffer) string { + r := buf.Rectangle + var lines []string + for y := r.Min.Y; y < r.Max.Y; y++ { + var sb strings.Builder + for x := r.Min.X; x < r.Max.X; x++ { + cell := buf.GetCell(image.Pt(x, y)) + if cell.Rune == 0 { + sb.WriteRune(' ') + continue + } + sb.WriteRune(cell.Rune) + } + lines = append(lines, sb.String()) + } + return strings.Join(lines, "\n") +} + +func hasPriceLevel(levels []PriceLevel, price fixedpoint.Value) bool { + for _, lvl := range levels { + if lvl.Price.Eq(price) { + return true + } + } + return false +} + +func requirePriceLevel(t *testing.T, levels []PriceLevel, price fixedpoint.Value) PriceLevel { + t.Helper() + + for _, lvl := range levels { + if lvl.Price.Eq(price) { + return lvl + } + } + + t.Fatalf("missing price level %s", price.String()) + return PriceLevel{} +} diff --git a/pkg/cmd/widget/util.go b/pkg/cmd/widget/util.go new file mode 100644 index 0000000000..b346f7aa2e --- /dev/null +++ b/pkg/cmd/widget/util.go @@ -0,0 +1,69 @@ +package widget + +import ( + "image" + + ui "github.com/gizak/termui/v3" +) + +const brailleOffset = '\u2800' + +var brailleDots = [4][2]rune{ + {0x01, 0x08}, + {0x02, 0x10}, + {0x04, 0x20}, + {0x40, 0x80}, +} + +func DrawCircle(buffer *ui.Buffer, center image.Point, radius int, style ui.Style) { + if radius <= 0 { + return + } + + cx := float64(center.X * 2) + cy := float64(center.Y * 4) + r2 := float64(radius * radius) + + cellRadiusX := radius/2 + 1 + cellRadiusY := radius/4 + 1 + + for cellY := center.Y - cellRadiusY; cellY <= center.Y+cellRadiusY; cellY++ { + for cellX := center.X - cellRadiusX; cellX <= center.X+cellRadiusX; cellX++ { + var brailleRune rune + for sy := 0; sy < 4; sy++ { + for sx := 0; sx < 2; sx++ { + dx := float64(cellX*2+sx) - cx + dy := float64(cellY*4+sy) - cy + if dx*dx+dy*dy <= r2 { + brailleRune |= brailleDots[sy][sx] + } + } + } + if brailleRune == 0 { + continue + } + + pt := image.Pt(cellX, cellY) + if existing := buffer.GetCell(pt).Rune; existing >= brailleOffset && existing < brailleOffset+0x100 { + brailleRune |= existing - brailleOffset + } + buffer.SetCell(ui.Cell{ + Rune: brailleOffset + brailleRune, + Style: style, + }, pt) + } + } +} + +// DrawHorizontalBar draws a horizontal bar from xstart to xend at the given y coordinate. +func DrawHorizontalBar(buffer *ui.Buffer, xstart, xend int, y int, style ui.Style) { + if xend < xstart { + xstart, xend = xend, xstart + } + for x := xstart; x <= xend; x++ { + buffer.SetCell( + ui.NewCell(ui.SHADED_BLOCKS[1], style), + image.Pt(x, y), + ) + } +}