package mujica import ( "fmt" "log/slog" "math" "github.com/egoist/mygo/ui" "mujicaui/icons" "mujicaui/internal" "mujicaui/theme" ) // DepthLadderOptions configure a DepthLadder. TickSize is required. type DepthLadderOptions struct { TickSize float64 // Decimals of prices and sizes; zero means 2 and 0. Decimals, SizeDecimals int // Rows shown; zero means 15. Rows int Disabled bool } // DepthLadderState is the ladder's scroll from last price, in ticks. type DepthLadderState struct { Offset int cache depthLadderCache } type depthLadderCache struct { key bookKey bids, asks map[int64]float64 } // DepthLadderResult is a built DepthLadder and its clicked cell. type DepthLadderResult struct { Element *ui.Element price float64 side OrderSide } // Clicked returns the clicked cell's price and side. func (r DepthLadderResult) Clicked() (price float64, side OrderSide, ok bool) { return r.price, r.side, r.side != 0 } // DepthLadder is a price ladder around last; cell clicks order. func DepthLadder(c *ui.Context, s *DepthLadderState, last float64, book OrderBookData, opts DepthLadderOptions) DepthLadderResult { dec := priceDecimals("DepthLadder", opts.Decimals) if opts.SizeDecimals < 0 || opts.Rows < 0 { panic("mujica: DepthLadder SizeDecimals and Rows cannot be negative") } scale := math.Pow(10, float64(dec)) tick := int64(math.Round(opts.TickSize * scale)) if tick < 1 || math.Abs(opts.TickSize*scale-float64(tick)) > 1e-6 { panic(fmt.Sprintf("mujica: DepthLadder TickSize %v is not a whole number of %d-decimal units", opts.TickSize, dec)) } rows := opts.Rows if rows == 0 { rows = 15 } depthLadderRefresh(s, book, scale, tick) center := int64(math.Round(last*scale/float64(tick))) * tick k := Tokens(c) d := Density(c) var r DepthLadderResult r.Element = ui.Column(c).Gap(d.Unit()).Disabled(opts.Disabled).Role(ui.RoleTable).Label(msg(c, "depth_ladder.label")) disabled := r.Element.IsDisabled() move := func(by int) { if !disabled && by != 0 { s.Offset += by slog.Debug("mujica: depth_ladder scrolled", "offset", s.Offset) } } if r.Element.Shortcut(0, ui.KeyPageUp) { move(rows / 2) } if r.Element.Shortcut(0, ui.KeyPageDown) { move(-rows / 2) } r.Element.Children(func() { ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { ui.Text(c, msg(c, "depth_ladder.bid")).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Grow(1).Basis(0).TextAlign(ui.End) ui.Text(c, msg(c, "depth_ladder.price")).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Grow(1).Basis(0).TextAlign(ui.Center) ui.Text(c, msg(c, "depth_ladder.ask")).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Grow(1).Basis(0) }) grid := ui.Column(c).FocusGroup(ui.Vertical | ui.Horizontal) grid.HandleInput(func(ev ui.InputEvent) bool { if ev.Kind != ui.InputScroll || disabled || ev.DY == 0 { return false } move(int(math.Copysign(1, float64(-ev.DY)))) return true }) grid.Children(func() { top := center + int64(s.Offset+rows/2)*tick for i := range rows { t := top - int64(i)*tick depthLadderRow(c, &r, s, t, t == center, scale, dec, opts.SizeDecimals) } }) if s.Offset != 0 { if Button(c, msg(c, "depth_ladder.recenter"), ButtonOptions{Variant: Ghost, Icon: icons.Must("refresh-cw"), Disabled: disabled}).Clicked() { s.Offset = 0 } } }) return r } // depthLadderRow is a buy cell, the price, a sell cell. func depthLadderRow(c *ui.Context, r *DepthLadderResult, s *DepthLadderState, t int64, current bool, scale float64, dec, sizeDec int) { k := Tokens(c) d := Density(c) price := float64(t) / scale text := statisticFormat(price, dec) ui.Row(c).Key(t).MinHeight(d.ControlHeight()).AlignItems(ui.Stretch).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() { cell := func(side OrderSide, size float64) { label := fmt.Sprintf(msg(c, fmt.Sprintf("depth_ladder.order%d", side)), text) b := ui.ButtonBase(c).Grow(1).Basis(0).Padding(0, d.Unit()*2).Label(label) if side == SideBuy { b.Justify(ui.End) } else { b.Justify(ui.Start) } internal.Ring(b, k.Focus, 0) if b.Hovered() { b.Background(trendColor(c, side.sign()).Alpha(0.14)) } b.Children(func() { if size > 0 { ui.Text(c, statisticFormat(size, sizeDec)).TextColor(trendColor(c, side.sign())).FontFeatures("tnum").FontWeight(600).SingleLine() } }) if b.Clicked() && !b.IsDisabled() { r.price, r.side = price, side slog.Debug("mujica: depth_ladder order", "side", side) } } cell(SideBuy, s.cache.bids[t]) mid := ui.Row(c).Grow(1).Basis(0).Justify(ui.Center).AlignItems(ui.Center).Gap(d.Unit()).Background(k.Background) if current { mid.Background(k.Selection).Label(msg(c, "depth_ladder.last") + " " + text) mid.DrawOver(func(p *ui.Painter, rect ui.Rect) { internal.Frame(p, rect, 2, 4, k.AccentText) }) } mid.Children(func() { if current { ui.Box(c).Size(8, 8).Draw(func(p *ui.Painter, rect ui.Rect) { p.FillPath(internal.Diamond(rect.X+4, rect.Y+4, 8), k.AccentText) }) } t := ui.Text(c, text).FontFeatures("tnum").SingleLine() if current { t.FontWeight(700) } }) cell(SideSell, s.cache.asks[t]) }) } // depthLadderRefresh indexes sizes by tick when the book changed. func depthLadderRefresh(s *DepthLadderState, book OrderBookData, scale float64, tick int64) { key := bookKeyOf(book, float64(tick)/scale) if s.cache.bids != nil && s.cache.key == key { return } index := func(ls []BookLevel) map[int64]float64 { m := make(map[int64]float64, len(ls)) for _, l := range ls { m[int64(math.Round(l.Price*scale/float64(tick)))*tick] += l.Size } return m } s.cache = depthLadderCache{key: key, bids: index(book.Bids), asks: index(book.Asks)} }