diff --git a/asset_allocation_chart.go b/asset_allocation_chart.go index 78eaf94..cd26015 100644 --- a/asset_allocation_chart.go +++ b/asset_allocation_chart.go @@ -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" diff --git a/asset_allocation_chart_test.go b/asset_allocation_chart_test.go new file mode 100644 index 0000000..9e513dc --- /dev/null +++ b/asset_allocation_chart_test.go @@ -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") + } +} diff --git a/currency_converter.go b/currency_converter.go index b61303d..7c0ac34 100644 --- a/currency_converter.go +++ b/currency_converter.go @@ -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 = © + } 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") } diff --git a/currency_converter_test.go b/currency_converter_test.go new file mode 100644 index 0000000..fa2d927 --- /dev/null +++ b/currency_converter_test.go @@ -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}) + + }) + }) +} diff --git a/docs/components/181-option-chain.md b/docs/components/181-option-chain.md new file mode 100644 index 0000000..7052dac --- /dev/null +++ b/docs/components/181-option-chain.md @@ -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 确认。 + +## 限制 + +到期日切换后,由调用方更换对应报价;排序与稳定合约标识由调用方保证。 diff --git a/docs/components/182-greeks-table.md b/docs/components/182-greeks-table.md new file mode 100644 index 0000000..d78cefd --- /dev/null +++ b/docs/components/182-greeks-table.md @@ -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。 + +## 状态 + +默认与数据边界。 + +## 事件 + +展示组件;数据由调用方更新。 + +## 键盘操作 + +展示组件;表头提供说明和悬停提示。 + +## 限制 + +通过虚拟列表构建可见行;精度为显示规则,数值计算由调用方提供。 diff --git a/docs/components/183-payoff-diagram.md b/docs/components/183-payoff-diagram.md new file mode 100644 index 0000000..4094ea1 --- /dev/null +++ b/docs/components/183-payoff-diagram.md @@ -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 指定。 diff --git a/docs/components/184-watchlist.md b/docs/components/184-watchlist.md new file mode 100644 index 0000000..d3ae8c4 --- /dev/null +++ b/docs/components/184-watchlist.md @@ -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。 diff --git a/docs/components/185-symbol-search.md b/docs/components/185-symbol-search.md new file mode 100644 index 0000000..9879e92 --- /dev/null +++ b/docs/components/185-symbol-search.md @@ -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 选择。 + +## 限制 + +调用方处理搜索、市场过滤和最近记录;组件提供异步加载、失败和重试界面。 diff --git a/docs/components/186-symbol-badge.md b/docs/components/186-symbol-badge.md new file mode 100644 index 0000000..0f9edba --- /dev/null +++ b/docs/components/186-symbol-badge.md @@ -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 激活。 + +## 限制 + +市场和类型属于业务标签,由调用方本地化。 diff --git a/docs/components/187-market-heatmap.md b/docs/components/187-market-heatmap.md new file mode 100644 index 0000000..9e36f9a --- /dev/null +++ b/docs/components/187-market-heatmap.md @@ -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。 diff --git a/docs/components/188-screener.md b/docs/components/188-screener.md new file mode 100644 index 0000000..0eedfe4 --- /dev/null +++ b/docs/components/188-screener.md @@ -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 方向键步进;表格方向键选择。 + +## 限制 + +全部条件取交集;保存由调用方处理;缺失值无法通过条件。 diff --git a/docs/components/189-market-overview.md b/docs/components/189-market-overview.md new file mode 100644 index 0000000..d51df94 --- /dev/null +++ b/docs/components/189-market-overview.md @@ -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 选择。 + +## 限制 + +提供展示与点击事件;行情获取由调用方负责。 diff --git a/docs/components/190-index-card.md b/docs/components/190-index-card.md new file mode 100644 index 0000000..1ee8e95 --- /dev/null +++ b/docs/components/190-index-card.md @@ -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;行情获取由调用方负责。 diff --git a/docs/components/191-market-status.md b/docs/components/191-market-status.md new file mode 100644 index 0000000..1bee179 --- /dev/null +++ b/docs/components/191-market-status.md @@ -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 时由调用方推进时间。 diff --git a/docs/components/192-trading-session-clock.md b/docs/components/192-trading-session-clock.md new file mode 100644 index 0000000..67239a0 --- /dev/null +++ b/docs/components/192-trading-session-clock.md @@ -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 指定;跨午夜时段拆分为两个日期的时段。 diff --git a/docs/components/193-economic-calendar.md b/docs/components/193-economic-calendar.md new file mode 100644 index 0000000..6f1a6d4 --- /dev/null +++ b/docs/components/193-economic-calendar.md @@ -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 提交行。 + +## 限制 + +事件数据与发布结果由调用方提供;过滤与排序缓存直到数据或条件变更。 diff --git a/docs/components/194-earnings-calendar.md b/docs/components/194-earnings-calendar.md new file mode 100644 index 0000000..9efde0e --- /dev/null +++ b/docs/components/194-earnings-calendar.md @@ -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 表示待公布;零预期仅显示金额差;财报数据由调用方提供。 diff --git a/docs/components/195-news-feed.md b/docs/components/195-news-feed.md new file mode 100644 index 0000000..7d340db --- /dev/null +++ b/docs/components/195-news-feed.md @@ -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 保存;链接打开由调用方负责。 diff --git a/docs/components/196-sentiment-gauge.md b/docs/components/196-sentiment-gauge.md new file mode 100644 index 0000000..43c5d1f --- /dev/null +++ b/docs/components/196-sentiment-gauge.md @@ -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。 diff --git a/docs/components/197-portfolio-summary.md b/docs/components/197-portfolio-summary.md new file mode 100644 index 0000000..f911137 --- /dev/null +++ b/docs/components/197-portfolio-summary.md @@ -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 时隐藏比例。 diff --git a/docs/components/198-asset-allocation-chart.md b/docs/components/198-asset-allocation-chart.md new file mode 100644 index 0000000..48080f9 --- /dev/null +++ b/docs/components/198-asset-allocation-chart.md @@ -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。 diff --git a/docs/components/199-performance-chart.md b/docs/components/199-performance-chart.md new file mode 100644 index 0000000..5140744 --- /dev/null +++ b/docs/components/199-performance-chart.md @@ -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 切换序列。 + +## 限制 + +净值先归一化为累计收益;最大回撤取峰值到谷值;日期与报价由调用方提供。 diff --git a/docs/components/200-currency-converter.md b/docs/components/200-currency-converter.md new file mode 100644 index 0000000..b580f81 --- /dev/null +++ b/docs/components/200-currency-converter.md @@ -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 并获取新汇率;结果按目标货币精度取整。 diff --git a/earnings_calendar.go b/earnings_calendar.go index b1133bc..87092ed 100644 --- a/earnings_calendar.go +++ b/earnings_calendar.go @@ -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 = © + } 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) { diff --git a/earnings_calendar_test.go b/earnings_calendar_test.go new file mode 100644 index 0000000..56ec7fa --- /dev/null +++ b/earnings_calendar_test.go @@ -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) + + }) + }) +} diff --git a/economic_calendar.go b/economic_calendar.go index c6643b1..adf2e71 100644 --- a/economic_calendar.go +++ b/economic_calendar.go @@ -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 = © + } 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 { diff --git a/economic_calendar_test.go b/economic_calendar_test.go new file mode 100644 index 0000000..07b2f5a --- /dev/null +++ b/economic_calendar_test.go @@ -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) + + }) + }) +} diff --git a/example_markets_test.go b/example_markets_test.go new file mode 100644 index 0000000..0051779 --- /dev/null +++ b/example_markets_test.go @@ -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) +} diff --git a/examples/gallery/pages_markets.go b/examples/gallery/pages_markets.go index 6e7e272..efe5759 100644 --- a/examples/gallery/pages_markets.go +++ b/examples/gallery/pages_markets.go @@ -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, "" +} diff --git a/examples/gallery/pages_markets_1.go b/examples/gallery/pages_markets_1.go new file mode 100644 index 0000000..0e0311a --- /dev/null +++ b/examples/gallery/pages_markets_1.go @@ -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)) + } + }) +} diff --git a/examples/gallery/pages_markets_2.go b/examples/gallery/pages_markets_2.go new file mode 100644 index 0000000..0a3efbc --- /dev/null +++ b/examples/gallery/pages_markets_2.go @@ -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)) + } + }) +} diff --git a/examples/gallery/pages_markets_3.go b/examples/gallery/pages_markets_3.go new file mode 100644 index 0000000..8bf7b00 --- /dev/null +++ b/examples/gallery/pages_markets_3.go @@ -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)) + } + }) +} diff --git a/examples/gallery/pages_markets_4.go b/examples/gallery/pages_markets_4.go new file mode 100644 index 0000000..a29ccfa --- /dev/null +++ b/examples/gallery/pages_markets_4.go @@ -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)) + } +} diff --git a/examples/gallery/pages_markets_test.go b/examples/gallery/pages_markets_test.go new file mode 100644 index 0000000..2d48730 --- /dev/null +++ b/examples/gallery/pages_markets_test.go @@ -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) + } + } +} diff --git a/greeks_table.go b/greeks_table.go index 282385c..c2367ff 100644 --- a/greeks_table.go +++ b/greeks_table.go @@ -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 { diff --git a/greeks_table_test.go b/greeks_table_test.go new file mode 100644 index 0000000..217e132 --- /dev/null +++ b/greeks_table_test.go @@ -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}) }) + }) +} diff --git a/index_card.go b/index_card.go index b7c5773..dc682cf 100644 --- a/index_card.go +++ b/index_card.go @@ -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() diff --git a/index_card_test.go b/index_card_test.go new file mode 100644 index 0000000..b1c9e4e --- /dev/null +++ b/index_card_test.go @@ -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{}) + }) + }) +} diff --git a/market_heatmap.go b/market_heatmap.go index 8fb2944..64bb2ee 100644 --- a/market_heatmap.go +++ b/market_heatmap.go @@ -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) + }) +} diff --git a/market_heatmap_test.go b/market_heatmap_test.go new file mode 100644 index 0000000..6bf5ad1 --- /dev/null +++ b/market_heatmap_test.go @@ -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}}) + + }) + }) +} diff --git a/market_overview.go b/market_overview.go index 1da1f6b..41c8ba0 100644 --- a/market_overview.go +++ b/market_overview.go @@ -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() diff --git a/market_overview_test.go b/market_overview_test.go new file mode 100644 index 0000000..f988965 --- /dev/null +++ b/market_overview_test.go @@ -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}) + + }) + }) +} diff --git a/market_status.go b/market_status.go index 77a1357..446cf20 100644 --- a/market_status.go +++ b/market_status.go @@ -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") diff --git a/market_status_test.go b/market_status_test.go new file mode 100644 index 0000000..103e048 --- /dev/null +++ b/market_status_test.go @@ -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{}) + + }) + }) +} diff --git a/markets_fixture_test.go b/markets_fixture_test.go new file mode 100644 index 0000000..1e3251d --- /dev/null +++ b/markets_fixture_test.go @@ -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) +} diff --git a/markets_regression_test.go b/markets_regression_test.go new file mode 100644 index 0000000..17b14d8 --- /dev/null +++ b/markets_regression_test.go @@ -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") + } + } + }) + } +} diff --git a/messages_markets.go b/messages_markets.go index baeb6bb..cf1e5d4 100644 --- a/messages_markets.go +++ b/messages_markets.go @@ -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", "自选分组"}, diff --git a/news_feed.go b/news_feed.go index 118ffa4..7cc72ab 100644 --- a/news_feed.go +++ b/news_feed.go @@ -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 = © + } + 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) diff --git a/news_feed_test.go b/news_feed_test.go new file mode 100644 index 0000000..2c2c87d --- /dev/null +++ b/news_feed_test.go @@ -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) + + }) + }) +} diff --git a/option_chain.go b/option_chain.go index 09a883a..efc11f7 100644 --- a/option_chain.go +++ b/option_chain.go @@ -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 = © + } + 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") }) diff --git a/option_chain_test.go b/option_chain_test.go new file mode 100644 index 0000000..05736be --- /dev/null +++ b/option_chain_test.go @@ -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) + } +} diff --git a/payoff_diagram.go b/payoff_diagram.go index 17f59c1..8b9c338 100644 --- a/payoff_diagram.go +++ b/payoff_diagram.go @@ -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 { diff --git a/payoff_diagram_test.go b/payoff_diagram_test.go new file mode 100644 index 0000000..4467156 --- /dev/null +++ b/payoff_diagram_test.go @@ -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") + } +} diff --git a/performance_chart.go b/performance_chart.go index 5ba745e..dcbda0b 100644 --- a/performance_chart.go +++ b/performance_chart.go @@ -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]))) diff --git a/performance_chart_test.go b/performance_chart_test.go new file mode 100644 index 0000000..9bfe10f --- /dev/null +++ b/performance_chart_test.go @@ -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) + } +} diff --git a/portfolio_summary.go b/portfolio_summary.go index 26f97c0..219e4d5 100644 --- a/portfolio_summary.go +++ b/portfolio_summary.go @@ -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() diff --git a/portfolio_summary_test.go b/portfolio_summary_test.go new file mode 100644 index 0000000..b939b52 --- /dev/null +++ b/portfolio_summary_test.go @@ -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{}) + + }) + }) +} diff --git a/screener.go b/screener.go index 84db81d..9da5112 100644 --- a/screener.go +++ b/screener.go @@ -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 = © + } 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 { diff --git a/screener_test.go b/screener_test.go new file mode 100644 index 0000000..cc95661 --- /dev/null +++ b/screener_test.go @@ -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) + + }) + }) +} diff --git a/sentiment_gauge.go b/sentiment_gauge.go index 94b34d1..6cc1c23 100644 --- a/sentiment_gauge.go +++ b/sentiment_gauge.go @@ -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 } diff --git a/sentiment_gauge_test.go b/sentiment_gauge_test.go new file mode 100644 index 0000000..d2466be --- /dev/null +++ b/sentiment_gauge_test.go @@ -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) + } +} diff --git a/symbol_badge.go b/symbol_badge.go index 4aaa8c0..fd46479 100644 --- a/symbol_badge.go +++ b/symbol_badge.go @@ -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. diff --git a/symbol_badge_test.go b/symbol_badge_test.go new file mode 100644 index 0000000..1a8ae62 --- /dev/null +++ b/symbol_badge_test.go @@ -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{}) + }) + + }) + }) +} diff --git a/symbol_search.go b/symbol_search.go index b65c69e..2a3a2c5 100644 --- a/symbol_search.go +++ b/symbol_search.go @@ -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 = © + } 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) } diff --git a/symbol_search_test.go b/symbol_search_test.go new file mode 100644 index 0000000..014a01e --- /dev/null +++ b/symbol_search_test.go @@ -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) + } +} diff --git a/testdata/snapshots/darwin/asset_allocation_chart-dark.png b/testdata/snapshots/darwin/asset_allocation_chart-dark.png new file mode 100644 index 0000000..12cd420 Binary files /dev/null and b/testdata/snapshots/darwin/asset_allocation_chart-dark.png differ diff --git a/testdata/snapshots/darwin/asset_allocation_chart-light.png b/testdata/snapshots/darwin/asset_allocation_chart-light.png new file mode 100644 index 0000000..3246d3d Binary files /dev/null and b/testdata/snapshots/darwin/asset_allocation_chart-light.png differ diff --git a/testdata/snapshots/darwin/currency_converter-dark.png b/testdata/snapshots/darwin/currency_converter-dark.png new file mode 100644 index 0000000..b939179 Binary files /dev/null and b/testdata/snapshots/darwin/currency_converter-dark.png differ diff --git a/testdata/snapshots/darwin/currency_converter-light.png b/testdata/snapshots/darwin/currency_converter-light.png new file mode 100644 index 0000000..aae47c7 Binary files /dev/null and b/testdata/snapshots/darwin/currency_converter-light.png differ diff --git a/testdata/snapshots/darwin/earnings_calendar-dark.png b/testdata/snapshots/darwin/earnings_calendar-dark.png new file mode 100644 index 0000000..1f1b6b5 Binary files /dev/null and b/testdata/snapshots/darwin/earnings_calendar-dark.png differ diff --git a/testdata/snapshots/darwin/earnings_calendar-light.png b/testdata/snapshots/darwin/earnings_calendar-light.png new file mode 100644 index 0000000..7b81de9 Binary files /dev/null and b/testdata/snapshots/darwin/earnings_calendar-light.png differ diff --git a/testdata/snapshots/darwin/economic_calendar-dark.png b/testdata/snapshots/darwin/economic_calendar-dark.png new file mode 100644 index 0000000..9cc7adf Binary files /dev/null and b/testdata/snapshots/darwin/economic_calendar-dark.png differ diff --git a/testdata/snapshots/darwin/economic_calendar-light.png b/testdata/snapshots/darwin/economic_calendar-light.png new file mode 100644 index 0000000..986adf8 Binary files /dev/null and b/testdata/snapshots/darwin/economic_calendar-light.png differ diff --git a/testdata/snapshots/darwin/greeks_table-dark.png b/testdata/snapshots/darwin/greeks_table-dark.png new file mode 100644 index 0000000..410b61e Binary files /dev/null and b/testdata/snapshots/darwin/greeks_table-dark.png differ diff --git a/testdata/snapshots/darwin/greeks_table-light.png b/testdata/snapshots/darwin/greeks_table-light.png new file mode 100644 index 0000000..44c224c Binary files /dev/null and b/testdata/snapshots/darwin/greeks_table-light.png differ diff --git a/testdata/snapshots/darwin/index_card-dark.png b/testdata/snapshots/darwin/index_card-dark.png new file mode 100644 index 0000000..66fb5a6 Binary files /dev/null and b/testdata/snapshots/darwin/index_card-dark.png differ diff --git a/testdata/snapshots/darwin/index_card-light.png b/testdata/snapshots/darwin/index_card-light.png new file mode 100644 index 0000000..7dc34f8 Binary files /dev/null and b/testdata/snapshots/darwin/index_card-light.png differ diff --git a/testdata/snapshots/darwin/market_heatmap-dark.png b/testdata/snapshots/darwin/market_heatmap-dark.png new file mode 100644 index 0000000..9aa3c0a Binary files /dev/null and b/testdata/snapshots/darwin/market_heatmap-dark.png differ diff --git a/testdata/snapshots/darwin/market_heatmap-light.png b/testdata/snapshots/darwin/market_heatmap-light.png new file mode 100644 index 0000000..e95a740 Binary files /dev/null and b/testdata/snapshots/darwin/market_heatmap-light.png differ diff --git a/testdata/snapshots/darwin/market_overview-dark.png b/testdata/snapshots/darwin/market_overview-dark.png new file mode 100644 index 0000000..03779fc Binary files /dev/null and b/testdata/snapshots/darwin/market_overview-dark.png differ diff --git a/testdata/snapshots/darwin/market_overview-light.png b/testdata/snapshots/darwin/market_overview-light.png new file mode 100644 index 0000000..1e2c40c Binary files /dev/null and b/testdata/snapshots/darwin/market_overview-light.png differ diff --git a/testdata/snapshots/darwin/market_status-dark.png b/testdata/snapshots/darwin/market_status-dark.png new file mode 100644 index 0000000..714289d Binary files /dev/null and b/testdata/snapshots/darwin/market_status-dark.png differ diff --git a/testdata/snapshots/darwin/market_status-light.png b/testdata/snapshots/darwin/market_status-light.png new file mode 100644 index 0000000..5efc34a Binary files /dev/null and b/testdata/snapshots/darwin/market_status-light.png differ diff --git a/testdata/snapshots/darwin/news_feed-dark.png b/testdata/snapshots/darwin/news_feed-dark.png new file mode 100644 index 0000000..b136af4 Binary files /dev/null and b/testdata/snapshots/darwin/news_feed-dark.png differ diff --git a/testdata/snapshots/darwin/news_feed-light.png b/testdata/snapshots/darwin/news_feed-light.png new file mode 100644 index 0000000..9589315 Binary files /dev/null and b/testdata/snapshots/darwin/news_feed-light.png differ diff --git a/testdata/snapshots/darwin/option_chain-dark.png b/testdata/snapshots/darwin/option_chain-dark.png new file mode 100644 index 0000000..0665657 Binary files /dev/null and b/testdata/snapshots/darwin/option_chain-dark.png differ diff --git a/testdata/snapshots/darwin/option_chain-light.png b/testdata/snapshots/darwin/option_chain-light.png new file mode 100644 index 0000000..71d14b9 Binary files /dev/null and b/testdata/snapshots/darwin/option_chain-light.png differ diff --git a/testdata/snapshots/darwin/payoff_diagram-dark.png b/testdata/snapshots/darwin/payoff_diagram-dark.png new file mode 100644 index 0000000..e4a1d8b Binary files /dev/null and b/testdata/snapshots/darwin/payoff_diagram-dark.png differ diff --git a/testdata/snapshots/darwin/payoff_diagram-light.png b/testdata/snapshots/darwin/payoff_diagram-light.png new file mode 100644 index 0000000..9e47aed Binary files /dev/null and b/testdata/snapshots/darwin/payoff_diagram-light.png differ diff --git a/testdata/snapshots/darwin/performance_chart-dark.png b/testdata/snapshots/darwin/performance_chart-dark.png new file mode 100644 index 0000000..4e767e9 Binary files /dev/null and b/testdata/snapshots/darwin/performance_chart-dark.png differ diff --git a/testdata/snapshots/darwin/performance_chart-light.png b/testdata/snapshots/darwin/performance_chart-light.png new file mode 100644 index 0000000..7e88487 Binary files /dev/null and b/testdata/snapshots/darwin/performance_chart-light.png differ diff --git a/testdata/snapshots/darwin/portfolio_summary-dark.png b/testdata/snapshots/darwin/portfolio_summary-dark.png new file mode 100644 index 0000000..1ded3f1 Binary files /dev/null and b/testdata/snapshots/darwin/portfolio_summary-dark.png differ diff --git a/testdata/snapshots/darwin/portfolio_summary-light.png b/testdata/snapshots/darwin/portfolio_summary-light.png new file mode 100644 index 0000000..1fd4b20 Binary files /dev/null and b/testdata/snapshots/darwin/portfolio_summary-light.png differ diff --git a/testdata/snapshots/darwin/screener-dark.png b/testdata/snapshots/darwin/screener-dark.png new file mode 100644 index 0000000..0bee2cf Binary files /dev/null and b/testdata/snapshots/darwin/screener-dark.png differ diff --git a/testdata/snapshots/darwin/screener-light.png b/testdata/snapshots/darwin/screener-light.png new file mode 100644 index 0000000..4b1467a Binary files /dev/null and b/testdata/snapshots/darwin/screener-light.png differ diff --git a/testdata/snapshots/darwin/sentiment_gauge-dark.png b/testdata/snapshots/darwin/sentiment_gauge-dark.png new file mode 100644 index 0000000..cedd180 Binary files /dev/null and b/testdata/snapshots/darwin/sentiment_gauge-dark.png differ diff --git a/testdata/snapshots/darwin/sentiment_gauge-light.png b/testdata/snapshots/darwin/sentiment_gauge-light.png new file mode 100644 index 0000000..8f8a1ef Binary files /dev/null and b/testdata/snapshots/darwin/sentiment_gauge-light.png differ diff --git a/testdata/snapshots/darwin/symbol_badge-dark.png b/testdata/snapshots/darwin/symbol_badge-dark.png new file mode 100644 index 0000000..96da4b4 Binary files /dev/null and b/testdata/snapshots/darwin/symbol_badge-dark.png differ diff --git a/testdata/snapshots/darwin/symbol_badge-light.png b/testdata/snapshots/darwin/symbol_badge-light.png new file mode 100644 index 0000000..f4a6448 Binary files /dev/null and b/testdata/snapshots/darwin/symbol_badge-light.png differ diff --git a/testdata/snapshots/darwin/symbol_search-dark.png b/testdata/snapshots/darwin/symbol_search-dark.png new file mode 100644 index 0000000..9d71c89 Binary files /dev/null and b/testdata/snapshots/darwin/symbol_search-dark.png differ diff --git a/testdata/snapshots/darwin/symbol_search-light.png b/testdata/snapshots/darwin/symbol_search-light.png new file mode 100644 index 0000000..e550b7b Binary files /dev/null and b/testdata/snapshots/darwin/symbol_search-light.png differ diff --git a/testdata/snapshots/darwin/trading_session_clock-dark.png b/testdata/snapshots/darwin/trading_session_clock-dark.png new file mode 100644 index 0000000..5f46222 Binary files /dev/null and b/testdata/snapshots/darwin/trading_session_clock-dark.png differ diff --git a/testdata/snapshots/darwin/trading_session_clock-light.png b/testdata/snapshots/darwin/trading_session_clock-light.png new file mode 100644 index 0000000..824a90d Binary files /dev/null and b/testdata/snapshots/darwin/trading_session_clock-light.png differ diff --git a/testdata/snapshots/darwin/watchlist-dark.png b/testdata/snapshots/darwin/watchlist-dark.png new file mode 100644 index 0000000..731fd12 Binary files /dev/null and b/testdata/snapshots/darwin/watchlist-dark.png differ diff --git a/testdata/snapshots/darwin/watchlist-light.png b/testdata/snapshots/darwin/watchlist-light.png new file mode 100644 index 0000000..38ddd47 Binary files /dev/null and b/testdata/snapshots/darwin/watchlist-light.png differ diff --git a/trading_session_clock.go b/trading_session_clock.go index bc56e6f..28cccef 100644 --- a/trading_session_clock.go +++ b/trading_session_clock.go @@ -9,13 +9,11 @@ import ( "mujicaui/theme" ) -// TradingSession is a phase from Start to End, in minutes after the market's local midnight. type TradingSession struct { Phase MarketPhase Start, End int } -// SessionMarket is a market, its time zone from the caller, and its daily sessions. type SessionMarket struct { Name string Location *time.Location @@ -30,7 +28,6 @@ type TradingSessionClockOptions struct { Now time.Time } -// TradingSessionClock shows each market's local time, phase, next change and a day bar of its sessions. func TradingSessionClock(c *ui.Context, markets []SessionMarket, opts TradingSessionClockOptions) *ui.Element { now := opts.Now if now.IsZero() { @@ -70,7 +67,9 @@ func TradingSessionClock(c *ui.Context, markets []SessionMarket, opts TradingSes } }) // Times and phases move on the minute. - c.After(time.Minute - time.Duration(now.Second())*time.Second) + if opts.Now.IsZero() && len(markets) > 0 { + g.DrawOver(func(p *ui.Painter, _ ui.Rect) { c.After(time.Minute - time.Duration(p.Now().Second())*time.Second) }) + } return g } @@ -88,13 +87,14 @@ func tradingSessionCheck(m SessionMarket) { } } -// tradingSessionPhase is the phase at local, the next change, and the phase it brings. func tradingSessionPhase(m SessionMarket, local time.Time) (MarketPhase, time.Time, MarketPhase) { if m.Holiday || len(m.Sessions) == 0 { return MarketHoliday, time.Time{}, MarketClosed } mid := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, m.Location) - at := func(min int) time.Time { return mid.Add(time.Duration(min) * time.Minute) } + at := func(min int) time.Time { + return time.Date(mid.Year(), mid.Month(), mid.Day(), min/60, min%60, 0, 0, m.Location) + } cur := local.Hour()*60 + local.Minute() for i, s := range m.Sessions { switch { diff --git a/trading_session_clock_test.go b/trading_session_clock_test.go new file mode 100644 index 0000000..6e3c0c1 --- /dev/null +++ b/trading_session_clock_test.go @@ -0,0 +1,55 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" + "time" +) + +func TestTradingSessionClockZonesBoundariesAndHoliday(t *testing.T) { + ny := SessionMarket{Name: "NYSE", Location: mktNewYork(), Sessions: []TradingSession{{MarketPreMarket, 240, 570}, {MarketOpen, 570, 960}, {MarketAfterHours, 960, 1200}}} + for _, tc := range []struct { + minute int + phase MarketPhase + }{{239, MarketClosed}, {240, MarketPreMarket}, {569, MarketPreMarket}, {570, MarketOpen}, {960, MarketAfterHours}, {1200, MarketClosed}} { + local := time.Date(2026, 10, 5, tc.minute/60, tc.minute%60, 0, 0, ny.Location) + phase, next, _ := tradingSessionPhase(ny, local) + if phase != tc.phase || !next.After(local) { + t.Fatalf("at %d phase %d next %v", tc.minute, phase, next) + } + } + tt := chatTester(960, 260, func(c *ui.Context) { + TradingSessionClock(c, []SessionMarket{ny, {Name: "SSE", Location: time.FixedZone("CST", 8*3600), Holiday: true}}, TradingSessionClockOptions{Now: mktNow}) + }) + if !tt.HasText("10:30 EDT") || !tt.HasText("22:30 CST") || !tt.HasText("Holiday") { + t.Fatalf("zones %q", tt.Texts()) + } + marketsPanic(t, func(c *ui.Context) { + TradingSessionClock(c, []SessionMarket{{Name: "Bad"}}, TradingSessionClockOptions{}) + }) +} +func TestTradingSessionClockSnapshot(t *testing.T) { + + snapshot(t, "trading_session_clock", 720, 220, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(12).Fill().Children(func() { + TradingSessionClock(c, []SessionMarket{ + {Name: "NYSE", Location: mktNewYork(), Sessions: []TradingSession{{MarketPreMarket, 240, 570}, {MarketOpen, 570, 960}, {MarketAfterHours, 960, 1200}}}, + {Name: "SSE", Location: time.FixedZone("CST", 8*3600), Sessions: []TradingSession{{MarketOpen, 570, 690}, {MarketOpen, 780, 900}}}, + }, TradingSessionClockOptions{Now: mktNow}) + + }) + }) +} + +func TestTradingSessionClockDST(t *testing.T) { + loc, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatal(err) + } + m := SessionMarket{Name: "NYSE", Location: loc, Sessions: []TradingSession{{MarketOpen, 570, 960}}} + local := time.Date(2026, 3, 8, 8, 0, 0, 0, loc) + _, next, _ := tradingSessionPhase(m, local) + if next.Hour() != 9 || next.Minute() != 30 { + t.Fatalf("DST open %v", next) + } +} diff --git a/watchlist.go b/watchlist.go index 1629eb5..1c6c104 100644 --- a/watchlist.go +++ b/watchlist.go @@ -7,6 +7,7 @@ import ( "github.com/egoist/mygo/ui" "mujicaui/icons" + "mujicaui/internal" "mujicaui/theme" ) @@ -36,7 +37,6 @@ const ( var watchColumns = [3]string{"watchlist.price", "watchlist.change", "watchlist.volume"} -// WatchlistState is the open group, the chosen symbol and the hidden columns. type WatchlistState struct { Group int Selected string @@ -54,28 +54,33 @@ type WatchlistOptions struct { // WatchlistView is a built Watchlist and this frame's events. type WatchlistView struct { - Element *ui.Element - // Changed reports a new State.Selected; Submitted is Enter or a double click. + Element *ui.Element Changed, Submitted bool - // Moved asks the caller to move rows Rows of the open group before index To. - Moved bool - Rows []int - To int + Moved bool + Rows []int + To int } -// Watchlist shows grouped watched symbols with chosen columns; rows reorder by dragging and keep the choice through updates. func Watchlist(c *ui.Context, s *WatchlistState, groups []WatchGroup, opts WatchlistOptions) WatchlistView { if s == nil || len(groups) == 0 { panic("mujica: Watchlist needs a state and a group") } dec := priceDecimals("Watchlist", opts.Decimals) + + if s.Group < 0 || s.Group >= len(groups) { + panic("mujica: Watchlist Group out of range") + } + d := Density(c) + var v WatchlistView + v.Element = ui.Column(c).Gap(d.Unit() * 2).Grow(1).MinHeight(0).Disabled(opts.Disabled) + opts.Disabled = v.Element.IsDisabled() + if opts.Disabled { + copy := *s + s = © + } if s.Hidden == nil { s.Hidden = map[WatchColumn]bool{} } - s.Group = max(0, min(s.Group, len(groups)-1)) - d := Density(c) - var v WatchlistView - v.Element = ui.Column(c).Gap(d.Unit() * 2).MinHeight(0).Disabled(opts.Disabled) v.Element.Children(func() { ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { tabs := make([]Tab, len(groups)) @@ -122,7 +127,10 @@ func watchlistRows(c *ui.Context, s *WatchlistState, items []WatchItem, dec int, s.rows.Key = func(i int) any { return items[i].Symbol } s.rows.Label = func(i int) string { return items[i].Symbol } s.rows.Selected = cursor - s.rows.Reorder = func(rows []int, to int) { v.Moved, v.Rows, v.To = true, slices.Clone(rows), to } + s.rows.Reorder = nil + if !disabled { + s.rows.Reorder = func(rows []int, to int) { v.Moved, v.Rows, v.To = true, slices.Clone(rows), to } + } if len(items) == 0 { ui.Text(c, msg(c, "watchlist.empty")).TextColor(k.TextMuted).Padding(d.Unit() * 3) return false, false @@ -155,6 +163,7 @@ func watchlistRows(c *ui.Context, s *WatchlistState, items []WatchItem, dec int, }) }).Grow(1).MinHeight(0).Padding(d.Unit()).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius) restore() + internal.Ring(list, k.Focus, theme.ControlRadius) if !disabled && s.cursor != before && s.cursor >= 0 { s.Selected, changed = items[s.cursor].Symbol, true } diff --git a/watchlist_test.go b/watchlist_test.go new file mode 100644 index 0000000..389764a --- /dev/null +++ b/watchlist_test.go @@ -0,0 +1,137 @@ +package mujica + +import ( + "fmt" + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func watchlistTester(s *WatchlistState, groups *[]WatchGroup, opts WatchlistOptions, got *WatchlistView) *ui.Tester { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(10).Height(400).Children(func() { + v := Watchlist(c, s, *groups, opts) + if v.Changed || v.Submitted || v.Moved { + *got = v + } + if v.Moved { + g := &(*groups)[s.Group] + g.Items = watchlistMove(g.Items, v.Rows, v.To) + } + }) + }, 700, 420) + tt.Frame() + return tt +} + +// watchlistMove is how a caller applies a Moved event. +func watchlistMove(items []WatchItem, rows []int, to int) []WatchItem { + var moved, rest []WatchItem + at := to + for i, it := range items { + if slices.Contains(rows, i) { + moved = append(moved, it) + if i < to { + at-- + } + } else { + rest = append(rest, it) + } + } + return slices.Insert(rest, at, moved...) +} + +func TestWatchlistSelectKeepsThroughUpdates(t *testing.T) { + var s WatchlistState + groups := mktWatch() + var got WatchlistView + tt := watchlistTester(&s, &groups, WatchlistOptions{}, &got) + if err := tt.Click("MSFT"); err != nil { + t.Fatal(err) + } + if s.Selected != "MSFT" || !got.Changed { + t.Fatalf("selected %q", s.Selected) + } + // A refresh with new prices in a new order keeps the choice on MSFT. + groups[0].Items = []WatchItem{groups[0].Items[2], groups[0].Items[1], groups[0].Items[0]} + groups[0].Items[1].Price = 420 + got = WatchlistView{} + tt.Frame() + tt.Key(0, ui.KeyDown) + if s.Selected != "AAPL" { + t.Fatalf("Down after refresh chose %q", s.Selected) + } + tt.Key(0, ui.KeyEnter) + if !got.Submitted { + t.Fatal("Enter did not submit") + } +} + +func TestWatchlistColumnsAndGroups(t *testing.T) { + var s WatchlistState + groups := mktWatch() + var got WatchlistView + tt := watchlistTester(&s, &groups, WatchlistOptions{}, &got) + if !tt.HasText("51,000,000") { + t.Fatalf("volume missing: %q", tt.Texts()) + } + _ = tt.Click("Columns") + _ = tt.Click("Volume") + tt.Key(0, ui.KeyEscape) + if tt.HasText("51,000,000") || !s.Hidden[WatchVolume] { + t.Fatal("volume column still shown") + } + _ = tt.Click("Energy") + if s.Group != 1 || !tt.HasText("XOM") || tt.HasText("AAPL") { + t.Fatalf("group %d texts %q", s.Group, tt.Texts()) + } +} + +func TestWatchlistDragReorders(t *testing.T) { + var s WatchlistState + groups := mktWatch() + var got WatchlistView + tt := watchlistTester(&s, &groups, WatchlistOptions{}, &got) + from, _ := tt.Find("NVDA") + to, _ := tt.Find("AAPL") + tt.Press(from.X, from.Y+from.H/2) + tt.Move(from.X, from.Y-10) + tt.Move(to.X, to.Y) + tt.Release(to.X, to.Y) + order := []string{} + for _, it := range groups[0].Items { + order = append(order, it.Symbol) + } + if !got.Moved || order[0] != "NVDA" { + t.Fatalf("moved %v order %v", got.Moved, order) + } +} + +func TestWatchlistDisabledAndLarge(t *testing.T) { + var s WatchlistState + groups := mktWatch() + var got WatchlistView + tt := watchlistTester(&s, &groups, WatchlistOptions{Disabled: true}, &got) + _ = tt.Click("MSFT") + if s.Selected != "" || got.Changed { + t.Fatalf("disabled list chose %q", s.Selected) + } + big := []WatchGroup{{Name: "All"}} + for i := range 100000 { + big[0].Items = append(big[0].Items, WatchItem{Symbol: fmt.Sprint("S", i), Price: 1}) + } + var b WatchlistState + tt = watchlistTester(&b, &big, WatchlistOptions{}, &got) + if n := len(tt.Texts()); n > 200 { + t.Fatalf("built %d texts for 100000 rows", n) + } +} + +func TestWatchlistSnapshot(t *testing.T) { + snapshot(t, "watchlist", 640, 260, func(c *ui.Context) { + s := &WatchlistState{Selected: "MSFT"} + ui.Column(c).Padding(16).Height(260).Children(func() { Watchlist(c, s, mktWatch(), WatchlistOptions{}) }) + }) +}