Bis vor ein paar Jahren zahlten Banken auf Spareinlagen dermaßen niedrigen Zinsen, dass es sich fast nicht rentierte, kurzzeitige Überschüsse überhaupt anzulegen. Nun geben Geldinstitute aber plötzlich 2, 3 oder mit Wertpapieren sogar 5 Prozent Zinsen, und es erscheint frevelhaft, unnötigerweise 5.000 Euro als Puffer auf dem Girokonto zu belassen, denn fest angelegt wüchsen daraus Zinsen, von denen man dauerhaft ein Netflix- und ein Spotify-Abo finanzieren könnte.
|
| Abbildung 1: Zukünftige Buchungen auf dem Girokonto |
Das Problem ist nur, dass der Saldo des Girokontos nie ins Negative abgleiten darf, sonst verlangt die Bank horrende Schuldzinsen oder verweigert eine Buchung gänzlich, und das wäre äußerst uncool. Die Kunst besteht nun darin, zu einem aktuellen Saldo abschätzen zu können, wie lange dieser zukünftigen Abbuchungen standhält, wenn zwischenzeitlich auch wieder Gehalt überwiesen wird. Abbuchungen trudeln oft an festen Tagen im Monat ein. So wird die Miete meist am Monatsende abgebucht, und Kreditkarten (also echte Chargecards, im Unterschied zu Debitkarten) rechnet das Karteninstitut monatlich an einem festen Tag ab.
Mit Einzahlungen und Abbuchungen zu festen Zeiten im Monat geht es also nicht darum, zu gewährleisten, dass alle Ausgaben jederzeit gedeckt sind. Der wahre Zinsmaverick stellt lediglich sicher, dass das Konto zum Zeitpunkt der Abbuchung die gewünschte Geldmenge führen wird. Was derweil in Kreditkarten gepuffert ist, muss erst später gedeckt sein. Abbildung 1 zeigt, dass die Kurve wegen zwischenzeitlicher Geldeingänge stets im Positiven bleibt.
Als Input eines neuen Analysetools dient nun die CSV-Datei in Listing 1, die ähnlich wie vom Bankinstitut heruntergeladene Buchungsdaten Kontobewegungen beschreibt, nur dass diese nicht in der Vergangenheit sondern in der Zukunft liegen. Woher stammen die genauen Beträge? Die Miete ist jeden Monat gleich, beim Strom gilt die Pauschale, und bei Kreditkarten steht normalerweise schon fast einen Monat im Voraus fest, wie hoch der am Ende des Abrechnungszeitraums fällige Betrag sein wird. Übrigens sind alle Beträge in diesem Artikel rein fiktiv, für 994 Dollar könnte man in San Francisco nicht einmal eine Hundehütte mieten.
1 date,amount,comment
2 2025-11-16,4234.43,Start Balance
3 2025-11-29,-994.16,Rent
4 2025-11-24,-1712.39,Visa
5 2025-11-30,1378.50,Income
6 2025-12-03,-1490.15,Amex
7 2025-12-12,-329.96,Mastercard
8 2025-12-15,1378.50,Income
9 2025-12-31,-994.16,Rent
In den Reihen der CSV-Datei steht jeweils das Datum, der Buchungsbetrag (mit Minuszeichen bei Abbuchungen) und ein Kommentarfeld zur Identifizierung. Diese Zeilen in einer Skriptsprache einzulesen wäre nun freilich ein Klacks, doch Go macht wegen seiner strengen Typen Mehrarbeit. Listing 2 zeigt wie's geht und dank des Fremdpakets gocsv von Github bleibt der Code dennoch schön kompakt.
Ein Datum im Format "2026-05-01", also der 1. Mai 2026, steht in der CSV-Datei zum Beispiel als String, da CSV keine Typen kennt. Go möchte hingegen lieber eine Variable vom Typ time.Time, denn damit findet schon mal eine gewisse Werteprüfung statt (den 32. Januar würde das time-Paket sofort zurückweisen) und spätere Datumsberechnungen gehen einfacher von der Hand.
Den Typ Record definiert Zeile 21 als Struktur mit drei Feldern, Date, Amount und Comment. Den Kommentar Comment legt Zeile 24 als string fest, und weist den CSV-Parser mit `csv:"comment"` darauf hin, dass die zugehörige Spalte in der Header-Zeile der CSV-Datei mit comment überschrieben ist.
01 package main
02 import (
03 "os"
04 "slices"
05 "time"
06 "github.com/gocarina/gocsv"
07 "github.com/govalues/decimal"
08 )
09 type Date struct{ time.Time }
10 func (d *Date) UnmarshalCSV(s string) error {
11 t, err := time.Parse("2006-01-02", s)
12 d.Time = t
13 return err
14 }
15 type Decimal struct{ decimal.Decimal }
16 func (d *Decimal) UnmarshalCSV(s string) error {
17 v, err := decimal.Parse(s)
18 d.Decimal = v
19 return err
20 }
21 type Record struct {
22 Date Date `csv:"date"`
23 Amount Decimal `csv:"amount"`
24 Comment string `csv:"comment"`
25 }
26 func FromStdin() ([]Record, error) {
27 var recs []Record
28 if err := gocsv.Unmarshal(os.Stdin, &recs); err != nil {
29 return recs, nil
30 }
31 slices.SortFunc(recs, func(a, b Record) int {
32 return a.Date.Time.Compare(b.Date.Time)
33 })
34 return recs, nil
35 }
Beim Datum und dem Buchungsbetrag Amount wird es schon kniffliger, denn hier muss der CSV-Parser Strings in Go-Typen verwandeln. Wie das geht, definiert die Funktion UnmarshalCSV() auf den jeweiligen Typ, und da Programmierer nicht auf Standardtypen wie time.Time herumorgeln dürfen, wickelt Zeile 9 zum Beispiel letzteren in einen Custom-Typ namens Date. An diesen wiederum hängt Zeile 10 mit UnmarshalCSV() Instruktionen zum Entpacken der CSV-Daten. Der Zeitparser time.Parse() schnappt sich dort mit dem in Go üblichen kuriosen Formatstring "2006-01-02" ein Datum im Format "JJJJ-MM-TT" und speichert es in einer Variablen des Standardtyps time.Time.
01 package main
02 import "fmt"
03 func main() {
04 a := 1000000.01
05 b := 2000000.02
06 c := a + b
07 fmt.Printf("a = %.17f\n", a)
08 fmt.Printf("b = %.17f\n", b)
09 fmt.Printf("a + b = %.17f\n", c)
10 }
Analoges gilt für die Buchungsbeträge in der zweiten Spalte der CSV-Datei. Geldbeträge sollte man ja bekanntlich niemals als Fließkommazahlen darstellen, unabhängig davon, welche Programmiersprache zum Einsatz kommt. Warum? Listing 3 zeigt, was passiert, wenn der Code die Geldbeträge 1000.01 und 2000.02 als in den in Go üblichen Float64-Typen speichert und addiert. Die Ausgabe lautet
a = 1000.00999999999999091
b = 2000.01999999999998181
a + b = 3000.02999999999974534
und überrascht erfahrene Programmierer nicht. Bekanntlich können CPUs krumme Euro und Centbeträge nicht hundertprozentig genau in ihren Fließkommaregistern speichern und wer Finanzdaten damit aufaddiert, vertut sich früher oder später um Centbeträge und rauft sich die Haare. Dies hat nichts mit Go zu tun, auch in Python oder C tritt derselbe Effekt auf. Bekanntlich kann ja selbst Ebay nicht richtig rechnen und verhaut die monatliche Abrechnung regelmäßig um ein paar Pfennige ([2]).
Stattdessen rechnet Finanzsoftware normalerweise mit Centbeträgen in Integer-Variablen, oder zieht ein Paket wie decimal wie in Zeile 7 von Listing 2 zurate. Der Unmarshaler für die Geldbeträge ab Zeile 16 ruft Parse() auf das CSV-Feld auf und liefert eine Variable vom Typ decimal.Decimal zurück, der hundertprozentig genaue Finanzarithmetik beherrscht.
Die Daten in der CSV-Datei müssen nicht unbedingt in der korrekten zeitlichen Reihenfolge stehen, und so sortiert die Funktion SortFunc() in Zeile 31 von Listing 2 die geparsten Ergebnisse, damit spätere Stationen in der Pipeline sich leichter beim Aufsummieren tun. Die Sortierfunktion SortFunc() ist übrigens relativ neu, sie erblickte mit Go 1.21 (August 2023) in dem neuen Paket slices das Licht der Welt. Vorher gab es sort.Slice() mit leicht abweichender Semantik.
01 package main
02 import (
03 "fmt"
04 "github.com/govalues/decimal"
05 )
06 func main() {
07 recs, err := FromStdin()
08 if err != nil {
09 panic(err)
10 }
11 var (
12 prevDate string
13 prevLine string
14 havePrev bool
15 )
16 fmt.Printf("date,amount,comment\n")
17 total, _ := decimal.Parse("0.00")
18 for _, r := range recs {
19 date := r.Date.Format("2006-01-02")
20 total, _ = total.Add(r.Amount.Decimal)
21 if havePrev && date != prevDate {
22 fmt.Print(prevLine)
23 }
24 prevDate = date
25 prevLine = fmt.Sprintf("%s,%s,%s\n", date, total, r.Comment)
26 havePrev = true
27 }
28 if havePrev {
29 fmt.Print(prevLine)
30 }
31 }
Simple Aritmetik mit den Geldbeträgen zeigt nun Listing 4, das die CSV-Daten mit den Buchungen mittels der Funktion FromStdin() aus Listing 2 einliest und ebenfalls CSV-Daten ausgibt, nur mit der laufenden Summe statt der Einzelbuchungen von Listing 1. Beim Aufsummieren der Geldbeträge hilft die Funktion Add() des Pakets decimal. Greift der Code nun mit r.Amount in die Record-Struktur, steht dort ein Feld mit dem Custom-Type Decimal aus Zeile 15 in Listing 2, und der greift mit einem weiteren Decimal in die Wrapper-Struktur hinein, um zum eigentlichen Typ Decimal im Paket decimal zu gelangen. Gos Typsystem ist manchmal schon unglaublich streng, da raucht einem der Kopf!
Am Ende der Pipeline cat data.csv | ./balance wartet nun ein aus Listing 5 kompiliertes Binary, das den Graphen in Abbildung 1 zeichnet. Es nutzt das Project go-echarts auf Github, eine Sammlung gängiger Chartformen, von der Balkengrafik bis zum Tacho und 3D-Plots ist alles dabei. Das Hauptaugenmerk des Projekts liegt auf formschönem Design. Dabei spuckt die Library keine PNG-Dateien aus wie andere Grafikpakete, sondern in eine HTML-Datei verpacktes JavaScript, das einen öffnenden Browser dazu veranlasst, den Graphen zu zeichnen.
Das Hauptprogramm main ab Zeile 47 in Listing 5 liest die auf Stdin hereinpurzelnden Datenhappen des CSV-Parser ein und pumpt sie in den Array-Slice pts, der wiederum Strukturen vom Typ pt enthält, die wiederum die Felder Time (Zeitpunkt der Buchung/ des Saldo), Value (Geldbetrag) und Comment (Beschreibung der Buchung) führen.
01 package main
02 import (
03 "os"
04 "time"
05 "github.com/go-echarts/go-echarts/v2/charts"
06 "github.com/go-echarts/go-echarts/v2/components"
07 "github.com/go-echarts/go-echarts/v2/opts"
08 )
09 type Point struct {
10 Time time.Time
11 Value float64
12 Comment string
13 }
14 func lineChart(pts []Point) *charts.Line {
15 x := make([]string, len(pts))
16 y := make([]opts.LineData, len(pts))
17 for i, p := range pts {
18 x[i] = p.Time.Format("2006-01-02")
19 // Name = label text
20 y[i] = opts.LineData{
21 Name: p.Comment,
22 Value: p.Value,
23 }
24 }
25 chart := charts.NewLine()
26 chart.SetGlobalOptions(
27 charts.WithTitleOpts(opts.Title{
28 Title: "Checking Account",
29 }),
30 charts.WithXAxisOpts(opts.XAxis{
31 Type: "category",
32 }),
33 charts.WithYAxisOpts(opts.YAxis{}),
34 )
35 chart.SetXAxis(x).AddSeries("Balance", y,
36 charts.WithLineChartOpts(opts.LineChart{
37 ShowSymbol: opts.Bool(true),
38 }),
39 charts.WithLabelOpts(opts.Label{
40 Show: opts.Bool(true),
41 Position: "left",
42 Formatter: "\n{b} ", // displays the Name field
43 }),
44 )
45 return chart
46 }
47 func main() {
48 recs, err := FromStdin()
49 if err != nil {
50 panic(err)
51 }
52 pts := []Point{}
53 for _, r := range recs {
54 val, _ := r.Amount.Decimal.Float64()
55 pt := Point{
56 Time: r.Date.Time,
57 Value: val,
58 Comment: r.Comment,
59 }
60 pts = append(pts, pt)
61 }
62 page := components.NewPage()
63 page.AddCharts(lineChart(pts))
64 f, err := os.Create("chart.html")
65 if err != nil {
66 panic(err)
67 }
68 defer f.Close()
69 page.Render(f)
70 }
In das Graphen-Areal der erzeugten HTML-Seite wandert der X/Y-Plot mit AddChart() in Zeile 63, die ihrerseits die Funktion lineChart() ab Zeile 14 heranzieht, um die Feineinstellungen des Graphen, wie Titel und Achsenbeschriftung festzulegen. Heraus kommt schließlich die Datei chart.html, die, in einen Browser geladen, den Graphen nach Abbildung 1 produziert. Dabei arbeitet das verwendete HTML und JavaScript selbständig, nachdem der Browser die Bibliothek einmal vom Github-Server geladen hat.
Zum Bauen des Binaries chart muss wieder der bekannte Go-Dreisprung aus go mod init chart; go mod tidy; go build chart.go ran, damit holt der Go-Compiler alle Abhängigkeiten von Github herein und bündelt allen Code in einem Binary.
Wer statt formschöner Grafik lieber auf Terminal-Ästhetik im Retro-Look steht, den bedient Listing 6 mit der Utility cal, die Zeichenkalender im Stil der Unix-Shell malt. Das Paket go-textcal auf Github beherrscht nicht nur das Zeichnen der Tage und Wochen eines Monats, sondern kann auch einzelne Tage im Ausgabetext farblich markieren und sogar neben bestimmten Wochenzeilen fußnotenartig Kommentare anfügen.
|
| Abbildung 2: Terminal-Kalender mit den Buchungen ... |
|
| Abbildung 3: ... und den resultierenden Saldi. |
Abbildung 2 zeigt die Textausgabe in der Shell für den aktuellen Monat mit den aus der CSV-Datei gelesenen und rechts angefügten Buchungen. Rote Beträge gingen vom Konto ab, grüne markieren Einzahlungen. Dabei korrespondiert eine Fußnote rechts immer mit dem identisch eingefärbten Tag in der Woche links.
01 package main
02 import (
03 "fmt"
04 "github.com/fatih/color"
05 "github.com/govalues/decimal"
06 "github.com/mschilli/go-textcal"
07 "time"
08 )
09 func main() {
10 recs, err := FromStdin()
11 if err != nil {
12 panic(err)
13 }
14 dayMap := map[time.Time]decimal.Decimal{}
15 for _, r := range recs {
16 v := dayMap[r.Date.Time]
17 v, _ = v.Add(r.Amount.Decimal)
18 dayMap[r.Date.Time] = v
19 }
20 start := time.Now()
21 fmt.Print(calMonth(start, dayMap), "\n")
22 fmt.Print(calMonth(start.AddDate(0, 1, 0), dayMap), "\n")
23 fmt.Print(calMonth(start.AddDate(0, 2, 0), dayMap), "\n")
24 }
25 func calMonth(date time.Time, dayMap map[time.Time]decimal.Decimal) string {
26 year, month, _ := date.Date()
27 firstDay := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
28 cal := textcal.New(date)
29 makegreen := cal.ColorFormatter(color.FgGreen, color.Reset)
30 makered := cal.ColorFormatter(color.FgRed, color.Reset)
31 for d := firstDay; d.Month() == month; d = d.AddDate(0, 0, 1) {
32 formatter := makered
33 switch dayMap[d].Sign() {
34 case 0:
35 continue
36 case 1:
37 formatter = makegreen
38 }
39 cal.UseFormatter(d.Day(), formatter)
40 cal.Annotate(d.Day(), formatter(dayMap[d].String()))
41 }
42 return cal.String()
43 }
Listing 6 holt das Kalenderpaket go-textcal von Github ab und muss sich deshalb nicht um die Details bei der Erzeugung der Terminal-Ausgabe kümmern. In der For-Schleife ab Zeile 15 baut es sich eine Hashtabelle auf, die als Schlüssel Datumsstempel und als zugewiesene Werte Record-Strukturen entgegennimmt, die Listing 2 aus den Zeilen der eingehenden Daten im CSV-Format produziert hat.
Da zu einem Datum mehrere Buchungen stattfinden können, holt die For-Schleife in Zeiel 16 erst einmal eventuell schon existierende Tageswert ein, addiert dann mit Add aus dem Paket decimal den neuen Wert hinzu (oder subtrahiert ihn, je nach Vorzeichen) und Zeile 18 weist das Ergebnis wieder dem Eintrag in der Hashtabelle zu. In Skriptsprachen geht dergleichen in einer Zeile, aber Go besteht auf der ausführlichen Version. Eine Abkürzung existiert jedoch: Besteht noch kein Eintrag in der Hashtabelle, liefert Zeile 16 den Nullwert des Typs, im vorliegenden Fall also eine auf Null Geldeinheiten gesetzte Variable vom Typ decimal, die ein folgendes Add() klaglos ausführt.
Die Funktion calMonth() ab Zeile 25 zeichnet für die Textausgabe eines Monatskalenders verantwortlich. Sie erzeugt in Zeile 28 eine neue Kalenderstruktur und iteriert in der For-Schleife ab Zeile 31 durch die Tage des Monats. Die dazu notwendige Kalenderarithmetik (schließlich hat nicht jeder Monat gleichviel Tage) hat Go in time.Time bereits eingebaut. So holt Zeile 26 erstmal Jahr und Monat des angegebenen Datums, dann setzt Zeile 27 den Tageszähler auf 1 und erhält so den ersten des Monats als time.Time-Struktur. Die For-Schleife ab Zeile 31 zählt dann in jedem Durchgang einen Tag weiter, solange, bis sich der Monat im Datum in d.Month() ändert, also das Zählerdatum d im Folgemonat gelandet ist.
Wie die Library bestimmte Tage im Kalender hervorhebt, bestimmt der Aufruf der Funktion UseFormater() in Zeile 39. Der gibt eine Funktion an das Paket textcal durch, welches beim Zeichnen des entsprechenden Tages später den Formatierer aufrufen wird. Dabei handelt es sich um einen der beiden Formatierer makegreen oder makered aus den Zeilen 29 und 30, die einen ihnen übergebenen String mit ANSIColor-Farben einfärben und den so kodierten Text an den Aufrufer zurückgeben.
Die Fußnoten rechts der Kalenderkolumnen hingegen setzt der Aufruf der Funktion Annotate() in Zeile 40. Die eingangs erstellte Hashtabelle dayMap weist ja Kalendertagen im Integer-Format aufsummierte Buchungen des Tages zu und färbt den daraus entstehenden Text mit dem vorher gesetzten formatter rot oder grün ein.
Die eben erläuterte Funktion calMonth ab Zeile 25 zeichnet jeweils einen Monat, und da die Ausgabe in den Abbildungen 2 und 3 jeweils die ersten drei Monate nach dem heutigen Datum anzeigt, rufen die Zeile 21 bis 23 die Funktion dreimal jeweils mit einem neuen Monat als Parameter auf.
Interessanterweise fängt in Amerika die Woche am Sonntag an, und nicht wie in Europa am Montag. Deshalb zeigt auch der Retro-Kalender dieses Format, ganz wie die Unix-Versionen des Tools cal übrigens. Und auch heute zeigt die Manualseite man cal ganz unten immer noch den Hinweis: "It is not possible to display Monday as the first day of the week with cal".
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2026/02/snapshot/
Michael Schilli, "Ladenhüter": Linux-Magazin 06/16, S.86, <U>http://www.linux-magazin.de/Ausgaben/2016/06/Perl-Snapshot<U>