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

131 lines
4.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}