Rückblickend (Linux-Magazin, Juni 2024)

Hernach ist man immer schlauer, heißt es im Volksmund, und das trifft auch auf den Börsenhandel zu. Anhand des Auf und Abs in der historischen Entwicklung einer Aktie scheint es sonnenklar, zu welchen Zeitpunkten der Investor das Wertpapier hätte kaufen oder verkaufen sollen, um Aufschwünge mitzunehmen und satte Gewinne einzustreichen.

Viel schwieriger ist es jedoch, erfolgreich mit Aktien zu spekulieren, deren Kursentwicklung aktuell noch nicht bekannt ist. Schon der Chemiefuchs Niels Bohr hat ja bekanntlich einst gewitzelt, dass Prognosen allgemein schwierig sind, aber besonders solche, die die Zukunft betreffen.

Narren des Zufalls

Kürzlich stolperte ich über das Buch "Fooled by Randomness" ([2]) und fand dort die Theorie, dass die meisten erfolgreichen Aktienspekulanten einfach nur Glück hatten, was für Otto Normalverbraucher zwar unwahrscheinlich klingt, aber wegen dem winzig kleinen Anteil tatsächlich erfolgreicher Spekulatanten durchaus vertretbar ist. Wie dem auch sei, ein Abschnitt aus dem Buch ließ mich aufhorchen: Autor Nicholas Taleb erwähnt dort, dass er eine Softwarefirma beauftragt hätte, ihm einen sogenannten "Backtester" zu schreiben, ein Programm, das die historischen Kursdaten interessanter Aktien kennt, und als Algorithmus verpackte Handelsstrategien daraufhin prüft, ob sie historisch erfolgreich gewesen wären.

Abbildung 1: Historische Entwicklung der Netflix-Aktie

Abbildung 2: Eine SQLite-Datenbank speichert die historischen Aktienkurse

Wie schwer wäre es wohl, so etwas als Go-Programm zu schreiben? Als Beispiel soll die Preisentwicklung der Netflix-Aktie über die letzten zwei Jahre dienen (Abbildung 1). Nach einem gewaltigen Kurseinbruch von 600 herunter auf 160 Dollar im April 2022 schraubte sie sich langsam wieder nach oben, und ist heute wieder auf dem Originalstand. Ein allwissender Investor hätte im Januar 2022 die Finger von der Aktie gelassen, den Crash im April abgewartet und dann eingekauft, um die Position bis heute zu halten. Der Reingewinn von 500 Dollar pro Aktie oder fast 300% scheint sehr verlockend, aber leider kann kein Spekulant eine Zeitreise in die Vergangenheit antreten, um diese Strategie zu fahren.

Strategie oder Random Walk

Statt dessen glauben viele Börsenspieler an pseudomathematische Voodoo-Methoden wie Chartanalyse, während andere dem Börsenzirkus jegliche Vorhersagbarkeit absprechen und an der Theorie "Random Walk" ([3]) festhalten. Strategien zu entwerfen macht aber Spaß, und mit dem Go-Framework dieser Ausgabe lassen sie sich anhand historischer Daten prüfen, ohne dass dazu ein Leser sein Erspartes aufs Spiel setzen müsste.

Für eine realistische Simulation benötigt das Programm Zugriff auf die Kursdaten einer Aktie über mindestens ein Jahr. Kosten soll das Ganze natürlich nichts, da kommt das Angebot des Anbieters twelvedata.com gerade recht, dessen registrierungspflichtiger aber kostenloser Basic-Plan bis zu acht API-Requests pro Minute erlaubt, bis zu 800 am Tag, bevor das Rate-Limiting einschreitet. Zwei Jahre an Daten einer Aktie zählen nur als ein Request, also reicht das dicke.

