Laut Vizepäsidentin Kamala Harris ginge der Mehrheit der Amerikaner das Geld aus, falls nur 400 Dollar unerwartete Kosten anfielen, und seitdem ist es mir ein Anliegen, jeden Tag darüber Bescheid zu wissen, ob ich noch genügend Reserven auf der hohen Kante habe. Dazu gehört, die aktuelle Kursentwicklung der Aktien bekannter Unternehmer zu verfolgen, und während es zuhauf Apps mit Portfolio-Einstellungen gibt, um eine Reihe ausgewählter Wertpapiere zu beobachten, nutze ich gerne in Go geschriebene Kommandozeilen-Tools, die im Terminal laufen.
Abbildung 1 zeigt die Ausgabe des Go-Programms pofo
(kurz für Portfolio). In insgesamt sechs Kacheln, drei unten und drei oben, veranschaulicht jeweils eine Balkengrafik den Kursverlauf der Aktien der Firmen Apple, Netflix, Meta, Amazon, Tesla und Google über die vergangenen sechs Wochen. Diese aktuellen historischen Kursdaten bezieht das Programm kurz nach dem Aufruf in einem Sekundenbruchteil vom Datendealer "Twelvedata". Der bietet für Bastler einen kostenlosen "Basic Plan" an, der bis zu acht Requests pro Minute (und bis zu 800 am Tag) erlaubt bevor das Rate-Limiting einschreitet.
Abbildung 1: Screenshot des fertigen Go-Programms "pofo" |
Dabei holt das Programm die Schlusskurse aller sechs dargestellten Aktien an den vergangenen 45 Werktagen an der New Yorker Börse in Dollars ein, und zwar alles in einer einzigen Anfrage an den Server, in einem gewaltigen Rutsch. Für die hypernervöse Netflix-Aktie zeigt Abbildung 2 die Kurse für die Zeitspanne zwischen dem 16. Juni und dem 31. Juli 2023, in denen der Wert der Aktie wild zwischen 413.17 und 477.59 schwankt. Würde eine Grafik allerdings diese Absolutwerte darstellen, ließen sich kaum Schwankungen ausmachen, denn schließlich handelt es sich nur um circa 15% des Gesamtwerts.
Abbildung 2: Kursentwicklung der Netflix-Aktie aus Json-Daten |
Die wilden Kursschwünge, die typische Chart-Apps wie in Abbildung 3 zeigen, entsprechen daher nicht den Absolutwerten, denn sonst kämen sehr statische Blöcke heraus, die niemand vom Hocker hauen würden. Stattdessen transformieren typische Charts die Schwankungen in relative Werte, sodass ein paar Dollar gleich die gesamte Charthöhe ausmachen.
Abbildung 3: Die Aktien-App auf dem iphone zeigt keine absoluten Werte an |
Damit die Firma TwelveData die aktuellen Kursdaten ausgewählter Aktien herausrückt, besteht sie darauf, dass Entwickler sich dort mit ihrer Email registrieren. Im Erfolgsfall erhalten sie dann einen API-Key (Abbildung 4), der jedem abgefeuerten Request beiliegen muss. Eine Kreditkarte ist nur für kostenpflichtige Endpunkte erforderlich. Den gesamten amerikanischen Aktienmarkt deckt der kostenlose Testaccount schon ab, wer allerdings noch den deutschen Aktienmarkt befragen möchte, wie zum Beispiel den Stand der Volkswagen-Aktie am Xetra-Markt mit dem Symbol "VOW3:XETR" der muss eine nicht unerhebliche Gebühr berappen. Seit Yahoo seine Finanz-API vor mehr als fünf Jahren eingestampft hat, steht es leider schlecht um kostenlose Zugriffe auf den deutschen Aktienmarkt. Das trifft nicht nur auf Twelvedata zu, sondern auch auf alle anderen von mir untersuchten APIs anderer Anbieter.
Abbildung 4: Kostenloser API-Key für Kursdaten auf twelvedata.com |
Listing 1 zeigt den Go-Code, der die historischen Kurse der in symbols
hinterlegten und kommaseparierten Aktienkürzel vom Kurshändler Twelvedata abholt. Die Funktion fetchQ()
ab Zeile 7 gibt im Erfolgsfall eine Hashmap zurück, die unter dem jeweiligen Tickersymbol (zum Beispiel "aapl" für Apple) einen Array-Slice von dateVal
-Strukturen führt, die jeweils ein Datum (zum Beispiel 10-01-2023) einem Kurs (zum Beispiel 123.45) zuordnen. Dazu baut sie einen Query an die Website von Twelvedata zusammen, der an die Basis-URL die Parameter symbol
für die gewünschten Tickersymbole, interval
für die Zeitabstände (im Beispiel ein Tag) sowie den vorher gegen Registrierung eingeholten API-Key anhängt.
01 package main 02 import ( 03 "io/ioutil" 04 "net/http" 05 "net/url" 06 ) 07 func fetchQ(symbols string) (map[string][]dateVal, error) { 08 u := url.URL{ 09 Scheme: "https", 10 Host: "api.twelvedata.com", 11 Path: "time_series", 12 } 13 q := u.Query() 14 q.Set("symbol", symbols) 15 q.Set("interval", "1day") 16 q.Set("apikey", "a1723ab98fa90ac307a0c5bf332d451c") 17 u.RawQuery = q.Encode() 18 res := map[string][]dateVal{} 19 resp, err := http.Get(u.String()) 20 if err != nil { 21 return res, err 22 } 23 body, err := ioutil.ReadAll(resp.Body) 24 if err != nil { 25 return res, err 26 } 27 return parse(string(body)), nil 28 }
Zeile 19 holt mit Get()
die Antwort vom Netz und die in Zeile 27 aufgerufene Funktion parse()
schnappt sich den zurückkommenden Json-Salat und verpackt das Ganze in die oben erwähnte Go-Datenstruktur einer verschachtelten Hashmap.
Abbildung 5: Json-Salat nach einer Kursabfrage an Twelvedata |
Vom Webserver bekommt die Funktion den Json-Salat in Abbildung 5 zurück. Die Struktur ist recht einleuchtend, einfach ein Dictionary mit den Stockticker-Symbolen, denen jeweils unter "values" ein Array zugewiesen wird, mit Elementen, die jeweils einem Datum einen Kurs der Aktie zu verschiedenen Zeitpunkten zuweisen, von denen uns hier nur close
, also der tägliche Schlusskurs, interessiert. Das Ganze ließe sich nun recht unproblematisch nach der konventionelle Methode anpacken, indem der Go-Code entsprechende Go-Datenstrukturen mit gleicher Schachteltiefe definiert, und die dann der in Go eingebaute Json-Unmarshaler aus Json nach Go importiert.
01 package main 02 import ( 03 "github.com/tidwall/gjson" 04 ) 05 type dateVal struct { 06 date string 07 price string 08 } 09 type qMap map[string][]dateVal 10 func parse(data string) qMap { 11 all := gjson.Get(data, "@this").Map() 12 res := qMap{} 13 for tick, _ := range all { 14 dates := gjson.Get(string(data), tick+".values.#.datetime").Array() 15 closes := gjson.Get(string(data), tick+".values.#.close").Array() 16 series := []dateVal{} 17 for i, date := range dates { 18 series = append(series, 19 dateVal{date: date.String(), price: closes[i].String()}) 20 } 21 res[tick] = series 22 } 23 return res 24 }
Stattdessen wählt Listing 2 das praktische Paket gjson
von Github, das Gos strenge Typprüfung elegant ausschaltet und mit jquery-artigen Anfragen die Nuggets aus dem Json-Erdreich gräbt. Die resultierende Datenstruktur, eine Hashmap mit Einträgen aus Tickersymbolen, die wiederum auf Array-Slices aus Tupeln aus Zeitstempeln und Kurswerten zeigen, definiert Zeile 9 in Listing 2 unter dem Namen qMap
. Die ab Zeile 10 definierte Funktion parse()
gibt eine Variable dieses Datentyps als Ergebnis an den Aufrufer zurück.
Die oberste Ebene der Daten, die Tickersymbole, klappert die Funktion Get()
in Zeile 11 mit dem Query @this
ab und Map()
aus dem gjson
-Paket macht gleich eine Go-Map daraus. Daraufhin kann die for
-Schleife in Zeile 13 über alle Tickersymbole iterieren. Der Query "[symbol].values.#.datetime" fieselt anschließend alle Zeitstempel verfügbarer Datenpunkte heraus und Array()
aus dem gjson
-Paket macht einen Array daraus. Analoges gilt für die unter close
liegenden Schlusskurse. Die for
-Schleife ab Zeile 17 mischt die beiden Arrays wieder zu einem Array von dateVal
-Typen zusammen, und fertig ist der Eintrag für das gerade bearbeitete Tickersymbol.
Nun muss das Hauptprogramm in Listing 3 die Einzelteile aus den Listings 1 und 2 zusammenleimen und die GUI aufsetzen. Als Grafikpaket für's Terminal hält wie schon in früheren Snapshots das unverwüstliche termui
her, aus dem diesmal das Widget BarChart
sowie der Kachel-Arrangierer grid
zum Zug kommen. Wie aus Abbildung 1 ersichtlich, erstrecken sich die insgesamt sechs Kacheln über die gesamte Terminalbreite und -länge und teilen sich den Platz brüderlich auf.
01 package main 02 import ( 03 ui "github.com/gizak/termui/v3" 04 "github.com/gizak/termui/v3/widgets" 05 "strconv" 06 "time" 07 ) 08 func main() { 09 s := "aapl,nflx,meta,amzn,tsla,goog" 10 res, err := fetchQ(s) 11 if err != nil { 12 panic(err) 13 } 14 if err := ui.Init(); err != nil { 15 panic(err) 16 } 17 defer ui.Close() 18 charts := []*widgets.BarChart{} 19 for s, _ := range res { 20 charts = append(charts, mkChart(s, res)) 21 } 22 grid := ui.NewGrid() 23 termWidth, termHeight := ui.TerminalDimensions() 24 grid.SetRect(0, 0, termWidth, termHeight) 25 grid.Set( 26 ui.NewRow(1.0/2, 27 ui.NewCol(1.0/3, charts[0]), 28 ui.NewCol(1.0/3, charts[1]), 29 ui.NewCol(1.0/3, charts[2]), 30 ), 31 ui.NewRow(1.0/2, 32 ui.NewCol(1.0/3, charts[3]), 33 ui.NewCol(1.0/3, charts[4]), 34 ui.NewCol(1.0/3, charts[5]), 35 ), 36 ) 37 ui.Render(grid) 38 <-ui.PollEvents() 39 } 40 func mkChart(symbol string, res qMap) *widgets.BarChart { 41 bc := widgets.NewBarChart() 42 bc.Data = []float64{} 43 bc.Labels = []string{} 44 bc.BarWidth = 1 45 bc.BarGap = 0 46 vals := res[symbol] 47 var min float64 48 for i := len(vals) - 1; i >= 0; i-- { 49 price, err := strconv.ParseFloat(vals[i].price, 64) 50 if err != nil { 51 panic(err) 52 } 53 bc.Data = append(bc.Data, price) 54 if min == 0 || price < min { 55 min = price 56 } 57 bc.Labels = append(bc.Labels, weekday(vals[i].date)) 58 } 59 for i, _ := range bc.Data { 60 bc.Data[i] -= min 61 } 62 bc.NumFormatter = func(f float64) string { 63 return "" 64 } 65 bc.Title = symbol 66 bc.BarWidth = 1 67 bc.BarColors = []ui.Color{ui.ColorRed, ui.ColorGreen} 68 return bc 69 } 70 func weekday(date string) string { 71 dt, _ := time.Parse("2006-01-02", date) 72 return string(dt.Weekday().String()[0]) 73 }
Statt nun zu jedem einzelnen Widget dessen x/y-Koordinaten auszurechnen, vertraut Listing 3 auf den von termui
bereitgestellten Fliesenleger grid
. Dessen Set()
-Funktion in Zeile 25 nimmt zwei Reihen von jeweils drei BarChart
-Widgets entgegen, die alle nacheinander im vorher erzeugten Array charts
liegen. Der Aufruf der Funktion Render()
des termui
-Pakets in Zeile 37 nimmt dann als einzigen Parameter den des grid
-Widgets entgegen und rechnet dann selbständig aus, wo die einzelnen Kacheln zu liegen kommen.
Die vier zu überwachenden FANG-Aktien (Facebook aka Meta, Apple, Netflix und Google), sowie die Tickersymbole von Tesla und Amazon definiert Zeile 9 als kommaseparierten String. Der Aufruf von fetchQ()
aus Listing 1 holt die Daten vom Provider ein und gibt die Map res
zurück. Daraufhin iteriert die for
-Schleife ab Zeile 19 über die Tickersymbole und ruft für jedes die Funktion mkChart()
auf, die ab Zeile 40 in Listing 3 steht.
Ein neues Widget mit einer Balkengrafik erzeugt Zeile 41 gleich zu Anfang von mkChart()
. Mit Daten gefüllt und fertig zur Anzeige gibt es die Funktion am Ende an den Aufrufer zurück. Von Haus aus stellt die Balkengrafik aus dem termui
-Paket die Zeitreihe der Zahlenwerte aus dem Feld Data
dar und schreibt unter die Balken jeweils die in Labels
definierten Strings.
Wegen der dicht gedrängten Balken in den Stock-Charts dürfen die Werte auf der X-Achse allerdings nur einen Buchstaben lang sein, also ermittelt weekday()
ab Zeile 70 jeweils den Wochentag eines Kursdatums, nimmt davon den ersten Buchstaben und propft ihn zur Anzeige in den Array im dem Feld Labels
. Die vom Anbieter verwendeten Zeitstempel im Format 2023-07-31
liest die Funktion Parse()
aus dem Go-Standardpaket time
mit dem Template 2006-01-02
ein, um daraus ein time
-Objekt zu machen, aus dem sich dann der Wochentag ablesen lässt. Erfahrene Snapshot-Leser wissen, dass dies Gos ulkigem Zeitstempel-Parser geschuldet ist, der die Lage von Tag, Monat und Jahr im Template nicht mittels traditioneller Platzhaltern erwartet, sondern die Werte des willkürlich festgelegten Datums des 2. Januars 2006 um 15:04:05 nutzt (Hinweis zur Hausaufgabe: 1, 2, 3, 4, 5, 6).
Weiter zeichnet das BarChart-Widget normalerweise auch noch den y-Wert jedes Balkens in die Grafik, aber das wäre im Gedränge nicht mehr lesbar, also definiert Zeile 62 den verwendeten NumFormatter
als eine Funktion die nur einen Leerstring zurückgibt.
Die Json-Antwort von Twelvedata enthält die Tageskurse absteigend nach dem Datum sortiert, bringt also die neuesten Kurse zuerst. Die Balkengrafik stellt sie allerdings zeitlich aufsteigend dar, also läuft die for
-Schleife ab Zeile 48 rückwärts, um die Werte in den BarChart
-Array Data
anzuhängen.
Zur Relativierung der Kurse, die wie eingangs erwähnt zu interessanteren Fluktuationen in der Anzeige führt, ermittelt die erste For-Schleife den Minimalwert aller Tageswerte in min
und zieht letzteren später in Zeile 60 von allen darzustellenden Werten ab. So stellt die Balkengrafik die Kurse mit y-Werten zwischen dem Minimal- und dem Maximalkurs dar.
Zum Übersetzen und Binden der Go-Sourcen in den Listings 1 bis 3 führt wie immer der Dreisprung
$ go mod init pofo
$ go mod tidy
$ go build pofo.go quote.go parse.go
der ein Binary namens pofo
erzeugt. Ohne Parameter aufgerufen schaltet es das Terminal in den Grafikmodus und nach einem Sekundenbruchteil stehen die Balkengrafen als Kacheln in der Anzeige. Voraussetzung ist eine bestehende Internetverbindung sowie ein gültiger API-Token in Listing 1. Letzterer sollte in Produktionsumgebungen nicht im Listing stehen, sondern in einer externen Konfigurationsdatei und die zu überwachenden Tickersymbole sollte zwecks Bedienungskomfort ebenfalls eine Yaml-Datei ausgelagert werden. Mögen die Kurse steigen!
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2023/10/snapshot/
TwelveData API, https://twelvedata.com/docs#getting-started
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc