Advertisement
Guest User

bpi

a guest
Nov 14th, 2017
172
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Perl 17.62 KB | None | 0 0
  1. #! /usr/bin/env perl
  2. #
  3. use strict;
  4. use warnings qw(FATAL);
  5.  
  6.  
  7. my @gnuplot = ("gnuplot", "-p");
  8.  
  9.  
  10. =pod
  11.  
  12. =head1 NAME
  13.  
  14. bpi.pl - download and plot MOEX Best Private Investor contest data
  15.  
  16. =head1 SYNOPSIS
  17.  
  18.   perl bpi.pl --update[=YEAR]
  19.   perl bpi.pl [OPTIONS] SYMBOL
  20.  
  21. =head2 OPTIONS
  22.  
  23. =over
  24.  
  25. =item C<--update[=YEAR]>
  26.  
  27. Download new data and add it to database.  If year is not given current
  28. year is assumed.
  29.  
  30. =item C<-b, --bond=PRICE>
  31.  
  32. Specify nominal bond price (aka "face value").
  33.  
  34. =item C<-i, --interval=MINUTES>
  35.  
  36. Candle time interval (in minutes).  Intervals are measured from
  37. midnight and won't cross day boundary.  Default is 1440 minutes (1
  38. day).
  39.  
  40. =item C<-f, --from=DATETIME>
  41.  
  42. Start time in format C<YYYY-MM-DD> or C<YYYY-MM-DD hh:mm>.  Start date
  43. is inclusive.  Default is the beginning of data.
  44.  
  45. =item C<-t, --to=DATETIME>
  46.  
  47. End time in format C<YYYY-MM-DD> or C<YYYY-MM-DD hh:mm>.  End date is
  48. not inclusive.  Default is the end of data.
  49.  
  50. =item C<--image=EXT,WIDTH,HEIGHT>
  51.  
  52. Create image file named F<SYMBOL.EXT> with size C<WIDTH> by C<HEIGHT>
  53. pixels.  For example:
  54.  
  55.   --image=png,1920,1080 MGNT
  56.  
  57. will create 1920x1080 F<MGNT.png>, and
  58.  
  59.   --image=svg,1600,1200 AFLT
  60.  
  61. will create scalable F<AFLT.svg> with base dimensions 1600x1200.
  62.  
  63. When C<--image> is not given (the default) the graph will be shown in
  64. an interactive window.
  65.  
  66. =item C<--fontscale=SCALE>
  67.  
  68. Scale font by C<SCALE> factor (float).  Default is 1.0.  You may set
  69. this to lower value if labels overlap.
  70.  
  71. =item C<-r, --root=DIR>
  72.  
  73. Directory of F<YEAR.db> file(s) and where to put PNG file(s).
  74. Default is the directory where this script is located.
  75.  
  76. Do not remove F<YEAR.db-journal> file if it exists or database will
  77. become corrupt.
  78.  
  79. =item C<-h, --help>
  80.  
  81. Print this message and exit.
  82.  
  83. =back
  84.  
  85. =cut
  86.  
  87. my $VERSION = "1.1";
  88.  
  89.  
  90. use Getopt::Long qw(:config gnu_getopt);
  91. use Pod::Usage;
  92. use FindBin;
  93. use Net::FTP;
  94. use Net::HTTP;
  95. use Archive::Zip;
  96. use Archive::Zip::MemberRead;
  97. use DBI;
  98.  
  99.  
  100. my %option = (
  101.     update => undef,
  102.     bond => 0,
  103.     interval => 1440,
  104.     from => "",
  105.     to => "",
  106.     image => "",
  107.     fontscale => 1.0,
  108.     root => $FindBin::Bin,
  109. );
  110. if (! GetOptions(\%option, qw(update:0 bond|b=i interval|i=i from|f=s to|t=s
  111.                               image=s fontscale=f root|r=s help|h))
  112.     || @ARGV != (defined $option{update} ? 0 : 1)) {
  113.     pod2usage(1);
  114. }
  115. if (exists $option{help}) {
  116.     pod2usage(0);
  117.     exit;
  118. }
  119. $option{interval} *= 60;
  120. $option{interval} = 60 if $option{interval} < 60;
  121. $option{interval} = 86400 if $option{interval} > 86400;
  122.  
  123.  
  124. my $year;
  125. unless (defined ($year = $option{update})) {
  126.     ($year) = $option{from} =~ /^(\d{4})/;
  127.     ($year) = $option{to} =~ /^(\d{4})/ unless $year;
  128. }
  129. $year = (localtime)[5] + 1900 unless $year;
  130.  
  131.  
  132. chdir($option{root})
  133.     or die "Can't change to directory $option{root}: $!";
  134.  
  135.  
  136. my $attrs = { AutoCommit => 1,
  137.               PrintError => 0,
  138.               RaiseError => 1 };
  139. my $dbh = DBI->connect("DBI:SQLite:dbname=$year.db", "", "", $attrs);
  140. $dbh->do(q{
  141.     PRAGMA page_size = 4096
  142. });
  143. $dbh->do(q{
  144.     CREATE TABLE IF NOT EXISTS source (
  145.         id INTEGER NOT NULL PRIMARY KEY,
  146.         dir INTEGER NOT NULL,
  147.         market INTEGER NOT NULL,
  148.         trader INTEGER NOT NULL,
  149.  
  150.         UNIQUE (trader, market, dir)
  151.     )
  152. });
  153. $dbh->do(q{
  154.     CREATE TABLE IF NOT EXISTS contest (
  155.         source_id INTEGER NOT NULL REFERENCES source (id),
  156.         date_time INTEGER NOT NULL,
  157.         symbol TEXT NOT NULL,
  158.         amount INTEGER NOT NULL,
  159.         price FLOAT NOT NULL
  160.     )
  161. });
  162. $dbh->do(q{
  163.     CREATE VIEW IF NOT EXISTS vcontest AS
  164.     SELECT contest.rowid AS rowid, dir, market, trader,
  165.            datetime(date_time, 'unixepoch') AS date_time, symbol, amount, price
  166.     FROM contest JOIN source ON source_id = id
  167. });
  168.  
  169.  
  170. if (defined $option{update}) {
  171.     update_db();
  172. } else {
  173.     plot_graph();
  174. }
  175.  
  176. exit 0;
  177.  
  178.  
  179. sub update_db {
  180.     $dbh->do(q{
  181.         PRAGMA synchronous = OFF
  182.     });
  183.  
  184.     my $source_exists = $dbh->prepare(q{
  185.         SELECT 1
  186.         FROM source
  187.         WHERE dir = ? AND market = ? AND trader = ?
  188.     });
  189.     my $source_insert = $dbh->prepare(q{
  190.         INSERT INTO source (dir, market, trader)
  191.         VALUES (?, ?, ?)
  192.     });
  193.     my $contest_insert = $dbh->prepare(q{
  194.         INSERT INTO contest (source_id, date_time, symbol, amount, price)
  195.         VALUES (?, strftime('%s', ?), ?, ?, ?)
  196.     });
  197.  
  198.     my $ftp_host = "ftp.moex.com";
  199.     my $ftp_path = "/pub/info/stats_contest/$year";
  200.     my $traders = "trader.csv";
  201.  
  202.     my $ftp = Net::FTP->new($ftp_host)
  203.         or die "Can't connect to $ftp_host: $@";
  204.     $ftp->login
  205.         or die "Can't login to $ftp_host: ", $ftp->message;
  206.     $ftp->cwd($ftp_path)
  207.         or die "Can't change remote directory to $ftp_path: ", $ftp->message;
  208.  
  209.     $| = 1;
  210.     $ftp->binary;
  211.  
  212.     foreach my $dir ($ftp->ls) {
  213.         next unless $dir =~ /^20\d{6}$/;
  214.         my @ls = $ftp->ls($dir);
  215.         foreach my $file (@ls) {
  216.             my ($market, $trader) = $file =~ /^([123])_(\d+)\.zip$/
  217.                 or next;
  218.  
  219.             next if $dbh->selectrow_array($source_exists, undef,
  220.                                           $dir, $market, $trader);
  221.  
  222.             print "Fetching $dir/$file...";
  223.             $dbh->begin_work;
  224.  
  225.             $source_insert->execute($dir, $market, $trader);
  226.             my $source_id = $dbh->last_insert_id(undef, undef, undef, undef);
  227.  
  228.             $ftp->get("$dir/$file", "tmp.zip")
  229.                 or die "Can't fetch $dir/$file: ", $ftp->message;
  230.             my $zip = Archive::Zip->new("tmp.zip");
  231.             my $csv = Archive::Zip::MemberRead->new($zip,
  232.                                                     "${market}_$trader.csv");
  233.             while (my $line = $csv->getline) {
  234.                 my ($date_time, $symbol, $amount, $price) = split /;/, $line;
  235.                 $symbol =~ s/\s+//g;
  236.                 $contest_insert->execute($source_id, $date_time, $symbol,
  237.                                          $amount, $price);
  238.             }
  239.  
  240.             $dbh->commit;
  241.             print "done\n";
  242.         }
  243.     }
  244.     unlink "tmp.zip" if -e "tmp.zip";
  245.  
  246.     print "Fetching $traders...";
  247.     $ftp->get($traders, "tmp.csv")
  248.         or die "Can't fetch $traders: ", $ftp->message;
  249.     $ftp->quit;
  250.     print "done\n";
  251.  
  252.     # To avoid possible encoding problems on Windows we hardcode
  253.     # market names as sequences of UTF-8 bytes.
  254.     my %market_name2index = (
  255.         # Fondovy
  256.         "\x{d0}\x{a4}\x{d0}\x{be}\x{d0}\x{bd}\x{d0}\x{b4}\x{d0}\x{be}"
  257.         . "\x{d0}\x{b2}\x{d1}\x{8b}\x{d0}\x{b9}" => 1,
  258.         # Srochny
  259.         "\x{d0}\x{a1}\x{d1}\x{80}\x{d0}\x{be}\x{d1}\x{87}\x{d0}\x{bd}"
  260.         . "\x{d1}\x{8b}\x{d0}\x{b9}" => 2,
  261.         # Valutny
  262.         "\x{d0}\x{92}\x{d0}\x{b0}\x{d0}\x{bb}\x{d1}\x{8e}\x{d1}\x{82}"
  263.         . "\x{d0}\x{bd}\x{d1}\x{8b}\x{d0}\x{b9}" => 3
  264.     );
  265.  
  266.     open(my $fh, "<", "tmp.csv")
  267.         or die "Can't open file tmp.csv: $!";
  268.     while (<$fh>) {
  269.         my ($trader) = /;(\d+)/;
  270.  
  271.         next if $dbh->selectrow_array($source_exists, undef, 0, 0, $trader);
  272.  
  273.         print "Fetching initial portfolio for $trader...";
  274.         $dbh->begin_work;
  275.  
  276.         $source_insert->execute(0, 0, $trader);
  277.  
  278.         my @market_source_id;
  279.  
  280.         my $dates = fetch_json("GetDates",
  281.                                "{'traderId':$trader,'isMy':0,'tableId':6}");
  282.         my ($date) = $dates =~ /\\"(\d{4}-\d\d-\d\d)\\"}\]"}$/;
  283.         unless ($date) {
  284.             $dbh->rollback;
  285.             print "missing\n";
  286.             next;
  287.         }
  288.  
  289.         my $portfolio = fetch_json("GetPortfolioData",
  290.                              "{'traderId':$trader,'date':'$date','tableId':6}");
  291.  
  292.         $portfolio =~ s/^{"d":"\[//;
  293.         while ($portfolio =~ /\G[^{]*({[^}]*})/g) {
  294.             my $rec = $1;
  295.  
  296.             my ($pos, $change) = $rec =~ /"pos\\?"[^"]+"(-?\d+)\s+\(([+-]\d*)/;
  297.             $pos -= $change if $change ne "-";
  298.             next if $pos == 0;
  299.  
  300.             my ($seccode) = $rec =~ /"seccode\\?"[^"]+"([^ \\"]+)/;
  301.  
  302.             my ($cost) = $rec =~ /"cost\\?"[^"]+"(-?\d*(?:\.\d*)?)/;
  303.             my ($saldo) = $rec =~ /"saldo\\?"[^\d+-]+([+-]?\d+(?:\.\d*)?)/;
  304.             my $price = $cost ne "-" ? ($cost - $saldo) / $pos : 0;
  305.  
  306.             my ($contype_name) = $rec =~ /"contype_name\\?"[^"]+"([^ \\"]+)/;
  307.             my $market = $market_name2index{$contype_name};
  308.  
  309.             unless (defined $market_source_id[$market]) {
  310.                 $source_insert->execute(0, $market, $trader);
  311.                 $market_source_id[$market] =
  312.                     $dbh->last_insert_id(undef, undef, undef, undef);
  313.             }
  314.             $contest_insert->execute($market_source_id[$market],
  315.                                      "$date 00:00:00", $seccode, $pos, $price);
  316.         }
  317.  
  318.         $dbh->commit;
  319.         print "done\n";
  320.     }
  321.     close $fh;
  322.     unlink "tmp.csv";
  323. }
  324.  
  325.  
  326. sub fetch_json {
  327.     my ($name, $request) = @_;
  328.  
  329.     my $http_host = "investor.moex.com";
  330.     my $http_path = "/ru/statistics/$year/portfolio.aspx/$name";
  331.  
  332.     my $http = Net::HTTP->new(Host => $http_host)
  333.         or die "Can't connect to $http_host: $@";
  334.     $http->write_request(POST => $http_path,
  335.                          "Content-Type" => "application/json", $request)
  336.         or die "Can't POST $http_path: $@";
  337.     my ($status, $message) = $http->read_response_headers;
  338.     $status == 200 or die "$http_path request failed: $status $message";
  339.     my $json = "";
  340.     while (1) {
  341.         my $buf;
  342.         my $res = $http->read_entity_body($buf, 1024);
  343.         defined $res or die "$http_path response read failed: $!";
  344.         last unless $res;
  345.         $json .= $buf;
  346.     }
  347.     return $json;
  348. }
  349.  
  350.  
  351. sub plot_graph {
  352.     open(my $plot, "|-", @gnuplot)
  353.         or die "Can't start @gnuplot: $!";
  354.     print $plot "\$data << EOD\n"
  355.         or die "Can't pipe to @gnuplot: $!";
  356.  
  357.     my %position = (
  358.         total_amount_long => 0,
  359.         total_amount_short => 0,
  360.         total_money_long => 0,
  361.         total_money_short => 0,
  362.     );
  363.     my $initial_seen = 1;
  364.     my $interval_start = 0;
  365.     my $interval_end = 0;
  366.     my $x_tic = 0;
  367.     my $x_tic_day = 0;
  368.     my ($candle_open, $candle_low, $candle_high, $candle_close) = (0, 0, 0, 0);
  369.  
  370.     my $output_tic_points = sub {
  371.         my ($no_labels) = @_;
  372.  
  373.         my $amount_long = $position{total_amount_long};
  374.         my $price_long = ($amount_long
  375.                           ? $position{total_money_long} / $amount_long
  376.                           : "NaN");
  377.         my $amount_short = -$position{total_amount_short};
  378.         my $price_short = ($amount_short
  379.                            ? -$position{total_money_short} / $amount_short
  380.                            : "NaN");
  381.  
  382.         my $x_label = "";
  383.         my $x2_label = "";
  384.         unless ($no_labels) {
  385.             my @date_time = gmtime($interval_start);
  386.             $x_label = sprintf("%02d:%02d", @date_time[2, 1]);
  387.             my $day = int($interval_start / 86400);
  388.             if ($x_tic_day != $day) {
  389.                 $x_tic_day = $day;
  390.                 $x_label = sprintf("%02d/%02d $x_label",
  391.                                    $date_time[4] + 1, $date_time[3]);
  392.             }
  393.  
  394.             my $ratio = "  ";
  395.             if ($amount_long && $amount_short) {
  396.                 if ($amount_long == $amount_short) {
  397.                     $ratio = "=";
  398.                 } else {
  399.                     $ratio = sprintf("%.1f", ($amount_long > $amount_short
  400.                                               ? $amount_long / $amount_short
  401.                                               : -$amount_short / $amount_long));
  402.                 }
  403.             }
  404.             my $gain_loss = "";
  405.             if ($x_tic) {
  406.                 my $margin_short = 1;
  407.                 # Money to close short position.
  408.                 my $money_margin_short =
  409.                     $candle_close * $amount_short * $margin_short;
  410.                 my $money_start =
  411.                     ($position{total_money_long} + $money_margin_short
  412.                      # Minus negative $position{total_money_short}.
  413.                      + $position{total_money_short});
  414.                 my $money_end =
  415.                     ($money_margin_short
  416.                      + $candle_close * ($amount_long - $amount_short));
  417.                 $gain_loss = sprintf("(%.2f%%)",
  418.                                      ($money_end / $money_start - 1) * 100);
  419.             }
  420.             $x2_label = "$ratio $gain_loss";
  421.         }
  422.  
  423.         my $data = join(" ", $x_tic, qq{"$x_label"}, qq{"$x2_label"},
  424.                         $price_long, $price_short, $amount_long, $amount_short);
  425.         $data .= join(" ", "",
  426.                       $candle_open, $candle_low, $candle_high, $candle_close)
  427.             unless $initial_seen;
  428.         print $plot $data, "\n"
  429.             or die "Can't pipe to @gnuplot: $!";
  430.     };
  431.  
  432.     my $select = $dbh->prepare(qq{
  433.         SELECT dir = 0, trader, date_time, amount, price,
  434.             @{[ $option{from} ? "date_time >= strftime('%s', ?)" : "?" ]}
  435.         FROM contest JOIN source ON source_id = id
  436.         WHERE symbol = ?
  437.             AND @{[ $option{to} ? "date_time < strftime('%s', ?)" : "?" ]}
  438.         ORDER BY date_time, contest.rowid
  439.     });
  440.     $select->execute($option{from} || 1, $ARGV[0], $option{to} || 1);
  441.     $select->bind_columns(\my ($initial, $trader, $date_time,
  442.                                $amount, $price, $in_range));
  443.     my $bond_face2percent = $option{bond} ? 100 / $option{bond} : 0;
  444.     while ($select->fetch) {
  445.         $price *= $bond_face2percent if $bond_face2percent && $initial;
  446.  
  447.         if ($in_range) {
  448.             if ($initial) {
  449.                 unless ($initial_seen) {
  450.                     $output_tic_points->(1) if $x_tic;
  451.                     $initial_seen = 1;
  452.                 }
  453.             } elsif ($date_time >= $interval_end) {
  454.                 ($interval_start, $interval_end) = interval($date_time);
  455.                 $output_tic_points->();  # Flush either candle or step.
  456.                 $initial_seen = 0;
  457.                 ++$x_tic;
  458.  
  459.                 $candle_open = $price;
  460.                 $candle_low = $price;
  461.                 $candle_high = $price;
  462.                 $candle_close = $price;
  463.             } else {
  464.                 $candle_low = $price if $candle_low > $price;
  465.                 $candle_high = $price if $candle_high < $price;
  466.                 $candle_close = $price;
  467.             }
  468.         }
  469.  
  470.         position_update(\%position, $trader, $amount, $price);
  471.     }
  472.     if ($x_tic) {
  473.         $interval_start = $interval_end;
  474.         $output_tic_points->();  # Flush either final candle or step.
  475.     }
  476.  
  477.     print $plot "EOD\n" or die "Can't pipe to @gnuplot: $!";
  478.  
  479.     die "No data to plot\n" unless $x_tic;
  480.  
  481.     if (my ($type, $size) = $option{image} =~ /^([^,]+),(\d+,\d+)$/) {
  482.         print $plot qq{
  483.             set terminal $type size $size
  484.             set output "$ARGV[0].$type"
  485.         } or die "Can't pipe to @gnuplot: $!";
  486.     }
  487.     print $plot qq{
  488.         set termoption fontscale $option{fontscale}
  489.         set grid xtics
  490.         set xtics nomirror out rotate right
  491.         set x2tics nomirror out rotate
  492.         set ytics nomirror out
  493.         set y2tics nomirror out
  494.         set format y "%.0f"
  495.         set boxwidth 0.65
  496.         set key outside center top horizontal height 2 \\
  497.             title "$ARGV[0] ($year)  [bpi.pl v$VERSION]"
  498.         set x2label \\
  499.             "Amount ratio (>1: long/short; <-1: -short/long), gain/loss percent"
  500.         set ylabel "Amount"
  501.         set y2label "Price"
  502.  
  503.         plot \$data using (\$1-0.5):8:9:10:11 axes x1y2 title "Price candle" \\
  504.                  with candlesticks lc rgb "#0000ff", \\
  505.              \$data using 1:4 axes x1y2 title "Long average price" \\
  506.                  with lines lw 2 lc rgb "#00ff00", \\
  507.              \$data using 1:5 axes x1y2 title "Short average price" \\
  508.                  with lines lw 2 lc rgb "#ff0000", \\
  509.              \$data using 1:6:xtic(2) axes x1y1 title "Long amount" \\
  510.                  with lines lc rgb "#00a000" dt 3, \\
  511.              \$data using 1:7:x2tic(3) axes x1y1 title "Short amount" \\
  512.                  with lines lc rgb "#a00000" dt 3
  513.     } or die "Can't pipe to @gnuplot: $!";
  514. }
  515.  
  516.  
  517. sub position_update {
  518.     my ($position, $trader, $amount, $price) = @_;
  519.  
  520.     $position->{$trader} = { amount => 0, money => 0 }
  521.         unless $position->{$trader};
  522.  
  523.     my $old_type = $position->{$trader}{amount} > 0 ? "long" : "short";
  524.     $position->{"total_amount_$old_type"} -= $position->{$trader}{amount};
  525.     $position->{"total_money_$old_type"} -= $position->{$trader}{money};
  526.  
  527.     $position->{$trader}{amount} += $amount;
  528.  
  529.     my $new_type = $position->{$trader}{amount} > 0 ? "long" : "short";
  530.     if ($position->{$trader}{amount} && $new_type eq $old_type) {
  531.         $position->{$trader}{money} += $amount * $price;
  532.     } else {
  533.         # Position has been closed or flipped, reset any previous gain/loss.
  534.         $position->{$trader}{money} = $position->{$trader}{amount} * $price;
  535.     }
  536.  
  537.     $position->{"total_amount_$new_type"} += $position->{$trader}{amount};
  538.     $position->{"total_money_$new_type"} += $position->{$trader}{money};
  539. }
  540.  
  541.  
  542. sub interval {
  543.     my ($date_time) = @_;
  544.  
  545.     # There are no daylight saving times and leap seconds to worry about.
  546.     my $day_start = $date_time - $date_time % 86400;
  547.     my $interval_start =
  548.         $date_time - ($date_time - $day_start) % $option{interval};
  549.     my $interval_end = $interval_start + $option{interval};
  550.     $interval_end = $day_start + 86400 if $interval_end > $day_start + 86400;
  551.     return ($interval_start, $interval_end);
  552. }
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement