5 Commits

Author SHA1 Message Date
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
7 changed files with 157 additions and 109 deletions

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

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

View File

@@ -28,6 +28,12 @@ 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:
@@ -44,6 +50,11 @@ 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: LTC:
goals: goals:
- name: $100 - name: $100

View File

@@ -2,6 +2,7 @@ package moon
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"math" "math"
"time" "time"
@@ -75,30 +76,10 @@ 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 {
c.StartingDate = c.Base.From(now) return c.project(ctx, m, 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
}) })
} }
err = tasks.Wait() err = tasks.Wait()
@@ -115,46 +96,49 @@ type Column struct {
Projections Projection Projections Projection
} }
func (c *Column) Column() (entries []string) { func (c *Column) project(ctx context.Context, m *Math, now time.Time) (err error) {
entries = append(entries, fmt.Sprintf("$%.2f", c.StartingPrice)) err = c.fillStartingPrice(ctx, m.Asset, now)
entries = append(entries, fmt.Sprintf("%.2f%%", (c.CDPR-1)*100)) if err != nil {
never := c.CDPR <= 1 return
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
} }
var DefaultGoals = []Goal{ func (c *Column) fillStartingPrice(
{"$100k", 100000}, ctx context.Context, asset coindesk.Asset, now time.Time,
{"$150k", 150000}, ) error {
{"$200k", 200000}, // if base provides a hardcoded starting price, use it
{"$250k", 250000}, c.StartingDate = c.Base.From(now)
{"$300k", 300000}, c.StartingPrice = coindesk.Price(c.Base.GetStartingPrice())
{"$500k", 500000}, if c.StartingPrice != 0 {
{"$1m", 1000000}, 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{ var errEmptyPriceEntries = errors.New("price values response has no entries")
{"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"`
@@ -165,12 +149,14 @@ 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 {
@@ -181,7 +167,11 @@ func (cb ConstantBase) Label() string {
return cb.Name 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 { type RelativeBase struct {
Name string `koanf:"name"` Name string `koanf:"name"`
Offset time.Duration `koanf:"offset"` Offset time.Duration `koanf:"offset"`
@@ -195,3 +185,7 @@ func (rb RelativeBase) From(now time.Time) time.Time {
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,6 +11,14 @@ func TestCDPR(t *testing.T) {
} }
func TestProjection(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 _ = p
} }

View File

@@ -30,7 +30,7 @@ type Msg struct {
inner tea.Msg inner tea.Msg
} }
func New(cfg config.Data) (m Model) { func New(cfg config.Asset) (m Model) {
m.math = moon.NewMath( m.math = moon.NewMath(
cfg.Asset, cfg.Asset,
cfg.Goals, cfg.Goals,
@@ -89,10 +89,6 @@ func New(cfg config.Data) (m Model) {
return return
} }
func (m Model) Handles(a coindesk.Asset) bool {
return m.math.Asset == a
}
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return tea.Batch( return tea.Batch(
m.indicator.Tick, 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) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case Msg: case Msg:
@@ -112,18 +112,19 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, tea.Batch( return m, tea.Batch(
m.resumeIndicator, m.resumeIndicator,
m.refresh, m.refresh,
m.scheduleRefresh(),
) )
case update: case update:
commands := []tea.Cmd{m.scheduleRefresh()} var cmd tea.Cmd
if msg.err == nil { if msg.err == nil {
m.math = msg.math m.math = msg.math
refillProperties(&m) refillProperties(&m)
refillProjections(&m) refillProjections(&m)
commands = append(commands, m.stopIndicator()) cmd = m.stopIndicator()
} else { } else {
m.indicator.Style = indicatorErrorStyle m.indicator.Style = indicatorErrorStyle
} }
return m, tea.Batch(commands...) return m, cmd
case stopIndicator: case stopIndicator:
m.refreshing = false m.refreshing = false
return m, nil return m, nil
@@ -147,9 +148,9 @@ type update struct {
type stopIndicator struct{} type stopIndicator struct{}
func (m Model) refresh() tea.Msg { func (m Model) refresh() tea.Msg {
ctx, cancel := context.WithDeadline( ctx, cancel := context.WithTimeout(
context.Background(), context.Background(),
time.Now().Add(time.Second*15)) refreshTimeout)
defer cancel() defer cancel()
err := m.math.Refresh(ctx) err := m.math.Refresh(ctx)
if err != nil { if err != nil {
@@ -166,7 +167,7 @@ func (m Model) resumeIndicator() tea.Msg {
} }
func (m Model) scheduleRefresh() tea.Cmd { func (m Model) scheduleRefresh() tea.Cmd {
return tea.Tick(time.Second*30, return tea.Tick(refreshInterval,
func(t time.Time) tea.Msg { func(t time.Time) tea.Msg {
return Msg{m.math.Asset, refresh{}} return Msg{m.math.Asset, refresh{}}
}) })
@@ -175,12 +176,16 @@ func (m Model) scheduleRefresh() tea.Cmd {
func (m Model) stopIndicator() tea.Cmd { func (m Model) stopIndicator() tea.Cmd {
// wait a bit to stop the indicator, so that it's more obvious // wait a bit to stop the indicator, so that it's more obvious
// even when the refresh completes quickly. // even when the refresh completes quickly.
return tea.Tick(time.Millisecond*500, return tea.Tick(stopIndicatorDelay,
func(t time.Time) tea.Msg { func(t time.Time) tea.Msg {
return Msg{m.math.Asset, stopIndicator{}} 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) { func refillProperties(m *Model) {
rows := []table.Row{ rows := []table.Row{
{"Asset", string(m.math.Asset)}, {"Asset", string(m.math.Asset)},
@@ -190,14 +195,34 @@ func refillProperties(m *Model) {
} }
func refillProjections(m *Model) { func refillProjections(m *Model) {
rows := []table.Row{m.math.Labels} cols := []table.Row{m.math.Labels}
for i := range m.math.Columns { 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) 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 { func transpose(slice []table.Row) []table.Row {
xl := len(slice[0]) xl := len(slice[0])
yl := len(slice) yl := len(slice)

View File

@@ -17,17 +17,16 @@ type Model struct {
displayStats bool 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.stats = perf.New()
m.displayStats = displayStats 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{}{} seen := map[coindesk.Asset]struct{}{}
for _, a := range assets { for _, a := range assets {
_, ok := seen[a] if _, ok := seen[a]; ok {
if ok {
continue continue
} }
assetCfg := cfg.GetData(a) assetCfg := cfg.ForAsset(a)
assetModel := asset.New(assetCfg) assetModel := asset.New(assetCfg)
m.assets = append(m.assets, assetModel) m.assets = append(m.assets, assetModel)
seen[a] = struct{}{} seen[a] = struct{}{}