mv /ana, /ledger to /src/

This commit is contained in:
bel
2023-10-28 09:29:39 -06:00
parent ec6d868ff7
commit ea13bf7e4a
35 changed files with 12 additions and 11 deletions

42
src/ana/legacy_bpi.go Normal file
View File

@@ -0,0 +1,42 @@
package ana
import (
"maps"
"regexp"
"time"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
)
func BPIsWithFixedGrowthPrediction(bpis ledger.BPIs, window time.Duration, pattern string, apy float64) (ledger.BPIs, error) {
last := map[ledger.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(ledger.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 / 12.0)
result[currency][k2] = was.v
last[currency] = was
}
}
}
return result, nil
}

View File

@@ -0,0 +1,35 @@
package ana
import (
"slices"
"testing"
"time"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
)
func TestBPIPrediction(t *testing.T) {
t.Run("fixed growth", func(t *testing.T) {
bpis := ledger.BPIs{
ledger.USD: ledger.BPI{
"2001-01": -1000,
"2001-02": 100,
},
}
got, err := BPIsWithFixedGrowthPrediction(bpis, time.Hour*24*365, string(ledger.USD), 0.06)
if err != nil {
t.Fatal(err)
}
dates := []string{}
for d := range got[ledger.USD] {
dates = append(dates, d)
}
slices.Sort(dates)
for _, d := range dates {
t.Logf("%s | %s %.2f", ledger.USD, d, got[ledger.USD][d])
}
})
}

45
src/ana/prediction.go Normal file
View File

@@ -0,0 +1,45 @@
package ana
import (
"maps"
"time"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
)
type Prediction []Predictor
func NewPrediction(predictor ...Predictor) Prediction {
return Prediction(predictor)
}
func (prediction Prediction) Predict(register ledger.Register, window time.Duration) ledger.Register {
return prediction.predict(register.Latest(), time.Now(), predictionTimes(window))
}
func (prediction Prediction) predict(latest ledger.Balances, from time.Time, these []time.Time) ledger.Register {
latestT := from
result := make(ledger.Register)
for i := range these {
k := these[i].Format("2006-01")
result[k] = make(ledger.Balances)
for k2, v2 := range latest {
result[k][k2] = maps.Clone(v2)
}
elapsed := these[i].Sub(latestT)
result[k] = prediction.predictOne(result[k], elapsed)
latest = result[k]
latestT = these[i]
}
return result
}
func (prediction Prediction) predictOne(balances ledger.Balances, elapsed time.Duration) ledger.Balances {
for i := range prediction {
next := prediction[i](balances, elapsed)
balances = next
}
return balances
}

121
src/ana/prediction_test.go Normal file
View File

@@ -0,0 +1,121 @@
package ana
import (
"maps"
"testing"
"time"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
)
func TestPredictionPredict(t *testing.T) {
inc := func(b ledger.Balances, _ time.Duration) ledger.Balances {
result := make(ledger.Balances)
for k, v := range b {
result[k] = maps.Clone(v)
for k2, v2 := range result[k] {
result[k][k2] = v2 + 1
}
}
return result
}
double := func(b ledger.Balances, _ time.Duration) ledger.Balances {
result := make(ledger.Balances)
for k, v := range b {
result[k] = maps.Clone(v)
for k2, v2 := range result[k] {
result[k][k2] = v2 * 2
}
}
return result
}
from := time.Now()
month := time.Hour * 24 * 365 / 12
these := []time.Time{time.Now().Add(time.Hour * 24 * 365 / 12)}
theseK := these[0].Format("2006-01")
thisMonth := time.Now().Add(-1 * time.Duration(time.Now().Day()) * time.Hour * 24)
monthsAgo1 := thisMonth.Add(-1 * month).Format("2006-01")
monthsAgo2 := thisMonth.Add(-2 * month).Format("2006-01")
_, _ = inc, double
cases := map[string]struct {
prediction Prediction
given ledger.Balances
want ledger.Register
}{
"empty": {
want: ledger.Register{theseK: {}},
},
"double": {
prediction: Prediction{double},
given: ledger.Balances{"X": ledger.Balance{"X": 2}},
want: ledger.Register{theseK: ledger.Balances{"X": ledger.Balance{"X": 4}}},
},
"inc": {
prediction: Prediction{inc},
given: ledger.Balances{"X": ledger.Balance{"X": 2}},
want: ledger.Register{theseK: ledger.Balances{"X": ledger.Balance{"X": 3}}},
},
"inc, double": {
prediction: Prediction{inc, double},
given: ledger.Balances{"X": ledger.Balance{"X": 5}},
want: ledger.Register{theseK: ledger.Balances{"X": ledger.Balance{"X": 12}}},
},
"double, inc": {
prediction: Prediction{double, inc},
given: ledger.Balances{"X": ledger.Balance{"X": 5}},
want: ledger.Register{theseK: ledger.Balances{"X": ledger.Balance{"X": 11}}},
},
"contribution": {
prediction: Prediction{
NewAutoContributionPredictor(ledger.Register{
"2001-01": ledger.Balances{"X": ledger.Balance{"X": 100}}, // too old
"2001-02": ledger.Balances{"X": ledger.Balance{"X": 10000}}, // too old
monthsAgo2: ledger.Balances{"X": ledger.Balance{"X": 100}},
monthsAgo1: ledger.Balances{"X": ledger.Balance{"X": 700}}, // +600 once in 6 months
}),
},
given: ledger.Balances{"X": ledger.Balance{"X": 5}},
want: ledger.Register{theseK: ledger.Balances{"X": ledger.Balance{"X": 5}}}, // too few contributions
},
"interest": {
prediction: Prediction{
NewInterestPredictor("X", "X", 12),
},
given: ledger.Balances{"X": ledger.Balance{"X": 5}},
want: ledger.Register{theseK: ledger.Balances{"X": ledger.Balance{"X": 10}}},
},
"interest, contribution": {
prediction: Prediction{
NewInterestPredictor("X", "X", 12),
NewAutoContributionPredictor(ledger.Register{
monthsAgo2: ledger.Balances{"X": ledger.Balance{"X": 100}},
monthsAgo1: ledger.Balances{"X": ledger.Balance{"X": 700}},
}),
},
given: ledger.Balances{"X": ledger.Balance{"X": 5}},
want: ledger.Register{theseK: ledger.Balances{"X": ledger.Balance{"X": 10}}}, // too few contributions
},
"contribution, interest": {
prediction: Prediction{
NewAutoContributionPredictor(ledger.Register{
monthsAgo2: ledger.Balances{"X": ledger.Balance{"X": 100}},
monthsAgo1: ledger.Balances{"X": ledger.Balance{"X": 700}},
}),
NewInterestPredictor("X", "X", 12),
},
given: ledger.Balances{"X": ledger.Balance{"X": 5}},
want: ledger.Register{theseK: ledger.Balances{"X": ledger.Balance{"X": 10}}}, // too few contributions
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
got := c.prediction.predict(c.given, from, these)
if got.Debug() != c.want.Debug() {
t.Errorf("want\n\t%+v, got\n\t%+v", c.want.Debug(), got.Debug())
}
})
}
}

159
src/ana/predictor.go Normal file
View File

@@ -0,0 +1,159 @@
package ana
import (
"maps"
"math"
"regexp"
"slices"
"time"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
)
const (
month = time.Hour * 24 * 365 / 12
)
type Predictor func(ledger.Balances, time.Duration) ledger.Balances
func NewInterestPredictor(namePattern string, currencyPattern string, apy float64) Predictor {
nameMatcher := regexp.MustCompile(namePattern)
currencyMatcher := regexp.MustCompile(currencyPattern)
return func(given ledger.Balances, delta time.Duration) ledger.Balances {
result := maps.Clone(given)
for k, v := range result {
result[k] = maps.Clone(v)
}
monthsPassed := float64(delta) / float64(month)
scalar := math.Pow(1.0+apy/12.0, monthsPassed)
for name := range result {
if !nameMatcher.MatchString(name) {
continue
}
for currency := range result[name] {
if !currencyMatcher.MatchString(string(currency)) {
continue
}
result[name][currency] *= scalar
}
}
return result
}
}
func NewAutoContributionPredictor(reg ledger.Register) Predictor {
monthlyRate := getMonthlyAutoContributionRates(reg)
return NewContributionPredictor(monthlyRate)
}
func NewContributionPredictor(monthlyRate ledger.Balances) Predictor {
return func(given ledger.Balances, delta time.Duration) ledger.Balances {
months := float64(delta) / float64(month)
result := make(ledger.Balances)
for k, v := range given {
result[k] = maps.Clone(v)
}
for name := range monthlyRate {
if _, ok := result[name]; !ok {
result[name] = make(ledger.Balance)
}
for currency := range monthlyRate[name] {
result[name][currency] += monthlyRate[name][currency] * months
}
}
return result
}
}
func getMonthlyAutoContributionRates(reg ledger.Register) map[string]ledger.Balance {
window := 6 * month
window = 12 * month
contributions := getRecentContributions(reg, window)
result := make(map[string]ledger.Balance)
for name := range contributions {
result[name] = getMonthlyAutoContributionRate(contributions[name], window)
}
return result
}
func getMonthlyAutoContributionRate(contributions []ledger.Balance, window time.Duration) ledger.Balance {
currencies := map[ledger.Currency]int{}
for _, balance := range contributions {
for currency := range balance {
currencies[currency] = 1
}
}
result := make(ledger.Balance)
for currency := range currencies {
result[currency] = getMonthlyAutoContributionRateForCurrency(contributions, window, currency)
}
return result
}
func getMonthlyAutoContributionRateForCurrency(contributions []ledger.Balance, window time.Duration, currency ledger.Currency) float64 {
values := []float64{}
for i := range contributions {
if v, ok := contributions[i][currency]; ok {
values = append(values, v)
}
}
slices.Sort(values)
if len(values) < 3 {
return 0
}
start := int(len(values) / 4)
end := min(1+max(0, len(values)-len(values)/4), len(values))
subvalues := values[start:end]
sum := 0.0
for _, v := range subvalues {
sum += v
}
standard := sum / float64(end-start)
standard = subvalues[len(subvalues)/2]
totalIfAllWereStandard := standard * float64(len(contributions))
result := totalIfAllWereStandard / float64(window) * float64(month)
//log.Printf("%s: %v contributions of about %.2f over %v can TLDR as %.2f per month from %v", currency, len(contributions), standard, window, result, values[start:end])
return result
}
func getRecentContributions(reg ledger.Register, window time.Duration) map[string][]ledger.Balance {
return getContributions(reg.Between(time.Now().Add(-1*window), time.Now()))
}
func getContributions(reg ledger.Register) map[string][]ledger.Balance {
contributions := make(map[string][]ledger.Balance)
for _, name := range reg.Names() {
contributions[name] = getContributionsFor(reg, name)
}
return contributions
}
func getContributionsFor(reg ledger.Register, name string) []ledger.Balance {
result := make([]ledger.Balance, 0)
var last ledger.Balance
for _, date := range reg.Dates() {
if _, ok := reg[date][name]; !ok {
continue
}
if last == nil {
last = reg[date][name]
continue
}
result = append(result, make(ledger.Balance))
this := result[len(result)-1]
for k, v := range last {
this[k] = -1 * v
}
for k, v := range reg[date][name] {
this[k] += v
}
last = reg[date][name]
}
return result
}

129
src/ana/predictor_test.go Normal file
View File

@@ -0,0 +1,129 @@
package ana
import (
"testing"
"time"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
)
func TestNewInterestPredictor(t *testing.T) {
acc := "x"
curr := ledger.USD
cases := map[string]struct {
apy float64
given ledger.Balances
delta time.Duration
want ledger.Balances
}{
"zero": {
apy: 0,
given: ledger.Balances{acc: ledger.Balance{curr: 100}},
delta: time.Hour * 24 * 365,
want: ledger.Balances{acc: ledger.Balance{curr: 100}},
},
"50%": {
apy: .5,
given: ledger.Balances{acc: ledger.Balance{curr: 100}},
delta: time.Hour * 24 * 365,
want: ledger.Balances{acc: ledger.Balance{curr: 163.21}},
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
predictor := NewInterestPredictor(acc, string(curr), c.apy)
got := predictor(c.given, c.delta)
if got.Debug() != c.want.Debug() {
t.Errorf("want\n\t%+v, got\n\t%+v", c.want, got)
}
})
}
}
func TestGetContributions(t *testing.T) {
input := ledger.Register{
"2001-01": ledger.Balances{
"a": ledger.Balance{"x": 1},
"b": ledger.Balance{"z": 1},
},
"2001-02": ledger.Balances{
"a": ledger.Balance{"y": 1},
},
"2001-03": ledger.Balances{
"b": ledger.Balance{"z": 4},
},
}
got := getContributions(input)
t.Logf("%+v", got)
if len(got) != 2 {
t.Error(len(got))
}
if len(got["a"]) != 1 {
t.Error(len(got["a"]))
} else if len(got["a"][0]) != 2 {
t.Error(got["a"])
} else if got["a"][0]["x"] != -1 {
t.Error(got["a"][0])
} else if got["a"][0]["y"] != 1 {
t.Error(got["a"][0])
}
if len(got["b"]) != 1 {
t.Error(len(got["b"]))
} else if len(got["b"][0]) != 1 {
t.Error(got["b"])
} else if got["b"][0]["z"] != 3 {
t.Error(got["b"][0])
}
}
func TestGetMonthlyAutoContributionRate(t *testing.T) {
input := []ledger.Balance{
ledger.Balance{"x": 2},
ledger.Balance{"x": 4},
ledger.Balance{"y": 3},
}
got := getMonthlyAutoContributionRate(input, time.Hour*24*365/4)
if len(got) != 2 {
t.Error(got)
}
if got["x"] != 0 { // too few contribution
t.Error(got["x"])
}
if got["y"] != 0 { // too few contribution
t.Error(got["y"])
}
}
func TestNewContributionPredictor(t *testing.T) {
name := "x"
currency := ledger.USD
predictor := NewContributionPredictor(map[string]ledger.Balance{
name: {currency: 10},
"y": {"XYZ": 3},
})
month := time.Hour * 24 * 365 / 12
if got := predictor(ledger.Balances{}, 2*month); got[name][currency] != 20 {
t.Error(got[name])
} else if got["y"]["XYZ"] != 6 {
t.Error(got["y"])
}
if got := predictor(ledger.Balances{name: {currency: 30}}, 2*month); got[name][currency] != 30+20 {
t.Error(got)
} else if got["y"]["XYZ"] != 6 {
t.Error(got["y"])
}
if got := predictor(ledger.Balances{"z": {"ABC": 100}}, 2*month); got[name][currency] != 20 {
t.Error(got)
} else if got["y"]["XYZ"] != 6 {
t.Error(got["y"])
} else if got["z"]["ABC"] != 100 {
t.Error(got["z"])
}
}

36
src/ana/window.go Normal file
View File

@@ -0,0 +1,36 @@
package ana
import (
"fmt"
"time"
)
func predictionTimes(window time.Duration) []time.Time {
result := []time.Time{time.Now()}
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 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)
}

92
src/ledger/balances.go Normal file
View File

@@ -0,0 +1,92 @@
package ledger
import (
"fmt"
"maps"
"regexp"
"strings"
)
type Balances map[string]Balance
type Balance map[Currency]float64
func (balances Balances) Like(pattern string) Balances {
result := make(Balances)
p := regexp.MustCompile(pattern)
for k, v := range balances {
if p.MatchString(k) {
result[k] = maps.Clone(v)
}
}
return result
}
func (balances Balances) WithBPIs(bpis BPIs) Balances {
return balances.WithBPIsAt(bpis, "9")
}
func (balances Balances) WithBPIsAt(bpis BPIs, date string) Balances {
result := make(Balances)
for k, v := range balances {
if _, ok := result[k]; !ok {
result[k] = make(Balance)
}
for k2, v2 := range v {
scalar := 1.0
if k2 != USD {
scalar = bpis[k2].Lookup(date)
}
result[k][USD] += v2 * scalar
}
}
return result
}
func (balances Balances) PushAll(other Balances) {
for k, v := range other {
if _, ok := balances[k]; !ok {
balances[k] = make(Balance)
}
for k2, v2 := range v {
if _, ok := balances[k][k2]; !ok {
balances[k][k2] = 0
}
balances[k][k2] += v2
}
}
}
func (balances Balances) Push(d Delta) {
if _, ok := balances[d.Name]; !ok {
balances[d.Name] = make(Balance)
}
balances[d.Name].Push(d)
}
func (balance Balance) Push(d Delta) {
if _, ok := balance[d.Currency]; !ok {
balance[d.Currency] = 0
}
balance[d.Currency] += d.Value
}
func (balances Balances) Debug() string {
result := []string{}
for k, v := range balances {
result = append(result, fmt.Sprintf("%s:[%s]", k, v.Debug()))
}
return strings.Join(result, " ")
}
func (balance Balance) Debug() string {
result := []string{}
for k, v := range balance {
if k == USD {
result = append(result, fmt.Sprintf("%s%.2f", k, v))
} else {
result = append(result, fmt.Sprintf("%.3f %s", v, k))
}
}
return strings.Join(result, " + ")
}

View File

@@ -0,0 +1,54 @@
package ledger
import "testing"
func TestBalances(t *testing.T) {
t.Run("push", func(t *testing.T) {
b := make(Balances)
b.Push(Delta{Name: "x", Currency: "y", Value: 0.1})
b.Push(Delta{Name: "x", Currency: "y", Value: 1.2})
b.Push(Delta{Name: "x", Currency: "z", Value: 2.3})
ba := b["x"]
if ba["y"] != 1.3 {
t.Error(ba["y"])
}
if ba["z"] != 2.3 {
t.Error(ba["z"])
}
})
t.Run("pushall", func(t *testing.T) {
a := make(Balances)
a.Push(Delta{Name: "a", Currency: USD, Value: 0.1})
a.Push(Delta{Name: "ab", Currency: USD, Value: 1.2})
b := make(Balances)
b.Push(Delta{Name: "b", Currency: USD, Value: 2.3})
b.Push(Delta{Name: "ab", Currency: USD, Value: 3.4})
b.PushAll(a)
if len(a) != 2 {
t.Error("modified original", len(a), a)
}
if a["a"][USD] != 0.1 {
t.Error("modified original a", a["a"])
}
if a["ab"][USD] != 1.2 {
t.Error("modified original ab", a["ab"])
}
if len(b) != 3 {
t.Error("didnt union names", len(b), b)
}
if b["a"][USD] != 0.1 {
t.Error("didnt pull other unique", b["a"])
}
if b["b"][USD] != 2.3 {
t.Error("didnt retain unique", b["b"])
}
if b["ab"][USD] != 4.6 {
t.Error("didnt sum other", b["ab"])
}
})
}

64
src/ledger/bpi.go Normal file
View File

@@ -0,0 +1,64 @@
package ledger
import (
"bufio"
"bytes"
"io"
"os"
"strconv"
)
type BPIs map[Currency]BPI
type BPI map[string]float64
func NewBPIs(p string) (BPIs, error) {
f, err := os.Open(p)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
defer f.Close()
result := make(map[Currency]BPI)
r := bufio.NewReader(f)
for {
line, err := readTransactionLine(r)
if len(line) > 0 {
fields := bytes.Fields(line)
if len(fields) > 3 {
date := string(fields[1])
currency := Currency(fields[len(fields)-2])
value, err := strconv.ParseFloat(string(fields[len(fields)-1][1:]), 64)
if err != nil {
return nil, err
}
if _, ok := result[currency]; !ok {
result[currency] = make(BPI)
}
result[currency][date] = value
}
}
if err == io.EOF {
return result, nil
} else if err != nil {
return result, err
}
}
}
func (bpi BPI) Lookup(date string) float64 {
var closestWithoutGoingOver string
for k := range bpi {
if k <= date && k > closestWithoutGoingOver {
closestWithoutGoingOver = k
}
}
if closestWithoutGoingOver == "" {
return 0
}
return bpi[closestWithoutGoingOver]
}

173
src/ledger/bpi_test.go Normal file
View File

@@ -0,0 +1,173 @@
package ledger
import (
"os"
"path"
"testing"
)
func TestBPIs(t *testing.T) {
d := t.TempDir()
p := path.Join(d, "bpi.dat")
os.WriteFile(p, []byte(`
P 2019-08-08 09:58:35 GME $151.77
P 2021-08-22 07:02:46 GME $159.30
P 2021-08-24 11:05:51 GME $164.89
P 2021-08-25 12:27:03 GME $210.29
P 2021-08-25 15:43:06 GME $210.29
P 2021-08-26 12:22:37 GME $199.65
P 2021-08-30 07:11:21 GME $204.95
P 2021-08-30 07:11:33 GME $204.95
P 2021-09-01 13:32:01 GME $218.24
P 2021-09-08 08:56:22 GME $199.00
P 2021-09-10 09:21:08 GME $199.18
P 2021-09-21 10:37:09 GME $192.20
P 2021-09-29 06:41:32 GME $178.60
P 2021-10-25 19:55:43 GME $173.97
P 2021-10-27 17:29:22 GME $173.51
P 2021-10-28 21:26:18 GME $182.85
P 2021-11-06 13:32:31 GME $213.25
P 2021-11-09 09:13:39 GME $218.64
P 2021-11-09 09:18:24 GME $218.64
P 2021-11-23 10:13:23 GME $247.55
P 2021-12-09 13:57:02 GME $173.65
P 2021-12-09 13:58:38 GME $173.65
P 2022-03-07 14:16:43 GME $111.66
P 2022-03-17 11:22:43 GME $86.86
P 2022-03-24 15:46:46 GME $142.38
P 2022-03-30 09:31:32 GME $179.90
P 2022-04-02 15:32:55 GME $165.00
P 2022-04-02 15:33:16 GME $165.00
P 2022-04-02 15:33:18 GME $165.00
P 2022-04-02 15:34:52 GME $165.00
P 2022-04-02 15:36:31 GME $165.00
P 2022-04-02 15:36:44 GME $165.00
P 2022-04-02 15:37:14 GME $165.00
P 2022-04-02 15:49:49 GME $165.00
P 2022-04-02 15:53:54 GME $165.00
P 2022-04-02 15:56:34 GME $165.00
P 2022-04-02 15:56:51 GME $165.00
P 2022-04-02 15:57:48 GME $165.00
P 2022-04-02 15:57:57 GME $165.00
P 2022-04-02 15:58:13 GME $165.00
P 2022-04-02 15:59:48 GME $165.00
P 2022-04-02 16:01:35 GME $165.00
P 2022-04-02 16:04:25 GME $165.00
P 2022-04-02 16:04:31 GME $165.00
P 2022-04-02 16:07:43 GME $165.00
P 2022-04-02 16:07:47 GME $165.00
P 2022-04-02 16:12:41 GME $165.00
P 2022-04-02 16:13:17 GME $165.00
P 2022-04-02 16:13:29 GME $165.00
P 2022-04-02 16:18:19 GME $165.00
P 2022-04-02 16:19:01 GME $165.00
P 2022-04-06 08:12:08 GME $153.59
P 2022-04-24 13:29:11 GME $138.22
P 2022-04-26 12:37:28 GME $135.94
P 2022-04-26 12:37:53 GME $135.94
P 2022-04-26 12:38:15 GME $135.94
P 2022-04-26 12:40:24 GME $135.94
P 2022-04-26 12:40:26 GME $135.94
P 2022-05-06 08:01:16 GME $135.94
P 2022-05-06 08:01:54 GME $135.94
P 2022-05-06 08:02:11 GME $135.94
P 2022-05-11 16:11:33 GME $135.94
P 2022-05-17 11:06:12 GME $135.94
P 2022-05-17 12:54:19 GME $135.94
P 2022-05-18 07:43:33 GME $135.94
P 2022-05-27 12:11:19 GME $135.94
P 2022-05-27 12:12:45 GME $135.94
P 2022-06-04 15:09:21 GME $135.94
P 2022-06-04 15:18:50 GME $135.94
P 2022-06-13 15:46:58 GME $118.25
P 2022-06-14 18:35:40 GME $118.25
P 2022-06-19 09:51:51 GME $118.25
P 2022-06-21 21:44:19 GME $118.25
P 2022-06-25 10:15:35 GME $118.25
P 2022-07-06 10:30:13 GME $120.23
P 2022-07-06 10:30:34 GME $120.23
P 2022-07-07 14:49:14 GME $120.23
P 2022-07-07 14:50:14 GME $120.23
P 2022-07-13 11:10:49 GME $120.23
P 2022-08-02 10:14:48 GME $120.23
P 2022-08-03 15:56:22 GME $120.23
P 2022-08-10 16:11:26 GME $120.23
P 2022-08-13 10:55:37 GME $120.23
P 2022-08-30 15:16:05 GME $29.84
P 2022-08-30 15:20:02 GME $29.84
P 2022-08-31 08:48:40 GME $29.84
P 2022-09-06 15:40:03 GME $25.14
P 2022-09-11 10:34:00 GME $28.92
P 2022-09-24 09:25:43 GME $28.92
P 2022-09-25 09:34:50 GME $28.92
P 2022-09-25 09:34:58 GME $28.92
P 2022-09-25 09:35:05 GME $28.92
P 2022-09-25 09:35:22 GME $28.92
P 2022-09-25 09:36:27 GME $28.92
P 2022-09-25 10:45:01 GME $28.92
P 2022-09-25 10:46:19 GME $28.92
P 2022-09-25 10:47:04 GME $28.92
P 2022-10-01 09:21:11 GME $28.92
P 2022-10-25 21:38:31 GME $28.92
P 2022-11-06 12:06:23 GME $28.92
P 2022-11-27 07:22:51 GME $28.92
P 2022-12-03 10:15:51 GME $28.92
P 2022-12-03 10:16:14 GME $28.92
P 2022-12-03 10:16:50 GME $28.92
P 2022-12-09 13:53:17 GME $28.92
P 2022-12-15 08:12:13 GME $28.92
P 2022-12-15 16:42:12 GME $20.58
P 2022-12-19 01:50:26 GME $20.58
P 2022-12-23 23:18:00 GME $20.58
P 2022-12-31 14:13:53 GME $20.58
P 2023-01-15 08:29:13 GME $20.58
P 2023-02-03 09:00:53 GME $20.58
P 2023-02-07 06:21:34 GME $20.58
P 2023-02-15 14:18:12 GME $19.87
P 2023-03-04 09:45:23 GME $19.87
P 2023-04-20 08:29:52 GME $19.87
P 2023-04-28 11:32:48 GME $19.87
P 2023-05-31 19:48:34 GME $19.87
P 2023-05-31 19:56:07 GME $19.87
P 2023-06-03 06:40:16 GME $19.87
P 2023-06-24 10:10:34 GME $19.87
P 2023-07-08 09:20:10 GME $22.71
P 2023-07-21 19:12:42 GME $22.71
P 2023-07-21 19:13:37 GME $22.71
P 2023-07-30 10:10:44 GME $22.71
P 2023-08-03 19:46:01 GME $20.93
P 2023-08-03 19:46:27 GME $20.93
P 2023-08-19 09:17:49 GME $20.93
P 2023-08-19 09:17:59 GME $20.93
P 2023-09-08 21:50:52 GME $20.93
P 2023-09-08 21:52:14 GME $20.93
P 2023-09-12 14:18:17 GME $20.93
P 2023-09-14 07:33:55 GME $20.93
P 2023-09-16 11:02:49 GME $20.93
P 2023-09-16 11:07:45 GME $20.93
P 2023-09-18 11:06:04 GME $18.22
P 2023-09-23 07:05:07 GME $17.18
P 2023-10-05 16:28:36 GME $17.18
P 2023-10-17 16:41:30 GME $17.18
P 2023-10-22 07:33:56 GME $17.18
`), os.ModePerm)
got, err := NewBPIs(p)
if err != nil {
t.Fatal(err)
}
if got := got["USDC"].Lookup("2099-01-01"); got != 0 {
t.Error("default got != 0:", got)
}
if got := got["GME"].Lookup("2099-01-01"); got != 17.18 {
t.Errorf("shouldve returned latest but got %v", got)
}
if got := got["GME"].Lookup("2023-09-19"); got != 18.22 {
t.Errorf("shouldve returned one day before but got %v", got)
}
if got := got["GME"].Lookup("2023-09-18"); got != 18.22 {
t.Errorf("shouldve returned day of but got %v", got)
}
t.Log(got)
}

38
src/ledger/delta.go Normal file
View File

@@ -0,0 +1,38 @@
package ledger
import "fmt"
type Currency string
const (
USD = Currency("$")
)
type Delta struct {
Date string
Name string
Value float64
Currency Currency
Description string
isSet bool
}
func newDelta(d, desc, name string, v float64, c string, isSet bool) Delta {
return Delta{
Date: d,
Name: name,
Value: v,
Currency: Currency(c),
Description: desc,
isSet: isSet,
}
}
func (delta Delta) Debug() string {
return fmt.Sprintf("{@%s %s:\"%s\" %s%.2f %s}", delta.Date, delta.Name, delta.Description, func() string {
if !delta.isSet {
return ""
}
return "= "
}(), delta.Value, delta.Currency)
}

24
src/ledger/delta_test.go Normal file
View File

@@ -0,0 +1,24 @@
package ledger
import (
"testing"
)
func TestDelta(t *testing.T) {
d := "2099-08-07"
delta := newDelta(d, "", "name", 34.56, "$", false)
if delta.Date != d {
t.Error(delta.Date)
}
if delta.Name != "name" {
t.Error(delta.Name)
}
if delta.Value != 34.56 {
t.Error(delta.Value)
}
if delta.Currency != USD {
t.Error(delta.Currency)
}
t.Log(delta)
}

68
src/ledger/deltas.go Normal file
View File

@@ -0,0 +1,68 @@
package ledger
import (
"slices"
)
type Deltas []Delta
func (deltas Deltas) Group(group ...Group) Deltas {
result := make(Deltas, 0, len(deltas))
for i := range deltas {
result = append(result, Groups(group).Each(deltas[i]))
}
return result
}
func (deltas Deltas) Like(like ...Like) Deltas {
result := make(Deltas, 0, len(deltas))
for i := range deltas {
if Likes(like).All(deltas[i]) {
result = append(result, deltas[i])
}
}
return result
}
func (deltas Deltas) Register() Register {
dateToBalances := make(Register)
for _, delta := range deltas {
if _, ok := dateToBalances[delta.Date]; !ok {
dateToBalances[delta.Date] = make(Balances)
}
dateToBalances[delta.Date].Push(delta)
}
dates := make([]string, 0, len(dateToBalances)+1)
for k := range dateToBalances {
dates = append(dates, k)
}
slices.Sort(dates)
for i := range dates {
if i == 0 {
continue
}
dateToBalances[dates[i]].PushAll(dateToBalances[dates[i-1]])
}
return dateToBalances
}
func (deltas Deltas) Balances() Balances {
result := make(Balances)
for _, delta := range deltas {
if _, ok := result[delta.Name]; !ok {
result[delta.Name] = make(Balance)
}
if _, ok := result[delta.Name][delta.Currency]; !ok {
result[delta.Name][delta.Currency] = 0
}
result[delta.Name][delta.Currency] += delta.Value
if result[delta.Name][delta.Currency] < 0.000000001 && result[delta.Name][delta.Currency] > -0.000000001 {
delete(result[delta.Name], delta.Currency)
if len(result[delta.Name]) == 0 {
delete(result, delta.Name)
}
}
}
return result
}

67
src/ledger/deltas_test.go Normal file
View File

@@ -0,0 +1,67 @@
package ledger
import "testing"
func TestDeltas(t *testing.T) {
t.Run("Groups", func(t *testing.T) {
deltas := Deltas{
{Name: "a1"},
{Name: "b1"},
{Name: "b2"},
{Name: "b3"},
}
deltas = deltas.Group(GroupName("^."))
if len(deltas) != 4 {
t.Error(len(deltas))
}
if deltas[0].Name != "a" {
t.Error(deltas[0].Name)
}
if deltas[1].Name != "b" {
t.Error(deltas[1].Name)
}
if deltas[2].Name != "b" {
t.Error(deltas[2].Name)
}
if deltas[3].Name != "b" {
t.Error(deltas[3].Name)
}
})
t.Run("balances", func(t *testing.T) {
deltas := Deltas{
{Name: "a", Value: 0},
{Name: "b", Value: 1},
{Name: "b", Value: -.999999999999999999999999},
{Name: "b", Value: 1.3},
}
balances := deltas.Balances()
if len(balances) != 1 {
t.Error(len(balances), balances)
}
if balances["b"][""] != 1.3 {
t.Error(balances["b"])
}
})
t.Run("register", func(t *testing.T) {
deltas := Deltas{
{Date: "a", Value: 0.1},
{Date: "a", Value: 2.2},
{Date: "b", Value: 4.3},
}
got := deltas.Register()
t.Logf("%+v", got)
if len(got) != 2 {
t.Error(len(got))
}
if int(10*got["a"][""][""]) != 23 {
t.Error(got["a"][""][""])
}
if int(10*got["b"][""][""]) != 66 {
t.Error(got["b"][""][""])
}
})
}

151
src/ledger/file.go Normal file
View File

@@ -0,0 +1,151 @@
package ledger
import (
"fmt"
"io"
"os"
"sort"
"unicode"
)
var filesAppendDelim = "\t"
type Files []string
func NewFiles(p string, q ...string) (Files, error) {
f := Files(append([]string{p}, q...))
_, err := f.Deltas()
return f, err
}
func (files Files) Add(payee string, delta Delta) error {
currencyValue := fmt.Sprintf("%s%.2f", delta.Currency, delta.Value)
if delta.Currency != USD {
currencyValue = fmt.Sprintf("%.2f %s", delta.Value, delta.Currency)
}
return files.append(fmt.Sprintf("%s %s\n%s%s%s%s\n%s%s",
delta.Date, delta.Description,
filesAppendDelim, delta.Name, filesAppendDelim+filesAppendDelim+filesAppendDelim, currencyValue,
filesAppendDelim, payee,
))
}
func (files Files) append(s string) error {
if err := files.trimTrainlingWhitespace(); err != nil {
return err
}
f, err := os.OpenFile(string(files[0]), os.O_APPEND|os.O_CREATE|os.O_WRONLY, os.ModePerm)
if err != nil {
return err
}
defer f.Close()
fmt.Fprintf(f, "\n%s", s)
return f.Close()
}
func (files Files) trimTrainlingWhitespace() error {
idx, err := files._lastNonWhitespacePos()
if err != nil {
return err
}
if idx < 1 {
return nil
}
f, err := os.OpenFile(string(files[0]), os.O_CREATE|os.O_WRONLY, os.ModePerm)
if err != nil {
return err
}
defer f.Close()
return f.Truncate(int64(idx + 1))
}
func (files Files) _lastNonWhitespacePos() (int, error) {
f, err := os.Open(string(files[0]))
if os.IsNotExist(err) {
return -1, nil
}
if err != nil {
return -1, err
}
defer f.Close()
b, err := io.ReadAll(f)
if err != nil {
return -1, err
}
for i := len(b) - 1; i >= 0; i-- {
if !unicode.IsSpace(rune(b[i])) {
return i, nil
}
}
return len(b) - 1, nil
}
func (files Files) Deltas(like ...Like) (Deltas, error) {
transactions, err := files.transactions()
if err != nil {
return nil, err
}
sort.Slice(transactions, func(i, j int) bool {
return fmt.Sprintf("%s %s", transactions[i].date, transactions[i].description) < fmt.Sprintf("%s %s", transactions[j].date, transactions[j].description)
})
result := make(Deltas, 0, len(transactions)*2)
for _, transaction := range transactions {
sums := map[string]float64{}
for _, recipient := range transaction.recipients {
sums[recipient.currency] += recipient.value
delta := newDelta(
transaction.date,
transaction.description,
recipient.name,
recipient.value,
recipient.currency,
recipient.isSet,
)
result = append(result, delta)
}
for currency, value := range sums {
if value == 0 {
continue
}
if transaction.payee == "" {
//return nil, fmt.Errorf("didnt find net zero and no dumping ground payee set: %+v", transaction)
} else {
delta := newDelta(
transaction.date,
transaction.description,
transaction.payee,
-1.0*value,
currency,
false,
)
result = append(result, delta)
}
}
}
balances := make(Balances)
for i := range result {
if result[i].isSet {
var was float64
if m, ok := balances[result[i].Name]; ok {
was = m[result[i].Currency]
}
result[i].Value = result[i].Value - was
result[i].isSet = false
}
balances.Push(result[i])
}
for i := range result {
if result[i].isSet {
return nil, fmt.Errorf("failed to resolve isSet: %+v", result[i])
}
}
return result.Like(like...), nil
}

277
src/ledger/file_test.go Normal file
View File

@@ -0,0 +1,277 @@
package ledger
import (
"os"
"path"
"path/filepath"
"testing"
)
func TestFileAdd(t *testing.T) {
filesAppendDelim = " "
payee := "name:3"
delta := Delta{
Date: "2999-88-77",
Description: "66",
Name: "name:1",
Value: 2.00,
Currency: USD,
}
cases := map[string]struct {
given []byte
want string
}{
"no file": {
given: nil,
want: `
2999-88-77 66
name:1 $2.00
name:3`,
},
"empty file": {
given: []byte{},
want: `
2999-88-77 66
name:1 $2.00
name:3`,
},
"happy without trailing whitespace": {
given: []byte(`
2000-01-02 desc
name:1 $1.00
name:2 $-1.00`),
want: `
2000-01-02 desc
name:1 $1.00
name:2 $-1.00
2999-88-77 66
name:1 $2.00
name:3`,
},
"happy with trailing newline": {
given: []byte(`
2000-01-02 desc
name:1 $1.00
name:2 $-1.00
`),
want: `
2000-01-02 desc
name:1 $1.00
name:2 $-1.00
2999-88-77 66
name:1 $2.00
name:3`,
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
p := path.Join(t.TempDir(), "input")
if c.given != nil {
if err := os.WriteFile(p, []byte(c.given), os.ModePerm); err != nil {
t.Fatal(err)
}
}
f, err := NewFiles(p)
if err != nil {
t.Fatal(err)
}
if err := f.Add(payee, delta); err != nil {
t.Fatal(err)
}
if got, err := os.ReadFile(p); err != nil {
t.Fatal(err)
} else if string(got) != c.want {
t.Errorf("wanted\n\t%s, got\n\t%s", c.want, got)
}
})
}
}
func TestFileTestdataMacroWithBPI(t *testing.T) {
paths, err := filepath.Glob("./testdata/macro.d/*")
if err != nil {
t.Fatal(err)
}
t.Log(paths)
f, err := NewFiles(paths[0], paths[1:]...)
if err != nil {
t.Fatal(err)
}
deltas, err := f.Deltas()
if err != nil {
t.Fatal(err)
}
bpis, err := NewBPIs("./testdata/bpi.bpi")
if err != nil {
t.Fatal(err)
}
t.Run("bal like", func(t *testing.T) {
bal := deltas.Like(LikeName(`^AssetAccount:Bond`)).Balances().WithBPIs(bpis)
for k, v := range bal {
t.Logf("%s: %+v", k, v)
}
})
t.Run("reg like", func(t *testing.T) {
reg := deltas.Like(LikeName(`^AssetAccount:Bond`)).Register()
for k, v := range reg {
t.Logf("%s: %+v", k, v.WithBPIs(bpis))
}
})
}
func TestFileTestdata(t *testing.T) {
t.Run("macro.d", func(t *testing.T) {
paths, err := filepath.Glob("./testdata/macro.d/*")
if err != nil {
t.Fatal(err)
}
f, err := NewFiles(paths[0], paths[1:]...)
if err != nil {
t.Fatal(err)
}
deltas, err := f.Deltas()
if err != nil {
t.Fatal(err)
}
t.Run("deltas", func(t *testing.T) {
for i := range deltas {
t.Logf("%+v", deltas[i].Debug())
}
})
t.Run("balances", func(t *testing.T) {
balances := deltas.Balances()
for k, v := range balances {
t.Logf("%s: %+v", k, v)
}
})
t.Run("balances like", func(t *testing.T) {
balances := deltas.Like(LikeName(`^AssetAccount:`)).Balances()
for k, v := range balances {
t.Logf("%s: %+v", k, v)
}
})
})
t.Run("single files", func(t *testing.T) {
paths, err := filepath.Glob("./testdata/*.dat")
if err != nil {
t.Fatal(err)
}
for _, pathd := range paths {
path := pathd
t.Run(path, func(t *testing.T) {
f, err := NewFiles(path)
if err != nil {
t.Fatal(err)
}
deltas, err := f.Deltas()
if err != nil {
t.Fatal(err)
}
t.Run("deltas", func(t *testing.T) {
for i := range deltas {
t.Logf("%+v", deltas[i].Debug())
}
})
t.Run("balances", func(t *testing.T) {
balances := deltas.Balances()
for k, v := range balances {
t.Logf("%s: %+v", k, v)
}
})
t.Run("balances like", func(t *testing.T) {
balances := deltas.Like(LikeName(`AssetAccount:Cash:Fidelity76`)).Balances()
for k, v := range balances {
t.Logf("%s: %+v", k, v)
}
})
})
}
})
}
func TestFileDeltas(t *testing.T) {
happy := []Delta{
{
Date: "2022-12-12",
Name: "AssetAccount:Cash:Fidelity76",
Value: -97.92,
Currency: USD,
Description: "Electricity / Power Bill TG2PJ-2PLP5",
},
{
Date: "2022-12-12",
Name: "Withdrawal:0:SharedHome:DominionEnergy",
Value: 97.92,
Currency: USD,
Description: "Electricity / Power Bill TG2PJ-2PLP5",
},
{
Date: "2022-12-12",
Name: "AssetAccount:Cash:Fidelity76",
Value: -1.00,
Currency: USD,
Description: "Test pay chase TG32S-BT2FF",
},
{
Date: "2022-12-12",
Name: "Debts:Credit:ChaseFreedomUltdVisa",
Value: 1.00,
Currency: USD,
Description: "Test pay chase TG32S-BT2FF",
},
}
cases := map[string][]Delta{
"empty": nil,
"one": happy[:2],
"happy": happy[:],
}
for name, d := range cases {
want := d
t.Run(name, func(t *testing.T) {
f, err := NewFiles("./testdata/" + name + ".dat")
if err != nil {
t.Fatal(err)
}
deltas, err := f.Deltas()
if err != nil {
t.Fatal(err)
}
if len(deltas) != len(want) {
t.Error(len(deltas))
}
for i := range want {
if i >= len(deltas) {
break
}
if want[i] != deltas[i] {
t.Errorf("[%d] \n\twant=%s, \n\t got=%s", i, want[i].Debug(), deltas[i].Debug())
}
}
})
}
}

30
src/ledger/group.go Normal file
View File

@@ -0,0 +1,30 @@
package ledger
import "regexp"
type Group func(Delta) Delta
type Groups []Group
func (groups Groups) Each(d Delta) Delta {
for i := range groups {
d = groups[i](d)
}
return d
}
func GroupDate(pattern string) Group {
p := regexp.MustCompile(pattern)
return func(d Delta) Delta {
d.Date = p.FindString(d.Date)
return d
}
}
func GroupName(pattern string) Group {
p := regexp.MustCompile(pattern)
return func(d Delta) Delta {
d.Name = p.FindString(d.Name)
return d
}
}

13
src/ledger/group_test.go Normal file
View File

@@ -0,0 +1,13 @@
package ledger
import "testing"
func TestGroup(t *testing.T) {
if got := GroupDate("^20..")(Delta{Date: "2021-01"}); got.Date != "2021" {
t.Error(got)
}
if got := GroupName("^[^:]*")(Delta{Name: "a:b:c"}); got.Name != "a" {
t.Error(got)
}
}

49
src/ledger/like.go Normal file
View File

@@ -0,0 +1,49 @@
package ledger
import (
"regexp"
)
type Like func(Delta) bool
type Likes []Like
func LikeBefore(date string) Like {
return func(d Delta) bool {
return date >= d.Date
}
}
func LikeAfter(date string) Like {
return func(d Delta) bool {
return date <= d.Date
}
}
func LikeName(pattern string) Like {
return func(d Delta) bool {
return like(pattern, d.Name)
}
}
func like(pattern string, other string) bool {
return regexp.MustCompile(pattern).MatchString(other)
}
func (likes Likes) Any(delta Delta) bool {
for i := range likes {
if likes[i](delta) {
return true
}
}
return false
}
func (likes Likes) All(delta Delta) bool {
for i := range likes {
if !likes[i](delta) {
return false
}
}
return true
}

38
src/ledger/like_test.go Normal file
View File

@@ -0,0 +1,38 @@
package ledger
import "testing"
func TestLikeBeforeAfter(t *testing.T) {
if got := LikeBefore("9")(Delta{Date: "2021"}); !got {
t.Error("got 2021 is NOT before 9")
}
if got := LikeBefore("1")(Delta{Date: "2021"}); got {
t.Error("got 2021 IS before 1")
}
if got := LikeAfter("9")(Delta{Date: "2021"}); got {
t.Error("got 2021 IS after 9")
}
if got := LikeAfter("1")(Delta{Date: "2021"}); !got {
t.Error("got 2021 is NOT after 1")
}
}
func TestLikeName(t *testing.T) {
delta := Delta{Name: "x"}
if got := LikeName("^x$")(delta); !got {
t.Error(got)
}
if got := LikeName("^y$")(delta); got {
t.Error(got)
}
}
func TestLikesAll(t *testing.T) {
delta := Delta{Name: "x"}
if likes := (Likes{LikeName("^x$")}); !likes.All(delta) {
t.Error(likes.All(delta))
}
if likes := (Likes{LikeName("^y$")}); likes.All(delta) {
t.Error(likes.All(delta))
}
}

118
src/ledger/register.go Normal file
View File

@@ -0,0 +1,118 @@
package ledger
import (
"fmt"
"maps"
"slices"
"time"
)
type Register map[string]Balances
func (register Register) Latest() Balances {
dates := register.Dates()
if len(dates) == 0 {
return nil
}
date := dates[len(dates)-1]
return register[date]
}
func (register Register) WithBPIs(bpis BPIs) Register {
result := make(Register)
for d := range register {
result[d] = register[d].WithBPIsAt(bpis, d)
}
return result
}
func (register Register) PushAll(other Register) {
for date := range other {
if _, ok := register[date]; !ok {
register[date] = make(Balances)
}
register[date].PushAll(other[date])
}
}
func (register Register) Names() []string {
names := map[string]int{}
for _, v := range register {
for name := range v {
names[name] = 1
}
}
result := make([]string, 0, len(register))
for k := range names {
result = append(result, k)
}
slices.Sort(result)
return result
}
func (register Register) Dates() []string {
result := make([]string, 0, len(register))
for k := range register {
result = append(result, k)
}
slices.Sort(result)
return result
}
func (register Register) Between(start, end time.Time) Register {
result := make(Register)
for k := range register {
t := mustDateToTime(k)
if t.Before(start) || t.After(end) {
continue
}
result[k] = maps.Clone(register[k])
}
return result
}
func (register Register) Times() []time.Time {
dates := register.Dates()
result := make([]time.Time, len(dates))
for i := range dates {
result[i] = mustDateToTime(dates[i])
}
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)
}
func (register Register) Debug() string {
result := "{"
for _, date := range register.Dates() {
result += "{" + date
for name, balance := range register[date] {
result += "{" + name
for cur, v := range balance {
result += fmt.Sprintf("%s=%.2f ", cur, v)
}
result += "}"
}
result += "}"
}
return result + "}"
}

View File

@@ -0,0 +1,31 @@
package ledger
import (
"testing"
"time"
)
func TestRegisterBetween(t *testing.T) {
reg := make(Register)
reg["2001-01"] = make(Balances)
reg["2002-01"] = make(Balances)
reg["2003-01"] = make(Balances)
reg["2004-01"] = make(Balances)
reg["2005-01"] = make(Balances)
start, _ := time.ParseInLocation("2006-01", "2002-01", time.Local)
end, _ := time.ParseInLocation("2006-01", "2004-01", time.Local)
got := reg.Between(start, end)
if len(got) != 3 {
t.Error(len(got))
}
if _, ok := got["2002-01"]; !ok {
t.Error("2002-01")
}
if _, ok := got["2003-01"]; !ok {
t.Error("2003-01")
}
if _, ok := got["2004-01"]; !ok {
t.Error("2004-01")
}
}

1
src/ledger/testdata/bpi.bpi vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../../../../Sync/Core/ledger/bpi.dat

0
src/ledger/testdata/empty.dat vendored Normal file
View File

7
src/ledger/testdata/happy.dat vendored Normal file
View File

@@ -0,0 +1,7 @@
2022-12-12 Electricity / Power Bill TG2PJ-2PLP5
AssetAccount:Cash:Fidelity76 $-97.92
Withdrawal:0:SharedHome:DominionEnergy
2022-12-12 Test pay chase TG32S-BT2FF
AssetAccount:Cash:Fidelity76 $-1.00
Debts:Credit:ChaseFreedomUltdVisa

