Files
MujicaUI/screener.go
T
2026-10-05 14:54:26 -04:00

193 lines
6.4 KiB
Go

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
}