diff --git a/bid_ask_bar.go b/bid_ask_bar.go new file mode 100644 index 0000000..b8d23ec --- /dev/null +++ b/bid_ask_bar.go @@ -0,0 +1,60 @@ +package mujica + +import ( + "fmt" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// BidAskBarOptions configure a BidAskBar. +type BidAskBarOptions struct { + // Decimals of the volumes shown; zero shows whole numbers. + Decimals int + // Label names the bar; empty uses the built-in name. + Label string +} + +// BidAskBar compares buy and sell volume in a split bar. +func BidAskBar(c *ui.Context, bid, ask float64, opts BidAskBarOptions) *ui.Element { + if bid < 0 || ask < 0 || math.IsNaN(bid+ask) { + panic("mujica: BidAskBar volumes must be zero or more") + } + if opts.Decimals < 0 { + panic("mujica: BidAskBar with negative Decimals") + } + k := Tokens(c) + u := Density(c).Unit() + total := bid + ask + share := 0.5 + if total > 0 { + share = bid / total + } + label := opts.Label + if label == "" { + label = msg(c, "bid_ask_bar.label") + } + buy := fmt.Sprintf(msg(c, "bid_ask_bar.buy"), statisticFormat(share*100, 1), statisticFormat(bid, opts.Decimals)) + sell := fmt.Sprintf(msg(c, "bid_ask_bar.sell"), statisticFormat(ask, opts.Decimals), statisticFormat((1-share)*100, 1)) + up, down := trendColor(c, 1), trendColor(c, -1) + upText, downText := up, down + if total == 0 { + up, down, upText, downText = k.Border, k.Border, k.TextMuted, k.TextMuted + } + e := ui.Column(c).Gap(u).MinWidth(160).Role(ui.RoleMeter).Label(label).Description(buy + ", " + sell) + e.Children(func() { + ui.Row(c).Justify(ui.SpaceBetween).Gap(u * 2).Children(func() { + ui.Text(c, buy).TextColor(upText).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").FontWeight(600).SingleLine() + ui.Text(c, sell).TextColor(downText).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").FontWeight(600).SingleLine() + }) + ui.Box(c).Height(8).FillWidth().Draw(func(p *ui.Painter, r ui.Rect) { + split := r.X + r.W*float32(share) + const gap = 2 + p.Fill(ui.Rect{X: r.X, Y: r.Y, W: max(0, split-r.X-gap/2), H: r.H}, up, theme.ControlRadius) + p.Fill(ui.Rect{X: split + gap/2, Y: r.Y, W: max(0, r.X+r.W-split-gap/2), H: r.H}, down, theme.ControlRadius) + }) + }) + return e +} diff --git a/bid_ask_bar_test.go b/bid_ask_bar_test.go new file mode 100644 index 0000000..89084d8 --- /dev/null +++ b/bid_ask_bar_test.go @@ -0,0 +1,34 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestBidAskBarShares(t *testing.T) { + bid, ask := 620.0, 380.0 + tt := tradingTester(GreenUp, 400, 100, func(c *ui.Context) { + ui.Box(c).Width(300).Children(func() { BidAskBar(c, bid, ask, BidAskBarOptions{}) }) + }) + if !tt.HasText("Buy 62.0% · 620") || !tt.HasText("380 · Sell 38.0%") { + t.Fatalf("texts %q", tt.Texts()) + } + bid, ask = 0, 0 + tt.Frame() + if !tt.HasText("Buy 50.0% · 0") { + t.Fatalf("empty: %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("negative volume accepted") + } + }() + tradingTester(GreenUp, 100, 100, func(c *ui.Context) { BidAskBar(c, -1, 2, BidAskBarOptions{}) }) +} + +func TestBidAskBarSnapshot(t *testing.T) { + snapshot(t, "bid_ask_bar", 340, 80, func(c *ui.Context) { + ui.Column(c).Padding(20).Children(func() { BidAskBar(c, 6234, 3820, BidAskBarOptions{}) }) + }) +} diff --git a/depth_ladder.go b/depth_ladder.go new file mode 100644 index 0000000..6b76af6 --- /dev/null +++ b/depth_ladder.go @@ -0,0 +1,178 @@ +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)} +} diff --git a/depth_ladder_test.go b/depth_ladder_test.go new file mode 100644 index 0000000..6af2c74 --- /dev/null +++ b/depth_ladder_test.go @@ -0,0 +1,113 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func depthLadderBook() OrderBookData { + return OrderBookData{Bids: []BookLevel{{100.00, 5}, {99.99, 3}}, Asks: []BookLevel{{100.01, 2}, {100.03, 7}}} +} + +func TestDepthLadderCentersAndOrders(t *testing.T) { + var s DepthLadderState + var price float64 + var side OrderSide + tt := tradingTester(GreenUp, 400, 600, func(c *ui.Context) { + if p, sd, ok := DepthLadder(c, &s, 100.004, depthLadderBook(), DepthLadderOptions{TickSize: 0.01, Rows: 7}).Clicked(); ok { + price, side = p, sd + } + }) + top, _ := tt.Find("100.03") + mid, _ := tt.Find("Last price 100.00") + bottom, _ := tt.Find("99.97") + if !(top.Y < mid.Y && mid.Y < bottom.Y) || tt.HasText("100.04") { + t.Fatalf("not centered: %v %v %v %q", top, mid, bottom, tt.Texts()) + } + if err := tt.Click("Sell at 100.03"); err != nil { + t.Fatal(err) + } + if price != 100.03 || side != SideSell { + t.Fatalf("clicked %v %v", price, side) + } + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeyEnter) + if price != 100.03 || side != SideBuy { + t.Fatalf("keyboard %v %v", price, side) + } + if !tt.HasText("7") || !tt.HasText("5") { + t.Fatalf("sizes: %q", tt.Texts()) + } +} + +func TestDepthLadderScrollsAndRecenters(t *testing.T) { + var s DepthLadderState + tt := tradingTester(GreenUp, 400, 600, func(c *ui.Context) { + DepthLadder(c, &s, 100, depthLadderBook(), DepthLadderOptions{TickSize: 0.01, Rows: 7}) + }) + _ = tt.Click("Buy at 100.00") + tt.Key(0, ui.KeyPageUp) + if s.Offset != 3 || !tt.HasText("100.06") { + t.Fatalf("page up: offset %d %q", s.Offset, tt.Texts()) + } + tt.Scroll(100, 200, 0, 40) + if s.Offset != 2 { + t.Fatalf("wheel: offset %d", s.Offset) + } + if err := tt.Click("Center on last price"); err != nil { + t.Fatal(err) + } + if s.Offset != 0 || tt.HasText("Center on last price") { + t.Fatalf("recenter: %d", s.Offset) + } +} + +// Only the visible rows are built, whatever the book's size. +func TestDepthLadderLargeBook(t *testing.T) { + var s DepthLadderState + book := OrderBookData{} + for i := range 100_000 { + book.Bids = append(book.Bids, BookLevel{Price: 100 - float64(i)/100, Size: 1}) + } + tt := tradingTester(GreenUp, 400, 600, func(c *ui.Context) { + DepthLadder(c, &s, 100, book, DepthLadderOptions{TickSize: 0.01, Rows: 9}) + }) + if n := len(tt.Texts()); n > 60 { + t.Fatalf("built %d texts", n) + } + // A sentinel survives only if the index is kept. + s.cache.bids[-1] = 42 + tt.Frame() + if s.cache.bids[-1] != 42 { + t.Fatal("index rebuilt without a change") + } +} + +func TestDepthLadderDisabledAndBadTick(t *testing.T) { + var s DepthLadderState + clicked := false + tt := tradingTester(GreenUp, 400, 600, func(c *ui.Context) { + _, _, clicked = DepthLadder(c, &s, 100, depthLadderBook(), DepthLadderOptions{TickSize: 0.01, Disabled: true}).Clicked() + }) + _ = tt.Click("Buy at 100.00") + tt.Scroll(100, 200, 0, 40) + if clicked || s.Offset != 0 { + t.Fatal("disabled ladder acted") + } + defer func() { + if recover() == nil { + t.Fatal("zero tick accepted") + } + }() + tradingTester(GreenUp, 100, 100, func(c *ui.Context) { DepthLadder(c, &s, 100, depthLadderBook(), DepthLadderOptions{}) }) +} + +func TestDepthLadderSnapshot(t *testing.T) { + snapshot(t, "depth_ladder", 340, 340, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + s := ui.Local(c.Root(), "snap", func() DepthLadderState { return DepthLadderState{} }) + DepthLadder(c, s, 100, depthLadderBook(), DepthLadderOptions{TickSize: 0.01, Rows: 7}) + }) + }) +} diff --git a/docs/components/161-ticker-tape.md b/docs/components/161-ticker-tape.md new file mode 100644 index 0000000..e97ec6a --- /dev/null +++ b/docs/components/161-ticker-tape.md @@ -0,0 +1,46 @@ +# 161 TickerTape + +行情滚动条。 + +## 用途 + +横向循环滚动的行情条,显示代码、价格与涨跌幅。指针悬停或键盘聚焦时暂停;减少动画时改为静态分页。 + +## 最小示例 + +```go +items := []mujica.TickerItem{{Symbol: "MUJC", Price: 412.08, ChangePercent: -1.35}, {Symbol: "AVE", Price: 88.2, ChangePercent: 2.1}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TickerTape(c, items, mujica.TickerTapeOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `items` | `[]TickerItem` | 必填 | 代码、价格、涨跌幅(%)。 | +| `Decimals` | `int` | 2 | 价格小数位,0 表示 2,负数 panic。 | +| `Speed` | `float32` | 40 | 每秒滚动的 DIP。 | +| `PerPage` | `int` | 4 | 减少动画时每页条数。 | +| `Label` | `string` | 内置“Market ticker” | 无障碍名称。 | + +## 状态 + +滚动、暂停(悬停或聚焦)、静态分页(减少动画)、空列表。 + +## 事件 + +无业务事件。 + +## 键盘操作 + +Tab 聚焦滚动条即暂停;减少动画时 ←/→ 翻页,也可用上一页、下一页按钮。 + +## 限制 + +- 滚动以四分之一 DIP 取整,暂停时停止请求重绘。 +- 第二份副本用于无缝循环,屏幕阅读器可能读到重复内容。 +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/162-quote-card.md b/docs/components/162-quote-card.md new file mode 100644 index 0000000..6df9188 --- /dev/null +++ b/docs/components/162-quote-card.md @@ -0,0 +1,43 @@ +# 162 QuoteCard + +报价卡片。 + +## 用途 + +显示代码、名称、现价、涨跌额与涨跌幅,并提供迷你图插槽。 + +## 最小示例 + +```go +quote := mujica.Quote{Symbol: "MUJC", Name: "Ave Mujica Holdings", Price: 412.08, Change: -5.62, ChangePercent: -1.35} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.QuoteCard(c, quote, mujica.QuoteCardOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `q` | `Quote` | 必填 | Symbol 必填,空值 panic;Name、Price、Change、ChangePercent。 | +| `Decimals` | `int` | 2 | 价格与涨跌额小数位。 | +| `Chart` | `func()` | nil | 迷你图插槽,高 40 DIP,例如 Sparkline。 | + +## 状态 + +默认;涨、跌、平三种方向。 + +## 事件 + +无。 + +## 键盘操作 + +卡片本身不获取焦点;插槽内控件自行处理键盘。 + +## 限制 + +- 不计算涨跌,数值由调用方提供。 +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/163-price-text.md b/docs/components/163-price-text.md new file mode 100644 index 0000000..a13c7a7 --- /dev/null +++ b/docs/components/163-price-text.md @@ -0,0 +1,46 @@ +# 163 PriceText + +价格文本。 + +## 用途 + +按固定小数位与千分位显示价格;可与参考价比较着色并加箭头;价格变化时闪烁一次。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.UseTrend(c, mujica.RedUp) + mujica.PriceText(c, 18234.5, mujica.PriceTextOptions{Compare: true, Reference: 18000}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `value` | `float64` | 必填 | 价格。 | +| `Decimals` | `int` | 2 | 小数位,0 表示 2,负数 panic。 | +| `NoGrouping` | `bool` | false | 不显示千分位。 | +| `Compare / Reference` | `bool / float64` | false / 0 | 按相对 Reference 的方向着色并加箭头。 | +| `NoFlash` | `bool` | false | 变化时不闪烁。 | +| `Size` | `float32` | 正文字号 | 字号(DIP),随系统文字缩放。 | + +## 状态 + +默认;上涨、下跌、持平;变化闪烁(360 ms 淡出)。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点。 + +## 限制 + +- 闪烁由帧间比较值得出:同一元素的值改变才会闪烁。减少动画时不闪烁,结束后不再请求重绘。 +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/164-price-change-badge.md b/docs/components/164-price-change-badge.md new file mode 100644 index 0000000..af8f3d3 --- /dev/null +++ b/docs/components/164-price-change-badge.md @@ -0,0 +1,42 @@ +# 164 PriceChangeBadge + +涨跌徽标。 + +## 用途 + +显示涨跌额与涨跌幅,带箭头、正负号与语义底色。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PriceChangeBadge(c, -5.62, -1.35, mujica.PriceChangeBadgeOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `change` | `float64` | 必填 | 涨跌额。 | +| `percent` | `float64` | 必填 | 涨跌幅(%),固定 2 位小数。 | +| `Decimals` | `int` | 2 | 涨跌额小数位。 | +| `HidePercent` | `bool` | false | 只显示涨跌额。 | + +## 状态 + +上涨、下跌、持平。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点;无障碍名称含方向词与数值。 + +## 限制 + +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/165-order-book.md b/docs/components/165-order-book.md new file mode 100644 index 0000000..a06c386 --- /dev/null +++ b/docs/components/165-order-book.md @@ -0,0 +1,52 @@ +# 165 OrderBook + +盘口。 + +## 用途 + +按价格显示卖档(上)与买档(下),带累计深度条与价差;可按价格步长合并档位;点击档位回传价格。 + +## 最小示例 + +```go +var state mujica.OrderBookState +book := mujica.OrderBookData{ + Bids: []mujica.BookLevel{{Price: 100.00, Size: 5}, {Price: 99.99, Size: 3}}, + Asks: []mujica.BookLevel{{Price: 100.01, Size: 1}, {Price: 100.02, Size: 2}}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if price, side, ok := mujica.OrderBook(c, &state, book, mujica.OrderBookOptions{Groups: []float64{0.01, 0.05}}).Clicked(); ok { + fmt.Println("order at", price, side) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `s` | `*OrderBookState` | 必填 | Group 为合并步长,0 取 Groups 第一项;不在 Groups 中 panic。 | +| `book` | `OrderBookData` | 必填 | Bids、Asks 与 Version(原地修改时递增)。 | +| `Decimals / SizeDecimals` | `int` | 2 / 0 | 价格与数量小数位。 | +| `Depth` | `int` | 8 | 每侧档数。 | +| `Groups` | `[]float64` | nil | 可选合并步长,须为价格精度的整数倍,否则 panic。 | +| `Disabled` | `bool` | false | 禁用点击与合并选择。 | + +## 状态 + +默认、悬停、聚焦、禁用;空盘口时只显示表头。 + +## 事件 + +`OrderBookResult.Clicked()` 返回被点击档位的价格与方向。 + +## 键盘操作 + +Tab 进入档位列表,↑/↓ 在档位间移动,Enter/Space 选中价格。 + +## 限制 + +- 合并以整数价格单位计算:买档向下取整、卖档向上取整。只在数据或步长变化时重新聚合。 +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/166-level2-quotes.md b/docs/components/166-level2-quotes.md new file mode 100644 index 0000000..1b4d9bf --- /dev/null +++ b/docs/components/166-level2-quotes.md @@ -0,0 +1,46 @@ +# 166 Level2Quotes + +多档行情。 + +## 用途 + +并排显示买卖各 N 档的价格、数量与委托笔数;档位变化时高亮一次,并以箭头标出数量增减。 + +## 最小示例 + +```go +data := mujica.Level2Data{ + Bids: []mujica.L2Level{{Price: 100.00, Size: 500, Orders: 12}}, + Asks: []mujica.L2Level{{Price: 100.01, Size: 200, Orders: 3}}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Level2Quotes(c, data, mujica.Level2QuotesOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `data` | `Level2Data` | 必填 | Bids、Asks,最优价在前。 | +| `Decimals / SizeDecimals` | `int` | 2 / 0 | 价格与数量小数位。 | +| `Levels` | `int` | 5 | 每侧档数;不足的档位显示“—”。 | + +## 状态 + +默认、档位变化高亮(减少动画时只显示箭头)、空档。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点;每行有包含档位、价格、数量和笔数的无障碍名称。 + +## 限制 + +- 价格变化视为新档位,不标增减箭头。 +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/167-time-and-sales.md b/docs/components/167-time-and-sales.md new file mode 100644 index 0000000..d68511f --- /dev/null +++ b/docs/components/167-time-and-sales.md @@ -0,0 +1,48 @@ +# 167 TimeAndSales + +逐笔成交。 + +## 用途 + +逐笔成交列表,最新在上,只构建可见行;大单以菱形与粗体标记;可暂停跟随。 + +## 最小示例 + +```go +var state mujica.TimeAndSalesState +trades := []mujica.TradePrint{{ID: "1", Time: time.Now(), Price: 100.01, Size: 300, Side: mujica.SideBuy}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TimeAndSales(c, &state, trades, mujica.TimeAndSalesOptions{LargeSize: 1000}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `s` | `*TimeAndSalesState` | 必填 | Paused 暂停跟随。 | +| `trades` | `[]TradePrint` | 必填 | 按时间先后追加;ID 唯一。 | +| `Decimals / SizeDecimals` | `int` | 2 / 0 | 价格与数量小数位。 | +| `LargeSize` | `float64` | 0 | 数量达到即标记为大单。 | +| `Height` | `float32` | 320 | 高度(DIP)。 | +| `Status / Error` | `DataStatus / string` | Ready | 加载、失败等状态。 | + +## 状态 + +跟随、暂停(显示新成交数量)、加载、失败、空列表。 + +## 事件 + +`ChoiceResult.Changed()` 在暂停或继续时为 true。 + +## 键盘操作 + +Tab 到达暂停按钮(Enter/Space 切换)与列表;列表内 ↑/↓ 移动。 + +## 限制 + +- 基于 List 虚拟化,100,000 条成交只构建可见行。 +- 暂停时冻结显示的条数,新成交不打断阅读。 +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/168-depth-ladder.md b/docs/components/168-depth-ladder.md new file mode 100644 index 0000000..2421ee9 --- /dev/null +++ b/docs/components/168-depth-ladder.md @@ -0,0 +1,50 @@ +# 168 DepthLadder + +价格阶梯。 + +## 用途 + +按最小变动价位排列的价格阶梯(DOM):买量、价格、卖量三列,最新价居中并加框;点击买量或卖量单元格回调下单。 + +## 最小示例 + +```go +var state mujica.DepthLadderState +book := mujica.OrderBookData{Bids: []mujica.BookLevel{{Price: 100, Size: 5}}, Asks: []mujica.BookLevel{{Price: 100.01, Size: 2}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if price, side, ok := mujica.DepthLadder(c, &state, 100, book, mujica.DepthLadderOptions{TickSize: 0.01}).Clicked(); ok { + fmt.Println("place", side, "at", price) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `s` | `*DepthLadderState` | 必填 | Offset 为相对最新价滚动的档数。 | +| `last` | `float64` | 必填 | 最新价。 | +| `book` | `OrderBookData` | 必填 | 盘口数据。 | +| `TickSize` | `float64` | 必填 | 最小变动价位,须为价格精度的整数倍,否则 panic。 | +| `Decimals / SizeDecimals` | `int` | 2 / 0 | 小数位。 | +| `Rows` | `int` | 15 | 显示的行数。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +默认、悬停、聚焦、最新价行(底色、框线与菱形)、已滚动(显示回到最新价按钮)、禁用。 + +## 事件 + +`DepthLadderResult.Clicked()` 返回价格与方向(买量列为买入,卖量列为卖出)。 + +## 键盘操作 + +Tab 进入阶梯,方向键在单元格间移动,Enter/Space 下单;PageUp/PageDown 滚动半屏;滚轮逐档滚动。 + +## 限制 + +- 只构建可见行;按价位建立的数量索引只在数据变化时重建。 +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/169-spread-indicator.md b/docs/components/169-spread-indicator.md new file mode 100644 index 0000000..f071535 --- /dev/null +++ b/docs/components/169-spread-indicator.md @@ -0,0 +1,41 @@ +# 169 SpreadIndicator + +价差指示。 + +## 用途 + +显示卖一减买一的价差与占中间价的百分比;超过阈值或买卖倒挂时给出带图标的文字提示。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SpreadIndicator(c, 99.95, 100.05, mujica.SpreadIndicatorOptions{WarnPercent: 0.5}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `bid / ask` | `float64` | 必填 | 买一价与卖一价。 | +| `Decimals` | `int` | 2 | 价差小数位。 | +| `WarnPercent` | `float64` | 0 | 价差百分比达到即提示;0 不提示,负数 panic。 | + +## 状态 + +正常、价差过大、倒挂。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点;以状态角色读出。 + +## 限制 + +- 无。 diff --git a/docs/components/170-bid-ask-bar.md b/docs/components/170-bid-ask-bar.md new file mode 100644 index 0000000..6ef8828 --- /dev/null +++ b/docs/components/170-bid-ask-bar.md @@ -0,0 +1,41 @@ +# 170 BidAskBar + +买卖力量条。 + +## 用途 + +以分段条对比买卖成交量,两端标出占比与数量。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BidAskBar(c, 6234, 3820, mujica.BidAskBarOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `bid / ask` | `float64` | 必填 | 买量与卖量,负数或 NaN panic。 | +| `Decimals` | `int` | 0 | 数量小数位。 | +| `Label` | `string` | 内置“Bid and ask volume” | 无障碍名称。 | + +## 状态 + +默认;两者都为 0 时显示中性灰条与 50%。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点;以计量角色读出买卖占比。 + +## 限制 + +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/171-order-entry.md b/docs/components/171-order-entry.md new file mode 100644 index 0000000..fdbceb3 --- /dev/null +++ b/docs/components/171-order-entry.md @@ -0,0 +1,52 @@ +# 171 OrderEntry + +下单面板。 + +## 用途 + +限价、市价、止损与条件限价单的录入:方向、类型、数量、价格、触发价与有效期;同步与调用方异步校验;预估金额;提交防重入。建立在 Form 之上。 + +## 最小示例 + +```go +var state mujica.OrderEntryState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if order, ok := mujica.OrderEntry(c, &state, mujica.OrderEntryOptions{Symbol: "MUJC", Last: 100.2}).Submitted(); ok { + fmt.Println("place", order.Quantity) + state.EndSubmit("") + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `s` | `*OrderEntryState` | 必填 | Draft 为当前订单,Form 为表单状态。 | +| `Symbol` | `string` | 必填 | 代码,空值 panic。 | +| `Last` | `float64` | 0 | 最新价,用于市价预估和止损方向检查。 | +| `TickSize / LotSize` | `float64` | 0.01 / 1 | 价格与数量步长。 | +| `MaxQuantity` | `float64` | 0 | 数量上限,0 不限。 | +| `Initial` | `OrderDraft` | 买入限价 | 重置恢复的订单。 | +| `Validate` | `func(OrderDraft) string` | nil | 调用方同步规则。 | +| `Check` | `func(OrderDraft, uint64)` | nil | 异步检查(如可用资金),以 `s.Resolve(version, msg)` 回传;旧版本结果被丢弃。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +默认、字段错误(失焦或提交后)、异步检查中、提交中(按钮加载)、表单错误、禁用。 + +## 事件 + +`OrderEntryResult.Submitted()` 返回通过全部校验的订单;处理完毕后调用 `s.EndSubmit(err)`。 + +## 键盘操作 + +Tab 依次经过方向、类型、各字段与按钮;数量与价格字段 ↑/↓ 按步长调整;提交失败聚焦第一个错误字段。 + +## 限制 + +- 止损单检查:买入触发价须高于最新价,卖出须低于。 +- 数量字段的异步检查覆盖整张订单:任一字段变化都会让旧结果失效。 diff --git a/docs/components/172-quick-trade-buttons.md b/docs/components/172-quick-trade-buttons.md new file mode 100644 index 0000000..a5e2a07 --- /dev/null +++ b/docs/components/172-quick-trade-buttons.md @@ -0,0 +1,47 @@ +# 172 QuickTradeButtons + +快速交易按钮。 + +## 用途 + +以预设数量一键市价买入或卖出;可配置在发送前就近确认。 + +## 最小示例 + +```go +quantity := 10.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if side, q, ok := mujica.QuickTradeButtons(c, &quantity, mujica.QuickTradeButtonsOptions{Symbol: "MUJC", Confirm: true}).Traded(); ok { + fmt.Println(side, q) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `quantity` | `*float64` | 必填 | 当前数量,须为 Presets 之一,0 取第一项。 | +| `Symbol` | `string` | 必填 | 代码。 | +| `Presets` | `[]float64` | 1, 10, 100 | 数量预设。 | +| `Decimals` | `int` | 0 | 数量小数位。 | +| `Confirm` | `bool` | false | 发送前弹出确认,默认聚焦取消。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +默认、悬停、按下、聚焦、确认中、禁用。 + +## 事件 + +`QuickTradeResult.Traded()` 返回方向与数量。 + +## 键盘操作 + +Tab 到达数量分段与买卖按钮;Enter/Space 触发;确认浮层中 Enter 默认取消,Escape 关闭。 + +## 限制 + +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/173-position-table.md b/docs/components/173-position-table.md new file mode 100644 index 0000000..95443d8 --- /dev/null +++ b/docs/components/173-position-table.md @@ -0,0 +1,49 @@ +# 173 PositionTable + +持仓表。 + +## 用途 + +持仓列表:数量、成本、现价、市值、浮动盈亏与比例,每行提供平仓操作。基于 DataTable,以稳定行 ID 保存选择与排序。 + +## 最小示例 + +```go +var state mujica.DataTableState[string] +rows := []mujica.PositionRow{{ID: "p1", Symbol: "MUJC", Quantity: 100, AvgCost: 400, Price: 412.08}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if id, ok := mujica.PositionTable(c, &state, rows, mujica.PositionTableOptions{}).Acted(); ok { + fmt.Println("close", id) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `s` | `*DataTableState[string]` | 必填 | 表格状态。 | +| `rows` | `[]PositionRow` | 必填 | ID 必须稳定。 | +| `Decimals` | `int` | 2 | 小数位。 | +| `Version` | `int` | 0 | 原地修改行时递增。 | +| `Status / Error` | `DataStatus / string` | Ready | 加载与失败状态。 | +| `Disabled` | `bool` | false | 禁用平仓。 | + +## 状态 + +默认、排序、选中、加载、失败、空、禁用。 + +## 事件 + +`TableActionResult.Acted()` 返回要平仓的行 ID;`Table` 为 DataTable 事件。 + +## 键盘操作 + +沿用 DataTable 的键盘操作;Tab 到达各行的平仓按钮。 + +## 限制 + +- 只构建可见行。 +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/174-order-table.md b/docs/components/174-order-table.md new file mode 100644 index 0000000..aa1d93d --- /dev/null +++ b/docs/components/174-order-table.md @@ -0,0 +1,48 @@ +# 174 OrderTable + +委托表。 + +## 用途 + +委托列表:方向、类型、价格、成交进度与状态,按状态筛选;进行中的委托提供撤单。基于 DataTable。 + +## 最小示例 + +```go +var state mujica.OrderTableState +rows := []mujica.OrderRow{{ID: "o1", Symbol: "MUJC", Side: mujica.SideBuy, Price: 99.5, Quantity: 100, Filled: 40, Status: mujica.OrderPartial}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if id, ok := mujica.OrderTable(c, &state, rows, mujica.OrderTableOptions{}).Acted(); ok { + fmt.Println("cancel", id) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `s` | `*OrderTableState` | 必填 | Filter 为状态筛选,Table 为表格状态。 | +| `rows` | `[]OrderRow` | 必填 | Filled 不得超过 Quantity,否则 panic。 | +| `Decimals / QuantityDecimals` | `int` | 2 / 0 | 小数位。 | +| `Version` | `int` | 0 | 原地修改行时递增。 | +| `Status / Error` | `DataStatus / string` | Ready | 加载与失败状态。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +全部、进行中、已成交、已关闭四种筛选;部分成交进度条;禁用。 + +## 事件 + +`TableActionResult.Acted()` 返回要撤销的委托 ID。 + +## 键盘操作 + +筛选分段用方向键切换;表格沿用 DataTable 键盘操作;Tab 到达撤单按钮。 + +## 限制 + +- 筛选只在数据或条件变化时执行。 diff --git a/docs/components/175-trade-history-table.md b/docs/components/175-trade-history-table.md new file mode 100644 index 0000000..429704f --- /dev/null +++ b/docs/components/175-trade-history-table.md @@ -0,0 +1,49 @@ +# 175 TradeHistoryTable + +成交记录表。 + +## 用途 + +成交记录:时间、代码、方向、价格、数量与手续费;按日期范围筛选;导出当前范围的记录交给调用方。 + +## 最小示例 + +```go +var state mujica.TradeHistoryTableState +rows := []mujica.FillRow{{ID: "f1", Time: time.Now(), Symbol: "MUJC", Side: mujica.SideBuy, Price: 400, Quantity: 10, Fee: 1.2}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if fills, ok := mujica.TradeHistoryTable(c, &state, rows, mujica.TradeHistoryTableOptions{}).Exported(); ok { + fmt.Println("export", len(fills)) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `s` | `*TradeHistoryTableState` | 必填 | Range 为日期范围,零值不筛选。 | +| `rows` | `[]FillRow` | 必填 | 成交记录。 | +| `Decimals / QuantityDecimals` | `int` | 2 / 0 | 小数位。 | +| `Version` | `int` | 0 | 原地修改行时递增。 | +| `Status / Error` | `DataStatus / string` | Ready | 加载与失败状态。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +全部日期、指定范围、空范围、加载、失败、禁用。 + +## 事件 + +`TradeHistoryResult.Exported()` 返回导出请求时当前范围内的记录。 + +## 键盘操作 + +日期范围选择器与按钮按 Tab 顺序;表格沿用 DataTable 键盘操作。 + +## 限制 + +- 只回传数据,不生成文件:导出格式与写入由调用方负责。 +- 日期按每条记录自身时区的日期比较。 diff --git a/docs/components/176-pnl-display.md b/docs/components/176-pnl-display.md new file mode 100644 index 0000000..947e0b6 --- /dev/null +++ b/docs/components/176-pnl-display.md @@ -0,0 +1,44 @@ +# 176 PnLDisplay + +盈亏显示。 + +## 用途 + +并列显示已实现、未实现与当日盈亏,带正负号、箭头与语义色;可切换隐私遮蔽。 + +## 最小示例 + +```go +hidden := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PnLDisplay(c, mujica.PnL{Realized: 1200.5, Unrealized: -340.2, Today: 88}, &hidden, mujica.PnLDisplayOptions{Currency: "$"}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `pnl` | `PnL` | 必填 | Realized、Unrealized、Today。 | +| `hidden` | `*bool` | 必填 | 为 true 时以“••••”遮蔽。 | +| `Decimals` | `int` | 2 | 小数位。 | +| `Currency` | `string` | "" | 金额前缀,例如 "$"。 | + +## 状态 + +正、负、零;已遮蔽。 + +## 事件 + +`ChoiceResult.Changed()` 在遮蔽切换时为 true。 + +## 键盘操作 + +Tab 到达遮蔽按钮,Enter/Space 切换。 + +## 限制 + +- 遮蔽时无障碍名称只读出“已隐藏”,不读出金额。 +- 涨跌颜色由 `UseTrend(c, GreenUp|RedUp)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。 diff --git a/docs/components/177-leverage-slider.md b/docs/components/177-leverage-slider.md new file mode 100644 index 0000000..31ecb1e --- /dev/null +++ b/docs/components/177-leverage-slider.md @@ -0,0 +1,46 @@ +# 177 LeverageSlider + +杠杆滑块。 + +## 用途 + +在预设杠杆档位间选择草稿值,显示高风险与极高风险区间提示,按“确认”后才写入值。 + +## 最小示例 + +```go +leverage := 5.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.LeverageSlider(c, &leverage, mujica.LeverageSliderOptions{Warning: 10, Danger: 50}).Changed() { + fmt.Println("leverage", leverage) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `value` | `*float64` | 必填 | 当前杠杆,须为 Levels 之一。 | +| `Levels` | `[]float64` | 1–100 八档 | 递增档位,至少两档。 | +| `Warning / Danger` | `float64` | 0 | 高风险与极高风险起点;Danger 须大于 Warning。 | +| `Label` | `string` | 内置“Leverage” | 名称。 | +| `Disabled` | `bool` | false | 禁用。 | + +## 状态 + +默认、聚焦、拖动、高风险、极高风险、草稿未确认、禁用。 + +## 事件 + +`ChoiceResult.Changed()` 在确认写入时为 true。 + +## 键盘操作 + +Tab 聚焦滑块:←/↓ 与 →/↑ 逐档、Home/End 到两端;Tab 到达确认按钮。 + +## 限制 + +- 滑块命中区高 44 DIP;风险区间以色带、图标和文字共同表达。 diff --git a/docs/components/178-margin-indicator.md b/docs/components/178-margin-indicator.md new file mode 100644 index 0000000..e3d3332 --- /dev/null +++ b/docs/components/178-margin-indicator.md @@ -0,0 +1,41 @@ +# 178 MarginIndicator + +保证金率指示。 + +## 用途 + +显示保证金率(维持保证金 / 权益)及其与警戒线、强平线的关系。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MarginIndicator(c, 0.86, mujica.MarginIndicatorOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `ratio` | `float64` | 必填 | 保证金率,0 以上。 | +| `Warning / Liquidation` | `float64` | 0.8 / 1 | 警戒与强平线;须 0 < Warning < Liquidation。 | +| `Label` | `string` | 内置“Margin ratio” | 名称。 | + +## 状态 + +安全、追加保证金、强制平仓。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点;以计量角色读出比率、状态与两条线。 + +## 限制 + +- 状态同时用图标、文字与颜色表达;两条线粗细不同,并在下方以文字标注。 diff --git a/docs/components/179-risk-meter.md b/docs/components/179-risk-meter.md new file mode 100644 index 0000000..208b797 --- /dev/null +++ b/docs/components/179-risk-meter.md @@ -0,0 +1,41 @@ +# 179 RiskMeter + +风险等级。 + +## 用途 + +分段刻度显示风险等级,当前段上方有菱形标记,并显示等级名称与说明。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RiskMeter(c, 4, mujica.RiskMeterOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `level` | `int` | 必填 | 从 1 开始,超出范围 panic。 | +| `Levels` | `[]RiskLevel` | 内置五级 | 各级名称与说明,至少两级。 | +| `Label` | `string` | 内置“Risk level” | 名称。 | + +## 状态 + +各等级。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点;以计量角色读出“第 n 级,共 N 级”和名称。 + +## 限制 + +- 颜色只辅助:位置标记与等级文字同时表达等级。 diff --git a/docs/components/180-order-confirm-dialog.md b/docs/components/180-order-confirm-dialog.md new file mode 100644 index 0000000..00225c1 --- /dev/null +++ b/docs/components/180-order-confirm-dialog.md @@ -0,0 +1,52 @@ +# 180 OrderConfirmDialog + +下单确认对话框。 + +## 用途 + +以模态对话框展示下单摘要与风险提示,默认聚焦取消;可要求勾选确认后才能下单。 + +## 最小示例 + +```go +open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.OrderConfirmDialog(c, &open, mujica.OrderConfirmDialogOptions{ + Lines: []mujica.OrderSummaryLine{{Label: "Symbol", Value: "MUJC"}, {Label: "Quantity", Value: "100"}}, + Risk: "Leveraged products can lose more than the deposit.", + Acknowledge: "I understand the risk", + }) + if r.Confirmed { + fmt.Println("send order") + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `open` | `*bool` | 必填 | 是否显示。 | +| `Lines` | `[]OrderSummaryLine` | 必填 | 摘要行,空值 panic。 | +| `Title` | `string` | 内置“Confirm order” | 标题。 | +| `Risk` | `string` | "" | 风险提示。 | +| `Acknowledge` | `string` | "" | 二次确认复选框文字;设置后须勾选才能下单。 | +| `ConfirmLabel` | `string` | 内置“Place order” | 确认按钮文字。 | + +## 状态 + +打开、未勾选(下单按钮禁用)、已勾选。 + +## 事件 + +`AlertDialogResult.Confirmed` / `Canceled`;Escape 视为取消。 + +## 键盘操作 + +打开时焦点在取消上;Tab 在复选框与按钮间移动;Escape 取消。点击遮罩不关闭。 + +## 限制 + +- 每次打开都需重新勾选。 diff --git a/example_trading_test.go b/example_trading_test.go new file mode 100644 index 0000000..6c32d4f --- /dev/null +++ b/example_trading_test.go @@ -0,0 +1,221 @@ +package mujica_test + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func ExampleTickerTape() { + items := []mujica.TickerItem{{Symbol: "MUJC", Price: 412.08, ChangePercent: -1.35}, {Symbol: "AVE", Price: 88.2, ChangePercent: 2.1}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TickerTape(c, items, mujica.TickerTapeOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleQuoteCard() { + quote := mujica.Quote{Symbol: "MUJC", Name: "Ave Mujica Holdings", Price: 412.08, Change: -5.62, ChangePercent: -1.35} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.QuoteCard(c, quote, mujica.QuoteCardOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePriceText() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.UseTrend(c, mujica.RedUp) + mujica.PriceText(c, 18234.5, mujica.PriceTextOptions{Compare: true, Reference: 18000}) + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePriceChangeBadge() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PriceChangeBadge(c, -5.62, -1.35, mujica.PriceChangeBadgeOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleOrderBook() { + var state mujica.OrderBookState + book := mujica.OrderBookData{ + Bids: []mujica.BookLevel{{Price: 100.00, Size: 5}, {Price: 99.99, Size: 3}}, + Asks: []mujica.BookLevel{{Price: 100.01, Size: 1}, {Price: 100.02, Size: 2}}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if price, side, ok := mujica.OrderBook(c, &state, book, mujica.OrderBookOptions{Groups: []float64{0.01, 0.05}}).Clicked(); ok { + fmt.Println("order at", price, side) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleLevel2Quotes() { + data := mujica.Level2Data{ + Bids: []mujica.L2Level{{Price: 100.00, Size: 500, Orders: 12}}, + Asks: []mujica.L2Level{{Price: 100.01, Size: 200, Orders: 3}}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Level2Quotes(c, data, mujica.Level2QuotesOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTimeAndSales() { + var state mujica.TimeAndSalesState + trades := []mujica.TradePrint{{ID: "1", Time: time.Now(), Price: 100.01, Size: 300, Side: mujica.SideBuy}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TimeAndSales(c, &state, trades, mujica.TimeAndSalesOptions{LargeSize: 1000}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleDepthLadder() { + var state mujica.DepthLadderState + book := mujica.OrderBookData{Bids: []mujica.BookLevel{{Price: 100, Size: 5}}, Asks: []mujica.BookLevel{{Price: 100.01, Size: 2}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if price, side, ok := mujica.DepthLadder(c, &state, 100, book, mujica.DepthLadderOptions{TickSize: 0.01}).Clicked(); ok { + fmt.Println("place", side, "at", price) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSpreadIndicator() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SpreadIndicator(c, 99.95, 100.05, mujica.SpreadIndicatorOptions{WarnPercent: 0.5}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleBidAskBar() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BidAskBar(c, 6234, 3820, mujica.BidAskBarOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleOrderEntry() { + var state mujica.OrderEntryState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if order, ok := mujica.OrderEntry(c, &state, mujica.OrderEntryOptions{Symbol: "MUJC", Last: 100.2}).Submitted(); ok { + fmt.Println("place", order.Quantity) + state.EndSubmit("") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleQuickTradeButtons() { + quantity := 10.0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if side, q, ok := mujica.QuickTradeButtons(c, &quantity, mujica.QuickTradeButtonsOptions{Symbol: "MUJC", Confirm: true}).Traded(); ok { + fmt.Println(side, q) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePositionTable() { + var state mujica.DataTableState[string] + rows := []mujica.PositionRow{{ID: "p1", Symbol: "MUJC", Quantity: 100, AvgCost: 400, Price: 412.08}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if id, ok := mujica.PositionTable(c, &state, rows, mujica.PositionTableOptions{}).Acted(); ok { + fmt.Println("close", id) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleOrderTable() { + var state mujica.OrderTableState + rows := []mujica.OrderRow{{ID: "o1", Symbol: "MUJC", Side: mujica.SideBuy, Price: 99.5, Quantity: 100, Filled: 40, Status: mujica.OrderPartial}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if id, ok := mujica.OrderTable(c, &state, rows, mujica.OrderTableOptions{}).Acted(); ok { + fmt.Println("cancel", id) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTradeHistoryTable() { + var state mujica.TradeHistoryTableState + rows := []mujica.FillRow{{ID: "f1", Time: time.Now(), Symbol: "MUJC", Side: mujica.SideBuy, Price: 400, Quantity: 10, Fee: 1.2}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if fills, ok := mujica.TradeHistoryTable(c, &state, rows, mujica.TradeHistoryTableOptions{}).Exported(); ok { + fmt.Println("export", len(fills)) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePnLDisplay() { + hidden := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PnLDisplay(c, mujica.PnL{Realized: 1200.5, Unrealized: -340.2, Today: 88}, &hidden, mujica.PnLDisplayOptions{Currency: "$"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleLeverageSlider() { + leverage := 5.0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.LeverageSlider(c, &leverage, mujica.LeverageSliderOptions{Warning: 10, Danger: 50}).Changed() { + fmt.Println("leverage", leverage) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMarginIndicator() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MarginIndicator(c, 0.86, mujica.MarginIndicatorOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleRiskMeter() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RiskMeter(c, 4, mujica.RiskMeterOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleOrderConfirmDialog() { + open := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.OrderConfirmDialog(c, &open, mujica.OrderConfirmDialogOptions{ + Lines: []mujica.OrderSummaryLine{{Label: "Symbol", Value: "MUJC"}, {Label: "Quantity", Value: "100"}}, + Risk: "Leveraged products can lose more than the deposit.", + Acknowledge: "I understand the risk", + }) + if r.Confirmed { + fmt.Println("send order") + } + } + ui.Render(view, 400, 300, 1) +} diff --git a/examples/gallery/pages_trading.go b/examples/gallery/pages_trading.go index e79ef71..5a65056 100644 --- a/examples/gallery/pages_trading.go +++ b/examples/gallery/pages_trading.go @@ -1,3 +1,276 @@ package main -var tradingPages = []page{} +import ( + "fmt" + "math" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +const tradingCategory = "Trading" + +// tradingTrend shows and applies the rise-color switch. +func tradingTrend(c *ui.Context, redUp *bool, log func(string)) { + if mujica.Checkbox(c, redUp, "Red means up (China)", mujica.CheckboxOptions{}).Changed() { + log(fmt.Sprintf("red up %v", *redUp)) + } + t := mujica.GreenUp + if *redUp { + t = mujica.RedUp + } + mujica.UseTrend(c, t) +} + +// tradingAction is a demo button at its natural width. +func tradingAction(c *ui.Context, label string) bool { + clicked := false + ui.Row(c).Children(func() { + clicked = mujica.Button(c, label, mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() + }) + return clicked +} + +// tradingBook is a deterministic book around mid, moved by step. +func tradingBook(mid float64, step int) mujica.OrderBookData { + var b mujica.OrderBookData + for i := range 12 { + w := float64((i*7+step*3)%11 + 1) + b.Bids = append(b.Bids, mujica.BookLevel{Price: math.Round((mid-0.01*float64(i+1))*100) / 100, Size: w * 10}) + b.Asks = append(b.Asks, mujica.BookLevel{Price: math.Round((mid+0.01*float64(i))*100) / 100, Size: float64((i*5+step)%9+1) * 10}) + } + b.Version = step + return b +} + +var tradingPages = []page{ + {ID: "161", Name: "TickerTape", ZhName: "行情滚动条", Category: tradingCategory, Code: tradingCode["TickerTape"], Demo: tradingTickerDemo, + Keys: "Tab focuses the tape, which pauses it. With reduced motion, Left and Right turn pages."}, + {ID: "162", Name: "QuoteCard", ZhName: "报价卡片", Category: tradingCategory, Code: tradingCode["QuoteCard"], Demo: tradingQuoteDemo, + Keys: "The card takes no focus; controls in its chart slot do."}, + {ID: "163", Name: "PriceText", ZhName: "价格文本", Category: tradingCategory, Code: tradingCode["PriceText"], Demo: tradingPriceDemo, + Keys: "Display only."}, + {ID: "164", Name: "PriceChangeBadge", ZhName: "涨跌徽标", Category: tradingCategory, Code: tradingCode["PriceChangeBadge"], Demo: tradingBadgeDemo, + Keys: "Display only; the name reads the direction and values."}, + {ID: "165", Name: "OrderBook", ZhName: "盘口", Category: tradingCategory, Code: tradingCode["OrderBook"], Demo: tradingBookDemo, + Keys: "Tab into the levels, Up and Down move, Enter picks a price."}, + {ID: "166", Name: "Level2Quotes", ZhName: "多档行情", Category: tradingCategory, Code: tradingCode["Level2Quotes"], Demo: tradingLevel2Demo, + Keys: "Display only; each row reads its level, price, size and orders."}, + {ID: "167", Name: "TimeAndSales", ZhName: "逐笔成交", Category: tradingCategory, Code: tradingCode["TimeAndSales"], Demo: tradingTapeDemo, + Keys: "Tab to Pause and the list; Up and Down move in the list."}, + {ID: "168", Name: "DepthLadder", ZhName: "价格阶梯", Category: tradingCategory, Code: tradingCode["DepthLadder"], Demo: tradingLadderDemo, + Keys: "Arrows move between cells, Enter orders, Page Up and Page Down scroll, the wheel steps a tick."}, + {ID: "169", Name: "SpreadIndicator", ZhName: "价差指示", Category: tradingCategory, Code: tradingCode["SpreadIndicator"], Demo: tradingSpreadDemo, + Keys: "Display only."}, + {ID: "170", Name: "BidAskBar", ZhName: "买卖力量条", Category: tradingCategory, Code: tradingCode["BidAskBar"], Demo: tradingBarDemo, + Keys: "Display only; read as a meter."}, + {ID: "171", Name: "OrderEntry", ZhName: "下单面板", Category: tradingCategory, Code: tradingCode["OrderEntry"], Demo: tradingEntryDemo, + Keys: "Tab through side, type and fields; Up and Down step numbers; a failed submit focuses the first error."}, + {ID: "172", Name: "QuickTradeButtons", ZhName: "快速交易按钮", Category: tradingCategory, Code: tradingCode["QuickTradeButtons"], Demo: tradingQuickDemo, + Keys: "Enter or Space trades; with Confirm, Enter in the popup cancels and Escape closes it."}, + {ID: "173", Name: "PositionTable", ZhName: "持仓表", Category: tradingCategory, Code: tradingCode["PositionTable"], Demo: tradingPositionsDemo, + Keys: "DataTable keys; Tab reaches each Close button."}, + {ID: "174", Name: "OrderTable", ZhName: "委托表", Category: tradingCategory, Code: tradingCode["OrderTable"], Demo: tradingOrdersDemo, + Keys: "Arrows switch the status filter; DataTable keys; Tab reaches Cancel."}, + {ID: "175", Name: "TradeHistoryTable", ZhName: "成交记录表", Category: tradingCategory, Code: tradingCode["TradeHistoryTable"], Demo: tradingHistoryDemo, + Keys: "Tab to the range picker, Export and the table."}, + {ID: "176", Name: "PnLDisplay", ZhName: "盈亏显示", Category: tradingCategory, Code: tradingCode["PnLDisplay"], Demo: tradingPnLDemo, + Keys: "Tab to the eye button; Enter or Space masks the amounts."}, + {ID: "177", Name: "LeverageSlider", ZhName: "杠杆滑块", Category: tradingCategory, Code: tradingCode["LeverageSlider"], Demo: tradingLeverageDemo, + Keys: "Arrows step levels, Home and End jump; Apply sets the value."}, + {ID: "178", Name: "MarginIndicator", ZhName: "保证金率指示", Category: tradingCategory, Code: tradingCode["MarginIndicator"], Demo: tradingMarginDemo, + Keys: "Display only; read as a meter with its lines."}, + {ID: "179", Name: "RiskMeter", ZhName: "风险等级", Category: tradingCategory, Code: tradingCode["RiskMeter"], Demo: tradingRiskDemo, + Keys: "Display only."}, + {ID: "180", Name: "OrderConfirmDialog", ZhName: "下单确认对话框", Category: tradingCategory, Code: tradingCode["OrderConfirmDialog"], Demo: tradingConfirmDemo, + Keys: "Cancel has the focus; Escape cancels; the backdrop does not close it."}, +} + +type tradingTickerState struct { + redUp bool + items []mujica.TickerItem +} + +func tradingTickerDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "161", func() tradingTickerState { + return tradingTickerState{items: []mujica.TickerItem{{Symbol: "MUJC", Price: 412.08, ChangePercent: -1.35}, {Symbol: "AVE", Price: 88.2, ChangePercent: 2.1}, {Symbol: "OBLV", Price: 1204.5, ChangePercent: 0.4}, + {Symbol: "DOLO", Price: 7.25, ChangePercent: -0.8}, {Symbol: "MORT", Price: 33.1, ChangePercent: 1.2}, {Symbol: "TIMR", Price: 61.9, ChangePercent: -2.3}}} + }) + tradingTrend(c, &d.redUp, log) + if tradingAction(c, "Move prices") { + for i := range d.items { + d.items[i].ChangePercent = -d.items[i].ChangePercent + } + log("prices moved") + } + ui.Box(c).Width(560).Children(func() { mujica.TickerTape(c, d.items, mujica.TickerTapeOptions{}) }) + formStates(c, func() { + ui.Box(c).Width(280).Children(func() { mujica.TickerTape(c, d.items[:2], mujica.TickerTapeOptions{Speed: 15}) }) + }) +} + +type tradingQuoteState struct { + redUp bool + price float64 +} + +func tradingQuoteDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "162", func() tradingQuoteState { return tradingQuoteState{price: 412.08} }) + tradingTrend(c, &d.redUp, log) + if mujica.Slider(c, &d.price, mujica.SliderOptions{Min: 380, Max: 440, Step: 0.01, Label: "Price", ShowValue: true}).Changed() { + log(fmt.Sprintf("price %.2f", d.price)) + } + change := d.price - 417.7 + ui.Row(c).Gap(16).Wrap().Children(func() { + mujica.QuoteCard(c, mujica.Quote{Symbol: "MUJC", Name: "Ave Mujica Holdings", Price: d.price, Change: change, ChangePercent: change / 417.7 * 100}, + mujica.QuoteCardOptions{Chart: func() { + k := mujica.Tokens(c) + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + p.Line(r.X, r.Y+r.H*0.7, r.X+r.W/2, r.Y+r.H*0.3, 1.5, k.Info) + p.Line(r.X+r.W/2, r.Y+r.H*0.3, r.X+r.W, r.Y+r.H*(0.5-float32(change)/40), 1.5, k.Info) + }) + }}) + mujica.QuoteCard(c, mujica.Quote{Symbol: "AVE", Price: 88.2}, mujica.QuoteCardOptions{}) + }) +} + +type tradingPriceState struct { + redUp, noFlash, grouping bool + price float64 +} + +func tradingPriceDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "163", func() tradingPriceState { return tradingPriceState{price: 18234.5, grouping: true} }) + tradingTrend(c, &d.redUp, log) + formToggles(c, []string{"Thousands separators", "No flash"}, &d.grouping, &d.noFlash) + ui.Row(c).Gap(8).Children(func() { + if mujica.Button(c, "Tick up", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + d.price += 12.5 + log(fmt.Sprintf("price %.2f", d.price)) + } + if mujica.Button(c, "Tick down", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + d.price -= 12.5 + log(fmt.Sprintf("price %.2f", d.price)) + } + }) + mujica.PriceText(c, d.price, mujica.PriceTextOptions{Size: 28, Compare: true, Reference: 18234.5, NoFlash: d.noFlash, NoGrouping: !d.grouping}) + formStates(c, func() { + mujica.PriceText(c, 0.004521, mujica.PriceTextOptions{Decimals: 6}) + mujica.PriceText(c, 100, mujica.PriceTextOptions{Compare: true, Reference: 100}) + }) +} + +type tradingBadgeState struct { + redUp, hidePercent bool + change float64 +} + +func tradingBadgeDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "164", func() tradingBadgeState { return tradingBadgeState{change: 1.25} }) + tradingTrend(c, &d.redUp, log) + formToggles(c, []string{"Hide percent"}, &d.hidePercent) + if mujica.Slider(c, &d.change, mujica.SliderOptions{Min: -5, Max: 5, Step: 0.05, Label: "Change", ShowValue: true}).Changed() { + log(fmt.Sprintf("change %.2f", d.change)) + } + mujica.PriceChangeBadge(c, d.change, d.change/1.47, mujica.PriceChangeBadgeOptions{HidePercent: d.hidePercent}) + formStates(c, func() { + mujica.PriceChangeBadge(c, 2.4, 1.2, mujica.PriceChangeBadgeOptions{}) + mujica.PriceChangeBadge(c, -2.4, -1.2, mujica.PriceChangeBadgeOptions{}) + mujica.PriceChangeBadge(c, 0, 0, mujica.PriceChangeBadgeOptions{}) + }) +} + +type tradingBookState struct { + redUp, disabled bool + step int + book mujica.OrderBookState +} + +func tradingBookDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "165", func() tradingBookState { return tradingBookState{} }) + tradingTrend(c, &d.redUp, log) + formToggles(c, []string{"Disabled"}, &d.disabled) + if tradingAction(c, "Next update") { + d.step++ + log(fmt.Sprintf("book update %d", d.step)) + } + ui.Box(c).Width(320).Children(func() { + r := mujica.OrderBook(c, &d.book, tradingBook(100, d.step), mujica.OrderBookOptions{Depth: 6, Groups: []float64{0.01, 0.05, 0.1}, Disabled: d.disabled}) + if price, side, ok := r.Clicked(); ok { + log(fmt.Sprintf("level %v at %.2f", side, price)) + } + }) +} + +type tradingLevel2State struct { + redUp bool + step int +} + +func tradingLevel2Demo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "166", func() tradingLevel2State { return tradingLevel2State{} }) + tradingTrend(c, &d.redUp, log) + if tradingAction(c, "Next update") { + d.step++ + log(fmt.Sprintf("level 2 update %d", d.step)) + } + book := tradingBook(100, d.step) + var data mujica.Level2Data + for i := range 5 { + data.Bids = append(data.Bids, mujica.L2Level{Price: book.Bids[i].Price, Size: book.Bids[i].Size, Orders: int(book.Bids[i].Size / 10)}) + data.Asks = append(data.Asks, mujica.L2Level{Price: book.Asks[i].Price, Size: book.Asks[i].Size, Orders: int(book.Asks[i].Size / 10)}) + } + ui.Box(c).Width(620).Children(func() { mujica.Level2Quotes(c, data, mujica.Level2QuotesOptions{}) }) +} + +type tradingTapeState struct { + redUp bool + trades []mujica.TradePrint + tape mujica.TimeAndSalesState +} + +func tradingTapeDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "167", func() tradingTapeState { return tradingTapeState{} }) + tradingTrend(c, &d.redUp, log) + if tradingAction(c, "Add 20 trades") { + at := time.Date(2026, 10, 5, 9, 30, 0, 0, time.Local) + for range 20 { + n := len(d.trades) + side := mujica.SideBuy + if n%3 == 1 { + side = mujica.SideSell + } + d.trades = append(d.trades, mujica.TradePrint{ID: fmt.Sprint(n), Time: at.Add(time.Duration(n) * time.Second), + Price: 100 + float64(n%17)/100, Size: float64((n*37)%1500 + 1), Side: side}) + } + log(fmt.Sprintf("%d trades", len(d.trades))) + } + ui.Box(c).Width(460).Children(func() { + if mujica.TimeAndSales(c, &d.tape, d.trades, mujica.TimeAndSalesOptions{LargeSize: 1000}).Changed() { + log(fmt.Sprintf("paused %v", d.tape.Paused)) + } + }) +} + +type tradingLadderState struct { + redUp, disabled bool + last float64 + ladder mujica.DepthLadderState +} + +func tradingLadderDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "168", func() tradingLadderState { return tradingLadderState{last: 100} }) + tradingTrend(c, &d.redUp, log) + formToggles(c, []string{"Disabled"}, &d.disabled) + if mujica.Slider(c, &d.last, mujica.SliderOptions{Min: 99.9, Max: 100.1, Step: 0.01, Label: "Last price", ShowValue: true}).Changed() { + log(fmt.Sprintf("last %.2f", d.last)) + } + ui.Box(c).Width(360).Children(func() { + r := mujica.DepthLadder(c, &d.ladder, d.last, tradingBook(100, 0), mujica.DepthLadderOptions{TickSize: 0.01, Rows: 11, Disabled: d.disabled}) + if price, side, ok := r.Clicked(); ok { + log(fmt.Sprintf("order %v at %.2f", side, price)) + } + }) +} diff --git a/examples/gallery/pages_trading_code.go b/examples/gallery/pages_trading_code.go new file mode 100644 index 0000000..b5fdbc7 --- /dev/null +++ b/examples/gallery/pages_trading_code.go @@ -0,0 +1,156 @@ +package main + +// tradingCode holds each page's copyable snippet, the same as its doc and Example. +var tradingCode = map[string]string{ + "TickerTape": `items := []mujica.TickerItem{{Symbol: "MUJC", Price: 412.08, ChangePercent: -1.35}, {Symbol: "AVE", Price: 88.2, ChangePercent: 2.1}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TickerTape(c, items, mujica.TickerTapeOptions{}) +} +ui.Render(view, 400, 300, 1)`, + "QuoteCard": `quote := mujica.Quote{Symbol: "MUJC", Name: "Ave Mujica Holdings", Price: 412.08, Change: -5.62, ChangePercent: -1.35} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.QuoteCard(c, quote, mujica.QuoteCardOptions{}) +} +ui.Render(view, 400, 300, 1)`, + "PriceText": `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.UseTrend(c, mujica.RedUp) + mujica.PriceText(c, 18234.5, mujica.PriceTextOptions{Compare: true, Reference: 18000}) +} +ui.Render(view, 400, 300, 1)`, + "PriceChangeBadge": `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PriceChangeBadge(c, -5.62, -1.35, mujica.PriceChangeBadgeOptions{}) +} +ui.Render(view, 400, 300, 1)`, + "OrderBook": `var state mujica.OrderBookState +book := mujica.OrderBookData{ + Bids: []mujica.BookLevel{{Price: 100.00, Size: 5}, {Price: 99.99, Size: 3}}, + Asks: []mujica.BookLevel{{Price: 100.01, Size: 1}, {Price: 100.02, Size: 2}}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if price, side, ok := mujica.OrderBook(c, &state, book, mujica.OrderBookOptions{Groups: []float64{0.01, 0.05}}).Clicked(); ok { + fmt.Println("order at", price, side) + } +} +ui.Render(view, 400, 300, 1)`, + "Level2Quotes": `data := mujica.Level2Data{ + Bids: []mujica.L2Level{{Price: 100.00, Size: 500, Orders: 12}}, + Asks: []mujica.L2Level{{Price: 100.01, Size: 200, Orders: 3}}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Level2Quotes(c, data, mujica.Level2QuotesOptions{}) +} +ui.Render(view, 400, 300, 1)`, + "TimeAndSales": `var state mujica.TimeAndSalesState +trades := []mujica.TradePrint{{ID: "1", Time: time.Now(), Price: 100.01, Size: 300, Side: mujica.SideBuy}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TimeAndSales(c, &state, trades, mujica.TimeAndSalesOptions{LargeSize: 1000}) +} +ui.Render(view, 400, 300, 1)`, + "DepthLadder": `var state mujica.DepthLadderState +book := mujica.OrderBookData{Bids: []mujica.BookLevel{{Price: 100, Size: 5}}, Asks: []mujica.BookLevel{{Price: 100.01, Size: 2}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if price, side, ok := mujica.DepthLadder(c, &state, 100, book, mujica.DepthLadderOptions{TickSize: 0.01}).Clicked(); ok { + fmt.Println("place", side, "at", price) + } +} +ui.Render(view, 400, 300, 1)`, + "SpreadIndicator": `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SpreadIndicator(c, 99.95, 100.05, mujica.SpreadIndicatorOptions{WarnPercent: 0.5}) +} +ui.Render(view, 400, 300, 1)`, + "BidAskBar": `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BidAskBar(c, 6234, 3820, mujica.BidAskBarOptions{}) +} +ui.Render(view, 400, 300, 1)`, + "OrderEntry": `var state mujica.OrderEntryState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if order, ok := mujica.OrderEntry(c, &state, mujica.OrderEntryOptions{Symbol: "MUJC", Last: 100.2}).Submitted(); ok { + fmt.Println("place", order.Quantity) + state.EndSubmit("") + } +} +ui.Render(view, 400, 300, 1)`, + "QuickTradeButtons": `quantity := 10.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if side, q, ok := mujica.QuickTradeButtons(c, &quantity, mujica.QuickTradeButtonsOptions{Symbol: "MUJC", Confirm: true}).Traded(); ok { + fmt.Println(side, q) + } +} +ui.Render(view, 400, 300, 1)`, + "PositionTable": `var state mujica.DataTableState[string] +rows := []mujica.PositionRow{{ID: "p1", Symbol: "MUJC", Quantity: 100, AvgCost: 400, Price: 412.08}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if id, ok := mujica.PositionTable(c, &state, rows, mujica.PositionTableOptions{}).Acted(); ok { + fmt.Println("close", id) + } +} +ui.Render(view, 400, 300, 1)`, + "OrderTable": `var state mujica.OrderTableState +rows := []mujica.OrderRow{{ID: "o1", Symbol: "MUJC", Side: mujica.SideBuy, Price: 99.5, Quantity: 100, Filled: 40, Status: mujica.OrderPartial}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if id, ok := mujica.OrderTable(c, &state, rows, mujica.OrderTableOptions{}).Acted(); ok { + fmt.Println("cancel", id) + } +} +ui.Render(view, 400, 300, 1)`, + "TradeHistoryTable": `var state mujica.TradeHistoryTableState +rows := []mujica.FillRow{{ID: "f1", Time: time.Now(), Symbol: "MUJC", Side: mujica.SideBuy, Price: 400, Quantity: 10, Fee: 1.2}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if fills, ok := mujica.TradeHistoryTable(c, &state, rows, mujica.TradeHistoryTableOptions{}).Exported(); ok { + fmt.Println("export", len(fills)) + } +} +ui.Render(view, 400, 300, 1)`, + "PnLDisplay": `hidden := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PnLDisplay(c, mujica.PnL{Realized: 1200.5, Unrealized: -340.2, Today: 88}, &hidden, mujica.PnLDisplayOptions{Currency: "$"}) +} +ui.Render(view, 400, 300, 1)`, + "LeverageSlider": `leverage := 5.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.LeverageSlider(c, &leverage, mujica.LeverageSliderOptions{Warning: 10, Danger: 50}).Changed() { + fmt.Println("leverage", leverage) + } +} +ui.Render(view, 400, 300, 1)`, + "MarginIndicator": `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MarginIndicator(c, 0.86, mujica.MarginIndicatorOptions{}) +} +ui.Render(view, 400, 300, 1)`, + "RiskMeter": `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RiskMeter(c, 4, mujica.RiskMeterOptions{}) +} +ui.Render(view, 400, 300, 1)`, + "OrderConfirmDialog": `open := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.OrderConfirmDialog(c, &open, mujica.OrderConfirmDialogOptions{ + Lines: []mujica.OrderSummaryLine{{Label: "Symbol", Value: "MUJC"}, {Label: "Quantity", Value: "100"}}, + Risk: "Leveraged products can lose more than the deposit.", + Acknowledge: "I understand the risk", + }) + if r.Confirmed { + fmt.Println("send order") + } +} +ui.Render(view, 400, 300, 1)`, +} diff --git a/examples/gallery/pages_trading_orders.go b/examples/gallery/pages_trading_orders.go new file mode 100644 index 0000000..cecdc50 --- /dev/null +++ b/examples/gallery/pages_trading_orders.go @@ -0,0 +1,286 @@ +package main + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +type tradingSpreadState struct { + ask, warn float64 +} + +func tradingSpreadDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "169", func() tradingSpreadState { return tradingSpreadState{ask: 100.05, warn: 0.5} }) + if mujica.Slider(c, &d.ask, mujica.SliderOptions{Min: 99.5, Max: 101, Step: 0.01, Label: "Ask (bid 99.95)", ShowValue: true}).Changed() { + log(fmt.Sprintf("ask %.2f", d.ask)) + } + if mujica.Slider(c, &d.warn, mujica.SliderOptions{Min: 0, Max: 2, Step: 0.1, Label: "Warn at percent", ShowValue: true}).Changed() { + log(fmt.Sprintf("warn %.1f%%", d.warn)) + } + mujica.SpreadIndicator(c, 99.95, d.ask, mujica.SpreadIndicatorOptions{WarnPercent: d.warn}) + formStates(c, func() { + mujica.SpreadIndicator(c, 99.95, 100.05, mujica.SpreadIndicatorOptions{}) + mujica.SpreadIndicator(c, 99, 101, mujica.SpreadIndicatorOptions{WarnPercent: 0.5}) + mujica.SpreadIndicator(c, 100.1, 100, mujica.SpreadIndicatorOptions{}) + }) +} + +type tradingBarState struct { + redUp bool + bid, ask float64 +} + +func tradingBarDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "170", func() tradingBarState { return tradingBarState{bid: 6234, ask: 3820} }) + tradingTrend(c, &d.redUp, log) + for _, v := range []struct { + name string + p *float64 + }{{"Bid volume", &d.bid}, {"Ask volume", &d.ask}} { + if mujica.Slider(c, v.p, mujica.SliderOptions{Min: 0, Max: 10000, Step: 1, Label: v.name, ShowValue: true}).Changed() { + log(fmt.Sprintf("%s %.0f", v.name, *v.p)) + } + } + ui.Box(c).Width(360).Children(func() { mujica.BidAskBar(c, d.bid, d.ask, mujica.BidAskBarOptions{}) }) +} + +type tradingEntryState struct { + redUp, disabled, reject bool + entry mujica.OrderEntryState +} + +func tradingEntryDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "171", func() tradingEntryState { return tradingEntryState{} }) + checks := *ui.Local(c.Root(), "171.checks", func() chan [2]any { return make(chan [2]any, 8) }) + placed := *ui.Local(c.Root(), "171.placed", func() chan string { return make(chan string, 1) }) + for drained := false; !drained; { + select { + case r := <-checks: + v, msg := r[0].(uint64), r[1].(string) + log(fmt.Sprintf("buying power check v%d: %q, kept %v", v, msg, d.entry.Resolve(v, msg))) + case e := <-placed: + d.entry.EndSubmit(e) + log(fmt.Sprintf("order placed, error %q", e)) + default: + drained = true + } + } + tradingTrend(c, &d.redUp, log) + formToggles(c, []string{"Disabled", "Broker rejects"}, &d.disabled, &d.reject) + r := mujica.OrderEntry(c, &d.entry, mujica.OrderEntryOptions{Symbol: "MUJC", Last: 100.2, MaxQuantity: 5000, Disabled: d.disabled, + Check: func(o mujica.OrderDraft, v uint64) { + log(fmt.Sprintf("check started v%d", v)) + go func() { + time.Sleep(600 * time.Millisecond) + msg := "" + if o.Quantity*100 > 250000 { + msg = "Not enough buying power." + } + checks <- [2]any{v, msg} + c.Invalidate() + }() + }}) + if o, ok := r.Submitted(); ok { + log(fmt.Sprintf("submitted %+v", o)) + fail := "" + if d.reject { + fail = "The broker rejected the order. Your entry is kept." + } + go func() { + time.Sleep(800 * time.Millisecond) + placed <- fail + c.Invalidate() + }() + } +} + +type tradingQuickState struct { + redUp, confirm, disabled bool + qty float64 +} + +func tradingQuickDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "172", func() tradingQuickState { return tradingQuickState{confirm: true, qty: 10} }) + tradingTrend(c, &d.redUp, log) + formToggles(c, []string{"Confirm first", "Disabled"}, &d.confirm, &d.disabled) + ui.Box(c).Width(400).Children(func() { + if side, q, ok := mujica.QuickTradeButtons(c, &d.qty, mujica.QuickTradeButtonsOptions{Symbol: "MUJC", Confirm: d.confirm, Disabled: d.disabled}).Traded(); ok { + log(fmt.Sprintf("trade %v %v", side, q)) + } + }) +} + +type tradingPositionsState struct { + redUp, disabled bool + rows []mujica.PositionRow + table mujica.DataTableState[string] +} + +func tradingPositionsDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "173", func() tradingPositionsState { + return tradingPositionsState{rows: []mujica.PositionRow{ + {ID: "p1", Symbol: "MUJC", Quantity: 100, AvgCost: 400, Price: 412.08}, + {ID: "p2", Symbol: "AVE", Quantity: 50, AvgCost: 90, Price: 88.2}, + {ID: "p3", Symbol: "OBLV", Quantity: 8, AvgCost: 1180, Price: 1204.5}, + }} + }) + tradingTrend(c, &d.redUp, log) + formToggles(c, []string{"Disabled"}, &d.disabled) + ui.Box(c).Width(1000).Height(260).Children(func() { + r := mujica.PositionTable(c, &d.table, d.rows, mujica.PositionTableOptions{Disabled: d.disabled}) + if id, ok := r.Acted(); ok { + for i, p := range d.rows { + if p.ID == id { + d.rows = append(d.rows[:i:i], d.rows[i+1:]...) + break + } + } + log("close " + id) + } + if r.Table.Changed() { + id, _ := d.table.Selected() + log("select " + id) + } + }) +} + +type tradingOrdersState struct { + disabled bool + rows []mujica.OrderRow + table mujica.OrderTableState + version int +} + +func tradingOrdersDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "174", func() tradingOrdersState { + return tradingOrdersState{rows: []mujica.OrderRow{ + {ID: "o1", Symbol: "MUJC", Side: mujica.SideBuy, Type: mujica.OrderLimit, Price: 99.5, Quantity: 100, Filled: 40, Status: mujica.OrderPartial}, + {ID: "o2", Symbol: "AVE", Side: mujica.SideSell, Type: mujica.OrderMarket, Quantity: 10, Filled: 10, Status: mujica.OrderFilled}, + {ID: "o3", Symbol: "OBLV", Side: mujica.SideBuy, Type: mujica.OrderStop, Price: 12, Quantity: 5, Status: mujica.OrderCanceled}, + {ID: "o4", Symbol: "DOLO", Side: mujica.SideSell, Type: mujica.OrderLimit, Price: 7.25, Quantity: 30, Status: mujica.OrderOpen}, + }} + }) + formToggles(c, []string{"Disabled"}, &d.disabled) + ui.Box(c).Width(1000).Height(300).Children(func() { + if id, ok := mujica.OrderTable(c, &d.table, d.rows, mujica.OrderTableOptions{Disabled: d.disabled, Version: d.version}).Acted(); ok { + for i := range d.rows { + if d.rows[i].ID == id { + d.rows[i].Status = mujica.OrderCanceled + } + } + d.version++ + log("cancel " + id) + } + }) +} + +type tradingHistoryState struct { + disabled bool + rows []mujica.FillRow + table mujica.TradeHistoryTableState +} + +func tradingHistoryDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "175", func() tradingHistoryState { + var rows []mujica.FillRow + for i := range 30 { + side := mujica.SideBuy + if i%3 == 0 { + side = mujica.SideSell + } + rows = append(rows, mujica.FillRow{ID: fmt.Sprint(i), Time: time.Date(2026, 9, 10+i/2, 9+i%6, 0, 0, 0, time.Local), + Symbol: []string{"MUJC", "AVE", "OBLV"}[i%3], Side: side, Price: 100 + float64(i), Quantity: float64(i%5+1) * 10, Fee: 0.5}) + } + return tradingHistoryState{rows: rows} + }) + formToggles(c, []string{"Disabled"}, &d.disabled) + ui.Box(c).Width(1000).Height(360).Children(func() { + if fills, ok := mujica.TradeHistoryTable(c, &d.table, d.rows, mujica.TradeHistoryTableOptions{Disabled: d.disabled}).Exported(); ok { + log(fmt.Sprintf("export %d fills", len(fills))) + } + }) +} + +type tradingPnLState struct { + redUp, hidden bool +} + +func tradingPnLDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "176", func() tradingPnLState { return tradingPnLState{} }) + tradingTrend(c, &d.redUp, log) + if mujica.PnLDisplay(c, mujica.PnL{Realized: 1200.5, Unrealized: -340.2, Today: 88}, &d.hidden, mujica.PnLDisplayOptions{Currency: "$"}).Changed() { + log(fmt.Sprintf("hidden %v", d.hidden)) + } +} + +type tradingLeverageState struct { + disabled bool + value float64 +} + +func tradingLeverageDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "177", func() tradingLeverageState { return tradingLeverageState{value: 5} }) + formToggles(c, []string{"Disabled"}, &d.disabled) + ui.Box(c).Width(380).Children(func() { + if mujica.LeverageSlider(c, &d.value, mujica.LeverageSliderOptions{Warning: 10, Danger: 50, Disabled: d.disabled}).Changed() { + log(fmt.Sprintf("leverage applied %v×", d.value)) + } + }) +} + +type tradingMarginState struct{ ratio float64 } + +func tradingMarginDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "178", func() tradingMarginState { return tradingMarginState{ratio: 0.55} }) + if mujica.Slider(c, &d.ratio, mujica.SliderOptions{Min: 0, Max: 1.4, Step: 0.01, Label: "Margin ratio", ShowValue: true}).Changed() { + log(fmt.Sprintf("ratio %.2f", d.ratio)) + } + ui.Box(c).Width(360).Children(func() { mujica.MarginIndicator(c, d.ratio, mujica.MarginIndicatorOptions{}) }) + formStates(c, func() { + for _, r := range []float64{0.3, 0.85, 1.1} { + ui.Box(c).Width(260).Children(func() { mujica.MarginIndicator(c, r, mujica.MarginIndicatorOptions{}) }) + } + }) +} + +type tradingRiskState struct{ level float64 } + +func tradingRiskDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "179", func() tradingRiskState { return tradingRiskState{level: 3} }) + if mujica.Slider(c, &d.level, mujica.SliderOptions{Min: 1, Max: 5, Step: 1, Label: "Level", ShowValue: true}).Changed() { + log(fmt.Sprintf("level %.0f", d.level)) + } + ui.Box(c).Width(360).Children(func() { mujica.RiskMeter(c, int(d.level), mujica.RiskMeterOptions{}) }) +} + +type tradingConfirmState struct { + open, acknowledge bool +} + +func tradingConfirmDemo(c *ui.Context, log func(string)) { + d := ui.Local(c.Root(), "180", func() tradingConfirmState { return tradingConfirmState{acknowledge: true} }) + formToggles(c, []string{"Require acknowledgement"}, &d.acknowledge) + if tradingAction(c, "Review order") { + d.open = true + log("open") + } + ack := "" + if d.acknowledge { + ack = "I understand leveraged products can lose more than the deposit" + } + r := mujica.OrderConfirmDialog(c, &d.open, mujica.OrderConfirmDialogOptions{ + Lines: []mujica.OrderSummaryLine{{Label: "Symbol", Value: "MUJC"}, {Label: "Side", Value: "Buy"}, {Label: "Type", Value: "Limit 100.20"}, + {Label: "Quantity", Value: "100"}, {Label: "Estimated amount", Value: "10,020.00"}}, + Risk: "5× leverage: a 20% move against you wipes out the margin.", Acknowledge: ack, + }) + if r.Confirmed { + log("confirmed") + } + if r.Canceled { + log("canceled") + } +} diff --git a/level2_quotes.go b/level2_quotes.go new file mode 100644 index 0000000..0df1966 --- /dev/null +++ b/level2_quotes.go @@ -0,0 +1,123 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// L2Level is a level's price, size and order count. +type L2Level struct { + Price, Size float64 + Orders int +} + +// Level2Data holds the bid and ask levels, best first. +type Level2Data struct { + Bids, Asks []L2Level +} + +// Level2QuotesOptions configure a Level2Quotes. +type Level2QuotesOptions struct { + // Decimals of prices and sizes; zero means 2 and 0. + Decimals, SizeDecimals int + // Levels shown each side; zero means 5. + Levels int +} + +// level2Mark is a level's last values and size move. +type level2Mark struct { + price, size float64 + dir int + at time.Time + seen bool +} + +// Level2Quotes lists bid and ask levels; changes flash and mark. +func Level2Quotes(c *ui.Context, data Level2Data, opts Level2QuotesOptions) *ui.Element { + dec := priceDecimals("Level2Quotes", opts.Decimals) + if opts.SizeDecimals < 0 || opts.Levels < 0 { + panic("mujica: Level2Quotes SizeDecimals and Levels cannot be negative") + } + n := opts.Levels + if n == 0 { + n = 5 + } + u := Density(c).Unit() + e := ui.Row(c).Gap(u * 3).AlignItems(ui.Start).Role(ui.RoleTable).Label(msg(c, "level2_quotes.label")) + marks := ui.Local(e, "marks", func() map[string]*level2Mark { return map[string]*level2Mark{} }) + e.Children(func() { + for _, side := range []OrderSide{SideBuy, SideSell} { + levels := data.Bids + if side == SideSell { + levels = data.Asks + } + ui.Column(c).Grow(1).Basis(0).MinWidth(0).Children(func() { + for i := range n { + var l *L2Level + if i < len(levels) { + l = &levels[i] + } + level2Row(c, *marks, side, i, l, dec, opts.SizeDecimals) + } + }) + } + }) + return e +} + +// level2Row is one level with its move mark. +func level2Row(c *ui.Context, marks map[string]*level2Mark, side OrderSide, i int, l *L2Level, dec, sizeDec int) { + k := Tokens(c) + d := Density(c) + name := fmt.Sprintf(msg(c, fmt.Sprintf("level2_quotes.side%d", side)), i+1) + key := fmt.Sprint(side, i) + m := marks[key] + if m == nil { + m = &level2Mark{} + marks[key] = m + } + row := ui.Row(c).Gap(d.Unit()*2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2). + Radius(theme.ControlRadius).BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border) + if l == nil { + row.Children(func() { + ui.Text(c, name).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Width(28) + ui.Text(c, "—").TextColor(k.TextMuted) + }) + return + } + if m.seen && (l.Price != m.price || l.Size != m.size) { + // A new price is a new level, not a move. + m.dir, m.at = 0, c.Now() + if l.Price == m.price { + m.dir = trendSign(l.Size - m.size) + } + slog.Debug("mujica: level2_quotes level changed", "level", name) + } + m.price, m.size, m.seen = l.Price, l.Size, true + if dur := Motion(c, priceTextFlash); dur > 0 && !m.at.IsZero() { + if t := c.Now().Sub(m.at); t < dur { + row.Background(k.Selection.Alpha(1 - float32(float64(t)/float64(dur)))) + c.AnimationFrame() + } + } + col := trendColor(c, side.sign()) + sizeText := statisticFormat(l.Size, sizeDec) + orders := fmt.Sprintf(msg(c, "level2_quotes.orders"), l.Orders) + row.Role(ui.RoleRow).Label(name + " " + statisticFormat(l.Price, dec) + " " + sizeText + " " + orders) + row.Children(func() { + ui.Text(c, name).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Width(28) + ui.Text(c, statisticFormat(l.Price, dec)).TextColor(col).FontFeatures("tnum").FontWeight(600).Grow(1).Basis(0).SingleLine() + ui.Row(c).Gap(2).AlignItems(ui.Center).Justify(ui.End).Grow(1).Basis(0).Children(func() { + if m.dir != 0 { + ui.Icon(c, trendIcon(m.dir)).Size(12, 12).TextColor(trendColor(c, m.dir)) + } + ui.Text(c, sizeText).FontFeatures("tnum").SingleLine() + }) + ui.Text(c, fmt.Sprint(l.Orders)).TextColor(k.TextMuted).FontFeatures("tnum").TextAlign(ui.End).Width(36).SingleLine() + }) +} diff --git a/level2_quotes_test.go b/level2_quotes_test.go new file mode 100644 index 0000000..492b5fe --- /dev/null +++ b/level2_quotes_test.go @@ -0,0 +1,63 @@ +package mujica + +import ( + "bytes" + "testing" + + "github.com/egoist/mygo/ui" +) + +func level2Sample() Level2Data { + return Level2Data{ + Bids: []L2Level{{100.00, 500, 12}, {99.99, 300, 4}}, + Asks: []L2Level{{100.01, 200, 3}, {100.02, 900, 21}, {100.03, 50, 1}}, + } +} + +func TestLevel2QuotesLevels(t *testing.T) { + tt := tradingTester(GreenUp, 600, 300, func(c *ui.Context) { + ui.Box(c).Width(560).Children(func() { Level2Quotes(c, level2Sample(), Level2QuotesOptions{Levels: 3}) }) + }) + for _, name := range []string{"B1 100.00 500 12 orders", "S2 100.02 900 21 orders"} { + if _, ok := tt.Find(name); !ok { + t.Fatalf("no row %q in %q", name, tt.Texts()) + } + } + if !tt.HasText("B3") || !tt.HasText("—") { + t.Fatal("missing third bid not shown as empty") + } +} + +// A changed level marks its size move and flashes. +func TestLevel2QuotesHighlightsChangedLevel(t *testing.T) { + data := level2Sample() + var marks *map[string]*level2Mark + tt := tradingTester(GreenUp, 600, 300, func(c *ui.Context) { + e := Level2Quotes(c, data, Level2QuotesOptions{Levels: 2}) + marks = ui.Local(e, "marks", func() map[string]*level2Mark { return nil }) + }) + before := bytes.Clone(tt.Image().Pix) + data.Bids = []L2Level{{100.00, 650, 13}, {99.99, 300, 4}} + tt.Frame() + m := (*marks)["1 0"] + if m.dir != 1 || m.at.IsZero() { + t.Fatalf("bid 1 mark %+v", m) + } + if (*marks)["1 1"].dir != 0 || !(*marks)["1 1"].at.IsZero() { + t.Fatal("unchanged level marked") + } + if bytes.Equal(before, tt.Image().Pix) { + t.Fatal("nothing drawn for the change") + } + data.Bids = []L2Level{{100.01, 650, 13}, {99.99, 300, 4}} + tt.Frame() + if (*marks)["1 0"].dir != 0 { + t.Fatal("new price read as a size move") + } +} + +func TestLevel2QuotesSnapshot(t *testing.T) { + snapshot(t, "level2_quotes", 600, 230, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { Level2Quotes(c, level2Sample(), Level2QuotesOptions{}) }) + }) +} diff --git a/leverage_slider.go b/leverage_slider.go new file mode 100644 index 0000000..dd2e101 --- /dev/null +++ b/leverage_slider.go @@ -0,0 +1,159 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// LeverageSliderOptions configure a LeverageSlider. +type LeverageSliderOptions struct { + // Levels are the rising leverages; nil means 1× to 100×. + Levels []float64 + // Warning and Danger start the risk zones; zero omits one. + Warning, Danger float64 + // Label names the slider; empty uses the built-in "Leverage". + Label string + Disabled bool +} + +type leverageSliderState struct { + draft float64 + seen float64 +} + +// LeverageSlider drafts a leverage; Apply sets *value. +func LeverageSlider(c *ui.Context, value *float64, opts LeverageSliderOptions) ChoiceResult { + levels := opts.Levels + if levels == nil { + levels = []float64{1, 2, 3, 5, 10, 20, 50, 100} + } + if len(levels) < 2 || !slices.IsSorted(levels) || slices.Index(levels, *value) < 0 { + panic(fmt.Sprintf("mujica: LeverageSlider value %v must be one of at least two rising Levels", *value)) + } + if opts.Warning < 0 || opts.Danger < 0 || opts.Danger != 0 && opts.Danger <= opts.Warning { + panic("mujica: LeverageSlider Danger must be above Warning") + } + k := Tokens(c) + d := Density(c) + label := opts.Label + if label == "" { + label = msg(c, "leverage_slider.label") + } + var r ChoiceResult + r.Element = ui.Column(c).Gap(d.Unit() * 2).MinWidth(260).Disabled(opts.Disabled) + st := ui.Local(r.Element, "lev", func() leverageSliderState { return leverageSliderState{draft: *value, seen: *value} }) + if *value != st.seen { + st.draft, st.seen = *value, *value + } + disabled := r.Element.IsDisabled() + if disabled { + st.draft = *value + } + zone := func(v float64) (string, ui.Color, string) { + switch { + case opts.Danger > 0 && v >= opts.Danger: + return msg(c, "leverage_slider.danger"), k.Danger, "circle-alert" + case opts.Warning > 0 && v >= opts.Warning: + return msg(c, "leverage_slider.warning"), k.Warning, "triangle-alert" + } + return "", k.TextMuted, "" + } + r.Element.Children(func() { + note, col, icon := zone(st.draft) + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, label).TextColor(k.TextMuted).Grow(1) + ui.Text(c, leverageText(st.draft)).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).FontFeatures("tnum") + }) + leverageTrack(c, st, levels, disabled, zone) + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Children(func() { + ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).TextColor(col).Grow(1).Children(func() { + if icon != "" { + ui.Icon(c, icons.Must(icon)).Size(14, 14) + ui.Text(c, note).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600).Role(ui.RoleStatus) + } + }) + if Button(c, msg(c, "leverage_slider.apply"), ButtonOptions{Disabled: disabled || st.draft == *value}).Clicked() && !disabled { + *value, st.seen, r.changed = st.draft, st.draft, true + slog.Debug("mujica: leverage_slider applied", "leverage", *value) + } + }) + }) + return r +} + +func leverageText(v float64) string { return statisticFormat(v, 0) + "×" } + +// leverageTrack is the slider with zones, ticks and thumb. +func leverageTrack(c *ui.Context, st *leverageSliderState, levels []float64, disabled bool, zone func(float64) (string, ui.Color, string)) { + k := Tokens(c) + n := len(levels) + at := slices.Index(levels, st.draft) + set := func(i int) { + i = max(0, min(n-1, i)) + if levels[i] != st.draft && !disabled { + st.draft = levels[i] + slog.Debug("mujica: leverage_slider draft", "leverage", st.draft) + } + } + const pad = 10 + t := ui.Box(c).Height(theme.MinHitSize+16).FillWidth().Padding(0, pad).Focusable().Role(ui.RoleSlider). + Label(msg(c, "leverage_slider.label")).Description(leverageText(st.draft)) + t.FocusRing(false) + if !disabled { + switch { + case t.Shortcut(0, ui.KeyRight), t.Shortcut(0, ui.KeyUp): + set(at + 1) + case t.Shortcut(0, ui.KeyLeft), t.Shortcut(0, ui.KeyDown): + set(at - 1) + case t.Shortcut(0, ui.KeyHome): + set(0) + case t.Shortcut(0, ui.KeyEnd): + set(n - 1) + } + if _, _, ok := t.Dragged(); ok { + x, _, _ := t.PointerPosition() + if w := t.Bounds().W - 2*pad; w > 0 { + set(int(math.Round(float64((x - pad) / w * float32(n-1))))) + } + } + } + at = slices.Index(levels, st.draft) + t.Draw(func(p *ui.Painter, r ui.Rect) { + x0, w := r.X+pad, r.W-2*pad + y := r.Y + 14 + xs := func(i int) float32 { return internal.Snap(p, x0+w*float32(i)/float32(n-1)) } + // Zone bands run under the track. + for i := 0; i < n-1; i++ { + _, col, icon := zone(levels[i+1]) + if icon != "" { + p.Fill(ui.Rect{X: xs(i), Y: y + 4, W: xs(i+1) - xs(i), H: 3}, col, 0) + } + } + p.Line(x0, y, x0+w, y, 2, k.ControlBorder) + p.Line(x0, y, xs(at), y, 2, k.AccentText) + size := FontSize(c, theme.CaptionSize) + widest, _ := p.MeasureText(0, ui.Span{Text: leverageText(levels[n-1]), Size: size}) + // Crowded labels skip levels, counted back from the last. + every := max(1, int(math.Ceil(float64((widest+8)/(w/float32(n-1)))))) + for i, v := range levels { + p.Line(xs(i), y+4, xs(i), y+8, 1, k.TextMuted) + if (n-1-i)%every == 0 { + tw, _ := p.MeasureText(0, ui.Span{Text: leverageText(v), Size: size}) + p.Text(xs(i)-tw/2, y+11, leverageText(v), size, k.TextMuted) + } + } + p.FillPath(internal.Diamond(xs(at), y, 16), k.Surface) + p.FillPath(internal.Diamond(xs(at), y, 13), k.AccentText) + if t.FocusVisible() { + internal.FocusRing(p, ui.Rect{X: xs(at) - 8, Y: y - 8, W: 16, H: 16}, theme.ControlRadius, k.Focus) + } + }) +} diff --git a/leverage_slider_test.go b/leverage_slider_test.go new file mode 100644 index 0000000..fbbf4ce --- /dev/null +++ b/leverage_slider_test.go @@ -0,0 +1,94 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +type leverageProbe struct { + value float64 + opts LeverageSliderOptions + applied int +} + +func (p *leverageProbe) tester(density theme.Density) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Density: density}) + ui.Column(c).Padding(10).Children(func() { + if LeverageSlider(c, &p.value, p.opts).Changed() { + p.applied++ + } + }) + }, 420, 200) +} + +func TestLeverageSliderAppliesOnlyOnConfirm(t *testing.T) { + p := &leverageProbe{value: 5, opts: LeverageSliderOptions{Warning: 10, Danger: 50}} + tt := p.tester(theme.Default) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Leverage") { + t.Fatal("slider not focusable") + } + tt.Key(0, ui.KeyRight) + if p.value != 5 || !tt.HasText("10×") || !tt.HasText("High risk") { + t.Fatalf("draft: value %v texts %q", p.value, tt.Texts()) + } + tt.Key(0, ui.KeyEnd) + if !tt.HasText("Very high risk") { + t.Fatalf("danger zone: %q", tt.Texts()) + } + if err := tt.Click("Apply"); err != nil { + t.Fatal(err) + } + if p.value != 100 || p.applied != 1 { + t.Fatalf("apply: %v %d", p.value, p.applied) + } + tt.Key(ui.Shift, ui.KeyTab) + tt.Key(0, ui.KeyHome) + if p.value != 100 || !tt.HasText("1×") { + t.Fatalf("home moved value: %v", p.value) + } +} + +func TestLeverageSliderPointerAndHitArea(t *testing.T) { + p := &leverageProbe{value: 1, opts: LeverageSliderOptions{Levels: []float64{1, 2, 3}}} + tt := p.tester(theme.Compact) + // The track is at least 28 DIPs tall, even Compact. + tt.ClickAt(10+400-10, 70) + if !tt.HasText("3×") { + t.Fatalf("pointer: %q", tt.Texts()) + } + tt.ClickAt(30, 70) + if !tt.HasText("1×") || p.applied != 0 { + t.Fatalf("pointer back: %q", tt.Texts()) + } +} + +func TestLeverageSliderDisabledAndBadValue(t *testing.T) { + p := &leverageProbe{value: 2, opts: LeverageSliderOptions{Disabled: true}} + tt := p.tester(theme.Default) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + _ = tt.Click("Apply") + if p.value != 2 || tt.HasText("3×") || !tt.HasText("2×") { + t.Fatal("disabled slider moved") + } + defer func() { + if recover() == nil { + t.Fatal("value outside Levels accepted") + } + }() + (&leverageProbe{value: 4}).tester(theme.Default) +} + +func TestLeverageSliderSnapshot(t *testing.T) { + v := 20.0 + snapshot(t, "leverage_slider", 360, 160, func(c *ui.Context) { + ui.Column(c).Padding(20).Children(func() { + LeverageSlider(c, &v, LeverageSliderOptions{Warning: 10, Danger: 50}) + }) + }) +} diff --git a/margin_indicator.go b/margin_indicator.go new file mode 100644 index 0000000..4c59696 --- /dev/null +++ b/margin_indicator.go @@ -0,0 +1,71 @@ +package mujica + +import ( + "fmt" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// MarginIndicatorOptions configure a MarginIndicator. Zero lines mean 80% and 100%. +type MarginIndicatorOptions struct { + // Warning and Liquidation are margin ratios, Warning below Liquidation. + Warning, Liquidation float64 + // Label names the indicator; empty uses the built-in "Margin ratio". + Label string +} + +// MarginIndicator shows maintenance over equity against its two lines. +func MarginIndicator(c *ui.Context, ratio float64, opts MarginIndicatorOptions) *ui.Element { + warn, liq := opts.Warning, opts.Liquidation + if warn == 0 && liq == 0 { + warn, liq = 0.8, 1 + } + if !(warn > 0 && warn < liq) || ratio < 0 || math.IsNaN(ratio) { + panic(fmt.Sprintf("mujica: MarginIndicator ratio %v, lines %v and %v: need 0 < Warning < Liquidation", ratio, warn, liq)) + } + k := Tokens(c) + u := Density(c).Unit() + label := opts.Label + if label == "" { + label = msg(c, "margin_indicator.label") + } + status, col, icon := msg(c, "margin_indicator.safe"), k.Success, "circle-check" + switch { + case ratio >= liq: + status, col, icon = msg(c, "margin_indicator.liquidation"), k.Danger, "circle-x" + case ratio >= warn: + status, col, icon = msg(c, "margin_indicator.warning"), k.Warning, "triangle-alert" + } + pct := func(v float64) string { return statisticFormat(v*100, 1) + "%" } + top := math.Max(liq*1.25, ratio) + e := ui.Column(c).Gap(u * 2).MinWidth(220).Role(ui.RoleMeter).Label(label). + Description(fmt.Sprintf(msg(c, "margin_indicator.spoken"), pct(ratio), status, pct(warn), pct(liq))) + e.Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, pct(ratio)).FontSize(FontSize(c, theme.H3Size)).FontFeatures("tnum").FontWeight(600).SingleLine() + ui.Row(c).Gap(u / 2).AlignItems(ui.Center).TextColor(col).Children(func() { + ui.Icon(c, icons.Must(icon)).Size(14, 14) + ui.Text(c, status).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600).SingleLine() + }) + }) + ui.Box(c).Height(14).FillWidth().Draw(func(p *ui.Painter, r ui.Rect) { + bar := ui.Rect{X: r.X, Y: r.Y + 3, W: r.W, H: 8} + p.Fill(bar, k.SurfaceHover, theme.ControlRadius) + p.Fill(ui.Rect{X: bar.X, Y: bar.Y, W: bar.W * float32(ratio/top), H: bar.H}, col, theme.ControlRadius) + // The liquidation line is drawn heavier than the warning line. + for i, line := range []float64{warn, liq} { + x := bar.X + bar.W*float32(line/top) + p.Line(x, r.Y, x, r.Y+r.H, 1.5+float32(i)*1.5, k.Text) + } + }) + ui.Row(c).Gap(u * 4).Wrap().Children(func() { + ui.Text(c, msg(c, "margin_indicator.warning_line")+" "+pct(warn)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + ui.Text(c, msg(c, "margin_indicator.liquidation_line")+" "+pct(liq)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + }) + }) + return e +} diff --git a/margin_indicator_test.go b/margin_indicator_test.go new file mode 100644 index 0000000..0eefd39 --- /dev/null +++ b/margin_indicator_test.go @@ -0,0 +1,39 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestMarginIndicatorStates(t *testing.T) { + ratio := 0.42 + tt := tradingTester(GreenUp, 400, 160, func(c *ui.Context) { + ui.Box(c).Width(300).Children(func() { MarginIndicator(c, ratio, MarginIndicatorOptions{}) }) + }) + for _, tc := range []struct { + ratio float64 + want string + }{{0.42, "Safe"}, {0.8, "Margin call"}, {1.2, "Liquidation"}} { + ratio = tc.ratio + tt.Frame() + if !tt.HasText(tc.want) { + t.Fatalf("%v: %q", tc.ratio, tt.Texts()) + } + } + if !tt.HasText("Warning line 80.0%") || !tt.HasText("Liquidation line 100.0%") || !tt.HasText("120.0%") { + t.Fatalf("lines: %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("warning above liquidation accepted") + } + }() + tradingTester(GreenUp, 100, 100, func(c *ui.Context) { MarginIndicator(c, 1, MarginIndicatorOptions{Warning: 2, Liquidation: 1}) }) +} + +func TestMarginIndicatorSnapshot(t *testing.T) { + snapshot(t, "margin_indicator", 340, 120, func(c *ui.Context) { + ui.Column(c).Padding(20).Children(func() { MarginIndicator(c, 0.86, MarginIndicatorOptions{}) }) + }) +} diff --git a/messages_trading.go b/messages_trading.go new file mode 100644 index 0000000..8f72fe7 --- /dev/null +++ b/messages_trading.go @@ -0,0 +1,161 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "price_text.up": {"up", "上涨"}, + "price_text.down": {"down", "下跌"}, + "price_text.flat": {"unchanged", "平"}, + + "ticker_tape.label": {"Market ticker", "行情滚动条"}, + "ticker_tape.pause_hint": {"Pauses under the pointer or keyboard focus", "指针悬停或键盘聚焦时暂停"}, + "ticker_tape.previous": {"Previous page", "上一页"}, + "ticker_tape.next": {"Next page", "下一页"}, + + "spread_indicator.title": {"Spread", "价差"}, + "spread_indicator.value": {"%s (%s%%)", "%s(%s%%)"}, + "spread_indicator.wide": {"Wide spread", "价差过大"}, + "spread_indicator.crossed": {"Crossed market", "买卖价倒挂"}, + + "bid_ask_bar.label": {"Bid and ask volume", "买卖力量"}, + "bid_ask_bar.buy": {"Buy %s%% · %s", "买 %s%% · %s"}, + "bid_ask_bar.sell": {"%s · Sell %s%%", "%s · 卖 %s%%"}, + + "risk_meter.label": {"Risk level", "风险等级"}, + "risk_meter.value": {"Level %d of %d, %s.", "第 %d 级,共 %d 级,%s。"}, + "risk_meter.level1": {"Low", "低"}, + "risk_meter.level2": {"Moderately low", "中低"}, + "risk_meter.level3": {"Medium", "中"}, + "risk_meter.level4": {"Moderately high", "中高"}, + "risk_meter.level5": {"High", "高"}, + + "margin_indicator.label": {"Margin ratio", "保证金率"}, + "margin_indicator.safe": {"Safe", "安全"}, + "margin_indicator.warning": {"Margin call", "追加保证金"}, + "margin_indicator.liquidation": {"Liquidation", "强制平仓"}, + "margin_indicator.warning_line": {"Warning line", "警戒线"}, + "margin_indicator.liquidation_line": {"Liquidation line", "强平线"}, + "margin_indicator.spoken": {"%s, %s. Warning at %s, liquidation at %s.", "%s,%s。警戒线 %s,强平线 %s。"}, + + "pnl_display.label": {"Profit and loss", "盈亏"}, + "pnl_display.realized": {"Realized", "已实现"}, + "pnl_display.unrealized": {"Unrealized", "未实现"}, + "pnl_display.today": {"Today", "当日"}, + "pnl_display.masked": {"hidden", "已隐藏"}, + "pnl_display.hide": {"Hide amounts", "隐藏金额"}, + "pnl_display.show": {"Show amounts", "显示金额"}, + + "order_book.label": {"Order book", "盘口"}, + "order_book.group": {"Group", "合并档位"}, + "order_book.price": {"Price", "价格"}, + "order_book.size": {"Size", "数量"}, + "order_book.total": {"Total", "累计"}, + "order_book.side1": {"Bid", "买"}, + "order_book.side2": {"Ask", "卖"}, + "order_book.level": {"%s %s, size %s", "%s %s,数量 %s"}, + + "level2_quotes.label": {"Level 2 quotes", "多档行情"}, + "level2_quotes.side1": {"B%d", "买%d"}, + "level2_quotes.side2": {"S%d", "卖%d"}, + "level2_quotes.orders": {"%d orders", "%d 笔委托"}, + + "time_and_sales.title": {"Time and sales", "逐笔成交"}, + "time_and_sales.pause": {"Pause", "暂停跟随"}, + "time_and_sales.resume": {"Resume", "继续跟随"}, + "time_and_sales.resume_count": {"Resume (%d new)", "继续跟随(%d 笔新成交)"}, + "time_and_sales.time": {"Time", "时间"}, + "time_and_sales.price": {"Price", "价格"}, + "time_and_sales.size": {"Size", "数量"}, + "time_and_sales.large": {"large trade", "大单"}, + "time_and_sales.empty": {"No trades yet", "暂无成交"}, + + "depth_ladder.label": {"Price ladder", "价格阶梯"}, + "depth_ladder.bid": {"Bid", "买量"}, + "depth_ladder.price": {"Price", "价格"}, + "depth_ladder.ask": {"Ask", "卖量"}, + "depth_ladder.last": {"Last price", "最新价"}, + "depth_ladder.order1": {"Buy at %s", "以 %s 买入"}, + "depth_ladder.order2": {"Sell at %s", "以 %s 卖出"}, + "depth_ladder.recenter": {"Center on last price", "回到最新价"}, + + "order_entry.label": {"Order entry", "下单"}, + "order_entry.side": {"Side", "方向"}, + "order_entry.buy": {"Buy", "买入"}, + "order_entry.sell": {"Sell", "卖出"}, + "order_entry.side1": {"Buy", "买入"}, + "order_entry.side2": {"Sell", "卖出"}, + "order_entry.type": {"Order type", "订单类型"}, + "order_entry.type0": {"Limit", "限价"}, + "order_entry.type1": {"Market", "市价"}, + "order_entry.type2": {"Stop", "止损"}, + "order_entry.type3": {"Stop limit", "条件限价"}, + "order_entry.quantity": {"Quantity", "数量"}, + "order_entry.price": {"Limit price", "限价"}, + "order_entry.trigger": {"Trigger price", "触发价"}, + "order_entry.tif": {"Time in force", "有效期"}, + "order_entry.tif0": {"Day", "当日有效"}, + "order_entry.tif1": {"Good till canceled", "撤销前有效"}, + "order_entry.tif2": {"Immediate or cancel", "立即成交剩余撤销"}, + "order_entry.tif3": {"Fill or kill", "全部成交否则撤销"}, + "order_entry.estimate": {"Estimated amount", "预估金额"}, + "order_entry.submit1": {"Buy %s", "买入 %s"}, + "order_entry.submit2": {"Sell %s", "卖出 %s"}, + "order_entry.quantity_positive": {"Enter a quantity above zero.", "请输入大于零的数量。"}, + "order_entry.quantity_max": {"At most %s.", "最多 %s。"}, + "order_entry.price_positive": {"Enter a price above zero.", "请输入大于零的价格。"}, + "order_entry.trigger_positive": {"Enter a trigger price above zero.", "请输入大于零的触发价。"}, + "order_entry.trigger_above": {"A buy stop triggers above the last price.", "买入止损的触发价须高于最新价。"}, + "order_entry.trigger_below": {"A sell stop triggers below the last price.", "卖出止损的触发价须低于最新价。"}, + + "quick_trade_buttons.label": {"Quick trade", "快速交易"}, + "quick_trade_buttons.quantity": {"Quantity", "数量"}, + "quick_trade_buttons.side1": {"Buy %s", "买入 %s"}, + "quick_trade_buttons.side2": {"Sell %s", "卖出 %s"}, + "quick_trade_buttons.confirm1": {"Buy %s %s?", "买入 %s %s?"}, + "quick_trade_buttons.confirm2": {"Sell %s %s?", "卖出 %s %s?"}, + "quick_trade_buttons.market": {"The order fills at the market price.", "订单将以市价成交。"}, + + "position_table.symbol": {"Symbol", "代码"}, + "position_table.quantity": {"Quantity", "数量"}, + "position_table.cost": {"Avg cost", "成本价"}, + "position_table.price": {"Price", "现价"}, + "position_table.value": {"Market value", "市值"}, + "position_table.pnl": {"Unrealized P/L", "浮动盈亏"}, + "position_table.pnl_percent": {"P/L %", "盈亏比例"}, + "position_table.action": {"Action", "操作"}, + "position_table.close": {"Close", "平仓"}, + "position_table.empty": {"No positions", "暂无持仓"}, + + "order_table.side": {"Side", "方向"}, + "order_table.market": {"Market", "市价"}, + "order_table.filled": {"Filled", "成交"}, + "order_table.status": {"Status", "状态"}, + "order_table.status0": {"Open", "未成交"}, + "order_table.status1": {"Partly filled", "部分成交"}, + "order_table.status2": {"Filled", "已成交"}, + "order_table.status3": {"Canceled", "已撤销"}, + "order_table.status4": {"Rejected", "已拒绝"}, + "order_table.filter": {"Order status", "委托状态"}, + "order_table.filter0": {"All", "全部"}, + "order_table.filter1": {"Working", "进行中"}, + "order_table.filter2": {"Filled", "已成交"}, + "order_table.filter3": {"Closed", "已关闭"}, + "order_table.cancel": {"Cancel", "撤单"}, + "order_table.empty": {"No orders", "暂无委托"}, + + "trade_history_table.time": {"Time", "时间"}, + "trade_history_table.fee": {"Fee", "手续费"}, + "trade_history_table.range": {"Date range", "日期范围"}, + "trade_history_table.all": {"All dates", "全部日期"}, + "trade_history_table.export": {"Export", "导出"}, + "trade_history_table.empty": {"No trades in this range", "该范围内无成交"}, + + "leverage_slider.label": {"Leverage", "杠杆"}, + "leverage_slider.warning": {"High risk", "高风险"}, + "leverage_slider.danger": {"Very high risk", "极高风险"}, + "leverage_slider.apply": {"Apply", "确认"}, + + "order_confirm_dialog.title": {"Confirm order", "确认下单"}, + "order_confirm_dialog.confirm": {"Place order", "确认下单"}, + "order_confirm_dialog.cancel": {"Cancel", "取消"}, + }) +} diff --git a/order_book.go b/order_book.go new file mode 100644 index 0000000..dbf2871 --- /dev/null +++ b/order_book.go @@ -0,0 +1,260 @@ +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 +} diff --git a/order_book_test.go b/order_book_test.go new file mode 100644 index 0000000..b0d6485 --- /dev/null +++ b/order_book_test.go @@ -0,0 +1,123 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func orderBookSample() OrderBookData { + return OrderBookData{ + Bids: []BookLevel{{100.00, 5}, {99.99, 3}, {99.94, 2}, {99.90, 4}}, + Asks: []BookLevel{{100.01, 1}, {100.02, 2}, {100.07, 6}}, + } +} + +func TestOrderBookClickReportsPrice(t *testing.T) { + var s OrderBookState + var price float64 + var side OrderSide + tt := tradingTester(GreenUp, 400, 500, func(c *ui.Context) { + if p, sd, ok := OrderBook(c, &s, orderBookSample(), OrderBookOptions{}).Clicked(); ok { + price, side = p, sd + } + }) + if err := tt.Click("Ask 100.02, size 2"); err != nil { + t.Fatal(err) + } + if price != 100.02 || side != SideSell { + t.Fatalf("clicked %v %v", price, side) + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if price != 100.01 || side != SideSell { + t.Fatalf("keyboard %v %v", price, side) + } + if !tt.HasText("0.01 (0.010%)") { + t.Fatalf("spread missing: %q", tt.Texts()) + } +} + +func TestOrderBookGroupsLevelsByTicks(t *testing.T) { + s := OrderBookState{Group: 0.05} + tt := tradingTester(GreenUp, 400, 500, func(c *ui.Context) { + OrderBook(c, &s, orderBookSample(), OrderBookOptions{Groups: []float64{0.01, 0.05, 0.1}}) + }) + // Bids round down, asks round up. + want := []BookLevel{{100.00, 5}, {99.95, 3}, {99.90, 6}} + for i, l := range want { + if s.cache.bids[i] != l { + t.Fatalf("bids %v", s.cache.bids) + } + } + if s.cache.asks[0] != (BookLevel{100.05, 3}) || s.cache.asks[1] != (BookLevel{100.10, 6}) { + t.Fatalf("asks %v", s.cache.asks) + } + if !tt.HasText("99.95") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestOrderBookAggregatesOnlyOnChange(t *testing.T) { + var s OrderBookState + book := orderBookSample() + tt := tradingTester(GreenUp, 400, 500, func(c *ui.Context) { OrderBook(c, &s, book, OrderBookOptions{}) }) + first := &s.cache.bids[0] + tt.Frame() + if &s.cache.bids[0] != first { + t.Fatal("regrouped without a change") + } + book.Bids[0].Size = 9 + book.Version++ + tt.Frame() + if s.cache.bids[0].Size != 9 { + t.Fatal("version change ignored") + } +} + +func TestOrderBookDisabledAndBadGroup(t *testing.T) { + var s OrderBookState + clicked := false + tt := tradingTester(GreenUp, 400, 500, func(c *ui.Context) { + _, _, clicked = OrderBook(c, &s, orderBookSample(), OrderBookOptions{Disabled: true}).Clicked() + }) + _ = tt.Click("Bid 100.00, size 5") + if clicked { + t.Fatal("disabled book clicked") + } + defer func() { + if recover() == nil { + t.Fatal("off-tick group accepted") + } + }() + bad := OrderBookState{Group: 0.015} + tradingTester(GreenUp, 100, 100, func(c *ui.Context) { + OrderBook(c, &bad, orderBookSample(), OrderBookOptions{Groups: []float64{0.015}}) + }) +} + +func TestOrderBookSnapshot(t *testing.T) { + snapshot(t, "order_book", 320, 420, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + s := ui.Local(c.Root(), "snap", func() OrderBookState { return OrderBookState{} }) + OrderBook(c, s, orderBookSample(), OrderBookOptions{Depth: 4, Groups: []float64{0.01, 0.05}}) + }) + }) +} + +// A 100,000-level book builds only the shown depth. +func TestOrderBookLargeBook(t *testing.T) { + var s OrderBookState + book := OrderBookData{} + for i := range 100_000 { + book.Bids = append(book.Bids, BookLevel{Price: 1000 - float64(i)/100, Size: 1}) + book.Asks = append(book.Asks, BookLevel{Price: 1000.01 + float64(i)/100, Size: 1}) + } + tt := tradingTester(GreenUp, 400, 900, func(c *ui.Context) { OrderBook(c, &s, book, OrderBookOptions{}) }) + if n := len(tt.Texts()); n > 80 { + t.Fatalf("built %d texts", n) + } + if len(s.cache.bids) != 100_000 || s.cache.bids[0].Price != 1000 { + t.Fatalf("bids %d", len(s.cache.bids)) + } +} diff --git a/order_confirm_dialog.go b/order_confirm_dialog.go new file mode 100644 index 0000000..357b5b0 --- /dev/null +++ b/order_confirm_dialog.go @@ -0,0 +1,88 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// OrderSummaryLine is one labeled value of an order summary. +type OrderSummaryLine struct { + Label, Value string +} + +// OrderConfirmDialogOptions configure an OrderConfirmDialog. Lines are required. +type OrderConfirmDialogOptions struct { + // Title is "Confirm order" when empty. + Title string + Lines []OrderSummaryLine + // Risk is a warning shown above the actions. + Risk string + // Acknowledge, when set, must be ticked before confirming. + Acknowledge string + // ConfirmLabel is "Place order" when empty. + ConfirmLabel string +} + +type orderConfirmState struct{ ack bool } + +// OrderConfirmDialog summarizes an order; Cancel has the focus. +func OrderConfirmDialog(c *ui.Context, open *bool, opts OrderConfirmDialogOptions) AlertDialogResult { + if len(opts.Lines) == 0 { + panic("mujica: OrderConfirmDialog needs summary Lines") + } + var res AlertDialogResult + k := Tokens(c) + u := Density(c).Unit() + title, confirm := opts.Title, opts.ConfirmLabel + if title == "" { + title = msg(c, "order_confirm_dialog.title") + } + if confirm == "" { + confirm = msg(c, "order_confirm_dialog.confirm") + } + was := *open + var st *orderConfirmState + Dialog(c, open, DialogOptions{Title: title, KeepOnBackdrop: true, HideClose: true, Actions: func() { + if Button(c, msg(c, "order_confirm_dialog.cancel"), ButtonOptions{Variant: Secondary}).AutoFocus().Clicked() { + res.Canceled = true + } + locked := opts.Acknowledge != "" && !st.ack + if Button(c, confirm, ButtonOptions{Disabled: locked}).Clicked() && !locked { + res.Confirmed = true + } + }}, func() { + list := ui.Column(c) + // The tick lives with the open dialog only. + st = ui.Local(list, "ack", func() orderConfirmState { return orderConfirmState{} }) + list.Children(func() { + for _, l := range opts.Lines { + ui.Row(c).Gap(u*4).MinHeight(Density(c).ControlHeight()).AlignItems(ui.Center). + BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() { + ui.Text(c, l.Label).TextColor(k.TextMuted).Grow(1) + ui.Text(c, l.Value).FontFeatures("tnum").FontWeight(600) + }) + } + }) + if opts.Risk != "" { + ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Padding(u * 3).Radius(theme.ControlRadius).Background(k.Warning.Alpha(0.12)).Children(func() { + ui.Icon(c, icons.Must("triangle-alert")).Size(16, 16).TextColor(k.Warning).Shrink(0) + ui.Text(c, opts.Risk).Grow(1) + }) + } + if opts.Acknowledge != "" { + Checkbox(c, &st.ack, opts.Acknowledge, CheckboxOptions{}) + } + }) + if was && !*open && !res.Confirmed { + res.Canceled = true + } + if res.Confirmed || res.Canceled { + *open = false + slog.Debug("mujica: order_confirm_dialog close", "confirmed", res.Confirmed) + } + return res +} diff --git a/order_confirm_dialog_test.go b/order_confirm_dialog_test.go new file mode 100644 index 0000000..68d48f2 --- /dev/null +++ b/order_confirm_dialog_test.go @@ -0,0 +1,78 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func orderConfirmTester(open *bool, opts OrderConfirmDialogOptions, res *AlertDialogResult) *ui.Tester { + return tradingTester(GreenUp, 600, 500, func(c *ui.Context) { + if r := OrderConfirmDialog(c, open, opts); r.Confirmed || r.Canceled { + *res = r + } + }) +} + +var orderConfirmLines = []OrderSummaryLine{{"Symbol", "MUJC"}, {"Side", "Buy"}, {"Quantity", "100"}, {"Est. amount", "10,020.00"}} + +func TestOrderConfirmDialogFocusesCancel(t *testing.T) { + open := true + var res AlertDialogResult + tt := orderConfirmTester(&open, OrderConfirmDialogOptions{Lines: orderConfirmLines, Risk: "Leveraged products can lose more than the deposit."}, &res) + if !tt.Focused("Cancel") || !tt.HasText("10,020.00") || !tt.HasText("Leveraged products can lose more than the deposit.") { + t.Fatalf("texts %q", tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + if !res.Canceled || open { + t.Fatalf("Enter on Cancel: %+v open %v", res, open) + } + open, res = true, AlertDialogResult{} + tt.Frame() + if err := tt.Click("Place order"); err != nil { + t.Fatal(err) + } + if !res.Confirmed || open { + t.Fatalf("confirm: %+v", res) + } +} + +func TestOrderConfirmDialogAcknowledgeAndEscape(t *testing.T) { + open := true + var res AlertDialogResult + tt := orderConfirmTester(&open, OrderConfirmDialogOptions{Lines: orderConfirmLines, Acknowledge: "I understand the risk"}, &res) + _ = tt.Click("Place order") + if res.Confirmed || !open { + t.Fatal("confirmed before acknowledging") + } + _ = tt.Click("I understand the risk") + _ = tt.Click("Place order") + if !res.Confirmed { + t.Fatal("acknowledged confirm refused") + } + open, res = true, AlertDialogResult{} + tt.Frame() + _ = tt.Click("Place order") + if res.Confirmed { + t.Fatal("acknowledgement survived reopening") + } + tt.Key(0, ui.KeyEscape) + if !res.Canceled || open { + t.Fatalf("escape: %+v", res) + } + defer func() { + if recover() == nil { + t.Fatal("empty summary accepted") + } + }() + open = true + orderConfirmTester(&open, OrderConfirmDialogOptions{}, &res) +} + +func TestOrderConfirmDialogSnapshot(t *testing.T) { + open := true + snapshot(t, "order_confirm_dialog", 560, 480, func(c *ui.Context) { + OrderConfirmDialog(c, &open, OrderConfirmDialogOptions{Lines: orderConfirmLines, + Risk: "Leveraged products can lose more than the deposit.", Acknowledge: "I understand the risk"}) + }) +} diff --git a/order_entry.go b/order_entry.go new file mode 100644 index 0000000..0c95398 --- /dev/null +++ b/order_entry.go @@ -0,0 +1,222 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// OrderType is limit, market, stop or stop limit. +type OrderType int + +const ( + OrderLimit OrderType = iota + OrderMarket + OrderStop + OrderStopLimit +) + +// TimeInForce is how long an order rests. +type TimeInForce int + +const ( + TIFDay TimeInForce = iota + TIFGoodTillCanceled + TIFImmediateOrCancel + TIFFillOrKill +) + +// OrderDraft is an order as entered. +type OrderDraft struct { + Side OrderSide + Type OrderType + Quantity float64 + Price float64 + Trigger float64 + TIF TimeInForce +} + +// OrderEntryOptions configure an OrderEntry. Symbol is required. +type OrderEntryOptions struct { + Symbol string + // Last is the last price; zero means unknown. + Last float64 + // TickSize and LotSize step prices and quantities; zero: 0.01, 1. + TickSize, LotSize float64 + // MaxQuantity caps the quantity; zero means no cap. + MaxQuantity float64 + // Initial is what Reset restores; a zero Side means buy. + Initial OrderDraft + // Validate adds the caller's rules, returning a message or "". + Validate func(o OrderDraft) string + // Check starts an async check, finished with OrderEntryState.Resolve. + Check func(o OrderDraft, version uint64) + Disabled bool +} + +// OrderEntryState holds the draft and its form state. +type OrderEntryState struct { + Draft OrderDraft + Form FormState + started bool +} + +// Resolve delivers Check's result; stale versions return false. +func (s *OrderEntryState) Resolve(version uint64, err string) bool { + return s.Form.Resolve("quantity", version, err) +} + +// EndSubmit finishes a submit; a formError shows, keeping the draft. +func (s *OrderEntryState) EndSubmit(formError string) { s.Form.EndSubmit(formError) } + +// OrderEntryResult is a built OrderEntry and its submitted order. +type OrderEntryResult struct { + Element *ui.Element + order OrderDraft + submitted bool +} + +// Submitted returns the checked order; call EndSubmit once placed. +func (r OrderEntryResult) Submitted() (OrderDraft, bool) { return r.order, r.submitted } + +// OrderEntry edits an order and submits it once checked. +func OrderEntry(c *ui.Context, s *OrderEntryState, opts OrderEntryOptions) OrderEntryResult { + if opts.Symbol == "" { + panic("mujica: OrderEntry needs a Symbol") + } + if opts.TickSize < 0 || opts.LotSize < 0 || opts.MaxQuantity < 0 || opts.Last < 0 { + panic("mujica: OrderEntry TickSize, LotSize, MaxQuantity and Last cannot be negative") + } + tick, lot := opts.TickSize, opts.LotSize + if tick == 0 { + tick = 0.01 + } + if lot == 0 { + lot = 1 + } + initial := opts.Initial + if initial.Side == 0 { + initial.Side = SideBuy + } + if !s.started { + s.started, s.Draft = true, initial + } + priceSpec := numberInputResolve(NumberInputOptions{Step: tick}) + k := Tokens(c) + u := Density(c).Unit() + var r OrderEntryResult + r.Element = ui.Column(c).Gap(u * 4).MaxWidth(360).Disabled(opts.Disabled).Role(ui.RoleGroup).Label(msg(c, "order_entry.label")) + disabled := r.Element.IsDisabled() + r.Element.Children(func() { + side := 0 + if s.Draft.Side == SideSell { + side = 1 + } + sides := []Segment{{Label: msg(c, "order_entry.buy")}, {Label: msg(c, "order_entry.sell")}} + SegmentedControl(c, &side, sides, SegmentedControlOptions{Equal: true, Label: msg(c, "order_entry.side"), Disabled: disabled}) + s.Draft.Side = SideBuy + OrderSide(side) + var types []SelectOption[OrderType] + for t := OrderLimit; t <= OrderStopLimit; t++ { + types = append(types, SelectOption[OrderType]{Value: t, Label: msg(c, fmt.Sprintf("order_entry.type%d", t))}) + } + ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Children(func() { + ui.Text(c, opts.Symbol).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).Grow(1) + Select(c, &s.Draft.Type, types, SelectOptions{Label: msg(c, "order_entry.type"), Disabled: disabled}) + }) + submit := fmt.Sprintf(msg(c, fmt.Sprintf("order_entry.submit%d", s.Draft.Side)), opts.Symbol) + res := Form(c, &s.Form, FormOptions{SubmitLabel: submit}, func(f *FormScope) { + orderEntryFields(c, f, s, opts, initial, tick, lot, disabled) + }) + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, msg(c, "order_entry.estimate")).TextColor(k.TextMuted) + est := "—" + if p := orderEntryPrice(s.Draft, opts.Last); p > 0 && s.Draft.Quantity > 0 { + est = "≈ " + statisticFormat(p*s.Draft.Quantity, priceSpec.decimals) + } + ui.Text(c, est).FontFeatures("tnum").FontWeight(600) + }) + if res.Submitted() { + r.order, r.submitted = s.Draft, true + slog.Debug("mujica: order_entry submitted", "side", s.Draft.Side, "type", s.Draft.Type) + } + }) + return r +} + +// orderEntryPrice is the expected fill price, or 0 if unknown. +func orderEntryPrice(o OrderDraft, last float64) float64 { + switch o.Type { + case OrderLimit, OrderStopLimit: + return o.Price + case OrderStop: + return o.Trigger + } + return last +} + +// orderEntryFields registers the fields the order type needs. +func orderEntryFields(c *ui.Context, f *FormScope, s *OrderEntryState, opts OrderEntryOptions, initial OrderDraft, tick, lot float64, disabled bool) { + maxQ := opts.MaxQuantity + FormItem(c, f, "quantity", &s.Draft, FormItemOptions[OrderDraft]{Label: msg(c, "order_entry.quantity"), Required: true, Initial: initial, + Validate: func(o OrderDraft) string { return orderEntryCheck(c, o, opts, maxQ) }, + Async: opts.Check, + }, func() *ui.Element { + return NumberInput(c, &s.Draft.Quantity, NumberInputOptions{Step: lot, Disabled: disabled, Error: s.Form.Error("quantity")}).Input + }) + if s.Draft.Type == OrderStop || s.Draft.Type == OrderStopLimit { + FormItem(c, f, "trigger", &s.Draft.Trigger, FormItemOptions[float64]{Label: msg(c, "order_entry.trigger"), Required: true, Initial: initial.Trigger, + Validate: func(v float64) string { return orderEntryTrigger(c, s.Draft, opts.Last) }, + }, func() *ui.Element { + return NumberInput(c, &s.Draft.Trigger, NumberInputOptions{Step: tick, Disabled: disabled, Error: s.Form.Error("trigger")}).Input + }) + } + if s.Draft.Type == OrderLimit || s.Draft.Type == OrderStopLimit { + FormItem(c, f, "price", &s.Draft.Price, FormItemOptions[float64]{Label: msg(c, "order_entry.price"), Required: true, Initial: initial.Price, + Validate: func(v float64) string { + if v <= 0 { + return msg(c, "order_entry.price_positive") + } + return "" + }, + }, func() *ui.Element { + return NumberInput(c, &s.Draft.Price, NumberInputOptions{Step: tick, Disabled: disabled, Error: s.Form.Error("price")}).Input + }) + } + var tifs []SelectOption[TimeInForce] + for t := TIFDay; t <= TIFFillOrKill; t++ { + tifs = append(tifs, SelectOption[TimeInForce]{Value: t, Label: msg(c, fmt.Sprintf("order_entry.tif%d", t))}) + } + FormItem(c, f, "tif", &s.Draft.TIF, FormItemOptions[TimeInForce]{Label: msg(c, "order_entry.tif"), Initial: initial.TIF}, func() *ui.Element { + return Select(c, &s.Draft.TIF, tifs, SelectOptions{Label: msg(c, "order_entry.tif"), Disabled: disabled}).Element + }) +} + +// orderEntryCheck checks the quantity, then the caller's rules. +func orderEntryCheck(c *ui.Context, o OrderDraft, opts OrderEntryOptions, maxQ float64) string { + switch { + case o.Quantity <= 0: + return msg(c, "order_entry.quantity_positive") + case maxQ > 0 && o.Quantity > maxQ: + return fmt.Sprintf(msg(c, "order_entry.quantity_max"), statisticFormat(maxQ, 0)) + } + if opts.Validate != nil { + return opts.Validate(o) + } + return "" +} + +// orderEntryTrigger checks a stop's side of the last price. +func orderEntryTrigger(c *ui.Context, o OrderDraft, last float64) string { + switch { + case o.Trigger <= 0: + return msg(c, "order_entry.trigger_positive") + case last > 0 && o.Side == SideBuy && o.Trigger <= last: + return msg(c, "order_entry.trigger_above") + case last > 0 && o.Side == SideSell && o.Trigger >= last: + return msg(c, "order_entry.trigger_below") + } + return "" +} diff --git a/order_entry_test.go b/order_entry_test.go new file mode 100644 index 0000000..58e9a19 --- /dev/null +++ b/order_entry_test.go @@ -0,0 +1,137 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type orderEntryProbe struct { + s OrderEntryState + opts OrderEntryOptions + orders []OrderDraft + checks []uint64 + checked []OrderDraft +} + +func (p *orderEntryProbe) tester() *ui.Tester { + return tradingTester(GreenUp, 520, 700, func(c *ui.Context) { + if o, ok := OrderEntry(c, &p.s, p.opts).Submitted(); ok { + p.orders = append(p.orders, o) + } + }) +} + +func TestOrderEntryLimitSubmitOnce(t *testing.T) { + p := &orderEntryProbe{opts: OrderEntryOptions{Symbol: "MUJC", Last: 100}} + tt := p.tester() + _ = tt.Click("Quantity") + tt.Type("10") + _ = tt.Click("Limit price") + tt.Key(ui.Cmd, ui.KeyA) + tt.Type("99.5") + if !tt.HasText("≈ 995.00") { + t.Fatalf("estimate: %q", tt.Texts()) + } + if err := tt.Click("Buy MUJC"); err != nil { + t.Fatal(err) + } + _ = tt.Click("Buy MUJC") + if len(p.orders) != 1 || p.orders[0] != (OrderDraft{Side: SideBuy, Type: OrderLimit, Quantity: 10, Price: 99.5}) { + t.Fatalf("orders %+v", p.orders) + } + p.s.EndSubmit("") + tt.Frame() + _ = tt.Click("Buy MUJC") + if len(p.orders) != 2 { + t.Fatalf("after EndSubmit: %d orders", len(p.orders)) + } +} + +func TestOrderEntryValidatesByType(t *testing.T) { + p := &orderEntryProbe{opts: OrderEntryOptions{Symbol: "MUJC", Last: 100, MaxQuantity: 50}} + tt := p.tester() + _ = tt.Click("Buy MUJC") + if len(p.orders) != 0 || !tt.HasText("Enter a quantity above zero.") || !tt.HasText("Required") { + t.Fatalf("empty order: %q", tt.Texts()) + } + if !tt.Focused("Quantity") { + t.Fatal("first error not focused") + } + tt.Type("80") + tt.Key(0, ui.KeyTab) + if !tt.HasText("At most 50.") { + t.Fatalf("max: %q", tt.Texts()) + } + p.s.Draft.Type, p.s.Draft.Side, p.s.Draft.Quantity, p.s.Draft.Trigger = OrderStop, SideSell, 5, 101 + tt.Frame() + _ = tt.Click("Sell MUJC") + if len(p.orders) != 0 || !tt.HasText("A sell stop triggers below the last price.") || tt.HasText("Limit price") { + t.Fatalf("stop: %q", tt.Texts()) + } + p.s.Draft.Trigger = 98 + tt.Frame() + _ = tt.Click("Sell MUJC") + if len(p.orders) != 1 || p.orders[0].Type != OrderStop || p.orders[0].Trigger != 98 { + t.Fatalf("orders %+v", p.orders) + } +} + +// An old draft's check cannot clear a newer draft. +func TestOrderEntryAsyncCheckIsVersioned(t *testing.T) { + p := &orderEntryProbe{} + p.opts = OrderEntryOptions{Symbol: "MUJC", Last: 100, Check: func(o OrderDraft, v uint64) { + p.checks, p.checked = append(p.checks, v), append(p.checked, o) + }} + p.s.started, p.s.Draft = true, OrderDraft{Side: SideBuy, Type: OrderMarket, Quantity: 10} + tt := p.tester() + _ = tt.Click("Buy MUJC") + if len(p.checks) != 1 || len(p.orders) != 0 { + t.Fatalf("checks %v orders %d", p.checks, len(p.orders)) + } + p.s.Draft.Quantity = 20 + tt.Frame() + if p.s.Resolve(p.checks[0], "") { + t.Fatal("stale check accepted") + } + tt.Frame() + if len(p.checks) != 2 || p.checked[1].Quantity != 20 || len(p.orders) != 0 { + t.Fatalf("checks %v orders %d", p.checks, len(p.orders)) + } + p.s.Resolve(p.checks[1], "Not enough buying power.") + tt.Frame() + tt.Frame() + if len(p.orders) != 0 || !tt.HasText("Not enough buying power.") { + t.Fatalf("failed check submitted: %q", tt.Texts()) + } +} + +func TestOrderEntryDisabledAndReset(t *testing.T) { + p := &orderEntryProbe{opts: OrderEntryOptions{Symbol: "MUJC", Disabled: true, Initial: OrderDraft{Quantity: 1, Price: 50}}} + tt := p.tester() + _ = tt.Click("Quantity") + tt.Type("9") + _ = tt.Click("Buy MUJC") + if p.s.Draft.Quantity != 1 || len(p.orders) != 0 { + t.Fatalf("disabled entry changed: %+v", p.s.Draft) + } + p.opts.Disabled = false + p.s.Draft.Quantity, p.s.Draft.Side = 7, SideSell + tt.Frame() + _ = tt.Click("Reset") + if p.s.Draft != (OrderDraft{Side: SideBuy, Quantity: 1, Price: 50}) { + t.Fatalf("reset: %+v", p.s.Draft) + } +} + +func TestOrderEntrySnapshot(t *testing.T) { + snapshot(t, "order_entry", 420, 520, func(c *ui.Context) { + ui.Column(c).Padding(20).Children(func() { + s := ui.Local(c.Root(), "snap", func() OrderEntryState { return OrderEntryState{} }) + if !s.started { + s.started, s.Draft = true, OrderDraft{Side: SideBuy, Type: OrderStopLimit, Quantity: 25, Price: 101.5, Trigger: 101} + } + OrderEntry(c, s, OrderEntryOptions{Symbol: "MUJC", Last: 100.2}) + }) + }) +} diff --git a/order_table.go b/order_table.go new file mode 100644 index 0000000..7e558ff --- /dev/null +++ b/order_table.go @@ -0,0 +1,194 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// OrderStatus is where an order stands. +type OrderStatus int + +const ( + OrderOpen OrderStatus = iota + OrderPartial + OrderFilled + OrderCanceled + OrderRejected +) + +// working reports an order that can still fill. +func (s OrderStatus) working() bool { return s == OrderOpen || s == OrderPartial } + +// OrderRow is one order; ID must be stable across updates. +type OrderRow struct { + ID, Symbol string + Side OrderSide + Type OrderType + Price float64 + Quantity float64 + Filled float64 + Status OrderStatus +} + +// OrderFilter picks which orders an OrderTable shows. +type OrderFilter int + +const ( + OrdersAll OrderFilter = iota + OrdersWorking + OrdersFilled + OrdersClosed +) + +func (f OrderFilter) keeps(s OrderStatus) bool { + switch f { + case OrdersWorking: + return s.working() + case OrdersFilled: + return s == OrderFilled + case OrdersClosed: + return s == OrderCanceled || s == OrderRejected + } + return true +} + +// OrderTableOptions configure an OrderTable. +type OrderTableOptions struct { + // Decimals of prices and quantities; zero means 2 and 0. + Decimals, QuantityDecimals int + // Version must change when rows change in place. + Version int + Status DataStatus + Error string + Disabled bool +} + +// OrderTableState is the status filter and table state. +type OrderTableState struct { + Filter OrderFilter + Table DataTableState[string] + cache tradingRows[OrderRow] +} + +// OrderTable lists orders with filter, progress and Cancel. +func OrderTable(c *ui.Context, s *OrderTableState, rows []OrderRow, opts OrderTableOptions) TableActionResult { + dec := priceDecimals("OrderTable", opts.Decimals) + if opts.QuantityDecimals < 0 { + panic("mujica: OrderTable with negative QuantityDecimals") + } + qd := opts.QuantityDecimals + shown := s.cache.view(rows, opts.Version, s.Filter, func(o OrderRow) bool { + if o.Quantity <= 0 || o.Filled < 0 || o.Filled > o.Quantity { + panic(fmt.Sprintf("mujica: OrderTable order %s fills %v of %v", o.ID, o.Filled, o.Quantity)) + } + return s.Filter.keeps(o.Status) + }) + var r TableActionResult + text := func(id, key string, t func(OrderRow) string) DataColumn[OrderRow] { + return DataColumn[OrderRow]{ID: id, Title: msg(c, key), Sortable: true, Text: t, + Compare: func(a, b OrderRow) int { return cmp.Compare(t(a), t(b)) }} + } + cols := []DataColumn[OrderRow]{ + text("symbol", "position_table.symbol", func(o OrderRow) string { return o.Symbol }), + {ID: "side", Title: msg(c, "order_table.side"), Text: func(o OrderRow) string { return msg(c, fmt.Sprintf("order_entry.side%d", o.Side)) }, + Cell: func(o OrderRow) { tradingSide(c, o.Side) }}, + text("type", "order_entry.type", func(o OrderRow) string { return msg(c, fmt.Sprintf("order_entry.type%d", o.Type)) }), + {ID: "price", Title: msg(c, "order_book.price"), Align: ui.End, Sortable: true, + Compare: func(a, b OrderRow) int { return cmp.Compare(a.Price, b.Price) }, + Text: func(o OrderRow) string { + if o.Type == OrderMarket { + return msg(c, "order_table.market") + } + return statisticFormat(o.Price, dec) + }}, + {ID: "filled", Title: msg(c, "order_table.filled"), Width: 140, + Text: func(o OrderRow) string { + return statisticFormat(o.Filled, qd) + " / " + statisticFormat(o.Quantity, qd) + }, + Cell: func(o OrderRow) { orderTableProgress(c, o, qd) }}, + text("status", "order_table.status", func(o OrderRow) string { return msg(c, fmt.Sprintf("order_table.status%d", o.Status)) }), + {ID: "action", Title: msg(c, "position_table.action"), Width: 96, Text: func(OrderRow) string { return "" }, + Cell: func(o OrderRow) { + if o.Status.working() && Button(c, msg(c, "order_table.cancel"), ButtonOptions{Variant: Secondary}). + Label(msg(c, "order_table.cancel")+" "+o.Symbol).Clicked() { + r.id = o.ID + } + }}, + } + d := Density(c) + r.Element = ui.Column(c).Gap(d.Unit() * 2).Disabled(opts.Disabled).Grow(1).FillWidth().MinHeight(0) + r.Element.Children(func() { + var segs []Segment + for f := OrdersAll; f <= OrdersClosed; f++ { + segs = append(segs, Segment{Label: msg(c, fmt.Sprintf("order_table.filter%d", f))}) + } + at := int(s.Filter) + if SegmentedControl(c, &at, segs, SegmentedControlOptions{Label: msg(c, "order_table.filter"), Disabled: opts.Disabled}).Changed() { + s.Filter = OrderFilter(at) + slog.Debug("mujica: order_table filter", "filter", s.Filter) + } + r.Table = DataTable(c, &s.Table, shown, DataTableOptions[OrderRow, string]{Columns: cols, Key: func(o OrderRow) string { return o.ID }, + Status: opts.Status, Error: opts.Error, Empty: msg(c, "order_table.empty")}) + r.Table.Element.Grow(1) + }) + if r.Element.IsDisabled() { + r.id = "" + } + if r.id != "" { + slog.Debug("mujica: order_table cancel") + } + return r +} + +// orderTableProgress shows the filled share as bar and count. +func orderTableProgress(c *ui.Context, o OrderRow, qd int) { + k := Tokens(c) + share := float32(o.Filled / o.Quantity) + ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).FillWidth().Children(func() { + ui.Box(c).Size(48, 6).Shrink(0).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(r, k.SurfaceHover, theme.ControlRadius) + p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * share, H: r.H}, k.AccentText, theme.ControlRadius) + }) + ui.Text(c, statisticFormat(o.Filled, qd)+" / "+statisticFormat(o.Quantity, qd)).FontFeatures("tnum").FontSize(FontSize(c, theme.CaptionSize)).SingleLine() + }) +} + +// tradingSide names a side with its arrow and trend color. +func tradingSide(c *ui.Context, side OrderSide) { + ui.Row(c).Gap(2).AlignItems(ui.Center).TextColor(trendColor(c, side.sign())).Children(func() { + ui.Icon(c, trendIcon(side.sign())).Size(12, 12) + ui.Text(c, msg(c, fmt.Sprintf("order_entry.side%d", side))).FontWeight(600).SingleLine() + }) +} + +// tradingRows is a filtered view, rebuilt only on change. +type tradingRows[R any] struct { + first *R + n, ver int + filter any + valid bool + visible []R +} + +func (t *tradingRows[R]) view(rows []R, version int, filter any, keep func(R) bool) []R { + var first *R + if len(rows) > 0 { + first = &rows[0] + } + if t.valid && t.first == first && t.n == len(rows) && t.ver == version && t.filter == filter { + return t.visible + } + t.first, t.n, t.ver, t.filter, t.valid = first, len(rows), version, filter, true + t.visible = make([]R, 0, len(rows)) + for _, r := range rows { + if keep(r) { + t.visible = append(t.visible, r) + } + } + return t.visible +} diff --git a/order_table_test.go b/order_table_test.go new file mode 100644 index 0000000..be31cfa --- /dev/null +++ b/order_table_test.go @@ -0,0 +1,118 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +func orderTableSample() []OrderRow { + return []OrderRow{ + {ID: "o1", Symbol: "MUJC", Side: SideBuy, Type: OrderLimit, Price: 99.5, Quantity: 100, Filled: 40, Status: OrderPartial}, + {ID: "o2", Symbol: "AVE", Side: SideSell, Type: OrderMarket, Quantity: 10, Filled: 10, Status: OrderFilled}, + {ID: "o3", Symbol: "OBLV", Side: SideBuy, Type: OrderStop, Price: 12, Quantity: 5, Status: OrderCanceled}, + {ID: "o4", Symbol: "DOLO", Side: SideSell, Type: OrderLimit, Price: 7.25, Quantity: 30, Status: OrderOpen}, + } +} + +func orderTableTester(s *OrderTableState, rows *[]OrderRow, opts *OrderTableOptions, acted *string) *ui.Tester { + return tradingTester(GreenUp, 1000, 400, func(c *ui.Context) { + ui.Box(c).Width(960).Height(320).Children(func() { + if id, ok := OrderTable(c, s, *rows, *opts).Acted(); ok { + *acted = id + } + }) + }) +} + +func TestOrderTableFilterProgressAndCancel(t *testing.T) { + var s OrderTableState + rows, opts, acted := orderTableSample(), OrderTableOptions{}, "" + tt := orderTableTester(&s, &rows, &opts, &acted) + if !tt.HasText("40 / 100") || !tt.HasText("Partly filled") || !tt.HasText("Market") { + t.Fatalf("texts %q", tt.Texts()) + } + if err := tt.Click("Working"); err != nil { + t.Fatal(err) + } + if tt.HasText("AVE") || tt.HasText("OBLV") || !tt.HasText("DOLO") { + t.Fatalf("working filter: %q", tt.Texts()) + } + if _, ok := tt.Find("Cancel AVE"); ok { + t.Fatal("filled order offers cancel") + } + if err := tt.Click("Cancel DOLO"); err != nil { + t.Fatal(err) + } + if acted != "o4" { + t.Fatalf("acted %q", acted) + } + _ = tt.Click("Closed") + if !tt.HasText("OBLV") || tt.HasText("MUJC") { + t.Fatalf("closed filter: %q", tt.Texts()) + } +} + +func TestOrderTableFiltersOnlyOnChange(t *testing.T) { + var s OrderTableState + rows, opts, acted := orderTableSample(), OrderTableOptions{}, "" + tt := orderTableTester(&s, &rows, &opts, &acted) + view := s.cache.visible + tt.Frame() + if &s.cache.visible[0] != &view[0] { + t.Fatal("refiltered without a change") + } + rows[0].Status = OrderFilled + opts.Version++ + _ = tt.Click("Working") + if len(s.cache.visible) != 1 { + t.Fatalf("version change ignored: %d rows", len(s.cache.visible)) + } +} + +func TestOrderTableDisabledAndBadFill(t *testing.T) { + var s OrderTableState + rows, opts, acted := orderTableSample(), OrderTableOptions{Disabled: true}, "" + tt := orderTableTester(&s, &rows, &opts, &acted) + _ = tt.Click("Cancel DOLO") + _ = tt.Click("Working") + if acted != "" || s.Filter != OrdersAll { + t.Fatal("disabled table acted") + } + defer func() { + if recover() == nil { + t.Fatal("overfilled order accepted") + } + }() + var s2 OrderTableState + bad := []OrderRow{{ID: "x", Quantity: 1, Filled: 2}} + orderTableTester(&s2, &bad, &OrderTableOptions{}, &acted) +} + +func TestOrderTableSnapshot(t *testing.T) { + snapshot(t, "order_table", 960, 300, func(c *ui.Context) { + ui.Column(c).Padding(16).Height(280).Width(940).Children(func() { + s := ui.Local(c.Root(), "snap", func() OrderTableState { return OrderTableState{} }) + OrderTable(c, s, orderTableSample(), OrderTableOptions{}) + }) + }) +} + +// 100,000 orders filter once and build only visible rows. +func TestOrderTableLargeData(t *testing.T) { + var s OrderTableState + rows := make([]OrderRow, 100_000) + for i := range rows { + rows[i] = OrderRow{ID: fmt.Sprint(i), Symbol: fmt.Sprintf("S%05d", i), Side: SideBuy, Price: 1, Quantity: 10, Status: OrderStatus(i % 5)} + } + opts, acted := OrderTableOptions{}, "" + tt := orderTableTester(&s, &rows, &opts, &acted) + _ = tt.Click("Working") + if len(s.cache.visible) != 40_000 { + t.Fatalf("working rows %d", len(s.cache.visible)) + } + if n := len(tt.Texts()); n > 400 { + t.Fatalf("built %d texts", n) + } +} diff --git a/pnl_display.go b/pnl_display.go new file mode 100644 index 0000000..605745d --- /dev/null +++ b/pnl_display.go @@ -0,0 +1,82 @@ +package mujica + +import ( + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// PnL is profit and loss: realized, unrealized and today's. +type PnL struct { + Realized, Unrealized, Today float64 +} + +// PnLDisplayOptions configure a PnLDisplay. +type PnLDisplayOptions struct { + // Decimals of the amounts; zero means 2. + Decimals int + // Currency prefixes each amount, as "$". + Currency string +} + +// PnLDisplay shows three profits signed; *hidden masks them. +func PnLDisplay(c *ui.Context, pnl PnL, hidden *bool, opts PnLDisplayOptions) ChoiceResult { + dec := priceDecimals("PnLDisplay", opts.Decimals) + k := Tokens(c) + u := Density(c).Unit() + var r ChoiceResult + r.Element = ui.Row(c).Gap(u * 6).AlignItems(ui.Start).Wrap().Role(ui.RoleGroup).Label(msg(c, "pnl_display.label")) + r.Element.Children(func() { + for _, item := range []struct { + key string + v float64 + }{{"pnl_display.realized", pnl.Realized}, {"pnl_display.unrealized", pnl.Unrealized}, {"pnl_display.today", pnl.Today}} { + title := msg(c, item.key) + sign := trendSign(item.v) + text := statisticSigned(item.v, dec) + if opts.Currency != "" { + text = pnlDisplaySign(sign) + opts.Currency + statisticFormat(math.Abs(item.v), dec) + } + col := trendColor(c, sign) + spoken := title + ", " + trendWord(c, sign) + " " + text + if *hidden { + text, col, spoken = "••••", k.TextMuted, title+", "+msg(c, "pnl_display.masked") + } + ui.Column(c).Gap(u).Label(spoken).Role(ui.RoleText).Children(func() { + ui.Text(c, title).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleNone) + ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(col).Role(ui.RoleNone).Children(func() { + if !*hidden { + ui.Icon(c, trendIcon(sign)).Size(16, 16) + } + ui.Text(c, text).FontSize(FontSize(c, theme.H3Size)).FontFeatures("tnum").FontWeight(600).SingleLine() + }) + }) + } + name := msg(c, "pnl_display.hide") + icon := icons.Must("eye-off") + if *hidden { + name, icon = msg(c, "pnl_display.show"), icons.Must("eye") + } + b := iconAction(c, icon, name) + if b.Clicked() && !b.IsDisabled() { + *hidden = !*hidden + r.changed = true + slog.Debug("mujica: pnl_display mask", "hidden", *hidden) + } + }) + return r +} + +func pnlDisplaySign(sign int) string { + switch { + case sign > 0: + return "+" + case sign < 0: + return "−" + } + return "" +} diff --git a/pnl_display_test.go b/pnl_display_test.go new file mode 100644 index 0000000..b258102 --- /dev/null +++ b/pnl_display_test.go @@ -0,0 +1,43 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestPnLDisplaySignsAndMask(t *testing.T) { + hidden, n := false, 0 + tt := tradingTester(GreenUp, 520, 160, func(c *ui.Context) { + if PnLDisplay(c, PnL{Realized: 1200.5, Unrealized: -340, Today: 0}, &hidden, PnLDisplayOptions{Currency: "$"}).Changed() { + n++ + } + }) + for _, want := range []string{"+$1,200.50", "−$340.00", "$0.00"} { + if !tt.HasText(want) { + t.Fatalf("missing %q in %q", want, tt.Texts()) + } + } + if err := tt.Click("Hide amounts"); err != nil { + t.Fatal(err) + } + if !hidden || n != 1 || tt.HasText("+$1,200.50") { + t.Fatalf("mask: hidden %v changes %d texts %q", hidden, n, tt.Texts()) + } + if _, ok := tt.Find("Realized, hidden"); !ok { + t.Fatal("masked amount still spoken") + } + tt.Key(0, ui.KeySpace) + if hidden || n != 2 { + t.Fatalf("keyboard toggle: hidden %v", hidden) + } +} + +func TestPnLDisplaySnapshot(t *testing.T) { + hidden := false + snapshot(t, "pnl_display", 460, 100, func(c *ui.Context) { + ui.Column(c).Padding(20).Children(func() { + PnLDisplay(c, PnL{Realized: 1200.5, Unrealized: -340.2, Today: 88}, &hidden, PnLDisplayOptions{Currency: "$"}) + }) + }) +} diff --git a/position_table.go b/position_table.go new file mode 100644 index 0000000..d868894 --- /dev/null +++ b/position_table.go @@ -0,0 +1,107 @@ +package mujica + +import ( + "cmp" + "log/slog" + + "github.com/egoist/mygo/ui" +) + +// PositionRow is one holding; ID must be stable across updates. +type PositionRow struct { + ID, Symbol string + Quantity float64 + AvgCost float64 + Price float64 +} + +// MarketValue is the quantity at the last price. +func (p PositionRow) MarketValue() float64 { return p.Quantity * p.Price } + +// Unrealized is the floating profit against the average cost. +func (p PositionRow) Unrealized() float64 { return (p.Price - p.AvgCost) * p.Quantity } + +// UnrealizedPercent is the floating profit as a percent of cost. +func (p PositionRow) UnrealizedPercent() float64 { + if p.AvgCost == 0 { + return 0 + } + return (p.Price - p.AvgCost) / p.AvgCost * 100 +} + +// PositionTableOptions configure a PositionTable. +type PositionTableOptions struct { + // Decimals of prices and amounts; zero means 2. + Decimals int + // Version must change when rows change in place. + Version int + Status DataStatus + Error string + Disabled bool +} + +// TableActionResult is a trading table and its acted row. +type TableActionResult struct { + Element *ui.Element + Table DataTableView + id string +} + +// Acted returns the ID of the row acted on. +func (r TableActionResult) Acted() (string, bool) { return r.id, r.id != "" } + +// PositionTable lists holdings with floating profit and Close. +func PositionTable(c *ui.Context, s *DataTableState[string], rows []PositionRow, opts PositionTableOptions) TableActionResult { + dec := priceDecimals("PositionTable", opts.Decimals) + var r TableActionResult + num := func(id, key string, v func(PositionRow) float64) DataColumn[PositionRow] { + return DataColumn[PositionRow]{ID: id, Title: msg(c, key), Align: ui.End, Sortable: true, + Compare: func(a, b PositionRow) int { return cmp.Compare(v(a), v(b)) }, + Text: func(p PositionRow) string { return statisticFormat(v(p), dec) }} + } + pnl := num("pnl", "position_table.pnl", PositionRow.Unrealized) + pnl.Cell = func(p PositionRow) { tradingSigned(c, p.Unrealized(), statisticSigned(p.Unrealized(), dec)) } + pct := num("pct", "position_table.pnl_percent", PositionRow.UnrealizedPercent) + pct.Cell = func(p PositionRow) { + tradingSigned(c, p.UnrealizedPercent(), statisticSigned(p.UnrealizedPercent(), 2)+"%") + } + cols := []DataColumn[PositionRow]{ + {ID: "symbol", Title: msg(c, "position_table.symbol"), Sortable: true, + Compare: func(a, b PositionRow) int { return cmp.Compare(a.Symbol, b.Symbol) }, + Text: func(p PositionRow) string { return p.Symbol }}, + num("qty", "position_table.quantity", func(p PositionRow) float64 { return p.Quantity }), + num("cost", "position_table.cost", func(p PositionRow) float64 { return p.AvgCost }), + num("price", "position_table.price", func(p PositionRow) float64 { return p.Price }), + num("value", "position_table.value", PositionRow.MarketValue), + pnl, pct, + {ID: "action", Title: msg(c, "position_table.action"), Width: 96, + Text: func(PositionRow) string { return "" }, + Cell: func(p PositionRow) { + if Button(c, msg(c, "position_table.close"), ButtonOptions{Variant: Secondary}).Label(msg(c, "position_table.close") + " " + p.Symbol).Clicked() { + r.id = p.ID + } + }}, + } + r.Element = ui.Column(c).Disabled(opts.Disabled).Grow(1).FillWidth().MinHeight(0) + r.Element.Children(func() { + r.Table = DataTable(c, s, rows, DataTableOptions[PositionRow, string]{Columns: cols, Key: func(p PositionRow) string { return p.ID }, + Version: opts.Version, Status: opts.Status, Error: opts.Error, Empty: msg(c, "position_table.empty")}) + r.Table.Element.Grow(1) + }) + if r.Element.IsDisabled() { + r.id = "" + } + if r.id != "" { + slog.Debug("mujica: position_table close") + } + return r +} + +// tradingSigned shows a signed amount with its arrow. +func tradingSigned(c *ui.Context, v float64, text string) { + sign := trendSign(v) + ui.Row(c).Gap(2).AlignItems(ui.Center).Justify(ui.End).FillWidth().TextColor(trendColor(c, sign)).Children(func() { + ui.Icon(c, trendIcon(sign)).Size(12, 12) + ui.Text(c, text).FontFeatures("tnum").FontWeight(600).SingleLine() + }) +} diff --git a/position_table_test.go b/position_table_test.go new file mode 100644 index 0000000..95e4a40 --- /dev/null +++ b/position_table_test.go @@ -0,0 +1,84 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +func positionSample() []PositionRow { + return []PositionRow{ + {ID: "p1", Symbol: "MUJC", Quantity: 100, AvgCost: 400, Price: 412.08}, + {ID: "p2", Symbol: "AVE", Quantity: 50, AvgCost: 90, Price: 88.2}, + } +} + +func TestPositionTableValuesAndClose(t *testing.T) { + var s DataTableState[string] + rows := positionSample() + closed := "" + tt := tradingTester(GreenUp, 1000, 400, func(c *ui.Context) { + ui.Box(c).Width(960).Height(300).Children(func() { + if id, ok := PositionTable(c, &s, rows, PositionTableOptions{}).Acted(); ok { + closed = id + } + }) + }) + for _, want := range []string{"41,208.00", "+1,208.00", "+3.02%", "−90.00", "−2.00%"} { + if !tt.HasText(want) { + t.Fatalf("missing %q in %q", want, tt.Texts()) + } + } + if err := tt.Click("Close AVE"); err != nil { + t.Fatal(err) + } + if closed != "p2" { + t.Fatalf("closed %q", closed) + } +} + +// Selection follows the row ID when rows reorder and change. +func TestPositionTableStableIDs(t *testing.T) { + var s DataTableState[string] + rows := positionSample() + tt := tradingTester(GreenUp, 1000, 400, func(c *ui.Context) { + ui.Box(c).Width(960).Height(300).Children(func() { PositionTable(c, &s, rows, PositionTableOptions{}) }) + }) + _ = tt.Click("AVE") + rows = []PositionRow{{ID: "p3", Symbol: "OBLV", Quantity: 1, AvgCost: 1, Price: 1}, {ID: "p2", Symbol: "AVE", Quantity: 60, AvgCost: 90, Price: 91}, rows[0]} + tt.Frame() + if id, ok := s.Selected(); !ok || id != "p2" { + t.Fatalf("selection %q %v", id, ok) + } +} + +func TestPositionTableDisabledAndLargeData(t *testing.T) { + var s DataTableState[string] + rows := make([]PositionRow, 100_000) + for i := range rows { + rows[i] = PositionRow{ID: fmt.Sprint(i), Symbol: fmt.Sprintf("S%05d", i), Quantity: 1, AvgCost: 1, Price: 2} + } + closed := false + tt := tradingTester(GreenUp, 1000, 400, func(c *ui.Context) { + ui.Box(c).Width(960).Height(300).Children(func() { + _, closed = PositionTable(c, &s, rows, PositionTableOptions{Disabled: true}).Acted() + }) + }) + _ = tt.Click("Close S00000") + if closed { + t.Fatal("disabled table closed a position") + } + if n := len(tt.Texts()); n > 400 { + t.Fatalf("built %d texts for 100,000 rows", n) + } +} + +func TestPositionTableSnapshot(t *testing.T) { + snapshot(t, "position_table", 1040, 220, func(c *ui.Context) { + ui.Column(c).Padding(16).Height(200).Width(1010).Children(func() { + s := ui.Local(c.Root(), "snap", func() DataTableState[string] { return DataTableState[string]{} }) + PositionTable(c, s, positionSample(), PositionTableOptions{}) + }) + }) +} diff --git a/price_change_badge.go b/price_change_badge.go new file mode 100644 index 0000000..5989747 --- /dev/null +++ b/price_change_badge.go @@ -0,0 +1,37 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// PriceChangeBadgeOptions configure a PriceChangeBadge. +type PriceChangeBadgeOptions struct { + // Decimals of the amount, zero meaning 2; percents show 2. + Decimals int + // HidePercent shows only the amount. + HidePercent bool +} + +// PriceChangeBadge shows a signed change and percent with an arrow. +func PriceChangeBadge(c *ui.Context, change, percent float64, opts PriceChangeBadgeOptions) *ui.Element { + dec := priceDecimals("PriceChangeBadge", opts.Decimals) + sign := trendSign(change) + if sign == 0 { + sign = trendSign(percent) + } + col := trendColor(c, sign) + u := Density(c).Unit() + text := statisticSigned(change, dec) + if !opts.HidePercent { + text += " (" + statisticSigned(percent, 2) + "%)" + } + e := ui.Row(c).Gap(u).AlignItems(ui.Center).Padding(u/2, u*2).Radius(theme.ControlRadius).Shrink(0). + Background(col.Alpha(0.12)).TextColor(col).Role(ui.RoleText).Label(trendWord(c, sign) + " " + text) + e.Children(func() { + ui.Icon(c, trendIcon(sign)).Size(14, 14) + ui.Text(c, text).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").FontWeight(600).SingleLine() + }) + return e +} diff --git a/price_change_badge_test.go b/price_change_badge_test.go new file mode 100644 index 0000000..42a6a1d --- /dev/null +++ b/price_change_badge_test.go @@ -0,0 +1,35 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestPriceChangeBadgeSignsAndWords(t *testing.T) { + tt := tradingTester(GreenUp, 400, 160, func(c *ui.Context) { + PriceChangeBadge(c, 1.25, 0.85, PriceChangeBadgeOptions{}) + PriceChangeBadge(c, -3, -1.5, PriceChangeBadgeOptions{Decimals: 1}) + PriceChangeBadge(c, 0, 0, PriceChangeBadgeOptions{HidePercent: true}) + }) + for _, want := range []string{"+1.25 (+0.85%)", "−3.0 (−1.50%)", "0.00"} { + if !tt.HasText(want) { + t.Fatalf("missing %q in %q", want, tt.Texts()) + } + } + for _, name := range []string{"up +1.25 (+0.85%)", "down −3.0 (−1.50%)", "unchanged 0.00"} { + if _, ok := tt.Find(name); !ok { + t.Fatalf("not spoken: %q", name) + } + } +} + +func TestPriceChangeBadgeSnapshot(t *testing.T) { + snapshot(t, "price_change_badge", 360, 80, func(c *ui.Context) { + ui.Row(c).Padding(20).Gap(10).Children(func() { + PriceChangeBadge(c, 1.25, 0.85, PriceChangeBadgeOptions{}) + PriceChangeBadge(c, -3.4, -1.52, PriceChangeBadgeOptions{}) + PriceChangeBadge(c, 0, 0, PriceChangeBadgeOptions{}) + }) + }) +} diff --git a/price_text.go b/price_text.go new file mode 100644 index 0000000..f415a16 --- /dev/null +++ b/price_text.go @@ -0,0 +1,171 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// Trend picks the rise color: green, or red (China). +type Trend int + +const ( + GreenUp Trend = iota + RedUp +) + +type trendKey struct{} + +// UseTrend sets the window's rise and fall colors. +func UseTrend(c *ui.Context, t Trend) { + if t != GreenUp && t != RedUp { + panic(fmt.Sprintf("mujica: unknown Trend %d", t)) + } + *ui.Local(c.Root(), trendKey{}, func() Trend { return GreenUp }) = t +} + +// trendColor colors a rise (sign > 0), fall or flat. +func trendColor(c *ui.Context, sign int) ui.Color { + k := Tokens(c) + up, down := k.Success, k.Danger + if *ui.Local(c.Root(), trendKey{}, func() Trend { return GreenUp }) == RedUp { + up, down = k.Danger, k.Success + } + switch { + case sign > 0: + return up + case sign < 0: + return down + } + return k.TextMuted +} + +// trendIcon is the arrow that says the direction without color. +func trendIcon(sign int) *ui.SVG { + switch { + case sign > 0: + return icons.Must("arrow-up") + case sign < 0: + return icons.Must("arrow-down") + } + return icons.Must("minus") +} + +// trendWord names the direction for assistive technology. +func trendWord(c *ui.Context, sign int) string { + switch { + case sign > 0: + return msg(c, "price_text.up") + case sign < 0: + return msg(c, "price_text.down") + } + return msg(c, "price_text.flat") +} + +func trendSign(v float64) int { + switch { + case v > 0: + return 1 + case v < 0: + return -1 + } + return 0 +} + +// priceDecimals resolves a Decimals option: zero means 2, negative panics. +func priceDecimals(name string, d int) int { + if d < 0 { + panic("mujica: " + name + " with negative Decimals") + } + if d == 0 { + return 2 + } + return d +} + +// priceTextFlash is the tint's fade, three state steps long. +const priceTextFlash = 3 * theme.StateDuration + +// PriceTextOptions configure a PriceText. +type PriceTextOptions struct { + // Decimals shown; zero means 2. + Decimals int + // NoGrouping drops the thousands separators. + NoGrouping bool + // Compare marks the price's move from Reference. + Compare bool + Reference float64 + // NoFlash keeps the price still when it changes. + NoFlash bool + // Size is the font size; zero is the body size. + Size float32 +} + +type priceTextState struct { + last float64 + seen bool + at time.Time + dir int +} + +// PriceText shows a price; a change tints it once. +func PriceText(c *ui.Context, value float64, opts PriceTextOptions) *ui.Element { + dec := priceDecimals("PriceText", opts.Decimals) + if opts.Size < 0 { + panic("mujica: PriceText with negative Size") + } + size := opts.Size + if size == 0 { + size = theme.BodySize + } + text := statisticFormat(value, dec) + if opts.NoGrouping { + text = priceUngrouped(value, dec) + } + e := ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).Padding(0, 2).Radius(theme.ControlRadius).Shrink(0) + st := ui.Local(e, "price", func() priceTextState { return priceTextState{} }) + if st.seen && value != st.last { + st.at, st.dir = c.Now(), trendSign(value-st.last) + slog.Debug("mujica: price_text changed", "dir", st.dir) + } + st.last, st.seen = value, true + sign := 0 + if opts.Compare { + sign = trendSign(value - opts.Reference) + } + col := Tokens(c).Text + spoken := text + if opts.Compare { + col = trendColor(c, sign) + spoken += ", " + trendWord(c, sign) + } + if d := Motion(c, priceTextFlash); d > 0 && !opts.NoFlash && !st.at.IsZero() { + if t := c.Now().Sub(st.at); t < d { + e.Background(trendColor(c, st.dir).Alpha(0.2 * float32(1-float64(t)/float64(d)))) + c.AnimationFrame() + } + } + e.Role(ui.RoleText).Label(spoken).TextColor(col) + e.Children(func() { + if opts.Compare { + ui.Icon(c, trendIcon(sign)).Size(size*0.85, size*0.85) + } + ui.Text(c, text).FontSize(FontSize(c, size)).FontFeatures("tnum").FontWeight(500).SingleLine() + }) + return e +} + +// priceUngrouped writes v with dec decimals and no separators. +func priceUngrouped(v float64, dec int) string { + s := fmt.Sprintf("%.*f", dec, math.Abs(v)) + if v < 0 && s != fmt.Sprintf("%.*f", dec, 0.0) { + return "−" + s + } + return s +} diff --git a/price_text_test.go b/price_text_test.go new file mode 100644 index 0000000..bb21a2c --- /dev/null +++ b/price_text_test.go @@ -0,0 +1,106 @@ +package mujica + +import ( + "bytes" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// tradingTester runs view in the default theme with trend t. +func tradingTester(t Trend, w, h int, view func(c *ui.Context)) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + UseTrend(c, t) + ui.Column(c).Padding(10).AlignItems(ui.Start).Children(func() { view(c) }) + }, w, h) +} + +func TestPriceTextFormatAndTrend(t *testing.T) { + price := 12345.678 + tt := tradingTester(GreenUp, 300, 100, func(c *ui.Context) { + PriceText(c, price, PriceTextOptions{Decimals: 2, Compare: true, Reference: 12000}) + }) + if !tt.HasText("12,345.68") { + t.Fatalf("texts %q", tt.Texts()) + } + if _, ok := tt.Find("12,345.68, up"); !ok { + t.Fatal("trend not spoken") + } + price = 11000 + tt.Frame() + if _, ok := tt.Find("11,000.00, down"); !ok { + t.Fatalf("down not spoken: %q", tt.Texts()) + } + tt = tradingTester(GreenUp, 300, 100, func(c *ui.Context) { + PriceText(c, 1234.5, PriceTextOptions{Decimals: 3, NoGrouping: true}) + }) + if !tt.HasText("1234.500") { + t.Fatalf("ungrouped %q", tt.Texts()) + } +} + +func TestTrendColorsFollowUseTrend(t *testing.T) { + var up, down [2]ui.Color + for i, tr := range []Trend{GreenUp, RedUp} { + tradingTester(tr, 100, 100, func(c *ui.Context) { + up[i], down[i] = trendColor(c, 1), trendColor(c, -1) + }) + } + k := theme.Light() + if up[0] != k.Success || down[0] != k.Danger || up[1] != k.Danger || down[1] != k.Success { + t.Fatalf("up %v down %v", up, down) + } + defer func() { + if recover() == nil { + t.Fatal("unknown trend accepted") + } + }() + tradingTester(Trend(7), 100, 100, func(*ui.Context) {}) +} + +// A change tints once; reduced motion never tints. +func TestPriceTextFlashesOnceAndNotUnderReducedMotion(t *testing.T) { + for _, still := range []bool{false, true} { + prefs := ui.Preferences{ReduceMotion: still, TextScale: 1} + price := 10.0 + tt := tradingTester(GreenUp, 200, 60, func(c *ui.Context) { PriceText(c, price, PriceTextOptions{}) }) + tt.SetPreferences(prefs) + price = 10.5 + tt.Frame() + fresh := tradingTester(GreenUp, 200, 60, func(c *ui.Context) { PriceText(c, price, PriceTextOptions{}) }) + fresh.SetPreferences(prefs) + fresh.Frame() + same := bytes.Equal(tt.Image().Pix, fresh.Image().Pix) + if same == !still { + t.Fatalf("reduced motion %v: flashed %v", still, !same) + } + time.Sleep(priceTextFlash + 20*time.Millisecond) + tt.Frame() + if !bytes.Equal(tt.Image().Pix, fresh.Image().Pix) { + t.Fatalf("reduced motion %v: flash did not end", still) + } + } +} + +func TestPriceTextRejectsNegativeDecimals(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("negative decimals accepted") + } + }() + tradingTester(GreenUp, 100, 100, func(c *ui.Context) { PriceText(c, 1, PriceTextOptions{Decimals: -1}) }) +} + +func TestPriceTextSnapshot(t *testing.T) { + snapshot(t, "price_text", 320, 150, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() { + PriceText(c, 18234.5, PriceTextOptions{Size: theme.H2Size, Compare: true, Reference: 18000}) + PriceText(c, 0.004521, PriceTextOptions{Decimals: 6, Compare: true, Reference: 0.0046}) + PriceText(c, 100, PriceTextOptions{Compare: true, Reference: 100}) + }) + }) +} diff --git a/quick_trade_buttons.go b/quick_trade_buttons.go new file mode 100644 index 0000000..d0926c1 --- /dev/null +++ b/quick_trade_buttons.go @@ -0,0 +1,127 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// QuickTradeButtonsOptions configure QuickTradeButtons. Symbol is required. +type QuickTradeButtonsOptions struct { + Symbol string + // Presets are the quantities offered; nil means 1, 10, 100. + Presets []float64 + // Decimals of the quantities shown. + Decimals int + // Confirm asks before sending, by the button pressed. + Confirm bool + Disabled bool +} + +// QuickTradeResult is built QuickTradeButtons and their trade. +type QuickTradeResult struct { + Element *ui.Element + side OrderSide + quantity float64 +} + +// Traded returns the side and quantity the user traded. +func (r QuickTradeResult) Traded() (OrderSide, float64, bool) { return r.side, r.quantity, r.side != 0 } + +type quickTradeState struct{ asking OrderSide } + +// QuickTradeButtons trade *quantity at market with one press. +func QuickTradeButtons(c *ui.Context, quantity *float64, opts QuickTradeButtonsOptions) QuickTradeResult { + if opts.Symbol == "" { + panic("mujica: QuickTradeButtons needs a Symbol") + } + if opts.Decimals < 0 { + panic("mujica: QuickTradeButtons with negative Decimals") + } + presets := opts.Presets + if presets == nil { + presets = []float64{1, 10, 100} + } + if *quantity == 0 { + *quantity = presets[0] + } + at := slices.Index(presets, *quantity) + if at < 0 { + panic(fmt.Sprintf("mujica: QuickTradeButtons quantity %v is not one of Presets", *quantity)) + } + u := Density(c).Unit() + var r QuickTradeResult + r.Element = ui.Column(c).Gap(u * 2).Disabled(opts.Disabled).Role(ui.RoleGroup).Label(msg(c, "quick_trade_buttons.label")) + st := ui.Local(r.Element, "quick", func() quickTradeState { return quickTradeState{} }) + disabled := r.Element.IsDisabled() + if disabled { + st.asking = 0 + } + qty := statisticFormat(*quantity, opts.Decimals) + r.Element.Children(func() { + var segs []Segment + for _, p := range presets { + segs = append(segs, Segment{Label: statisticFormat(p, opts.Decimals)}) + } + if SegmentedControl(c, &at, segs, SegmentedControlOptions{Equal: true, Label: msg(c, "quick_trade_buttons.quantity"), Disabled: disabled}).Changed() { + *quantity = presets[at] + qty = statisticFormat(*quantity, opts.Decimals) + } + ui.Row(c).Gap(u * 2).Children(func() { + for _, side := range []OrderSide{SideBuy, SideSell} { + label := fmt.Sprintf(msg(c, fmt.Sprintf("quick_trade_buttons.side%d", side)), qty) + b := quickTradeButton(c, label, side) + if b.Clicked() && !disabled { + if opts.Confirm { + st.asking = side + } else { + r.side, r.quantity = side, *quantity + } + } + open := st.asking == side + res := Popconfirm(c, b, &open, PopconfirmOptions{ + Title: fmt.Sprintf(msg(c, fmt.Sprintf("quick_trade_buttons.confirm%d", side)), qty, opts.Symbol), + Description: msg(c, "quick_trade_buttons.market"), + ConfirmLabel: msg(c, fmt.Sprintf("order_entry.side%d", side)), + Danger: true, + }) + if res.Confirmed && !disabled { + r.side, r.quantity = side, *quantity + } + if !open && st.asking == side { + st.asking = 0 + } + } + }) + }) + if r.side != 0 { + slog.Debug("mujica: quick_trade_buttons traded", "side", r.side) + } + return r +} + +// quickTradeButton is a large button tinted by its side. +func quickTradeButton(c *ui.Context, label string, side OrderSide) *ui.Element { + k := Tokens(c) + d := Density(c) + col := trendColor(c, side.sign()) + b := ui.ButtonBase(c).Grow(1).Basis(0).MinHeight(d.ControlHeight()+d.Unit()*2).Radius(theme.ControlRadius). + Border(theme.BorderWidth*1.5, col).TextColor(col).Background(col.Alpha(0.1)).Label(label) + internal.Ring(b, k.Focus, theme.ControlRadius) + switch { + case b.Pressed(): + b.Background(col.Alpha(0.28)) + case b.Hovered(): + b.Background(col.Alpha(0.18)) + } + b.Children(func() { + ui.Icon(c, trendIcon(side.sign())).Size(16, 16) + ui.Text(c, label).FontWeight(700).SingleLine() + }) + return b +} diff --git a/quick_trade_buttons_test.go b/quick_trade_buttons_test.go new file mode 100644 index 0000000..c764999 --- /dev/null +++ b/quick_trade_buttons_test.go @@ -0,0 +1,86 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type quickTradeProbe struct { + qty float64 + opts QuickTradeButtonsOptions + trades []OrderSide + sizes []float64 +} + +func (p *quickTradeProbe) tester() *ui.Tester { + return tradingTester(GreenUp, 520, 400, func(c *ui.Context) { + ui.Box(c).Width(400).Children(func() { + if side, q, ok := QuickTradeButtons(c, &p.qty, p.opts).Traded(); ok { + p.trades, p.sizes = append(p.trades, side), append(p.sizes, q) + } + }) + }) +} + +func TestQuickTradeButtonsPresetsAndTrade(t *testing.T) { + p := &quickTradeProbe{opts: QuickTradeButtonsOptions{Symbol: "MUJC"}} + tt := p.tester() + if p.qty != 1 { + t.Fatalf("default preset %v", p.qty) + } + if err := tt.Click("10"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Sell 10"); err != nil { + t.Fatal(err) + } + if len(p.trades) != 1 || p.trades[0] != SideSell || p.sizes[0] != 10 { + t.Fatalf("trades %v %v", p.trades, p.sizes) + } +} + +func TestQuickTradeButtonsConfirmFocusesCancel(t *testing.T) { + p := &quickTradeProbe{qty: 100, opts: QuickTradeButtonsOptions{Symbol: "MUJC", Confirm: true}} + tt := p.tester() + _ = tt.Click("Buy 100") + if len(p.trades) != 0 || !tt.HasText("Buy 100 MUJC?") || !tt.Focused("Cancel") { + t.Fatalf("confirm: %q", tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + if len(p.trades) != 0 || tt.HasText("Buy 100 MUJC?") { + t.Fatal("Enter traded instead of canceling") + } + _ = tt.Click("Buy 100") + if err := tt.Click("Buy"); err != nil { + t.Fatal(err) + } + if len(p.trades) != 1 || p.trades[0] != SideBuy || p.sizes[0] != 100 { + t.Fatalf("trades %v", p.trades) + } +} + +func TestQuickTradeButtonsDisabledAndBadQuantity(t *testing.T) { + p := &quickTradeProbe{opts: QuickTradeButtonsOptions{Symbol: "MUJC", Disabled: true}} + tt := p.tester() + _ = tt.Click("Buy 1") + _ = tt.Click("10") + if len(p.trades) != 0 || p.qty != 1 { + t.Fatal("disabled buttons acted") + } + defer func() { + if recover() == nil { + t.Fatal("quantity outside presets accepted") + } + }() + (&quickTradeProbe{qty: 7, opts: QuickTradeButtonsOptions{Symbol: "MUJC"}}).tester() +} + +func TestQuickTradeButtonsSnapshot(t *testing.T) { + qty := 10.0 + snapshot(t, "quick_trade_buttons", 380, 130, func(c *ui.Context) { + ui.Column(c).Padding(20).Children(func() { + QuickTradeButtons(c, &qty, QuickTradeButtonsOptions{Symbol: "MUJC"}) + }) + }) +} diff --git a/quote_card.go b/quote_card.go new file mode 100644 index 0000000..a35e886 --- /dev/null +++ b/quote_card.go @@ -0,0 +1,52 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// Quote is an instrument's price and change since the close. +type Quote struct { + Symbol, Name string + Price float64 + Change float64 + ChangePercent float64 +} + +// QuoteCardOptions configure a QuoteCard. +type QuoteCardOptions struct { + // Decimals of the price and change; zero means 2. + Decimals int + // Chart builds the mini chart slot; nil leaves it out. + Chart func() +} + +// QuoteCard shows a symbol, name, price, change and mini chart. +func QuoteCard(c *ui.Context, q Quote, opts QuoteCardOptions) *ui.Element { + if q.Symbol == "" { + panic("mujica: QuoteCard needs a Symbol") + } + dec := priceDecimals("QuoteCard", opts.Decimals) + k := Tokens(c) + u := Density(c).Unit() + e := ui.Column(c).Gap(u*2).Padding(u*4).MinWidth(200).Radius(theme.CardRadius). + Background(k.Surface).Border(theme.BorderWidth, k.Border).Role(ui.RoleGroup).Label(q.Symbol) + e.Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.End).Children(func() { + ui.Text(c, q.Symbol).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).SingleLine() + if q.Name != "" { + ui.Text(c, q.Name).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).SingleLine(). + Ellipsis("…").MinWidth(0).Shrink(1) + } + }) + ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Wrap().Children(func() { + PriceText(c, q.Price, PriceTextOptions{Decimals: dec, Size: theme.H2Size}) + PriceChangeBadge(c, q.Change, q.ChangePercent, PriceChangeBadgeOptions{Decimals: dec}) + }) + if opts.Chart != nil { + ui.Box(c).Height(40).FillWidth().Children(opts.Chart) + } + }) + return e +} diff --git a/quote_card_test.go b/quote_card_test.go new file mode 100644 index 0000000..6d53d20 --- /dev/null +++ b/quote_card_test.go @@ -0,0 +1,43 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestQuoteCardShowsQuoteAndChartSlot(t *testing.T) { + charted := false + tt := tradingTester(RedUp, 400, 240, func(c *ui.Context) { + QuoteCard(c, Quote{Symbol: "600519", Name: "Kweichow Moutai", Price: 1688.5, Change: 12.3, ChangePercent: 0.73}, + QuoteCardOptions{Chart: func() { charted = true; ui.Text(c, "chart") }}) + }) + for _, want := range []string{"600519", "Kweichow Moutai", "1,688.50", "+12.30 (+0.73%)", "chart"} { + if !tt.HasText(want) { + t.Fatalf("missing %q in %q", want, tt.Texts()) + } + } + if !charted { + t.Fatal("chart slot not built") + } + defer func() { + if recover() == nil { + t.Fatal("empty symbol accepted") + } + }() + tradingTester(GreenUp, 100, 100, func(c *ui.Context) { QuoteCard(c, Quote{}, QuoteCardOptions{}) }) +} + +func TestQuoteCardSnapshot(t *testing.T) { + snapshot(t, "quote_card", 300, 190, func(c *ui.Context) { + ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { + QuoteCard(c, Quote{Symbol: "MUJC", Name: "Ave Mujica Holdings", Price: 412.08, Change: -5.62, ChangePercent: -1.35}, + QuoteCardOptions{Chart: func() { + k := Tokens(c) + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + p.Line(r.X, r.Y+r.H*0.3, r.X+r.W, r.Y+r.H*0.7, 1.5, k.Info) + }) + }}) + }) + }) +} diff --git a/risk_meter.go b/risk_meter.go new file mode 100644 index 0000000..d0a5685 --- /dev/null +++ b/risk_meter.go @@ -0,0 +1,84 @@ +package mujica + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// RiskLevel is one step of a RiskMeter's scale. +type RiskLevel struct { + Label string + Description string +} + +// RiskMeterOptions configure a RiskMeter. +type RiskMeterOptions struct { + // Levels rise in risk; nil uses five built-in levels. + Levels []RiskLevel + // Label names the meter; empty uses the built-in "Risk level". + Label string +} + +// RiskMeter shows level, from 1, on a named graded scale. +func RiskMeter(c *ui.Context, level int, opts RiskMeterOptions) *ui.Element { + levels := opts.Levels + if levels == nil { + for i := range 5 { + levels = append(levels, RiskLevel{Label: msg(c, fmt.Sprintf("risk_meter.level%d", i+1))}) + } + } + if len(levels) < 2 || level < 1 || level > len(levels) { + panic(fmt.Sprintf("mujica: RiskMeter level %d outside 1..%d", level, len(levels))) + } + k := Tokens(c) + u := Density(c).Unit() + label := opts.Label + if label == "" { + label = msg(c, "risk_meter.label") + } + cur := levels[level-1] + spoken := fmt.Sprintf(msg(c, "risk_meter.value"), level, len(levels), cur.Label) + col := riskMeterColor(k, level, len(levels)) + e := ui.Column(c).Gap(u * 2).MinWidth(200).Role(ui.RoleMeter).Label(label).Description(spoken + " " + cur.Description) + e.Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, cur.Label).TextColor(col).FontWeight(600).SingleLine() + ui.Text(c, fmt.Sprintf("%d / %d", level, len(levels))).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum") + }) + ui.Box(c).Height(18).FillWidth().Draw(func(p *ui.Painter, r ui.Rect) { + n := len(levels) + const gap = 3 + w := (r.W - gap*float32(n-1)) / float32(n) + for i := range n { + seg := ui.Rect{X: r.X + float32(i)*(w+gap), Y: r.Y + 8, W: w, H: 8} + if i < level { + p.Fill(seg, riskMeterColor(k, i+1, n), theme.ControlRadius) + } else { + p.Stroke(seg, k.ControlBorder, theme.ControlRadius, theme.BorderWidth) + } + if i == level-1 { + p.FillPath(internal.Diamond(seg.X+seg.W/2, r.Y+3, 7), k.Text) + } + } + }) + if cur.Description != "" { + ui.Text(c, cur.Description).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)) + } + }) + return e +} + +// riskMeterColor grades from success through warning to danger. +func riskMeterColor(k theme.Tokens, level, n int) ui.Color { + switch f := float64(level-1) / float64(n-1); { + case f < 0.34: + return k.Success + case f < 0.67: + return k.Warning + } + return k.Danger +} diff --git a/risk_meter_test.go b/risk_meter_test.go new file mode 100644 index 0000000..83058ed --- /dev/null +++ b/risk_meter_test.go @@ -0,0 +1,42 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestRiskMeterLevelsAndText(t *testing.T) { + level := 4 + levels := []RiskLevel{{Label: "Conservative", Description: "Capital first."}, {Label: "Balanced"}, {Label: "Aggressive", Description: "Losses can exceed half."}} + custom := false + tt := tradingTester(GreenUp, 400, 160, func(c *ui.Context) { + if custom { + RiskMeter(c, level, RiskMeterOptions{Levels: levels}) + } else { + RiskMeter(c, level, RiskMeterOptions{}) + } + }) + if !tt.HasText("Moderately high") || !tt.HasText("4 / 5") { + t.Fatalf("default: %q", tt.Texts()) + } + custom, level = true, 3 + tt.Frame() + if !tt.HasText("Aggressive") || !tt.HasText("Losses can exceed half.") || !tt.HasText("3 / 3") { + t.Fatalf("custom: %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("level 0 accepted") + } + }() + tradingTester(GreenUp, 100, 100, func(c *ui.Context) { RiskMeter(c, 0, RiskMeterOptions{}) }) +} + +func TestRiskMeterSnapshot(t *testing.T) { + snapshot(t, "risk_meter", 320, 110, func(c *ui.Context) { + ui.Column(c).Padding(20).Children(func() { + RiskMeter(c, 4, RiskMeterOptions{Levels: []RiskLevel{{Label: "Low"}, {Label: "Medium"}, {Label: "Elevated"}, {Label: "High", Description: "Suited to experienced traders."}, {Label: "Extreme"}}}) + }) + }) +} diff --git a/spread_indicator.go b/spread_indicator.go new file mode 100644 index 0000000..be7c7fa --- /dev/null +++ b/spread_indicator.go @@ -0,0 +1,53 @@ +package mujica + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// SpreadIndicatorOptions configure a SpreadIndicator. +type SpreadIndicatorOptions struct { + // Decimals of the spread; zero means 2. + Decimals int + // WarnPercent flags spreads this wide, in percent; zero never. + WarnPercent float64 +} + +// SpreadIndicator shows ask minus bid and its percent. +func SpreadIndicator(c *ui.Context, bid, ask float64, opts SpreadIndicatorOptions) *ui.Element { + if opts.WarnPercent < 0 { + panic("mujica: SpreadIndicator with negative WarnPercent") + } + dec := priceDecimals("SpreadIndicator", opts.Decimals) + k := Tokens(c) + u := Density(c).Unit() + spread := ask - bid + pct := 0.0 + if mid := (ask + bid) / 2; mid > 0 { + pct = spread / mid * 100 + } + text := fmt.Sprintf(msg(c, "spread_indicator.value"), statisticFormat(spread, dec), statisticFormat(pct, 3)) + note, col, icon := "", k.TextMuted, "" + switch { + case spread < 0: + note, col, icon = msg(c, "spread_indicator.crossed"), k.Danger, "circle-alert" + case opts.WarnPercent > 0 && pct >= opts.WarnPercent: + note, col, icon = msg(c, "spread_indicator.wide"), k.Warning, "triangle-alert" + } + e := ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Shrink(0).Role(ui.RoleStatus).Label(text + " " + note) + e.Children(func() { + ui.Text(c, msg(c, "spread_indicator.title")).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)) + ui.Text(c, text).FontFeatures("tnum").FontWeight(500).SingleLine() + if note != "" { + ui.Row(c).Gap(u / 2).AlignItems(ui.Center).TextColor(col).Children(func() { + ui.Icon(c, icons.Must(icon)).Size(14, 14) + ui.Text(c, note).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600).SingleLine() + }) + } + }) + return e +} diff --git a/spread_indicator_test.go b/spread_indicator_test.go new file mode 100644 index 0000000..c9835e4 --- /dev/null +++ b/spread_indicator_test.go @@ -0,0 +1,42 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestSpreadIndicatorValueAndThreshold(t *testing.T) { + bid, ask := 99.95, 100.05 + tt := tradingTester(GreenUp, 400, 100, func(c *ui.Context) { + SpreadIndicator(c, bid, ask, SpreadIndicatorOptions{WarnPercent: 0.5}) + }) + if !tt.HasText("0.10 (0.100%)") || tt.HasText("Wide spread") { + t.Fatalf("narrow: %q", tt.Texts()) + } + ask = 101 + tt.Frame() + if !tt.HasText("Wide spread") { + t.Fatalf("wide: %q", tt.Texts()) + } + ask = 99 + tt.Frame() + if !tt.HasText("Crossed market") { + t.Fatalf("crossed: %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("negative threshold accepted") + } + }() + tradingTester(GreenUp, 100, 100, func(c *ui.Context) { SpreadIndicator(c, 1, 2, SpreadIndicatorOptions{WarnPercent: -1}) }) +} + +func TestSpreadIndicatorSnapshot(t *testing.T) { + snapshot(t, "spread_indicator", 380, 100, func(c *ui.Context) { + ui.Column(c).Padding(20).Gap(10).Children(func() { + SpreadIndicator(c, 99.95, 100.05, SpreadIndicatorOptions{WarnPercent: 0.5}) + SpreadIndicator(c, 99, 101, SpreadIndicatorOptions{WarnPercent: 0.5}) + }) + }) +} diff --git a/testdata/snapshots/darwin/bid_ask_bar-dark.png b/testdata/snapshots/darwin/bid_ask_bar-dark.png new file mode 100644 index 0000000..16712d6 Binary files /dev/null and b/testdata/snapshots/darwin/bid_ask_bar-dark.png differ diff --git a/testdata/snapshots/darwin/bid_ask_bar-light.png b/testdata/snapshots/darwin/bid_ask_bar-light.png new file mode 100644 index 0000000..a2c9c78 Binary files /dev/null and b/testdata/snapshots/darwin/bid_ask_bar-light.png differ diff --git a/testdata/snapshots/darwin/depth_ladder-dark.png b/testdata/snapshots/darwin/depth_ladder-dark.png new file mode 100644 index 0000000..a4c3a5b Binary files /dev/null and b/testdata/snapshots/darwin/depth_ladder-dark.png differ diff --git a/testdata/snapshots/darwin/depth_ladder-light.png b/testdata/snapshots/darwin/depth_ladder-light.png new file mode 100644 index 0000000..5055483 Binary files /dev/null and b/testdata/snapshots/darwin/depth_ladder-light.png differ diff --git a/testdata/snapshots/darwin/level2_quotes-dark.png b/testdata/snapshots/darwin/level2_quotes-dark.png new file mode 100644 index 0000000..021ce64 Binary files /dev/null and b/testdata/snapshots/darwin/level2_quotes-dark.png differ diff --git a/testdata/snapshots/darwin/level2_quotes-light.png b/testdata/snapshots/darwin/level2_quotes-light.png new file mode 100644 index 0000000..6a9a591 Binary files /dev/null and b/testdata/snapshots/darwin/level2_quotes-light.png differ diff --git a/testdata/snapshots/darwin/leverage_slider-dark.png b/testdata/snapshots/darwin/leverage_slider-dark.png new file mode 100644 index 0000000..4657808 Binary files /dev/null and b/testdata/snapshots/darwin/leverage_slider-dark.png differ diff --git a/testdata/snapshots/darwin/leverage_slider-light.png b/testdata/snapshots/darwin/leverage_slider-light.png new file mode 100644 index 0000000..155c312 Binary files /dev/null and b/testdata/snapshots/darwin/leverage_slider-light.png differ diff --git a/testdata/snapshots/darwin/margin_indicator-dark.png b/testdata/snapshots/darwin/margin_indicator-dark.png new file mode 100644 index 0000000..f77e26e Binary files /dev/null and b/testdata/snapshots/darwin/margin_indicator-dark.png differ diff --git a/testdata/snapshots/darwin/margin_indicator-light.png b/testdata/snapshots/darwin/margin_indicator-light.png new file mode 100644 index 0000000..d6f4140 Binary files /dev/null and b/testdata/snapshots/darwin/margin_indicator-light.png differ diff --git a/testdata/snapshots/darwin/order_book-dark.png b/testdata/snapshots/darwin/order_book-dark.png new file mode 100644 index 0000000..a3cbd72 Binary files /dev/null and b/testdata/snapshots/darwin/order_book-dark.png differ diff --git a/testdata/snapshots/darwin/order_book-light.png b/testdata/snapshots/darwin/order_book-light.png new file mode 100644 index 0000000..4c132d4 Binary files /dev/null and b/testdata/snapshots/darwin/order_book-light.png differ diff --git a/testdata/snapshots/darwin/order_confirm_dialog-dark.png b/testdata/snapshots/darwin/order_confirm_dialog-dark.png new file mode 100644 index 0000000..e36eea5 Binary files /dev/null and b/testdata/snapshots/darwin/order_confirm_dialog-dark.png differ diff --git a/testdata/snapshots/darwin/order_confirm_dialog-light.png b/testdata/snapshots/darwin/order_confirm_dialog-light.png new file mode 100644 index 0000000..4d91420 Binary files /dev/null and b/testdata/snapshots/darwin/order_confirm_dialog-light.png differ diff --git a/testdata/snapshots/darwin/order_entry-dark.png b/testdata/snapshots/darwin/order_entry-dark.png new file mode 100644 index 0000000..45148b8 Binary files /dev/null and b/testdata/snapshots/darwin/order_entry-dark.png differ diff --git a/testdata/snapshots/darwin/order_entry-light.png b/testdata/snapshots/darwin/order_entry-light.png new file mode 100644 index 0000000..c8505f3 Binary files /dev/null and b/testdata/snapshots/darwin/order_entry-light.png differ diff --git a/testdata/snapshots/darwin/order_table-dark.png b/testdata/snapshots/darwin/order_table-dark.png new file mode 100644 index 0000000..4279f9b Binary files /dev/null and b/testdata/snapshots/darwin/order_table-dark.png differ diff --git a/testdata/snapshots/darwin/order_table-light.png b/testdata/snapshots/darwin/order_table-light.png new file mode 100644 index 0000000..442feec Binary files /dev/null and b/testdata/snapshots/darwin/order_table-light.png differ diff --git a/testdata/snapshots/darwin/pnl_display-dark.png b/testdata/snapshots/darwin/pnl_display-dark.png new file mode 100644 index 0000000..e700740 Binary files /dev/null and b/testdata/snapshots/darwin/pnl_display-dark.png differ diff --git a/testdata/snapshots/darwin/pnl_display-light.png b/testdata/snapshots/darwin/pnl_display-light.png new file mode 100644 index 0000000..5652165 Binary files /dev/null and b/testdata/snapshots/darwin/pnl_display-light.png differ diff --git a/testdata/snapshots/darwin/position_table-dark.png b/testdata/snapshots/darwin/position_table-dark.png new file mode 100644 index 0000000..4c1ad60 Binary files /dev/null and b/testdata/snapshots/darwin/position_table-dark.png differ diff --git a/testdata/snapshots/darwin/position_table-light.png b/testdata/snapshots/darwin/position_table-light.png new file mode 100644 index 0000000..860ce9f Binary files /dev/null and b/testdata/snapshots/darwin/position_table-light.png differ diff --git a/testdata/snapshots/darwin/price_change_badge-dark.png b/testdata/snapshots/darwin/price_change_badge-dark.png new file mode 100644 index 0000000..735c548 Binary files /dev/null and b/testdata/snapshots/darwin/price_change_badge-dark.png differ diff --git a/testdata/snapshots/darwin/price_change_badge-light.png b/testdata/snapshots/darwin/price_change_badge-light.png new file mode 100644 index 0000000..2008a6c Binary files /dev/null and b/testdata/snapshots/darwin/price_change_badge-light.png differ diff --git a/testdata/snapshots/darwin/price_text-dark.png b/testdata/snapshots/darwin/price_text-dark.png new file mode 100644 index 0000000..9c9c101 Binary files /dev/null and b/testdata/snapshots/darwin/price_text-dark.png differ diff --git a/testdata/snapshots/darwin/price_text-light.png b/testdata/snapshots/darwin/price_text-light.png new file mode 100644 index 0000000..c891bbf Binary files /dev/null and b/testdata/snapshots/darwin/price_text-light.png differ diff --git a/testdata/snapshots/darwin/quick_trade_buttons-dark.png b/testdata/snapshots/darwin/quick_trade_buttons-dark.png new file mode 100644 index 0000000..b6a619e Binary files /dev/null and b/testdata/snapshots/darwin/quick_trade_buttons-dark.png differ diff --git a/testdata/snapshots/darwin/quick_trade_buttons-light.png b/testdata/snapshots/darwin/quick_trade_buttons-light.png new file mode 100644 index 0000000..34f5b42 Binary files /dev/null and b/testdata/snapshots/darwin/quick_trade_buttons-light.png differ diff --git a/testdata/snapshots/darwin/quote_card-dark.png b/testdata/snapshots/darwin/quote_card-dark.png new file mode 100644 index 0000000..4d553ac Binary files /dev/null and b/testdata/snapshots/darwin/quote_card-dark.png differ diff --git a/testdata/snapshots/darwin/quote_card-light.png b/testdata/snapshots/darwin/quote_card-light.png new file mode 100644 index 0000000..85c9d4a Binary files /dev/null and b/testdata/snapshots/darwin/quote_card-light.png differ diff --git a/testdata/snapshots/darwin/risk_meter-dark.png b/testdata/snapshots/darwin/risk_meter-dark.png new file mode 100644 index 0000000..805964c Binary files /dev/null and b/testdata/snapshots/darwin/risk_meter-dark.png differ diff --git a/testdata/snapshots/darwin/risk_meter-light.png b/testdata/snapshots/darwin/risk_meter-light.png new file mode 100644 index 0000000..2ac5c3f Binary files /dev/null and b/testdata/snapshots/darwin/risk_meter-light.png differ diff --git a/testdata/snapshots/darwin/spread_indicator-dark.png b/testdata/snapshots/darwin/spread_indicator-dark.png new file mode 100644 index 0000000..df230e2 Binary files /dev/null and b/testdata/snapshots/darwin/spread_indicator-dark.png differ diff --git a/testdata/snapshots/darwin/spread_indicator-light.png b/testdata/snapshots/darwin/spread_indicator-light.png new file mode 100644 index 0000000..d0a9759 Binary files /dev/null and b/testdata/snapshots/darwin/spread_indicator-light.png differ diff --git a/testdata/snapshots/darwin/ticker_tape-dark.png b/testdata/snapshots/darwin/ticker_tape-dark.png new file mode 100644 index 0000000..d749ec0 Binary files /dev/null and b/testdata/snapshots/darwin/ticker_tape-dark.png differ diff --git a/testdata/snapshots/darwin/ticker_tape-light.png b/testdata/snapshots/darwin/ticker_tape-light.png new file mode 100644 index 0000000..f3b79a8 Binary files /dev/null and b/testdata/snapshots/darwin/ticker_tape-light.png differ diff --git a/testdata/snapshots/darwin/time_and_sales-dark.png b/testdata/snapshots/darwin/time_and_sales-dark.png new file mode 100644 index 0000000..5b8a1e8 Binary files /dev/null and b/testdata/snapshots/darwin/time_and_sales-dark.png differ diff --git a/testdata/snapshots/darwin/time_and_sales-light.png b/testdata/snapshots/darwin/time_and_sales-light.png new file mode 100644 index 0000000..813e949 Binary files /dev/null and b/testdata/snapshots/darwin/time_and_sales-light.png differ diff --git a/testdata/snapshots/darwin/trade_history_table-dark.png b/testdata/snapshots/darwin/trade_history_table-dark.png new file mode 100644 index 0000000..900b54e Binary files /dev/null and b/testdata/snapshots/darwin/trade_history_table-dark.png differ diff --git a/testdata/snapshots/darwin/trade_history_table-light.png b/testdata/snapshots/darwin/trade_history_table-light.png new file mode 100644 index 0000000..1b2f118 Binary files /dev/null and b/testdata/snapshots/darwin/trade_history_table-light.png differ diff --git a/ticker_tape.go b/ticker_tape.go new file mode 100644 index 0000000..67cc954 --- /dev/null +++ b/ticker_tape.go @@ -0,0 +1,155 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// TickerItem is one symbol on a TickerTape. +type TickerItem struct { + Symbol string + Price float64 + ChangePercent float64 +} + +// TickerTapeOptions configure a TickerTape. +type TickerTapeOptions struct { + // Decimals of the prices; zero means 2. + Decimals int + // Speed is in DIPs a second; zero means 40. + Speed float32 + // PerPage is items per reduced-motion page; zero means 4. + PerPage int + // Label names the tape; empty uses the built-in "Market ticker". + Label string +} + +type tickerTapeState struct { + offset float32 + last time.Time + page int + paused bool +} + +// TickerTape loops quotes, pausing on hover or focus. +func TickerTape(c *ui.Context, items []TickerItem, opts TickerTapeOptions) *ui.Element { + if opts.Speed < 0 || opts.PerPage < 0 { + panic("mujica: TickerTape Speed and PerPage cannot be negative") + } + dec := priceDecimals("TickerTape", opts.Decimals) + speed, per := opts.Speed, opts.PerPage + if speed == 0 { + speed = 40 + } + if per == 0 { + per = 4 + } + label := opts.Label + if label == "" { + label = msg(c, "ticker_tape.label") + } + k := Tokens(c) + d := Density(c) + e := ui.Row(c).MinHeight(d.ControlHeight()).AlignItems(ui.Center).Background(k.Surface). + BorderWidth(theme.BorderWidth, 0).BorderColor(k.Border).Focusable().Role(ui.RoleGroup).Label(label) + e.FocusRing(false) + st := ui.Local(e, "tape", func() tickerTapeState { return tickerTapeState{} }) + if c.Preferences().ReduceMotion || len(items) == 0 { + tickerTapePages(c, e, st, items, per, dec) + return e + } + paused := e.Hovered() || e.FocusWithin() + if paused != st.paused { + st.paused = paused + slog.Debug("mujica: ticker_tape paused", "paused", paused) + } + now := c.Now() + if !paused && !st.last.IsZero() { + st.offset += float32(now.Sub(st.last).Seconds()) * speed + } + st.last = now + if !paused { + c.AnimationFrame() + } + e.Description(msg(c, "ticker_tape.pause_hint")) + e.ClipX().Children(func() { + strip := ui.Row(c).Absolute().Top(0).Bottom(0).AlignItems(ui.Center) + if w := strip.Bounds().W / 2; w > 0 { + st.offset = float32(math.Mod(float64(st.offset), float64(w))) + } + // Quarter steps keep the moving text on the renderer's grid. + strip.Left(-float32(math.Round(float64(st.offset)*4)) / 4) + strip.Children(func() { + // The second copy fills in as the first scrolls out. + for n := range 2 { + r := ui.Row(c).Key(n).AlignItems(ui.Center).Shrink(0) + if n == 1 { + r.Role(ui.RoleNone) + } + r.Children(func() { + for _, it := range items { + tickerTapeItem(c, it, dec) + } + }) + } + }) + }) + return e +} + +// tickerTapePages shows one page of items, for reduced motion. +func tickerTapePages(c *ui.Context, e *ui.Element, st *tickerTapeState, items []TickerItem, per, dec int) { + pages := max(1, (len(items)+per-1)/per) + st.page = min(st.page, pages-1) + turn := func(by int) { + if !e.IsDisabled() { + st.page = (st.page + by + pages) % pages + slog.Debug("mujica: ticker_tape page", "page", st.page) + } + } + if e.Shortcut(0, ui.KeyRight) { + turn(1) + } + if e.Shortcut(0, ui.KeyLeft) { + turn(-1) + } + k := Tokens(c) + e.Children(func() { + ui.Row(c).Grow(1).MinWidth(0).ClipX().AlignItems(ui.Center).Children(func() { + for _, it := range items[st.page*per : min(len(items), (st.page+1)*per)] { + tickerTapeItem(c, it, dec) + } + }) + if pages < 2 { + return + } + if iconAction(c, icons.Must("chevron-left"), msg(c, "ticker_tape.previous")).Clicked() { + turn(-1) + } + ui.Text(c, fmt.Sprintf("%d / %d", st.page+1, pages)).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum") + if iconAction(c, icons.Must("chevron-right"), msg(c, "ticker_tape.next")).Clicked() { + turn(1) + } + }) +} + +// tickerTapeItem is one symbol, price and change. +func tickerTapeItem(c *ui.Context, it TickerItem, dec int) { + u := Density(c).Unit() + sign := trendSign(it.ChangePercent) + ui.Row(c).Gap(u*1.5).AlignItems(ui.Center).Padding(0, u*4).Shrink(0).Children(func() { + ui.Text(c, it.Symbol).FontWeight(600).SingleLine() + ui.Text(c, statisticFormat(it.Price, dec)).FontFeatures("tnum").SingleLine() + ui.Row(c).Gap(u / 2).AlignItems(ui.Center).TextColor(trendColor(c, sign)).Label(trendWord(c, sign) + " " + statisticSigned(it.ChangePercent, 2) + "%").Children(func() { + ui.Icon(c, trendIcon(sign)).Size(12, 12) + ui.Text(c, statisticSigned(it.ChangePercent, 2)+"%").FontFeatures("tnum").SingleLine() + }) + }) +} diff --git a/ticker_tape_test.go b/ticker_tape_test.go new file mode 100644 index 0000000..12073db --- /dev/null +++ b/ticker_tape_test.go @@ -0,0 +1,93 @@ +package mujica + +import ( + "fmt" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func tickerTapeItems(n int) []TickerItem { + var items []TickerItem + for i := range n { + items = append(items, TickerItem{Symbol: fmt.Sprintf("SYM%d", i), Price: 100 + float64(i), ChangePercent: float64(i%3 - 1)}) + } + return items +} + +func tickerTapeX(t *testing.T, tt *ui.Tester) float32 { + t.Helper() + r, ok := tt.Find("SYM0") + if !ok { + t.Fatalf("no SYM0 in %q", tt.Texts()) + } + return r.X +} + +func TestTickerTapeScrollsAndPausesOnHover(t *testing.T) { + tt := tradingTester(GreenUp, 400, 80, func(c *ui.Context) { + ui.Box(c).Width(380).Children(func() { TickerTape(c, tickerTapeItems(6), TickerTapeOptions{Speed: 200}) }) + }) + x0 := tickerTapeX(t, tt) + time.Sleep(60 * time.Millisecond) + tt.Frame() + x1 := tickerTapeX(t, tt) + if x1 >= x0 { + t.Fatalf("did not scroll: %v -> %v", x0, x1) + } + tt.Move(100, 25) + tt.Frame() + x2 := tickerTapeX(t, tt) + time.Sleep(60 * time.Millisecond) + tt.Frame() + if x3 := tickerTapeX(t, tt); x3 != x2 { + t.Fatalf("moved under the pointer: %v -> %v", x2, x3) + } +} + +func TestTickerTapePausesOnFocus(t *testing.T) { + tt := tradingTester(GreenUp, 400, 80, func(c *ui.Context) { + ui.Box(c).Width(380).Children(func() { TickerTape(c, tickerTapeItems(6), TickerTapeOptions{Speed: 200}) }) + }) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Market ticker") { + t.Fatal("tape not focusable") + } + x0 := tickerTapeX(t, tt) + time.Sleep(60 * time.Millisecond) + tt.Frame() + if x1 := tickerTapeX(t, tt); x1 != x0 { + t.Fatalf("moved while focused: %v -> %v", x0, x1) + } +} + +func TestTickerTapePagesUnderReducedMotion(t *testing.T) { + tt := tradingTester(GreenUp, 500, 80, func(c *ui.Context) { + TickerTape(c, tickerTapeItems(5), TickerTapeOptions{PerPage: 2}) + }) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + if !tt.HasText("SYM0") || tt.HasText("SYM2") || !tt.HasText("1 / 3") || !tt.HasText("−1.00%") { + t.Fatalf("page 1: %q", tt.Texts()) + } + if err := tt.Click("Next page"); err != nil { + t.Fatal(err) + } + if !tt.HasText("SYM2") || tt.HasText("SYM0") || !tt.HasText("+1.00%") { + t.Fatalf("page 2: %q", tt.Texts()) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeyLeft) + if !tt.HasText("SYM4") || !tt.HasText("3 / 3") { + t.Fatalf("wrap back: %q", tt.Texts()) + } +} + +func TestTickerTapeSnapshot(t *testing.T) { + snapshot(t, "ticker_tape", 520, 60, func(c *ui.Context) { + ui.Column(c).Padding(10).Children(func() { + TickerTape(c, []TickerItem{{"MUJC", 412.08, -1.35}, {"AVE", 88.2, 2.1}, {"OBLV", 1204.5, 0}}, TickerTapeOptions{}) + }) + }) +} diff --git a/time_and_sales.go b/time_and_sales.go new file mode 100644 index 0000000..0a6c186 --- /dev/null +++ b/time_and_sales.go @@ -0,0 +1,137 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// TradePrint is one executed trade on the tape. +type TradePrint struct { + // ID identifies the trade; required and unique. + ID string + Time time.Time + Price float64 + Size float64 + Side OrderSide +} + +// TimeAndSalesOptions configure a TimeAndSales. +type TimeAndSalesOptions struct { + // Decimals of prices and sizes; zero means 2 and 0. + Decimals, SizeDecimals int + // LargeSize marks trades at or above it; zero marks none. + LargeSize float64 + // Height of the list in DIPs; zero means 320. + Height float32 + Status DataStatus + Error string +} + +// TimeAndSalesState is the tape's follow state and list place. +type TimeAndSalesState struct { + // Paused stops new trades from showing until resumed. + Paused bool + frozen int + shown int + list ListState[string] +} + +// TimeAndSales lists trades newest on top; Paused holds the view. +func TimeAndSales(c *ui.Context, s *TimeAndSalesState, trades []TradePrint, opts TimeAndSalesOptions) ChoiceResult { + dec := priceDecimals("TimeAndSales", opts.Decimals) + if opts.SizeDecimals < 0 || opts.LargeSize < 0 || opts.Height < 0 { + panic("mujica: TimeAndSales SizeDecimals, LargeSize and Height cannot be negative") + } + height := opts.Height + if height == 0 { + height = 320 + } + if !s.Paused { + s.frozen = len(trades) + } + s.frozen = min(s.frozen, len(trades)) + n := s.frozen + if !s.Paused && n != s.shown { + s.list.ScrollTo(0) + } + s.shown = n + k := Tokens(c) + d := Density(c) + var r ChoiceResult + r.Element = ui.Column(c).Gap(d.Unit() * 2).Height(height) + r.Element.Children(func() { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, msg(c, "time_and_sales.title")).Font(theme.SerifFont).FontWeight(600).Grow(1) + label := msg(c, "time_and_sales.pause") + if s.Paused { + label = msg(c, "time_and_sales.resume") + if waiting := len(trades) - n; waiting > 0 { + label = fmt.Sprintf(msg(c, "time_and_sales.resume_count"), waiting) + } + } + if Button(c, label, ButtonOptions{Variant: Secondary}).Clicked() { + s.Paused, r.changed = !s.Paused, true + slog.Debug("mujica: time_and_sales follow", "paused", s.Paused) + } + }) + ui.Row(c).Padding(0, d.Unit()*4).Gap(d.Unit() * 2).Children(func() { + for i, key := range []string{"time_and_sales.time", "time_and_sales.price", "time_and_sales.size"} { + 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) + } + } + ui.Box(c).Width(16) + }) + at := func(i int) TradePrint { return trades[n-1-i] } + List(c, &s.list, n, ListOptions[string]{ + Key: func(i int) string { return at(i).ID }, + Label: func(i int) string { + t := at(i) + return timeAndSalesSpoken(c, t, dec, opts) + }, + Status: opts.Status, Error: opts.Error, Empty: msg(c, "time_and_sales.empty"), + }, func(i int) { timeAndSalesRow(c, at(i), dec, opts) }).Element.Grow(1) + }) + return r +} + +func timeAndSalesLarge(t TradePrint, opts TimeAndSalesOptions) bool { + return opts.LargeSize > 0 && t.Size >= opts.LargeSize +} + +// timeAndSalesSpoken reads a trade with its side and size. +func timeAndSalesSpoken(c *ui.Context, t TradePrint, dec int, opts TimeAndSalesOptions) string { + s := fmt.Sprintf("%s %s %s %s", t.Time.Format("15:04:05"), msg(c, fmt.Sprintf("order_book.side%d", t.Side)), + statisticFormat(t.Price, dec), statisticFormat(t.Size, opts.SizeDecimals)) + if timeAndSalesLarge(t, opts) { + s += ", " + msg(c, "time_and_sales.large") + } + return s +} + +// timeAndSalesRow is one trade with its side and large mark. +func timeAndSalesRow(c *ui.Context, t TradePrint, dec int, opts TimeAndSalesOptions) { + k := Tokens(c) + large := timeAndSalesLarge(t, opts) + sign := t.Side.sign() + ui.Text(c, t.Time.Format("15:04:05")).FontFeatures("tnum").TextColor(k.TextMuted).Grow(1).Basis(0).SingleLine() + ui.Row(c).Gap(2).Justify(ui.End).AlignItems(ui.Center).Grow(1).Basis(0).TextColor(trendColor(c, sign)).Children(func() { + ui.Icon(c, trendIcon(sign)).Size(12, 12) + ui.Text(c, statisticFormat(t.Price, dec)).FontFeatures("tnum").SingleLine() + }) + size := ui.Text(c, statisticFormat(t.Size, opts.SizeDecimals)).FontFeatures("tnum").TextAlign(ui.End).Grow(1).Basis(0).SingleLine() + mark := ui.Box(c).Size(16, 16).Shrink(0) + if large { + size.FontWeight(700) + mark.Draw(func(p *ui.Painter, r ui.Rect) { + p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, 10), k.AccentText) + }) + } +} diff --git a/time_and_sales_test.go b/time_and_sales_test.go new file mode 100644 index 0000000..5bb8056 --- /dev/null +++ b/time_and_sales_test.go @@ -0,0 +1,90 @@ +package mujica + +import ( + "fmt" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func timeAndSalesTrades(n int) []TradePrint { + at := time.Date(2026, 10, 5, 9, 30, 0, 0, time.UTC) + out := make([]TradePrint, n) + for i := range out { + side := SideBuy + if i%2 == 1 { + side = SideSell + } + out[i] = TradePrint{ID: fmt.Sprint(i), Time: at.Add(time.Duration(i) * time.Second), Price: 100 + float64(i%50)/100, Size: float64(i%7 + 1), Side: side} + } + return out +} + +func TestTimeAndSalesNewestFirstAndLargeMark(t *testing.T) { + var s TimeAndSalesState + trades := timeAndSalesTrades(3) + trades[1].Size = 5000 + tt := tradingTester(GreenUp, 500, 400, func(c *ui.Context) { + TimeAndSales(c, &s, trades, TimeAndSalesOptions{LargeSize: 1000}) + }) + newest, _ := tt.Find("09:30:02") + oldest, _ := tt.Find("09:30:00") + if newest.Y >= oldest.Y { + t.Fatalf("newest %v not above oldest %v", newest, oldest) + } + if !tt.HasText("5,000") { + t.Fatalf("texts %q", tt.Texts()) + } + if _, ok := tt.Find("09:30:01 Ask 100.01 5,000, large trade"); !ok { + t.Fatal("large trade not spoken") + } +} + +func TestTimeAndSalesPauseHoldsView(t *testing.T) { + var s TimeAndSalesState + trades := timeAndSalesTrades(2) + n := 0 + tt := tradingTester(GreenUp, 500, 400, func(c *ui.Context) { + if TimeAndSales(c, &s, trades, TimeAndSalesOptions{}).Changed() { + n++ + } + }) + if err := tt.Click("Pause"); err != nil { + t.Fatal(err) + } + trades = timeAndSalesTrades(5) + tt.Frame() + if tt.HasText("09:30:04") || !tt.HasText("Resume (3 new)") || !s.Paused || n != 1 { + t.Fatalf("paused view moved: %q", tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + if !tt.HasText("09:30:04") || s.Paused || n != 2 { + t.Fatalf("resume: %q", tt.Texts()) + } +} + +func TestTimeAndSalesBuildsOnlyVisibleRows(t *testing.T) { + var s TimeAndSalesState + trades := timeAndSalesTrades(100_000) + tt := tradingTester(GreenUp, 500, 400, func(c *ui.Context) { + TimeAndSales(c, &s, trades, TimeAndSalesOptions{}) + }) + if !tt.HasText("13:16:39") { + t.Fatalf("newest trade not on top: %q", tt.Texts()[:8]) + } + if got := len(tt.Texts()); got > 120 { + t.Fatalf("built %d texts for 100,000 trades", got) + } +} + +func TestTimeAndSalesSnapshot(t *testing.T) { + trades := timeAndSalesTrades(8) + trades[5].Size = 4000 + snapshot(t, "time_and_sales", 420, 360, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + s := ui.Local(c.Root(), "snap", func() TimeAndSalesState { return TimeAndSalesState{} }) + TimeAndSales(c, s, trades, TimeAndSalesOptions{LargeSize: 1000, Height: 320}) + }) + }) +} diff --git a/trade_history_table.go b/trade_history_table.go new file mode 100644 index 0000000..2f64054 --- /dev/null +++ b/trade_history_table.go @@ -0,0 +1,110 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +// FillRow is one of the account's fills; ID is stable. +type FillRow struct { + ID string + Time time.Time + Symbol string + Side OrderSide + Price float64 + Quantity float64 + Fee float64 +} + +// TradeHistoryTableOptions configure a TradeHistoryTable. +type TradeHistoryTableOptions struct { + // Decimals of prices and quantities; zero means 2 and 0. + Decimals, QuantityDecimals int + // Version must change when rows change in place. + Version int + Status DataStatus + Error string + Disabled bool +} + +// TradeHistoryTableState is the date range and table state. +type TradeHistoryTableState struct { + // Range keeps fills on its days; zero keeps all. + Range DateRange + Table DataTableState[string] + cache tradingRows[FillRow] +} + +// TradeHistoryResult is a built table and its export request. +type TradeHistoryResult struct { + Element *ui.Element + Table DataTableView + exported []FillRow + export bool +} + +// Exported returns the range's fills when export was asked. +func (r TradeHistoryResult) Exported() ([]FillRow, bool) { return r.exported, r.export } + +// TradeHistoryTable lists fills by date range, with export. +func TradeHistoryTable(c *ui.Context, s *TradeHistoryTableState, rows []FillRow, opts TradeHistoryTableOptions) TradeHistoryResult { + dec := priceDecimals("TradeHistoryTable", opts.Decimals) + if opts.QuantityDecimals < 0 { + panic("mujica: TradeHistoryTable with negative QuantityDecimals") + } + rng := s.Range + shown := s.cache.view(rows, opts.Version, rng, func(f FillRow) bool { + if rng.IsZero() { + return true + } + d := DateOf(f.Time) + return d.Compare(rng.Start) >= 0 && d.Compare(rng.End) <= 0 + }) + num := func(id, key string, dp int, v func(FillRow) float64) DataColumn[FillRow] { + return DataColumn[FillRow]{ID: id, Title: msg(c, key), Align: ui.End, Sortable: true, + Compare: func(a, b FillRow) int { return cmp.Compare(v(a), v(b)) }, + Text: func(f FillRow) string { return statisticFormat(v(f), dp) }} + } + cols := []DataColumn[FillRow]{ + {ID: "time", Title: msg(c, "trade_history_table.time"), Sortable: true, Width: 180, + Compare: func(a, b FillRow) int { return a.Time.Compare(b.Time) }, + Text: func(f FillRow) string { return f.Time.Format("2006-01-02 15:04:05") }}, + {ID: "symbol", Title: msg(c, "position_table.symbol"), Sortable: true, + Compare: func(a, b FillRow) int { return cmp.Compare(a.Symbol, b.Symbol) }, + Text: func(f FillRow) string { return f.Symbol }}, + {ID: "side", Title: msg(c, "order_table.side"), Text: func(f FillRow) string { return msg(c, fmt.Sprintf("order_entry.side%d", f.Side)) }, + Cell: func(f FillRow) { tradingSide(c, f.Side) }}, + num("price", "order_book.price", dec, func(f FillRow) float64 { return f.Price }), + num("qty", "order_entry.quantity", opts.QuantityDecimals, func(f FillRow) float64 { return f.Quantity }), + num("fee", "trade_history_table.fee", dec, func(f FillRow) float64 { return f.Fee }), + } + d := Density(c) + var r TradeHistoryResult + r.Element = ui.Column(c).Gap(d.Unit() * 2).Disabled(opts.Disabled).Grow(1).FillWidth().MinHeight(0) + disabled := r.Element.IsDisabled() + r.Element.Children(func() { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + if DateRangePicker(c, &s.Range, DateRangePickerOptions{Label: msg(c, "trade_history_table.range"), Disabled: disabled}).Changed() { + slog.Debug("mujica: trade_history_table range", "days", s.Range.Days()) + } + if !s.Range.IsZero() && Button(c, msg(c, "trade_history_table.all"), ButtonOptions{Variant: Ghost, Disabled: disabled}).Clicked() && !disabled { + s.Range = DateRange{} + } + ui.Box(c).Grow(1) + if Button(c, msg(c, "trade_history_table.export"), ButtonOptions{Variant: Secondary, Icon: icons.Must("upload"), Disabled: disabled || len(shown) == 0}).Clicked() && !disabled { + r.exported, r.export = shown, true + slog.Debug("mujica: trade_history_table export", "rows", len(shown)) + } + }) + r.Table = DataTable(c, &s.Table, shown, DataTableOptions[FillRow, string]{Columns: cols, Key: func(f FillRow) string { return f.ID }, + Status: opts.Status, Error: opts.Error, Empty: msg(c, "trade_history_table.empty")}) + r.Table.Element.Grow(1) + }) + return r +} diff --git a/trade_history_table_test.go b/trade_history_table_test.go new file mode 100644 index 0000000..c2a07b8 --- /dev/null +++ b/trade_history_table_test.go @@ -0,0 +1,87 @@ +package mujica + +import ( + "fmt" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func tradeHistorySample() []FillRow { + day := func(d, h int) time.Time { return time.Date(2026, 10, d, h, 0, 0, 0, time.UTC) } + return []FillRow{ + {ID: "f1", Time: day(1, 10), Symbol: "MUJC", Side: SideBuy, Price: 400, Quantity: 10, Fee: 1.2}, + {ID: "f2", Time: day(3, 14), Symbol: "AVE", Side: SideSell, Price: 88.2, Quantity: 5, Fee: 0.4}, + {ID: "f3", Time: day(5, 9), Symbol: "OBLV", Side: SideBuy, Price: 12, Quantity: 100, Fee: 0.6}, + } +} + +func TestTradeHistoryTableRangeAndExport(t *testing.T) { + s := TradeHistoryTableState{Range: DateRange{Start: Date{2026, 10, 2}, End: Date{2026, 10, 4}}} + var exported []FillRow + tt := tradingTester(GreenUp, 1000, 600, func(c *ui.Context) { + ui.Box(c).Width(960).Height(400).Children(func() { + if rows, ok := TradeHistoryTable(c, &s, tradeHistorySample(), TradeHistoryTableOptions{}).Exported(); ok { + exported = rows + } + }) + }) + if tt.HasText("MUJC") || !tt.HasText("AVE") || tt.HasText("OBLV") { + t.Fatalf("range: %q", tt.Texts()) + } + if err := tt.Click("Export"); err != nil { + t.Fatal(err) + } + if len(exported) != 1 || exported[0].ID != "f2" { + t.Fatalf("exported %v", exported) + } + if err := tt.Click("All dates"); err != nil { + t.Fatal(err) + } + if !tt.HasText("MUJC") || !tt.HasText("OBLV") || !s.Range.IsZero() { + t.Fatalf("all dates: %q", tt.Texts()) + } + _ = tt.Click("Export") + if len(exported) != 3 { + t.Fatalf("exported %d", len(exported)) + } +} + +func TestTradeHistoryTableDisabled(t *testing.T) { + var s TradeHistoryTableState + exported := false + tt := tradingTester(GreenUp, 1000, 600, func(c *ui.Context) { + ui.Box(c).Width(960).Height(400).Children(func() { + _, exported = TradeHistoryTable(c, &s, tradeHistorySample(), TradeHistoryTableOptions{Disabled: true}).Exported() + }) + }) + _ = tt.Click("Export") + if exported { + t.Fatal("disabled table exported") + } +} + +func TestTradeHistoryTableSnapshot(t *testing.T) { + snapshot(t, "trade_history_table", 900, 260, func(c *ui.Context) { + ui.Column(c).Padding(16).Height(240).Width(880).Children(func() { + s := ui.Local(c.Root(), "snap", func() TradeHistoryTableState { return TradeHistoryTableState{} }) + TradeHistoryTable(c, s, tradeHistorySample(), TradeHistoryTableOptions{}) + }) + }) +} + +// 100,000 fills build only visible rows. +func TestTradeHistoryTableLargeData(t *testing.T) { + var s TradeHistoryTableState + rows := make([]FillRow, 100_000) + for i := range rows { + rows[i] = FillRow{ID: fmt.Sprint(i), Time: time.Date(2026, 1, 1, 0, 0, i, 0, time.UTC), Symbol: "S", Side: SideSell, Price: 1, Quantity: 1} + } + tt := tradingTester(GreenUp, 1000, 600, func(c *ui.Context) { + ui.Box(c).Width(960).Height(400).Children(func() { TradeHistoryTable(c, &s, rows, TradeHistoryTableOptions{}) }) + }) + if n := len(tt.Texts()); n > 400 { + t.Fatalf("built %d texts", n) + } +}