Egal ob mit Quicken oder Mint, oder mit dem etwas volkstümlicheren (wenngleich gebührenpflichtigen) Ynab ("You Need A Budget"): der Weg zum finanziellen Erfolg steht nur denjenigen offen, die ihre Eingaben und Ausgaben mit Adleraugen im Blick behalten. In [2] habe ich das Thema schon einmal angerissen und eine Terminal-UI aus Go zur Abfrage der aktuellen Pegelstände meiner Geldspeicher vorgestellt. In dieser Ausgabe kommt die Auswertung der Ausgaben zum Zug. Wo und für was habe ich mein sauer verdientes Geld ausgegeben? Ein Mann wie ich muss mit dem Kreuzer rechnen.
|
| Abbildung 1: Ynab erlaubt den Export aller erfassten Daten ... |
|
| Abbildung 2: ... im CSV-Format für einfache Weiterverarbeitung. |
Da sich YNAB kumpelhaft offen gibt, erlaubt es auch den Export aller bislang eingegebenen Daten im CSV-Format (Abbildung 1 und Abbildung 2). Da kommt ganz schön was zusammen, in den gut anderthalb Jahren meiner Ynab-Nutzung sind insgesamt 1976 Buchungen angefallen (Abbildung 3). Ein El Dorado für statistische Auswertungen!
|
| Abbildung 3: Die CSV-Datei mit den in Ynab registrierten Einzelposten |
An der exportierten CSV-Datei beißt sich allerdings Gos CSV-Parser die Zähne aus, warum? Die ersten drei Bytes "EF BB BF" (Abbildung 4) definieren die sogenannte Byte Order Mark (BOM) für UTF8-codierten Text, die tatsächlich die meisten Kommaparser aus der Bahn wirft.
|
| Abbildung 4: Drei Bytes am Anfang der CSV-Datei setzen die Byte Order Mark (BOM) |
Bevor Listing 1 anfängt, die CSV-Daten zeilenweise einzulesen, setzt es mit NewReader() einen neuen CSV-Parser auf, der vorher mittels BOMOverride() in Zeile 14 einen Transformator spendiert bekam. Dieser schluckt die drei Bytes bevor die Karussellfahrt beginnt und nordet den Parser auch gleich auf den nun folgenden UTF8-Text ein.
Da zweitausend Zeilen ein Klacks für moderne Rechner sind, liest ReadAll() in Zeile 16 alle CSV-Zeilen in einem Rutsch ein und gibt einen Array-Slice mit allen Datenreihen zurück, die wiederum als Array-Slices von Strings vorliegen.
01 package main
02 import (
03 "encoding/csv"
04 "golang.org/x/text/encoding/unicode"
05 "golang.org/x/text/transform"
06 "os"
07 )
08 func CSVRows(path string) ([][]string, error) {
09 file, err := os.Open(path)
10 if err != nil {
11 return [][]string{}, err
12 }
13 defer file.Close()
14 utf8bom := unicode.BOMOverride(unicode.UTF8.NewDecoder())
15 r := csv.NewReader(transform.NewReader(file, utf8bom))
16 rows, err := r.ReadAll()
17 return rows, err
18 }
Nun zur Auswertung der Buchungsdaten. Ynab weist jeder Buchung eine Kategorie zu, das sind einmal grobe Einteilungen in "Needs" und "Wants", also Dinge die man braucht, oder Käufe, die man sich gönnt. Dann kommt noch eine Unterkategorie, was explizit erworben wurde, also ist "Needs: Rent", für die Wohnungsmiete, und "Wants: Travel" für Reisen.
Was sind nun die zehn Kategorien, in die das meiste Geld abfließt, und wieviel geht genau ab, beziehungsweise wie stehen sie untereinander im Verhältnis? Listing 2 hangelt sich durch die von Listing 1 eingelesenen Buchungsdaten und summiert sie in Zeile 16 in Map-Einträgen der Variable categorySpend unter dem Namen der jeweiligen Kategorie auf.
Die For-Schleife ab Zeile 13 verwirft die erste Header-Zeile der CSV-Datei mit [1:] und greift anschließend die Spalten 5 und 9 (Index 4 und 8) jeder Zeile ab, in die der Ynab-Exporteur den Namen der Kategorie und den abgeflossenen Geldbetrag abgelegt hat. In letzterer steht eine Fließkommazahl mit führendem Währungssymbol, das Zeile 15 mit [1:] abschneidet, bevor der Zahlenparser ParseFloat sich des numerischen Eintrags annimmt.
01 package main
02 import (
03 "fmt"
04 "sort"
05 "strconv"
06 )
07 func main() {
08 rows, err := CSVRows("register.csv")
09 if err != nil {
10 panic(err)
11 }
12 categorySpend := make(map[string]float64)
13 for _, row := range rows[1:] {
14 category := row[4]
15 outflow, _ := strconv.ParseFloat(row[8][1:], 64)
16 categorySpend[category] += outflow
17 }
18 type kv struct {
19 Key string
20 Value float64
21 }
22 var sorted []kv
23 for k, v := range categorySpend {
24 sorted = append(sorted, kv{k, v})
25 }
26 sort.Slice(sorted, func(i, j int) bool {
27 return sorted[i].Value > sorted[j].Value
28 })
29 n := 10
30 if len(sorted) < 10 {
31 n = len(sorted)
32 }
33 for i := 0; i < n; i++ {
34 fmt.Printf("%s,%.2f\n", sorted[i].Key, sorted[i].Value)
35 }
36 }
Um die Top-10 zu ermitteln, müssen die Map-Einträge in einem Array absteigend nach dem größten Budget sortiert werden. Das geht in Go nicht so elegant wie in mancher Skriptsprache, denn Hashmaps kommen von Haus aus unsortiert daher und nur Arrays lassen sich sortieren. Die dafür in Zeile 18 definierte Struktur kv nimmt den Namen einer Kategorie in Key und den Budgetwert in Value auf. Einen Array dieser Strukturen enthält die in Zeile 22 definierte Variable sorted.
Die Standardfunktion sort.Slice() sortiert dann in Zeile 26 die Einträge absteigend nach dem numerischen Wert in Value. Der als zweites Argument beigefügte Callback nennt sich auf der Manualseite less, gibt also einen wahren Wert zurück, falls die zwei Einträge an den Indexpositionen i und j so im sortierten Array stehen sollen, dass der an Position i vor dem an Position j zu liegen kommt. Die Funktion Slice() sortiert den Array an Ort und Stelle, verschiebt also die Elemente nur im bereits existierenden Array, ohne irgendwo Kopien anzulegen. Nun stehen in sorted die Einträge mit den höchsten Umsätzen zuerst und die Die For-Schleife ab Zeile 33 gibt die ersten zehn Wertepaare zeilenweise auf der Standardausgabe im CSV-Format aus.
Drögen Zahlenreihen fehlt aber der nötige Pizzazz, deswegen macht Listing 3 ein Histogramm mit Balken daraus. Moderne Unix-Terminals zeigen ja gottlob nicht nur ASCII-Text an, sondern beherrschen auch manches Unicode-Zeichen zum Erstellen von Terminal-UI-Grafiken. Abbildung 5 zeigt die Ausgabe der Top-10 geldverschlingenen Kategorien im Terminal, aufgemotzt mit farbigen Balken und Emojis. Das dazu aufgerufene Kommando bycat ist das Binary, das aus den Listings 2 und 1 mit go build bycat.go csv.go generiert wurde, und seine Ausgabe fließt durch die getippte Pipe ins Programm histo, das die gezeigte Grafik generiert.
|
| Abbildung 5: Nach Kategorien aufgespaltete Ausgaben |
Listing 3 zeigt das dazugehörige Hauptprogramm, das zunächst die als CSV hereinkommenden (String, Float)-Wertepaare zeilenweise einliest und berechnet wieviel Platz der jeweils breiteste Eintrag in den beiden Spalten ist. Schließlich muss jeder Eintrag später in der Ausgabe entsprechend seiner tatsächlichen Länge mit Leerzeichen aufgefüllt werden, damit die Tabellenspalten ordentlich aussehen.
Die Funktion fmt.Printf() in Zeile 38 reserviert unter Zuhilfenahme der weiter unten definierten Funktion pad() entsprechend Platz, indem sie kürzere Einträge mit Leerzeichen längt. Zeile 47 nutzt dazu eine nicht allzu bekannte Funktion der Platzhalter von Printf() aus dem Standardpaket fmt. Jeder kennt "%20s" oder "%-20s", um einen String rechtsbündig oder linksbündig in einen 20 Zeichen breiten Behälter einzufplanzen und den Rest mit Leerzeichen aufzufüllen.
Aber was ist zu tun, falls die Länge des Behälters nicht statisch feststeht und somit nicht hartkodiert im Formatstring stehen kann? Der Trick ist hier, als Platzhalter %*s im Formatstring anzugeben und Printf() statt einem Parameter später zwei mitzugeben: Erst die dynamische Länge als Integer-Variable, dann den zu formatierenden String. Wenn also Zeile 47 in Listing 3 "%*s" im Formatstring stehen hat und Sprintf() später den numerischen Wert padding und den Nullstring (!) "" mitgibt, macht die Funktion daraus einen Leerstring mit der in padding festgelegten Anzahl von Leerzeichen. Raffiniert!
01 package main
02 import (
03 "encoding/csv"
04 "fmt"
05 "github.com/mattn/go-runewidth"
06 "os"
07 "strconv"
08 "strings"
09 )
10 func main() {
11 reader := csv.NewReader(os.Stdin)
12 records, err := reader.ReadAll()
13 if err != nil {
14 panic(err)
15 }
16 maxVal := 0.0
17 maxKeyLen := 0
18 sum := 0.0
19 for _, rec := range records {
20 key := rec[0]
21 val, _ := strconv.ParseFloat(rec[1], 64)
22 if len(key) > maxKeyLen {
23 maxKeyLen = len(key)
24 }
25 if val > maxVal {
26 maxVal = val
27 }
28 sum += val
29 }
30 if maxVal == 0 {
31 maxVal = 1
32 }
33 for _, rec := range records {
34 key := rec[0]
35 val, _ := strconv.ParseFloat(rec[1], 64)
36 width := int(val * 30 / maxVal)
37 bar := strings.Repeat("\033[34m\u2593\033[0m", width)
38 fmt.Printf("%s | %s %.1f%%\n", pad(key, maxKeyLen), bar, val*100.0/sum)
39 }
40 }
41 func pad(s string, width int) string {
42 dispw := runewidth.StringWidth(s)
43 padding := width - dispw
44 if padding < 0 {
45 padding = 0
46 }
47 return fmt.Sprintf("%s%*s", s, padding, "")
48 }
Nun kapriziert sich Ynab darauf, in die Kategorietexte Emojis hineinzupflanzen, also ein blaues Spielzeugauto für Kfz-Wartung oder ein Häuschen für die Miete. Das sieht lustig aus, wirft aber den Formatierer der Funktion Printf() des Standardpaket fmt aus der Bahn. Warum?
01 package main
02 import (
03 "fmt"
04 "github.com/mattn/go-runewidth"
05 )
06 func main() {
07 car := "\U0001F697"
08 fmt.Printf("%s\n", car)
09 fmt.Printf("%d chars long.\n", len(car))
10 fmt.Printf("%d display width.\n", runewidth.StringWidth(car))
11 }
|
| Abbildung 6: Falsche und richtige Länge von Strings mit Emojis |
Nehmen wir zum Beispiel das Auto-Emoji das unter dem Unicode-Punkt "\U0001F697" firmiert. Listing 4 legt es im String car ab. Gos eingebaute len()-Funktion behauptet nun in Zeile 9, dass dieser String vier Zeichen breit wäre. Tatsächlich breitet sich das Auto-Emoji in einem Terminal aber über zwei Zeichen aus, was die Ausgabe in Abbildung 6 zeigt. Zu Hilfe kommt nun das Paket runewidth auf Github, dessen Funktion StringWidth() Emojis kennt und die Darstellungsbreite des Strings korrekt als zwei ermittelt.
Zurück zu Listing 3. Die einzelnen Rechtecke, von denen das Histogramm-Programm eine Anzahl aneinandergereiht als Balken bestimmter Länge ausgibt, sind ebenfalls Unicode-Zeichen, im vorliegenden Fall ist dies das "Block"-Zeichen am Code-Punkt "U+2593", das einen "Dark Shade", also ein dunkles kariertes Muster zeigt. Die blaue Farbe kommt in Zeile 37 durch Ansicolor-Escape-Sequenzen in die Balkengrafik.
Wer die Balken blau statt schwarz möchte, kann dies mit den ANSI-Colors des Terminals einstellen. Diese Escape-Sequenzen beeinflussen entweder den Vorder- oder Hintergrund eines dargestellten Zeichens, in der Standardpalette stehen 16 Farben zur Verfügung. Die Umstellung auf eine andere Farbe leitet jeweils ein Escape-Zeichen ein ("\033" im String). Dann folgt ein alphanumerischer Code, zum Beispiel "[34m]" für "blauer Vordergrund" oder "[0m" zum Zurücksetzen auf den normalen Vordergrund. Zeile 37 tut genau dies für jedes einzelne Rechteck in den Balken des Histogramms.
Nach der Monatsmiete für die Wohnung sind Restaurantbesuche ein weiterer kapitalintensiver Posten. Wie wär's mit einer monatlichen Aufstellung der über die letzten anderthalb Jahre in Schlemmerlokalen verpulverten Geldbeträge? Dazu iteriert Listing 5 wieder durch die CSV-Daten des Ynab-Exporters und erstellt eine Hashmap monthSpend, die als Schlüssel einen Monat im Format "YYYY/MM" führt und als Werte die in diesem Monat aufsummierten Restaurantrechnungen.
|
| Abbildung 7: Monatliche Ausgaben beim Auswärtsessen |
Dazu muss Zeile 19 mit time.Parse() das Datum jeder Buchung aus der CSV-Datei auslesen, was mit dem in Go üblichen Formatstring 01/02/2006 (Monat, Tag, Jahr) passiert. Für den Hashmap-Schlüssel macht Format("2006/01") aus der Variablen vom Type time.Time wieder einen String, und zwar im Format "YYYY/MM". So kann Zeile 23 eine Restaurantrechung dem jeweiligen Monat in der Hashmap zuordnen.
Die etwas merkwürdigen Namen der Platzhalter in Gos Time-Parser und -Formatierer basieren auf einem leicht zu merkenden Datum, dem 2. Januar, um 3 Uhr, 4 Minuten und 5 Sekunden, im Jahre 2006. Der Amerikaner stellt in Datumsangaben ja den Monat vor den Tag also ergibt sich "1-2-3-4-5-6". Wer also ein deutsches Datum im Format "TT.MM.JJJJ" einlesen oder ausgeben möchte, würde "02.01.2006" in den Formatstring schreiben.
01 package main
02 import (
03 "fmt"
04 "strconv"
05 "strings"
06 "time"
07 )
08 func main() {
09 rows, err := CSVRows("register.csv")
10 if err != nil {
11 panic(err)
12 }
13 monthSpend := make(map[string]float64)
14 for _, row := range rows[1:] {
15 if !strings.Contains(row[4], "Dining") {
16 continue
17 }
18 outflow, _ := strconv.ParseFloat(row[8][1:], 64)
19 t, err := time.Parse("01/02/2006", row[2])
20 if err != nil {
21 panic(err)
22 }
23 monthSpend[t.Format("2006/01")] += outflow
24 }
25 for k, v := range monthSpend {
26 fmt.Printf("%s,%.2f\n", k, v)
27 }
28 }
Alle Schlüssel im Format "YYYY/MM" lassen sich nun wegen dem vorstehenden Jahr schlauerweise als Strings alphabetisch sortieren, und heraus kommt ganz automatisch die richtige zeitliche Reihenfolge.
Analog zum vorher vorgestellten Analyseprogramm nach Kategorien kompiliert sich das Binary dining aus go build dining.go csv.go, vorausgesetzt es wurden vorher alle Abhängigkeiten mit go mod init ynab; go mod tidy aufgelöst.
Abbildung 7 zeigt den Aufruf des Binaries dining, gefolgt von einem sort-Kommando der Unix-Shell, gefolgt von histo, dem Binary aus Listing 3. Die Ausgabe zeigt eine kurze Restaurantflaute im Sommer 2024, gefolgt von vermehrten Restaurantbesuchen in der kälteren Jahreszeit. Während Covid wäre sicher auch ein Einbruch erkennbar gewesen, aber da hatte ich Ynab noch nicht.
Die zweitausend Buchungen habe ich übrigens natürlich nicht von Hand eingetippt, sondern aus Kreditkartenabrechnungen extrahiert. Ynab bietet zwar an, sich in regelmäßigen Abständen bei Banken- und Kartenwebseiten einzuloggen, wenn man seine Login-Daten hinterlegt, aber das erscheint mir recht leichtsinnig.
Statt dessen habe ich auf [3] ein Go-Projekt hochgeladen, das die CSV-Dateien bekannter Kreditinstitute in Ynab-kompatibles CSV umwandelt, das sich über Ynabs Import-Funktion einlesen lässt. Wer das einmal im Monat macht, dessen Buchungen sind auch auf dem neuesten Stand.
Die beiden vorgestellten Auswertungen sind natürlich erst der Anfang, weitere Ideen wären zum Beispiel ein gestapeltes Balkendiagramm mit den monatlichen Ausgaben, so ließe sich feststellen, ob Ausgaben von einer Kategorie in eine andere abgewandert sind. Ganz so, wie der Wahlonkel im Fernsehen das Ergebnis kommentiert, ließe sich dann bei der monatlichen Familenkonferenz unter dem Motto "Rückblick und Strategiewechsel" ein willkommen geheißener Vortag halten. Wie immer sind der Kreativität keine Grenzen gesetzt, wenn saubere Daten vorliegen und Go im Werkzeuggürtel steckt!
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2025/12/snapshot/
Michael Schilli, "Ohne Moos nix los": Linux-Magazin 02/25, https://www.linux-magazin.de/ausgaben/2025/02/snapshot/
Go-ynabler, Github-Projekt zum Importieren von .csv-Dateien bekannter Kreditinstitute in Ynab, https://github.com/mschilli/go-ynabler