5 Commits

Author SHA1 Message Date
7b445a02a2 Refresh indicator goes red on error.
All checks were successful
Build & Test / Main (push) Successful in 1m0s
Release / Release (push) Successful in 1m3s
It will stay flashing red until the next refresh, at which point it goes
back to its normal color. On a successful refresh, it still stops.

Also, add a deadline of 15 seconds to the refresh command.
2024-03-24 02:29:45 -07:00
97f4793ec3 Write errors to log file.
All checks were successful
Build & Test / Main (push) Successful in 1m1s
2024-03-22 22:55:41 -07:00
270534c0d5 Pause spinner ticks when not refreshing.
All checks were successful
Build & Test / Main (push) Successful in 59s
Also, add a quick-and-dirty model for displaying basic performance
stats, currently just the number of calls to the root Update() and
View() methods.
2024-03-22 21:14:34 -07:00
c4dde38d23 Support multiple assets simultaneously.
All checks were successful
Build & Test / Main (push) Successful in 1m0s
Release / Release (push) Successful in 1m1s
2024-03-22 17:43:15 -07:00
e14f0488c5 Add config defaults for more assets.
All checks were successful
Build & Test / Main (push) Successful in 59s
* LTC
* SOL
* XRP
* DOGE
* ADA
2024-03-21 22:07:16 -07:00
8 changed files with 511 additions and 188 deletions

View File

@@ -12,14 +12,27 @@ program. It's written in Go using the [Bubble Tea][tea] library, and uses
[tea]: https://github.com/charmbracelet/bubbletea [tea]: https://github.com/charmbracelet/bubbletea
[coin]: https://www.coindesk.com [coin]: https://www.coindesk.com
### Installation
Go to the [Releases page](https://code.humancabbage.net/sam/moonmath/releases)
and download the archive for your operating system and architecture. (For the
uninitiated, "Darwin" means macOS.)
### Configuration ### Configuration
By default, the program will use Bitcoin along with various goals and bases By default, the program will use Bitcoin along with various goals and bases of
of comparison. With the `--asset` flag, another asset supported by Coindesk can comparison. With the `--asset` flag, another asset supported by Coindesk can be
be chosen. The [builtin default config](./config/default.yaml) only has special chosen. These can even be chained, e.g. `--asset BTC --asset ETH`, to show
goals for Ethereum ("ETH"). With the `--config-file` flag, however, one can projections for multiple assets simultaneously.
specify a YAML file that overrides these defaults and adds goals for other
assets. The [builtin default config](./config/default.yaml) only has special
goals a handful of the most popular assets. With the `--config-file` flag,
however, one can specify a YAML file that overrides these defaults and adds
goals for other assets.
Check out [coindesk/assets.go](./coindesk/assets.go) for a full list of
supported assets. Keep in mind these have not been exhaustively tested, and
it's likely that many will fail with the default configuration settings.
### "Theory" ### "Theory"

View File

@@ -49,7 +49,7 @@ type Data struct {
RelativeBases []moon.RelativeBase `koanf:"relativeBases"` RelativeBases []moon.RelativeBase `koanf:"relativeBases"`
} }
func (all All) GetData(asset coindesk.Asset) (data Data, err error) { func (all All) GetData(asset coindesk.Asset) (data Data) {
data, ok := all.Assets[asset] data, ok := all.Assets[asset]
if !ok { if !ok {
data = all.Defaults data = all.Defaults

View File

@@ -44,3 +44,88 @@ assets:
value: 20000 value: 20000
- name: $25k - name: $25k
value: 25000 value: 25000
LTC:
goals:
- name: $100
value: 100
- name: $150
value: 150
- name: $200
value: 200
- name: $300
value: 300
- name: $500
value: 500
- name: $1k
value: 1000
constantBases:
- name: 2021-
time: 2020-12-31T16:00:00-08:00
SOL:
goals:
- name: $250
value: 250
- name: $375
value: 375
- name: $500
value: 500
- name: $750
value: 750
- name: $1k
value: 1000
- name: $2k
value: 2000
constantBases:
- name: 2022-
time: 2021-12-31T16:00:00-08:00
XRP:
goals:
- name: $1
value: 1
- name: $1.5
value: 1.5
- name: $2
value: 2
- name: $3
value: 3
- name: $5
value: 5
- name: $10
value: 10
constantBases:
- name: 2022-
time: 2021-12-31T16:00:00-08:00
DOGE:
goals:
- name: $1
value: 1
- name: $1.5
value: 1.5
- name: $2
value: 2
- name: $3
value: 3
- name: $5
value: 5
- name: $10
value: 10
constantBases:
- name: 2022-
time: 2021-12-31T16:00:00-08:00
ADA:
goals:
- name: $1
value: 1
- name: $1.5
value: 1.5
- name: $2
value: 2
- name: $3
value: 3
- name: $5
value: 5
- name: $10
value: 10
constantBases:
- name: 2022-
time: 2021-12-31T16:00:00-08:00

View File

@@ -86,18 +86,18 @@ func (m *Math) Refresh(ctx context.Context) (err error) {
if err != nil { if err != nil {
return err return err
} }
if len(resp.Data.Entries) == 0 {
c.Projections.Dates = nil
return nil
}
c.StartingPrice = resp.Data.Entries[0].Price c.StartingPrice = resp.Data.Entries[0].Price
c.Gain = float64(m.CurrentPrice) / float64(c.StartingPrice) c.Gain = float64(m.CurrentPrice) / float64(c.StartingPrice)
days := now.Sub(c.StartingDate).Hours() / 24 days := now.Sub(c.StartingDate).Hours() / 24
c.CDPR = CDPR(days, c.Gain) c.CDPR = CDPR(days, c.Gain)
if c.CDPR > 1 { c.Projections = ProjectDates(
c.Projections = ProjectDates( now, float64(m.CurrentPrice),
now, float64(m.CurrentPrice), c.CDPR, m.Goals,
c.CDPR, m.Goals, )
)
} else {
c.Projections.Dates = nil
}
return nil return nil
}) })
} }

