package ledger import ( "fmt" "maps" "regexp" "slices" "sort" "time" ) func RegisterWithContributionPrediction(reg Register, window time.Duration) (Register, error) { result := make(Register) result.PushAll(reg) for _, name := range result.Names() { err := registerWithContributionPredictionForName(result, window, name) if err != nil { return nil, err } } return result, nil } func registerWithContributionPredictionForName(reg Register, window time.Duration, name string) error { currencies := make(map[Currency]int) for d := range reg { for c := range reg[d][name] { currencies[c] = 1 } } for c := range currencies { err := registerWithContributionPredictionForNameForCurrency(reg, window, name, c) if err != nil { return err } } return nil } func registerWithContributionPredictionForNameForCurrency(reg Register, window time.Duration, name string, currency Currency) error { type contribution struct { t time.Time v float64 } contributions := make([]contribution, 0) for d := range reg { t, err := dateToTime(d) if err != nil { return err } if time.Since(t) > time.Hour*24*180 { continue } if v, ok := reg[d][name][currency]; ok { contributions = append(contributions, contribution{t: t, v: v}) } } sort.Slice(contributions, func(i, j int) bool { return contributions[i].t.Before(contributions[j].t) }) if len(contributions) < 3 { return nil } getMedianValueDelta := func(contributions []contribution) float64 { values := make([]float64, len(contributions)) for i := 1; i < len(contributions); i++ { values[i] = contributions[i].v - contributions[i-1].v } slices.Sort(values) return values[len(values)/2] } getMedianLapse := func(contributions []contribution) time.Duration { lapses := make([]time.Duration, len(contributions)-1) for i := 1; i < len(contributions); i++ { lapses = append(lapses, contributions[i].t.Sub(contributions[i-1].t)) } slices.Sort(lapses) return lapses[len(lapses)/2] } contributsSlice := func(percent float64) []contribution { wouldBe := int(percent * float64(len(contributions))) if wouldBe == 0 { wouldBe = 2 } return contributions[len(contributions)-1-wouldBe:] } eighth := contributsSlice(7.0 / 8.0) quarter := contributsSlice(3.0 / 4.0) half := contributsSlice(1.0 / 2.0) medianValueDelta := func() float64 { medians := []float64{ getMedianValueDelta(eighth), getMedianValueDelta(quarter), getMedianValueDelta(half), } slices.Sort(medians) return medians[1] }() medianLapse := func() time.Duration { medians := []time.Duration{ getMedianLapse(eighth), getMedianLapse(quarter), getMedianLapse(half), } slices.Sort(medians) return medians[1] }() latest := func() float64 { last := 0.0 for _, d := range reg.Dates() { last = reg[d][name][currency] } return last }() for _, predictionTime := range predictionTimes(window) { k := predictionTime.Format("2006-01") if _, ok := reg[k]; !ok { reg[k] = make(Balances) } if _, ok := reg[k][name]; !ok { reg[k][name] = make(Balance) } expectedDelta := float64(predictionTime.Sub(time.Now())) * medianValueDelta / float64(medianLapse) reg[k][name][currency] = latest + expectedDelta } return nil } func BPIsWithFixedGrowthPrediction(bpis BPIs, window time.Duration, pattern string, apy float64) (BPIs, error) { last := map[Currency]struct { t string v float64 }{} for currency, bpi := range bpis { for date, value := range bpi { if date > last[currency].t { was := last[currency] was.t = date was.v = value last[currency] = was } } } result := make(BPIs) p := regexp.MustCompile(pattern) for currency, v := range bpis { result[currency] = maps.Clone(v) if p.MatchString(string(currency)) { for _, predictionTime := range predictionTimes(window) { k2 := predictionTime.Format("2006-01") was := last[currency] was.v *= 1.0 + apy result[currency][k2] = was.v last[currency] = was } } } return result, nil } func RegisterWithCompoundingInterestPrediction(reg Register, window time.Duration, pattern string, apy float64) (Register, error) { lastBalances := make(Balances) p := regexp.MustCompile(pattern) for _, date := range reg.Dates() { for name := range reg[date] { if p.MatchString(name) { lastBalances[name] = reg[date][name] } } } result := maps.Clone(reg) for _, predictionTime := range predictionTimes(window) { k := predictionTime.Format("2006-01") if _, ok := result[k]; !ok { result[k] = make(Balances) } for name, balance := range lastBalances { balance2 := maps.Clone(balance) for k := range balance2 { balance2[k] *= 1.0 + (apy / 12) } result[k][name] = balance2 lastBalances[name] = balance2 } } return result, nil } func predictionTimes(window time.Duration) []time.Time { result := []time.Time{} last := time.Now() for last.Before(time.Now().Add(window)) { last = last.Add(-1 * time.Hour * 24 * time.Duration(last.Day())).Add(time.Hour * 24 * 33) result = append(result, last) } return result } func dateToTime(s string) (time.Time, error) { for _, layout := range []string{ "2006-01-02", "2006-01", } { if t, err := time.ParseInLocation(layout, s, time.Local); err == nil { return t, err } } return time.Time{}, fmt.Errorf("no layout matching %q", s) }