moonmath/moon/moon.go
Sam Fredrickson f67323c5f4
All checks were successful
Build & Test / Main (push) Successful in 1m0s
Support hardcoded starting prices.
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

192 lines
4.1 KiB
Go

package moon
import (
"context"
"errors"
"fmt"
"math"
"time"
"code.humancabbage.net/sam/moonmath/coindesk"
"github.com/sourcegraph/conc/pool"
)
type Math struct {
Asset coindesk.Asset
CurrentPrice coindesk.Price
Columns []Column
Goals []Goal
Labels []string
}
func NewMath(asset coindesk.Asset, goals []Goal, bases []Base) (m Math) {
if goals == nil || bases == nil {
panic("goals and bases must be set")
}
m.Asset = asset
m.Goals = goals
m.Labels = []string{"Starting", "CDPR"}
m.Columns = make([]Column, len(bases))
for i := range bases {
m.Columns[i].Base = bases[i]
}
for i := range goals {
m.Labels = append(m.Labels, goals[i].Name)
}
return
}
func CDPR(days, gain float64) float64 {
if gain <= 0 {
return 0
}
cdpr := math.Pow(gain, 1/days)
return cdpr
}
type Projection struct {
Dates []time.Time
}
func ProjectDates(
from time.Time, currentPrice float64, cdpr float64, goals []Goal,
) (p Projection) {
if cdpr <= 0 {
return
}
logP := math.Log(currentPrice)
logR := math.Log(cdpr)
for _, goal := range goals {
daysToGo := (math.Log(goal.Value) - logP) / logR
date := from.Add(time.Hour * 24 * time.Duration(daysToGo))
p.Dates = append(p.Dates, date)
}
return
}
func (m *Math) Refresh(ctx context.Context) (err error) {
resp, err := coindesk.GetAssetTickers(ctx, m.Asset)
if err != nil {
return
}
m.CurrentPrice = resp.Data[m.Asset].OHLC.Closing
tasks := pool.New().WithErrors()
tasks.WithMaxGoroutines(len(m.Columns))
//tasks.WithMaxGoroutines(1)
now := time.Now()
for i := range m.Columns {
c := &m.Columns[i]
tasks.Go(func() error {
return c.project(ctx, m, now)
})
}
err = tasks.Wait()
return
}
type Column struct {
Base Base
StartingDate time.Time
StartingPrice coindesk.Price
Gain float64
CDPR float64
Projections Projection
}
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
}
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 errEmptyPriceEntries = errors.New("price values response has no entries")
type Goal struct {
Name string `koanf:"name"`
Value float64 `koanf:"value"`
}
// Base is a temporal point of comparison used for price projection.
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"`
StartingPrice float64 `koanf:"startingPrice"`
}
func (cb ConstantBase) From(_ time.Time) time.Time {
return cb.Time
}
func (cb ConstantBase) Label() string {
return cb.Name
}
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))
return then
}
func (rb RelativeBase) Label() string {
return rb.Name
}
func (rb RelativeBase) GetStartingPrice() float64 {
return 0
}