Aus Linux-Magazin 06/2024

Go-Programm analysiert historischen Aktienhandel

© rawpixel / 123RF.com

Ob eine Strategie zum Handeln von Aktien Gewinne oder Verluste einfährt, prüft Mike Schilli anhand historischer Kursdaten mit einem Go-Programm.

Hinterher ist man immer schlauer, sagt der Volksmund, und das trifft auch auf den Börsenhandel zu. Anhand des Auf und Ab 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 man noch nicht kennt. Schon der Chemiefuchs Niels Bohr hat ja bekanntlich einst gewitzelt, dass Prognosen allgemein schwierig seien, besonders solche, die die Zukunft betreffen.

Narren des Zufalls

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

Wie schwer wäre es wohl, so etwas als Go-Programm zu schreiben? Als Beispiel soll die Preisentwicklung der Netflix-Aktie (NASDAQ: NFLX) über die letzten zwei Jahre dienen (Abbildung 1). Nach einem gewaltigen Kurseinbruch von 600 (Januar 2022) auf 180 US-Dollar (April 2022) schraubte sie sich langsam wieder nach oben; heute ist sie 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 gut 400 Dollar pro Aktie oder rund 222 Prozent klingt sehr verlockend. Dennoch kann kein Spekulant eine Zeitreise in die Vergangenheit antreten, um diese Strategie zu fahren.

Abbildung 1: Die historische Entwicklung der Netflix-Aktie.

Abbildung 1: Die historische Entwicklung der Netflix-Aktie.

Abbildung 2: Eine SQLite-Datenbank speichert die historischen Aktienkurse.

Abbildung 2: Eine SQLite-Datenbank speichert die historischen Aktienkurse.

Strategie oder Random Walk

Allerdings glauben viele Börsenspieler an pseudomathematische Voodoo-Methoden wie Chart-Analyse, während andere dem Börsenzirkus jegliche Vorhersagbarkeit absprechen und an der Random-Walk-Theorie [2] festhalten. Es macht aber Spaß, Strategien zu entwerfen. Mit dem Go-Framework dieser Ausgabe lassen sie sich sogar anhand historischer Daten prüfen, ohne dass dazu jemand 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 nichts. Da kommt das Angebot des Anbieters Twelvedata.com gerade recht: Dessen registrierungspflichtiger, aber kostenloser Basic-Plan erlaubt bis zu acht API-Requests pro Minute und bis zu 800 am Tag, bevor das Rate-Limiting einschreitet. Zwei Jahre an Daten einer Aktie zählen nur als ein Request, also genügt das völlig.

Listing 1

bt.go

package main
import (
  "flag"
  "fmt"
  "log"
  "database/sql"
  _ "github.com/mattn/go-sqlite3"
)
func main() {
  update := flag.Bool("update", false, "update quotes in db")
  strategy := flag.String("strategy", "hold", "trader strategy")
  flag.Parse()
  db, err := sql.Open("sqlite3", "./quotes.db")
  if err != nil {
    log.Fatal(err)
  }
  defer db.Close()
  if *update {
    err := updater(db, "nflx")
    if err != nil {
      log.Fatal(err)
    }
    return
  }
  tr := newTrader(*strategy)
  err = replay(db, tr.trade)
  if err != nil {
    log.Fatal(err)
  }
  if tr.holds { // leftover
    tr.sell(tr.prevDt, tr.prevQ)
  }
  fmt.Printf("Total: %+.2f\n", tr.ledger)
}

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

Abbildung 3: Drei Investmentstrategien mit unterschiedlichen Gewinnentwicklungen.

Abbildung 3: Drei Investmentstrategien mit unterschiedlichen Gewinnentwicklungen.

Abbildung 3 zeigt das Go-Programm in Aktion, mit drei verschiedenen Investmentstrategien im Vergleich. Die erste, »hold« genannt, investiert gemäß dem für Privatanleger 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 im untersuchten Zeitraum ein Tal durchlaufen hat, streicht der Anleger mit dieser Strategie nur einen kleinen Gewinn ein: 21,02 Dollar. Na ja – besser als nichts oder gar Verlust.

Börsenspieler

