diff --git a/ana/contributions.go b/ana/contributions.go new file mode 100644 index 0000000..ac9c259 --- /dev/null +++ b/ana/contributions.go @@ -0,0 +1,124 @@ +package ana + +import ( + "maps" + "slices" + "sort" + "time" + + "gogs.inhome.blapointe.com/ana-ledger/ledger" +) + +func RegisterWithContributionPrediction(reg ledger.Register, window time.Duration) (ledger.Register, error) { + result := make(ledger.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 ledger.Register, window time.Duration, name string) error { + latest := make(ledger.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(ledger.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 ledger.Register, window time.Duration, name string, currency ledger.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 +} diff --git a/ana/contributions_test.go b/ana/contributions_test.go new file mode 100644 index 0000000..78b886c --- /dev/null +++ b/ana/contributions_test.go @@ -0,0 +1,53 @@ +package ana + +import ( + "testing" + "time" + + "gogs.inhome.blapointe.com/ana-ledger/ledger" +) + +func TestRegisterPrediction(t *testing.T) { + t.Run("contribution", func(t *testing.T) { + input := newTestRegister() + + got, err := RegisterWithContributionPrediction(input, time.Hour*24*365) + if err != nil { + t.Fatal(err) + } + t.Logf("%+v", got) + + if len(got) != len(input)+13 { + t.Error(len(got)) + } + + for _, date := range got.Dates() { + for _, name := range got.Names() { + t.Logf("%s | %s = %+v", date, name, got[date][name]) + if ti, _ := dateToTime(date); ti.After(time.Now().Add(time.Hour*24*60)) && got[date][name]["XYZ"] == 0 { + t.Error("predicting future contributions lost unmodified currency", got[date][name]) + } + } + } + }) +} + +func newTestRegister() map[string]ledger.Balances { + s := func(t time.Time) string { + return t.Format("2006-01") + } + day := time.Hour * 24 + lastYear := time.Now().Add(-1 * day * time.Duration(time.Now().YearDay())) + return map[string]ledger.Balances{ + s(lastYear.Add(day * 0)): ledger.Balances{"X": ledger.Balance{ledger.USD: 1}}, + s(lastYear.Add(day * 32)): ledger.Balances{"X": ledger.Balance{ledger.USD: 2}}, + s(lastYear.Add(day * 64)): ledger.Balances{"X": ledger.Balance{ledger.USD: 3}}, + s(lastYear.Add(day * 94)): ledger.Balances{"X": ledger.Balance{ledger.USD: 4}}, + s(lastYear.Add(day * 124)): ledger.Balances{"X": ledger.Balance{ledger.USD: 5}}, + s(lastYear.Add(day * 154)): ledger.Balances{"X": ledger.Balance{ledger.USD: 6}}, + s(lastYear.Add(day * 184)): ledger.Balances{"X": ledger.Balance{ledger.USD: 8}}, + s(lastYear.Add(day * 214)): ledger.Balances{"X": ledger.Balance{ledger.USD: 10}}, + s(lastYear.Add(day * 244)): ledger.Balances{"X": ledger.Balance{ledger.USD: 12}}, + s(lastYear.Add(day * 274)): ledger.Balances{"X": ledger.Balance{ledger.USD: 16, "XYZ": 1}}, + } +} diff --git a/ana/window.go b/ana/window.go index 4ef1b9c..b69ea8c 100644 --- a/ana/window.go +++ b/ana/window.go @@ -1,6 +1,9 @@ package ana -import "time" +import ( + "fmt" + "time" +) func predictionTimes(window time.Duration) []time.Time { result := []time.Time{} @@ -11,3 +14,23 @@ func predictionTimes(window time.Duration) []time.Time { } return result } + +func mustDateToTime(s string) time.Time { + result, err := dateToTime(s) + if err != nil { + panic(err) + } + 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) +} diff --git a/ledger/predict.go b/ledger/predict.go index 5635f7c..0a1291d 100644 --- a/ledger/predict.go +++ b/ledger/predict.go @@ -5,125 +5,9 @@ import ( "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) diff --git a/ledger/predict_test.go b/ledger/predict_test.go index 39c7720..f92c5d8 100644 --- a/ledger/predict_test.go +++ b/ledger/predict_test.go @@ -6,29 +6,6 @@ import ( ) func TestRegisterPrediction(t *testing.T) { - t.Run("contribution", func(t *testing.T) { - input := newTestRegister() - - got, err := RegisterWithContributionPrediction(input, time.Hour*24*365) - if err != nil { - t.Fatal(err) - } - t.Logf("%+v", got) - - if len(got) != len(input)+13 { - t.Error(len(got)) - } - - for _, date := range got.Dates() { - for _, name := range got.Names() { - t.Logf("%s | %s = %+v", date, name, got[date][name]) - if ti, _ := dateToTime(date); ti.After(time.Now().Add(time.Hour*24*60)) && got[date][name]["XYZ"] == 0 { - t.Error("predicting future contributions lost unmodified currency", got[date][name]) - } - } - } - }) - t.Run("compounding interest", func(t *testing.T) { input := newTestRegister()