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.
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.
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.
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!
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.
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 }
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.
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.
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.
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 }
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.
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.
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.
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, "e) 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.
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.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2024/06/snapshot/
"Fooled by Randomness: The Hidden Role of Chance in Life and in the Markets", Nassim Nicholas Taleb, Random House, 2005
"A Random Walk down Wall Street", Burton G. Malkiel, https://www.amazon.com/Random-Walk-Down-Wall-Street-ebook/dp/B00QH9NTSI
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc