Compare commits

..

No commits in common. "master" and "v0.0.4" have entirely different histories.

22 changed files with 322 additions and 674 deletions

View File

@ -17,12 +17,12 @@ jobs:
name: Set up Go name: Set up Go
uses: actions/setup-go@v4 uses: actions/setup-go@v4
with: with:
go-version: 1.23.1 go-version: 1.22.1
- -
name: Run linter name: Run linter
uses: golangci/golangci-lint-action@v3 uses: golangci/golangci-lint-action@v3
with: with:
version: v1.61 version: v1.56
- -
name: Run tests name: Run tests
run: go test ./... run: go test ./...
@ -31,7 +31,7 @@ jobs:
uses: goreleaser/goreleaser-action@v5 uses: goreleaser/goreleaser-action@v5
with: with:
distribution: goreleaser distribution: goreleaser
version: 2.2.0 version: 1.24.0
args: release --clean --snapshot args: release --clean --snapshot
env: env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}

View File

@ -17,13 +17,13 @@ jobs:
name: Set up Go name: Set up Go
uses: actions/setup-go@v4 uses: actions/setup-go@v4
with: with:
go-version: 1.23.1 go-version: 1.22.1
- -
name: Run GoReleaser name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5 uses: goreleaser/goreleaser-action@v5
with: with:
distribution: goreleaser distribution: goreleaser
version: 2.2.0 version: 1.24.0
args: release --clean args: release --clean
env: env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}

View File

@ -1,15 +0,0 @@
linters:
disable-all: true
enable:
- errcheck
- godot
- goimports
- gosimple
- govet
- ineffassign
- nilerr
- nilnil
- staticcheck
- typecheck
- unused
- usestdlibvars

View File

@ -6,7 +6,7 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json # yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj # vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2 version: 1
before: before:
hooks: hooks:

1
.idea/.gitignore vendored
View File

@ -1 +0,0 @@
workspace.xml

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/moonmath.iml" filepath="$PROJECT_DIR$/.idea/moonmath.iml" />
</modules>
</component>
</project>

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

20
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,20 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch",
"type": "go",
"request": "launch",
"mode": "debug",
"console": "integratedTerminal",
"program": "${workspaceFolder}/moonmath.go",
"args": [
"--config-file",
"moonmath.toml"
]
}
]
}

18
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,18 @@
{
"cSpell.words": [
"alecthomas",
"bitcoinity",
"Bitfinex",
"Bitstamp",
"bubbletea",
"CDPR",
"charmbracelet",
"Coindesk",
"knadh",
"koanf",
"lipgloss",
"moonmath",
"OHLC",
"sourcegraph"
]
}

View File

@ -12,27 +12,14 @@ 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 of By default, the program will use Bitcoin along with various goals and bases
comparison. With the `--asset` flag, another asset supported by Coindesk can be of comparison. With the `--asset` flag, another asset supported by Coindesk can
chosen. These can even be chained, e.g. `--asset BTC --asset ETH`, to show be chosen. The [builtin default config](./config/default.yaml) only has special
projections for multiple assets simultaneously. goals for Ethereum ("ETH"). With the `--config-file` flag, however, one can
specify a YAML file that overrides these defaults and adds goals for other
The [builtin default config](./config/default.yaml) only has special assets.
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

