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 }