Die zweite Strategie »”buydrop”« wartet ab, bis die Aktie leicht einsackt (2 Prozent des Vortageskurses) und kauft dann ein. Dann wartet sie, bis der Kurs entweder eine Schranke nach oben oder unten durchbricht (±10 Prozent) und verkauft dann sofort. Das ist typisches Daytrader-Verhalten, und im Beispiel hat der Börsenspieler sogar Glück und streicht 142,67 Dollar ein. 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.

Abbildung 4: Der Trader kauft bei 2 Prozent Kurseinbruch und verkauft bei ±10 Prozent Gewinn oder Verlust.

Abbildung 4: Der Trader kauft bei 2 Prozent Kurseinbruch und verkauft bei ±10 Prozent Gewinn oder Verlust.

Als drittes Beispiel dient das eher kuriose Verfahren »firstweek«, das wohl in der Realität nicht zum Einsatz käme. Es zeigt aber, zu welchen Kapriolen die Simulation in der Lage ist: Sie investiert immer am Monatsanfang und hält die Aktie dann fünf Börsentage (Abbildung 5). Daraus resultiert allerdings ein satter Verlust von 224,88 Dollar.

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

Abbildung 5: Obskur: Der Trader kauft am Monatsanfang und verkauft fünf 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. Januar 2022 in einem Rutsch per API vom Anbieter Twelvedata. Die Datenbanktabelle mit den Tageskursen packt die 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 es bei der kostenlosen Registrierung gibt; ohne ihn käme eine Fehlermeldung zurück. Im Erfolgsfall schickt der Datenanbieter eine JSON-Antwort, aus der 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 Kommando »Exec()« 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

package main
import (
  "database/sql"
  "io/ioutil"
  "net/http"
  "net/url"
  _ "github.com/mattn/go-sqlite3"
  "github.com/tidwall/gjson"
)
func updater(db *sql.DB, ticker string) error {
  createTableSQL := `CREATE TABLE IF NOT EXISTS quotes (
        "date" DATE NOT NULL,
        "quote" REAL NOT NULL,
        UNIQUE(date, quote)
      );`
  _, err := db.Exec(createTableSQL)
  if err != nil {
    return err
  }
  dates, quotes, err := fetchQ(ticker)
  if err != nil {
    return err
  }
  insertSQL := `INSERT OR REPLACE INTO quotes(date, quote) VALUES (?, ?)`
  statement, err := db.Prepare(insertSQL)
  if err != nil {
    return err
  }
  defer statement.Close()
  for i, date := range dates {
    quote := quotes[i]
    _, err := statement.Exec(date.String(), quote.String())
    if err != nil {
      return err
    }
  }
  return nil
}
func fetchQ(symbols string) ([]gjson.Result, []gjson.Result, error) {
  dates := []gjson.Result{}
  quotes := []gjson.Result{}
  u := url.URL{
    Scheme: "https",
    Host:   "api.twelvedata.com",
    Path:   "time_series",
  }
  q := u.Query()
  q.Set("symbol", symbols)
  q.Set("interval", "1day")
  q.Set("start_date", "2022-01-01")
  q.Set("apikey", "fa99cbec4a071bd770427bb70ee9fda814bf3f8d")
  u.RawQuery = q.Encode()
  resp, err := http.Get(u.String())
  if err != nil {
    return dates, quotes, err
  }
  body, err := ioutil.ReadAll(resp.Body)
  if err != nil {
    return dates, quotes, err
  }
  dates = gjson.Get(string(body), "values.#.datetime").Array()
  quotes = gjson.Get(string(body), "values.#.close").Array()
  return dates, quotes, nil
}

Investoren als Code

Um einen Investor zu simulieren, der vorgefertigte Strategien verfolgt, definiert Listing 3 ab Zeile 7 die Struktur »trader«. Sie zeigt mit der booleschen Variablen »holds« an, ob der Trader die Position hält.

Die Variable »cost« speichert, zu welchem Preis die Aktie gekauft wurde, »prevQ« und »prevDt« enthalten den Kurs vom Vortag und das zugehörige Datum. Außerdem gibt es das Kassenbuch »ledger«, das Gewinne und Verluste aus allen vorhergegangenen Transaktionen aufaddiert.

