Beat the Market (Linux-Magazin, Juni 2002)

An Börsenstatistiken herrscht kein Mangel. Aber wer hätte gewusst, dass die Microsoft-Aktie an Freitagen am teuersten ist? Oder IBM am 1. jedes Monats auf dem Tiefststand? Die historischen Kursdaten sind alle verfügbar, fehlt nur noch ein Skript, um darin zu stöbern ...

Das Modul Finance::QuoteHist von Matt Sisk bringt die auf yahoo.com verfügbaren historischen Kursdaten beliebiger Aktien blitzschnell ins Wohnzimmer -- uns erlaubt es uns Börsenhobbyisten, Skripts darauf abzufeuern und die abgefahrensten Statistiken zu erstellen. Um die Sache zu beschleunigen und nicht jedes Mal Kontakt mit den freundlichen Yahoo-Servern aufnehmen zu müssen, holt das heute vorgestellte Modul QuoteDB.pm beim ersten Aufruf auf einen Schlag gleich die täglichen Daten für sieben Jahre ab und speichert sie persistent in einer lokalen Datenbank, sodass auch nachfolgende Aufrufe der Wühlskripts für die gleiche Aktie nie wieder den relativ langsamen Netzzugriff ausführen müssen.

Wann Microsoft verkaufen?

In welchen Monaten im Jahr ist die Microsoft-Aktie durchschnittlich am billigsten, in welchen am teuersten? Listing bestmonth holt mittels der von QuoteDB.pm exportierten Funktion quote() alle Tageskurse der Microsoftaktie der Jahre 1995 bis 2001, bildet die monatlichen Mittelwerte und gibt sie nach dem Durchschnittspreis sortiert aus. Das Ergebnis ist verblüffend:

    12: 50.96
    07: 50.57
    11: 48.98
    ...  
    02: 45.68
    01: 45.03
    05: 44.80

Microsoft ist im Dezember durchschnittlich am teuersten und im Mai am billigsten. Dieses Ergebnis bestätigt die alten Börsenhasen bekannte Regel, dass Aktien in der zweiten Jahreshälfte deutlich besser zulegen als in den ersten 6 Monaten jeden Jahres.

Listing bestmonth holt mittels

    use QuoteDB 'quotes.db';

das neue Modul herein und gibt ihm den Namen einer Datei mit, unter der es die Kursdatenbank aufbauen soll. QuoteDB.pm wird daraufhin die Funktion

    quote($symbol, $y, $m, $d);

exportieren, die das Börsentickersymbol der gewünschten Aktie (z.B. MSFT) und das aktuelle Datum im Format Jahr ($y), Monat ($m), Tag ($d) entgegennimmt und den Tageskurs (in diesem Fall in Dollar) zurückliefert. Da die Datenbank beim ersten Aufruf des Skripts noch leer ist, wird sich zunächst

    Updating ...

zeigen, wenn QuoteDB.pm die Microsoft-Kursdaten der Jahre 1995 bis 2001 vom Yahoo-Finanzservice abholt. Beim nächsten Aufruf von bestmonth ist die Datenbank schon heiß und das Ergebnis steht praktisch sofort da.

quote() ist außerdem so schlau, dass es zu Tagen, an denen keine Börse stattfand (z.B. sonntags) einfach den Kurs des letzten Börsentags liefert. Außerdem behandelt es ungültige Datumsangaben wie den 31. Februar großzügig und gibt ohne Murren einfach den Tageskurs des letzten gültigen Datums/Börsentags zurück.

Das vereinfacht die Implementierung von bestmonth beträchtlich, denn es iteriert einfach über alle Jahre 1995 bis 2001 und alle Monatstage 1 bis 31 und bildet den Mittelwert über alle Januarkurse, Februarkurse usw. Hierzu addiert es einfach für jeden Monat über die Jahre die Tageswerte in $sum auf und zählt in $count mit, wieviele Einträge es insgesamt waren. In Zeile 23 bildet es dann den Mittelwert, indem es $sum durch $count teilt und unter der Monatsnummer als Schlüssel im Hash %per_month ablegt. Die for-Schleife ab Zeile 26 iteriert dann über alle Hash-Schlüssel, sortiert die entstehende Liste mit dem Spaceship-Operator (<=>) numerisch absteigend nach den Hash-Werten und gibt die Elemente untereinander aus. Während der letzten sieben Jahre war also im Schnitt der Dezember der günstigste Monat, um die alten Lappen loszuschlagen.

Listing 1: bestmonth

    01 #!/usr/bin/perl
    02 ###########################################
    03 # In welchen Monat ist MSFT am teuersten?
    04 # Mike Schilli, 2002 (m@perlmeister.com)
    05 ###########################################
    06 use warnings;
    07 use strict;
    08 
    09 use QuoteDB 'quotes.db';
    10 
    11 my %per_month;
    12 
    13 for my $month (1 .. 12) {
    14   my $sum   = 0;
    15   my $count = 0;
    16   for my $year (1995 .. 2001) {
    17     for my $day (1 .. 31) {
    18       $sum += 
    19        quote("MSFT", $year, $month, $day);
    20       $count++;
    21     }
    22   }
    23   $per_month{$month} = $sum/$count;
    24 }
    25 
    26 for my $month (sort { 
    27         $per_month{$b} <=> $per_month{$a} 
    28                     } keys %per_month) {
    29     printf "%02d: %.2f\n", $month, 
    30            $per_month{$month};
    31 }

Wann im Monat in IBM investieren?

Ist es besser, die IBM-Aktie eher am Anfang oder am Ende des Monats zu kaufen, was sagt die Erfahrung der letzten Jahre dazu? Wie immer findet man solche Tipps nicht in den Publikationen schlipstragender Börsenschlaumeier -- sondern eben nur im Linux-Magazin. Die Ausgabe von Listing bestday zeigt wissenschaftlich einwandfrei, dass diejenigen Leute besser bedient waren, die IBM am Monatsanfang kauften:

    26: 69.50
    27: 69.35
    25: 69.29
    ...
    03: 68.30
    02: 68.08
    01: 67.92

Wer zum Beispiel monatlich einen festen Betrag in IBM investierte, kuckte mit dem Ofenrohr ins Gebirge, falls die Buchung immer um den 26. abging -- wer hingegen fünf Tage wartete, sparte bares Geld!

Listing bestmonth iteriert hierzu über alle Tages-, Monats- und Jahreswerte während der Jahre 1995 bis 2001 und legt in einem Hash %per_day unter dem Schlüssel des Tags im Monat (1..31) jeweils ein dreielementiges Array an: Unter der Indexnummer 0 steht dort die Summe aller bislang für diesem Tageswert gefundenen Kurse, unter 1 deren Anzahl und unter Indexwert 2 der bisherige Mittelwert, der sich einfach aus Division der Positionen 0 und 1 ergibt.

Die for-Schleife am Ende iteriert über alle Hash-Einträge, sortiert numerisch absteigend nach dem Mittelwert für den jeweiligen Tageswert und gibt die Elemente der Liste untereinander aus.

Listing 2: bestday

    01 #!/usr/bin/perl
    02 ###########################################
    03 # An welchem Tag im Monat is IBM am 
    04 # teuersten?
    05 # Mike Schilli, 2002 (m@perlmeister.com)
    06 ###########################################
    07 use warnings;
    08 use strict;
    09 
    10 use QuoteDB 'quotes.db';
    11 
    12 my %per_day = ();
    13 
    14 for my $d (1 .. 31) {
    15   for my $y (1995 .. 2001) {
    16     for my $m (1 .. 12) {
    17       my ($p) = ($per_day{$d} ||= []);
    18       $p->[0] += quote("IBM", $y, $m, $d);
    19       $p->[1]++;
    20       $p->[2] = $p->[0] / $p->[1];
    21     }
    22   }
    23 }
    24 
    25 for my $d (sort { $per_day{$b}->[2] <=> 
    26                   $per_day{$a}->[2] } 
    27            keys %per_day) {
    28     printf "%02d: %.2f\n", $d, 
    29            $per_day{$d}->[2];
    30 }

