wip markets
This commit is contained in:
21 files changed
+2836
No files matched your search
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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) + "%)"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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] }
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user