Compare commits

...

8 Commits

Author SHA1 Message Date
0f4e9f4c41 Also update golangci-lint.
All checks were successful
Build & Test / Main (push) Successful in 19s
Release / Release (push) Successful in 24s
2024-09-09 23:55:04 -07:00
2c8e198163 Update Go, Goreleaser.
Some checks are pending
Build & Test / Main (push) Waiting to run
2024-09-09 23:48:32 -07:00
eda4200585 Switch to GoLand.
All checks were successful
Build & Test / Main (push) Successful in 1m49s
2024-03-31 01:07:31 -07:00
5c22d85e2b Add custom .golangci.yaml config.
All checks were successful
Build & Test / Main (push) Successful in 1m0s
Release / Release (push) Successful in 1m35s
2024-03-29 19:29:29 -07:00
f67323c5f4 Support hardcoded starting prices.
All checks were successful
Build & Test / Main (push) Successful in 1m0s
The Coindesk API doesn't have data going all the way back. But since history
isn't changing, we can simply put in known prices.

Also, extend the CDPR cells to have four digits instead of just two.
2024-03-29 19:24:10 -07:00
4d5dcc46d2 Misc small improvements.
All checks were successful
Build & Test / Main (push) Successful in 1m0s
2024-03-29 18:40:33 -07:00
9e6abb1112 Naming things is hard.
All checks were successful
Build & Test / Main (push) Successful in 2m26s
2024-03-29 01:15:59 -07:00
2d991880ce Schedule refreshes more consistently.
All checks were successful
Build & Test / Main (push) Successful in 1m1s
Instead of returning the `scheduleRefresh` command only after receiving
an `update` message, do it while handling the `refresh` message. For
this not to cause weird behavior, the refresh deadline should be shorter
than the refresh interval.
2024-03-24 23:01:51 -07:00
20 changed files with 193 additions and 164 deletions

View File

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

View File

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

15
.golangci.yaml Normal file
View File

@ -0,0 +1,15 @@
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
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 1
version: 2
before:
hooks:

1
.idea/.gitignore vendored Normal file
View File

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

8
.idea/modules.xml Normal file
View File

@ -0,0 +1,8 @@
<?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>

9
.idea/moonmath.iml Normal file
View File

@ -0,0 +1,9 @@
<?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>

6
.idea/vcs.xml Normal file
View File

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

20
.vscode/launch.json vendored
View File

@ -1,20 +0,0 @@
{
// 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
View File

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

View File

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

View File

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

View File

@ -28,6 +28,12 @@ defaults:
time: 2019-12-31T16:00:00-08:00
- name: 2017-
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:
ETH:
@ -44,6 +50,11 @@ assets:
value: 20000
- name: $25k
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

3
go.mod
View File

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

9
go.sum
View File

@ -17,7 +17,6 @@ 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/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
@ -75,14 +74,10 @@ 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/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
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/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
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=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
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/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=

View File

@ -2,6 +2,7 @@ package moon
import (
"context"
"errors"
"fmt"
"math"
"time"
@ -75,30 +76,10 @@ func (m *Math) Refresh(ctx context.Context) (err error) {
tasks.WithMaxGoroutines(len(m.Columns))
//tasks.WithMaxGoroutines(1)
now := time.Now()
for i := range m.Columns {
c := &m.Columns[i]
tasks.Go(func() error {
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
}
if len(resp.Data.Entries) == 0 {
c.Projections.Dates = nil
return nil
}
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)
c.Projections = ProjectDates(
now, float64(m.CurrentPrice),
c.CDPR, m.Goals,
)
return nil
return c.project(ctx, m, now)
})
}
err = tasks.Wait()
@ -115,46 +96,49 @@ type Column struct {
Projections Projection
}
func (c *Column) Column() (entries []string) {
entries = append(entries, fmt.Sprintf("$%.2f", c.StartingPrice))
entries = append(entries, fmt.Sprintf("%.2f%%", (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)
func (c *Column) project(ctx context.Context, m *Math, now time.Time) (err error) {
err = c.fillStartingPrice(ctx, m.Asset, now)
if err != nil {
return
}
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
}
var DefaultGoals = []Goal{
{"$100k", 100000},
{"$150k", 150000},
{"$200k", 200000},
{"$250k", 250000},
{"$300k", 300000},
{"$500k", 500000},
{"$1m", 1000000},
func (c *Column) fillStartingPrice(
ctx context.Context, asset coindesk.Asset, now time.Time,
) error {
// if base provides a hardcoded starting price, use it
c.StartingDate = c.Base.From(now)
c.StartingPrice = coindesk.Price(c.Base.GetStartingPrice())
if c.StartingPrice != 0 {
return nil
}
// 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 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},
}
var errEmptyPriceEntries = errors.New("price values response has no entries")
type Goal struct {
Name string `koanf:"name"`
@ -165,12 +149,14 @@ type Goal struct {
type Base interface {
From(now time.Time) time.Time
Label() string
GetStartingPrice() float64
}
// ConstantBase is a base that is a constant time, e.g. 2020-01-01.
type ConstantBase struct {
Name string `koanf:"name"`
Time time.Time `koanf:"time"`
Name string `koanf:"name"`
Time time.Time `koanf:"time"`
StartingPrice float64 `koanf:"startingPrice"`
}
func (cb ConstantBase) From(_ time.Time) time.Time {
@ -181,17 +167,25 @@ func (cb ConstantBase) Label() string {
return cb.Name
}
// RelativeBase is a base that is relative, e.g. "90 days ago."
func (cb ConstantBase) GetStartingPrice() float64 {
return cb.StartingPrice
}
// RelativeBase is a base that is relative, e.g. "90 days ago".
type RelativeBase struct {
Name string `koanf:"name"`
Offset time.Duration `koanf:"offset"`
}
func (rb RelativeBase) From(now time.Time) time.Time {
then := now.Add(time.Duration(rb.Offset))
then := now.Add(rb.Offset)
return then
}
func (rb RelativeBase) Label() string {
return rb.Name
}
func (rb RelativeBase) GetStartingPrice() float64 {
return 0
}

View File

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

View File

@ -30,7 +30,7 @@ type Msg struct {
inner tea.Msg
}
func New(cfg config.Data) (m Model) {
func New(cfg config.Asset) (m Model) {
m.math = moon.NewMath(
cfg.Asset,
cfg.Goals,
@ -89,10 +89,6 @@ func New(cfg config.Data) (m Model) {
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,
@ -102,6 +98,10 @@ func (m Model) Init() tea.Cmd {
)
}
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:
@ -112,18 +112,19 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, tea.Batch(
m.resumeIndicator,
m.refresh,
m.scheduleRefresh(),
)
case update:
commands := []tea.Cmd{m.scheduleRefresh()}
var cmd tea.Cmd
if msg.err == nil {
m.math = msg.math
refillProperties(&m)
refillProjections(&m)
commands = append(commands, m.stopIndicator())
cmd = m.stopIndicator()
} else {
m.indicator.Style = indicatorErrorStyle
}
return m, tea.Batch(commands...)
return m, cmd
case stopIndicator:
m.refreshing = false
return m, nil
@ -147,9 +148,9 @@ type update struct {
type stopIndicator struct{}
func (m Model) refresh() tea.Msg {
ctx, cancel := context.WithDeadline(
ctx, cancel := context.WithTimeout(
context.Background(),
time.Now().Add(time.Second*15))
refreshTimeout)
defer cancel()
err := m.math.Refresh(ctx)
if err != nil {
@ -166,7 +167,7 @@ func (m Model) resumeIndicator() tea.Msg {
}
func (m Model) scheduleRefresh() tea.Cmd {
return tea.Tick(time.Second*30,
return tea.Tick(refreshInterval,
func(t time.Time) tea.Msg {
return Msg{m.math.Asset, refresh{}}
})
@ -175,12 +176,16 @@ func (m Model) scheduleRefresh() tea.Cmd {
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,
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)},
@ -190,14 +195,34 @@ func refillProperties(m *Model) {
}
func refillProjections(m *Model) {
rows := []table.Row{m.math.Labels}
cols := []table.Row{m.math.Labels}
for i := range m.math.Columns {
rows = append(rows, m.math.Columns[i].Column())
entries := renderEntries(m.math.Columns[i])
cols = append(cols, entries)
}
rows = transpose(rows)
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)

View File

@ -22,7 +22,7 @@ func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
func (m Model) Update(_ tea.Msg) (Model, tea.Cmd) {
return m, nil
}

View File

@ -17,17 +17,16 @@ type Model struct {
displayStats bool
}
func New(assets []coindesk.Asset, cfg config.All, displayStats bool) (m Model) {
func New(assets []coindesk.Asset, cfg config.Root, displayStats bool) (m Model) {
m.stats = perf.New()
m.displayStats = displayStats
// construct models for each asset, but don't filter out dupes
// construct models for each asset, but remove dupes
seen := map[coindesk.Asset]struct{}{}
for _, a := range assets {
_, ok := seen[a]
if ok {
if _, ok := seen[a]; ok {
continue
}
assetCfg := cfg.GetData(a)
assetCfg := cfg.ForAsset(a)
assetModel := asset.New(assetCfg)
m.assets = append(m.assets, assetModel)
seen[a] = struct{}{}