package mujica import ( "cmp" "fmt" "log/slog" "math" "slices" "github.com/egoist/mygo/ui" "mujicaui/internal" "mujicaui/theme" ) // OrderSide is the buy or sell side. type OrderSide int const ( SideBuy OrderSide = iota + 1 SideSell ) // sign is +1 for buying and -1 for selling. func (s OrderSide) sign() int { switch s { case SideBuy: return 1 case SideSell: return -1 } panic(fmt.Sprintf("mujica: unknown OrderSide %d", s)) } // BookLevel is a price and the size resting there. type BookLevel struct { Price, Size float64 } // OrderBookData is the book; bump Version on in-place changes. type OrderBookData struct { Bids, Asks []BookLevel Version int } // OrderBookOptions configure an OrderBook. type OrderBookOptions struct { // Decimals of prices and sizes; zero means 2 and 0. Decimals, SizeDecimals int // Depth is the levels shown each side; zero means 8. Depth int // Groups are the offered aggregation steps; nil shows raw levels. Groups []float64 Disabled bool } // OrderBookState is an OrderBook's chosen grouping and its aggregated view. type OrderBookState struct { // Group is the aggregation step; zero takes Groups[0]. Group float64 cache orderBookCache } type orderBookCache struct { key bookKey valid bool bids, asks []BookLevel } // bookKey tells when a book or its grouping changed. type bookKey struct { bid, ask *BookLevel nb, na int version int group float64 } func bookKeyOf(book OrderBookData, group float64) bookKey { first := func(ls []BookLevel) *BookLevel { if len(ls) == 0 { return nil } return &ls[0] } return bookKey{first(book.Bids), first(book.Asks), len(book.Bids), len(book.Asks), book.Version, group} } // OrderBookResult is a built OrderBook and its clicked level. type OrderBookResult struct { Element *ui.Element price float64 side OrderSide } // Clicked returns the clicked level's price and side. func (r OrderBookResult) Clicked() (price float64, side OrderSide, ok bool) { return r.price, r.side, r.side != 0 } // OrderBook shows clickable asks over bids with depth bars. func OrderBook(c *ui.Context, s *OrderBookState, book OrderBookData, opts OrderBookOptions) OrderBookResult { dec := priceDecimals("OrderBook", opts.Decimals) if opts.SizeDecimals < 0 || opts.Depth < 0 { panic("mujica: OrderBook SizeDecimals and Depth cannot be negative") } depth := opts.Depth if depth == 0 { depth = 8 } if len(opts.Groups) > 0 && s.Group == 0 { s.Group = opts.Groups[0] } if s.Group != 0 && !slices.Contains(opts.Groups, s.Group) { panic(fmt.Sprintf("mujica: OrderBook Group %v is not one of Groups", s.Group)) } orderBookRefresh(s, book, dec) k := Tokens(c) d := Density(c) var r OrderBookResult r.Element = ui.Column(c).Gap(d.Unit()).MinWidth(240).Disabled(opts.Disabled).Role(ui.RoleTable).Label(msg(c, "order_book.label")) r.Element.Children(func() { if len(opts.Groups) > 0 { var choices []SelectOption[float64] for _, g := range opts.Groups { choices = append(choices, SelectOption[float64]{Value: g, Label: statisticFormat(g, dec)}) } ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { ui.Text(c, msg(c, "order_book.group")).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)) if Select(c, &s.Group, choices, SelectOptions{Label: msg(c, "order_book.group"), Disabled: opts.Disabled}).Changed() { slog.Debug("mujica: order_book group", "step", s.Group) } }) } asks := s.cache.asks[:min(depth, len(s.cache.asks))] bids := s.cache.bids[:min(depth, len(s.cache.bids))] total := max(orderBookSum(asks), orderBookSum(bids)) orderBookHeader(c) list := ui.Column(c).FocusGroup(ui.Vertical) list.Children(func() { cum := orderBookSum(asks) for i := len(asks) - 1; i >= 0; i-- { orderBookRow(c, &r, asks[i], cum, total, SideSell, dec, opts.SizeDecimals) cum -= asks[i].Size } if len(asks) > 0 && len(bids) > 0 { ui.Row(c).Padding(d.Unit(), d.Unit()*2).BorderWidth(theme.BorderWidth, 0).BorderColor(k.Border).Children(func() { SpreadIndicator(c, bids[0].Price, asks[0].Price, SpreadIndicatorOptions{Decimals: dec}) }) } cum = 0 for _, l := range bids { cum += l.Size orderBookRow(c, &r, l, cum, total, SideBuy, dec, opts.SizeDecimals) } }) }) if r.side != 0 { slog.Debug("mujica: order_book clicked", "side", r.side) } return r } func orderBookSum(ls []BookLevel) float64 { var t float64 for _, l := range ls { t += l.Size } return t } // orderBookHeader names the columns. func orderBookHeader(c *ui.Context) { k := Tokens(c) d := Density(c) ui.Row(c).Padding(0, d.Unit()*2).Children(func() { for i, key := range []string{"order_book.price", "order_book.size", "order_book.total"} { t := ui.Text(c, msg(c, key)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Grow(1).Basis(0) if i > 0 { t.TextAlign(ui.End) } } }) } // orderBookRow is one clickable level with its depth bar. func orderBookRow(c *ui.Context, r *OrderBookResult, l BookLevel, cum, total float64, side OrderSide, dec, sizeDec int) { k := Tokens(c) d := Density(c) col := trendColor(c, side.sign()) price := statisticFormat(l.Price, dec) b := ui.ButtonBase(c).Key(fmt.Sprint(side, price)).MinHeight(d.ControlHeight()). Padding(0, d.Unit()*2).Radius(theme.ControlRadius). Label(fmt.Sprintf(msg(c, "order_book.level"), msg(c, fmt.Sprintf("order_book.side%d", side)), price, statisticFormat(l.Size, sizeDec))) internal.Ring(b, k.Focus, theme.ControlRadius) if b.Hovered() { b.Background(k.SurfaceHover) } share := float32(0) if total > 0 { share = float32(cum / total) } b.Draw(func(p *ui.Painter, rect ui.Rect) { w := rect.W * share p.Fill(ui.Rect{X: rect.X + rect.W - w, Y: rect.Y + 2, W: w, H: rect.H - 4}, col.Alpha(0.14), 0) }) b.Children(func() { ui.Text(c, price).TextColor(col).FontFeatures("tnum").FontWeight(600).Grow(1).Basis(0).SingleLine() ui.Text(c, statisticFormat(l.Size, sizeDec)).FontFeatures("tnum").TextAlign(ui.End).Grow(1).Basis(0).SingleLine() ui.Text(c, statisticFormat(cum, sizeDec)).FontFeatures("tnum").TextColor(k.TextMuted).TextAlign(ui.End).Grow(1).Basis(0).SingleLine() }) if b.Clicked() && !b.IsDisabled() { r.price, r.side = l.Price, side } } // orderBookRefresh regroups only when book or grouping changed. func orderBookRefresh(s *OrderBookState, book OrderBookData, dec int) { key := bookKeyOf(book, s.Group) if s.cache.valid && s.cache.key == key { return } s.cache = orderBookCache{key: key, valid: true, bids: orderBookGroup(book.Bids, s.Group, dec, false), asks: orderBookGroup(book.Asks, s.Group, dec, true)} } // orderBookGroup merges levels into group steps, best first. func orderBookGroup(ls []BookLevel, group float64, dec int, up bool) []BookLevel { scale := math.Pow(10, float64(dec)) step := int64(1) if group != 0 { step = int64(math.Round(group * scale)) if step < 1 || math.Abs(group*scale-float64(step)) > 1e-6 { panic(fmt.Sprintf("mujica: OrderBook group %v is not a whole number of %d-decimal ticks", group, dec)) } } sizes := map[int64]float64{} for _, l := range ls { if l.Price < 0 || l.Size < 0 { panic("mujica: OrderBook levels need prices and sizes of zero or more") } t := int64(math.Round(l.Price * scale)) // Bids round down, asks up: groups never beat real prices. b := t / step * step if up && b < t { b += step } sizes[b] += l.Size } out := make([]BookLevel, 0, len(sizes)) for t, sz := range sizes { out = append(out, BookLevel{Price: float64(t) / scale, Size: sz}) } slices.SortFunc(out, func(a, b BookLevel) int { if up { return cmp.Compare(a.Price, b.Price) } return cmp.Compare(b.Price, a.Price) }) return out }