Seid umschlungen, Millionen! (Linux-Magazin, März 2005)

Ein Perlskript mit flexibler Plugin-Architektur zeigt auf Kommando die aktuellen Vermögensverhältnisse an.

Wer wie Dagobert Duck gerne sein Geld zählt, muss dafür nicht mehr kopfüber in den Geldspeicher springen: Programme wie GnuCash unterstützen bei der Buchführung und geben Kontostände sauber formatiert oder sogar grafisch gestaltet aus.

Allerdings verlangen die in der Tradition von Quicken und Microsoft Money stehenden freien Ableger eiserne Disziplin bei der Buchführung. Doch welcher Freizeitbuchhalter hat schon die Zeit, minutiös alle Ausgaben einzutragen? Und mal ganz abgesehen von der Installationsorgie, die GnuCash verlangt, wenn es nicht gerade schon einer Distro beiliegt, birgt es einen weitere Hürde: Es ist nicht einfach und beliebig erweiterbar.

Das heute vorgestellte Perlskript ist für kleine Dagoberts, die nur einmal im Monat 10 Minuten Arbeit dafür erübrigen, Kontostände aufzufrischen, dann aber täglich mit Online verfügbaren Börsenkursen ruckzuck ihr Hab und Gut in bare Münze umrechnen können.

Außerdem lässt sich das System durch eine flexible Plugin-Architektur beliebig erweitern. Währungsumrechnungen oder für den Einzelfall anwendbare Steuergesetze lassen sich so schnell einarbeiten und das System an spezielle private Bedürfnisse anpassen, ohne gleich Bloatware zu erzeugen.

Freilich kann man's auch übertreiben: Für Aktien-Splits, die sich alle paar Jahre mal ereignen, braucht man kein extra Modul, die kann man auch von Hand korrigieren. Also Vorsicht: ``Wer jedermanns Liebling sein will, ist irgendwann jedermanns Depp''.

Das heute vorgestellte Skript dagobert sucht den Mittelweg. Es bietet Basisfunktionen, um den Inhalt mehrerer Konten und Aktiendepots zusammenzurechnen, überlässt es aber dem Anwender, Plugins für spezielle Anforderungen einzuhängen.

Abbildung 1: Definition der Kontodaten in einer Konfigurationsdatei, die gleichzeitig ein ausführbares Skript ist.

Abbildung 2: Geldzähler dagobert in Aktion: Von der Kommandozeile aus aufgerufen, zeigt er Einzelkonten und Gesamtvermögen farblich aufgebessert an.

Skript als Interpreter

Kontendaten definiert der Anwender in einer Datei money nach Abbildung 1. Ein neues Konto definiert das Schlüsselwort account, eine Aktienposition startet mit stock und Bargeldbestand mit cash.

Der Interpreter dieser Finanzdaten ist das Skript dagobert aus Listing 1, das die Kontodefinitionen einliest, aktuelle Börsenkurse einholt und die Gewinne, Verluste und Gesamtstand ausrechnet. Das Finanz-Skript könnte man nun mit

    dagobert money

von der Kommandozeile aus aufrufen, aber es geht noch einfacher: Die Konfigurationsdatei money wird ausführbar gemacht und der dagobert-Interpreter in die She-Bang-Zeile eingehängt. So läuft money als Skript ab, nicht mit perl als Interpreter, sondern mit dagobert.

Wenn man aber nicht gerade unter der hypermodernen zsh-Shell arbeitet, sondern mit der guten alten bash, die Skripts und Programme ohne Magie gleich vom Kernel ausführen lässt, sind in der She-Bang-Zeile keine Skripts zugelassen. Statt dessen wird einfach schnell ein C-Wrapper in einem C-Programm dago.c herumgewickelt:

    main(int argc, char **argv) {
        execv("/usr/bin/dagobert", argv); }

Compiliert man dago.c anschließend einfach mit

    cc -o dago dago.c

dann lässt sich das Executable dago im She-Bang als Interpreter der Finanzdaten verwenden:

     #!/usr/bin/dago
    
     account DeutscheBank
       stock SIEGn.DE 10 62.38
       # ...

Und falls nun die diesen 'Code' enthaltende Datei money ausführbar ist, genügt der Aufruf money, um den Geldzähler zu starten. Was eigentlich aussieht wie eine Konfigurationsdatei, ist in Wirklichkeit ein ausführbares Skript. Abbildung 2 zeigt die Ausgabe. Praktisch!

Listing 1: dagobert

    01 #!/usr/local/bin/perl -w
    02 ###########################################
    03 # dagobert - Money Counting Interpreter
    04 # Mike Schilli, 2004 (m@perlmeister.com)
    05 ###########################################
    06 use strict;
    07 
    08 use lib '/home/mschilli/perl-modules';
    09 use Log::Log4perl qw(:easy);
    10 Log::Log4perl->easy_init($ERROR);
    11 use Plugger;
    12 my $string = join '', <>;
    13 
    14 my $plugger = Plugger->new();
    15 $plugger->init();
    16 $plugger->parse($string);

Dummer Interpreter schlau durch Plugins

Der Interpreter in Listing dagobert ist sehr kurz gehalten: Er erzeugt lediglich eine neue Instanz eines Objektes vom Typ Plugger, initialisiert die dahinterliegende Plugin-Architektur mit init() und übergibt die vorher von der Standardeingabe (mittels <>) eingelesenen Konfigurationsdaten der parse()-Methode des Plugin-Systems.

Das in Listing Plugger.pm gezeigte Framework interpretiert das jeweils erste Wort einer Zeile als Kommando. Ohne Plugins ist das Framework jedoch nicht in der Lage, irgendein Kommando zu interpretieren. Lediglich Kommentarzeilen, die mit einem '#' beginnen, verwirft es vorsorglich.

Abbildung 3: Die Plugin-Architektur: Der Plugin-Verwalter Plugger.pm liest mit Module::Pluggable alle Plugins unter C ein und initialisiert sie mit init(). Die Plugins registrieren daraufhin Kommandos beim Verwalter mit register_cmd().

Jedes unter dem Verzeichnis Plugger/ hinzugefügte Modul liest Plugger.pm während der Compile-Phase automatisch ein. Dies erledigt das in Zeile 6 eingebundene CPAN-Modul Module::Pluggable, denn das require-Flag ist gesetzt und der Suchpfad Plugger, relativ zum aktuellen Verzeichnis oder einem @INC-Pfad.

Diese Plugins haben keinen new()-Konstruktor, wie in der Objektorientierung üblich, sondern eine init()-Funktion, die Plugger.pm als Herr aller Plugins für jedes gefundene Plugin-Modul nacheinander aufruft. Module::Pluggable fügt in seinen Wirt (Plugger in diesem Fall) automatisch eine Methode plugins() ein, die die Namen aller gefundenen Plugins als Liste zurückgibt. Zeile 28 in Plugger.pm nutzt dies, um durch die init()-Funktionen aller Plugins zu orgeln.

Listing 2: Plugger.pm

    01 ###########################################
    02 package Plugger;
    03 ###########################################
    04 use strict; use warnings;
    05 
    06 use Module::Pluggable 
    07   require     => 1,
    08   search_path => [qw(Plugger)];
    09 
    10 our %DISPATCH = ();
    11 our %MEM      = ();
    12 
    13 ###########################################
    14 sub new {
    15 ###########################################
    16     my($class) = @_;
    17 
    18     bless my $self = {}, $class;
    19 
    20     return $self;
    21 }
    22 
    23 ###########################################
    24 sub init {
    25 ###########################################
    26     my($self) = @_;
    27 
    28     print "plugins=", join('-', $self->plugins()), "\n";
    29     $_->init($self) for $self->plugins();
    30 }
    31 
    32 sub mem { return \%MEM; }
    33 
    34 ###########################################
    35 sub parse {
    36 ###########################################
    37     my($self, $string) = @_;
    38 
    39     for(sort keys %DISPATCH) {
    40         $DISPATCH{$_}->{start}->($self) if 
    41             $DISPATCH{$_}->{start};
    42     }
    43 
    44     for(split /\n/, $string) {
    45 
    46         s/#.*//;
    47         next if /^\s*$/;
    48         last if /^__END__/;
    49         chomp;
    50 
    51         my($cmd, @args) = split ' ', $_;
    52         
    53         die "Unknown command: $cmd" unless
    54             exists $DISPATCH{$cmd};
    55 
    56         $DISPATCH{$cmd}->{process}->($self,
    57                               $cmd, @args);
    58     }
    59 
    60     for(sort keys %DISPATCH) {
    61         $DISPATCH{$_}->{finish}->($self) if 
    62             $DISPATCH{$_}->{finish};
    63     }
    64 }
    65 
    66 ###########################################
    67 sub register_cmd {
    68 ###########################################
    69     my($self, $cmd, $start, 
    70        $process, $finish) = @_;
    71 
    72     $DISPATCH{$cmd} = {
    73         start   => $start, 
    74         process => $process, 
    75         finish  => $finish,
    76     };
    77 }
    78 
    79 1;

