WIP components (as built)
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 确认。
|
||||
|
||||
## 限制
|
||||
|
||||
到期日切换后,由调用方更换对应报价;排序与稳定合约标识由调用方保证。
|
||||
@@ -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。
|
||||
|
||||
## 状态
|
||||
|
||||
默认与数据边界。
|
||||
|
||||
## 事件
|
||||
|
||||
展示组件;数据由调用方更新。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
展示组件;表头提供说明和悬停提示。
|
||||
|
||||
## 限制
|
||||
|
||||
通过虚拟列表构建可见行;精度为显示规则,数值计算由调用方提供。
|
||||
@@ -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 指定。
|
||||
@@ -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。
|
||||
@@ -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 选择。
|
||||
|
||||
## 限制
|
||||
|
||||
调用方处理搜索、市场过滤和最近记录;组件提供异步加载、失败和重试界面。
|
||||
@@ -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 激活。
|
||||
|
||||
## 限制
|
||||
|
||||
市场和类型属于业务标签,由调用方本地化。
|
||||
@@ -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。
|
||||
@@ -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 方向键步进;表格方向键选择。
|
||||
|
||||
## 限制
|
||||
|
||||
全部条件取交集;保存由调用方处理;缺失值无法通过条件。
|
||||
@@ -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 选择。
|
||||
|
||||
## 限制
|
||||
|
||||
提供展示与点击事件;行情获取由调用方负责。
|
||||
@@ -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;行情获取由调用方负责。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 191 MarketStatus
|
||||
|
||||
## 用途
|
||||
|
||||
市场状态。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
now := time.Date(2026, 10, 5, 14, 30, 0, 0, time.UTC)
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.MarketStatus(c, mujica.MarketOpen, mujica.MarketStatusOptions{Market: "NYSE", Now: now, Next: now.Add(5 * time.Hour), NextPhase: mujica.MarketAfterHours})
|
||||
}
|
||||
ui.Render(view, 960, 440, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`phase` 支持五个 MarketPhase;`Market` 默认空;`Next` 零值隐藏;`NextPhase` 默认 Closed;`Now` 零值使用窗口时间。
|
||||
|
||||
## 状态
|
||||
|
||||
默认与数据边界。
|
||||
|
||||
## 事件
|
||||
|
||||
展示组件;数据由调用方更新。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
展示组件。
|
||||
|
||||
## 限制
|
||||
|
||||
使用窗口时间时每分钟刷新;传入 Now 时由调用方推进时间。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 192 TradingSessionClock
|
||||
|
||||
## 用途
|
||||
|
||||
交易时段时钟。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
now := time.Date(2026, 10, 5, 14, 30, 0, 0, time.UTC)
|
||||
markets := []mujica.SessionMarket{{Name: "NYSE", Location: time.FixedZone("EDT", -4*3600), Sessions: []mujica.TradingSession{{Phase: mujica.MarketOpen, Start: 570, End: 960}}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.TradingSessionClock(c, markets, mujica.TradingSessionClockOptions{Now: now})
|
||||
}
|
||||
ui.Render(view, 960, 440, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`markets` 每项包含 Name、Location 与有序时段;Start/End 是本地午夜后的分钟;`Holiday` 默认 false;`Now` 零值使用窗口时间。
|
||||
|
||||
## 状态
|
||||
|
||||
默认与数据边界。
|
||||
|
||||
## 事件
|
||||
|
||||
展示组件;数据由调用方更新。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
展示组件。
|
||||
|
||||
## 限制
|
||||
|
||||
每天重复时段由调用方指定;节假日由 Holiday 指定;跨午夜时段拆分为两个日期的时段。
|
||||
@@ -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 提交行。
|
||||
|
||||
## 限制
|
||||
|
||||
事件数据与发布结果由调用方提供;过滤与排序缓存直到数据或条件变更。
|
||||
@@ -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 表示待公布;零预期仅显示金额差;财报数据由调用方提供。
|
||||
@@ -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 保存;链接打开由调用方负责。
|
||||
@@ -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。
|
||||
@@ -0,0 +1,40 @@
|
||||
# 197 PortfolioSummary
|
||||
|
||||
## 用途
|
||||
|
||||
资产总览。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
account := ""
|
||||
accounts := []mujica.PortfolioAccount{{ID: "a", Name: "Brokerage", Total: 120000, DayPnL: 1250, TotalReturn: 20000, Cost: 100000}, {ID: "b", Name: "Retirement", Total: 80000, DayPnL: -300, TotalReturn: 5000, Cost: 75000}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.PortfolioSummary(c, &account, accounts, mujica.PortfolioSummaryOptions{})
|
||||
if r.Changed() {
|
||||
fmt.Println(account)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 960, 440, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`account` 空值汇总全部账户;`accounts` 使用稳定 ID。`Currency` 默认 $;`Decimals` 默认 2;`Disabled` 默认 false。
|
||||
|
||||
## 状态
|
||||
|
||||
默认;交互区域支持聚焦与选中,Disabled 参数控制禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
if r.Changed() { 调用方回调(account) }
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦账户组;方向键切换账户。
|
||||
|
||||
## 限制
|
||||
|
||||
收益、成本与资产数值由调用方计算;基数为 0 时隐藏比例。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 198 AssetAllocationChart
|
||||
|
||||
## 用途
|
||||
|
||||
资产配置图。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
items := []mujica.AllocationItem{{Name: "Stocks", Value: 60, Target: 50}, {Name: "Bonds", Value: 30, Target: 40}, {Name: "Cash", Value: 10, Target: 10}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.AssetAllocationChart(c, items, mujica.AssetAllocationChartOptions{Frame: mujica.ChartFrame{Label: "Allocation"}})
|
||||
if r.Changed() || r.Retried() {
|
||||
fmt.Println(r.Point)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 960, 440, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`Version` 在原地修改数据时递增。环形图合并小于 1 度的多个扇区;列表虚拟化保留每项的占比、目标与偏离。
|
||||
|
||||
`items` 包含 Name、Value、Target;目标总和为 100 或全部 0。`Frame.Label` 必填;`Tolerance` 默认 5 个百分点;`Format` 可覆盖数值。
|
||||
|
||||
## 状态
|
||||
|
||||
默认与数据边界;图表的 Frame 支持加载、空、失败占位。
|
||||
|
||||
## 事件
|
||||
|
||||
if r.Changed() || r.Retried() { 调用方回调(r.Point) }
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦环形图;方向键逐项读数;Home/End 到首尾。
|
||||
|
||||
## 限制
|
||||
|
||||
列表同时提供占比、目标、偏离;负资产值触发 panic。
|
||||
@@ -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 切换序列。
|
||||
|
||||
## 限制
|
||||
|
||||
净值先归一化为累计收益;最大回撤取峰值到谷值;日期与报价由调用方提供。
|
||||
@@ -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 并获取新汇率;结果按目标货币精度取整。
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, ""
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}) })
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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}})
|
||||
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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})
|
||||
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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{})
|
||||
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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", "自选分组"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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])))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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{})
|
||||
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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{})
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.8 KiB |