@ -1,6 +1,5 @@
package coindesk package coindesk
//goland:noinspection ALL
const ( const (
BTC Asset = "BTC" BTC Asset = "BTC"
ETH Asset = "ETH" ETH Asset = "ETH"

View File

@ -16,58 +16,62 @@ import (
var k = koanf.New(".") var k = koanf.New(".")
func Load(filePath string) (r Root, err error) { func Load(path string) (all All, err error) {
err = k.Load(structs.Provider(Default, "koanf"), nil) err = k.Load(structs.Provider(Default, "koanf"), nil)
if err != nil { if err != nil {
return return
} }
if filePath != "" { if path != "" {
err = k.Load(file.Provider(filePath), yaml.Parser()) err = k.Load(file.Provider(path), yaml.Parser())
if err != nil { if err != nil {
return return
} }
} }
err = k.Unmarshal("", &r) err = k.Unmarshal("", &all)
if err != nil {
return
}
return return
} }
var Default Root var Default All
type Root struct { type All struct {
Defaults Asset `koanf:"defaults"` Defaults Data `koanf:"defaults"`
Assets map[coindesk.Asset]Asset `koanf:"assets"` Assets map[coindesk.Asset]Data `koanf:"assets"`
} }
type Asset struct { type Data struct {
Asset coindesk.Asset `koanf:"asset"` Asset coindesk.Asset `koanf:"asset"`
Goals []moon.Goal `koanf:"goals"` Goals []moon.Goal `koanf:"goals"`
ConstantBases []moon.ConstantBase `koanf:"constantBases"` ConstantBases []moon.ConstantBase `koanf:"constantBases"`
RelativeBases []moon.RelativeBase `koanf:"relativeBases"` RelativeBases []moon.RelativeBase `koanf:"relativeBases"`
} }
func (r Root) ForAsset(a coindesk.Asset) (cfg Asset) { func (all All) GetData(asset coindesk.Asset) (data Data, err error) {
cfg = merge(r.Assets[a], r.Defaults) data, ok := all.Assets[asset]
cfg.Asset = a if !ok {
data = all.Defaults
}
if data.Asset == "" {
data.Asset = asset
}
if data.Goals == nil || len(data.Goals) == 0 {
data.Goals = all.Defaults.Goals
}
if data.ConstantBases == nil || len(data.ConstantBases) == 0 {
data.ConstantBases = all.Defaults.ConstantBases
}
if data.RelativeBases == nil || len(data.RelativeBases) == 0 {
data.RelativeBases = all.Defaults.RelativeBases
}
return return
} }
func merge(dst, src Asset) Asset {
if len(dst.Goals) == 0 {
dst.Goals = src.Goals
}
if len(dst.ConstantBases) == 0 {
dst.ConstantBases = src.ConstantBases
}
if len(dst.RelativeBases) == 0 {
dst.RelativeBases = src.RelativeBases
}
return dst
}
// GetBases returns the concatenation of the constant and relative bases, sorted // GetBases returns the concatenation of the constant and relative bases, sorted
// from most recent to least recent in time. // from most recent to least recent in time.
func GetBases(d *Asset) (bases []moon.Base) { func GetBases(d *Data) (bases []moon.Base) {
for _, b := range d.ConstantBases { for _, b := range d.ConstantBases {
bases = append(bases, b) bases = append(bases, b)
} }

View File

@ -28,12 +28,6 @@ defaults:
time: 2019-12-31T16:00:00-08:00 time: 2019-12-31T16:00:00-08:00
- name: 2017- - name: 2017-
time: 2016-12-31T16:00:00-08:00 time: 2016-12-31T16:00:00-08:00
- name: 2013-
time: 2012-12-31T16:00:00-08:00
startingPrice: 13.30
- name: 2011-
time: 2010-12-31T16:00:00-08:00
startingPrice: 0.30
assets: assets:
ETH: ETH:
@ -50,93 +44,3 @@ assets:
value: 20000 value: 20000
- name: $25k - name: $25k
value: 25000 value: 25000
constantBases:
- name: 2020-
time: 2019-12-31T16:00:00-08:00
- name: 2017-
time: 2016-12-31T16:00:00-08:00
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

3
go.mod
View File

@ -36,7 +36,8 @@ require (
github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/sync v0.6.0 // indirect golang.org/x/sync v0.6.0 // indirect
golang.org/x/sys v0.18.0 // indirect golang.org/x/sys v0.18.0 // indirect
golang.org/x/term v0.18.0 // indirect golang.org/x/term v0.18.0 // indirect

9
go.sum
View File

@ -17,6 +17,7 @@ github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy12
github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro=
github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
@ -74,10 +75,14 @@ github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZV
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=

View File

@ -2,7 +2,6 @@ package moon
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"math" "math"
"time" "time"
@ -76,10 +75,30 @@ func (m *Math) Refresh(ctx context.Context) (err error) {
tasks.WithMaxGoroutines(len(m.Columns)) tasks.WithMaxGoroutines(len(m.Columns))
//tasks.WithMaxGoroutines(1) //tasks.WithMaxGoroutines(1)
now := time.Now() now := time.Now()
for i := range m.Columns { for i := range m.Columns {
c := &m.Columns[i] c := &m.Columns[i]
tasks.Go(func() error { tasks.Go(func() error {
return c.project(ctx, m, now) c.StartingDate = c.Base.From(now)
nextDay := c.StartingDate.Add(time.Hour * 24)
resp, err := coindesk.GetPriceValues(ctx,
m.Asset, c.StartingDate, nextDay)
if err != nil {
return err
}
c.StartingPrice = resp.Data.Entries[0].Price
c.Gain = float64(m.CurrentPrice) / float64(c.StartingPrice)
days := now.Sub(c.StartingDate).Hours() / 24
c.CDPR = CDPR(days, c.Gain)
if c.CDPR > 1 {
c.Projections = ProjectDates(
now, float64(m.CurrentPrice),
c.CDPR, m.Goals,
)
} else {
c.Projections.Dates = nil
}
return nil
}) })
} }
err = tasks.Wait() err = tasks.Wait()
@ -96,49 +115,46 @@ type Column struct {
Projections Projection Projections Projection
} }
func (c *Column) project(ctx context.Context, m *Math, now time.Time) (err error) { func (c *Column) Column() (entries []string) {
err = c.fillStartingPrice(ctx, m.Asset, now) entries = append(entries, fmt.Sprintf("$%.2f", c.StartingPrice))
if err != nil { entries = append(entries, fmt.Sprintf("%.2f%%", (c.CDPR-1)*100))
return never := c.CDPR <= 1
for i := range c.Projections.Dates {
var cell string
if never {
cell = "NEVER!!!!!"
} else {
cell = c.
Projections.
Dates[i].
Format("2006-01-02")
}
entries = append(entries, cell)
} }
c.Gain = float64(m.CurrentPrice) / float64(c.StartingPrice)
days := now.Sub(c.StartingDate).Hours() / 24
c.CDPR = CDPR(days, c.Gain)
c.Projections = ProjectDates(
now, float64(m.CurrentPrice),
c.CDPR, m.Goals,
)
return return
} }
func (c *Column) fillStartingPrice( var DefaultGoals = []Goal{
ctx context.Context, asset coindesk.Asset, now time.Time, {"$100k", 100000},
) error { {"$150k", 150000},
// if base provides a hardcoded starting price, use it {"$200k", 200000},
c.StartingDate = c.Base.From(now) {"$250k", 250000},
c.StartingPrice = coindesk.Price(c.Base.GetStartingPrice()) {"$300k", 300000},
if c.StartingPrice != 0 { {"$500k", 500000},
return nil {"$1m", 1000000},
}
// otherwise, look up the starting price via Coindesk
nextDay := c.StartingDate.Add(time.Hour * 24)
resp, err := coindesk.GetPriceValues(ctx, asset, c.StartingDate, nextDay)
if err != nil {
err = fmt.Errorf("getting price for %s on %v: %w",
asset, c.StartingDate, err)
return err
}
if len(resp.Data.Entries) == 0 {
c.Projections.Dates = nil
return errEmptyPriceEntries
}
c.StartingPrice = resp.Data.Entries[0].Price
return nil
} }
var errEmptyPriceEntries = errors.New("price values response has no entries") var DefaultConstantBases = []ConstantBase{
{"2020-", time.Unix(1577836800, 0)},
{"2017-", time.Unix(1483228800, 0)},
}
var DefaultRelativeBases = []RelativeBase{
{"Month", time.Duration(-30) * time.Hour * 24},
{"Quarter", time.Duration(-90) * time.Hour * 24},
{"Half-Year", time.Duration(-182) * time.Hour * 24},
{"Year", time.Duration(-365) * time.Hour * 24},
}
type Goal struct { type Goal struct {
Name string `koanf:"name"` Name string `koanf:"name"`
@ -149,14 +165,12 @@ type Goal struct {
type Base interface { type Base interface {
From(now time.Time) time.Time From(now time.Time) time.Time
Label() string Label() string
GetStartingPrice() float64
} }
// ConstantBase is a base that is a constant time, e.g. 2020-01-01. // ConstantBase is a base that is a constant time, e.g. 2020-01-01.
type ConstantBase struct { type ConstantBase struct {
Name string `koanf:"name"` Name string `koanf:"name"`
Time time.Time `koanf:"time"` Time time.Time `koanf:"time"`
StartingPrice float64 `koanf:"startingPrice"`
} }
func (cb ConstantBase) From(_ time.Time) time.Time { func (cb ConstantBase) From(_ time.Time) time.Time {
@ -167,25 +181,17 @@ func (cb ConstantBase) Label() string {
return cb.Name return cb.Name
} }
func (cb ConstantBase) GetStartingPrice() float64 { // RelativeBase is a base that is relative, e.g. "90 days ago."
return cb.StartingPrice
}
// RelativeBase is a base that is relative, e.g. "90 days ago".
type RelativeBase struct { type RelativeBase struct {
Name string `koanf:"name"` Name string `koanf:"name"`
Offset time.Duration `koanf:"offset"` Offset time.Duration `koanf:"offset"`
} }
func (rb RelativeBase) From(now time.Time) time.Time { func (rb RelativeBase) From(now time.Time) time.Time {
then := now.Add(rb.Offset) then := now.Add(time.Duration(rb.Offset))
return then return then
} }
func (rb RelativeBase) Label() string { func (rb RelativeBase) Label() string {
return rb.Name return rb.Name
} }
func (rb RelativeBase) GetStartingPrice() float64 {
return 0
}

View File

@ -11,14 +11,6 @@ func TestCDPR(t *testing.T) {
} }
func TestProjection(t *testing.T) { func TestProjection(t *testing.T) {
p := moon.ProjectDates(time.Now(), 68900, 1.0055, []moon.Goal{ p := moon.ProjectDates(time.Now(), 68900, 1.0055, moon.DefaultGoals)
{"$100k", 100000},
{"$150k", 150000},
{"$200k", 200000},
{"$250k", 250000},
{"$300k", 300000},
{"$500k", 500000},
{"$1m", 1000000},
})
_ = p _ = p
} }

View File

@ -2,30 +2,21 @@ 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(s) to project."` Asset string `short:"a" default:"BTC" help:"Asset 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)
@ -34,50 +25,17 @@ func main() {
if err != nil { if err != nil {
fail(err) fail(err)
} }
var assets []coindesk.Asset CLI.Asset = strings.ToUpper(CLI.Asset)
for i := range CLI.Asset { cfg, err := allCfg.GetData(coindesk.Asset(CLI.Asset))
asset := coindesk.Asset(strings.ToUpper(CLI.Asset[i])) if err != nil {
assets = append(assets, asset) fail(err)
} }
m := tui.New(assets, allCfg, CLI.Perf) p := tui.New(cfg)
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)

View File

@ -1,268 +0,0 @@
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.Asset) (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) Init() tea.Cmd {
return tea.Batch(
m.indicator.Tick,
func() tea.Msg {
return Msg{m.math.Asset, refresh{}}
},
)
}
func (m Model) Handles(a coindesk.Asset) bool {
return m.math.Asset == a
}
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,
m.scheduleRefresh(),
)
case update:
var cmd tea.Cmd
if msg.err == nil {
m.math = msg.math
refillProperties(&m)
refillProjections(&m)
cmd = m.stopIndicator()
} else {
m.indicator.Style = indicatorErrorStyle
}
return m, cmd
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.WithTimeout(
context.Background(),
refreshTimeout)
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(refreshInterval,
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(stopIndicatorDelay,
func(t time.Time) tea.Msg {
return Msg{m.math.Asset, stopIndicator{}}
})
}
var refreshInterval = time.Second * 30
var refreshTimeout = time.Second * 15
var stopIndicatorDelay = time.Millisecond * 500
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) {
cols := []table.Row{m.math.Labels}
for i := range m.math.Columns {
entries := renderEntries(m.math.Columns[i])
cols = append(cols, entries)
}
rows := transpose(cols)
m.projections.SetRows(rows)
}
func renderEntries(c moon.Column) (entries []string) {
entries = append(entries, fmt.Sprintf("$%.2f", c.StartingPrice))
entries = append(entries, fmt.Sprintf("%.4f%%", (c.CDPR-1)*100))
never := c.CDPR <= 1
for i := range c.Projections.Dates {
var cell string
if never {
cell = "NEVER!!!!!"
} else {
cell = c.
Projections.
Dates[i].
Format("2006-01-02")
}
entries = append(entries, cell)
}
return
}
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"))

View File

@ -1,42 +0,0 @@
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(_ 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,99 +1,202 @@
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/tui/asset" "code.humancabbage.net/sam/moonmath/moon"
"code.humancabbage.net/sam/moonmath/tui/perf" "github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
type Model struct { func New(cfg config.Data) (p *tea.Program) {
assets []asset.Model p = tea.NewProgram(
stats perf.Model newModel(cfg),
displayStats bool tea.WithAltScreen(),
tea.WithFPS(30),
)
return
} }
func New(assets []coindesk.Asset, cfg config.Root, displayStats bool) (m Model) { type Model struct {
m.stats = perf.New() math moon.Math
m.displayStats = displayStats
// construct models for each asset, but remove dupes refreshing bool
seen := map[coindesk.Asset]struct{}{} indicator spinner.Model
for _, a := range assets {
if _, ok := seen[a]; ok { properties table.Model
continue projections table.Model
}
func newModel(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)
} }
assetCfg := cfg.ForAsset(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 {
// initialize child models, collecting their commands, return tea.Batch(
// then return them all in a batch m.indicator.Tick,
var inits []tea.Cmd func() tea.Msg {
for i := range m.assets { return refresh{}
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) {
// handle keys for quitting case refresh:
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
} }
func (m Model) View() string { type refresh struct{}
m.stats.AddView() type stopIndicator struct{}
var ss []string
for i := range m.assets { func refillProperties(m *Model) {
s := m.assets[i].View() rows := []table.Row{
ss = append(ss, s) {"Asset", string(m.math.Asset)},
{"Price", fmt.Sprintf("$%0.2f", m.math.CurrentPrice)},
} }
if m.displayStats { m.properties.SetRows(rows)
ss = append(ss, m.stats.View())
}
r := lipgloss.JoinVertical(lipgloss.Center, ss...)
return r
} }
func (m Model) forward(a coindesk.Asset, msg tea.Msg) (cmd tea.Cmd) { func refillProjections(m *Model) {
// O(n) is fine when n is small rows := []table.Row{m.math.Labels}
for i := range m.assets { for i := range m.math.Columns {
if !m.assets[i].Handles(a) { rows = append(rows, m.math.Columns[i].Column())
continue
} }
m.assets[i], cmd = m.assets[i].Update(msg) rows = transpose(rows)
return m.projections.SetRows(rows)
}
panic(fmt.Errorf("rogue message: %v", msg))
} }
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"))