wip markets

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:54:26 -04:00
1 parent fbd584b6aa
commit b2fafd99ce
21 files changed
+2836

No files matched your search

+113
View File
@@ -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")
})
}
+124
View File
@@ -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")
}
+121
View File
@@ -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) + "%)"
}
+139
View File
@@ -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
}
+85
View File
@@ -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] }
+68
View File
@@ -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}
}
+186
View File
@@ -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
}
+96
View File
@@ -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")
})
}
})
})
}
+99
View File
@@ -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))
}
+142
View File
@@ -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", "等于"},
})
}
+154
View File
@@ -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
}
+211
View File
@@ -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
}
+241
View File
@@ -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
}
+177
View File
@@ -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
}
+130
View File
@@ -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
}
+192
View File
@@ -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
}
+76
View File
@@ -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
}
+78
View File
@@ -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, ", ")
}
+130
View File
@@ -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()
}
+112
View File
@@ -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
}
+162
View File
@@ -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
}