An welchen Wochentagen schwächelt Microsoft?

Listing bestdow (dow = Day of Week) wählt einen etwas anderen Ansatz und rennt nicht mehr durch Datumsangaben, die es gar nicht gibt, sondern nutzt das Modul Date::Calc und dessen Funktion Add_Delta_Days(), um Tag für Tag im Kalender voranzuschreiten. Die Funktion Day_of_Week() teilt jeweils den zu einem Datum gehörenden Wochentag (von 1=Montag bis 7=Sonntag) mit, was bestdow dazu nutzt, in Zeile 23 explizit die Wochenenden auszulassen. Das Ergebnis für Microsoft zeigt durchschnittlich, dass die Aktie übers Wochenende leicht absackt, um dann von Montag bis Freitag wieder kontinuierlich aufzuholen:

    Freitag   : 47.39
    Donnerstag: 47.33
    Mittwoch  : 47.27
    Dienstag  : 47.22
    Montag    : 47.18

Dieses Phänomen manifestiert sich jedoch in einer sehr geringen Streuung, das könnte ebenso purer Zufall sein.

Listing 3: bestdow

    01 #!/usr/bin/perl
    02 ###########################################
    03 # An welchen Wochentagen ist die
    04 # Microsoft-Aktie am teuersten?
    05 # Mike Schilli, 2002 (m@perlmeister.com)
    06 ###########################################
    07 use warnings;
    08 use strict;
    09 
    10 use QuoteDB 'quotes.db';
    11 use Date::Calc qw(Add_Delta_Days 
    12       Day_of_Week Language Decode_Language 
    13       Day_of_Week_to_Text);
    14 
    15 my %per_dow;
    16 
    17 Language(Decode_Language("Deutsch"));
    18 
    19 for(my @date = (1995, 1, 1); 
    20     "@date" ne "2002 1 1";    
    21     @date = Add_Delta_Days(@date, 1)) {
    22     my $dow = Day_of_Week(@date);
    23     next if $dow >=6;
    24     my $p = ($per_dow{$dow} ||= []);
    25     $p->[0] += quote("MSFT", @date);
    26     $p->[1]++;
    27     $p->[2] = $p->[0] / $p->[1];
    28 }
    29 
    30 for my $dow (sort { $per_dow{$b}->[2] <=> 
    31                     $per_dow{$a}->[2] } 
    32                   keys %per_dow) {
    33     printf "%-10s: %.2f\n", 
    34            Day_of_Week_to_Text($dow),
    35            $per_dow{$dow}->[2];
    36 }

QuoteDB.pm

Listing QuoteDB.pm definiert ab Zeile 35 die Funktion quote(), die mit dem Börsensymbol einer Aktie (z.B. MSFT) und dem Datum im Format Jahr, Monat, Tag den jeweiligen Tageskurs zutage fördert. Sie versucht zunächst, den Wert in dem persistenten Datenbank-Hash %QUOTES zu finden. Wenn das nichts hilft, kommt die Funktion update_db() zum Zug und gibt Finance::QuoteHist den Auftrag, die Kursdaten dieser Aktie vom 20.12.1994 bis zum heutigen Tag vom Yahoo-Finanzservice abzuholen, damit wir ganz sicher die Kurse ab dem 1.1.1995 haben, die irgendwann Ende Dezember 1994 an der Börse ausgekartelt wurden. quote() setzt den Eintrag $QUOTES{SYMBOL} auf 1 (Zeile 58, damit wir nächstes Mal wissen, dass die Kurse dieser Aktie vorliegen) und speichert die Kursdaten für jeden Tag im Hash jeweils unter $QUOTES{"SYMBOL,JAHR,MONAT,TAG"} ab, wobei SYMBOL das Tickersymbol der Aktie ist (z.B. MSFT).

Die quote_get()-Methode des Moduls Finance::QuoteHist liefert eine Liste mit Arrayreferenzen zurück, von denen jede auf folgende Tagesdaten verweist:

    $stock  # Aktienname
    $date   # Datum JJJJ/MM/TT
    $open   # Öffnungskurs
    $high   # Tageshöchstkurs
    $low    # Tagestiefstkurs 
    $close  # Schlusskurs 
    $volume # Anzahl gehandelter Aktien

Wir extrahieren lediglich den Schlusskurs ($close) und das Datum $date im Format JJJJ/MM/TT. die Zeilen 63-65 werfen eventuelle Nullen raus ("07" => "7") und legen Tag, Monat und Jahr in $day, $month und $year ab.

Die ab Zeile 72 definierte Funktion latest_price() ermittelt den im Hash %QUOTES gespeicherten Tagespreis einer Aktie für ein angegebenes Datum. Nun ist aber nicht jeden Tag ein Börsentag -- und deswegen marschiert latest_price() bis zu 10 Tage in die Vergangenheit zurück, falls es zu einem Datum keinen Kurs findet. Die rare Spezies des continue-Blocks des while-Konstrukts kommt immer dann zum Einsatz, wenn while in die nächste Runde springt.

Falls nach 10 Versuchen noch kein Börsentag gefunden wurde, stimmt wahrscheinlich etwas mit %QUOTES nicht (vielleicht gibt's die angegebene Aktie auch noch nicht so lange) und latest_price() gibt mit einer die()-Meldung auf.

latest_price() verkraftet auch falsche Datumsangaben, damit das aufrufende Programm nicht mit Kalenderfeinheiten herumschlagen muss. So kann es ruhig nach dem Kurs für den 31. Februar fragen -- die check_date()-Funktion aus dem Date::Calc-Modul wird dies in Zeile 81 sofort merken und die zugehörige while-Schleife den Tagesteil des Datums solange herunterzählen, bis ein gültiger Wert erreicht ist.

Zum Schluss noch einige Eigenheiten von QuoteDB.pm: Der globale Hash %QUOTES ist tatsächlich permanent, behält also auch beim nächsten Skriptaufruf die dort abgelegten Daten, da er über die DB_File-Technologie und die tie()-Funktion in Zeile 23 mit einer Datenbankdatei verknüpft ist. Wann immer jemand aus dem Hash liest (z.B. mit $QUOTES{MSFT}) oder hineinschreibt, liest und schreibt Perl hinter den Kulissen eigentlich in einer Datei. Die Konstante O_CREAT kommt aus dem in Zeile 13 hinzugezogenen POSIX-Modul und veranlasst tie(), einfach und ohne zu grummeln eine neue Datenbankdatei anzulegen, falls diese noch nicht existiert.

Deren Name legt das aufrufende Skript fest, indem es beim Hereinziehen des Moduls einfach den Dateinamen mit angibt:

    use QuoteHist 'quotes.db';

Dies ist etwas ungewöhnlich, da man Modulen sonst auf diese Weise nur die Namen von Funktionen oder Variablen mitgibt, die diese dann in den Namensraums des Skripts exportieren. QuoteDB.pm ist aber eigen und nimmt nicht nur einen Dateinamen entgegen, sondern exportiert ungefragt die Funktion quote() in den Namensraum des aufrufenden Skripts.

Während Module sonst vom Modul Exporter erben, das den traditionellen Symbolexport erledigt, implementiert QuoteDB.pm ab Zeile 19 einfach seine eigene import()-Funktion, die perl anspringt, falls ein Skript ein Modul mit use hereinzieht.

perl liefert import als ersten Parameter automatisch den Namen des Moduls, gefolgt von eventuell beim use-Aufruf darangehängten Parametern. Im obigen Fall steht der zweite Parameter auf "quotes.db", den der in Zeile 23 stehende tie()-Befehl als Dateinamen interpretiert und unter den Unix-Zugriffsrechten 0644 (rw-r--r--) eine DB_File-Datenbank anlegt.

Die Zeilen 26 und 27 exportieren anschließend die Funktion quote des QuoteDB-Moduls in den Namensraum des aufrufenden Skipts. Wie? Zunächst ermittelt die Funktion caller() den package-Namen des Aufrufers -- im Fall des Hauptprogramms ist das main. Zeile 27 setzt dann zwei sogenannte Typeglobs gleich und sagt sinngemäß: Wann immer du main::quote() siehst, interpretiere es als quote() -- und quote() ist in diesem Fall gerade im Paket QuoteDB, also wird perl statt main::quote() (also quote() im Hauptprogramm) einfach QuoteDB::quote() aufrufen. So arbeitet auch der Exporter intern. Aber -- pssst! -- das wissen nur die Perl-Haudegen mit Augenklappe. Bis zum nächsten Mal, Piraten!

Listing 4: QuoteDB.pm

    01 package QuoteDB;
    02 ###########################################
    03 # Store and retrieve historical quotes 
    04 # Mike Schilli, 2002 (m@perlmeister.com)
    05 ###########################################
    06 use warnings;
    07 use strict qw(vars subs);
    08 
    09 use Date::Calc qw(Add_Delta_Days 
    10                   check_date);
    11 use Finance::QuoteHist;
    12 use DB_File;
    13 use POSIX;
    14 
    15 our $VERSION = "1.0";
    16 our %QUOTES = ();
    17 
    18 ###########################################
    19 sub import {
    20 ###########################################
    21     my($class, $filename) = @_;
    22 
    23     tie %QUOTES, "DB_File", $filename, 
    24         O_CREAT, 0644;
    25 
    26     my $callerpkg = caller(0);
    27     *{"$callerpkg\::quote"} = *quote;
    28 
    29     1;
    30 }
    31 
    32 END { untie %QUOTES; }
    33 
    34 ###########################################
    35 sub quote {
    36 ###########################################
    37     my($symbol, $year, $month, $day) = @_;
    38 
    39     if(!exists $QUOTES{$symbol}) {
    40         print "Updating ...\n";
    41         db_update($symbol);
    42     }
    43 
    44     return latest_price($symbol, $year, 
    45                         $month, $day);
    46 }
    47 
    48 ###########################################
    49 sub db_update {
    50 ###########################################
    51     my($sym) = @_;
    52 
    53     my $q = Finance::QuoteHist->new(
    54         symbols    => [$sym],
    55         start_date => '12/20/1994',
    56         end_date   => 'today');
    57 
    58     $QUOTES{$sym}++;
    59 
    60     foreach my $row ($q->quote_get()) {
    61         my ($stock, $date, $open, $high, 
    62             $low, $close, $volume) = @$row;
    63         my($year, $month, $day) = 
    64             map { sprintf "%d", $_ } 
    65             split(m#/#, $date);
    66         $QUOTES{"$sym,$year,$month,$day"} = 
    67                                     $close;
    68     }
    69 }
    70 
    71 ###########################################
    72 sub latest_price {
    73 ###########################################
    74     my($symbol, $year, $month, $day) = @_;
    75 
    76     my @try = ($year, $month, $day);
    77 
    78     my $maxtries = 10;
    79 
    80     while($maxtries--) {
    81         while(!check_date($year, $month, 
    82                           $day)) {
    83             $day--;
    84         }
    85         my $key = 
    86               "$symbol,$year,$month,$day";
    87         if(exists($QUOTES{$key})) {
    88             return $QUOTES{$key};
    89         }
    90     } continue {
    91         ($year, $month, $day) = 
    92             Add_Delta_Days($year, $month, 
    93                            $day, -1);
    94     }
    95 
    96     die "Can't find date (@try)";
    97 }
    98 
    99 1;

Infos

[1]
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2002/06/Perl

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.