Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 16:18:48 -04:00
1 parent 5a7e921c7e
commit 22b53e9e9e
1822 files changed
+24568 -21314

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
package finance
import (
"fmt"
"math"
"slices"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/chart"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
type AllocationItem struct {
Name string
Value float64
Target float64
}
// AssetAllocationChartOptions configure an AssetAllocationChart.
type AssetAllocationChartOptions struct {
Frame chart.ChartFrame
Version int
Tolerance float64
Format func(v float64) string
}
func AssetAllocationChart(c *ui.Context, items []AllocationItem, opts AssetAllocationChartOptions) chart.ChartResult {
if opts.Tolerance < 0 {
panic("mujica: AssetAllocationChart Tolerance is negative")
}
tol := opts.Tolerance
if tol == 0 {
tol = 5
}
k := core.Tokens(c)
u := core.Density(c).Unit()
pal := internal.Palette(k)
var res chart.ChartResult
row := ui.Row(c).Gap(u * 6).AlignItems(ui.Start).Wrap()
row.Children(func() {
memo := ui.Local(row, "mujica.allocation", func() allocationSlices { return allocationSlices{} })
if id := chart.ChartID(items) + fmt.Sprint(opts.Version) + core.Msg(c, "pie_chart.other"); id != memo.id {
total, targets := 0.0, 0.0
for _, it := range items {
if it.Name == "" || it.Value < 0 || it.Target < 0 || math.IsNaN(it.Value) || math.IsNaN(it.Target) || math.IsInf(it.Value, 0) || math.IsInf(it.Target, 0) {
panic("mujica: AllocationItem needs a Name and values ≥ 0")
}
total += it.Value
targets += it.Target
}
if len(items) > 0 && math.Abs(targets-100) > 0.01 && targets != 0 {
panic(fmt.Sprintf("mujica: AllocationItem targets sum to %v, not 100", targets))
}
memo.total = total
memo.id, memo.slices = id, make([]chart.ChartSlice, len(items))
for i, it := range items {
memo.slices[i] = chart.ChartSlice{Label: it.Name, Value: it.Value}
}
memo.parts = chart.PieMerge(memo.slices, 1, core.Msg(c, "pie_chart.other"))
memo.markers = make([]int, len(items))
for i, it := range items {
at := slices.IndexFunc(memo.parts, func(part chart.ChartSlice) bool { return part.Label == it.Name })
if at < 0 {
at = len(memo.parts) - 1
}
memo.markers[i] = at
}
}
parts := memo.parts
total := memo.total
frame := opts.Frame
if frame.Height == 0 {
frame.Height = 220
}
ui.Column(c).Width(max(frame.Height+40, 300)).Children(func() {
res = chart.PieChart(c, parts, chart.PieChartOptions{Frame: frame, Donut: true, Format: opts.Format})
})
if total == 0 {
return
}
size := core.FontSize(c, theme.CaptionSize)
ui.Column(c).MinWidth(340).Grow(1).Children(func() {
g := ui.Grid(c).ColumnTracks(ui.Fr(2), ui.Fr(1), ui.Fr(1), ui.Fr(2)).GapX(u * 2).GapY(u * 2).
Role(ui.RoleTable).Label(frame.Label)
g.Children(func() {
for _, h := range []string{"asset_allocation_chart.asset", "asset_allocation_chart.weight", "asset_allocation_chart.target", "asset_allocation_chart.drift"} {
ui.Text(c, core.Msg(c, h)).FontSize(size).TextColor(k.TextMuted).Role(ui.RoleColumnHeader)
}
})
state := ui.Local(row, "mujica.allocation.rows", func() ui.ListState { return ui.ListState{} })
ui.List(c, state, len(items), func(i int) {
it := items[i]
ui.Grid(c).ColumnTracks(ui.Fr(2), ui.Fr(1), ui.Fr(1), ui.Fr(2)).GapX(u * 2).MinHeight(core.Density(c).ControlHeight()).Children(func() {
w := it.Value / total * 100
drift := w - it.Target
ui.Row(c).Key(it.Name).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Box(c).Size(12, 12).Draw(func(p *ui.Painter, r ui.Rect) {
internal.Marker(p, memo.markers[i], r.X+6, r.Y+6, 10, pal[memo.markers[i]%len(pal)])
})
ui.Text(c, it.Name).SingleLine()
})
ui.Text(c, fmt.Sprintf("%.1f%%", w)).FontFeatures("tnum").JustifySelf(ui.End)
ui.Text(c, fmt.Sprintf("%.1f%%", it.Target)).FontFeatures("tnum").TextColor(k.TextMuted).JustifySelf(ui.End)
allocationDrift(c, drift, tol)
})
}).Height(min(float32(len(items))*core.Density(c).ControlHeight(), 240))
})
})
res.Element = row
return res
}
type allocationSlices struct {
id string
slices []chart.ChartSlice
total float64
parts []chart.ChartSlice
markers []int
}
func allocationDrift(c *ui.Context, drift, tol float64) {
k := core.Tokens(c)
text := data.StatisticSigned(drift, 1) + " pt"
over := math.Abs(drift) > tol
ink := k.TextMuted
if over {
ink = k.Warning
}
ui.Row(c).Gap(4).AlignItems(ui.Center).JustifySelf(ui.End).TextColor(ink).Children(func() {
if over {
ui.Icon(c, icons.Must("triangle-alert")).Size(12, 12)
word := "asset_allocation_chart.over"
if drift < 0 {
word = "asset_allocation_chart.under"
}
ui.Text(c, core.Msg(c, word)).FontSize(core.FontSize(c, theme.CaptionSize))
}
ui.Text(c, text).FontFeatures("tnum")
})
}
+65
View File
@@ -0,0 +1,65 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/chart"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
func TestAssetAllocationChartWeightsDriftAndKeys(t *testing.T) {
items := []AllocationItem{{"Stocks", 60, 50}, {"Bonds", 30, 40}, {"Cash", 10, 10}}
var last chart.ChartResult
tt := chatTester(960, 420, func(c *ui.Context) {
last = AssetAllocationChart(c, items, AssetAllocationChartOptions{Frame: chart.ChartFrame{Label: "Allocation"}})
})
if !tt.HasText("60.0%") || !tt.HasText("+10.0 pt") || !tt.HasText("−10.0 pt") || !tt.HasText("Over") || !tt.HasText("Under") {
t.Fatalf("weights %q", tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyHome)
if last.Point != 0 {
t.Fatalf("point %d", last.Point)
}
tt.Key(0, ui.KeyEnd)
if last.Point != 2 {
t.Fatalf("point %d", last.Point)
}
marketsPanic(t, func(c *ui.Context) {
AssetAllocationChart(c, []AllocationItem{{"Bad", 1, 101}}, AssetAllocationChartOptions{Frame: chart.ChartFrame{Label: "x"}})
})
}
func TestAssetAllocationChartSnapshot(t *testing.T) {
snapshot(t, "asset_allocation_chart", 760, 380, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(12).Fill().Children(func() {
AssetAllocationChart(c, []AllocationItem{{"Stocks", 62000, 55}, {"Bonds", 25000, 30}, {"Cash", 8000, 10}, {"Gold", 5000, 5}}, AssetAllocationChartOptions{Frame: chart.ChartFrame{Label: "Allocation"}})
})
})
}
func TestAssetAllocationChartMergedMarkers(t *testing.T) {
items := []AllocationItem{{"Tiny A", 0.1, 0}, {"Tiny B", 0.1, 0}, {"Stocks", 99.8, 0}}
tt := chatTester(960, 420, func(c *ui.Context) {
AssetAllocationChart(c, items, AssetAllocationChartOptions{Frame: chart.ChartFrame{Label: "Allocation"}})
})
r, _ := tt.Find("99.8%")
asset, _ := tt.Find("Asset")
palette := internal.Palette(theme.Light())
found := false
for y := int(r.Y); y < int(r.Y+r.H); y++ {
for x := int(asset.X); x < int(r.X); x++ {
pixel := tt.Image().RGBAAt(x, y)
if pixel.R == palette[0].R && pixel.G == palette[0].G && pixel.B == palette[0].B {
found = true
}
}
}
if !found {
t.Fatal("Stocks list marker differs from its donut marker")
}
}
+62
View File
@@ -0,0 +1,62 @@
package finance
import (
"fmt"
"math"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
u := core.Density(c).Unit()
total := bid + ask
share := 0.5
if total > 0 {
share = bid / total
}
label := opts.Label
if label == "" {
label = core.Msg(c, "bid_ask_bar.label")
}
buy := fmt.Sprintf(core.Msg(c, "bid_ask_bar.buy"), data.StatisticFormat(share*100, 1), data.StatisticFormat(bid, opts.Decimals))
sell := fmt.Sprintf(core.Msg(c, "bid_ask_bar.sell"), data.StatisticFormat(ask, opts.Decimals), data.StatisticFormat((1-share)*100, 1))
up, down := core.TrendColor(c, 1), core.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(core.FontSize(c, theme.CaptionSize)).FontFeatures("tnum").FontWeight(600).SingleLine()
ui.Text(c, sell).TextColor(downText).FontSize(core.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
}
+36
View File
@@ -0,0 +1,36 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestBidAskBarShares(t *testing.T) {
bid, ask := 620.0, 380.0
tt := tradingTester(core.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(core.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{}) })
})
}
+157
View File
@@ -0,0 +1,157 @@
package finance
import (
"fmt"
"log/slog"
"math"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
type Currency struct {
Code, Name string
Decimals int
}
// CurrencyConverterState is the amount and the pair being converted.
type CurrencyConverterState struct {
Amount float64
From, To string
rate float64
rateFrom, rateTo string
rateBound bool
}
// CurrencyConverterOptions configure a CurrencyConverter.
type CurrencyConverterOptions struct {
Rate float64
// RateFrom and RateTo identify refreshed rates, including equal rates.
RateFrom, RateTo string
RateTime time.Time
Disabled bool
}
type CurrencyConverterView struct {
Element *ui.Element
Result float64
// PairChanged asks the caller for the new pair's rate.
PairChanged bool
}
func CurrencyConverter(c *ui.Context, s *CurrencyConverterState, currencies []Currency, opts CurrencyConverterOptions) CurrencyConverterView {
if s == nil || len(currencies) < 2 {
panic("mujica: CurrencyConverter needs a state and two currencies")
}
if opts.Rate < 0 || math.IsNaN(opts.Rate) || math.IsInf(opts.Rate, 0) {
panic("mujica: CurrencyConverter Rate must be finite and not negative")
}
seen := map[string]bool{}
for _, cur := range currencies {
if cur.Code == "" || seen[cur.Code] || cur.Decimals < 0 || cur.Decimals > 12 {
panic("mujica: currencies need unique codes and decimals in 0..12")
}
seen[cur.Code] = true
}
if s.Amount < 0 || math.IsNaN(s.Amount) || math.IsInf(s.Amount, 0) {
panic("mujica: currency amount must be finite and nonnegative")
}
from, to := currencyFind(currencies, s.From), currencyFind(currencies, s.To)
rate := opts.Rate
if !s.rateBound || s.rate != rate || opts.RateFrom != "" || opts.RateTo != "" {
s.rate, s.rateFrom, s.rateTo, s.rateBound = rate, s.From, s.To, true
if opts.RateFrom != "" || opts.RateTo != "" {
s.rateFrom, s.rateTo = opts.RateFrom, opts.RateTo
}
}
if s.From != s.rateFrom || s.To != s.rateTo {
rate = 0
}
k := core.Tokens(c)
u := core.Density(c).Unit()
options := make([]input.SelectOption[string], len(currencies))
for i, cur := range currencies {
options[i] = input.SelectOption[string]{Value: cur.Code, Label: cur.Code + " · " + cur.Name}
}
v := CurrencyConverterView{Result: math.NaN()}
if rate > 0 {
v.Result = input.NumberInputSpec{Decimals: to.Decimals}.Round(s.Amount * rate)
}
v.Element = ui.Column(c).Gap(u*3).Padding(u*4).Background(k.Surface).Border(theme.BorderWidth, k.Border).
Radius(theme.CardRadius).Disabled(opts.Disabled)
opts.Disabled = v.Element.IsDisabled()
if opts.Disabled {
copy := *s
s = &copy
}
v.Element.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.End).Wrap().Children(func() {
input.FormField(c, core.Msg(c, "currency_converter.amount"), input.FormFieldOptions{}, func() *ui.Element {
r := input.NumberInput(c, &s.Amount, input.NumberInputOptions{Min: 0, Max: 1e15, Step: math.Pow(10, -float64(from.Decimals)),
Precision: from.Decimals, Label: core.Msg(c, "currency_converter.amount"), Disabled: opts.Disabled})
return r.Element.Width(170)
})
input.FormField(c, core.Msg(c, "currency_converter.from"), input.FormFieldOptions{}, func() *ui.Element {
r := input.Select(c, &s.From, options, input.SelectOptions{Label: core.Msg(c, "currency_converter.from"), Disabled: opts.Disabled})
v.PairChanged = v.PairChanged || r.Changed()
return r.Element
})
swap := core.IconAction(c, icons.Must("arrow-up-down"), core.Msg(c, "currency_converter.swap")).Rotate(90)
if swap.Clicked() && !swap.IsDisabled() {
s.From, s.To, v.PairChanged = s.To, s.From, true
}
input.FormField(c, core.Msg(c, "currency_converter.to"), input.FormFieldOptions{}, func() *ui.Element {
r := input.Select(c, &s.To, options, input.SelectOptions{Label: core.Msg(c, "currency_converter.to"), Disabled: opts.Disabled})
v.PairChanged = v.PairChanged || r.Changed()
return r.Element
})
})
from, to = currencyFind(currencies, s.From), currencyFind(currencies, s.To)
if v.PairChanged {
v.Result = math.NaN()
} else if rate > 0 {
v.Result = input.NumberInputSpec{Decimals: to.Decimals}.Round(s.Amount * rate)
}
if rate == 0 || v.PairChanged {
ui.Text(c, core.Msg(c, "currency_converter.no_rate")).TextColor(k.TextMuted).Role(ui.RoleStatus)
return
}
ui.Row(c).Gap(u * 2).AlignItems(ui.End).Role(ui.RoleStatus).Label(fmt.Sprintf("%s %s = %s %s",
data.StatisticFormat(s.Amount, from.Decimals), from.Code, data.StatisticFormat(v.Result, to.Decimals), to.Code)).Children(func() {
ui.Text(c, data.StatisticFormat(s.Amount, from.Decimals)+" "+from.Code+" =").TextColor(k.TextMuted).FontFeatures("tnum")
ui.Text(c, data.StatisticFormat(v.Result, to.Decimals)+" "+to.Code).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H2Size)).
TextColor(k.Text).FontFeatures("tnum")
})
note := fmt.Sprintf(core.Msg(c, "currency_converter.rate"), from.Code, rate, to.Code)
if !opts.RateTime.IsZero() {
note += " · " + opts.RateTime.Format("2006-01-02 15:04")
}
ui.Text(c, note).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
})
if v.Element.IsDisabled() {
v.PairChanged = false
}
if v.PairChanged {
slog.Debug("mujica: currency_converter pair", "from", s.From, "to", s.To)
}
return v
}
// currencyFind returns the currency with code, panicking when absent.
func currencyFind(currencies []Currency, code string) Currency {
for _, cur := range currencies {
if cur.Decimals < 0 {
panic("mujica: Currency Decimals is negative")
}
if cur.Code == code {
return cur
}
}
panic("mujica: CurrencyConverter currency " + code + " is not offered")
}
+43
View File
@@ -0,0 +1,43 @@
package finance
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
)
func TestCurrencyConverterPrecisionAmountAndNoRate(t *testing.T) {
s := CurrencyConverterState{Amount: 1.23, From: "USD", To: "JPY"}
var last CurrencyConverterView
rate := 149.37
disabled := false
currencies := []Currency{{"USD", "Dollar", 2}, {"JPY", "Yen", 0}}
tt := chatTester(960, 300, func(c *ui.Context) {
last = CurrencyConverter(c, &s, currencies, CurrencyConverterOptions{Rate: rate, Disabled: disabled})
})
if last.Result != 184 || !tt.HasText("184 JPY") {
t.Fatalf("result %v text %q", last.Result, tt.Texts())
}
rate = 0
tt.Frame()
if !math.IsNaN(last.Result) || !tt.HasText("Rate unavailable") {
t.Fatal("missing rate")
}
disabled = true
tt.Frame()
_ = tt.Click("Swap currencies")
if s.From != "USD" {
t.Fatal("disabled swapped")
}
marketsPanic(t, func(c *ui.Context) { CurrencyConverter(c, &s, currencies, CurrencyConverterOptions{Rate: -1}) })
}
func TestCurrencyConverterSnapshot(t *testing.T) {
cs := CurrencyConverterState{Amount: 100, From: "USD", To: "JPY"}
snapshot(t, "currency_converter", 760, 260, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(12).Fill().Children(func() {
CurrencyConverter(c, &cs, []Currency{{"USD", "US Dollar", 2}, {"JPY", "Yen", 0}, {"EUR", "Euro", 2}}, CurrencyConverterOptions{Rate: 149.37, RateTime: mktNow})
})
})
}
+181
View File
@@ -0,0 +1,181 @@
package finance
import (
"fmt"
"log/slog"
"math"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/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 := core.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 := core.Tokens(c)
d := core.Density(c)
var r DepthLadderResult
r.Element = ui.Column(c).Gap(d.Unit()).Disabled(opts.Disabled).Role(ui.RoleTable).Label(core.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, core.Msg(c, "depth_ladder.bid")).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).Grow(1).Basis(0).TextAlign(ui.End)
ui.Text(c, core.Msg(c, "depth_ladder.price")).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).Grow(1).Basis(0).TextAlign(ui.Center)
ui.Text(c, core.Msg(c, "depth_ladder.ask")).TextColor(k.TextMuted).FontSize(core.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 input.Button(c, core.Msg(c, "depth_ladder.recenter"), input.ButtonOptions{Variant: input.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 := core.Tokens(c)
d := core.Density(c)
price := float64(t) / scale
text := data.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(core.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(core.TrendColor(c, side.sign()).Alpha(0.14))
}
b.Children(func() {
if size > 0 {
ui.Text(c, data.StatisticFormat(size, sizeDec)).TextColor(core.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(core.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)}
}
+115
View File
@@ -0,0 +1,115 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
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(core.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(core.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(core.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(core.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(core.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})
})
})
}
+2
View File
@@ -0,0 +1,2 @@
// Package finance holds MujicaUI quotes, order entry, trading and market views.
package finance
+124
View File
@@ -0,0 +1,124 @@
package finance
import (
"cmp"
"math"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
type EarningsTiming int
const (
EarningsUnknown EarningsTiming = iota
EarningsBeforeOpen
EarningsAfterClose
)
type EarningsEvent struct {
ID, Symbol, Name string
Date core.Date
Timing EarningsTiming
EPSEstimate float64
EPSActual float64
}
// EarningsCalendarOptions configure an EarningsCalendar.
type EarningsCalendarOptions struct {
Version int
Status data.DataStatus
Error string
Disabled bool
}
func EarningsCalendar(c *ui.Context, s *data.DataTableState[string], events []EarningsEvent, opts EarningsCalendarOptions) data.DataTableView {
if s == nil {
panic("mujica: EarningsCalendar needs a state")
}
cols := []data.DataColumn[EarningsEvent]{
{ID: "date", Title: core.Msg(c, "earnings_calendar.date"), Width: 110, Sortable: true,
Compare: func(a, b EarningsEvent) int { return a.Date.Compare(b.Date) },
Text: func(e EarningsEvent) string { return e.Date.String() }},
{ID: "timing", Title: core.Msg(c, "earnings_calendar.timing"), Width: 130, Sortable: true,
Compare: func(a, b EarningsEvent) int { return cmp.Compare(a.Timing, b.Timing) },
Text: func(e EarningsEvent) string { return core.Msg(c, earningsTimings[earningsTimingCheck(e.Timing)].key) },
Cell: func(e EarningsEvent) { earningsTiming(c, e.Timing) }},
{ID: "symbol", Title: core.Msg(c, "earnings_calendar.symbol"), Width: 90, Sortable: true,
Compare: func(a, b EarningsEvent) int { return cmp.Compare(a.Symbol, b.Symbol) },
Text: func(e EarningsEvent) string { return e.Symbol }},
{ID: "name", Title: core.Msg(c, "earnings_calendar.name"), Text: func(e EarningsEvent) string { return e.Name }},
{ID: "estimate", Title: core.Msg(c, "earnings_calendar.estimate"), Width: 90, Align: ui.End, Sortable: true,
Compare: func(a, b EarningsEvent) int { return cmp.Compare(a.EPSEstimate, b.EPSEstimate) },
Text: func(e EarningsEvent) string { return earningsValue(e.EPSEstimate) }},
{ID: "actual", Title: core.Msg(c, "earnings_calendar.actual"), Width: 90, Align: ui.End, Sortable: true,
Compare: func(a, b EarningsEvent) int { return cmp.Compare(a.EPSActual, b.EPSActual) },
Text: func(e EarningsEvent) string { return earningsValue(e.EPSActual) }},
{ID: "surprise", Title: core.Msg(c, "earnings_calendar.surprise"), Width: 150, Align: ui.End,
Text: func(e EarningsEvent) string { return earningsSurpriseText(e) },
Cell: func(e EarningsEvent) {
if d := e.EPSActual - e.EPSEstimate; !math.IsNaN(d) {
tradingSigned(c, d, earningsSurpriseText(e))
} else {
ui.Text(c, "—").TextColor(core.Tokens(c).TextMuted).FillWidth().TextAlign(ui.End)
}
}},
}
var v data.DataTableView
col := ui.Column(c).MinHeight(0).Grow(1).Disabled(opts.Disabled)
opts.Disabled = col.IsDisabled()
if opts.Disabled {
copy := *s
s = &copy
}
col.Children(func() {
v = data.DataTable(c, s, events, data.DataTableOptions[EarningsEvent, string]{Columns: cols, Key: func(e EarningsEvent) string { return e.ID },
Version: opts.Version, Status: opts.Status, Error: opts.Error, Empty: core.Msg(c, "earnings_calendar.empty")})
v.Element.Grow(1)
})
v.Element = col
return v
}
var earningsTimings = [3]struct{ key, icon string }{
{"earnings_calendar.unknown", "clock"}, {"earnings_calendar.before", "sun"}, {"earnings_calendar.after", "moon"},
}
func earningsTimingCheck(t EarningsTiming) EarningsTiming {
if t < EarningsUnknown || t > EarningsAfterClose {
panic("mujica: EarningsTiming out of range")
}
return t
}
// earningsTiming shows the timing as an icon and words.
func earningsTiming(c *ui.Context, t EarningsTiming) {
k := core.Tokens(c)
tm := earningsTimings[earningsTimingCheck(t)]
ui.Row(c).Gap(4).AlignItems(ui.Center).Children(func() {
ui.Icon(c, icons.Must(tm.icon)).Size(14, 14).TextColor(k.TextMuted)
ui.Text(c, core.Msg(c, tm.key)).FontSize(core.FontSize(c, theme.CaptionSize)).SingleLine()
})
}
func earningsValue(v float64) string {
if math.IsNaN(v) {
return "—"
}
return data.StatisticFormat(v, 2)
}
func earningsSurpriseText(e EarningsEvent) string {
d := e.EPSActual - e.EPSEstimate
if math.IsNaN(d) {
return "—"
}
if e.EPSEstimate == 0 {
return data.StatisticSigned(d, 2)
}
return data.StatisticSigned(d, 2) + " (" + data.StatisticSigned(d/math.Abs(e.EPSEstimate)*100, 1) + "%)"
}
+42
View File
@@ -0,0 +1,42 @@
package finance
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
)
func TestEarningsCalendarTimingActualAndKeys(t *testing.T) {
var s data.DataTableState[string]
rows := []EarningsEvent{{ID: "a", Symbol: "AAPL", Date: core.Date{Year: 2026, Month: 10, Day: 30}, Timing: EarningsAfterClose, EPSEstimate: 1.6, EPSActual: 1.64}, {ID: "b", Symbol: "JPM", Date: core.Date{Year: 2026, Month: 10, Day: 11}, Timing: EarningsBeforeOpen, EPSEstimate: 4, EPSActual: math.NaN()}}
tt := chatTester(960, 420, func(c *ui.Context) { EarningsCalendar(c, &s, rows, EarningsCalendarOptions{}).Element.Height(380) })
if !tt.HasText("Before open") || !tt.HasText("After close") || !tt.HasText("+0.04 (+2.5%)") || !tt.HasText("—") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("AAPL")
tt.Key(0, ui.KeyDown)
id, ok := s.Selected()
if !ok || id != "b" {
t.Fatalf("selected %s %v", id, ok)
}
if earningsSurpriseText(EarningsEvent{EPSEstimate: 0, EPSActual: 2}) != "+2.00" {
t.Fatal("zero estimate")
}
marketsPanic(t, func(c *ui.Context) {
EarningsCalendar(c, &s, []EarningsEvent{{ID: "x", Timing: 99}}, EarningsCalendarOptions{})
})
}
func TestEarningsCalendarSnapshot(t *testing.T) {
snapshot(t, "earnings_calendar", 900, 300, func(c *ui.Context) {
es := ui.Local(c.Root(), "snapshot.earnings_calendar", func() data.DataTableState[string] { return data.DataTableState[string]{} })
ui.Column(c).Padding(16).Gap(12).Fill().Children(func() {
EarningsCalendar(c, es, []EarningsEvent{{ID: "1", Symbol: "AAPL", Name: "Apple", Date: core.Date{Year: 2026, Month: 10, Day: 30}, Timing: EarningsAfterClose, EPSEstimate: 1.6, EPSActual: 1.64},
{ID: "2", Symbol: "JPM", Name: "JPMorgan", Date: core.Date{Year: 2026, Month: 10, Day: 11}, Timing: EarningsBeforeOpen, EPSEstimate: 4.0, EPSActual: math.NaN()}}, EarningsCalendarOptions{}).Element.Height(240)
})
})
}
+155
View File
@@ -0,0 +1,155 @@
package finance
import (
"cmp"
"fmt"
"log/slog"
"slices"
"strings"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/chart"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// EconomicEvent is a scheduled release; Actual stays empty until published.
type EconomicEvent struct {
ID, Country, Title string
Time time.Time
// Importance is 1 (low) to 3 (high).
Importance int
Forecast, Previous, Actual string
}
type EconomicCalendarState struct {
Countries []string
// MinImportance hides events below it; zero shows all.
MinImportance int
Table data.DataTableState[string]
cache economicCache
}
type economicCache struct {
key string
rows []EconomicEvent
data string
countries []input.SelectOption[string]
}
// EconomicCalendarOptions configure an EconomicCalendar.
type EconomicCalendarOptions struct {
Version int
Status data.DataStatus
Error string
Disabled bool
}
func EconomicCalendar(c *ui.Context, s *EconomicCalendarState, events []EconomicEvent, opts EconomicCalendarOptions) data.DataTableView {
if s == nil {
panic("mujica: EconomicCalendar needs a state")
}
if s.MinImportance < 0 || s.MinImportance > 3 {
panic("mujica: EconomicCalendar MinImportance must be 0..3")
}
u := core.Density(c).Unit()
var v data.DataTableView
col := ui.Column(c).Gap(u * 2).MinHeight(0).Grow(1).Disabled(opts.Disabled)
opts.Disabled = col.IsDisabled()
if opts.Disabled {
copy := *s
s = &copy
}
col.Children(func() {
if data := chart.ChartID(events) + fmt.Sprint(opts.Version); data != s.cache.data {
s.cache.data, s.cache.countries = data, nil
seen := map[string]bool{}
for _, e := range events {
if e.ID == "" || seen[e.ID] || e.Importance < 1 || e.Importance > 3 {
panic("mujica: EconomicEvent needs an ID and Importance 1..3")
}
seen[e.ID] = true
if !slices.ContainsFunc(s.cache.countries, func(o input.SelectOption[string]) bool { return o.Value == e.Country }) {
s.cache.countries = append(s.cache.countries, input.SelectOption[string]{Value: e.Country, Label: e.Country})
}
}
slices.SortFunc(s.cache.countries, func(a, b input.SelectOption[string]) int { return cmp.Compare(a.Value, b.Value) })
}
countries := s.cache.countries
ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Wrap().Children(func() {
input.MultiSelect(c, &s.Countries, countries, input.MultiSelectOptions{Placeholder: core.Msg(c, "economic_calendar.all_countries"),
Label: core.Msg(c, "economic_calendar.countries"), Disabled: opts.Disabled}).Element.MinWidth(220)
imp := []input.RadioItem[int]{{Value: 0, Label: core.Msg(c, "economic_calendar.all")}, {Value: 2, Label: core.Msg(c, "economic_calendar.medium_up")},
{Value: 3, Label: core.Msg(c, "economic_calendar.high")}}
input.RadioGroup(c, &s.MinImportance, imp, input.RadioGroupOptions{Horizontal: true, Label: core.Msg(c, "economic_calendar.importance"), Disabled: opts.Disabled})
})
rows := economicFilter(s, events, opts.Version)
cols := []data.DataColumn[EconomicEvent]{
{ID: "time", Title: core.Msg(c, "economic_calendar.time"), Width: 130, Sortable: true,
Compare: func(a, b EconomicEvent) int { return a.Time.Compare(b.Time) },
Text: func(e EconomicEvent) string { return e.Time.Format("01-02 15:04") }},
{ID: "country", Title: core.Msg(c, "economic_calendar.country"), Width: 80, Sortable: true,
Compare: func(a, b EconomicEvent) int { return cmp.Compare(a.Country, b.Country) },
Text: func(e EconomicEvent) string { return e.Country }},
{ID: "importance", Title: core.Msg(c, "economic_calendar.importance"), Width: 110, Sortable: true,
Compare: func(a, b EconomicEvent) int { return cmp.Compare(a.Importance, b.Importance) },
Text: func(e EconomicEvent) string {
return core.Msg(c, fmt.Sprintf("economic_calendar.level_%d", e.Importance))
},
Cell: func(e EconomicEvent) { economicImportance(c, e.Importance) }},
{ID: "event", Title: core.Msg(c, "economic_calendar.event"), Text: func(e EconomicEvent) string { return e.Title }},
{ID: "actual", Title: core.Msg(c, "economic_calendar.actual"), Width: 90, Align: ui.End,
Text: func(e EconomicEvent) string { return cmp.Or(e.Actual, "—") },
Cell: func(e EconomicEvent) {
ui.Text(c, cmp.Or(e.Actual, "—")).FontWeight(700).FontFeatures("tnum").JustifySelf(ui.End).FillWidth().TextAlign(ui.End)
}},
{ID: "forecast", Title: core.Msg(c, "economic_calendar.forecast"), Width: 90, Align: ui.End, Text: func(e EconomicEvent) string { return cmp.Or(e.Forecast, "—") }},
{ID: "previous", Title: core.Msg(c, "economic_calendar.previous"), Width: 90, Align: ui.End, Text: func(e EconomicEvent) string { return cmp.Or(e.Previous, "—") }},
}
v = data.DataTable(c, &s.Table, rows, data.DataTableOptions[EconomicEvent, string]{Columns: cols, Key: func(e EconomicEvent) string { return e.ID },
Version: opts.Version, Status: opts.Status, Error: opts.Error, Empty: core.Msg(c, "economic_calendar.empty")})
v.Element.Grow(1)
})
v.Element = col
return v
}
// economicImportance draws one to three diamonds and names the level.
func economicImportance(c *ui.Context, level int) {
k := core.Tokens(c)
word := core.Msg(c, fmt.Sprintf("economic_calendar.level_%d", level))
ui.Row(c).Gap(4).AlignItems(ui.Center).Label(word).Children(func() {
ui.Box(c).Size(34, 10).Draw(func(p *ui.Painter, r ui.Rect) {
for i := range 3 {
d := internal.Diamond(r.X+5+float32(i)*12, r.Y+5, 9)
if i < level {
p.FillPath(d, k.AccentText)
} else {
p.StrokePath(d, 1, k.ControlBorder)
}
}
})
ui.Text(c, word).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
})
}
func economicFilter(s *EconomicCalendarState, events []EconomicEvent, version int) []EconomicEvent {
key := fmt.Sprint(chart.ChartID(events), version, s.MinImportance, strings.Join(s.Countries, "\x00"))
if key == s.cache.key {
return s.cache.rows
}
s.cache.key, s.cache.rows = key, nil
for _, e := range events {
if e.Importance >= s.MinImportance && (len(s.Countries) == 0 || slices.Contains(s.Countries, e.Country)) {
s.cache.rows = append(s.cache.rows, e)
}
}
slog.Debug("mujica: economic_calendar filter", "shown", len(s.cache.rows))
return s.cache.rows
}
+39
View File
@@ -0,0 +1,39 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestEconomicCalendarCountryAndImportance(t *testing.T) {
s := EconomicCalendarState{}
rows := []EconomicEvent{{ID: "us", Country: "US", Title: "Payrolls", Time: mktNow, Importance: 3, Forecast: "150K", Previous: "142K", Actual: "254K"}, {ID: "eu", Country: "EU", Title: "CPI", Time: mktNow, Importance: 1}}
tt := chatTester(960, 420, func(c *ui.Context) { EconomicCalendar(c, &s, rows, EconomicCalendarOptions{}).Element.Height(380) })
if s.Table.Rows() != 2 || !tt.HasText("254K") || !tt.HasText("150K") || !tt.HasText("142K") {
t.Fatalf("rows %d texts %q", s.Table.Rows(), tt.Texts())
}
_ = tt.Click("High")
if s.MinImportance != 3 || s.Table.Rows() != 1 {
t.Fatalf("importance %d rows %d", s.MinImportance, s.Table.Rows())
}
s.MinImportance = 0
s.Countries = []string{"EU"}
tt.Frame()
if s.Table.Rows() != 1 || !tt.HasText("CPI") || tt.HasText("Payrolls") {
t.Fatalf("country %q", tt.Texts())
}
marketsPanic(t, func(c *ui.Context) {
EconomicCalendar(c, &s, []EconomicEvent{{ID: "x", Importance: 4}}, EconomicCalendarOptions{})
})
}
func TestEconomicCalendarSnapshot(t *testing.T) {
snapshot(t, "economic_calendar", 900, 360, func(c *ui.Context) {
ec := ui.Local(c.Root(), "snapshot.economic_calendar", func() EconomicCalendarState { return EconomicCalendarState{} })
ui.Column(c).Padding(16).Gap(12).Fill().Children(func() {
EconomicCalendar(c, ec, []EconomicEvent{{ID: "1", Country: "US", Title: "Nonfarm payrolls", Time: mktNow, Importance: 3, Forecast: "150K", Previous: "142K", Actual: "254K"},
{ID: "2", Country: "EU", Title: "CPI", Time: mktNow, Importance: 2, Forecast: "1.8%", Previous: "2.2%"}}, EconomicCalendarOptions{}).Element.Height(300)
})
})
}
+247
View File
@@ -0,0 +1,247 @@
package finance_test
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/chart"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/finance"
)
func ExampleOptionChain() {
var state finance.OptionChainState
rows := []finance.OptionStrike{{Strike: 100, Call: finance.OptionQuote{Bid: 3, Ask: 3.2, Last: 3.1}, Put: finance.OptionQuote{Bid: 2, Ask: 2.2, Last: 2.1}}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := finance.OptionChain(c, &state, []string{"Oct 17", "Nov 21"}, rows, finance.OptionChainOptions{Spot: 100.4})
if r.Changed || r.Submitted || r.ExpiryChanged {
fmt.Println(state.Expiry, state.Strike, state.Side)
}
}
ui.Render(view, 960, 440, 1)
}
func ExampleGreeksTable() {
rows := []finance.GreeksRow{{Label: "AAPL 100 C", Greeks: finance.OptionGreeks{Delta: 0.5, Gamma: 0.03, Theta: -0.1, Vega: 0.2, Rho: 0.05}}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
finance.GreeksTable(c, rows, finance.GreeksTableOptions{Notes: true})
}
ui.Render(view, 960, 440, 1)
}
func ExamplePayoffDiagram() {
legs := []finance.OptionLeg{{Kind: finance.LegCall, Strike: 100, Premium: 3, Quantity: 1}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := finance.PayoffDiagram(c, legs, finance.PayoffDiagramOptions{Frame: chart.ChartFrame{Label: "Call payoff"}, Spot: 103})
if r.Changed() || r.Retried() {
fmt.Println(r.Point)
}
}
ui.Render(view, 960, 440, 1)
}
func ExampleWatchlist() {
var state finance.WatchlistState
groups := []finance.WatchGroup{{Name: "Technology", Items: []finance.WatchItem{{Symbol: "AAPL", Name: "Apple", Price: 227.5, Change: 2.7, ChangePercent: 1.2}}}, {Name: "Energy", Items: []finance.WatchItem{{Symbol: "XOM", Price: 117.4}}}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := finance.Watchlist(c, &state, groups, finance.WatchlistOptions{})
if r.Changed || r.Submitted || r.Moved {
fmt.Println(state.Selected, r.Rows, r.To)
}
}
ui.Render(view, 960, 440, 1)
}
func ExampleSymbolSearch() {
var state finance.SymbolSearchState
rows := []finance.MarketSymbol{{Code: "AAPL", Name: "Apple", Market: "NASDAQ", Type: "Stock"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := finance.SymbolSearch(c, &state, rows, finance.SymbolSearchOptions{Markets: []string{"NASDAQ", "NYSE"}, Recent: rows})
if r.Changed || r.Picked || r.Retry {
fmt.Println(state.Query, state.Market, r.Chosen.Code)
}
}
ui.Render(view, 960, 440, 1)
}
func ExampleSymbolBadge() {
symbol := finance.MarketSymbol{Code: "AAPL", Name: "Apple", Market: "NASDAQ", Type: "Stock"}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := finance.SymbolBadge(c, symbol, finance.SymbolBadgeOptions{Clickable: true})
if r.Changed() {
fmt.Println(symbol.Code)
}
}
ui.Render(view, 960, 440, 1)
}
func ExampleScreener() {
var state finance.ScreenerState
fields := []finance.ScreenerField{{ID: "pe", Label: "P/E", Decimals: 1}}
rows := []finance.ScreenerRow{{Symbol: "AAPL", Values: map[string]float64{"pe": 34}}, {Symbol: "XOM", Values: map[string]float64{"pe": 13}}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := finance.Screener(c, &state, fields, rows, finance.ScreenerOptions{})
if r.Changed || r.Save || r.Table.Changed() || r.Table.Submitted() || r.Table.SortChanged() || r.Table.Retried() {
fmt.Println(state.Conditions)
}
}
ui.Render(view, 960, 440, 1)
}
func ExampleMarketOverview() {
indices := []finance.IndexQuote{{Name: "S&P 500", Price: 5751.07, Change: 51.13, ChangePercent: 0.9, Series: []float64{5700, 5730, 5720, 5751}}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := finance.MarketOverview(c, indices, finance.MarketOverviewOptions{Breadth: finance.MarketBreadth{Advancing: 2100, Declining: 1400, Unchanged: 300}})
if r.Chosen >= 0 {
fmt.Println(indices[r.Chosen].Name)
}
}
ui.Render(view, 960, 440, 1)
}
func ExampleIndexCard() {
quote := finance.IndexQuote{Name: "S&P 500", Price: 5751.07, Change: 51.13, ChangePercent: 0.9, Series: []float64{5700, 5730, 5720, 5751}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := finance.IndexCard(c, quote, finance.IndexCardOptions{Clickable: true})
if r.Changed() {
fmt.Println(quote.Name)
}
}
ui.Render(view, 960, 440, 1)
}
func ExampleMarketStatus() {
now := time.Date(2026, 10, 5, 14, 30, 0, 0, time.UTC)
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
finance.MarketStatus(c, finance.MarketOpen, finance.MarketStatusOptions{Market: "NYSE", Now: now, Next: now.Add(5 * time.Hour), NextPhase: finance.MarketAfterHours})
}
ui.Render(view, 960, 440, 1)
}
func ExampleTradingSessionClock() {
now := time.Date(2026, 10, 5, 14, 30, 0, 0, time.UTC)
markets := []finance.SessionMarket{{Name: "NYSE", Location: time.FixedZone("EDT", -4*3600), Sessions: []finance.TradingSession{{Phase: finance.MarketOpen, Start: 570, End: 960}}}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
finance.TradingSessionClock(c, markets, finance.TradingSessionClockOptions{Now: now})
}
ui.Render(view, 960, 440, 1)
}
func ExampleEconomicCalendar() {
var state finance.EconomicCalendarState
rows := []finance.EconomicEvent{{ID: "jobs", Country: "US", Title: "Payrolls", Time: time.Date(2026, 10, 5, 14, 30, 0, 0, time.UTC), Importance: 3, Forecast: "150K", Previous: "142K", Actual: "254K"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := finance.EconomicCalendar(c, &state, rows, finance.EconomicCalendarOptions{})
if r.Changed() || r.Submitted() || r.SortChanged() || r.Retried() {
fmt.Println(state.Table.Selected())
}
}
ui.Render(view, 960, 440, 1)
}
func ExampleEarningsCalendar() {
var state data.DataTableState[string]
rows := []finance.EarningsEvent{{ID: "a", Symbol: "AAPL", Name: "Apple", Date: core.Date{Year: 2026, Month: 10, Day: 30}, Timing: finance.EarningsAfterClose, EPSEstimate: 1.6, EPSActual: 1.64}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := finance.EarningsCalendar(c, &state, rows, finance.EarningsCalendarOptions{})
if r.Changed() || r.Submitted() || r.SortChanged() || r.Retried() {
fmt.Println(state.Selected())
}
}
ui.Render(view, 960, 440, 1)
}
func ExampleNewsFeed() {
var state finance.NewsFeedState
rows := []finance.NewsItem{{ID: "a", Title: "Apple unveils new chips", Source: "Wire", Time: time.Date(2026, 10, 5, 14, 30, 0, 0, time.UTC), Symbols: []string{"AAPL"}}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := finance.NewsFeed(c, &state, rows, finance.NewsFeedOptions{})
if r.Opened != "" || r.Symbol != "" || r.Retry {
fmt.Println(r.Opened, r.Symbol)
}
}
ui.Render(view, 960, 440, 1)
}
func ExampleSentimentGauge() {
value := 72.0
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
finance.SentimentGauge(c, value, finance.SentimentGaugeOptions{Label: "Fear & Greed"})
}
ui.Render(view, 960, 440, 1)
}
func ExamplePortfolioSummary() {
account := ""
accounts := []finance.PortfolioAccount{{ID: "a", Name: "Brokerage", Total: 120000, DayPnL: 1250, TotalReturn: 20000, Cost: 100000}, {ID: "b", Name: "Retirement", Total: 80000, DayPnL: -300, TotalReturn: 5000, Cost: 75000}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := finance.PortfolioSummary(c, &account, accounts, finance.PortfolioSummaryOptions{})
if r.Changed() {
fmt.Println(account)
}
}
ui.Render(view, 960, 440, 1)
}
func ExampleAssetAllocationChart() {
items := []finance.AllocationItem{{Name: "Stocks", Value: 60, Target: 50}, {Name: "Bonds", Value: 30, Target: 40}, {Name: "Cash", Value: 10, Target: 10}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := finance.AssetAllocationChart(c, items, finance.AssetAllocationChartOptions{Frame: chart.ChartFrame{Label: "Allocation"}})
if r.Changed() || r.Retried() {
fmt.Println(r.Point)
}
}
ui.Render(view, 960, 440, 1)
}
func ExamplePerformanceChart() {
values := []float64{100, 110, 95, 120}
benchmark := []float64{100, 105, 108, 112}
times := []time.Time{time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := finance.PerformanceChart(c, values, finance.PerformanceChartOptions{Frame: chart.ChartFrame{Label: "Performance"}, Times: times, Benchmark: benchmark})
if r.Changed() || r.Retried() {
fmt.Println(r.Point)
}
if name, ok := r.Toggled(); ok {
fmt.Println(name)
}
}
ui.Render(view, 960, 440, 1)
}
func ExampleCurrencyConverter() {
state := finance.CurrencyConverterState{Amount: 100, From: "USD", To: "JPY"}
currencies := []finance.Currency{{Code: "USD", Name: "Dollar", Decimals: 2}, {Code: "JPY", Name: "Yen", Decimals: 0}}
rate := 149.37
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := finance.CurrencyConverter(c, &state, currencies, finance.CurrencyConverterOptions{Rate: rate})
if r.PairChanged {
rate = 0
fmt.Println(state.From, state.To)
}
}
ui.Render(view, 960, 440, 1)
}
+223
View File
@@ -0,0 +1,223 @@
package finance_test
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/finance"
)
func ExampleTickerTape() {
items := []finance.TickerItem{{Symbol: "MUJC", Price: 412.08, ChangePercent: -1.35}, {Symbol: "AVE", Price: 88.2, ChangePercent: 2.1}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
finance.TickerTape(c, items, finance.TickerTapeOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleQuoteCard() {
quote := finance.Quote{Symbol: "MUJC", Name: "Ave Mujica Holdings", Price: 412.08, Change: -5.62, ChangePercent: -1.35}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
finance.QuoteCard(c, quote, finance.QuoteCardOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExamplePriceText() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
core.UseTrend(c, core.RedUp)
finance.PriceText(c, 18234.5, finance.PriceTextOptions{Compare: true, Reference: 18000})
}
ui.Render(view, 400, 300, 1)
}
func ExamplePriceChangeBadge() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
finance.PriceChangeBadge(c, -5.62, -1.35, finance.PriceChangeBadgeOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleOrderBook() {
var state finance.OrderBookState
book := finance.OrderBookData{
Bids: []finance.BookLevel{{Price: 100.00, Size: 5}, {Price: 99.99, Size: 3}},
Asks: []finance.BookLevel{{Price: 100.01, Size: 1}, {Price: 100.02, Size: 2}},
}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if price, side, ok := finance.OrderBook(c, &state, book, finance.OrderBookOptions{Groups: []float64{0.01, 0.05}}).Clicked(); ok {
fmt.Println("order at", price, side)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleLevel2Quotes() {
data := finance.Level2Data{
Bids: []finance.L2Level{{Price: 100.00, Size: 500, Orders: 12}},
Asks: []finance.L2Level{{Price: 100.01, Size: 200, Orders: 3}},
}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
finance.Level2Quotes(c, data, finance.Level2QuotesOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleTimeAndSales() {
var state finance.TimeAndSalesState
trades := []finance.TradePrint{{ID: "1", Time: time.Now(), Price: 100.01, Size: 300, Side: finance.SideBuy}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
finance.TimeAndSales(c, &state, trades, finance.TimeAndSalesOptions{LargeSize: 1000})
}
ui.Render(view, 400, 300, 1)
}
func ExampleDepthLadder() {
var state finance.DepthLadderState
book := finance.OrderBookData{Bids: []finance.BookLevel{{Price: 100, Size: 5}}, Asks: []finance.BookLevel{{Price: 100.01, Size: 2}}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if price, side, ok := finance.DepthLadder(c, &state, 100, book, finance.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) {
core.Use(c, core.Settings{})
finance.SpreadIndicator(c, 99.95, 100.05, finance.SpreadIndicatorOptions{WarnPercent: 0.5})
}
ui.Render(view, 400, 300, 1)
}
func ExampleBidAskBar() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
finance.BidAskBar(c, 6234, 3820, finance.BidAskBarOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleOrderEntry() {
var state finance.OrderEntryState
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if order, ok := finance.OrderEntry(c, &state, finance.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) {
core.Use(c, core.Settings{})
if side, q, ok := finance.QuickTradeButtons(c, &quantity, finance.QuickTradeButtonsOptions{Symbol: "MUJC", Confirm: true}).Traded(); ok {
fmt.Println(side, q)
}
}
ui.Render(view, 400, 300, 1)
}
func ExamplePositionTable() {
var state data.DataTableState[string]
rows := []finance.PositionRow{{ID: "p1", Symbol: "MUJC", Quantity: 100, AvgCost: 400, Price: 412.08}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if id, ok := finance.PositionTable(c, &state, rows, finance.PositionTableOptions{}).Acted(); ok {
fmt.Println("close", id)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleOrderTable() {
var state finance.OrderTableState
rows := []finance.OrderRow{{ID: "o1", Symbol: "MUJC", Side: finance.SideBuy, Price: 99.5, Quantity: 100, Filled: 40, Status: finance.OrderPartial}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if id, ok := finance.OrderTable(c, &state, rows, finance.OrderTableOptions{}).Acted(); ok {
fmt.Println("cancel", id)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleTradeHistoryTable() {
var state finance.TradeHistoryTableState
rows := []finance.FillRow{{ID: "f1", Time: time.Now(), Symbol: "MUJC", Side: finance.SideBuy, Price: 400, Quantity: 10, Fee: 1.2}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if fills, ok := finance.TradeHistoryTable(c, &state, rows, finance.TradeHistoryTableOptions{}).Exported(); ok {
fmt.Println("export", len(fills))
}
}
ui.Render(view, 400, 300, 1)
}
func ExamplePnLDisplay() {
hidden := false
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
finance.PnLDisplay(c, finance.PnL{Realized: 1200.5, Unrealized: -340.2, Today: 88}, &hidden, finance.PnLDisplayOptions{Currency: "$"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleLeverageSlider() {
leverage := 5.0
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if finance.LeverageSlider(c, &leverage, finance.LeverageSliderOptions{Warning: 10, Danger: 50}).Changed() {
fmt.Println("leverage", leverage)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleMarginIndicator() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
finance.MarginIndicator(c, 0.86, finance.MarginIndicatorOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleRiskMeter() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
finance.RiskMeter(c, 4, finance.RiskMeterOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleOrderConfirmDialog() {
open := true
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := finance.OrderConfirmDialog(c, &open, finance.OrderConfirmDialogOptions{
Lines: []finance.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)
}
+92
View File
@@ -0,0 +1,92 @@
package finance
import (
"cmp"
"math"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// OptionGreeks are an option's sensitivities.
type OptionGreeks struct {
Delta, Gamma, Theta, Vega, Rho float64
}
// GreeksRow is a labeled contract's greeks; NaN shows a dash.
type GreeksRow struct {
Label string
Greeks OptionGreeks
}
// GreeksTableOptions configure a GreeksTable.
type GreeksTableOptions struct {
// Decimals per value; zero means 4.
Decimals int
// Notes shows what each greek means under the table.
Notes bool
}
var greekNames = [5]string{"Delta", "Gamma", "Theta", "Vega", "Rho"}
func GreeksTable(c *ui.Context, rows []GreeksRow, opts GreeksTableOptions) *ui.Element {
if opts.Decimals < 0 || opts.Decimals > 12 {
panic("mujica: GreeksTable Decimals is negative")
}
dec := cmp.Or(opts.Decimals, 4)
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
col := ui.Column(c).Gap(u * 2)
col.Children(func() {
g := ui.Grid(c).ColumnTracks(ui.Fr(2), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1)).
GapX(u*2).Padding(u*2, u*3).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).
Role(ui.RoleTable).Label(core.Msg(c, "greeks_table.title"))
g.Children(func() {
ui.Text(c, core.Msg(c, "greeks_table.contract")).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).
MinHeight(d.ControlHeight()).Role(ui.RoleColumnHeader)
for i, name := range greekNames {
h := ui.Row(c).Key(name).Gap(2).AlignItems(ui.Center).Justify(ui.End).MinHeight(d.ControlHeight()).
Role(ui.RoleColumnHeader).Label(name).Description(core.Msg(c, "greeks_table.note_"+greekKey(i)))
h.Tooltip(core.Msg(c, "greeks_table.note_"+greekKey(i)))
h.Children(func() {
ui.Text(c, name).SingleLine().Font(theme.SerifFont).TextColor(k.Text)
ui.Icon(c, icons.Must("info")).Size(12, 12).TextColor(k.TextMuted)
})
}
})
state := ui.Local(col, "mujica.greeks.rows", func() ui.ListState { return ui.ListState{} })
ui.List(c, state, len(rows), func(ri int) {
ui.Grid(c).ColumnTracks(ui.Fr(2), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1)).GapX(u*2).Padding(0, u*3).MinHeight(d.ControlHeight()).Children(func() {
r := rows[ri]
vals := [5]float64{r.Greeks.Delta, r.Greeks.Gamma, r.Greeks.Theta, r.Greeks.Vega, r.Greeks.Rho}
ui.Text(c, r.Label).Key(ri).SingleLine().MinHeight(d.ControlHeight()).Role(ui.RoleCell)
for i, v := range vals {
s := "—"
if !math.IsNaN(v) {
s = data.StatisticSigned(v, dec)
}
ui.Text(c, s).Key([2]int{ri, i}).JustifySelf(ui.End).FontFeatures("tnum").TextColor(k.Text).
Role(ui.RoleCell).Label(greekNames[i] + " " + s)
}
})
}).Height(min(float32(len(rows))*d.ControlHeight(), 240)).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius)
if opts.Notes {
ui.Column(c).Gap(u).Children(func() {
for i, name := range greekNames {
ui.RichText(c, ui.Span{Text: name + " ", Weight: 700}, ui.Span{Text: core.Msg(c, "greeks_table.note_"+greekKey(i))}).
FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
}
})
}
})
return col
}
func greekKey(i int) string { return [5]string{"delta", "gamma", "theta", "vega", "rho"}[i] }
+56
View File
@@ -0,0 +1,56 @@
package finance
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func greeksRows() []GreeksRow {
return []GreeksRow{
{Label: "AAPL 230 C", Greeks: OptionGreeks{0.52, 0.031, -0.12, 0.21, 0.05}},
{Label: "AAPL 220 P", Greeks: OptionGreeks{-0.38, 0.028, -0.10, 0.19, math.NaN()}},
}
}
func TestGreeksTableValuesAndPrecision(t *testing.T) {
tt := chatTester(640, 320, func(c *ui.Context) { GreeksTable(c, greeksRows(), GreeksTableOptions{}) })
for _, s := range []string{"Delta", "Rho", "+0.5200", "−0.1200", "+0.0310", "—"} {
if !tt.HasText(s) {
t.Errorf("%q missing from %q", s, tt.Texts())
}
}
tt = chatTester(640, 320, func(c *ui.Context) { GreeksTable(c, greeksRows(), GreeksTableOptions{Decimals: 2}) })
if !tt.HasText("+0.52") || tt.HasText("+0.5200") {
t.Fatalf("decimals: %q", tt.Texts())
}
}
func TestGreeksTableNotes(t *testing.T) {
tt := chatTester(640, 320, func(c *ui.Context) { GreeksTable(c, greeksRows(), GreeksTableOptions{}) })
if tt.HasText("Price change per day as time passes.") {
t.Fatal("notes shown without Notes")
}
tt = chatTester(640, 420, func(c *ui.Context) { GreeksTable(c, greeksRows(), GreeksTableOptions{Notes: true}) })
if !tt.HasText("Price change per day as time passes.") {
t.Fatal("notes missing")
}
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.Render(func(c *ui.Context) {
core.Use(c, core.Settings{})
GreeksTable(c, nil, GreeksTableOptions{Decimals: -1})
}, 100, 100, 1)
}
func TestGreeksTableSnapshot(t *testing.T) {
snapshot(t, "greeks_table", 640, 300, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { GreeksTable(c, greeksRows(), GreeksTableOptions{Notes: true}) })
})
}
+31
View File
@@ -0,0 +1,31 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal/snaptest"
)
func chatTester(w, h int, view func(c *ui.Context)) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(10).Fill().Children(func() { view(c) })
}, w, h)
}
// snapshot compares view in both themes with testdata/snapshots/<os>/<name>-<mode>.png.
func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
t.Helper()
snaptest.Run(t, name, w, h, snapshotTheme, view, nil)
}
func snapshotTheme(c *ui.Context, mode string) {
m := core.Light
if mode == "dark" {
m = core.Dark
}
core.Use(c, core.Settings{Mode: m})
}
+68
View File
@@ -0,0 +1,68 @@
package finance
import (
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/chart"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
type IndexQuote struct {
Name string
Price float64
Change float64
ChangePercent float64
// Series is recent levels; empty hides the chart.
Series []float64
}
// IndexCardOptions configure an IndexCard.
type IndexCardOptions struct {
// Decimals of the level and change; zero means 2.
Decimals int
// Clickable makes the card a button.
Clickable bool
Disabled bool
}
func IndexCard(c *ui.Context, q IndexQuote, opts IndexCardOptions) core.ChoiceResult {
if q.Name == "" {
panic("mujica: IndexCard needs a Name")
}
dec := core.PriceDecimals("IndexCard", opts.Decimals)
k := core.Tokens(c)
u := core.Density(c).Unit()
var e *ui.Element
ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() {
if opts.Clickable {
e = ui.ButtonBase(c).Justify(ui.Start)
internal.Ring(e, k.Focus, theme.CardRadius)
} else {
e = ui.Row(c).Role(ui.RoleGroup)
}
})
e.Column().AlignItems(ui.Stretch).Gap(u).Padding(u*3).Width(200).Radius(theme.CardRadius).Background(k.Surface).
Label(q.Name).Border(theme.BorderWidth, k.Border)
if opts.Clickable && e.Hovered() {
e.Border(theme.BorderWidth, k.ControlBorder).Background(k.SurfaceHover)
}
e.Children(func() {
ui.Text(c, q.Name).Font(theme.SerifFont).TextColor(k.TextMuted).SingleLine()
PriceText(c, q.Price, PriceTextOptions{Decimals: dec, Size: theme.H3Size})
ui.Row(c).Children(func() {
PriceChangeBadge(c, q.Change, q.ChangePercent, PriceChangeBadgeOptions{Decimals: dec})
})
if len(q.Series) > 0 {
chart.Sparkline(c, q.Series, chart.SparklineOptions{Frame: chart.ChartFrame{Label: q.Name}, Width: 120})
}
})
clicked := opts.Clickable && e.Clicked() && !e.IsDisabled()
if clicked {
slog.Debug("mujica: index_card click", "name", q.Name)
}
return core.NewChoiceResult(e, clicked)
}
+40
View File
@@ -0,0 +1,40 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestIndexCardQuoteAndKeyboard(t *testing.T) {
count := 0
disabled := false
q := IndexQuote{Name: "Index", Price: 5751.07, Change: 51.13, ChangePercent: 0.9, Series: []float64{100, 110, 105}}
tt := chatTester(500, 300, func(c *ui.Context) {
if IndexCard(c, q, IndexCardOptions{Clickable: true, Disabled: disabled}).Changed() {
count++
}
})
if !tt.HasText("5,751.07") || !tt.HasText("+51.13 (+0.90%)") {
t.Fatalf("texts %q", tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if count != 1 {
t.Fatalf("count %d", count)
}
disabled = true
tt.Frame()
_ = tt.Click("Index")
if count != 1 {
t.Fatal("disabled click")
}
marketsPanic(t, func(c *ui.Context) { IndexCard(c, IndexQuote{}, IndexCardOptions{}) })
}
func TestIndexCardSnapshot(t *testing.T) {
snapshot(t, "index_card", 260, 220, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
IndexCard(c, IndexQuote{Name: "S&P 500", Price: 5751.07, Change: 51.13, ChangePercent: 0.9, Series: mktSeries(30, 0)}, IndexCardOptions{})
})
})
}
+125
View File
@@ -0,0 +1,125 @@
package finance
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/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 := core.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 := core.Density(c).Unit()
e := ui.Row(c).Gap(u * 3).AlignItems(ui.Start).Role(ui.RoleTable).Label(core.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 := core.Tokens(c)
d := core.Density(c)
name := fmt.Sprintf(core.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(core.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 = core.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 := core.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 := core.TrendColor(c, side.sign())
sizeText := data.StatisticFormat(l.Size, sizeDec)
orders := fmt.Sprintf(core.Msg(c, "level2_quotes.orders"), l.Orders)
row.Role(ui.RoleRow).Label(name + " " + data.StatisticFormat(l.Price, dec) + " " + sizeText + " " + orders)
row.Children(func() {
ui.Text(c, name).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).Width(28)
ui.Text(c, data.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, core.TrendIcon(m.dir)).Size(12, 12).TextColor(core.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()
})
}
+65
View File
@@ -0,0 +1,65 @@
package finance
import (
"bytes"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
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(core.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(core.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{}) })
})
}
+162
View File
@@ -0,0 +1,162 @@
package finance
import (
"fmt"
"log/slog"
"math"
"slices"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/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) core.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 := core.Tokens(c)
d := core.Density(c)
label := opts.Label
if label == "" {
label = core.Msg(c, "leverage_slider.label")
}
var r core.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 core.Msg(c, "leverage_slider.danger"), k.Danger, "circle-alert"
case opts.Warning > 0 && v >= opts.Warning:
return core.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(core.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(core.FontSize(c, theme.CaptionSize)).FontWeight(600).Role(ui.RoleStatus)
}
})
if input.Button(c, core.Msg(c, "leverage_slider.apply"), input.ButtonOptions{Disabled: disabled || st.draft == *value}).Clicked() && !disabled {
*value, st.seen, r = st.draft, st.draft, core.NewChoiceResult(r.Element, true)
slog.Debug("mujica: leverage_slider applied", "leverage", *value)
}
})
})
return r
}
func leverageText(v float64) string { return data.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 := core.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(core.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 := core.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)
}
})
}
+95
View File
@@ -0,0 +1,95 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/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) {
core.Use(c, core.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})
})
})
}
+73
View File
@@ -0,0 +1,73 @@
package finance
import (
"fmt"
"math"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
u := core.Density(c).Unit()
label := opts.Label
if label == "" {
label = core.Msg(c, "margin_indicator.label")
}
status, col, icon := core.Msg(c, "margin_indicator.safe"), k.Success, "circle-check"
switch {
case ratio >= liq:
status, col, icon = core.Msg(c, "margin_indicator.liquidation"), k.Danger, "circle-x"
case ratio >= warn:
status, col, icon = core.Msg(c, "margin_indicator.warning"), k.Warning, "triangle-alert"
}
pct := func(v float64) string { return data.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(core.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(core.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(core.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, core.Msg(c, "margin_indicator.warning_line")+" "+pct(warn)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
ui.Text(c, core.Msg(c, "margin_indicator.liquidation_line")+" "+pct(liq)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
})
})
return e
}
+41
View File
@@ -0,0 +1,41 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestMarginIndicatorStates(t *testing.T) {
ratio := 0.42
tt := tradingTester(core.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(core.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{}) })
})
}
+93
View File
@@ -0,0 +1,93 @@
package finance
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// MarketBreadth counts the issues that rose, fell and stayed flat.
type MarketBreadth struct{ Advancing, Declining, Unchanged int }
// MarketOverviewOptions configure a MarketOverview.
type MarketOverviewOptions struct {
Breadth MarketBreadth
AsOf time.Time
Decimals int
Disabled bool
}
type MarketOverviewView struct {
Element *ui.Element
Chosen int
}
func MarketOverview(c *ui.Context, indices []IndexQuote, opts MarketOverviewOptions) MarketOverviewView {
b := opts.Breadth
if b.Advancing < 0 || b.Declining < 0 || b.Unchanged < 0 {
panic("mujica: MarketOverview breadth counts must not be negative")
}
k := core.Tokens(c)
u := core.Density(c).Unit()
v := MarketOverviewView{Chosen: -1}
v.Element = ui.Column(c).Gap(u * 3).Disabled(opts.Disabled)
v.Element.Children(func() {
ui.Row(c).Wrap().Gap(u * 3).Children(func() {
for i, q := range indices {
ui.Column(c).Key(q.Name).Children(func() {
if IndexCard(c, q, IndexCardOptions{Decimals: opts.Decimals, Clickable: true, Disabled: opts.Disabled}).Changed() {
v.Chosen = i
}
})
}
})
if total := b.Advancing + b.Declining + b.Unchanged; total > 0 {
marketBreadthBar(c, b, total)
}
if !opts.AsOf.IsZero() {
ui.Text(c, fmt.Sprintf(core.Msg(c, "market_overview.as_of"), opts.AsOf.Format("2006-01-02 15:04"))).
FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
}
})
if v.Chosen >= 0 {
slog.Debug("mujica: market_overview choose", "index", indices[v.Chosen].Name)
}
return v
}
func marketBreadthBar(c *ui.Context, b MarketBreadth, total int) {
k := core.Tokens(c)
u := core.Density(c).Unit()
up, down := core.TrendColor(c, 1), core.TrendColor(c, -1)
label := fmt.Sprintf(core.Msg(c, "market_overview.breadth"), b.Advancing, b.Declining, b.Unchanged)
ui.Column(c).Gap(u).MaxWidth(640).FillWidth().Role(ui.RoleMeter).Label(label).Children(func() {
ui.Box(c).FillWidth().Height(10).Draw(func(p *ui.Painter, r ui.Rect) {
x := r.X
for _, part := range []struct {
n int
col ui.Color
}{{b.Advancing, up}, {b.Unchanged, k.Border}, {b.Declining, down}} {
w := r.W * float32(part.n) / float32(total)
p.Fill(ui.Rect{X: x, Y: r.Y, W: max(w-1, 0), H: r.H}, part.col, 1)
x += w
}
})
ui.Row(c).Gap(u * 4).Children(func() {
for _, part := range []struct {
n int
sign int
key string
}{{b.Advancing, 1, "market_overview.up"}, {b.Unchanged, 0, "market_overview.flat"}, {b.Declining, -1, "market_overview.down"}} {
ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(core.TrendColor(c, part.sign)).Children(func() {
ui.Icon(c, core.TrendIcon(part.sign)).Size(12, 12)
ui.Text(c, fmt.Sprintf(core.Msg(c, part.key), part.n)).FontSize(core.FontSize(c, theme.CaptionSize)).FontFeatures("tnum")
})
}
})
})
}
+46
View File
@@ -0,0 +1,46 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestMarketOverviewBreadthStampAndChoice(t *testing.T) {
var got MarketOverviewView
disabled := false
tt := chatTester(760, 360, func(c *ui.Context) {
v := MarketOverview(c, []IndexQuote{{Name: "S&P 500", Price: 5751.07, Change: 51.13, ChangePercent: 0.9}}, MarketOverviewOptions{Breadth: MarketBreadth{2100, 1400, 300}, AsOf: mktNow, Disabled: disabled})
if v.Chosen >= 0 {
got = v
}
})
if !tt.HasText("2100 up") || !tt.HasText("1400 down") || !tt.HasText("As of 2026-10-05 14:30") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("S&P 500")
if got.Chosen != 0 {
t.Fatalf("chosen %d", got.Chosen)
}
got.Chosen = -1
disabled = true
tt.Frame()
_ = tt.Click("S&P 500")
if got.Chosen != -1 {
t.Fatal("disabled chose")
}
marketsPanic(t, func(c *ui.Context) {
MarketOverview(c, nil, MarketOverviewOptions{Breadth: MarketBreadth{Advancing: -1}})
})
}
func TestMarketOverviewSnapshot(t *testing.T) {
snapshot(t, "market_overview", 760, 360, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(12).Fill().Children(func() {
MarketOverview(c, []IndexQuote{{Name: "S&P 500", Price: 5751.07, Change: 51.13, ChangePercent: 0.9, Series: mktSeries(30, 0)},
{Name: "Nasdaq", Price: 18137.85, Change: -219.22, ChangePercent: -1.2, Series: mktSeries(30, 2)}},
MarketOverviewOptions{Breadth: MarketBreadth{2100, 1400, 300}, AsOf: mktNow})
})
})
}
+98
View File
@@ -0,0 +1,98 @@
package finance
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
type MarketPhase int
const (
MarketClosed MarketPhase = iota
MarketPreMarket
MarketOpen
MarketAfterHours
MarketHoliday
)
var marketPhases = [5]struct{ key, icon string }{
{"market_status.closed", "lock"}, {"market_status.pre", "sun"}, {"market_status.open", "circle-check"},
{"market_status.after", "moon"}, {"market_status.holiday", "calendar"},
}
// MarketStatusOptions configure a MarketStatus.
type MarketStatusOptions struct {
// Market names the venue, such as "NYSE".
Market string
Next time.Time
NextPhase MarketPhase
// Now counts down to Next; zero uses the window clock.
Now time.Time
}
func MarketStatus(c *ui.Context, phase MarketPhase, opts MarketStatusOptions) *ui.Element {
marketPhaseCheck(phase)
marketPhaseCheck(opts.NextPhase)
k := core.Tokens(c)
u := core.Density(c).Unit()
now := opts.Now
if now.IsZero() {
now = c.Now()
}
word := core.Msg(c, marketPhases[phase].key)
ink := k.TextMuted
if phase == MarketOpen {
ink = k.Success
}
label := word
if opts.Market != "" {
label = opts.Market + ": " + word
}
next := ""
if !opts.Next.IsZero() {
next = fmt.Sprintf(core.Msg(c, "market_status.next"), core.Msg(c, marketPhases[opts.NextPhase].key), opts.Next.Format("15:04"),
marketStatusIn(c, opts.Next.Sub(now)))
label += ", " + next
}
row := ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Role(ui.RoleStatus).Label(label)
row.Children(func() {
ui.Row(c).Gap(u).AlignItems(ui.Center).Padding(u/2, u*2).Radius(theme.ControlRadius).Border(theme.BorderWidth, ink).Children(func() {
ui.Icon(c, icons.Must(marketPhases[phase].icon)).Size(14, 14).TextColor(ink)
if opts.Market != "" {
ui.Text(c, opts.Market).Bold().FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.Text).SingleLine()
}
ui.Text(c, word).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(ink).FontWeight(600).SingleLine()
})
if next != "" {
ui.Text(c, next).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
}
})
if opts.Now.IsZero() && opts.Next.After(now) {
row.DrawOver(func(p *ui.Painter, _ ui.Rect) { c.After(time.Minute - time.Duration(p.Now().Second())*time.Second) })
}
return row
}
func marketPhaseCheck(p MarketPhase) {
if p < MarketClosed || p > MarketHoliday {
panic("mujica: MarketPhase out of range")
}
}
func marketStatusIn(c *ui.Context, d time.Duration) string {
if d <= 0 {
return core.Msg(c, "market_status.now")
}
m := int(d.Round(time.Minute) / time.Minute)
if m >= 60 {
return fmt.Sprintf(core.Msg(c, "market_status.hm"), m/60, m%60)
}
return fmt.Sprintf(core.Msg(c, "market_status.m"), max(m, 1))
}
+30
View File
@@ -0,0 +1,30 @@
package finance
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func TestMarketStatusPhasesAndNext(t *testing.T) {
for phase, word := range []string{"Closed", "Pre-market", "Open", "After hours", "Holiday"} {
tt := chatTester(760, 150, func(c *ui.Context) {
MarketStatus(c, MarketPhase(phase), MarketStatusOptions{Market: "NYSE", Now: mktNow, Next: mktNow.Add(90 * time.Minute), NextPhase: MarketAfterHours})
})
if !tt.HasText(word) || !tt.HasText("After hours at 16:00 (in 1h 30m)") {
t.Fatalf("phase %d texts %q", phase, tt.Texts())
}
}
marketsPanic(t, func(c *ui.Context) { MarketStatus(c, MarketPhase(99), MarketStatusOptions{}) })
}
func TestMarketStatusSnapshot(t *testing.T) {
snapshot(t, "market_status", 520, 160, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(12).Fill().Children(func() {
MarketStatus(c, MarketOpen, MarketStatusOptions{Market: "NYSE", Next: mktNow.Add(5*3600e9 + 30*60e9), NextPhase: MarketAfterHours, Now: mktNow})
MarketStatus(c, MarketHoliday, MarketStatusOptions{})
})
})
}
+75
View File
@@ -0,0 +1,75 @@
package finance
import (
"math"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/chart"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
// mktNow is the fixed clock of the markets tests: 10:30 in New York.
var mktNow = time.Date(2026, time.October, 5, 14, 30, 0, 0, time.UTC)
func mktNewYork() *time.Location { return time.FixedZone("EDT", -4*3600) }
func mktStrikes() []OptionStrike {
var out []OptionStrike
for i := range 21 {
k := 90 + float64(i)
call := math.Max(100-k, 0) + 1.2 + float64(20-i)*0.05
put := math.Max(k-100, 0) + 1.2 + float64(i)*0.05
out = append(out, OptionStrike{Strike: k,
Call: OptionQuote{Bid: call - 0.05, Ask: call + 0.05, Last: call},
Put: OptionQuote{Bid: put - 0.05, Ask: put + 0.05, Last: put}})
}
return out
}
func mktSeries(n int, seed float64) []float64 {
out := make([]float64, n)
for i := range out {
out[i] = 100 + 10*math.Sin(float64(i)/6+seed) + float64(i)*0.3
}
return out
}
func mktTimes(n int) []time.Time {
out := make([]time.Time, n)
for i := range out {
out[i] = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 0, i)
}
return out
}
func mktHeat() chart.HeatNode {
return chart.HeatNode{Name: "Market", Children: []chart.HeatNode{
{Name: "Technology", Children: []chart.HeatNode{{Name: "AAPL", Cap: 3000, Change: 1.2}, {Name: "MSFT", Cap: 2800, Change: -0.6}, {Name: "NVDA", Cap: 2200, Change: 3.4}}},
{Name: "Energy", Children: []chart.HeatNode{{Name: "XOM", Cap: 450, Change: -2.1}, {Name: "CVX", Cap: 300, Change: -1.4}}},
{Name: "Health", Children: []chart.HeatNode{{Name: "LLY", Cap: 700, Change: 0.4}, {Name: "JNJ", Cap: 380, Change: 0}}},
}}
}
func mktWatch() []WatchGroup {
return []WatchGroup{
{Name: "Tech", Items: []WatchItem{
{Symbol: "AAPL", Name: "Apple", Price: 227.5, Change: 2.7, ChangePercent: 1.2, Volume: 51e6},
{Symbol: "MSFT", Name: "Microsoft", Price: 418.2, Change: -2.5, ChangePercent: -0.6, Volume: 21e6},
{Symbol: "NVDA", Name: "Nvidia", Price: 124.9, Change: 4.1, ChangePercent: 3.4, Volume: 310e6},
}},
{Name: "Energy", Items: []WatchItem{{Symbol: "XOM", Name: "Exxon", Price: 117.4, Change: -2.5, ChangePercent: -2.1, Volume: 18e6}}},
}
}
func marketsPanic(t *testing.T, view func(*ui.Context)) {
t.Helper()
defer func() {
if recover() == nil {
t.Fatal("expected panic")
}
}()
ui.Render(func(c *ui.Context) { core.Use(c, core.Settings{}); view(c) }, 960, 500, 1)
}
+223
View File
@@ -0,0 +1,223 @@
package finance
import (
"fmt"
"math"
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/chart"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
func TestMarketsAncestorDisabled(t *testing.T) {
s := NewsFeedState{}
disabled := false
var last NewsFeedView
items := []NewsItem{{ID: "a", Title: "Alpha", Source: "Wire", Time: mktNow}, {ID: "b", Title: "Beta", Source: "Wire", Time: mktNow}}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Height(360).Disabled(disabled).Children(func() { last = NewsFeed(c, &s, items, NewsFeedOptions{Today: core.DateOf(mktNow)}) })
}, 640, 400)
tt.Frame()
_ = tt.Click("Alpha")
disabled = true
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if s.Read["b"] || last.Opened != "" {
t.Fatal("ancestor-disabled feed changed reads")
}
}
func TestCurrencyConverterSwapInvalidatesRate(t *testing.T) {
s := CurrencyConverterState{Amount: 100, From: "USD", To: "JPY"}
var last CurrencyConverterView
tt := chatTester(760, 300, func(c *ui.Context) {
last = CurrencyConverter(c, &s, []Currency{{"USD", "Dollar", 2}, {"JPY", "Yen", 0}}, CurrencyConverterOptions{Rate: 150})
})
_ = tt.Click("Swap currencies")
if s.From != "JPY" || s.To != "USD" || !math.IsNaN(last.Result) {
t.Fatalf("swapped pair %s/%s result %v", s.From, s.To, last.Result)
}
}
func TestGreeksTableLarge(t *testing.T) {
rows := make([]GreeksRow, 100000)
for i := range rows {
rows[i] = GreeksRow{Label: "Contract", Greeks: OptionGreeks{Delta: 0.5}}
}
tt := chatTester(640, 400, func(c *ui.Context) { GreeksTable(c, rows, GreeksTableOptions{}) })
if n := len(tt.Texts()); n > 200 {
t.Fatalf("built %d texts", n)
}
}
func TestSymbolSearchLarge(t *testing.T) {
rows := make([]MarketSymbol, 100000)
for i := range rows {
rows[i] = MarketSymbol{Code: data.StatisticFormat(float64(i), 0), Name: "Company"}
}
s := SymbolSearchState{Query: "company"}
tt := chatTester(640, 400, func(c *ui.Context) { SymbolSearch(c, &s, rows, SymbolSearchOptions{}) })
if n := len(tt.Texts()); n > 200 {
t.Fatalf("built %d texts", n)
}
}
func TestMarketsLargeViews(t *testing.T) {
const n = 100000
news := make([]NewsItem, n)
econ := make([]EconomicEvent, n)
earn := make([]EarningsEvent, n)
rows := make([]ScreenerRow, n)
strikes := make([]OptionStrike, n)
for i := range n {
id := fmt.Sprint(i)
news[i] = NewsItem{ID: id, Title: "Headline " + id, Time: mktNow}
econ[i] = EconomicEvent{ID: id, Country: "US", Title: "Event " + id, Importance: 3}
earn[i] = EarningsEvent{ID: id, Symbol: "S" + id}
rows[i] = ScreenerRow{Symbol: "S" + id, Values: map[string]float64{"pe": 10}}
strikes[i] = OptionStrike{Strike: float64(i)}
}
for _, tc := range []struct {
name string
view func(*ui.Context)
}{
{"news", func(c *ui.Context) {
s := ui.Local(c.Root(), "large.news", func() NewsFeedState { return NewsFeedState{} })
NewsFeed(c, s, news, NewsFeedOptions{}).Element.Height(350)
}},
{"economic", func(c *ui.Context) {
s := ui.Local(c.Root(), "large.econ", func() EconomicCalendarState { return EconomicCalendarState{} })
EconomicCalendar(c, s, econ, EconomicCalendarOptions{}).Element.Height(350)
}},
{"earnings", func(c *ui.Context) {
s := ui.Local(c.Root(), "large.earn", func() data.DataTableState[string] { return data.DataTableState[string]{} })
EarningsCalendar(c, s, earn, EarningsCalendarOptions{}).Element.Height(350)
}},
{"screener", func(c *ui.Context) {
s := ui.Local(c.Root(), "large.screen", func() ScreenerState { return ScreenerState{} })
Screener(c, s, []ScreenerField{{ID: "pe", Label: "P/E"}}, rows, ScreenerOptions{}).Element.Height(350)
}},
{"chain", func(c *ui.Context) {
s := ui.Local(c.Root(), "large.chain", func() OptionChainState { return OptionChainState{} })
OptionChain(c, s, []string{"Oct"}, strikes, OptionChainOptions{Spot: 50000}).Element.Height(350)
}},
} {
t.Run(tc.name, func(t *testing.T) {
tt := chatTester(960, 400, tc.view)
tt.Frame()
if count := len(tt.Texts()); count > 300 {
t.Fatalf("built %d texts", count)
}
})
}
}
func TestMarketHeatmapLargeGeometry(t *testing.T) {
m := chart.MarketHeatMemo{}
for i := range 100000 {
m.Tiles = append(m.Tiles, chart.MarketHeatTile{Name: fmt.Sprint(i), Cap: 1, Change: 1})
m.Caps = append(m.Caps, 1)
}
m.Layout(m.Caps, ui.Rect{W: 640, H: 240})
if count := len(m.Visible) + len(m.Small); count > 640*240/16+640/4+240/4 {
t.Fatalf("built %d marks", count)
}
if len(m.Rects) != 100000 {
t.Fatalf("keyboard points %d", len(m.Rects))
}
first := &m.Rects[0]
m.Layout(m.Caps, ui.Rect{W: 640, H: 240})
if first != &m.Rects[0] {
t.Fatal("idle layout recomputed")
}
}
func TestMarketsPaletteContrast(t *testing.T) {
for _, k := range []theme.Tokens{theme.Light(), theme.Dark()} {
for i, col := range internal.Palette(k) {
if ratio := theme.Contrast(col, k.Surface); ratio < 3 {
t.Fatalf("color %d ratio %v", i, ratio)
}
}
}
}
func TestMarketsChartStatesAndDisabled(t *testing.T) {
for _, name := range []string{"payoff", "heatmap", "allocation", "performance"} {
t.Run(name, func(t *testing.T) {
for _, status := range []data.DataStatus{data.DataLoading, data.DataFailed} {
frame := chart.ChartFrame{Label: "Chart", Height: 180, Status: status, Error: "Caller failed"}
retry := false
tt := chatTester(960, 420, func(c *ui.Context) {
var r chart.ChartResult
switch name {
case "payoff":
r = PayoffDiagram(c, bullSpread(), PayoffDiagramOptions{Frame: frame})
case "heatmap":
path := ui.Local(c.Root(), "path", func() []string { return nil })
r = chart.MarketHeatmap(c, path, mktHeat(), chart.MarketHeatmapOptions{Frame: frame})
case "allocation":
r = AssetAllocationChart(c, []AllocationItem{{"Stocks", 1, 100}}, AssetAllocationChartOptions{Frame: frame})
case "performance":
r = PerformanceChart(c, []float64{100, 110}, PerformanceChartOptions{Frame: frame, Times: mktTimes(2)})
}
retry = retry || r.Retried()
})
if status == data.DataFailed {
_ = tt.Click("Retry")
if !retry {
t.Fatal("retry missing")
}
} else if !strings.Contains(strings.Join(tt.Texts(), "|"), "Loading") {
t.Fatalf("loading %q", tt.Texts())
}
}
})
}
}
func TestMarketsLargeChartGeometry(t *testing.T) {
values := mktSeries(100000, 0)
times := make([]time.Time, 100000)
items := make([]AllocationItem, 100000)
for i := range times {
times[i] = mktNow.Add(time.Duration(i) * time.Minute)
items[i] = AllocationItem{Name: fmt.Sprint("Asset ", i), Value: 1}
}
for _, name := range []string{"payoff", "performance", "allocation"} {
t.Run(name, func(t *testing.T) {
var geometry *chart.ChartMemoEntry
tt := chatTester(960, 600, func(c *ui.Context) {
var r chart.ChartResult
frame := chart.ChartFrame{Label: "Large"}
switch name {
case "payoff":
r = PayoffDiagram(c, bullSpread(), PayoffDiagramOptions{Frame: frame, Points: 100000})
geometry = ui.Local(r.Element, "mujica.memo.payoff.geometry", func() chart.ChartMemoEntry { return chart.ChartMemoEntry{} })
case "performance":
r = PerformanceChart(c, values, PerformanceChartOptions{Frame: frame, Times: times})
geometry = ui.Local(r.Element, "mujica.memo.performance.geometry.Portfolio", func() chart.ChartMemoEntry { return chart.ChartMemoEntry{} })
case "allocation":
r = AssetAllocationChart(c, items, AssetAllocationChartOptions{Frame: frame})
}
})
tt.Frame()
if len(tt.Texts()) > 300 {
t.Fatalf("built %d texts", len(tt.Texts()))
}
if geometry != nil {
points := geometry.Val.([]int)
if len(points) > 4*960 {
t.Fatalf("built %d points", len(points))
}
first := &points[0]
tt.Frame()
if first != &geometry.Val.([]int)[0] {
t.Fatal("idle geometry recomputed")
}
}
})
}
}
+169
View File
@@ -0,0 +1,169 @@
package finance
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/chart"
"github.com/ZacharyZhang-NY/MujicaUI/chat"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
type NewsItem struct {
ID, Title, Source string
Time time.Time
Symbols []string
}
type NewsFeedState struct {
Read map[string]bool
rows ui.ListState
cursor int
readVersion int
count struct {
key string
unread int
}
}
func newsFeedUnread(s *NewsFeedState, items []NewsItem, version int) int {
key := fmt.Sprint(chart.ChartID(items), version, s.readVersion)
if key != s.count.key {
s.count.key, s.count.unread = key, 0
for _, it := range items {
if !s.Read[it.ID] {
s.count.unread++
}
}
}
return s.count.unread
}
// NewsFeedOptions configure a NewsFeed.
type NewsFeedOptions struct {
// Today decides the time stamps; zero uses the window clock.
Today core.Date
Version int
Status data.DataStatus
Error string
Disabled bool
}
// NewsFeedView is a built NewsFeed and this frame's events.
type NewsFeedView struct {
Element *ui.Element
Opened string
// Symbol is the related code clicked; empty for none.
Symbol string
Retry bool
ReadChanged bool
}
func NewsFeed(c *ui.Context, s *NewsFeedState, items []NewsItem, opts NewsFeedOptions) NewsFeedView {
if s == nil {
panic("mujica: NewsFeed needs a state")
}
today := opts.Today
if today.IsZero() {
today = core.DateOf(c.Now())
}
k := core.Tokens(c)
d := core.Density(c)
var v NewsFeedView
v.Element = ui.Column(c).Gap(d.Unit() * 2).Grow(1).MinHeight(0).Disabled(opts.Disabled)
opts.Disabled = v.Element.IsDisabled()
if opts.Disabled {
copy := *s
s = &copy
}
if s.Read == nil {
s.Read = map[string]bool{}
}
v.Element.Children(func() {
unread := newsFeedUnread(s, items, opts.Version)
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, fmt.Sprintf(core.Msg(c, "news_feed.unread"), unread)).TextColor(k.TextMuted).Grow(1).Role(ui.RoleStatus)
if input.Button(c, core.Msg(c, "news_feed.mark_all"), input.ButtonOptions{Variant: input.Ghost, Disabled: opts.Disabled || unread == 0}).Clicked() {
for _, it := range items {
s.Read[it.ID] = true
}
s.readVersion++
v.ReadChanged = true
slog.Debug("mujica: news_feed mark_all", "count", unread)
}
})
if len(items) == 0 {
v.Retry = data.DataPlaceholder(c, opts.Status, opts.Error, "", core.Msg(c, "news_feed.empty"))
return
}
cursor := &s.cursor
if opts.Disabled {
cp := s.cursor
cursor = &cp
}
s.rows.Key = func(i int) any { return items[i].ID }
s.rows.Label = func(i int) string { return items[i].Title }
s.rows.Selected = cursor
restore := data.DataSelectTheme(c, 0)
list := ui.List(c, &s.rows, len(items), func(i int) {
it := items[i]
read := s.Read[it.ID]
row := ui.Row(c).Padding(d.Unit()*2, d.Unit()*2).Gap(d.Unit() * 2).AlignItems(ui.Start).MinHeight(d.ControlHeight())
if row.Clicked() && !row.IsDisabled() {
*cursor, v.Opened = i, it.ID
}
row.Children(func() {
ui.Box(c).Size(8, 18).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
if !read {
p.FillPath(internal.Diamond(r.X+4, r.Y+9, 8), k.AccentText)
}
})
ui.Column(c).Grow(1).MinWidth(0).Gap(2).Children(func() {
t := ui.Text(c, it.Title).MaxLines(2)
if !read {
t.FontWeight(700)
} else {
t.TextColor(k.TextMuted)
}
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Wrap().Children(func() {
ui.Text(c, it.Source+" · "+chat.DateSeparatorWhen(c, it.Time, today)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
for _, sym := range it.Symbols {
ui.Row(c).Key(sym).Children(func() {
if SymbolBadge(c, MarketSymbol{Code: sym}, SymbolBadgeOptions{Clickable: true, Disabled: opts.Disabled}).Changed() {
v.Symbol = sym
}
})
}
})
})
})
}).Grow(1).MinHeight(0).Padding(d.Unit()).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius)
restore()
internal.Ring(list, k.Focus, theme.ControlRadius)
if !list.IsDisabled() && list.Submitted() && s.cursor >= 0 && s.cursor < len(items) {
v.Opened = items[s.cursor].ID
}
if v.Opened != "" && !opts.Disabled {
if !s.Read[v.Opened] {
s.readVersion++
v.ReadChanged = true
}
s.Read[v.Opened] = true
}
})
if v.Element.IsDisabled() {
v.Opened, v.Symbol, v.Retry, v.ReadChanged = "", "", false, false
}
if v.Opened != "" || v.Symbol != "" {
slog.Debug("mujica: news_feed open", "item", v.Opened, "symbol", v.Symbol)
}
return v
}
+52
View File
@@ -0,0 +1,52 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestNewsFeedReadSourceSymbolsAndKeyboard(t *testing.T) {
var s NewsFeedState
var got NewsFeedView
rows := []NewsItem{{ID: "a", Title: "Alpha", Source: "Wire", Time: mktNow, Symbols: []string{"AAPL"}}, {ID: "b", Title: "Beta", Source: "Wire", Time: mktNow}}
tt := chatTester(640, 420, func(c *ui.Context) {
v := NewsFeed(c, &s, rows, NewsFeedOptions{Today: core.DateOf(mktNow)})
v.Element.Height(380)
if v.Opened != "" || v.Symbol != "" {
got = v
}
})
if !tt.HasText("2 unread") {
t.Fatalf("count %q", tt.Texts())
}
_ = tt.Click("AAPL")
if got.Symbol != "AAPL" || s.Read["a"] {
t.Fatalf("symbol %+v read %v", got, s.Read)
}
_ = tt.Click("Alpha")
if !s.Read["a"] || got.Opened != "a" {
t.Fatalf("read %v", s.Read)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if !s.Read["b"] {
t.Fatal("keyboard read missing")
}
tt.Frame()
if !tt.HasText("0 unread") {
t.Fatalf("count %q", tt.Texts())
}
}
func TestNewsFeedSnapshot(t *testing.T) {
snapshot(t, "news_feed", 560, 360, func(c *ui.Context) {
ns := ui.Local(c.Root(), "snapshot.news_feed", func() NewsFeedState { return NewsFeedState{} })
ui.Column(c).Padding(16).Gap(12).Fill().Children(func() {
NewsFeed(c, ns, []NewsItem{{ID: "1", Title: "Apple unveils new chips", Source: "Reuters", Time: mktNow, Symbols: []string{"AAPL"}},
{ID: "2", Title: "Oil falls as supply grows", Source: "Bloomberg", Time: mktNow.Add(-26 * 3600e9), Symbols: []string{"XOM", "CVX"}}}, NewsFeedOptions{Today: core.DateOf(mktNow)}).Element.Height(300)
})
})
}
+258
View File
@@ -0,0 +1,258 @@
package finance
import (
"cmp"
"fmt"
"log/slog"
"math"
"slices"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// OptionQuote is one contract's market.
type OptionQuote struct {
Bid, Ask, Last float64
}
// OptionStrike is a strike with its call and put.
type OptionStrike struct {
Strike float64
Call, Put OptionQuote
}
type OptionSide int
const (
OptionCall OptionSide = iota
OptionPut
)
// OptionChainState is the expiry shown and the chosen contract.
type OptionChainState struct {
Expiry string
Strike float64
Side OptionSide
Chosen bool
rows ui.ListState
cursor int
center string
}
// OptionChainOptions configure an OptionChain.
type OptionChainOptions struct {
Spot float64
Decimals int
Disabled bool
}
// OptionChainView is a built OptionChain and this frame's events.
type OptionChainView struct {
Element *ui.Element
// ExpiryChanged asks the caller for the strikes of State.Expiry.
ExpiryChanged bool
Changed, Submitted bool
}
func OptionChain(c *ui.Context, s *OptionChainState, expiries []string, strikes []OptionStrike, opts OptionChainOptions) OptionChainView {
if s == nil || len(expiries) == 0 {
panic("mujica: OptionChain needs a state and an expiry")
}
if opts.Spot < 0 || math.IsNaN(opts.Spot) || math.IsInf(opts.Spot, 0) || s.Side < OptionCall || s.Side > OptionPut {
panic("mujica: OptionChain price or side is invalid")
}
for i, o := range strikes {
if i > 0 && o.Strike == strikes[i-1].Strike {
panic("mujica: OptionChain strikes must be unique")
}
for _, v := range []float64{o.Strike, o.Call.Bid, o.Call.Ask, o.Call.Last, o.Put.Bid, o.Put.Ask, o.Put.Last} {
if v < 0 || math.IsNaN(v) || math.IsInf(v, 0) {
panic("mujica: OptionChain quotes must be finite and nonnegative")
}
}
}
if !slices.IsSortedFunc(strikes, func(a, b OptionStrike) int { return cmp.Compare(a.Strike, b.Strike) }) {
panic("mujica: OptionChain strikes must ascend")
}
dec := core.PriceDecimals("OptionChain", opts.Decimals)
k := core.Tokens(c)
d := core.Density(c)
var v OptionChainView
v.Element = ui.Column(c).Gap(d.Unit() * 2).Grow(1).MinHeight(0).Disabled(opts.Disabled)
opts.Disabled = v.Element.IsDisabled()
if opts.Disabled {
copy := *s
s = &copy
}
if s.Expiry == "" {
s.Expiry = expiries[0]
} else if !slices.Contains(expiries, s.Expiry) {
panic("mujica: OptionChain expiry is not listed")
}
v.Element.Children(func() {
ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() {
items := make([]input.SelectOption[string], len(expiries))
for i, e := range expiries {
items[i] = input.SelectOption[string]{Value: e, Label: e}
}
v.ExpiryChanged = input.Select(c, &s.Expiry, items, input.SelectOptions{Label: core.Msg(c, "option_chain.expiry"), Disabled: opts.Disabled}).Changed()
if opts.Spot > 0 {
ui.Text(c, fmt.Sprintf(core.Msg(c, "option_chain.spot"), data.StatisticFormat(opts.Spot, dec))).TextColor(k.TextMuted)
}
})
optionChainHeader(c)
v.Changed, v.Submitted = optionChainRows(c, s, strikes, opts, dec)
})
if v.Element.IsDisabled() {
v.ExpiryChanged, v.Changed, v.Submitted = false, false, false
}
if v.Changed {
slog.Debug("mujica: option_chain choose", "strike", s.Strike, "side", s.Side)
}
return v
}
var optionChainCols = [3]string{"option_chain.bid", "option_chain.ask", "option_chain.last"}
func optionChainHeader(c *ui.Context) {
t := core.Tokens(c)
d := core.Density(c)
size := core.FontSize(c, theme.CaptionSize)
ui.Row(c).Padding(0, d.Unit()*3).Children(func() {
ui.Text(c, core.Msg(c, "option_chain.calls")).Grow(1).Basis(0).TextAlign(ui.Center).FontSize(size).TextColor(t.TextMuted)
ui.Box(c).Width(90)
ui.Text(c, core.Msg(c, "option_chain.puts")).Grow(1).Basis(0).TextAlign(ui.Center).FontSize(size).TextColor(t.TextMuted)
})
ui.Row(c).Padding(0, d.Unit()*3).Children(func() {
for side, title := range []string{"option_chain.calls", "option_chain.puts"} {
if side == 1 {
ui.Text(c, core.Msg(c, "option_chain.strike")).Width(90).TextAlign(ui.Center).FontSize(size).TextColor(t.TextMuted)
}
ui.Row(c).Grow(1).Basis(0).Children(func() {
for _, col := range optionChainCols {
ui.Text(c, core.Msg(c, col)).Grow(1).Basis(0).TextAlign(ui.End).FontSize(size).TextColor(t.TextMuted)
}
}).Label(core.Msg(c, title))
}
})
}
func optionChainRows(c *ui.Context, s *OptionChainState, strikes []OptionStrike, opts OptionChainOptions, dec int) (changed, submitted bool) {
k := core.Tokens(c)
d := core.Density(c)
if len(strikes) == 0 {
ui.Text(c, core.Msg(c, "option_chain.empty")).TextColor(k.TextMuted).Padding(d.Unit() * 3)
return false, false
}
s.cursor = -1
if s.Chosen {
s.cursor = slices.IndexFunc(strikes, func(o OptionStrike) bool { return o.Strike == s.Strike })
}
before, side := s.cursor, s.Side
cursor := &s.cursor
if opts.Disabled {
cp := s.cursor
cursor = &cp
}
atm, _ := slices.BinarySearchFunc(strikes, opts.Spot, func(o OptionStrike, v float64) int { return cmp.Compare(o.Strike, v) })
if atm == len(strikes) || atm > 0 && opts.Spot-strikes[atm-1].Strike < strikes[atm].Strike-opts.Spot {
atm--
}
if key := fmt.Sprint(s.Expiry, len(strikes)); key != s.center {
s.center = key
s.rows.ScrollTo(min(atm, len(strikes)-1), ui.Center)
}
s.rows.Key = func(i int) any { return strikes[i].Strike }
s.rows.Label = func(i int) string { return data.StatisticFormat(strikes[i].Strike, dec) }
s.rows.Selected = cursor
clickedRow, clickedSide := -1, OptionCall
double := false
restore := data.DataSelectTheme(c, 0)
list := ui.List(c, &s.rows, len(strikes), func(i int) {
o := strikes[i]
chosen := s.Chosen && i == s.cursor
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).AlignItems(ui.Stretch).Children(func() {
for sd, q := range []OptionQuote{o.Call, o.Put} {
if sd == 1 {
optionChainStrike(c, o.Strike, dec, i == atm && opts.Spot > 0)
}
itm := opts.Spot > 0 && (sd == 0 && o.Strike < opts.Spot || sd == 1 && o.Strike > opts.Spot)
half := ui.Row(c).Grow(1).Basis(0).AlignItems(ui.Center).Radius(theme.ControlRadius).
Label(optionChainName(c, o.Strike, OptionSide(sd), q, dec, itm))
if half.Clicked() && !half.IsDisabled() {
clickedRow, clickedSide, double = i, OptionSide(sd), half.DoubleClicked()
}
if itm {
half.Background(k.Selection)
}
half.DrawOver(func(p *ui.Painter, r ui.Rect) {
if itm {
p.FillPath(internal.Diamond(internal.Snap(p, r.X+5), internal.Snap(p, r.Y+r.H/2), 6), k.AccentText)
}
if chosen && OptionSide(sd) == s.Side {
internal.Frame(p, r, 1, 5, k.AccentText)
}
})
half.Children(func() {
for _, v := range []float64{q.Bid, q.Ask, q.Last} {
ui.Text(c, data.StatisticFormat(v, dec)).Grow(1).Basis(0).TextAlign(ui.End).FontFeatures("tnum").SingleLine()
}
})
}
})
}).Grow(1).MinHeight(0).Padding(d.Unit()).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius)
restore()
internal.Ring(list, k.Focus, theme.ControlRadius)
pointed := OptionSide(-1)
if x, _, over := list.PointerPosition(); over {
pointed = OptionCall
if x > list.Bounds().W/2 {
pointed = OptionPut
}
}
if !list.IsDisabled() {
if clickedRow >= 0 {
s.cursor, s.Side = clickedRow, clickedSide
list.Focus()
}
switch {
case clickedRow < 0 && s.cursor != before && pointed >= 0:
s.Side = pointed
case list.Shortcut(0, ui.KeyLeft):
s.Side = OptionCall
case list.Shortcut(0, ui.KeyRight):
s.Side = OptionPut
}
if s.cursor >= 0 && (s.cursor != before || s.Side != side) {
s.Strike, s.Chosen, changed = strikes[s.cursor].Strike, true, true
}
submitted = (list.Submitted() || double) && s.Chosen
}
return changed, submitted
}
func optionChainStrike(c *ui.Context, strike float64, dec int, atm bool) {
k := core.Tokens(c)
ui.Row(c).Width(90).Gap(4).Justify(ui.Center).AlignItems(ui.Center).Background(k.Background).Children(func() {
if atm {
ui.Box(c).Size(8, 8).Draw(func(p *ui.Painter, r ui.Rect) { p.FillPath(internal.Diamond(r.X+4, r.Y+4, 8), k.AccentText) })
}
ui.Text(c, data.StatisticFormat(strike, dec)).FontWeight(700).FontFeatures("tnum")
})
}
func optionChainName(c *ui.Context, strike float64, side OptionSide, q OptionQuote, dec int, itm bool) string {
s := fmt.Sprintf(core.Msg(c, []string{"option_chain.call_name", "option_chain.put_name"}[side]), data.StatisticFormat(strike, dec),
data.StatisticFormat(q.Bid, dec), data.StatisticFormat(q.Ask, dec), data.StatisticFormat(q.Last, dec))
if itm {
s += ", " + core.Msg(c, "option_chain.itm")
}
return s
}
+132
View File
@@ -0,0 +1,132 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func optionChainTester(s *OptionChainState, strikes []OptionStrike, opts OptionChainOptions, got *OptionChainView) *ui.Tester {
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(10).Height(420).Children(func() {
v := OptionChain(c, s, []string{"Oct 17", "Nov 21"}, strikes, opts)
if v.Changed || v.Submitted || v.ExpiryChanged {
*got = v
}
})
}, 780, 440)
// Rows show once the list has been laid out.
tt.Frame()
return tt
}
func TestOptionChainChooseByClickAndKeys(t *testing.T) {
var s OptionChainState
var got OptionChainView
tt := optionChainTester(&s, mktStrikes(), OptionChainOptions{Spot: 100.4}, &got)
tt.Frame()
optionChainClick(t, tt, "102.00", OptionPut)
if !s.Chosen || s.Strike != 102 || s.Side != OptionPut || !got.Changed {
t.Fatalf("state %+v", s)
}
tt.Key(0, ui.KeyLeft)
if s.Side != OptionCall || s.Strike != 102 {
t.Fatalf("Left kept side %v", s.Side)
}
tt.Key(0, ui.KeyDown)
if s.Strike != 103 {
t.Fatalf("Down chose %v", s.Strike)
}
tt.Key(0, ui.KeyEnter)
if !got.Submitted {
t.Fatal("Enter did not submit")
}
}
func TestOptionChainMarksInTheMoneyAndCenters(t *testing.T) {
var strikes []OptionStrike
for i := range 300 {
strikes = append(strikes, OptionStrike{Strike: float64(i) + 0.5})
}
var s OptionChainState
var got OptionChainView
tt := optionChainTester(&s, strikes, OptionChainOptions{Spot: 200}, &got)
tt.Frame()
if !tt.HasText("200.50") || tt.HasText("Call 5.50") {
t.Fatalf("not centered on the spot: %q", tt.Texts())
}
if n := len(tt.Texts()); n > 400 {
t.Fatalf("built %d texts for 300 strikes", n)
}
if !tt.HasText("Call 199.50: bid 0.00, ask 0.00, last 0.00, in the money") || tt.HasText("Put 199.50: bid 0.00, ask 0.00, last 0.00, in the money") {
t.Fatal("in-the-money marks wrong")
}
}
func TestOptionChainExpiryAndDisabled(t *testing.T) {
var s OptionChainState
var got OptionChainView
tt := optionChainTester(&s, mktStrikes(), OptionChainOptions{Spot: 100}, &got)
_ = tt.Click("Oct 17")
_ = tt.Click("Nov 21")
if s.Expiry != "Nov 21" || !got.ExpiryChanged {
t.Fatalf("expiry %q", s.Expiry)
}
var d OptionChainState
got = OptionChainView{}
tt = optionChainTester(&d, mktStrikes(), OptionChainOptions{Spot: 100, Disabled: true}, &got)
optionChainClick(t, tt, "102.00", OptionPut)
if d.Chosen || got.Changed {
t.Fatal("disabled chain chose")
}
}
func TestOptionChainRejectsUnsortedStrikes(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
var s OptionChainState
ui.Render(func(c *ui.Context) {
core.Use(c, core.Settings{})
OptionChain(c, &s, []string{"x"}, []OptionStrike{{Strike: 2}, {Strike: 1}}, OptionChainOptions{})
}, 100, 100, 1)
}
func TestOptionChainSnapshot(t *testing.T) {
snapshot(t, "option_chain", 760, 400, func(c *ui.Context) {
s := &OptionChainState{Strike: 99, Side: OptionCall, Chosen: true}
ui.Column(c).Padding(16).Height(400).Children(func() {
OptionChain(c, s, []string{"Oct 17", "Nov 21"}, mktStrikes(), OptionChainOptions{Spot: 100.4})
})
})
}
// optionChainClick clicks the call or put half of the row for strike.
func optionChainClick(t *testing.T, tt *ui.Tester, strike string, side OptionSide) {
t.Helper()
r, ok := tt.Find(strike)
if !ok {
t.Fatalf("%q not shown", strike)
}
x := r.X + r.W*0.2
if side == OptionPut {
x = r.X + r.W*0.8
}
tt.ClickAt(x, r.Y+r.H/2)
}
func TestOptionChainSameStrikeOtherSide(t *testing.T) {
var s OptionChainState
var got OptionChainView
tt := optionChainTester(&s, mktStrikes(), OptionChainOptions{Spot: 100}, &got)
optionChainClick(t, tt, "102.00", OptionCall)
optionChainClick(t, tt, "102.00", OptionPut)
if s.Strike != 102 || s.Side != OptionPut {
t.Fatalf("contract %+v", s)
}
}
+263
View File
@@ -0,0 +1,263 @@
package finance
import (
"cmp"
"fmt"
"log/slog"
"math"
"slices"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/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 := core.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 := core.Tokens(c)
d := core.Density(c)
var r OrderBookResult
r.Element = ui.Column(c).Gap(d.Unit()).MinWidth(240).Disabled(opts.Disabled).Role(ui.RoleTable).Label(core.Msg(c, "order_book.label"))
r.Element.Children(func() {
if len(opts.Groups) > 0 {
var choices []input.SelectOption[float64]
for _, g := range opts.Groups {
choices = append(choices, input.SelectOption[float64]{Value: g, Label: data.StatisticFormat(g, dec)})
}
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, core.Msg(c, "order_book.group")).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize))
if input.Select(c, &s.Group, choices, input.SelectOptions{Label: core.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 := core.Tokens(c)
d := core.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, core.Msg(c, key)).FontSize(core.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 := core.Tokens(c)
d := core.Density(c)
col := core.TrendColor(c, side.sign())
price := data.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(core.Msg(c, "order_book.level"), core.Msg(c, fmt.Sprintf("order_book.side%d", side)), price, data.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, data.StatisticFormat(l.Size, sizeDec)).FontFeatures("tnum").TextAlign(ui.End).Grow(1).Basis(0).SingleLine()
ui.Text(c, data.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
}
+125
View File
@@ -0,0 +1,125 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
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(core.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(core.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(core.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(core.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(core.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(core.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))
}
}
+91
View File
@@ -0,0 +1,91 @@
package finance
import (
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
"github.com/ZacharyZhang-NY/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) overlay.AlertDialogResult {
if len(opts.Lines) == 0 {
panic("mujica: OrderConfirmDialog needs summary Lines")
}
var res overlay.AlertDialogResult
k := core.Tokens(c)
u := core.Density(c).Unit()
title, confirm := opts.Title, opts.ConfirmLabel
if title == "" {
title = core.Msg(c, "order_confirm_dialog.title")
}
if confirm == "" {
confirm = core.Msg(c, "order_confirm_dialog.confirm")
}
was := *open
var st *orderConfirmState
overlay.Dialog(c, open, overlay.DialogOptions{Title: title, KeepOnBackdrop: true, HideClose: true, Actions: func() {
if input.Button(c, core.Msg(c, "order_confirm_dialog.cancel"), input.ButtonOptions{Variant: input.Secondary}).AutoFocus().Clicked() {
res.Canceled = true
}
locked := opts.Acknowledge != "" && !st.ack
if input.Button(c, confirm, input.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(core.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 != "" {
input.Checkbox(c, &st.ack, opts.Acknowledge, input.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
}
+81
View File
@@ -0,0 +1,81 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
)
func orderConfirmTester(open *bool, opts OrderConfirmDialogOptions, res *overlay.AlertDialogResult) *ui.Tester {
return tradingTester(core.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 overlay.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, overlay.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 overlay.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, overlay.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"})
})
}
+226
View File
@@ -0,0 +1,226 @@
package finance
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/navigation"
"github.com/ZacharyZhang-NY/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 input.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 := input.NumberInputResolve(input.NumberInputOptions{Step: tick})
k := core.Tokens(c)
u := core.Density(c).Unit()
var r OrderEntryResult
r.Element = ui.Column(c).Gap(u * 4).MaxWidth(360).Disabled(opts.Disabled).Role(ui.RoleGroup).Label(core.Msg(c, "order_entry.label"))
disabled := r.Element.IsDisabled()
r.Element.Children(func() {
side := 0
if s.Draft.Side == SideSell {
side = 1
}
sides := []navigation.Segment{{Label: core.Msg(c, "order_entry.buy")}, {Label: core.Msg(c, "order_entry.sell")}}
navigation.SegmentedControl(c, &side, sides, navigation.SegmentedControlOptions{Equal: true, Label: core.Msg(c, "order_entry.side"), Disabled: disabled})
s.Draft.Side = SideBuy + OrderSide(side)
var types []input.SelectOption[OrderType]
for t := OrderLimit; t <= OrderStopLimit; t++ {
types = append(types, input.SelectOption[OrderType]{Value: t, Label: core.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(core.FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
input.Select(c, &s.Draft.Type, types, input.SelectOptions{Label: core.Msg(c, "order_entry.type"), Disabled: disabled})
})
submit := fmt.Sprintf(core.Msg(c, fmt.Sprintf("order_entry.submit%d", s.Draft.Side)), opts.Symbol)
res := input.Form(c, &s.Form, input.FormOptions{SubmitLabel: submit}, func(f *input.FormScope) {
orderEntryFields(c, f, s, opts, initial, tick, lot, disabled)
})
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, core.Msg(c, "order_entry.estimate")).TextColor(k.TextMuted)
est := "—"
if p := orderEntryPrice(s.Draft, opts.Last); p > 0 && s.Draft.Quantity > 0 {
est = "≈ " + data.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 *input.FormScope, s *OrderEntryState, opts OrderEntryOptions, initial OrderDraft, tick, lot float64, disabled bool) {
maxQ := opts.MaxQuantity
input.FormItem(c, f, "quantity", &s.Draft, input.FormItemOptions[OrderDraft]{Label: core.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 input.NumberInput(c, &s.Draft.Quantity, input.NumberInputOptions{Step: lot, Disabled: disabled, Error: s.Form.Error("quantity")}).Input
})
if s.Draft.Type == OrderStop || s.Draft.Type == OrderStopLimit {
input.FormItem(c, f, "trigger", &s.Draft.Trigger, input.FormItemOptions[float64]{Label: core.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 input.NumberInput(c, &s.Draft.Trigger, input.NumberInputOptions{Step: tick, Disabled: disabled, Error: s.Form.Error("trigger")}).Input
})
}
if s.Draft.Type == OrderLimit || s.Draft.Type == OrderStopLimit {
input.FormItem(c, f, "price", &s.Draft.Price, input.FormItemOptions[float64]{Label: core.Msg(c, "order_entry.price"), Required: true, Initial: initial.Price,
Validate: func(v float64) string {
if v <= 0 {
return core.Msg(c, "order_entry.price_positive")
}
return ""
},
}, func() *ui.Element {
return input.NumberInput(c, &s.Draft.Price, input.NumberInputOptions{Step: tick, Disabled: disabled, Error: s.Form.Error("price")}).Input
})
}
var tifs []input.SelectOption[TimeInForce]
for t := TIFDay; t <= TIFFillOrKill; t++ {
tifs = append(tifs, input.SelectOption[TimeInForce]{Value: t, Label: core.Msg(c, fmt.Sprintf("order_entry.tif%d", t))})
}
input.FormItem(c, f, "tif", &s.Draft.TIF, input.FormItemOptions[TimeInForce]{Label: core.Msg(c, "order_entry.tif"), Initial: initial.TIF}, func() *ui.Element {
return input.Select(c, &s.Draft.TIF, tifs, input.SelectOptions{Label: core.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 core.Msg(c, "order_entry.quantity_positive")
case maxQ > 0 && o.Quantity > maxQ:
return fmt.Sprintf(core.Msg(c, "order_entry.quantity_max"), data.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 core.Msg(c, "order_entry.trigger_positive")
case last > 0 && o.Side == SideBuy && o.Trigger <= last:
return core.Msg(c, "order_entry.trigger_above")
case last > 0 && o.Side == SideSell && o.Trigger >= last:
return core.Msg(c, "order_entry.trigger_below")
}
return ""
}
+139
View File
@@ -0,0 +1,139 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
type orderEntryProbe struct {
s OrderEntryState
opts OrderEntryOptions
orders []OrderDraft
checks []uint64
checked []OrderDraft
}
func (p *orderEntryProbe) tester() *ui.Tester {
return tradingTester(core.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})
})
})
}
+198
View File
@@ -0,0 +1,198 @@
package finance
import (
"cmp"
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/navigation"
"github.com/ZacharyZhang-NY/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 data.DataStatus
Error string
Disabled bool
}
// OrderTableState is the status filter and table state.
type OrderTableState struct {
Filter OrderFilter
Table data.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 := core.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) data.DataColumn[OrderRow] {
return data.DataColumn[OrderRow]{ID: id, Title: core.Msg(c, key), Sortable: true, Text: t,
Compare: func(a, b OrderRow) int { return cmp.Compare(t(a), t(b)) }}
}
cols := []data.DataColumn[OrderRow]{
text("symbol", "position_table.symbol", func(o OrderRow) string { return o.Symbol }),
{ID: "side", Title: core.Msg(c, "order_table.side"), Text: func(o OrderRow) string { return core.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 core.Msg(c, fmt.Sprintf("order_entry.type%d", o.Type)) }),
{ID: "price", Title: core.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 core.Msg(c, "order_table.market")
}
return data.StatisticFormat(o.Price, dec)
}},
{ID: "filled", Title: core.Msg(c, "order_table.filled"), Width: 140,
Text: func(o OrderRow) string {
return data.StatisticFormat(o.Filled, qd) + " / " + data.StatisticFormat(o.Quantity, qd)
},
Cell: func(o OrderRow) { orderTableProgress(c, o, qd) }},
text("status", "order_table.status", func(o OrderRow) string { return core.Msg(c, fmt.Sprintf("order_table.status%d", o.Status)) }),
{ID: "action", Title: core.Msg(c, "position_table.action"), Width: 96, Text: func(OrderRow) string { return "" },
Cell: func(o OrderRow) {
if o.Status.working() && input.Button(c, core.Msg(c, "order_table.cancel"), input.ButtonOptions{Variant: input.Secondary}).
Label(core.Msg(c, "order_table.cancel")+" "+o.Symbol).Clicked() {
r.id = o.ID
}
}},
}
d := core.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 []navigation.Segment
for f := OrdersAll; f <= OrdersClosed; f++ {
segs = append(segs, navigation.Segment{Label: core.Msg(c, fmt.Sprintf("order_table.filter%d", f))})
}
at := int(s.Filter)
if navigation.SegmentedControl(c, &at, segs, navigation.SegmentedControlOptions{Label: core.Msg(c, "order_table.filter"), Disabled: opts.Disabled}).Changed() {
s.Filter = OrderFilter(at)
slog.Debug("mujica: order_table filter", "filter", s.Filter)
}
r.Table = data.DataTable(c, &s.Table, shown, data.DataTableOptions[OrderRow, string]{Columns: cols, Key: func(o OrderRow) string { return o.ID },
Status: opts.Status, Error: opts.Error, Empty: core.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 := core.Tokens(c)
share := float32(o.Filled / o.Quantity)
ui.Row(c).Gap(core.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, data.StatisticFormat(o.Filled, qd)+" / "+data.StatisticFormat(o.Quantity, qd)).FontFeatures("tnum").FontSize(core.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(core.TrendColor(c, side.sign())).Children(func() {
ui.Icon(c, core.TrendIcon(side.sign())).Size(12, 12)
ui.Text(c, core.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
}
+120
View File
@@ -0,0 +1,120 @@
package finance
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
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(core.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)
}
}
+247
View File
@@ -0,0 +1,247 @@
package finance
import (
"fmt"
"math"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/chart"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
type OptionLegKind int
const (
LegCall OptionLegKind = iota
LegPut
// LegStock is the underlying, bought or sold at Premium.
LegStock
)
type OptionLeg struct {
Kind OptionLegKind
Strike float64
Premium float64
Quantity float64
}
func PayoffAt(legs []OptionLeg, price float64) float64 {
sum := 0.0
for _, l := range legs {
switch l.Kind {
case LegCall:
sum += l.Quantity * (max(price-l.Strike, 0) - l.Premium)
case LegPut:
sum += l.Quantity * (max(l.Strike-price, 0) - l.Premium)
case LegStock:
sum += l.Quantity * (price - l.Premium)
default:
panic("mujica: OptionLeg Kind out of range")
}
}
return sum
}
func Breakevens(legs []OptionLeg, lo, hi float64) []float64 {
kinks := []float64{lo, hi}
for _, l := range legs {
if l.Kind != LegStock && l.Strike > lo && l.Strike < hi {
kinks = append(kinks, l.Strike)
}
}
slices.Sort(kinks)
var out []float64
for i := 1; i < len(kinks); i++ {
a, b := kinks[i-1], kinks[i]
fa, fb := PayoffAt(legs, a), PayoffAt(legs, b)
if fa == 0 && (len(out) == 0 || out[len(out)-1] != a) {
out = append(out, a)
}
if fa*fb < 0 {
out = append(out, a+(b-a)*fa/(fa-fb))
}
}
if PayoffAt(legs, hi) == 0 && (len(out) == 0 || out[len(out)-1] != hi) {
out = append(out, hi)
}
return out
}
// PayoffDiagramOptions configure a PayoffDiagram.
type PayoffDiagramOptions struct {
Frame chart.ChartFrame
Spot float64
Low, High float64
Points int
Format func(v float64) string
}
func PayoffDiagram(c *ui.Context, legs []OptionLeg, opts PayoffDiagramOptions) chart.ChartResult {
lo, hi := payoffRange(legs, opts)
n := opts.Points
if n == 0 {
n = 101
}
if n < 2 {
panic("mujica: PayoffDiagram needs at least 2 Points")
}
ch := chart.NewChart(c, opts.Frame)
k := core.Tokens(c)
size := core.FontSize(c, theme.CaptionSize)
type data struct {
px, pl, be []float64
gx, gp []float64
ylo, yhi float64
}
d := chart.ChartMemo(ch, "payoff", fmt.Sprint(legs, lo, hi, n), func() data {
var v data
v.be = Breakevens(legs, lo, hi)
v.ylo, v.yhi = 0, 0
for i := range n {
x := lo + (hi-lo)*float64(i)/float64(n-1)
v.px, v.pl = append(v.px, x), append(v.pl, PayoffAt(legs, x))
v.ylo, v.yhi = min(v.ylo, v.pl[i]), max(v.yhi, v.pl[i])
}
v.gx = slices.Clone(v.px)
for _, l := range legs {
if l.Kind != LegStock && l.Strike > lo && l.Strike < hi {
v.gx = append(v.gx, l.Strike)
}
}
v.gx = append(v.gx, v.be...)
slices.Sort(v.gx)
v.gx = slices.Compact(v.gx)
for _, x := range v.gx {
value := PayoffAt(legs, x)
v.gp = append(v.gp, value)
v.ylo, v.yhi = min(v.ylo, value), max(v.yhi, value)
}
return v
})
if len(legs) == 0 {
n = 0
}
format := func(v float64) string { return chart.ChartValue(c, v, opts.Format) }
ylo, yhi, step := internal.Nice(d.ylo, d.yhi, 4)
ticks := internal.Ticks(ylo, yhi, step)
geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) {
plot := chart.ChartInset(r, 56, 26)
return plot, internal.Linear{D0: lo, D1: hi, R0: float64(plot.X), R1: float64(plot.X + plot.W)},
internal.Linear{D0: ylo, D1: yhi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
}
up, down := core.TrendColor(c, 1), core.TrendColor(c, -1)
spec := chart.ChartSpec{Marks: n}
spec.Readout = func(i int) string {
word := [3]string{"payoff_diagram.loss", "payoff_diagram.even", "payoff_diagram.profit"}[core.TrendSign(d.pl[i])+1]
return fmt.Sprintf(core.Msg(c, "payoff_diagram.readout"), format(d.px[i]), core.Msg(c, word), format(d.pl[i]))
}
spec.Pick = func(r ui.Rect, x, y float32) int {
plot, xs, _ := geom(r)
if !plot.Contains(x, y) {
return -1
}
return max(0, min(n-1, int(math.Round((xs.Invert(x)-lo)/(hi-lo)*float64(n-1)))))
}
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) {
plot, xs, ys := geom(r)
internal.AxisY(p, plot, ys, ticks, func(v float64) string { return internal.FormatTick(v, step) }, size, k)
lx, ll, lstep := internal.Nice(lo, hi, max(2, int(plot.W/90)))
var tx []float32
var tl []string
for _, t := range internal.Ticks(lx, ll, lstep) {
if t >= lo && t <= hi {
tx, tl = append(tx, xs.Map(t)), append(tl, internal.FormatTick(t, lstep))
}
}
internal.AxisX(p, plot, tx, tl, size, k)
zero := internal.Snap(p, ys.Map(0))
pt := func(x, v float64) (float32, float32) { return internal.Snap(p, xs.Map(x)), internal.Snap(p, ys.Map(v)) }
// Exact corners: the grid plus strikes and breakevens.
indices := chart.ChartMemo(ch, "payoff.geometry", [2]any{chart.ChartID(d.gx), plot.W}, func() []int {
return internal.Columns(len(d.gx), func(i int) float32 { return float32((d.gx[i]-lo)/(hi-lo)) * plot.W }, func(i int) float64 { return d.gp[i] })
})
for j := 1; j < len(indices); j++ {
prev, i := indices[j-1], indices[j]
a, b := d.gx[prev], d.gx[i]
fa, fb := d.gp[prev], d.gp[i]
col := up
if fa+fb < 0 {
col = down
}
var area ui.Path
ax, ay := pt(a, fa)
bx, by := pt(b, fb)
area.MoveTo(ax, zero).LineTo(ax, ay).LineTo(bx, by).LineTo(bx, zero).Close()
p.FillPath(&area, col.Alpha(0.16))
}
p.Line(plot.X, zero, plot.X+plot.W, zero, 1, k.ControlBorder)
var line ui.Path
for j, i := range indices {
if j == 0 {
line.MoveTo(pt(d.gx[i], d.gp[i]))
} else {
line.LineTo(pt(d.gx[i], d.gp[i]))
}
}
p.StrokePath(&line, 2, k.AccentText)
for _, b := range d.be {
bx, _ := pt(b, 0)
p.FillPath(internal.Diamond(bx, zero, 9), k.Text)
chart.ChartText(p, format(b), bx, zero-6, size, k.Text, 0.5, 1)
}
if opts.Spot > lo && opts.Spot < hi {
sx, _ := pt(opts.Spot, 0)
for y := plot.Y; y < plot.Y+plot.H; y += 6 {
p.Line(sx, y, sx, min(y+3, plot.Y+plot.H), 1, k.Info)
}
chart.ChartText(p, core.Msg(c, "payoff_diagram.spot")+" "+format(opts.Spot), sx+4, plot.Y, size, k.Info, 0, 0)
}
if active >= 0 {
x, y := pt(d.px[active], d.pl[active])
p.Line(x, plot.Y, x, plot.Y+plot.H, 1, k.TextMuted)
internal.Marker(p, 0, x, y, 13, k.Surface)
internal.Marker(p, 0, x, y, 9, k.AccentText)
}
}
res, _ := ch.Build(spec)
if len(d.be) > 0 && n > 0 {
parts := make([]string, len(d.be))
for i, b := range d.be {
parts[i] = format(b)
}
res.Element.Children(func() {
ui.Text(c, core.Msg(c, "payoff_diagram.breakevens")+" "+strings.Join(parts, ", ")).FontSize(size).TextColor(k.TextMuted)
})
}
return res
}
func payoffRange(legs []OptionLeg, opts PayoffDiagramOptions) (lo, hi float64) {
if opts.Low != 0 || opts.High != 0 {
if !(opts.High > opts.Low) || opts.Low < 0 {
panic("mujica: PayoffDiagram needs 0 ≤ Low < High")
}
return opts.Low, opts.High
}
lo, hi = math.Inf(1), math.Inf(-1)
for _, l := range legs {
ref := l.Strike
if l.Kind == LegStock {
ref = l.Premium
}
lo, hi = min(lo, ref), max(hi, ref)
}
if opts.Spot > 0 {
lo, hi = min(lo, opts.Spot), max(hi, opts.Spot)
}
if math.IsInf(lo, 1) || hi <= 0 {
return 0, 100
}
return lo * 0.7, hi * 1.3
}
+111
View File
@@ -0,0 +1,111 @@
package finance
import (
"slices"
"strings"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/chart"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
func bullSpread() []OptionLeg {
return []OptionLeg{{Kind: LegCall, Strike: 100, Premium: 3, Quantity: 1}, {Kind: LegCall, Strike: 110, Premium: 1, Quantity: -1}}
}
func TestPayoffMath(t *testing.T) {
legs := bullSpread()
for _, tc := range []struct{ price, want float64 }{{90, -2}, {100, -2}, {102, 0}, {110, 8}, {130, 8}} {
if got := PayoffAt(legs, tc.price); got != tc.want {
t.Errorf("payoff at %v = %v, want %v", tc.price, got, tc.want)
}
}
if got := Breakevens(legs, 70, 140); !slices.Equal(got, []float64{102}) {
t.Fatalf("breakevens %v", got)
}
straddle := []OptionLeg{{Kind: LegCall, Strike: 100, Premium: 4, Quantity: 1}, {Kind: LegPut, Strike: 100, Premium: 3, Quantity: 1}}
if got := Breakevens(straddle, 50, 150); !slices.Equal(got, []float64{93, 107}) {
t.Fatalf("straddle breakevens %v", got)
}
covered := []OptionLeg{{Kind: LegStock, Premium: 50, Quantity: 100}, {Kind: LegCall, Strike: 55, Premium: 2, Quantity: -100}}
if got := PayoffAt(covered, 60); got != 700 {
t.Fatalf("covered call at 60 = %v", got)
}
}
func TestPayoffDiagramKeysAndText(t *testing.T) {
var last chart.ChartResult
tt := chatTester(640, 380, func(c *ui.Context) {
last = PayoffDiagram(c, bullSpread(), PayoffDiagramOptions{Frame: chart.ChartFrame{Label: "Spread"}, Spot: 103, Low: 90, High: 120, Points: 31})
})
if !tt.HasText("Breakeven: 102") {
t.Fatalf("texts %q", tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnd)
if last.Point != 30 || !tt.HasText("Price 120 · profit 8") {
t.Fatalf("End: point %d texts %q", last.Point, tt.Texts())
}
tt.Key(0, ui.KeyHome)
if !tt.HasText("Price 90 · loss -2") {
t.Fatalf("Home: %q", tt.Texts())
}
ann := strings.Join(tt.Announcements(), "|")
if !strings.Contains(ann, "Price 90") {
t.Fatalf("announcements %q", ann)
}
}
func TestPayoffDiagramEmptyAndBadRange(t *testing.T) {
tt := chatTester(640, 380, func(c *ui.Context) {
PayoffDiagram(c, nil, PayoffDiagramOptions{Frame: chart.ChartFrame{Label: "Empty"}})
})
if !tt.HasText("No data") {
t.Fatalf("texts %q", tt.Texts())
}
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.Render(func(c *ui.Context) {
core.Use(c, core.Settings{})
PayoffDiagram(c, bullSpread(), PayoffDiagramOptions{Frame: chart.ChartFrame{Label: "x"}, Low: 10, High: 5})
}, 100, 100, 1)
}
func TestPayoffDiagramSnapshot(t *testing.T) {
snapshot(t, "payoff_diagram", 640, 360, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
PayoffDiagram(c, bullSpread(), PayoffDiagramOptions{Frame: chart.ChartFrame{Label: "Bull call spread"}, Spot: 103})
})
})
}
func TestPayoffDiagramSparseGridKeepsPeak(t *testing.T) {
legs := []OptionLeg{{Kind: LegCall, Strike: 90, Premium: 1, Quantity: 1}, {Kind: LegCall, Strike: 100, Quantity: -2}, {Kind: LegCall, Strike: 110, Quantity: 1}}
tt := chatTester(640, 380, func(c *ui.Context) {
PayoffDiagram(c, legs, PayoffDiagramOptions{Frame: chart.ChartFrame{Label: "Butterfly"}, Low: 80, High: 120, Points: 2})
})
r, ok := tt.Find("Butterfly")
if !ok {
t.Fatal("plot missing")
}
x := int(r.X + 56 + (r.W-68)/2)
found := false
ink := theme.Light().AccentText
for y := int(r.Y + 8); y < int(r.Y+r.H/2); y++ {
for dx := -2; dx <= 2; dx++ {
pixel := tt.Image().RGBAAt(x+dx, y)
if pixel.R == ink.R && pixel.G == ink.G && pixel.B == ink.B {
found = true
}
}
}
if !found {
t.Fatal("profit peak falls outside the plot")
}
}
+187
View File
@@ -0,0 +1,187 @@
package finance
import (
"fmt"
"math"
"strings"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/chart"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// PerformanceChartOptions configure a PerformanceChart.
type PerformanceChartOptions struct {
Frame chart.ChartFrame
Version int
// Times dates each value; required, ascending.
Times []time.Time
// Benchmark is compared against the portfolio; nil shows none.
Benchmark []float64
Name, BenchmarkName string
}
type Drawdown struct {
Peak, Trough int
Percent float64
}
func MaxDrawdown(values []float64) Drawdown {
var dd Drawdown
peak := 0
for i, v := range values {
if v > values[peak] {
peak = i
}
if f := (v/values[peak] - 1) * 100; f < dd.Percent {
dd = Drawdown{Peak: peak, Trough: i, Percent: f}
}
}
return dd
}
func PerformanceChart(c *ui.Context, values []float64, opts PerformanceChartOptions) chart.ChartResult {
if opts.Benchmark != nil && len(opts.Benchmark) != len(values) {
panic("mujica: PerformanceChart Benchmark length differs")
}
if len(opts.Times) != len(values) {
panic("mujica: PerformanceChart Times length differs")
}
ch := chart.NewChart(c, opts.Frame)
k := core.Tokens(c)
pal := internal.Palette(k)
size := core.FontSize(c, theme.CaptionSize)
name := opts.Name
if name == "" {
name = core.Msg(c, "performance_chart.portfolio")
}
bname := opts.BenchmarkName
if bname == "" {
bname = core.Msg(c, "performance_chart.benchmark")
}
if opts.Benchmark != nil && name == bname {
panic("mujica: PerformanceChart series names must differ")
}
series := chart.ChartMemo(ch, "returns", [4]string{chart.ChartID(values), chart.ChartID(opts.Benchmark), name + fmt.Sprint(opts.Version), bname}, func() []chart.ChartSeries {
for _, vs := range [][]float64{values, opts.Benchmark} {
for _, v := range vs {
if !(v > 0) || math.IsInf(v, 0) {
panic("mujica: PerformanceChart values must be positive")
}
}
}
out := []chart.ChartSeries{{Name: name, Values: performanceReturns(values)}}
if opts.Benchmark != nil {
out = append(out, chart.ChartSeries{Name: bname, Values: performanceReturns(opts.Benchmark)})
}
return out
})
n := len(values)
ax := chart.ChartMemo(ch, "x", chart.ChartID(opts.Times)+fmt.Sprint(opts.Version), func() chart.ChartX { return chart.NewChartX(n, nil, opts.Times) })
dd := chart.ChartMemo(ch, "dd", chart.ChartID(values)+fmt.Sprint(opts.Version), func() Drawdown { return MaxDrawdown(values) })
lo, hi, ok := chart.ChartYRange(ch, series, true, nil)
if !ok {
n = 0
}
keys := make([]chart.ChartLegendItem, len(series))
for i, s := range series {
keys[i] = chart.ChartLegendItem{Name: s.Name, Color: pal[i], Shape: i}
}
var ticks []float64
var step float64
if n > 0 {
lo, hi, step = internal.Nice(lo, hi, 4)
ticks = internal.Ticks(lo, hi, step)
}
pct := func(v float64) string { return data.StatisticSigned(v, 2) + "%" }
geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) {
plot := chart.ChartInset(r, 56, 26)
return plot, ax.Scale(plot), internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
}
spec := chart.ChartSpec{Marks: n, Legend: keys, Hidden: ch.Toggles(true)}
spec.Pick = func(r ui.Rect, x, y float32) int {
plot, xs, _ := geom(r)
if !plot.Contains(x, y) {
return -1
}
return ax.Nearest(xs.Invert(x))
}
spec.Readout = func(i int) string {
parts := []string{opts.Times[i].Format("2006-01-02")}
for _, s := range series {
if !ch.Hidden(s.Name) {
parts = append(parts, s.Name+" "+pct(s.Values[i]))
}
}
return strings.Join(parts, " · ")
}
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) {
plot, xs, ys := geom(r)
x := func(i int) float32 { return internal.Snap(p, xs.Map(ax.Vals[i])) }
if dd.Percent < 0 && !ch.Hidden(name) {
band := ui.Rect{X: x(dd.Peak), Y: plot.Y, W: x(dd.Trough) - x(dd.Peak), H: plot.H}
p.Fill(band, core.TrendColor(c, -1).Alpha(0.1), 0)
p.Line(band.X, plot.Y, band.X, plot.Y+plot.H, 1, core.TrendColor(c, -1).Alpha(0.5))
p.Line(band.X+band.W, plot.Y, band.X+band.W, plot.Y+plot.H, 1, core.TrendColor(c, -1).Alpha(0.5))
chart.ChartText(p, fmt.Sprintf(core.Msg(c, "performance_chart.drawdown"), pct(dd.Percent)), band.X+4, plot.Y+2, size, chart.ChartInk(k, k.Surface), 0, 0)
}
internal.AxisY(p, plot, ys, ticks, func(v float64) string { return internal.FormatTick(v, step) + "%" }, size, k)
tx, tl := ax.Ticks(plot)
internal.AxisX(p, plot, tx, tl, size, k)
zero := internal.Snap(p, ys.Map(0))
p.Line(plot.X, zero, plot.X+plot.W, zero, 1, k.ControlBorder)
for si, s := range series {
if ch.Hidden(s.Name) {
continue
}
var line ui.Path
idx := chart.ChartMemo(ch, "performance.geometry."+s.Name, [3]any{chart.ChartID(s.Values), chart.ChartID(ax.Vals), plot.W}, func() []int {
return internal.Columns(n, func(i int) float32 { return xs.Map(ax.Vals[i]) - plot.X }, func(i int) float64 { return s.Values[i] })
})
for j, i := range idx {
if j == 0 {
line.MoveTo(x(i), internal.Snap(p, ys.Map(s.Values[i])))
} else {
line.LineTo(x(i), internal.Snap(p, ys.Map(s.Values[i])))
}
}
p.StrokePath(&line, 2, keys[si].Color)
every := max(1, int(float32(n)/max(plot.W/48, 1)))
for i := 0; i < n; i += every {
internal.Marker(p, si, x(i), ys.Map(s.Values[i]), 7, keys[si].Color)
}
}
if active >= 0 {
p.Line(x(active), plot.Y, x(active), plot.Y+plot.H, 1, k.TextMuted)
for si, s := range series {
if !ch.Hidden(s.Name) {
internal.Marker(p, si, x(active), ys.Map(s.Values[active]), 13, k.Surface)
internal.Marker(p, si, x(active), ys.Map(s.Values[active]), 9, keys[si].Color)
}
}
}
}
res, _ := ch.Build(spec)
if dd.Percent < 0 && n > 0 {
res.Element.Children(func() {
ui.Text(c, fmt.Sprintf(core.Msg(c, "performance_chart.drawdown_range"), pct(dd.Percent),
opts.Times[dd.Peak].Format("2006-01-02"), opts.Times[dd.Trough].Format("2006-01-02"))).FontSize(size).TextColor(k.TextMuted)
})
}
return res
}
// performanceReturns turns values into cumulative percent return from the first.
func performanceReturns(values []float64) []float64 {
out := make([]float64, len(values))
for i, v := range values {
out[i] = (v/values[0] - 1) * 100
}
return out
}
+72
View File
@@ -0,0 +1,72 @@
package finance
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/chart"
)
func TestPerformanceChartBenchmarkDrawdownAndKeys(t *testing.T) {
values := []float64{100, 110, 88, 120}
times := mktTimes(4)
var last chart.ChartResult
tt := chatTester(760, 420, func(c *ui.Context) {
last = PerformanceChart(c, values, PerformanceChartOptions{Frame: chart.ChartFrame{Label: "Performance"}, Times: times, Benchmark: []float64{100, 105, 108, 112}})
})
if dd := MaxDrawdown(values); dd.Peak != 1 || dd.Trough != 2 || math.Abs(dd.Percent+20) > 1e-9 {
t.Fatalf("drawdown %+v", dd)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnd)
if last.Point != 3 || !tt.HasText("2026-01-04 · Portfolio +20.00% · Benchmark +12.00%") {
t.Fatalf("point %d text %q", last.Point, tt.Texts())
}
_ = tt.Click("Benchmark")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnd)
if !tt.HasText("2026-01-04 · Portfolio +20.00%") {
t.Fatalf("legend %q", tt.Texts())
}
marketsPanic(t, func(c *ui.Context) {
PerformanceChart(c, []float64{-1}, PerformanceChartOptions{Frame: chart.ChartFrame{Label: "x"}})
})
}
func TestPerformanceChartSnapshot(t *testing.T) {
snapshot(t, "performance_chart", 720, 380, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(12).Fill().Children(func() {
PerformanceChart(c, mktSeries(120, 0), PerformanceChartOptions{Frame: chart.ChartFrame{Label: "Performance"}, Times: mktTimes(120), Benchmark: mktSeries(120, 1)})
})
})
}
func TestPerformanceChartRequiresTimes(t *testing.T) {
marketsPanic(t, func(c *ui.Context) {
PerformanceChart(c, []float64{100, 110}, PerformanceChartOptions{Frame: chart.ChartFrame{Label: "x"}})
})
}
func TestPerformanceChartDateVersion(t *testing.T) {
values := []float64{100, 105, 110}
times := mktTimes(3)
version := 0
var axis chart.ChartX
tt := chatTester(760, 420, func(c *ui.Context) {
r := PerformanceChart(c, values, PerformanceChartOptions{Frame: chart.ChartFrame{Label: "Performance"}, Times: times, Version: version})
memo := ui.Local(r.Element, "mujica.memo.x", func() chart.ChartMemoEntry { return chart.ChartMemoEntry{} })
axis = memo.Val.(chart.ChartX)
})
times[2] = times[0].AddDate(0, 0, 9)
values[2] = 120
version++
tt.Frame()
if got := axis.Vals[2]; got != float64(times[2].UnixNano())/1e9 {
t.Fatalf("axis time %v", got)
}
}
+84
View File
@@ -0,0 +1,84 @@
package finance
import (
"log/slog"
"math"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/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) core.ChoiceResult {
dec := core.PriceDecimals("PnLDisplay", opts.Decimals)
k := core.Tokens(c)
u := core.Density(c).Unit()
var r core.ChoiceResult
r.Element = ui.Row(c).Gap(u * 6).AlignItems(ui.Start).Wrap().Role(ui.RoleGroup).Label(core.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 := core.Msg(c, item.key)
sign := core.TrendSign(item.v)
text := data.StatisticSigned(item.v, dec)
if opts.Currency != "" {
text = pnlDisplaySign(sign) + opts.Currency + data.StatisticFormat(math.Abs(item.v), dec)
}
col := core.TrendColor(c, sign)
spoken := title + ", " + core.TrendWord(c, sign) + " " + text
if *hidden {
text, col, spoken = "••••", k.TextMuted, title+", "+core.Msg(c, "pnl_display.masked")
}
ui.Column(c).Gap(u).Label(spoken).Role(ui.RoleText).Children(func() {
ui.Text(c, title).FontSize(core.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, core.TrendIcon(sign)).Size(16, 16)
}
ui.Text(c, text).FontSize(core.FontSize(c, theme.H3Size)).FontFeatures("tnum").FontWeight(600).SingleLine()
})
})
}
name := core.Msg(c, "pnl_display.hide")
icon := icons.Must("eye-off")
if *hidden {
name, icon = core.Msg(c, "pnl_display.show"), icons.Must("eye")
}
b := core.IconAction(c, icon, name)
if b.Clicked() && !b.IsDisabled() {
*hidden = !*hidden
r = core.NewChoiceResult(r.Element, 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 ""
}
+45
View File
@@ -0,0 +1,45 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestPnLDisplaySignsAndMask(t *testing.T) {
hidden, n := false, 0
tt := tradingTester(core.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: "$"})
})
})
}
+125
View File
@@ -0,0 +1,125 @@
package finance
import (
"cmp"
"fmt"
"log/slog"
"math"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// PortfolioAccount is one account's value and profit.
type PortfolioAccount struct {
ID, Name string
Total float64
// DayPnL is today's profit; TotalReturn the profit since inception.
DayPnL, TotalReturn float64
Cost float64
}
// PortfolioSummaryOptions configure a PortfolioSummary.
type PortfolioSummaryOptions struct {
// Currency prefixes amounts; empty is "$".
Currency string
// Decimals of amounts; zero means 2.
Decimals int
Disabled bool
}
// PortfolioSummaryView is a built summary; Changed reports another account chosen.
func PortfolioSummary(c *ui.Context, account *string, accounts []PortfolioAccount, opts PortfolioSummaryOptions) core.ChoiceResult {
if account == nil {
panic("mujica: PortfolioSummary needs an account")
}
dec := core.PriceDecimals("PortfolioSummary", opts.Decimals)
cur := cmp.Or(opts.Currency, "$")
k := core.Tokens(c)
u := core.Density(c).Unit()
var sum PortfolioAccount
found := *account == ""
for _, a := range accounts {
if a.ID == "" {
panic("mujica: PortfolioAccount needs an ID")
}
if *account == "" || a.ID == *account {
found = found || a.ID == *account
sum.Total += a.Total
sum.DayPnL += a.DayPnL
sum.TotalReturn += a.TotalReturn
sum.Cost += a.Cost
}
}
if !found {
panic("mujica: PortfolioSummary account " + *account + " is not listed")
}
before := *account
v := core.ChoiceResult{}
v.Element = ui.Column(c).Gap(u*3).Padding(u*4).Background(k.Surface).Border(theme.BorderWidth, k.Border).
Radius(theme.CardRadius).Disabled(opts.Disabled)
v.Element.Children(func() {
if len(accounts) > 1 {
items := []input.RadioItem[string]{{Value: "", Label: core.Msg(c, "portfolio_summary.all")}}
for _, a := range accounts {
items = append(items, input.RadioItem[string]{Value: a.ID, Label: a.Name})
}
input.RadioGroup(c, account, items, input.RadioGroupOptions{Horizontal: true, Label: core.Msg(c, "portfolio_summary.accounts"), Disabled: opts.Disabled})
}
ui.Box(c).FillWidth().Height(9).Draw(func(p *ui.Painter, r ui.Rect) { internal.RuleDiamond(p, r, 6, 5, k.Ornament) })
ui.Row(c).Wrap().Gap(u * 8).Children(func() {
portfolioFigure(c, core.Msg(c, "portfolio_summary.total"), func() {
ui.Text(c, cur+data.StatisticFormat(sum.Total, dec)).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H1Size)).FontFeatures("tnum")
})
prev := sum.Total - sum.DayPnL
portfolioFigure(c, core.Msg(c, "portfolio_summary.day"), func() {
portfolioSigned(c, sum.DayPnL, cur, dec, portfolioPercent(sum.DayPnL, prev))
})
portfolioFigure(c, core.Msg(c, "portfolio_summary.return"), func() {
portfolioSigned(c, sum.TotalReturn, cur, dec, portfolioPercent(sum.TotalReturn, sum.Cost))
})
})
})
v = core.NewChoiceResult(v.Element, *account != before && !v.Element.IsDisabled())
if v.Changed() {
slog.Debug("mujica: portfolio_summary account", "id", *account)
}
return v
}
func portfolioFigure(c *ui.Context, title string, value func()) {
ui.Column(c).Gap(2).Role(ui.RoleGroup).Label(title).Children(func() {
ui.Text(c, title).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(core.Tokens(c).TextMuted)
value()
})
}
func portfolioSigned(c *ui.Context, v float64, cur string, dec int, pct float64) {
sign := core.TrendSign(v)
text := cur + data.StatisticFormat(math.Abs(v), dec)
if sign > 0 {
text = "+" + text
} else if sign < 0 {
text = "−" + text
}
ui.Row(c).Gap(4).AlignItems(ui.Center).TextColor(core.TrendColor(c, sign)).Label(core.TrendWord(c, sign) + " " + text).Children(func() {
ui.Icon(c, core.TrendIcon(sign)).Size(16, 16)
ui.Text(c, text).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600).FontFeatures("tnum")
if !math.IsNaN(pct) {
ui.Text(c, fmt.Sprintf("(%s%%)", data.StatisticSigned(pct, 2))).FontFeatures("tnum")
}
})
}
func portfolioPercent(part, base float64) float64 {
if base == 0 {
return math.NaN()
}
return part / base * 100
}
+46
View File
@@ -0,0 +1,46 @@
package finance
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
)
func TestPortfolioSummaryAccountsAndTotals(t *testing.T) {
account := ""
disabled := false
changed := false
rows := []PortfolioAccount{{ID: "a", Name: "Brokerage", Total: 120000, DayPnL: 1250, TotalReturn: 20000, Cost: 100000}, {ID: "b", Name: "Retirement", Total: 80000, DayPnL: -300, TotalReturn: 5000, Cost: 75000}}
tt := chatTester(960, 300, func(c *ui.Context) {
r := PortfolioSummary(c, &account, rows, PortfolioSummaryOptions{Disabled: disabled})
changed = changed || r.Changed()
})
if !tt.HasText("$200,000.00") || !tt.HasText("+$950.00") || !tt.HasText("+$25,000.00") {
t.Fatalf("totals %q", tt.Texts())
}
_ = tt.Click("Retirement")
tt.Frame()
if account != "b" || !changed || !tt.HasText("$80,000.00") {
t.Fatalf("account %s texts %q", account, tt.Texts())
}
disabled = true
tt.Frame()
_ = tt.Click("Brokerage")
if account != "b" {
t.Fatal("disabled changed")
}
if !math.IsNaN(portfolioPercent(2, 0)) {
t.Fatal("zero base")
}
}
func TestPortfolioSummarySnapshot(t *testing.T) {
acct := ""
snapshot(t, "portfolio_summary", 760, 260, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(12).Fill().Children(func() {
PortfolioSummary(c, &acct, []PortfolioAccount{{ID: "a", Name: "Brokerage", Total: 120000, DayPnL: 1250, TotalReturn: 20000, Cost: 100000},
{ID: "b", Name: "Retirement", Total: 80000, DayPnL: -300, TotalReturn: 5000, Cost: 75000}}, PortfolioSummaryOptions{})
})
})
}
+111
View File
@@ -0,0 +1,111 @@
package finance
import (
"cmp"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
// 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 data.DataStatus
Error string
Disabled bool
}
// TableActionResult is a trading table and its acted row.
type TableActionResult struct {
Element *ui.Element
Table data.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 *data.DataTableState[string], rows []PositionRow, opts PositionTableOptions) TableActionResult {
dec := core.PriceDecimals("PositionTable", opts.Decimals)
var r TableActionResult
num := func(id, key string, v func(PositionRow) float64) data.DataColumn[PositionRow] {
return data.DataColumn[PositionRow]{ID: id, Title: core.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 data.StatisticFormat(v(p), dec) }}
}
pnl := num("pnl", "position_table.pnl", PositionRow.Unrealized)
pnl.Cell = func(p PositionRow) { tradingSigned(c, p.Unrealized(), data.StatisticSigned(p.Unrealized(), dec)) }
pct := num("pct", "position_table.pnl_percent", PositionRow.UnrealizedPercent)
pct.Cell = func(p PositionRow) {
tradingSigned(c, p.UnrealizedPercent(), data.StatisticSigned(p.UnrealizedPercent(), 2)+"%")
}
cols := []data.DataColumn[PositionRow]{
{ID: "symbol", Title: core.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: core.Msg(c, "position_table.action"), Width: 96,
Text: func(PositionRow) string { return "" },
Cell: func(p PositionRow) {
if input.Button(c, core.Msg(c, "position_table.close"), input.ButtonOptions{Variant: input.Secondary}).Label(core.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 = data.DataTable(c, s, rows, data.DataTableOptions[PositionRow, string]{Columns: cols, Key: func(p PositionRow) string { return p.ID },
Version: opts.Version, Status: opts.Status, Error: opts.Error, Empty: core.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 := core.TrendSign(v)
ui.Row(c).Gap(2).AlignItems(ui.Center).Justify(ui.End).FillWidth().TextColor(core.TrendColor(c, sign)).Children(func() {
ui.Icon(c, core.TrendIcon(sign)).Size(12, 12)
ui.Text(c, text).FontFeatures("tnum").FontWeight(600).SingleLine()
})
}
+87
View File
@@ -0,0 +1,87 @@
package finance
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
)
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 data.DataTableState[string]
rows := positionSample()
closed := ""
tt := tradingTester(core.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 data.DataTableState[string]
rows := positionSample()
tt := tradingTester(core.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 data.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(core.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() data.DataTableState[string] { return data.DataTableState[string]{} })
PositionTable(c, s, positionSample(), PositionTableOptions{})
})
})
}
+39
View File
@@ -0,0 +1,39 @@
package finance
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/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 := core.PriceDecimals("PriceChangeBadge", opts.Decimals)
sign := core.TrendSign(change)
if sign == 0 {
sign = core.TrendSign(percent)
}
col := core.TrendColor(c, sign)
u := core.Density(c).Unit()
text := data.StatisticSigned(change, dec)
if !opts.HidePercent {
text += " (" + data.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(core.TrendWord(c, sign) + " " + text)
e.Children(func() {
ui.Icon(c, core.TrendIcon(sign)).Size(14, 14)
ui.Text(c, text).FontSize(core.FontSize(c, theme.CaptionSize)).FontFeatures("tnum").FontWeight(600).SingleLine()
})
return e
}
+37
View File
@@ -0,0 +1,37 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestPriceChangeBadgeSignsAndWords(t *testing.T) {
tt := tradingTester(core.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{})
})
})
}
+84
View File
@@ -0,0 +1,84 @@
package finance
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// 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 := core.PriceDecimals("PriceText", opts.Decimals)
if opts.Size < 0 {
panic("mujica: PriceText with negative Size")
}
size := opts.Size
if size == 0 {
size = theme.BodySize
}
text := data.StatisticFormat(value, dec)
if opts.NoGrouping {
text = core.PriceUngrouped(value, dec)
}
e := ui.Row(c).Gap(core.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(), core.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 = core.TrendSign(value - opts.Reference)
}
col := core.Tokens(c).Text
spoken := text
if opts.Compare {
col = core.TrendColor(c, sign)
spoken += ", " + core.TrendWord(c, sign)
}
if d := core.Motion(c, priceTextFlash); d > 0 && !opts.NoFlash && !st.at.IsZero() {
if t := c.Now().Sub(st.at); t < d {
e.Background(core.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, core.TrendIcon(sign)).Size(size*0.85, size*0.85)
}
ui.Text(c, text).FontSize(core.FontSize(c, size)).FontFeatures("tnum").FontWeight(500).SingleLine()
})
return e
}
+107
View File
@@ -0,0 +1,107 @@
package finance
import (
"bytes"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// tradingTester runs view in the default theme with trend t.
func tradingTester(t core.Trend, w, h int, view func(c *ui.Context)) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
core.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(core.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(core.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 []core.Trend{core.GreenUp, core.RedUp} {
tradingTester(tr, 100, 100, func(c *ui.Context) {
up[i], down[i] = core.TrendColor(c, 1), core.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(core.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(core.GreenUp, 200, 60, func(c *ui.Context) { PriceText(c, price, PriceTextOptions{}) })
tt.SetPreferences(prefs)
price = 10.5
tt.Frame()
fresh := tradingTester(core.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(core.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})
})
})
}
+131
View File
@@ -0,0 +1,131 @@
package finance
import (
"fmt"
"log/slog"
"slices"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/navigation"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
"github.com/ZacharyZhang-NY/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 := core.Density(c).Unit()
var r QuickTradeResult
r.Element = ui.Column(c).Gap(u * 2).Disabled(opts.Disabled).Role(ui.RoleGroup).Label(core.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 := data.StatisticFormat(*quantity, opts.Decimals)
r.Element.Children(func() {
var segs []navigation.Segment
for _, p := range presets {
segs = append(segs, navigation.Segment{Label: data.StatisticFormat(p, opts.Decimals)})
}
if navigation.SegmentedControl(c, &at, segs, navigation.SegmentedControlOptions{Equal: true, Label: core.Msg(c, "quick_trade_buttons.quantity"), Disabled: disabled}).Changed() {
*quantity = presets[at]
qty = data.StatisticFormat(*quantity, opts.Decimals)
}
ui.Row(c).Gap(u * 2).Children(func() {
for _, side := range []OrderSide{SideBuy, SideSell} {
label := fmt.Sprintf(core.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 := overlay.Popconfirm(c, b, &open, overlay.PopconfirmOptions{
Title: fmt.Sprintf(core.Msg(c, fmt.Sprintf("quick_trade_buttons.confirm%d", side)), qty, opts.Symbol),
Description: core.Msg(c, "quick_trade_buttons.market"),
ConfirmLabel: core.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 := core.Tokens(c)
d := core.Density(c)
col := core.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, core.TrendIcon(side.sign())).Size(16, 16)
ui.Text(c, label).FontWeight(700).SingleLine()
})
return b
}
+88
View File
@@ -0,0 +1,88 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
type quickTradeProbe struct {
qty float64
opts QuickTradeButtonsOptions
trades []OrderSide
sizes []float64
}
func (p *quickTradeProbe) tester() *ui.Tester {
return tradingTester(core.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"})
})
})
}
+53
View File
@@ -0,0 +1,53 @@
package finance
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/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 := core.PriceDecimals("QuoteCard", opts.Decimals)
k := core.Tokens(c)
u := core.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(core.FontSize(c, theme.H3Size)).FontWeight(600).SingleLine()
if q.Name != "" {
ui.Text(c, q.Name).TextColor(k.TextMuted).FontSize(core.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
}
+45
View File
@@ -0,0 +1,45 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestQuoteCardShowsQuoteAndChartSlot(t *testing.T) {
charted := false
tt := tradingTester(core.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(core.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 := core.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)
})
}})
})
})
}
+85
View File
@@ -0,0 +1,85 @@
package finance
import (
"fmt"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/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: core.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 := core.Tokens(c)
u := core.Density(c).Unit()
label := opts.Label
if label == "" {
label = core.Msg(c, "risk_meter.label")
}
cur := levels[level-1]
spoken := fmt.Sprintf(core.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(core.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(core.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
}
+44
View File
@@ -0,0 +1,44 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
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(core.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(core.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"}}})
})
})
}
+208
View File
@@ -0,0 +1,208 @@
package finance
import (
"cmp"
"fmt"
"log/slog"
"math"
"slices"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/chart"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
type ScreenerField struct {
ID, Label string
Decimals int
}
type ScreenerOp int
const (
ScreenerAbove ScreenerOp = iota
ScreenerAtLeast
ScreenerBelow
ScreenerAtMost
ScreenerEqual
)
var screenerOps = [5]string{">", "≥", "<", "≤", "="}
// ScreenerCondition is one test: Field Op Value.
type ScreenerCondition struct {
Field string
Op ScreenerOp
Value float64
}
type ScreenerRow struct {
Symbol, Name string
Values map[string]float64
}
// ScreenerMatch reports whether row passes every condition.
func ScreenerMatch(row ScreenerRow, conds []ScreenerCondition) bool {
for _, cd := range conds {
v, ok := row.Values[cd.Field]
if !ok || math.IsNaN(v) {
return false
}
pass := [5]bool{v > cd.Value, v >= cd.Value, v < cd.Value, v <= cd.Value, v == cd.Value}
if cd.Op < ScreenerAbove || cd.Op > ScreenerEqual {
panic("mujica: ScreenerOp out of range")
}
if !pass[cd.Op] {
return false
}
}
return true
}
// ScreenerState is the conditions and the result table's state.
type ScreenerState struct {
Conditions []ScreenerCondition
Table data.DataTableState[string]
cache screenerCache
}
type screenerCache struct {
key string
rows []ScreenerRow
}
// ScreenerOptions configure a Screener.
type ScreenerOptions struct {
// Version must change when rows change in place.
Version int
Status data.DataStatus
Error string
Disabled bool
}
// ScreenerView is a built Screener and this frame's events.
type ScreenerView struct {
Element *ui.Element
Changed, Save bool
Table data.DataTableView
}
func Screener(c *ui.Context, s *ScreenerState, fields []ScreenerField, rows []ScreenerRow, opts ScreenerOptions) ScreenerView {
if s == nil || len(fields) == 0 {
panic("mujica: Screener needs a state and fields")
}
seen := map[string]bool{}
for _, f := range fields {
if f.ID == "" || seen[f.ID] || f.Decimals < 0 || f.Decimals > 12 {
panic("mujica: Screener fields need unique IDs and decimals in 0..12")
}
seen[f.ID] = true
}
for _, cd := range s.Conditions {
if cd.Op < ScreenerAbove || cd.Op > ScreenerEqual || math.IsNaN(cd.Value) || math.IsInf(cd.Value, 0) {
panic("mujica: Screener condition is invalid")
}
}
k := core.Tokens(c)
u := core.Density(c).Unit()
before := fmt.Sprint(s.Conditions)
var v ScreenerView
v.Element = ui.Column(c).Gap(u * 3).Grow(1).MinHeight(0).Disabled(opts.Disabled)
opts.Disabled = v.Element.IsDisabled()
if opts.Disabled {
copy := *s
copy.Conditions = slices.Clone(s.Conditions)
s = &copy
}
v.Element.Children(func() {
fieldItems := make([]input.SelectOption[string], len(fields))
for i, f := range fields {
fieldItems[i] = input.SelectOption[string]{Value: f.ID, Label: f.Label}
}
opItems := make([]input.SelectOption[ScreenerOp], len(screenerOps))
for i, o := range screenerOps {
opItems[i] = input.SelectOption[ScreenerOp]{Value: ScreenerOp(i), Label: o + " " + core.Msg(c, fmt.Sprintf("screener.op_%d", i))}
}
ui.Column(c).Gap(u*2).Padding(u*3).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).
Role(ui.RoleGroup).Label(core.Msg(c, "screener.conditions")).Children(func() {
if len(s.Conditions) == 0 {
ui.Text(c, core.Msg(c, "screener.none")).TextColor(k.TextMuted)
}
for i := 0; i < len(s.Conditions); i++ {
cd := &s.Conditions[i]
f := screenerField(fields, cd.Field)
removed := false
ui.Row(c).Key(i).Gap(u * 2).AlignItems(ui.Center).Children(func() {
input.Select(c, &cd.Field, fieldItems, input.SelectOptions{Label: core.Msg(c, "screener.field"), Disabled: opts.Disabled})
input.Select(c, &cd.Op, opItems, input.SelectOptions{Label: core.Msg(c, "screener.compare"), Disabled: opts.Disabled}).Element.MinWidth(120)
input.NumberInput(c, &cd.Value, input.NumberInputOptions{Step: math.Pow(10, -float64(f.Decimals)), Precision: f.Decimals,
Label: core.Msg(c, "screener.value"), Disabled: opts.Disabled}).Element.Width(150)
x := core.IconAction(c, icons.Must("x"), fmt.Sprintf(core.Msg(c, "screener.remove"), i+1))
removed = x.Clicked() && !x.IsDisabled()
})
if removed {
s.Conditions = slices.Delete(slices.Clone(s.Conditions), i, i+1)
i--
}
}
ui.Row(c).Gap(u * 2).Children(func() {
if input.Button(c, core.Msg(c, "screener.add"), input.ButtonOptions{Variant: input.Secondary, Icon: icons.Must("plus"), Disabled: opts.Disabled}).Clicked() {
s.Conditions = append(s.Conditions, ScreenerCondition{Field: fields[0].ID})
}
v.Save = input.Button(c, core.Msg(c, "screener.save"), input.ButtonOptions{Variant: input.Ghost, Disabled: opts.Disabled}).Clicked()
})
})
matched := screenerFilter(s, rows, opts.Version)
ui.Text(c, fmt.Sprintf(core.Msg(c, "screener.matches"), len(matched), len(rows))).FontSize(core.FontSize(c, theme.CaptionSize)).
TextColor(k.TextMuted).Role(ui.RoleStatus)
cols := []data.DataColumn[ScreenerRow]{{ID: "symbol", Title: core.Msg(c, "screener.symbol"), Sortable: true,
Compare: func(a, b ScreenerRow) int { return cmp.Compare(a.Symbol, b.Symbol) },
Text: func(r ScreenerRow) string { return r.Symbol }}}
for _, f := range fields {
cols = append(cols, data.DataColumn[ScreenerRow]{ID: f.ID, Title: f.Label, Align: ui.End, Sortable: true,
Compare: func(a, b ScreenerRow) int { return cmp.Compare(a.Values[f.ID], b.Values[f.ID]) },
Text: func(r ScreenerRow) string { return data.StatisticFormat(r.Values[f.ID], f.Decimals) }})
}
v.Table = data.DataTable(c, &s.Table, matched, data.DataTableOptions[ScreenerRow, string]{Columns: cols, Key: func(r ScreenerRow) string { return r.Symbol },
Version: opts.Version, Status: opts.Status, Error: opts.Error, Empty: core.Msg(c, "screener.empty")})
v.Table.Element.Grow(1)
})
v.Changed = fmt.Sprint(s.Conditions) != before
if v.Element.IsDisabled() {
v.Save = false
}
if v.Changed || v.Save {
slog.Debug("mujica: screener conditions", "count", len(s.Conditions), "save", v.Save)
}
return v
}
func screenerField(fields []ScreenerField, id string) ScreenerField {
for _, f := range fields {
if f.Decimals < 0 {
panic("mujica: ScreenerField Decimals is negative")
}
if f.ID == id {
return f
}
}
panic("mujica: Screener condition on unknown field " + id)
}
func screenerFilter(s *ScreenerState, rows []ScreenerRow, version int) []ScreenerRow {
key := fmt.Sprint(chart.ChartID(rows), version, s.Conditions)
if key != s.cache.key {
s.cache.key, s.cache.rows = key, nil
for _, r := range rows {
if ScreenerMatch(r, s.Conditions) {
s.cache.rows = append(s.cache.rows, r)
}
}
}
return s.cache.rows
}
+60
View File
@@ -0,0 +1,60 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestScreenerConditionsResultsSave(t *testing.T) {
s := ScreenerState{Conditions: []ScreenerCondition{{Field: "pe", Op: ScreenerBelow, Value: 30}}}
fields := []ScreenerField{{ID: "pe", Label: "P/E", Decimals: 1}}
rows := []ScreenerRow{{Symbol: "AAPL", Values: map[string]float64{"pe": 34}}, {Symbol: "XOM", Values: map[string]float64{"pe": 13}}}
var got ScreenerView
disabled := false
tt := chatTester(800, 500, func(c *ui.Context) {
v := Screener(c, &s, fields, rows, ScreenerOptions{Disabled: disabled})
v.Element.Height(460)
if v.Changed || v.Save {
got = v
}
})
if s.Table.Rows() != 1 || !tt.HasText("XOM") {
t.Fatalf("rows %d", s.Table.Rows())
}
_ = tt.Click("Save conditions")
if !got.Save {
t.Fatal("save missing")
}
_ = tt.Click("Add condition")
if len(s.Conditions) != 2 || !got.Changed {
t.Fatalf("conditions %v", s.Conditions)
}
disabled = true
tt.Frame()
_ = tt.Click("Add condition")
if len(s.Conditions) != 2 {
t.Fatal("disabled added")
}
for op := ScreenerAbove; op <= ScreenerEqual; op++ {
want := []bool{false, true, false, true, true}[op]
if got := ScreenerMatch(ScreenerRow{Values: map[string]float64{"x": 10}}, []ScreenerCondition{{Field: "x", Op: op, Value: 10}}); got != want {
t.Fatalf("op %d %v", op, got)
}
}
if ScreenerMatch(ScreenerRow{}, []ScreenerCondition{{Field: "x", Op: ScreenerAbove, Value: 0}}) {
t.Fatal("missing passed")
}
}
func TestScreenerSnapshot(t *testing.T) {
snapshot(t, "screener", 820, 480, func(c *ui.Context) {
sc := ui.Local(c.Root(), "snapshot.screener", func() ScreenerState {
return ScreenerState{Conditions: []ScreenerCondition{{Field: "pe", Op: ScreenerBelow, Value: 30}}}
})
ui.Column(c).Padding(16).Gap(12).Fill().Children(func() {
Screener(c, sc, []ScreenerField{{ID: "pe", Label: "P/E", Decimals: 1}, {ID: "cap", Label: "Market cap (B)"}},
[]ScreenerRow{{Symbol: "AAPL", Values: map[string]float64{"pe": 34, "cap": 3000}}, {Symbol: "XOM", Values: map[string]float64{"pe": 13, "cap": 450}}}, ScreenerOptions{}).Element.Height(440)
})
})
}
+107
View File
@@ -0,0 +1,107 @@
package finance
import (
"fmt"
"math"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/chart"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// SentimentGaugeOptions configure a SentimentGauge.
type SentimentGaugeOptions struct {
// Label names the index, such as "Fear & Greed".
Label string
// Size is the dial width; zero is 240 DIPs.
Size float32
Status data.DataStatus
Error string
Empty bool
Retry func()
}
var sentimentBands = [5]string{"sentiment_gauge.extreme_fear", "sentiment_gauge.fear", "sentiment_gauge.neutral",
"sentiment_gauge.greed", "sentiment_gauge.extreme_greed"}
func sentimentBand(v float64) int { return min(4, int(v/20)) }
func SentimentGauge(c *ui.Context, value float64, opts SentimentGaugeOptions) *ui.Element {
if math.IsNaN(value) || value < 0 || value > 100 {
panic("mujica: SentimentGauge value must be in 0..100")
}
if math.IsNaN(float64(opts.Size)) || math.IsInf(float64(opts.Size), 0) || opts.Size != 0 && opts.Size < 48 {
panic("mujica: SentimentGauge Size is negative")
}
w := opts.Size
if w == 0 {
w = 240
}
k := core.Tokens(c)
u := core.Density(c).Unit()
dialHeight := max(w/2+8, u*30)
band := sentimentBand(value)
word := core.Msg(c, sentimentBands[band])
label := fmt.Sprintf("%.0f, %s", value, word)
if opts.Status == data.DataLoading || opts.Status == data.DataLoadingMore || opts.Status == data.DataUnloaded {
label = core.Msg(c, "list.loading")
} else if opts.Status == data.DataFailed {
label = data.DataError(c, opts.Error)
} else if opts.Empty {
label = core.Msg(c, "chart.empty")
}
if opts.Label != "" {
label = opts.Label + ": " + label
}
pal := internal.Palette(k)
cols := [5]ui.Color{pal[0], pal[3], pal[5], pal[1], pal[2]}
size := core.FontSize(c, theme.CaptionSize)
col := ui.Column(c).Gap(u).AlignItems(ui.Center).Role(ui.RoleMeter).Label(label).Focusable()
internal.Ring(col, k.Focus, theme.ControlRadius)
if !col.IsDisabled() {
for _, key := range []ui.Key{ui.KeyLeft, ui.KeyRight, ui.KeyUp, ui.KeyDown, ui.KeyHome, ui.KeyEnd} {
if col.Shortcut(0, key) {
c.Announce(label)
}
}
}
col.Children(func() {
if opts.Label != "" {
ui.Text(c, opts.Label).Font(theme.SerifFont).TextColor(k.TextMuted)
}
if opts.Status != data.DataReady || opts.Empty {
ui.Box(c).Size(w, dialHeight).Children(func() {
ui.Column(c).Fill().Children(func() {
if data.DataPlaceholder(c, opts.Status, opts.Error, "", core.Msg(c, "chart.empty")) && opts.Retry != nil {
opts.Retry()
}
})
})
ui.Box(c).Height(core.FontSize(c, theme.H1Size)*theme.LineHeight + u + core.FontSize(c, theme.BodySize)*theme.LineHeight)
return
}
ui.Box(c).Size(w, dialHeight).Draw(func(p *ui.Painter, r ui.Rect) {
cx, cy := r.X+r.W/2, r.Y+r.W/2
outer, inner := r.W/2-2, r.W/2-18
for i := range 5 {
a0 := -math.Pi/2 + float64(i)*math.Pi/5
p.FillPath(internal.Sector(cx, cy, inner, outer, a0+0.012, a0+math.Pi/5-0.012), cols[i])
}
a := -math.Pi/2 + value/100*math.Pi
tx, ty := internal.Polar(cx, cy, inner-6, a)
var needle ui.Path
needle.MoveTo(internal.Snap(p, cx), internal.Snap(p, cy)).LineTo(internal.Snap(p, tx), internal.Snap(p, ty))
p.StrokePath(&needle, 2.5, k.Text)
p.FillPath(internal.Diamond(internal.Snap(p, cx), internal.Snap(p, cy), 10), k.Text)
chart.ChartText(p, "0", r.X+2, cy+4, size, k.TextMuted, 0, 0)
chart.ChartText(p, "100", r.X+r.W-2, cy+4, size, k.TextMuted, 1, 0)
})
ui.Text(c, fmt.Sprintf("%.0f", value)).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H1Size)).TextColor(k.Text).FontFeatures("tnum")
ui.Text(c, word).FontWeight(600).TextColor(k.Text)
})
return col
}
+65
View File
@@ -0,0 +1,65 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/data"
)
func TestSentimentGaugeBandsAndBoundary(t *testing.T) {
for i, value := range []float64{0, 20, 40, 60, 80, 100} {
tt := chatTester(400, 300, func(c *ui.Context) { SentimentGauge(c, value, SentimentGaugeOptions{Label: "Sentiment"}) })
words := []string{"Extreme fear", "Fear", "Neutral", "Greed", "Extreme greed", "Extreme greed"}
if !tt.HasText(words[i]) {
t.Fatalf("value %v texts %q", value, tt.Texts())
}
}
marketsPanic(t, func(c *ui.Context) { SentimentGauge(c, 101, SentimentGaugeOptions{}) })
}
func TestSentimentGaugeSnapshot(t *testing.T) {
snapshot(t, "sentiment_gauge", 320, 280, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(12).Fill().Children(func() {
SentimentGauge(c, 72, SentimentGaugeOptions{Label: "Fear & Greed"})
})
})
}
func TestSentimentGaugeKeyboardReadout(t *testing.T) {
tt := chatTester(400, 300, func(c *ui.Context) { SentimentGauge(c, 72, SentimentGaugeOptions{Label: "Sentiment"}) })
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if len(tt.Announcements()) == 0 {
t.Fatal("missing keyboard readout")
}
}
func TestSentimentGaugeStatesKeepSize(t *testing.T) {
status := data.DataReady
empty := false
retried := 0
var element *ui.Element
tt := chatTester(400, 300, func(c *ui.Context) {
element = SentimentGauge(c, 72, SentimentGaugeOptions{Label: "Sentiment", Status: status, Empty: empty, Error: "Caller failed", Retry: func() { retried++ }})
})
height := element.Bounds().H
status = data.DataLoading
tt.Frame()
if element.Bounds().H != height {
t.Fatalf("loading height %v, ready %v", element.Bounds().H, height)
}
status = data.DataFailed
tt.Frame()
_ = tt.Click("Retry")
if retried != 1 {
t.Fatalf("retries %d", retried)
}
status = data.DataReady
empty = true
tt.Frame()
if !tt.HasText("No data") || element.Bounds().H != height {
t.Fatalf("empty height %v, ready %v", element.Bounds().H, height)
}
}
+55
View File
@@ -0,0 +1,55 @@
package finance
import (
"fmt"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/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 := core.PriceDecimals("SpreadIndicator", opts.Decimals)
k := core.Tokens(c)
u := core.Density(c).Unit()
spread := ask - bid
pct := 0.0
if mid := (ask + bid) / 2; mid > 0 {
pct = spread / mid * 100
}
text := fmt.Sprintf(core.Msg(c, "spread_indicator.value"), data.StatisticFormat(spread, dec), data.StatisticFormat(pct, 3))
note, col, icon := "", k.TextMuted, ""
switch {
case spread < 0:
note, col, icon = core.Msg(c, "spread_indicator.crossed"), k.Danger, "circle-alert"
case opts.WarnPercent > 0 && pct >= opts.WarnPercent:
note, col, icon = core.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, core.Msg(c, "spread_indicator.title")).TextColor(k.TextMuted).FontSize(core.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(core.FontSize(c, theme.CaptionSize)).FontWeight(600).SingleLine()
})
}
})
return e
}
+44
View File
@@ -0,0 +1,44 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestSpreadIndicatorValueAndThreshold(t *testing.T) {
bid, ask := 99.95, 100.05
tt := tradingTester(core.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(core.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})
})
})
}
+78
View File
@@ -0,0 +1,78 @@
package finance
import (
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// MarketSymbol is a tradable instrument as the caller lists it.
type MarketSymbol struct {
Code, Name string
// Market is the venue, such as NASDAQ or SSE.
Market string
Type string
}
// SymbolBadgeOptions configure a SymbolBadge.
type SymbolBadgeOptions struct {
// Clickable makes the badge a button reporting clicks.
Clickable bool
Disabled bool
}
// SymbolBadge shows a code with its market and type tags.
func SymbolBadge(c *ui.Context, s MarketSymbol, opts SymbolBadgeOptions) core.ChoiceResult {
if s.Code == "" {
panic("mujica: SymbolBadge needs a Code")
}
k := core.Tokens(c)
u := core.Density(c).Unit()
var e *ui.Element
wrap := ui.Row(c).Shrink(0).Disabled(opts.Disabled)
wrap.Children(func() {
if opts.Clickable {
e = ui.ButtonBase(c)
internal.Ring(e, k.Focus, theme.ControlRadius)
if e.Hovered() {
e.Background(k.SurfaceHover)
}
e.Transition(core.ColorFade)
} else {
e = ui.Row(c).Role(ui.RoleText)
}
})
e.MinHeight(theme.MinHitSize).Gap(u).AlignItems(ui.Center).Padding(0, u).Radius(theme.ControlRadius).Shrink(0).
Label(symbolBadgeName(s))
e.Children(func() {
ui.Text(c, s.Code).Font(theme.MonoFont).FontWeight(700).TextColor(k.Text).SingleLine()
for _, tag := range []string{s.Market, s.Type} {
if tag != "" {
ui.Row(c).Padding(1, u).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.Border).Background(k.Background).Children(func() {
ui.Text(c, tag).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
})
}
}
})
clicked := opts.Clickable && e.Clicked() && !e.IsDisabled()
if clicked {
slog.Debug("mujica: symbol_badge click", "code", s.Code)
}
return core.NewChoiceResult(e, clicked)
}
// symbolBadgeName reads code, name, market and type.
func symbolBadgeName(s MarketSymbol) string {
var parts []string
for _, p := range []string{s.Code, s.Name, s.Market, s.Type} {
if p != "" {
parts = append(parts, p)
}
}
return strings.Join(parts, ", ")
}
+42
View File
@@ -0,0 +1,42 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestSymbolBadgeClickKeysAndDisabled(t *testing.T) {
disabled := false
count := 0
tt := chatTester(500, 150, func(c *ui.Context) {
if SymbolBadge(c, MarketSymbol{Code: "AAPL", Name: "Apple", Market: "NASDAQ", Type: "Stock"}, SymbolBadgeOptions{Clickable: true, Disabled: disabled}).Changed() {
count++
}
})
_ = tt.Click("AAPL")
tt.Key(0, ui.KeyEnter)
if count != 2 {
t.Fatalf("clicks %d", count)
}
disabled = true
tt.Frame()
_ = tt.Click("AAPL")
tt.Key(0, ui.KeyEnter)
if count != 2 {
t.Fatal("disabled activated")
}
marketsPanic(t, func(c *ui.Context) { SymbolBadge(c, MarketSymbol{}, SymbolBadgeOptions{}) })
}
func TestSymbolBadgeSnapshot(t *testing.T) {
snapshot(t, "symbol_badge", 420, 100, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(12).Fill().Children(func() {
ui.Row(c).Gap(8).Children(func() {
SymbolBadge(c, MarketSymbol{Code: "AAPL", Market: "NASDAQ", Type: "Stock"}, SymbolBadgeOptions{Clickable: true})
SymbolBadge(c, MarketSymbol{Code: "600519", Market: "SSE"}, SymbolBadgeOptions{})
})
})
})
}
+152
View File
@@ -0,0 +1,152 @@
package finance
import (
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
type SymbolSearchState struct {
Query, Market string
focusFirst bool
cursor int
}
// SymbolSearchOptions configure a SymbolSearch.
type SymbolSearchOptions struct {
// Markets are the filter choices; nil hides the filter.
Markets []string
// Recent shows while the query is empty.
Recent []MarketSymbol
// Loading and Error are the caller's search for the query.
Loading bool
Error string
Disabled bool
}
// SymbolSearchView is a built SymbolSearch and this frame's events.
type SymbolSearchView struct {
Element *ui.Element
Changed bool
// Chosen is the symbol picked when Picked is set.
Chosen MarketSymbol
Picked bool
Retry bool
}
func SymbolSearch(c *ui.Context, s *SymbolSearchState, results []MarketSymbol, opts SymbolSearchOptions) SymbolSearchView {
if s == nil {
panic("mujica: SymbolSearch needs a state")
}
k := core.Tokens(c)
u := core.Density(c).Unit()
var v SymbolSearchView
v.Element = ui.Column(c).Gap(u * 2).Disabled(opts.Disabled)
opts.Disabled = v.Element.IsDisabled()
if opts.Disabled {
copy := *s
s = &copy
}
v.Element.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
in := input.SearchInput(c, &s.Query, input.SearchInputOptions{Placeholder: core.Msg(c, "symbol_search.placeholder"), Disabled: opts.Disabled})
in.Element.Grow(1)
v.Changed = in.Changed()
if in.Input.Shortcut(0, ui.KeyDown) && !in.Input.IsDisabled() {
s.focusFirst = true
}
if opts.Markets != nil {
items := []input.SelectOption[string]{{Value: "", Label: core.Msg(c, "symbol_search.all_markets")}}
for _, m := range opts.Markets {
items = append(items, input.SelectOption[string]{Value: m, Label: m})
}
r := input.Select(c, &s.Market, items, input.SelectOptions{Label: core.Msg(c, "symbol_search.market"), Disabled: opts.Disabled})
r.Element.MinWidth(140)
v.Changed = v.Changed || r.Changed()
}
})
list, title := results, ""
switch {
case opts.Error != "":
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).TextColor(k.Danger).Children(func() {
ui.Icon(c, icons.Must("circle-alert")).Size(16, 16)
ui.Text(c, opts.Error).Role(ui.RoleStatus).Grow(1)
v.Retry = input.Button(c, core.Msg(c, "symbol_search.retry"), input.ButtonOptions{Variant: input.Secondary}).Clicked()
})
return
case opts.Loading && len(results) == 0:
core.Spinner(c, core.SpinnerOptions{Label: core.Msg(c, "symbol_search.searching"), ShowLabel: true})
return
case s.Query == "":
list, title = opts.Recent, core.Msg(c, "symbol_search.recent")
case len(results) == 0:
ui.Text(c, core.Msg(c, "symbol_search.no_results")).TextColor(k.TextMuted).Role(ui.RoleStatus)
return
}
if title != "" && len(list) > 0 {
ui.Text(c, title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
}
rows := ui.Local(v.Element, "mujica.symbol.rows", func() ui.ListState { return ui.ListState{} })
rows.Key = func(i int) any { return list[i].Market + ":" + list[i].Code }
rows.Label = func(i int) string { return symbolBadgeName(list[i]) }
rows.Selected = &s.cursor
if s.focusFirst {
s.cursor = 0
rows.ScrollTo(0, ui.Start)
}
restore := data.DataSelectTheme(c, 0)
resultList := ui.List(c, rows, len(list), func(i int) {
if symbolSearchRow(c, list[i], i == s.cursor) {
s.cursor = i
v.Chosen, v.Picked = list[i], true
}
}).Height(320).MaxHeight(320)
restore()
internal.Ring(resultList, k.Focus, theme.ControlRadius)
if s.focusFirst || v.Picked {
resultList.Focus()
}
if !resultList.IsDisabled() && resultList.Submitted() && s.cursor >= 0 && s.cursor < len(list) {
v.Chosen, v.Picked = list[s.cursor], true
}
s.focusFirst = false
})
if v.Element.IsDisabled() {
v.Changed, v.Picked, v.Retry = false, false, false
}
if v.Picked {
slog.Debug("mujica: symbol_search pick", "code", v.Chosen.Code)
}
return v
}
func symbolSearchRow(c *ui.Context, sym MarketSymbol, selected bool) bool {
k := core.Tokens(c)
d := core.Density(c)
b := ui.ButtonBase(c).Key(sym.Market+":"+sym.Code).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 3).
Justify(ui.Start).Radius(theme.ControlRadius).Label(symbolBadgeName(sym)).Role(ui.RoleListItem)
internal.Ring(b, k.Focus, theme.ControlRadius)
if b.Hovered() {
b.Background(k.SurfaceHover)
}
b.Children(func() {
ui.Text(c, sym.Code).Font(theme.MonoFont).FontWeight(700).Width(80).SingleLine()
ui.Text(c, sym.Name).SingleLine().Grow(1).MinWidth(0).TextColor(k.Text)
if selected {
ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(k.AccentText)
}
if sym.Market != "" {
ui.Text(c, sym.Market).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
}
})
return b.Clicked() && !b.IsDisabled()
}
+83
View File
@@ -0,0 +1,83 @@
package finance
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestSymbolSearchResultsAndAsync(t *testing.T) {
s := SymbolSearchState{}
rows := []MarketSymbol{{Code: "AAPL", Name: "Apple", Market: "NASDAQ"}}
var got SymbolSearchView
opts := SymbolSearchOptions{Markets: []string{"NASDAQ", "NYSE"}, Recent: rows}
tt := chatTester(640, 450, func(c *ui.Context) {
v := SymbolSearch(c, &s, rows, opts)
if v.Picked || v.Changed || v.Retry {
got = v
}
})
if !tt.HasText("Recent") {
t.Fatal("recent missing")
}
_ = tt.Click("AAPL")
if !got.Picked || got.Chosen.Code != "AAPL" {
t.Fatalf("pick %+v", got)
}
tt.Key(0, ui.KeyTab)
s.Query = "apple"
opts.Loading = true
rows = nil
tt.Frame()
if !tt.HasText("Searching") {
t.Fatal("loading missing")
}
opts.Loading = false
opts.Error = "Caller failed"
tt.Frame()
_ = tt.Click("Retry")
if !got.Retry {
t.Fatal("retry missing")
}
opts.Error = ""
tt.Frame()
if !tt.HasText("No symbols match") {
t.Fatal("empty missing")
}
opts.Disabled = true
rows = []MarketSymbol{{Code: "AAPL"}}
got = SymbolSearchView{}
tt.Frame()
_ = tt.Click("AAPL")
if got.Picked {
t.Fatal("disabled pick")
}
}
func TestSymbolSearchSnapshot(t *testing.T) {
var ss SymbolSearchState
snapshot(t, "symbol_search", 520, 300, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(12).Fill().Children(func() {
SymbolSearch(c, &ss, nil, SymbolSearchOptions{Markets: []string{"NASDAQ", "NYSE"}, Recent: []MarketSymbol{{Code: "AAPL", Name: "Apple", Market: "NASDAQ"}}})
})
})
}
func TestSymbolSearchArrowNavigation(t *testing.T) {
s := SymbolSearchState{Query: "tech"}
rows := []MarketSymbol{{Code: "AAPL", Name: "Apple"}, {Code: "MSFT", Name: "Microsoft"}}
var chosen string
tt := chatTester(640, 450, func(c *ui.Context) {
r := SymbolSearch(c, &s, rows, SymbolSearchOptions{})
if r.Picked {
chosen = r.Chosen.Code
}
})
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if chosen != "MSFT" {
t.Fatalf("chosen %q", chosen)
}
}
+157
View File
@@ -0,0 +1,157 @@
package finance
import (
"fmt"
"log/slog"
"math"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/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 := core.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 = core.Msg(c, "ticker_tape.label")
}
k := core.Tokens(c)
d := core.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(core.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 := core.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 core.IconAction(c, icons.Must("chevron-left"), core.Msg(c, "ticker_tape.previous")).Clicked() {
turn(-1)
}
ui.Text(c, fmt.Sprintf("%d / %d", st.page+1, pages)).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).FontFeatures("tnum")
if core.IconAction(c, icons.Must("chevron-right"), core.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 := core.Density(c).Unit()
sign := core.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, data.StatisticFormat(it.Price, dec)).FontFeatures("tnum").SingleLine()
ui.Row(c).Gap(u / 2).AlignItems(ui.Center).TextColor(core.TrendColor(c, sign)).Label(core.TrendWord(c, sign) + " " + data.StatisticSigned(it.ChangePercent, 2) + "%").Children(func() {
ui.Icon(c, core.TrendIcon(sign)).Size(12, 12)
ui.Text(c, data.StatisticSigned(it.ChangePercent, 2)+"%").FontFeatures("tnum").SingleLine()
})
})
}
+95
View File
@@ -0,0 +1,95 @@
package finance
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func tickerTapeItems(n int) []TickerItem {
var items []TickerItem
for i := range n {
items = append(items, TickerItem{Symbol: fmt.Sprintf("SYM%d", i), Price: 100 + float64(i), ChangePercent: float64(i%3 - 1)})
}
return items
}
func tickerTapeX(t *testing.T, tt *ui.Tester) float32 {
t.Helper()
r, ok := tt.Find("SYM0")
if !ok {
t.Fatalf("no SYM0 in %q", tt.Texts())
}
return r.X
}
func TestTickerTapeScrollsAndPausesOnHover(t *testing.T) {
tt := tradingTester(core.GreenUp, 400, 80, func(c *ui.Context) {
ui.Box(c).Width(380).Children(func() { TickerTape(c, tickerTapeItems(6), TickerTapeOptions{Speed: 200}) })
})
x0 := tickerTapeX(t, tt)
time.Sleep(60 * time.Millisecond)
tt.Frame()
x1 := tickerTapeX(t, tt)
if x1 >= x0 {
t.Fatalf("did not scroll: %v -> %v", x0, x1)
}
tt.Move(100, 25)
tt.Frame()
x2 := tickerTapeX(t, tt)
time.Sleep(60 * time.Millisecond)
tt.Frame()
if x3 := tickerTapeX(t, tt); x3 != x2 {
t.Fatalf("moved under the pointer: %v -> %v", x2, x3)
}
}
func TestTickerTapePausesOnFocus(t *testing.T) {
tt := tradingTester(core.GreenUp, 400, 80, func(c *ui.Context) {
ui.Box(c).Width(380).Children(func() { TickerTape(c, tickerTapeItems(6), TickerTapeOptions{Speed: 200}) })
})
tt.Key(0, ui.KeyTab)
if !tt.Focused("Market ticker") {
t.Fatal("tape not focusable")
}
x0 := tickerTapeX(t, tt)
time.Sleep(60 * time.Millisecond)
tt.Frame()
if x1 := tickerTapeX(t, tt); x1 != x0 {
t.Fatalf("moved while focused: %v -> %v", x0, x1)
}
}
func TestTickerTapePagesUnderReducedMotion(t *testing.T) {
tt := tradingTester(core.GreenUp, 500, 80, func(c *ui.Context) {
TickerTape(c, tickerTapeItems(5), TickerTapeOptions{PerPage: 2})
})
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
if !tt.HasText("SYM0") || tt.HasText("SYM2") || !tt.HasText("1 / 3") || !tt.HasText("−1.00%") {
t.Fatalf("page 1: %q", tt.Texts())
}
if err := tt.Click("Next page"); err != nil {
t.Fatal(err)
}
if !tt.HasText("SYM2") || tt.HasText("SYM0") || !tt.HasText("+1.00%") {
t.Fatalf("page 2: %q", tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeyLeft)
if !tt.HasText("SYM4") || !tt.HasText("3 / 3") {
t.Fatalf("wrap back: %q", tt.Texts())
}
}
func TestTickerTapeSnapshot(t *testing.T) {
snapshot(t, "ticker_tape", 520, 60, func(c *ui.Context) {
ui.Column(c).Padding(10).Children(func() {
TickerTape(c, []TickerItem{{"MUJC", 412.08, -1.35}, {"AVE", 88.2, 2.1}, {"OBLV", 1204.5, 0}}, TickerTapeOptions{})
})
})
}
+140
View File
@@ -0,0 +1,140 @@
package finance
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// TradePrint is one executed trade on the tape.
type TradePrint struct {
// ID identifies the trade; required and unique.
ID string
Time time.Time
Price float64
Size float64
Side OrderSide
}
// TimeAndSalesOptions configure a TimeAndSales.
type TimeAndSalesOptions struct {
// Decimals of prices and sizes; zero means 2 and 0.
Decimals, SizeDecimals int
// LargeSize marks trades at or above it; zero marks none.
LargeSize float64
// Height of the list in DIPs; zero means 320.
Height float32
Status data.DataStatus
Error string
}
// TimeAndSalesState is the tape's follow state and list place.
type TimeAndSalesState struct {
// Paused stops new trades from showing until resumed.
Paused bool
frozen int
shown int
list data.ListState[string]
}
// TimeAndSales lists trades newest on top; Paused holds the view.
func TimeAndSales(c *ui.Context, s *TimeAndSalesState, trades []TradePrint, opts TimeAndSalesOptions) core.ChoiceResult {
dec := core.PriceDecimals("TimeAndSales", opts.Decimals)
if opts.SizeDecimals < 0 || opts.LargeSize < 0 || opts.Height < 0 {
panic("mujica: TimeAndSales SizeDecimals, LargeSize and Height cannot be negative")
}
height := opts.Height
if height == 0 {
height = 320
}
if !s.Paused {
s.frozen = len(trades)
}
s.frozen = min(s.frozen, len(trades))
n := s.frozen
if !s.Paused && n != s.shown {
s.list.ScrollTo(0)
}
s.shown = n
k := core.Tokens(c)
d := core.Density(c)
var r core.ChoiceResult
r.Element = ui.Column(c).Gap(d.Unit() * 2).Height(height)
r.Element.Children(func() {
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, core.Msg(c, "time_and_sales.title")).Font(theme.SerifFont).FontWeight(600).Grow(1)
label := core.Msg(c, "time_and_sales.pause")
if s.Paused {
label = core.Msg(c, "time_and_sales.resume")
if waiting := len(trades) - n; waiting > 0 {
label = fmt.Sprintf(core.Msg(c, "time_and_sales.resume_count"), waiting)
}
}
if input.Button(c, label, input.ButtonOptions{Variant: input.Secondary}).Clicked() {
s.Paused, r = !s.Paused, core.NewChoiceResult(r.Element, true)
slog.Debug("mujica: time_and_sales follow", "paused", s.Paused)
}
})
ui.Row(c).Padding(0, d.Unit()*4).Gap(d.Unit() * 2).Children(func() {
for i, key := range []string{"time_and_sales.time", "time_and_sales.price", "time_and_sales.size"} {
t := ui.Text(c, core.Msg(c, key)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Grow(1).Basis(0)
if i > 0 {
t.TextAlign(ui.End)
}
}
ui.Box(c).Width(16)
})
at := func(i int) TradePrint { return trades[n-1-i] }
data.List(c, &s.list, n, data.ListOptions[string]{
Key: func(i int) string { return at(i).ID },
Label: func(i int) string {
t := at(i)
return timeAndSalesSpoken(c, t, dec, opts)
},
Status: opts.Status, Error: opts.Error, Empty: core.Msg(c, "time_and_sales.empty"),
}, func(i int) { timeAndSalesRow(c, at(i), dec, opts) }).Element.Grow(1)
})
return r
}
func timeAndSalesLarge(t TradePrint, opts TimeAndSalesOptions) bool {
return opts.LargeSize > 0 && t.Size >= opts.LargeSize
}
// timeAndSalesSpoken reads a trade with its side and size.
func timeAndSalesSpoken(c *ui.Context, t TradePrint, dec int, opts TimeAndSalesOptions) string {
s := fmt.Sprintf("%s %s %s %s", t.Time.Format("15:04:05"), core.Msg(c, fmt.Sprintf("order_book.side%d", t.Side)),
data.StatisticFormat(t.Price, dec), data.StatisticFormat(t.Size, opts.SizeDecimals))
if timeAndSalesLarge(t, opts) {
s += ", " + core.Msg(c, "time_and_sales.large")
}
return s
}
// timeAndSalesRow is one trade with its side and large mark.
func timeAndSalesRow(c *ui.Context, t TradePrint, dec int, opts TimeAndSalesOptions) {
k := core.Tokens(c)
large := timeAndSalesLarge(t, opts)
sign := t.Side.sign()
ui.Text(c, t.Time.Format("15:04:05")).FontFeatures("tnum").TextColor(k.TextMuted).Grow(1).Basis(0).SingleLine()
ui.Row(c).Gap(2).Justify(ui.End).AlignItems(ui.Center).Grow(1).Basis(0).TextColor(core.TrendColor(c, sign)).Children(func() {
ui.Icon(c, core.TrendIcon(sign)).Size(12, 12)
ui.Text(c, data.StatisticFormat(t.Price, dec)).FontFeatures("tnum").SingleLine()
})
size := ui.Text(c, data.StatisticFormat(t.Size, opts.SizeDecimals)).FontFeatures("tnum").TextAlign(ui.End).Grow(1).Basis(0).SingleLine()
mark := ui.Box(c).Size(16, 16).Shrink(0)
if large {
size.FontWeight(700)
mark.Draw(func(p *ui.Painter, r ui.Rect) {
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, 10), k.AccentText)
})
}
}
+92
View File
@@ -0,0 +1,92 @@
package finance
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func timeAndSalesTrades(n int) []TradePrint {
at := time.Date(2026, 10, 5, 9, 30, 0, 0, time.UTC)
out := make([]TradePrint, n)
for i := range out {
side := SideBuy
if i%2 == 1 {
side = SideSell
}
out[i] = TradePrint{ID: fmt.Sprint(i), Time: at.Add(time.Duration(i) * time.Second), Price: 100 + float64(i%50)/100, Size: float64(i%7 + 1), Side: side}
}
return out
}
func TestTimeAndSalesNewestFirstAndLargeMark(t *testing.T) {
var s TimeAndSalesState
trades := timeAndSalesTrades(3)
trades[1].Size = 5000
tt := tradingTester(core.GreenUp, 500, 400, func(c *ui.Context) {
TimeAndSales(c, &s, trades, TimeAndSalesOptions{LargeSize: 1000})
})
newest, _ := tt.Find("09:30:02")
oldest, _ := tt.Find("09:30:00")
if newest.Y >= oldest.Y {
t.Fatalf("newest %v not above oldest %v", newest, oldest)
}
if !tt.HasText("5,000") {
t.Fatalf("texts %q", tt.Texts())
}
if _, ok := tt.Find("09:30:01 Ask 100.01 5,000, large trade"); !ok {
t.Fatal("large trade not spoken")
}
}
func TestTimeAndSalesPauseHoldsView(t *testing.T) {
var s TimeAndSalesState
trades := timeAndSalesTrades(2)
n := 0
tt := tradingTester(core.GreenUp, 500, 400, func(c *ui.Context) {
if TimeAndSales(c, &s, trades, TimeAndSalesOptions{}).Changed() {
n++
}
})
if err := tt.Click("Pause"); err != nil {
t.Fatal(err)
}
trades = timeAndSalesTrades(5)
tt.Frame()
if tt.HasText("09:30:04") || !tt.HasText("Resume (3 new)") || !s.Paused || n != 1 {
t.Fatalf("paused view moved: %q", tt.Texts())
}
tt.Key(0, ui.KeyEnter)
if !tt.HasText("09:30:04") || s.Paused || n != 2 {
t.Fatalf("resume: %q", tt.Texts())
}
}
func TestTimeAndSalesBuildsOnlyVisibleRows(t *testing.T) {
var s TimeAndSalesState
trades := timeAndSalesTrades(100_000)
tt := tradingTester(core.GreenUp, 500, 400, func(c *ui.Context) {
TimeAndSales(c, &s, trades, TimeAndSalesOptions{})
})
if !tt.HasText("13:16:39") {
t.Fatalf("newest trade not on top: %q", tt.Texts()[:8])
}
if got := len(tt.Texts()); got > 120 {
t.Fatalf("built %d texts for 100,000 trades", got)
}
}
func TestTimeAndSalesSnapshot(t *testing.T) {
trades := timeAndSalesTrades(8)
trades[5].Size = 4000
snapshot(t, "time_and_sales", 420, 360, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
s := ui.Local(c.Root(), "snap", func() TimeAndSalesState { return TimeAndSalesState{} })
TimeAndSales(c, s, trades, TimeAndSalesOptions{LargeSize: 1000, Height: 320})
})
})
}
+114
View File
@@ -0,0 +1,114 @@
package finance
import (
"cmp"
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/datetime"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
// FillRow is one of the account's fills; ID is stable.
type FillRow struct {
ID string
Time time.Time
Symbol string
Side OrderSide
Price float64
Quantity float64
Fee float64
}
// TradeHistoryTableOptions configure a TradeHistoryTable.
type TradeHistoryTableOptions struct {
// Decimals of prices and quantities; zero means 2 and 0.
Decimals, QuantityDecimals int
// Version must change when rows change in place.
Version int
Status data.DataStatus
Error string
Disabled bool
}
// TradeHistoryTableState is the date range and table state.
type TradeHistoryTableState struct {
// Range keeps fills on its days; zero keeps all.
Range datetime.DateRange
Table data.DataTableState[string]
cache tradingRows[FillRow]
}
// TradeHistoryResult is a built table and its export request.
type TradeHistoryResult struct {
Element *ui.Element
Table data.DataTableView
exported []FillRow
export bool
}
// Exported returns the range's fills when export was asked.
func (r TradeHistoryResult) Exported() ([]FillRow, bool) { return r.exported, r.export }
// TradeHistoryTable lists fills by date range, with export.
func TradeHistoryTable(c *ui.Context, s *TradeHistoryTableState, rows []FillRow, opts TradeHistoryTableOptions) TradeHistoryResult {
dec := core.PriceDecimals("TradeHistoryTable", opts.Decimals)
if opts.QuantityDecimals < 0 {
panic("mujica: TradeHistoryTable with negative QuantityDecimals")
}
rng := s.Range
shown := s.cache.view(rows, opts.Version, rng, func(f FillRow) bool {
if rng.IsZero() {
return true
}
d := core.DateOf(f.Time)
return d.Compare(rng.Start) >= 0 && d.Compare(rng.End) <= 0
})
num := func(id, key string, dp int, v func(FillRow) float64) data.DataColumn[FillRow] {
return data.DataColumn[FillRow]{ID: id, Title: core.Msg(c, key), Align: ui.End, Sortable: true,
Compare: func(a, b FillRow) int { return cmp.Compare(v(a), v(b)) },
Text: func(f FillRow) string { return data.StatisticFormat(v(f), dp) }}
}
cols := []data.DataColumn[FillRow]{
{ID: "time", Title: core.Msg(c, "trade_history_table.time"), Sortable: true, Width: 180,
Compare: func(a, b FillRow) int { return a.Time.Compare(b.Time) },
Text: func(f FillRow) string { return f.Time.Format("2006-01-02 15:04:05") }},
{ID: "symbol", Title: core.Msg(c, "position_table.symbol"), Sortable: true,
Compare: func(a, b FillRow) int { return cmp.Compare(a.Symbol, b.Symbol) },
Text: func(f FillRow) string { return f.Symbol }},
{ID: "side", Title: core.Msg(c, "order_table.side"), Text: func(f FillRow) string { return core.Msg(c, fmt.Sprintf("order_entry.side%d", f.Side)) },
Cell: func(f FillRow) { tradingSide(c, f.Side) }},
num("price", "order_book.price", dec, func(f FillRow) float64 { return f.Price }),
num("qty", "order_entry.quantity", opts.QuantityDecimals, func(f FillRow) float64 { return f.Quantity }),
num("fee", "trade_history_table.fee", dec, func(f FillRow) float64 { return f.Fee }),
}
d := core.Density(c)
var r TradeHistoryResult
r.Element = ui.Column(c).Gap(d.Unit() * 2).Disabled(opts.Disabled).Grow(1).FillWidth().MinHeight(0)
disabled := r.Element.IsDisabled()
r.Element.Children(func() {
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
if datetime.DateRangePicker(c, &s.Range, datetime.DateRangePickerOptions{Label: core.Msg(c, "trade_history_table.range"), Disabled: disabled}).Changed() {
slog.Debug("mujica: trade_history_table range", "days", s.Range.Days())
}
if !s.Range.IsZero() && input.Button(c, core.Msg(c, "trade_history_table.all"), input.ButtonOptions{Variant: input.Ghost, Disabled: disabled}).Clicked() && !disabled {
s.Range = datetime.DateRange{}
}
ui.Box(c).Grow(1)
if input.Button(c, core.Msg(c, "trade_history_table.export"), input.ButtonOptions{Variant: input.Secondary, Icon: icons.Must("upload"), Disabled: disabled || len(shown) == 0}).Clicked() && !disabled {
r.exported, r.export = shown, true
slog.Debug("mujica: trade_history_table export", "rows", len(shown))
}
})
r.Table = data.DataTable(c, &s.Table, shown, data.DataTableOptions[FillRow, string]{Columns: cols, Key: func(f FillRow) string { return f.ID },
Status: opts.Status, Error: opts.Error, Empty: core.Msg(c, "trade_history_table.empty")})
r.Table.Element.Grow(1)
})
return r
}
+90
View File
@@ -0,0 +1,90 @@
package finance
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/datetime"
)
func tradeHistorySample() []FillRow {
day := func(d, h int) time.Time { return time.Date(2026, 10, d, h, 0, 0, 0, time.UTC) }
return []FillRow{
{ID: "f1", Time: day(1, 10), Symbol: "MUJC", Side: SideBuy, Price: 400, Quantity: 10, Fee: 1.2},
{ID: "f2", Time: day(3, 14), Symbol: "AVE", Side: SideSell, Price: 88.2, Quantity: 5, Fee: 0.4},
{ID: "f3", Time: day(5, 9), Symbol: "OBLV", Side: SideBuy, Price: 12, Quantity: 100, Fee: 0.6},
}
}
func TestTradeHistoryTableRangeAndExport(t *testing.T) {
s := TradeHistoryTableState{Range: datetime.DateRange{Start: core.Date{Year: 2026, Month: 10, Day: 2}, End: core.Date{Year: 2026, Month: 10, Day: 4}}}
var exported []FillRow
tt := tradingTester(core.GreenUp, 1000, 600, func(c *ui.Context) {
ui.Box(c).Width(960).Height(400).Children(func() {
if rows, ok := TradeHistoryTable(c, &s, tradeHistorySample(), TradeHistoryTableOptions{}).Exported(); ok {
exported = rows
}
})
})
if tt.HasText("MUJC") || !tt.HasText("AVE") || tt.HasText("OBLV") {
t.Fatalf("range: %q", tt.Texts())
}
if err := tt.Click("Export"); err != nil {
t.Fatal(err)
}
if len(exported) != 1 || exported[0].ID != "f2" {
t.Fatalf("exported %v", exported)
}
if err := tt.Click("All dates"); err != nil {
t.Fatal(err)
}
if !tt.HasText("MUJC") || !tt.HasText("OBLV") || !s.Range.IsZero() {
t.Fatalf("all dates: %q", tt.Texts())
}
_ = tt.Click("Export")
if len(exported) != 3 {
t.Fatalf("exported %d", len(exported))
}
}
func TestTradeHistoryTableDisabled(t *testing.T) {
var s TradeHistoryTableState
exported := false
tt := tradingTester(core.GreenUp, 1000, 600, func(c *ui.Context) {
ui.Box(c).Width(960).Height(400).Children(func() {
_, exported = TradeHistoryTable(c, &s, tradeHistorySample(), TradeHistoryTableOptions{Disabled: true}).Exported()
})
})
_ = tt.Click("Export")
if exported {
t.Fatal("disabled table exported")
}
}
func TestTradeHistoryTableSnapshot(t *testing.T) {
snapshot(t, "trade_history_table", 900, 260, func(c *ui.Context) {
ui.Column(c).Padding(16).Height(240).Width(880).Children(func() {
s := ui.Local(c.Root(), "snap", func() TradeHistoryTableState { return TradeHistoryTableState{} })
TradeHistoryTable(c, s, tradeHistorySample(), TradeHistoryTableOptions{})
})
})
}
// 100,000 fills build only visible rows.
func TestTradeHistoryTableLargeData(t *testing.T) {
var s TradeHistoryTableState
rows := make([]FillRow, 100_000)
for i := range rows {
rows[i] = FillRow{ID: fmt.Sprint(i), Time: time.Date(2026, 1, 1, 0, 0, i, 0, time.UTC), Symbol: "S", Side: SideSell, Price: 1, Quantity: 1}
}
tt := tradingTester(core.GreenUp, 1000, 600, func(c *ui.Context) {
ui.Box(c).Width(960).Height(400).Children(func() { TradeHistoryTable(c, &s, rows, TradeHistoryTableOptions{}) })
})
if n := len(tt.Texts()); n > 400 {
t.Fatalf("built %d texts", n)
}
}
+113
View File
@@ -0,0 +1,113 @@
package finance
import (
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
type TradingSession struct {
Phase MarketPhase
Start, End int
}
type SessionMarket struct {
Name string
Location *time.Location
Sessions []TradingSession
// Holiday closes the market all day.
Holiday bool
}
// TradingSessionClockOptions configure a TradingSessionClock.
type TradingSessionClockOptions struct {
// Now is the instant shown; zero uses the window clock.
Now time.Time
}
func TradingSessionClock(c *ui.Context, markets []SessionMarket, opts TradingSessionClockOptions) *ui.Element {
now := opts.Now
if now.IsZero() {
now = c.Now()
}
k := core.Tokens(c)
u := core.Density(c).Unit()
size := core.FontSize(c, theme.CaptionSize)
g := ui.Grid(c).ColumnTracks(ui.FitContent(), ui.FitContent(), ui.Fr(1)).GapX(u * 4).GapY(u * 3).AlignItems(ui.Center)
g.Children(func() {
for _, m := range markets {
tradingSessionCheck(m)
local := now.In(m.Location)
phase, next, nextPhase := tradingSessionPhase(m, local)
ui.Column(c).Key(m.Name).Gap(2).Children(func() {
ui.Text(c, m.Name).Font(theme.SerifFont).FontWeight(600)
ui.Text(c, local.Format("15:04 MST")).FontSize(size).TextColor(k.TextMuted).FontFeatures("tnum")
})
MarketStatus(c, phase, MarketStatusOptions{Next: next, NextPhase: nextPhase, Now: now})
minute := float32(local.Hour()*60 + local.Minute())
ui.Box(c).MinWidth(160).Height(22).Draw(func(p *ui.Painter, r ui.Rect) {
bar := ui.Rect{X: r.X, Y: r.Y + 4, W: r.W, H: 10}
p.Fill(bar, k.Background, 2)
p.Stroke(bar, k.Border, 2, 1)
for _, s := range m.Sessions {
col := k.Info.Alpha(0.45)
if s.Phase == MarketOpen {
col = k.Success
}
x0, x1 := bar.X+bar.W*float32(s.Start)/1440, bar.X+bar.W*float32(s.End)/1440
p.Fill(ui.Rect{X: x0, Y: bar.Y + 1, W: x1 - x0, H: bar.H - 2}, col, 1)
}
x := internal.Snap(p, bar.X+bar.W*minute/1440)
p.FillPath(internal.Diamond(x, bar.Y+bar.H+3, 8), k.Text)
p.Line(x, bar.Y-2, x, bar.Y+bar.H, 1.5, k.Text)
}).Role(ui.RoleImage).Label(m.Name + " " + core.Msg(c, marketPhases[phase].key))
}
})
// Times and phases move on the minute.
if opts.Now.IsZero() && len(markets) > 0 {
g.DrawOver(func(p *ui.Painter, _ ui.Rect) { c.After(time.Minute - time.Duration(p.Now().Second())*time.Second) })
}
return g
}
func tradingSessionCheck(m SessionMarket) {
if m.Name == "" || m.Location == nil {
panic("mujica: SessionMarket needs a Name and a Location")
}
for i, s := range m.Sessions {
if s.Start < 0 || s.End > 1440 || s.Start >= s.End || i > 0 && s.Start < m.Sessions[i-1].End {
panic("mujica: TradingSession spans must be ordered within 0..1440 minutes")
}
if s.Phase != MarketPreMarket && s.Phase != MarketOpen && s.Phase != MarketAfterHours {
panic("mujica: TradingSession Phase must be pre-market, open or after-hours")
}
}
}
func tradingSessionPhase(m SessionMarket, local time.Time) (MarketPhase, time.Time, MarketPhase) {
if m.Holiday || len(m.Sessions) == 0 {
return MarketHoliday, time.Time{}, MarketClosed
}
mid := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, m.Location)
at := func(min int) time.Time {
return time.Date(mid.Year(), mid.Month(), mid.Day(), min/60, min%60, 0, 0, m.Location)
}
cur := local.Hour()*60 + local.Minute()
for i, s := range m.Sessions {
switch {
case cur < s.Start:
return MarketClosed, at(s.Start), s.Phase
case cur < s.End:
next := MarketClosed
if i+1 < len(m.Sessions) && m.Sessions[i+1].Start == s.End {
next = m.Sessions[i+1].Phase
}
return s.Phase, at(s.End), next
}
}
return MarketClosed, at(1440 + m.Sessions[0].Start), m.Sessions[0].Phase
}
+56
View File
@@ -0,0 +1,56 @@
package finance
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func TestTradingSessionClockZonesBoundariesAndHoliday(t *testing.T) {
ny := SessionMarket{Name: "NYSE", Location: mktNewYork(), Sessions: []TradingSession{{MarketPreMarket, 240, 570}, {MarketOpen, 570, 960}, {MarketAfterHours, 960, 1200}}}
for _, tc := range []struct {
minute int
phase MarketPhase
}{{239, MarketClosed}, {240, MarketPreMarket}, {569, MarketPreMarket}, {570, MarketOpen}, {960, MarketAfterHours}, {1200, MarketClosed}} {
local := time.Date(2026, 10, 5, tc.minute/60, tc.minute%60, 0, 0, ny.Location)
phase, next, _ := tradingSessionPhase(ny, local)
if phase != tc.phase || !next.After(local) {
t.Fatalf("at %d phase %d next %v", tc.minute, phase, next)
}
}
tt := chatTester(960, 260, func(c *ui.Context) {
TradingSessionClock(c, []SessionMarket{ny, {Name: "SSE", Location: time.FixedZone("CST", 8*3600), Holiday: true}}, TradingSessionClockOptions{Now: mktNow})
})
if !tt.HasText("10:30 EDT") || !tt.HasText("22:30 CST") || !tt.HasText("Holiday") {
t.Fatalf("zones %q", tt.Texts())
}
marketsPanic(t, func(c *ui.Context) {
TradingSessionClock(c, []SessionMarket{{Name: "Bad"}}, TradingSessionClockOptions{})
})
}
func TestTradingSessionClockSnapshot(t *testing.T) {
snapshot(t, "trading_session_clock", 720, 220, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(12).Fill().Children(func() {
TradingSessionClock(c, []SessionMarket{
{Name: "NYSE", Location: mktNewYork(), Sessions: []TradingSession{{MarketPreMarket, 240, 570}, {MarketOpen, 570, 960}, {MarketAfterHours, 960, 1200}}},
{Name: "SSE", Location: time.FixedZone("CST", 8*3600), Sessions: []TradingSession{{MarketOpen, 570, 690}, {MarketOpen, 780, 900}}},
}, TradingSessionClockOptions{Now: mktNow})
})
})
}
func TestTradingSessionClockDST(t *testing.T) {
loc, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatal(err)
}
m := SessionMarket{Name: "NYSE", Location: loc, Sessions: []TradingSession{{MarketOpen, 570, 960}}}
local := time.Date(2026, 3, 8, 8, 0, 0, 0, loc)
_, next, _ := tradingSessionPhase(m, local)
if next.Hour() != 9 || next.Minute() != 30 {
t.Fatalf("DST open %v", next)
}
}
+175
View File
@@ -0,0 +1,175 @@
package finance
import (
"log/slog"
"slices"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/navigation"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// WatchItem is a watched symbol with its latest quote.
type WatchItem struct {
Symbol, Name string
Price float64
Change float64
ChangePercent float64
Volume float64
}
// WatchGroup is a named list of watched symbols.
type WatchGroup struct {
Name string
Items []WatchItem
}
// WatchColumn is an optional Watchlist column.
type WatchColumn int
const (
WatchPrice WatchColumn = iota
WatchChange
WatchVolume
)
var watchColumns = [3]string{"watchlist.price", "watchlist.change", "watchlist.volume"}
type WatchlistState struct {
Group int
Selected string
Hidden map[WatchColumn]bool
rows ui.ListState
cursor int
}
// WatchlistOptions configure a Watchlist.
type WatchlistOptions struct {
// Decimals of prices; zero means 2.
Decimals int
Disabled bool
}
// WatchlistView is a built Watchlist and this frame's events.
type WatchlistView struct {
Element *ui.Element
Changed, Submitted bool
Moved bool
Rows []int
To int
}
func Watchlist(c *ui.Context, s *WatchlistState, groups []WatchGroup, opts WatchlistOptions) WatchlistView {
if s == nil || len(groups) == 0 {
panic("mujica: Watchlist needs a state and a group")
}
dec := core.PriceDecimals("Watchlist", opts.Decimals)
if s.Group < 0 || s.Group >= len(groups) {
panic("mujica: Watchlist Group out of range")
}
d := core.Density(c)
var v WatchlistView
v.Element = ui.Column(c).Gap(d.Unit() * 2).Grow(1).MinHeight(0).Disabled(opts.Disabled)
opts.Disabled = v.Element.IsDisabled()
if opts.Disabled {
copy := *s
s = &copy
}
if s.Hidden == nil {
s.Hidden = map[WatchColumn]bool{}
}
v.Element.Children(func() {
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
tabs := make([]navigation.Tab, len(groups))
for i, g := range groups {
tabs[i] = navigation.Tab{Label: g.Name}
}
ui.Column(c).Grow(1).MinWidth(0).Children(func() {
navigation.Tabs(c, &s.Group, tabs, navigation.TabsOptions{Label: core.Msg(c, "watchlist.groups")})
})
overlay.DropdownMenu(c, "", overlay.DropdownMenuOptions{Icon: icons.Must("settings"), Label: core.Msg(c, "watchlist.columns"), Disabled: opts.Disabled}, func(m *overlay.PopupMenu) {
for col, key := range watchColumns {
on := !s.Hidden[WatchColumn(col)]
if m.Check(core.Msg(c, key), &on, overlay.PopupMenuItemOptions{KeepOpen: true}) {
s.Hidden[WatchColumn(col)] = !on
}
}
})
})
items := groups[s.Group].Items
v.Changed, v.Submitted = watchlistRows(c, s, items, dec, &v, opts.Disabled)
})
if v.Element.IsDisabled() {
v.Changed, v.Submitted, v.Moved = false, false, false
}
switch {
case v.Moved:
slog.Debug("mujica: watchlist move", "rows", v.Rows, "to", v.To)
case v.Changed:
slog.Debug("mujica: watchlist select", "symbol", s.Selected)
}
return v
}
func watchlistRows(c *ui.Context, s *WatchlistState, items []WatchItem, dec int, v *WatchlistView, disabled bool) (changed, submitted bool) {
k := core.Tokens(c)
d := core.Density(c)
s.cursor = slices.IndexFunc(items, func(it WatchItem) bool { return it.Symbol == s.Selected })
before := s.cursor
cursor := &s.cursor
if disabled {
cp := s.cursor
cursor = &cp
}
s.rows.Key = func(i int) any { return items[i].Symbol }
s.rows.Label = func(i int) string { return items[i].Symbol }
s.rows.Selected = cursor
s.rows.Reorder = nil
if !disabled {
s.rows.Reorder = func(rows []int, to int) { v.Moved, v.Rows, v.To = true, slices.Clone(rows), to }
}
if len(items) == 0 {
ui.Text(c, core.Msg(c, "watchlist.empty")).TextColor(k.TextMuted).Padding(d.Unit() * 3)
return false, false
}
restore := data.DataSelectTheme(c, 0)
list := ui.List(c, &s.rows, len(items), func(i int) {
it := items[i]
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() {
ui.Icon(c, icons.Must("grip-vertical")).Size(14, 14).TextColor(k.TextMuted)
ui.Column(c).Grow(1).MinWidth(80).Children(func() {
ui.Text(c, it.Symbol).Font(theme.MonoFont).FontWeight(700).SingleLine()
if it.Name != "" {
ui.Text(c, it.Name).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
}
})
if !s.Hidden[WatchPrice] {
ui.Row(c).Width(96).Justify(ui.End).Children(func() { PriceText(c, it.Price, PriceTextOptions{Decimals: dec}) })
}
if !s.Hidden[WatchChange] {
ui.Row(c).Width(150).Justify(ui.End).Children(func() {
PriceChangeBadge(c, it.Change, it.ChangePercent, PriceChangeBadgeOptions{Decimals: dec})
})
}
if !s.Hidden[WatchVolume] {
ui.Text(c, data.StatisticFormat(it.Volume, 0)).Width(90).TextAlign(ui.End).FontFeatures("tnum").TextColor(k.TextMuted)
}
if items[i].Symbol == s.Selected {
ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(k.AccentText)
}
})
}).Grow(1).MinHeight(0).Padding(d.Unit()).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius)
restore()
internal.Ring(list, k.Focus, theme.ControlRadius)
if !disabled && s.cursor != before && s.cursor >= 0 {
s.Selected, changed = items[s.cursor].Symbol, true
}
return changed, list.Submitted() && !disabled
}
+139
View File
@@ -0,0 +1,139 @@
package finance
import (
"fmt"
"slices"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func watchlistTester(s *WatchlistState, groups *[]WatchGroup, opts WatchlistOptions, got *WatchlistView) *ui.Tester {
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(10).Height(400).Children(func() {
v := Watchlist(c, s, *groups, opts)
if v.Changed || v.Submitted || v.Moved {
*got = v
}
if v.Moved {
g := &(*groups)[s.Group]
g.Items = watchlistMove(g.Items, v.Rows, v.To)
}
})
}, 700, 420)
tt.Frame()
return tt
}
// watchlistMove is how a caller applies a Moved event.
func watchlistMove(items []WatchItem, rows []int, to int) []WatchItem {
var moved, rest []WatchItem
at := to
for i, it := range items {
if slices.Contains(rows, i) {
moved = append(moved, it)
if i < to {
at--
}
} else {
rest = append(rest, it)
}
}
return slices.Insert(rest, at, moved...)
}
func TestWatchlistSelectKeepsThroughUpdates(t *testing.T) {
var s WatchlistState
groups := mktWatch()
var got WatchlistView
tt := watchlistTester(&s, &groups, WatchlistOptions{}, &got)
if err := tt.Click("MSFT"); err != nil {
t.Fatal(err)
}
if s.Selected != "MSFT" || !got.Changed {
t.Fatalf("selected %q", s.Selected)
}
// A refresh with new prices in a new order keeps the choice on MSFT.
groups[0].Items = []WatchItem{groups[0].Items[2], groups[0].Items[1], groups[0].Items[0]}
groups[0].Items[1].Price = 420
got = WatchlistView{}
tt.Frame()
tt.Key(0, ui.KeyDown)
if s.Selected != "AAPL" {
t.Fatalf("Down after refresh chose %q", s.Selected)
}
tt.Key(0, ui.KeyEnter)
if !got.Submitted {
t.Fatal("Enter did not submit")
}
}
func TestWatchlistColumnsAndGroups(t *testing.T) {
var s WatchlistState
groups := mktWatch()
var got WatchlistView
tt := watchlistTester(&s, &groups, WatchlistOptions{}, &got)
if !tt.HasText("51,000,000") {
t.Fatalf("volume missing: %q", tt.Texts())
}
_ = tt.Click("Columns")
_ = tt.Click("Volume")
tt.Key(0, ui.KeyEscape)
if tt.HasText("51,000,000") || !s.Hidden[WatchVolume] {
t.Fatal("volume column still shown")
}
_ = tt.Click("Energy")
if s.Group != 1 || !tt.HasText("XOM") || tt.HasText("AAPL") {
t.Fatalf("group %d texts %q", s.Group, tt.Texts())
}
}
func TestWatchlistDragReorders(t *testing.T) {
var s WatchlistState
groups := mktWatch()
var got WatchlistView
tt := watchlistTester(&s, &groups, WatchlistOptions{}, &got)
from, _ := tt.Find("NVDA")
to, _ := tt.Find("AAPL")
tt.Press(from.X, from.Y+from.H/2)
tt.Move(from.X, from.Y-10)
tt.Move(to.X, to.Y)
tt.Release(to.X, to.Y)
order := []string{}
for _, it := range groups[0].Items {
order = append(order, it.Symbol)
}
if !got.Moved || order[0] != "NVDA" {
t.Fatalf("moved %v order %v", got.Moved, order)
}
}
func TestWatchlistDisabledAndLarge(t *testing.T) {
var s WatchlistState
groups := mktWatch()
var got WatchlistView
tt := watchlistTester(&s, &groups, WatchlistOptions{Disabled: true}, &got)
_ = tt.Click("MSFT")
if s.Selected != "" || got.Changed {
t.Fatalf("disabled list chose %q", s.Selected)
}
big := []WatchGroup{{Name: "All"}}
for i := range 100000 {
big[0].Items = append(big[0].Items, WatchItem{Symbol: fmt.Sprint("S", i), Price: 1})
}
var b WatchlistState
tt = watchlistTester(&b, &big, WatchlistOptions{}, &got)
if n := len(tt.Texts()); n > 200 {
t.Fatalf("built %d texts for 100000 rows", n)
}
}
func TestWatchlistSnapshot(t *testing.T) {
snapshot(t, "watchlist", 640, 260, func(c *ui.Context) {
s := &WatchlistState{Selected: "MSFT"}
ui.Column(c).Padding(16).Height(260).Children(func() { Watchlist(c, s, mktWatch(), WatchlistOptions{}) })
})
}