Börsengucker (Linux-Magazin, Oktober 2023)

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

Relativ macht's wilder

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

Frei nach Registrierung

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.

Listing 1: quote.go

    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

Wühlen in Json

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.

Listing 2: parse.go

    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.

Fliesenleger

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.

Listing 3: pofo.go

    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.

FANG und mehr

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.

Nächste Fahrt geht rückwärts

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!

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2023/10/snapshot/

[2]

TwelveData API, https://twelvedata.com/docs#getting-started

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc