| line | stmt | bran | cond | sub | pod | time | code | 
| 1 |  |  |  |  |  |  | # London Metal Exchange (LME) setups. | 
| 2 |  |  |  |  |  |  |  | 
| 3 |  |  |  |  |  |  | # Copyright 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2013, 2016 Kevin Ryde | 
| 4 |  |  |  |  |  |  |  | 
| 5 |  |  |  |  |  |  | # This file is part of Chart. | 
| 6 |  |  |  |  |  |  | # | 
| 7 |  |  |  |  |  |  | # Chart is free software; you can redistribute it and/or modify it under the | 
| 8 |  |  |  |  |  |  | # terms of the GNU General Public License as published by the Free Software | 
| 9 |  |  |  |  |  |  | # Foundation; either version 3, or (at your option) any later version. | 
| 10 |  |  |  |  |  |  | # | 
| 11 |  |  |  |  |  |  | # Chart is distributed in the hope that it will be useful, but WITHOUT ANY | 
| 12 |  |  |  |  |  |  | # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS | 
| 13 |  |  |  |  |  |  | # FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more | 
| 14 |  |  |  |  |  |  | # details. | 
| 15 |  |  |  |  |  |  | # | 
| 16 |  |  |  |  |  |  | # You should have received a copy of the GNU General Public License along | 
| 17 |  |  |  |  |  |  | # with Chart.  If not, see <http://www.gnu.org/licenses/>. | 
| 18 |  |  |  |  |  |  |  | 
| 19 |  |  |  |  |  |  | package App::Chart::Suffix::LME; | 
| 20 | 1 |  |  | 1 |  | 535 | use 5.010; | 
|  | 1 |  |  |  |  | 4 |  | 
| 21 | 1 |  |  | 1 |  | 4 | use strict; | 
|  | 1 |  |  |  |  | 2 |  | 
|  | 1 |  |  |  |  | 16 |  | 
| 22 | 1 |  |  | 1 |  | 4 | use warnings; | 
|  | 1 |  |  |  |  | 2 |  | 
|  | 1 |  |  |  |  | 20 |  | 
| 23 | 1 |  |  | 1 |  | 4 | use Carp; | 
|  | 1 |  |  |  |  | 2 |  | 
|  | 1 |  |  |  |  | 43 |  | 
| 24 | 1 |  |  | 1 |  | 296 | use Date::Calc; | 
|  | 1 |  |  |  |  | 4676 |  | 
|  | 1 |  |  |  |  | 34 |  | 
| 25 | 1 |  |  | 1 |  | 285 | use Date::Parse; | 
|  | 1 |  |  |  |  | 5811 |  | 
|  | 1 |  |  |  |  | 118 |  | 
| 26 | 1 |  |  | 1 |  | 520 | use File::Temp; | 
|  | 1 |  |  |  |  | 12532 |  | 
|  | 1 |  |  |  |  | 67 |  | 
| 27 | 1 |  |  | 1 |  | 7 | use File::Basename; | 
|  | 1 |  |  |  |  | 3 |  | 
|  | 1 |  |  |  |  | 47 |  | 
| 28 | 1 |  |  | 1 |  | 399 | use HTML::Form; | 
|  | 1 |  |  |  |  | 17134 |  | 
|  | 1 |  |  |  |  | 36 |  | 
| 29 | 1 |  |  | 1 |  | 8 | use List::Util; | 
|  | 1 |  |  |  |  | 2 |  | 
|  | 1 |  |  |  |  | 55 |  | 
| 30 | 1 |  |  | 1 |  | 452 | use File::Slurp; | 
|  | 1 |  |  |  |  | 5267 |  | 
|  | 1 |  |  |  |  | 82 |  | 
| 31 | 1 |  |  | 1 |  | 8 | use URI::Escape; | 
|  | 1 |  |  |  |  | 2 |  | 
|  | 1 |  |  |  |  | 49 |  | 
| 32 | 1 |  |  | 1 |  | 381 | use Locale::TextDomain ('App-Chart'); | 
|  | 1 |  |  |  |  | 6327 |  | 
|  | 1 |  |  |  |  | 10 |  | 
| 33 |  |  |  |  |  |  |  | 
| 34 | 1 |  |  | 1 |  | 5958 | use App::Chart; | 
|  | 0 |  |  |  |  |  |  | 
|  | 0 |  |  |  |  |  |  | 
| 35 |  |  |  |  |  |  | use App::Chart::Database; | 
| 36 |  |  |  |  |  |  | use App::Chart::Download; | 
| 37 |  |  |  |  |  |  | use App::Chart::DownloadHandler; | 
| 38 |  |  |  |  |  |  | use App::Chart::Sympred; | 
| 39 |  |  |  |  |  |  | use App::Chart::Timebase::Months; | 
| 40 |  |  |  |  |  |  | use App::Chart::TZ; | 
| 41 |  |  |  |  |  |  | use App::Chart::Weblink; | 
| 42 |  |  |  |  |  |  |  | 
| 43 |  |  |  |  |  |  | use constant DEBUG => 0; | 
| 44 |  |  |  |  |  |  |  | 
| 45 |  |  |  |  |  |  |  | 
| 46 |  |  |  |  |  |  | # As of July 2007, in https requests to secure.lme.com for the daily metals | 
| 47 |  |  |  |  |  |  | # prices it seems essential to use http/1.1 persistent connections.  If | 
| 48 |  |  |  |  |  |  | # "Connection: close" is requested by the client something fishy happens and | 
| 49 |  |  |  |  |  |  | # the connection hangs at about byte 48887 out of about 62110 (waiting for | 
| 50 |  |  |  |  |  |  | # the last 16kbyte tls packet).  This is with either gnutls or openssl and a | 
| 51 |  |  |  |  |  |  | # trace with gnutls shows it just stops sending, though the TCP connection | 
| 52 |  |  |  |  |  |  | # remains up.  Either the default http/1.1 persistence (no Connection header | 
| 53 |  |  |  |  |  |  | # at all) or the compatibility "Connection: keep-alive" style seems to make | 
| 54 |  |  |  |  |  |  | # it better.  Presumably it's something buggy in the server (Microsoft-IIS | 
| 55 |  |  |  |  |  |  | # 6.0). | 
| 56 |  |  |  |  |  |  |  | 
| 57 |  |  |  |  |  |  | my $pred = App::Chart::Sympred::Suffix->new ('.LME'); | 
| 58 |  |  |  |  |  |  | App::Chart::TZ->london->setup_for_symbol ($pred); | 
| 59 |  |  |  |  |  |  |  | 
| 60 |  |  |  |  |  |  | # App::Chart::setup_source_help | 
| 61 |  |  |  |  |  |  | #   ($pred, __p('manual-node','London Metal Exchange')); | 
| 62 |  |  |  |  |  |  |  | 
| 63 |  |  |  |  |  |  |  | 
| 64 |  |  |  |  |  |  | my %polypropylene_hash = ('PP'=>1,'PA'=>1,'PE'=>1,'PN'=>1); | 
| 65 |  |  |  |  |  |  | my %linearlow_hash     = ('LP'=>1,'LA'=>1,'LE'=>1,'LN'=>1); | 
| 66 |  |  |  |  |  |  | my %steel_hash         = ('FM'=>1,'FF'=>1); | 
| 67 |  |  |  |  |  |  |  | 
| 68 |  |  |  |  |  |  | sub type { | 
| 69 |  |  |  |  |  |  | my ($symbol) = @_; | 
| 70 |  |  |  |  |  |  | my $commodity = App::Chart::symbol_commodity ($symbol); | 
| 71 |  |  |  |  |  |  | if ($polypropylene_hash{$commodity} || $linearlow_hash{$commodity}) { | 
| 72 |  |  |  |  |  |  | return 'plastics'; | 
| 73 |  |  |  |  |  |  | } | 
| 74 |  |  |  |  |  |  | if ($steel_hash{$commodity}) { | 
| 75 |  |  |  |  |  |  | return 'steels'; | 
| 76 |  |  |  |  |  |  | } | 
| 77 |  |  |  |  |  |  | return 'metals'; | 
| 78 |  |  |  |  |  |  | } | 
| 79 |  |  |  |  |  |  |  | 
| 80 |  |  |  |  |  |  | #----------------------------------------------------------------------------- | 
| 81 |  |  |  |  |  |  | # weblink - commodity pages | 
| 82 |  |  |  |  |  |  |  | 
| 83 |  |  |  |  |  |  | App::Chart::Weblink->new | 
| 84 |  |  |  |  |  |  | (pred => $pred, | 
| 85 |  |  |  |  |  |  | name => __('LME _Commodity Page'), | 
| 86 |  |  |  |  |  |  | desc => __('Open web browser at the London Metal Exchange page for this commodity'), | 
| 87 |  |  |  |  |  |  | proc => sub { | 
| 88 |  |  |  |  |  |  | my ($symbol) = @_; | 
| 89 |  |  |  |  |  |  |  | 
| 90 |  |  |  |  |  |  | if ($symbol =~ /^AA/) { return 'http://www.lme.co.uk/aluminiumalloy.asp' } | 
| 91 |  |  |  |  |  |  | if ($symbol =~ /^AH/) { return 'http://www.lme.co.uk/aluminium.asp' } | 
| 92 |  |  |  |  |  |  | if ($symbol =~ /^CA/) { return 'http://www.lme.co.uk/copper.asp' } | 
| 93 |  |  |  |  |  |  | if ($symbol =~ /^NA/) { return 'http://www.lme.co.uk/nasaac.asp' } | 
| 94 |  |  |  |  |  |  | if ($symbol =~ /^NI/) { return 'http://www.lme.co.uk/nickel.asp' } | 
| 95 |  |  |  |  |  |  | if ($symbol =~ /^PB/) { return 'http://www.lme.co.uk/lead.asp' } | 
| 96 |  |  |  |  |  |  | if ($symbol =~ /^SN/) { return 'http://www.lme.co.uk/tin.asp' } | 
| 97 |  |  |  |  |  |  | if ($symbol =~ /^ZS/) { return 'http://www.lme.co.uk/zinc.asp' } | 
| 98 |  |  |  |  |  |  | if ($symbol =~ /^F/)  { return 'http://www.lme.co.uk/steel.asp' } | 
| 99 |  |  |  |  |  |  | if ($symbol =~ /^P/)  { return 'http://www.lme.co.uk/plastics.asp' } | 
| 100 |  |  |  |  |  |  | if ($symbol =~ /^L/)  { return 'http://www.lme.co.uk/plastics.asp' } | 
| 101 |  |  |  |  |  |  | return undef; | 
| 102 |  |  |  |  |  |  | }); | 
| 103 |  |  |  |  |  |  |  | 
| 104 |  |  |  |  |  |  |  | 
| 105 |  |  |  |  |  |  | #----------------------------------------------------------------------------- | 
| 106 |  |  |  |  |  |  | # HTTP::Cookies extras | 
| 107 |  |  |  |  |  |  |  | 
| 108 |  |  |  |  |  |  | # $jar is a HTTP::Cookies object, read $str into it with $jar->load (which | 
| 109 |  |  |  |  |  |  | # would normally read from a file) | 
| 110 |  |  |  |  |  |  | # | 
| 111 |  |  |  |  |  |  | sub http_cookies_set_string { | 
| 112 |  |  |  |  |  |  | my ($jar, $str) = @_; | 
| 113 |  |  |  |  |  |  | my $fh = File::Temp->new (TEMPLATE => 'chart-cookie-jar-XXXXXX', | 
| 114 |  |  |  |  |  |  | TMPDIR => 1); | 
| 115 |  |  |  |  |  |  | if (DEBUG) { print "cookie set tempfile ",$fh->filename,"\n"; } | 
| 116 |  |  |  |  |  |  | print $fh $str; | 
| 117 |  |  |  |  |  |  | close $fh or die; | 
| 118 |  |  |  |  |  |  | $jar->load ($fh->filename); | 
| 119 |  |  |  |  |  |  | } | 
| 120 |  |  |  |  |  |  |  | 
| 121 |  |  |  |  |  |  | # $jar is a HTTP::Cookies object, return a string which is $jar->save output | 
| 122 |  |  |  |  |  |  | # (which would normally go to a file) | 
| 123 |  |  |  |  |  |  | # | 
| 124 |  |  |  |  |  |  | sub http_cookies_get_string { | 
| 125 |  |  |  |  |  |  | my ($jar) = @_; | 
| 126 |  |  |  |  |  |  | my $fh = File::Temp->new (TEMPLATE => 'chart-cookie-jar-XXXXXX', | 
| 127 |  |  |  |  |  |  | TMPDIR => 1); | 
| 128 |  |  |  |  |  |  | if (DEBUG) { print "cookie get $fh tempfile ",$fh->filename,"\n"; } | 
| 129 |  |  |  |  |  |  | $jar->save ($fh->filename); | 
| 130 |  |  |  |  |  |  | close $fh or die; | 
| 131 |  |  |  |  |  |  | # not certain if File::Temp 0.21 blessed handle is ok, use the filename | 
| 132 |  |  |  |  |  |  | return File::Slurp::slurp ($fh->filename); | 
| 133 |  |  |  |  |  |  | } | 
| 134 |  |  |  |  |  |  |  | 
| 135 |  |  |  |  |  |  |  | 
| 136 |  |  |  |  |  |  | #----------------------------------------------------------------------------- | 
| 137 |  |  |  |  |  |  | # secure login | 
| 138 |  |  |  |  |  |  | # | 
| 139 |  |  |  |  |  |  | # This logs in at the data service page, | 
| 140 |  |  |  |  |  |  | # | 
| 141 |  |  |  |  |  |  | use constant LOGIN_URL => | 
| 142 |  |  |  |  |  |  | 'https://secure.lme.com/Data/Community/Login.aspx?ReturnUrl=%2fData%2fcommunity%2findex.aspx'; | 
| 143 |  |  |  |  |  |  | # | 
| 144 |  |  |  |  |  |  | # The result is a cookie ".ASPXAUTH" recorded under "lme-cookie-jar" in the | 
| 145 |  |  |  |  |  |  | # database ready for subsequent use.  An extra cookie with a dummy domain, | 
| 146 |  |  |  |  |  |  | # | 
| 147 |  |  |  |  |  |  | use constant LOGIN_DOMAIN  => 'chart-lme-logged-in.local'; | 
| 148 |  |  |  |  |  |  | # | 
| 149 |  |  |  |  |  |  | # is used to note success.  Not sure how long a login is supposed to last | 
| 150 |  |  |  |  |  |  | # (the server doesn't put an expiry on the cookie), but for now consider it | 
| 151 |  |  |  |  |  |  | # expired after an hour, | 
| 152 |  |  |  |  |  |  | # | 
| 153 |  |  |  |  |  |  | use constant LOGIN_EXPIRY_SECONDS => 3600; | 
| 154 |  |  |  |  |  |  | # | 
| 155 |  |  |  |  |  |  |  | 
| 156 |  |  |  |  |  |  | # create and return a new HTTP::Cookies which is the jar in the database | 
| 157 |  |  |  |  |  |  | sub login_read_jar { | 
| 158 |  |  |  |  |  |  | require HTTP::Cookies; | 
| 159 |  |  |  |  |  |  | my $jar = HTTP::Cookies->new; | 
| 160 |  |  |  |  |  |  | my $str = App::Chart::Database->read_extra ('', 'lme-cookie-jar'); | 
| 161 |  |  |  |  |  |  | if ($str) { http_cookies_set_string ($jar, $str); } | 
| 162 |  |  |  |  |  |  | return $jar; | 
| 163 |  |  |  |  |  |  | } | 
| 164 |  |  |  |  |  |  |  | 
| 165 |  |  |  |  |  |  | # $jar is a HTTP::Cookies object, save it to the database | 
| 166 |  |  |  |  |  |  | sub login_write_jar { | 
| 167 |  |  |  |  |  |  | my ($jar) = @_; | 
| 168 |  |  |  |  |  |  | App::Chart::Database->write_extra ('', 'lme-cookie-jar', | 
| 169 |  |  |  |  |  |  | http_cookies_get_string ($jar)); | 
| 170 |  |  |  |  |  |  | } | 
| 171 |  |  |  |  |  |  |  | 
| 172 |  |  |  |  |  |  | # return true if we're still logged in | 
| 173 |  |  |  |  |  |  | sub login_is_logged_in { | 
| 174 |  |  |  |  |  |  | my $jar = login_read_jar(); | 
| 175 |  |  |  |  |  |  | my $login_timestamp = jar_get_login_timestamp ($jar); | 
| 176 |  |  |  |  |  |  | return App::Chart::Download::timestamp_within ($login_timestamp, | 
| 177 |  |  |  |  |  |  | LOGIN_EXPIRY_SECONDS); | 
| 178 |  |  |  |  |  |  | } | 
| 179 |  |  |  |  |  |  |  | 
| 180 |  |  |  |  |  |  | sub login_ensure { | 
| 181 |  |  |  |  |  |  | if (login_is_logged_in()) { return; } | 
| 182 |  |  |  |  |  |  |  | 
| 183 |  |  |  |  |  |  | App::Chart::Download::status (__('LME login')); | 
| 184 |  |  |  |  |  |  | App::Chart::Database->write_extra ('', 'lme-cookie-jar', undef); | 
| 185 |  |  |  |  |  |  |  | 
| 186 |  |  |  |  |  |  | my $username = App::Chart::Database->preference_get ('lme-username', undef); | 
| 187 |  |  |  |  |  |  | my $password = App::Chart::Database->preference_get ('lme-password', ''); | 
| 188 |  |  |  |  |  |  | if (! defined $username || $username eq '') { | 
| 189 |  |  |  |  |  |  | die 'No LME username set in preferences'; | 
| 190 |  |  |  |  |  |  | } | 
| 191 |  |  |  |  |  |  |  | 
| 192 |  |  |  |  |  |  | require App::Chart::UserAgent; | 
| 193 |  |  |  |  |  |  | require HTTP::Cookies; | 
| 194 |  |  |  |  |  |  | my $ua = App::Chart::UserAgent->instance->clone; | 
| 195 |  |  |  |  |  |  | my $jar = HTTP::Cookies->new; | 
| 196 |  |  |  |  |  |  | $ua->cookie_jar ($jar); | 
| 197 |  |  |  |  |  |  |  | 
| 198 |  |  |  |  |  |  | my $login_url = LOGIN_URL; | 
| 199 |  |  |  |  |  |  | $login_url = 'http://localhost/Login.aspx'; | 
| 200 |  |  |  |  |  |  | my $resp = App::Chart::Download->get ($login_url, ua => $ua); | 
| 201 |  |  |  |  |  |  |  | 
| 202 |  |  |  |  |  |  | my $content = $resp->decoded_content(raise_error=>1); | 
| 203 |  |  |  |  |  |  | my $form = HTML::Form->parse($content, $login_url) | 
| 204 |  |  |  |  |  |  | or die "LME login page not a form"; | 
| 205 |  |  |  |  |  |  |  | 
| 206 |  |  |  |  |  |  | # these are literal "$" in the field name | 
| 207 |  |  |  |  |  |  | $form->value ("_logIn\$_userID",   $username); | 
| 208 |  |  |  |  |  |  | $form->value ("_logIn\$_password", $password); | 
| 209 |  |  |  |  |  |  |  | 
| 210 |  |  |  |  |  |  | my $req = $form->click(); | 
| 211 |  |  |  |  |  |  | $ua->requests_redirectable ([]); | 
| 212 |  |  |  |  |  |  | $resp = $ua->request ($req); | 
| 213 |  |  |  |  |  |  | # The POST is to the Login.aspx page and success is a redirect to the main | 
| 214 |  |  |  |  |  |  | # data page /Data/community/index.aspx.  So failure is anything other than | 
| 215 |  |  |  |  |  |  | # 302, or no Location, or a Location but containing "Login". | 
| 216 |  |  |  |  |  |  | if ($resp->code != 302 | 
| 217 |  |  |  |  |  |  | || ! $resp->header ('Location') | 
| 218 |  |  |  |  |  |  | || $resp->header ('Location') =~ /Login/) { | 
| 219 |  |  |  |  |  |  | die "LME: login failed"; | 
| 220 |  |  |  |  |  |  | } | 
| 221 |  |  |  |  |  |  |  | 
| 222 |  |  |  |  |  |  | jar_set_login_timestamp ($jar); | 
| 223 |  |  |  |  |  |  | login_write_jar ($jar); | 
| 224 |  |  |  |  |  |  | } | 
| 225 |  |  |  |  |  |  |  | 
| 226 |  |  |  |  |  |  |  | 
| 227 |  |  |  |  |  |  | sub jar_get_login_timestamp { | 
| 228 |  |  |  |  |  |  | my ($jar) = @_; | 
| 229 |  |  |  |  |  |  | my $login_timestamp; | 
| 230 |  |  |  |  |  |  | $jar->scan(sub { | 
| 231 |  |  |  |  |  |  | my ($version, $key, $val, $path, $domain, $port, $path_spec, | 
| 232 |  |  |  |  |  |  | $secure, $expires, $discard, $hash) = @_; | 
| 233 |  |  |  |  |  |  | if ($domain eq LOGIN_DOMAIN && $key eq 'timestamp') { | 
| 234 |  |  |  |  |  |  | $login_timestamp = $val; | 
| 235 |  |  |  |  |  |  | } | 
| 236 |  |  |  |  |  |  | }); | 
| 237 |  |  |  |  |  |  | return $login_timestamp; | 
| 238 |  |  |  |  |  |  | } | 
| 239 |  |  |  |  |  |  | sub jar_set_login_timestamp { | 
| 240 |  |  |  |  |  |  | my ($jar) = @_; | 
| 241 |  |  |  |  |  |  | $jar->set_cookie (0,                    # version | 
| 242 |  |  |  |  |  |  | 'timestamp',          # key | 
| 243 |  |  |  |  |  |  | App::Chart::Download::timestamp_now(), # value | 
| 244 |  |  |  |  |  |  | '/',                  # path | 
| 245 |  |  |  |  |  |  | LOGIN_DOMAIN,         # domain | 
| 246 |  |  |  |  |  |  | 0,                    # port | 
| 247 |  |  |  |  |  |  | 0,                    # path_spec | 
| 248 |  |  |  |  |  |  | 0,                    # secure | 
| 249 |  |  |  |  |  |  | LOGIN_EXPIRY_SECONDS, # maxage | 
| 250 |  |  |  |  |  |  | 0);                   # discard | 
| 251 |  |  |  |  |  |  | } | 
| 252 |  |  |  |  |  |  |  | 
| 253 |  |  |  |  |  |  |  | 
| 254 |  |  |  |  |  |  | #----------------------------------------------------------------------------- | 
| 255 |  |  |  |  |  |  | # Daily data | 
| 256 |  |  |  |  |  |  |  | 
| 257 |  |  |  |  |  |  | # return tdate for available daily report | 
| 258 |  |  |  |  |  |  | # | 
| 259 |  |  |  |  |  |  | sub daily_available_date { | 
| 260 |  |  |  |  |  |  | my ($symbol) = @_; | 
| 261 |  |  |  |  |  |  | my $type = type($symbol); | 
| 262 |  |  |  |  |  |  | if ($type eq 'metals') { | 
| 263 |  |  |  |  |  |  | # http://www.lme.co.uk/who_how_ringtimes.asp | 
| 264 |  |  |  |  |  |  | #     Prices after second ring session each trading day, which would be | 
| 265 |  |  |  |  |  |  | #     16:15 maybe, try at 16:30. | 
| 266 |  |  |  |  |  |  | return App::Chart::Download::weekday_date_after_time | 
| 267 |  |  |  |  |  |  | (16,30, App::Chart::TZ->london, -1); | 
| 268 |  |  |  |  |  |  | } | 
| 269 |  |  |  |  |  |  | if ($type eq 'plastics') { | 
| 270 |  |  |  |  |  |  | # https://secure.lme.com/Data/community/Dataprices_daily_prices_plastics.aspx | 
| 271 |  |  |  |  |  |  | # per prices page, available at 2am the following day | 
| 272 |  |  |  |  |  |  | return App::Chart::Download::weekday_date_after_time | 
| 273 |  |  |  |  |  |  | (2,0, App::Chart::TZ->london, -1); | 
| 274 |  |  |  |  |  |  | } | 
| 275 |  |  |  |  |  |  | if ($type eq 'steels') { | 
| 276 |  |  |  |  |  |  | # per prices page, available at 2am the following day | 
| 277 |  |  |  |  |  |  | return App::Chart::Download::weekday_date_after_time | 
| 278 |  |  |  |  |  |  | (2,0, App::Chart::TZ->london, -1); | 
| 279 |  |  |  |  |  |  | } | 
| 280 |  |  |  |  |  |  | die; | 
| 281 |  |  |  |  |  |  | } | 
| 282 |  |  |  |  |  |  |  | 
| 283 |  |  |  |  |  |  |  | 
| 284 |  |  |  |  |  |  |  | 
| 285 |  |  |  |  |  |  | #----------------------------------------------------------------------------- | 
| 286 |  |  |  |  |  |  | # Daily price page parsing | 
| 287 |  |  |  |  |  |  |  | 
| 288 |  |  |  |  |  |  | sub daily_parse { | 
| 289 |  |  |  |  |  |  | my ($resp, $want_tdate) = @_; | 
| 290 |  |  |  |  |  |  | my @data = (); | 
| 291 |  |  |  |  |  |  | my $h = { source => __PACKAGE__, | 
| 292 |  |  |  |  |  |  | currency => 'USD', | 
| 293 |  |  |  |  |  |  | data => \@data }; | 
| 294 |  |  |  |  |  |  |  | 
| 295 |  |  |  |  |  |  | my $content = $resp->decoded_content (raise_error => 1); | 
| 296 |  |  |  |  |  |  | $content = mung_1x1_tables ($content); | 
| 297 |  |  |  |  |  |  |  | 
| 298 |  |  |  |  |  |  | # Eg. "Official Prices, US$ per tonne for\n\t\t19 September 2008" | 
| 299 |  |  |  |  |  |  | # Eg. "LME Official Prices, US$ per tonne for 18 September 2008" | 
| 300 |  |  |  |  |  |  | # | 
| 301 |  |  |  |  |  |  | $content =~ /Prices.*?for\s*\n?\s*([0-9]{1,2}\s+[A-Za-z]+\s+[0-9][0-9][0-9][0-9])/i | 
| 302 |  |  |  |  |  |  | or die "LME daily: date not found"; | 
| 303 |  |  |  |  |  |  | my $date = App::Chart::Download::Decode_Date_EU_to_iso ($1); | 
| 304 |  |  |  |  |  |  | if (defined $want_tdate) { | 
| 305 |  |  |  |  |  |  | my $want_date = App::Chart::tdate_to_iso($want_tdate); | 
| 306 |  |  |  |  |  |  | if ($date ne $want_tdate) { | 
| 307 |  |  |  |  |  |  | die "LME daily: didn't get expected date, got $date want $want_tdate"; | 
| 308 |  |  |  |  |  |  | } | 
| 309 |  |  |  |  |  |  | } | 
| 310 |  |  |  |  |  |  |  | 
| 311 |  |  |  |  |  |  | require HTML::TableExtract; | 
| 312 |  |  |  |  |  |  | my $te = HTML::TableExtract->new (headers => [qr/PP.*Global/is], | 
| 313 |  |  |  |  |  |  | keep_headers => 1, | 
| 314 |  |  |  |  |  |  | slice_columns => 0); | 
| 315 |  |  |  |  |  |  | $te->parse($content); | 
| 316 |  |  |  |  |  |  | my $ts = $te->first_table_found(); | 
| 317 |  |  |  |  |  |  | if (! $ts) { | 
| 318 |  |  |  |  |  |  | $te = HTML::TableExtract->new (headers => [qr/COPPER|STEEL/i], | 
| 319 |  |  |  |  |  |  | keep_headers => 1, | 
| 320 |  |  |  |  |  |  | slice_columns => 0); | 
| 321 |  |  |  |  |  |  | $te->parse($content); | 
| 322 |  |  |  |  |  |  | $ts = $te->first_table_found() | 
| 323 |  |  |  |  |  |  | || die "LME daily: prices table not found"; | 
| 324 |  |  |  |  |  |  | } | 
| 325 |  |  |  |  |  |  |  | 
| 326 |  |  |  |  |  |  | my $rows = $ts->rows(); | 
| 327 |  |  |  |  |  |  | my $lastrow = $#$rows; | 
| 328 |  |  |  |  |  |  | my $lastcol = $#{$rows->[0]}; | 
| 329 |  |  |  |  |  |  |  | 
| 330 |  |  |  |  |  |  | my @column; | 
| 331 |  |  |  |  |  |  | my @column_commodity; | 
| 332 |  |  |  |  |  |  | my @column_name; | 
| 333 |  |  |  |  |  |  | foreach my $c (2 .. $lastcol) { | 
| 334 |  |  |  |  |  |  | my $commodity = $rows->[0]->[$c] || next; | 
| 335 |  |  |  |  |  |  | my $name; | 
| 336 |  |  |  |  |  |  | if    ($commodity =~ /ALUMINIUM ALLOY/i)       { $commodity = 'AA'; } | 
| 337 |  |  |  |  |  |  | elsif ($commodity =~ /ALUMINIUM/i)             { $commodity = 'AH'; } | 
| 338 |  |  |  |  |  |  | elsif ($commodity =~ /COPPER/i)                { $commodity = 'CA'; } | 
| 339 |  |  |  |  |  |  | elsif ($commodity =~ /LEAD/i)                  { $commodity = 'PB'; } | 
| 340 |  |  |  |  |  |  | elsif ($commodity =~ /NICKEL/i)                { $commodity = 'NI'; } | 
| 341 |  |  |  |  |  |  | elsif ($commodity =~ /TIN/i)                   { $commodity = 'SN'; } | 
| 342 |  |  |  |  |  |  | elsif ($commodity =~ /ZINC/i)                  { $commodity = 'ZS'; } | 
| 343 |  |  |  |  |  |  | elsif ($commodity =~ /NASAAC/i)                { $commodity = 'NI'; } | 
| 344 |  |  |  |  |  |  | elsif ($commodity =~ /STEEL.*MEDITERRANEAN/s)  { $commodity = 'FM'; } | 
| 345 |  |  |  |  |  |  | elsif ($commodity =~ /STEEL.*FAR EAST/s)       { $commodity = 'FF'; } | 
| 346 |  |  |  |  |  |  | elsif ($commodity =~ /^([A-Z][A-Z])\s+(.*)/is) { $commodity = $1; $name = $2; } | 
| 347 |  |  |  |  |  |  | else { next; } | 
| 348 |  |  |  |  |  |  |  | 
| 349 |  |  |  |  |  |  | push @column,           $c; | 
| 350 |  |  |  |  |  |  | push @column_commodity, $commodity; | 
| 351 |  |  |  |  |  |  | push @column_name,      $name; | 
| 352 |  |  |  |  |  |  | } | 
| 353 |  |  |  |  |  |  | if (DEBUG) { require Data::Dumper; | 
| 354 |  |  |  |  |  |  | print "columns ", Data::Dumper::Dumper(\@column); | 
| 355 |  |  |  |  |  |  | print "columns ", Data::Dumper::Dumper(\@column_commodity); | 
| 356 |  |  |  |  |  |  | print "columns ", Data::Dumper::Dumper(\@column_name); } | 
| 357 |  |  |  |  |  |  |  | 
| 358 |  |  |  |  |  |  | my %bid; | 
| 359 |  |  |  |  |  |  | foreach my $r (1 .. $lastrow) { | 
| 360 |  |  |  |  |  |  | my $row = $rows->[$r]; | 
| 361 |  |  |  |  |  |  | if (DEBUG) { require Data::Dumper; | 
| 362 |  |  |  |  |  |  | print Data::Dumper::Dumper($row); } | 
| 363 |  |  |  |  |  |  | my $type = $row->[1]; | 
| 364 |  |  |  |  |  |  | if (! $type) { next; } | 
| 365 |  |  |  |  |  |  |  | 
| 366 |  |  |  |  |  |  | my $side; | 
| 367 |  |  |  |  |  |  | if ($type =~ /^\s*$/is) { | 
| 368 |  |  |  |  |  |  | next; # empty | 
| 369 |  |  |  |  |  |  | } elsif ($type =~ /buyer/i) { | 
| 370 |  |  |  |  |  |  | $side = 'bid'; | 
| 371 |  |  |  |  |  |  | } elsif ($type =~ /seller/i) { | 
| 372 |  |  |  |  |  |  | $side = 'offer'; | 
| 373 |  |  |  |  |  |  | } else { | 
| 374 |  |  |  |  |  |  | die "LME daily: unrecognised row type '$type'\n"; | 
| 375 |  |  |  |  |  |  | } | 
| 376 |  |  |  |  |  |  |  | 
| 377 |  |  |  |  |  |  | my $month; | 
| 378 |  |  |  |  |  |  | my $post; | 
| 379 |  |  |  |  |  |  | if (DEBUG) { print "type $type\n"; } | 
| 380 |  |  |  |  |  |  | if ($type =~ /cash/i) { | 
| 381 |  |  |  |  |  |  | $post = ''; | 
| 382 |  |  |  |  |  |  | } elsif ($type =~ /([0-9]+)[- \t]*month/s) { | 
| 383 |  |  |  |  |  |  | $post = $1; | 
| 384 |  |  |  |  |  |  | } elsif ($type =~ /^(.*?)\s+(buyer|seller)/i) { | 
| 385 |  |  |  |  |  |  | $month = month_str_to_nearest_iso ($1); | 
| 386 |  |  |  |  |  |  | $post = " " . App::Chart::Download::iso_to_MMM_YY($month); | 
| 387 |  |  |  |  |  |  | } else { | 
| 388 |  |  |  |  |  |  | die "LME daily: unrecognised row type '$type'\n"; | 
| 389 |  |  |  |  |  |  | } | 
| 390 |  |  |  |  |  |  |  | 
| 391 |  |  |  |  |  |  | foreach my $i (0 .. $#column) { | 
| 392 |  |  |  |  |  |  | my $c = $column[$i]; | 
| 393 |  |  |  |  |  |  | my $commodity = $column_commodity[$i]; | 
| 394 |  |  |  |  |  |  | my $price = $row->[$c]; | 
| 395 |  |  |  |  |  |  |  | 
| 396 |  |  |  |  |  |  | if ($side eq 'bid') { | 
| 397 |  |  |  |  |  |  | $bid{$commodity} = $price; | 
| 398 |  |  |  |  |  |  | next; | 
| 399 |  |  |  |  |  |  | } | 
| 400 |  |  |  |  |  |  | push @data, { symbol    => "$commodity$post.LME", | 
| 401 |  |  |  |  |  |  | month     => $month, | 
| 402 |  |  |  |  |  |  | name      => $column_name[$i], | 
| 403 |  |  |  |  |  |  | date      => $date, | 
| 404 |  |  |  |  |  |  | bid       => delete $bid{$commodity}, | 
| 405 |  |  |  |  |  |  | offer     => $price, | 
| 406 |  |  |  |  |  |  | close     => $price, | 
| 407 |  |  |  |  |  |  | }; | 
| 408 |  |  |  |  |  |  | } | 
| 409 |  |  |  |  |  |  | } | 
| 410 |  |  |  |  |  |  |  | 
| 411 |  |  |  |  |  |  | return $h; | 
| 412 |  |  |  |  |  |  | } | 
| 413 |  |  |  |  |  |  |  | 
| 414 |  |  |  |  |  |  | # $str is some html (in wide chars) | 
| 415 |  |  |  |  |  |  | # flatten out any little 1x1 tables to their contents | 
| 416 |  |  |  |  |  |  | # such tables are found in the rows of the daily plastics page | 
| 417 |  |  |  |  |  |  | # | 
| 418 |  |  |  |  |  |  | sub mung_1x1_tables { | 
| 419 |  |  |  |  |  |  | my ($str) = @_; | 
| 420 |  |  |  |  |  |  | require HTML::TreeBuilder; | 
| 421 |  |  |  |  |  |  | my $top = HTML::TreeBuilder->new_from_content ($str); | 
| 422 |  |  |  |  |  |  | my $changed = 0; | 
| 423 |  |  |  |  |  |  | $top->traverse | 
| 424 |  |  |  |  |  |  | ([sub { | 
| 425 |  |  |  |  |  |  | my ($elem) = @_; | 
| 426 |  |  |  |  |  |  | if ($elem->tag ne 'table') { return 1; } | 
| 427 |  |  |  |  |  |  | my $table = $elem; | 
| 428 |  |  |  |  |  |  |  | 
| 429 |  |  |  |  |  |  | # possible tbody within | 
| 430 |  |  |  |  |  |  | my $tbody = List::Util::first {ref $_ && $_->tag eq 'tbody'} | 
| 431 |  |  |  |  |  |  | $table->content_list; | 
| 432 |  |  |  |  |  |  | if (! $tbody) { $tbody = $table; } | 
| 433 |  |  |  |  |  |  |  | 
| 434 |  |  |  |  |  |  | my @rows = grep {ref $_ && $_->tag eq 'tr'} $tbody->content_list; | 
| 435 |  |  |  |  |  |  | if (@rows != 1) { return 1; } | 
| 436 |  |  |  |  |  |  | my $row = $rows[0]; | 
| 437 |  |  |  |  |  |  |  | 
| 438 |  |  |  |  |  |  | my @cols = grep {ref $_ && $_->tag eq 'td' | 
| 439 |  |  |  |  |  |  | && ! html_element_contains_only_img($_) } | 
| 440 |  |  |  |  |  |  | $row->content_list; | 
| 441 |  |  |  |  |  |  | if (@cols != 1) { return 1; } | 
| 442 |  |  |  |  |  |  | my $col = $cols[0]; | 
| 443 |  |  |  |  |  |  |  | 
| 444 |  |  |  |  |  |  | $table->replace_with ($col->content_list); | 
| 445 |  |  |  |  |  |  | $changed = 1; | 
| 446 |  |  |  |  |  |  | return 0; # prune | 
| 447 |  |  |  |  |  |  | } | 
| 448 |  |  |  |  |  |  | ], | 
| 449 |  |  |  |  |  |  | 1); # pre-order, no text | 
| 450 |  |  |  |  |  |  | if (DEBUG) { print "mung_1x1 changed $changed\n"; } | 
| 451 |  |  |  |  |  |  | if ($changed) { | 
| 452 |  |  |  |  |  |  | return $top->as_HTML; | 
| 453 |  |  |  |  |  |  | } else { | 
| 454 |  |  |  |  |  |  | return $str; | 
| 455 |  |  |  |  |  |  | } | 
| 456 |  |  |  |  |  |  | } | 
| 457 |  |  |  |  |  |  |  | 
| 458 |  |  |  |  |  |  | sub html_element_contains_only_img { | 
| 459 |  |  |  |  |  |  | my ($elem) = @_; | 
| 460 |  |  |  |  |  |  | my @list = $elem->content_list; | 
| 461 |  |  |  |  |  |  | return (@list == 1 | 
| 462 |  |  |  |  |  |  | && ref $list[0] | 
| 463 |  |  |  |  |  |  | && $list[0]->tag eq 'img'); | 
| 464 |  |  |  |  |  |  | } | 
| 465 |  |  |  |  |  |  |  | 
| 466 |  |  |  |  |  |  | sub month_str_to_nearest_iso { | 
| 467 |  |  |  |  |  |  | my ($str) = @_; | 
| 468 |  |  |  |  |  |  | my $month = Date::Calc::Decode_Month ($str) | 
| 469 |  |  |  |  |  |  | || die "LME parse: unrecognised month: '$str'"; | 
| 470 |  |  |  |  |  |  | my $year = App::Chart::Download::month_to_nearest_year ($month); | 
| 471 |  |  |  |  |  |  | return App::Chart::ymd_to_iso ($year, $month, 1); | 
| 472 |  |  |  |  |  |  | } | 
| 473 |  |  |  |  |  |  |  | 
| 474 |  |  |  |  |  |  |  | 
| 475 |  |  |  |  |  |  | #----------------------------------------------------------------------------- | 
| 476 |  |  |  |  |  |  | # historical download page | 
| 477 |  |  |  |  |  |  | # | 
| 478 |  |  |  |  |  |  | # This uses the historical data at | 
| 479 |  |  |  |  |  |  | # | 
| 480 |  |  |  |  |  |  | use constant HISTORICAL_XLS_URL => | 
| 481 |  |  |  |  |  |  | 'http://www.lme.co.uk/dataprices_historical.asp'; | 
| 482 |  |  |  |  |  |  | # | 
| 483 |  |  |  |  |  |  | # That page is downloaded to get urls of XLS files for prices and volumes | 
| 484 |  |  |  |  |  |  | # for each calendar month.  A price file is like | 
| 485 |  |  |  |  |  |  | # | 
| 486 |  |  |  |  |  |  | #     http://www.lme.co.uk/downloads/January_2007.xls | 
| 487 |  |  |  |  |  |  | # | 
| 488 |  |  |  |  |  |  | # and a volumes file | 
| 489 |  |  |  |  |  |  | # | 
| 490 |  |  |  |  |  |  | #     http://www.lme.co.uk/downloads/volumes_September_2007.xls | 
| 491 |  |  |  |  |  |  | # | 
| 492 |  |  |  |  |  |  | # Sometimes there's a rev num like | 
| 493 |  |  |  |  |  |  | # | 
| 494 |  |  |  |  |  |  | #     http://www.lme.co.uk/downloads/historic_data/May_2008(1).xls | 
| 495 |  |  |  |  |  |  | #     http://www.lme.co.uk/downloads/historic_data/December_2008_3.xls | 
| 496 |  |  |  |  |  |  |  | 
| 497 |  |  |  |  |  |  | sub historical_xls_files { | 
| 498 |  |  |  |  |  |  | require App::Chart::Pagebits; | 
| 499 |  |  |  |  |  |  | my $h = App::Chart::Pagebits::get | 
| 500 |  |  |  |  |  |  | (name      => __('LME historical downloads page'), | 
| 501 |  |  |  |  |  |  | url       => HISTORICAL_XLS_URL, | 
| 502 |  |  |  |  |  |  | method    => 'POST', | 
| 503 |  |  |  |  |  |  | data      => 'disclaimer=agreed', | 
| 504 |  |  |  |  |  |  | key       => 'lme-historical-xls', | 
| 505 |  |  |  |  |  |  | freq_days => 2, | 
| 506 |  |  |  |  |  |  | timezone  => App::Chart::TZ->london, | 
| 507 |  |  |  |  |  |  | parse     => \&historical_xls_parse); | 
| 508 |  |  |  |  |  |  | my $aref = $h->{'files'} || []; | 
| 509 |  |  |  |  |  |  | return @$aref; | 
| 510 |  |  |  |  |  |  | } | 
| 511 |  |  |  |  |  |  |  | 
| 512 |  |  |  |  |  |  | # $content is the "dataprices_historical.asp" page. | 
| 513 |  |  |  |  |  |  | # Return a hashref like { 'files' => [ {elem}, {elem}, ...] } | 
| 514 |  |  |  |  |  |  | # | 
| 515 |  |  |  |  |  |  | # At the start of the year there can be nothing available (the previous year | 
| 516 |  |  |  |  |  |  | # files being made chargable items) so it's possible for 'urls' to be empty. | 
| 517 |  |  |  |  |  |  | # | 
| 518 |  |  |  |  |  |  | # There's a size in the text following each link, but since there's no | 
| 519 |  |  |  |  |  |  | # overlapping files to choose between there's no need to pick that out. | 
| 520 |  |  |  |  |  |  | # | 
| 521 |  |  |  |  |  |  | sub historical_xls_parse { | 
| 522 |  |  |  |  |  |  | my ($content) = @_; | 
| 523 |  |  |  |  |  |  |  | 
| 524 |  |  |  |  |  |  | my %urls; | 
| 525 |  |  |  |  |  |  | require HTML::LinkExtor; | 
| 526 |  |  |  |  |  |  | my $p = HTML::LinkExtor->new | 
| 527 |  |  |  |  |  |  | (sub { | 
| 528 |  |  |  |  |  |  | my($tag, %links) = @_; | 
| 529 |  |  |  |  |  |  | $tag eq 'a' or return; | 
| 530 |  |  |  |  |  |  | my $link = $links{'href'} or return; | 
| 531 |  |  |  |  |  |  |  | 
| 532 |  |  |  |  |  |  | # only the .xls files | 
| 533 |  |  |  |  |  |  | $link =~ /\.xls$/i or return; | 
| 534 |  |  |  |  |  |  |  | 
| 535 |  |  |  |  |  |  | # exclude warehouse stocks | 
| 536 |  |  |  |  |  |  | if ($link =~ /stocks/i) { return; } | 
| 537 |  |  |  |  |  |  |  | 
| 538 |  |  |  |  |  |  | $urls{$link} = 1; | 
| 539 |  |  |  |  |  |  | }, HISTORICAL_XLS_URL); | 
| 540 |  |  |  |  |  |  | $p->parse($content); | 
| 541 |  |  |  |  |  |  |  | 
| 542 |  |  |  |  |  |  | my @files; | 
| 543 |  |  |  |  |  |  | foreach my $url (keys %urls) { | 
| 544 |  |  |  |  |  |  | if (DEBUG) { print "url $url\n"; } | 
| 545 |  |  |  |  |  |  |  | 
| 546 |  |  |  |  |  |  | $url =~ m{([^/]+)$} or die; # only a plain file | 
| 547 |  |  |  |  |  |  | my $basename = $1; | 
| 548 |  |  |  |  |  |  |  | 
| 549 |  |  |  |  |  |  | # rev num in parens like "May_2008(1).xls" | 
| 550 |  |  |  |  |  |  | $basename =~ s/%28.*%29//; | 
| 551 |  |  |  |  |  |  | $basename =~ s/\(.*\)//; | 
| 552 |  |  |  |  |  |  |  | 
| 553 |  |  |  |  |  |  | # rev num with underscore like "December_2008_3.xls" | 
| 554 |  |  |  |  |  |  | $basename =~ s/(\d\d\d\d)_\d+(\.)/$1$2/; | 
| 555 |  |  |  |  |  |  |  | 
| 556 |  |  |  |  |  |  | $basename =~ s/volumes//i; | 
| 557 |  |  |  |  |  |  | my $month = App::Chart::Download::Decode_Date_EU_to_iso ("1 $basename"); | 
| 558 |  |  |  |  |  |  | push @files, { url => $url, | 
| 559 |  |  |  |  |  |  | month_iso => $month }; | 
| 560 |  |  |  |  |  |  | } | 
| 561 |  |  |  |  |  |  |  | 
| 562 |  |  |  |  |  |  | @files = sort {$a->{'month_iso'} cmp $b->{'month_iso'} | 
| 563 |  |  |  |  |  |  | || $a->{'url'} cmp $b->{'url'} | 
| 564 |  |  |  |  |  |  | } @files; | 
| 565 |  |  |  |  |  |  | return { 'files' => \@files }; | 
| 566 |  |  |  |  |  |  | } | 
| 567 |  |  |  |  |  |  |  | 
| 568 |  |  |  |  |  |  | # return mdate for STR like "January_2007" or "Jan_07", or #f if not that | 
| 569 |  |  |  |  |  |  | # format | 
| 570 |  |  |  |  |  |  | # sub Mmm_yyy_str_to_mdate { | 
| 571 |  |  |  |  |  |  | #   my ($str) = @_; | 
| 572 |  |  |  |  |  |  | #   # drop "(1)" part of "http://www.lme.co.uk/downloads/March_2008(1).xls" | 
| 573 |  |  |  |  |  |  | #   $str =~ s/\(.*\)//; | 
| 574 |  |  |  |  |  |  | #   $str = '1_' . $str; | 
| 575 |  |  |  |  |  |  | #   my ($year, $month, $day) = Date::Calc::Decode_Date_EU ($str); | 
| 576 |  |  |  |  |  |  | #   if (! $year || ! $month) { die "LME: unrecognised filename month: $str"; } | 
| 577 |  |  |  |  |  |  | #   return App::Chart::Timebase::Months::ymd_to_mdate ($year, $month, 1); | 
| 578 |  |  |  |  |  |  | # } | 
| 579 |  |  |  |  |  |  |  | 
| 580 |  |  |  |  |  |  |  | 
| 581 |  |  |  |  |  |  | #----------------------------------------------------------------------------- | 
| 582 |  |  |  |  |  |  | # download - month price xls files | 
| 583 |  |  |  |  |  |  | # | 
| 584 |  |  |  |  |  |  | # This crunches files like | 
| 585 |  |  |  |  |  |  | #     http://www.lme.co.uk/downloads/April_2008.xls | 
| 586 |  |  |  |  |  |  | # | 
| 587 |  |  |  |  |  |  |  | 
| 588 |  |  |  |  |  |  | App::Chart::DownloadHandler->new | 
| 589 |  |  |  |  |  |  | (name   => __('LME month xls'), | 
| 590 |  |  |  |  |  |  | pred   => $pred, | 
| 591 |  |  |  |  |  |  | proc   => \&monthxls_download, | 
| 592 |  |  |  |  |  |  | # backto => \&monthxls_backto, | 
| 593 |  |  |  |  |  |  | available_tdate => \&monthxls_available_tdate); | 
| 594 |  |  |  |  |  |  |  | 
| 595 |  |  |  |  |  |  | # Return tdate of anticipated available montly .xls download, that being | 
| 596 |  |  |  |  |  |  | # the end of the previous month. | 
| 597 |  |  |  |  |  |  | # | 
| 598 |  |  |  |  |  |  | # Don't know exactly when a new month full of data becomes available, | 
| 599 |  |  |  |  |  |  | # assume here midnight at the start of the second trading day of the new | 
| 600 |  |  |  |  |  |  | # month. | 
| 601 |  |  |  |  |  |  | # | 
| 602 |  |  |  |  |  |  | sub monthxls_available_tdate { | 
| 603 |  |  |  |  |  |  | my $tdate = App::Chart::Download::tdate_today | 
| 604 |  |  |  |  |  |  | (App::Chart::TZ->london); | 
| 605 |  |  |  |  |  |  | $tdate--; # not until second business day into this month | 
| 606 |  |  |  |  |  |  | $tdate = tdate_start_of_month ($tdate); | 
| 607 |  |  |  |  |  |  | return $tdate - 1; # last day of previous month | 
| 608 |  |  |  |  |  |  | } | 
| 609 |  |  |  |  |  |  |  | 
| 610 |  |  |  |  |  |  | sub monthxls_download { | 
| 611 |  |  |  |  |  |  | my ($symbol_list) = @_; | 
| 612 |  |  |  |  |  |  | if (DEBUG) { print "LME ",@$symbol_list,"\n"; } | 
| 613 |  |  |  |  |  |  |  | 
| 614 |  |  |  |  |  |  | my $lo_tdate = App::Chart::Download::start_tdate_for_update (@$symbol_list); | 
| 615 |  |  |  |  |  |  | my $hi_tdate = monthxls_available_tdate(); | 
| 616 |  |  |  |  |  |  |  | 
| 617 |  |  |  |  |  |  | my @files = grep {$_->{'url'} !~ /volume/i} historical_xls_files(); | 
| 618 |  |  |  |  |  |  | my $files = App::Chart::Download::choose_files (\@files, $lo_tdate, $hi_tdate); | 
| 619 |  |  |  |  |  |  |  | 
| 620 |  |  |  |  |  |  | foreach my $f (@$files) { | 
| 621 |  |  |  |  |  |  | my $url = $f->{'url'}; | 
| 622 |  |  |  |  |  |  | require File::Basename; | 
| 623 |  |  |  |  |  |  | my $filename = File::Basename::basename($url); | 
| 624 |  |  |  |  |  |  | App::Chart::Download::status (__x('LME data {filename}', | 
| 625 |  |  |  |  |  |  | filename => $filename)); | 
| 626 |  |  |  |  |  |  | my $resp = App::Chart::Download->get ($url); | 
| 627 |  |  |  |  |  |  | my $h = monthxls_parse ($resp); | 
| 628 |  |  |  |  |  |  | App::Chart::Download::write_daily_group ($h); | 
| 629 |  |  |  |  |  |  | } | 
| 630 |  |  |  |  |  |  | } | 
| 631 |  |  |  |  |  |  |  | 
| 632 |  |  |  |  |  |  | sub tdate_start_of_month { | 
| 633 |  |  |  |  |  |  | my ($tdate) = @_; | 
| 634 |  |  |  |  |  |  | my ($year,$month,$day) = App::Chart::tdate_to_ymd ($tdate); | 
| 635 |  |  |  |  |  |  | return App::Chart::ymd_to_tdate_ceil ($year, $month, 1); | 
| 636 |  |  |  |  |  |  | } | 
| 637 |  |  |  |  |  |  |  | 
| 638 |  |  |  |  |  |  | my %monthxls_sheet_to_commodity = | 
| 639 |  |  |  |  |  |  | ('Copper'        => 'CA', | 
| 640 |  |  |  |  |  |  | 'Al. Alloy'     => 'AA', | 
| 641 |  |  |  |  |  |  | 'NASAAC'        => 'NA', | 
| 642 |  |  |  |  |  |  | 'Zinc'          => 'ZS', | 
| 643 |  |  |  |  |  |  | 'Lead'          => 'PB', | 
| 644 |  |  |  |  |  |  | 'Pr. Aluminium' => 'AH', | 
| 645 |  |  |  |  |  |  | 'Tin'           => 'SN', | 
| 646 |  |  |  |  |  |  | 'Nickel'        => 'NI', | 
| 647 |  |  |  |  |  |  | 'Far East'      => 'FF',  # steel | 
| 648 |  |  |  |  |  |  | 'Med'           => 'FM',  # steel | 
| 649 |  |  |  |  |  |  | 'Averages'              => undef, | 
| 650 |  |  |  |  |  |  | 'Plastic Avg'           => undef, | 
| 651 |  |  |  |  |  |  | 'Averages inc. Euro Eq' => undef); | 
| 652 |  |  |  |  |  |  |  | 
| 653 |  |  |  |  |  |  | sub monthxls_parse { | 
| 654 |  |  |  |  |  |  | my ($resp) = @_; | 
| 655 |  |  |  |  |  |  | my $content = $resp->decoded_content (charset => 'none', raise_error => 1); | 
| 656 |  |  |  |  |  |  |  | 
| 657 |  |  |  |  |  |  | require Spreadsheet::ParseExcel; | 
| 658 |  |  |  |  |  |  | require Spreadsheet::ParseExcel::Utility; | 
| 659 |  |  |  |  |  |  |  | 
| 660 |  |  |  |  |  |  | my @data = (); | 
| 661 |  |  |  |  |  |  | my $h = { source     => __PACKAGE__, | 
| 662 |  |  |  |  |  |  | cover_pred => $pred, | 
| 663 |  |  |  |  |  |  | data       => \@data }; | 
| 664 |  |  |  |  |  |  |  | 
| 665 |  |  |  |  |  |  | my $excel = Spreadsheet::ParseExcel::Workbook->Parse (\$content); | 
| 666 |  |  |  |  |  |  | foreach my $sheet (@{$excel->{Worksheet}}) { | 
| 667 |  |  |  |  |  |  | my $sheet_name = $sheet->{'Name'}; | 
| 668 |  |  |  |  |  |  | if (DEBUG) { print "Sheet: $sheet_name\n"; } | 
| 669 |  |  |  |  |  |  | my $commodity; | 
| 670 |  |  |  |  |  |  | if ($sheet_name =~ /^[A-Z][A-Z]$/) { | 
| 671 |  |  |  |  |  |  | # plastics symbol | 
| 672 |  |  |  |  |  |  | $commodity = $sheet_name; | 
| 673 |  |  |  |  |  |  | } elsif (exists $monthxls_sheet_to_commodity{$sheet_name}) { | 
| 674 |  |  |  |  |  |  | $commodity = $monthxls_sheet_to_commodity{$sheet_name} | 
| 675 |  |  |  |  |  |  | // next;  # undef for ignored sheets | 
| 676 |  |  |  |  |  |  | } else { | 
| 677 |  |  |  |  |  |  | warn "LME: unrecognised month data sheet: $sheet_name\n"; | 
| 678 |  |  |  |  |  |  | next; | 
| 679 |  |  |  |  |  |  | } | 
| 680 |  |  |  |  |  |  |  | 
| 681 |  |  |  |  |  |  | my ($minrow, $maxrow) = $sheet->RowRange; | 
| 682 |  |  |  |  |  |  | my ($mincol, $maxcol) = $sheet->ColRange; | 
| 683 |  |  |  |  |  |  |  | 
| 684 |  |  |  |  |  |  | my $heading_row = $minrow; | 
| 685 |  |  |  |  |  |  | my $date_col; | 
| 686 |  |  |  |  |  |  | my $seller_col; | 
| 687 |  |  |  |  |  |  | HEADING: for (;; $heading_row++) { | 
| 688 |  |  |  |  |  |  | if ($heading_row > $maxrow) { die "LME: headings row not found\n"; } | 
| 689 |  |  |  |  |  |  | for ($seller_col = $mincol; $seller_col <= $maxcol; $seller_col++) { | 
| 690 |  |  |  |  |  |  | my $cell = $sheet->Cell($heading_row,$seller_col) // next; | 
| 691 |  |  |  |  |  |  | my $str = $cell->Value; | 
| 692 |  |  |  |  |  |  | if (DEBUG >= 2) { print "  cell $heading_row,$seller_col $str\n"; } | 
| 693 |  |  |  |  |  |  | if ($str =~ /SELLER/i) { last HEADING; } | 
| 694 |  |  |  |  |  |  | } | 
| 695 |  |  |  |  |  |  | } | 
| 696 |  |  |  |  |  |  | $date_col = $seller_col - 2; | 
| 697 |  |  |  |  |  |  | if (DEBUG) { print "  heading row $heading_row seller col $seller_col\n"; } | 
| 698 |  |  |  |  |  |  |  | 
| 699 |  |  |  |  |  |  | my @column_num = (); | 
| 700 |  |  |  |  |  |  | my @column_symbol = (); | 
| 701 |  |  |  |  |  |  | for (my $col = $seller_col; $col+2 <= $maxcol; $col += 3) { | 
| 702 |  |  |  |  |  |  | my $cell = $sheet->Cell($heading_row,$col) || last; | 
| 703 |  |  |  |  |  |  | $cell->Value =~ /SELLER/i or next; | 
| 704 |  |  |  |  |  |  |  | 
| 705 |  |  |  |  |  |  | my $period = $sheet->Cell($heading_row-1,$col)->Value; | 
| 706 |  |  |  |  |  |  | if (DEBUG >= 2) { print "  col=$col period=$period\n"; } | 
| 707 |  |  |  |  |  |  | if ($period =~ /cash/i) { | 
| 708 |  |  |  |  |  |  | $period = ''; | 
| 709 |  |  |  |  |  |  | } elsif ($period =~ /([0-9]+).*(months|mths)/i) { | 
| 710 |  |  |  |  |  |  | $period = $1; | 
| 711 |  |  |  |  |  |  | } elsif ($period eq '') { | 
| 712 |  |  |  |  |  |  | last; | 
| 713 |  |  |  |  |  |  | } else { | 
| 714 |  |  |  |  |  |  | die "LME: month sheet '$sheet_name' heading row=$heading_row col=$col period unrecognised: '$period'\n"; | 
| 715 |  |  |  |  |  |  | } | 
| 716 |  |  |  |  |  |  | push @column_num, $col; | 
| 717 |  |  |  |  |  |  | push @column_symbol, "$commodity$period.LME"; | 
| 718 |  |  |  |  |  |  | } | 
| 719 |  |  |  |  |  |  | if (! @column_num) { | 
| 720 |  |  |  |  |  |  | die "LME: oops, sheet '$sheet_name' month data columns not matched\n"; | 
| 721 |  |  |  |  |  |  | } | 
| 722 |  |  |  |  |  |  |  | 
| 723 |  |  |  |  |  |  | my $seen_date = 0; | 
| 724 |  |  |  |  |  |  | foreach my $row ($heading_row+1 .. $maxrow) { | 
| 725 |  |  |  |  |  |  | my $datecell = $sheet->Cell($row,$date_col) or next; | 
| 726 |  |  |  |  |  |  | # skip blanks at end, avoid "Total" | 
| 727 |  |  |  |  |  |  | $datecell->{'Type'} eq 'Date' or next; | 
| 728 |  |  |  |  |  |  | # default format is like 31-Jan-08, go straight to ISO to be unambiguous | 
| 729 |  |  |  |  |  |  | my $date = Spreadsheet::ParseExcel::Utility::ExcelFmt | 
| 730 |  |  |  |  |  |  | ('yyyy-mm-dd', $datecell->{'Val'}, $excel->{'Flg1904'}); | 
| 731 |  |  |  |  |  |  | $seen_date = 1; | 
| 732 |  |  |  |  |  |  |  | 
| 733 |  |  |  |  |  |  | foreach my $i (0 .. $#column_num) { | 
| 734 |  |  |  |  |  |  | my $col = $column_num[$i]; | 
| 735 |  |  |  |  |  |  | my $symbol = $column_symbol[$i]; | 
| 736 |  |  |  |  |  |  |  | 
| 737 |  |  |  |  |  |  | # unformatted value gets '1490.00' instead of '$1,490.00' | 
| 738 |  |  |  |  |  |  | my $seller = $sheet->Cell($row,$col)->{'Val'}; | 
| 739 |  |  |  |  |  |  | push @data, { symbol => $symbol, | 
| 740 |  |  |  |  |  |  | date   => $date, | 
| 741 |  |  |  |  |  |  | close  => $seller, | 
| 742 |  |  |  |  |  |  | }; | 
| 743 |  |  |  |  |  |  | } | 
| 744 |  |  |  |  |  |  | } | 
| 745 |  |  |  |  |  |  | if (! $seen_date) { | 
| 746 |  |  |  |  |  |  | die "LME month data: no dates found in sheet '$sheet_name'"; | 
| 747 |  |  |  |  |  |  | } | 
| 748 |  |  |  |  |  |  | } | 
| 749 |  |  |  |  |  |  | my $date = $data[0]->{'date'}; | 
| 750 |  |  |  |  |  |  | my ($year, $month, $day) = App::Chart::iso_to_ymd ($date); | 
| 751 |  |  |  |  |  |  | $h->{'cover_lo_date'} = App::Chart::ymd_to_iso ($year, $month, 1); | 
| 752 |  |  |  |  |  |  | ($year, $month, $day) = Date::Calc::Add_Delta_YMD ($year, $month, $day, | 
| 753 |  |  |  |  |  |  | 0, 1, -1); | 
| 754 |  |  |  |  |  |  | $h->{'cover_hi_date'} = App::Chart::ymd_to_iso ($year, $month, $day); | 
| 755 |  |  |  |  |  |  | return $h; | 
| 756 |  |  |  |  |  |  | } | 
| 757 |  |  |  |  |  |  |  | 
| 758 |  |  |  |  |  |  | #----------------------------------------------------------------------------- | 
| 759 |  |  |  |  |  |  | # download - volume xls files | 
| 760 |  |  |  |  |  |  | # | 
| 761 |  |  |  |  |  |  | # This crunches files like | 
| 762 |  |  |  |  |  |  | #     http://www.lme.co.uk/downloads/volumes_Jan_08.xls | 
| 763 |  |  |  |  |  |  | # | 
| 764 |  |  |  |  |  |  |  | 
| 765 |  |  |  |  |  |  | # App::Chart::DownloadHandler->new | 
| 766 |  |  |  |  |  |  | #   (name   => __('LME month volumes'), | 
| 767 |  |  |  |  |  |  | #    pred   => $pred, | 
| 768 |  |  |  |  |  |  | #    proc   => \&volume_download, | 
| 769 |  |  |  |  |  |  | #    # backto => \&volume_backto, | 
| 770 |  |  |  |  |  |  | #    available_tdate => \&monthxls_available_tdate); | 
| 771 |  |  |  |  |  |  |  | 
| 772 |  |  |  |  |  |  | sub volume_download { | 
| 773 |  |  |  |  |  |  | my ($symbol_list) = @_; | 
| 774 |  |  |  |  |  |  |  | 
| 775 |  |  |  |  |  |  | my $lo_tdate = App::Chart::Download::start_tdate_for_update (@$symbol_list); | 
| 776 |  |  |  |  |  |  | my $hi_tdate = monthxls_available_tdate(); | 
| 777 |  |  |  |  |  |  |  | 
| 778 |  |  |  |  |  |  | my @files = grep {$_->{'url'} =~ /volume/i} historical_xls_files(); | 
| 779 |  |  |  |  |  |  | my $files = App::Chart::Download::choose_files (\@files, $lo_tdate, $hi_tdate); | 
| 780 |  |  |  |  |  |  |  | 
| 781 |  |  |  |  |  |  | foreach my $f (@$files) { | 
| 782 |  |  |  |  |  |  | my $url = $f->{'url'}; | 
| 783 |  |  |  |  |  |  | require File::Basename; | 
| 784 |  |  |  |  |  |  | my $filename = File::Basename::basename($url); | 
| 785 |  |  |  |  |  |  | App::Chart::Download::status (__x('LME volumes {filename}', | 
| 786 |  |  |  |  |  |  | filename => $filename)); | 
| 787 |  |  |  |  |  |  | my $resp = App::Chart::Download->get ($url); | 
| 788 |  |  |  |  |  |  | my $h = volume_parse ($resp); | 
| 789 |  |  |  |  |  |  | App::Chart::Download::write_daily_group ($h); | 
| 790 |  |  |  |  |  |  | } | 
| 791 |  |  |  |  |  |  | } | 
| 792 |  |  |  |  |  |  |  | 
| 793 |  |  |  |  |  |  | sub volume_parse { | 
| 794 |  |  |  |  |  |  | my ($resp) = @_; | 
| 795 |  |  |  |  |  |  | my $content = $resp->decoded_content (charset => 'none', raise_error => 1); | 
| 796 |  |  |  |  |  |  |  | 
| 797 |  |  |  |  |  |  | require Spreadsheet::ParseExcel; | 
| 798 |  |  |  |  |  |  | require Spreadsheet::ParseExcel::Utility; | 
| 799 |  |  |  |  |  |  |  | 
| 800 |  |  |  |  |  |  | my @data = (); | 
| 801 |  |  |  |  |  |  | my $h = { source => __PACKAGE__, | 
| 802 |  |  |  |  |  |  | data   => \@data }; | 
| 803 |  |  |  |  |  |  |  | 
| 804 |  |  |  |  |  |  | my $excel = Spreadsheet::ParseExcel::Workbook->Parse (\$content); | 
| 805 |  |  |  |  |  |  | my $sheet = $excel->Worksheet (0); | 
| 806 |  |  |  |  |  |  | if (DEBUG) { print "Sheet: ",$sheet->{'Name'},"\n"; } | 
| 807 |  |  |  |  |  |  |  | 
| 808 |  |  |  |  |  |  | my ($minrow, $maxrow) = $sheet->RowRange; | 
| 809 |  |  |  |  |  |  | my ($mincol, $maxcol) = $sheet->ColRange; | 
| 810 |  |  |  |  |  |  |  | 
| 811 |  |  |  |  |  |  | # headings are like "AAFUT" for Aluminium Alloy, find that row | 
| 812 |  |  |  |  |  |  | my $heading_row; | 
| 813 |  |  |  |  |  |  | HEADINGROW: foreach my $row ($minrow .. $maxrow) { | 
| 814 |  |  |  |  |  |  | foreach my $col ($mincol .. $maxcol) { | 
| 815 |  |  |  |  |  |  | my $cell = $sheet->Cell($row,$col) or next; | 
| 816 |  |  |  |  |  |  | if ($cell->Value =~ /FUT$/) { | 
| 817 |  |  |  |  |  |  | $heading_row = $row; | 
| 818 |  |  |  |  |  |  | last HEADINGROW; | 
| 819 |  |  |  |  |  |  | } | 
| 820 |  |  |  |  |  |  | } | 
| 821 |  |  |  |  |  |  | } | 
| 822 |  |  |  |  |  |  | if (! $heading_row) { die 'LME Volumes: unrecognised headings'; } | 
| 823 |  |  |  |  |  |  | if (DEBUG) { print "  heading row $heading_row\n"; } | 
| 824 |  |  |  |  |  |  |  | 
| 825 |  |  |  |  |  |  | # look for each "AAFUT" etc column in the heading row | 
| 826 |  |  |  |  |  |  | my @column_num = (); | 
| 827 |  |  |  |  |  |  | my @column_symbol = (); | 
| 828 |  |  |  |  |  |  | foreach my $col ($mincol .. $maxcol) { | 
| 829 |  |  |  |  |  |  | my $cell = $sheet->Cell($heading_row,$col) // next; # skip empties | 
| 830 |  |  |  |  |  |  | $cell->{'Type'} eq 'Text' or next;  # skip dates in heading | 
| 831 |  |  |  |  |  |  | my $str = $cell->Value; | 
| 832 |  |  |  |  |  |  | $str =~ /(.*)FUT$/ or next; | 
| 833 |  |  |  |  |  |  | my $commodity = $1; | 
| 834 |  |  |  |  |  |  | push @column_num, $col; | 
| 835 |  |  |  |  |  |  | push @column_symbol, $commodity . '.LME'; | 
| 836 |  |  |  |  |  |  | } | 
| 837 |  |  |  |  |  |  |  | 
| 838 |  |  |  |  |  |  | my $seen_date = 0; | 
| 839 |  |  |  |  |  |  | foreach my $row ($heading_row+1 .. $maxrow) { | 
| 840 |  |  |  |  |  |  | my $date; | 
| 841 |  |  |  |  |  |  | # Jan 2008 has 'Date' type in column 1 | 
| 842 |  |  |  |  |  |  | # May 2008 onwards has text d-Mmm-yy in column 0 | 
| 843 |  |  |  |  |  |  | my $datecell = $sheet->Cell($row,0); | 
| 844 |  |  |  |  |  |  | if ($datecell->{'Type'} eq 'Text') { | 
| 845 |  |  |  |  |  |  | $date = App::Chart::Download::Decode_Date_EU_to_iso($datecell->{'Val'},1); | 
| 846 |  |  |  |  |  |  | # skip blanks at end, avoid "Total" | 
| 847 |  |  |  |  |  |  | if (! defined $date) { next; } | 
| 848 |  |  |  |  |  |  | } else { | 
| 849 |  |  |  |  |  |  | $datecell = $sheet->Cell($row,1); | 
| 850 |  |  |  |  |  |  | # skip blanks at end, avoid "Total" | 
| 851 |  |  |  |  |  |  | $datecell->{'Type'} eq 'Date' or next; | 
| 852 |  |  |  |  |  |  | # default format is like 31-Jan-08, go straight to ISO to be unambiguous | 
| 853 |  |  |  |  |  |  | $date = Spreadsheet::ParseExcel::Utility::ExcelFmt | 
| 854 |  |  |  |  |  |  | ('yyyy-mm-dd', $datecell->{'Val'}, $excel->{'Flg1904'}); | 
| 855 |  |  |  |  |  |  | } | 
| 856 |  |  |  |  |  |  | $seen_date = 1; | 
| 857 |  |  |  |  |  |  |  | 
| 858 |  |  |  |  |  |  | foreach my $i (0 .. $#column_num) { | 
| 859 |  |  |  |  |  |  | my $col = $column_num[$i]; | 
| 860 |  |  |  |  |  |  | my $symbol = $column_symbol[$i]; | 
| 861 |  |  |  |  |  |  | my $volume = $sheet->Cell($row,$col)->Value; | 
| 862 |  |  |  |  |  |  | push @data, { symbol    => $symbol, | 
| 863 |  |  |  |  |  |  | date      => $date, | 
| 864 |  |  |  |  |  |  | volume    => $volume, | 
| 865 |  |  |  |  |  |  | }; | 
| 866 |  |  |  |  |  |  | } | 
| 867 |  |  |  |  |  |  | } | 
| 868 |  |  |  |  |  |  | if (! $seen_date) { | 
| 869 |  |  |  |  |  |  | die 'LME volumes: no dates found'; | 
| 870 |  |  |  |  |  |  | } | 
| 871 |  |  |  |  |  |  |  | 
| 872 |  |  |  |  |  |  | return $h; | 
| 873 |  |  |  |  |  |  | } | 
| 874 |  |  |  |  |  |  |  | 
| 875 |  |  |  |  |  |  | #----------------------------------------------------------------------------- | 
| 876 |  |  |  |  |  |  | # download - daily | 
| 877 |  |  |  |  |  |  | # | 
| 878 |  |  |  |  |  |  | # This uses the metals and plastics settlement pages (login required) at | 
| 879 |  |  |  |  |  |  | # | 
| 880 |  |  |  |  |  |  | # https://secure.lme.com/Data/community/Dataprices_daily_metals.aspx | 
| 881 |  |  |  |  |  |  | # https://secure.lme.com/Data/community/Dataprices_daily_prices_plastics.aspx | 
| 882 |  |  |  |  |  |  | # https://secure.lme.com/Data/community/Dataprices_Steels_OfficialPrices.aspx | 
| 883 |  |  |  |  |  |  | # | 
| 884 |  |  |  |  |  |  |  | 
| 885 |  |  |  |  |  |  | my $daily_pred = App::Chart::Sympred::Proc->new (\&is_daily_symbol); | 
| 886 |  |  |  |  |  |  | sub is_daily_symbol { | 
| 887 |  |  |  |  |  |  | my ($symbol) = @_; | 
| 888 |  |  |  |  |  |  | return ($pred->match ($symbol) && is_enabled()); | 
| 889 |  |  |  |  |  |  | } | 
| 890 |  |  |  |  |  |  | sub is_enabled { | 
| 891 |  |  |  |  |  |  | my $username = App::Chart::Database->preference_get ('lme-username', undef); | 
| 892 |  |  |  |  |  |  | return (defined $username && $username ne ''); | 
| 893 |  |  |  |  |  |  | } | 
| 894 |  |  |  |  |  |  |  | 
| 895 |  |  |  |  |  |  | # App::Chart::DownloadHandler->new | 
| 896 |  |  |  |  |  |  | #   (name   => __('LME daily'), | 
| 897 |  |  |  |  |  |  | #    pred   => $daily_pred, | 
| 898 |  |  |  |  |  |  | #    proc   => \&daily_download, | 
| 899 |  |  |  |  |  |  | #    available_tdate_by_symbol => \&daily_available_tdate); | 
| 900 |  |  |  |  |  |  | # | 
| 901 |  |  |  |  |  |  | # sub daily_available_tdate { | 
| 902 |  |  |  |  |  |  | #   my ($symbol) = @_; | 
| 903 |  |  |  |  |  |  | #   return | 
| 904 |  |  |  |  |  |  | #     App::Chart::Download::iso_to_tdate_floor (daily_available_date ($symbol)); | 
| 905 |  |  |  |  |  |  | # } | 
| 906 |  |  |  |  |  |  |  | 
| 907 |  |  |  |  |  |  | sub daily_download { | 
| 908 |  |  |  |  |  |  | my ($symbol_list) = @_; | 
| 909 |  |  |  |  |  |  |  | 
| 910 |  |  |  |  |  |  | my $sm = partition_by_key ($symbol_list, \&type); | 
| 911 |  |  |  |  |  |  | while (my ($type, $symbol_list) = each %$sm) { | 
| 912 |  |  |  |  |  |  | App::Chart::Download::verbose_message ('LME', $type, @$symbol_list); | 
| 913 |  |  |  |  |  |  |  | 
| 914 |  |  |  |  |  |  | login_ensure(); | 
| 915 |  |  |  |  |  |  | my $l = daily_latest ($type); | 
| 916 |  |  |  |  |  |  |  | 
| 917 |  |  |  |  |  |  | my $lo_tdate = App::Chart::Download::start_tdate_for_update (@$symbol_list); | 
| 918 |  |  |  |  |  |  | my $hi_tdate = $l->{'tdate'} - 1; | 
| 919 |  |  |  |  |  |  |  | 
| 920 |  |  |  |  |  |  | foreach my $tdate ($lo_tdate .. $hi_tdate) { | 
| 921 |  |  |  |  |  |  | my $resp = daily_download_one ($type, $tdate, $l); | 
| 922 |  |  |  |  |  |  | my $h = daily_parse ($resp, $tdate); | 
| 923 |  |  |  |  |  |  | App::Chart::Download::write_daily_group ($h); | 
| 924 |  |  |  |  |  |  | } | 
| 925 |  |  |  |  |  |  | App::Chart::Download::write_daily_group ($l->{'h'}); | 
| 926 |  |  |  |  |  |  | } | 
| 927 |  |  |  |  |  |  | } | 
| 928 |  |  |  |  |  |  |  | 
| 929 |  |  |  |  |  |  | sub partition_by_key { | 
| 930 |  |  |  |  |  |  | my ($list, $func) = @_; | 
| 931 |  |  |  |  |  |  | require Tie::IxHash; | 
| 932 |  |  |  |  |  |  | my %sm; | 
| 933 |  |  |  |  |  |  | tie %sm, 'Tie::IxHash'; | 
| 934 |  |  |  |  |  |  | foreach my $elem (@$list) { | 
| 935 |  |  |  |  |  |  | my $key = $func->($elem); | 
| 936 |  |  |  |  |  |  | push @{$sm{$key}}, $elem; | 
| 937 |  |  |  |  |  |  | } | 
| 938 |  |  |  |  |  |  | return \%sm; | 
| 939 |  |  |  |  |  |  | } | 
| 940 |  |  |  |  |  |  |  | 
| 941 |  |  |  |  |  |  | sub daily_download_one { | 
| 942 |  |  |  |  |  |  | my ($type, $tdate, $l) = @_; | 
| 943 |  |  |  |  |  |  |  | 
| 944 |  |  |  |  |  |  | require HTML::Form; | 
| 945 |  |  |  |  |  |  | my $content  = $l->{'content'}; | 
| 946 |  |  |  |  |  |  | my $url  = $l->{'url'}; | 
| 947 |  |  |  |  |  |  | my $form = HTML::Form->parse($content, $url) | 
| 948 |  |  |  |  |  |  | or die "LME metals page not a form"; | 
| 949 |  |  |  |  |  |  |  | 
| 950 |  |  |  |  |  |  | my ($year, $month, $day) = App::Chart::tdate_to_ymd ($tdate); | 
| 951 |  |  |  |  |  |  | # these are literal "$" in the field name | 
| 952 |  |  |  |  |  |  | $form->value ("_searchForm\$_lstdate",  $day); | 
| 953 |  |  |  |  |  |  | $form->value ("_searchForm\$_lstmonth", $month); | 
| 954 |  |  |  |  |  |  | $form->value ("_searchForm\$_lstyear",  $year); | 
| 955 |  |  |  |  |  |  |  | 
| 956 |  |  |  |  |  |  | App::Chart::Download::status | 
| 957 |  |  |  |  |  |  | (__x('LME daily {type} {date}', | 
| 958 |  |  |  |  |  |  | type => $type, | 
| 959 |  |  |  |  |  |  | date => App::Chart::Download::tdate_range_string ($tdate))); | 
| 960 |  |  |  |  |  |  |  | 
| 961 |  |  |  |  |  |  | require App::Chart::UserAgent; | 
| 962 |  |  |  |  |  |  | require HTTP::Cookies; | 
| 963 |  |  |  |  |  |  | my $ua = App::Chart::UserAgent->instance->clone; | 
| 964 |  |  |  |  |  |  | $ua->requests_redirectable ([]); | 
| 965 |  |  |  |  |  |  | my $jar = HTTP::Cookies->new; | 
| 966 |  |  |  |  |  |  | $ua->cookie_jar ($jar); | 
| 967 |  |  |  |  |  |  |  | 
| 968 |  |  |  |  |  |  | my $req = $form->click(); | 
| 969 |  |  |  |  |  |  | my $resp = $ua->request ($req); | 
| 970 |  |  |  |  |  |  |  | 
| 971 |  |  |  |  |  |  | if (! $resp->is_success) { | 
| 972 |  |  |  |  |  |  | die "Cannot download $url\n",$resp->headers->as_string,"\n"; | 
| 973 |  |  |  |  |  |  | } | 
| 974 |  |  |  |  |  |  | return $resp; | 
| 975 |  |  |  |  |  |  | } | 
| 976 |  |  |  |  |  |  |  | 
| 977 |  |  |  |  |  |  | my %type_to_daily_url | 
| 978 |  |  |  |  |  |  | = (metals   => 'https://secure.lme.com/Data/community/Dataprices_daily_metals.aspx', | 
| 979 |  |  |  |  |  |  | plastics => 'https://secure.lme.com/Data/community/Dataprices_daily_prices_plastics.aspx', | 
| 980 |  |  |  |  |  |  | steels   => 'https://secure.lme.com/Data/community/Dataprices_Steels_OfficialPrices.aspx'); | 
| 981 |  |  |  |  |  |  |  | 
| 982 |  |  |  |  |  |  | sub daily_latest { | 
| 983 |  |  |  |  |  |  | my ($type) = @_; | 
| 984 |  |  |  |  |  |  | require App::Chart::Pagebits; | 
| 985 |  |  |  |  |  |  | return App::Chart::Pagebits::get | 
| 986 |  |  |  |  |  |  | (name      => __x('LME daily latest {type}', | 
| 987 |  |  |  |  |  |  | type => $type), | 
| 988 |  |  |  |  |  |  | url       => $type_to_daily_url{$type}, | 
| 989 |  |  |  |  |  |  | key       => "lme-daily-latest-$type", | 
| 990 |  |  |  |  |  |  | freq_days => 0, | 
| 991 |  |  |  |  |  |  | timezone  => App::Chart::TZ->london, | 
| 992 |  |  |  |  |  |  | parse     => \&daily_latest_parse); | 
| 993 |  |  |  |  |  |  | } | 
| 994 |  |  |  |  |  |  |  | 
| 995 |  |  |  |  |  |  | sub daily_latest_parse { | 
| 996 |  |  |  |  |  |  | my ($resp) = @_; | 
| 997 |  |  |  |  |  |  | my $content = $resp->decoded_content (raise_error => 1); | 
| 998 |  |  |  |  |  |  | my $h = daily_parse ($resp); | 
| 999 |  |  |  |  |  |  | return { h       => $h, | 
| 1000 |  |  |  |  |  |  | date    => $h->{'data'}->[0]->{'date'}, | 
| 1001 |  |  |  |  |  |  | url     => $resp->uri->as_string, | 
| 1002 |  |  |  |  |  |  | content => $content }; | 
| 1003 |  |  |  |  |  |  | } | 
| 1004 |  |  |  |  |  |  |  | 
| 1005 |  |  |  |  |  |  |  | 
| 1006 |  |  |  |  |  |  | 1; | 
| 1007 |  |  |  |  |  |  | __END__ | 
| 1008 |  |  |  |  |  |  |  | 
| 1009 |  |  |  |  |  |  |  | 
| 1010 |  |  |  |  |  |  | #----------------------------------------------------------------------------- | 
| 1011 |  |  |  |  |  |  | # download - daily | 
| 1012 |  |  |  |  |  |  | # | 
| 1013 |  |  |  |  |  |  | # | 
| 1014 |  |  |  |  |  |  |  | 
| 1015 |  |  |  |  |  |  | # LST has elements (SYMBOL NAME TDATE BUY-STR SELL-STR MDATE) per | 
| 1016 |  |  |  |  |  |  | # `daily-html-parse' | 
| 1017 |  |  |  |  |  |  | # | 
| 1018 |  |  |  |  |  |  | # The sell price is used.  The report for cash prices has the seller marked | 
| 1019 |  |  |  |  |  |  | # as the settlement and for the forwards the historical files can be seen | 
| 1020 |  |  |  |  |  |  | # with the seller price. | 
| 1021 |  |  |  |  |  |  | # | 
| 1022 |  |  |  |  |  |  | (define (daily-process symbol-list lst) | 
| 1023 |  |  |  |  |  |  | (download-process | 
| 1024 |  |  |  |  |  |  | #:module      (_ "LME") | 
| 1025 |  |  |  |  |  |  | #:symbol-list symbol-list | 
| 1026 |  |  |  |  |  |  | #:currency    "USD" | 
| 1027 |  |  |  |  |  |  | #:row-list | 
| 1028 |  |  |  |  |  |  | (map (lambda (row) | 
| 1029 |  |  |  |  |  |  | (receive-list (symbol name tdate buy sell mdate) | 
| 1030 |  |  |  |  |  |  | row | 
| 1031 |  |  |  |  |  |  | (list #:tdate     tdate | 
| 1032 |  |  |  |  |  |  | #:mdate     mdate | 
| 1033 |  |  |  |  |  |  | #:commodity (chart-symbol-commodity symbol) | 
| 1034 |  |  |  |  |  |  | #:close     sell))) | 
| 1035 |  |  |  |  |  |  | lst))) | 
| 1036 |  |  |  |  |  |  |  | 
| 1037 |  |  |  |  |  |  | (define (lme-daily-download symbol-list type) | 
| 1038 |  |  |  |  |  |  | (define selector (case type | 
| 1039 |  |  |  |  |  |  | ((metals)   lme-metal-symbol?) | 
| 1040 |  |  |  |  |  |  | ((plastics) lme-plastics-symbol?))) | 
| 1041 |  |  |  |  |  |  |  | 
| 1042 |  |  |  |  |  |  | (set! symbol-list (filter selector symbol-list)) | 
| 1043 |  |  |  |  |  |  | (if (not (null? symbol-list)) | 
| 1044 |  |  |  |  |  |  |  | 
| 1045 |  |  |  |  |  |  | (let* ((end-data  (assq-ref (daily-latest-info type) 'data)) | 
| 1046 |  |  |  |  |  |  | (end-tdate (if end-data | 
| 1047 |  |  |  |  |  |  | (data-tdate end-data) | 
| 1048 |  |  |  |  |  |  | (daily-available-tdate type)))) | 
| 1049 |  |  |  |  |  |  |  | 
| 1050 |  |  |  |  |  |  | (set! symbol-list | 
| 1051 |  |  |  |  |  |  | (download-also symbol-list #:selector selector)) | 
| 1052 |  |  |  |  |  |  |  | 
| 1053 |  |  |  |  |  |  | # only go back 25 days for LMEX or others without yearly data, | 
| 1054 |  |  |  |  |  |  | # since at 70kbytes per day it quickly becomes slow | 
| 1055 |  |  |  |  |  |  | # | 
| 1056 |  |  |  |  |  |  | (do ((t (apply min (map (lambda (symbol) | 
| 1057 |  |  |  |  |  |  | (download-start-tdate symbol #:initial 25)) | 
| 1058 |  |  |  |  |  |  | symbol-list)) | 
| 1059 |  |  |  |  |  |  | (1+ t))) | 
| 1060 |  |  |  |  |  |  | ((>= t end-tdate)) | 
| 1061 |  |  |  |  |  |  | (and-let* ((data (lme-daily-download-tdate type t))) | 
| 1062 |  |  |  |  |  |  | (daily-process symbol-list data))) | 
| 1063 |  |  |  |  |  |  |  | 
| 1064 |  |  |  |  |  |  | (if end-data | 
| 1065 |  |  |  |  |  |  | (daily-process symbol-list end-data))))) | 
| 1066 |  |  |  |  |  |  |  | 
| 1067 |  |  |  |  |  |  |  | 
| 1068 |  |  |  |  |  |  | (define (lme-historical-download symbol-list) | 
| 1069 |  |  |  |  |  |  |  | 
| 1070 |  |  |  |  |  |  | (let* ((avail-tdate (monthxls-available-tdate))) | 
| 1071 |  |  |  |  |  |  |  | 
| 1072 |  |  |  |  |  |  | # whether can update prices for SYMBOL using xls | 
| 1073 |  |  |  |  |  |  | (define (want-monthxls? symbol) | 
| 1074 |  |  |  |  |  |  | (>= avail-tdate (download-start-tdate symbol))) | 
| 1075 |  |  |  |  |  |  |  | 
| 1076 |  |  |  |  |  |  | # whether can update volume for SYMBOL | 
| 1077 |  |  |  |  |  |  | (define (want-volume? symbol) | 
| 1078 |  |  |  |  |  |  | # no volumes for forward symbols like "ZINC 3.LME" or futures | 
| 1079 |  |  |  |  |  |  | # specific symbols like "PP MAY 06.LME" | 
| 1080 |  |  |  |  |  |  | (and (not (string-any char-numeric? symbol)) | 
| 1081 |  |  |  |  |  |  | (let ((last-tdate (database-last-volume symbol))) | 
| 1082 |  |  |  |  |  |  | (or (not last-tdate) | 
| 1083 |  |  |  |  |  |  | (< last-tdate avail-tdate))))) | 
| 1084 |  |  |  |  |  |  |  | 
| 1085 |  |  |  |  |  |  | # whether can update anything for SYMBOL | 
| 1086 |  |  |  |  |  |  | (define (want-update? symbol) | 
| 1087 |  |  |  |  |  |  | (or (want-monthxls? symbol) | 
| 1088 |  |  |  |  |  |  | (want-volume? symbol))) | 
| 1089 |  |  |  |  |  |  |  | 
| 1090 |  |  |  |  |  |  | (if (any want-update? symbol-list) | 
| 1091 |  |  |  |  |  |  | (begin | 
| 1092 |  |  |  |  |  |  | (if (any want-monthxls? symbol-list) | 
| 1093 |  |  |  |  |  |  | (monthxls-download symbol-list)) | 
| 1094 |  |  |  |  |  |  | (if (any want-volume? symbol-list) | 
| 1095 |  |  |  |  |  |  | (volume-download symbol-list)))))) | 
| 1096 |  |  |  |  |  |  |  | 
| 1097 |  |  |  |  |  |  |  | 
| 1098 |  |  |  |  |  |  |  | 
| 1099 |  |  |  |  |  |  | (let ((vol-tdate (database-last-volume symbol))) | 
| 1100 |  |  |  |  |  |  |  | 
| 1101 |  |  |  |  |  |  |  | 
| 1102 |  |  |  |  |  |  |  | 
| 1103 |  |  |  |  |  |  | # date is: "26 Jan 2005 (Data >1 day old)   </b></td>" | 
| 1104 |  |  |  |  |  |  | # or:      "3 Feb 2005   </b></td>" | 
| 1105 |  |  |  |  |  |  | (let* ((m        (must-match (string-match " Prices[ ,][^\n]*for +([0-9]+) ([A-Za-z]+) ([0-9][0-9][0-9][0-9])" body))) | 
| 1106 |  |  |  |  |  |  | (tdate    (ymd->tdate (string->number (match:substring m 3)) | 
| 1107 |  |  |  |  |  |  | (Mmm-str->month (match:substring m 2)) | 
| 1108 |  |  |  |  |  |  | (string->number (match:substring m 1)))) | 
| 1109 |  |  |  |  |  |  | (row-list (html-table-rows body (match:end m)))) | 
| 1110 |  |  |  |  |  |  |  | 
| 1111 |  |  |  |  |  |  | # blank separator lines | 
| 1112 |  |  |  |  |  |  | (set! row-list (remove! (lambda (row) | 
| 1113 |  |  |  |  |  |  | (every string-null? row)) | 
| 1114 |  |  |  |  |  |  | row-list)) | 
| 1115 |  |  |  |  |  |  |  | 
| 1116 |  |  |  |  |  |  | (let ((commodity-list (map daily-heading->commodity+name | 
| 1117 |  |  |  |  |  |  | (first row-list)))) | 
| 1118 |  |  |  |  |  |  | (set! row-list (cdr row-list)) | 
| 1119 |  |  |  |  |  |  |  | 
| 1120 |  |  |  |  |  |  | (for-each-two | 
| 1121 |  |  |  |  |  |  | (lambda (buyer-row seller-row) | 
| 1122 |  |  |  |  |  |  | # row like ("" "September Buyer" "" "932" "" "931" "") | 
| 1123 |  |  |  |  |  |  | #          ("" "Cash buyer" "" "1,555.00" "" "1,737.00" "" ...) | 
| 1124 |  |  |  |  |  |  | (for-each | 
| 1125 |  |  |  |  |  |  | (lambda (commodity+name buy sell) | 
| 1126 |  |  |  |  |  |  | (if commodity+name | 
| 1127 |  |  |  |  |  |  | (receive-list (commodity name) | 
| 1128 |  |  |  |  |  |  | commodity+name | 
| 1129 |  |  |  |  |  |  |  | 
| 1130 |  |  |  |  |  |  | (define symbol (commodity+label->symbol | 
| 1131 |  |  |  |  |  |  | commodity (second buyer-row))) | 
| 1132 |  |  |  |  |  |  | (define (lat sym) | 
| 1133 |  |  |  |  |  |  | (set! ret (cons (list sym name tdate buy sell | 
| 1134 |  |  |  |  |  |  | (chart-symbol-mdate symbol)) | 
| 1135 |  |  |  |  |  |  | ret))) | 
| 1136 |  |  |  |  |  |  |  | 
| 1137 |  |  |  |  |  |  | (set! buy  (crunch-price buy)) | 
| 1138 |  |  |  |  |  |  | (set! sell (crunch-price sell)) | 
| 1139 |  |  |  |  |  |  |  | 
| 1140 |  |  |  |  |  |  | # first row as front month | 
| 1141 |  |  |  |  |  |  | (if (and (eq? buyer-row (first row-list)) | 
| 1142 |  |  |  |  |  |  | (chart-symbol-mdate symbol)) | 
| 1143 |  |  |  |  |  |  | (lat (string-append commodity ".LME"))) | 
| 1144 |  |  |  |  |  |  |  | 
| 1145 |  |  |  |  |  |  | # all rows with month in symbol | 
| 1146 |  |  |  |  |  |  | (lat symbol)))) | 
| 1147 |  |  |  |  |  |  |  | 
| 1148 |  |  |  |  |  |  | commodity-list buyer-row seller-row)) | 
| 1149 |  |  |  |  |  |  | row-list))) | 
| 1150 |  |  |  |  |  |  |  | 
| 1151 |  |  |  |  |  |  | (and-let* ((m (string-match "LMEX Index value [^0-9\n]*([0-9]+ [A-Za-z]+ [0-9][0-9][0-9][0-9])[^0-9.\n]+([0-9.]+)" body))) | 
| 1152 |  |  |  |  |  |  | (set! ret (cons (list "LMEX.LME" | 
| 1153 |  |  |  |  |  |  | #f | 
| 1154 |  |  |  |  |  |  | (d/m/y-str->tdate (match:substring m 1)) | 
| 1155 |  |  |  |  |  |  | #f # no separate buy price | 
| 1156 |  |  |  |  |  |  | (match:substring m 2) | 
| 1157 |  |  |  |  |  |  | #f) | 
| 1158 |  |  |  |  |  |  | ret))) | 
| 1159 |  |  |  |  |  |  |  | 
| 1160 |  |  |  |  |  |  | ret) | 
| 1161 |  |  |  |  |  |  |  | 
| 1162 |  |  |  |  |  |  |  | 
| 1163 |  |  |  |  |  |  |  | 
| 1164 |  |  |  |  |  |  | (define (daily-latest-parse body) | 
| 1165 |  |  |  |  |  |  | (list | 
| 1166 |  |  |  |  |  |  | (cons 'form   (html-form-parse body)) | 
| 1167 |  |  |  |  |  |  | (cons 'prices (daily-html-parse body)))) | 
| 1168 |  |  |  |  |  |  |  | 
| 1169 |  |  |  |  |  |  | (define (daily-latest-info type) | 
| 1170 |  |  |  |  |  |  | (lme-ensure-login) | 
| 1171 |  |  |  |  |  |  |  | 
| 1172 |  |  |  |  |  |  | (pagebits-read #:filename  (case type | 
| 1173 |  |  |  |  |  |  | ((metals)   "lme-latest-metals") | 
| 1174 |  |  |  |  |  |  | ((plastics) "lme-latest-plastics")) | 
| 1175 |  |  |  |  |  |  | #:status    (list (_ "LME") | 
| 1176 |  |  |  |  |  |  | (case type | 
| 1177 |  |  |  |  |  |  | ((metals)   (_ "metals latest")) | 
| 1178 |  |  |  |  |  |  | ((plastics) (_ "plastics latest")))) | 
| 1179 |  |  |  |  |  |  | #:url       (list (case type | 
| 1180 |  |  |  |  |  |  | ((metals) | 
| 1181 |  |  |  |  |  |  | ((plastics) "https://secure.lme.com/Data/community/Dataprices_daily_prices_plastics.aspx")) | 
| 1182 |  |  |  |  |  |  | #:cookiejar lme-cookiejar-filename | 
| 1183 |  |  |  |  |  |  | #:follow    #f) | 
| 1184 |  |  |  |  |  |  | #:timezone  (timezone-london) | 
| 1185 |  |  |  |  |  |  | #:parse     daily-latest-parse)) | 
| 1186 |  |  |  |  |  |  |  | 
| 1187 |  |  |  |  |  |  |  | 
| 1188 |  |  |  |  |  |  | #----------------------------------------------------------------------------- | 
| 1189 |  |  |  |  |  |  | # latest | 
| 1190 |  |  |  |  |  |  | # | 
| 1191 |  |  |  |  |  |  | # This uses the daily prices in the login "free data service", login | 
| 1192 |  |  |  |  |  |  | # required, at | 
| 1193 |  |  |  |  |  |  | # | 
| 1194 |  |  |  |  |  |  | #     https://secure.lme.com/Data/community/Dataprices_daily_metals.aspx | 
| 1195 |  |  |  |  |  |  | # | 
| 1196 |  |  |  |  |  |  | # This plain url gives the most recent prices, which we take as a quote for | 
| 1197 |  |  |  |  |  |  | # the indicated day then work back with form-data fetching previous days to | 
| 1198 |  |  |  |  |  |  | # find price change amounts.  Or the database is used if it covers the | 
| 1199 |  |  |  |  |  |  | # desired symbol(s). | 
| 1200 |  |  |  |  |  |  | # | 
| 1201 |  |  |  |  |  |  | # Unfortunately there's no Last-Modified or ETag to save refetching if the | 
| 1202 |  |  |  |  |  |  | # latest GET contents have not yet updated.  (???) | 
| 1203 |  |  |  |  |  |  |  | 
| 1204 |  |  |  |  |  |  |  | 
| 1205 |  |  |  |  |  |  | (define (lme-latest-update-database type newest-data prev-data) | 
| 1206 |  |  |  |  |  |  | (let* ((end-tdate   (data-tdate newest-data)) | 
| 1207 |  |  |  |  |  |  | (start-tdate (if prev-data | 
| 1208 |  |  |  |  |  |  | (data-tdate prev-data) | 
| 1209 |  |  |  |  |  |  | end-tdate)) | 
| 1210 |  |  |  |  |  |  | (db-list     (download-also '() #:selector (lme-type->selector type) | 
| 1211 |  |  |  |  |  |  | #:start-tdate start-tdate | 
| 1212 |  |  |  |  |  |  | #:end-tdate end-tdate))) | 
| 1213 |  |  |  |  |  |  | (if prev-data | 
| 1214 |  |  |  |  |  |  | (daily-process db-list prev-data)) | 
| 1215 |  |  |  |  |  |  | (daily-process db-list newest-data))) | 
| 1216 |  |  |  |  |  |  |  | 
| 1217 |  |  |  |  |  |  | (define (lme-latest-process newest-data prev-data proc) | 
| 1218 |  |  |  |  |  |  | (define lst '()) | 
| 1219 |  |  |  |  |  |  |  | 
| 1220 |  |  |  |  |  |  | (for-each | 
| 1221 |  |  |  |  |  |  | (lambda (elem) | 
| 1222 |  |  |  |  |  |  | (receive-list (symbol name tdate buy sell mdate) | 
| 1223 |  |  |  |  |  |  | elem | 
| 1224 |  |  |  |  |  |  |  | 
| 1225 |  |  |  |  |  |  | (and-let* ((prev-elem (assoc symbol prev-data))) # match car | 
| 1226 |  |  |  |  |  |  | (let ((prev-sell (fifth prev-elem))) | 
| 1227 |  |  |  |  |  |  |  | 
| 1228 |  |  |  |  |  |  | (receive-list (decimals buy sell prev-sell) | 
| 1229 |  |  |  |  |  |  | (strings->numbers+decimals buy sell prev-sell) | 
| 1230 |  |  |  |  |  |  |  | 
| 1231 |  |  |  |  |  |  | # need both buy and sell to show as quote | 
| 1232 |  |  |  |  |  |  | (define bid         buy) | 
| 1233 |  |  |  |  |  |  | (define offer       (and buy sell)) | 
| 1234 |  |  |  |  |  |  | (define quote-tdate (and buy sell tdate)) | 
| 1235 |  |  |  |  |  |  |  | 
| 1236 |  |  |  |  |  |  | # sell is normally always present, but have seen entire page | 
| 1237 |  |  |  |  |  |  | # blank (empty fields "" which become #f) 31aug05 after | 
| 1238 |  |  |  |  |  |  | # 29aug05 bank holiday | 
| 1239 |  |  |  |  |  |  |  | 
| 1240 |  |  |  |  |  |  | (set! lst | 
| 1241 |  |  |  |  |  |  | (cons (latest-new #:symbol         symbol | 
| 1242 |  |  |  |  |  |  | #:name           name | 
| 1243 |  |  |  |  |  |  | #:quote-tdate    quote-tdate | 
| 1244 |  |  |  |  |  |  | #:bid            bid | 
| 1245 |  |  |  |  |  |  | #:offer          offer | 
| 1246 |  |  |  |  |  |  | #:last-tdate     tdate | 
| 1247 |  |  |  |  |  |  | #:last           sell | 
| 1248 |  |  |  |  |  |  | #:prev           prev-sell | 
| 1249 |  |  |  |  |  |  | #:decimals       decimals | 
| 1250 |  |  |  |  |  |  | #:contract-mdate mdate | 
| 1251 |  |  |  |  |  |  | #:source         'lme) | 
| 1252 |  |  |  |  |  |  | lst))))))) | 
| 1253 |  |  |  |  |  |  | newest-data) | 
| 1254 |  |  |  |  |  |  |  | 
| 1255 |  |  |  |  |  |  | (proc lst)) | 
| 1256 |  |  |  |  |  |  |  | 
| 1257 |  |  |  |  |  |  | (define (lme-latest-type symbol-list type proc) | 
| 1258 |  |  |  |  |  |  |  | 
| 1259 |  |  |  |  |  |  | (and-let* ((newest-data  (assq-ref (daily-latest-info type) 'prices))) | 
| 1260 |  |  |  |  |  |  |  | 
| 1261 |  |  |  |  |  |  | # COVERED-TDATE is the data we already have for all of SYMBOL-LIST (or | 
| 1262 |  |  |  |  |  |  | # rather for the worst among that list), default to a dummy 100 days | 
| 1263 |  |  |  |  |  |  | # ago | 
| 1264 |  |  |  |  |  |  | (let* ((newest-tdate  (data-tdate newest-data)) | 
| 1265 |  |  |  |  |  |  | (covered-data  (daily-from-database symbol-list)) | 
| 1266 |  |  |  |  |  |  | (covered-tdate (if covered-data | 
| 1267 |  |  |  |  |  |  | (data-tdate covered-data) | 
| 1268 |  |  |  |  |  |  | (- (daily-available-tdate type) 100)))) | 
| 1269 |  |  |  |  |  |  | (let more ((attempt 1)) | 
| 1270 |  |  |  |  |  |  | (if (> attempt 5) | 
| 1271 |  |  |  |  |  |  | (error "LME: can't find previous daily data")) | 
| 1272 |  |  |  |  |  |  |  | 
| 1273 |  |  |  |  |  |  | (let ((prev-tdate (- newest-tdate attempt))) | 
| 1274 |  |  |  |  |  |  | (if (>= covered-tdate prev-tdate) | 
| 1275 |  |  |  |  |  |  | (begin | 
| 1276 |  |  |  |  |  |  | (lme-latest-update-database type newest-data #f) | 
| 1277 |  |  |  |  |  |  | (lme-latest-process newest-data covered-data proc)) | 
| 1278 |  |  |  |  |  |  |  | 
| 1279 |  |  |  |  |  |  | (let ((prev-data (lme-daily-download-tdate type prev-tdate))) | 
| 1280 |  |  |  |  |  |  | (if prev-data | 
| 1281 |  |  |  |  |  |  | (begin | 
| 1282 |  |  |  |  |  |  | (lme-latest-update-database type newest-data prev-data) | 
| 1283 |  |  |  |  |  |  | (lme-latest-process newest-data prev-data proc)) | 
| 1284 |  |  |  |  |  |  |  | 
| 1285 |  |  |  |  |  |  | (more (1+ attempt)))))))))) | 
| 1286 |  |  |  |  |  |  |  | 
| 1287 |  |  |  |  |  |  | (define (lme-symbol->type symbol) | 
| 1288 |  |  |  |  |  |  | (if (lme-metal-symbol? symbol) 'metals 'plastics)) | 
| 1289 |  |  |  |  |  |  |  | 
| 1290 |  |  |  |  |  |  | (define (lme-latest-get symbol-list extra-list proc) | 
| 1291 |  |  |  |  |  |  |  | 
| 1292 |  |  |  |  |  |  | (if (string-null? (preference-get 'lme-username "")) | 
| 1293 |  |  |  |  |  |  | (proc (map (lambda (symbol) | 
| 1294 |  |  |  |  |  |  | (latest-new #:symbol symbol | 
| 1295 |  |  |  |  |  |  | #:note   (_ "must register") | 
| 1296 |  |  |  |  |  |  | #:source 'lme)) | 
| 1297 |  |  |  |  |  |  | (append symbol-list extra-list))) | 
| 1298 |  |  |  |  |  |  |  | 
| 1299 |  |  |  |  |  |  | # look for one or both metal and plastics in symbol-list, do the two in | 
| 1300 |  |  |  |  |  |  | # the order they appear in SYMBOL-LIST | 
| 1301 |  |  |  |  |  |  | (for-each (lambda (type) | 
| 1302 |  |  |  |  |  |  | (lme-latest-type symbol-list type proc)) | 
| 1303 |  |  |  |  |  |  | (delete-duplicates (map lme-symbol->type symbol-list))))) | 
| 1304 |  |  |  |  |  |  |  | 
| 1305 |  |  |  |  |  |  | (define (lme-quote-adate-time symbol) | 
| 1306 |  |  |  |  |  |  | (list (tdate->adate (daily-available-tdate (lme-symbol->type symbol))) #f)) | 
| 1307 |  |  |  |  |  |  |  | 
| 1308 |  |  |  |  |  |  | (latest-handler! #:selector   lme-symbol? | 
| 1309 |  |  |  |  |  |  | #:handler    lme-latest-get | 
| 1310 |  |  |  |  |  |  | #:adate-time lme-quote-adate-time) | 
| 1311 |  |  |  |  |  |  |  | 
| 1312 |  |  |  |  |  |  |  | 
| 1313 |  |  |  |  |  |  | #----------------------------------------------------------------------------- | 
| 1314 |  |  |  |  |  |  | # download - historical prices and/or volumes | 
| 1315 |  |  |  |  |  |  |  | 
| 1316 |  |  |  |  |  |  | # return tdate of last volume value recorded for SYMBOL, or #f if none ever | 
| 1317 |  |  |  |  |  |  | (define (database-last-volume symbol) | 
| 1318 |  |  |  |  |  |  | (and-let* ((series (database-read-series symbol))) | 
| 1319 |  |  |  |  |  |  | (series-array series   # initial request past month to look at | 
| 1320 |  |  |  |  |  |  | (- (series-hi series) 25) | 
| 1321 |  |  |  |  |  |  | (series-hi series)) | 
| 1322 |  |  |  |  |  |  | (let more ((i (series-hi series))) | 
| 1323 |  |  |  |  |  |  | (and (>= i (series-lo series)) | 
| 1324 |  |  |  |  |  |  | (if (array-ref (series-array series i i) i 4) | 
| 1325 |  |  |  |  |  |  | i | 
| 1326 |  |  |  |  |  |  | (more (1- i))))))) | 
| 1327 |  |  |  |  |  |  |  | 
| 1328 |  |  |  |  |  |  |  | 
| 1329 |  |  |  |  |  |  | #----------------------------------------------------------------------------- | 
| 1330 |  |  |  |  |  |  | # download | 
| 1331 |  |  |  |  |  |  |  | 
| 1332 |  |  |  |  |  |  | (define (lme-download-available-tdate) | 
| 1333 |  |  |  |  |  |  | (daily-available-tdate 'metals) | 
| 1334 |  |  |  |  |  |  | (monthxls-available-tdate)) | 
| 1335 |  |  |  |  |  |  |  | 
| 1336 |  |  |  |  |  |  | (download-now-handler! (lambda (symbol-list) | 
| 1337 |  |  |  |  |  |  | (and (any lme-symbol? symbol-list) | 
| 1338 |  |  |  |  |  |  | (download-now-all-commodities-and-months)))) |