package ledger import ( "fmt" "maps" "math" "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 { latest := make(Balance) for _, d := range reg.Dates() { if _, ok := reg[d][name]; ok { latest = reg[d][name] } } for _, predictionTime := range predictionTimes(window) { k := predictionTime.Format("2006-01") if _, ok := reg[k]; !ok { reg[k] = make(Balances) } reg[k][name] = maps.Clone(latest) } for c := range latest { 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 || time.Now().Before(t) { // only include -6months..now continue } if v, ok := reg[d][name][currency]; ok && (len(contributions) == 0 || contributions[len(contributions)-1].v != v) { 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) < 5 { 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] } contributionsSlice := func(percent float64) []contribution { wouldBe := int(percent * float64(len(contributions))) if wouldBe == 0 { wouldBe = 2 } return contributions[len(contributions)-1-wouldBe:] } eighth := contributionsSlice(7.0 / 8.0) quarter := contributionsSlice(3.0 / 4.0) half := contributionsSlice(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] }() for _, predictionTime := range predictionTimes(window) { k := predictionTime.Format("2006-01") expectedDelta := float64(predictionTime.Sub(time.Now())) * medianValueDelta / float64(medianLapse) reg[k][name][currency] += expectedDelta } return nil } func RegisterWithCompoundingInterestPrediction(reg Register, window time.Duration, pattern string, apy float64) (Register, error) { lastBalances := make(Balances) p := regexp.MustCompile(pattern) for _, d := range reg.Dates() { if t, _ := dateToTime(d); time.Now().Before(t) { continue } for name := range reg[d] { if p.MatchString(name) { lastBalances[name] = reg[d][name] } } } predictedTimes := predictionTimes(window) result := maps.Clone(reg) for _, predictionTime := range predictedTimes { k := predictionTime.Format("2006-01") if _, ok := result[k]; !ok { result[k] = make(Balances) } for k2, v2 := range lastBalances { if _, ok := result[k][k2]; !ok { result[k][k2] = maps.Clone(v2) } } } addedSoFar := make(Balances) for _, predictionTime := range predictedTimes { k := predictionTime.Format("2006-01") for name := range lastBalances { if _, ok := addedSoFar[name]; !ok { addedSoFar[name] = make(Balance) } for currency := range result[k][name] { // A = P(1 + r/n)**nt p := result[k][name][currency] + addedSoFar[name][currency] r := apy n := 12.0 t := 1.0 / 12.0 result[k][name][currency] = p * math.Pow(1.0+(r/n), n*t) addedSoFar[name][currency] += (result[k][name][currency] - p) } } } 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) }