diff --git a/asset_allocation_chart.go b/asset_allocation_chart.go new file mode 100644 index 0000000..78eaf94 --- /dev/null +++ b/asset_allocation_chart.go @@ -0,0 +1,113 @@ +package mujica + +import ( + "fmt" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// AllocationItem is an asset class's value and target weight in percent. +type AllocationItem struct { + Name string + Value float64 + Target float64 +} + +// AssetAllocationChartOptions configure an AssetAllocationChart. +type AssetAllocationChartOptions struct { + Frame ChartFrame + // Tolerance is the drift in points that warns; zero means 5. + 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") + } + tol := opts.Tolerance + 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} + } + 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}) + }) + 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.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) + } + }) + }) + res.Element = row + return res +} + +// allocationDrift shows the drift in points; beyond tolerance it warns with an icon and a word. +func allocationDrift(c *ui.Context, drift, tol float64) { + k := Tokens(c) + text := statisticSigned(drift, 1) + " pt" + over := math.Abs(drift) > tol + ink := k.TextMuted + if over { + ink = k.Warning + } + ui.Row(c).Gap(4).AlignItems(ui.Center).JustifySelf(ui.End).TextColor(ink).Children(func() { + if over { + ui.Icon(c, icons.Must("triangle-alert")).Size(12, 12) + word := "asset_allocation_chart.over" + if drift < 0 { + word = "asset_allocation_chart.under" + } + ui.Text(c, msg(c, word)).FontSize(FontSize(c, theme.CaptionSize)) + } + ui.Text(c, text).FontFeatures("tnum") + }) +} diff --git a/currency_converter.go b/currency_converter.go new file mode 100644 index 0000000..b61303d --- /dev/null +++ b/currency_converter.go @@ -0,0 +1,124 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// Currency is a currency the converter offers; Decimals is its minor-unit precision. +type Currency struct { + Code, Name string + Decimals int +} + +// CurrencyConverterState is the amount and the pair being converted. +type CurrencyConverterState struct { + Amount float64 + From, To string +} + +// 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 +} + +// 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 + // 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") + } + if opts.Rate < 0 || math.IsNaN(opts.Rate) || math.IsInf(opts.Rate, 0) { + panic("mujica: CurrencyConverter Rate must be finite and not negative") + } + from, to := currencyFind(currencies, s.From), currencyFind(currencies, s.To) + k := Tokens(c) + u := Density(c).Unit() + options := make([]SelectOption[string], len(currencies)) + for i, cur := range currencies { + 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) + } + 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() { + ui.Row(c).Gap(u * 2).AlignItems(ui.End).Wrap().Children(func() { + FormField(c, msg(c, "currency_converter.amount"), FormFieldOptions{}, func() *ui.Element { + r := NumberInput(c, &s.Amount, NumberInputOptions{Min: 0, Max: 1e15, Step: math.Pow(10, -float64(from.Decimals)), + Precision: from.Decimals, Label: msg(c, "currency_converter.amount"), Disabled: opts.Disabled}) + return r.Element.Width(170) + }) + FormField(c, msg(c, "currency_converter.from"), FormFieldOptions{}, func() *ui.Element { + r := Select(c, &s.From, options, SelectOptions{Label: msg(c, "currency_converter.from"), Disabled: opts.Disabled}) + v.PairChanged = v.PairChanged || r.Changed() + return r.Element + }) + swap := iconAction(c, icons.Must("arrow-up-down"), msg(c, "currency_converter.swap")).Rotate(90) + if swap.Clicked() && !swap.IsDisabled() { + s.From, s.To, v.PairChanged = s.To, s.From, true + } + FormField(c, msg(c, "currency_converter.to"), FormFieldOptions{}, func() *ui.Element { + r := Select(c, &s.To, options, SelectOptions{Label: msg(c, "currency_converter.to"), Disabled: opts.Disabled}) + v.PairChanged = v.PairChanged || r.Changed() + return r.Element + }) + }) + if opts.Rate == 0 { + ui.Text(c, msg(c, "currency_converter.no_rate")).TextColor(k.TextMuted).Role(ui.RoleStatus) + return + } + ui.Row(c).Gap(u * 2).AlignItems(ui.End).Role(ui.RoleStatus).Label(fmt.Sprintf("%s %s = %s %s", + statisticFormat(s.Amount, from.Decimals), from.Code, statisticFormat(v.Result, to.Decimals), to.Code)).Children(func() { + ui.Text(c, statisticFormat(s.Amount, from.Decimals)+" "+from.Code+" =").TextColor(k.TextMuted).FontFeatures("tnum") + 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) + if !opts.RateTime.IsZero() { + note += " · " + opts.RateTime.Format("2006-01-02 15:04") + } + ui.Text(c, note).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + }) + if v.Element.IsDisabled() { + v.PairChanged = false + } + if v.PairChanged { + slog.Debug("mujica: currency_converter pair", "from", s.From, "to", s.To) + } + return v +} + +// currencyFind returns the currency with code, panicking when absent. +func currencyFind(currencies []Currency, code string) Currency { + for _, cur := range currencies { + if cur.Decimals < 0 { + panic("mujica: Currency Decimals is negative") + } + if cur.Code == code { + return cur + } + } + panic("mujica: CurrencyConverter currency " + code + " is not offered") +} diff --git a/earnings_calendar.go b/earnings_calendar.go new file mode 100644 index 0000000..b1133bc --- /dev/null +++ b/earnings_calendar.go @@ -0,0 +1,121 @@ +package mujica + +import ( + "cmp" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// EarningsTiming is when a report comes. The zero value is EarningsUnknown. +type EarningsTiming int + +const ( + EarningsUnknown EarningsTiming = iota + EarningsBeforeOpen + EarningsAfterClose +) + +// EarningsEvent is a scheduled report; NaN actuals are not yet reported. +type EarningsEvent struct { + ID, Symbol, Name string + Date Date + Timing EarningsTiming + EPSEstimate float64 + EPSActual float64 +} + +// EarningsCalendarOptions configure an EarningsCalendar. +type EarningsCalendarOptions struct { + Version int + Status DataStatus + Error string + 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") + } + cols := []DataColumn[EarningsEvent]{ + {ID: "date", Title: msg(c, "earnings_calendar.date"), Width: 110, Sortable: true, + Compare: func(a, b EarningsEvent) int { return a.Date.Compare(b.Date) }, + Text: func(e EarningsEvent) string { return e.Date.String() }}, + {ID: "timing", Title: msg(c, "earnings_calendar.timing"), Width: 130, Sortable: true, + Compare: func(a, b EarningsEvent) int { return cmp.Compare(a.Timing, b.Timing) }, + Text: func(e EarningsEvent) string { return msg(c, earningsTimings[earningsTimingCheck(e.Timing)].key) }, + Cell: func(e EarningsEvent) { earningsTiming(c, e.Timing) }}, + {ID: "symbol", Title: msg(c, "earnings_calendar.symbol"), Width: 90, Sortable: true, + Compare: func(a, b EarningsEvent) int { return cmp.Compare(a.Symbol, b.Symbol) }, + Text: func(e EarningsEvent) string { return e.Symbol }}, + {ID: "name", Title: msg(c, "earnings_calendar.name"), Text: func(e EarningsEvent) string { return e.Name }}, + {ID: "estimate", Title: msg(c, "earnings_calendar.estimate"), Width: 90, Align: ui.End, Sortable: true, + Compare: func(a, b EarningsEvent) int { return cmp.Compare(a.EPSEstimate, b.EPSEstimate) }, + Text: func(e EarningsEvent) string { return earningsValue(e.EPSEstimate) }}, + {ID: "actual", Title: 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, + Text: func(e EarningsEvent) string { return earningsSurpriseText(e) }, + Cell: func(e EarningsEvent) { + if d := e.EPSActual - e.EPSEstimate; !math.IsNaN(d) { + tradingSigned(c, d, earningsSurpriseText(e)) + } else { + ui.Text(c, "—").TextColor(Tokens(c).TextMuted).FillWidth().TextAlign(ui.End) + } + }}, + } + var v DataTableView + col := ui.Column(c).MinHeight(0).Grow(1).Disabled(opts.Disabled) + 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")}) + v.Element.Grow(1) + }) + v.Element = col + return v +} + +var earningsTimings = [3]struct{ key, icon string }{ + {"earnings_calendar.unknown", "clock"}, {"earnings_calendar.before", "sun"}, {"earnings_calendar.after", "moon"}, +} + +func earningsTimingCheck(t EarningsTiming) EarningsTiming { + if t < EarningsUnknown || t > EarningsAfterClose { + panic("mujica: EarningsTiming out of range") + } + return t +} + +// earningsTiming shows the timing as an icon and words. +func earningsTiming(c *ui.Context, t EarningsTiming) { + k := Tokens(c) + tm := earningsTimings[earningsTimingCheck(t)] + ui.Row(c).Gap(4).AlignItems(ui.Center).Children(func() { + ui.Icon(c, icons.Must(tm.icon)).Size(14, 14).TextColor(k.TextMuted) + ui.Text(c, msg(c, tm.key)).FontSize(FontSize(c, theme.CaptionSize)).SingleLine() + }) +} + +func earningsValue(v float64) string { + if math.IsNaN(v) { + return "—" + } + 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) { + return "—" + } + if e.EPSEstimate == 0 { + return statisticSigned(d, 2) + } + return statisticSigned(d, 2) + " (" + statisticSigned(d/math.Abs(e.EPSEstimate)*100, 1) + "%)" +} diff --git a/economic_calendar.go b/economic_calendar.go new file mode 100644 index 0000000..c6643b1 --- /dev/null +++ b/economic_calendar.go @@ -0,0 +1,139 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "slices" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// EconomicEvent is a scheduled release; Actual stays empty until published. +type EconomicEvent struct { + ID, Country, Title string + Time time.Time + // Importance is 1 (low) to 3 (high). + Importance int + Forecast, Previous, Actual string +} + +// 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. + MinImportance int + Table DataTableState[string] + cache economicCache +} + +type economicCache struct { + key string + rows []EconomicEvent +} + +// EconomicCalendarOptions configure an EconomicCalendar. +type EconomicCalendarOptions struct { + Version int + Status DataStatus + Error string + 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") + } + if s.MinImportance < 0 || s.MinImportance > 3 { + panic("mujica: EconomicCalendar MinImportance must be 0..3") + } + u := Density(c).Unit() + var v DataTableView + col := ui.Column(c).Gap(u * 2).MinHeight(0).Grow(1).Disabled(opts.Disabled) + 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}) + } + } + slices.SortFunc(countries, func(a, b SelectOption[string]) int { return cmp.Compare(a.Value, b.Value) }) + 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) + imp := []RadioItem[int]{{Value: 0, Label: msg(c, "economic_calendar.all")}, {Value: 2, Label: msg(c, "economic_calendar.medium_up")}, + {Value: 3, Label: msg(c, "economic_calendar.high")}} + RadioGroup(c, &s.MinImportance, imp, RadioGroupOptions{Horizontal: true, Label: msg(c, "economic_calendar.importance"), Disabled: opts.Disabled}) + }) + rows := economicFilter(s, events, opts.Version) + cols := []DataColumn[EconomicEvent]{ + {ID: "time", Title: msg(c, "economic_calendar.time"), Width: 130, Sortable: true, + Compare: func(a, b EconomicEvent) int { return a.Time.Compare(b.Time) }, + Text: func(e EconomicEvent) string { return e.Time.Format("01-02 15:04") }}, + {ID: "country", Title: msg(c, "economic_calendar.country"), Width: 80, Sortable: true, + Compare: func(a, b EconomicEvent) int { return cmp.Compare(a.Country, b.Country) }, + Text: func(e EconomicEvent) string { return e.Country }}, + {ID: "importance", Title: msg(c, "economic_calendar.importance"), Width: 110, Sortable: true, + Compare: func(a, b EconomicEvent) int { return cmp.Compare(a.Importance, b.Importance) }, + Text: func(e EconomicEvent) string { return msg(c, fmt.Sprintf("economic_calendar.level_%d", e.Importance)) }, + Cell: func(e EconomicEvent) { economicImportance(c, e.Importance) }}, + {ID: "event", Title: msg(c, "economic_calendar.event"), Text: func(e EconomicEvent) string { return e.Title }}, + {ID: "actual", Title: msg(c, "economic_calendar.actual"), Width: 90, Align: ui.End, + Text: func(e EconomicEvent) string { return cmp.Or(e.Actual, "—") }, + Cell: func(e EconomicEvent) { + ui.Text(c, cmp.Or(e.Actual, "—")).FontWeight(700).FontFeatures("tnum").JustifySelf(ui.End).FillWidth().TextAlign(ui.End) + }}, + {ID: "forecast", Title: msg(c, "economic_calendar.forecast"), Width: 90, Align: ui.End, Text: func(e EconomicEvent) string { return cmp.Or(e.Forecast, "—") }}, + {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")}) + v.Element.Grow(1) + }) + v.Element = col + return v +} + +// economicImportance draws one to three diamonds and names the level. +func economicImportance(c *ui.Context, level int) { + k := Tokens(c) + word := msg(c, fmt.Sprintf("economic_calendar.level_%d", level)) + ui.Row(c).Gap(4).AlignItems(ui.Center).Label(word).Children(func() { + ui.Box(c).Size(34, 10).Draw(func(p *ui.Painter, r ui.Rect) { + for i := range 3 { + d := internal.Diamond(r.X+5+float32(i)*12, r.Y+5, 9) + if i < level { + p.FillPath(d, k.AccentText) + } else { + p.StrokePath(d, 1, k.ControlBorder) + } + } + }) + ui.Text(c, word).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + }) +} + +// 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 { + return s.cache.rows + } + s.cache.key, s.cache.rows = key, nil + for _, e := range events { + if e.Importance >= s.MinImportance && (len(s.Countries) == 0 || slices.Contains(s.Countries, e.Country)) { + s.cache.rows = append(s.cache.rows, e) + } + } + slog.Debug("mujica: economic_calendar filter", "shown", len(s.cache.rows)) + return s.cache.rows +} diff --git a/greeks_table.go b/greeks_table.go new file mode 100644 index 0000000..282385c --- /dev/null +++ b/greeks_table.go @@ -0,0 +1,85 @@ +package mujica + +import ( + "cmp" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// OptionGreeks are an option's sensitivities. +type OptionGreeks struct { + Delta, Gamma, Theta, Vega, Rho float64 +} + +// GreeksRow is a labeled contract's greeks; NaN shows a dash. +type GreeksRow struct { + Label string + Greeks OptionGreeks +} + +// GreeksTableOptions configure a GreeksTable. +type GreeksTableOptions struct { + // Decimals per value; zero means 4. + Decimals int + // Notes shows what each greek means under the table. + Notes bool +} + +var greekNames = [5]string{"Delta", "Gamma", "Theta", "Vega", "Rho"} + +// 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 { + panic("mujica: GreeksTable Decimals is negative") + } + dec := cmp.Or(opts.Decimals, 4) + k := Tokens(c) + d := Density(c) + 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). + 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)). + MinHeight(d.ControlHeight()).Role(ui.RoleColumnHeader) + for i, name := range greekNames { + h := ui.Row(c).Key(name).Gap(2).AlignItems(ui.Center).Justify(ui.End).MinHeight(d.ControlHeight()). + Role(ui.RoleColumnHeader).Label(name).Description(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.Icon(c, icons.Must("info")).Size(12, 12).TextColor(k.TextMuted) + }) + } + for ri, r := range rows { + vals := [5]float64{r.Greeks.Delta, r.Greeks.Gamma, r.Greeks.Theta, r.Greeks.Vega, r.Greeks.Rho} + ui.Text(c, r.Label).Key(ri).SingleLine().MinHeight(d.ControlHeight()).Role(ui.RoleCell) + for i, v := range vals { + s := "—" + if !math.IsNaN(v) { + s = statisticSigned(v, dec) + } + ui.Text(c, s).Key([2]int{ri, i}).JustifySelf(ui.End).FontFeatures("tnum").TextColor(k.Text). + Role(ui.RoleCell).Label(greekNames[i] + " " + s) + } + } + }) + if opts.Notes { + ui.Column(c).Gap(u).Children(func() { + for i, name := range greekNames { + ui.RichText(c, ui.Span{Text: name + " ", Weight: 700}, ui.Span{Text: msg(c, "greeks_table.note_"+greekKey(i))}). + FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + } + }) + } + }) + return col +} + +func greekKey(i int) string { return [5]string{"delta", "gamma", "theta", "vega", "rho"}[i] } diff --git a/index_card.go b/index_card.go new file mode 100644 index 0000000..b7c5773 --- /dev/null +++ b/index_card.go @@ -0,0 +1,68 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// IndexQuote is an index level, its change and recent values for the mini chart. +type IndexQuote struct { + Name string + Price float64 + Change float64 + ChangePercent float64 + // Series is recent levels; empty hides the chart. + Series []float64 +} + +// IndexCardOptions configure an IndexCard. +type IndexCardOptions struct { + // Decimals of the level and change; zero means 2. + Decimals int + // Clickable makes the card a button. + Clickable bool + Disabled bool +} + +// 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") + } + dec := priceDecimals("IndexCard", opts.Decimals) + k := Tokens(c) + u := Density(c).Unit() + var e *ui.Element + ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() { + if opts.Clickable { + e = ui.ButtonBase(c).Justify(ui.Start) + internal.Ring(e, k.Focus, theme.CardRadius) + } else { + e = ui.Row(c).Role(ui.RoleGroup) + } + }) + e.Column().AlignItems(ui.Stretch).Gap(u).Padding(u*3).Width(200).Radius(theme.CardRadius).Background(k.Surface). + Label(q.Name).Border(theme.BorderWidth, k.Border) + if opts.Clickable && e.Hovered() { + e.Border(theme.BorderWidth, k.ControlBorder).Background(k.SurfaceHover) + } + e.Children(func() { + ui.Text(c, q.Name).Font(theme.SerifFont).TextColor(k.TextMuted).SingleLine() + PriceText(c, q.Price, PriceTextOptions{Decimals: dec, Size: theme.H3Size}) + ui.Row(c).Children(func() { + PriceChangeBadge(c, q.Change, q.ChangePercent, PriceChangeBadgeOptions{Decimals: dec}) + }) + if len(q.Series) > 0 { + Sparkline(c, q.Series, SparklineOptions{Frame: ChartFrame{Label: q.Name}, Width: 172}) + } + }) + clicked := opts.Clickable && e.Clicked() && !e.IsDisabled() + if clicked { + slog.Debug("mujica: index_card click", "name", q.Name) + } + return ChoiceResult{Element: e, changed: clicked} +} diff --git a/market_heatmap.go b/market_heatmap.go new file mode 100644 index 0000000..8fb2944 --- /dev/null +++ b/market_heatmap.go @@ -0,0 +1,186 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "math" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// HeatNode is a sector or a stock: market cap sizes it, Change (percent) colors it. +type HeatNode struct { + Name string + Cap float64 + Change float64 + Children []HeatNode +} + +// MarketHeatmapOptions configure a MarketHeatmap. +type MarketHeatmapOptions struct { + Frame ChartFrame + // Scale is the change in percent at full color; zero means 3. + Scale float64 +} + +type marketHeatTile struct { + name string + cap, change float64 + 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 { + 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) + var res ChartResult + wrap := ui.Column(c).Gap(u * 2).MinWidth(0) + 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) + } + } + 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 + } + 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))) + } + ch := newChart(c, opts.Frame) + spec := chartSpec{n: len(tiles)} + spec.readout = func(i int) string { + t := tiles[i] + s := fmt.Sprintf("%s · %s %s%% · %s", t.name, trendWord(c, trendSign(t.change)), statisticSigned(t.change, 2), statisticFormat(t.cap, 0)) + if t.branch { + s += " · " + msg(c, "chart.opens") + } + return s + } + spec.pick = func(r ui.Rect, x, y float32) int { + for i, t := range squarify(caps, r) { + if t.Contains(x, y) { + return i + } + } + return -1 + } + spec.activate = func(i int) { + if tiles[i].branch { + *path = append(slices.Clone(*path), tiles[i].name) + res.value = true + slog.Debug("mujica: market_heatmap drill", "path", strings.Join(*path, "/")) + } + } + spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + for i, t := range squarify(caps, r) { + bg := color(tiles[i].change) + p.Fill(t, bg, 2) + p.Stroke(t, k.Surface, 2, 2) + ink := chartInk(k, bg) + if i == active { + p.Stroke(ui.Rect{X: t.X + 3, Y: t.Y + 3, W: t.W - 6, H: t.H - 6}, ink, 1, 2) + } + // Signed percents say the direction without color. + if t.W >= 40 && t.H >= 34 { + chartText(p, chartFit(p, tiles[i].name, t.W-12, size), t.X+t.W/2, t.Y+t.H/2-1, size, ink, 0.5, 1) + chartText(p, statisticSigned(tiles[i].change, 2)+"%", t.X+t.W/2, t.Y+t.H/2+1, size, ink, 0.5, 0) + } + } + } + r, plot := ch.build(spec) + if plot != nil && !plot.IsDisabled() && plot.Shortcut(0, ui.KeyBackspace) && len(*path) > 0 { + *path, res.value = (*path)[:len(*path)-1], true + } + res.Element, res.Point, res.changed, res.retry = r.Element, r.Point, r.changed, r.retry + marketHeatLegend(c, scale, color) + }) + res.Element = wrap + 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) + steps := []float64{-scale, -scale / 2, 0, scale / 2, scale} + ui.Row(c).Gap(2).Children(func() { + for _, s := range steps { + ui.Column(c).Gap(2).AlignItems(ui.Center).Children(func() { + ui.Box(c).Size(44, 10).Background(color(s)).Radius(1) + ui.Text(c, statisticSigned(s, 1)+"%").FontSize(size).TextColor(k.TextMuted).FontFeatures("tnum") + }) + } + }) +} + +func (n HeatNode) total() float64 { + if len(n.Children) == 0 { + return n.Cap + } + s := 0.0 + for _, ch := range n.Children { + s += ch.total() + } + 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 + } + sum, w := 0.0, 0.0 + for _, ch := range n.Children { + sum += ch.change() * ch.total() + w += ch.total() + } + if w == 0 { + return 0 + } + return sum / w +} + +// marketHeatCheck panics on unnamed, duplicate-sibling, negative or non-finite nodes. +func marketHeatCheck(n HeatNode) { + if n.Name == "" || n.Cap < 0 || math.IsNaN(n.Cap) || math.IsInf(n.Cap, 0) || math.IsNaN(n.Change) || math.IsInf(n.Change, 0) { + panic("mujica: heat nodes need a name, a finite cap ≥ 0 and a finite change") + } + seen := map[string]bool{} + for _, ch := range n.Children { + if seen[ch.Name] { + panic("mujica: heat node has two children named " + ch.Name) + } + seen[ch.Name] = true + marketHeatCheck(ch) + } +} + +func marketHeatDescend(root HeatNode, path []string) HeatNode { + n := root + for _, name := range path { + i := slices.IndexFunc(n.Children, func(ch HeatNode) bool { return ch.Name == name }) + if i < 0 { + panic("mujica: heatmap path has no node " + name) + } + n = n.Children[i] + } + return n +} diff --git a/market_overview.go b/market_overview.go new file mode 100644 index 0000000..1da1f6b --- /dev/null +++ b/market_overview.go @@ -0,0 +1,96 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// MarketBreadth counts the issues that rose, fell and stayed flat. +type MarketBreadth struct{ Advancing, Declining, Unchanged int } + +// MarketOverviewOptions configure a MarketOverview. +type MarketOverviewOptions struct { + Breadth MarketBreadth + // AsOf is when the data was taken; zero hides the stamp. + 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 { + panic("mujica: MarketOverview breadth counts must not be negative") + } + k := Tokens(c) + u := Density(c).Unit() + v := MarketOverviewView{Chosen: -1} + v.Element = ui.Column(c).Gap(u * 3).Disabled(opts.Disabled) + v.Element.Children(func() { + ui.Row(c).Wrap().Gap(u * 3).Children(func() { + for i, q := range indices { + ui.Column(c).Key(q.Name).Children(func() { + if IndexCard(c, q, IndexCardOptions{Decimals: opts.Decimals, Clickable: true, Disabled: opts.Disabled}).Changed() { + v.Chosen = i + } + }) + } + }) + if total := b.Advancing + b.Declining + b.Unchanged; total > 0 { + marketBreadthBar(c, b, total) + } + if !opts.AsOf.IsZero() { + ui.Text(c, fmt.Sprintf(msg(c, "market_overview.as_of"), opts.AsOf.Format("2006-01-02 15:04"))). + FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + } + }) + if v.Chosen >= 0 { + slog.Debug("mujica: market_overview choose", "index", indices[v.Chosen].Name) + } + 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() + up, down := trendColor(c, 1), trendColor(c, -1) + label := fmt.Sprintf(msg(c, "market_overview.breadth"), b.Advancing, b.Declining, b.Unchanged) + ui.Column(c).Gap(u).MaxWidth(640).FillWidth().Role(ui.RoleMeter).Label(label).Children(func() { + ui.Box(c).FillWidth().Height(10).Draw(func(p *ui.Painter, r ui.Rect) { + x := r.X + for _, part := range []struct { + n int + col ui.Color + }{{b.Advancing, up}, {b.Unchanged, k.Border}, {b.Declining, down}} { + w := r.W * float32(part.n) / float32(total) + p.Fill(ui.Rect{X: x, Y: r.Y, W: max(w-1, 0), H: r.H}, part.col, 1) + x += w + } + }) + ui.Row(c).Gap(u * 4).Children(func() { + for _, part := range []struct { + n int + sign int + key string + }{{b.Advancing, 1, "market_overview.up"}, {b.Unchanged, 0, "market_overview.flat"}, {b.Declining, -1, "market_overview.down"}} { + ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(trendColor(c, part.sign)).Children(func() { + ui.Icon(c, trendIcon(part.sign)).Size(12, 12) + ui.Text(c, fmt.Sprintf(msg(c, part.key), part.n)).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum") + }) + } + }) + }) +} diff --git a/market_status.go b/market_status.go new file mode 100644 index 0000000..77a1357 --- /dev/null +++ b/market_status.go @@ -0,0 +1,99 @@ +package mujica + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// MarketPhase is a market's trading state. The zero value is MarketClosed. +type MarketPhase int + +const ( + MarketClosed MarketPhase = iota + MarketPreMarket + MarketOpen + MarketAfterHours + MarketHoliday +) + +var marketPhases = [5]struct{ key, icon string }{ + {"market_status.closed", "lock"}, {"market_status.pre", "sun"}, {"market_status.open", "circle-check"}, + {"market_status.after", "moon"}, {"market_status.holiday", "calendar"}, +} + +// MarketStatusOptions configure a MarketStatus. +type MarketStatusOptions struct { + // Market names the venue, such as "NYSE". + Market string + // Next and NextPhase say when the phase changes; zero Next hides it. + 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) + k := Tokens(c) + u := Density(c).Unit() + now := opts.Now + if now.IsZero() { + now = c.Now() + } + word := msg(c, marketPhases[phase].key) + ink := k.TextMuted + if phase == MarketOpen { + ink = k.Success + } + label := word + if opts.Market != "" { + label = opts.Market + ": " + word + } + next := "" + if !opts.Next.IsZero() { + next = fmt.Sprintf(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() { + ui.Row(c).Gap(u).AlignItems(ui.Center).Padding(u/2, u*2).Radius(theme.ControlRadius).Border(theme.BorderWidth, ink).Children(func() { + ui.Icon(c, icons.Must(marketPhases[phase].icon)).Size(14, 14).TextColor(ink) + if opts.Market != "" { + ui.Text(c, opts.Market).Bold().FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Text).SingleLine() + } + ui.Text(c, word).FontSize(FontSize(c, theme.CaptionSize)).TextColor(ink).FontWeight(600).SingleLine() + }) + if next != "" { + ui.Text(c, next).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() + } + }) + return row +} + +func marketPhaseCheck(p MarketPhase) { + if p < MarketClosed || p > MarketHoliday { + panic("mujica: MarketPhase out of range") + } +} + +// 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") + } + m := int(d.Round(time.Minute) / time.Minute) + if m >= 60 { + return fmt.Sprintf(msg(c, "market_status.hm"), m/60, m%60) + } + return fmt.Sprintf(msg(c, "market_status.m"), max(m, 1)) +} diff --git a/messages_markets.go b/messages_markets.go new file mode 100644 index 0000000..baeb6bb --- /dev/null +++ b/messages_markets.go @@ -0,0 +1,142 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "option_chain.expiry": {"Expiry", "到期日"}, + "option_chain.spot": {"Spot %s", "现价 %s"}, + "option_chain.calls": {"Calls", "认购"}, + "option_chain.puts": {"Puts", "认沽"}, + "option_chain.strike": {"Strike", "行权价"}, + "option_chain.bid": {"Bid", "买价"}, + "option_chain.ask": {"Ask", "卖价"}, + "option_chain.last": {"Last", "最新"}, + "option_chain.empty": {"No strikes for this expiry", "该到期日没有合约"}, + "option_chain.call_name": {"Call %s: bid %s, ask %s, last %s", "认购 %s:买 %s,卖 %s,最新 %s"}, + "option_chain.put_name": {"Put %s: bid %s, ask %s, last %s", "认沽 %s:买 %s,卖 %s,最新 %s"}, + "option_chain.itm": {"in the money", "价内"}, + + "greeks_table.title": {"Greeks", "希腊值"}, + "greeks_table.contract": {"Contract", "合约"}, + "greeks_table.note_delta": {"Price change per 1 move in the underlying.", "标的每变动 1 时期权价格的变化。"}, + "greeks_table.note_gamma": {"Delta change per 1 move in the underlying.", "标的每变动 1 时 Delta 的变化。"}, + "greeks_table.note_theta": {"Price change per day as time passes.", "每过一天期权价格的变化。"}, + "greeks_table.note_vega": {"Price change per 1 point of volatility.", "波动率每变动 1 个百分点时价格的变化。"}, + "greeks_table.note_rho": {"Price change per 1 point of interest rate.", "利率每变动 1 个百分点时价格的变化。"}, + + "payoff_diagram.readout": {"Price %s · %s %s", "价格 %s · %s %s"}, + "payoff_diagram.spot": {"Spot", "现价"}, + "payoff_diagram.breakevens": {"Breakeven:", "盈亏平衡点:"}, + + "watchlist.groups": {"Watchlists", "自选分组"}, + "watchlist.columns": {"Columns", "列设置"}, + "watchlist.price": {"Price", "价格"}, + "watchlist.change": {"Change", "涨跌"}, + "watchlist.volume": {"Volume", "成交量"}, + "watchlist.empty": {"No symbols in this list", "该分组没有自选"}, + + "symbol_search.placeholder": {"Search code or name", "搜索代码或名称"}, + "symbol_search.market": {"Market", "市场"}, + "symbol_search.all_markets": {"All markets", "全部市场"}, + "symbol_search.recent": {"Recent", "最近搜索"}, + "symbol_search.searching": {"Searching", "正在搜索"}, + "symbol_search.no_results": {"No symbols match", "没有匹配的代码"}, + "symbol_search.retry": {"Retry", "重试"}, + + "market_overview.as_of": {"As of %s", "截至 %s"}, + "market_overview.breadth": {"%d advancing, %d declining, %d unchanged", "上涨 %d 家,下跌 %d 家,平盘 %d 家"}, + "market_overview.up": {"%d up", "上涨 %d"}, + "market_overview.flat": {"%d flat", "平盘 %d"}, + "market_overview.down": {"%d down", "下跌 %d"}, + + "market_status.closed": {"Closed", "已收盘"}, + "market_status.pre": {"Pre-market", "盘前"}, + "market_status.open": {"Open", "交易中"}, + "market_status.after": {"After hours", "盘后"}, + "market_status.holiday": {"Holiday", "休市"}, + "market_status.next": {"%s at %s (in %s)", "%[2]s %[1]s(%[3]s后)"}, + "market_status.now": {"now", "即将"}, + "market_status.hm": {"%dh %02dm", "%d小时%02d分"}, + "market_status.m": {"%dm", "%d分钟"}, + + "economic_calendar.countries": {"Countries", "国家或地区"}, + "economic_calendar.all_countries": {"All countries", "全部国家或地区"}, + "economic_calendar.importance": {"Importance", "重要性"}, + "economic_calendar.all": {"All", "全部"}, + "economic_calendar.medium_up": {"Medium and high", "中高"}, + "economic_calendar.high": {"High", "高"}, + "economic_calendar.level_1": {"Low", "低"}, + "economic_calendar.level_2": {"Medium", "中"}, + "economic_calendar.level_3": {"High", "高"}, + "economic_calendar.time": {"Time", "时间"}, + "economic_calendar.country": {"Country", "地区"}, + "economic_calendar.event": {"Event", "事件"}, + "economic_calendar.actual": {"Actual", "实际"}, + "economic_calendar.forecast": {"Forecast", "预期"}, + "economic_calendar.previous": {"Previous", "前值"}, + "economic_calendar.empty": {"No events", "没有事件"}, + + "earnings_calendar.date": {"Date", "日期"}, + "earnings_calendar.timing": {"Timing", "时段"}, + "earnings_calendar.symbol": {"Symbol", "代码"}, + "earnings_calendar.name": {"Company", "公司"}, + "earnings_calendar.estimate": {"EPS est.", "EPS 预期"}, + "earnings_calendar.actual": {"EPS actual", "EPS 实际"}, + "earnings_calendar.surprise": {"Surprise", "超预期"}, + "earnings_calendar.unknown": {"Time not set", "时间未定"}, + "earnings_calendar.before": {"Before open", "盘前"}, + "earnings_calendar.after": {"After close", "盘后"}, + "earnings_calendar.empty": {"No reports", "没有财报"}, + + "news_feed.unread": {"%d unread", "%d 条未读"}, + "news_feed.mark_all": {"Mark all read", "全部标为已读"}, + "news_feed.empty": {"No news", "暂无新闻"}, + + "sentiment_gauge.extreme_fear": {"Extreme fear", "极度恐惧"}, + "sentiment_gauge.fear": {"Fear", "恐惧"}, + "sentiment_gauge.neutral": {"Neutral", "中性"}, + "sentiment_gauge.greed": {"Greed", "贪婪"}, + "sentiment_gauge.extreme_greed": {"Extreme greed", "极度贪婪"}, + + "portfolio_summary.accounts": {"Accounts", "账户"}, + "portfolio_summary.all": {"All accounts", "全部账户"}, + "portfolio_summary.total": {"Total assets", "总资产"}, + "portfolio_summary.day": {"Today's P/L", "当日盈亏"}, + "portfolio_summary.return": {"Total return", "累计收益"}, + + "asset_allocation_chart.asset": {"Asset", "资产"}, + "asset_allocation_chart.weight": {"Weight", "占比"}, + "asset_allocation_chart.target": {"Target", "目标"}, + "asset_allocation_chart.drift": {"Drift", "偏离"}, + "asset_allocation_chart.over": {"Over", "超配"}, + "asset_allocation_chart.under": {"Under", "低配"}, + + "performance_chart.portfolio": {"Portfolio", "组合"}, + "performance_chart.benchmark": {"Benchmark", "基准"}, + "performance_chart.drawdown": {"Max drawdown %s", "最大回撤 %s"}, + "performance_chart.drawdown_range": {"Max drawdown %s, from %s to %s", "最大回撤 %s,%s 至 %s"}, + + "currency_converter.amount": {"Amount", "金额"}, + "currency_converter.from": {"From", "源币种"}, + "currency_converter.to": {"To", "目标币种"}, + "currency_converter.swap": {"Swap currencies", "互换币种"}, + "currency_converter.no_rate": {"Rate unavailable", "暂无汇率"}, + "currency_converter.rate": {"1 %s = %g %s", "1 %s = %g %s"}, + + "screener.conditions": {"Conditions", "筛选条件"}, + "screener.none": {"No conditions: every symbol passes.", "暂无条件:全部代码通过。"}, + "screener.field": {"Field", "字段"}, + "screener.compare": {"Comparison", "比较"}, + "screener.value": {"Value", "数值"}, + "screener.remove": {"Remove condition %d", "删除条件 %d"}, + "screener.add": {"Add condition", "添加条件"}, + "screener.save": {"Save conditions", "保存条件"}, + "screener.matches": {"%d of %d match", "%d / %d 符合"}, + "screener.symbol": {"Symbol", "代码"}, + "screener.empty": {"No symbols match", "没有符合的代码"}, + "screener.op_0": {"above", "大于"}, + "screener.op_1": {"at least", "大于等于"}, + "screener.op_2": {"below", "小于"}, + "screener.op_3": {"at most", "小于等于"}, + "screener.op_4": {"equal to", "等于"}, + }) +} diff --git a/news_feed.go b/news_feed.go new file mode 100644 index 0000000..118ffa4 --- /dev/null +++ b/news_feed.go @@ -0,0 +1,154 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "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 { + 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)) + if key != s.count.key { + s.count.key, s.count.unread = key, 0 + for _, it := range items { + if !s.Read[it.ID] { + s.count.unread++ + } + } + } + return s.count.unread +} + +// NewsFeedOptions configure a NewsFeed. +type NewsFeedOptions struct { + // Today decides the time stamps; zero uses the window clock. + Today Date + Status DataStatus + Error string + Disabled bool +} + +// 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 + // Symbol is the related code clicked; empty for none. + Symbol string + Retry 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()) + } + 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.Children(func() { + unread := newsFeedUnread(s, items) + 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 + } + slog.Debug("mujica: news_feed mark_all", "count", unread) + } + }) + if len(items) == 0 { + v.Retry = dataPlaceholder(c, opts.Status, opts.Error, "", msg(c, "news_feed.empty")) + return + } + cursor := &s.cursor + if opts.Disabled { + cp := s.cursor + cursor = &cp + } + s.rows.Key = func(i int) any { return items[i].ID } + s.rows.Label = func(i int) string { return items[i].Title } + s.rows.Selected = cursor + restore := dataSelectTheme(c, 0) + list := ui.List(c, &s.rows, len(items), func(i int) { + it := items[i] + read := s.Read[it.ID] + row := ui.Row(c).Padding(d.Unit()*2, d.Unit()*2).Gap(d.Unit() * 2).AlignItems(ui.Start).MinHeight(d.ControlHeight()) + if row.Clicked() && !row.IsDisabled() { + *cursor, v.Opened = i, it.ID + } + row.Children(func() { + ui.Box(c).Size(8, 18).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { + if !read { + p.FillPath(internal.Diamond(r.X+4, r.Y+9, 8), k.AccentText) + } + }) + ui.Column(c).Grow(1).MinWidth(0).Gap(2).Children(func() { + t := ui.Text(c, it.Title).MaxLines(2) + if !read { + t.FontWeight(700) + } else { + t.TextColor(k.TextMuted) + } + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Wrap().Children(func() { + ui.Text(c, it.Source+" · "+dateSeparatorWhen(c, it.Time, today)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + for _, sym := range it.Symbols { + ui.Row(c).Key(sym).Children(func() { + if SymbolBadge(c, MarketSymbol{Code: sym}, SymbolBadgeOptions{Clickable: true, Disabled: opts.Disabled}).Changed() { + v.Symbol = sym + } + }) + } + }) + }) + }) + }).Grow(1).MinHeight(0).Padding(d.Unit()).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius) + restore() + internal.Ring(list, k.Focus, theme.ControlRadius) + if !opts.Disabled && list.Submitted() && s.cursor >= 0 && s.cursor < len(items) { + v.Opened = items[s.cursor].ID + } + if v.Opened != "" && !opts.Disabled { + s.Read[v.Opened] = true + } + }) + if v.Element.IsDisabled() { + v.Opened, v.Symbol, v.Retry = "", "", false + } + if v.Opened != "" || v.Symbol != "" { + slog.Debug("mujica: news_feed open", "item", v.Opened, "symbol", v.Symbol) + } + return v +} diff --git a/option_chain.go b/option_chain.go new file mode 100644 index 0000000..09a883a --- /dev/null +++ b/option_chain.go @@ -0,0 +1,211 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// OptionQuote is one contract's market. +type OptionQuote struct { + Bid, Ask, Last float64 +} + +// OptionStrike is a strike with its call and put. +type OptionStrike struct { + Strike float64 + Call, Put OptionQuote +} + +// OptionSide is a call or a put. The zero value is OptionCall. +type OptionSide int + +const ( + OptionCall OptionSide = iota + OptionPut +) + +// 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 + rows ui.ListState + cursor int + center string +} + +// 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 +} + +// OptionChainView is a built OptionChain and this frame's events. +type OptionChainView struct { + Element *ui.Element + // ExpiryChanged asks the caller for the strikes of State.Expiry. + ExpiryChanged bool + // Changed reports a newly chosen contract; Submitted is Enter or a double click. + 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 !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.Children(func() { + ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() { + items := make([]SelectOption[string], len(expiries)) + for i, e := range expiries { + items[i] = SelectOption[string]{Value: e, Label: e} + } + v.ExpiryChanged = Select(c, &s.Expiry, items, SelectOptions{Label: msg(c, "option_chain.expiry"), Disabled: opts.Disabled}).Changed() + if opts.Spot > 0 { + ui.Text(c, fmt.Sprintf(msg(c, "option_chain.spot"), statisticFormat(opts.Spot, dec))).TextColor(k.TextMuted) + } + }) + optionChainHeader(c) + v.Changed, v.Submitted = optionChainRows(c, s, strikes, opts, dec) + }) + if v.Element.IsDisabled() { + v.ExpiryChanged, v.Changed, v.Submitted = false, false, false + } + if v.Changed { + slog.Debug("mujica: option_chain choose", "strike", s.Strike, "side", s.Side) + } + return v +} + +var optionChainCols = [3]string{"option_chain.bid", "option_chain.ask", "option_chain.last"} + +func optionChainHeader(c *ui.Context) { + t := Tokens(c) + d := Density(c) + size := FontSize(c, theme.CaptionSize) + ui.Row(c).Padding(0, d.Unit()*3).Children(func() { + for side, title := range []string{"option_chain.calls", "option_chain.puts"} { + if side == 1 { + ui.Text(c, msg(c, "option_chain.strike")).Width(90).TextAlign(ui.Center).FontSize(size).TextColor(t.TextMuted) + } + ui.Row(c).Grow(1).Basis(0).Children(func() { + for _, col := range optionChainCols { + ui.Text(c, msg(c, col)).Grow(1).Basis(0).TextAlign(ui.End).FontSize(size).TextColor(t.TextMuted) + } + }).Label(msg(c, title)) + } + }) +} + +func optionChainRows(c *ui.Context, s *OptionChainState, strikes []OptionStrike, opts OptionChainOptions, dec int) (changed, submitted bool) { + k := Tokens(c) + d := Density(c) + if len(strikes) == 0 { + ui.Text(c, msg(c, "option_chain.empty")).TextColor(k.TextMuted).Padding(d.Unit() * 3) + return false, false + } + s.cursor = -1 + if s.Chosen { + s.cursor = slices.IndexFunc(strikes, func(o OptionStrike) bool { return o.Strike == s.Strike }) + } + before, side := s.cursor, s.Side + cursor := &s.cursor + if opts.Disabled { + cp := s.cursor + cursor = &cp + } + atm, _ := slices.BinarySearchFunc(strikes, opts.Spot, func(o OptionStrike, v float64) int { return cmp.Compare(o.Strike, v) }) + if key := fmt.Sprint(s.Expiry, len(strikes)); key != s.center { + s.center = key + s.rows.ScrollTo(min(atm, len(strikes)-1), ui.Center) + } + s.rows.Key = func(i int) any { return strikes[i].Strike } + s.rows.Label = func(i int) string { return statisticFormat(strikes[i].Strike, dec) } + s.rows.Selected = cursor + restore := dataSelectTheme(c, 0) + list := ui.List(c, &s.rows, len(strikes), func(i int) { + o := strikes[i] + chosen := s.Chosen && i == s.cursor + ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).AlignItems(ui.Stretch).Children(func() { + for sd, q := range []OptionQuote{o.Call, o.Put} { + if sd == 1 { + optionChainStrike(c, o.Strike, dec, i == atm && opts.Spot > 0) + } + itm := opts.Spot > 0 && (sd == 0 && o.Strike < opts.Spot || sd == 1 && o.Strike > opts.Spot) + half := ui.Row(c).Grow(1).Basis(0).AlignItems(ui.Center).Radius(theme.ControlRadius). + Label(optionChainName(c, o.Strike, OptionSide(sd), q, dec, itm)) + if 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) + } + 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() + } + }) + } + }) + }).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 { + switch { + case list.Shortcut(0, ui.KeyLeft): + s.Side = OptionCall + case list.Shortcut(0, ui.KeyRight): + s.Side = OptionPut + } + if s.cursor >= 0 && (s.cursor != before || s.Side != side) { + s.Strike, s.Chosen, changed = strikes[s.cursor].Strike, true, true + } + submitted = list.Submitted() && 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.Text(c, statisticFormat(strike, dec)).FontWeight(700).FontFeatures("tnum") + }) +} + +func optionChainName(c *ui.Context, strike float64, side OptionSide, q OptionQuote, dec int, itm bool) string { + s := fmt.Sprintf(msg(c, []string{"option_chain.call_name", "option_chain.put_name"}[side]), statisticFormat(strike, dec), + statisticFormat(q.Bid, dec), statisticFormat(q.Ask, dec), statisticFormat(q.Last, dec)) + if itm { + s += ", " + msg(c, "option_chain.itm") + } + return s +} diff --git a/payoff_diagram.go b/payoff_diagram.go new file mode 100644 index 0000000..17f59c1 --- /dev/null +++ b/payoff_diagram.go @@ -0,0 +1,241 @@ +package mujica + +import ( + "fmt" + "math" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// OptionLegKind is what a leg holds. The zero value is LegCall. +type OptionLegKind int + +const ( + LegCall OptionLegKind = iota + LegPut + // LegStock is the underlying, bought or sold at Premium. + LegStock +) + +// OptionLeg is one position of a strategy; Quantity is negative for short. +type OptionLeg struct { + Kind OptionLegKind + Strike float64 + Premium float64 + 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 { + switch l.Kind { + case LegCall: + sum += l.Quantity * (max(price-l.Strike, 0) - l.Premium) + case LegPut: + sum += l.Quantity * (max(l.Strike-price, 0) - l.Premium) + case LegStock: + sum += l.Quantity * (price - l.Premium) + default: + panic("mujica: OptionLeg Kind out of range") + } + } + return sum +} + +// 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 { + if l.Kind != LegStock && l.Strike > lo && l.Strike < hi { + kinks = append(kinks, l.Strike) + } + } + slices.Sort(kinks) + var out []float64 + for i := 1; i < len(kinks); i++ { + a, b := kinks[i-1], kinks[i] + fa, fb := PayoffAt(legs, a), PayoffAt(legs, b) + if fa == 0 && (len(out) == 0 || out[len(out)-1] != a) { + out = append(out, a) + } + if fa*fb < 0 { + out = append(out, a+(b-a)*fa/(fa-fb)) + } + } + if PayoffAt(legs, hi) == 0 && (len(out) == 0 || out[len(out)-1] != hi) { + out = append(out, hi) + } + return out +} + +// PayoffDiagramOptions configure a PayoffDiagram. +type PayoffDiagramOptions struct { + Frame 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%. + Low, High float64 + // Points is how many prices the keys step through; zero means 101. + 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 + if n == 0 { + n = 101 + } + if n < 2 { + panic("mujica: PayoffDiagram needs at least 2 Points") + } + ch := newChart(c, opts.Frame) + k := Tokens(c) + size := FontSize(c, theme.CaptionSize) + type data struct { + px, pl, be []float64 + ylo, yhi float64 + } + d := chartMemo(ch, "payoff", fmt.Sprint(legs, lo, hi, n), func() data { + var v data + v.be = Breakevens(legs, lo, hi) + v.ylo, v.yhi = 0, 0 + for i := range n { + x := lo + (hi-lo)*float64(i)/float64(n-1) + v.px, v.pl = append(v.px, x), append(v.pl, PayoffAt(legs, x)) + v.ylo, v.yhi = min(v.ylo, v.pl[i]), max(v.yhi, v.pl[i]) + } + return v + }) + if len(legs) == 0 { + n = 0 + } + format := func(v float64) string { return chartValue(c, v, opts.Format) } + ylo, yhi, step := internal.Nice(d.ylo, d.yhi, 4) + ticks := internal.Ticks(ylo, yhi, step) + geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) { + plot := chartInset(r, 56, 26) + return plot, internal.Linear{D0: lo, D1: hi, R0: float64(plot.X), R1: float64(plot.X + plot.W)}, + internal.Linear{D0: ylo, D1: yhi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)} + } + up, down := 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])) + } + spec.pick = func(r ui.Rect, x, y float32) int { + plot, xs, _ := geom(r) + if !plot.Contains(x, y) { + return -1 + } + return max(0, min(n-1, int(math.Round((xs.Invert(x)-lo)/(hi-lo)*float64(n-1))))) + } + spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + plot, xs, ys := geom(r) + internal.AxisY(p, plot, ys, ticks, func(v float64) string { return internal.FormatTick(v, step) }, size, k) + lx, ll, lstep := internal.Nice(lo, hi, max(2, int(plot.W/90))) + var tx []float32 + var tl []string + for _, t := range internal.Ticks(lx, ll, lstep) { + if t >= lo && t <= hi { + tx, tl = append(tx, xs.Map(t)), append(tl, internal.FormatTick(t, lstep)) + } + } + internal.AxisX(p, plot, tx, tl, size, k) + zero := internal.Snap(p, ys.Map(0)) + pt := func(x, v float64) (float32, float32) { return internal.Snap(p, xs.Map(x)), internal.Snap(p, ys.Map(v)) } + // Exact corners: the grid plus strikes and breakevens. + 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) + col := up + if fa+fb < 0 { + col = down + } + var area ui.Path + ax, ay := pt(a, fa) + bx, by := pt(b, fb) + area.MoveTo(ax, zero).LineTo(ax, ay).LineTo(bx, by).LineTo(bx, zero).Close() + p.FillPath(&area, col.Alpha(0.16)) + } + p.Line(plot.X, zero, plot.X+plot.W, zero, 1, k.ControlBorder) + var line ui.Path + for i, x := range xsAll { + if i == 0 { + line.MoveTo(pt(x, PayoffAt(legs, x))) + } else { + line.LineTo(pt(x, PayoffAt(legs, x))) + } + } + p.StrokePath(&line, 2, k.AccentText) + for _, b := range d.be { + bx, _ := pt(b, 0) + p.FillPath(internal.Diamond(bx, zero, 9), k.Text) + chartText(p, format(b), bx, zero-6, size, k.Text, 0.5, 1) + } + if opts.Spot > lo && opts.Spot < hi { + sx, _ := pt(opts.Spot, 0) + for y := plot.Y; y < plot.Y+plot.H; y += 6 { + p.Line(sx, y, sx, min(y+3, plot.Y+plot.H), 1, k.Info) + } + chartText(p, msg(c, "payoff_diagram.spot")+" "+format(opts.Spot), sx+4, plot.Y, size, k.Info, 0, 0) + } + if active >= 0 { + x, y := pt(d.px[active], d.pl[active]) + p.Line(x, plot.Y, x, plot.Y+plot.H, 1, k.TextMuted) + internal.Marker(p, 0, x, y, 13, k.Surface) + internal.Marker(p, 0, x, y, 9, k.AccentText) + } + } + res, _ := ch.build(spec) + if len(d.be) > 0 && n > 0 { + parts := make([]string, len(d.be)) + for i, b := range d.be { + parts[i] = format(b) + } + res.Element.Children(func() { + ui.Text(c, msg(c, "payoff_diagram.breakevens")+" "+strings.Join(parts, ", ")).FontSize(size).TextColor(k.TextMuted) + }) + } + 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 { + panic("mujica: PayoffDiagram needs 0 ≤ Low < High") + } + return opts.Low, opts.High + } + lo, hi = math.Inf(1), math.Inf(-1) + for _, l := range legs { + ref := l.Strike + if l.Kind == LegStock { + ref = l.Premium + } + lo, hi = min(lo, ref), max(hi, ref) + } + if opts.Spot > 0 { + lo, hi = min(lo, opts.Spot), max(hi, opts.Spot) + } + if math.IsInf(lo, 1) || hi <= 0 { + return 0, 100 + } + return lo * 0.7, hi * 1.3 +} diff --git a/performance_chart.go b/performance_chart.go new file mode 100644 index 0000000..5ba745e --- /dev/null +++ b/performance_chart.go @@ -0,0 +1,177 @@ +package mujica + +import ( + "fmt" + "math" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// PerformanceChartOptions configure a PerformanceChart. +type PerformanceChartOptions struct { + Frame ChartFrame + // Times dates each value; required, ascending. + Times []time.Time + // Benchmark is compared against the portfolio; nil shows none. + Benchmark []float64 + 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 + for i, v := range values { + if v > values[peak] { + peak = i + } + if f := (v/values[peak] - 1) * 100; f < dd.Percent { + dd = Drawdown{Peak: peak, Trough: i, Percent: f} + } + } + return dd +} + +// 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") + } + ch := newChart(c, opts.Frame) + k := Tokens(c) + pal := internal.Palette(k) + size := FontSize(c, theme.CaptionSize) + name := opts.Name + if name == "" { + name = msg(c, "performance_chart.portfolio") + } + bname := opts.BenchmarkName + if bname == "" { + bname = msg(c, "performance_chart.benchmark") + } + series := chartMemo(ch, "returns", [4]string{chartID(values), chartID(opts.Benchmark), name, bname}, func() []ChartSeries { + out := []ChartSeries{{Name: name, Values: performanceReturns(values)}} + if opts.Benchmark != nil { + out = append(out, ChartSeries{Name: bname, Values: performanceReturns(opts.Benchmark)}) + } + 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) }) + lo, hi, ok := chartYRange(ch, series, true, nil) + if !ok { + n = 0 + } + keys := make([]chartKey, len(series)) + for i, s := range series { + keys[i] = chartKey{Name: s.Name, Color: pal[i], Shape: i} + } + var ticks []float64 + var step float64 + if n > 0 { + lo, hi, step = internal.Nice(lo, hi, 4) + ticks = internal.Ticks(lo, hi, step) + } + pct := func(v float64) string { return statisticSigned(v, 2) + "%" } + geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) { + plot := chartInset(r, 56, 26) + return plot, ax.scale(plot), internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)} + } + spec := chartSpec{n: n, keys: keys, toggle: true} + spec.pick = func(r ui.Rect, x, y float32) int { + plot, xs, _ := geom(r) + if !plot.Contains(x, y) { + return -1 + } + return ax.nearest(xs.Invert(x)) + } + spec.readout = func(i int) string { + parts := []string{opts.Times[i].Format("2006-01-02")} + for _, s := range series { + if !ch.hidden(s.Name) { + parts = append(parts, s.Name+" "+pct(s.Values[i])) + } + } + return strings.Join(parts, " · ") + } + spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + plot, xs, ys := geom(r) + x := func(i int) float32 { return internal.Snap(p, xs.Map(ax.vals[i])) } + if dd.Percent < 0 && !ch.hidden(name) { + band := ui.Rect{X: x(dd.Peak), Y: plot.Y, W: x(dd.Trough) - x(dd.Peak), H: plot.H} + p.Fill(band, trendColor(c, -1).Alpha(0.1), 0) + p.Line(band.X, plot.Y, band.X, plot.Y+plot.H, 1, trendColor(c, -1).Alpha(0.5)) + p.Line(band.X+band.W, plot.Y, band.X+band.W, plot.Y+plot.H, 1, trendColor(c, -1).Alpha(0.5)) + chartText(p, fmt.Sprintf(msg(c, "performance_chart.drawdown"), pct(dd.Percent)), band.X+4, plot.Y+2, size, chartInk(k, k.Surface), 0, 0) + } + internal.AxisY(p, plot, ys, ticks, func(v float64) string { return internal.FormatTick(v, step) + "%" }, size, k) + tx, tl := ax.ticks(plot) + internal.AxisX(p, plot, tx, tl, size, k) + zero := internal.Snap(p, ys.Map(0)) + p.Line(plot.X, zero, plot.X+plot.W, zero, 1, k.ControlBorder) + for si, s := range series { + if ch.hidden(s.Name) { + continue + } + var line ui.Path + idx := internal.Columns(n, func(i int) float32 { return xs.Map(ax.vals[i]) }, func(i int) float64 { return s.Values[i] }) + for j, i := range idx { + if j == 0 { + line.MoveTo(x(i), internal.Snap(p, ys.Map(s.Values[i]))) + } else { + line.LineTo(x(i), internal.Snap(p, ys.Map(s.Values[i]))) + } + } + p.StrokePath(&line, 2, keys[si].Color) + every := max(1, int(float32(n)/max(plot.W/48, 1))) + for i := 0; i < n; i += every { + internal.Marker(p, si, x(i), ys.Map(s.Values[i]), 7, keys[si].Color) + } + } + if active >= 0 { + p.Line(x(active), plot.Y, x(active), plot.Y+plot.H, 1, k.TextMuted) + for si, s := range series { + if !ch.hidden(s.Name) { + internal.Marker(p, si, x(active), ys.Map(s.Values[active]), 13, k.Surface) + internal.Marker(p, si, x(active), ys.Map(s.Values[active]), 9, keys[si].Color) + } + } + } + } + res, _ := ch.build(spec) + if dd.Percent < 0 && n > 0 { + res.Element.Children(func() { + ui.Text(c, fmt.Sprintf(msg(c, "performance_chart.drawdown_range"), pct(dd.Percent), + opts.Times[dd.Peak].Format("2006-01-02"), opts.Times[dd.Trough].Format("2006-01-02"))).FontSize(size).TextColor(k.TextMuted) + }) + } + return res +} + +// performanceReturns turns values into cumulative percent return from the first. +func performanceReturns(values []float64) []float64 { + out := make([]float64, len(values)) + for i, v := range values { + out[i] = (v/values[0] - 1) * 100 + } + return out +} diff --git a/portfolio_summary.go b/portfolio_summary.go new file mode 100644 index 0000000..26f97c0 --- /dev/null +++ b/portfolio_summary.go @@ -0,0 +1,130 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// PortfolioAccount is one account's value and profit. +type PortfolioAccount struct { + ID, Name string + Total float64 + // DayPnL is today's profit; TotalReturn the profit since inception. + DayPnL, TotalReturn float64 + // Cost is what went in; it turns TotalReturn into a percent. + Cost float64 +} + +// PortfolioSummaryOptions configure a PortfolioSummary. +type PortfolioSummaryOptions struct { + // Currency prefixes amounts; empty is "$". + Currency string + // Decimals of amounts; zero means 2. + Decimals int + Disabled bool +} + +// PortfolioSummaryView is a built summary; Changed reports another account chosen. +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 { + if account == nil { + panic("mujica: PortfolioSummary needs an account") + } + dec := priceDecimals("PortfolioSummary", opts.Decimals) + cur := cmp.Or(opts.Currency, "$") + k := Tokens(c) + u := Density(c).Unit() + var sum PortfolioAccount + found := *account == "" + for _, a := range accounts { + if a.ID == "" { + panic("mujica: PortfolioAccount needs an ID") + } + if *account == "" || a.ID == *account { + found = found || a.ID == *account + sum.Total += a.Total + sum.DayPnL += a.DayPnL + sum.TotalReturn += a.TotalReturn + sum.Cost += a.Cost + } + } + if !found { + panic("mujica: PortfolioSummary account " + *account + " is not listed") + } + before := *account + v := PortfolioSummaryView{} + v.Element = ui.Column(c).Gap(u*3).Padding(u*4).Background(k.Surface).Border(theme.BorderWidth, k.Border). + Radius(theme.CardRadius).Disabled(opts.Disabled) + v.Element.Children(func() { + if len(accounts) > 1 { + items := []RadioItem[string]{{Value: "", Label: msg(c, "portfolio_summary.all")}} + for _, a := range accounts { + items = append(items, RadioItem[string]{Value: a.ID, Label: a.Name}) + } + RadioGroup(c, account, items, RadioGroupOptions{Horizontal: true, Label: msg(c, "portfolio_summary.accounts"), Disabled: opts.Disabled}) + } + ui.Box(c).FillWidth().Height(9).Draw(func(p *ui.Painter, r ui.Rect) { internal.RuleDiamond(p, r, 6, 5, k.Ornament) }) + ui.Row(c).Wrap().Gap(u * 8).Children(func() { + portfolioFigure(c, msg(c, "portfolio_summary.total"), func() { + ui.Text(c, cur+statisticFormat(sum.Total, dec)).Font(theme.SerifFont).FontSize(FontSize(c, theme.H1Size)).FontFeatures("tnum") + }) + prev := sum.Total - sum.DayPnL + portfolioFigure(c, msg(c, "portfolio_summary.day"), func() { + portfolioSigned(c, sum.DayPnL, cur, dec, portfolioPercent(sum.DayPnL, prev)) + }) + portfolioFigure(c, msg(c, "portfolio_summary.return"), func() { + portfolioSigned(c, sum.TotalReturn, cur, dec, portfolioPercent(sum.TotalReturn, sum.Cost)) + }) + }) + }) + v.Changed = *account != before && !v.Element.IsDisabled() + if v.Changed { + slog.Debug("mujica: portfolio_summary account", "id", *account) + } + return v +} + +func portfolioFigure(c *ui.Context, title string, value func()) { + ui.Column(c).Gap(2).Role(ui.RoleGroup).Label(title).Children(func() { + ui.Text(c, title).FontSize(FontSize(c, theme.CaptionSize)).TextColor(Tokens(c).TextMuted) + value() + }) +} + +// 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) + if sign > 0 { + text = "+" + text + } else if sign < 0 { + text = "−" + text + } + ui.Row(c).Gap(4).AlignItems(ui.Center).TextColor(trendColor(c, sign)).Label(trendWord(c, sign) + " " + text).Children(func() { + ui.Icon(c, trendIcon(sign)).Size(16, 16) + ui.Text(c, text).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).FontFeatures("tnum") + if !math.IsNaN(pct) { + ui.Text(c, fmt.Sprintf("(%s%%)", statisticSigned(pct, 2))).FontFeatures("tnum") + } + }) +} + +// portfolioPercent is part of base in percent, NaN when base is zero. +func portfolioPercent(part, base float64) float64 { + if base == 0 { + return math.NaN() + } + return part / base * 100 +} diff --git a/screener.go b/screener.go new file mode 100644 index 0000000..84db81d --- /dev/null +++ b/screener.go @@ -0,0 +1,192 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "math" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "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 ( + ScreenerAbove ScreenerOp = iota + ScreenerAtLeast + ScreenerBelow + ScreenerAtMost + ScreenerEqual +) + +var screenerOps = [5]string{">", "≥", "<", "≤", "="} + +// ScreenerCondition is one test: Field Op Value. +type ScreenerCondition struct { + Field string + Op ScreenerOp + Value float64 +} + +// 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 +} + +// ScreenerMatch reports whether row passes every condition. +func ScreenerMatch(row ScreenerRow, conds []ScreenerCondition) bool { + for _, cd := range conds { + v, ok := row.Values[cd.Field] + if !ok || math.IsNaN(v) { + return false + } + pass := [5]bool{v > cd.Value, v >= cd.Value, v < cd.Value, v <= cd.Value, v == cd.Value} + if cd.Op < ScreenerAbove || cd.Op > ScreenerEqual { + panic("mujica: ScreenerOp out of range") + } + if !pass[cd.Op] { + return false + } + } + return true +} + +// ScreenerState is the conditions and the result table's state. +type ScreenerState struct { + Conditions []ScreenerCondition + Table DataTableState[string] + cache screenerCache +} + +type screenerCache struct { + key string + rows []ScreenerRow +} + +// ScreenerOptions configure a Screener. +type ScreenerOptions struct { + // Version must change when rows change in place. + Version int + Status DataStatus + Error string + Disabled bool +} + +// 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. + 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") + } + 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.Children(func() { + fieldItems := make([]SelectOption[string], len(fields)) + for i, f := range fields { + fieldItems[i] = SelectOption[string]{Value: f.ID, Label: f.Label} + } + opItems := make([]SelectOption[ScreenerOp], len(screenerOps)) + for i, o := range screenerOps { + opItems[i] = SelectOption[ScreenerOp]{Value: ScreenerOp(i), Label: o + " " + msg(c, fmt.Sprintf("screener.op_%d", i))} + } + ui.Column(c).Gap(u*2).Padding(u*3).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius). + Role(ui.RoleGroup).Label(msg(c, "screener.conditions")).Children(func() { + if len(s.Conditions) == 0 { + ui.Text(c, msg(c, "screener.none")).TextColor(k.TextMuted) + } + for i := 0; i < len(s.Conditions); i++ { + cd := &s.Conditions[i] + f := screenerField(fields, cd.Field) + removed := false + ui.Row(c).Key(i).Gap(u * 2).AlignItems(ui.Center).Children(func() { + Select(c, &cd.Field, fieldItems, SelectOptions{Label: msg(c, "screener.field"), Disabled: opts.Disabled}) + Select(c, &cd.Op, opItems, SelectOptions{Label: msg(c, "screener.compare"), Disabled: opts.Disabled}).Element.MinWidth(120) + NumberInput(c, &cd.Value, NumberInputOptions{Step: math.Pow(10, -float64(f.Decimals)), Precision: f.Decimals, + Label: msg(c, "screener.value"), Disabled: opts.Disabled}).Element.Width(150) + x := iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "screener.remove"), i+1)) + removed = x.Clicked() && !x.IsDisabled() + }) + if removed { + s.Conditions = slices.Delete(slices.Clone(s.Conditions), i, i+1) + i-- + } + } + ui.Row(c).Gap(u * 2).Children(func() { + if Button(c, msg(c, "screener.add"), ButtonOptions{Variant: Secondary, Icon: icons.Must("plus"), Disabled: opts.Disabled}).Clicked() { + s.Conditions = append(s.Conditions, ScreenerCondition{Field: fields[0].ID}) + } + v.Save = Button(c, msg(c, "screener.save"), ButtonOptions{Variant: Ghost, Disabled: opts.Disabled}).Clicked() + }) + }) + matched := screenerFilter(s, rows, opts.Version) + ui.Text(c, fmt.Sprintf(msg(c, "screener.matches"), len(matched), len(rows))).FontSize(FontSize(c, theme.CaptionSize)). + TextColor(k.TextMuted).Role(ui.RoleStatus) + cols := []DataColumn[ScreenerRow]{{ID: "symbol", Title: msg(c, "screener.symbol"), Sortable: true, + Compare: func(a, b ScreenerRow) int { return cmp.Compare(a.Symbol, b.Symbol) }, + Text: func(r ScreenerRow) string { return r.Symbol }}} + for _, f := range fields { + cols = append(cols, DataColumn[ScreenerRow]{ID: f.ID, Title: f.Label, Align: ui.End, Sortable: true, + Compare: func(a, b ScreenerRow) int { return cmp.Compare(a.Values[f.ID], b.Values[f.ID]) }, + Text: func(r ScreenerRow) string { return 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")}) + v.Table.Element.Grow(1) + }) + v.Changed = fmt.Sprint(s.Conditions) != before + if v.Element.IsDisabled() { + v.Save = false + } + if v.Changed || v.Save { + slog.Debug("mujica: screener conditions", "count", len(s.Conditions), "save", v.Save) + } + return v +} + +func screenerField(fields []ScreenerField, id string) ScreenerField { + for _, f := range fields { + if f.Decimals < 0 { + panic("mujica: ScreenerField Decimals is negative") + } + if f.ID == id { + return f + } + } + panic("mujica: Screener condition on unknown field " + id) +} + +// 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 { + s.cache.key, s.cache.rows = key, nil + for _, r := range rows { + if ScreenerMatch(r, s.Conditions) { + s.cache.rows = append(s.cache.rows, r) + } + } + } + return s.cache.rows +} diff --git a/sentiment_gauge.go b/sentiment_gauge.go new file mode 100644 index 0000000..94b34d1 --- /dev/null +++ b/sentiment_gauge.go @@ -0,0 +1,76 @@ +package mujica + +import ( + "fmt" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// SentimentGaugeOptions configure a SentimentGauge. +type SentimentGaugeOptions struct { + // Label names the index, such as "Fear & Greed". + Label string + // Size is the dial width; zero is 240 DIPs. + Size float32 +} + +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 { + panic("mujica: SentimentGauge Size is negative") + } + w := opts.Size + if w == 0 { + w = 240 + } + k := Tokens(c) + u := Density(c).Unit() + band := sentimentBand(value) + word := msg(c, sentimentBands[band]) + label := fmt.Sprintf("%.0f, %s", value, word) + 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} + size := FontSize(c, theme.CaptionSize) + col := ui.Column(c).Gap(u).AlignItems(ui.Center).Role(ui.RoleMeter).Label(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) { + cx, cy := r.X+r.W/2, r.Y+r.W/2 + outer, inner := r.W/2-2, r.W/2-18 + for i := range 5 { + a0 := -math.Pi/2 + float64(i)*math.Pi/5 + p.FillPath(internal.Sector(cx, cy, inner, outer, a0+0.012, a0+math.Pi/5-0.012), cols[i]) + } + a := -math.Pi/2 + value/100*math.Pi + tx, ty := internal.Polar(cx, cy, inner-6, a) + var needle ui.Path + needle.MoveTo(internal.Snap(p, cx), internal.Snap(p, cy)).LineTo(internal.Snap(p, tx), internal.Snap(p, ty)) + p.StrokePath(&needle, 2.5, k.Text) + p.FillPath(internal.Diamond(internal.Snap(p, cx), internal.Snap(p, cy), 10), k.Text) + chartText(p, "0", r.X+2, cy+4, size, k.TextMuted, 0, 0) + 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)) + }) + return col +} diff --git a/symbol_badge.go b/symbol_badge.go new file mode 100644 index 0000000..4aaa8c0 --- /dev/null +++ b/symbol_badge.go @@ -0,0 +1,78 @@ +package mujica + +import ( + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// MarketSymbol is a tradable instrument as the caller lists it. +type MarketSymbol struct { + Code, Name string + // Market is the venue, such as NASDAQ or SSE. + Market string + // Type is the asset kind, such as Stock, ETF or Option. + Type string +} + +// SymbolBadgeOptions configure a SymbolBadge. +type SymbolBadgeOptions struct { + // Clickable makes the badge a button reporting clicks. + Clickable bool + Disabled bool +} + +// SymbolBadge shows a code with its market and type tags. +func SymbolBadge(c *ui.Context, s MarketSymbol, opts SymbolBadgeOptions) ChoiceResult { + if s.Code == "" { + panic("mujica: SymbolBadge needs a Code") + } + k := Tokens(c) + u := Density(c).Unit() + var e *ui.Element + wrap := ui.Row(c).Shrink(0).Disabled(opts.Disabled) + wrap.Children(func() { + if opts.Clickable { + e = ui.ButtonBase(c) + internal.Ring(e, k.Focus, theme.ControlRadius) + if e.Hovered() { + e.Background(k.SurfaceHover) + } + e.Transition(colorFade) + } else { + e = ui.Row(c).Role(ui.RoleText) + } + }) + e.MinHeight(theme.MinHitSize).Gap(u).AlignItems(ui.Center).Padding(0, u).Radius(theme.ControlRadius).Shrink(0). + Label(symbolBadgeName(s)) + e.Children(func() { + ui.Text(c, s.Code).Font(theme.MonoFont).FontWeight(700).TextColor(k.Text).SingleLine() + for _, tag := range []string{s.Market, s.Type} { + if tag != "" { + ui.Row(c).Padding(1, u).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.Border).Background(k.Background).Children(func() { + ui.Text(c, tag).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() + }) + } + } + }) + clicked := opts.Clickable && e.Clicked() && !e.IsDisabled() + if clicked { + slog.Debug("mujica: symbol_badge click", "code", s.Code) + } + return ChoiceResult{Element: e, changed: clicked} +} + +// symbolBadgeName reads code, name, market and type. +func symbolBadgeName(s MarketSymbol) string { + var parts []string + for _, p := range []string{s.Code, s.Name, s.Market, s.Type} { + if p != "" { + parts = append(parts, p) + } + } + return strings.Join(parts, ", ") +} diff --git a/symbol_search.go b/symbol_search.go new file mode 100644 index 0000000..b65c69e --- /dev/null +++ b/symbol_search.go @@ -0,0 +1,130 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// SymbolSearchState is the query and the market filter, empty for all markets. +type SymbolSearchState struct { + Query, Market string + focusFirst bool +} + +// SymbolSearchOptions configure a SymbolSearch. +type SymbolSearchOptions struct { + // Markets are the filter choices; nil hides the filter. + Markets []string + // Recent shows while the query is empty. + Recent []MarketSymbol + // Loading and Error are the caller's search for the query. + Loading bool + Error string + Disabled bool +} + +// SymbolSearchView is a built SymbolSearch and this frame's events. +type SymbolSearchView struct { + Element *ui.Element + // Changed 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 + Picked bool + 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") + } + k := Tokens(c) + u := Density(c).Unit() + var v SymbolSearchView + v.Element = ui.Column(c).Gap(u * 2).Disabled(opts.Disabled) + 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}) + in.Element.Grow(1) + v.Changed = in.Changed() + if in.Input.Shortcut(0, ui.KeyDown) && !in.Input.IsDisabled() { + s.focusFirst = true + } + if opts.Markets != nil { + items := []SelectOption[string]{{Value: "", Label: msg(c, "symbol_search.all_markets")}} + for _, m := range opts.Markets { + items = append(items, SelectOption[string]{Value: m, Label: m}) + } + r := Select(c, &s.Market, items, SelectOptions{Label: msg(c, "symbol_search.market"), Disabled: opts.Disabled}) + r.Element.MinWidth(140) + v.Changed = v.Changed || r.Changed() + } + }) + list, title := results, "" + switch { + case opts.Error != "": + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).TextColor(k.Danger).Children(func() { + ui.Icon(c, icons.Must("circle-alert")).Size(16, 16) + ui.Text(c, opts.Error).Role(ui.RoleStatus).Grow(1) + v.Retry = 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 len(results) == 0: + ui.Text(c, msg(c, "symbol_search.no_results")).TextColor(k.TextMuted).Role(ui.RoleStatus) + return + } + if title != "" && len(list) > 0 { + ui.Text(c, title).Font(theme.SerifFont).FontSize(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 + } + } + }) + }) + s.focusFirst = false + }) + if v.Element.IsDisabled() { + v.Changed, v.Picked, v.Retry = false, false, false + } + if v.Picked { + slog.Debug("mujica: symbol_search pick", "code", v.Chosen.Code) + } + return v +} + +func symbolSearchRow(c *ui.Context, sym MarketSymbol, focus 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 sym.Market != "" { + ui.Text(c, sym.Market).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + } + }) + return b.Clicked() && !b.IsDisabled() +} diff --git a/trading_session_clock.go b/trading_session_clock.go new file mode 100644 index 0000000..bc56e6f --- /dev/null +++ b/trading_session_clock.go @@ -0,0 +1,112 @@ +package mujica + +import ( + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// TradingSession is a phase from Start to End, in minutes after the market's local midnight. +type TradingSession struct { + Phase MarketPhase + Start, End int +} + +// SessionMarket is a market, its time zone from the caller, and its daily sessions. +type SessionMarket struct { + Name string + Location *time.Location + Sessions []TradingSession + // Holiday closes the market all day. + Holiday bool +} + +// TradingSessionClockOptions configure a TradingSessionClock. +type TradingSessionClockOptions struct { + // Now is the instant shown; zero uses the window clock. + Now time.Time +} + +// TradingSessionClock shows each market's local time, phase, next change and a day bar of its sessions. +func TradingSessionClock(c *ui.Context, markets []SessionMarket, opts TradingSessionClockOptions) *ui.Element { + now := opts.Now + if now.IsZero() { + now = c.Now() + } + k := Tokens(c) + u := Density(c).Unit() + size := FontSize(c, theme.CaptionSize) + g := ui.Grid(c).ColumnTracks(ui.FitContent(), ui.FitContent(), ui.Fr(1)).GapX(u * 4).GapY(u * 3).AlignItems(ui.Center) + g.Children(func() { + for _, m := range markets { + tradingSessionCheck(m) + local := now.In(m.Location) + phase, next, nextPhase := tradingSessionPhase(m, local) + ui.Column(c).Key(m.Name).Gap(2).Children(func() { + ui.Text(c, m.Name).Font(theme.SerifFont).FontWeight(600) + ui.Text(c, local.Format("15:04 MST")).FontSize(size).TextColor(k.TextMuted).FontFeatures("tnum") + }) + MarketStatus(c, phase, MarketStatusOptions{Next: next, NextPhase: nextPhase, Now: now}) + minute := float32(local.Hour()*60 + local.Minute()) + ui.Box(c).MinWidth(160).Height(22).Draw(func(p *ui.Painter, r ui.Rect) { + bar := ui.Rect{X: r.X, Y: r.Y + 4, W: r.W, H: 10} + p.Fill(bar, k.Background, 2) + p.Stroke(bar, k.Border, 2, 1) + for _, s := range m.Sessions { + col := k.Info.Alpha(0.45) + if s.Phase == MarketOpen { + col = k.Success + } + x0, x1 := bar.X+bar.W*float32(s.Start)/1440, bar.X+bar.W*float32(s.End)/1440 + p.Fill(ui.Rect{X: x0, Y: bar.Y + 1, W: x1 - x0, H: bar.H - 2}, col, 1) + } + x := internal.Snap(p, bar.X+bar.W*minute/1440) + p.FillPath(internal.Diamond(x, bar.Y+bar.H+3, 8), k.Text) + p.Line(x, bar.Y-2, x, bar.Y+bar.H, 1.5, k.Text) + }).Role(ui.RoleImage).Label(m.Name + " " + msg(c, marketPhases[phase].key)) + } + }) + // Times and phases move on the minute. + c.After(time.Minute - time.Duration(now.Second())*time.Second) + return g +} + +func tradingSessionCheck(m SessionMarket) { + if m.Name == "" || m.Location == nil { + panic("mujica: SessionMarket needs a Name and a Location") + } + for i, s := range m.Sessions { + if s.Start < 0 || s.End > 1440 || s.Start >= s.End || i > 0 && s.Start < m.Sessions[i-1].End { + panic("mujica: TradingSession spans must be ordered within 0..1440 minutes") + } + if s.Phase != MarketPreMarket && s.Phase != MarketOpen && s.Phase != MarketAfterHours { + panic("mujica: TradingSession Phase must be pre-market, open or after-hours") + } + } +} + +// tradingSessionPhase is the phase at local, the next change, and the phase it brings. +func tradingSessionPhase(m SessionMarket, local time.Time) (MarketPhase, time.Time, MarketPhase) { + if m.Holiday || len(m.Sessions) == 0 { + return MarketHoliday, time.Time{}, MarketClosed + } + mid := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, m.Location) + at := func(min int) time.Time { return mid.Add(time.Duration(min) * time.Minute) } + cur := local.Hour()*60 + local.Minute() + for i, s := range m.Sessions { + switch { + case cur < s.Start: + return MarketClosed, at(s.Start), s.Phase + case cur < s.End: + next := MarketClosed + if i+1 < len(m.Sessions) && m.Sessions[i+1].Start == s.End { + next = m.Sessions[i+1].Phase + } + return s.Phase, at(s.End), next + } + } + return MarketClosed, at(1440 + m.Sessions[0].Start), m.Sessions[0].Phase +} diff --git a/watchlist.go b/watchlist.go new file mode 100644 index 0000000..1629eb5 --- /dev/null +++ b/watchlist.go @@ -0,0 +1,162 @@ +package mujica + +import ( + "log/slog" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// WatchItem is a watched symbol with its latest quote. +type WatchItem struct { + Symbol, Name string + Price float64 + Change float64 + ChangePercent float64 + Volume float64 +} + +// WatchGroup is a named list of watched symbols. +type WatchGroup struct { + Name string + Items []WatchItem +} + +// WatchColumn is an optional Watchlist column. +type WatchColumn int + +const ( + WatchPrice WatchColumn = iota + WatchChange + WatchVolume +) + +var watchColumns = [3]string{"watchlist.price", "watchlist.change", "watchlist.volume"} + +// WatchlistState is the open group, the chosen symbol and the hidden columns. +type WatchlistState struct { + Group int + Selected string + Hidden map[WatchColumn]bool + rows ui.ListState + cursor int +} + +// WatchlistOptions configure a Watchlist. +type WatchlistOptions struct { + // Decimals of prices; zero means 2. + Decimals int + Disabled bool +} + +// WatchlistView is a built Watchlist and this frame's events. +type WatchlistView struct { + Element *ui.Element + // Changed reports a new State.Selected; Submitted is Enter or a double click. + Changed, Submitted bool + // Moved asks the caller to move rows Rows of the open group before index To. + Moved bool + Rows []int + To int +} + +// Watchlist shows grouped watched symbols with chosen columns; rows reorder by dragging and keep the choice through updates. +func Watchlist(c *ui.Context, s *WatchlistState, groups []WatchGroup, opts WatchlistOptions) WatchlistView { + if s == nil || len(groups) == 0 { + panic("mujica: Watchlist needs a state and a group") + } + dec := priceDecimals("Watchlist", opts.Decimals) + if s.Hidden == nil { + s.Hidden = map[WatchColumn]bool{} + } + s.Group = max(0, min(s.Group, len(groups)-1)) + d := Density(c) + var v WatchlistView + v.Element = ui.Column(c).Gap(d.Unit() * 2).MinHeight(0).Disabled(opts.Disabled) + v.Element.Children(func() { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + tabs := make([]Tab, len(groups)) + for i, g := range groups { + tabs[i] = Tab{Label: g.Name} + } + ui.Column(c).Grow(1).MinWidth(0).Children(func() { + Tabs(c, &s.Group, tabs, TabsOptions{Label: msg(c, "watchlist.groups")}) + }) + DropdownMenu(c, "", DropdownMenuOptions{Icon: icons.Must("settings"), Label: msg(c, "watchlist.columns"), Disabled: opts.Disabled}, func(m *PopupMenu) { + for col, key := range watchColumns { + on := !s.Hidden[WatchColumn(col)] + if m.Check(msg(c, key), &on, PopupMenuItemOptions{KeepOpen: true}) { + s.Hidden[WatchColumn(col)] = !on + } + } + }) + }) + items := groups[s.Group].Items + v.Changed, v.Submitted = watchlistRows(c, s, items, dec, &v, opts.Disabled) + }) + if v.Element.IsDisabled() { + v.Changed, v.Submitted, v.Moved = false, false, false + } + switch { + case v.Moved: + slog.Debug("mujica: watchlist move", "rows", v.Rows, "to", v.To) + case v.Changed: + slog.Debug("mujica: watchlist select", "symbol", s.Selected) + } + return v +} + +func watchlistRows(c *ui.Context, s *WatchlistState, items []WatchItem, dec int, v *WatchlistView, disabled bool) (changed, submitted bool) { + k := Tokens(c) + d := Density(c) + s.cursor = slices.IndexFunc(items, func(it WatchItem) bool { return it.Symbol == s.Selected }) + before := s.cursor + cursor := &s.cursor + if disabled { + cp := s.cursor + cursor = &cp + } + s.rows.Key = func(i int) any { return items[i].Symbol } + s.rows.Label = func(i int) string { return items[i].Symbol } + s.rows.Selected = cursor + s.rows.Reorder = func(rows []int, to int) { v.Moved, v.Rows, v.To = true, slices.Clone(rows), to } + if len(items) == 0 { + ui.Text(c, msg(c, "watchlist.empty")).TextColor(k.TextMuted).Padding(d.Unit() * 3) + return false, false + } + restore := dataSelectTheme(c, 0) + list := ui.List(c, &s.rows, len(items), func(i int) { + it := items[i] + ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() { + ui.Icon(c, icons.Must("grip-vertical")).Size(14, 14).TextColor(k.TextMuted) + ui.Column(c).Grow(1).MinWidth(80).Children(func() { + ui.Text(c, it.Symbol).Font(theme.MonoFont).FontWeight(700).SingleLine() + if it.Name != "" { + ui.Text(c, it.Name).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() + } + }) + if !s.Hidden[WatchPrice] { + ui.Row(c).Width(96).Justify(ui.End).Children(func() { PriceText(c, it.Price, PriceTextOptions{Decimals: dec}) }) + } + if !s.Hidden[WatchChange] { + ui.Row(c).Width(150).Justify(ui.End).Children(func() { + PriceChangeBadge(c, it.Change, it.ChangePercent, PriceChangeBadgeOptions{Decimals: dec}) + }) + } + if !s.Hidden[WatchVolume] { + ui.Text(c, statisticFormat(it.Volume, 0)).Width(90).TextAlign(ui.End).FontFeatures("tnum").TextColor(k.TextMuted) + } + if items[i].Symbol == s.Selected { + ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(k.AccentText) + } + }) + }).Grow(1).MinHeight(0).Padding(d.Unit()).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius) + restore() + if !disabled && s.cursor != before && s.cursor >= 0 { + s.Selected, changed = items[s.cursor].Symbol, true + } + return changed, list.Submitted() && !disabled +}