Die Strategie selbst implementiert »runStrat()«, eine Funktion, die das Programm zu jedem Kurstag aufruft, um zu entscheiden, was zu tun ist: kaufen, verkaufen oder einfach abwarten.

Listing 3

trade.go

package main
import (
  "fmt"
  "time"
)
type tradeFu func(time.Time, float64)
type trader struct {
  holds    bool
  cost     float64
  prevQ    float64
  prevDt   time.Time
  ledger   float64
  runStrat tradeFu
}
func newTrader(strategy string) *trader {
  tr := trader{}
  disp := map[string]func() tradeFu{
    "hold":      tr.strat_hold,
    "buydrop":   tr.strat_buydrop,
    "firstweek": tr.strat_firstweek,
  }
  tr.runStrat = disp[strategy]()
  return &tr
}
func (tr *trader) sell(dt time.Time, quote float64) {
  tr.holds = false
  tr.ledger += quote - tr.cost
  fmt.Printf("Selling %s %.2f (%+.2f) total %+.2f\n",
    dt.Format("2006-01-02"), quote, quote-tr.cost, tr.ledger)
}
func (tr *trader) buy(dt time.Time, quote float64) {
  fmt.Printf("Buying %s %.2f\n", dt.Format("2006-01-02"), quote)
  tr.holds = true
  tr.cost = quote
}
func (tr *trader) trade(dt time.Time, quote float64) {
  tr.runStrat(dt, quote)
  tr.prevQ = quote
  tr.prevDt = dt
}

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 für spätere Abfragen in die »trader«-Struktur aufzunehmen.

Welche Strategie zum Einsatz kommt, geben Sie 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 Hash-Tabelle, die Strings Funktions-Pointer zuweist – genauer gesagt Methoden, denn die »trader«-Struktur »tr« als Go-typischer Receiver steht davor.

Schlaue Strategien

Die Implementierung einer Strategie besteht jeweils aus einer Funktion, die den Zeitstempel an einem Kurstag sowie den Schlusskurs (»float64«) der Aktie als Parameter erhält. Auf dieser Basis trifft sie Entscheidungen und führt passende Transaktionen aus.

Die Signatur dieser Funktion steckt im Typ »tradeFu«, den vorher Zeile 6 in Listing 3 setzt. Listing 4 implementiert nun die Buy-and-Hold-Strategie ab Zeile 5. Dazu gibt die Funktion »strat_hold()« eine Funktion mit der »tradeFu«-Signatur zurück, sodass die Börsen-Engine sie zu jedem Kurstag wieder und wieder 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 Kaufen-und-Halten-Strategie von »strat_hold()« ab Zeile 5 prüft in Zeile 7 lediglich anhand der booleschen Variablen »holds«, ob sich die Aktie bereits im Besitz des Investors befindet. Ist das noch nicht der Fall, kauft sie sie mit »buy()«. Bei den folgenden Aufrufen der Strategiefunktion ist dann »holds« wahr, und es gibt an darauffolgenden Börsentagen nichts weiter zu tun. Am Ende des Handelszeitraums kommt dann wieder die Börsen-Engine an die Reihe. Sie sieht, dass die Aktie sich noch im Depot befindet, und verkauft sie zum vorangegangenen Schlusspreis.

Listing 4

strategy.go

package main
import (
  "time"
)
func (tr *trader) strat_hold() tradeFu {
  return func(dt time.Time, quote float64) {
    if !tr.holds {
      tr.buy(dt, quote)
    }
  }
}
func (tr *trader) strat_buydrop() tradeFu {
  return func(dt time.Time, quote float64) {
    if tr.prevQ != 0 {
      if tr.holds {
        if quote > 1.1*tr.cost || quote < 0.9*tr.cost {
          tr.sell(dt, quote)
        }
      } else {
        if quote < 0.98*tr.prevQ {
          tr.buy(dt, quote)
        }
      }
    }
  }
}
func (tr *trader) strat_firstweek() tradeFu {
  held := 0
  return func(dt time.Time, quote float64) {
    if tr.holds {
      held += 1
     if held > 5 {
        tr.sell(dt, quote)
        held = 0
      }
    } else {
      if dt.Day() < 7 {
        tr.buy(dt, quote)
      }
    }
  }
}