Listing 1: bt.go

    01 package main
    02 import (
    03   "flag"
    04   "fmt"
    05   "log"
    06   "database/sql"
    07   _ "github.com/mattn/go-sqlite3"
    08 )
    09 func main() {
    10   update := flag.Bool("update", false, "update quotes in db")
    11   strategy := flag.String("strategy", "hold", "trader strategy")
    12   flag.Parse()
    13   db, err := sql.Open("sqlite3", "./quotes.db")
    14   if err != nil {
    15     log.Fatal(err)
    16   }
    17   defer db.Close()
    18   if *update {
    19     err := updater(db, "nflx")
    20     if err != nil {
    21       log.Fatal(err)
    22     }
    23     return
    24   }
    25   tr := newTrader(*strategy)
    26   err = replay(db, tr.trade)
    27   if err != nil {
    28     log.Fatal(err)
    29   }
    30   if tr.holds { // leftover
    31     tr.sell(tr.prevDt, tr.prevQ)
    32   }
    33   fmt.Printf("Total: %+.2f\n", tr.ledger)
    34 }

Listing 1 zeigt das Hauptprogram bt.go (für Back Tester), das ein Flag --update entgegennimmt, um mit der Funktion updater() aus Listing 2 die Kursdaten einzuholen und sie in einer SQLite-Datenbank für spätere Analysen abzuspeichern (Abbildung 2). Als Beispielaktie gibt Zeile 19 mit "nflx" das Tickersymbol der Firma Netflix an, deren Wertpapier in den letzten Jahren aufregende Schwankungen durchlief und deswegen gut zur Analyse von Algorithmen taugt.

Abbildung 3: Drei Investmentstrategien mit unterschiedlichen Gewinnentwicklungen

Abbildung 3 zeigt das Go-Programm in Aktion, mit drei verschiedene Investmentstrategien im Vergleich. Die erste, "hold" genannt, investiert nach dem Privatanlegern empfohlenen Verfahren "Buy and Hold" und kauft die Aktie am ersten Tag, um sie anschließend bis ans Ende der Zeit im Depot zu halten. Da die Netflix-Aktie wie aus Abbildung 1 ersichtlich im untersuchten Zeitraum ein Tal durchlaufen hat, streicht der Angeleger mit dieser Strategie nur einen kleinen Gewinn ein, nämlich 21.02 Dollar. Besser als nichts oder Verlust!

Börsenspieler

Die zweite Strategie "buydrop" wartet ab, ob die Aktie leicht einsackt (2% vom Vortageskurs) und kauft dann ein, und wartet, bis der Kurs entweder eine Schranke nach oben oder unten durchbricht (plus oder minus 10%) und verkauft dann sofort. Typisches Daytrader-Verhalten, und im Beispiel hat der Börsenspieler Glück und zweigt 142.67 Dollar ab. Allerdings muss der Investor die Aktie im untersuchten Zeitraum dazu 24 mal kaufen und verkaufen, da käme einiges an Gebühren zusammen. Abbildung 4 zeigt in den grünen Bereichen des Graphen die Zeiträume an, in denen sich die Aktie im Depot befindet.

Als drittes Beispiel dient ein eher kurioses Verfahren, das wohl in der Realität nicht zum Einsatz käme, aber zeigt, zu welchen Kapriolen die Simulation in der Lage ist: Sie investiert immer am Monatsanfang und hält die Aktie dann 5 Börsentage (Abbildung 5). Leider kommt allerdings so nur ein Verlust von 224.88 Dollars zustande (unterster Absatz von Abbildung 3).

Abbildung 4: Der Trader kauft bei 2% Kurseinbruch und verkauft bei +-10% Gewinn oder Verlust.

Abbildung 5: Der Trader kauft am Monatsanfang und verkauft 5 Börsentage später.

