Trading: components 161-180

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:28:19 -04:00
1 parent 6f0c02d339
commit 2c40e5a89a
105 files changed
+6042 -1

No files matched your search

+60
View File
@@ -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
}
+34
View File
@@ -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{}) })
})
}
+178
View File
@@ -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)}
}
+113
View File
@@ -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})
})
})
}
+46
View File
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
+43
View File
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
+46
View File
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
+42
View File
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
+52
View File
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
+46
View File
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
+48
View File
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
+50
View File
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
+41
View File
@@ -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。 |
## 状态
正常、价差过大、倒挂。
## 事件
无。
## 键盘操作
不获取焦点;以状态角色读出。
## 限制
- 无。
+41
View File
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
+52
View File
@@ -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 依次经过方向、类型、各字段与按钮;数量与价格字段 ↑/↓ 按步长调整;提交失败聚焦第一个错误字段。
## 限制
- 止损单检查:买入触发价须高于最新价,卖出须低于。
- 数量字段的异步检查覆盖整张订单:任一字段变化都会让旧结果失效。
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
+49
View File
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
+48
View File
@@ -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 到达撤单按钮。
## 限制
- 筛选只在数据或条件变化时执行。
@@ -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 键盘操作。
## 限制
- 只回传数据,不生成文件:导出格式与写入由调用方负责。
- 日期按每条记录自身时区的日期比较。
+44
View File
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
+46
View File
@@ -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;风险区间以色带、图标和文字共同表达。
+41
View File
@@ -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” | 名称。 |
## 状态
安全、追加保证金、强制平仓。
## 事件
无。
## 键盘操作
不获取焦点;以计量角色读出比率、状态与两条线。
## 限制
- 状态同时用图标、文字与颜色表达;两条线粗细不同,并在下方以文字标注。
+41
View File
@@ -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 级”和名称。
## 限制
- 颜色只辅助:位置标记与等级文字同时表达等级。
@@ -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 取消。点击遮罩不关闭。
## 限制
- 每次打开都需重新勾选。
+221
View File
@@ -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)
}
+274 -1
View File
@@ -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))
}
})
}
+156
View File
@@ -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)`,
}
+286
View File
@@ -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")
}
}
+123
View File
@@ -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()
})
}
+63
View File
@@ -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{}) })
})
}
+159
View File
@@ -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)
}
})
}
+94
View File
@@ -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})
})
})
}
+71
View File
@@ -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
}
+39
View File
@@ -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{}) })
})
}
+161
View File
@@ -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", "取消"},
})
}
+260
View File
@@ -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
}
+123
View File
@@ -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))
}
}
+88
View File
@@ -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
}
+78
View File
@@ -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"})
})
}
+222
View File
@@ -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 ""
}
+137
View File
@@ -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})
})
})
}
+194
View File
@@ -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
}
+118
View File
@@ -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)
}
}
+82
View File
@@ -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 ""
}
+43
View File
@@ -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: "$"})
})
})
}
+107
View File
@@ -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()
})
}
+84
View File
@@ -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{})
})
})
}
+37
View File
@@ -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
}
+35
View File
@@ -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{})
})
})
}
+171
View File
@@ -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
}
+106
View File
@@ -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})
})
})
}
+127
View File
@@ -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
}
+86
View File
@@ -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"})
})
})
}
+52
View File
@@ -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
}
+43
View File
@@ -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)
})
}})
})
})
}
+84
View File
@@ -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
}
+42
View File
@@ -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"}}})
})
})
}
+53
View File
@@ -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
}
+42
View File
@@ -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})
})
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+155
View File
@@ -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()
})
})
}
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more