View File

@@ -2,21 +2,30 @@ package main
import ( import (
"fmt" "fmt"
"io"
"log/slog"
"os" "os"
"path/filepath"
"strings" "strings"
"code.humancabbage.net/sam/moonmath/coindesk" "code.humancabbage.net/sam/moonmath/coindesk"
"code.humancabbage.net/sam/moonmath/config" "code.humancabbage.net/sam/moonmath/config"
"code.humancabbage.net/sam/moonmath/tui" "code.humancabbage.net/sam/moonmath/tui"
"github.com/alecthomas/kong" "github.com/alecthomas/kong"
tea "github.com/charmbracelet/bubbletea"
) )
var CLI struct { var CLI struct {
Asset string `short:"a" default:"BTC" help:"Asset to project."` Asset []string `short:"a" default:"BTC" help:"Asset(s) to project."`
ConfigFile string `short:"c" help:"Path to YAML configuration file."` ConfigFile string `short:"c" help:"Path to YAML configuration file."`
Perf bool `help:"Display internal performance stats."`
} }
func main() { func main() {
logFile := setupLogging()
defer func() {
_ = logFile.Close()
}()
ctx := kong.Parse(&CLI) ctx := kong.Parse(&CLI)
if ctx.Error != nil { if ctx.Error != nil {
fail(ctx.Error) fail(ctx.Error)
@@ -25,17 +34,50 @@ func main() {
if err != nil { if err != nil {
fail(err) fail(err)
} }
CLI.Asset = strings.ToUpper(CLI.Asset) var assets []coindesk.Asset
cfg, err := allCfg.GetData(coindesk.Asset(CLI.Asset)) for i := range CLI.Asset {
if err != nil { asset := coindesk.Asset(strings.ToUpper(CLI.Asset[i]))
fail(err) assets = append(assets, asset)
} }
p := tui.New(cfg) m := tui.New(assets, allCfg, CLI.Perf)
p := tea.NewProgram(m,
tea.WithAltScreen(),
tea.WithFPS(30),
)
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
fail(err) fail(err)
} }
} }
func setupLogging() io.Closer {
homePath, err := os.UserHomeDir()
if err != nil {
panic(err)
}
programConfigPath := filepath.Join(homePath, ".moonmath")
err = os.MkdirAll(programConfigPath, 0755)
if err != nil {
panic(err)
}
errLogPath := filepath.Join(programConfigPath, "errors.log")
errLogFile, err := os.OpenFile(
errLogPath,
os.O_CREATE|os.O_APPEND|os.O_WRONLY,
0600,
)
if err != nil {
panic(err)
}
slog.SetDefault(slog.New(
slog.NewTextHandler(errLogFile, &slog.HandlerOptions{
Level: slog.LevelError,
})))
return errLogFile
}
func fail(err error) { func fail(err error) {
fmt.Printf("program error: %v\n", err) fmt.Printf("program error: %v\n", err)
os.Exit(1) os.Exit(1)

243
tui/asset/asset.go Normal file
View File

@@ -0,0 +1,243 @@
package asset
import (
"context"
"fmt"
"log/slog"
"time"
"code.humancabbage.net/sam/moonmath/coindesk"
"code.humancabbage.net/sam/moonmath/config"
"code.humancabbage.net/sam/moonmath/moon"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type Model struct {
math moon.Math
refreshing bool
indicator spinner.Model
properties table.Model
projections table.Model
}
type Msg struct {
Asset coindesk.Asset
inner tea.Msg
}
func New(cfg config.Data) (m Model) {
m.math = moon.NewMath(
cfg.Asset,
cfg.Goals,
config.GetBases(&cfg),
)
tableStyle := table.DefaultStyles()
tableStyle.Selected = tableStyle.Cell.Copy().
Padding(0)
tableStyle.Header = tableStyle.Header.
Bold(true).
Foreground(lipgloss.Color("214")).
Border(baseStyle.GetBorderStyle(), false, false, true, false)
// properties table
m.properties = table.New(
table.WithColumns([]table.Column{
{Title: "Property", Width: 9},
{Title: "Value", Width: 9},
}),
table.WithHeight(2),
table.WithStyles(tableStyle),
)
// projections table
labelsWidth := 0
for _, l := range m.math.Labels {
if len(l) > labelsWidth {
labelsWidth = len(l)
}
}
projectionCols := []table.Column{
{Title: "Labels", Width: labelsWidth},
}
for _, c := range m.math.Columns {
projectionCols = append(projectionCols,
table.Column{
Title: c.Base.Label(),
Width: 10,
})
}
m.projections = table.New(
table.WithColumns(projectionCols),
table.WithHeight(len(m.math.Labels)),
table.WithStyles(tableStyle),
)
// indicator spinner
m.indicator = spinner.New()
m.indicator.Spinner = spinner.Points
m.indicator.Style = indicatorNormalStyle
return
}
func (m Model) Handles(a coindesk.Asset) bool {
return m.math.Asset == a
}
func (m Model) Init() tea.Cmd {
return tea.Batch(
m.indicator.Tick,
func() tea.Msg {
return Msg{m.math.Asset, refresh{}}
},
)
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case Msg:
switch msg := msg.inner.(type) {
case refresh:
m.refreshing = true
m.indicator.Style = indicatorNormalStyle
return m, tea.Batch(
m.resumeIndicator,
m.refresh,
)
case update:
commands := []tea.Cmd{m.scheduleRefresh()}
if msg.err == nil {
m.math = msg.math
refillProperties(&m)
refillProjections(&m)
commands = append(commands, m.stopIndicator())
} else {
m.indicator.Style = indicatorErrorStyle
}
return m, tea.Batch(commands...)
case stopIndicator:
m.refreshing = false
return m, nil
}
case spinner.TickMsg:
if !m.refreshing {
return m, nil
}
var cmd tea.Cmd
m.indicator, cmd = m.indicator.Update(msg)
return m, cmd
}
return m, nil
}
type refresh struct{}
type update struct {
math moon.Math
err error
}
type stopIndicator struct{}
func (m Model) refresh() tea.Msg {
ctx, cancel := context.WithDeadline(
context.Background(),
time.Now().Add(time.Second*15))
defer cancel()
err := m.math.Refresh(ctx)
if err != nil {
slog.Error("refresh",
"asset", m.math.Asset,
"err", err,
)
}
return Msg{m.math.Asset, update{m.math, err}}
}
func (m Model) resumeIndicator() tea.Msg {
return m.indicator.Tick()
}
func (m Model) scheduleRefresh() tea.Cmd {
return tea.Tick(time.Second*30,
func(t time.Time) tea.Msg {
return Msg{m.math.Asset, refresh{}}
})
}
func (m Model) stopIndicator() tea.Cmd {
// wait a bit to stop the indicator, so that it's more obvious
// even when the refresh completes quickly.
return tea.Tick(time.Millisecond*500,
func(t time.Time) tea.Msg {
return Msg{m.math.Asset, stopIndicator{}}
})
}
func refillProperties(m *Model) {
rows := []table.Row{
{"Asset", string(m.math.Asset)},
{"Price", fmt.Sprintf("$%0.2f", m.math.CurrentPrice)},
}
m.properties.SetRows(rows)
}
func refillProjections(m *Model) {
rows := []table.Row{m.math.Labels}
for i := range m.math.Columns {
rows = append(rows, m.math.Columns[i].Column())
}
rows = transpose(rows)
m.projections.SetRows(rows)
}
func transpose(slice []table.Row) []table.Row {
xl := len(slice[0])
yl := len(slice)
result := make([]table.Row, xl)
for i := range result {
result[i] = make(table.Row, yl)
}
for i := 0; i < xl; i++ {
for j := 0; j < yl; j++ {
result[i][j] = slice[j][i]
}
}
return result
}
func (m Model) View() string {
var s string
indicator := ""
if m.refreshing {
indicator = m.indicator.View()
}
right := lipgloss.JoinVertical(
lipgloss.Center,
baseStyle.Render(m.properties.View()),
indicator,
)
s += lipgloss.JoinHorizontal(
lipgloss.Center,
right,
baseStyle.Render(m.projections.View()),
)
return s + "\n"
}
var baseStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240"))
var indicatorNormalStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("69"))
var indicatorErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("160"))

42
tui/perf/perf.go Normal file
View File

@@ -0,0 +1,42 @@
package perf
import (
"fmt"
"sync/atomic"
tea "github.com/charmbracelet/bubbletea"
)
type Model struct {
updates *atomic.Int64
views *atomic.Int64
}
func New() (m Model) {
m.updates = new(atomic.Int64)
m.views = new(atomic.Int64)
return
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, nil
}
func (m Model) View() string {
updates := m.updates.Load()
views := m.views.Load()
s := fmt.Sprintf("updates: %d\tviews: %d", updates, views)
return s
}
func (m Model) AddUpdate() {
m.updates.Add(1)
}
func (m Model) AddView() {
m.views.Add(1)
}

View File

@@ -1,202 +1,100 @@
package tui package tui
import ( import (
"context"
"fmt" "fmt"
"time"
"code.humancabbage.net/sam/moonmath/coindesk"
"code.humancabbage.net/sam/moonmath/config" "code.humancabbage.net/sam/moonmath/config"
"code.humancabbage.net/sam/moonmath/moon" "code.humancabbage.net/sam/moonmath/tui/asset"
"github.com/charmbracelet/bubbles/spinner" "code.humancabbage.net/sam/moonmath/tui/perf"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
func New(cfg config.Data) (p *tea.Program) {
p = tea.NewProgram(
newModel(cfg),
tea.WithAltScreen(),
tea.WithFPS(30),
)
return
}
type Model struct { type Model struct {
math moon.Math assets []asset.Model
stats perf.Model
refreshing bool displayStats bool
indicator spinner.Model
properties table.Model
projections table.Model
} }
func newModel(cfg config.Data) (m Model) { func New(assets []coindesk.Asset, cfg config.All, displayStats bool) (m Model) {
m.math = moon.NewMath( m.stats = perf.New()
cfg.Asset, m.displayStats = displayStats
cfg.Goals, // construct models for each asset, but don't filter out dupes
config.GetBases(&cfg), seen := map[coindesk.Asset]struct{}{}
) for _, a := range assets {
_, ok := seen[a]
tableStyle := table.DefaultStyles() if ok {
tableStyle.Selected = tableStyle.Cell.Copy(). continue
Padding(0)
tableStyle.Header = tableStyle.Header.
Bold(true).
Foreground(lipgloss.Color("214")).
Border(baseStyle.GetBorderStyle(), false, false, true, false)
// properties table
m.properties = table.New(
table.WithColumns([]table.Column{
{Title: "Property", Width: 9},
{Title: "Value", Width: 9},
}),
table.WithHeight(2),
table.WithStyles(tableStyle),
)
// projections table
labelsWidth := 0
for _, l := range m.math.Labels {
if len(l) > labelsWidth {
labelsWidth = len(l)
} }
assetCfg := cfg.GetData(a)
assetModel := asset.New(assetCfg)
m.assets = append(m.assets, assetModel)
seen[a] = struct{}{}
} }
projectionCols := []table.Column{
{Title: "Labels", Width: labelsWidth},
}
for _, c := range m.math.Columns {
projectionCols = append(projectionCols,
table.Column{
Title: c.Base.Label(),
Width: 10,
})
}
m.projections = table.New(
table.WithColumns(projectionCols),
table.WithHeight(len(m.math.Labels)),
table.WithStyles(tableStyle),
)
// indicator spinner
m.indicator = spinner.New()
m.indicator.Spinner = spinner.Points
m.indicator.Style = lipgloss.NewStyle().
Foreground(lipgloss.Color("69"))
return return
} }
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return tea.Batch( // initialize child models, collecting their commands,
m.indicator.Tick, // then return them all in a batch
func() tea.Msg { var inits []tea.Cmd
return refresh{} for i := range m.assets {
}, cmd := m.assets[i].Init()
) inits = append(inits, cmd)
}
return tea.Batch(inits...)
} }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.stats.AddUpdate()
switch msg := msg.(type) { switch msg := msg.(type) {
case refresh: // handle keys for quitting
m.refreshing = true
return m, func() tea.Msg {
// TODO: log errors
_ = m.math.Refresh(context.TODO())
return m.math
}
case moon.Math:
m.math = msg
refillProperties(&m)
refillProjections(&m)
return m, tea.Batch(
// schedule the next refresh
tea.Tick(time.Second*30,
func(t time.Time) tea.Msg {
return refresh{}
}),
// wait a bit to stop the indicator, so that it's more obvious
// even when the refresh completes quickly.
tea.Tick(time.Millisecond*500,
func(t time.Time) tea.Msg {
return stopIndicator{}
}),
)
case stopIndicator:
m.refreshing = false
return m, nil
case spinner.TickMsg:
var cmd tea.Cmd
m.indicator, cmd = m.indicator.Update(msg)
return m, cmd
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "ctrl+c", "q", "esc": case "ctrl+c", "q", "esc":
return m, tea.Quit return m, tea.Quit
} }
// forward asset messages to the appropriate model
case asset.Msg:
cmd := m.forward(msg.Asset, msg)
return m, cmd
// forward any other message to each child model.
// typically, this is for animation.
default:
var commands []tea.Cmd
for i := range m.assets {
var cmd tea.Cmd
m.assets[i], cmd = m.assets[i].Update(msg)
commands = append(commands, cmd)
}
return m, tea.Batch(commands...)
} }
return m, nil return m, nil
} }
type refresh struct{}
type stopIndicator struct{}
func refillProperties(m *Model) {
rows := []table.Row{
{"Asset", string(m.math.Asset)},
{"Price", fmt.Sprintf("$%0.2f", m.math.CurrentPrice)},
}
m.properties.SetRows(rows)
}
func refillProjections(m *Model) {
rows := []table.Row{m.math.Labels}
for i := range m.math.Columns {
rows = append(rows, m.math.Columns[i].Column())
}
rows = transpose(rows)
m.projections.SetRows(rows)
}
func transpose(slice []table.Row) []table.Row {
xl := len(slice[0])
yl := len(slice)
result := make([]table.Row, xl)
for i := range result {
result[i] = make(table.Row, yl)
}
for i := 0; i < xl; i++ {
for j := 0; j < yl; j++ {
result[i][j] = slice[j][i]
}
}
return result
}
func (m Model) View() string { func (m Model) View() string {
var s string m.stats.AddView()
indicator := "" var ss []string
if m.refreshing { for i := range m.assets {
indicator = m.indicator.View() s := m.assets[i].View()
ss = append(ss, s)
} }
right := lipgloss.JoinVertical( if m.displayStats {
lipgloss.Center, ss = append(ss, m.stats.View())
baseStyle.Render(m.properties.View()), }
indicator, r := lipgloss.JoinVertical(lipgloss.Center, ss...)
) return r
s += lipgloss.JoinHorizontal(
lipgloss.Center,
right,
baseStyle.Render(m.projections.View()),
)
return s + "\n"
} }
var baseStyle = lipgloss.NewStyle(). func (m Model) forward(a coindesk.Asset, msg tea.Msg) (cmd tea.Cmd) {
BorderStyle(lipgloss.RoundedBorder()). // O(n) is fine when n is small
BorderForeground(lipgloss.Color("240")) for i := range m.assets {
if !m.assets[i].Handles(a) {
continue
}
m.assets[i], cmd = m.assets[i].Update(msg)
return
}
panic(fmt.Errorf("rogue message: %v", msg))
}