WIP components (as built)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 15:51:57 -04:00
1 parent 73df90b9b1
commit b4798a6287
110 files changed
+3975 -273

No files matched your search

+67 -37
View File
@@ -3,6 +3,7 @@ package mujica
import (
"fmt"
"math"
"slices"
"github.com/egoist/mygo/ui"
@@ -11,7 +12,6 @@ import (
"mujicaui/theme"
)
// AllocationItem is an asset class's value and target weight in percent.
type AllocationItem struct {
Name string
Value float64
@@ -20,13 +20,12 @@ type AllocationItem struct {
// AssetAllocationChartOptions configure an AssetAllocationChart.
type AssetAllocationChartOptions struct {
Frame ChartFrame
// Tolerance is the drift in points that warns; zero means 5.
Frame ChartFrame
Version int
Tolerance float64
Format func(v float64) string
}
// AssetAllocationChart shows holdings as a donut and lists each weight against its target.
func AssetAllocationChart(c *ui.Context, items []AllocationItem, opts AssetAllocationChartOptions) ChartResult {
if opts.Tolerance < 0 {
panic("mujica: AssetAllocationChart Tolerance is negative")
@@ -35,62 +34,93 @@ func AssetAllocationChart(c *ui.Context, items []AllocationItem, opts AssetAlloc
if tol == 0 {
tol = 5
}
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) {
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))
}
k := Tokens(c)
u := Density(c).Unit()
pal := internal.Palette(k)
var res ChartResult
row := ui.Row(c).Gap(u * 6).AlignItems(ui.Start).Wrap()
row.Children(func() {
slices := make([]ChartSlice, len(items))
for i, it := range items {
slices[i] = ChartSlice{Label: it.Name, Value: it.Value}
memo := ui.Local(row, "mujica.allocation", func() allocationSlices { return allocationSlices{} })
if id := chartID(items) + fmt.Sprint(opts.Version) + 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([]ChartSlice, len(items))
for i, it := range items {
memo.slices[i] = ChartSlice{Label: it.Name, Value: it.Value}
}
memo.parts = pieMerge(memo.slices, 1, msg(c, "pie_chart.other"))
memo.markers = make([]int, len(items))
for i, it := range items {
at := slices.IndexFunc(memo.parts, func(part 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(frame.Height + 40).Children(func() {
res = PieChart(c, slices, PieChartOptions{Frame: frame, Donut: true, Format: opts.Format})
ui.Column(c).Width(max(frame.Height+40, 300)).Children(func() {
res = PieChart(c, parts, PieChartOptions{Frame: frame, Donut: true, Format: opts.Format})
})
if total == 0 {
return
}
size := FontSize(c, theme.CaptionSize)
g := ui.Grid(c).ColumnTracks(ui.FitContent(), ui.FitContent(), ui.FitContent(), ui.FitContent()).GapX(u * 4).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, msg(c, h)).FontSize(size).TextColor(k.TextMuted).Role(ui.RoleColumnHeader)
}
for i, it := range items {
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, i, r.X+6, r.Y+6, 10, pal[i%len(pal)]) })
ui.Text(c, it.Name).SingleLine()
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, 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(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)
})
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))*Density(c).ControlHeight(), 240))
})
})
res.Element = row
return res
}
// allocationDrift shows the drift in points; beyond tolerance it warns with an icon and a word.
type allocationSlices struct {
id string
slices []ChartSlice
total float64
parts []ChartSlice
markers []int
}
func allocationDrift(c *ui.Context, drift, tol float64) {
k := Tokens(c)
text := statisticSigned(drift, 1) + " pt"
+62
View File
@@ -0,0 +1,62 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
"testing"
)
func TestAssetAllocationChartWeightsDriftAndKeys(t *testing.T) {
items := []AllocationItem{{"Stocks", 60, 50}, {"Bonds", 30, 40}, {"Cash", 10, 10}}
var last ChartResult
tt := chatTester(960, 420, func(c *ui.Context) {
last = AssetAllocationChart(c, items, AssetAllocationChartOptions{Frame: 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: 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: 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: 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")
}
}
+45 -15
View File
@@ -12,7 +12,6 @@ import (
"mujicaui/theme"
)
// Currency is a currency the converter offers; Decimals is its minor-unit precision.
type Currency struct {
Code, Name string
Decimals int
@@ -20,29 +19,29 @@ type Currency struct {
// CurrencyConverterState is the amount and the pair being converted.
type CurrencyConverterState struct {
Amount float64
From, To string
Amount float64
From, To string
rate float64
rateFrom, rateTo string
rateBound bool
}
// CurrencyConverterOptions configure a CurrencyConverter.
type CurrencyConverterOptions struct {
// Rate converts one From into To; zero shows it is unavailable.
Rate float64
// RateTime is when the caller's rate was quoted; zero hides it.
RateTime time.Time
Disabled bool
// RateFrom and RateTo identify refreshed rates, including equal rates.
RateFrom, RateTo string
RateTime time.Time
Disabled bool
}
// CurrencyConverterView is a built converter, the result, and whether the pair changed.
type CurrencyConverterView struct {
Element *ui.Element
// Result is Amount in To, rounded to its decimals; NaN without a rate.
Result float64
Result float64
// PairChanged asks the caller for the new pair's rate.
PairChanged bool
}
// CurrencyConverter picks and swaps two currencies and converts at the caller's rate.
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")
@@ -50,7 +49,27 @@ func CurrencyConverter(c *ui.Context, s *CurrencyConverterState, currencies []Cu
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 := Tokens(c)
u := Density(c).Unit()
options := make([]SelectOption[string], len(currencies))
@@ -58,11 +77,16 @@ func CurrencyConverter(c *ui.Context, s *CurrencyConverterState, currencies []Cu
options[i] = SelectOption[string]{Value: cur.Code, Label: cur.Code + " · " + cur.Name}
}
v := CurrencyConverterView{Result: math.NaN()}
if opts.Rate > 0 {
v.Result = numberInputSpec{decimals: to.Decimals}.round(s.Amount * opts.Rate)
if rate > 0 {
v.Result = 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() {
FormField(c, msg(c, "currency_converter.amount"), FormFieldOptions{}, func() *ui.Element {
@@ -85,7 +109,13 @@ func CurrencyConverter(c *ui.Context, s *CurrencyConverterState, currencies []Cu
return r.Element
})
})
if opts.Rate == 0 {
from, to = currencyFind(currencies, s.From), currencyFind(currencies, s.To)
if v.PairChanged {
v.Result = math.NaN()
} else if rate > 0 {
v.Result = numberInputSpec{decimals: to.Decimals}.round(s.Amount * rate)
}
if rate == 0 || v.PairChanged {
ui.Text(c, msg(c, "currency_converter.no_rate")).TextColor(k.TextMuted).Role(ui.RoleStatus)
return
}
@@ -95,7 +125,7 @@ func CurrencyConverter(c *ui.Context, s *CurrencyConverterState, currencies []Cu
ui.Text(c, statisticFormat(v.Result, to.Decimals)+" "+to.Code).Font(theme.SerifFont).FontSize(FontSize(c, theme.H2Size)).
TextColor(k.Text).FontFeatures("tnum")
})
note := fmt.Sprintf(msg(c, "currency_converter.rate"), from.Code, opts.Rate, to.Code)
note := fmt.Sprintf(msg(c, "currency_converter.rate"), from.Code, rate, to.Code)
if !opts.RateTime.IsZero() {
note += " · " + opts.RateTime.Format("2006-01-02 15:04")
}
+42
View File
@@ -0,0 +1,42 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"math"
"testing"
)
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})
})
})
}
+40
View File
@@ -0,0 +1,40 @@
# 181 OptionChain
## 用途
期权链。
## 最小示例
```go
var state mujica.OptionChainState
rows := []mujica.OptionStrike{{Strike: 100, Call: mujica.OptionQuote{Bid: 3, Ask: 3.2, Last: 3.1}, Put: mujica.OptionQuote{Bid: 2, Ask: 2.2, Last: 2.1}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.OptionChain(c, &state, []string{"Oct 17", "Nov 21"}, rows, mujica.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)
```
## 参数
`s` 保存到期日和合约;`expiries` 至少一个;`strikes` 升序。`Spot` 默认 0 隐藏现价;`Decimals` 默认 2;`Disabled` 默认 false。
## 状态
默认;交互区域支持聚焦与选中,Disabled 参数控制禁用。
## 事件
if r.Changed || r.Submitted || r.ExpiryChanged { 调用方回调(state.Expiry, state.Strike, state.Side) }
## 键盘操作
Tab 切换到期日或列表;上下、Home/End 选择行;左右切换认购认沽;Enter 确认。
## 限制
到期日切换后,由调用方更换对应报价;排序与稳定合约标识由调用方保证。
+38
View File
@@ -0,0 +1,38 @@
# 182 GreeksTable
## 用途
希腊值表。
## 最小示例
```go
rows := []mujica.GreeksRow{{Label: "AAPL 100 C", Greeks: mujica.OptionGreeks{Delta: 0.5, Gamma: 0.03, Theta: -0.1, Vega: 0.2, Rho: 0.05}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.GreeksTable(c, rows, mujica.GreeksTableOptions{Notes: true})
}
ui.Render(view, 960, 440, 1)
```
## 参数
小数位范围 0—12;0 表示默认 4 位。
`rows` 为合约与五个数值;NaN 显示破折号。`Decimals` 默认 4;`Notes` 默认 false。
## 状态
默认与数据边界。
## 事件
展示组件;数据由调用方更新。
## 键盘操作
展示组件;表头提供说明和悬停提示。
## 限制
通过虚拟列表构建可见行;精度为显示规则,数值计算由调用方提供。
+39
View File
@@ -0,0 +1,39 @@
# 183 PayoffDiagram
## 用途
到期损益图。
## 最小示例
```go
legs := []mujica.OptionLeg{{Kind: mujica.LegCall, Strike: 100, Premium: 3, Quantity: 1}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.PayoffDiagram(c, legs, mujica.PayoffDiagramOptions{Frame: mujica.ChartFrame{Label: "Call payoff"}, Spot: 103})
if r.Changed() || r.Retried() {
fmt.Println(r.Point)
}
}
ui.Render(view, 960, 440, 1)
```
## 参数
`legs` 为认购、认沽或标的组合;负数量表示卖出。`Frame.Label` 必填,`Height` 默认 240;`Spot` 默认 0;`Low/High` 默认行权价上下 30%;`Points` 默认 101;`Format` 可覆盖读数。
## 状态
默认与数据边界;图表的 Frame 支持加载、空、失败占位。
## 事件
if r.Changed() || r.Retried() { 调用方回调(r.Point) }
## 键盘操作
Tab 聚焦;方向键、Home/End 逐点读数;Escape 清除。
## 限制
计算到期损益,合约乘数由 Quantity 表达;价格采样数量由 Points 指定。
+40
View File
@@ -0,0 +1,40 @@
# 184 Watchlist
## 用途
自选列表。
## 最小示例
```go
var state mujica.WatchlistState
groups := []mujica.WatchGroup{{Name: "Technology", Items: []mujica.WatchItem{{Symbol: "AAPL", Name: "Apple", Price: 227.5, Change: 2.7, ChangePercent: 1.2}}}, {Name: "Energy", Items: []mujica.WatchItem{{Symbol: "XOM", Price: 117.4}}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.Watchlist(c, &state, groups, mujica.WatchlistOptions{})
if r.Changed || r.Submitted || r.Moved {
fmt.Println(state.Selected, r.Rows, r.To)
}
}
ui.Render(view, 960, 440, 1)
```
## 参数
`s` 保存分组、代码和隐藏列;`groups` 至少一个;`Decimals` 默认 2;`Disabled` 默认 false。
## 状态
默认;交互区域支持聚焦与选中,Disabled 参数控制禁用。
## 事件
if r.Changed || r.Submitted || r.Moved { 调用方回调(state.Selected, r.Rows, r.To) }
## 键盘操作
Tab 切换分组或列菜单;上下、Home/End 选择;Enter 确认;菜单 Space 开关列。
## 限制
拖动仅返回 Moved;调用方应用 Rows/To 调整顺序;刷新采用稳定 Symbol。
+40
View File
@@ -0,0 +1,40 @@
# 185 SymbolSearch
## 用途
代码搜索。
## 最小示例
```go
var state mujica.SymbolSearchState
rows := []mujica.MarketSymbol{{Code: "AAPL", Name: "Apple", Market: "NASDAQ", Type: "Stock"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.SymbolSearch(c, &state, rows, mujica.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)
```
## 参数
`s.Query/Market` 为调用方搜索条件;`results` 是调用方异步结果。`Markets` nil 隐藏过滤;`Recent` 为空查询时显示;`Loading/Error/Disabled` 默认零值。
## 状态
默认、聚焦、禁用;异步结果支持加载、失败和空。
## 事件
if r.Changed || r.Picked || r.Retry { 调用方回调(state.Query, state.Market, r.Chosen.Code) }
## 键盘操作
Tab 聚焦输入与市场;Down 进入结果;Tab 到达结果按钮,Enter 选择。
## 限制
调用方处理搜索、市场过滤和最近记录;组件提供异步加载、失败和重试界面。
+39
View File
@@ -0,0 +1,39 @@
# 186 SymbolBadge
## 用途
代码徽标。
## 最小示例
```go
symbol := mujica.MarketSymbol{Code: "AAPL", Name: "Apple", Market: "NASDAQ", Type: "Stock"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.SymbolBadge(c, symbol, mujica.SymbolBadgeOptions{Clickable: true})
if r.Changed() {
fmt.Println(symbol.Code)
}
}
ui.Render(view, 960, 440, 1)
```
## 参数
`MarketSymbol.Code` 必填,Name、Market、Type 为调用方标签。`Clickable/Disabled` 默认 false。
## 状态
默认;交互区域支持聚焦与选中,Disabled 参数控制禁用。
## 事件
if r.Changed() { 调用方回调(symbol.Code) }
## 键盘操作
Clickable 开启时 Tab 聚焦,Enter/Space 激活。
## 限制
市场和类型属于业务标签,由调用方本地化。
+42
View File
@@ -0,0 +1,42 @@
# 187 MarketHeatmap
## 用途
市场热力图。
## 最小示例
```go
var path []string
root := mujica.HeatNode{Name: "Market", Children: []mujica.HeatNode{{Name: "Technology", Children: []mujica.HeatNode{{Name: "AAPL", Cap: 3000, Change: 1.2}, {Name: "MSFT", Cap: 2800, Change: -0.6}}}, {Name: "Energy", Cap: 450, Change: -2.1}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.MarketHeatmap(c, &path, root, mujica.MarketHeatmapOptions{Frame: mujica.ChartFrame{Label: "Market"}})
if r.Changed() || r.ValueChanged() || r.Retried() {
fmt.Println(path, r.Point)
}
}
ui.Render(view, 960, 440, 1)
```
## 参数
`Version` 在原地修改数据时递增。小于 4 DIP 的瓦片按屏幕网格汇总绘制,键盘仍逐项读取原始数据。
`path` 保存下钻路径;`root` 名称必填,子项名称唯一;`Cap` 为面积,`Change` 为百分比。`Frame.Label` 必填;`Scale` 默认 3。
## 状态
默认与数据边界;图表的 Frame 支持加载、空、失败占位。
## 事件
if r.Changed() || r.ValueChanged() || r.Retried() { 调用方回调(path, r.Point) }
## 键盘操作
Tab 聚焦图;方向键逐块读数;Enter 下钻;Backspace 返回;路径按钮可用 Tab。
## 限制
市场数据由调用方提供;原地修改数据后更换 Children 切片或递增 Version。
+41
View File
@@ -0,0 +1,41 @@
# 188 Screener
## 用途
选股器。
## 最小示例
```go
var state mujica.ScreenerState
fields := []mujica.ScreenerField{{ID: "pe", Label: "P/E", Decimals: 1}}
rows := []mujica.ScreenerRow{{Symbol: "AAPL", Values: map[string]float64{"pe": 34}}, {Symbol: "XOM", Values: map[string]float64{"pe": 13}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.Screener(c, &state, fields, rows, mujica.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)
```
## 参数
`s.Conditions` 保存条件;`fields` 至少一项,ID 唯一;`rows.Values` 为字段值。`Version` 原地更新递增;`Status/Error/Disabled` 默认零值。
## 状态
默认、聚焦、禁用;异步结果支持加载、失败和空。
## 事件
if r.Changed || r.Save || r.Table.Changed() || r.Table.Submitted() || r.Table.SortChanged() || r.Table.Retried() { 调用方回调(state.Conditions) }
## 键盘操作
Tab 到达字段、比较、数值和保存;Select 方向键选择;NumberInput 方向键步进;表格方向键选择。
## 限制
全部条件取交集;保存由调用方处理;缺失值无法通过条件。
+39
View File
@@ -0,0 +1,39 @@
# 189 MarketOverview
## 用途
市场概览。
## 最小示例
```go
indices := []mujica.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) {
mujica.Use(c, mujica.Settings{})
r := mujica.MarketOverview(c, indices, mujica.MarketOverviewOptions{Breadth: mujica.MarketBreadth{Advancing: 2100, Declining: 1400, Unchanged: 300}})
if r.Chosen >= 0 {
fmt.Println(indices[r.Chosen].Name)
}
}
ui.Render(view, 960, 440, 1)
```
## 参数
`indices` 为指数报价组;`Breadth` 是非负涨跌家数;`AsOf` 零值隐藏时间戳;`Decimals` 默认 2;`Disabled` 默认 false。
## 状态
默认;交互区域支持聚焦与选中,Disabled 参数控制禁用。
## 事件
if r.Chosen >= 0 { 调用方回调(indices[r.Chosen].Name) }
## 键盘操作
Tab 到达指数卡片;Enter/Space 选择。
## 限制
提供展示与点击事件;行情获取由调用方负责。
+39
View File
@@ -0,0 +1,39 @@
# 190 IndexCard
## 用途
指数卡片。
## 最小示例
```go
quote := mujica.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) {
mujica.Use(c, mujica.Settings{})
r := mujica.IndexCard(c, quote, mujica.IndexCardOptions{Clickable: true})
if r.Changed() {
fmt.Println(quote.Name)
}
}
ui.Render(view, 960, 440, 1)
```
## 参数
`q.Name` 必填;Price、Change、ChangePercent 和 Series 来自调用方。`Decimals` 默认 2;`Clickable/Disabled` 默认 false。
## 状态
默认;交互区域支持聚焦与选中,Disabled 参数控制禁用。
## 事件
if r.Changed() { 调用方回调(quote.Name) }
## 键盘操作
Tab 聚焦卡片或迷你图;Enter/Space 激活卡片,图表方向键读数。
## 限制
迷你图复用 Sparkline;行情获取由调用方负责。
+36
View File
@@ -0,0 +1,36 @@
# 191 MarketStatus
## 用途
市场状态。
## 最小示例
```go
now := time.Date(2026, 10, 5, 14, 30, 0, 0, time.UTC)
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.MarketStatus(c, mujica.MarketOpen, mujica.MarketStatusOptions{Market: "NYSE", Now: now, Next: now.Add(5 * time.Hour), NextPhase: mujica.MarketAfterHours})
}
ui.Render(view, 960, 440, 1)
```
## 参数
`phase` 支持五个 MarketPhase;`Market` 默认空;`Next` 零值隐藏;`NextPhase` 默认 Closed;`Now` 零值使用窗口时间。
## 状态
默认与数据边界。
## 事件
展示组件;数据由调用方更新。
## 键盘操作
展示组件。
## 限制
使用窗口时间时每分钟刷新;传入 Now 时由调用方推进时间。
@@ -0,0 +1,37 @@
# 192 TradingSessionClock
## 用途
交易时段时钟。
## 最小示例
```go
now := time.Date(2026, 10, 5, 14, 30, 0, 0, time.UTC)
markets := []mujica.SessionMarket{{Name: "NYSE", Location: time.FixedZone("EDT", -4*3600), Sessions: []mujica.TradingSession{{Phase: mujica.MarketOpen, Start: 570, End: 960}}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.TradingSessionClock(c, markets, mujica.TradingSessionClockOptions{Now: now})
}
ui.Render(view, 960, 440, 1)
```
## 参数
`markets` 每项包含 Name、Location 与有序时段;Start/End 是本地午夜后的分钟;`Holiday` 默认 false;`Now` 零值使用窗口时间。
## 状态
默认与数据边界。
## 事件
展示组件;数据由调用方更新。
## 键盘操作
展示组件。
## 限制
每天重复时段由调用方指定;节假日由 Holiday 指定;跨午夜时段拆分为两个日期的时段。
+40
View File
@@ -0,0 +1,40 @@
# 193 EconomicCalendar
## 用途
经济日历。
## 最小示例
```go
var state mujica.EconomicCalendarState
rows := []mujica.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) {
mujica.Use(c, mujica.Settings{})
r := mujica.EconomicCalendar(c, &state, rows, mujica.EconomicCalendarOptions{})
if r.Changed() || r.Submitted() || r.SortChanged() || r.Retried() {
fmt.Println(state.Table.Selected())
}
}
ui.Render(view, 960, 440, 1)
```
## 参数
`s` 保存国家、重要性和表格状态;`events` 的 ID 稳定且重要性 1—3。`Version` 原地更新递增;`Status/Error/Disabled` 默认零值。
## 状态
默认、聚焦、禁用;异步结果支持加载、失败和空。
## 事件
if r.Changed() || r.Submitted() || r.SortChanged() || r.Retried() { 调用方回调(state.Table.Selected()) }
## 键盘操作
Tab 到达国家过滤、重要性和表格;方向键选择;Enter 提交行。
## 限制
事件数据与发布结果由调用方提供;过滤与排序缓存直到数据或条件变更。
+40
View File
@@ -0,0 +1,40 @@
# 194 EarningsCalendar
## 用途
财报日历。
## 最小示例
```go
var state mujica.DataTableState[string]
rows := []mujica.EarningsEvent{{ID: "a", Symbol: "AAPL", Name: "Apple", Date: mujica.Date{Year: 2026, Month: 10, Day: 30}, Timing: mujica.EarningsAfterClose, EPSEstimate: 1.6, EPSActual: 1.64}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.EarningsCalendar(c, &state, rows, mujica.EarningsCalendarOptions{})
if r.Changed() || r.Submitted() || r.SortChanged() || r.Retried() {
fmt.Println(state.Selected())
}
}
ui.Render(view, 960, 440, 1)
```
## 参数
`s` 保存表格状态;`events` 使用稳定 ID、Date 和盘前盘后时段;EPSActual 的 NaN 表示待公布。`Version` 原地更新递增;`Status/Error/Disabled` 默认零值。
## 状态
默认、聚焦、禁用;异步结果支持加载、失败和空。
## 事件
if r.Changed() || r.Submitted() || r.SortChanged() || r.Retried() { 调用方回调(state.Selected()) }
## 键盘操作
Tab 聚焦表格;方向键及 Home/End 选择;Enter 提交;表头支持键盘排序。
## 限制
NaN 表示待公布;零预期仅显示金额差;财报数据由调用方提供。
+42
View File
@@ -0,0 +1,42 @@
# 195 NewsFeed
## 用途
新闻流。
## 最小示例
```go
var state mujica.NewsFeedState
rows := []mujica.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) {
mujica.Use(c, mujica.Settings{})
r := mujica.NewsFeed(c, &state, rows, mujica.NewsFeedOptions{})
if r.Opened != "" || r.Symbol != "" || r.Retry {
fmt.Println(r.Opened, r.Symbol)
}
}
ui.Render(view, 960, 440, 1)
```
## 参数
`Version` 在调用方原地修改新闻或已读映射时递增;`ReadChanged` 报告已读状态变化。
`s.Read` 以 ID 保存已读;`items` 按调用方给定顺序显示。`Today` 默认窗口日期;`Status/Error/Disabled` 默认零值。
## 状态
默认、聚焦、禁用;异步结果支持加载、失败和空。
## 事件
if r.Opened != "" || r.Symbol != "" || r.Retry { 调用方回调(r.Opened, r.Symbol) }
## 键盘操作
Tab 聚焦列表或相关代码;上下选择;Enter 打开并标记已读。
## 限制
虚拟化构建可见行;读取状态以稳定 ID 保存;链接打开由调用方负责。
+36
View File
@@ -0,0 +1,36 @@
# 196 SentimentGauge
## 用途
情绪仪表。
## 最小示例
```go
value := 72.0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SentimentGauge(c, value, mujica.SentimentGaugeOptions{Label: "Fear & Greed"})
}
ui.Render(view, 960, 440, 1)
```
## 参数
`value` 范围 0—100;`Label` 默认空;`Size` 默认 240 DIP,指定值至少 48 DIP。`Status/Error/Empty` 默认就绪、空文本、false,保留同一尺寸的加载、失败与空占位。
## 状态
默认与数据边界。
## 事件
`Retry` 回调报告错误占位的重试请求;数值由调用方更新。
## 键盘操作
Tab 聚焦仪表;方向键和 Home/End 朗读当前数值与区间文字。
## 限制
区间边界为 20、40、60、80;值域错误触发 panic。
+40
View File
@@ -0,0 +1,40 @@
# 197 PortfolioSummary
## 用途
资产总览。
## 最小示例
```go
account := ""
accounts := []mujica.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) {
mujica.Use(c, mujica.Settings{})
r := mujica.PortfolioSummary(c, &account, accounts, mujica.PortfolioSummaryOptions{})
if r.Changed() {
fmt.Println(account)
}
}
ui.Render(view, 960, 440, 1)
```
## 参数
`account` 空值汇总全部账户;`accounts` 使用稳定 ID。`Currency` 默认 $;`Decimals` 默认 2;`Disabled` 默认 false。
## 状态
默认;交互区域支持聚焦与选中,Disabled 参数控制禁用。
## 事件
if r.Changed() { 调用方回调(account) }
## 键盘操作
Tab 聚焦账户组;方向键切换账户。
## 限制
收益、成本与资产数值由调用方计算;基数为 0 时隐藏比例。
@@ -0,0 +1,41 @@
# 198 AssetAllocationChart
## 用途
资产配置图。
## 最小示例
```go
items := []mujica.AllocationItem{{Name: "Stocks", Value: 60, Target: 50}, {Name: "Bonds", Value: 30, Target: 40}, {Name: "Cash", Value: 10, Target: 10}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.AssetAllocationChart(c, items, mujica.AssetAllocationChartOptions{Frame: mujica.ChartFrame{Label: "Allocation"}})
if r.Changed() || r.Retried() {
fmt.Println(r.Point)
}
}
ui.Render(view, 960, 440, 1)
```
## 参数
`Version` 在原地修改数据时递增。环形图合并小于 1 度的多个扇区;列表虚拟化保留每项的占比、目标与偏离。
`items` 包含 Name、Value、Target;目标总和为 100 或全部 0。`Frame.Label` 必填;`Tolerance` 默认 5 个百分点;`Format` 可覆盖数值。
## 状态
默认与数据边界;图表的 Frame 支持加载、空、失败占位。
## 事件
if r.Changed() || r.Retried() { 调用方回调(r.Point) }
## 键盘操作
Tab 聚焦环形图;方向键逐项读数;Home/End 到首尾。
## 限制
列表同时提供占比、目标、偏离;负资产值触发 panic。
+46
View File
@@ -0,0 +1,46 @@
# 199 PerformanceChart
## 用途
收益曲线。
## 最小示例
```go
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) {
mujica.Use(c, mujica.Settings{})
r := mujica.PerformanceChart(c, values, mujica.PerformanceChartOptions{Frame: mujica.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)
```
## 参数
`Version` 在原地修改净值或基准时递增。
`values` 为正净值;`Times` 必填且递增;`Benchmark` nil 隐藏或与净值等长。`Frame.Label` 必填;`Name/BenchmarkName` 默认本地化名称。
## 状态
默认与数据边界;图表的 Frame 支持加载、空、失败占位。
## 事件
if r.Changed() || r.Retried() { 调用方回调(r.Point) }; if name, ok := r.Toggled(); ok { 调用方回调(name) }
## 键盘操作
Tab 聚焦图和图例;方向键读数;Space 切换序列。
## 限制
净值先归一化为累计收益;最大回撤取峰值到谷值;日期与报价由调用方提供。
+44
View File
@@ -0,0 +1,44 @@
# 200 CurrencyConverter
## 用途
货币换算。
## 最小示例
```go
state := mujica.CurrencyConverterState{Amount: 100, From: "USD", To: "JPY"}
currencies := []mujica.Currency{{Code: "USD", Name: "Dollar", Decimals: 2}, {Code: "JPY", Name: "Yen", Decimals: 0}}
rate := 149.37
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.CurrencyConverter(c, &state, currencies, mujica.CurrencyConverterOptions{Rate: rate})
if r.PairChanged {
rate = 0
fmt.Println(state.From, state.To)
}
}
ui.Render(view, 960, 440, 1)
```
## 参数
`RateFrom/RateTo` 指定汇率所属货币对,支持数值相同的刷新结果;货币小数位范围 0—12。
`s` 保存金额及货币对;`currencies` 至少两种,Decimals 为最小单位精度。`Rate` 默认 0 表示待提供;`RateTime` 零值隐藏;`Disabled` 默认 false。
## 状态
默认;交互区域支持聚焦与选中,Disabled 参数控制禁用。
## 事件
if r.PairChanged { rate = 0; 调用方回调(state.From, state.To) }
## 键盘操作
Tab 到达金额、币种和互换;方向键步进、选择;Enter/Space 互换。
## 限制
币种切换产生 PairChanged,调用方立即将 Rate 置 0 并获取新汇率;结果按目标货币精度取整。
+6 -5
View File
@@ -10,7 +10,6 @@ import (
"mujicaui/theme"
)
// EarningsTiming is when a report comes. The zero value is EarningsUnknown.
type EarningsTiming int
const (
@@ -19,7 +18,6 @@ const (
EarningsAfterClose
)
// EarningsEvent is a scheduled report; NaN actuals are not yet reported.
type EarningsEvent struct {
ID, Symbol, Name string
Date Date
@@ -36,7 +34,6 @@ type EarningsCalendarOptions struct {
Disabled bool
}
// EarningsCalendar lists reports with before-open or after-close timing, estimate, actual and surprise.
func EarningsCalendar(c *ui.Context, s *DataTableState[string], events []EarningsEvent, opts EarningsCalendarOptions) DataTableView {
if s == nil {
panic("mujica: EarningsCalendar needs a state")
@@ -59,7 +56,7 @@ func EarningsCalendar(c *ui.Context, s *DataTableState[string], events []Earning
{ID: "actual", Title: 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: msg(c, "earnings_calendar.surprise"), Width: 110, Align: ui.End,
{ID: "surprise", Title: 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) {
@@ -71,6 +68,11 @@ func EarningsCalendar(c *ui.Context, s *DataTableState[string], events []Earning
}
var v 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 = DataTable(c, s, events, DataTableOptions[EarningsEvent, string]{Columns: cols, Key: func(e EarningsEvent) string { return e.ID },
Version: opts.Version, Status: opts.Status, Error: opts.Error, Empty: msg(c, "earnings_calendar.empty")})
@@ -108,7 +110,6 @@ func earningsValue(v float64) string {
return statisticFormat(v, 2)
}
// earningsSurpriseText is actual minus estimate, with the percent of the estimate.
func earningsSurpriseText(e EarningsEvent) string {
d := e.EPSActual - e.EPSEstimate
if math.IsNaN(d) {
+38
View File
@@ -0,0 +1,38 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"math"
"testing"
)
func TestEarningsCalendarTimingActualAndKeys(t *testing.T) {
var s DataTableState[string]
rows := []EarningsEvent{{ID: "a", Symbol: "AAPL", Date: Date{2026, 10, 30}, Timing: EarningsAfterClose, EPSEstimate: 1.6, EPSActual: 1.64}, {ID: "b", Symbol: "JPM", Date: Date{2026, 10, 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() DataTableState[string] { return DataTableState[string]{} })
ui.Column(c).Padding(16).Gap(12).Fill().Children(func() {
EarningsCalendar(c, es, []EarningsEvent{{ID: "1", Symbol: "AAPL", Name: "Apple", Date: Date{2026, 10, 30}, Timing: EarningsAfterClose, EPSEstimate: 1.6, EPSActual: 1.64},
{ID: "2", Symbol: "JPM", Name: "JPMorgan", Date: Date{2026, 10, 11}, Timing: EarningsBeforeOpen, EPSEstimate: 4.0, EPSActual: math.NaN()}}, EarningsCalendarOptions{}).Element.Height(240)
})
})
}
+24 -14
View File
@@ -23,7 +23,6 @@ type EconomicEvent struct {
Forecast, Previous, Actual string
}
// EconomicCalendarState is the country filter (empty shows all) and the table's state.
type EconomicCalendarState struct {
Countries []string
// MinImportance hides events below it; zero shows all.
@@ -33,8 +32,10 @@ type EconomicCalendarState struct {
}
type economicCache struct {
key string
rows []EconomicEvent
key string
rows []EconomicEvent
data string
countries []SelectOption[string]
}
// EconomicCalendarOptions configure an EconomicCalendar.
@@ -45,7 +46,6 @@ type EconomicCalendarOptions struct {
Disabled bool
}
// EconomicCalendar lists economic events with importance, forecast, previous and actual, filtered by country.
func EconomicCalendar(c *ui.Context, s *EconomicCalendarState, events []EconomicEvent, opts EconomicCalendarOptions) DataTableView {
if s == nil {
panic("mujica: EconomicCalendar needs a state")
@@ -56,17 +56,28 @@ func EconomicCalendar(c *ui.Context, s *EconomicCalendarState, events []Economic
u := Density(c).Unit()
var v 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() {
var countries []SelectOption[string]
for _, e := range events {
if e.ID == "" || e.Importance < 1 || e.Importance > 3 {
panic("mujica: EconomicEvent needs an ID and Importance 1..3")
}
if !slices.ContainsFunc(countries, func(o SelectOption[string]) bool { return o.Value == e.Country }) {
countries = append(countries, SelectOption[string]{Value: e.Country, Label: e.Country})
if data := 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 SelectOption[string]) bool { return o.Value == e.Country }) {
s.cache.countries = append(s.cache.countries, SelectOption[string]{Value: e.Country, Label: e.Country})
}
}
slices.SortFunc(s.cache.countries, func(a, b SelectOption[string]) int { return cmp.Compare(a.Value, b.Value) })
}
slices.SortFunc(countries, func(a, b 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() {
MultiSelect(c, &s.Countries, countries, MultiSelectOptions{Placeholder: msg(c, "economic_calendar.all_countries"),
Label: msg(c, "economic_calendar.countries"), Disabled: opts.Disabled}).Element.MinWidth(220)
@@ -96,7 +107,7 @@ func EconomicCalendar(c *ui.Context, s *EconomicCalendarState, events []Economic
{ID: "previous", Title: msg(c, "economic_calendar.previous"), Width: 90, Align: ui.End, Text: func(e EconomicEvent) string { return cmp.Or(e.Previous, "—") }},
}
v = DataTable(c, &s.Table, rows, DataTableOptions[EconomicEvent, string]{Columns: cols, Key: func(e EconomicEvent) string { return e.ID },
Status: opts.Status, Error: opts.Error, Empty: msg(c, "economic_calendar.empty")})
Version: opts.Version, Status: opts.Status, Error: opts.Error, Empty: msg(c, "economic_calendar.empty")})
v.Element.Grow(1)
})
v.Element = col
@@ -122,7 +133,6 @@ func economicImportance(c *ui.Context, level int) {
})
}
// economicFilter keeps events of the chosen countries and importance, only refiltering on change.
func economicFilter(s *EconomicCalendarState, events []EconomicEvent, version int) []EconomicEvent {
key := fmt.Sprint(chartID(events), version, s.MinImportance, strings.Join(s.Countries, "\x00"))
if key == s.cache.key {
+38
View File
@@ -0,0 +1,38 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"testing"
)
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)
})
})
}
+255
View File
@@ -0,0 +1,255 @@
package mujica_test
import (
"fmt"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"time"
)
func ExampleOptionChain() {
var state mujica.OptionChainState
rows := []mujica.OptionStrike{{Strike: 100, Call: mujica.OptionQuote{Bid: 3, Ask: 3.2, Last: 3.1}, Put: mujica.OptionQuote{Bid: 2, Ask: 2.2, Last: 2.1}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.OptionChain(c, &state, []string{"Oct 17", "Nov 21"}, rows, mujica.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 := []mujica.GreeksRow{{Label: "AAPL 100 C", Greeks: mujica.OptionGreeks{Delta: 0.5, Gamma: 0.03, Theta: -0.1, Vega: 0.2, Rho: 0.05}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.GreeksTable(c, rows, mujica.GreeksTableOptions{Notes: true})
}
ui.Render(view, 960, 440, 1)
}
func ExamplePayoffDiagram() {
legs := []mujica.OptionLeg{{Kind: mujica.LegCall, Strike: 100, Premium: 3, Quantity: 1}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.PayoffDiagram(c, legs, mujica.PayoffDiagramOptions{Frame: mujica.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 mujica.WatchlistState
groups := []mujica.WatchGroup{{Name: "Technology", Items: []mujica.WatchItem{{Symbol: "AAPL", Name: "Apple", Price: 227.5, Change: 2.7, ChangePercent: 1.2}}}, {Name: "Energy", Items: []mujica.WatchItem{{Symbol: "XOM", Price: 117.4}}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.Watchlist(c, &state, groups, mujica.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 mujica.SymbolSearchState
rows := []mujica.MarketSymbol{{Code: "AAPL", Name: "Apple", Market: "NASDAQ", Type: "Stock"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.SymbolSearch(c, &state, rows, mujica.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 := mujica.MarketSymbol{Code: "AAPL", Name: "Apple", Market: "NASDAQ", Type: "Stock"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.SymbolBadge(c, symbol, mujica.SymbolBadgeOptions{Clickable: true})
if r.Changed() {
fmt.Println(symbol.Code)
}
}
ui.Render(view, 960, 440, 1)
}
func ExampleMarketHeatmap() {
var path []string
root := mujica.HeatNode{Name: "Market", Children: []mujica.HeatNode{{Name: "Technology", Children: []mujica.HeatNode{{Name: "AAPL", Cap: 3000, Change: 1.2}, {Name: "MSFT", Cap: 2800, Change: -0.6}}}, {Name: "Energy", Cap: 450, Change: -2.1}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.MarketHeatmap(c, &path, root, mujica.MarketHeatmapOptions{Frame: mujica.ChartFrame{Label: "Market"}})
if r.Changed() || r.ValueChanged() || r.Retried() {
fmt.Println(path, r.Point)
}
}
ui.Render(view, 960, 440, 1)
}
func ExampleScreener() {
var state mujica.ScreenerState
fields := []mujica.ScreenerField{{ID: "pe", Label: "P/E", Decimals: 1}}
rows := []mujica.ScreenerRow{{Symbol: "AAPL", Values: map[string]float64{"pe": 34}}, {Symbol: "XOM", Values: map[string]float64{"pe": 13}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.Screener(c, &state, fields, rows, mujica.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 := []mujica.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) {
mujica.Use(c, mujica.Settings{})
r := mujica.MarketOverview(c, indices, mujica.MarketOverviewOptions{Breadth: mujica.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 := mujica.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) {
mujica.Use(c, mujica.Settings{})
r := mujica.IndexCard(c, quote, mujica.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) {
mujica.Use(c, mujica.Settings{})
mujica.MarketStatus(c, mujica.MarketOpen, mujica.MarketStatusOptions{Market: "NYSE", Now: now, Next: now.Add(5 * time.Hour), NextPhase: mujica.MarketAfterHours})
}
ui.Render(view, 960, 440, 1)
}
func ExampleTradingSessionClock() {
now := time.Date(2026, 10, 5, 14, 30, 0, 0, time.UTC)
markets := []mujica.SessionMarket{{Name: "NYSE", Location: time.FixedZone("EDT", -4*3600), Sessions: []mujica.TradingSession{{Phase: mujica.MarketOpen, Start: 570, End: 960}}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.TradingSessionClock(c, markets, mujica.TradingSessionClockOptions{Now: now})
}
ui.Render(view, 960, 440, 1)
}
func ExampleEconomicCalendar() {
var state mujica.EconomicCalendarState
rows := []mujica.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) {
mujica.Use(c, mujica.Settings{})
r := mujica.EconomicCalendar(c, &state, rows, mujica.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 mujica.DataTableState[string]
rows := []mujica.EarningsEvent{{ID: "a", Symbol: "AAPL", Name: "Apple", Date: mujica.Date{Year: 2026, Month: 10, Day: 30}, Timing: mujica.EarningsAfterClose, EPSEstimate: 1.6, EPSActual: 1.64}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.EarningsCalendar(c, &state, rows, mujica.EarningsCalendarOptions{})
if r.Changed() || r.Submitted() || r.SortChanged() || r.Retried() {
fmt.Println(state.Selected())
}
}
ui.Render(view, 960, 440, 1)
}
func ExampleNewsFeed() {
var state mujica.NewsFeedState
rows := []mujica.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) {
mujica.Use(c, mujica.Settings{})
r := mujica.NewsFeed(c, &state, rows, mujica.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) {
mujica.Use(c, mujica.Settings{})
mujica.SentimentGauge(c, value, mujica.SentimentGaugeOptions{Label: "Fear & Greed"})
}
ui.Render(view, 960, 440, 1)
}
func ExamplePortfolioSummary() {
account := ""
accounts := []mujica.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) {
mujica.Use(c, mujica.Settings{})
r := mujica.PortfolioSummary(c, &account, accounts, mujica.PortfolioSummaryOptions{})
if r.Changed() {
fmt.Println(account)
}
}
ui.Render(view, 960, 440, 1)
}
func ExampleAssetAllocationChart() {
items := []mujica.AllocationItem{{Name: "Stocks", Value: 60, Target: 50}, {Name: "Bonds", Value: 30, Target: 40}, {Name: "Cash", Value: 10, Target: 10}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.AssetAllocationChart(c, items, mujica.AssetAllocationChartOptions{Frame: mujica.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) {
mujica.Use(c, mujica.Settings{})
r := mujica.PerformanceChart(c, values, mujica.PerformanceChartOptions{Frame: mujica.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 := mujica.CurrencyConverterState{Amount: 100, From: "USD", To: "JPY"}
currencies := []mujica.Currency{{Code: "USD", Name: "Dollar", Decimals: 2}, {Code: "JPY", Name: "Yen", Decimals: 0}}
rate := 149.37
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.CurrencyConverter(c, &state, currencies, mujica.CurrencyConverterOptions{Rate: rate})
if r.PairChanged {
rate = 0
fmt.Println(state.From, state.To)
}
}
ui.Render(view, 960, 440, 1)
}
+231 -1
View File
@@ -1,3 +1,233 @@
package main
var marketsPages = []page{}
import (
"fmt"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
var marketsPages = []page{
{ID: "181", Name: "OptionChain", ZhName: "期权链", Category: "Markets", Demo: marketsOptionChainDemo, Code: `var state mujica.OptionChainState
rows := []mujica.OptionStrike{{Strike: 100, Call: mujica.OptionQuote{Bid: 3, Ask: 3.2, Last: 3.1}, Put: mujica.OptionQuote{Bid: 2, Ask: 2.2, Last: 2.1}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.OptionChain(c, &state, []string{"Oct 17", "Nov 21"}, rows, mujica.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)
`, Keys: "Tab 切换到期日或列表;上下、Home/End 选择行;左右切换认购认沽;Enter 确认。"},
{ID: "182", Name: "GreeksTable", ZhName: "希腊值表", Category: "Markets", Demo: marketsGreeksTableDemo, Code: `rows := []mujica.GreeksRow{{Label: "AAPL 100 C", Greeks: mujica.OptionGreeks{Delta: 0.5, Gamma: 0.03, Theta: -0.1, Vega: 0.2, Rho: 0.05}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.GreeksTable(c, rows, mujica.GreeksTableOptions{Notes: true})
}
ui.Render(view, 960, 440, 1)
`, Keys: "展示组件;表头提供说明和悬停提示。"},
{ID: "183", Name: "PayoffDiagram", ZhName: "到期损益图", Category: "Markets", Demo: marketsPayoffDiagramDemo, Code: `legs := []mujica.OptionLeg{{Kind: mujica.LegCall, Strike: 100, Premium: 3, Quantity: 1}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.PayoffDiagram(c, legs, mujica.PayoffDiagramOptions{Frame: mujica.ChartFrame{Label: "Call payoff"}, Spot: 103})
if r.Changed() || r.Retried() {
fmt.Println(r.Point)
}
}
ui.Render(view, 960, 440, 1)
`, Keys: "Tab 聚焦;方向键、Home/End 逐点读数;Escape 清除。"},
{ID: "184", Name: "Watchlist", ZhName: "自选列表", Category: "Markets", Demo: marketsWatchlistDemo, Code: `var state mujica.WatchlistState
groups := []mujica.WatchGroup{{Name: "Technology", Items: []mujica.WatchItem{{Symbol: "AAPL", Name: "Apple", Price: 227.5, Change: 2.7, ChangePercent: 1.2}}}, {Name: "Energy", Items: []mujica.WatchItem{{Symbol: "XOM", Price: 117.4}}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.Watchlist(c, &state, groups, mujica.WatchlistOptions{})
if r.Changed || r.Submitted || r.Moved {
fmt.Println(state.Selected, r.Rows, r.To)
}
}
ui.Render(view, 960, 440, 1)
`, Keys: "Tab 切换分组或列菜单;上下、Home/End 选择;Enter 确认;菜单 Space 开关列。"},
{ID: "185", Name: "SymbolSearch", ZhName: "代码搜索", Category: "Markets", Demo: marketsSymbolSearchDemo, Code: `var state mujica.SymbolSearchState
rows := []mujica.MarketSymbol{{Code: "AAPL", Name: "Apple", Market: "NASDAQ", Type: "Stock"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.SymbolSearch(c, &state, rows, mujica.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)
`, Keys: "Tab 聚焦输入与市场;Down 进入结果;Tab 到达结果按钮,Enter 选择。"},
{ID: "186", Name: "SymbolBadge", ZhName: "代码徽标", Category: "Markets", Demo: marketsSymbolBadgeDemo, Code: `symbol := mujica.MarketSymbol{Code: "AAPL", Name: "Apple", Market: "NASDAQ", Type: "Stock"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.SymbolBadge(c, symbol, mujica.SymbolBadgeOptions{Clickable: true})
if r.Changed() {
fmt.Println(symbol.Code)
}
}
ui.Render(view, 960, 440, 1)
`, Keys: "Clickable 开启时 Tab 聚焦,Enter/Space 激活。"},
{ID: "187", Name: "MarketHeatmap", ZhName: "市场热力图", Category: "Markets", Demo: marketsMarketHeatmapDemo, Code: `var path []string
root := mujica.HeatNode{Name: "Market", Children: []mujica.HeatNode{{Name: "Technology", Children: []mujica.HeatNode{{Name: "AAPL", Cap: 3000, Change: 1.2}, {Name: "MSFT", Cap: 2800, Change: -0.6}}}, {Name: "Energy", Cap: 450, Change: -2.1}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.MarketHeatmap(c, &path, root, mujica.MarketHeatmapOptions{Frame: mujica.ChartFrame{Label: "Market"}})
if r.Changed() || r.ValueChanged() || r.Retried() {
fmt.Println(path, r.Point)
}
}
ui.Render(view, 960, 440, 1)
`, Keys: "Tab 聚焦图;方向键逐块读数;Enter 下钻;Backspace 返回;路径按钮可用 Tab。"},
{ID: "188", Name: "Screener", ZhName: "选股器", Category: "Markets", Demo: marketsScreenerDemo, Code: `var state mujica.ScreenerState
fields := []mujica.ScreenerField{{ID: "pe", Label: "P/E", Decimals: 1}}
rows := []mujica.ScreenerRow{{Symbol: "AAPL", Values: map[string]float64{"pe": 34}}, {Symbol: "XOM", Values: map[string]float64{"pe": 13}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.Screener(c, &state, fields, rows, mujica.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)
`, Keys: "Tab 到达字段、比较、数值和保存;Select 方向键选择;NumberInput 方向键步进;表格方向键选择。"},
{ID: "189", Name: "MarketOverview", ZhName: "市场概览", Category: "Markets", Demo: marketsMarketOverviewDemo, Code: `indices := []mujica.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) {
mujica.Use(c, mujica.Settings{})
r := mujica.MarketOverview(c, indices, mujica.MarketOverviewOptions{Breadth: mujica.MarketBreadth{Advancing: 2100, Declining: 1400, Unchanged: 300}})
if r.Chosen >= 0 {
fmt.Println(indices[r.Chosen].Name)
}
}
ui.Render(view, 960, 440, 1)
`, Keys: "Tab 到达指数卡片;Enter/Space 选择。"},
{ID: "190", Name: "IndexCard", ZhName: "指数卡片", Category: "Markets", Demo: marketsIndexCardDemo, Code: `quote := mujica.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) {
mujica.Use(c, mujica.Settings{})
r := mujica.IndexCard(c, quote, mujica.IndexCardOptions{Clickable: true})
if r.Changed() {
fmt.Println(quote.Name)
}
}
ui.Render(view, 960, 440, 1)
`, Keys: "Tab 聚焦卡片或迷你图;Enter/Space 激活卡片,图表方向键读数。"},
{ID: "191", Name: "MarketStatus", ZhName: "市场状态", Category: "Markets", Demo: marketsMarketStatusDemo, Code: `now := time.Date(2026, 10, 5, 14, 30, 0, 0, time.UTC)
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.MarketStatus(c, mujica.MarketOpen, mujica.MarketStatusOptions{Market: "NYSE", Now: now, Next: now.Add(5 * time.Hour), NextPhase: mujica.MarketAfterHours})
}
ui.Render(view, 960, 440, 1)
`, Keys: "展示组件。"},
{ID: "192", Name: "TradingSessionClock", ZhName: "交易时段时钟", Category: "Markets", Demo: marketsTradingSessionClockDemo, Code: `now := time.Date(2026, 10, 5, 14, 30, 0, 0, time.UTC)
markets := []mujica.SessionMarket{{Name: "NYSE", Location: time.FixedZone("EDT", -4*3600), Sessions: []mujica.TradingSession{{Phase: mujica.MarketOpen, Start: 570, End: 960}}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.TradingSessionClock(c, markets, mujica.TradingSessionClockOptions{Now: now})
}
ui.Render(view, 960, 440, 1)
`, Keys: "展示组件。"},
{ID: "193", Name: "EconomicCalendar", ZhName: "经济日历", Category: "Markets", Demo: marketsEconomicCalendarDemo, Code: `var state mujica.EconomicCalendarState
rows := []mujica.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) {
mujica.Use(c, mujica.Settings{})
r := mujica.EconomicCalendar(c, &state, rows, mujica.EconomicCalendarOptions{})
if r.Changed() || r.Submitted() || r.SortChanged() || r.Retried() {
fmt.Println(state.Table.Selected())
}
}
ui.Render(view, 960, 440, 1)
`, Keys: "Tab 到达国家过滤、重要性和表格;方向键选择;Enter 提交行。"},
{ID: "194", Name: "EarningsCalendar", ZhName: "财报日历", Category: "Markets", Demo: marketsEarningsCalendarDemo, Code: `var state mujica.DataTableState[string]
rows := []mujica.EarningsEvent{{ID: "a", Symbol: "AAPL", Name: "Apple", Date: mujica.Date{Year: 2026, Month: 10, Day: 30}, Timing: mujica.EarningsAfterClose, EPSEstimate: 1.6, EPSActual: 1.64}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.EarningsCalendar(c, &state, rows, mujica.EarningsCalendarOptions{})
if r.Changed() || r.Submitted() || r.SortChanged() || r.Retried() {
fmt.Println(state.Selected())
}
}
ui.Render(view, 960, 440, 1)
`, Keys: "Tab 聚焦表格;方向键及 Home/End 选择;Enter 提交;表头支持键盘排序。"},
{ID: "195", Name: "NewsFeed", ZhName: "新闻流", Category: "Markets", Demo: marketsNewsFeedDemo, Code: `var state mujica.NewsFeedState
rows := []mujica.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) {
mujica.Use(c, mujica.Settings{})
r := mujica.NewsFeed(c, &state, rows, mujica.NewsFeedOptions{})
if r.Opened != "" || r.Symbol != "" || r.Retry {
fmt.Println(r.Opened, r.Symbol)
}
}
ui.Render(view, 960, 440, 1)
`, Keys: "Tab 聚焦列表或相关代码;上下选择;Enter 打开并标记已读。"},
{ID: "196", Name: "SentimentGauge", ZhName: "情绪仪表", Category: "Markets", Demo: marketsSentimentGaugeDemo, Code: `value := 72.0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SentimentGauge(c, value, mujica.SentimentGaugeOptions{Label: "Fear & Greed"})
}
ui.Render(view, 960, 440, 1)
`, Keys: "展示组件,数值与文字同时呈现。"},
{ID: "197", Name: "PortfolioSummary", ZhName: "资产总览", Category: "Markets", Demo: marketsPortfolioSummaryDemo, Code: `account := ""
accounts := []mujica.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) {
mujica.Use(c, mujica.Settings{})
r := mujica.PortfolioSummary(c, &account, accounts, mujica.PortfolioSummaryOptions{})
if r.Changed() {
fmt.Println(account)
}
}
ui.Render(view, 960, 440, 1)
`, Keys: "Tab 聚焦账户组;方向键切换账户。"},
{ID: "198", Name: "AssetAllocationChart", ZhName: "资产配置图", Category: "Markets", Demo: marketsAssetAllocationChartDemo, Code: `items := []mujica.AllocationItem{{Name: "Stocks", Value: 60, Target: 50}, {Name: "Bonds", Value: 30, Target: 40}, {Name: "Cash", Value: 10, Target: 10}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.AssetAllocationChart(c, items, mujica.AssetAllocationChartOptions{Frame: mujica.ChartFrame{Label: "Allocation"}})
if r.Changed() || r.Retried() {
fmt.Println(r.Point)
}
}
ui.Render(view, 960, 440, 1)
`, Keys: "Tab 聚焦环形图;方向键逐项读数;Home/End 到首尾。"},
{ID: "199", Name: "PerformanceChart", ZhName: "收益曲线", Category: "Markets", Demo: marketsPerformanceChartDemo, Code: `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) {
mujica.Use(c, mujica.Settings{})
r := mujica.PerformanceChart(c, values, mujica.PerformanceChartOptions{Frame: mujica.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)
`, Keys: "Tab 聚焦图和图例;方向键读数;Space 切换序列。"},
{ID: "200", Name: "CurrencyConverter", ZhName: "货币换算", Category: "Markets", Demo: marketsCurrencyConverterDemo, Code: `state := mujica.CurrencyConverterState{Amount: 100, From: "USD", To: "JPY"}
currencies := []mujica.Currency{{Code: "USD", Name: "Dollar", Decimals: 2}, {Code: "JPY", Name: "Yen", Decimals: 0}}
rate := 149.37
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.CurrencyConverter(c, &state, currencies, mujica.CurrencyConverterOptions{Rate: rate})
if r.PairChanged {
rate = 0
fmt.Println(state.From, state.To)
}
}
ui.Render(view, 960, 440, 1)
`, Keys: "Tab 到达金额、币种和互换;方向键步进、选择;Enter/Space 互换。"},
}
func marketsDataState(c *ui.Context, id string, log func(string)) (mujica.DataStatus, bool, string) {
state := ui.Local(c.Root(), id+".status", func() int { return 0 })
if mujica.RadioGroup(c, state, []mujica.RadioItem[int]{{Value: 0, Label: "Ready"}, {Value: 1, Label: "Loading"}, {Value: 2, Label: "Empty"}, {Value: 3, Label: "Error"}}, mujica.RadioGroupOptions{Horizontal: true}).Changed() {
log(fmt.Sprint("status ", *state))
}
switch *state {
case 1:
return mujica.DataLoading, false, ""
case 2:
return mujica.DataReady, true, ""
case 3:
return mujica.DataFailed, false, "Sample failed"
}
return mujica.DataReady, false, ""
}
+165
View File
@@ -0,0 +1,165 @@
package main
import (
"fmt"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"slices"
"strings"
)
func marketsOptionChainDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "181.disabled", func() bool { return false })
if mujica.Checkbox(c, disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("disabled ", *disabled))
}
state := ui.Local(c.Root(), "181.state", func() mujica.OptionChainState { return mujica.OptionChainState{} })
rows := *ui.Local(c.Root(), "markets.OptionChain.rows.pages_markets_1", func() []mujica.OptionStrike {
return []mujica.OptionStrike{{Strike: 100, Call: mujica.OptionQuote{Bid: 3, Ask: 3.2, Last: 3.1}, Put: mujica.OptionQuote{Bid: 2, Ask: 2.2, Last: 2.1}}}
})
precision := ui.Local(c.Root(), "markets.OptionChain.precision", func() float64 { return 2 })
if mujica.Slider(c, precision, mujica.SliderOptions{Min: 1, Max: 8, Step: 1, Label: "Decimals", ShowValue: true}).Changed() {
log(fmt.Sprint("decimals ", *precision))
}
spot := ui.Local(c.Root(), "181.spot", func() float64 { return 100.4 })
if mujica.Slider(c, spot, mujica.SliderOptions{Min: 95, Max: 105, Step: 0.1, Label: "Spot", ShowValue: true}).Changed() {
log(fmt.Sprint("spot ", *spot))
}
ui.Column(c).Disabled(*disabled).MinHeight(0).Height(400).Children(func() {
r := mujica.OptionChain(c, state, []string{"Oct 17", "Nov 21"}, rows, mujica.OptionChainOptions{Decimals: int(*precision), Spot: *spot})
if r.Changed || r.Submitted || r.ExpiryChanged {
log(fmt.Sprint(state.Expiry, state.Strike, state.Side))
}
})
}
func marketsGreeksTableDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "182.disabled", func() bool { return false })
if mujica.Checkbox(c, disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("disabled ", *disabled))
}
rows := *ui.Local(c.Root(), "markets.GreeksTable.rows.pages_markets_1", func() []mujica.GreeksRow {
return []mujica.GreeksRow{{Label: "AAPL 100 C", Greeks: mujica.OptionGreeks{Delta: 0.5, Gamma: 0.03, Theta: -0.1, Vega: 0.2, Rho: 0.05}}}
})
precision := ui.Local(c.Root(), "markets.GreeksTable.precision", func() float64 { return 2 })
if mujica.Slider(c, precision, mujica.SliderOptions{Min: 1, Max: 8, Step: 1, Label: "Decimals", ShowValue: true}).Changed() {
log(fmt.Sprint("decimals ", *precision))
}
notes := ui.Local(c.Root(), "182.notes", func() bool { return true })
if mujica.Checkbox(c, notes, "Notes", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("notes ", *notes))
}
ui.Column(c).Disabled(*disabled).MinHeight(0).Height(400).Children(func() {
mujica.GreeksTable(c, rows, mujica.GreeksTableOptions{Decimals: int(*precision), Notes: *notes})
})
}
func marketsPayoffDiagramDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "183.disabled", func() bool { return false })
if mujica.Checkbox(c, disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("disabled ", *disabled))
}
legs := *ui.Local(c.Root(), "markets.PayoffDiagram.legs.pages_markets_1", func() []mujica.OptionLeg {
return []mujica.OptionLeg{{Kind: mujica.LegCall, Strike: 100, Premium: 3, Quantity: 1}}
})
status := ui.Local(c.Root(), "183.status", func() int { return 0 })
if mujica.RadioGroup(c, status, []mujica.RadioItem[int]{{Value: 0, Label: "Ready"}, {Value: 1, Label: "Loading"}, {Value: 2, Label: "Empty"}, {Value: 3, Label: "Error"}}, mujica.RadioGroupOptions{Horizontal: true}).Changed() {
log(fmt.Sprint("status ", *status))
}
frame := mujica.ChartFrame{Label: "PayoffDiagram"}
if *status == 1 {
frame.Status = mujica.DataLoading
}
if *status == 3 {
frame.Status = mujica.DataFailed
frame.Error = "Sample failed"
}
if *status == 2 {
legs = nil
}
ui.Column(c).Disabled(*disabled).MinHeight(0).Height(400).Children(func() {
r := mujica.PayoffDiagram(c, legs, mujica.PayoffDiagramOptions{Frame: frame, Spot: 103})
if r.Changed() || r.Retried() {
log(fmt.Sprint(r.Point))
}
})
}
func marketsWatchlistDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "184.disabled", func() bool { return false })
if mujica.Checkbox(c, disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("disabled ", *disabled))
}
state := ui.Local(c.Root(), "184.state", func() mujica.WatchlistState { return mujica.WatchlistState{} })
groups := *ui.Local(c.Root(), "markets.Watchlist.groups.pages_markets_1", func() []mujica.WatchGroup {
return []mujica.WatchGroup{{Name: "Technology", Items: []mujica.WatchItem{{Symbol: "AAPL", Name: "Apple", Price: 227.5, Change: 2.7, ChangePercent: 1.2}, {Symbol: "MSFT", Name: "Microsoft", Price: 418.2, Change: -2.5, ChangePercent: -0.6}}}, {Name: "Energy", Items: []mujica.WatchItem{{Symbol: "XOM", Price: 117.4}}}}
})
precision := ui.Local(c.Root(), "markets.Watchlist.precision", func() float64 { return 2 })
if mujica.Slider(c, precision, mujica.SliderOptions{Min: 1, Max: 8, Step: 1, Label: "Decimals", ShowValue: true}).Changed() {
log(fmt.Sprint("decimals ", *precision))
}
if mujica.Button(c, "Refresh quotes", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
groups[0].Items = slices.Clone(groups[0].Items)
groups[0].Items[0].Price += 0.1
log("quotes refreshed")
}
beforeGroup, beforeColumns := state.Group, fmt.Sprint(state.Hidden)
ui.Column(c).Disabled(*disabled).MinHeight(0).Height(400).Children(func() {
r := mujica.Watchlist(c, state, groups, mujica.WatchlistOptions{Decimals: int(*precision)})
if r.Changed || r.Submitted || r.Moved {
log(fmt.Sprint(state.Selected, r.Rows, r.To))
}
if r.Moved {
items := groups[state.Group].Items
var moving []mujica.WatchItem
var rest []mujica.WatchItem
at := r.To
for j, item := range items {
if slices.Contains(r.Rows, j) {
moving = append(moving, item)
if j < r.To {
at--
}
} else {
rest = append(rest, item)
}
}
groups[state.Group].Items = slices.Insert(rest, at, moving...)
}
})
if state.Group != beforeGroup || fmt.Sprint(state.Hidden) != beforeColumns {
log(fmt.Sprint("group/columns ", state.Group, state.Hidden))
}
}
func marketsSymbolSearchDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "185.disabled", func() bool { return false })
if mujica.Checkbox(c, disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("disabled ", *disabled))
}
state := ui.Local(c.Root(), "185.state", func() mujica.SymbolSearchState { return mujica.SymbolSearchState{} })
rows := *ui.Local(c.Root(), "markets.SymbolSearch.rows.pages_markets_1", func() []mujica.MarketSymbol {
return []mujica.MarketSymbol{{Code: "AAPL", Name: "Apple", Market: "NASDAQ", Type: "Stock"}}
})
status, empty, err := marketsDataState(c, "185", log)
if empty || status != mujica.DataReady {
rows = nil
}
results := []mujica.MarketSymbol{}
for _, row := range rows {
if (state.Market == "" || state.Market == row.Market) && strings.Contains(strings.ToUpper(row.Code+" "+row.Name), strings.ToUpper(state.Query)) {
results = append(results, row)
}
}
ui.Column(c).Disabled(*disabled).MinHeight(0).Height(400).Children(func() {
r := mujica.SymbolSearch(c, state, results, mujica.SymbolSearchOptions{Loading: status == mujica.DataLoading, Error: err, Markets: []string{"NASDAQ", "NYSE"}, Recent: rows})
if r.Changed || r.Picked || r.Retry {
log(fmt.Sprint(state.Query, state.Market, r.Chosen.Code))
}
})
}
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"fmt"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func marketsSymbolBadgeDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "186.disabled", func() bool { return false })
if mujica.Checkbox(c, disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("disabled ", *disabled))
}
symbol := *ui.Local(c.Root(), "markets.SymbolBadge.symbol.pages_markets_2", func() mujica.MarketSymbol {
return mujica.MarketSymbol{Code: "AAPL", Name: "Apple", Market: "NASDAQ", Type: "Stock"}
})
kind := ui.Local(c.Root(), "186.kind", func() string { return "Stock" })
if mujica.RadioGroup(c, kind, []mujica.RadioItem[string]{{Value: "Stock", Label: "Stock"}, {Value: "ETF", Label: "ETF"}}, mujica.RadioGroupOptions{Horizontal: true}).Changed() {
log(*kind)
}
symbol.Type = *kind
ui.Column(c).Disabled(*disabled).MinHeight(0).Height(400).Children(func() {
r := mujica.SymbolBadge(c, symbol, mujica.SymbolBadgeOptions{Clickable: true})
if r.Changed() {
log(fmt.Sprint(symbol.Code))
}
})
}
func marketsMarketHeatmapDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "187.disabled", func() bool { return false })
if mujica.Checkbox(c, disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("disabled ", *disabled))
}
path := ui.Local(c.Root(), "187.path", func() []string { return []string{} })
root := *ui.Local(c.Root(), "markets.MarketHeatmap.root.pages_markets_2", func() mujica.HeatNode {
return mujica.HeatNode{Name: "Market", Children: []mujica.HeatNode{{Name: "Technology", Children: []mujica.HeatNode{{Name: "AAPL", Cap: 3000, Change: 1.2}, {Name: "MSFT", Cap: 2800, Change: -0.6}}}, {Name: "Energy", Cap: 450, Change: -2.1}}}
})
status := ui.Local(c.Root(), "187.status", func() int { return 0 })
if mujica.RadioGroup(c, status, []mujica.RadioItem[int]{{Value: 0, Label: "Ready"}, {Value: 1, Label: "Loading"}, {Value: 2, Label: "Empty"}, {Value: 3, Label: "Error"}}, mujica.RadioGroupOptions{Horizontal: true}).Changed() {
log(fmt.Sprint("status ", *status))
}
frame := mujica.ChartFrame{Label: "MarketHeatmap"}
if *status == 1 {
frame.Status = mujica.DataLoading
}
if *status == 3 {
frame.Status = mujica.DataFailed
frame.Error = "Sample failed"
}
if *status == 2 {
root.Children = nil
if len(*path) > 0 {
*path = nil
log("path reset: empty data")
}
}
scale := ui.Local(c.Root(), "187.scale", func() float64 { return 3 })
if mujica.Slider(c, scale, mujica.SliderOptions{Min: 1, Max: 10, Step: 1, Label: "Color scale", ShowValue: true}).Changed() {
log(fmt.Sprint(*scale))
}
ui.Column(c).Disabled(*disabled).MinHeight(0).Height(400).Children(func() {
r := mujica.MarketHeatmap(c, path, root, mujica.MarketHeatmapOptions{Frame: frame, Scale: *scale})
if r.Changed() || r.ValueChanged() || r.Retried() {
log(fmt.Sprint(path, r.Point))
}
})
}
func marketsScreenerDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "188.disabled", func() bool { return false })
if mujica.Checkbox(c, disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("disabled ", *disabled))
}
state := ui.Local(c.Root(), "188.state", func() mujica.ScreenerState { return mujica.ScreenerState{} })
fields := *ui.Local(c.Root(), "markets.Screener.fields.pages_markets_2", func() []mujica.ScreenerField { return []mujica.ScreenerField{{ID: "pe", Label: "P/E", Decimals: 1}} })
rows := *ui.Local(c.Root(), "markets.Screener.rows.pages_markets_2", func() []mujica.ScreenerRow {
return []mujica.ScreenerRow{{Symbol: "AAPL", Values: map[string]float64{"pe": 34}}, {Symbol: "XOM", Values: map[string]float64{"pe": 13}}}
})
status, empty, err := marketsDataState(c, "188", log)
if empty || status != mujica.DataReady {
rows = nil
}
ui.Column(c).Disabled(*disabled).MinHeight(0).Height(400).Children(func() {
r := mujica.Screener(c, state, fields, rows, mujica.ScreenerOptions{Status: status, Error: err})
if r.Changed || r.Save || r.Table.Changed() || r.Table.Submitted() || r.Table.SortChanged() || r.Table.Retried() {
log(fmt.Sprint(state.Conditions))
}
})
}
func marketsMarketOverviewDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "189.disabled", func() bool { return false })
if mujica.Checkbox(c, disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("disabled ", *disabled))
}
indices := *ui.Local(c.Root(), "markets.MarketOverview.indices.pages_markets_2", func() []mujica.IndexQuote {
return []mujica.IndexQuote{{Name: "S&P 500", Price: 5751.07, Change: 51.13, ChangePercent: 0.9, Series: []float64{5700, 5730, 5720, 5751}}}
})
advancing := ui.Local(c.Root(), "189.advancing", func() float64 { return 2100 })
if mujica.Slider(c, advancing, mujica.SliderOptions{Min: 0, Max: 4000, Step: 100, Label: "Advancing", ShowValue: true}).Changed() {
log(fmt.Sprint(*advancing))
}
ui.Column(c).Disabled(*disabled).MinHeight(0).Height(400).Children(func() {
r := mujica.MarketOverview(c, indices, mujica.MarketOverviewOptions{Breadth: mujica.MarketBreadth{Advancing: int(*advancing), Declining: 1400, Unchanged: 300}})
if r.Chosen >= 0 {
log(fmt.Sprint(indices[r.Chosen].Name))
}
})
}
func marketsIndexCardDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "190.disabled", func() bool { return false })
if mujica.Checkbox(c, disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("disabled ", *disabled))
}
quote := *ui.Local(c.Root(), "markets.IndexCard.quote.pages_markets_2", func() mujica.IndexQuote {
return mujica.IndexQuote{Name: "S&P 500", Price: 5751.07, Change: 51.13, ChangePercent: 0.9, Series: []float64{5700, 5730, 5720, 5751}}
})
precision := ui.Local(c.Root(), "markets.IndexCard.precision", func() float64 { return 2 })
if mujica.Slider(c, precision, mujica.SliderOptions{Min: 1, Max: 8, Step: 1, Label: "Decimals", ShowValue: true}).Changed() {
log(fmt.Sprint("decimals ", *precision))
}
price := ui.Local(c.Root(), "190.price", func() float64 { return 5751.07 })
if mujica.NumberInput(c, price, mujica.NumberInputOptions{Min: 1, Max: 10000, Step: 0.01, Precision: 2, Label: "Price"}).Changed() {
log(fmt.Sprint(*price))
}
quote.Price = *price
ui.Column(c).Disabled(*disabled).MinHeight(0).Height(400).Children(func() {
r := mujica.IndexCard(c, quote, mujica.IndexCardOptions{Decimals: int(*precision), Clickable: true})
if r.Changed() {
log(fmt.Sprint(quote.Name))
}
})
}
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"fmt"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"time"
)
func marketsMarketStatusDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "191.disabled", func() bool { return false })
if mujica.Checkbox(c, disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("disabled ", *disabled))
}
now := time.Date(2026, 10, 5, 14, 30, 0, 0, time.UTC)
phase := ui.Local(c.Root(), "191.phase", func() mujica.MarketPhase { return mujica.MarketOpen })
if mujica.RadioGroup(c, phase, []mujica.RadioItem[mujica.MarketPhase]{{Value: mujica.MarketClosed, Label: "Closed"}, {Value: mujica.MarketPreMarket, Label: "Pre-market"}, {Value: mujica.MarketOpen, Label: "Open"}, {Value: mujica.MarketAfterHours, Label: "After hours"}, {Value: mujica.MarketHoliday, Label: "Holiday"}}, mujica.RadioGroupOptions{Horizontal: true}).Changed() {
log(fmt.Sprint(*phase))
}
ui.Column(c).Disabled(*disabled).MinHeight(0).Height(400).Children(func() {
mujica.MarketStatus(c, *phase, mujica.MarketStatusOptions{Market: "NYSE", Now: now, Next: now.Add(5 * time.Hour), NextPhase: mujica.MarketAfterHours})
})
}
func marketsTradingSessionClockDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "192.disabled", func() bool { return false })
if mujica.Checkbox(c, disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("disabled ", *disabled))
}
now := time.Date(2026, 10, 5, 14, 30, 0, 0, time.UTC)
markets := *ui.Local(c.Root(), "markets.TradingSessionClock.markets.pages_markets_3", func() []mujica.SessionMarket {
return []mujica.SessionMarket{{Name: "NYSE", Location: time.FixedZone("EDT", -4*3600), Sessions: []mujica.TradingSession{{Phase: mujica.MarketOpen, Start: 570, End: 960}}}}
})
holiday := ui.Local(c.Root(), "192.holiday", func() bool { return false })
if mujica.Checkbox(c, holiday, "Holiday", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint(*holiday))
}
markets[0].Holiday = *holiday
minute := ui.Local(c.Root(), "192.minute", func() float64 { return 870 })
if mujica.Slider(c, minute, mujica.SliderOptions{Min: 0, Max: 1439, Step: 1, Label: "UTC minute", ShowValue: true}).Changed() {
log(fmt.Sprint(*minute))
}
now = now.Truncate(24 * time.Hour).Add(time.Duration(*minute) * time.Minute)
ui.Column(c).Disabled(*disabled).MinHeight(0).Height(400).Children(func() {
mujica.TradingSessionClock(c, markets, mujica.TradingSessionClockOptions{Now: now})
})
}
func marketsEconomicCalendarDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "193.disabled", func() bool { return false })
if mujica.Checkbox(c, disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("disabled ", *disabled))
}
state := ui.Local(c.Root(), "193.state", func() mujica.EconomicCalendarState { return mujica.EconomicCalendarState{} })
rows := *ui.Local(c.Root(), "markets.EconomicCalendar.rows.pages_markets_3", func() []mujica.EconomicEvent {
return []mujica.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"}}
})
status, empty, err := marketsDataState(c, "193", log)
if empty || status != mujica.DataReady {
rows = nil
}
beforeFilter := fmt.Sprint(state.Countries, state.MinImportance)
ui.Column(c).Disabled(*disabled).MinHeight(0).Height(400).Children(func() {
r := mujica.EconomicCalendar(c, state, rows, mujica.EconomicCalendarOptions{Status: status, Error: err})
if r.Changed() || r.Submitted() || r.SortChanged() || r.Retried() {
log(fmt.Sprint(state.Table.Selected()))
}
})
if fmt.Sprint(state.Countries, state.MinImportance) != beforeFilter {
log(fmt.Sprint("filter ", state.Countries, state.MinImportance))
}
}
func marketsEarningsCalendarDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "194.disabled", func() bool { return false })
if mujica.Checkbox(c, disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("disabled ", *disabled))
}
state := ui.Local(c.Root(), "194.state", func() mujica.DataTableState[string] { return mujica.DataTableState[string]{} })
rows := *ui.Local(c.Root(), "markets.EarningsCalendar.rows.pages_markets_3", func() []mujica.EarningsEvent {
return []mujica.EarningsEvent{{ID: "a", Symbol: "AAPL", Name: "Apple", Date: mujica.Date{Year: 2026, Month: 10, Day: 30}, Timing: mujica.EarningsAfterClose, EPSEstimate: 1.6, EPSActual: 1.64}}
})
status, empty, err := marketsDataState(c, "194", log)
if empty || status != mujica.DataReady {
rows = nil
}
ui.Column(c).Disabled(*disabled).MinHeight(0).Height(400).Children(func() {
r := mujica.EarningsCalendar(c, state, rows, mujica.EarningsCalendarOptions{Status: status, Error: err})
if r.Changed() || r.Submitted() || r.SortChanged() || r.Retried() {
log(fmt.Sprint(state.Selected()))
}
})
}
func marketsNewsFeedDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "195.disabled", func() bool { return false })
if mujica.Checkbox(c, disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("disabled ", *disabled))
}
state := ui.Local(c.Root(), "195.state", func() mujica.NewsFeedState { return mujica.NewsFeedState{} })
rows := *ui.Local(c.Root(), "markets.NewsFeed.rows.pages_markets_3", func() []mujica.NewsItem {
return []mujica.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"}}}
})
status, empty, err := marketsDataState(c, "195", log)
if empty || status != mujica.DataReady {
rows = nil
}
ui.Column(c).Disabled(*disabled).MinHeight(0).Height(400).Children(func() {
r := mujica.NewsFeed(c, state, rows, mujica.NewsFeedOptions{Status: status, Error: err})
if r.Opened != "" || r.Symbol != "" || r.Retry || r.ReadChanged {
log(fmt.Sprint(r.Opened, r.Symbol))
}
})
}
+160
View File
@@ -0,0 +1,160 @@
package main
import (
"fmt"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"time"
)
func marketsSentimentGaugeDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "196.disabled", func() bool { return false })
if mujica.Checkbox(c, disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("disabled ", *disabled))
}
value := 72.0
level := ui.Local(c.Root(), "196.value", func() float64 { return 72 })
if mujica.Slider(c, level, mujica.SliderOptions{Min: 0, Max: 100, Step: 1, Label: "Sentiment", ShowValue: true}).Changed() {
log(fmt.Sprint(*level))
}
value = *level
status, empty, err := marketsDataState(c, "196", log)
ui.Column(c).Disabled(*disabled).MinHeight(0).Height(400).Children(func() {
mujica.SentimentGauge(c, value, mujica.SentimentGaugeOptions{Label: "Fear & Greed", Status: status, Empty: empty, Error: err, Retry: func() { log("retry requested") }})
})
}
func marketsPortfolioSummaryDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "197.disabled", func() bool { return false })
if mujica.Checkbox(c, disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("disabled ", *disabled))
}
account := ui.Local(c.Root(), "197.account", func() string { return "" })
accounts := *ui.Local(c.Root(), "markets.PortfolioSummary.accounts.pages_markets_4", func() []mujica.PortfolioAccount {
return []mujica.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}}
})
assets := ui.Local(c.Root(), "197.assets", func() float64 { return 120000 })
if mujica.NumberInput(c, assets, mujica.NumberInputOptions{Min: 0, Max: 1000000, Step: 100, Precision: 2, Label: "Brokerage assets"}).Changed() {
log(fmt.Sprint(*assets))
}
accounts[0].Total = *assets
ui.Column(c).Disabled(*disabled).MinHeight(0).Height(400).Children(func() {
r := mujica.PortfolioSummary(c, account, accounts, mujica.PortfolioSummaryOptions{})
if r.Changed() {
log(fmt.Sprint(account))
}
})
}
func marketsAssetAllocationChartDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "198.disabled", func() bool { return false })
if mujica.Checkbox(c, disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("disabled ", *disabled))
}
items := *ui.Local(c.Root(), "markets.AssetAllocationChart.items.pages_markets_4", func() []mujica.AllocationItem {
return []mujica.AllocationItem{{Name: "Stocks", Value: 60, Target: 50}, {Name: "Bonds", Value: 30, Target: 40}, {Name: "Cash", Value: 10, Target: 10}}
})
status := ui.Local(c.Root(), "198.status", func() int { return 0 })
if mujica.RadioGroup(c, status, []mujica.RadioItem[int]{{Value: 0, Label: "Ready"}, {Value: 1, Label: "Loading"}, {Value: 2, Label: "Empty"}, {Value: 3, Label: "Error"}}, mujica.RadioGroupOptions{Horizontal: true}).Changed() {
log(fmt.Sprint("status ", *status))
}
frame := mujica.ChartFrame{Label: "AssetAllocationChart"}
if *status == 1 {
frame.Status = mujica.DataLoading
}
if *status == 3 {
frame.Status = mujica.DataFailed
frame.Error = "Sample failed"
}
if *status == 2 {
items = nil
}
tolerance := ui.Local(c.Root(), "198.tolerance", func() float64 { return 5 })
if mujica.Slider(c, tolerance, mujica.SliderOptions{Min: 1, Max: 15, Step: 1, Label: "Drift tolerance", ShowValue: true}).Changed() {
log(fmt.Sprint(*tolerance))
}
ui.Column(c).Disabled(*disabled).MinHeight(0).Height(400).Children(func() {
r := mujica.AssetAllocationChart(c, items, mujica.AssetAllocationChartOptions{Frame: frame, Tolerance: *tolerance})
if r.Changed() || r.Retried() {
log(fmt.Sprint(r.Point))
}
})
}
func marketsPerformanceChartDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "199.disabled", func() bool { return false })
if mujica.Checkbox(c, disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("disabled ", *disabled))
}
values := *ui.Local(c.Root(), "markets.PerformanceChart.values.pages_markets_4", func() []float64 { return []float64{100, 110, 95, 120} })
benchmark := *ui.Local(c.Root(), "markets.PerformanceChart.benchmark.pages_markets_4", func() []float64 { return []float64{100, 105, 108, 112} })
times := *ui.Local(c.Root(), "markets.PerformanceChart.times.pages_markets_4", func() []time.Time {
return []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)}
})
status := ui.Local(c.Root(), "199.status", func() int { return 0 })
if mujica.RadioGroup(c, status, []mujica.RadioItem[int]{{Value: 0, Label: "Ready"}, {Value: 1, Label: "Loading"}, {Value: 2, Label: "Empty"}, {Value: 3, Label: "Error"}}, mujica.RadioGroupOptions{Horizontal: true}).Changed() {
log(fmt.Sprint("status ", *status))
}
frame := mujica.ChartFrame{Label: "PerformanceChart"}
if *status == 1 {
frame.Status = mujica.DataLoading
}
if *status == 3 {
frame.Status = mujica.DataFailed
frame.Error = "Sample failed"
}
if *status == 2 {
values = nil
benchmark = nil
times = nil
}
ui.Column(c).Disabled(*disabled).MinHeight(0).Height(400).Children(func() {
r := mujica.PerformanceChart(c, values, mujica.PerformanceChartOptions{Frame: frame, Times: times, Benchmark: benchmark})
if r.Changed() || r.Retried() {
log(fmt.Sprint(r.Point))
}
if name, ok := r.Toggled(); ok {
log(fmt.Sprint(name))
}
})
}
func marketsCurrencyConverterDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "200.disabled", func() bool { return false })
if mujica.Checkbox(c, disabled, "Disabled", mujica.CheckboxOptions{}).Changed() {
log(fmt.Sprint("disabled ", *disabled))
}
state := ui.Local(c.Root(), "200.state", func() mujica.CurrencyConverterState {
return mujica.CurrencyConverterState{Amount: 100, From: "USD", To: "JPY"}
})
currencies := *ui.Local(c.Root(), "markets.CurrencyConverter.currencies.pages_markets_4", func() []mujica.Currency {
return []mujica.Currency{{Code: "USD", Name: "Dollar", Decimals: 2}, {Code: "JPY", Name: "Yen", Decimals: 0}}
})
rate := ui.Local(c.Root(), "200.rate", func() float64 { return 149.37 })
if mujica.NumberInput(c, rate, mujica.NumberInputOptions{Min: 0, Max: 1000, Step: 0.0001, Precision: 4, Label: "Caller rate"}).Changed() {
log(fmt.Sprint("rate ", *rate))
}
if mujica.Button(c, "Load pair rate", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
*rate = 149.37
if state.From == "JPY" {
*rate = 1 / 149.37
}
log("pair rate loaded")
}
beforeAmount := state.Amount
ui.Column(c).Disabled(*disabled).MinHeight(0).Height(400).Children(func() {
r := mujica.CurrencyConverter(c, state, currencies, mujica.CurrencyConverterOptions{Rate: *rate, RateFrom: state.From, RateTo: state.To})
if r.PairChanged {
*rate = 0
log(fmt.Sprint(state.From, state.To))
}
})
if state.Amount != beforeAmount {
log(fmt.Sprint("amount ", state.Amount))
}
}
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"slices"
"strings"
"testing"
)
func marketsDemo(t *testing.T, id string) (*ui.Tester, *[]string) {
t.Helper()
i := slices.IndexFunc(marketsPages, func(p page) bool { return p.ID == id })
if i < 0 {
t.Fatalf("missing page %s", id)
}
events := &[]string{}
tt := ui.NewTester(func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
ui.Scroll(c).Fill().Padding(24).Children(func() { marketsPages[i].Demo(c, func(s string) { *events = append(*events, s) }) })
}, 1000, 800)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
tt.Frame()
return tt, events
}
func TestMarketsGalleryControls(t *testing.T) {
if len(marketsPages) != 20 {
t.Fatalf("pages %d", len(marketsPages))
}
for _, p := range marketsPages {
t.Run(p.ID, func(t *testing.T) {
if p.Category != "Markets" || p.Code == "" || p.Keys == "" {
t.Fatalf("incomplete page %+v", p)
}
tt, events := marketsDemo(t, p.ID)
if err := tt.Click("Disabled"); err != nil {
t.Fatal(err)
}
if len(*events) == 0 || !strings.Contains((*events)[len(*events)-1], "disabled true") {
t.Fatalf("toggle events %v", *events)
}
_ = tt.Click("Disabled")
if tt.HasText("Error") {
_ = tt.Click("Error")
if !tt.HasText("Sample failed") {
t.Fatalf("error state %q", tt.Texts())
}
_ = tt.Click("Empty")
_ = tt.Click("Ready")
}
})
}
}
func TestMarketsGalleryInteractionLogs(t *testing.T) {
for _, tc := range []struct{ id, label string }{{"186", "AAPL"}, {"188", "Add condition"}, {"189", "S&P 500"}, {"190", "S&P 500"}, {"195", "Mark all read"}, {"197", "Retirement"}, {"200", "Swap currencies"}} {
tt, events := marketsDemo(t, tc.id)
before := len(*events)
if err := tt.Click(tc.label); err != nil {
t.Fatal(err)
}
if len(*events) == before {
t.Fatalf("%s %s not logged", tc.id, tc.label)
}
}
}
+13 -8
View File
@@ -31,9 +31,8 @@ type GreeksTableOptions struct {
var greekNames = [5]string{"Delta", "Gamma", "Theta", "Vega", "Rho"}
// GreeksTable lists Delta, Gamma, Theta, Vega and Rho per contract; headers explain each.
func GreeksTable(c *ui.Context, rows []GreeksRow, opts GreeksTableOptions) *ui.Element {
if opts.Decimals < 0 {
if opts.Decimals < 0 || opts.Decimals > 12 {
panic("mujica: GreeksTable Decimals is negative")
}
dec := cmp.Or(opts.Decimals, 4)
@@ -42,8 +41,8 @@ func GreeksTable(c *ui.Context, rows []GreeksRow, opts GreeksTableOptions) *ui.E
u := d.Unit()
col := ui.Column(c).Gap(u * 2)
col.Children(func() {
g := ui.Grid(c).ColumnTracks(ui.Fr(1), ui.FitContent(), ui.FitContent(), ui.FitContent(), ui.FitContent(), ui.FitContent()).
GapX(u*5).Padding(u*2, u*3).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).
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(msg(c, "greeks_table.title"))
g.Children(func() {
ui.Text(c, msg(c, "greeks_table.contract")).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).
@@ -53,11 +52,16 @@ func GreeksTable(c *ui.Context, rows []GreeksRow, opts GreeksTableOptions) *ui.E
Role(ui.RoleColumnHeader).Label(name).Description(msg(c, "greeks_table.note_"+greekKey(i)))
h.Tooltip(msg(c, "greeks_table.note_"+greekKey(i)))
h.Children(func() {
ui.Text(c, name).Font(theme.SerifFont).TextColor(k.Text)
ui.Text(c, name).SingleLine().Font(theme.SerifFont).TextColor(k.Text)
ui.Icon(c, icons.Must("info")).Size(12, 12).TextColor(k.TextMuted)
})
}
for ri, r := range rows {
})
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 {
@@ -68,8 +72,9 @@ func GreeksTable(c *ui.Context, rows []GreeksRow, opts GreeksTableOptions) *ui.E
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 {
+54
View File
@@ -0,0 +1,54 @@
package mujica
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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}) })
})
}
+1 -3
View File
@@ -9,7 +9,6 @@ import (
"mujicaui/theme"
)
// IndexQuote is an index level, its change and recent values for the mini chart.
type IndexQuote struct {
Name string
Price float64
@@ -28,7 +27,6 @@ type IndexCardOptions struct {
Disabled bool
}
// IndexCard shows an index level, its change and a mini chart.
func IndexCard(c *ui.Context, q IndexQuote, opts IndexCardOptions) ChoiceResult {
if q.Name == "" {
panic("mujica: IndexCard needs a Name")
@@ -57,7 +55,7 @@ func IndexCard(c *ui.Context, q IndexQuote, opts IndexCardOptions) ChoiceResult
PriceChangeBadge(c, q.Change, q.ChangePercent, PriceChangeBadgeOptions{Decimals: dec})
})
if len(q.Series) > 0 {
Sparkline(c, q.Series, SparklineOptions{Frame: ChartFrame{Label: q.Name}, Width: 172})
Sparkline(c, q.Series, SparklineOptions{Frame: ChartFrame{Label: q.Name}, Width: 120})
}
})
clicked := opts.Clickable && e.Clicked() && !e.IsDisabled()
+39
View File
@@ -0,0 +1,39 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"testing"
)
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{})
})
})
}
+74 -21
View File
@@ -13,7 +13,6 @@ import (
"mujicaui/theme"
)
// HeatNode is a sector or a stock: market cap sizes it, Change (percent) colors it.
type HeatNode struct {
Name string
Cap float64
@@ -23,9 +22,19 @@ type HeatNode struct {
// MarketHeatmapOptions configure a MarketHeatmap.
type MarketHeatmapOptions struct {
Frame ChartFrame
// Scale is the change in percent at full color; zero means 3.
Scale float64
Frame ChartFrame
Version int
Scale float64
}
type marketHeatMemo struct {
id string
tiles []marketHeatTile
caps []float64
width, height float32
rects []ui.Rect
visible []int
small []marketHeatCell
}
type marketHeatTile struct {
@@ -34,13 +43,11 @@ type marketHeatTile struct {
branch bool
}
// MarketHeatmap tiles sectors and stocks by market cap, colored by change; branches drill in.
func MarketHeatmap(c *ui.Context, path *[]string, root HeatNode, opts MarketHeatmapOptions) ChartResult {
if opts.Scale < 0 {
if opts.Scale < 0 || math.IsNaN(opts.Scale) || math.IsInf(opts.Scale, 0) || path == nil {
panic("mujica: MarketHeatmap Scale is negative")
}
scale := cmp.Or(opts.Scale, 3)
marketHeatCheck(root)
k := Tokens(c)
u := Density(c).Unit()
size := FontSize(c, theme.CaptionSize)
@@ -49,18 +56,23 @@ func MarketHeatmap(c *ui.Context, path *[]string, root HeatNode, opts MarketHeat
wrap.Children(func() {
res.value = chartPath(c, root.Name, path, wrap.IsDisabled())
node := marketHeatDescend(root, *path)
var tiles []marketHeatTile
for _, ch := range node.Children {
t := marketHeatTile{ch.Name, ch.total(), ch.change(), len(ch.Children) > 0}
if t.cap > 0 {
tiles = append(tiles, t)
memo := ui.Local(wrap, "mujica.heat", func() marketHeatMemo { return marketHeatMemo{} })
if id := strings.Join(*path, "/") + "|" + chartID(node.Children) + fmt.Sprint(opts.Version); id != memo.id {
marketHeatCheck(root)
memo.id, memo.tiles, memo.caps = id, nil, nil
memo.rects = nil
for _, ch := range node.Children {
t := marketHeatTile{ch.Name, ch.total(), ch.change(), len(ch.Children) > 0}
if t.cap > 0 {
memo.tiles = append(memo.tiles, t)
}
}
slices.SortStableFunc(memo.tiles, func(a, b marketHeatTile) int { return cmp.Compare(b.cap, a.cap) })
for _, t := range memo.tiles {
memo.caps = append(memo.caps, t.cap)
}
}
slices.SortStableFunc(tiles, func(a, b marketHeatTile) int { return cmp.Compare(b.cap, a.cap) })
caps := make([]float64, len(tiles))
for i, t := range tiles {
caps[i] = t.cap
}
tiles, caps := memo.tiles, memo.caps
color := func(ch float64) ui.Color {
return k.Surface.Mix(trendColor(c, trendSign(ch)), float32(0.15+0.85*min(math.Abs(ch)/scale, 1)))
}
@@ -75,7 +87,8 @@ func MarketHeatmap(c *ui.Context, path *[]string, root HeatNode, opts MarketHeat
return s
}
spec.pick = func(r ui.Rect, x, y float32) int {
for i, t := range squarify(caps, r) {
memo.layout(caps, r)
for i, t := range memo.rects {
if t.Contains(x, y) {
return i
}
@@ -90,7 +103,13 @@ func MarketHeatmap(c *ui.Context, path *[]string, root HeatNode, opts MarketHeat
}
}
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
for i, t := range squarify(caps, r) {
memo.layout(caps, r)
for _, cell := range memo.small {
p.Fill(ui.Rect{X: r.X + cell.x, Y: r.Y + cell.y, W: 4, H: 4}, color(cell.change), 0)
}
for _, i := range memo.visible {
t := memo.rects[i]
t.X, t.Y = t.X+r.X, t.Y+r.Y
bg := color(tiles[i].change)
p.Fill(t, bg, 2)
p.Stroke(t, k.Surface, 2, 2)
@@ -116,7 +135,6 @@ func MarketHeatmap(c *ui.Context, path *[]string, root HeatNode, opts MarketHeat
return res
}
// marketHeatLegend is the color scale from −Scale to +Scale with signed labels.
func marketHeatLegend(c *ui.Context, scale float64, color func(float64) ui.Color) {
k := Tokens(c)
size := FontSize(c, theme.CaptionSize)
@@ -142,7 +160,6 @@ func (n HeatNode) total() float64 {
return s
}
// change is the node's own change, or its children's cap-weighted mean.
func (n HeatNode) change() float64 {
if len(n.Children) == 0 {
return n.Change
@@ -184,3 +201,39 @@ func marketHeatDescend(root HeatNode, path []string) HeatNode {
}
return n
}
type marketHeatCell struct {
x, y float32
change float64
}
func (m *marketHeatMemo) layout(caps []float64, r ui.Rect) {
if m.rects != nil && m.width == r.W && m.height == r.H {
return
}
m.width, m.height = r.W, r.H
m.rects = squarify(caps, ui.Rect{W: r.W, H: r.H})
m.visible, m.small = nil, nil
type aggregate struct{ weight, change float64 }
bins := map[[2]int]aggregate{}
for i, t := range m.rects {
if t.W >= 4 && t.H >= 4 {
m.visible = append(m.visible, i)
continue
}
key := [2]int{int((t.X + t.W/2) / 4), int((t.Y + t.H/2) / 4)}
a := bins[key]
a.weight += caps[i]
a.change += caps[i] * m.tiles[i].change
bins[key] = a
}
for key, a := range bins {
m.small = append(m.small, marketHeatCell{float32(key[0]) * 4, float32(key[1]) * 4, a.change / a.weight})
}
slices.SortFunc(m.small, func(a, b marketHeatCell) int {
if a.y != b.y {
return cmp.Compare(a.y, b.y)
}
return cmp.Compare(a.x, b.x)
})
}
+46
View File
@@ -0,0 +1,46 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"testing"
)
func TestMarketHeatmapAreaDrillAndKeys(t *testing.T) {
root := mktHeat()
var path []string
var got ChartResult
tt := chatTester(640, 400, func(c *ui.Context) {
got = MarketHeatmap(c, &path, root, MarketHeatmapOptions{Frame: ChartFrame{Label: "Market"}})
})
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyHome)
if !tt.HasText("Technology · up +1.18% · 8,000 · Enter opens") {
t.Fatalf("readout %q", tt.Texts())
}
tt.Key(0, ui.KeyEnter)
if len(path) != 1 || path[0] != "Technology" {
t.Fatalf("path %v", path)
}
tt.Key(0, ui.KeyBackspace)
if len(path) != 0 {
t.Fatalf("back %v", path)
}
if got.Element == nil {
t.Fatal("element missing")
}
if root.total() != 9830 {
t.Fatalf("cap %v", root.total())
}
marketsPanic(t, func(c *ui.Context) {
MarketHeatmap(c, &path, HeatNode{Name: "Bad", Cap: -1}, MarketHeatmapOptions{Frame: ChartFrame{Label: "x"}})
})
}
func TestMarketHeatmapSnapshot(t *testing.T) {
var path []string
snapshot(t, "market_heatmap", 640, 400, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(12).Fill().Children(func() {
MarketHeatmap(c, &path, mktHeat(), MarketHeatmapOptions{Frame: ChartFrame{Label: "S&P 500", Height: 280}})
})
})
}
+1 -5
View File
@@ -15,20 +15,17 @@ type MarketBreadth struct{ Advancing, Declining, Unchanged int }
// MarketOverviewOptions configure a MarketOverview.
type MarketOverviewOptions struct {
Breadth MarketBreadth
// AsOf is when the data was taken; zero hides the stamp.
Breadth MarketBreadth
AsOf time.Time
Decimals int
Disabled bool
}
// MarketOverviewView is a built overview and the index clicked, -1 for none.
type MarketOverviewView struct {
Element *ui.Element
Chosen int
}
// MarketOverview shows index cards, advancers against decliners, and a time stamp.
func MarketOverview(c *ui.Context, indices []IndexQuote, opts MarketOverviewOptions) MarketOverviewView {
b := opts.Breadth
if b.Advancing < 0 || b.Declining < 0 || b.Unchanged < 0 {
@@ -62,7 +59,6 @@ func MarketOverview(c *ui.Context, indices []IndexQuote, opts MarketOverviewOpti
return v
}
// marketBreadthBar splits a bar by counts; each part has an arrow and a number.
func marketBreadthBar(c *ui.Context, b MarketBreadth, total int) {
k := Tokens(c)
u := Density(c).Unit()
+45
View File
@@ -0,0 +1,45 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"testing"
)
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})
})
})
}
+5 -7
View File
@@ -10,7 +10,6 @@ import (
"mujicaui/theme"
)
// MarketPhase is a market's trading state. The zero value is MarketClosed.
type MarketPhase int
const (
@@ -29,15 +28,13 @@ var marketPhases = [5]struct{ key, icon string }{
// MarketStatusOptions configure a MarketStatus.
type MarketStatusOptions struct {
// Market names the venue, such as "NYSE".
Market string
// Next and NextPhase say when the phase changes; zero Next hides it.
Market string
Next time.Time
NextPhase MarketPhase
// Now counts down to Next; zero uses the window clock.
Now time.Time
}
// MarketStatus shows a market's phase with an icon and word, and when it changes next.
func MarketStatus(c *ui.Context, phase MarketPhase, opts MarketStatusOptions) *ui.Element {
marketPhaseCheck(phase)
marketPhaseCheck(opts.NextPhase)
@@ -61,8 +58,7 @@ func MarketStatus(c *ui.Context, phase MarketPhase, opts MarketStatusOptions) *u
next = fmt.Sprintf(msg(c, "market_status.next"), msg(c, marketPhases[opts.NextPhase].key), opts.Next.Format("15:04"),
marketStatusIn(c, opts.Next.Sub(now)))
label += ", " + next
// The countdown changes once a minute.
c.After(time.Minute - time.Duration(now.Second())*time.Second)
}
row := ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Role(ui.RoleStatus).Label(label)
row.Children(func() {
@@ -77,6 +73,9 @@ func MarketStatus(c *ui.Context, phase MarketPhase, opts MarketStatusOptions) *u
ui.Text(c, next).FontSize(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
}
@@ -86,7 +85,6 @@ func marketPhaseCheck(p MarketPhase) {
}
}
// marketStatusIn writes a duration as "2h 05m" or "12m"; past times read "now".
func marketStatusIn(c *ui.Context, d time.Duration) string {
if d <= 0 {
return msg(c, "market_status.now")
+29
View File
@@ -0,0 +1,29 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"testing"
"time"
)
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{})
})
})
}
+71
View File
@@ -0,0 +1,71 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"math"
"testing"
"time"
)
// 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() HeatNode {
return HeatNode{Name: "Market", Children: []HeatNode{
{Name: "Technology", Children: []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: []HeatNode{{Name: "XOM", Cap: 450, Change: -2.1}, {Name: "CVX", Cap: 300, Change: -1.4}}},
{Name: "Health", Children: []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) { Use(c, Settings{}); view(c) }, 960, 500, 1)
}
+218
View File
@@ -0,0 +1,218 @@
package mujica
import (
"fmt"
"github.com/egoist/mygo/ui"
"math"
"mujicaui/internal"
"mujicaui/theme"
"strings"
"testing"
"time"
)
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) {
Use(c, Settings{})
ui.Column(c).Height(360).Disabled(disabled).Children(func() { last = NewsFeed(c, &s, items, NewsFeedOptions{Today: 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: 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() DataTableState[string] { return 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 := marketHeatMemo{}
for i := range 100000 {
m.tiles = append(m.tiles, 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 []DataStatus{DataLoading, DataFailed} {
frame := ChartFrame{Label: "Chart", Height: 180, Status: status, Error: "Caller failed"}
retry := false
tt := chatTester(960, 420, func(c *ui.Context) {
var r 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 = MarketHeatmap(c, path, mktHeat(), 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 == 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 *chartMemoEntry
tt := chatTester(960, 600, func(c *ui.Context) {
var r ChartResult
frame := 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() chartMemoEntry { return chartMemoEntry{} })
case "performance":
r = PerformanceChart(c, values, PerformanceChartOptions{Frame: frame, Times: times})
geometry = ui.Local(r.Element, "mujica.memo.performance.geometry.Portfolio", func() chartMemoEntry { return 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")
}
}
})
}
}
+3
View File
@@ -25,6 +25,9 @@ func init() {
"payoff_diagram.readout": {"Price %s · %s %s", "价格 %s · %s %s"},
"payoff_diagram.spot": {"Spot", "现价"},
"payoff_diagram.profit": {"profit", "盈利"},
"payoff_diagram.loss": {"loss", "亏损"},
"payoff_diagram.even": {"break even", "持平"},
"payoff_diagram.breakevens": {"Breakeven:", "盈亏平衡点:"},
"watchlist.groups": {"Watchlists", "自选分组"},
+31 -21
View File
@@ -11,27 +11,25 @@ import (
"mujicaui/theme"
)
// NewsItem is a headline with its source, time and related symbols.
type NewsItem struct {
ID, Title, Source string
Time time.Time
Symbols []string
}
// NewsFeedState holds which items were read, by ID, and the list's place.
type NewsFeedState struct {
Read map[string]bool
rows ui.ListState
cursor int
count struct {
Read map[string]bool
rows ui.ListState
cursor int
readVersion int
count struct {
key string
unread int
}
}
// newsFeedUnread counts unread items, recounting only when items or reads change.
func newsFeedUnread(s *NewsFeedState, items []NewsItem) int {
key := fmt.Sprint(chartID(items), len(s.Read))
func newsFeedUnread(s *NewsFeedState, items []NewsItem, version int) int {
key := fmt.Sprint(chartID(items), version, s.readVersion)
if key != s.count.key {
s.count.key, s.count.unread = key, 0
for _, it := range items {
@@ -47,6 +45,7 @@ func newsFeedUnread(s *NewsFeedState, items []NewsItem) int {
type NewsFeedOptions struct {
// Today decides the time stamps; zero uses the window clock.
Today Date
Version int
Status DataStatus
Error string
Disabled bool
@@ -55,21 +54,18 @@ type NewsFeedOptions struct {
// NewsFeedView is a built NewsFeed and this frame's events.
type NewsFeedView struct {
Element *ui.Element
// Opened is the item opened, which is now read; empty for none.
Opened string
Opened string
// Symbol is the related code clicked; empty for none.
Symbol string
Retry bool
Symbol string
Retry bool
ReadChanged bool
}
// NewsFeed lists headlines newest first as given, marking unread ones; only rows in view are built.
func NewsFeed(c *ui.Context, s *NewsFeedState, items []NewsItem, opts NewsFeedOptions) NewsFeedView {
if s == nil {
panic("mujica: NewsFeed needs a state")
}
if s.Read == nil {
s.Read = map[string]bool{}
}
today := opts.Today
if today.IsZero() {
today = DateOf(c.Now())
@@ -77,15 +73,25 @@ func NewsFeed(c *ui.Context, s *NewsFeedState, items []NewsItem, opts NewsFeedOp
k := Tokens(c)
d := Density(c)
var v NewsFeedView
v.Element = ui.Column(c).Gap(d.Unit() * 2).MinHeight(0).Disabled(opts.Disabled)
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)
unread := newsFeedUnread(s, items, opts.Version)
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, fmt.Sprintf(msg(c, "news_feed.unread"), unread)).TextColor(k.TextMuted).Grow(1).Role(ui.RoleStatus)
if Button(c, msg(c, "news_feed.mark_all"), ButtonOptions{Variant: 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)
}
})
@@ -137,15 +143,19 @@ func NewsFeed(c *ui.Context, s *NewsFeedState, items []NewsItem, opts NewsFeedOp
}).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 !opts.Disabled && list.Submitted() && s.cursor >= 0 && s.cursor < len(items) {
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 = "", "", false
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)
+49
View File
@@ -0,0 +1,49 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"testing"
)
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: 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: DateOf(mktNow)}).Element.Height(300)
})
})
}
+65 -21
View File
@@ -4,6 +4,7 @@ import (
"cmp"
"fmt"
"log/slog"
"math"
"slices"
"github.com/egoist/mygo/ui"
@@ -23,7 +24,6 @@ type OptionStrike struct {
Call, Put OptionQuote
}
// OptionSide is a call or a put. The zero value is OptionCall.
type OptionSide int
const (
@@ -34,7 +34,6 @@ const (
// OptionChainState is the expiry shown and the chosen contract.
type OptionChainState struct {
Expiry string
// Strike and Side are the chosen contract when Chosen is set.
Strike float64
Side OptionSide
Chosen bool
@@ -45,7 +44,6 @@ type OptionChainState struct {
// OptionChainOptions configure an OptionChain.
type OptionChainOptions struct {
// Spot is the underlying price; it centers the strikes and marks in-the-money contracts.
Spot float64
Decimals int
Disabled bool
@@ -55,27 +53,46 @@ type OptionChainOptions struct {
type OptionChainView struct {
Element *ui.Element
// ExpiryChanged asks the caller for the strikes of State.Expiry.
ExpiryChanged bool
// Changed reports a newly chosen contract; Submitted is Enter or a double click.
ExpiryChanged bool
Changed, Submitted bool
}
// OptionChain shows calls and puts around ascending strikes, centered on the spot, for the chosen expiry.
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")
}
if !slices.Contains(expiries, s.Expiry) {
s.Expiry = expiries[0]
}
dec := priceDecimals("OptionChain", opts.Decimals)
k := Tokens(c)
d := Density(c)
var v OptionChainView
v.Element = ui.Column(c).Gap(d.Unit() * 2).MinHeight(0).Disabled(opts.Disabled)
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([]SelectOption[string], len(expiries))
@@ -105,6 +122,11 @@ func optionChainHeader(c *ui.Context) {
t := Tokens(c)
d := Density(c)
size := FontSize(c, theme.CaptionSize)
ui.Row(c).Padding(0, d.Unit()*3).Children(func() {
ui.Text(c, 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, 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 {
@@ -137,6 +159,9 @@ func optionChainRows(c *ui.Context, s *OptionChainState, strikes []OptionStrike,
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)
@@ -144,6 +169,8 @@ func optionChainRows(c *ui.Context, s *OptionChainState, strikes []OptionStrike,
s.rows.Key = func(i int) any { return strikes[i].Strike }
s.rows.Label = func(i int) string { return statisticFormat(strikes[i].Strike, dec) }
s.rows.Selected = cursor
clickedRow, clickedSide := -1, OptionCall
double := false
restore := dataSelectTheme(c, 0)
list := ui.List(c, &s.rows, len(strikes), func(i int) {
o := strikes[i]
@@ -156,15 +183,20 @@ func optionChainRows(c *ui.Context, s *OptionChainState, strikes []OptionStrike,
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 itm {
half.Background(k.Ornament.Alpha(0.14))
}
if chosen && OptionSide(sd) == s.Side {
half.DrawOver(func(p *ui.Painter, r ui.Rect) { internal.Frame(p, r, 1, 5, k.AccentText) })
}
if half.Clicked() && !half.IsDisabled() {
*cursor, s.Side = i, OptionSide(sd)
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, statisticFormat(v, dec)).Grow(1).Basis(0).TextAlign(ui.End).FontFeatures("tnum").SingleLine()
@@ -175,8 +207,21 @@ func optionChainRows(c *ui.Context, s *OptionChainState, strikes []OptionStrike,
}).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 !opts.Disabled {
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):
@@ -185,17 +230,16 @@ func optionChainRows(c *ui.Context, s *OptionChainState, strikes []OptionStrike,
if s.cursor >= 0 && (s.cursor != before || s.Side != side) {
s.Strike, s.Chosen, changed = strikes[s.cursor].Strike, true, true
}
submitted = list.Submitted() && s.Chosen
submitted = (list.Submitted() || double) && s.Chosen
}
return changed, submitted
}
// optionChainStrike is the middle strike cell; the one at the spot carries a diamond.
func optionChainStrike(c *ui.Context, strike float64, dec int, atm bool) {
k := 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.Ornament) })
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, statisticFormat(strike, dec)).FontWeight(700).FontFeatures("tnum")
})
+130
View File
@@ -0,0 +1,130 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func optionChainTester(s *OptionChainState, strikes []OptionStrike, opts OptionChainOptions, got *OptionChainView) *ui.Tester {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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) {
Use(c, 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)
}
}
+33 -29
View File
@@ -12,7 +12,6 @@ import (
"mujicaui/theme"
)
// OptionLegKind is what a leg holds. The zero value is LegCall.
type OptionLegKind int
const (
@@ -22,7 +21,6 @@ const (
LegStock
)
// OptionLeg is one position of a strategy; Quantity is negative for short.
type OptionLeg struct {
Kind OptionLegKind
Strike float64
@@ -30,7 +28,6 @@ type OptionLeg struct {
Quantity float64
}
// PayoffAt is the strategy's profit at expiry with the underlying at price.
func PayoffAt(legs []OptionLeg, price float64) float64 {
sum := 0.0
for _, l := range legs {
@@ -48,7 +45,6 @@ func PayoffAt(legs []OptionLeg, price float64) float64 {
return sum
}
// Breakevens are the prices in lo..hi where the strategy's payoff crosses zero, ascending.
func Breakevens(legs []OptionLeg, lo, hi float64) []float64 {
kinks := []float64{lo, hi}
for _, l := range legs {
@@ -76,17 +72,13 @@ func Breakevens(legs []OptionLeg, lo, hi float64) []float64 {
// PayoffDiagramOptions configure a PayoffDiagram.
type PayoffDiagramOptions struct {
Frame ChartFrame
// Spot is the current price, marked on the chart; zero hides it.
Spot float64
// Low and High bound the price axis; both zero span the strikes ±30%.
Frame ChartFrame
Spot float64
Low, High float64
// Points is how many prices the keys step through; zero means 101.
Points int
Format func(v float64) string
Points int
Format func(v float64) string
}
// PayoffDiagram plots a strategy's profit at expiry with breakevens and the current price.
func PayoffDiagram(c *ui.Context, legs []OptionLeg, opts PayoffDiagramOptions) ChartResult {
lo, hi := payoffRange(legs, opts)
n := opts.Points
@@ -101,6 +93,7 @@ func PayoffDiagram(c *ui.Context, legs []OptionLeg, opts PayoffDiagramOptions) C
size := FontSize(c, theme.CaptionSize)
type data struct {
px, pl, be []float64
gx, gp []float64
ylo, yhi float64
}
d := chartMemo(ch, "payoff", fmt.Sprint(legs, lo, hi, n), func() data {
@@ -112,6 +105,20 @@ func PayoffDiagram(c *ui.Context, legs []OptionLeg, opts PayoffDiagramOptions) C
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 {
@@ -128,7 +135,8 @@ func PayoffDiagram(c *ui.Context, legs []OptionLeg, opts PayoffDiagramOptions) C
up, down := trendColor(c, 1), trendColor(c, -1)
spec := chartSpec{n: n}
spec.readout = func(i int) string {
return fmt.Sprintf(msg(c, "payoff_diagram.readout"), format(d.px[i]), trendWord(c, trendSign(d.pl[i])), format(d.pl[i]))
word := [3]string{"payoff_diagram.loss", "payoff_diagram.even", "payoff_diagram.profit"}[trendSign(d.pl[i])+1]
return fmt.Sprintf(msg(c, "payoff_diagram.readout"), format(d.px[i]), msg(c, word), format(d.pl[i]))
}
spec.pick = func(r ui.Rect, x, y float32) int {
plot, xs, _ := geom(r)
@@ -152,17 +160,14 @@ func PayoffDiagram(c *ui.Context, legs []OptionLeg, opts PayoffDiagramOptions) C
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.
xsAll := slices.Clone(d.px)
for _, l := range legs {
if l.Kind != LegStock && l.Strike > lo && l.Strike < hi {
xsAll = append(xsAll, l.Strike)
}
}
xsAll = append(xsAll, d.be...)
slices.Sort(xsAll)
for i := 1; i < len(xsAll); i++ {
a, b := xsAll[i-1], xsAll[i]
fa, fb := PayoffAt(legs, a), PayoffAt(legs, b)
indices := chartMemo(ch, "payoff.geometry", [2]any{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
@@ -175,11 +180,11 @@ func PayoffDiagram(c *ui.Context, legs []OptionLeg, opts PayoffDiagramOptions) C
}
p.Line(plot.X, zero, plot.X+plot.W, zero, 1, k.ControlBorder)
var line ui.Path
for i, x := range xsAll {
if i == 0 {
line.MoveTo(pt(x, PayoffAt(legs, x)))
for j, i := range indices {
if j == 0 {
line.MoveTo(pt(d.gx[i], d.gp[i]))
} else {
line.LineTo(pt(x, PayoffAt(legs, x)))
line.LineTo(pt(d.gx[i], d.gp[i]))
}
}
p.StrokePath(&line, 2, k.AccentText)
@@ -215,7 +220,6 @@ func PayoffDiagram(c *ui.Context, legs []OptionLeg, opts PayoffDiagramOptions) C
return res
}
// payoffRange is the price axis: the options' range, or the strikes ±30%.
func payoffRange(legs []OptionLeg, opts PayoffDiagramOptions) (lo, hi float64) {
if opts.Low != 0 || opts.High != 0 {
if !(opts.High > opts.Low) || opts.Low < 0 {
+108
View File
@@ -0,0 +1,108 @@
package mujica
import (
"mujicaui/theme"
"slices"
"strings"
"testing"
"github.com/egoist/mygo/ui"
)
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 ChartResult
tt := chatTester(640, 380, func(c *ui.Context) {
last = PayoffDiagram(c, bullSpread(), PayoffDiagramOptions{Frame: 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: 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) {
Use(c, Settings{})
PayoffDiagram(c, bullSpread(), PayoffDiagramOptions{Frame: 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: 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: 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")
}
}
+22 -15
View File
@@ -14,7 +14,8 @@ import (
// PerformanceChartOptions configure a PerformanceChart.
type PerformanceChartOptions struct {
Frame ChartFrame
Frame ChartFrame
Version int
// Times dates each value; required, ascending.
Times []time.Time
// Benchmark is compared against the portfolio; nil shows none.
@@ -22,13 +23,11 @@ type PerformanceChartOptions struct {
Name, BenchmarkName string
}
// Drawdown is the deepest fall from a peak: indices and the fall in percent.
type Drawdown struct {
Peak, Trough int
Percent float64
}
// MaxDrawdown finds the largest peak-to-trough fall of positive values; Percent ≤ 0.
func MaxDrawdown(values []float64) Drawdown {
var dd Drawdown
peak := 0
@@ -43,18 +42,13 @@ func MaxDrawdown(values []float64) Drawdown {
return dd
}
// PerformanceChart plots cumulative return against a benchmark and shades the maximum drawdown.
func PerformanceChart(c *ui.Context, values []float64, opts PerformanceChartOptions) ChartResult {
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")
}
}
}
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 := newChart(c, opts.Frame)
k := Tokens(c)
pal := internal.Palette(k)
@@ -67,7 +61,18 @@ func PerformanceChart(c *ui.Context, values []float64, opts PerformanceChartOpti
if bname == "" {
bname = msg(c, "performance_chart.benchmark")
}
series := chartMemo(ch, "returns", [4]string{chartID(values), chartID(opts.Benchmark), name, bname}, func() []ChartSeries {
if opts.Benchmark != nil && name == bname {
panic("mujica: PerformanceChart series names must differ")
}
series := chartMemo(ch, "returns", [4]string{chartID(values), chartID(opts.Benchmark), name + fmt.Sprint(opts.Version), bname}, func() []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 := []ChartSeries{{Name: name, Values: performanceReturns(values)}}
if opts.Benchmark != nil {
out = append(out, ChartSeries{Name: bname, Values: performanceReturns(opts.Benchmark)})
@@ -75,8 +80,8 @@ func PerformanceChart(c *ui.Context, values []float64, opts PerformanceChartOpti
return out
})
n := len(values)
ax := chartMemo(ch, "x", chartID(opts.Times), func() chartX { return newChartX(n, nil, opts.Times) })
dd := chartMemo(ch, "dd", chartID(values), func() Drawdown { return MaxDrawdown(values) })
ax := chartMemo(ch, "x", chartID(opts.Times)+fmt.Sprint(opts.Version), func() chartX { return newChartX(n, nil, opts.Times) })
dd := chartMemo(ch, "dd", chartID(values)+fmt.Sprint(opts.Version), func() Drawdown { return MaxDrawdown(values) })
lo, hi, ok := chartYRange(ch, series, true, nil)
if !ok {
n = 0
@@ -133,7 +138,9 @@ func PerformanceChart(c *ui.Context, values []float64, opts PerformanceChartOpti
continue
}
var line ui.Path
idx := internal.Columns(n, func(i int) float32 { return xs.Map(ax.vals[i]) }, func(i int) float64 { return s.Values[i] })
idx := chartMemo(ch, "performance.geometry."+s.Name, [3]any{chartID(s.Values), 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])))
+69
View File
@@ -0,0 +1,69 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"math"
"testing"
)
func TestPerformanceChartBenchmarkDrawdownAndKeys(t *testing.T) {
values := []float64{100, 110, 88, 120}
times := mktTimes(4)
var last ChartResult
tt := chatTester(760, 420, func(c *ui.Context) {
last = PerformanceChart(c, values, PerformanceChartOptions{Frame: 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: 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: 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: ChartFrame{Label: "x"}})
})
}
func TestPerformanceChartDateVersion(t *testing.T) {
values := []float64{100, 105, 110}
times := mktTimes(3)
version := 0
var axis chartX
tt := chatTester(760, 420, func(c *ui.Context) {
r := PerformanceChart(c, values, PerformanceChartOptions{Frame: ChartFrame{Label: "Performance"}, Times: times, Version: version})
memo := ui.Local(r.Element, "mujica.memo.x", func() chartMemoEntry { return chartMemoEntry{} })
axis = memo.val.(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)
}
}
+5 -13
View File
@@ -18,8 +18,7 @@ type PortfolioAccount struct {
Total float64
// DayPnL is today's profit; TotalReturn the profit since inception.
DayPnL, TotalReturn float64
// Cost is what went in; it turns TotalReturn into a percent.
Cost float64
Cost float64
}
// PortfolioSummaryOptions configure a PortfolioSummary.
@@ -32,13 +31,8 @@ type PortfolioSummaryOptions struct {
}
// PortfolioSummaryView is a built summary; Changed reports another account chosen.
type PortfolioSummaryView struct {
Element *ui.Element
Changed bool
}
// PortfolioSummary shows total assets, today's profit and total return, for all accounts or one.
func PortfolioSummary(c *ui.Context, account *string, accounts []PortfolioAccount, opts PortfolioSummaryOptions) PortfolioSummaryView {
func PortfolioSummary(c *ui.Context, account *string, accounts []PortfolioAccount, opts PortfolioSummaryOptions) ChoiceResult {
if account == nil {
panic("mujica: PortfolioSummary needs an account")
}
@@ -64,7 +58,7 @@ func PortfolioSummary(c *ui.Context, account *string, accounts []PortfolioAccoun
panic("mujica: PortfolioSummary account " + *account + " is not listed")
}
before := *account
v := PortfolioSummaryView{}
v := 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() {
@@ -89,8 +83,8 @@ func PortfolioSummary(c *ui.Context, account *string, accounts []PortfolioAccoun
})
})
})
v.Changed = *account != before && !v.Element.IsDisabled()
if v.Changed {
v.changed = *account != before && !v.Element.IsDisabled()
if v.Changed() {
slog.Debug("mujica: portfolio_summary account", "id", *account)
}
return v
@@ -103,7 +97,6 @@ func portfolioFigure(c *ui.Context, title string, value func()) {
})
}
// portfolioSigned shows a signed amount and percent with the trend arrow.
func portfolioSigned(c *ui.Context, v float64, cur string, dec int, pct float64) {
sign := trendSign(v)
text := cur + statisticFormat(math.Abs(v), dec)
@@ -121,7 +114,6 @@ func portfolioSigned(c *ui.Context, v float64, cur string, dec int, pct float64)
})
}
// portfolioPercent is part of base in percent, NaN when base is zero.
func portfolioPercent(part, base float64) float64 {
if base == 0 {
return math.NaN()
+45
View File
@@ -0,0 +1,45 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"math"
"testing"
)
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{})
})
})
}
+21 -9
View File
@@ -13,13 +13,11 @@ import (
"mujicaui/theme"
)
// ScreenerField is a numeric field conditions test, such as P/E or market cap.
type ScreenerField struct {
ID, Label string
Decimals int
}
// ScreenerOp compares a field with a value. The zero value is ScreenerAbove.
type ScreenerOp int
const (
@@ -39,7 +37,6 @@ type ScreenerCondition struct {
Value float64
}
// ScreenerRow is a symbol and its field values by field ID; missing values fail every test.
type ScreenerRow struct {
Symbol, Name string
Values map[string]float64
@@ -86,22 +83,38 @@ type ScreenerOptions struct {
// ScreenerView is a built Screener and this frame's events.
type ScreenerView struct {
Element *ui.Element
// Changed reports edited conditions; Save asks the caller to store them.
Element *ui.Element
Changed, Save bool
Table DataTableView
}
// Screener builds field, comparison and value conditions and lists the rows that pass them.
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 := Tokens(c)
u := Density(c).Unit()
before := fmt.Sprint(s.Conditions)
var v ScreenerView
v.Element = ui.Column(c).Gap(u * 3).MinHeight(0).Disabled(opts.Disabled)
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([]SelectOption[string], len(fields))
for i, f := range fields {
@@ -152,7 +165,7 @@ func Screener(c *ui.Context, s *ScreenerState, fields []ScreenerField, rows []Sc
Text: func(r ScreenerRow) string { return statisticFormat(r.Values[f.ID], f.Decimals) }})
}
v.Table = DataTable(c, &s.Table, matched, DataTableOptions[ScreenerRow, string]{Columns: cols, Key: func(r ScreenerRow) string { return r.Symbol },
Status: opts.Status, Error: opts.Error, Empty: msg(c, "screener.empty")})
Version: opts.Version, Status: opts.Status, Error: opts.Error, Empty: msg(c, "screener.empty")})
v.Table.Element.Grow(1)
})
v.Changed = fmt.Sprint(s.Conditions) != before
@@ -177,7 +190,6 @@ func screenerField(fields []ScreenerField, id string) ScreenerField {
panic("mujica: Screener condition on unknown field " + id)
}
// screenerFilter keeps passing rows, refiltering only when rows, version or conditions change.
func screenerFilter(s *ScreenerState, rows []ScreenerRow, version int) []ScreenerRow {
key := fmt.Sprint(chartID(rows), version, s.Conditions)
if key != s.cache.key {
+59
View File
@@ -0,0 +1,59 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"testing"
)
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)
})
})
}
+38 -10
View File
@@ -15,21 +15,23 @@ type SentimentGaugeOptions struct {
// Label names the index, such as "Fear & Greed".
Label string
// Size is the dial width; zero is 240 DIPs.
Size float32
Size float32
Status 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"}
// sentimentBand is the band index of a 0 to 100 value.
func sentimentBand(v float64) int { return min(4, int(v/20)) }
// SentimentGauge is a half dial from extreme fear to extreme greed, with the value and its word.
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 opts.Size < 0 {
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
@@ -38,22 +40,48 @@ func SentimentGauge(c *ui.Context, value float64, opts SentimentGaugeOptions) *u
}
k := Tokens(c)
u := Density(c).Unit()
dialHeight := max(w/2+8, u*30)
band := sentimentBand(value)
word := msg(c, sentimentBands[band])
label := fmt.Sprintf("%.0f, %s", value, word)
if opts.Status == DataLoading || opts.Status == DataLoadingMore || opts.Status == DataUnloaded {
label = msg(c, "list.loading")
} else if opts.Status == DataFailed {
label = dataError(c, opts.Error)
} else if opts.Empty {
label = msg(c, "chart.empty")
}
if opts.Label != "" {
label = opts.Label + ": " + label
}
// Fear to greed runs from the fall color to the rise color.
fear, greed := trendColor(c, -1), trendColor(c, 1)
cols := [5]ui.Color{fear, fear.Mix(k.Surface, 0.45), k.TextMuted, greed.Mix(k.Surface, 0.45), greed}
pal := internal.Palette(k)
cols := [5]ui.Color{pal[0], pal[3], pal[5], pal[1], pal[2]}
size := FontSize(c, theme.CaptionSize)
col := ui.Column(c).Gap(u).AlignItems(ui.Center).Role(ui.RoleMeter).Label(label)
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)
}
ui.Box(c).Size(w, w/2+8).Draw(func(p *ui.Painter, r ui.Rect) {
if opts.Status != DataReady || opts.Empty {
ui.Box(c).Size(w, dialHeight).Children(func() {
ui.Column(c).Fill().Children(func() {
if dataPlaceholder(c, opts.Status, opts.Error, "", msg(c, "chart.empty")) && opts.Retry != nil {
opts.Retry()
}
})
})
ui.Box(c).Height(FontSize(c, theme.H1Size)*theme.LineHeight + u + 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 {
@@ -70,7 +98,7 @@ func SentimentGauge(c *ui.Context, value float64, opts SentimentGaugeOptions) *u
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(FontSize(c, theme.H1Size)).TextColor(k.Text).FontFeatures("tnum")
ui.Text(c, word).FontWeight(600).TextColor(cols[band].Mix(k.Text, 0.25))
ui.Text(c, word).FontWeight(600).TextColor(k.Text)
})
return col
}
+62
View File
@@ -0,0 +1,62 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"testing"
)
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 := 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 = DataLoading
tt.Frame()
if element.Bounds().H != height {
t.Fatalf("loading height %v, ready %v", element.Bounds().H, height)
}
status = DataFailed
tt.Frame()
_ = tt.Click("Retry")
if retried != 1 {
t.Fatalf("retries %d", retried)
}
status = 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)
}
}
+1 -2
View File
@@ -15,8 +15,7 @@ type MarketSymbol struct {
Code, Name string
// Market is the venue, such as NASDAQ or SSE.
Market string
// Type is the asset kind, such as Stock, ETF or Option.
Type string
Type string
}
// SymbolBadgeOptions configure a SymbolBadge.
+41
View File
@@ -0,0 +1,41 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"testing"
)
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{})
})
})
})
}
+37 -18
View File
@@ -10,10 +10,10 @@ import (
"mujicaui/theme"
)
// SymbolSearchState is the query and the market filter, empty for all markets.
type SymbolSearchState struct {
Query, Market string
focusFirst bool
cursor int
}
// SymbolSearchOptions configure a SymbolSearch.
@@ -31,7 +31,6 @@ type SymbolSearchOptions struct {
// SymbolSearchView is a built SymbolSearch and this frame's events.
type SymbolSearchView struct {
Element *ui.Element
// Changed asks the caller to search again for State.Query and State.Market.
Changed bool
// Chosen is the symbol picked when Picked is set.
Chosen MarketSymbol
@@ -39,7 +38,6 @@ type SymbolSearchView struct {
Retry bool
}
// SymbolSearch finds symbols by code or name, filtered by market, with recent picks; the caller supplies results.
func SymbolSearch(c *ui.Context, s *SymbolSearchState, results []MarketSymbol, opts SymbolSearchOptions) SymbolSearchView {
if s == nil {
panic("mujica: SymbolSearch needs a state")
@@ -48,6 +46,11 @@ func SymbolSearch(c *ui.Context, s *SymbolSearchState, results []MarketSymbol, o
u := 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 := SearchInput(c, &s.Query, SearchInputOptions{Placeholder: msg(c, "symbol_search.placeholder"), Disabled: opts.Disabled})
@@ -75,11 +78,11 @@ func SymbolSearch(c *ui.Context, s *SymbolSearchState, results []MarketSymbol, o
v.Retry = Button(c, msg(c, "symbol_search.retry"), ButtonOptions{Variant: Secondary}).Clicked()
})
return
case s.Query == "":
list, title = opts.Recent, msg(c, "symbol_search.recent")
case opts.Loading && len(results) == 0:
Spinner(c, SpinnerOptions{Label: msg(c, "symbol_search.searching"), ShowLabel: true})
return
case s.Query == "":
list, title = opts.Recent, msg(c, "symbol_search.recent")
case len(results) == 0:
ui.Text(c, msg(c, "symbol_search.no_results")).TextColor(k.TextMuted).Role(ui.RoleStatus)
return
@@ -87,15 +90,30 @@ func SymbolSearch(c *ui.Context, s *SymbolSearchState, results []MarketSymbol, o
if title != "" && len(list) > 0 {
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
}
ui.Scroll(c).MaxHeight(320).Children(func() {
ui.Column(c).Gap(2).FocusGroup(ui.Vertical).Role(ui.RoleList).Children(func() {
for i, sym := range list {
if symbolSearchRow(c, sym, i == 0 && s.focusFirst) {
v.Chosen, v.Picked = sym, true
}
}
})
})
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 := 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() {
@@ -107,21 +125,22 @@ func SymbolSearch(c *ui.Context, s *SymbolSearchState, results []MarketSymbol, o
return v
}
func symbolSearchRow(c *ui.Context, sym MarketSymbol, focus bool) bool {
func symbolSearchRow(c *ui.Context, sym MarketSymbol, selected bool) bool {
k := Tokens(c)
d := 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 focus {
b.Focus()
}
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(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
}
+82
View File
@@ -0,0 +1,82 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"testing"
)
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)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more