Die zweite Strategie »strat_buydrop()« ab Zeile 12 kauft die Aktie, falls der Schlusskurs mehr als 2 Prozent unter dem des Vortags (»prevQ«) liegt. Schlägt der Investor in Zeile 21 mit »buy()« zu, ist »holds« beim nächsten Aufruf der Funktion gesetzt. Dann prüft Zeile 16, ob der noch nicht materialisierte Gewinn (Schlusspreis minus Kaufpreis in »tr.cost«) um wenigstens 10 Prozent nach oben oder unten abweicht. Liegt der aktuelle Schlusspreis außerhalb dieses Rahmens, ruft die Strategie in Zeile 17 die Funktion »sell()« auf, und die verkauft die Aktie zum aktuellen Schlusskurs.

Closure: Funktion plus Daten

Die dritte Strategie »strat_firstweek« investiert nur in der ersten Woche des Monats. Sie demonstriert, wie die Strategiefunktion sich zwischen zwei Aufrufen seitens der Börsen-Engine Daten merken kann.

Die Strategiefunktion gibt ja an ihren Aufrufer eine Investor-Funktion zurück, kann aber vorher in deren Umfeld Variablen definieren. Die schleppt die Funktion dann mit dem Closure-Mechanismus aus der funktionalen Programmierung mit sich herum. 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. Zeile 31 zählt dann den Wert der lokalen (und per Closure mitgeschleppten) Variable »held« um eins hoch. Nach fünf Tagen im Depot ist die Bedingung »held > 5« in Zeile 32 wahr, und »sell()« in Zeile 33 verkauft die Aktie.

Dieses Verfahren erweist sich wie in Abbildung 3 ersichtlich als verlustbringende Strategie. Mit ähnlichen Programmiertricks können aber Strategien durchaus sinnvolle Zwischenwerte abbilden, zum Beispiel den Durchschnittswert der Aktie über die vergangene Börsenwoche, und darauf basierend dann hoffentlich gewinnbringenden Entscheidungen fällen.

Börsenmotor als Schleife

Der Antrieb der Simulation, die durch alle Börsentage eines Zweijahreszeitraums rattert und zu jedem Datum die aktuell ausgewählte Strategiefunktion anspringt, findet sich 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 sortiert ausliest, also der Zeit folgend. Für jedes Wertepaar aus Datum und Kurswert ruft es die ihr überreichte Callback-Funktion »cb()« auf. Die wiederum übergibt gemäß der in Listing 3 definierten Dispatch-Tabelle an die passende Strategie- beziehungsweise Trading-Funktion.

Listing 5

replay.go

package main
import (
  "database/sql"
  _ "github.com/mattn/go-sqlite3"
  "time"
)
func replay(db *sql.DB, cb func(time.Time, float64)) error {
  query := `SELECT date, quote FROM quotes ORDER BY date ASC`
  rows, err := db.Query(query)
  if err != nil {
    return err
  }
  defer rows.Close()
  for rows.Next() {
    var date string
    var quote float64
    err := rows.Scan(&date, &quote)
    if err != nil {
      return err
    }
    dt, err := time.Parse("2006-01-02T15:04:05Z07:00", date)
    if err != nil {
      return err
    }
    cb(dt, quote)
  }
  if err = rows.Err(); err != nil {
    return err
  }
  return nil
}

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

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 Quellen und diversen Bibliotheken von Github zusammenbauen.

Listing 6

build.sh

$ go mod init bt
$ go mod tidy
$ go build bt.go update.go trade.go strategy.go replay.go
$

Ausblick

Zu guter Letzt geht es daran, nach den Beispielvorlagen in Listing 4 eine Strategie zu entwerfen, die tatsächlich Gewinn abwirft. Haben Sie das erledigt, 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. Die meisten mussten irgendwann einsehen, dass es ohne Verlustrisiko nicht geht. Manche Experten behaupten, die Strategie des Kaufens und Haltens sei – über viele Jahre hinweg praktiziert – die beste. (uba)

Der Autor

Michael Schilli arbeitet als Software Engineer in der kalifornischen San Francisco Bay Area. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mailto:mschilli@perlmeister.com beantwortet er gern Ihre Fragen.

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 7 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben