├── lib ├── syntax │ ├── parser │ │ └── recursive.go │ ├── bayes │ │ ├── bayes_test.go │ │ └── bayes.go │ ├── syntax.go │ └── directives │ │ └── directives.go ├── model │ ├── commodity │ │ ├── commodity.go │ │ └── registry.go │ ├── open │ │ └── open.go │ ├── close │ │ └── close.go │ ├── price │ │ ├── price.go │ │ ├── prices.go │ │ └── prices_test.go │ ├── registry │ │ └── registry.go │ ├── assertion │ │ └── assertion.go │ ├── posting │ │ └── posting.go │ ├── model.go │ ├── account │ │ ├── account.go │ │ └── registry.go │ └── transaction │ │ └── transaction.go ├── common │ ├── regex │ │ └── regex.go │ ├── mapper │ │ └── mapper.go │ ├── cpr │ │ ├── slice_test.go │ │ └── cpr.go │ ├── dict │ │ └── dict.go │ ├── predicate │ │ └── predicate.go │ ├── set │ │ └── set.go │ ├── compare │ │ └── compare.go │ ├── table │ │ ├── table_test.go │ │ ├── csv.go │ │ └── table.go │ ├── multimap │ │ └── multimap.go │ └── date │ │ └── date_test.go ├── journal │ ├── performance │ │ └── universe.go │ ├── beancount │ │ └── beancount.go │ ├── check │ │ └── check.go │ └── printer │ │ └── printer.go ├── quotes │ ├── yahoo │ │ ├── yahoo_test.go │ │ └── yahoo.go │ └── yahoo2 │ │ └── yahoo2.go └── reports │ ├── balance │ ├── report.go │ └── renderer.go │ ├── register │ └── register.go │ └── weights │ └── weights.go ├── .gitattributes ├── cmd ├── importer │ ├── supercard │ │ ├── testdata │ │ │ ├── example1.input │ │ │ └── example1.golden │ │ └── supercard_test.go │ ├── postfinance │ │ ├── testdata │ │ │ ├── example1.golden │ │ │ └── example1.input │ │ └── postfinance_test.go │ ├── cumulus │ │ ├── testdata │ │ │ ├── example1.golden │ │ │ └── example1.input │ │ └── cumulus_test.go │ ├── importer.go │ ├── swisscard │ │ ├── testdata │ │ │ ├── example1.input │ │ │ └── example1.golden │ │ ├── swisscard_test.go │ │ └── swisscard.go │ ├── swisscard2 │ │ ├── testdata │ │ │ ├── example1.input │ │ │ └── example1.golden │ │ ├── swisscard2_test.go │ │ └── swisscard2.go │ ├── swissquote │ │ ├── testdata │ │ │ ├── example1.input │ │ │ └── example1.golden │ │ └── swissquote_test.go │ ├── interactivebrokers │ │ ├── testdata │ │ │ ├── example1.input │ │ │ └── example1.golden │ │ └── interactivebrokers_test.go │ ├── viac │ │ ├── viac_test.go │ │ └── viac.go │ ├── revolut │ │ ├── revolut_test.go │ │ └── testdata │ │ │ ├── example1.input │ │ │ └── example1.golden │ ├── revolut2 │ │ ├── revolut2_test.go │ │ └── testdata │ │ │ ├── example1.input │ │ │ └── example1.golden │ └── wise │ │ ├── testdata │ │ ├── example1.golden │ │ └── example1.input │ │ └── wise_test.go ├── commands │ ├── testdata │ │ ├── infer │ │ │ ├── target.knut │ │ │ ├── target.golden │ │ │ └── training.knut │ │ └── transcode │ │ │ ├── USD.prices │ │ │ ├── AAPL.prices │ │ │ └── example.knut │ ├── transcode_test.go │ ├── infer_test.go │ ├── import.go │ ├── portfolio.go │ ├── format.go │ ├── print.go │ ├── completion.go │ ├── transcode.go │ ├── check.go │ ├── portfolio │ │ ├── returns.go │ │ └── weights.go │ └── infer.go ├── flags │ └── templates.go ├── cmdtest │ └── cmdtest.go └── root.go ├── .goreleaser.yml ├── .gitignore ├── doc ├── prices.yaml └── example.knut ├── Makefile ├── .github └── workflows │ ├── go.yml │ └── release.yml ├── flake.lock ├── flake.nix ├── go.mod ├── main.go └── scripts └── builddoc.go /lib/syntax/parser/recursive.go: -------------------------------------------------------------------------------- 1 | package parser 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.golden text eol=lf 2 | *.knut text eol=lf -------------------------------------------------------------------------------- /cmd/importer/supercard/testdata/example1.input: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sboehler/knut/HEAD/cmd/importer/supercard/testdata/example1.input -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - goos: 3 | - darwin 4 | - linux 5 | - windows 6 | goarch: 7 | - amd64 8 | - arm64 -------------------------------------------------------------------------------- /cmd/commands/testdata/infer/target.knut: -------------------------------------------------------------------------------- 1 | 2021-06-18 "foo2" 2 | Assets:Bankaccount Expenses:TBD 50 USD 3 | 4 | 2021-06-18 "something" 5 | Assets:Bankaccount Expenses:TBD 50 USD 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin 2 | knut 3 | knut.exe 4 | .code-workspace 5 | builddoc 6 | *.sqlite3 7 | coverage.out 8 | .vscode 9 | node_modules 10 | web/build 11 | .envrc 12 | .direnv 13 | -------------------------------------------------------------------------------- /cmd/commands/testdata/infer/target.golden: -------------------------------------------------------------------------------- 1 | 2021-06-18 "foo2" 2 | Assets:Bankaccount Expenses:Foo2 50 USD 3 | 4 | 2021-06-18 "something" 5 | Assets:Bankaccount Expenses:Baz 50 USD 6 | -------------------------------------------------------------------------------- /doc/prices.yaml: -------------------------------------------------------------------------------- 1 | - commodity: "USD" 2 | target_commodity: "CHF" 3 | file: "USD.prices" 4 | symbol: "USDCHF=X" 5 | - commodity: "AAPL" 6 | target_commodity: "USD" 7 | file: "AAPL.prices" 8 | symbol: "AAPL" 9 | -------------------------------------------------------------------------------- /cmd/commands/testdata/infer/training.knut: -------------------------------------------------------------------------------- 1 | 2 | 3 | 2021-05-03 "foo bar purchase" 4 | Assets:Bankaccount Expenses:FooBar 50 USD 5 | 6 | 2021-05-20 "foo2 purchase" 7 | Assets:Bankaccount Expenses:Foo2 50 USD 8 | 9 | 2021-05-18 "foo3 something" 10 | Assets:Bankaccount Expenses:Baz 50 USD -------------------------------------------------------------------------------- /cmd/importer/postfinance/testdata/example1.golden: -------------------------------------------------------------------------------- 1 | 2022-03-07 "desc2" 2 | Expenses:TBD Assets:Postfinance 4.95 CHF 3 | 4 | 2022-03-07 "desc3" 5 | Assets:Postfinance Expenses:TBD 1139.6 CHF 6 | 7 | 2022-03-08 "desc1 bar foo" 8 | Assets:Postfinance Expenses:TBD 19 CHF 9 | 10 | -------------------------------------------------------------------------------- /cmd/importer/cumulus/testdata/example1.golden: -------------------------------------------------------------------------------- 1 | 2020-08-22 "Desc0" 2 | Liabilities:Cumulus Expenses:TBD 12.34 CHF 3 | 4 | 2020-09-09 "Desc1 FXComment1" 5 | Liabilities:Cumulus Expenses:TBD 1233.45 CHF 6 | 7 | 2020-09-23 "Rundungskorrektur" 8 | Expenses:TBD Liabilities:Cumulus 0.02 CHF 9 | 10 | -------------------------------------------------------------------------------- /lib/model/commodity/commodity.go: -------------------------------------------------------------------------------- 1 | package commodity 2 | 3 | // Commodity represents a currency or security. 4 | type Commodity struct { 5 | name string 6 | IsCurrency bool 7 | } 8 | 9 | func (c Commodity) Name() string { 10 | return c.name 11 | } 12 | 13 | func (c Commodity) String() string { 14 | return c.name 15 | } 16 | -------------------------------------------------------------------------------- /cmd/importer/importer.go: -------------------------------------------------------------------------------- 1 | package importer 2 | 3 | import "github.com/spf13/cobra" 4 | 5 | var importers []func() *cobra.Command 6 | 7 | // RegisterImporter registers an importer constructor. 8 | func RegisterImporter(f func() *cobra.Command) { 9 | importers = append(importers, f) 10 | } 11 | 12 | func GetImporters() []func() *cobra.Command { 13 | return importers 14 | } 15 | -------------------------------------------------------------------------------- /lib/common/regex/regex.go: -------------------------------------------------------------------------------- 1 | package regex 2 | 3 | import "regexp" 4 | 5 | type Regexes []*regexp.Regexp 6 | 7 | func (rxs *Regexes) Add(r *regexp.Regexp) { 8 | *rxs = append(*rxs, r) 9 | } 10 | 11 | // Value returns the flag value. 12 | func (rf Regexes) MatchString(s string) bool { 13 | for _, r := range rf { 14 | if r.MatchString(s) { 15 | return true 16 | } 17 | } 18 | return false 19 | } 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: knut 2 | 3 | .PHONY: clean test test-update doc install 4 | 5 | doc: 6 | go run scripts/builddoc.go > README.md 7 | 8 | test: 9 | go test ./... 10 | 11 | coverage: 12 | go test -race -covermode=atomic -coverprofile=coverage.out ./... 13 | 14 | test-update: 15 | go test ./... --update || true 16 | 17 | clean: 18 | rm -f ./knut 19 | 20 | knut: 21 | go build 22 | 23 | install: 24 | go install -------------------------------------------------------------------------------- /cmd/importer/cumulus/testdata/example1.input: -------------------------------------------------------------------------------- 1 | Verbucht am,Beschreibung,Gutschrift CHF,Belastung CHF 2 | "",Saldovortrag letzte Rechnung,,1'234.56 3 | 04.09.2020,Ihre LSV-Zahlung - Besten Dank,1'234.56, 4 | Einkaufs-Datum,Verbucht am,Beschreibung,Gutschrift CHF,Belastung CHF 5 | 22.08.2020,24.08.2020,Desc0,,12.34 6 | 09.09.2020,10.09.2020,Desc1,,1'233.45 7 | "",,"FXComment1",, 8 | Verbucht am,Beschreibung,Gutschrift CHF,Belastung CHF 9 | 23.09.2020,Rundungskorrektur,0.02, -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | strategy: 8 | matrix: 9 | go-version: ["1.21"] 10 | os: [ubuntu-latest, macos-latest, windows-latest] 11 | runs-on: ${{ matrix.os }} 12 | steps: 13 | - uses: actions/setup-go@v2 14 | with: 15 | go-version: ${{ matrix.go-version }} 16 | - uses: actions/checkout@v2 17 | with: 18 | fetch-depth: 0 19 | - run: go test ./... 20 | -------------------------------------------------------------------------------- /cmd/importer/postfinance/testdata/example1.input: -------------------------------------------------------------------------------- 1 | Buchungsart:;="Alle Buchungen" 2 | Konto:;="CH4609000000877991229" 3 | Währung:;="CHF" 4 | 5 | Buchungsdatum;Avisierungstext;Gutschrift in CHF;Lastschrift in CHF;Label;Kategorie;Valuta;Saldo in CHF 6 | 7 | 08.03.2022;desc1 ;;-19;foo;bar;08.03.2022;796.44 8 | 07.03.2022;desc2;4.95;;;;07.03.2022;787.44 9 | 07.03.2022;desc3;;-1139.6;;;07.03.2022; 10 | 11 | Disclaimer: 12 | Dies ist kein durch PostFinance AG erstelltes Dokument. PostFinance AG ist nicht verantwortlich für den Inhalt. -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | pull_request: 5 | push: 6 | 7 | jobs: 8 | goreleaser: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/setup-go@v2 12 | with: 13 | go-version: "1.21" 14 | - uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 0 17 | - uses: goreleaser/goreleaser-action@v2 18 | with: 19 | version: latest 20 | args: release --clean 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /lib/common/mapper/mapper.go: -------------------------------------------------------------------------------- 1 | package mapper 2 | 3 | type Mapper[T any] func(T) T 4 | 5 | func Identity[T any](t T) T { 6 | return t 7 | } 8 | 9 | func Nil[P interface{ *T }, T any](P) P { 10 | return nil 11 | } 12 | 13 | func Sequence[T any](ms ...Mapper[T]) Mapper[T] { 14 | return func(t T) T { 15 | for _, m := range ms { 16 | t = m(t) 17 | } 18 | return t 19 | } 20 | } 21 | 22 | func IdentityIf[T any](p bool) Mapper[T] { 23 | if p { 24 | return Identity[T] 25 | } 26 | var z T 27 | return func(_ T) T { 28 | return z 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /cmd/flags/templates.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | import ( 4 | "github.com/sboehler/knut/lib/common/date" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | type Multiperiod struct { 9 | period PeriodFlag 10 | last int 11 | interval IntervalFlags 12 | } 13 | 14 | func (mp *Multiperiod) Setup(cmd *cobra.Command) { 15 | mp.period.Setup(cmd, date.Period{End: date.Today()}) 16 | cmd.Flags().IntVar(&mp.last, "last", 0, "last n periods") 17 | mp.interval.Setup(cmd, date.Once) 18 | } 19 | 20 | func (mp *Multiperiod) Partition(clip date.Period) date.Partition { 21 | return date.NewPartition(mp.period.Value().Clip(clip), mp.interval.Value(), mp.last) 22 | } 23 | -------------------------------------------------------------------------------- /cmd/importer/swisscard/testdata/example1.input: -------------------------------------------------------------------------------- 1 | Transaction Date, Posting Date, Card Number ,Billing Amount, Description, Merchant City , Merchant State , Merchant Zip , Reference Number , Debit/Credit Flag , SICMCC Code 2 | 14.02.2020,14.02.2020,1234,CHF0.50,"desc0","",,, "",D, 3 | 12.02.2020,13.02.2020,1234,CHF34.65,"desc1","desc2",CHE,1111, "42",D,5411 4 | 12.02.2020,13.02.2020,1234,CHF64.60,"desc3","town",CHE,1111, "42",D,5411 5 | 06.02.2020,06.02.2020,1234,-CHF2'000.50,"IHRE ZAHLUNG . BESTEN DANK","",,, "43",C, 6 | 18.01.2020,20.01.2020,1234,CHF14.00,"desc4","ZURICH",CHE,8003, "44",D,5812 7 | 14.01.2020,15.01.2020,1234,-CHF0.50,"RÜCKVERGÜTUNG RECHNUNGSGEBÜHR","",,, "45",C, 8 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1713537308, 6 | "narHash": "sha256-XtTSSIB2DA6tOv+l0FhvfDMiyCmhoRbNB+0SeInZkbk=", 7 | "owner": "nixos", 8 | "repo": "nixpkgs", 9 | "rev": "5c24cf2f0a12ad855f444c30b2421d044120c66f", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "nixos", 14 | "ref": "nixos-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /cmd/importer/swisscard/testdata/example1.golden: -------------------------------------------------------------------------------- 1 | 2020-01-14 "1234 RÜCKVERGÜTUNG RECHNUNGSGEBÜHR 45" 2 | Expenses:TBD Liabilities:CreditCard 0.5 CHF 3 | 4 | 2020-01-18 "1234 desc4 ZURICH CHE 8003 44" 5 | Liabilities:CreditCard Expenses:TBD 14 CHF 6 | 7 | 2020-02-06 "1234 IHRE ZAHLUNG . BESTEN DANK 43" 8 | Expenses:TBD Liabilities:CreditCard 2000.5 CHF 9 | 10 | 2020-02-12 "1234 desc1 desc2 CHE 1111 42" 11 | Liabilities:CreditCard Expenses:TBD 34.65 CHF 12 | 13 | 2020-02-12 "1234 desc3 town CHE 1111 42" 14 | Liabilities:CreditCard Expenses:TBD 64.6 CHF 15 | 16 | 2020-02-14 "1234 desc0" 17 | Liabilities:CreditCard Expenses:TBD 0.5 CHF 18 | 19 | -------------------------------------------------------------------------------- /lib/model/open/open.go: -------------------------------------------------------------------------------- 1 | package open 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/sboehler/knut/lib/model/account" 7 | "github.com/sboehler/knut/lib/model/registry" 8 | "github.com/sboehler/knut/lib/syntax" 9 | ) 10 | 11 | // Open represents an open command. 12 | type Open struct { 13 | Src *syntax.Open 14 | Date time.Time 15 | Account *account.Account 16 | } 17 | 18 | func Create(reg *registry.Registry, o *syntax.Open) (*Open, error) { 19 | account, err := reg.Accounts().Create(o.Account) 20 | if err != nil { 21 | return nil, err 22 | } 23 | date, err := o.Date.Parse() 24 | if err != nil { 25 | return nil, err 26 | } 27 | return &Open{ 28 | Src: o, 29 | Date: date, 30 | Account: account, 31 | }, nil 32 | } 33 | -------------------------------------------------------------------------------- /lib/model/close/close.go: -------------------------------------------------------------------------------- 1 | package close 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/sboehler/knut/lib/model/account" 7 | "github.com/sboehler/knut/lib/model/registry" 8 | "github.com/sboehler/knut/lib/syntax" 9 | ) 10 | 11 | // Open represents an open command. 12 | type Close struct { 13 | Src *syntax.Close 14 | Date time.Time 15 | Account *account.Account 16 | } 17 | 18 | func Create(reg *registry.Registry, c *syntax.Close) (*Close, error) { 19 | account, err := reg.Accounts().Create(c.Account) 20 | if err != nil { 21 | return nil, err 22 | } 23 | date, err := c.Date.Parse() 24 | if err != nil { 25 | return nil, err 26 | } 27 | return &Close{ 28 | Src: c, 29 | Date: date, 30 | Account: account, 31 | }, nil 32 | } 33 | -------------------------------------------------------------------------------- /cmd/importer/swisscard2/testdata/example1.input: -------------------------------------------------------------------------------- 1 | Transaktionsdatum,Beschreibung,Händler,Kartennummer,Währung,Betrag,Fremdwährung,Betrag in Fremdwährung,Debit/Kredit,Status,Händlerkategorie,Registrierte Kategorie 2 | "06.07.2024","a","aa","11","CHF","72.60","","","Belastung","Gebucht","Familie & Haushalt","FAMILY CLOTHING STORES" 3 | "06.07.2024","b","bb","22","CHF","6.00","","","Belastung","Gebucht","Auto","AUTOMOBILE TRUCK DEALERS, SALES, SERVICE" 4 | "06.07.2024","c","cc","33","CHF","80.70","","","Belastung","Gebucht","Lebensmittel","DEPARTMENT STORES" 5 | "06.07.2024","d","dd","44","CHF","4.95","","","Belastung","Gebucht","Lebensmittel","DEPARTMENT STORES" 6 | "05.07.2024","e","ee","55","CHF","36.00","","","Belastung","Gebucht","Gesundheit und Schönheit","DRUG STORES and Pharmacies" 7 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "knut"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; 6 | }; 7 | 8 | outputs = { self, nixpkgs, ... }@inputs: 9 | let 10 | supportedSystems = ["x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin"]; 11 | 12 | forAllSystems = nixpkgs.lib.genAttrs supportedSystems; 13 | 14 | nixpkgsFor = forAllSystems (system: import nixpkgs { 15 | inherit system; 16 | config = { }; 17 | }); 18 | 19 | in { 20 | devShells = forAllSystems (system: 21 | let 22 | pkgs = nixpkgsFor.${system}; 23 | in { 24 | default = pkgs.mkShell { 25 | name = "knut"; 26 | buildInputs = with pkgs; [ 27 | go 28 | ]; 29 | }; 30 | }); 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /cmd/importer/swisscard2/testdata/example1.golden: -------------------------------------------------------------------------------- 1 | 2024-07-05 "e / ee / Gesundheit und Schönheit / 55 / DRUG STORES and Pharmacies / Belastung" 2 | Liabilities:CreditCard Expenses:TBD 36 CHF 3 | 4 | 2024-07-06 "a / aa / Familie & Haushalt / 11 / FAMILY CLOTHING STORES / Belastung" 5 | Liabilities:CreditCard Expenses:TBD 72.6 CHF 6 | 7 | 2024-07-06 "b / bb / Auto / 22 / AUTOMOBILE TRUCK DEALERS, SALES, SERVICE / Belastung" 8 | Liabilities:CreditCard Expenses:TBD 6 CHF 9 | 10 | 2024-07-06 "c / cc / Lebensmittel / 33 / DEPARTMENT STORES / Belastung" 11 | Liabilities:CreditCard Expenses:TBD 80.7 CHF 12 | 13 | 2024-07-06 "d / dd / Lebensmittel / 44 / DEPARTMENT STORES / Belastung" 14 | Liabilities:CreditCard Expenses:TBD 4.95 CHF 15 | 16 | -------------------------------------------------------------------------------- /cmd/importer/supercard/testdata/example1.golden: -------------------------------------------------------------------------------- 1 | 2021-05-09 "A CHE Tankstelle" 2 | Liabilities:CreditCard Expenses:TBD 3.2 CHF 3 | 4 | 2021-05-09 "B CHE Mitgliedschaft in Sportclubs" 5 | Liabilities:CreditCard Expenses:TBD 3 CHF 6 | 7 | 2021-05-11 "C CHE Ärztliche Dienstleistungen" 8 | Liabilities:CreditCard Expenses:TBD 10 CHF 9 | 10 | 2021-05-13 "D CHE Elektronikgeschäfte, Radio/TV" 11 | Liabilities:CreditCard Expenses:TBD 167.1 CHF 12 | 13 | 2021-05-14 "E CHE Warenhaus" 14 | Liabilities:CreditCard Expenses:TBD 73 CHF 15 | 16 | 2021-05-14 "F CHE Warenhaus" 17 | Liabilities:CreditCard Expenses:TBD 66 CHF 18 | 19 | 2021-06-10 "G CHE Warenhaus" 20 | Expenses:TBD Liabilities:CreditCard 9 CHF 21 | 22 | -------------------------------------------------------------------------------- /lib/common/cpr/slice_test.go: -------------------------------------------------------------------------------- 1 | package cpr 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | type input struct { 9 | a, b, c int 10 | } 11 | 12 | func TestSeq(t *testing.T) { 13 | const size = 100000 14 | var list []*input 15 | for i := 0; i < size; i++ { 16 | list = append(list, &input{i, i + 1, i + 2}) 17 | } 18 | fnA := func(in *input) error { 19 | in.a++ 20 | return nil 21 | } 22 | fnB := func(in *input) error { 23 | in.b = in.a + in.b 24 | return nil 25 | } 26 | fnC := func(in *input) error { 27 | in.c = in.c + in.b 28 | return nil 29 | } 30 | 31 | got, err := Seq(context.Background(), list, fnA, fnB, fnC) 32 | 33 | if err != nil { 34 | t.Fatalf("Parallel() returned unexpected error: %v", err) 35 | } 36 | for i, l := range got { 37 | if l.a != i+1 || l.b != 2*(i+1) || l.c != 3*i+4 { 38 | t.Fatalf("invalid test[%d]: %v", i, l) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /cmd/importer/swissquote/testdata/example1.input: -------------------------------------------------------------------------------- 1 | Datum;Auftrag #;Transaktionen;Symbol;Name;ISIN;Anzahl;Stückpreis;Kosten;Aufgelaufene Zinsen;Nettobetrag;Saldo;Währung 2 | 09-10-2020 12:17:42;76396333;Kauf;VWRL;Vanguard All World ETF Dist;IE00B3RBWM25;8.0;87.60;12.90;0.00;-713.70;85.12;CHF 3 | 09-10-2020 12:13:40;00000000;Forex-Gutschrift;;;;1.0;830.07;0.00;0.00;830.07;798.82;CHF 4 | 09-10-2020 12:13:40;00000000;Forex-Belastung;;;;1.0;918.00;0.00;0.00;-918.00;0.80;USD 5 | 09-10-2020 07:02:32;00000000;Dividende;VWRL;Vanguard All World ETF Dist;IE00B3RBWM25;1.0;23.80;0.00;0.00;23.80;23.80;USD 6 | 30-09-2020 12:18:46;00000000;Depotgebühren;;;;1.0;42.27;3.25;0.00;-45.52;-31.25;CHF 7 | 30-12-2017 13:19:07;00000000;Zins;;;;1.0;0.19;0.00;0.00;0.19;418.08;USD 8 | 05-05-2015 09:05:02;00000000;Capital Gain;SYM;NAME;CH00XX;1.0;82.00;0.00;0.00;82.00;3'441.70;CHF 9 | 27-05-2020 07:02:56;00000000;Einzahlung;;;;1.0;3'656.89;0.00;0.00;3'656.89;3'656.88;USD 10 | 11 | -------------------------------------------------------------------------------- /lib/common/dict/dict.go: -------------------------------------------------------------------------------- 1 | package dict 2 | 3 | import ( 4 | "github.com/sboehler/knut/lib/common/compare" 5 | ) 6 | 7 | func Keys[K comparable, V any](m map[K]V) []K { 8 | res := make([]K, 0, len(m)) 9 | for k := range m { 10 | res = append(res, k) 11 | } 12 | return res 13 | } 14 | 15 | func SortedKeys[K comparable, V any](m map[K]V, c compare.Compare[K]) []K { 16 | res := Keys(m) 17 | compare.Sort(res, c) 18 | return res 19 | } 20 | 21 | func Values[K comparable, V any](m map[K]V) []V { 22 | res := make([]V, 0, len(m)) 23 | for _, v := range m { 24 | res = append(res, v) 25 | } 26 | return res 27 | } 28 | 29 | func SortedValues[K comparable, V any](m map[K]V, c compare.Compare[V]) []V { 30 | res := Values(m) 31 | compare.Sort(res, c) 32 | return res 33 | } 34 | 35 | func GetDefault[K comparable, V any](m map[K]V, k K, c func() V) V { 36 | v, ok := m[k] 37 | if !ok { 38 | v = c() 39 | m[k] = v 40 | } 41 | return v 42 | } 43 | -------------------------------------------------------------------------------- /cmd/importer/interactivebrokers/testdata/example1.input: -------------------------------------------------------------------------------- 1 | Statement,Header,Field Name,Field Value 2 | Statement,Data,BrokerName,Interactive Brokers 3 | Statement,Data,BrokerAddress, 4 | Statement,Data,Title,Activity Statement 5 | Statement,Data,Period,"January 1, 2020 - April 22, 2020" 6 | Account Information,Data,Base Currency,EUR 7 | Open Positions,Data,Summary,Stocks,USD,AAPL,10,1,100.00,100.00,100.00,100.00,100.00,100.00, 8 | Forex Balances,Data,Forex,CHF,CHF,320.072033121,1,-320.072033121,1,320.072033121,0, 9 | Trades,Data,Order,Stocks,USD,AAPL,"2020-04-12, 10:17:49",7,10.00,10.00,-1.00,-1.00,0,0,40.425,O 10 | Trades,Data,Order,Forex,USD,CHF.USD,"2020-03-21, 11:17:34","-4,59",1.03371,,449.38889,-1.1,,,,3.446, 11 | Deposits & Withdrawals,Data,CHF,2020-01-20,Electronic Fund Transfer,1000 12 | Dividends,Data,USD,2020-02-13,AAPL(US0378331005) Cash Dividend USD 0.77 per Share (Ordinary Dividend),10.00 13 | Withholding Tax,Data,USD,2020-02-13,AAPL(US0378331005) Cash Dividend USD 0.77 per Share - US Tax,-1.23, -------------------------------------------------------------------------------- /cmd/importer/viac/viac_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package viac 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/sebdah/goldie/v2" 21 | 22 | "github.com/sboehler/knut/cmd/cmdtest" 23 | ) 24 | 25 | func TestGolden(t *testing.T) { 26 | 27 | got := cmdtest.Run(t, CreateCmd(), "--commodity", "Viac", "testdata/example1.input") 28 | 29 | goldie.New(t).Assert(t, "example1", got) 30 | } 31 | -------------------------------------------------------------------------------- /cmd/importer/interactivebrokers/testdata/example1.golden: -------------------------------------------------------------------------------- 1 | 2020-01-20 "Deposit 1000 CHF" 2 | Expenses:TBD Assets:IB 1000 CHF 3 | 4 | @performance(AAPL) 5 | 2020-02-13 "AAPL(US0378331005) Cash Dividend USD 0.77 per Share (Ordinary Dividend)" 6 | Income:Dividends Assets:IB 10 USD 7 | 8 | @performance(AAPL) 9 | 2020-02-13 "AAPL(US0378331005) Cash Dividend USD 0.77 per Share - US Tax" 10 | Assets:IB Expenses:Tax 1.23 USD 11 | 12 | @performance(CHF,USD) 13 | 2020-03-21 "Sell -459 CHF @ 1.03371 USD" 14 | Assets:IB Expenses:Trading 459 CHF 15 | Expenses:Trading Assets:IB 449.39 USD 16 | Assets:IB Expenses:Fees 1.1 EUR 17 | 18 | @performance(AAPL,USD) 19 | 2020-04-12 "Buy 7 AAPL @ 10 USD" 20 | Expenses:Trading Assets:IB 7 AAPL 21 | Assets:IB Expenses:Trading 1 USD 22 | Assets:IB Expenses:Fees 1 USD 23 | 24 | 2020-04-22 balance Assets:IB 10 AAPL 25 | 2020-04-22 balance Assets:IB 320.07 CHF 26 | 27 | -------------------------------------------------------------------------------- /cmd/importer/cumulus/cumulus_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cumulus 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/sboehler/knut/cmd/cmdtest" 21 | 22 | "github.com/sebdah/goldie/v2" 23 | ) 24 | 25 | func TestGolden(t *testing.T) { 26 | 27 | got := cmdtest.Run(t, CreateCmd(), "--account", "Liabilities:Cumulus", "testdata/example1.input") 28 | 29 | goldie.New(t).Assert(t, "example1", got) 30 | } 31 | -------------------------------------------------------------------------------- /cmd/importer/supercard/supercard_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package supercard 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/sboehler/knut/cmd/cmdtest" 21 | 22 | "github.com/sebdah/goldie/v2" 23 | ) 24 | 25 | func TestGolden(t *testing.T) { 26 | 27 | got := cmdtest.Run(t, CreateCmd(), "--account", "Liabilities:CreditCard", "testdata/example1.input") 28 | 29 | goldie.New(t).Assert(t, "example1", got) 30 | } 31 | -------------------------------------------------------------------------------- /cmd/importer/swisscard/swisscard_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package swisscard 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/sboehler/knut/cmd/cmdtest" 21 | 22 | "github.com/sebdah/goldie/v2" 23 | ) 24 | 25 | func TestGolden(t *testing.T) { 26 | 27 | got := cmdtest.Run(t, CreateCmd(), "--account", "Liabilities:CreditCard", "testdata/example1.input") 28 | 29 | goldie.New(t).Assert(t, "example1", got) 30 | } 31 | -------------------------------------------------------------------------------- /cmd/importer/swisscard2/swisscard2_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package swisscard 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/sboehler/knut/cmd/cmdtest" 21 | "github.com/sebdah/goldie/v2" 22 | ) 23 | 24 | func TestGolden(t *testing.T) { 25 | 26 | got := cmdtest.Run(t, CreateCmd(), "--account", "Liabilities:CreditCard", "testdata/example1.input") 27 | 28 | goldie.New(t).Assert(t, "example1", got) 29 | } 30 | -------------------------------------------------------------------------------- /cmd/importer/postfinance/postfinance_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package postfinance 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/sebdah/goldie/v2" 21 | 22 | "github.com/sboehler/knut/cmd/cmdtest" 23 | ) 24 | 25 | func TestGolden(t *testing.T) { 26 | 27 | got := cmdtest.Run(t, CreateCmd(), "--account", "Assets:Postfinance", "testdata/example1.input") 28 | 29 | goldie.New(t).Assert(t, "example1", got) 30 | } 31 | -------------------------------------------------------------------------------- /cmd/importer/revolut/revolut_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package revolut 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/sebdah/goldie/v2" 21 | 22 | "github.com/sboehler/knut/cmd/cmdtest" 23 | ) 24 | 25 | func TestGolden(t *testing.T) { 26 | 27 | got := cmdtest.Run(t, CreateCmd(), "--account", "Assets:Accounts:Revolut", "testdata/example1.input") 28 | 29 | goldie.New(t).Assert(t, "example1", got) 30 | 31 | } 32 | -------------------------------------------------------------------------------- /lib/common/predicate/predicate.go: -------------------------------------------------------------------------------- 1 | package predicate 2 | 3 | import ( 4 | "github.com/sboehler/knut/lib/common/regex" 5 | ) 6 | 7 | type Predicate[T any] func(T) bool 8 | 9 | func And[T any](predicates ...Predicate[T]) Predicate[T] { 10 | return func(t T) bool { 11 | for _, pred := range predicates { 12 | if !pred(t) { 13 | return false 14 | } 15 | } 16 | return true 17 | } 18 | } 19 | 20 | func True[T any](_ T) bool { 21 | return true 22 | } 23 | 24 | type Named interface { 25 | Name() string 26 | } 27 | 28 | func ByName[T Named](rxs regex.Regexes) Predicate[T] { 29 | if len(rxs) == 0 { 30 | return True[T] 31 | } 32 | return func(t T) bool { 33 | return rxs.MatchString(t.Name()) 34 | } 35 | } 36 | 37 | func Or[T any](fs ...Predicate[T]) Predicate[T] { 38 | return func(t T) bool { 39 | for _, f := range fs { 40 | if f(t) { 41 | return true 42 | } 43 | } 44 | return false 45 | } 46 | } 47 | 48 | func Not[T any](f Predicate[T]) Predicate[T] { 49 | return func(t T) bool { 50 | return !f(t) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /cmd/commands/transcode_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package commands 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/sboehler/knut/cmd/cmdtest" 21 | "github.com/sebdah/goldie/v2" 22 | ) 23 | 24 | func TestGolden(t *testing.T) { 25 | 26 | got := cmdtest.Run(t, CreateTranscodeCommand(), "-v", "CHF", "testdata/transcode/example.knut") 27 | 28 | goldie.New(t, goldie.WithFixtureDir("testdata/transcode")).Assert(t, "example", got) 29 | } 30 | -------------------------------------------------------------------------------- /cmd/importer/revolut2/revolut2_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package revolut2 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/sebdah/goldie/v2" 21 | 22 | "github.com/sboehler/knut/cmd/cmdtest" 23 | ) 24 | 25 | func TestGolden(t *testing.T) { 26 | 27 | got := cmdtest.Run(t, CreateCmd(), "--account", "Assets:Accounts:Revolut", "--fee", "Expenses:Fees", "testdata/example1.input") 28 | 29 | goldie.New(t).Assert(t, "example1", got) 30 | 31 | } 32 | -------------------------------------------------------------------------------- /cmd/importer/wise/testdata/example1.golden: -------------------------------------------------------------------------------- 1 | 2023-09-25 "BALANCE TRANSACTION 14 / convert 11945.05 CHF to 21960.02 NZD" 2 | Assets:Accounts:Wise Expenses:Fees 54.95 CHF 3 | Assets:Accounts:Wise Expenses:Trading 11945.05 CHF 4 | Expenses:Trading Assets:Accounts:Wise 21960.02 NZD 5 | 6 | 2023-11-27 "CARD TRANSACTION 15 / Lake Taupo Resort" 7 | Assets:Accounts:Wise Expenses:TBD 26.01 NZD 8 | 9 | 2023-12-04 "CARD TRANSACTION 17 / Kaikoura" 10 | Assets:Accounts:Wise Expenses:Fees 3 NZD 11 | Assets:Accounts:Wise Expenses:TBD 200 NZD 12 | 13 | 2023-12-06 "TRANSFER 13 / Rocky Balboa" 14 | Expenses:TBD Assets:Accounts:Wise 2000 CHF 15 | 16 | 2024-01-11 "CARD TRANSACTION 12 / Linkt" 17 | Assets:Accounts:Wise Expenses:TBD 21.53 AUD 18 | 19 | 2024-01-11 "CARD TRANSACTION 12 / convert 12.25 CHF to 21.53 AUD" 20 | Assets:Accounts:Wise Expenses:Fees 0.06 CHF 21 | Assets:Accounts:Wise Expenses:Trading 12.25 CHF 22 | Expenses:Trading Assets:Accounts:Wise 21.53 AUD 23 | 24 | -------------------------------------------------------------------------------- /cmd/commands/infer_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package commands 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/sboehler/knut/cmd/cmdtest" 21 | 22 | "github.com/sebdah/goldie/v2" 23 | ) 24 | 25 | func TestInfer(t *testing.T) { 26 | 27 | got := cmdtest.Run(t, CreateInferCmd(), "--training-file", "testdata/infer/training.knut", "testdata/infer/target.knut") 28 | 29 | goldie.New(t, goldie.WithFixtureDir("testdata/infer")).Assert(t, "target", got) 30 | } 31 | -------------------------------------------------------------------------------- /cmd/importer/wise/wise_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package wise 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/sebdah/goldie/v2" 21 | 22 | "github.com/sboehler/knut/cmd/cmdtest" 23 | ) 24 | 25 | func TestGolden(t *testing.T) { 26 | 27 | got := cmdtest.Run(t, CreateCmd(), "--account", "Assets:Accounts:Wise", "--fee", "Expenses:Fees", "--trading", "Expenses:Trading", "testdata/example1.input") 28 | 29 | goldie.New(t).Assert(t, "example1", got) 30 | 31 | } 32 | -------------------------------------------------------------------------------- /lib/model/price/price.go: -------------------------------------------------------------------------------- 1 | package price 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/sboehler/knut/lib/model/commodity" 7 | "github.com/sboehler/knut/lib/model/registry" 8 | "github.com/sboehler/knut/lib/syntax" 9 | "github.com/shopspring/decimal" 10 | ) 11 | 12 | // Price represents a price command. 13 | type Price struct { 14 | Src *syntax.Price 15 | Date time.Time 16 | Commodity *commodity.Commodity 17 | Price decimal.Decimal 18 | Target *commodity.Commodity 19 | } 20 | 21 | func Create(reg *registry.Registry, p *syntax.Price) (*Price, error) { 22 | date, err := p.Date.Parse() 23 | if err != nil { 24 | return nil, err 25 | } 26 | com, err := reg.Commodities().Create(p.Commodity) 27 | if err != nil { 28 | return nil, err 29 | } 30 | pr, err := p.Price.Parse() 31 | if err != nil { 32 | return nil, err 33 | } 34 | tgt, err := reg.Commodities().Create(p.Target) 35 | if err != nil { 36 | return nil, err 37 | } 38 | return &Price{ 39 | Src: p, 40 | Date: date, 41 | Commodity: com, 42 | Price: pr, 43 | Target: tgt, 44 | }, nil 45 | } 46 | -------------------------------------------------------------------------------- /cmd/commands/import.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package commands 16 | 17 | import ( 18 | "github.com/sboehler/knut/cmd/importer" 19 | "github.com/spf13/cobra" 20 | ) 21 | 22 | // CreateImportCommand is the import command. 23 | func CreateImportCommand() *cobra.Command { 24 | cmd := cobra.Command{ 25 | Use: "import", 26 | Short: "Import financial account statements", 27 | } 28 | for _, constructor := range importer.GetImporters() { 29 | cmd.AddCommand(constructor()) 30 | } 31 | return &cmd 32 | } 33 | -------------------------------------------------------------------------------- /cmd/importer/swissquote/testdata/example1.golden: -------------------------------------------------------------------------------- 1 | @performance(SYM) 2 | 2015-05-05 "Capital Gain SYM NAME CH00XX" 3 | Income:Dividends Assets:Swissquote 82 CHF 4 | 5 | @performance(USD) 6 | 2017-12-30 "Zins" 7 | Income:Interest Assets:Swissquote 0.19 USD 8 | 9 | 2020-05-27 "Einzahlung" 10 | Expenses:TBD Assets:Swissquote 3656.89 USD 11 | 12 | @performance() 13 | 2020-09-30 "Depotgebühren" 14 | Assets:Swissquote Expenses:Fees 45.52 CHF 15 | 16 | @performance(VWRL,CHF) 17 | 2020-10-09 "76396333 Kauf 8 x VWRL Vanguard All World ETF Dist IE00B3RBWM25 @ 87.6 CHF" 18 | Expenses:Trading Assets:Swissquote 8 VWRL 19 | Assets:Swissquote Expenses:Trading 700.8 CHF 20 | Assets:Swissquote Expenses:Fees 12.9 CHF 21 | 22 | @performance(VWRL) 23 | 2020-10-09 "Dividende VWRL Vanguard All World ETF Dist IE00B3RBWM25" 24 | Income:Dividends Assets:Swissquote 23.8 USD 25 | 26 | @performance(CHF,USD) 27 | 2020-10-09 "Forex-Gutschrift 830.07 CHF / Forex-Belastung -918 USD" 28 | Expenses:Trading Assets:Swissquote 830.07 CHF 29 | Assets:Swissquote Expenses:Trading 918 USD 30 | 31 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sboehler/knut 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/cheggaaa/pb/v3 v3.1.4 7 | github.com/dimchansky/utfbom v1.1.1 8 | github.com/fatih/color v1.15.0 9 | github.com/google/go-cmp v0.5.9 10 | github.com/natefinch/atomic v1.0.1 11 | github.com/sebdah/goldie/v2 v2.5.3 12 | github.com/shopspring/decimal v1.3.1 13 | github.com/sourcegraph/conc v0.3.0 14 | github.com/spf13/cobra v1.7.0 15 | github.com/spf13/pflag v1.0.5 16 | go.uber.org/multierr v1.11.0 17 | golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 18 | golang.org/x/sync v0.3.0 19 | golang.org/x/text v0.12.0 20 | gopkg.in/yaml.v2 v2.4.0 21 | ) 22 | 23 | require ( 24 | github.com/VividCortex/ewma v1.2.0 // indirect 25 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 26 | github.com/mattn/go-colorable v0.1.13 // indirect 27 | github.com/mattn/go-isatty v0.0.19 // indirect 28 | github.com/mattn/go-runewidth v0.0.15 // indirect 29 | github.com/pmezard/go-difflib v1.0.0 // indirect 30 | github.com/rivo/uniseg v0.4.4 // indirect 31 | github.com/sergi/go-diff v1.3.1 // indirect 32 | golang.org/x/sys v0.11.0 // indirect 33 | ) 34 | -------------------------------------------------------------------------------- /lib/common/set/set.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | import "github.com/sboehler/knut/lib/common/compare" 4 | 5 | type Set[T comparable] map[T]struct{} 6 | 7 | func New[T comparable]() Set[T] { 8 | return make(Set[T]) 9 | } 10 | 11 | func FromSlice[T comparable](ts []T) Set[T] { 12 | s := make(Set[T], len(ts)) 13 | s.AddAll(ts...) 14 | return s 15 | } 16 | 17 | func (set Set[T]) Add(t T) { 18 | set[t] = struct{}{} 19 | } 20 | 21 | func (set Set[T]) AddAll(ts ...T) { 22 | for _, t := range ts { 23 | set.Add(t) 24 | } 25 | } 26 | 27 | func (set Set[T]) Has(t T) bool { 28 | _, ok := set[t] 29 | return ok 30 | } 31 | 32 | func (set Set[T]) Remove(t T) { 33 | delete(set, t) 34 | } 35 | 36 | func (set Set[T]) Slice() []T { 37 | res := make([]T, 0, len(set)) 38 | for elem := range set { 39 | res = append(res, elem) 40 | } 41 | return res 42 | } 43 | 44 | func (set Set[T]) Sorted(cmp compare.Compare[T]) []T { 45 | res := set.Slice() 46 | compare.Sort(res, cmp) 47 | return res 48 | } 49 | 50 | func Of[T comparable](ts ...T) Set[T] { 51 | res := New[T]() 52 | for _, t := range ts { 53 | res.Add(t) 54 | } 55 | return res 56 | } 57 | -------------------------------------------------------------------------------- /cmd/commands/portfolio.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package commands 16 | 17 | import ( 18 | "github.com/spf13/cobra" 19 | 20 | returns "github.com/sboehler/knut/cmd/commands/portfolio" 21 | ) 22 | 23 | // CreatePortfolioCommand creates the command. 24 | func CreatePortfolioCommand() *cobra.Command { 25 | c := &cobra.Command{ 26 | Use: "portfolio", 27 | Short: "Portfolio management commands", 28 | Long: `Portfolio management commands`, 29 | } 30 | c.AddCommand(returns.CreateReturnsCommand()) 31 | c.AddCommand(returns.CreateWeightsCommand()) 32 | return c 33 | } 34 | -------------------------------------------------------------------------------- /cmd/cmdtest/cmdtest.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmdtest 16 | 17 | import ( 18 | "bytes" 19 | "io" 20 | "testing" 21 | 22 | "github.com/spf13/cobra" 23 | ) 24 | 25 | // Run runs the given command and args and returns the output 26 | // to stdout. 27 | func Run(t *testing.T, cmd *cobra.Command, args ...string) []byte { 28 | t.Helper() 29 | cmd.SetArgs(args) 30 | var b bytes.Buffer 31 | cmd.SetOut(&b) 32 | if err := cmd.Execute(); err != nil { 33 | t.Fatal(err) 34 | } 35 | out, err := io.ReadAll(&b) 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | return out 40 | } 41 | -------------------------------------------------------------------------------- /cmd/importer/wise/testdata/example1.input: -------------------------------------------------------------------------------- 1 | ID,Status,Direction,"Created on","Finished on","Source fee amount","Source fee currency","Target fee amount","Target fee currency","Source name","Source amount (after fees)","Source currency","Target name","Target amount (after fees)","Target currency","Exchange rate",Reference,Batch 2 | "CARD_TRANSACTION-12",COMPLETED,OUT,"2024-01-11 15:20:30","2024-01-11 15:20:30",0.06,CHF,,,"Rocky Balboa",12.25,CHF,Linkt,21.53,AUD,1.75685000,, 3 | TRANSFER-13,COMPLETED,IN,"2023-12-06 02:48:20","2023-12-07 01:33:37",0.00,CHF,,,"Rocky Balboa",2000.0,CHF,"Rocky Balboa",2000.0,CHF,1.0,, 4 | "BALANCE_TRANSACTION-14",COMPLETED,NEUTRAL,"2023-09-25 19:49:24","2023-09-25 19:49:24",54.95,CHF,,,"Rocky Balboa",11945.05,CHF,"Rocky Balboa",21960.02,NZD,1.83842000,, 5 | "CARD_TRANSACTION-15",COMPLETED,OUT,"2023-11-27 02:29:25","2023-11-27 02:29:25",0.00,NZD,,,"Rocky Balboa",26.01,NZD,"Lake Taupo Resort",26.01,NZD,1.00000000,, 6 | "CARD_TRANSACTION-16",CANCELLED,OUT,"2023-11-30 06:39:26","2023-11-30 06:39:26",,,,,"Rocky Balboa",41.94,NZD,Uber,41.94,NZD,1,, 7 | "CARD_TRANSACTION-17",COMPLETED,OUT,"2023-12-04 02:05:40","2023-12-04 02:05:40",3.00,NZD,,,"Rocky Balboa",200.00,NZD,Kaikoura,203.00,NZD,1.00000000,, 8 | -------------------------------------------------------------------------------- /cmd/importer/swissquote/swissquote_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package swissquote 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/sebdah/goldie/v2" 21 | 22 | "github.com/sboehler/knut/cmd/cmdtest" 23 | ) 24 | 25 | func TestGolden(t *testing.T) { 26 | 27 | got := cmdtest.Run(t, CreateCmd(), 28 | "--account", "Assets:Swissquote", 29 | "--dividend", "Income:Dividends", 30 | "--fee", "Expenses:Fees", 31 | "--interest", "Income:Interest", 32 | "--tax", "Expenses:Tax", 33 | "--trading", "Expenses:Trading", 34 | "testdata/example1.input") 35 | 36 | goldie.New(t).Assert(t, "example1", got) 37 | } 38 | -------------------------------------------------------------------------------- /cmd/importer/interactivebrokers/interactivebrokers_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package interactivebrokers 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/sebdah/goldie/v2" 21 | 22 | "github.com/sboehler/knut/cmd/cmdtest" 23 | ) 24 | 25 | func TestGolden(t *testing.T) { 26 | 27 | got := cmdtest.Run(t, CreateCmd(), 28 | "--account", "Assets:IB", 29 | "--dividend", "Income:Dividends", 30 | "--fee", "Expenses:Fees", 31 | "--tax", "Expenses:Tax", 32 | "--interest", "Expenses:Interest", 33 | "--trading", "Expenses:Trading", 34 | "testdata/example1.input") 35 | 36 | goldie.New(t).Assert(t, "example1", got) 37 | } 38 | -------------------------------------------------------------------------------- /lib/common/compare/compare.go: -------------------------------------------------------------------------------- 1 | package compare 2 | 3 | import ( 4 | "cmp" 5 | "sort" 6 | "time" 7 | 8 | "github.com/shopspring/decimal" 9 | "golang.org/x/exp/constraints" 10 | ) 11 | 12 | type Order = int 13 | 14 | const ( 15 | Smaller Order = -1 16 | Equal Order = 0 17 | Greater Order = 1 18 | ) 19 | 20 | type Compare[T any] func(t1, t2 T) Order 21 | 22 | func Ordered[T constraints.Ordered](t1, t2 T) Order { 23 | return cmp.Compare(t1, t2) 24 | } 25 | 26 | func Time(t1, t2 time.Time) Order { 27 | if t1 == t2 { 28 | return Equal 29 | } 30 | if t1.Before(t2) { 31 | return Smaller 32 | } 33 | return Greater 34 | } 35 | 36 | func Decimal(t1, t2 decimal.Decimal) Order { 37 | if t1.Equal(t2) { 38 | return Equal 39 | } 40 | if t1.LessThan(t2) { 41 | return Smaller 42 | } 43 | return Greater 44 | } 45 | 46 | func Desc[T any](cmp Compare[T]) Compare[T] { 47 | return func(t1, t2 T) Order { 48 | return cmp(t2, t1) 49 | } 50 | } 51 | 52 | func Asc[T any](cmp Compare[T]) Compare[T] { 53 | return cmp 54 | } 55 | 56 | func Combine[T any](cmp ...Compare[T]) Compare[T] { 57 | return func(t1, t2 T) Order { 58 | for _, c := range cmp { 59 | if o := c(t1, t2); o != Equal { 60 | return o 61 | } 62 | } 63 | return Equal 64 | } 65 | } 66 | 67 | func Sort[T any](ts []T, cmp func(T, T) Order) { 68 | sort.Slice(ts, func(i, j int) bool { 69 | return cmp(ts[i], ts[j]) == Smaller 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /cmd/importer/revolut/testdata/example1.input: -------------------------------------------------------------------------------- 1 | Completed Date;Reference;Paid Out (EUR);Paid In (EUR);Exchange Out;Exchange In; Balance (EUR);Exchange Rate;Category 2 | 26 Nov 2020;Sold EUR to CHF; 184.98;;CHF  199.95;; 100.00;FX-rate € 1 = CHF 1.0809;General 3 | 17 Aug 2020;Desc1; 81.64;;;; 284.98; ;Transport 4 | 17 Aug 2020;Desc2; 18.00;;;; 366.62; ;Travel 5 | 17 Aug 2020;Desc3; 3.60;;;; 384.62; ;Restaurants 6 | 17 Aug 2020;Desc4; 63.37;;;; 388.22; ;Travel 7 | 17 Aug 2020;Desc5; 14.67;;;; 451.59; ;Groceries 8 | 17 Aug 2020;Desc6; 62.09;;;; 466.26; ;Transport 9 | 17 Aug 2020;Desc7; 0.70;;;; 528.35; ;Services 10 | 10 Aug 2020;Desc8; 26.02;;;; 529.05; ;Groceries 11 | 10 Aug 2020;Desc9; 98.53;;;; 555.07; ;Groceries 12 | 10 Aug 2020;Desc10; 2.00;;;; 653.60; ;Transport 13 | 10 Aug 2020;Desc11; 20.00;;;; 655.60; ;Shopping 14 | 10 Aug 2020;Desc12; 3.30;;;; 675.60; ;Groceries 15 | 9 Aug 2020;Desc13; 5.00;;;; 678.90; ;Groceries 16 | 9 Aug 2020;Desc14; 3.00;;;; 683.90; ;Transport 17 | 9 Aug 2020;Desc15; 20.00;;;; 686.90; ;Transport 18 | 9 Aug 2020;Desc16; 145.00;;;; 706.90; ;Restaurants 19 | 7 Aug 2020;Desc17; 180.00;;;; 851.90; ;Restaurants 20 | 7 Aug 2020;Desc18; 7.70;;;; 1'031.90; ;Restaurants 21 | 7 Aug 2020;Desc19; 61.00;;;; 1'039.60; ;Entertainment 22 | 6 Aug 2020;Desc20; 5.60;;;; 1'100.60; ;Groceries 23 | 6 Aug 2020;Desc21; 9.80;;;; 1'106.20; ;Restaurants 24 | 6 Aug 2020;Desc22; 84.00;;;; 1'116.00; ;Travel 25 | 5 Aug 2020;Bought EUR from CHF;; 1'200.00;;CHF  1'293.25; 1'200.00;FX-rate € 1 = CHF 1.0777;General 26 | -------------------------------------------------------------------------------- /lib/common/table/table_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package table 16 | 17 | import "testing" 18 | 19 | func TestAddThousandsSep(t *testing.T) { 20 | tests := []struct { 21 | input, want string 22 | }{ 23 | {"1000.000", "1,000.000"}, 24 | {"1.234", "1.234"}, 25 | {"12.34", "12.34"}, 26 | {"123.45", "123.45"}, 27 | {"1234.56", "1,234.56"}, 28 | {"12345.67", "12,345.67"}, 29 | {"12345678.9", "12,345,678.9"}, 30 | {"12345678", "12,345,678"}, 31 | {"-12345678", "-12,345,678"}, 32 | {"-123.45", "-123.45"}, 33 | {"0", "0"}, 34 | {"10", "10"}, 35 | {"100", "100"}, 36 | } 37 | 38 | for _, test := range tests { 39 | test := test 40 | t.Run(test.input, func(t *testing.T) { 41 | got := addThousandsSep(test.input) 42 | 43 | if got != test.want { 44 | t.Errorf("fmt2(%q) = %q, want %q", test.input, got, test.want) 45 | } 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/model/registry/registry.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package registry 16 | 17 | import ( 18 | "github.com/sboehler/knut/lib/model/account" 19 | "github.com/sboehler/knut/lib/model/commodity" 20 | ) 21 | 22 | type Account = account.Account 23 | type Commodity = commodity.Commodity 24 | 25 | // Registry has context for the model, namely a collection of 26 | // referenced accounts and commodities. 27 | type Registry struct { 28 | accounts *account.Registry 29 | commodities *commodity.Registry 30 | } 31 | 32 | // New creates a new, empty context. 33 | func New() *Registry { 34 | return &Registry{ 35 | accounts: account.NewRegistry(), 36 | commodities: commodity.NewCommodities(), 37 | } 38 | } 39 | 40 | // Accounts returns the accounts. 41 | func (reg Registry) Accounts() *account.Registry { 42 | return reg.accounts 43 | } 44 | 45 | // Commodities returns the commodities. 46 | func (reg Registry) Commodities() *commodity.Registry { 47 | return reg.commodities 48 | } 49 | -------------------------------------------------------------------------------- /lib/journal/performance/universe.go: -------------------------------------------------------------------------------- 1 | package performance 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | 9 | "github.com/sboehler/knut/lib/model" 10 | "github.com/sboehler/knut/lib/model/commodity" 11 | "gopkg.in/yaml.v2" 12 | ) 13 | 14 | type yamlUniverseFile map[string][]string 15 | 16 | func LoadUniverseFromFile(reg *commodity.Registry, path string) (Universe, error) { 17 | f, err := os.Open(path) 18 | if err != nil { 19 | return nil, err 20 | } 21 | defer f.Close() 22 | return LoadUniverse(reg, f) 23 | } 24 | 25 | func LoadUniverse(reg *commodity.Registry, r io.Reader) (Universe, error) { 26 | dec := yaml.NewDecoder(r) 27 | dec.SetStrict(true) 28 | var t yamlUniverseFile 29 | if err := dec.Decode(&t); err != nil { 30 | return nil, err 31 | } 32 | return fromYAML(reg, t) 33 | } 34 | 35 | type Universe map[*model.Commodity][]string 36 | 37 | func fromYAML(reg *commodity.Registry, yaml yamlUniverseFile) (Universe, error) { 38 | universe := make(Universe) 39 | for class, commodities := range yaml { 40 | for _, name := range commodities { 41 | com, err := reg.Get(name) 42 | if err != nil { 43 | return nil, err 44 | } 45 | if _, ok := universe[com]; ok { 46 | return nil, fmt.Errorf("commodity %s already has a classification", com.Name()) 47 | } 48 | universe[com] = append(strings.Split(class, ":"), com.Name()) 49 | } 50 | } 51 | return universe, nil 52 | } 53 | 54 | func (un Universe) Locate(c *model.Commodity) []string { 55 | class, ok := un[c] 56 | if ok { 57 | return class 58 | } 59 | return []string{"Other", c.Name()} 60 | } 61 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | "os" 20 | 21 | "github.com/sboehler/knut/cmd" 22 | 23 | // enable importers here 24 | _ "github.com/sboehler/knut/cmd/importer/cumulus" 25 | _ "github.com/sboehler/knut/cmd/importer/interactivebrokers" 26 | _ "github.com/sboehler/knut/cmd/importer/postfinance" 27 | _ "github.com/sboehler/knut/cmd/importer/revolut" 28 | _ "github.com/sboehler/knut/cmd/importer/revolut2" 29 | _ "github.com/sboehler/knut/cmd/importer/supercard" 30 | _ "github.com/sboehler/knut/cmd/importer/swisscard" 31 | _ "github.com/sboehler/knut/cmd/importer/swisscard2" 32 | _ "github.com/sboehler/knut/cmd/importer/swissquote" 33 | _ "github.com/sboehler/knut/cmd/importer/viac" 34 | _ "github.com/sboehler/knut/cmd/importer/wise" 35 | ) 36 | 37 | var version = "development" 38 | 39 | func main() { 40 | c := cmd.CreateCmd(version) 41 | if err := c.Execute(); err != nil { 42 | fmt.Fprintln(c.ErrOrStderr(), err) 43 | os.Exit(1) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /cmd/commands/testdata/transcode/USD.prices: -------------------------------------------------------------------------------- 1 | 2019-12-31 price USD 0.96863 CHF 2 | 2020-01-01 price USD 0.9672 CHF 3 | 2020-01-02 price USD 0.9675 CHF 4 | 2020-01-03 price USD 0.9712 CHF 5 | 2020-01-06 price USD 0.97148 CHF 6 | 2020-01-07 price USD 0.9685 CHF 7 | 2020-01-08 price USD 0.96883 CHF 8 | 2020-01-09 price USD 0.9732 CHF 9 | 2020-01-10 price USD 0.97312 CHF 10 | 2020-01-13 price USD 0.97314 CHF 11 | 2020-01-14 price USD 0.9707 CHF 12 | 2020-01-15 price USD 0.96707 CHF 13 | 2020-01-16 price USD 0.9637 CHF 14 | 2020-01-17 price USD 0.96488 CHF 15 | 2020-01-20 price USD 0.96821 CHF 16 | 2020-01-21 price USD 0.96838 CHF 17 | 2020-01-22 price USD 0.9688 CHF 18 | 2020-01-23 price USD 0.9674 CHF 19 | 2020-01-24 price USD 0.9695 CHF 20 | 2020-01-27 price USD 0.96994 CHF 21 | 2020-01-28 price USD 0.96985 CHF 22 | 2020-01-29 price USD 0.97298 CHF 23 | 2020-01-30 price USD 0.97318 CHF 24 | 2020-01-31 price USD 0.96941 CHF 25 | 2020-02-03 price USD 0.96336 CHF 26 | 2020-02-04 price USD 0.9657 CHF 27 | 2020-02-05 price USD 0.96927 CHF 28 | 2020-02-06 price USD 0.9733 CHF 29 | 2020-02-07 price USD 0.9745 CHF 30 | 2020-02-10 price USD 0.97666 CHF 31 | 2020-02-11 price USD 0.9771 CHF 32 | 2020-02-12 price USD 0.9756 CHF 33 | 2020-02-13 price USD 0.97756 CHF 34 | 2020-02-14 price USD 0.97888 CHF 35 | 2020-02-17 price USD 0.98169 CHF 36 | 2020-02-18 price USD 0.9804 CHF 37 | 2020-02-19 price USD 0.9829 CHF 38 | 2020-02-20 price USD 0.9835 CHF 39 | 2020-02-21 price USD 0.98376 CHF 40 | 2020-02-24 price USD 0.97884 CHF 41 | 2020-02-25 price USD 0.97978 CHF 42 | 2020-02-26 price USD 0.9759 CHF 43 | 2020-02-27 price USD 0.97639 CHF 44 | 2020-02-28 price USD 0.96875 CHF 45 | -------------------------------------------------------------------------------- /cmd/commands/testdata/transcode/AAPL.prices: -------------------------------------------------------------------------------- 1 | 2019-12-31 price AAPL 73.412498 USD 2 | 2020-01-02 price AAPL 75.087502 USD 3 | 2020-01-03 price AAPL 74.357498 USD 4 | 2020-01-06 price AAPL 74.949997 USD 5 | 2020-01-07 price AAPL 74.597504 USD 6 | 2020-01-08 price AAPL 75.797501 USD 7 | 2020-01-09 price AAPL 77.407501 USD 8 | 2020-01-10 price AAPL 77.582497 USD 9 | 2020-01-13 price AAPL 79.239998 USD 10 | 2020-01-14 price AAPL 78.169998 USD 11 | 2020-01-15 price AAPL 77.834999 USD 12 | 2020-01-16 price AAPL 78.809998 USD 13 | 2020-01-17 price AAPL 79.682503 USD 14 | 2020-01-21 price AAPL 79.142502 USD 15 | 2020-01-22 price AAPL 79.425003 USD 16 | 2020-01-23 price AAPL 79.807503 USD 17 | 2020-01-24 price AAPL 79.577499 USD 18 | 2020-01-27 price AAPL 77.237503 USD 19 | 2020-01-28 price AAPL 79.422501 USD 20 | 2020-01-29 price AAPL 81.084999 USD 21 | 2020-01-30 price AAPL 80.967499 USD 22 | 2020-01-31 price AAPL 77.377502 USD 23 | 2020-02-03 price AAPL 77.165001 USD 24 | 2020-02-04 price AAPL 79.712502 USD 25 | 2020-02-05 price AAPL 80.362503 USD 26 | 2020-02-06 price AAPL 81.302498 USD 27 | 2020-02-07 price AAPL 80.0075 USD 28 | 2020-02-10 price AAPL 80.387497 USD 29 | 2020-02-11 price AAPL 79.902496 USD 30 | 2020-02-12 price AAPL 81.800003 USD 31 | 2020-02-13 price AAPL 81.217499 USD 32 | 2020-02-14 price AAPL 81.237503 USD 33 | 2020-02-18 price AAPL 79.75 USD 34 | 2020-02-19 price AAPL 80.904999 USD 35 | 2020-02-20 price AAPL 80.074997 USD 36 | 2020-02-21 price AAPL 78.262497 USD 37 | 2020-02-24 price AAPL 74.544998 USD 38 | 2020-02-25 price AAPL 72.019997 USD 39 | 2020-02-26 price AAPL 73.162498 USD 40 | 2020-02-27 price AAPL 68.379997 USD 41 | 2020-02-28 price AAPL 68.339996 USD 42 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package cmd is the main command file for Cobra 16 | package cmd 17 | 18 | import ( 19 | "github.com/sboehler/knut/cmd/commands" 20 | 21 | "github.com/spf13/cobra" 22 | ) 23 | 24 | // CreateCmd creates the command. 25 | func CreateCmd(version string) *cobra.Command { 26 | c := &cobra.Command{ 27 | Use: "knut", 28 | Short: "knut is a plain text accounting tool", 29 | Long: `knut is a plain text accounting tool for tracking personal finances and investments.`, 30 | Version: version, 31 | } 32 | c.AddCommand(commands.CreateBalanceCommand()) 33 | c.AddCommand(commands.CreateCheckCommand()) 34 | c.AddCommand(commands.CreateCompletionCommand(c)) 35 | c.AddCommand(commands.CreateFormatCommand()) 36 | c.AddCommand(commands.CreateImportCommand()) 37 | c.AddCommand(commands.CreateInferCmd()) 38 | c.AddCommand(commands.CreatePortfolioCommand()) 39 | c.AddCommand(commands.CreateFetchCommand()) 40 | c.AddCommand(commands.CreateRegisterCmd()) 41 | c.AddCommand(commands.CreateTranscodeCommand()) 42 | c.AddCommand(commands.CreatePrintCommand()) 43 | 44 | return c 45 | } 46 | -------------------------------------------------------------------------------- /doc/example.knut: -------------------------------------------------------------------------------- 1 | include "USD.prices" 2 | include "AAPL.prices" 3 | 4 | * Open Accounts 5 | 6 | 2019-12-31 open Equity:Equity 7 | 2019-12-31 open Assets:BankAccount 8 | 2019-12-31 open Assets:Portfolio 9 | 10 | 2019-12-31 open Expenses:Groceries 11 | 2019-12-31 open Expenses:Fees 12 | 2019-12-31 open Expenses:Rent 13 | 14 | 2019-12-31 open Income:Salary 15 | 2019-12-31 open Income:Dividends 16 | 17 | * Opening Balances 18 | 19 | 2019-12-31 "Opening balance" 20 | Equity:Equity Assets:BankAccount 10000 CHF 21 | 22 | * 2020-01 23 | 24 | 2020-01-25 "Salary January 2020" 25 | Income:Salary Assets:BankAccount 5000 CHF 26 | 27 | 2020-01-02 "Rent January" 28 | Assets:BankAccount Expenses:Rent 2000 CHF 29 | 30 | 2020-01-15 "Groceries" 31 | Assets:BankAccount Expenses:Groceries 200 CHF 32 | 33 | 2020-01-05 "Transfer to portfolio" 34 | Assets:BankAccount Assets:Portfolio 1000 CHF 35 | 36 | 2020-01-06 "Currency exchange" 37 | Equity:Equity Assets:Portfolio 1001 USD 38 | Assets:Portfolio Equity:Equity 969 CHF 39 | 40 | 2020-01-06 "Buy 12 AAPL shares" 41 | Equity:Equity Assets:Portfolio 12 AAPL 42 | Assets:Portfolio Equity:Equity 900 USD 43 | Assets:Portfolio Expenses:Fees 4 USD 44 | 45 | * 2020-02 46 | 47 | 2020-02-25 "Salary January 2020" 48 | Income:Salary Assets:BankAccount 5000 CHF 49 | 50 | 2020-02-02 "Rent January" 51 | Assets:BankAccount Expenses:Rent 2000 CHF 52 | 53 | 2020-02-05 "Groceries" 54 | Assets:BankAccount Expenses:Groceries 250 CHF 55 | 56 | 2020-02-25 "Groceries" 57 | Assets:BankAccount Expenses:Groceries 423 CHF 58 | -------------------------------------------------------------------------------- /cmd/commands/testdata/transcode/example.knut: -------------------------------------------------------------------------------- 1 | include "USD.prices" 2 | include "AAPL.prices" 3 | 4 | * Open Accounts 5 | 6 | 2019-12-31 open Equity:Equity 7 | 2019-12-31 open Assets:BankAccount 8 | 2019-12-31 open Assets:Portfolio 9 | 10 | 2019-12-31 open Expenses:Groceries 11 | 2019-12-31 open Expenses:Fees 12 | 2019-12-31 open Expenses:Rent 13 | 14 | 2019-12-31 open Income:Salary 15 | 2019-12-31 open Income:Dividends 16 | 17 | * Opening Balances 18 | 19 | 2019-12-31 "Opening balance" 20 | Equity:Equity Assets:BankAccount 10000 CHF 21 | 22 | * 2020-01 23 | 24 | 2020-01-25 "Salary January 2020" 25 | Income:Salary Assets:BankAccount 5000 CHF 26 | 27 | 2020-01-02 "Rent January" 28 | Assets:BankAccount Expenses:Rent 2000 CHF 29 | 30 | 2020-01-15 "Groceries" 31 | Assets:BankAccount Expenses:Groceries 200 CHF 32 | 33 | 2020-01-05 "Transfer to portfolio" 34 | Assets:BankAccount Assets:Portfolio 1000 CHF 35 | 36 | 2020-01-06 "Currency exchange" 37 | Equity:Equity Assets:Portfolio 1001 USD 38 | Assets:Portfolio Equity:Equity 969 CHF 39 | 40 | 2020-01-06 "Buy 3 AAPL shares" 41 | Equity:Equity Assets:Portfolio 12 AAPL 42 | Assets:Portfolio Equity:Equity 900 USD 43 | Assets:Portfolio Expenses:Fees 4 USD 44 | 45 | * 2020-02 46 | 47 | 2020-02-25 "Salary January 2020" 48 | Income:Salary Assets:BankAccount 5000 CHF 49 | 50 | 2020-02-02 "Rent January" 51 | Assets:BankAccount Expenses:Rent 2000 CHF 52 | 53 | 2020-02-05 "Groceries" 54 | Assets:BankAccount Expenses:Groceries 250 CHF 55 | 56 | 2020-02-25 "Groceries" 57 | Assets:BankAccount Expenses:Groceries 423 CHF 58 | -------------------------------------------------------------------------------- /lib/model/assertion/assertion.go: -------------------------------------------------------------------------------- 1 | package assertion 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/sboehler/knut/lib/common/compare" 7 | "github.com/sboehler/knut/lib/model/account" 8 | "github.com/sboehler/knut/lib/model/commodity" 9 | "github.com/sboehler/knut/lib/model/registry" 10 | "github.com/sboehler/knut/lib/syntax" 11 | "github.com/shopspring/decimal" 12 | ) 13 | 14 | // Assertion represents a balance assertion. 15 | type Assertion struct { 16 | Src *syntax.Assertion 17 | Date time.Time 18 | Balances []Balance 19 | } 20 | 21 | type Balance struct { 22 | Src *syntax.Balance 23 | Account *account.Account 24 | Quantity decimal.Decimal 25 | Commodity *commodity.Commodity 26 | } 27 | 28 | func Create(reg *registry.Registry, a *syntax.Assertion) (*Assertion, error) { 29 | date, err := a.Date.Parse() 30 | if err != nil { 31 | return nil, err 32 | } 33 | balances := make([]Balance, 0, len(a.Balances)) 34 | for _, bal := range a.Balances { 35 | account, err := reg.Accounts().Create(bal.Account) 36 | if err != nil { 37 | return nil, err 38 | } 39 | quantity, err := bal.Quantity.Parse() 40 | if err != nil { 41 | return nil, err 42 | } 43 | commodity, err := reg.Commodities().Create(bal.Commodity) 44 | if err != nil { 45 | return nil, err 46 | } 47 | balances = append(balances, Balance{ 48 | Src: &bal, 49 | Account: account, 50 | Quantity: quantity, 51 | Commodity: commodity, 52 | }) 53 | 54 | } 55 | return &Assertion{ 56 | Src: a, 57 | Date: date, 58 | Balances: balances, 59 | }, nil 60 | } 61 | 62 | func CompareBalance(x, y Balance) compare.Order { 63 | if x.Account != y.Account { 64 | return account.Compare(x.Account, y.Account) 65 | } 66 | if x.Commodity != y.Commodity { 67 | return commodity.Compare(x.Commodity, y.Commodity) 68 | } 69 | return compare.Decimal(x.Quantity, y.Quantity) 70 | } 71 | -------------------------------------------------------------------------------- /lib/common/table/csv.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package table 16 | 17 | import ( 18 | "encoding/csv" 19 | "fmt" 20 | "io" 21 | ) 22 | 23 | // CSVRenderer renders a table to text. 24 | type CSVRenderer struct{} 25 | 26 | // Render renders this table to a string. 27 | func (r *CSVRenderer) Render(t *Table, w io.Writer) error { 28 | writer := csv.NewWriter(w) 29 | for _, row := range t.rows { 30 | var rec []string 31 | for _, c := range row.cells { 32 | s, err := r.renderCell(c) 33 | if err != nil { 34 | return err 35 | } 36 | rec = append(rec, s) 37 | } 38 | var hasText bool 39 | for _, r := range rec { 40 | if len(r) > 0 { 41 | hasText = true 42 | break 43 | } 44 | } 45 | if !hasText { 46 | continue 47 | } 48 | if err := writer.Write(rec); err != nil { 49 | return err 50 | } 51 | } 52 | writer.Flush() 53 | return nil 54 | } 55 | 56 | func (r *CSVRenderer) renderCell(c cell) (string, error) { 57 | switch t := c.(type) { 58 | 59 | case emptyCell, SeparatorCell: 60 | return "", nil 61 | 62 | case textCell: 63 | return t.Content, nil 64 | 65 | case numberCell: 66 | return t.n.String(), nil 67 | 68 | case percentCell: 69 | return fmt.Sprintf("%f", t.n), nil 70 | } 71 | return "", fmt.Errorf("%v is not a valid cell type", c) 72 | } 73 | -------------------------------------------------------------------------------- /cmd/importer/revolut2/testdata/example1.input: -------------------------------------------------------------------------------- 1 | Type,Product,Started Date,Completed Date,Description,Amount,Fee,Currency,State,Balance 2 | CARD_PAYMENT,Current,2020-07-01 16:35:02,2020-07-02 05:27:33,a,-16.95,1.00,CHF,COMPLETED,779.65 3 | CARD_PAYMENT,Current,2020-07-02 10:39:51,2020-07-03 04:32:46,b,-31.80,0.00,CHF,COMPLETED,747.85 4 | CARD_PAYMENT,Current,2020-07-02 12:03:28,2020-07-03 04:32:46,b,-6.00,0.00,CHF,COMPLETED,741.85 5 | CARD_PAYMENT,Current,2020-07-02 23:35:49,2020-07-03 07:23:45,c,-3.00,0.00,CHF,COMPLETED,738.85 6 | CARD_PAYMENT,Current,2020-07-06 11:11:20,2020-07-07 04:23:31,d,-17.95,0.00,CHF,COMPLETED,720.90 7 | CARD_PAYMENT,Current,2020-07-06 22:38:13,2020-07-07 23:49:36,e,-39.51,0.00,CHF,COMPLETED,681.39 8 | CARD_PAYMENT,Current,2020-07-07 13:13:54,2020-07-08 04:45:32,d,-35.90,0.00,CHF,COMPLETED,645.49 9 | CARD_PAYMENT,Current,2020-07-12 11:52:10,2020-07-13 09:02:08,f,-35.90,0.00,CHF,COMPLETED,609.59 10 | CARD_PAYMENT,Current,2020-07-16 16:21:53,2020-07-19 02:56:14,g,-11.85,0.00,CHF,COMPLETED,597.74 11 | CARD_PAYMENT,Current,2020-07-22 16:41:59,2020-07-23 01:01:46,h,-43.90,0.00,CHF,COMPLETED,553.84 12 | CARD_PAYMENT,Current,2020-07-22 18:10:36,2020-07-23 01:01:46,b,-5.00,0.00,CHF,COMPLETED,548.84 13 | CARD_PAYMENT,Current,2020-07-26 12:54:33,2020-07-27 04:30:17,i,-19.90,0.00,CHF,COMPLETED,528.94 14 | CARD_PAYMENT,Current,2020-07-25 08:34:25,2020-07-27 13:43:26,j,-4.60,0.00,CHF,COMPLETED,524.34 15 | CARD_PAYMENT,Current,2020-07-30 12:41:36,2020-07-31 05:51:30,k,-35.90,0.00,CHF,COMPLETED,488.44 16 | TOPUP,Current,2020-08-04 00:03:38,2020-08-04 00:03:38,l,2000.00,0.00,CHF,COMPLETED,2488.44 17 | CARD_PAYMENT,Current,2020-08-03 18:37:23,2020-08-04 01:51:10,b,-5.00,0.00,CHF,COMPLETED,2483.44 18 | CARD_PAYMENT,Current,2020-08-03 12:48:21,2020-08-04 01:51:10,m,-1.00,0.00,CHF,COMPLETED,2482.44 19 | CARD_PAYMENT,Current,2020-08-03 17:17:56,2020-08-04 07:26:32,n,-95.96,0.00,CHF,COMPLETED,2386.48 20 | EXCHANGE,Current,2020-08-05 10:59:52,2020-08-05 10:59:52,o,-1293.25,0.00,CHF,COMPLETED,1093.23 21 | -------------------------------------------------------------------------------- /cmd/commands/format.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package commands 16 | 17 | import ( 18 | "bytes" 19 | "fmt" 20 | "os" 21 | 22 | "github.com/natefinch/atomic" 23 | "github.com/sourcegraph/conc/iter" 24 | "github.com/spf13/cobra" 25 | "go.uber.org/multierr" 26 | 27 | "github.com/sboehler/knut/lib/syntax" 28 | ) 29 | 30 | // CreateFormatCommand creates the command. 31 | func CreateFormatCommand() *cobra.Command { 32 | var runner formatRunner 33 | return &cobra.Command{ 34 | Use: "format", 35 | Short: "Format the given journal", 36 | Long: `Format the given journal in-place. Any white space and comments between directives is preserved.`, 37 | 38 | Run: runner.run, 39 | } 40 | } 41 | 42 | type formatRunner struct{} 43 | 44 | func (r formatRunner) run(cmd *cobra.Command, args []string) { 45 | if err := r.execute(cmd, args); err != nil { 46 | fmt.Fprintln(cmd.ErrOrStderr(), err) 47 | os.Exit(1) 48 | } 49 | } 50 | 51 | func (r formatRunner) execute(cmd *cobra.Command, args []string) error { 52 | return multierr.Combine(iter.Map(args, r.formatFile)...) 53 | } 54 | 55 | func (formatRunner) formatFile(target *string) error { 56 | file, err := syntax.ParseFile(*target) 57 | if err != nil { 58 | return err 59 | } 60 | var dest bytes.Buffer 61 | if err := syntax.FormatFile(&dest, file); err != nil { 62 | return err 63 | } 64 | return atomic.WriteFile(*target, &dest) 65 | } 66 | -------------------------------------------------------------------------------- /cmd/commands/print.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package commands 16 | 17 | import ( 18 | "bufio" 19 | "fmt" 20 | "os" 21 | 22 | "github.com/sboehler/knut/lib/journal" 23 | "github.com/sboehler/knut/lib/journal/check" 24 | "github.com/sboehler/knut/lib/model/registry" 25 | 26 | "github.com/spf13/cobra" 27 | ) 28 | 29 | // CreatePrintCommand creates the command. 30 | func CreatePrintCommand() *cobra.Command { 31 | var r printRunner 32 | 33 | // Cmd is the balance command. 34 | cmd := &cobra.Command{ 35 | Use: "print", 36 | Short: "print the journal", 37 | Long: `Print the given journal.`, 38 | 39 | Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), 40 | 41 | Run: r.run, 42 | } 43 | r.setupFlags(cmd) 44 | return cmd 45 | } 46 | 47 | type printRunner struct { 48 | } 49 | 50 | func (r *printRunner) setupFlags(c *cobra.Command) { 51 | } 52 | 53 | func (r *printRunner) run(cmd *cobra.Command, args []string) { 54 | if err := r.execute(cmd, args); err != nil { 55 | fmt.Fprintln(cmd.ErrOrStderr(), err) 56 | os.Exit(1) 57 | } 58 | } 59 | 60 | func (r *printRunner) execute(cmd *cobra.Command, args []string) (errors error) { 61 | reg := registry.New() 62 | j, err := journal.FromPath(cmd.Context(), reg, args[0]) 63 | if err != nil { 64 | return err 65 | } 66 | if err := j.Build().Process(check.Check()); err != nil { 67 | return err 68 | } 69 | w := bufio.NewWriter(cmd.OutOrStdout()) 70 | defer w.Flush() 71 | return journal.Print(w, j.Build()) 72 | } 73 | -------------------------------------------------------------------------------- /lib/common/multimap/multimap.go: -------------------------------------------------------------------------------- 1 | package multimap 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/sboehler/knut/lib/common/compare" 7 | "github.com/sboehler/knut/lib/common/dict" 8 | ) 9 | 10 | type Node[V any] struct { 11 | Segment string 12 | Value V 13 | Children map[string]*Node[V] 14 | Sorted []*Node[V] 15 | } 16 | 17 | func New[V any](segment string) *Node[V] { 18 | return &Node[V]{ 19 | Segment: segment, 20 | Children: make(map[string]*Node[V]), 21 | } 22 | } 23 | 24 | // GetOrCreate creates or returns the node at the given key. 25 | func (n *Node[V]) GetOrCreate(ss []string) *Node[V] { 26 | if len(ss) == 0 { 27 | return n 28 | } 29 | head, tail := ss[0], ss[1:] 30 | return dict. 31 | GetDefault(n.Children, head, func() *Node[V] { return New[V](head) }). 32 | GetOrCreate(tail) 33 | } 34 | 35 | // Get creates or returns the node at the given key. 36 | func (n *Node[V]) GetPath(ss []string) (*Node[V], bool) { 37 | if len(ss) == 0 { 38 | return n, true 39 | } 40 | head, tail := ss[0], ss[1:] 41 | if child, ok := n.Children[head]; ok { 42 | return child.GetPath(tail) 43 | } 44 | return nil, false 45 | } 46 | 47 | // Get gets an immediate child of this node. 48 | func (n *Node[V]) Get(key string) (*Node[V], bool) { 49 | ch, ok := n.Children[key] 50 | return ch, ok 51 | } 52 | 53 | func (n *Node[V]) MustGet(key string) *Node[V] { 54 | ch, ok := n.Children[key] 55 | if !ok { 56 | panic(fmt.Sprintf("no child with key %s", key)) 57 | } 58 | return ch 59 | } 60 | 61 | // Create creates an immediate child of this node. 62 | func (n *Node[V]) Create(key string) (*Node[V], error) { 63 | if _, found := n.Children[key]; found { 64 | return nil, fmt.Errorf("child already exists") 65 | } 66 | child := New[V](key) 67 | n.Children[key] = child 68 | return child, nil 69 | } 70 | 71 | func (n *Node[V]) Sort(f compare.Compare[*Node[V]]) { 72 | for _, ch := range n.Children { 73 | ch.Sort(f) 74 | } 75 | n.Sorted = dict.SortedValues(n.Children, f) 76 | } 77 | 78 | func SortAlpha[V any](n1, n2 *Node[V]) compare.Order { 79 | return compare.Ordered(n1.Segment, n2.Segment) 80 | } 81 | 82 | func (n *Node[V]) PostOrder(f func(*Node[V])) { 83 | for _, ch := range n.Children { 84 | ch.PostOrder(f) 85 | } 86 | f(n) 87 | } 88 | -------------------------------------------------------------------------------- /cmd/commands/completion.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package commands 16 | 17 | import ( 18 | "fmt" 19 | "io" 20 | "os" 21 | 22 | "github.com/spf13/cobra" 23 | ) 24 | 25 | // CreateCompletionCommand creates a command. 26 | func CreateCompletionCommand(rootCmd *cobra.Command) *cobra.Command { 27 | c := &cobra.Command{ 28 | Use: "completion [bash|zsh]", 29 | Short: "output shell completion code [bash|zsh]", 30 | Long: `To load completions: 31 | 32 | Bash: 33 | 34 | $ source <(knut completion bash) 35 | 36 | # To load completions for each session, execute once: 37 | Linux: 38 | $ knut completion bash > /etc/bash_completion.d/knut 39 | MacOS: 40 | $ knut completion bash > /usr/local/etc/bash_completion.d/knut 41 | 42 | Zsh: 43 | 44 | # If shell completion is not already enabled in your environment you will need 45 | # to enable it. You can execute the following once: 46 | 47 | $ echo "autoload -U compinit; compinit" >> ~/.zshrc 48 | 49 | # To load completions in your current shell session: 50 | $ source <(knut completion zsh) 51 | 52 | # To load completions for each session, execute once: 53 | $ knut completion zsh > "${fpath[1]}/_knut" 54 | 55 | # You will need to start a new shell for this setup to take effect. 56 | `, 57 | 58 | Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), 59 | 60 | Run: func(cmd *cobra.Command, args []string) { 61 | switch args[0] { 62 | case `bash`: 63 | rootCmd.GenBashCompletion(os.Stdout) 64 | case `zsh`: 65 | rootCmd.GenZshCompletion(os.Stdout) 66 | io.WriteString(os.Stdout, "\ncompdef _knut knut\n") 67 | default: 68 | fmt.Printf("Unknown shell: %s", args[0]) 69 | } 70 | }, 71 | } 72 | 73 | return c 74 | } 75 | -------------------------------------------------------------------------------- /cmd/importer/revolut2/testdata/example1.golden: -------------------------------------------------------------------------------- 1 | 2020-07-02 "a" 2 | Assets:Accounts:Revolut Expenses:TBD 16.95 CHF 3 | Assets:Accounts:Revolut Expenses:Fees 1 CHF 4 | 5 | 2020-07-02 balance Assets:Accounts:Revolut 779.65 CHF 6 | 7 | 2020-07-03 "b" 8 | Assets:Accounts:Revolut Expenses:TBD 31.8 CHF 9 | 10 | 2020-07-03 "b" 11 | Assets:Accounts:Revolut Expenses:TBD 6 CHF 12 | 13 | 2020-07-03 "c" 14 | Assets:Accounts:Revolut Expenses:TBD 3 CHF 15 | 16 | 2020-07-03 balance Assets:Accounts:Revolut 738.85 CHF 17 | 18 | 2020-07-07 "d" 19 | Assets:Accounts:Revolut Expenses:TBD 17.95 CHF 20 | 21 | 2020-07-07 "e" 22 | Assets:Accounts:Revolut Expenses:TBD 39.51 CHF 23 | 24 | 2020-07-07 balance Assets:Accounts:Revolut 681.39 CHF 25 | 26 | 2020-07-08 "d" 27 | Assets:Accounts:Revolut Expenses:TBD 35.9 CHF 28 | 29 | 2020-07-08 balance Assets:Accounts:Revolut 645.49 CHF 30 | 31 | 2020-07-13 "f" 32 | Assets:Accounts:Revolut Expenses:TBD 35.9 CHF 33 | 34 | 2020-07-13 balance Assets:Accounts:Revolut 609.59 CHF 35 | 36 | 2020-07-19 "g" 37 | Assets:Accounts:Revolut Expenses:TBD 11.85 CHF 38 | 39 | 2020-07-19 balance Assets:Accounts:Revolut 597.74 CHF 40 | 41 | 2020-07-23 "b" 42 | Assets:Accounts:Revolut Expenses:TBD 5 CHF 43 | 44 | 2020-07-23 "h" 45 | Assets:Accounts:Revolut Expenses:TBD 43.9 CHF 46 | 47 | 2020-07-23 balance Assets:Accounts:Revolut 548.84 CHF 48 | 49 | 2020-07-27 "i" 50 | Assets:Accounts:Revolut Expenses:TBD 19.9 CHF 51 | 52 | 2020-07-27 "j" 53 | Assets:Accounts:Revolut Expenses:TBD 4.6 CHF 54 | 55 | 2020-07-27 balance Assets:Accounts:Revolut 524.34 CHF 56 | 57 | 2020-07-31 "k" 58 | Assets:Accounts:Revolut Expenses:TBD 35.9 CHF 59 | 60 | 2020-07-31 balance Assets:Accounts:Revolut 488.44 CHF 61 | 62 | 2020-08-04 "b" 63 | Assets:Accounts:Revolut Expenses:TBD 5 CHF 64 | 65 | 2020-08-04 "l" 66 | Expenses:TBD Assets:Accounts:Revolut 2000 CHF 67 | 68 | 2020-08-04 "m" 69 | Assets:Accounts:Revolut Expenses:TBD 1 CHF 70 | 71 | 2020-08-04 "n" 72 | Assets:Accounts:Revolut Expenses:TBD 95.96 CHF 73 | 74 | 2020-08-04 balance Assets:Accounts:Revolut 2386.48 CHF 75 | 76 | 2020-08-05 "o" 77 | Assets:Accounts:Revolut Expenses:TBD 1293.25 CHF 78 | 79 | 2020-08-05 balance Assets:Accounts:Revolut 1093.23 CHF 80 | 81 | -------------------------------------------------------------------------------- /lib/syntax/bayes/bayes_test.go: -------------------------------------------------------------------------------- 1 | package bayes 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | "github.com/sboehler/knut/lib/syntax" 10 | "github.com/sboehler/knut/lib/syntax/parser" 11 | ) 12 | 13 | func TestPrintFile(t *testing.T) { 14 | tests := []struct { 15 | desc string 16 | training string 17 | target string 18 | want string 19 | }{ 20 | { 21 | desc: "print transaction", 22 | training: lines( 23 | `2022-03-03 "Hello world"`, 24 | `A B 400 CHF`, 25 | ``, 26 | `2022-03-03 "Hello Europe"`, 27 | `A C 400 CHF`, 28 | ``, 29 | `2022-03-03 "Hello Asia"`, 30 | `A D 400 CHF`, 31 | ``, 32 | ), 33 | target: lines( 34 | `2022-03-03 "hello europe"`, 35 | `A TBD 400 CHF`, 36 | ``, 37 | `2022-03-03 "hello world"`, 38 | `A TBD 400 CHF`, 39 | ``, 40 | `2022-03-03 "hello asia"`, 41 | `A TBD 400 CHF`, 42 | ), 43 | want: lines( 44 | `2022-03-03 "hello europe"`, 45 | `A C 400 CHF`, 46 | ``, 47 | `2022-03-03 "hello world"`, 48 | `A B 400 CHF`, 49 | ``, 50 | `2022-03-03 "hello asia"`, 51 | `A D 400 CHF`, 52 | ), 53 | }, 54 | } 55 | 56 | for _, test := range tests { 57 | t.Run(test.desc, func(t *testing.T) { 58 | training := parse(t, test.training) 59 | target := parse(t, test.target) 60 | model := NewModel("TBD") 61 | for _, d := range training.Directives { 62 | if t, ok := d.Directive.(syntax.Transaction); ok { 63 | model.Update(&t) 64 | } 65 | } 66 | 67 | for _, d := range target.Directives { 68 | if t, ok := d.Directive.(syntax.Transaction); ok { 69 | model.Infer(&t) 70 | } 71 | } 72 | var got bytes.Buffer 73 | 74 | err := syntax.FormatFile(&got, target) 75 | 76 | if err != nil { 77 | t.Fatalf("pr.Format() returned unexpected error: %v", err) 78 | } 79 | if diff := cmp.Diff(test.want, got.String()); diff != "" { 80 | t.Fatalf("PrintFile() returned unexpected diff (-want/+got):\n%s\n", diff) 81 | } 82 | }) 83 | } 84 | } 85 | 86 | func lines(ss ...string) string { 87 | return strings.Join(ss, "\n") + "\n" 88 | } 89 | 90 | func parse(t *testing.T, s string) syntax.File { 91 | t.Helper() 92 | p := parser.New(s, "") 93 | if err := p.Advance(); err != nil { 94 | t.Fatal(err) 95 | } 96 | f, err := p.ParseFile() 97 | if err != nil { 98 | t.Fatalf("p.ParseFile() returned unexpected error: %#v", err) 99 | } 100 | return f 101 | } 102 | -------------------------------------------------------------------------------- /lib/quotes/yahoo/yahoo_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package yahoo 16 | 17 | import ( 18 | "net/http" 19 | "net/http/httptest" 20 | "testing" 21 | "time" 22 | 23 | "github.com/google/go-cmp/cmp" 24 | ) 25 | 26 | func TestFetch(t *testing.T) { 27 | var ( 28 | gotQuery map[string][]string 29 | response = "Date,Open,High,Low,Close,Adj Close,Volume\n" + 30 | "2019-11-07,1294.280029,1323.739990,1294.244995,1308.859985,1308.859985,2030000\n" + 31 | "2019-11-08,1305.280029,1318.000000,1304.364990,1311.369995,1311.369995,1251400" 32 | srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 | gotQuery = r.URL.Query() 34 | w.Write([]byte(response)) 35 | })) 36 | ) 37 | defer srv.Close() 38 | var ( 39 | want = []Quote{ 40 | { 41 | Date: time.Date(2019, 11, 07, 0, 0, 0, 0, time.UTC), 42 | Open: 1294.280029, 43 | High: 1323.73999, 44 | Low: 1294.244995, 45 | Close: 1308.859985, 46 | AdjClose: 1308.859985, 47 | Volume: 2030000, 48 | }, 49 | { 50 | Date: time.Date(2019, 11, 8, 0, 0, 0, 0, time.UTC), 51 | Open: 1305.280029, 52 | High: 1318, 53 | Low: 1304.36499, 54 | Close: 1311.369995, 55 | AdjClose: 1311.369995, 56 | Volume: 1251400, 57 | }, 58 | } 59 | wantQuery = map[string][]string{ 60 | "period1": {"1573084800"}, 61 | "period2": {"1573257600"}, 62 | "events": {"history"}, 63 | "interval": {"1d"}, 64 | } 65 | client = Client{srv.URL} 66 | ) 67 | 68 | got, err := client.Fetch("GOOG", time.Date(2019, 11, 7, 0, 0, 0, 0, time.UTC), time.Date(2019, 11, 9, 0, 0, 0, 0, time.UTC)) 69 | 70 | if diff := cmp.Diff(wantQuery, gotQuery); diff != "" { 71 | t.Errorf("client.Fetch(): unexpected diff in query parameters (-want, +got):\n%s", diff) 72 | } 73 | if err != nil { 74 | t.Errorf("client.Fetch(): returned unexpected error %v", err) 75 | } 76 | if diff := cmp.Diff(want, got); diff != "" { 77 | t.Errorf("client.Fetch() returned difference (-want, +got):\n%s", diff) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /cmd/commands/transcode.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package commands 16 | 17 | import ( 18 | "bufio" 19 | "fmt" 20 | "os" 21 | 22 | "github.com/sboehler/knut/cmd/flags" 23 | "github.com/sboehler/knut/lib/journal" 24 | "github.com/sboehler/knut/lib/journal/beancount" 25 | "github.com/sboehler/knut/lib/journal/check" 26 | "github.com/sboehler/knut/lib/model" 27 | "github.com/sboehler/knut/lib/model/registry" 28 | 29 | "github.com/spf13/cobra" 30 | ) 31 | 32 | // CreateTranscodeCommand creates the command. 33 | func CreateTranscodeCommand() *cobra.Command { 34 | var r transcodeRunner 35 | 36 | // Cmd is the balance command. 37 | cmd := &cobra.Command{ 38 | Use: "transcode", 39 | Short: "transcode to beancount", 40 | Long: `Transcode the given journal to beancount, to leverage their amazing tooling. This command requires a valuation commodity, so` + 41 | ` that all currency conversions can be done by knut.`, 42 | 43 | Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), 44 | 45 | Run: r.run, 46 | } 47 | r.setupFlags(cmd) 48 | return cmd 49 | } 50 | 51 | type transcodeRunner struct { 52 | valuation flags.CommodityFlag 53 | } 54 | 55 | func (r *transcodeRunner) setupFlags(c *cobra.Command) { 56 | c.Flags().VarP(&r.valuation, "val", "v", "valuate in the given commodity") 57 | } 58 | 59 | func (r *transcodeRunner) run(cmd *cobra.Command, args []string) { 60 | if err := r.execute(cmd, args); err != nil { 61 | fmt.Fprintln(cmd.ErrOrStderr(), err) 62 | os.Exit(1) 63 | } 64 | } 65 | 66 | func (r *transcodeRunner) execute(cmd *cobra.Command, args []string) (errors error) { 67 | var ( 68 | reg = registry.New() 69 | valuation *model.Commodity 70 | err error 71 | ) 72 | if valuation, err = r.valuation.Value(reg); err != nil { 73 | return err 74 | } 75 | b, err := journal.FromPath(cmd.Context(), reg, args[0]) 76 | if err != nil { 77 | return err 78 | } 79 | j := b.Build() 80 | err = j.Process( 81 | journal.Sort(), 82 | journal.ComputePrices(valuation), 83 | check.Check(), 84 | journal.Valuate(reg, valuation), 85 | ) 86 | if err != nil { 87 | return err 88 | } 89 | w := bufio.NewWriter(cmd.OutOrStdout()) 90 | defer w.Flush() 91 | 92 | return beancount.Transcode(w, j, valuation) 93 | } 94 | -------------------------------------------------------------------------------- /cmd/commands/check.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package commands 16 | 17 | import ( 18 | "bufio" 19 | "fmt" 20 | "os" 21 | 22 | "github.com/sboehler/knut/lib/journal" 23 | "github.com/sboehler/knut/lib/journal/check" 24 | "github.com/sboehler/knut/lib/model" 25 | "github.com/sboehler/knut/lib/model/registry" 26 | 27 | "github.com/spf13/cobra" 28 | ) 29 | 30 | // CreateCheckCommand creates the command. 31 | func CreateCheckCommand() *cobra.Command { 32 | 33 | var r checkRunner 34 | 35 | // Cmd is the balance command. 36 | c := &cobra.Command{ 37 | Use: "check", 38 | Short: "check the journal", 39 | Long: `Check the journal.`, 40 | Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), 41 | Run: r.run, 42 | } 43 | r.setupFlags(c) 44 | return c 45 | } 46 | 47 | type checkRunner struct { 48 | write bool 49 | noCheck bool 50 | } 51 | 52 | func (r *checkRunner) run(cmd *cobra.Command, args []string) { 53 | 54 | if err := r.execute(cmd, args); err != nil { 55 | fmt.Fprintf(cmd.ErrOrStderr(), "%s\n", err.Error()) 56 | os.Exit(1) 57 | } 58 | } 59 | 60 | func (r *checkRunner) setupFlags(c *cobra.Command) { 61 | c.Flags().BoolVar(&r.write, "write", false, "create a complete set of assertions") 62 | c.Flags().BoolVar(&r.noCheck, "no-check", false, "do not check assertions") 63 | } 64 | 65 | func (r *checkRunner) execute(cmd *cobra.Command, args []string) error { 66 | reg := registry.New() 67 | 68 | j, err := journal.FromPath(cmd.Context(), reg, args[0]) 69 | if err != nil { 70 | return err 71 | } 72 | checker := check.Checker{ 73 | Write: r.write, 74 | NoCheck: r.noCheck, 75 | } 76 | 77 | err = j.Build().Process( 78 | checker.Check(), 79 | ) 80 | if err != nil { 81 | return err 82 | } 83 | if r.write { 84 | out := bufio.NewWriter(os.Stdout) 85 | defer out.Flush() 86 | return r.writeFile(checker.Assertions()) 87 | } 88 | return nil 89 | } 90 | 91 | func (r *checkRunner) writeFile(assertions []*model.Assertion) error { 92 | out := bufio.NewWriter(os.Stdout) 93 | defer out.Flush() 94 | j := journal.New() 95 | for _, a := range assertions { 96 | j.Add(a) 97 | } 98 | return journal.Print(out, j.Build()) 99 | } 100 | -------------------------------------------------------------------------------- /lib/model/commodity/registry.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package commodity 16 | 17 | import ( 18 | "fmt" 19 | "sync" 20 | "unicode" 21 | 22 | "github.com/sboehler/knut/lib/common/compare" 23 | "github.com/sboehler/knut/lib/common/mapper" 24 | "github.com/sboehler/knut/lib/syntax" 25 | ) 26 | 27 | // Registry is a thread-safe collection of commodities. 28 | type Registry struct { 29 | index map[string]*Commodity 30 | mutex sync.RWMutex 31 | } 32 | 33 | // NewCommodities creates a new thread-safe collection of commodities. 34 | func NewCommodities() *Registry { 35 | return &Registry{ 36 | index: make(map[string]*Commodity), 37 | } 38 | } 39 | 40 | // Get creates a new commodity. 41 | func (cs *Registry) Get(name string) (*Commodity, error) { 42 | cs.mutex.RLock() 43 | res, ok := cs.index[name] 44 | cs.mutex.RUnlock() 45 | if ok { 46 | return res, nil 47 | } 48 | cs.mutex.Lock() 49 | defer cs.mutex.Unlock() 50 | // check if the commodity has been created in the meantime 51 | if res, ok = cs.index[name]; ok { 52 | return res, nil 53 | } 54 | if !isValidCommodity(name) { 55 | return nil, fmt.Errorf("invalid commodity name %q", name) 56 | } 57 | res = &Commodity{name: name} 58 | cs.insert(res) 59 | 60 | return res, nil 61 | } 62 | 63 | func (cs *Registry) MustGet(name string) *Commodity { 64 | com, err := cs.Get(name) 65 | if err != nil { 66 | panic(err) 67 | } 68 | return com 69 | } 70 | 71 | func (as *Registry) Create(a syntax.Commodity) (*Commodity, error) { 72 | return as.Get(a.Extract()) 73 | } 74 | 75 | func (cs *Registry) insert(c *Commodity) { 76 | cs.index[c.name] = c 77 | } 78 | 79 | // TagCurrency tags the commodity as a currency. 80 | func (cs *Registry) TagCurrency(name string) error { 81 | commodity, err := cs.Get(name) 82 | if err != nil { 83 | return err 84 | } 85 | cs.mutex.Lock() 86 | defer cs.mutex.Unlock() 87 | commodity.IsCurrency = true 88 | return nil 89 | } 90 | 91 | func isValidCommodity(s string) bool { 92 | if len(s) == 0 { 93 | return false 94 | } 95 | for _, c := range s { 96 | if !(unicode.IsLetter(c) || unicode.IsDigit(c)) { 97 | return false 98 | } 99 | } 100 | return true 101 | } 102 | 103 | func IdentityIf(t bool) func(*Commodity) *Commodity { 104 | if t { 105 | return mapper.Identity[*Commodity] 106 | } 107 | return mapper.Nil[*Commodity, Commodity] 108 | } 109 | 110 | func Compare(c1, c2 *Commodity) compare.Order { 111 | return compare.Ordered(c1.Name(), c2.Name()) 112 | } 113 | -------------------------------------------------------------------------------- /lib/model/posting/posting.go: -------------------------------------------------------------------------------- 1 | package posting 2 | 3 | import ( 4 | "github.com/sboehler/knut/lib/common/compare" 5 | "github.com/sboehler/knut/lib/model/account" 6 | "github.com/sboehler/knut/lib/model/commodity" 7 | "github.com/sboehler/knut/lib/model/registry" 8 | "github.com/sboehler/knut/lib/syntax" 9 | "github.com/shopspring/decimal" 10 | ) 11 | 12 | // Posting represents a posting. 13 | type Posting struct { 14 | Src *syntax.Booking 15 | Quantity, Value decimal.Decimal 16 | Account, Other *account.Account 17 | Commodity *commodity.Commodity 18 | } 19 | 20 | type Builder struct { 21 | Src *syntax.Booking 22 | Quantity, Value decimal.Decimal 23 | Credit, Debit *account.Account 24 | Commodity *commodity.Commodity 25 | } 26 | 27 | func (pb Builder) Build() []*Posting { 28 | if pb.Quantity.IsNegative() || pb.Quantity.IsZero() && pb.Value.IsNegative() { 29 | pb.Credit, pb.Debit, pb.Quantity, pb.Value = pb.Debit, pb.Credit, pb.Quantity.Neg(), pb.Value.Neg() 30 | } 31 | return []*Posting{ 32 | { 33 | Src: pb.Src, 34 | Account: pb.Credit, 35 | Other: pb.Debit, 36 | Commodity: pb.Commodity, 37 | Quantity: pb.Quantity.Neg(), 38 | Value: pb.Value.Neg(), 39 | }, 40 | { 41 | Src: pb.Src, 42 | Account: pb.Debit, 43 | Other: pb.Credit, 44 | Commodity: pb.Commodity, 45 | Quantity: pb.Quantity, 46 | Value: pb.Value, 47 | }, 48 | } 49 | } 50 | 51 | type Builders []Builder 52 | 53 | func (pbs Builders) Build() []*Posting { 54 | res := make([]*Posting, 0, 2*len(pbs)) 55 | for _, pb := range pbs { 56 | res = append(res, pb.Build()...) 57 | } 58 | return res 59 | } 60 | 61 | func Compare(p, p2 *Posting) compare.Order { 62 | if o := account.Compare(p.Account, p2.Account); o != compare.Equal { 63 | return o 64 | } 65 | if o := account.Compare(p.Other, p2.Other); o != compare.Equal { 66 | return o 67 | } 68 | if o := compare.Decimal(p.Quantity, p2.Quantity); o != compare.Equal { 69 | return o 70 | } 71 | if o := compare.Decimal(p.Value, p2.Value); o != compare.Equal { 72 | return o 73 | } 74 | return compare.Ordered(p.Commodity.Name(), p2.Commodity.Name()) 75 | } 76 | 77 | func Create(reg *registry.Registry, bs []syntax.Booking) ([]*Posting, error) { 78 | var builder Builders 79 | for i, b := range bs { 80 | credit, err := reg.Accounts().Create(b.Credit) 81 | if err != nil { 82 | return nil, err 83 | } 84 | debit, err := reg.Accounts().Create(b.Debit) 85 | if err != nil { 86 | return nil, err 87 | } 88 | amount, err := decimal.NewFromString(b.Quantity.Extract()) 89 | if err != nil { 90 | return nil, syntax.Error{Range: b.Quantity.Range, Message: "parsing amount", Wrapped: err} 91 | } 92 | commodity, err := reg.Commodities().Create(b.Commodity) 93 | if err != nil { 94 | return nil, err 95 | } 96 | builder = append(builder, Builder{ 97 | Src: &bs[i], 98 | Credit: credit, 99 | Debit: debit, 100 | Quantity: amount, 101 | Commodity: commodity, 102 | }) 103 | } 104 | return builder.Build(), nil 105 | } 106 | -------------------------------------------------------------------------------- /cmd/importer/revolut/testdata/example1.golden: -------------------------------------------------------------------------------- 1 | 2020-08-05 "Bought EUR from CHF FX-rate € 1 = CHF 1.0777 General" 2 | Income:Accounts:Revolut Assets:Accounts:Revolut 1200 EUR 3 | Assets:Accounts:Revolut Income:Accounts:Revolut 1293.25 CHF 4 | 5 | 2020-08-05 balance Assets:Accounts:Revolut 1200 EUR 6 | 7 | 2020-08-06 "Desc20 Groceries" 8 | Assets:Accounts:Revolut Expenses:TBD 5.6 EUR 9 | 10 | 2020-08-06 "Desc21 Restaurants" 11 | Assets:Accounts:Revolut Expenses:TBD 9.8 EUR 12 | 13 | 2020-08-06 "Desc22 Travel" 14 | Assets:Accounts:Revolut Expenses:TBD 84 EUR 15 | 16 | 2020-08-06 balance Assets:Accounts:Revolut 1100.6 EUR 17 | 18 | 2020-08-07 "Desc17 Restaurants" 19 | Assets:Accounts:Revolut Expenses:TBD 180 EUR 20 | 21 | 2020-08-07 "Desc18 Restaurants" 22 | Assets:Accounts:Revolut Expenses:TBD 7.7 EUR 23 | 24 | 2020-08-07 "Desc19 Entertainment" 25 | Assets:Accounts:Revolut Expenses:TBD 61 EUR 26 | 27 | 2020-08-07 balance Assets:Accounts:Revolut 851.9 EUR 28 | 29 | 2020-08-09 "Desc13 Groceries" 30 | Assets:Accounts:Revolut Expenses:TBD 5 EUR 31 | 32 | 2020-08-09 "Desc14 Transport" 33 | Assets:Accounts:Revolut Expenses:TBD 3 EUR 34 | 35 | 2020-08-09 "Desc15 Transport" 36 | Assets:Accounts:Revolut Expenses:TBD 20 EUR 37 | 38 | 2020-08-09 "Desc16 Restaurants" 39 | Assets:Accounts:Revolut Expenses:TBD 145 EUR 40 | 41 | 2020-08-09 balance Assets:Accounts:Revolut 678.9 EUR 42 | 43 | 2020-08-10 "Desc10 Transport" 44 | Assets:Accounts:Revolut Expenses:TBD 2 EUR 45 | 46 | 2020-08-10 "Desc11 Shopping" 47 | Assets:Accounts:Revolut Expenses:TBD 20 EUR 48 | 49 | 2020-08-10 "Desc12 Groceries" 50 | Assets:Accounts:Revolut Expenses:TBD 3.3 EUR 51 | 52 | 2020-08-10 "Desc8 Groceries" 53 | Assets:Accounts:Revolut Expenses:TBD 26.02 EUR 54 | 55 | 2020-08-10 "Desc9 Groceries" 56 | Assets:Accounts:Revolut Expenses:TBD 98.53 EUR 57 | 58 | 2020-08-10 balance Assets:Accounts:Revolut 529.05 EUR 59 | 60 | 2020-08-17 "Desc1 Transport" 61 | Assets:Accounts:Revolut Expenses:TBD 81.64 EUR 62 | 63 | 2020-08-17 "Desc2 Travel" 64 | Assets:Accounts:Revolut Expenses:TBD 18 EUR 65 | 66 | 2020-08-17 "Desc3 Restaurants" 67 | Assets:Accounts:Revolut Expenses:TBD 3.6 EUR 68 | 69 | 2020-08-17 "Desc4 Travel" 70 | Assets:Accounts:Revolut Expenses:TBD 63.37 EUR 71 | 72 | 2020-08-17 "Desc5 Groceries" 73 | Assets:Accounts:Revolut Expenses:TBD 14.67 EUR 74 | 75 | 2020-08-17 "Desc6 Transport" 76 | Assets:Accounts:Revolut Expenses:TBD 62.09 EUR 77 | 78 | 2020-08-17 "Desc7 Services" 79 | Assets:Accounts:Revolut Expenses:TBD 0.7 EUR 80 | 81 | 2020-08-17 balance Assets:Accounts:Revolut 284.98 EUR 82 | 83 | 2020-11-26 "Sold EUR to CHF FX-rate € 1 = CHF 1.0809 General" 84 | Assets:Accounts:Revolut Income:Accounts:Revolut 184.98 EUR 85 | Income:Accounts:Revolut Assets:Accounts:Revolut 199.95 CHF 86 | 87 | 2020-11-26 balance Assets:Accounts:Revolut 100 EUR 88 | 89 | -------------------------------------------------------------------------------- /lib/model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/sboehler/knut/lib/common/cpr" 8 | "github.com/sboehler/knut/lib/model/account" 9 | "github.com/sboehler/knut/lib/model/assertion" 10 | cls "github.com/sboehler/knut/lib/model/close" 11 | "github.com/sboehler/knut/lib/model/commodity" 12 | "github.com/sboehler/knut/lib/model/open" 13 | "github.com/sboehler/knut/lib/model/posting" 14 | "github.com/sboehler/knut/lib/model/price" 15 | "github.com/sboehler/knut/lib/model/registry" 16 | "github.com/sboehler/knut/lib/model/transaction" 17 | "github.com/sboehler/knut/lib/syntax" 18 | "github.com/sourcegraph/conc/pool" 19 | ) 20 | 21 | type Commodity = commodity.Commodity 22 | type AccountType = account.Type 23 | type Account = account.Account 24 | type Posting = posting.Posting 25 | type Transaction = transaction.Transaction 26 | type Open = open.Open 27 | type Close = cls.Close 28 | type Price = price.Price 29 | type Assertion = assertion.Assertion 30 | type Balance = assertion.Balance 31 | 32 | type Registry = registry.Registry 33 | 34 | type Directive any 35 | 36 | var ( 37 | _ Directive = (*assertion.Assertion)(nil) 38 | _ Directive = (*cls.Close)(nil) 39 | _ Directive = (*open.Open)(nil) 40 | _ Directive = (*price.Price)(nil) 41 | _ Directive = (*transaction.Transaction)(nil) 42 | ) 43 | 44 | type Result struct { 45 | Err error 46 | Directives []any 47 | } 48 | 49 | func FromStream(reg *registry.Registry, inCh <-chan syntax.File) (<-chan []Directive, func(context.Context) error) { 50 | return cpr.Produce(func(ctx context.Context, ch chan<- []Directive) error { 51 | wg := pool.New().WithContext(ctx).WithCancelOnError().WithFirstError() 52 | cpr.ForEach(ctx, inCh, func(input syntax.File) error { 53 | wg.Go(func(ctx context.Context) error { 54 | var ds []Directive 55 | for _, d := range input.Directives { 56 | m, err := ParseDirective(reg, d) 57 | if err != nil { 58 | return err 59 | } 60 | ds = append(ds, m...) 61 | } 62 | return cpr.Push(ctx, ch, ds) 63 | }) 64 | return nil 65 | }) 66 | return wg.Wait() 67 | }) 68 | } 69 | 70 | func ParseDirective(reg *registry.Registry, w syntax.Directive) ([]Directive, error) { 71 | switch d := w.Directive.(type) { 72 | case syntax.Transaction: 73 | ts, err := transaction.Create(reg, &d) 74 | if err != nil { 75 | return nil, err 76 | } 77 | var res []Directive 78 | for _, t := range ts { 79 | res = append(res, t) 80 | } 81 | return res, nil 82 | case syntax.Open: 83 | o, err := open.Create(reg, &d) 84 | if err != nil { 85 | return nil, err 86 | } 87 | return []Directive{o}, nil 88 | case syntax.Close: 89 | o, err := cls.Create(reg, &d) 90 | if err != nil { 91 | return nil, err 92 | } 93 | return []Directive{o}, nil 94 | case syntax.Assertion: 95 | o, err := assertion.Create(reg, &d) 96 | if err != nil { 97 | return nil, err 98 | } 99 | return []Directive{o}, nil 100 | case syntax.Price: 101 | o, err := price.Create(reg, &d) 102 | if err != nil { 103 | return nil, err 104 | } 105 | return []Directive{o}, nil 106 | case syntax.Include: 107 | return nil, nil 108 | } 109 | return nil, fmt.Errorf("unknown directive: %T", w) 110 | } 111 | -------------------------------------------------------------------------------- /lib/model/price/prices.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package price 16 | 17 | import ( 18 | "fmt" 19 | 20 | "github.com/sboehler/knut/lib/common/dict" 21 | "github.com/sboehler/knut/lib/model/commodity" 22 | "github.com/shopspring/decimal" 23 | ) 24 | 25 | // Prices stores the price for a commodity to a target commodity 26 | // Outer map: target commodity 27 | // Inner map: commodity 28 | // value: price in (target commodity / commodity) 29 | type Prices map[*commodity.Commodity]NormalizedPrices 30 | 31 | var one = decimal.NewFromInt(1) 32 | 33 | // Insert inserts a new price. 34 | func (ps Prices) Insert(commodity *commodity.Commodity, price decimal.Decimal, target *commodity.Commodity) error { 35 | if price.IsZero() { 36 | return fmt.Errorf("invalid price %s for commodity %s in %s", price.String(), commodity.Name(), target.Name()) 37 | } 38 | ps.addPrice(target, commodity, price) 39 | ps.addPrice(commodity, target, one.Div(price).Truncate(8)) 40 | return nil 41 | } 42 | 43 | func (ps Prices) addPrice(target, commodity *commodity.Commodity, price decimal.Decimal) { 44 | dict.GetDefault(ps, target, newNormalizedPrices)[commodity] = price 45 | } 46 | 47 | // Normalize creates a normalized price map for the given commodity. 48 | func (ps Prices) Normalize(t *commodity.Commodity) NormalizedPrices { 49 | res := NormalizedPrices{t: one} 50 | ps.normalize(t, res) 51 | return res 52 | } 53 | 54 | // normalize recursively computes prices by traversing the price graph. 55 | // res must already contain a price for c. 56 | func (ps Prices) normalize(c *commodity.Commodity, res NormalizedPrices) { 57 | for neighbor, price := range ps[c] { 58 | if _, done := res[neighbor]; done { 59 | continue 60 | } 61 | res[neighbor] = Multiply(price, res[c]) 62 | ps.normalize(neighbor, res) 63 | } 64 | } 65 | 66 | // NormalizedPrices is a map representing the price of 67 | // commodities in some base commodity. 68 | type NormalizedPrices map[*commodity.Commodity]decimal.Decimal 69 | 70 | func newNormalizedPrices() NormalizedPrices { 71 | return make(NormalizedPrices) 72 | } 73 | 74 | func (np NormalizedPrices) Price(c *commodity.Commodity) (decimal.Decimal, error) { 75 | price, ok := np[c] 76 | if !ok { 77 | return decimal.Zero, fmt.Errorf("no price found for %v in %v", c, np) 78 | } 79 | return price, nil 80 | } 81 | 82 | // Valuate valuates the given amount. 83 | func (np NormalizedPrices) Valuate(c *commodity.Commodity, a decimal.Decimal) (decimal.Decimal, error) { 84 | price, ok := np[c] 85 | if !ok { 86 | return decimal.Zero, fmt.Errorf("no price found for %v in %v", c, np) 87 | } 88 | return Multiply(a, price), nil 89 | } 90 | 91 | func Multiply(n1, n2 decimal.Decimal) decimal.Decimal { 92 | return n1.Mul(n2).Truncate(8) 93 | } 94 | -------------------------------------------------------------------------------- /cmd/importer/viac/viac.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package viac 16 | 17 | import ( 18 | "bufio" 19 | "encoding/json" 20 | "os" 21 | "time" 22 | 23 | "github.com/shopspring/decimal" 24 | "github.com/spf13/cobra" 25 | 26 | "github.com/sboehler/knut/cmd/flags" 27 | "github.com/sboehler/knut/cmd/importer" 28 | "github.com/sboehler/knut/lib/journal" 29 | "github.com/sboehler/knut/lib/model" 30 | "github.com/sboehler/knut/lib/model/registry" 31 | ) 32 | 33 | // CreateCmd creates the command. 34 | func CreateCmd() *cobra.Command { 35 | var r runner 36 | cmd := &cobra.Command{ 37 | Use: "ch.viac", 38 | Short: "Import VIAC values from JSON files", 39 | Long: `Open app.viac.ch, choose a portfolio, and select "From start" in the overview dash. In the Chrome dev tools, save the response from the "performance" XHR call, and pass the resulting file to this importer.`, 40 | 41 | Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), 42 | 43 | RunE: r.run, 44 | } 45 | r.setupFlags(cmd) 46 | return cmd 47 | } 48 | 49 | func init() { 50 | importer.RegisterImporter(CreateCmd) 51 | } 52 | 53 | func (r *runner) setupFlags(cmd *cobra.Command) { 54 | cmd.Flags().VarP(&r.from, "from", "f", "YYYY-MM-DD - ignore entries before this date") 55 | cmd.Flags().VarP(&r.account, "commodity", "a", "commodity name") 56 | } 57 | 58 | type runner struct { 59 | from flags.DateFlag 60 | account flags.CommodityFlag 61 | } 62 | 63 | func (r *runner) run(cmd *cobra.Command, args []string) error { 64 | reg := registry.New() 65 | account, err := r.account.Value(reg) 66 | if err != nil { 67 | return err 68 | } 69 | commodity, err := reg.Commodities().Get("CHF") 70 | if err != nil { 71 | return err 72 | } 73 | b, err := os.ReadFile(args[0]) 74 | if err != nil { 75 | return err 76 | } 77 | var resp response 78 | if err := json.Unmarshal(b, &resp); err != nil { 79 | return err 80 | } 81 | j := journal.New() 82 | for _, dv := range resp.DailyValues { 83 | d, err := time.Parse("2006-01-02", dv.Date) 84 | if err != nil { 85 | return err 86 | } 87 | if d.Before(r.from.Value()) { 88 | continue 89 | } 90 | a, err := decimal.NewFromString(dv.Value.String()) 91 | if err != nil { 92 | return err 93 | } 94 | if a.IsZero() { 95 | continue 96 | } 97 | j.Add(&model.Price{ 98 | Date: d, 99 | Commodity: account, 100 | Price: a.Round(2), 101 | Target: commodity, 102 | }) 103 | } 104 | 105 | out := bufio.NewWriter(cmd.OutOrStdout()) 106 | defer out.Flush() 107 | return journal.Print(out, j.Build()) 108 | } 109 | 110 | type response struct { 111 | DailyValues []dailyValue `json:"dailyWealth"` 112 | } 113 | 114 | type dailyValue struct { 115 | Date string `json:"date"` 116 | Value json.Number `json:"value"` 117 | } 118 | -------------------------------------------------------------------------------- /lib/syntax/syntax.go: -------------------------------------------------------------------------------- 1 | package syntax 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "os" 7 | "path" 8 | "path/filepath" 9 | "text/scanner" 10 | 11 | "github.com/sboehler/knut/lib/common/cpr" 12 | "github.com/sboehler/knut/lib/syntax/directives" 13 | "github.com/sboehler/knut/lib/syntax/parser" 14 | "github.com/sboehler/knut/lib/syntax/printer" 15 | "golang.org/x/sync/errgroup" 16 | ) 17 | 18 | type Commodity = directives.Commodity 19 | 20 | type Account = directives.Account 21 | 22 | type Date = directives.Date 23 | 24 | type Decimal = directives.Decimal 25 | 26 | type QuotedString = directives.QuotedString 27 | 28 | type Booking = directives.Booking 29 | 30 | type Performance = directives.Performance 31 | 32 | type Interval = directives.Interval 33 | 34 | type Directive = directives.Directive 35 | 36 | type File = directives.File 37 | 38 | type Accrual = directives.Accrual 39 | 40 | type Addons = directives.Addons 41 | 42 | type Transaction = directives.Transaction 43 | 44 | type Open = directives.Open 45 | 46 | type Close = directives.Close 47 | 48 | type Assertion = directives.Assertion 49 | 50 | type Balance = directives.Balance 51 | 52 | type Price = directives.Price 53 | 54 | type Include = directives.Include 55 | 56 | type Range = directives.Range 57 | 58 | type Location = directives.Location 59 | 60 | var _ error = Error{} 61 | 62 | type Error = directives.Error 63 | 64 | type Parser = parser.Parser 65 | 66 | type Scanner = scanner.Scanner 67 | 68 | func ParseFile(file string) (directives.File, error) { 69 | text, err := os.ReadFile(file) 70 | if err != nil { 71 | return directives.File{}, err 72 | } 73 | p := parser.New(string(text), file) 74 | if err := p.Advance(); err != nil { 75 | return directives.File{}, err 76 | } 77 | return p.ParseFile() 78 | } 79 | 80 | func ParseFileRecursively(file string) (<-chan directives.File, func(context.Context) error) { 81 | return cpr.Produce(func(ctx context.Context, ch chan<- directives.File) error { 82 | wg, ctx := errgroup.WithContext(ctx) 83 | wg.Go(func() error { 84 | res, err := parseRec(ctx, wg, ch, file) 85 | if err != nil { 86 | return err 87 | } 88 | return cpr.Push(ctx, ch, res) 89 | }) 90 | return wg.Wait() 91 | }) 92 | } 93 | 94 | type Result struct { 95 | File directives.File 96 | Err error 97 | } 98 | 99 | func parseRec(ctx context.Context, wg *errgroup.Group, resCh chan<- directives.File, file string) (directives.File, error) { 100 | text, err := os.ReadFile(file) 101 | if err != nil { 102 | return directives.File{}, err 103 | } 104 | p := parser.New(string(text), file) 105 | if err := p.Advance(); err != nil { 106 | return directives.File{}, err 107 | } 108 | p.Callback = func(d directives.Directive) { 109 | if inc, ok := d.Directive.(directives.Include); ok { 110 | file := path.Join(filepath.Dir(file), inc.IncludePath.Content.Extract()) 111 | wg.Go(func() error { 112 | res, err := parseRec(ctx, wg, resCh, file) 113 | if err != nil { 114 | return err 115 | } 116 | return cpr.Push(ctx, resCh, res) 117 | }) 118 | } 119 | } 120 | return p.ParseFile() 121 | } 122 | 123 | func FormatFile(w io.Writer, f directives.File) error { 124 | p := printer.New(w) 125 | return p.Format(f) 126 | } 127 | 128 | func PrintFile(w io.Writer, f directives.File) error { 129 | p := printer.New(w) 130 | _, err := p.PrintFile(f) 131 | return err 132 | } 133 | -------------------------------------------------------------------------------- /lib/reports/balance/report.go: -------------------------------------------------------------------------------- 1 | package balance 2 | 3 | import ( 4 | "github.com/sboehler/knut/lib/amounts" 5 | "github.com/sboehler/knut/lib/common/compare" 6 | "github.com/sboehler/knut/lib/common/date" 7 | "github.com/sboehler/knut/lib/common/mapper" 8 | "github.com/sboehler/knut/lib/common/multimap" 9 | "github.com/sboehler/knut/lib/model" 10 | "github.com/sboehler/knut/lib/model/account" 11 | "github.com/shopspring/decimal" 12 | ) 13 | 14 | type Report struct { 15 | Registry *model.Registry 16 | AL, EIE *multimap.Node[Value] 17 | partition date.Partition 18 | } 19 | 20 | type Value struct { 21 | Account *model.Account 22 | Amounts amounts.Amounts 23 | Weight decimal.Decimal 24 | } 25 | 26 | type Node = multimap.Node[Value] 27 | 28 | func NewReport(reg *model.Registry, part date.Partition) *Report { 29 | return &Report{ 30 | Registry: reg, 31 | AL: multimap.New[Value](""), 32 | EIE: multimap.New[Value](""), 33 | partition: part, 34 | } 35 | } 36 | 37 | func (r *Report) Insert(k amounts.Key, v decimal.Decimal) { 38 | if k.Account == nil { 39 | return 40 | } 41 | var n *Node 42 | if k.Account.IsAL() { 43 | n = r.AL.GetOrCreate(k.Account.Segments()) 44 | } else { 45 | n = r.EIE.GetOrCreate(k.Account.Segments()) 46 | } 47 | if n.Value.Account == nil { 48 | n.Value.Account = k.Account 49 | n.Value.Amounts = make(amounts.Amounts) 50 | } 51 | n.Value.Amounts.Add(k, v) 52 | } 53 | 54 | func (r *Report) SortAlpha() { 55 | f := func(n1, n2 *Node) compare.Order { 56 | if n1.Value.Account.Level() == 1 && n2.Value.Account.Level() == 1 { 57 | return compare.Ordered(n1.Value.Account.Type(), n2.Value.Account.Type()) 58 | } 59 | return multimap.SortAlpha(n1, n2) 60 | } 61 | r.AL.Sort(f) 62 | r.EIE.Sort(f) 63 | } 64 | 65 | func (r *Report) SortWeighted() { 66 | computeWeights := func(n *Node) { 67 | w := n.Value.Amounts.SumOver(func(k amounts.Key) bool { 68 | return k.Valuation != nil 69 | }).Abs().Neg() 70 | for _, ch := range n.Children { 71 | w = w.Add(ch.Value.Weight) 72 | } 73 | n.Value.Weight = w 74 | } 75 | r.AL.PostOrder(computeWeights) 76 | r.EIE.PostOrder(computeWeights) 77 | f := func(n1, n2 *Node) compare.Order { 78 | if n1.Value.Account.Level() == 1 && n2.Value.Account.Level() == 1 { 79 | return compare.Ordered(n1.Value.Account.Type(), n2.Value.Account.Type()) 80 | } 81 | return compare.Decimal(n1.Value.Weight, n2.Value.Weight) 82 | } 83 | r.AL.Sort(f) 84 | r.EIE.Sort(f) 85 | } 86 | 87 | func (r *Report) SetAccounts() { 88 | setAccounts(r.Registry.Accounts(), r.AL) 89 | setAccounts(r.Registry.Accounts(), r.EIE) 90 | } 91 | 92 | func setAccounts(reg *account.Registry, n *Node) { 93 | var acc *account.Account 94 | for _, ch := range n.Children { 95 | setAccounts(reg, ch) 96 | if acc != nil { 97 | continue 98 | } 99 | if ch.Value.Account.Level() == 1 { 100 | continue 101 | } 102 | ss := ch.Value.Account.Segments() 103 | acc = reg.MustGetPath(ss[:len(ss)-1]) 104 | } 105 | if n.Value.Account == nil { 106 | n.Value.Account = acc 107 | } 108 | } 109 | 110 | func (r *Report) Totals(m mapper.Mapper[amounts.Key]) (amounts.Amounts, amounts.Amounts) { 111 | al, eie := make(amounts.Amounts), make(amounts.Amounts) 112 | r.AL.PostOrder(func(n *Node) { 113 | n.Value.Amounts.SumIntoBy(al, nil, m) 114 | }) 115 | r.EIE.PostOrder(func(n *Node) { 116 | n.Value.Amounts.SumIntoBy(eie, nil, m) 117 | }) 118 | return al, eie 119 | } 120 | -------------------------------------------------------------------------------- /lib/reports/register/register.go: -------------------------------------------------------------------------------- 1 | package register 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/sboehler/knut/lib/amounts" 7 | "github.com/sboehler/knut/lib/common/compare" 8 | "github.com/sboehler/knut/lib/common/dict" 9 | "github.com/sboehler/knut/lib/common/table" 10 | "github.com/sboehler/knut/lib/model/account" 11 | "github.com/sboehler/knut/lib/model/commodity" 12 | "github.com/sboehler/knut/lib/model/registry" 13 | "github.com/shopspring/decimal" 14 | ) 15 | 16 | type Report struct { 17 | Context *registry.Registry 18 | 19 | nodes map[time.Time]*Node 20 | } 21 | 22 | type Node struct { 23 | Date time.Time 24 | Amounts amounts.Amounts 25 | } 26 | 27 | func NewReport(reg *registry.Registry) *Report { 28 | return &Report{ 29 | nodes: make(map[time.Time]*Node), 30 | } 31 | } 32 | 33 | func newNode(d time.Time) *Node { 34 | return &Node{ 35 | Date: d, 36 | Amounts: make(amounts.Amounts), 37 | } 38 | } 39 | 40 | func (r *Report) Insert(k amounts.Key, v decimal.Decimal) { 41 | n := dict.GetDefault(r.nodes, k.Date, func() *Node { return newNode(k.Date) }) 42 | n.Amounts.Add(k, v) 43 | } 44 | 45 | type Renderer struct { 46 | ShowCommodities bool 47 | ShowSource bool 48 | ShowDescriptions bool 49 | SortAlphabetically bool 50 | } 51 | 52 | func (rn *Renderer) Render(r *Report) *table.Table { 53 | cols := []int{1, 1, 1} 54 | if rn.ShowCommodities { 55 | cols = append(cols, 1) 56 | } 57 | if rn.ShowSource { 58 | cols = append(cols, 1) 59 | } 60 | if rn.ShowDescriptions { 61 | cols = append(cols, 1) 62 | } 63 | tbl := table.New(cols...) 64 | tbl.AddSeparatorRow() 65 | header := tbl.AddRow().AddText("Date", table.Center) 66 | if rn.ShowSource { 67 | header.AddText("Source", table.Center) 68 | } 69 | header.AddText("Dest", table.Center) 70 | header.AddText("Amount", table.Center) 71 | if rn.ShowCommodities { 72 | header.AddText("Comm", table.Center) 73 | } 74 | if rn.ShowDescriptions { 75 | header.AddText("Desc", table.Center) 76 | } 77 | tbl.AddSeparatorRow() 78 | 79 | dates := dict.SortedKeys(r.nodes, compare.Time) 80 | for _, d := range dates { 81 | n := r.nodes[d] 82 | rn.renderNode(tbl, n) 83 | } 84 | return tbl 85 | } 86 | 87 | func (rn *Renderer) renderNode(tbl *table.Table, n *Node) { 88 | var cmp compare.Compare[amounts.Key] 89 | if rn.ShowCommodities { 90 | cmp = compareAccountAndCommodities 91 | } else { 92 | cmp = compareAccount 93 | } 94 | idx := n.Amounts.Index(cmp) 95 | for i, k := range idx { 96 | row := tbl.AddRow() 97 | if i == 0 { 98 | row.AddText(n.Date.Format("2006-01-02"), table.Left) 99 | } else { 100 | row.AddEmpty() 101 | } 102 | if rn.ShowSource { 103 | row.AddText(k.Account.Name(), table.Left) 104 | } 105 | row.AddText(k.Other.Name(), table.Left) 106 | row.AddDecimal(n.Amounts[k].Neg()) 107 | if rn.ShowCommodities { 108 | row.AddText(k.Commodity.Name(), table.Left) 109 | } 110 | if rn.ShowDescriptions { 111 | desc := k.Description 112 | if len(desc) > 100 { 113 | desc = desc[:100] 114 | } 115 | row.AddText(desc, table.Left) 116 | } 117 | } 118 | tbl.AddSeparatorRow() 119 | } 120 | 121 | func compareAccount(k1, k2 amounts.Key) compare.Order { 122 | return account.Compare(k1.Other, k2.Other) 123 | } 124 | 125 | func compareAccountAndCommodities(k1, k2 amounts.Key) compare.Order { 126 | if c := account.Compare(k1.Other, k2.Other); c != compare.Equal { 127 | return c 128 | } 129 | return commodity.Compare(k1.Commodity, k2.Commodity) 130 | } 131 | -------------------------------------------------------------------------------- /cmd/commands/portfolio/returns.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | package portfolio 15 | 16 | import ( 17 | "fmt" 18 | "log" 19 | "os" 20 | "runtime/pprof" 21 | 22 | "github.com/spf13/cobra" 23 | 24 | "github.com/sboehler/knut/cmd/flags" 25 | "github.com/sboehler/knut/lib/common/predicate" 26 | "github.com/sboehler/knut/lib/journal" 27 | "github.com/sboehler/knut/lib/journal/check" 28 | "github.com/sboehler/knut/lib/journal/performance" 29 | "github.com/sboehler/knut/lib/model" 30 | "github.com/sboehler/knut/lib/model/registry" 31 | ) 32 | 33 | // CreateReturnsCommand creates the command. 34 | func CreateReturnsCommand() *cobra.Command { 35 | 36 | var r returnsRunner 37 | // Cmd is the balance command. 38 | c := &cobra.Command{ 39 | Use: "returns", 40 | Short: "compute portfolio returns", 41 | Long: `Compute portfolio returns.`, 42 | 43 | Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), 44 | 45 | Run: r.run, 46 | } 47 | r.setupFlags(c) 48 | return c 49 | } 50 | 51 | type returnsRunner struct { 52 | flags.Multiperiod 53 | cpuprofile string 54 | valuation flags.CommodityFlag 55 | accounts, commodities flags.RegexFlag 56 | } 57 | 58 | func (r *returnsRunner) setupFlags(cmd *cobra.Command) { 59 | r.Multiperiod.Setup(cmd) 60 | cmd.Flags().StringVar(&r.cpuprofile, "cpuprofile", "", "file to write profile") 61 | cmd.Flags().VarP(&r.valuation, "val", "v", "valuate in the given commodity") 62 | cmd.Flags().Var(&r.accounts, "account", "filter accounts with a regex") 63 | cmd.Flags().Var(&r.commodities, "commodity", "filter commodities with a regex") 64 | } 65 | 66 | func (r *returnsRunner) run(cmd *cobra.Command, args []string) { 67 | if r.cpuprofile != "" { 68 | f, err := os.Create(r.cpuprofile) 69 | if err != nil { 70 | log.Fatal(err) 71 | } 72 | pprof.StartCPUProfile(f) 73 | defer pprof.StopCPUProfile() 74 | } 75 | if err := r.execute(cmd, args); err != nil { 76 | fmt.Fprintln(cmd.ErrOrStderr(), err) 77 | os.Exit(1) 78 | } 79 | } 80 | 81 | func (r *returnsRunner) execute(cmd *cobra.Command, args []string) error { 82 | ctx := cmd.Context() 83 | reg := registry.New() 84 | valuation, err := r.valuation.Value(reg) 85 | if err != nil { 86 | return err 87 | } 88 | j, err := journal.FromPath(ctx, reg, args[0]) 89 | if err != nil { 90 | return err 91 | } 92 | partition := r.Multiperiod.Partition(j.Period()) 93 | calculator := &performance.Calculator{ 94 | Context: reg, 95 | Valuation: valuation, 96 | AccountFilter: predicate.ByName[*model.Account](r.accounts.Regex()), 97 | CommodityFilter: predicate.ByName[*model.Commodity](r.commodities.Regex()), 98 | } 99 | err = j.Build().Process( 100 | journal.ComputePrices(valuation), 101 | check.Check(), 102 | journal.Valuate(reg, valuation), 103 | calculator.ComputeValues(), 104 | calculator.ComputeFlows(), 105 | performance.Perf(j, partition), 106 | ) 107 | return err 108 | } 109 | -------------------------------------------------------------------------------- /lib/model/price/prices_test.go: -------------------------------------------------------------------------------- 1 | package price 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/sboehler/knut/lib/model/commodity" 8 | "github.com/sboehler/knut/lib/model/registry" 9 | 10 | "github.com/shopspring/decimal" 11 | ) 12 | 13 | func TestPrices(t *testing.T) { 14 | reg := registry.New() 15 | com1 := reg.Commodities().MustGet("COM1") 16 | com2 := reg.Commodities().MustGet("COM2") 17 | 18 | tests := []struct { 19 | desc string 20 | input []*Price 21 | want Prices 22 | }{ 23 | { 24 | desc: "case 1", 25 | input: []*Price{ 26 | { 27 | Commodity: com1, 28 | Target: com2, 29 | Price: decimal.RequireFromString("4.0"), 30 | }, 31 | }, 32 | want: Prices{ 33 | com2: { 34 | com1: decimal.RequireFromString("4.0"), 35 | }, 36 | com1: { 37 | com2: decimal.RequireFromString("0.25"), 38 | }, 39 | }, 40 | }, 41 | } 42 | 43 | for _, test := range tests { 44 | t.Run(test.desc, func(t *testing.T) { 45 | got := make(Prices) 46 | for _, in := range test.input { 47 | got.Insert(in.Commodity, in.Price, in.Target) 48 | } 49 | if diff := cmp.Diff(got, test.want); diff != "" { 50 | t.Fatalf("unexpected diff (+got/-want):\n%s", diff) 51 | } 52 | }) 53 | } 54 | } 55 | 56 | func TestNormalize(t *testing.T) { 57 | reg := registry.New() 58 | com1 := reg.Commodities().MustGet("COM1") 59 | com2 := reg.Commodities().MustGet("COM2") 60 | com3 := reg.Commodities().MustGet("COM3") 61 | 62 | tests := []struct { 63 | desc string 64 | input []*Price 65 | target *commodity.Commodity 66 | want NormalizedPrices 67 | }{ 68 | { 69 | desc: "case 1", 70 | input: []*Price{ 71 | {Commodity: com1, Price: decimal.RequireFromString("4.0"), Target: com2}, 72 | {Commodity: com2, Price: decimal.RequireFromString("2.0"), Target: com3}, 73 | }, 74 | target: com1, 75 | want: NormalizedPrices{ 76 | com1: decimal.RequireFromString("1"), 77 | com2: decimal.RequireFromString("0.25"), 78 | com3: decimal.RequireFromString("0.125"), 79 | }, 80 | }, 81 | { 82 | desc: "case 2", 83 | input: []*Price{ 84 | {Commodity: com1, Price: decimal.RequireFromString("4.0"), Target: com2}, 85 | {Commodity: com2, Price: decimal.RequireFromString("2.0"), Target: com3}, 86 | }, 87 | target: com2, 88 | want: NormalizedPrices{ 89 | com1: decimal.RequireFromString("4"), 90 | com2: decimal.RequireFromString("1"), 91 | com3: decimal.RequireFromString("0.5"), 92 | }, 93 | }, 94 | { 95 | desc: "case 3", 96 | input: []*Price{ 97 | {Commodity: com1, Price: decimal.RequireFromString("4.0"), Target: com2}, 98 | {Commodity: com2, Price: decimal.RequireFromString("2.0"), Target: com3}, 99 | }, 100 | target: com3, 101 | want: NormalizedPrices{ 102 | com1: decimal.RequireFromString("8"), 103 | com2: decimal.RequireFromString("2"), 104 | com3: decimal.RequireFromString("1"), 105 | }, 106 | }, 107 | { 108 | desc: "case 4", 109 | input: []*Price{ 110 | {Commodity: com1, Price: decimal.RequireFromString("4.0"), Target: com2}, 111 | {Commodity: com3, Price: decimal.RequireFromString("2.0"), Target: com2}, 112 | }, 113 | target: com3, 114 | want: NormalizedPrices{ 115 | com1: decimal.RequireFromString("2"), 116 | com2: decimal.RequireFromString("0.5"), 117 | com3: decimal.RequireFromString("1"), 118 | }, 119 | }, 120 | } 121 | 122 | for _, test := range tests { 123 | t.Run(test.desc, func(t *testing.T) { 124 | pr := make(Prices) 125 | for _, in := range test.input { 126 | pr.Insert(in.Commodity, in.Price, in.Target) 127 | } 128 | 129 | got := pr.Normalize(test.target) 130 | 131 | if diff := cmp.Diff(test.want, got); diff != "" { 132 | t.Fatalf("unexpected diff (-want/+got):\n%s", diff) 133 | } 134 | }) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /lib/journal/beancount/beancount.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package beancount 16 | 17 | import ( 18 | "fmt" 19 | "io" 20 | "regexp" 21 | "strings" 22 | 23 | "github.com/sboehler/knut/lib/common/compare" 24 | "github.com/sboehler/knut/lib/common/set" 25 | "github.com/sboehler/knut/lib/journal" 26 | "github.com/sboehler/knut/lib/journal/printer" 27 | "github.com/sboehler/knut/lib/model" 28 | "github.com/sboehler/knut/lib/model/transaction" 29 | "github.com/shopspring/decimal" 30 | ) 31 | 32 | // Transcode transcodes the given journal to beancount. 33 | func Transcode(w io.Writer, j *journal.Journal, c *model.Commodity) error { 34 | if _, err := fmt.Fprintf(w, `option "operating_currency" "%s"`, c.Name()); err != nil { 35 | return err 36 | } 37 | if _, err := io.WriteString(w, "\n\n"); err != nil { 38 | return err 39 | } 40 | p := printer.New(w) 41 | openValAccounts := set.New[*model.Account]() 42 | for _, day := range j.Days { 43 | for _, open := range day.Openings { 44 | if _, err := p.PrintDirective(open); err != nil { 45 | return err 46 | } 47 | if _, err := io.WriteString(w, "\n\n"); err != nil { 48 | return err 49 | } 50 | } 51 | compare.Sort(day.Transactions, transaction.Compare) 52 | 53 | for _, trx := range day.Transactions { 54 | for _, pst := range trx.Postings { 55 | if strings.HasPrefix(pst.Account.Name(), "Equity:Valuation:") && !openValAccounts.Has(pst.Account) { 56 | openValAccounts.Add(pst.Account) 57 | if _, err := p.PrintDirective(&model.Open{Date: trx.Date, Account: pst.Account}); err != nil { 58 | return err 59 | } 60 | if _, err := io.WriteString(w, "\n\n"); err != nil { 61 | return err 62 | } 63 | } 64 | } 65 | } 66 | for _, trx := range day.Transactions { 67 | if err := writeTrx(w, trx, c); err != nil { 68 | return err 69 | } 70 | } 71 | for _, close := range day.Closings { 72 | if _, err := p.PrintDirective(close); err != nil { 73 | return err 74 | } 75 | if _, err := io.WriteString(w, "\n\n"); err != nil { 76 | return err 77 | } 78 | } 79 | } 80 | return nil 81 | } 82 | 83 | func writeTrx(w io.Writer, t *model.Transaction, c *model.Commodity) error { 84 | if _, err := fmt.Fprintf(w, `%s * "%s"`, t.Date.Format("2006-01-02"), t.Description); err != nil { 85 | return err 86 | } 87 | if _, err := io.WriteString(w, "\n"); err != nil { 88 | return err 89 | } 90 | for _, p := range t.Postings { 91 | if err := writePosting(w, p, c); err != nil { 92 | return err 93 | } 94 | } 95 | _, err := io.WriteString(w, "\n") 96 | return err 97 | } 98 | 99 | // WriteTo pretty-prints a posting. 100 | func writePosting(w io.Writer, p *model.Posting, c *model.Commodity) error { 101 | var quantity decimal.Decimal 102 | if c == nil { 103 | quantity = p.Quantity 104 | } else { 105 | quantity = p.Value 106 | } 107 | if _, err := fmt.Fprintf(w, " %s %s %s", p.Account.Name(), quantity, stripNonAlphanum(c)); err != nil { 108 | return err 109 | } 110 | if _, err := io.WriteString(w, "\n"); err != nil { 111 | return err 112 | } 113 | return nil 114 | } 115 | 116 | var regex = regexp.MustCompile("[^a-zA-Z]") 117 | 118 | func stripNonAlphanum(c *model.Commodity) string { 119 | return regex.ReplaceAllString(c.Name(), "X") 120 | } 121 | -------------------------------------------------------------------------------- /lib/common/cpr/cpr.go: -------------------------------------------------------------------------------- 1 | // Package cpr contains concurrency primitives. 2 | package cpr 3 | 4 | import ( 5 | "context" 6 | "sync" 7 | 8 | "github.com/sourcegraph/conc/pool" 9 | ) 10 | 11 | // Pop returns a new T from the ch. It returns a boolean which indicates 12 | // whether the channel is still open. The error indicates whether the context 13 | // has been canceled. 14 | func Pop[T any](ctx context.Context, ch <-chan T) (T, bool, error) { 15 | var res T 16 | select { 17 | case d, ok := <-ch: 18 | return d, ok, ctx.Err() 19 | case <-ctx.Done(): 20 | return res, false, ctx.Err() 21 | } 22 | } 23 | 24 | // Push tries to push a T to the ch. The error indicates whether the context 25 | // has been canceled. 26 | func Push[T any](ctx context.Context, ch chan<- T, ts ...T) error { 27 | for _, t := range ts { 28 | select { 29 | case <-ctx.Done(): 30 | return ctx.Err() 31 | case ch <- t: 32 | } 33 | } 34 | return nil 35 | } 36 | 37 | // Demultiplex demultiplexes the given channels. 38 | func Demultiplex[T any](inChs ...<-chan T) chan T { 39 | var ( 40 | wg sync.WaitGroup 41 | resCh = make(chan T) 42 | ) 43 | wg.Add(len(inChs)) 44 | for _, inCh := range inChs { 45 | go func(ch <-chan T) { 46 | defer wg.Done() 47 | for t := range ch { 48 | resCh <- t 49 | } 50 | }(inCh) 51 | } 52 | go func() { 53 | wg.Wait() 54 | close(resCh) 55 | }() 56 | return resCh 57 | } 58 | 59 | func Parallel(fs ...func()) func() { 60 | var wg sync.WaitGroup 61 | wg.Add(len(fs)) 62 | for _, f := range fs { 63 | f := f 64 | go func() { 65 | f() 66 | wg.Done() 67 | }() 68 | } 69 | return wg.Wait 70 | } 71 | 72 | func ForAll[T any](ts []T, f func(T)) func() { 73 | var wg sync.WaitGroup 74 | wg.Add(len(ts)) 75 | for _, t := range ts { 76 | go func(t T) { 77 | f(t) 78 | wg.Done() 79 | }(t) 80 | } 81 | return wg.Wait 82 | } 83 | 84 | func ForEach[T any](ctx context.Context, ch <-chan T, f func(T) error) error { 85 | for { 86 | t, ok, err := Pop(ctx, ch) 87 | if err != nil || !ok { 88 | return err 89 | } 90 | if err := f(t); err != nil { 91 | return err 92 | } 93 | } 94 | } 95 | 96 | func Produce[T any](f func(context.Context, chan<- T) error) (<-chan T, func(context.Context) error) { 97 | ch := make(chan T) 98 | return ch, func(ctx context.Context) error { 99 | defer close(ch) 100 | return f(ctx, ch) 101 | } 102 | } 103 | 104 | func Seq[T any](ctx context.Context, ts []T, fs ...func(T) error) ([]T, error) { 105 | var workers []func(context.Context) error 106 | prevCh, w := Produce(func(ctx context.Context, ch chan<- T) error { 107 | for _, t := range ts { 108 | if err := Push(ctx, ch, t); err != nil { 109 | return err 110 | } 111 | } 112 | return nil 113 | }) 114 | workers = append(workers, w) 115 | for _, f := range fs { 116 | f, inCh := f, prevCh 117 | ch, w := Produce(func(ctx context.Context, ch chan<- T) error { 118 | return ForEach(ctx, inCh, func(t T) error { 119 | if err := f(t); err != nil { 120 | return err 121 | } 122 | return Push(ctx, ch, t) 123 | }) 124 | }) 125 | workers = append(workers, w) 126 | prevCh = ch 127 | } 128 | 129 | ch, w := FanIn(func(ctx context.Context, ch chan<- []T) error { 130 | var res []T 131 | err := ForEach(ctx, prevCh, func(t T) error { 132 | res = append(res, t) 133 | return nil 134 | }) 135 | if err != nil { 136 | return err 137 | } 138 | return Push(ctx, ch, res) 139 | }) 140 | 141 | workers = append(workers, w) 142 | p := pool.New().WithContext(ctx).WithCancelOnError().WithFirstError() 143 | for _, w := range workers { 144 | p.Go(w) 145 | } 146 | if err := p.Wait(); err != nil { 147 | return nil, err 148 | } 149 | return <-ch, nil 150 | } 151 | 152 | func FanIn[T any](f func(context.Context, chan<- T) error) (<-chan T, func(context.Context) error) { 153 | ch := make(chan T, 1) 154 | return ch, func(ctx context.Context) error { 155 | defer close(ch) 156 | return f(ctx, ch) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /lib/journal/check/check.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/sboehler/knut/lib/amounts" 8 | "github.com/sboehler/knut/lib/common/set" 9 | "github.com/sboehler/knut/lib/journal" 10 | "github.com/sboehler/knut/lib/journal/printer" 11 | "github.com/sboehler/knut/lib/model" 12 | "github.com/sboehler/knut/lib/model/assertion" 13 | "golang.org/x/exp/slices" 14 | ) 15 | 16 | // Error is a processing error, with a reference to a directive with 17 | // a source location. 18 | type Error struct { 19 | Directive model.Directive 20 | Msg string 21 | } 22 | 23 | func (be Error) Error() string { 24 | var s strings.Builder 25 | s.WriteString(be.Msg) 26 | s.WriteRune('\n') 27 | s.WriteRune('\n') 28 | p := printer.New(&s) 29 | p.PrintDirectiveLn(be.Directive) 30 | return s.String() 31 | } 32 | 33 | type Checker struct { 34 | Write bool 35 | NoCheck bool 36 | 37 | quantities amounts.Amounts 38 | accounts set.Set[*model.Account] 39 | assertions []*model.Assertion 40 | } 41 | 42 | func (ch *Checker) Assertions() []*model.Assertion { 43 | return ch.assertions 44 | } 45 | 46 | func (ch *Checker) open(o *model.Open) error { 47 | if ch.accounts.Has(o.Account) { 48 | return Error{Directive: o, Msg: "account is already open"} 49 | } 50 | ch.accounts.Add(o.Account) 51 | return nil 52 | } 53 | 54 | func (ch *Checker) posting(t *model.Transaction, p *model.Posting) error { 55 | if !ch.accounts.Has(p.Account) { 56 | return Error{Directive: t, Msg: fmt.Sprintf("account %s is not open", p.Account)} 57 | } 58 | if p.Account.IsAL() { 59 | ch.quantities.Add(amounts.AccountCommodityKey(p.Account, p.Commodity), p.Quantity) 60 | } 61 | return nil 62 | } 63 | 64 | func (ch *Checker) balance(a *model.Assertion, bal *model.Balance) error { 65 | if !ch.accounts.Has(bal.Account) { 66 | return Error{Directive: a, Msg: "account is not open"} 67 | } 68 | position := amounts.AccountCommodityKey(bal.Account, bal.Commodity) 69 | if ch.NoCheck { 70 | return nil 71 | } 72 | if qty, ok := ch.quantities[position]; !ok || !qty.Equal(bal.Quantity) { 73 | return Error{Directive: a, Msg: fmt.Sprintf("failed assertion: %s has position: %s %s", position.Account.Name(), qty, position.Commodity.Name())} 74 | } 75 | return nil 76 | } 77 | 78 | func (ch *Checker) close(c *model.Close) error { 79 | for pos, amount := range ch.quantities { 80 | if pos.Account != c.Account { 81 | continue 82 | } 83 | if !amount.IsZero() { 84 | return Error{Directive: c, Msg: fmt.Sprintf("account has nonzero position: %s %s", amount, pos.Commodity.Name())} 85 | } 86 | delete(ch.quantities, pos) 87 | } 88 | if !ch.accounts.Has(c.Account) { 89 | return Error{Directive: c, Msg: "account is not open"} 90 | } 91 | ch.accounts.Remove(c.Account) 92 | return nil 93 | } 94 | 95 | func (ch *Checker) dayEnd(d *journal.Day) error { 96 | if len(ch.quantities) == 0 { 97 | return nil 98 | } 99 | bal := make([]model.Balance, 0, len(ch.quantities)) 100 | for pos, qty := range ch.quantities { 101 | bal = append(bal, model.Balance{ 102 | Account: pos.Account, 103 | Quantity: qty, 104 | Commodity: pos.Commodity, 105 | }) 106 | } 107 | slices.SortFunc(bal, assertion.CompareBalance) 108 | ch.assertions = append(ch.assertions, &model.Assertion{ 109 | Date: d.Date, 110 | Balances: bal, 111 | }) 112 | return nil 113 | } 114 | 115 | func (ch *Checker) Check() *journal.Processor { 116 | ch.quantities = make(amounts.Amounts) 117 | ch.accounts = set.New[*model.Account]() 118 | ch.assertions = nil 119 | 120 | var dayEnd func(*journal.Day) error 121 | if ch.Write { 122 | dayEnd = ch.dayEnd 123 | } 124 | 125 | return &journal.Processor{ 126 | Open: ch.open, 127 | Posting: ch.posting, 128 | Balance: ch.balance, 129 | Close: ch.close, 130 | DayEnd: dayEnd, 131 | } 132 | } 133 | 134 | // Checker checks the journal (with default options). 135 | func Check() *journal.Processor { 136 | var checker Checker 137 | return checker.Check() 138 | } 139 | -------------------------------------------------------------------------------- /cmd/commands/infer.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package commands 16 | 17 | import ( 18 | "bufio" 19 | "bytes" 20 | "context" 21 | "fmt" 22 | "os" 23 | 24 | "github.com/natefinch/atomic" 25 | "github.com/sourcegraph/conc/pool" 26 | "github.com/spf13/cobra" 27 | 28 | "github.com/sboehler/knut/lib/common/cpr" 29 | "github.com/sboehler/knut/lib/syntax" 30 | "github.com/sboehler/knut/lib/syntax/bayes" 31 | ) 32 | 33 | // CreateInferCmd creates the command. 34 | func CreateInferCmd() *cobra.Command { 35 | var r inferRunner 36 | cmd := &cobra.Command{ 37 | Use: "infer", 38 | Short: "Auto-assign accounts in a journal", 39 | Long: `Build a Bayes model using the supplied training file and apply it to replace 40 | the indicated account in the target file. Training file and target file may be the same.`, 41 | Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), 42 | Run: r.run, 43 | } 44 | r.setupFlags(cmd) 45 | return cmd 46 | } 47 | 48 | type inferRunner struct { 49 | account string 50 | trainingFile string 51 | inplace bool 52 | } 53 | 54 | func (r *inferRunner) setupFlags(cmd *cobra.Command) { 55 | cmd.Flags().StringVarP(&r.account, "account", "a", "Expenses:TBD", "account name") 56 | cmd.Flags().BoolVarP(&r.inplace, "inplace", "i", false, "infer the accounts inplace") 57 | cmd.Flags().StringVarP(&r.trainingFile, "training-file", "t", "", "the journal file with existing data") 58 | cmd.MarkFlagRequired("training-file") 59 | } 60 | 61 | func (r *inferRunner) run(cmd *cobra.Command, args []string) { 62 | if err := r.execute(cmd, args); err != nil { 63 | fmt.Fprintln(cmd.ErrOrStderr(), err) 64 | os.Exit(1) 65 | } 66 | } 67 | 68 | func (r *inferRunner) execute(cmd *cobra.Command, args []string) (errors error) { 69 | var ( 70 | targetFile = args[0] 71 | err error 72 | ) 73 | model, err := r.train(cmd.Context(), r.trainingFile, r.account) 74 | if err != nil { 75 | return err 76 | } 77 | file, err := r.parseAndInfer(cmd.Context(), model, targetFile) 78 | if err != nil { 79 | return err 80 | } 81 | if r.inplace { 82 | var buf bytes.Buffer 83 | if err := syntax.FormatFile(&buf, file); err != nil { 84 | return err 85 | } 86 | return atomic.WriteFile(targetFile, &buf) 87 | } else { 88 | out := bufio.NewWriter(cmd.OutOrStdout()) 89 | defer out.Flush() 90 | return syntax.FormatFile(out, file) 91 | } 92 | } 93 | 94 | func (inferRunner) train(ctx context.Context, file string, account string) (*bayes.Model, error) { 95 | model := bayes.NewModel(account) 96 | p := pool.New().WithErrors().WithFirstError().WithContext(ctx) 97 | ch, worker := syntax.ParseFileRecursively(file) 98 | p.Go(worker) 99 | p.Go(func(ctx context.Context) error { 100 | return cpr.ForEach(ctx, ch, func(res syntax.File) error { 101 | for _, d := range res.Directives { 102 | if t, ok := d.Directive.(syntax.Transaction); ok { 103 | model.Update(&t) 104 | } 105 | } 106 | return nil 107 | }) 108 | }) 109 | return model, p.Wait() 110 | } 111 | 112 | func (r *inferRunner) parseAndInfer(ctx context.Context, model *bayes.Model, targetFile string) (syntax.File, error) { 113 | f, err := syntax.ParseFile(targetFile) 114 | if err != nil { 115 | return syntax.File{}, err 116 | } 117 | for i := range f.Directives { 118 | if t, ok := f.Directives[i].Directive.(syntax.Transaction); ok { 119 | model.Infer(&t) 120 | } 121 | } 122 | return f, nil 123 | } 124 | -------------------------------------------------------------------------------- /lib/reports/weights/weights.go: -------------------------------------------------------------------------------- 1 | package weights 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/sboehler/knut/lib/common/compare" 8 | "github.com/sboehler/knut/lib/common/date" 9 | "github.com/sboehler/knut/lib/common/multimap" 10 | "github.com/sboehler/knut/lib/common/set" 11 | "github.com/sboehler/knut/lib/common/table" 12 | "github.com/sboehler/knut/lib/journal" 13 | "github.com/sboehler/knut/lib/journal/performance" 14 | "github.com/sboehler/knut/lib/model/account" 15 | ) 16 | 17 | type Query struct { 18 | Partition date.Partition 19 | Universe performance.Universe 20 | Mapping account.Mapping 21 | } 22 | 23 | func (q Query) Execute(j *journal.Builder, r *Report) *journal.Processor { 24 | days := set.FromSlice(j.Days(q.Partition.EndDates())) 25 | return &journal.Processor{ 26 | DayEnd: func(d *journal.Day) error { 27 | if !days.Has(d) { 28 | return nil 29 | } 30 | var total float64 31 | for _, v := range d.Performance.V1 { 32 | total += v 33 | } 34 | for com, v := range d.Performance.V1 { 35 | ss := q.Universe.Locate(com) 36 | level, suffix, ok := q.Mapping.Level(strings.Join(ss, ":")) 37 | if ok && level < len(ss)-suffix { 38 | ss = append(ss[:level], ss[len(ss)-suffix:]...) 39 | } 40 | r.Add(ss, d.Date, v/total) 41 | } 42 | return nil 43 | }, 44 | } 45 | } 46 | 47 | type Node = multimap.Node[Value] 48 | 49 | type Value struct { 50 | Leaf bool 51 | Weights map[time.Time]float64 52 | Weight float64 53 | } 54 | 55 | type Report struct { 56 | dates set.Set[time.Time] 57 | weights *Node 58 | } 59 | 60 | func NewReport() *Report { 61 | return &Report{ 62 | dates: set.New[time.Time](), 63 | weights: multimap.New[Value](""), 64 | } 65 | } 66 | 67 | func (r *Report) Add(ss []string, date time.Time, w float64) error { 68 | n := r.weights.GetOrCreate(ss) 69 | if n.Value.Weights == nil { 70 | n.Value.Weights = make(map[time.Time]float64) 71 | n.Value.Leaf = true 72 | } 73 | n.Value.Weights[date] += w 74 | r.dates.Add(date) 75 | return nil 76 | } 77 | 78 | func (r *Report) PropagateWeights() { 79 | r.weights.PostOrder(func(n *Node) { 80 | if n.Value.Weights == nil { 81 | n.Value.Weights = make(map[time.Time]float64) 82 | } 83 | for _, ch := range n.Children { 84 | for date, w := range ch.Value.Weights { 85 | n.Value.Weights[date] += w 86 | } 87 | } 88 | }) 89 | } 90 | 91 | func (r *Report) SortWeighted() { 92 | r.weights.PostOrder(func(n *Node) { 93 | var total float64 94 | for _, w := range n.Value.Weights { 95 | total += w 96 | } 97 | n.Value.Weight = -total 98 | }) 99 | r.weights.Sort(func(n1, n2 *Node) compare.Order { 100 | return compare.Ordered(n1.Value.Weight, n2.Value.Weight) 101 | }) 102 | } 103 | 104 | type Renderer struct { 105 | SortAlphabetically bool 106 | 107 | table *table.Table 108 | report *Report 109 | dates []time.Time 110 | } 111 | 112 | func (rn *Renderer) Render(rep *Report) *table.Table { 113 | rep.PropagateWeights() 114 | if rn.SortAlphabetically { 115 | rep.weights.Sort(multimap.SortAlpha) 116 | } else { 117 | rep.SortWeighted() 118 | } 119 | 120 | rn.dates = rep.dates.Sorted(compare.Time) 121 | rn.table = table.New(1, len(rn.dates)) 122 | rn.report = rep 123 | 124 | rn.table.AddSeparatorRow() 125 | rn.renderHeader() 126 | rn.table.AddSeparatorRow() 127 | 128 | for _, node := range rep.weights.Sorted { 129 | rn.renderNode(node, 0) 130 | } 131 | rn.table.AddSeparatorRow() 132 | 133 | return rn.table 134 | } 135 | 136 | func (rn *Renderer) renderHeader() { 137 | row := rn.table.AddRow() 138 | row.AddText("Commodity", table.Center) 139 | for _, date := range rn.dates { 140 | row.AddText(date.Format("2006-01-02"), table.Center) 141 | } 142 | } 143 | 144 | func (rn *Renderer) renderNode(n *Node, indent int) { 145 | row := rn.table.AddRow() 146 | row.AddIndented(n.Segment, indent) 147 | for _, date := range rn.dates { 148 | if weight, ok := n.Value.Weights[date]; ok && weight != 0 { 149 | row.AddPercent(weight) 150 | } else { 151 | row.AddEmpty() 152 | } 153 | } 154 | for _, child := range n.Sorted { 155 | rn.renderNode(child, indent+2) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /lib/common/table/table.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package table 16 | 17 | import ( 18 | "github.com/shopspring/decimal" 19 | ) 20 | 21 | // CellType is the type of a table cell. 22 | type CellType int 23 | 24 | // Table is a matrix of table cells. 25 | type Table struct { 26 | columns []int 27 | rows []*Row 28 | } 29 | 30 | // New creates a new table with column groups. 31 | func New(groups ...int) *Table { 32 | var columns []int 33 | for groupNo, groupSize := range groups { 34 | for i := 0; i < groupSize; i++ { 35 | columns = append(columns, groupNo) 36 | } 37 | } 38 | return &Table{columns: columns} 39 | } 40 | 41 | // Width returns the width of this table. 42 | func (t *Table) Width() int { 43 | return len(t.columns) 44 | } 45 | 46 | // AddRow adds a row. 47 | func (t *Table) AddRow() *Row { 48 | var ( 49 | cells = make([]cell, 0, t.Width()) 50 | row = &Row{cells} 51 | ) 52 | t.rows = append(t.rows, row) 53 | return row 54 | } 55 | 56 | // AddSeparatorRow adds a separator row. 57 | func (t *Table) AddSeparatorRow() { 58 | r := t.AddRow() 59 | for i := 0; i < t.Width(); i++ { 60 | r.addCell(SeparatorCell{}) 61 | } 62 | } 63 | 64 | // AddEmptyRow adds a separator row. 65 | func (t *Table) AddEmptyRow() { 66 | r := t.AddRow() 67 | for i := 0; i < t.Width(); i++ { 68 | r.addCell(emptyCell{}) 69 | } 70 | } 71 | 72 | // Row is a table row. 73 | type Row struct { 74 | cells []cell 75 | } 76 | 77 | func (r *Row) addCell(c cell) { 78 | r.cells = append(r.cells, c) 79 | } 80 | 81 | // AddEmpty adds an empty cell. 82 | func (r *Row) AddEmpty() *Row { 83 | r.addCell(emptyCell{}) 84 | return r 85 | } 86 | 87 | // AddText adds a text cell. 88 | func (r *Row) AddText(content string, align Alignment) *Row { 89 | r.addCell(textCell{ 90 | Indent: 0, 91 | Content: content, 92 | Align: align, 93 | }) 94 | return r 95 | } 96 | 97 | // AddDecimal adds a number cell. 98 | func (r *Row) AddDecimal(n decimal.Decimal) *Row { 99 | r.addCell(numberCell{n}) 100 | return r 101 | } 102 | 103 | func (r *Row) AddPercent(n float64) *Row { 104 | r.addCell(percentCell{n}) 105 | return r 106 | } 107 | 108 | // AddIndented adds an indented cell. 109 | func (r *Row) AddIndented(content string, indent int) *Row { 110 | r.addCell(textCell{ 111 | Content: content, 112 | Indent: indent, 113 | Align: Left, 114 | }) 115 | return r 116 | } 117 | 118 | // FillEmpty fills the row with empty cells. 119 | func (r *Row) FillEmpty() { 120 | for i := len(r.cells); i < cap(r.cells); i++ { 121 | r.AddEmpty() 122 | } 123 | } 124 | 125 | type cell interface { 126 | isSep() bool 127 | } 128 | 129 | // Alignment is the alignment of a table cell. 130 | type Alignment int 131 | 132 | const ( 133 | // Left aligns to the left. 134 | Left Alignment = iota 135 | // Right align to the right. 136 | Right 137 | // Center centers. 138 | Center 139 | ) 140 | 141 | // textCell is a cell containing text. 142 | type textCell struct { 143 | Content string 144 | Align Alignment 145 | Indent int 146 | } 147 | 148 | func (t textCell) isSep() bool { 149 | return false 150 | } 151 | 152 | // textCell is a cell containing text. 153 | type numberCell struct { 154 | n decimal.Decimal 155 | } 156 | 157 | func (t numberCell) isSep() bool { 158 | return false 159 | } 160 | 161 | // percentCell is a cell containing a percentage 162 | type percentCell struct { 163 | n float64 164 | } 165 | 166 | func (t percentCell) isSep() bool { 167 | return false 168 | } 169 | 170 | // SeparatorCell is a cell containing a separator. 171 | type SeparatorCell struct{} 172 | 173 | func (SeparatorCell) isSep() bool { 174 | return true 175 | } 176 | 177 | // emptyCell is an empty cell. 178 | type emptyCell struct{} 179 | 180 | func (emptyCell) isSep() bool { 181 | return false 182 | } 183 | -------------------------------------------------------------------------------- /scripts/builddoc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "os" 19 | "strings" 20 | "text/template" 21 | 22 | "github.com/sboehler/knut/cmd" 23 | 24 | // enable importers here 25 | _ "github.com/sboehler/knut/cmd/importer/cumulus" 26 | _ "github.com/sboehler/knut/cmd/importer/interactivebrokers" 27 | _ "github.com/sboehler/knut/cmd/importer/postfinance" 28 | _ "github.com/sboehler/knut/cmd/importer/revolut" 29 | _ "github.com/sboehler/knut/cmd/importer/revolut2" 30 | _ "github.com/sboehler/knut/cmd/importer/supercard" 31 | _ "github.com/sboehler/knut/cmd/importer/swisscard" 32 | _ "github.com/sboehler/knut/cmd/importer/swisscard2" 33 | _ "github.com/sboehler/knut/cmd/importer/swissquote" 34 | _ "github.com/sboehler/knut/cmd/importer/viac" 35 | ) 36 | 37 | type config struct { 38 | ExampleFile string 39 | PricesFile string 40 | Commands map[string]string 41 | } 42 | 43 | func main() { 44 | c, err := createConfig() 45 | if err != nil { 46 | panic(err) 47 | } 48 | err = generate(c) 49 | if err != nil { 50 | panic(err) 51 | } 52 | } 53 | 54 | func createConfig() (*config, error) { 55 | c := &config{ 56 | Commands: make(map[string]string), 57 | } 58 | content, err := os.ReadFile("doc/example.knut") 59 | if err != nil { 60 | return nil, err 61 | } 62 | c.ExampleFile = string(content) 63 | content, err = os.ReadFile("doc/prices.yaml") 64 | if err != nil { 65 | return nil, err 66 | } 67 | c.PricesFile = string(content) 68 | 69 | c.Commands["help"] = run([]string{"--help"}) 70 | c.Commands["HelpImport"] = run([]string{"import", "--help"}) 71 | 72 | c.Commands["BalanceIntro"] = run([]string{"balance", 73 | "--color=false", "-v", "CHF", "--months", "--from", 74 | "2020-01-01", "--to", "2020-04-01", "doc/example.knut", 75 | }) 76 | c.Commands["FilterAccount"] = run([]string{"balance", 77 | "--color=false", "-v", "CHF", "--months", "--from", 78 | "2020-01-01", "--to", "2020-04-01", "--diff", "--account", "Portfolio", "doc/example.knut", 79 | }) 80 | c.Commands["FilterCommodity"] = run([]string{"balance", 81 | "--color=false", "-v", "CHF", "--months", "--from", 82 | "2020-01-01", "--to", "2020-04-01", "--diff", "--commodity", "AAPL", "doc/example.knut", 83 | }) 84 | c.Commands["Collapse"] = run([]string{"balance", 85 | "--color=false", "-v", "CHF", "--months", "--from", 86 | "2020-01-01", "--to", "2020-04-01", "--diff", "-m0,(Income|Expenses)", "doc/example.knut", 87 | }) 88 | c.Commands["Collapse1"] = run([]string{"balance", 89 | "--color=false", "-v", "CHF", "--months", "--from", 90 | "2020-01-01", "--to", "2020-04-01", "--diff", "-m1,(Income|Expenses|Equity)", "doc/example.knut", 91 | }) 92 | c.Commands["BalanceMonthlyCHF"] = run([]string{"balance", 93 | "--color=false", "-v", "CHF", "--months", "--to", "2020-04-01", "doc/example.knut", 94 | }) 95 | c.Commands["BalanceMonthlyUSD"] = run([]string{"balance", 96 | "--color=false", "-v", "USD", "--months", "--to", "2020-04-01", "doc/example.knut", 97 | }) 98 | c.Commands["BalanceBasic"] = run([]string{"balance", "--color=false", "doc/example.knut", "--to", "2020-04-01"}) 99 | return c, nil 100 | } 101 | 102 | func generate(c *config) error { 103 | tpl, err := template.ParseFiles("doc/README.md") 104 | if err != nil { 105 | return err 106 | } 107 | if err = tpl.Execute(os.Stdout, c); err != nil { 108 | return err 109 | } 110 | return nil 111 | } 112 | 113 | func run(args []string) string { 114 | var b strings.Builder 115 | b.WriteString("$ knut") 116 | for _, a := range args { 117 | b.WriteRune(' ') 118 | b.WriteString(a) 119 | } 120 | b.WriteRune('\n') 121 | 122 | c := cmd.CreateCmd("development") 123 | c.SetArgs(args) 124 | c.SetOut(&b) 125 | if err := c.Execute(); err != nil { 126 | panic(err) 127 | } 128 | return b.String() 129 | } 130 | -------------------------------------------------------------------------------- /cmd/importer/swisscard2/swisscard2.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package swisscard 16 | 17 | import ( 18 | "bufio" 19 | "encoding/csv" 20 | "fmt" 21 | "io" 22 | "time" 23 | 24 | "github.com/shopspring/decimal" 25 | "github.com/spf13/cobra" 26 | 27 | "github.com/sboehler/knut/cmd/flags" 28 | "github.com/sboehler/knut/cmd/importer" 29 | "github.com/sboehler/knut/lib/journal" 30 | "github.com/sboehler/knut/lib/model" 31 | "github.com/sboehler/knut/lib/model/posting" 32 | "github.com/sboehler/knut/lib/model/registry" 33 | "github.com/sboehler/knut/lib/model/transaction" 34 | ) 35 | 36 | // CreateCmd creates the command. 37 | func CreateCmd() *cobra.Command { 38 | var r runner 39 | cmd := &cobra.Command{ 40 | Use: "ch.swisscard2", 41 | Short: "Import Swisscard credit card statements (from mid 2023)", 42 | Long: `Download the CSV file from their account management tool.`, 43 | 44 | Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), 45 | 46 | RunE: r.run, 47 | } 48 | r.setupFlags(cmd) 49 | return cmd 50 | } 51 | 52 | func init() { 53 | importer.RegisterImporter(CreateCmd) 54 | } 55 | 56 | type runner struct { 57 | account flags.AccountFlag 58 | } 59 | 60 | func (r *runner) setupFlags(cmd *cobra.Command) { 61 | cmd.Flags().VarP(&r.account, "account", "a", "account name") 62 | cmd.MarkFlagRequired("account") 63 | 64 | } 65 | 66 | func (r *runner) run(cmd *cobra.Command, args []string) error { 67 | reg := registry.New() 68 | f, err := flags.OpenFile(args[0]) 69 | if err != nil { 70 | return err 71 | } 72 | account, err := r.account.Value(reg.Accounts()) 73 | if err != nil { 74 | return err 75 | } 76 | p := parser{ 77 | registry: reg, 78 | reader: csv.NewReader(f), 79 | builder: journal.New(), 80 | account: account, 81 | } 82 | if err = p.parse(); err != nil { 83 | return err 84 | } 85 | w := bufio.NewWriter(cmd.OutOrStdout()) 86 | defer w.Flush() 87 | return journal.Print(w, p.builder.Build()) 88 | } 89 | 90 | type parser struct { 91 | registry *model.Registry 92 | reader *csv.Reader 93 | account *model.Account 94 | builder *journal.Builder 95 | } 96 | 97 | func (p *parser) parse() error { 98 | p.reader.TrimLeadingSpace = true 99 | p.reader.FieldsPerRecord = 12 100 | 101 | if err := p.readHeader(); err != nil { 102 | return err 103 | } 104 | for { 105 | err := p.readBooking() 106 | if err == io.EOF { 107 | return nil 108 | } 109 | if err != nil { 110 | return err 111 | } 112 | } 113 | } 114 | 115 | type column int 116 | 117 | const ( 118 | transaktionsdatum column = iota 119 | beschreibung 120 | Händler 121 | kartennummer 122 | währung 123 | betrag 124 | fremdwährung 125 | betragInFremdwährung 126 | debitKredit 127 | status 128 | händlerKategorie 129 | registrierteKategorie 130 | ) 131 | 132 | func (p *parser) readHeader() error { 133 | _, err := p.reader.Read() 134 | return err 135 | } 136 | 137 | func (p *parser) readBooking() error { 138 | r, err := p.reader.Read() 139 | if err != nil { 140 | return err 141 | } 142 | d, err := time.Parse("02.01.2006", r[transaktionsdatum]) 143 | if err != nil { 144 | return fmt.Errorf("invalid date in record %v: %w", r, err) 145 | } 146 | c := p.registry.Commodities().MustGet(r[währung]) 147 | quantity, err := decimal.NewFromString(r[betrag]) 148 | if err != nil { 149 | return fmt.Errorf("invalid amount in record %v: %w", r, err) 150 | } 151 | p.builder.Add(transaction.Builder{ 152 | Date: d, 153 | Description: fmt.Sprintf("%s / %s / %s / %s / %s / %s", r[beschreibung], r[Händler], r[händlerKategorie], r[kartennummer], r[registrierteKategorie], r[debitKredit]), 154 | Postings: posting.Builder{ 155 | Credit: p.account, 156 | Debit: p.registry.Accounts().TBDAccount(), 157 | Commodity: c, 158 | Quantity: quantity, 159 | }.Build(), 160 | }.Build()) 161 | return nil 162 | } 163 | -------------------------------------------------------------------------------- /lib/syntax/bayes/bayes.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package bayes 16 | 17 | import ( 18 | "math" 19 | "strings" 20 | 21 | "github.com/sboehler/knut/lib/common/dict" 22 | "github.com/sboehler/knut/lib/common/set" 23 | "github.com/sboehler/knut/lib/syntax" 24 | ) 25 | 26 | // Model implements a Bayes model for accounts and text tokens derived from transactions. 27 | type Model struct { 28 | count int 29 | countByAccount countByAccount 30 | countByTokenAndAccount map[token]countByAccount 31 | 32 | account string 33 | } 34 | 35 | type token string 36 | 37 | // NewModel creates a new model. 38 | func NewModel(account string) *Model { 39 | return &Model{ 40 | count: 0, 41 | countByAccount: make(countByAccount), 42 | countByTokenAndAccount: make(map[token]countByAccount), 43 | account: account, 44 | } 45 | } 46 | 47 | // Update updates the model with the given transaction. 48 | func (m *Model) Update(t *syntax.Transaction) { 49 | for i, b := range t.Bookings { 50 | if b.Credit.Macro || b.Debit.Macro { 51 | continue 52 | } 53 | credit := b.Credit.Extract() 54 | debit := b.Debit.Extract() 55 | if credit == "" || debit == "" { 56 | continue 57 | } 58 | if credit == m.account || debit == m.account { 59 | continue 60 | } 61 | m.update(t, &t.Bookings[i], credit, debit) 62 | m.update(t, &t.Bookings[i], debit, credit) 63 | } 64 | } 65 | 66 | func (m *Model) update(t *syntax.Transaction, b *syntax.Booking, account, other string) { 67 | m.count++ 68 | m.countByAccount[account]++ 69 | for token := range tokenize(t, b, other) { 70 | dict.GetDefault(m.countByTokenAndAccount, token, newCountByAccount)[account]++ 71 | } 72 | } 73 | 74 | type countByAccount map[string]int 75 | 76 | func newCountByAccount() countByAccount { 77 | return make(map[string]int) 78 | } 79 | 80 | // Infer replaces the given account with an inferred account. 81 | // P(A | T1 & T2 & ... & Tn) ~ P(A) * P(T1|A) * P(T2|A) * ... * P(Tn|A) 82 | func (m *Model) Infer(t *syntax.Transaction) { 83 | for i := range t.Bookings { 84 | credit := t.Bookings[i].Credit.Extract() 85 | debit := t.Bookings[i].Debit.Extract() 86 | if credit == m.account { 87 | t.Bookings[i].Credit = m.inferAccount(t, &t.Bookings[i], debit) 88 | } 89 | if debit == m.account { 90 | t.Bookings[i].Debit = m.inferAccount(t, &t.Bookings[i], credit) 91 | } 92 | } 93 | } 94 | 95 | func (m *Model) inferAccount(t *syntax.Transaction, b *syntax.Booking, other string) syntax.Account { 96 | var ( 97 | tokens = tokenize(t, b, other) 98 | max = math.Inf(-1) 99 | best string 100 | ) 101 | for candidate := range m.countByAccount { 102 | if candidate == other { 103 | continue // the other account of this booking is not a valid candidate 104 | } 105 | score := m.scoreCandidate(candidate, tokens) 106 | if score > max { 107 | best = candidate 108 | max = score 109 | } 110 | } 111 | return syntax.Account{ 112 | Range: syntax.Range{Start: 0, End: len(best), Text: best}, 113 | } 114 | } 115 | 116 | func (m *Model) scoreCandidate(candidate string, tokens set.Set[token]) float64 { 117 | count := float64(m.countByAccount[candidate]) 118 | score := math.Log(count / float64(m.count)) 119 | for token := range tokens { 120 | if countForToken, ok := m.countByTokenAndAccount[token][candidate]; ok { 121 | score += math.Log(float64(countForToken) / count) 122 | } else { 123 | score += math.Log(1.0 / float64(m.count)) 124 | } 125 | } 126 | return score 127 | } 128 | 129 | func tokenize(t *syntax.Transaction, b *syntax.Booking, other string) set.Set[token] { 130 | tokens := append(strings.Fields(t.Description.Content.Extract()), b.Commodity.Extract(), b.Quantity.Extract(), other) 131 | result := set.New[token]() 132 | for _, t := range tokens { 133 | result.Add(token(strings.ToLower(t))) 134 | } 135 | return result 136 | } 137 | -------------------------------------------------------------------------------- /lib/reports/balance/renderer.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package balance 16 | 17 | import ( 18 | "time" 19 | 20 | "github.com/sboehler/knut/lib/amounts" 21 | "github.com/sboehler/knut/lib/common/date" 22 | "github.com/sboehler/knut/lib/common/mapper" 23 | "github.com/sboehler/knut/lib/common/regex" 24 | "github.com/sboehler/knut/lib/common/table" 25 | "github.com/sboehler/knut/lib/model" 26 | "github.com/sboehler/knut/lib/model/commodity" 27 | "github.com/shopspring/decimal" 28 | ) 29 | 30 | // Renderer renders a report. 31 | type Renderer struct { 32 | Valuation *model.Commodity 33 | CommodityDetails regex.Regexes 34 | SortAlphabetically bool 35 | Diff bool 36 | 37 | drawCommsColumn bool 38 | partition date.Partition 39 | } 40 | 41 | // Render renders a report. 42 | func (rn *Renderer) Render(r *Report) *table.Table { 43 | rn.drawCommsColumn = rn.Valuation == nil || len(rn.CommodityDetails) > 0 44 | rn.partition = r.partition 45 | r.SetAccounts() 46 | if rn.SortAlphabetically { 47 | r.SortAlpha() 48 | } else { 49 | r.SortWeighted() 50 | } 51 | var tbl *table.Table 52 | if rn.drawCommsColumn { 53 | tbl = table.New(1, 1, rn.partition.Size()) 54 | } else { 55 | tbl = table.New(1, rn.partition.Size()) 56 | } 57 | tbl.AddSeparatorRow() 58 | header := tbl.AddRow().AddText("Account", table.Center) 59 | if rn.drawCommsColumn { 60 | header.AddText("Comm", table.Center) 61 | } 62 | for _, d := range rn.partition.EndDates() { 63 | header.AddText(d.Format("2006-01-02"), table.Center) 64 | } 65 | tbl.AddSeparatorRow() 66 | 67 | totalAL, totalEIE := r.Totals(amounts.KeyMapper{ 68 | Date: mapper.Identity[time.Time], 69 | Commodity: commodity.IdentityIf(rn.Valuation == nil), 70 | }.Build()) 71 | 72 | for _, n := range r.AL.Sorted { 73 | rn.renderNode(tbl, 0, false, n) 74 | tbl.AddEmptyRow() 75 | } 76 | rn.render(tbl, 0, "Total (A+L)", false, totalAL) 77 | tbl.AddSeparatorRow() 78 | for _, n := range r.EIE.Sorted { 79 | rn.renderNode(tbl, 0, true, n) 80 | tbl.AddEmptyRow() 81 | } 82 | rn.render(tbl, 0, "Total (E+I+E)", true, totalEIE) 83 | tbl.AddSeparatorRow() 84 | totalAL.Plus(totalEIE) 85 | rn.render(tbl, 0, "Delta", false, totalAL) 86 | tbl.AddSeparatorRow() 87 | 88 | return tbl 89 | } 90 | 91 | func (rn *Renderer) renderNode(t *table.Table, indent int, neg bool, n *Node) { 92 | var vals amounts.Amounts 93 | if n.Value.Account != nil { 94 | showCommodities := rn.Valuation == nil || rn.CommodityDetails.MatchString(n.Value.Account.Name()) 95 | vals = n.Value.Amounts.SumBy(nil, amounts.KeyMapper{ 96 | Date: mapper.Identity[time.Time], 97 | Commodity: commodity.IdentityIf(showCommodities), 98 | }.Build()) 99 | } 100 | if n.Segment != "" { 101 | rn.render(t, indent, n.Segment, neg, vals) 102 | } 103 | for _, ch := range n.Sorted { 104 | rn.renderNode(t, indent+2, neg, ch) 105 | } 106 | } 107 | 108 | func (rn *Renderer) render(t *table.Table, indent int, name string, neg bool, vals amounts.Amounts) { 109 | if len(vals) == 0 { 110 | t.AddRow().AddIndented(name, indent).FillEmpty() 111 | return 112 | } 113 | for i, commodity := range vals.CommoditiesSorted() { 114 | row := t.AddRow() 115 | if i == 0 { 116 | row.AddIndented(name, indent) 117 | } else { 118 | row.AddEmpty() 119 | } 120 | if rn.drawCommsColumn { 121 | if commodity != nil { 122 | row.AddText(commodity.Name(), table.Left) 123 | } else if rn.Valuation != nil { 124 | row.AddText(rn.Valuation.Name(), table.Left) 125 | } else { 126 | row.AddEmpty() 127 | } 128 | } 129 | var total decimal.Decimal 130 | for _, date := range rn.partition.EndDates() { 131 | v := vals[amounts.DateCommodityKey(date, commodity)] 132 | if !rn.Diff { 133 | total = total.Add(v) 134 | v = total 135 | } 136 | if neg { 137 | v = v.Neg() 138 | } 139 | row.AddDecimal(v) 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /lib/quotes/yahoo2/yahoo2.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package yahoo2 16 | 17 | import ( 18 | "encoding/json" 19 | "fmt" 20 | "io" 21 | "net/http" 22 | "net/url" 23 | "path" 24 | "time" 25 | ) 26 | 27 | const yahooURL string = "https://query2.finance.yahoo.com/v8/finance/chart" 28 | 29 | // Quote represents a quote on a given day. 30 | type Quote struct { 31 | Date time.Time 32 | Open float64 33 | High float64 34 | Low float64 35 | Close float64 36 | AdjClose float64 37 | Volume int 38 | } 39 | 40 | // Client is a client for Yahoo! quotes. 41 | type Client struct { 42 | url string 43 | } 44 | 45 | // New creates a new client with the default URL. 46 | func New() Client { 47 | return Client{yahooURL} 48 | } 49 | 50 | // Fetch fetches a set of quotes 51 | func (c *Client) Fetch(sym string, t0, t1 time.Time) ([]Quote, error) { 52 | u, err := createURL(c.url, sym, t0, t1) 53 | if err != nil { 54 | return nil, fmt.Errorf("error creating URL for symbol %s: %w", sym, err) 55 | } 56 | resp, err := http.Get(u.String()) 57 | if err != nil { 58 | return nil, fmt.Errorf("error fetching data from URL %s: %w", u.String(), err) 59 | } 60 | defer resp.Body.Close() 61 | quote, err := decodeResponse(resp.Body) 62 | if err != nil { 63 | return nil, fmt.Errorf("error decoding response for symbol %s (url: %s): %w", sym, u, err) 64 | } 65 | return quote, nil 66 | } 67 | 68 | // createURL creates a URL for the given root URL and parameters. 69 | func createURL(rootURL, sym string, t0, t1 time.Time) (*url.URL, error) { 70 | u, err := url.Parse(rootURL) 71 | if err != nil { 72 | return u, err 73 | } 74 | u.Path = path.Join(u.Path, url.PathEscape(sym)) 75 | u.RawQuery = url.Values{ 76 | "events": {"history"}, 77 | "interval": {"1d"}, 78 | "period1": {fmt.Sprint(t0.Unix())}, 79 | "period2": {fmt.Sprint(t1.Unix())}, 80 | }.Encode() 81 | return u, nil 82 | } 83 | 84 | // decodeResponse takes a reader for the response and returns 85 | // the parsed quotes. 86 | func decodeResponse(r io.Reader) ([]Quote, error) { 87 | d := json.NewDecoder(r) 88 | var body jbody 89 | if err := d.Decode(&body); err != nil { 90 | return nil, err 91 | } 92 | result := body.Chart.Result[0] 93 | loc, err := time.LoadLocation(result.Meta.ExchangeTimezoneName) 94 | if err != nil { 95 | return nil, fmt.Errorf("unknown time zone: %s", result.Meta.ExchangeTimezoneName) 96 | } 97 | var res []Quote 98 | for i, ts := range body.Chart.Result[0].Timestamp { 99 | date := time.Unix(int64(ts), 0).In(loc) 100 | q := Quote{ 101 | Date: time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC), 102 | Open: result.Indicators.Quote[0].Open[i], 103 | Close: result.Indicators.Quote[0].Close[i], 104 | High: result.Indicators.Quote[0].High[i], 105 | Low: result.Indicators.Quote[0].Low[i], 106 | AdjClose: result.Indicators.Adjclose[0].Adjclose[i], 107 | Volume: result.Indicators.Quote[0].Volume[i], 108 | } 109 | if q.Close > 0 { 110 | res = append(res, q) 111 | } 112 | } 113 | return res, nil 114 | } 115 | 116 | type jbody struct { 117 | Chart jchart `json:"chart"` 118 | } 119 | type jchart struct { 120 | Result []jresult `json:"result"` 121 | } 122 | 123 | type jresult struct { 124 | Meta jmeta `json:"meta"` 125 | Timestamp []int `json:"timestamp"` 126 | Indicators jindicators `json:"indicators"` 127 | } 128 | 129 | type jmeta struct { 130 | ExchangeTimezoneName string `json:"exchangeTimezoneName"` 131 | } 132 | 133 | type jindicators struct { 134 | Quote []jquote `json:"quote"` 135 | Adjclose []jadjclose `json:"adjclose"` 136 | } 137 | 138 | type jquote struct { 139 | Volume []int `json:"volume"` 140 | High []float64 `json:"high"` 141 | Close []float64 `json:"close"` 142 | Low []float64 `json:"low"` 143 | Open []float64 `json:"open"` 144 | } 145 | 146 | type jadjclose struct { 147 | Adjclose []float64 `json:"adjclose"` 148 | } 149 | -------------------------------------------------------------------------------- /lib/model/account/account.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/sboehler/knut/lib/common/compare" 9 | "github.com/sboehler/knut/lib/common/mapper" 10 | "github.com/sboehler/knut/lib/common/regex" 11 | ) 12 | 13 | // Type is the type of an account. 14 | type Type int 15 | 16 | const ( 17 | // ASSETS represents an asset account. 18 | ASSETS Type = iota 19 | // LIABILITIES represents a liability account. 20 | LIABILITIES 21 | // EQUITY represents an equity account. 22 | EQUITY 23 | // INCOME represents an income account. 24 | INCOME 25 | // EXPENSES represents an expenses account. 26 | EXPENSES 27 | ) 28 | 29 | func (t Type) String() string { 30 | switch t { 31 | case ASSETS: 32 | return "Assets" 33 | case LIABILITIES: 34 | return "Liabilities" 35 | case EQUITY: 36 | return "Equity" 37 | case INCOME: 38 | return "Income" 39 | case EXPENSES: 40 | return "Expenses" 41 | } 42 | return "" 43 | } 44 | 45 | // Types is an array with the ordered accont types. 46 | var Types = []Type{ASSETS, LIABILITIES, EQUITY, INCOME, EXPENSES} 47 | 48 | var types = map[string]Type{ 49 | "Assets": ASSETS, 50 | "Liabilities": LIABILITIES, 51 | "Equity": EQUITY, 52 | "Expenses": EXPENSES, 53 | "Income": INCOME, 54 | } 55 | 56 | // Account represents an account which can be used in bookings. 57 | type Account struct { 58 | accountType Type 59 | name string 60 | segments []string 61 | } 62 | 63 | // Segments returns the account name split into segments. 64 | func (a *Account) Segments() []string { 65 | return a.segments 66 | } 67 | 68 | // Name returns the name of this account. 69 | func (a Account) Name() string { 70 | return a.name 71 | } 72 | 73 | // Type returns the account type. 74 | func (a Account) Type() Type { 75 | return a.accountType 76 | } 77 | 78 | // IsAL returns whether this account is an asset or liability account. 79 | func (a Account) IsAL() bool { 80 | return a.accountType == ASSETS || a.accountType == LIABILITIES 81 | } 82 | 83 | // IsIE returns whether this account is an income or expense account. 84 | func (a Account) IsIE() bool { 85 | return a.accountType == EXPENSES || a.accountType == INCOME 86 | } 87 | 88 | func (a Account) String() string { 89 | return a.name 90 | } 91 | 92 | func (a Account) Level() int { 93 | return len(a.segments) 94 | } 95 | 96 | func Compare(a1, a2 *Account) compare.Order { 97 | o := compare.Ordered(a1.accountType, a2.accountType) 98 | if o != compare.Equal { 99 | return o 100 | } 101 | return compare.Ordered(a1.name, a2.name) 102 | } 103 | 104 | // Rule is a rule to shorten accounts which match the given regex. 105 | type Rule struct { 106 | Level int 107 | Suffix int 108 | Regex *regexp.Regexp 109 | } 110 | 111 | func (rule Rule) String() string { 112 | return fmt.Sprintf("%d,%v", rule.Level, rule.Regex) 113 | } 114 | 115 | func (rule Rule) Match(s string) (int, int, bool) { 116 | if rule.Regex == nil { 117 | return rule.Level, rule.Suffix, true 118 | } 119 | if rule.Regex.MatchString(s) { 120 | return rule.Level, rule.Suffix, true 121 | } 122 | return 0, 0, false 123 | } 124 | 125 | // Mapping is a set of mapping rules. 126 | type Mapping []Rule 127 | 128 | func (m Mapping) String() string { 129 | var s []string 130 | for _, r := range m { 131 | s = append(s, r.String()) 132 | } 133 | return strings.Join(s, ", ") 134 | } 135 | 136 | // Level returns the Level to which an account should be shortened. 137 | func (m Mapping) Level(s string) (int, int, bool) { 138 | for _, rule := range m { 139 | if level, suffix, ok := rule.Match(s); ok { 140 | return level, suffix, ok 141 | } 142 | } 143 | return 0, 0, false 144 | } 145 | 146 | func Shorten(reg *Registry, m Mapping) mapper.Mapper[*Account] { 147 | if len(m) == 0 { 148 | return mapper.Identity[*Account] 149 | } 150 | return func(a *Account) *Account { 151 | level, suffix, ok := m.Level(a.name) 152 | if !ok { 153 | return a 154 | } 155 | if level == 0 { 156 | return nil 157 | } 158 | if suffix >= a.Level() { 159 | return a 160 | } 161 | if level > a.Level()-suffix { 162 | return a 163 | } 164 | splitPos := a.Level() - suffix 165 | ss := a.Segments() 166 | pref, suff := ss[:splitPos], ss[splitPos:] 167 | return reg.MustGetPath(append(pref[:level], suff...)) 168 | } 169 | } 170 | 171 | func Remap(reg *Registry, rs regex.Regexes) mapper.Mapper[*Account] { 172 | return func(a *Account) *Account { 173 | if rs.MatchString(a.name) { 174 | return reg.SwapType(a) 175 | } 176 | return a 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /lib/quotes/yahoo/yahoo.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package yahoo implements fetching pricing data from Yahoo!. The method 16 | // in this packages stopped working around 2024-09-11, see package yahoo2 17 | // for an updated version that uses a different API. 18 | package yahoo 19 | 20 | import ( 21 | "encoding/csv" 22 | "fmt" 23 | "io" 24 | "net/http" 25 | "net/url" 26 | "path" 27 | "strconv" 28 | "time" 29 | ) 30 | 31 | const yahooURL string = "https://query1.finance.yahoo.com/v7/finance/download" 32 | 33 | // Quote represents a quote on a given day. 34 | type Quote struct { 35 | Date time.Time 36 | Open float64 37 | High float64 38 | Low float64 39 | Close float64 40 | AdjClose float64 41 | Volume int 42 | } 43 | 44 | // Client is a client for Yahoo! quotes. 45 | type Client struct { 46 | url string 47 | } 48 | 49 | // New creates a new client with the default URL. 50 | func New() Client { 51 | return Client{yahooURL} 52 | } 53 | 54 | // Fetch fetches a set of quotes 55 | func (c *Client) Fetch(sym string, t0, t1 time.Time) ([]Quote, error) { 56 | u, err := createURL(c.url, sym, t0, t1) 57 | if err != nil { 58 | return nil, fmt.Errorf("error creating URL for symbol %s: %w", sym, err) 59 | } 60 | resp, err := http.Get(u.String()) 61 | if err != nil { 62 | return nil, fmt.Errorf("error fetching data from URL %s: %w", u.String(), err) 63 | } 64 | defer resp.Body.Close() 65 | quote, err := decodeResponse(resp.Body) 66 | if err != nil { 67 | return nil, fmt.Errorf("error decoding response for symbol %s: %w", sym, err) 68 | } 69 | return quote, nil 70 | } 71 | 72 | // createURL creates a URL for the given root URL and parameters. 73 | func createURL(rootURL, sym string, t0, t1 time.Time) (*url.URL, error) { 74 | u, err := url.Parse(rootURL) 75 | if err != nil { 76 | return u, err 77 | } 78 | u.Path = path.Join(u.Path, url.PathEscape(sym)) 79 | u.RawQuery = url.Values{ 80 | "events": {"history"}, 81 | "interval": {"1d"}, 82 | "period1": {fmt.Sprint(t0.Unix())}, 83 | "period2": {fmt.Sprint(t1.Unix())}, 84 | }.Encode() 85 | return u, nil 86 | } 87 | 88 | // decodeResponse takes a reader for the response and returns 89 | // the parsed quotes. 90 | func decodeResponse(r io.ReadCloser) ([]Quote, error) { 91 | csvReader := csv.NewReader(r) 92 | csvReader.FieldsPerRecord = 7 93 | // skip header 94 | if _, err := csvReader.Read(); err != nil { 95 | return nil, err 96 | } 97 | // read lines 98 | var res []Quote 99 | for { 100 | r, err := csvReader.Read() 101 | if err == io.EOF { 102 | return res, nil 103 | } 104 | if err != nil { 105 | return nil, err 106 | } 107 | quote, ok, err := recordToQuote(r) 108 | if err != nil { 109 | return nil, err 110 | } 111 | if !ok { 112 | continue 113 | } 114 | res = append(res, quote) 115 | } 116 | } 117 | 118 | // recordToQuote decodes one line of the response CSV. 119 | func recordToQuote(r []string) (Quote, bool, error) { 120 | var ( 121 | quote Quote 122 | err error 123 | ) 124 | for _, item := range r { 125 | if item == "null" { 126 | return quote, false, nil 127 | } 128 | } 129 | quote.Date, err = time.Parse("2006-01-02", r[0]) 130 | if err != nil { 131 | return quote, false, err 132 | } 133 | quote.Open, err = strconv.ParseFloat(r[1], 64) 134 | if err != nil { 135 | return quote, false, err 136 | } 137 | quote.High, err = strconv.ParseFloat(r[2], 64) 138 | if err != nil { 139 | return quote, false, err 140 | } 141 | quote.Low, err = strconv.ParseFloat(r[3], 64) 142 | if err != nil { 143 | return quote, false, err 144 | } 145 | quote.Close, err = strconv.ParseFloat(r[4], 64) 146 | if err != nil { 147 | return quote, false, err 148 | } 149 | quote.AdjClose, err = strconv.ParseFloat(r[5], 64) 150 | if err != nil { 151 | return quote, false, err 152 | } 153 | quote.Volume, err = strconv.Atoi(r[6]) 154 | if err != nil { 155 | return quote, false, err 156 | } 157 | return quote, true, nil 158 | } 159 | -------------------------------------------------------------------------------- /cmd/importer/swisscard/swisscard.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package swisscard 16 | 17 | import ( 18 | "bufio" 19 | "encoding/csv" 20 | "fmt" 21 | "io" 22 | "regexp" 23 | "strings" 24 | "time" 25 | 26 | "github.com/shopspring/decimal" 27 | "github.com/spf13/cobra" 28 | 29 | "github.com/sboehler/knut/cmd/flags" 30 | "github.com/sboehler/knut/cmd/importer" 31 | "github.com/sboehler/knut/lib/journal" 32 | "github.com/sboehler/knut/lib/model" 33 | "github.com/sboehler/knut/lib/model/posting" 34 | "github.com/sboehler/knut/lib/model/registry" 35 | "github.com/sboehler/knut/lib/model/transaction" 36 | ) 37 | 38 | // CreateCmd creates the command. 39 | func CreateCmd() *cobra.Command { 40 | var r runner 41 | cmd := &cobra.Command{ 42 | Use: "ch.swisscard", 43 | Short: "Import Swisscard credit card statements (before mid 2023)", 44 | Long: `Download the CSV file from their account management tool.`, 45 | 46 | Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), 47 | 48 | RunE: r.run, 49 | } 50 | r.setupFlags(cmd) 51 | return cmd 52 | } 53 | 54 | func init() { 55 | importer.RegisterImporter(CreateCmd) 56 | } 57 | 58 | type runner struct { 59 | account flags.AccountFlag 60 | } 61 | 62 | func (r *runner) setupFlags(cmd *cobra.Command) { 63 | cmd.Flags().VarP(&r.account, "account", "a", "account name") 64 | cmd.MarkFlagRequired("account") 65 | 66 | } 67 | 68 | func (r *runner) run(cmd *cobra.Command, args []string) error { 69 | var ( 70 | reg = registry.New() 71 | f *bufio.Reader 72 | err error 73 | ) 74 | if f, err = flags.OpenFile(args[0]); err != nil { 75 | return err 76 | } 77 | p := parser{ 78 | registry: reg, 79 | reader: csv.NewReader(f), 80 | builder: journal.New(), 81 | } 82 | if p.account, err = r.account.Value(reg.Accounts()); err != nil { 83 | return err 84 | } 85 | if err = p.parse(); err != nil { 86 | return err 87 | } 88 | w := bufio.NewWriter(cmd.OutOrStdout()) 89 | defer w.Flush() 90 | return journal.Print(w, p.builder.Build()) 91 | } 92 | 93 | type parser struct { 94 | registry *model.Registry 95 | reader *csv.Reader 96 | account *model.Account 97 | builder *journal.Builder 98 | } 99 | 100 | func (p *parser) parse() error { 101 | p.reader.TrimLeadingSpace = true 102 | for { 103 | err := p.readLine() 104 | if err == io.EOF { 105 | return nil 106 | } 107 | if err != nil { 108 | return err 109 | } 110 | } 111 | } 112 | 113 | func (p *parser) readLine() error { 114 | r, err := p.reader.Read() 115 | if err != nil { 116 | return err 117 | } 118 | if ok, err := p.parseBooking(r); ok || err != nil { 119 | return err 120 | } 121 | return nil 122 | } 123 | 124 | var dateRegex = regexp.MustCompile(`\d\d.\d\d.\d\d\d\d`) 125 | 126 | var replacer = strings.NewReplacer("CHF", "", "'", "") 127 | 128 | func (p *parser) parseBooking(r []string) (bool, error) { 129 | if !dateRegex.MatchString(r[0]) || !dateRegex.MatchString(r[1]) { 130 | return false, nil 131 | } 132 | if len(r) != 11 { 133 | return false, fmt.Errorf("expected 11 items, got %v", r) 134 | } 135 | var words []string 136 | for _, i := range []int{2, 4, 5, 6, 7, 8} { 137 | s := strings.TrimSpace(r[i]) 138 | if len(s) > 0 { 139 | words = append(words, s) 140 | } 141 | } 142 | var ( 143 | err error 144 | desc = strings.Join(words, " ") 145 | chf *model.Commodity 146 | quantity decimal.Decimal 147 | d time.Time 148 | ) 149 | if d, err = time.Parse("02.01.2006", r[0]); err != nil { 150 | return false, err 151 | } 152 | if quantity, err = decimal.NewFromString(replacer.Replace(r[3])); err != nil { 153 | return false, err 154 | } 155 | if chf, err = p.registry.Commodities().Get("CHF"); err != nil { 156 | return false, err 157 | } 158 | p.builder.Add(transaction.Builder{ 159 | Date: d, 160 | Description: desc, 161 | Postings: posting.Builder{ 162 | Credit: p.account, 163 | Debit: p.registry.Accounts().TBDAccount(), 164 | Commodity: chf, 165 | Quantity: quantity, 166 | }.Build(), 167 | }.Build()) 168 | return true, nil 169 | } 170 | -------------------------------------------------------------------------------- /lib/model/transaction/transaction.go: -------------------------------------------------------------------------------- 1 | package transaction 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/sboehler/knut/lib/common/compare" 8 | "github.com/sboehler/knut/lib/common/date" 9 | "github.com/sboehler/knut/lib/model/commodity" 10 | "github.com/sboehler/knut/lib/model/posting" 11 | "github.com/sboehler/knut/lib/model/registry" 12 | "github.com/sboehler/knut/lib/syntax" 13 | "github.com/shopspring/decimal" 14 | ) 15 | 16 | // Transaction represents a transaction. 17 | type Transaction struct { 18 | Src *syntax.Transaction 19 | Date time.Time 20 | Description string 21 | Postings []*posting.Posting 22 | Targets []*commodity.Commodity 23 | } 24 | 25 | // Less defines an order on transactions. 26 | func Compare(t *Transaction, t2 *Transaction) compare.Order { 27 | if o := compare.Time(t.Date, t2.Date); o != compare.Equal { 28 | return o 29 | } 30 | if o := compare.Ordered(t.Description, t2.Description); o != compare.Equal { 31 | return o 32 | } 33 | for i := 0; i < len(t.Postings) && i < len(t2.Postings); i++ { 34 | if o := posting.Compare(t.Postings[i], t2.Postings[i]); o != compare.Equal { 35 | return o 36 | } 37 | } 38 | return compare.Ordered(len(t.Postings), len(t2.Postings)) 39 | } 40 | 41 | // Builder builds transactions. 42 | type Builder struct { 43 | Src *syntax.Transaction 44 | Date time.Time 45 | Description string 46 | Postings []*posting.Posting 47 | Targets []*commodity.Commodity 48 | } 49 | 50 | // Build builds a transactions. 51 | func (tb Builder) Build() *Transaction { 52 | return &Transaction{ 53 | Src: tb.Src, 54 | Date: tb.Date, 55 | Description: tb.Description, 56 | Postings: tb.Postings, 57 | Targets: tb.Targets, 58 | } 59 | } 60 | 61 | func Create(reg *registry.Registry, t *syntax.Transaction) ([]*Transaction, error) { 62 | date, err := t.Date.Parse() 63 | if err != nil { 64 | return nil, err 65 | } 66 | desc := t.Description.Content.Extract() 67 | postings, err := posting.Create(reg, t.Bookings) 68 | if err != nil { 69 | return nil, err 70 | } 71 | var targets []*commodity.Commodity 72 | if !t.Addons.Performance.Empty() { 73 | targets = []*commodity.Commodity{} 74 | for _, c := range t.Addons.Performance.Targets { 75 | com, err := reg.Commodities().Create(c) 76 | if err != nil { 77 | return nil, err 78 | } 79 | targets = append(targets, com) 80 | } 81 | } 82 | res := Builder{ 83 | Src: t, 84 | Date: date, 85 | Description: desc, 86 | Postings: postings, 87 | Targets: targets, 88 | }.Build() 89 | if !t.Addons.Accrual.Empty() { 90 | return expand(reg, res, &t.Addons.Accrual) 91 | } 92 | return []*Transaction{res}, nil 93 | 94 | } 95 | 96 | // Expand expands an accrual transaction. 97 | func expand(reg *registry.Registry, t *Transaction, accrual *syntax.Accrual) ([]*Transaction, error) { 98 | account, err := reg.Accounts().Create(accrual.Account) 99 | if err != nil { 100 | return nil, err 101 | } 102 | start, err := accrual.Start.Parse() 103 | if err != nil { 104 | return nil, err 105 | } 106 | end, err := accrual.End.Parse() 107 | if err != nil { 108 | return nil, err 109 | } 110 | interval, err := date.ParseInterval(accrual.Interval.Extract()) 111 | if err != nil { 112 | return nil, syntax.Error{ 113 | Message: "parsing interval", 114 | Range: accrual.Interval.Range, 115 | Wrapped: err, 116 | } 117 | } 118 | var result []*Transaction 119 | for _, p := range t.Postings { 120 | if p.Account.IsAL() { 121 | result = append(result, Builder{ 122 | Src: t.Src, 123 | Date: t.Date, 124 | Description: t.Description, 125 | Postings: posting.Builder{ 126 | Credit: account, 127 | Debit: p.Account, 128 | Commodity: p.Commodity, 129 | Quantity: p.Quantity, 130 | }.Build(), 131 | Targets: t.Targets, 132 | }.Build()) 133 | } 134 | if p.Account.IsIE() { 135 | partition := date.NewPartition(date.Period{Start: start, End: end}, interval, 0) 136 | amount, rem := p.Quantity.QuoRem(decimal.NewFromInt(int64(partition.Size())), 1) 137 | for i, dt := range partition.EndDates() { 138 | a := amount 139 | if i == 0 { 140 | a = a.Add(rem) 141 | } 142 | result = append(result, Builder{ 143 | Src: t.Src, 144 | Date: dt, 145 | Description: fmt.Sprintf("%s (accrual %d/%d)", t.Description, i+1, partition.Size()), 146 | Postings: posting.Builder{ 147 | Credit: account, 148 | Debit: p.Account, 149 | Commodity: p.Commodity, 150 | Quantity: a, 151 | }.Build(), 152 | Targets: t.Targets, 153 | }.Build()) 154 | } 155 | } 156 | } 157 | return result, nil 158 | } 159 | -------------------------------------------------------------------------------- /lib/syntax/directives/directives.go: -------------------------------------------------------------------------------- 1 | package directives 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/shopspring/decimal" 9 | ) 10 | 11 | type Commodity struct{ Range } 12 | 13 | type Account struct { 14 | Range 15 | Macro bool 16 | } 17 | 18 | type Date struct{ Range } 19 | 20 | func (d Date) Parse() (time.Time, error) { 21 | date, err := time.Parse("2006-01-02", d.Extract()) 22 | if err != nil { 23 | return date, Error{ 24 | Message: "parsing date", 25 | Range: d.Range, 26 | Wrapped: err, 27 | } 28 | } 29 | return date, nil 30 | } 31 | 32 | type Decimal struct{ Range } 33 | 34 | func (d Decimal) Parse() (decimal.Decimal, error) { 35 | dec, err := decimal.NewFromString(d.Extract()) 36 | if err != nil { 37 | return dec, Error{ 38 | Message: "parsing date", 39 | Range: d.Range, 40 | Wrapped: err, 41 | } 42 | } 43 | return dec, nil 44 | } 45 | 46 | type QuotedString struct { 47 | Range 48 | Content Range 49 | } 50 | 51 | type Booking struct { 52 | Range 53 | Credit, Debit Account 54 | Quantity Decimal 55 | Commodity Commodity 56 | } 57 | 58 | type Performance struct { 59 | Range 60 | Targets []Commodity 61 | } 62 | 63 | type Interval struct{ Range } 64 | 65 | type Directive struct { 66 | Range 67 | Directive any 68 | } 69 | 70 | type File struct { 71 | Range 72 | Directives []Directive 73 | } 74 | 75 | type Accrual struct { 76 | Range 77 | Interval Interval 78 | Start, End Date 79 | Account Account 80 | } 81 | 82 | type Addons struct { 83 | Range 84 | Performance Performance 85 | Accrual Accrual 86 | } 87 | 88 | type Transaction struct { 89 | Range 90 | Date Date 91 | Description QuotedString 92 | Bookings []Booking 93 | Addons Addons 94 | } 95 | 96 | type Open struct { 97 | Range 98 | Date Date 99 | Account Account 100 | } 101 | 102 | type Close struct { 103 | Range 104 | Date Date 105 | Account Account 106 | } 107 | 108 | type Assertion struct { 109 | Range 110 | Date Date 111 | Balances []Balance 112 | } 113 | 114 | type Balance struct { 115 | Range 116 | Account Account 117 | Quantity Decimal 118 | Commodity Commodity 119 | } 120 | 121 | type Price struct { 122 | Range 123 | Date Date 124 | Commodity, Target Commodity 125 | Price Decimal 126 | } 127 | 128 | type Include struct { 129 | Range 130 | IncludePath QuotedString 131 | } 132 | 133 | type Range struct { 134 | Start, End int 135 | Path, Text string 136 | } 137 | 138 | func (r Range) Extract() string { 139 | return r.Text[r.Start:r.End] 140 | } 141 | 142 | func (r *Range) SetRange(r2 Range) { 143 | *r = r2 144 | } 145 | 146 | func (r Range) Length() int { 147 | return r.End - r.Start 148 | } 149 | 150 | func (r *Range) Extend(r2 Range) { 151 | if r.Start > r2.Start { 152 | r.Start = r2.Start 153 | } 154 | if r.End < r2.End { 155 | r.End = r2.End 156 | } 157 | } 158 | 159 | func SetRange[T any, P interface { 160 | *T 161 | SetRange(Range) 162 | }](t P, r Range) T { 163 | t.SetRange(r) 164 | return *t 165 | } 166 | 167 | func (r Range) Empty() bool { 168 | return r.Start == r.End 169 | } 170 | 171 | func (r Range) Location() Location { 172 | loc := Location{Line: 1, Col: 1} 173 | for pos, ch := range r.Text { 174 | if pos == r.End { 175 | return loc 176 | } 177 | if ch == '\n' { 178 | loc.Line++ 179 | loc.Col = 1 180 | } else { 181 | loc.Col++ 182 | } 183 | } 184 | return loc 185 | } 186 | 187 | func (r Range) Context(previous int) []string { 188 | start := r.Start 189 | end := r.End 190 | for i := 0; i <= previous; i++ { 191 | start = r.firstOfLine(start) 192 | } 193 | end = r.lastOfLine(end) 194 | return strings.Split(r.Text[start:end], "\n") 195 | } 196 | 197 | func (r Range) firstOfLine(pos int) int { 198 | for pos > 0 && r.Text[pos-1] != '\n' { 199 | pos-- 200 | } 201 | return pos 202 | } 203 | 204 | func (r Range) lastOfLine(pos int) int { 205 | for pos < len(r.Text) && r.Text[pos] != '\n' { 206 | pos++ 207 | } 208 | return pos 209 | } 210 | 211 | type Location struct { 212 | Line, Col int 213 | } 214 | 215 | func (l Location) String() string { 216 | return fmt.Sprintf("%d:%d", l.Line, l.Col) 217 | } 218 | 219 | var _ error = Error{} 220 | 221 | type Error struct { 222 | Range 223 | Message string 224 | Wrapped error 225 | } 226 | 227 | func (e Error) Error() string { 228 | var s strings.Builder 229 | if e.Wrapped != nil { 230 | s.WriteString(e.Wrapped.Error()) 231 | s.WriteString("\n") 232 | } 233 | if len(e.Path) > 0 { 234 | s.WriteString(e.Path) 235 | s.WriteString(": ") 236 | } 237 | loc := e.Location() 238 | s.WriteString(loc.String()) 239 | s.WriteString(" ") 240 | s.WriteString(e.Message) 241 | return s.String() 242 | } 243 | -------------------------------------------------------------------------------- /cmd/commands/portfolio/weights.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | package portfolio 15 | 16 | import ( 17 | "bufio" 18 | "fmt" 19 | "io" 20 | "os" 21 | 22 | "github.com/spf13/cobra" 23 | 24 | "github.com/sboehler/knut/cmd/flags" 25 | "github.com/sboehler/knut/lib/common/predicate" 26 | "github.com/sboehler/knut/lib/common/table" 27 | "github.com/sboehler/knut/lib/journal" 28 | "github.com/sboehler/knut/lib/journal/check" 29 | "github.com/sboehler/knut/lib/journal/performance" 30 | "github.com/sboehler/knut/lib/model" 31 | "github.com/sboehler/knut/lib/model/registry" 32 | "github.com/sboehler/knut/lib/reports/weights" 33 | ) 34 | 35 | // CreateWeightsCommand creates the command. 36 | func CreateWeightsCommand() *cobra.Command { 37 | 38 | var r weightsRunner 39 | // Cmd is the balance command. 40 | c := &cobra.Command{ 41 | Use: "weights", 42 | Short: "compute portfolio weights", 43 | Long: `Compute portfolio weights.`, 44 | 45 | Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), 46 | 47 | Run: r.run, 48 | } 49 | r.setupFlags(c) 50 | return c 51 | } 52 | 53 | type weightsRunner struct { 54 | flags.Multiperiod 55 | 56 | valuation flags.CommodityFlag 57 | accounts, commodities flags.RegexFlag 58 | 59 | // formatting 60 | thousands bool 61 | color bool 62 | digits int32 63 | 64 | mapping flags.MappingFlag 65 | sortAlphabetically bool 66 | 67 | universe string 68 | 69 | csv bool 70 | } 71 | 72 | func (r *weightsRunner) setupFlags(cmd *cobra.Command) { 73 | r.Multiperiod.Setup(cmd) 74 | cmd.Flags().StringVarP(&r.universe, "universe", "", "", "universe file") 75 | cmd.Flags().VarP(&r.valuation, "val", "v", "valuate in the given commodity") 76 | cmd.Flags().Var(&r.accounts, "account", "filter accounts with a regex") 77 | cmd.Flags().Var(&r.commodities, "commodity", "filter commodities with a regex") 78 | 79 | cmd.Flags().BoolVarP(&r.sortAlphabetically, "sort", "a", false, "Sort accounts alphabetically") 80 | cmd.Flags().BoolVar(&r.csv, "csv", false, "render csv") 81 | cmd.Flags().VarP(&r.mapping, "map", "m", ",") 82 | cmd.Flags().Int32Var(&r.digits, "digits", 0, "round to number of digits") 83 | cmd.Flags().BoolVarP(&r.thousands, "thousands", "k", false, "show numbers in units of 1000") 84 | cmd.Flags().BoolVar(&r.color, "color", true, "print output in color") 85 | 86 | } 87 | 88 | func (r *weightsRunner) run(cmd *cobra.Command, args []string) { 89 | if err := r.execute(cmd, args); err != nil { 90 | fmt.Fprintln(cmd.ErrOrStderr(), err) 91 | os.Exit(1) 92 | } 93 | } 94 | 95 | func (r *weightsRunner) execute(cmd *cobra.Command, args []string) error { 96 | ctx := cmd.Context() 97 | reg := registry.New() 98 | var universe performance.Universe 99 | if len(r.universe) > 0 { 100 | var err error 101 | universe, err = performance.LoadUniverseFromFile(reg.Commodities(), r.universe) 102 | if err != nil { 103 | return err 104 | } 105 | } 106 | valuation, err := r.valuation.Value(reg) 107 | if err != nil { 108 | return err 109 | } 110 | j, err := journal.FromPath(ctx, reg, args[0]) 111 | if err != nil { 112 | return err 113 | } 114 | partition := r.Multiperiod.Partition(j.Period()) 115 | calculator := &performance.Calculator{ 116 | Context: reg, 117 | Valuation: valuation, 118 | AccountFilter: predicate.ByName[*model.Account](r.accounts.Regex()), 119 | CommodityFilter: predicate.ByName[*model.Commodity](r.commodities.Regex()), 120 | } 121 | j.Days(partition.EndDates()) 122 | rep := weights.NewReport() 123 | err = j.Build().Process( 124 | journal.ComputePrices(valuation), 125 | check.Check(), 126 | journal.Valuate(reg, valuation), 127 | calculator.ComputeValues(), 128 | weights.Query{ 129 | Universe: universe, 130 | Partition: partition, 131 | Mapping: r.mapping.Value(), 132 | }.Execute(j, rep), 133 | ) 134 | if err != nil { 135 | return err 136 | } 137 | reportRenderer := weights.Renderer{ 138 | SortAlphabetically: r.sortAlphabetically, 139 | } 140 | var tableRenderer Renderer 141 | if r.csv { 142 | tableRenderer = &table.CSVRenderer{} 143 | } else { 144 | tableRenderer = &table.TextRenderer{ 145 | Color: r.color, 146 | Round: r.digits, 147 | } 148 | } 149 | out := bufio.NewWriter(cmd.OutOrStdout()) 150 | defer out.Flush() 151 | return tableRenderer.Render(reportRenderer.Render(rep), out) 152 | } 153 | 154 | type Renderer interface { 155 | Render(*table.Table, io.Writer) error 156 | } 157 | -------------------------------------------------------------------------------- /lib/journal/printer/printer.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package printer 16 | 17 | import ( 18 | "fmt" 19 | "io" 20 | "strings" 21 | "unicode/utf8" 22 | 23 | "github.com/sboehler/knut/lib/model" 24 | ) 25 | 26 | // Printer prints directives. 27 | type Printer struct { 28 | writer io.Writer 29 | padding int 30 | count int 31 | } 32 | 33 | // New creates a new Printer. 34 | func New(w io.Writer) *Printer { 35 | return &Printer{writer: w} 36 | } 37 | 38 | func (p *Printer) Write(bs []byte) (int, error) { 39 | n, err := p.writer.Write(bs) 40 | p.count += n 41 | return n, err 42 | } 43 | 44 | // PrintDirective prints a directive to the given Writer. 45 | func (p *Printer) PrintDirective(directive model.Directive) (n int, err error) { 46 | switch d := directive.(type) { 47 | case *model.Transaction: 48 | return p.printTransaction(d) 49 | case *model.Open: 50 | return p.printOpen(d) 51 | case *model.Close: 52 | return p.printClose(d) 53 | case *model.Assertion: 54 | return p.printAssertion(d) 55 | case *model.Price: 56 | return p.printPrice(d) 57 | } 58 | return 0, fmt.Errorf("unknown directive: %v", directive) 59 | } 60 | 61 | // PrintDirectiveLn prints a directive to the given Writer, followed by a newline. 62 | func (p *Printer) PrintDirectiveLn(d model.Directive) (n int, err error) { 63 | start := p.count 64 | if _, err := p.PrintDirective(d); err != nil { 65 | return p.count - start, err 66 | } 67 | _, err = io.WriteString(p, "\n") 68 | return p.count - start, err 69 | } 70 | 71 | func (p *Printer) printTransaction(t *model.Transaction) (n int, err error) { 72 | start := p.count 73 | if t.Targets != nil { 74 | var s []string 75 | for _, t := range t.Targets { 76 | s = append(s, t.Name()) 77 | } 78 | if _, err := fmt.Fprintf(p, "@performance(%s)\n", strings.Join(s, ",")); err != nil { 79 | return p.count - start, err 80 | } 81 | } 82 | if _, err := fmt.Fprintf(p, "%s \"%s\"", t.Date.Format("2006-01-02"), t.Description); err != nil { 83 | return p.count - start, err 84 | } 85 | if _, err := io.WriteString(p, "\n"); err != nil { 86 | return p.count - start, err 87 | } 88 | for i, po := range t.Postings { 89 | if i%2 == 0 { 90 | continue 91 | } 92 | if _, err := p.printPosting(po); err != nil { 93 | return p.count - start, err 94 | } 95 | if _, err := io.WriteString(p, "\n"); err != nil { 96 | return p.count - start, err 97 | } 98 | } 99 | return p.count - start, nil 100 | } 101 | 102 | func (p *Printer) printPosting(t *model.Posting) (int, error) { 103 | return fmt.Fprintf(p, "%-*s %-*s %10s %s", p.padding, t.Other.String(), p.padding, t.Account.String(), t.Quantity.String(), t.Commodity.Name()) 104 | } 105 | 106 | func (p *Printer) printOpen(o *model.Open) (int, error) { 107 | return fmt.Fprintf(p, "%s open %s", o.Date.Format("2006-01-02"), o.Account) 108 | } 109 | 110 | func (p *Printer) printClose(c *model.Close) (int, error) { 111 | return fmt.Fprintf(p, "%s close %s", c.Date.Format("2006-01-02"), c.Account) 112 | } 113 | 114 | func (p *Printer) printPrice(pr *model.Price) (int, error) { 115 | return fmt.Fprintf(p, "%s price %s %s %s", pr.Date.Format("2006-01-02"), pr.Commodity.Name(), pr.Price, pr.Target.Name()) 116 | } 117 | 118 | func (p *Printer) printAssertion(a *model.Assertion) (int, error) { 119 | start := p.count 120 | if _, err := fmt.Fprintf(p, "%s balance", a.Date.Format("2006-01-02")); err != nil { 121 | return p.count - start, err 122 | } 123 | if len(a.Balances) == 1 { 124 | if _, err := fmt.Fprintf(p, " %s %s %s", a.Balances[0].Account, a.Balances[0].Quantity, a.Balances[0].Commodity.Name()); err != nil { 125 | return p.count - start, err 126 | } 127 | } else { 128 | for _, bal := range a.Balances { 129 | if _, err := fmt.Fprintf(p, "\n%s %s %s", bal.Account, bal.Quantity, bal.Commodity.Name()); err != nil { 130 | return p.count - start, err 131 | } 132 | } 133 | } 134 | return p.count - start, nil 135 | } 136 | 137 | // Initialize initializes the padding of this printer. 138 | func (p *Printer) Initialize(directive []model.Directive) { 139 | for _, d := range directive { 140 | switch t := d.(type) { 141 | case *model.Transaction: 142 | p.UpdatePadding(t) 143 | } 144 | } 145 | } 146 | 147 | func (p *Printer) UpdatePadding(t *model.Transaction) { 148 | for _, pt := range t.Postings { 149 | cr, dr := utf8.RuneCountInString(pt.Account.String()), utf8.RuneCountInString(pt.Other.String()) 150 | if p.padding < cr { 151 | p.padding = cr 152 | } 153 | if p.padding < dr { 154 | p.padding = dr 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /lib/model/account/registry.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package account 16 | 17 | import ( 18 | "fmt" 19 | "strings" 20 | "sync" 21 | "unicode" 22 | 23 | "github.com/sboehler/knut/lib/common/multimap" 24 | "github.com/sboehler/knut/lib/syntax" 25 | ) 26 | 27 | // Registry is a thread-safe collection of accounts. 28 | type Registry struct { 29 | mutex sync.RWMutex 30 | index map[string]*Account 31 | accounts *multimap.Node[*Account] 32 | swaps map[*Account]*Account 33 | } 34 | 35 | // NewRegistry creates a new thread-safe collection of accounts. 36 | func NewRegistry() *Registry { 37 | reg := &Registry{ 38 | accounts: multimap.New[*Account](""), 39 | index: make(map[string]*Account), 40 | swaps: make(map[*Account]*Account), 41 | } 42 | for _, t := range types { 43 | reg.Get(t.String()) 44 | } 45 | 46 | return reg 47 | } 48 | 49 | // Get returns an account. 50 | func (as *Registry) Get(name string) (*Account, error) { 51 | as.mutex.RLock() 52 | res, ok := as.index[name] 53 | as.mutex.RUnlock() 54 | if ok { 55 | return res, nil 56 | } 57 | return as.getOrCreatePath(strings.Split(name, ":")) 58 | } 59 | 60 | // Get returns an account. 61 | func (as *Registry) GetPath(segments []string) (*Account, error) { 62 | as.mutex.RLock() 63 | res, ok := as.accounts.GetPath(segments) 64 | as.mutex.RUnlock() 65 | if ok { 66 | return res.Value, nil 67 | } 68 | return as.getOrCreatePath(segments) 69 | } 70 | 71 | func (as *Registry) getOrCreatePath(segments []string) (*Account, error) { 72 | as.mutex.Lock() 73 | defer as.mutex.Unlock() 74 | if res, ok := as.accounts.GetPath(segments); ok { 75 | return res.Value, nil 76 | } 77 | if len(segments) == 0 { 78 | return nil, fmt.Errorf("invalid account: %s", segments) 79 | } 80 | head, tail := segments[0], segments[1:] 81 | accountType, ok := types[head] 82 | if !ok { 83 | return nil, fmt.Errorf("account %s has an invalid account type %s", segments, head) 84 | } 85 | for _, s := range tail { 86 | if !isValidSegment(s) { 87 | return nil, fmt.Errorf("account %s has an invalid segment %q", segments, s) 88 | } 89 | } 90 | current := as.accounts 91 | for i, segment := range segments { 92 | if ch, ok := current.Get(segment); ok { 93 | current = ch 94 | continue 95 | } 96 | var err error 97 | if current, err = current.Create(segment); err != nil { 98 | return nil, err 99 | } 100 | name := strings.Join(segments[:i+1], ":") 101 | current.Value = &Account{ 102 | accountType: accountType, 103 | name: name, 104 | segments: strings.Split(name, ":"), 105 | } 106 | as.index[name] = current.Value 107 | } 108 | return current.Value, nil 109 | } 110 | 111 | func (as *Registry) MustGet(name string) *Account { 112 | a, err := as.Get(name) 113 | if err != nil { 114 | panic(err) 115 | } 116 | return a 117 | } 118 | 119 | func (as *Registry) MustGetPath(ss []string) *Account { 120 | res, err := as.GetPath(ss) 121 | if err != nil { 122 | panic(fmt.Sprintf("account %s not found: %v", ss, err)) 123 | } 124 | return res 125 | } 126 | 127 | func (as *Registry) Create(a syntax.Account) (*Account, error) { 128 | return as.Get(a.Extract()) 129 | } 130 | 131 | func isValidSegment(s string) bool { 132 | if len(s) == 0 { 133 | return false 134 | } 135 | for _, c := range s { 136 | if unicode.IsLetter(c) { 137 | continue 138 | } 139 | if unicode.IsDigit(c) { 140 | continue 141 | } 142 | return false 143 | } 144 | return true 145 | } 146 | 147 | func (as *Registry) SwapType(a *Account) *Account { 148 | as.mutex.RLock() 149 | sw, ok := as.swaps[a] 150 | as.mutex.RUnlock() 151 | if ok { 152 | return sw 153 | } 154 | n := a.name 155 | switch a.Type() { 156 | case ASSETS: 157 | n = LIABILITIES.String() + strings.TrimPrefix(n, ASSETS.String()) 158 | case LIABILITIES: 159 | n = ASSETS.String() + strings.TrimPrefix(n, LIABILITIES.String()) 160 | case INCOME: 161 | n = EXPENSES.String() + strings.TrimPrefix(n, INCOME.String()) 162 | case EXPENSES: 163 | n = INCOME.String() + strings.TrimPrefix(n, EXPENSES.String()) 164 | } 165 | sw, err := as.Get(n) 166 | if err != nil { 167 | panic(err) 168 | } 169 | as.mutex.Lock() 170 | defer as.mutex.Unlock() 171 | as.swaps[a] = sw 172 | return sw 173 | } 174 | 175 | // TBDAccount returns the TBD account. 176 | func (as *Registry) TBDAccount() *Account { 177 | return as.MustGet("Expenses:TBD") 178 | } 179 | 180 | // ValuationAccountFor returns the valuation account which corresponds to 181 | // the given Asset or Liability account. 182 | func (as *Registry) ValuationAccountFor(a *Account) *Account { 183 | segments := append(as.MustGet("Income").Segments(), a.Segments()[1:]...) 184 | return as.MustGet(strings.Join(segments, ":")) 185 | } 186 | -------------------------------------------------------------------------------- /lib/common/date/date_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Silvio Böhler 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package date 16 | 17 | import ( 18 | "fmt" 19 | "testing" 20 | "time" 21 | 22 | "github.com/google/go-cmp/cmp" 23 | ) 24 | 25 | func TestStartOf(t *testing.T) { 26 | tests := []struct { 27 | date time.Time 28 | result map[Interval]time.Time 29 | }{ 30 | { 31 | date: Date(2020, 1, 1), 32 | result: map[Interval]time.Time{ 33 | Weekly: Date(2019, 12, 30), 34 | Monthly: Date(2020, 1, 1), 35 | Quarterly: Date(2020, 1, 1), 36 | }, 37 | }, 38 | { 39 | date: Date(2020, 1, 31), 40 | result: map[Interval]time.Time{ 41 | Weekly: Date(2020, 1, 27), 42 | Monthly: Date(2020, 1, 1), 43 | Quarterly: Date(2020, 1, 1), 44 | }, 45 | }, 46 | { 47 | date: Date(2020, 2, 1), 48 | result: map[Interval]time.Time{ 49 | Weekly: Date(2020, 1, 27), 50 | Monthly: Date(2020, 2, 1), 51 | Quarterly: Date(2020, 1, 1), 52 | }, 53 | }, 54 | { 55 | date: Date(2020, 6, 1), 56 | result: map[Interval]time.Time{ 57 | Quarterly: Date(2020, 4, 1), 58 | }, 59 | }, 60 | { 61 | date: Date(2020, 12, 3), 62 | result: map[Interval]time.Time{ 63 | Quarterly: Date(2020, 10, 1), 64 | }, 65 | }, 66 | } 67 | 68 | for _, test := range tests { 69 | for interval, result := range test.result { 70 | if got := StartOf(test.date, interval); got != result { 71 | t.Errorf("StartOf(%v, %v): Got %v, wanted %v", test.date, interval, got, result) 72 | } 73 | } 74 | } 75 | } 76 | 77 | func TestEndOf(t *testing.T) { 78 | tests := []struct { 79 | date time.Time 80 | result map[Interval]time.Time 81 | }{ 82 | { 83 | date: Date(2020, 1, 1), 84 | result: map[Interval]time.Time{ 85 | Weekly: Date(2020, 1, 5), 86 | Monthly: Date(2020, 1, 31), 87 | Quarterly: Date(2020, 3, 31), 88 | }, 89 | }, 90 | { 91 | date: Date(2020, 1, 31), 92 | result: map[Interval]time.Time{ 93 | Weekly: Date(2020, 2, 2), 94 | Monthly: Date(2020, 1, 31), 95 | Quarterly: Date(2020, 3, 31), 96 | }, 97 | }, 98 | { 99 | date: Date(2020, 2, 1), 100 | result: map[Interval]time.Time{ 101 | Weekly: Date(2020, 2, 2), 102 | Monthly: Date(2020, 2, 29), 103 | Quarterly: Date(2020, 3, 31), 104 | }, 105 | }, 106 | { 107 | date: Date(2020, 6, 1), 108 | result: map[Interval]time.Time{ 109 | Quarterly: Date(2020, 6, 30), 110 | }, 111 | }, 112 | { 113 | date: Date(2020, 12, 31), 114 | result: map[Interval]time.Time{ 115 | Quarterly: Date(2020, 12, 31), 116 | }, 117 | }, 118 | } 119 | 120 | for _, test := range tests { 121 | for interval, result := range test.result { 122 | if got := EndOf(test.date, interval); got != result { 123 | t.Errorf("EndOf(%v, %v): Got %v, wanted %v", test.date, interval, got, result) 124 | } 125 | } 126 | } 127 | } 128 | 129 | func TestPartitionEndDates(t *testing.T) { 130 | tests := []struct { 131 | period Period 132 | interval Interval 133 | result []time.Time 134 | }{ 135 | { 136 | period: Period{Start: Date(2020, 5, 19), End: Date(2020, 5, 22)}, 137 | interval: Once, 138 | result: []time.Time{Date(2020, 5, 22)}, 139 | }, 140 | { 141 | period: Period{Start: Date(2020, 5, 19), End: Date(2020, 5, 22)}, 142 | interval: Daily, 143 | result: []time.Time{ 144 | Date(2020, 5, 19), 145 | Date(2020, 5, 20), 146 | Date(2020, 5, 21), 147 | Date(2020, 5, 22), 148 | }, 149 | }, 150 | { 151 | period: Period{Start: Date(2020, 1, 1), End: Date(2020, 1, 31)}, 152 | interval: Weekly, 153 | result: []time.Time{ 154 | Date(2020, 1, 5), 155 | Date(2020, 1, 12), 156 | Date(2020, 1, 19), 157 | Date(2020, 1, 26), 158 | Date(2020, 1, 31), 159 | }, 160 | }, 161 | { 162 | period: Period{Start: Date(2019, 12, 31), End: Date(2020, 1, 31)}, 163 | interval: Monthly, 164 | result: []time.Time{ 165 | Date(2019, 12, 31), 166 | Date(2020, 1, 31), 167 | }, 168 | }, 169 | { 170 | period: Period{Start: Date(2020, 1, 1), End: Date(2020, 1, 31)}, 171 | interval: Monthly, 172 | result: []time.Time{Date(2020, 1, 31)}, 173 | }, 174 | { 175 | period: Period{Start: Date(2017, 4, 1), End: Date(2019, 3, 3)}, 176 | interval: Yearly, 177 | result: []time.Time{ 178 | Date(2017, 12, 31), 179 | Date(2018, 12, 31), 180 | Date(2019, 3, 3), 181 | }, 182 | }, 183 | } 184 | 185 | for i, test := range tests { 186 | t.Run(fmt.Sprintf("test %d", i), func(t *testing.T) { 187 | part := NewPartition(test.period, test.interval, 0) 188 | 189 | got := part.EndDates() 190 | 191 | if diff := cmp.Diff(test.result, got); diff != "" { 192 | t.Fatalf("Periods(%v, %v): unexpected diff (+got/-want):\n%s", test.period, test.interval, diff) 193 | } 194 | }) 195 | } 196 | } 197 | --------------------------------------------------------------------------------