From e5907c70880f95ae3fc1fb1322534bf82939552b Mon Sep 17 00:00:00 2001 From: dboyliao <6830390+dboyliao@users.noreply.github.com> Date: Sat, 13 Jun 2026 13:28:30 +0800 Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=93=9D=20update(go.mod,=20go.sum):?= =?UTF-8?q?=20add=20termui=20and=20termbox-go=20dependencies=20for=20termi?= =?UTF-8?q?nal=20UI=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 3 +++ go.sum | 7 +++++++ 2 files changed, 10 insertions(+) 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= From 26b0f142bf050de160908678c450c3735ba57b49 Mon Sep 17 00:00:00 2001 From: dboyliao <6830390+dboyliao@users.noreply.github.com> Date: Sat, 13 Jun 2026 13:28:40 +0800 Subject: [PATCH 02/10] =?UTF-8?q?=E2=9C=A8=20feat(dashboard):=20add=20orde?= =?UTF-8?q?r=20flow=20dashboard=20command=20with=20real-time=20trade=20upd?= =?UTF-8?q?ates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/cmd/dashboard.go | 106 +++++++++++++++++++ pkg/cmd/widget/orderflow.go | 197 ++++++++++++++++++++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 pkg/cmd/dashboard.go create mode 100644 pkg/cmd/widget/orderflow.go diff --git a/pkg/cmd/dashboard.go b/pkg/cmd/dashboard.go new file mode 100644 index 0000000000..3ef9ebf6f4 --- /dev/null +++ b/pkg/cmd/dashboard.go @@ -0,0 +1,106 @@ +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/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") + 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") + } + // 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 Circles (總量正規化) " + stream.OnMarketTrade(types.TradeWith(symbol, func(trade types.Trade) { + 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 + } + case ui.ResizeEvent: + payload := e.Payload.(ui.Resize) + w.SetRect(0, 0, payload.Width, payload.Height) + ui.Render(w) + } + } + } + }, +} diff --git a/pkg/cmd/widget/orderflow.go b/pkg/cmd/widget/orderflow.go new file mode 100644 index 0000000000..62c250e9d8 --- /dev/null +++ b/pkg/cmd/widget/orderflow.go @@ -0,0 +1,197 @@ +package widget + +import ( + "fmt" + "image" + "math" + + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" + ui "github.com/gizak/termui/v3" +) + +const brailleOffset = '\u2800' + +var brailleDots = [4][2]rune{ + {0x01, 0x08}, + {0x02, 0x10}, + {0x04, 0x20}, + {0x40, 0x80}, +} + +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 + levels []PriceLevel + indicesMap map[fixedpoint.Value]int +} + +func (w *OrderFlowWidget) SetLevels(levels []PriceLevel) { + w.levels = levels + for i, lvl := range levels { + w.indicesMap[lvl.Price] = i + } +} + +func (w *OrderFlowWidget) UpdateTrade(trade types.Trade) { + idx, ok := w.indicesMap[trade.Price] + if !ok { + newLevel := PriceLevel{ + Price: trade.Price, + } + if trade.Side == types.SideTypeBuy { + newLevel.BidVol = trade.Quantity + } else { + newLevel.AskVol = trade.Quantity + } + w.levels = append(w.levels, newLevel) + w.indicesMap[trade.Price] = len(w.levels) - 1 + } else { + if trade.Side == types.SideTypeBuy { + w.levels[idx].BidVol = w.levels[idx].BidVol.Add(trade.Quantity) + } else { + w.levels[idx].AskVol = w.levels[idx].AskVol.Add(trade.Quantity) + } + } +} + +func NewOrderFlowWidget() *OrderFlowWidget { + return &OrderFlowWidget{ + Block: *ui.NewBlock(), + indicesMap: make(map[fixedpoint.Value]int), + } +} + +func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { + w.Block.Draw(buf) + + if len(w.levels) == 0 { + return + } + + maxVol := fixedpoint.Zero + totalDelta := fixedpoint.Zero + pocPrice := fixedpoint.Zero + pocVol := fixedpoint.Zero + for _, lvl := range w.levels { + if v := lvl.TotalVol(); v.Compare(maxVol) > 0 { + maxVol = v + pocPrice = lvl.Price + pocVol = v + } + totalDelta = totalDelta.Add(lvl.Delta()) + } + if maxVol.IsZero() { + return + } + + inner := w.Inner + numLevels := len(w.levels) + + priceColWidth := 8 + infoColWidth := 16 + circleStart := inner.Min.X + priceColWidth + circleEnd := inner.Max.X - infoColWidth + circleWidth := circleEnd - circleStart + if circleWidth < 4 { + return + } + + availableHeight := inner.Max.Y - inner.Min.Y - 2 + rowHeight := availableHeight / numLevels + if rowHeight < 2 { + rowHeight = 2 + } + + maxRadius := math.Min(float64(circleWidth), float64(rowHeight*2)) * 0.85 + + for i, lvl := range w.levels { + rowTopY := inner.Min.Y + i*rowHeight + rowCenterY := rowTopY + rowHeight/2 + if rowCenterY >= inner.Max.Y-2 { + break + } + + delta := lvl.Delta() + totalVol := lvl.TotalVol() + ratio := totalVol.Div(maxVol).Float64() + radius := ratio * maxRadius + if radius < 1.5 { + radius = 1.5 + } + + color := ui.ColorGreen + if delta < 0 { + color = ui.ColorRed + } else if delta == 0 { + color = ui.ColorYellow + } + + priceStyle := ui.NewStyle(ui.ColorWhite) + if lvl.Price.Eq(pocPrice) { + priceStyle = ui.NewStyle(ui.ColorYellow, ui.ColorClear, ui.ModifierBold) + } + buf.SetString(fmt.Sprintf("%.5f", lvl.Price.Float64()), priceStyle, image.Pt(inner.Min.X, rowCenterY)) + + cxBraille := float64(circleWidth) + cyBraille := float64(rowHeight*4) / 2.0 + + for ty := 0; ty < rowHeight; ty++ { + termY := rowTopY + ty + if termY >= inner.Max.Y-2 { + break + } + for tx := 0; tx < circleWidth; tx++ { + termX := circleStart + tx + var brailleRune rune + for sy := 0; sy < 4; sy++ { + for sx := 0; sx < 2; sx++ { + bx := float64(tx*2 + sx) + by := float64(ty*4 + sy) + dx := bx - cxBraille + dy := by - cyBraille + if dx*dx+dy*dy <= radius*radius { + brailleRune |= brailleDots[sy][sx] + } + } + } + if brailleRune != 0 { + buf.SetCell(ui.Cell{ + Rune: brailleOffset + brailleRune, + Style: ui.NewStyle(color), + }, image.Pt(termX, termY)) + } + } + } + + deltaStr := fmt.Sprintf(" %.4f", delta.Float64()) + buf.SetString(deltaStr, ui.NewStyle(color), image.Pt(circleEnd-9, rowCenterY)) + + volStr := fmt.Sprintf(" V:%.5f", totalVol.Float64()) + buf.SetString(volStr, ui.NewStyle(ui.ColorCyan), image.Pt(circleEnd, rowCenterY)) + } + + summaryY := inner.Max.Y - 1 + deltaColor := ui.ColorGreen + if totalDelta < 0 { + deltaColor = ui.ColorRed + } + summary := fmt.Sprintf( + " Total Δ: %.4f | POC: %.5f (Vol %.5f)", + totalDelta.Float64(), pocPrice.Float64(), pocVol.Float64()) + buf.SetString(summary, ui.NewStyle(deltaColor), image.Pt(inner.Min.X, summaryY)) + buf.SetString(" [q] quit", ui.NewStyle(ui.ColorWhite), image.Pt(inner.Max.X-10, summaryY)) +} From 5188a1b98a80c364521e317e42e0354936fc914b Mon Sep 17 00:00:00 2001 From: Peter Date: Sat, 13 Jun 2026 23:58:32 +0800 Subject: [PATCH 03/10] feat(dashboard): improve order flow widget rendering and add price aggregation --- pkg/cmd/dashboard.go | 19 +++ pkg/cmd/dashboard_test.go | 70 +++++++++++ pkg/cmd/widget/orderflow.go | 190 +++++++++++++++++++---------- pkg/cmd/widget/orderflow_test.go | 199 +++++++++++++++++++++++++++++++ 4 files changed, 413 insertions(+), 65 deletions(-) create mode 100644 pkg/cmd/dashboard_test.go create mode 100644 pkg/cmd/widget/orderflow_test.go diff --git a/pkg/cmd/dashboard.go b/pkg/cmd/dashboard.go index 3ef9ebf6f4..263ee1f860 100644 --- a/pkg/cmd/dashboard.go +++ b/pkg/cmd/dashboard.go @@ -9,6 +9,7 @@ import ( "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" @@ -19,6 +20,7 @@ func init() { orderFlowCmd.MarkFlagRequired("exchange") orderFlowCmd.Flags().String("symbol", "", "trading pair symbol") orderFlowCmd.MarkFlagRequired("symbol") + orderFlowCmd.Flags().String("aggregate", "0", "aggregate price unit; 0 disables aggregation") dashboardCmd.AddCommand(orderFlowCmd) RootCmd.AddCommand(dashboardCmd) } @@ -52,6 +54,15 @@ var orderFlowCmd = &cobra.Command{ if len(exName) == 0 || len(symbol) == 0 { return errors.New("--exchange and --symbol are required") } + aggregate, err := cmd.Flags().GetString("aggregate") + if err != nil { + return err + } + aggregateUnit, err := fixedpoint.NewFromString(aggregate) + if err != nil { + return err + } + // create a new public trade stream exMin, err := exchange.NewWithEnvVarPrefix(types.ExchangeName(exName), strings.ToUpper(exName)) ex, ok := exMin.(types.Exchange) @@ -64,6 +75,7 @@ var orderFlowCmd = &cobra.Command{ w := widget.NewOrderFlowWidget() w.Title = " Order Flow Circles (總量正規化) " stream.OnMarketTrade(types.TradeWith(symbol, func(trade types.Trade) { + trade.Price = aggregatePrice(trade.Price, aggregateUnit) w.UpdateTrade(trade) })) stream.SetPublicOnly() @@ -104,3 +116,10 @@ var orderFlowCmd = &cobra.Command{ } }, } + +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 index 62c250e9d8..1674a59497 100644 --- a/pkg/cmd/widget/orderflow.go +++ b/pkg/cmd/widget/orderflow.go @@ -4,6 +4,8 @@ import ( "fmt" "image" "math" + "sort" + "sync" "github.com/c9s/bbgo/pkg/fixedpoint" "github.com/c9s/bbgo/pkg/types" @@ -12,6 +14,16 @@ import ( const brailleOffset = '\u2800' +// 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 +) + var brailleDots = [4][2]rune{ {0x01, 0x08}, {0x02, 0x10}, @@ -35,62 +47,73 @@ func (p *PriceLevel) TotalVol() fixedpoint.Value { type OrderFlowWidget struct { ui.Block - levels []PriceLevel - indicesMap map[fixedpoint.Value]int + mu sync.Mutex + levels []PriceLevel } -func (w *OrderFlowWidget) SetLevels(levels []PriceLevel) { - w.levels = levels - for i, lvl := range levels { - w.indicesMap[lvl.Price] = i +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) } -func (w *OrderFlowWidget) UpdateTrade(trade types.Trade) { - idx, ok := w.indicesMap[trade.Price] - if !ok { - newLevel := PriceLevel{ - Price: trade.Price, - } - if trade.Side == types.SideTypeBuy { - newLevel.BidVol = trade.Quantity - } else { - newLevel.AskVol = trade.Quantity - } - w.levels = append(w.levels, newLevel) - w.indicesMap[trade.Price] = len(w.levels) - 1 - } else { - if trade.Side == types.SideTypeBuy { - w.levels[idx].BidVol = w.levels[idx].BidVol.Add(trade.Quantity) - } else { - w.levels[idx].AskVol = w.levels[idx].AskVol.Add(trade.Quantity) +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 { + w.mu.Lock() + levels := append([]PriceLevel(nil), w.levels...) + w.mu.Unlock() + + sort.Slice(levels, func(i, j int) bool { + return levels[i].Price.Compare(levels[j].Price) > 0 + }) + + return levels } func NewOrderFlowWidget() *OrderFlowWidget { return &OrderFlowWidget{ - Block: *ui.NewBlock(), - indicesMap: make(map[fixedpoint.Value]int), + Block: *ui.NewBlock(), } } func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { w.Block.Draw(buf) - if len(w.levels) == 0 { + levels := w.sortedLevelsSnapshot() + if len(levels) == 0 { return } maxVol := fixedpoint.Zero totalDelta := fixedpoint.Zero pocPrice := fixedpoint.Zero - pocVol := fixedpoint.Zero - for _, lvl := range w.levels { + pocIdx := 0 + for idx, lvl := range levels { if v := lvl.TotalVol(); v.Compare(maxVol) > 0 { maxVol = v pocPrice = lvl.Price - pocVol = v + pocIdx = idx } totalDelta = totalDelta.Add(lvl.Delta()) } @@ -99,10 +122,8 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { } inner := w.Inner - numLevels := len(w.levels) - priceColWidth := 8 - infoColWidth := 16 + infoColWidth := deltaColWidth + volColWidth circleStart := inner.Min.X + priceColWidth circleEnd := inner.Max.X - infoColWidth circleWidth := circleEnd - circleStart @@ -111,87 +132,126 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { } availableHeight := inner.Max.Y - inner.Min.Y - 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 + if start < 0 { + start = 0 + } + end := pocIdx + side + 1 + if end > len(levels) { + end = len(levels) + } + visibleLevels := levels[start:end] + hiddenBelow := len(levels) - end + + numLevels := len(visibleLevels) rowHeight := availableHeight / numLevels - if rowHeight < 2 { - rowHeight = 2 + if rowHeight < minRowHeight { + rowHeight = minRowHeight } maxRadius := math.Min(float64(circleWidth), float64(rowHeight*2)) * 0.85 + cyBraille := float64(rowHeight*4) / 2.0 - for i, lvl := range w.levels { - rowTopY := inner.Min.Y + i*rowHeight + // Circles and the price/delta/volume columns occupy disjoint x-ranges, so a + // single pass in price order is enough; on overlap the lower price's circle + // paints over the higher one. + for idx, lvl := range visibleLevels { + rowTopY := inner.Min.Y + idx*rowHeight rowCenterY := rowTopY + rowHeight/2 - if rowCenterY >= inner.Max.Y-2 { - break - } delta := lvl.Delta() totalVol := lvl.TotalVol() - ratio := totalVol.Div(maxVol).Float64() - radius := ratio * maxRadius + radius := totalVol.Div(maxVol).Float64() * maxRadius if radius < 1.5 { radius = 1.5 } color := ui.ColorGreen - if delta < 0 { + if delta.Sign() < 0 { color = ui.ColorRed - } else if delta == 0 { - color = ui.ColorYellow + } else if delta.IsZero() { + color = ui.ColorWhite } - priceStyle := ui.NewStyle(ui.ColorWhite) - if lvl.Price.Eq(pocPrice) { - priceStyle = ui.NewStyle(ui.ColorYellow, ui.ColorClear, ui.ModifierBold) - } - buf.SetString(fmt.Sprintf("%.5f", lvl.Price.Float64()), priceStyle, image.Pt(inner.Min.X, rowCenterY)) - - cxBraille := float64(circleWidth) - cyBraille := float64(rowHeight*4) / 2.0 - for ty := 0; ty < rowHeight; ty++ { termY := rowTopY + ty + if termY < inner.Min.Y { + continue + } if termY >= inner.Max.Y-2 { break } for tx := 0; tx < circleWidth; tx++ { - termX := circleStart + tx var brailleRune rune for sy := 0; sy < 4; sy++ { for sx := 0; sx < 2; sx++ { - bx := float64(tx*2 + sx) - by := float64(ty*4 + sy) - dx := bx - cxBraille - dy := by - cyBraille + dx := float64(tx*2+sx) - float64(circleWidth) + dy := float64(ty*4+sy) - cyBraille if dx*dx+dy*dy <= radius*radius { brailleRune |= brailleDots[sy][sx] } } } if brailleRune != 0 { + pt := image.Pt(circleStart+tx, termY) + // Merge dots with any circle already drawn here so a smaller + // circle drawn later cannot erase a larger one's filled cell. + if existing := buf.GetCell(pt).Rune; existing >= brailleOffset && existing < brailleOffset+0x100 { + brailleRune |= existing - brailleOffset + } buf.SetCell(ui.Cell{ Rune: brailleOffset + brailleRune, Style: ui.NewStyle(color), - }, image.Pt(termX, termY)) + }, pt) } } } - deltaStr := fmt.Sprintf(" %.4f", delta.Float64()) - buf.SetString(deltaStr, ui.NewStyle(color), image.Pt(circleEnd-9, rowCenterY)) + 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(circleEnd, rowCenterY), deltaColWidth) volStr := fmt.Sprintf(" V:%.5f", totalVol.Float64()) - buf.SetString(volStr, ui.NewStyle(ui.ColorCyan), image.Pt(circleEnd, rowCenterY)) + setPaddedString(buf, volStr, ui.NewStyle(ui.ColorCyan), image.Pt(circleEnd+deltaColWidth, rowCenterY), volColWidth) } summaryY := inner.Max.Y - 1 deltaColor := ui.ColorGreen - if totalDelta < 0 { + if totalDelta.Sign() < 0 { deltaColor = ui.ColorRed } summary := fmt.Sprintf( " Total Δ: %.4f | POC: %.5f (Vol %.5f)", - totalDelta.Float64(), pocPrice.Float64(), pocVol.Float64()) + totalDelta.Float64(), pocPrice.Float64(), maxVol.Float64()) + if start > 0 || hiddenBelow > 0 { + summary += fmt.Sprintf(" | hidden ↑%d ↓%d", start, hiddenBelow) + } buf.SetString(summary, ui.NewStyle(deltaColor), image.Pt(inner.Min.X, summaryY)) buf.SetString(" [q] quit", ui.NewStyle(ui.ColorWhite), image.Pt(inner.Max.X-10, summaryY)) } + +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..7d4c6240ef --- /dev/null +++ b/pkg/cmd/widget/orderflow_test.go @@ -0,0 +1,199 @@ +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). + w.SetRect(0, 0, 80, 46) + buf := ui.NewBuffer(image.Rect(0, 0, 80, 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{} +} From fdea4028f069e93c6ed566613ee5ac19083ab766 Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 14 Jun 2026 11:16:02 +0800 Subject: [PATCH 04/10] fix(orderflow): position price levels proportionally to price distance --- pkg/cmd/widget/orderflow.go | 45 ++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/widget/orderflow.go b/pkg/cmd/widget/orderflow.go index 1674a59497..0301604900 100644 --- a/pkg/cmd/widget/orderflow.go +++ b/pkg/cmd/widget/orderflow.go @@ -163,15 +163,54 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { rowHeight = minRowHeight } + // Position rows proportionally to price so the vertical gap between levels + // reflects the actual price distance, not just their rank. + 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) + + 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 + } + maxRadius := math.Min(float64(circleWidth), float64(rowHeight*2)) * 0.85 cyBraille := float64(rowHeight*4) / 2.0 // Circles and the price/delta/volume columns occupy disjoint x-ranges, so a // single pass in price order is enough; on overlap the lower price's circle // paints over the higher one. - for idx, lvl := range visibleLevels { - rowTopY := inner.Min.Y + idx*rowHeight - rowCenterY := rowTopY + rowHeight/2 + lastRowCenterY := drawMinY - 1 + for _, lvl := range visibleLevels { + // Map price → Y. Higher price sits nearer the top (smaller Y); equal + // prices share the mid-line. Nudge down by one row when two levels would + // collide so every visible level stays readable. + rowCenterY := (drawMinY + drawMaxY) / 2 + if !priceRange.IsZero() { + priceOffset := maxPrice.Sub(lvl.Price).Div(priceRange).Float64() + rowCenterY = drawMinY + int(math.Round(priceOffset*float64(drawMaxY-drawMinY))) + } + if rowCenterY <= lastRowCenterY { + rowCenterY = lastRowCenterY + 1 + } + if rowCenterY > drawMaxY || rowCenterY >= inner.Max.Y-2 { + break + } + lastRowCenterY = rowCenterY + rowTopY := rowCenterY - rowHeight/2 delta := lvl.Delta() totalVol := lvl.TotalVol() From 594bd299687e3bde833427628eacc11591a84977 Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 14 Jun 2026 11:21:00 +0800 Subject: [PATCH 05/10] refactor(dashboard): use Float64 flag for --aggregate instead of String Addresses dboyliao's review: the String flag + NewFromString introduced an unnecessary error path. Switching to Float64/GetFloat64/NewFromFloat removes that branch while preserving fractional units (0.1, 2.5, etc.) that Int would have silently broken. --- pkg/cmd/dashboard.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/dashboard.go b/pkg/cmd/dashboard.go index 263ee1f860..a20a06f3ce 100644 --- a/pkg/cmd/dashboard.go +++ b/pkg/cmd/dashboard.go @@ -20,7 +20,7 @@ func init() { orderFlowCmd.MarkFlagRequired("exchange") orderFlowCmd.Flags().String("symbol", "", "trading pair symbol") orderFlowCmd.MarkFlagRequired("symbol") - orderFlowCmd.Flags().String("aggregate", "0", "aggregate price unit; 0 disables aggregation") + orderFlowCmd.Flags().Float64("aggregate", 0, "aggregate price unit; 0 disables aggregation") dashboardCmd.AddCommand(orderFlowCmd) RootCmd.AddCommand(dashboardCmd) } @@ -54,14 +54,11 @@ var orderFlowCmd = &cobra.Command{ if len(exName) == 0 || len(symbol) == 0 { return errors.New("--exchange and --symbol are required") } - aggregate, err := cmd.Flags().GetString("aggregate") - if err != nil { - return err - } - aggregateUnit, err := fixedpoint.NewFromString(aggregate) + 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)) From c7d323541aa02de738417a69a681242dc0423b80 Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 14 Jun 2026 12:09:37 +0800 Subject: [PATCH 06/10] fix(orderflow): keep every selected level visible with proportional spacing --- pkg/cmd/widget/orderflow.go | 53 ++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/pkg/cmd/widget/orderflow.go b/pkg/cmd/widget/orderflow.go index 0301604900..a7216464b9 100644 --- a/pkg/cmd/widget/orderflow.go +++ b/pkg/cmd/widget/orderflow.go @@ -163,8 +163,7 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { rowHeight = minRowHeight } - // Position rows proportionally to price so the vertical gap between levels - // reflects the actual price distance, not just their rank. + // Price range of the visible levels, used to position rows proportionally. minPrice := visibleLevels[0].Price maxPrice := visibleLevels[0].Price for _, lvl := range visibleLevels { @@ -177,6 +176,8 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { } priceRange := maxPrice.Sub(minPrice) + // Band the rows may occupy: half a row of margin top and bottom (so circles + // aren't clipped) 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 { @@ -187,29 +188,43 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { 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 + } + } + maxRadius := math.Min(float64(circleWidth), float64(rowHeight*2)) * 0.85 cyBraille := float64(rowHeight*4) / 2.0 // Circles and the price/delta/volume columns occupy disjoint x-ranges, so a // single pass in price order is enough; on overlap the lower price's circle // paints over the higher one. - lastRowCenterY := drawMinY - 1 - for _, lvl := range visibleLevels { - // Map price → Y. Higher price sits nearer the top (smaller Y); equal - // prices share the mid-line. Nudge down by one row when two levels would - // collide so every visible level stays readable. - rowCenterY := (drawMinY + drawMaxY) / 2 - if !priceRange.IsZero() { - priceOffset := maxPrice.Sub(lvl.Price).Div(priceRange).Float64() - rowCenterY = drawMinY + int(math.Round(priceOffset*float64(drawMaxY-drawMinY))) - } - if rowCenterY <= lastRowCenterY { - rowCenterY = lastRowCenterY + 1 - } - if rowCenterY > drawMaxY || rowCenterY >= inner.Max.Y-2 { - break - } - lastRowCenterY = rowCenterY + for i, lvl := range visibleLevels { + rowCenterY := rowCenterYs[i] rowTopY := rowCenterY - rowHeight/2 delta := lvl.Delta() From 5b4278bd44078816c1918f799330e9f11bb66220 Mon Sep 17 00:00:00 2001 From: dboyliao <6830390+dboyliao@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:03:27 +0800 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=94=84=20refactor(orderflow):=20sim?= =?UTF-8?q?plify=20drawing=20logic=20by=20extracting=20circle=20drawing=20?= =?UTF-8?q?to=20a=20separate=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/cmd/widget/orderflow.go | 45 +---------------------------- pkg/cmd/widget/util.go | 56 +++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 44 deletions(-) create mode 100644 pkg/cmd/widget/util.go diff --git a/pkg/cmd/widget/orderflow.go b/pkg/cmd/widget/orderflow.go index a7216464b9..a987264e6f 100644 --- a/pkg/cmd/widget/orderflow.go +++ b/pkg/cmd/widget/orderflow.go @@ -12,8 +12,6 @@ import ( ui "github.com/gizak/termui/v3" ) -const brailleOffset = '\u2800' - // 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 @@ -24,13 +22,6 @@ const ( volColWidth = 16 ) -var brailleDots = [4][2]rune{ - {0x01, 0x08}, - {0x02, 0x10}, - {0x04, 0x20}, - {0x40, 0x80}, -} - type PriceLevel struct { Price fixedpoint.Value AskVol fixedpoint.Value @@ -218,14 +209,12 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { } maxRadius := math.Min(float64(circleWidth), float64(rowHeight*2)) * 0.85 - cyBraille := float64(rowHeight*4) / 2.0 // Circles and the price/delta/volume columns occupy disjoint x-ranges, so a // single pass in price order is enough; on overlap the lower price's circle // paints over the higher one. for i, lvl := range visibleLevels { rowCenterY := rowCenterYs[i] - rowTopY := rowCenterY - rowHeight/2 delta := lvl.Delta() totalVol := lvl.TotalVol() @@ -241,39 +230,7 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { color = ui.ColorWhite } - for ty := 0; ty < rowHeight; ty++ { - termY := rowTopY + ty - if termY < inner.Min.Y { - continue - } - if termY >= inner.Max.Y-2 { - break - } - for tx := 0; tx < circleWidth; tx++ { - var brailleRune rune - for sy := 0; sy < 4; sy++ { - for sx := 0; sx < 2; sx++ { - dx := float64(tx*2+sx) - float64(circleWidth) - dy := float64(ty*4+sy) - cyBraille - if dx*dx+dy*dy <= radius*radius { - brailleRune |= brailleDots[sy][sx] - } - } - } - if brailleRune != 0 { - pt := image.Pt(circleStart+tx, termY) - // Merge dots with any circle already drawn here so a smaller - // circle drawn later cannot erase a larger one's filled cell. - if existing := buf.GetCell(pt).Rune; existing >= brailleOffset && existing < brailleOffset+0x100 { - brailleRune |= existing - brailleOffset - } - buf.SetCell(ui.Cell{ - Rune: brailleOffset + brailleRune, - Style: ui.NewStyle(color), - }, pt) - } - } - } + drawCircle(buf, image.Pt(circleStart+circleWidth/2, rowCenterY), int(math.Round(radius)), ui.NewStyle(color)) priceStyle := ui.NewStyle(ui.ColorWhite) if lvl.Price.Eq(pocPrice) { diff --git a/pkg/cmd/widget/util.go b/pkg/cmd/widget/util.go new file mode 100644 index 0000000000..13fd7ca043 --- /dev/null +++ b/pkg/cmd/widget/util.go @@ -0,0 +1,56 @@ +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) + 0.5 + cy := float64(center.Y*4) + 1.5 + 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) + } + } +} From b57ce0272a072796e81ac26a2b921b845188d1c4 Mon Sep 17 00:00:00 2001 From: dboyliao <6830390+dboyliao@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:35:45 +0800 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=94=84=20refactor(orderflow):=20upd?= =?UTF-8?q?ate=20OrderFlowWidget=20to=20store=20and=20show=20last=20trade?= =?UTF-8?q?=20info?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/cmd/widget/orderflow.go | 38 +++++++++++++++++++++++++++----- pkg/cmd/widget/orderflow_test.go | 10 +++++---- pkg/cmd/widget/util.go | 6 ++--- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/widget/orderflow.go b/pkg/cmd/widget/orderflow.go index a987264e6f..037391259e 100644 --- a/pkg/cmd/widget/orderflow.go +++ b/pkg/cmd/widget/orderflow.go @@ -38,8 +38,9 @@ func (p *PriceLevel) TotalVol() fixedpoint.Value { type OrderFlowWidget struct { ui.Block - mu sync.Mutex - levels []PriceLevel + mu sync.Mutex + levels []PriceLevel + lastTrade *types.Trade } func (w *OrderFlowWidget) UpdateTrade(trade types.Trade) { @@ -55,6 +56,8 @@ func (w *OrderFlowWidget) UpdateTrade(trade types.Trade) { w.mu.Lock() defer w.mu.Unlock() w.upsertLevel(level) + t := trade + w.lastTrade = &t } func (w *OrderFlowWidget) upsertLevel(level PriceLevel) { @@ -70,16 +73,17 @@ func (w *OrderFlowWidget) upsertLevel(level PriceLevel) { w.levels = append(w.levels, level) } -func (w *OrderFlowWidget) sortedLevelsSnapshot() []PriceLevel { +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 + return levels, lastTrade } func NewOrderFlowWidget() *OrderFlowWidget { @@ -91,7 +95,7 @@ func NewOrderFlowWidget() *OrderFlowWidget { func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { w.Block.Draw(buf) - levels := w.sortedLevelsSnapshot() + levels, lastTrade := w.sortedLevelsSnapshot() if len(levels) == 0 { return } @@ -230,7 +234,7 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { color = ui.ColorWhite } - drawCircle(buf, image.Pt(circleStart+circleWidth/2, rowCenterY), int(math.Round(radius)), ui.NewStyle(color)) + DrawCircle(buf, image.Pt(circleStart+circleWidth/2, rowCenterY), int(math.Round(radius)), ui.NewStyle(color)) priceStyle := ui.NewStyle(ui.ColorWhite) if lvl.Price.Eq(pocPrice) { @@ -253,11 +257,33 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { summary := fmt.Sprintf( " Total Δ: %.4f | POC: %.5f (Vol %.5f)", totalDelta.Float64(), pocPrice.Float64(), maxVol.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 %.5f (%s)", lastTrade.Side, lastTrade.Price.Float64(), pocRel) + } if start > 0 || hiddenBelow > 0 { summary += fmt.Sprintf(" | hidden ↑%d ↓%d", start, hiddenBelow) } buf.SetString(summary, ui.NewStyle(deltaColor), image.Pt(inner.Min.X, summaryY)) buf.SetString(" [q] quit", ui.NewStyle(ui.ColorWhite), image.Pt(inner.Max.X-10, summaryY)) + + if lastTrade != nil && summaryY-1 >= inner.Min.Y { + tradeColor := ui.ColorGreen + if lastTrade.Side == types.SideTypeSell { + tradeColor = ui.ColorRed + } + tradeLine := fmt.Sprintf( + " Last Trade: %-4s %.5f x %.5f @ %s", + lastTrade.Side, lastTrade.Price.Float64(), lastTrade.Quantity.Float64(), + lastTrade.Time.Time().Format("15:04:05")) + buf.SetString(tradeLine, ui.NewStyle(tradeColor), image.Pt(inner.Min.X, summaryY-1)) + } } func setPaddedString(buf *ui.Buffer, s string, style ui.Style, point image.Point, width int) { diff --git a/pkg/cmd/widget/orderflow_test.go b/pkg/cmd/widget/orderflow_test.go index 7d4c6240ef..6e5a5fe661 100644 --- a/pkg/cmd/widget/orderflow_test.go +++ b/pkg/cmd/widget/orderflow_test.go @@ -37,7 +37,7 @@ func TestOrderFlowWidget_UpdateTradeAggregatesByPriceAndSide(t *testing.T) { Side: types.SideTypeSell, }) - levels := w.sortedLevelsSnapshot() + levels, _ := w.sortedLevelsSnapshot() assert.Len(t, levels, n) assert.True(t, hasPriceLevel(levels, fixedpoint.NewFromInt(1))) assert.True(t, hasPriceLevel(levels, fixedpoint.NewFromInt(n))) @@ -70,9 +70,11 @@ func TestOrderFlowWidget_DrawWindowsAroundPOC(t *testing.T) { }) } - // Tall enough that height is not the limiter (21 rows × 2). - w.SetRect(0, 0, 80, 46) - buf := ui.NewBuffer(image.Rect(0, 0, 80, 46)) + // 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) diff --git a/pkg/cmd/widget/util.go b/pkg/cmd/widget/util.go index 13fd7ca043..a88f91c4ef 100644 --- a/pkg/cmd/widget/util.go +++ b/pkg/cmd/widget/util.go @@ -15,13 +15,13 @@ var brailleDots = [4][2]rune{ {0x40, 0x80}, } -func drawCircle(buffer *ui.Buffer, center image.Point, radius int, style ui.Style) { +func DrawCircle(buffer *ui.Buffer, center image.Point, radius int, style ui.Style) { if radius <= 0 { return } - cx := float64(center.X*2) + 0.5 - cy := float64(center.Y*4) + 1.5 + cx := float64(center.X * 2) + cy := float64(center.Y * 4) r2 := float64(radius * radius) cellRadiusX := radius/2 + 1 From e971e5a757629797589dbd094e87c84ea2ab5311 Mon Sep 17 00:00:00 2001 From: dboyliao <6830390+dboyliao@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:41:26 +0800 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=94=84=20refactor(orderflow):=20upd?= =?UTF-8?q?ate=20OrderFlowWidget=20title=20and=20improve=20drawing=20with?= =?UTF-8?q?=20horizontal=20bars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/cmd/dashboard.go | 2 +- pkg/cmd/widget/orderflow.go | 77 ++++++++++++++++++++++--------------- pkg/cmd/widget/util.go | 13 +++++++ 3 files changed, 61 insertions(+), 31 deletions(-) diff --git a/pkg/cmd/dashboard.go b/pkg/cmd/dashboard.go index a20a06f3ce..746c532dc4 100644 --- a/pkg/cmd/dashboard.go +++ b/pkg/cmd/dashboard.go @@ -70,7 +70,7 @@ var orderFlowCmd = &cobra.Command{ stream.Subscribe(types.MarketTradeChannel, symbol, types.SubscribeOptions{}) w := widget.NewOrderFlowWidget() - w.Title = " Order Flow Circles (總量正規化) " + 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) diff --git a/pkg/cmd/widget/orderflow.go b/pkg/cmd/widget/orderflow.go index 037391259e..4e51414983 100644 --- a/pkg/cmd/widget/orderflow.go +++ b/pkg/cmd/widget/orderflow.go @@ -6,6 +6,7 @@ import ( "math" "sort" "sync" + "time" "github.com/c9s/bbgo/pkg/fixedpoint" "github.com/c9s/bbgo/pkg/types" @@ -41,6 +42,7 @@ type OrderFlowWidget struct { mu sync.Mutex levels []PriceLevel lastTrade *types.Trade + startTime time.Time } func (w *OrderFlowWidget) UpdateTrade(trade types.Trade) { @@ -89,6 +91,8 @@ func (w *OrderFlowWidget) sortedLevelsSnapshot() ([]PriceLevel, *types.Trade) { func NewOrderFlowWidget() *OrderFlowWidget { return &OrderFlowWidget{ Block: *ui.NewBlock(), + + startTime: time.Now(), } } @@ -119,12 +123,18 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { inner := w.Inner infoColWidth := deltaColWidth + volColWidth - circleStart := inner.Min.X + priceColWidth - circleEnd := inner.Max.X - infoColWidth - circleWidth := circleEnd - circleStart - if circleWidth < 4 { + 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.Max.Y - inner.Min.Y - 2 @@ -171,8 +181,8 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { } priceRange := maxPrice.Sub(minPrice) - // Band the rows may occupy: half a row of margin top and bottom (so circles - // aren't clipped) and three rows reserved at the bottom for the summary. + // 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 { @@ -212,20 +222,18 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { } } - maxRadius := math.Min(float64(circleWidth), float64(rowHeight*2)) * 0.85 + maxAbsDelta := fixedpoint.Zero + for _, lvl := range visibleLevels { + if d := lvl.Delta().Abs(); d.Compare(maxAbsDelta) > 0 { + maxAbsDelta = d + } + } - // Circles and the price/delta/volume columns occupy disjoint x-ranges, so a - // single pass in price order is enough; on overlap the lower price's circle - // paints over the higher one. for i, lvl := range visibleLevels { rowCenterY := rowCenterYs[i] delta := lvl.Delta() totalVol := lvl.TotalVol() - radius := totalVol.Div(maxVol).Float64() * maxRadius - if radius < 1.5 { - radius = 1.5 - } color := ui.ColorGreen if delta.Sign() < 0 { @@ -234,7 +242,16 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { color = ui.ColorWhite } - DrawCircle(buf, image.Pt(circleStart+circleWidth/2, rowCenterY), int(math.Round(radius)), ui.NewStyle(color)) + 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) { @@ -243,10 +260,10 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { 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(circleEnd, rowCenterY), deltaColWidth) + 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(circleEnd+deltaColWidth, rowCenterY), volColWidth) + setPaddedString(buf, volStr, ui.NewStyle(ui.ColorCyan), image.Pt(barEnd+deltaColWidth, rowCenterY), volColWidth) } summaryY := inner.Max.Y - 1 @@ -265,25 +282,25 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { case -1: pocRel = "↓ POC" } - summary += fmt.Sprintf(" | Last Trade: %s %.5f (%s)", lastTrade.Side, lastTrade.Price.Float64(), pocRel) + 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)) buf.SetString(" [q] quit", ui.NewStyle(ui.ColorWhite), image.Pt(inner.Max.X-10, summaryY)) - - if lastTrade != nil && summaryY-1 >= inner.Min.Y { - tradeColor := ui.ColorGreen - if lastTrade.Side == types.SideTypeSell { - tradeColor = ui.ColorRed - } - tradeLine := fmt.Sprintf( - " Last Trade: %-4s %.5f x %.5f @ %s", - lastTrade.Side, lastTrade.Price.Float64(), lastTrade.Quantity.Float64(), - lastTrade.Time.Time().Format("15:04:05")) - buf.SetString(tradeLine, ui.NewStyle(tradeColor), image.Pt(inner.Min.X, summaryY-1)) - } } func setPaddedString(buf *ui.Buffer, s string, style ui.Style, point image.Point, width int) { diff --git a/pkg/cmd/widget/util.go b/pkg/cmd/widget/util.go index a88f91c4ef..b346f7aa2e 100644 --- a/pkg/cmd/widget/util.go +++ b/pkg/cmd/widget/util.go @@ -54,3 +54,16 @@ func DrawCircle(buffer *ui.Buffer, center image.Point, radius int, style ui.Styl } } } + +// 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), + ) + } +} From e8a0e817ed6c42d3e77b141b44f7902b0a32281c Mon Sep 17 00:00:00 2001 From: dboyliao <6830390+dboyliao@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:52:43 +0800 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=94=84=20refactor(orderflow):=20sup?= =?UTF-8?q?port=20scrolling=20up/down=20price=20levels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/cmd/dashboard.go | 1 + pkg/cmd/widget/orderflow.go | 61 +++++++++++++++++++++++++++++-------- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/dashboard.go b/pkg/cmd/dashboard.go index 746c532dc4..8b554f533b 100644 --- a/pkg/cmd/dashboard.go +++ b/pkg/cmd/dashboard.go @@ -104,6 +104,7 @@ var orderFlowCmd = &cobra.Command{ 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) diff --git a/pkg/cmd/widget/orderflow.go b/pkg/cmd/widget/orderflow.go index 4e51414983..192b3d3542 100644 --- a/pkg/cmd/widget/orderflow.go +++ b/pkg/cmd/widget/orderflow.go @@ -39,10 +39,11 @@ func (p *PriceLevel) TotalVol() fixedpoint.Value { type OrderFlowWidget struct { ui.Block - mu sync.Mutex - levels []PriceLevel - lastTrade *types.Trade - startTime time.Time + mu sync.Mutex + levels []PriceLevel + lastTrade *types.Trade + startTime time.Time + visibleLevelsOffset int } func (w *OrderFlowWidget) UpdateTrade(trade types.Trade) { @@ -99,27 +100,30 @@ func NewOrderFlowWidget() *OrderFlowWidget { 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 } - maxVol := fixedpoint.Zero + // 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(maxVol) > 0 { - maxVol = v + if v := lvl.TotalVol(); v.Compare(pocVol) > 0 { + pocVol = v pocPrice = lvl.Price pocIdx = idx } totalDelta = totalDelta.Add(lvl.Delta()) } - if maxVol.IsZero() { + if pocVol.IsZero() { return } + // Calculate the canvas area inner := w.Inner infoColWidth := deltaColWidth + volColWidth @@ -136,7 +140,7 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { maxBarHalfWidth = 1 } - availableHeight := inner.Max.Y - inner.Min.Y - 2 + availableHeight := inner.Dy() - 2 // Show at most maxOrderFlowSideLevels above and below the POC, shrinking // further if the terminal cannot fit that many rows. @@ -152,13 +156,32 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { } start := pocIdx - side + end := pocIdx + side + 1 if start < 0 { start = 0 } - end := pocIdx + side + 1 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 @@ -273,7 +296,7 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { } summary := fmt.Sprintf( " Total Δ: %.4f | POC: %.5f (Vol %.5f)", - totalDelta.Float64(), pocPrice.Float64(), maxVol.Float64()) + totalDelta.Float64(), pocPrice.Float64(), pocVol.Float64()) if lastTrade != nil { pocRel := "= POC" switch lastTrade.Price.Compare(pocPrice) { @@ -282,7 +305,7 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { case -1: pocRel = "↓ POC" } - summary += fmt.Sprintf(" | Last Trade: %s %s@%.5f (%s)", + summary += fmt.Sprintf(" | Last Trade: %s %s @ %.5f (%s)", lastTrade.Side, lastTrade.Quantity, lastTrade.Price.Float64(), @@ -300,7 +323,19 @@ func (w *OrderFlowWidget) Draw(buf *ui.Buffer) { image.Pt(inner.Min.X, summaryY-1), ) buf.SetString(summary, ui.NewStyle(deltaColor), image.Pt(inner.Min.X, summaryY)) - buf.SetString(" [q] quit", ui.NewStyle(ui.ColorWhite), image.Pt(inner.Max.X-10, 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) {