Trading: components 161-180
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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{}) })
|
||||
})
|
||||
}
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
|
||||
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
|
||||
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
|
||||
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
|
||||
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
|
||||
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
|
||||
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
|
||||
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
|
||||
@@ -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。 |
|
||||
|
||||
## 状态
|
||||
|
||||
正常、价差过大、倒挂。
|
||||
|
||||
## 事件
|
||||
|
||||
无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
不获取焦点;以状态角色读出。
|
||||
|
||||
## 限制
|
||||
|
||||
- 无。
|
||||
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
|
||||
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
|
||||
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
|
||||
@@ -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 键盘操作。
|
||||
|
||||
## 限制
|
||||
|
||||
- 只回传数据,不生成文件:导出格式与写入由调用方负责。
|
||||
- 日期按每条记录自身时区的日期比较。
|
||||
@@ -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)` 按窗口设置,默认绿涨红跌;方向同时用箭头与正负号表达,不只靠颜色。
|
||||
@@ -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;风险区间以色带、图标和文字共同表达。
|
||||
@@ -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” | 名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
安全、追加保证金、强制平仓。
|
||||
|
||||
## 事件
|
||||
|
||||
无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
不获取焦点;以计量角色读出比率、状态与两条线。
|
||||
|
||||
## 限制
|
||||
|
||||
- 状态同时用图标、文字与颜色表达;两条线粗细不同,并在下方以文字标注。
|
||||
@@ -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 取消。点击遮罩不关闭。
|
||||
|
||||
## 限制
|
||||
|
||||
- 每次打开都需重新勾选。
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)`,
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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{}) })
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}) })
|
||||
})
|
||||
}
|
||||
@@ -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", "取消"},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
})
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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: "$"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"}}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||