1
src/ledger/testdata/macro.d vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../../../../Sync/Core/ledger/eras/2022-/

1
src/ledger/testdata/macro.dat vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../../../../Sync/Core/ledger/eras/2022-/fidelity.76.dat.txt

3
src/ledger/testdata/one.dat vendored Normal file
View File

@@ -0,0 +1,3 @@
2022-12-12 Electricity / Power Bill TG2PJ-2PLP5
AssetAccount:Cash:Fidelity76 $-97.92
Withdrawal:0:SharedHome:DominionEnergy

223
src/ledger/transaction.go Normal file
View File

@@ -0,0 +1,223 @@
package ledger
import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"regexp"
"strconv"
"unicode"
)
type transaction struct {
date string
description string
payee string
recipients []transactionRecipient
}
func (t transaction) empty() bool {
return fmt.Sprint(t) == fmt.Sprint(transaction{})
}
type transactionRecipient struct {
name string
value float64
currency string
isSet bool
}
func (t transactionRecipient) empty() bool {
return t == (transactionRecipient{})
}
func (files Files) transactions() ([]transaction, error) {
result := make([]transaction, 0)
for i := range files {
some, err := files._transactions(files[i])
if err != nil {
return nil, err
}
result = append(result, some...)
}
return result, nil
}
func (files Files) _transactions(file string) ([]transaction, error) {
f, err := os.Open(string(file))
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
defer f.Close()
r := bufio.NewReaderSize(f, 2048)
result := make([]transaction, 0)
for {
one, err := readTransaction(r)
if !one.empty() {
result = append(result, one)
}
if err == io.EOF {
return result, nil
}
if err != nil {
return result, err
}
}
}
func readTransaction(r *bufio.Reader) (transaction, error) {
result, err := _readTransaction(r)
if err != nil {
return result, err
}
if result.empty() {
return result, nil
}
if result.payee == "" && len(result.recipients) < 2 {
return result, fmt.Errorf("found a transaction with no payee and less than 2 recipeints: %+v", result)
}
if result.payee != "" && len(result.recipients) < 1 {
return result, fmt.Errorf("found a transaction with payee but no recipeints: %+v", result)
}
return result, nil
}
func _readTransaction(r *bufio.Reader) (transaction, error) {
readTransactionLeadingWhitespace(r)
firstLine, err := readTransactionLine(r)
if len(bytes.TrimSpace(firstLine)) == 0 {
return transaction{}, err
}
dateDescriptionPattern := regexp.MustCompile(`^([0-9]+-[0-9]+-[0-9]+)\s+(.*)$`)
dateDescriptionMatches := dateDescriptionPattern.FindAllSubmatch(firstLine, 4)
if len(dateDescriptionMatches) != 1 {
return transaction{}, fmt.Errorf("bad first line: %v matches: %q", len(dateDescriptionMatches), firstLine)
} else if len(dateDescriptionMatches[0]) != 3 {
return transaction{}, fmt.Errorf("bad first line: %v submatches: %q", len(dateDescriptionMatches[0]), firstLine)
}
result := transaction{
date: string(dateDescriptionMatches[0][1]),
description: string(dateDescriptionMatches[0][2]),
}
for {
name, value, currency, isSet, err := readTransactionName(r)
if name != "" {
if currency == "" {
result.payee = name
} else {
result.recipients = append(result.recipients, transactionRecipient{
name: name,
value: value,
currency: currency,
isSet: isSet,
})
}
}
if name == "" || err != nil {
return result, err
}
}
}
func readTransactionLeadingWhitespace(r *bufio.Reader) {
b, err := r.Peek(2048)
if err != nil && err != io.EOF {
return
}
i := 0
for i < len(b) {
if len(bytes.TrimSpace(b[:i])) != 0 {
break
}
i++
}
if i > 0 {
r.Read(make([]byte, i-1))
}
}
func readTransactionLine(r *bufio.Reader) ([]byte, error) {
for {
b, err := _readTransactionLine(r)
if err != nil || (len(bytes.TrimSpace(b)) > 0 && bytes.TrimSpace(b)[0] != '#') {
return b, err
}
}
}
func _readTransactionLine(r *bufio.Reader) ([]byte, error) {
b, err := r.Peek(2048)
if len(b) == 0 {
return nil, err
}
endOfLine := len(b)
if idx := bytes.Index(b, []byte{'\n'}); idx > -1 {
endOfLine = idx
}
b2 := make([]byte, endOfLine)
n, err := r.Read(b2)
if err == io.EOF {
err = nil
}
if check, _ := r.Peek(1); len(check) == 1 && check[0] == '\n' {
r.Read(make([]byte, 1))
}
return b2[:n], err
}
func readTransactionName(r *bufio.Reader) (string, float64, string, bool, error) {
line, err := readTransactionLine(r)
if err != nil {
return "", 0, "", false, err
}
if len(line) > 0 && !unicode.IsSpace(rune(line[0])) {
r2 := *r
*r = *bufio.NewReader(io.MultiReader(bytes.NewReader(append(line, '\n')), &r2))
return "", 0, "", false, nil
}
fields := bytes.Fields(line)
isSet := false
if len(fields) > 2 && string(fields[1]) == "=" {
isSet = true
fields = append(fields[:1], fields[2:]...)
}
switch len(fields) {
case 1: // payee
return string(fields[0]), 0, "", false, nil
case 2: // payee $00.00
b := bytes.TrimLeft(fields[1], "$")
value, err := strconv.ParseFloat(string(b), 64)
if err != nil {
return "", 0, "", isSet, fmt.Errorf("failed to parse value from $XX.YY from %q (%q): %w", line, fields[1], err)
}
return string(fields[0]), value, string(USD), isSet, nil
case 3: // payee 00.00 XYZ
value, err := strconv.ParseFloat(string(fields[1]), 64)
if err != nil {
return "", 0, "", false, fmt.Errorf("failed to parse value from XX.YY XYZ from %q (%q): %w", line, fields[1], err)
}
return string(fields[0]), value, string(fields[2]), isSet, nil
default:
return "", 0, "", isSet, fmt.Errorf("cannot interpret %q", line)
}
}

View File

@@ -0,0 +1,68 @@
package ledger
import (
"bufio"
"fmt"
"io"
"strings"
"testing"
)
func TestReadTransaction(t *testing.T) {
cases := map[string]struct {
input string
want transaction
err error
}{
"empty": {
input: "",
want: transaction{},
err: io.EOF,
},
"white space": {
input: " ",
want: transaction{},
err: io.EOF,
},
"verbose": {
input: `
2003-04-05 Reasoning here
A:B $1.00
C:D $-1.00
`,
want: transaction{
date: "2003-04-05",
description: "Reasoning here",
payee: "",
recipients: []transactionRecipient{
{
name: "A:B",
value: 1.0,
currency: "$",
},
{
name: "C:D",
value: -1.0,
currency: "$",
},
},
},
err: io.EOF,
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
r := bufio.NewReader(strings.NewReader(c.input))
got, err := readTransaction(r)
if err != c.err {
t.Error(err)
}
if fmt.Sprintf("%+v", got) != fmt.Sprintf("%+v", c.want) {
t.Errorf("want\n\t%+v, got\n\t%+v", c.want, got)
}
})
}
}