Damit der Plugin auch weiss, wer der Aufrufer ist und gegebenenfalls dessen Methoden aufrufen kann, übergibt Plugger.pm der init()-Methode des Plugins jeweils eine Referenz $ctx (von Context). Dahinter steckt nichts anderes als eine Referenz auf das einzige existierende Plugger-Objekt, dem Plugin-Verwalter.

Damit kann Plugin nun seinerseits Anweisungen an den Verwalter Plugger schicken. Da Plugger die Kommandos in einer Konfigurationsdatei interpretiert, ruft der Plugin die Verwalter-Methode register_cmd() auf, um neue Kommandos zu registrieren.

In Listing Account.pm, das einen im Verzeichnis Plugger/ hängenden Plugin zeigt, nutzt der Plugin dies, um dem Pluginverwalter das Kommando account beizubringen:

    $ctx->register_cmd("account", 
        \&start, \&process, \&finish);

Gemäß den Regeln des Plugger-Frameworks definiert dies, dass Plugger nach der Interpretation des Schlüsselwortes account in der Konfigurationsdatei die Funktion process() in Plugger/Account.pm aufruft und ihr die aufgesplitteten Teile der Konfigurationszeile als Argumente überreicht.

Außerdem wird Plugger.pm die Funktion start() aus Plugger/Account.pm aufrufen, bevor die Interpretation der Konfigurationsdatei beginnt und die finish()-Funktion des Plugins, nachdem die Arbeit erledigt ist.

Listing 3: Account.pm

    001 ###########################################
    002 package Plugger::Account;
    003 # 2004, Mike Schilli <m@perlmeister.com>
    004 ###########################################
    005 
    006 use strict;
    007 use warnings;
    008 use Term::ANSIColor qw(:constants);
    009 
    010 ###########################################
    011 sub init {
    012 ###########################################
    013     my($class, $ctx) = @_;
    014 
    015     $ctx->register_cmd("account",
    016         \&start, \&process, \&finish);
    017 }
    018 
    019 ###########################################
    020 sub start {
    021 ###########################################
    022     my($ctx) = @_;
    023 
    024     $ctx->mem()->{account_total} = 0;
    025 }
    026 
    027 ###########################################
    028 sub account_start {
    029 ###########################################
    030     my($ctx, $name) = @_;
    031 
    032     print BOLD, BLUE,
    033           "Account: $name\n",
    034           RESET;
    035 
    036     $ctx->mem()->{account_subtotal} = 0;
    037     $ctx->mem()->{account_current} = $name;
    038 }
    039 
    040 ###########################################
    041 sub account_end {
    042 ###########################################
    043     my($ctx, $name) = @_;
    044 
    045     print  BOLD, BLUE;
    046     printf "%-47s %9.2f\n\n", "Subtotal:", 
    047            $ctx->mem()->{account_subtotal};
    048     print RESET;
    049 }
    050 
    051 ###########################################
    052 sub account_end_all {
    053 ###########################################
    054     my($ctx) = @_;
    055 
    056     print  BOLD, BLUE;
    057     printf "%-47s %9.2f\n\n", "Total:", 
    058            $ctx->mem()->{account_total};
    059     print RESET;
    060 }
    061 
    062 ###########################################
    063 sub process {
    064 ###########################################
    065     my($ctx, @args) = @_;
    066 
    067     my $c = $ctx->mem()->{account_current};
    068     account_end($ctx, $c) if $c;
    069     account_start($ctx, $args[1]);
    070 }
    071 
    072 ###########################################
    073 sub finish {
    074 ###########################################
    075     my($ctx) = @_;
    076 
    077     my $c = $ctx->mem()->{account_current};
    078     account_end($ctx, $c) if $c;
    079     account_end_all($ctx);
    080 }
    081 
    082 ###########################################
    083 sub position {
    084 ###########################################
    085     my($type, $ticker, $n, $at, $price,
    086        $value, $gain) = @_;
    087 
    088     unless(defined $ticker) {
    089         printf "%-47s %9.2f\n", 
    090         $type, $value;
    091         return;
    092     }
    093 
    094     my $clr = $gain > 0 ? GREEN : RED;
    095 
    096     printf "%-8s %-10s %9.3f %9.3f %7.2f" .
    097            " %9.2f %s(%+9.2f)%s\n",
    098              $type, $ticker, $n, $at,
    099              $price,
    100              $value, $clr, $gain, RESET;
    101 }
    102 
    103 1;

Der Plugin Account.pm nutzt dies, um vor Beginn des Parse-Reigens den in der globalen Variablen account_total gespeicherten Gesamtwert aller definierten Konten auf 0 zu setzen.

Doch wo definiert man einen derartigen Zähler, auf den womöglich nicht nur Account.pm, sondern auch andere Plugins Zugriff haben sollen? Hierfür definiert das Modul Plugger.pm einen Hash %MEM, und es wird jedem, der mit dem Accessor mem() danach fragt, eine Referenz darauf zuspielen. So kann ein Plugin wie Account.pm mit

    $ctx->mem()->{account_total} = 0;

eine Variable setzen, die jeder andere Plugin ebenso zugreifen kann, der ebenfalls mit $ctx eine Referenz auf den Plugin-Verwalter Plugger besitzt.

Auf diese Weise kommunizieren die zwei Plugins Account.pm und Position.pm miteinander: Account.pm setzt account_total am Anfang auf 0, und Position.pm, das mit jeder stock- oder cash-Definition drankommt, rechnet deren Wert aus und addiert ihn zu account_total hinzu.

Listing 4: Position.pm

    01 ###########################################
    02 package Plugger::Position;
    03 # 2004, Mike Schilli <m@perlmeister.com>
    04 ###########################################
    05 use strict; use warnings;
    06 use Log::Log4perl qw(:easy);
    07 use Finance::YahooQuote;
    08 use Term::ANSIColor;
    09 
    10 ###########################################
    11 sub init {
    12 ###########################################
    13     my($class, $ctx) = @_;
    14 
    15     DEBUG "Registering @_";
    16 
    17     $ctx->register_cmd("stock",
    18         undef, \&process, undef);
    19     $ctx->register_cmd("cash",
    20         undef, \&process_cash, undef);
    21     $ctx->register_cmd("txcash",
    22         undef, \&process_tx_cash, undef);
    23 }
    24 
    25 ###########################################
    26 sub process {
    27 ###########################################
    28     my($ctx, $cmd, @args) = @_;
    29 
    30     my $value = price($args[0]) * $args[1];
    31     my $gain  = $value - 
    32                 $args[2] * $args[1];
    33 
    34     Plugger::Account::position(
    35        ucfirst($cmd),
    36        @args[0..2], price($args[0]),
    37        $value, $gain);
    38 
    39     my $mem = $ctx->mem();
    40     $mem->{account_subtotal} += $value;
    41     $mem->{account_total}    += $value;
    42 }
    43 
    44 ###########################################
    45 sub process_cash {
    46 ###########################################
    47     my($ctx, $cmd, @args) = @_;
    48 
    49     my $mem = $ctx->mem();
    50     $mem->{account_subtotal} += $args[0];
    51     $mem->{account_total}    += $args[0];
    52 
    53     Plugger::Account::position(
    54        ucfirst($cmd),
    55        (undef) x 4, $args[0], undef);
    56 }
    57 
    58 ###########################################
    59 sub process_tx_cash {
    60 ###########################################
    61     my($ctx, $cmd, @args) = @_;
    62 
    63     $args[0] /= 2.0;
    64     process_cash($ctx, $cmd, @args);
    65 }
    66 
    67 use Cache::FileCache;
    68 
    69 my $cache = Cache::FileCache->new(
    70   { namespace          => 'Dagobert',
    71     default_expires_in => 600,
    72   });
    73 
    74 ###########################################
    75 sub price {
    76 ###########################################
    77     my($stock) = @_;
    78 
    79     DEBUG "Fetching quote for $stock";
    80 
    81     my $cached = $cache->get($stock);
    82 
    83     if(defined $cached) {
    84          DEBUG "Using cached value: $cached";
    85          return $cached;
    86     }
    87 
    88     my @quote = getonequote $stock;
    89     die "Cannot get quote for $stock" 
    90         unless @quote;
    91     $cache->set($stock, $quote[2]);
    92 
    93     return $quote[2];
    94 }
    95 
    96 1;

Farblich verschönert

Damit Account.pm die Kopfzeilen eines Kontos und dessen Stand schön fett und in blau anzeigt, nutzt es das CPAN-Modul Term::ANSIColor. Dessen :constants-Tag am Ende der use-Anweisung veranlasst es, 'Konstanten' wie BLUE (blauer Text), BOLD (Fettdruck) und RESET (zurück zur Normalschrift) in den Namensraum des aufrufenden Skripts zu exportieren. Anschließend geben print-Anweisungen wie

    print BLUE, BOLD, 
          "In blau und fett!", RESET;

spezielle ANSI-Sonderzeichen aus, die den angegebenen Text im aktuellen Terminal fett und blau markieren, bevor RESET für die nächste print-Anweisung wieder in den Normalmodus zurückschaltetet.

Onlinekurse mit Zwischenspeicher

Aktuelle (gut, 20 Minuten verzögerte) Börsenkurse holt Position einfach mit Finance::YahooQuote von Yahoos Finanzseite. Die exportierte Funktion getonequote() nimmt ein Tickersymbol wie BMWG.DE für die deutsche Notierung der BMW-Aktie oder EBAY für die Ebay-Notierung an amerikanischen Börsen. Eine ausführliche Liste deutscher Tickersymbole findet sich auf [3].

Da dagobert aber unter Umständen denselben Kurs mehrmals braucht, puffert Position die Kurse einfach in einem 10 Minuten lang bestehenden Cache. Das Modul Cache::Cache vom CPAN bietet eine denkbar einfache Schnittstelle: set() um einen Cache-Eintrag zu setzen und get() um ihn wieder zu holen. An Implementierungen bietet es unter anderem einen In-Memory-Cache namens Cache::MemoryCache und mit Cache::FileCache einen Datei-gestützten persistenten Cache. Position.pm legt mit

    my $cache = Cache::FileCache->new(
      { namespace          => 'Dagobert',
        default_expires_in => 600,
      });

ein neues Cache-Objekt an und kümmert sich um alle Details, wie die effiziente Speicherung in temporären Dateien ohne mit anderen Applikationen zu kollidieren. Der Anwender braucht nur noch $cache->set() und $cache->get() aufzurufen.

Plugin für Herrn Sondermann

Für eine einfache Kontoanzeige ist dagobert natürlich vollkommen Over-Engineered, offensichtlich das Werk eines Architektur-Astronauten wie Joel Spolsky es in [4] so treffend formuliert! Das Plugger-Framework läuft allerdings erst zu seiner vollen Spielstärke auf, wenn es darum geht, benutzerspezifische Funktionalität einzuhängen, ohne den ursprünglichen Code zu verändern.

Der Plugin Plugger/TaxedPosition.pm zeigt ein solches Beispiel: Er verbucht bei mit txstock definierten Aktien eventuelle Gewinne nicht 1:1, sondern zieht vorher 50% Abgaben ab. Im diesem ``Ab auf die Insel''-Modus lässt sich ausrechnen, wieviel Geld man bei einer sofortigen Realisierung von eventuellen Aktiengewinnen und einem virtuellen Steuersatz von 50% übrig behielte. Bei Aktienverlusten zieht TaxedPosition nichts ab, sondern zeigt den Wert der Depotposition an, die nach dem Verkauf der Versageraktie übrigbliebe.

Je nach persönlicher Situation kann nun der Anwender Plugins für weitere Schlüsselworte schreiben, ins Framework einhängen und das System anpassen.

Da TaxedPosition.pm auf die in Position.pm definierte Funktion price() zurückgreift, liegt es nahe, eine Art Vererbung oder Interface-Mechanismus zwischen TaxedPosition.pm und Position.pm einzusetzen. Da im Plugger-Framework jedoch keine Klassen im Spiel sind, definiert TaxedPosition.pm in Zeile 9 einfach einen AUTOLOAD-Handler, der Aufrufe von unbekannten Funktionen nach Position.pm kanalisiert.

So übernimmt der Plugin Position.pm aus Gründen der Übersichtlich- und Wartbarkeit alle sichtbaren Bildschirmausgaben. Die Funktion position() nimmt die Daten einer Position mit Typ, Tickersymbol, Anzahl, Kaufkurs, aktuellem Kurs, aktuellem Gesamtwert und Gewinn/Verlust entgegen und gibt sie schön formatiert aus. Für Bargeldposten entfällt alles außer der linken und der rechten Spalte. Weitere eingefügte Plugins sollten, wie TaxedPosition.pm, ebenfalls auf die print-Funktionen von Position.pm zurückgreifen. Auch die Funktion price() aus Position.pm dürfte für den ein oder anderen Plugin von Nutzen sein.

Listing 5: TaxedPosition.pm

    01 ###########################################
    02 package Plugger::TaxedPosition;
    03 # 2004, Mike Schilli <m@perlmeister.com>
    04 ###########################################
    05 use strict; use warnings;
    06 use Log::Log4perl qw(:easy);
    07 
    08 ###########################################
    09 sub AUTOLOAD {
    10 ###########################################
    11     no strict qw(vars refs);
    12 
    13     (my $func = $AUTOLOAD) =~ 
    14         s/.*::/Plugger::Position::/;
    15     $func->(@_);
    16 }
    17 
    18 ###########################################
    19 sub init {
    20 ###########################################
    21     my($class, $ctx) = @_;
    22 
    23     $ctx->register_cmd("txstock",
    24         undef, \&process, undef);
    25 }
    26 
    27 ###########################################
    28 sub process {
    29 ###########################################
    30     my($ctx, $cmd, @args) = @_;
    31 
    32     my $value = price($args[0]) * $args[1];
    33     my $gain  = $value - 
    34                 $args[2] * $args[1];
    35 
    36     my $tax = $gain / 2;
    37 
    38     $value -= $tax if $gain > 0;
    39     $gain  -= $tax if $gain > 0;
    40 
    41     Plugger::Account::position(
    42        ucfirst($cmd),
    43        @args[0..2], price($args[0]),
    44        $value, $gain);
    45 
    46     my $mem = $ctx->mem();
    47     $mem->{account_subtotal} += $value;
    48     $mem->{account_total}    += $value;
    49 }
    50 
    51 1;

Installation

Sowohl das Skript dagobert als auch der kompilierte C-Wrapper dago sollen ins Verzeichnis /usr/bin und dort ausführbar sein. Das Modul Plugger.pm und alle Plugins unter Plugger/ sollten irgendwo in einen der @INC-Pfade der Perl-Installation. Alternativ kann die Zeile

    use lib '/home/mschilli/perl-modules';

im Perlskript dagobert den Pfad bekanntmachen, falls Plugger & Co. unter dem angegebenen Verzeichnis installiert sind. Die Module Module::Pluggable, Finance::YahooQuote und Term::ANSIColor stehen auf dem CPAN bereit und lassen sich am einfachsten mit einer CPAN-Shell installieren. Auf geht's, zählt euren Zaster!

Infos

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

[2]
Module::Pluggable Tutorial, http://www.perladvent.org/2004/6th

[3]
Kürzel für bekannte deutsche Aktien: http://de.biz.yahoo.com/p/de/cpi/cpib0.html

[4]
``Don't let Architecture Astronauts scare you'', Joel Spolsky, ``Joel on Software'', Apress 2004, S. 111ff.

TODO: * Finance::Rounding * SWISH::API auf laeppi

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.