Bevor es ans Starten der Trade-Strategien geht, holt die Funktion updater() ab Zeile 10 von Listing 2 die Tageskurse seit dem 1.1.2022 in einem Rutsch per API vom Anbieter Twelvedata. Die Datenbanktabelle, die die Tageskurse speichert, packt der SQLite-Engine in die Datei quote.db auf der Festplatte. Den eigentlichen API-Request setzt fetchQ() ab Zeile 39 ab. Zeile 51 fügt den API-Key von Twelvedata hinzu, den's bei der kostenlosen Registrierung gibt, ohne ihn käme eine Fehlermeldung zurück. Im Erfolgsfall schickt der Datenanbieter eine Json-Antwort, aus dem die Library gjson mit den Queries values.#.datetime und values.#.close die Börsentage und die Schlusskurse als Arrays extrahiert.

Wieder zurück in updater() fügt das Exec()-Kommando in Zeile 32 die Schlusskurse für jedes Datum per Insert or Replace in die Datenbanktabelle ein. Letzteres ist praktisch, falls schon ein Eintrag zum Datum existiert, um Duplikate zu vermeiden.

Listing 2: update.go

    01 package main
    02 import (
    03   "database/sql"
    04   "io/ioutil"
    05   "net/http"
    06   "net/url"
    07   _ "github.com/mattn/go-sqlite3"
    08   "github.com/tidwall/gjson"
    09 )
    10 func updater(db *sql.DB, ticker string) error {
    11   createTableSQL := `CREATE TABLE IF NOT EXISTS quotes (
    12         "date" DATE NOT NULL,
    13         "quote" REAL NOT NULL,
    14         UNIQUE(date, quote)
    15       );`
    16   _, err := db.Exec(createTableSQL)
    17   if err != nil {
    18     return err
    19   }
    20   dates, quotes, err := fetchQ(ticker)
    21   if err != nil {
    22     return err
    23   }
    24   insertSQL := `INSERT OR REPLACE INTO quotes(date, quote) VALUES (?, ?)`
    25   statement, err := db.Prepare(insertSQL)
    26   if err != nil {
    27     return err
    28   }
    29   defer statement.Close()
    30   for i, date := range dates {
    31     quote := quotes[i]
    32     _, err := statement.Exec(date.String(), quote.String())
    33     if err != nil {
    34       return err
    35     }
    36   }
    37   return nil
    38 }
    39 func fetchQ(symbols string) ([]gjson.Result, []gjson.Result, error) {
    40   dates := []gjson.Result{}
    41   quotes := []gjson.Result{}
    42   u := url.URL{
    43     Scheme: "https",
    44     Host:   "api.twelvedata.com",
    45     Path:   "time_series",
    46   }
    47   q := u.Query()
    48   q.Set("symbol", symbols)
    49   q.Set("interval", "1day")
    50   q.Set("start_date", "2022-01-01")
    51   q.Set("apikey", "fa99cbec4a071bd770427bb70ee9fda814bf3f8d")
    52   u.RawQuery = q.Encode()
    53   resp, err := http.Get(u.String())
    54   if err != nil {
    55     return dates, quotes, err
    56   }
    57   body, err := ioutil.ReadAll(resp.Body)
    58   if err != nil {
    59     return dates, quotes, err
    60   }
    61   dates = gjson.Get(string(body), "values.#.datetime").Array()
    62   quotes = gjson.Get(string(body), "values.#.close").Array()
    63   return dates, quotes, nil
    64 }

Investoren als Code

Zur Simulation eines Investors, der vorgefertigte Strategien verfolgt, definiert Listing 3 ab Zeile 7 die Struktur trader. Diese zeigt mit der boolschen Variablen holds an, ob der Trader die Position hält oder nicht, zu welchem Preis cost er sie gekauft hat, was der Kurs vom Vortag samt Datum war (prevQ und prevDt), sowie einem Kassenbuch in ledger, das Gewinne und Verluste aus allen vorhergegangenen Transaktionen aufaddiert.

Die Strategie selbst implementiert runStrat(), eine Funktion, die zu jedem Kurstag aufgerufen wird, um zu entscheiden, was zu tun ist: Kaufen, Verkaufen, oder einfach abwarten.

Listing 3: trade.go

    01 package main
    02 import (
    03   "fmt"
    04   "time"
    05 )
    06 type tradeFu func(time.Time, float64)
    07 type trader struct {
    08   holds    bool
    09   cost     float64
    10   prevQ    float64
    11   prevDt   time.Time
    12   ledger   float64
    13   runStrat tradeFu
    14 }
    15 func newTrader(strategy string) *trader {
    16   tr := trader{}
    17   disp := map[string]func() tradeFu{
    18     "hold":      tr.strat_hold,
    19     "buydrop":   tr.strat_buydrop,
    20     "firstweek": tr.strat_firstweek,
    21   }
    22   tr.runStrat = disp[strategy]()
    23   return &tr
    24 }
    25 func (tr *trader) sell(dt time.Time, quote float64) {
    26   tr.holds = false
    27   tr.ledger += quote - tr.cost
    28   fmt.Printf("Selling %s %.2f (%+.2f) total %+.2f\n",
    29     dt.Format("2006-01-02"), quote, quote-tr.cost, tr.ledger)
    30 }
    31 func (tr *trader) buy(dt time.Time, quote float64) {
    32   fmt.Printf("Buying %s %.2f\n", dt.Format("2006-01-02"), quote)
    33   tr.holds = true
    34   tr.cost = quote
    35 }
    36 func (tr *trader) trade(dt time.Time, quote float64) {
    37   tr.runStrat(dt, quote)
    38   tr.prevQ = quote
    39   tr.prevDt = dt
    40 }

Entsprechend agieren die Strategien strat_hold, strat_buydrop und strat_firstweek. Sie nutzen die Hilfsmethoden sell() und buy() ab den Zeilen 25 und 31, um Transaktionen entsprechend ihrer Mission auszuführen. Vor dem Aufruf der jeweiligen Strategiefunktion steht der Wrapper trade() ab Zeile 36, der erst die Strategie aufruft und dann dafür sorgt, den Vortageskurs in die trader-Struktur für spätere Abfragen aufzunehmen.

Welche Strategie zum Einsatz kommt, gibt der User dem Hauptprogramm auf der Kommandozeile mit der Option --strategy als String mit, auf die zugehörige Funktion verweist jeweils die Dispatch-Tabelle disp ab Zeile 17. Dabei handelt es sich um eine Hashtabelle, die Strings Funktions-Pointer zuweist, genauer gesagt Methoden, denn die trader-Struktur tr als Go-typischer Receiver steht vornedran.

Investoren als Code

Die Implementierung einer Strategie besteht jeweils aus einer Funktion, die den Zeitstempel an einem Kurstag sowie dem Schlusskurs der Aktie als float64 als Parameter erhält und damit Entscheidungen trifft und Transaktionen ausführt. Die Signatur dieser Funktion steckt im Typ tradeFu, der vorher in Zeile 6 in Listing 3 gesetzt wurde. Listing 4 implementiert nun die erste "Buy and Hold"-Strategie ab Zeile 5. Dazu gibt die Funktion strat_hold() eine Funktion mit der tradeFu-Signatur zurück, sodass der Börsen-Engine diese wieder und wieder zu jedem Kurstag aufrufen kann. Die Funktion hat über den Receiver-Mechanismus Zugriff auf das trader-Objekt und kann nach Bedarf dessen buy() oder sell()-Funktion aufrufen, je nachdem was am jeweiligen Börsentag der Strategie folgend zu tun ist.

Die Kauf-und-Halte-Strategie von strat_hold() ab Zeile 5 prüft in Zeile 7 lediglich anhand der boolschen Variablen holds, ob die Aktie bereits im Besitz des Investors ist, und kauft sie mit buy() falls das noch nicht der Fall ist. Bei den folgenden Aufrufen der Strategiefunktion ist dann holds wahr und es gibt nichts weiter zu tun an darauffolgenden Börsentagen. Am Ende des Handelszeitraums kommt dann wieder der Börsen-Engine dran, sieht, dass sich die Aktie sich noch im Depot befindet, und verkauft sie zum vorangegangenen Schlusspreis.

Listing 4: strategy.go

    01 package main
    02 import (
    03   "time"
    04 )
    05 func (tr *trader) strat_hold() tradeFu {
    06   return func(dt time.Time, quote float64) {
    07     if !tr.holds {
    08       tr.buy(dt, quote)
    09     }
    10   }
    11 }
    12 func (tr *trader) strat_buydrop() tradeFu {
    13   return func(dt time.Time, quote float64) {
    14     if tr.prevQ != 0 {
    15       if tr.holds {
    16         if quote > 1.1*tr.cost || quote < 0.9*tr.cost {
    17           tr.sell(dt, quote)
    18         }
    19       } else {
    20         if quote < 0.98*tr.prevQ {
    21           tr.buy(dt, quote)
    22         }
    23       }
    24     }
    25   }
    26 }
    27 func (tr *trader) strat_firstweek() tradeFu {
    28   held := 0
    29   return func(dt time.Time, quote float64) {
    30     if tr.holds {
    31       held += 1
    32       if held > 5 {
    33         tr.sell(dt, quote)
    34         held = 0
    35       }
    36     } else {
    37       if dt.Day() < 7 {
    38         tr.buy(dt, quote)
    39       }
    40     }
    41   }
    42 }

Billig kaufen

Die zweite Strategie strat_buydrop() ab Zeile 12 kauft die Aktie, falls ihr Schlusskurs mehr als 2% unter dem Schlusskurs des Vortages (prevQ) liegt. Schlägt der Investor in Zeile 21 mit buy() zu, ist holds beim nächsten Aufruf der Funktion gesetzt und Zeile 16 prüft, ob der noch nicht materialisierte Gewinn (Schlusspreis minus Kaufpreis in tr.cost) entweder 10% positiv oder negativ ist. Ist der aktuelle Schlusspreis außerhalb dieses Rahmens, ruft die Strategie in Zeile 17 die Funktion sell() auf und die Aktie wird zum aktuellen Schlusskurs verkauft.

Closure: Funktion plus Daten

Die dritte Strategie strat_firstweek investiert nur in der ersten Woche des Monats und zeigt, wie die Strategiefunktion sich zwischen zwei Aufrufen seitens des Börsen-Engines Daten merken kann.

Die Strategiefunktion gibt ja an ihren Aufrufer eine Investorfunktion zurück, kann aber vorher in deren Umfeld Variablen definieren, die die Funktion dann mit dem Closure-Mechanismus aus der funktionalen Programmierung mit sich herumschleppt. So definiert die lokale Variable held ab Zeile 28 die Anzahl der Tage, während der die Strategie die Position schon gehalten hat. Anfangs ist dieser Wert 0. Befindet aber Zeile 37 später, dass gerade der erste Börsentag eines Monats gekommen ist, kauft buy() in Zeile 38 die Aktie zum aktuellen Kurs.

Beim nächsten Aufruf der Strategiefunktion bestätigt holds in Zeile 30, dass die Aktie im Depot liegt, und Zeile 31 zählt den Wert der lokalen (und per Closure mitgeschleppten) Variable held um Eins hoch. Bei fünf Tagen im Depot ist die Bedingung held > 5 in Zeile 32 wahr und sell() in Zeile 33 verkauft die Aktie. Heraus kommt bei dem Verfahren, wie aus Abbildung 3 ersichtlich, eine verlustbringende Strategie, aber mit ähnlichen Programmiertricks können Strategien durchaus sinnvolle Zwischenwerte mitschleppen, wie zum Beispiel den Durchschnittswert der Aktie über die vergangene Börsenwoche gemittelt, und darauf basierend ihre hoffentlich gewinnbringenden Entscheidungen fällen.

Börsenmotor als Schleife

Der Antrieb der Simulation, der durch alle Börsentage in einem Zweijahreszeitraum rattert und zu jedem die aktuell aufgewählte Strategiefunktion anspringt, steht in Listing 5. Die Funktion replay() nimmt ein Datenbank-Handle auf die SQLite-Datei entgegen und führt darauf das SQL-Kommando select aus, das alle Zeitstempel und Schlusskurse, aufsteigend (also der Zeit folgend) sortiert ausliest. Für jedes Wertepaar aus Datum und Kurswert ruft es die ihr überreichte Callback-Funktion cb auf, die wiederum gemäß der in Listing 3 definierten Dispatch-Tabelle die passende Strategie- beziehungsweise Trading-Funktion aufruft.

Listing 5: replay.go

    01 package main
    02 import (
    03   "database/sql"
    04   _ "github.com/mattn/go-sqlite3"
    05   "time"
    06 )
    07 func replay(db *sql.DB, cb func(time.Time, float64)) error {
    08   query := `SELECT date, quote FROM quotes ORDER BY date ASC`
    09   rows, err := db.Query(query)
    10   if err != nil {
    11     return err
    12   }
    13   defer rows.Close()
    14   for rows.Next() {
    15     var date string
    16     var quote float64
    17     err := rows.Scan(&date, &quote)
    18     if err != nil {
    19       return err
    20     }
    21     dt, err := time.Parse("2006-01-02T15:04:05Z07:00", date)
    22     if err != nil {
    23       return err
    24     }
    25     cb(dt, quote)
    26   }
    27   if err = rows.Err(); err != nil {
    28     return err
    29   }
    30   return nil
    31 }

Der SQLite-Datenbank-Engine kennt, anders als zum Beispiel MySQL, keinen eigenen Datumstyp und speichert Zeitstempel als Strings ab, die dann die Applikation nach Belieben interpretieren darf. Deshalb ruft Zeile 21 die Funktion time.Parse() aus der Go Standard-Library auf, mit dem in Go üblichen numerischen Format-Platzhaltern, um daraus einen Go-internen Datumstyp time.Time zu machen, mit dem die Applikation dann später einfache Datumsarithmetik ausführen kann, wie zum Beispiel um festzustellen, auf welchen Wochentag ein Zeitstempel gerade fällt.

Listing 6: build.sh

    1 $ go mod init bt
    2 $ go mod tidy
    3 $ go build bt.go update.go \
    4  trade.go strategy.go replay.go
    5 $

Mit diesem Framework und den drei Teststrategien lässt sich nun wie in Listing 6 gezeigt mit dem üblichen Dreisprung die Applikation bt aus den Sourcen und diversen Libraries von Github wie SQLite zusammenbauen. Nun geht es daran, nach den Beispielvorlagen in Listing 4 eine Strategie zu entwerfen, die tatsächlich Gewinn abwirft. Ist dies geschafft, wäre es vielleicht noch ratsam, sie testweise auf die Kursentwicklung einer anderen Aktie anzusetzen, um zu sehen, ob es sich um einen echten Dukatenesel handelt. Wie in der Alchemie mit der Goldherstellung haben sich schon viele an Börsenalgorithmen versucht, und die meisten mussten irgendwann einsehen, dass es ohne Verlustrisiko nicht geht. Experten behaupten, dass die Strategie "Kaufen und Halten", über viele Jahre hinweg praktiziert, doch die beste sei.

Infos

[1]

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

[2]

"Fooled by Randomness: The Hidden Role of Chance in Life and in the Markets", Nassim Nicholas Taleb, Random House, 2005

[3]

"A Random Walk down Wall Street", Burton G. Malkiel, https://www.amazon.com/Random-Walk-Down-Wall-Street-ebook/dp/B00QH9NTSI

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