File Coverage

blib/lib/App/Chart/Download.pm
Criterion Covered Total %
statement 33 35 94.2
branch n/a
condition n/a
subroutine 12 12 100.0
pod n/a
total 45 47 95.7


line stmt bran cond sub pod time code
1             # Download functions.
2              
3             # Copyright 2007, 2008, 2009, 2010, 2011, 2013, 2015, 2016, 2017 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::Download;
20 2     2   885 use 5.010;
  2         8  
21 2     2   12 use strict;
  2         4  
  2         73  
22 2     2   14 use warnings;
  2         5  
  2         77  
23 2     2   13 use Carp 'carp','croak';
  2         3  
  2         121  
24 2     2   556 use Date::Calc;
  2         9884  
  2         78  
25 2     2   29 use List::Util qw(min max);
  2         3  
  2         106  
26 2     2   355 use List::MoreUtils;
  2         10254  
  2         12  
27 2     2   2235 use Regexp::Common 'whitespace';
  2         4934  
  2         8  
28 2     2   1827 use Locale::TextDomain ('App-Chart');
  2         15759  
  2         15  
29              
30 2     2   6842 use PerlIO::via::EscStatus;
  2         26822  
  2         127  
31 2     2   799 use Tie::TZ;
  2         1304  
  2         93  
32              
33 2     2   1096 use App::Chart;
  0            
  0            
34             use App::Chart::Database;
35             use App::Chart::DBI;
36             use App::Chart::TZ;
37              
38             # uncomment this to run the ### lines
39             # use Smart::Comments;
40              
41             use constant DEBUG => 0;
42              
43             #------------------------------------------------------------------------------
44              
45             sub get {
46             my ($class, $url, %options) = @_;
47             ### Download get(): $url
48              
49             # URI object becomes string
50             $url = "$url";
51              
52             my $ua = $options{'ua'} || do { require App::Chart::UserAgent;
53             App::Chart::UserAgent->instance };
54             $ua->cookie_jar ($options{'cookie_jar'}); # undef for none
55              
56             require HTTP::Request;
57             my $method = $options{'method'} || 'GET';
58             my @headers = (Referer => $options{'referer'});
59             my $data = $options{'data'};
60             if (defined $data) {
61             push @headers, 'Content-Type' => 'application/x-www-form-urlencoded';
62             }
63             my $req = HTTP::Request->new ($method, $url, \@headers, $data);
64              
65             # possible override
66             if (my $user_agent = $options{'user_agent'}) {
67             $req->user_agent($user_agent);
68             }
69              
70             my $etag = $options{'etag'};
71             my $lastmod = $options{'last_modified'};
72              
73             if (my $key = $options{'url_tags_key'}) {
74             my $symbol = $options{'symbol'};
75             my $prev_url = App::Chart::Database->read_extra($symbol,"$key-URL");
76             if (defined $prev_url && $url eq $prev_url) {
77             $etag = App::Chart::Database->read_extra($symbol,"$key-ETag");
78             $lastmod = App::Chart::Database->read_extra($symbol,"$key-Last-Modified");
79             }
80             }
81              
82             if ($etag) { $req->header ('If-None-Match' => $etag); }
83             if ($lastmod) { $req->header ('If-Modified-Since' => $lastmod); }
84              
85             $ua->prepare_request ($req);
86             if (DEBUG) { print $req->as_string; }
87              
88             if ($App::Chart::option{'verbose'} || DEBUG) {
89             if ($App::Chart::option{'verbose'} >= 2 || DEBUG >= 2) {
90             print $req->as_string;
91             } else {
92             print "$method $url\n";
93             }
94             if (defined $data) {
95             print "$data\n";
96             }
97             }
98              
99             my $resp = $ua->request ($req);
100             if (DEBUG) { print $resp->status_line,"\n";
101             print $resp->headers->as_string,"\n"; }
102              
103             # internal message from LWP when a keep-alive has missed the boat
104             if ($resp->status_line =~ /500 Server closed connection/i) {
105             substatus (__('retry'));
106             $resp = $ua->request ($req);
107             if (DEBUG) { print $resp->status_line,"\n";
108             print $resp->headers->as_string,"\n"; }
109             }
110              
111             if ($resp->is_success
112             || ($options{'allow_401'} && $resp->code == 401)
113             || ($options{'allow_404'} && $resp->code == 404)
114             || (($etag || $lastmod) && $resp->code == 304)) {
115             substatus (__('processing'));
116             return $resp;
117             } else {
118             croak "Cannot download $url\n",$resp->status_line,"\n";
119             }
120             }
121              
122             #------------------------------------------------------------------------------
123              
124             my $last_status = ''; # without substatus addition
125              
126             sub download_message {
127             print join (' ',@_),"\n";
128             }
129             sub verbose_message {
130             if ($App::Chart::option{'verbose'}) {
131             print join (' ',@_),"\n";
132             }
133             }
134              
135             sub status {
136             my $str = join (' ', @_);
137             $last_status = $str;
138             PerlIO::via::EscStatus::print_status ($str);
139             }
140             sub substatus {
141             my ($str) = @_;
142             if ($str) {
143             PerlIO::via::EscStatus::print_status ($last_status, ' [', $str, ']');
144             }
145             }
146              
147             #------------------------------------------------------------------------------
148              
149             sub split_lines {
150             my ($str) = @_;
151             my @lines = split (/[\r\n]+/, $str); # LF or CRLF
152             foreach (@lines) { $_ =~ s/[ \t]+$// } # trailing whitespace
153             return grep {$_ ne ''} @lines; # no blanks
154             }
155              
156             #------------------------------------------------------------------------------
157              
158             sub trim_decimals {
159             my ($str, $want_decimals) = @_;
160             if ($str && $str =~ /(.*\.[0-9]{$want_decimals}[0-9]*?)0+$/) {
161             return $1;
162             } else {
163             return $str;
164             }
165             }
166              
167             #------------------------------------------------------------------------------
168              
169             sub str_is_zero {
170             my ($str) = @_;
171             return ($str =~ /^0+(\.0*)?$|^0*(\.0+)$/ ? 1 : 0);
172             }
173              
174              
175             #------------------------------------------------------------------------------
176              
177             sub cents_to_dollars {
178             my ($str) = @_;
179             $str =~ /^([^.]*)(\.(.*))?$/
180             or croak "cents_to_dollars(): bad string: \"$str\"";
181             my $int = $1;
182             my $frac = (defined $3 ? $3 : '');
183             if (length ($int) < 3) {
184             $int = sprintf ('%03s', $int);
185             }
186             return substr ($int, 0, length($int)-2) . '.' .
187             substr ($int, length($int)-2) . $frac;
188             }
189              
190              
191             #------------------------------------------------------------------------------
192              
193             sub month_to_nearest_year {
194             my ($target_month) = @_;
195             my ($year, $month, $day) = Date::Calc::Today();
196             $month --; # 0=January
197             $target_month --;
198              
199             my $diff = $target_month - $month;
200             $diff += 5;
201             $diff %= 12;
202             $diff -= 5; # now range -5 to +6
203              
204             # applying $diff makes $month == $target_month modulo 12,
205             # but $month<0 is last year, 0 to 11 this year, >= 12 next year
206             $month += $diff;
207             return $year + ($month < 0 ? -1 : $month < 12 ? 0 : 1);
208             }
209              
210              
211             #------------------------------------------------------------------------------
212              
213             sub Decode_Date_EU_to_iso {
214             my ($str, $noerror) = @_;
215             my ($year, $month, $day) = Date::Calc::Decode_Date_EU ($str);
216             if (! (defined $year && defined $month && defined $day)) {
217             if ($noerror) {
218             return undef;
219             } else {
220             croak "Decode_Date_EU_to_iso: unrecognised date \"$str\"\n";
221             }
222             }
223             return App::Chart::ymd_to_iso ($year, $month, $day);
224             }
225              
226             sub Decode_Date_US_to_iso {
227             my ($str) = @_;
228             my ($year, $month, $day) = Date::Calc::Decode_Date_US ($str);
229             return App::Chart::ymd_to_iso ($year, $month, $day);
230             }
231              
232             #------------------------------------------------------------------------------
233              
234             sub Decode_Date_YMD {
235             my ($str) = @_;
236             ($str =~ m{^ # 6 or 8 digits are yyyymmdd or yymmdd
237             ["'[:space:]]*
238             (\d{2,4}) # $1 year
239             ((\d{2})|([A-Za-z]+)) # $3 numeric month, $4 alpha month
240             (\d{2}) # $5 day
241             ["'[:space:]]*
242             $}x)
243             or
244             ($str =~ m{^
245             ["'[:space:]]*
246             (\d{2,4}) # $1 year
247             [-_/:.[:space:]]*
248             ((\d{1,2})|([A-Za-z]+)) # $3 numeric month, $4 alpha month
249             [-_/:.[:space:]]*
250             (\d{1,2}) # $5 day
251             ["'[:space:]]*
252             $}x)
253             or return;
254              
255             my $year = $1;
256             my $num_month = $3;
257             my $alpha_month = $4,
258             my $day = $5;
259             my $month = $num_month || Date::Calc::Decode_Month ($alpha_month);
260             $year = Date::Calc::Moving_Window ($year);
261             return ($year, $month, $day);
262             }
263              
264             sub Decode_Date_YMD_to_iso {
265             my ($str) = @_;
266             my ($year, $month, $day) = Decode_Date_YMD ($str);
267             if (! defined $year || ! defined $month || ! defined $day
268             || ! Date::Calc::check_date ($year, $month, $day)) {
269             croak "Decode_Date_YMD_to_iso: invalid date \"$str\"\n";
270             }
271             return App::Chart::ymd_to_iso ($year, $month, $day);
272             }
273              
274              
275             #------------------------------------------------------------------------------
276              
277             sub date_parse_to_iso {
278             my ($str) = @_;
279             require Date::Parse;
280             my ($ss,$mm,$hh,$day,$month,$year,$zone) = Date::Parse::strptime ($str);
281             if (! defined ($day) || ! defined ($month) || ! defined ($year)) {
282             croak "date_parse_to_iso: unrecognised date \"$str\"\n";
283             }
284             return App::Chart::ymd_to_iso ($year + 1900, $month + 1, $day);
285             }
286              
287             #------------------------------------------------------------------------------
288              
289             # $h is a hash of share prices
290             # data => [ { symbol=>$str,
291             # date => $str,
292             # open => $price,
293             # high => $price,
294             # low => $price,
295             # close => $price,
296             # volume => $number,
297             # openint => $number,
298             # },
299             # ...
300             # ]
301             # dividends => [ { symbol => $str,
302             # ex_date => $date,
303             # record_date => $date,
304             # pay_date => $date,
305             # type => $str,
306             # amount => $price,
307             # imputation => $price,
308             # qualifier => $str,
309             # note => $str,
310             # },
311             # ...
312             # ],
313             # write it as daily data in the database
314             sub write_daily_group {
315             my ($h) = @_;
316              
317             crunch_h ($h);
318              
319             my $prefer_decimals = $h->{'prefer_decimals'};
320             my $database_symbols_hash = App::Chart::Database::database_symbols_hash();
321              
322             my %decimals;
323             my %data_changed;
324              
325             substatus (__('writing database'));
326             my $dbh = App::Chart::DBI->instance;
327             App::Chart::Database::call_with_transaction
328             ($dbh, sub {
329             if ($h->{'cost_key'}) {
330             require App::Chart::DownloadCost;
331             App::Chart::DownloadCost::cost_store_h ($h);
332             }
333              
334             my %symbol_hash;
335             {
336             my $sth = $dbh->prepare_cached
337             ('INSERT OR REPLACE INTO daily
338             (symbol, date, open, high, low, close, volume, openint)
339             VALUES (?,?,?,?,?,?,?,?)');
340              
341             foreach my $data (@{$h->{'data'}}) {
342             my $symbol = $data->{'symbol'};
343             next unless exists $database_symbols_hash->{$symbol};
344              
345             if (defined $prefer_decimals) {
346             $decimals{$symbol} = $prefer_decimals;
347             }
348             $data_changed{$symbol} = 1;
349              
350             $sth->execute($symbol,
351             $data->{'date'},
352             $data->{'open'},
353             $data->{'high'},
354             $data->{'low'},
355             $data->{'close'},
356             $data->{'volume'},
357             $data->{'openint'});
358             $sth->finish;
359             }
360             }
361              
362             foreach my $dividend (@{$h->{'dividends'}}) {
363             my $symbol = $dividend->{'symbol'}
364             or croak "write_daily_group: missing symbol in dividend record";
365             next unless exists $database_symbols_hash->{$symbol};
366              
367             my $ex_date = $dividend->{'ex_date'};
368             my $record_date = $dividend->{'record_date'};
369             my $pay_date = $dividend->{'pay_date'};
370              
371             my $type = $dividend->{'type'} || '';
372             my $amount = $dividend->{'amount'};
373             my $imputation = $dividend->{'imputation'};
374             my $qualifier = crunch_empty_undef ($dividend->{'qualifier'});
375             my $note = crunch_empty_undef ($dividend->{'note'});
376              
377             my $old_qualifier = App::Chart::DBI->read_single
378             ('SELECT qualifier FROM dividend WHERE symbol=? AND ex_date=? AND type=?', $symbol, $ex_date, $type);
379              
380             my $sth = $dbh->prepare_cached
381             ('INSERT OR REPLACE INTO dividend
382             (symbol, ex_date, record_date, pay_date,
383             type, amount, imputation, qualifier, note)
384             VALUES (?,?,?,?, ?,?,?,?,?)');
385              
386             $sth->execute ($symbol, $ex_date, $record_date, $pay_date,
387             $type, $amount, $imputation, $qualifier, $note);
388             $sth->finish;
389             $data_changed{$symbol} = 1;
390             }
391              
392             foreach my $split (@{$h->{'splits'}}) {
393             my $symbol = $split->{'symbol'}
394             or croak "write_daily_group: missing symbol in split record";
395             next unless exists $database_symbols_hash->{$symbol};
396              
397             my $date = $split->{'date'};
398             my $new = crunch_number ($split->{'new'});
399             my $old = crunch_number ($split->{'old'});
400             my $note = crunch_empty_undef ($split->{'note'});
401              
402             my $sth = $dbh->prepare_cached
403             ('INSERT OR REPLACE INTO split (symbol, date, new, old, note)
404             VALUES (?,?,?,?,?)');
405             $sth->execute ($symbol, $date, $new, $old, $note);
406             $sth->finish;
407             $data_changed{$symbol} = 1;
408             }
409              
410             if (my $names = $h->{'names'}) {
411             while (my ($symbol, $name) = each %$names) {
412             if (defined $name) {
413             $data_changed{$symbol} |= set_symbol_name ($symbol, $name);
414             }
415             }
416             }
417             if (my $currencies = $h->{'currencies'}) {
418             while (my ($symbol, $currency) = each %$currencies) {
419             if (defined $currency) {
420             $data_changed{$symbol} |= set_currency ($symbol, $currency);
421             }
422             }
423             }
424             if (my $isins = $h->{'isins'}) {
425             while (my ($symbol, $isin) = each %$isins) {
426             if (defined $isin) {
427             $data_changed{$symbol} |= set_isin ($symbol, $isin);
428             }
429             }
430             }
431             if (my $exchanges = $h->{'exchanges'}) {
432             while (my ($symbol, $exchange) = each %$exchanges) {
433             if (defined $exchange) {
434             $data_changed{$symbol} |= set_exchange ($symbol, $exchange);
435             }
436             }
437             }
438             while (my ($symbol, $decimals) = each %decimals) {
439             if (defined $decimals) {
440             $data_changed{$symbol} |= set_decimals ($symbol, $decimals);
441             }
442             }
443              
444             my $symbol_list = [ keys %data_changed ];
445              
446             if (my $key = $h->{'url_tags_key'}) {
447             my $resp = $h->{'resp'};
448             foreach my $symbol (@$symbol_list) {
449             my $url = (defined $resp ? $resp->request->uri : undef);
450             my $etag = (defined $resp ? scalar $resp->header('ETag') : undef);
451             my $last_modified =(defined $resp ? $resp->last_modified : undef);
452              
453             App::Chart::Database->write_extra
454             ($symbol, "$key-URL", "$url"); # stringize URI object
455             App::Chart::Database->write_extra
456             ($symbol, "$key-ETag", $etag);
457             App::Chart::Database->write_extra
458             ($symbol, "$key-Last-Modified", $last_modified);
459             }
460             }
461              
462             if (my $key = $h->{'copyright_key'}) {
463             foreach my $symbol (@$symbol_list) {
464             App::Chart::Database->write_extra
465             ($symbol, $key, $h->{'copyright'});
466             }
467             }
468              
469             my $timestamp = timestamp_now();
470              
471             if (my $key = $h->{'recheck_key'}) {
472             my @recheck_list;
473             if (my $l = $h->{'recheck_list'}) {
474             @recheck_list = @$l;
475             }
476             if (my $pred = $h->{'recheck_pred'}) {
477             push @recheck_list,
478             grep { $pred->match($_) } keys %$database_symbols_hash;
479             }
480             if (DEBUG) { print "Recheck write ",join(' ',@recheck_list),"\n"; }
481             foreach my $symbol (@recheck_list) {
482             App::Chart::Database->write_extra ($symbol, $key, $timestamp);
483             }
484             }
485              
486             if ($h->{'last_download'}) {
487             foreach my $symbol (keys %data_changed) {
488             App::Chart::Database->write_extra ($symbol, 'last-download',
489             $timestamp);
490             }
491             consider_historical ($symbol_list);
492             }
493             consider_latest_from_daily ($h, $database_symbols_hash);
494             });
495              
496             ### data_changed: %data_changed
497             App::Chart::chart_dirbroadcast()->send ('data-changed', \%data_changed);
498             }
499              
500             sub consider_historical {
501             my ($symbol_list) = @_;
502              
503             my $all_list;
504             my $historical_list;
505              
506             foreach my $symbol (@$symbol_list) {
507             if (App::Chart::Database->symbol_is_historical ($symbol)) {
508             next; # already marked
509             }
510             my $reason = want_historical($symbol) // next;
511             download_message ($reason);
512              
513             my $dbh = App::Chart::DBI->instance;
514             $dbh->do ('UPDATE info SET historical=1 WHERE symbol=?',
515             {}, $symbol);
516              
517             require App::Chart::Gtk2::Symlist;
518             $all_list ||= App::Chart::Gtk2::Symlist::All->instance;
519             $historical_list ||= App::Chart::Gtk2::Symlist::Historical->instance;
520              
521             $all_list->delete_symbol ($symbol);
522             $historical_list->insert_symbol ($symbol);
523             }
524             }
525              
526             # return true if $symbol should be marked historical, meaning it has had no
527             # new daily data for a long time
528             #
529             sub want_historical {
530             my ($symbol) = @_;
531             my $last_download_timestamp
532             = App::Chart::Database->read_extra ($symbol, 'last-download')
533             // return undef; # no download attempted, not historical
534              
535             my $date = App::Chart::DBI->read_single
536             ('SELECT date FROM daily WHERE (symbol=?) AND (close NOTNULL)
537             ORDER BY date DESC LIMIT 1',
538             $symbol);
539             if (! defined $date) {
540             return __x('{symbol} no data at all, marked historical',
541             symbol => $symbol);
542             }
543             my $days = iso_timestamp_days_ago ($date, $last_download_timestamp);
544             if ($days > 21) {
545             return __x('{symbol} no data for {days} days, marked historical',
546             days => $days,
547             symbol => $symbol);
548             }
549             return undef;
550             }
551              
552             sub iso_timestamp_days_ago {
553             my ($iso, $prev_timestamp) = @_;
554             my ($prev_year,$prev_month,$prev_day) = timestamp_to_ymdhms($prev_timestamp);
555             return Date::Calc::Delta_Days (App::Chart::iso_to_ymd($iso),
556             $prev_year, $prev_month, $prev_day);
557             }
558              
559             sub consider_latest_from_daily {
560             my ($h) = @_;
561             my $dbh = App::Chart::DBI->instance;
562             my %latest_changed;
563             my $timestamp;
564              
565             my $database_symbols_hash = App::Chart::Database::database_symbols_hash();
566              
567             # find the newest and second newest data record of each symbol
568             my %data_newest;
569             my %data_second;
570             foreach my $data (@{$h->{'data'}}) {
571             my $symbol = $data->{'symbol'};
572             if ($data->{'date'} ge ($data_newest{$symbol}->{'date'} // '')) {
573             $data_second{$symbol} = $data_newest{$symbol};
574             $data_newest{$symbol} = $data;
575             } elsif ($data->{'date'} ge ($data_second{$symbol}->{'date'} // '')) {
576             $data_second{$symbol} = $data;
577             }
578             }
579              
580             foreach my $symbol (keys %data_newest) {
581             my $newest = $data_newest{$symbol};
582             my $date = $newest->{'date'};
583              
584             # For symbols in the database, if newest daily is >= latest quote then
585             # delete that quote in order to prefer the daily data in the database.
586             if (exists $database_symbols_hash->{$symbol}) {
587             my $latest_delete_sth = $dbh->prepare_cached
588             ('DELETE FROM latest WHERE symbol=? AND quote_date < ?');
589             if ($latest_delete_sth->execute ($symbol, $date)) {
590             $latest_changed{$symbol} = 1;
591             }
592             next;
593             }
594              
595             # For symbols not in the database, if the newest daily is >= latest quote
596             # then replace that quote with the daily.
597             #
598             # Times in the latest record are not considered, so it's possible a
599             # quote taken after close of trading will be deleted or overwritten.
600             # Would want something in the latest to say it's after the close ...
601              
602             my $latest_get_sth = $dbh->prepare_cached
603             ('SELECT last_date, name FROM latest WHERE symbol=?');
604             my ($last_date, $name, $dividend)
605             = $dbh->selectrow_array ($latest_get_sth, undef, $symbol);
606             if (defined $last_date && $last_date gt $date) { next; }
607              
608             # "name" from the daily, or retain name from existing latest record.
609             $name = $h->{'names'}->{$symbol} // $name;
610              
611             # "dividend" from existing latest record retained, but only if same date.
612             unless (defined $last_date && $last_date eq $date) {
613             undef $dividend;
614             }
615              
616             # change by difference from second newest daily, if have one
617             my $change = undef;
618             if (defined(my $second_close = $data_second{$symbol}->{'close'})) {
619             $change = App::Chart::decimal_sub ($newest->{'close'}, $second_close);
620             }
621              
622             $timestamp ||= timestamp_now();
623              
624             my $latest_set_sth = $dbh->prepare_cached
625             ('INSERT OR REPLACE INTO latest
626             (symbol, name, currency, exchange, dividend,
627             last_date, open, high, low, last, change, volume,
628             source, fetch_timestamp)
629             VALUES (?,?,?,?,?, ?,?,?,?,?,?,?, ?,?)');
630             $latest_set_sth->execute
631             ($symbol,
632             $name,
633             $h->{'currencies'}->{$symbol},
634             $h->{'exchanges'}->{$symbol},
635             $dividend,
636             #
637             $newest->{'date'},
638             $newest->{'open'},
639             $newest->{'high'},
640             $newest->{'low'},
641             $newest->{'close'},
642             $change,
643             $newest->{'volume'},
644             #
645             $h->{'source'},
646             $timestamp);
647             $latest_changed{$symbol} = 1;
648             }
649              
650             foreach my $dividend (@{$h->{'dividends'}}) {
651             my $latest_dividend_sth = $dbh->prepare_cached
652             ('UPDATE latest SET dividend=? WHERE symbol=? AND last_date=?');
653             my $symbol = $dividend->{'symbol'};
654             if ($latest_dividend_sth->execute ($dividend->{'amount'},
655             $symbol,
656             $dividend->{'ex_date'})) {
657             $latest_changed{$symbol} = 1;
658             }
659             }
660              
661             App::Chart::chart_dirbroadcast()->send ('latest-changed', \%latest_changed);
662              
663             require App::Chart::Annotation;
664             foreach my $symbol (keys %latest_changed) {
665             App::Chart::Annotation::Alert::update_alert ($symbol);
666             }
667             }
668              
669              
670             #------------------------------------------------------------------------------
671              
672             sub write_latest_group {
673             my ($h) = @_;
674             ### write_latest_group(): $h
675              
676             crunch_h ($h);
677             ### crunched: $h
678              
679             my $fetch_timestamp = timestamp_now();
680             my $prefer_decimals = $h->{'prefer_decimals'};
681             my $source = $h->{'source'}
682             or croak 'missing "source" for latest records';
683             my %latest;
684              
685             my $dbh = App::Chart::DBI->instance;
686             App::Chart::Database::call_with_transaction
687             ($dbh, sub {
688              
689             my $sth = $dbh->prepare_cached
690             ('INSERT OR REPLACE INTO latest
691             (symbol, name, month, exchange, currency,
692             quote_date, quote_time, bid, offer,
693             last_date, last_time, open, high, low, last, change, volume,
694             note, error, dividend, copyright, source,
695             fetch_timestamp, url, etag, last_modified)
696             VALUES (?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?)');
697              
698             my $resp = $h->{'resp'};
699             my $etag = (defined $resp ? scalar $resp->header('ETag') : undef);
700             my $last_modified = (defined $resp ? $resp->last_modified : undef);
701              
702             foreach my $data (@{$h->{'data'}}) {
703             my $symbol = $data->{'symbol'};
704             my $this_date = $data->{'date'};
705             if ($latest{$symbol}) {
706             my $got_date = $latest{$symbol}->{'date'};
707             if (! defined $got_date || ! defined $this_date) {
708             carp "write_latest_group: $source: two records for '$symbol', but no 'date' field";
709             if (DEBUG || 1) {
710             require Data::Dumper;
711             print Data::Dumper->Dump([$data,$latest{$symbol}],
712             ['data','latest-so-far']);
713             }
714             next;
715             }
716             if ($got_date ge $this_date) { next; }
717             }
718             $latest{$symbol} = $data;
719             }
720              
721             my $error = $h->{'error'};
722             if (! defined $error && defined $resp && ! $resp->is_success) {
723             $error = $resp->status_line;
724             }
725              
726             foreach my $data (values %latest) {
727             my $symbol = $data->{'symbol'};
728              
729             my $bid = $data->{'bid'};
730             my $offer = $data->{'offer'};
731              
732             # disallow 0 for prices
733             if ($bid && $bid == 0) { $bid = undef; }
734             if ($offer && $offer == 0) { $offer = undef; }
735              
736             my $quote_date = crunch_date ($data->{'quote_date'});
737             my $quote_time = crunch_time ($data->{'quote_time'});
738             if ($quote_time && ! $quote_date) {
739             croak "quote_time without quote_date for $symbol";
740             }
741             # default quote date/time to now
742             if (($bid || $offer) && ! $quote_date) {
743             my $symbol_timezone = App::Chart::TZ->for_symbol ($symbol);
744             ($quote_date, $quote_time)
745             = $symbol_timezone->iso_date_time
746             (time() - 60 * ($data->{'quote_delay_minutes'} || 0));
747             }
748              
749             my $last_date = crunch_date ($data->{'last_date'} || $data->{'date'});
750             my $last_time = crunch_time ($data->{'last_time'});
751              
752             my $open = $data->{'open'};
753             my $high = $data->{'high'};
754             my $low = $data->{'low'};
755             my $last = $data->{'last'} || $data->{'close'};
756             my $change = $data->{'change'};
757             my $prev = crunch_price ($data->{'prev'}, $prefer_decimals);
758             my $volume = $data->{'volume'};
759              
760             if (! defined $last) {
761             # if there's no last price then try to use the prev
762             $open = $high = $low = undef;
763             $last = $prev;
764             $prev = undef;
765             $change = undef;
766             $last_date = undef;
767             $last_time = undef;
768              
769             } elsif (! defined $change) {
770             # if no change given then try to calculate it from last and prev
771              
772             if (! defined $prev) {
773             # if no prev then look for one among other $data records, as for
774             # when the group is a few consecutive daily data
775             my $prev_date;
776             foreach my $data (@{$h->{'data'}}) {
777             if ($data->{'symbol'} eq $symbol
778             && exists $data->{'date'}
779             && $data->{'date'} lt $last_date
780             && (! $prev_date
781             || $data->{'date'} gt $prev_date)) {
782             $prev_date = $data->{'date'};
783             $prev = $data->{'close'};
784             }
785             }
786             }
787             if ($prev) {
788             $change = App::Chart::decimal_sub ($last, $prev);
789             }
790             }
791              
792             $sth->execute ($symbol,
793             $h->{'names'}->{$symbol},
794             $data->{'month'},
795             $h->{'exchanges'}->{$symbol},
796             $h->{'currencies'}->{$symbol},
797              
798             $quote_date, $quote_time,
799             $bid, $offer,
800              
801             $last_date, $last_time,
802             $open, $high, $low, $last, $change, $volume,
803              
804             $data->{'note'},
805             $data->{'error'} || $error,
806             $data->{'dividend'},
807             $h->{'copyright'},
808             $source,
809              
810             $fetch_timestamp,
811             $h->{'url'},
812             $etag,
813             $last_modified);
814             $sth->finish;
815             }
816             });
817              
818             App::Chart::chart_dirbroadcast()->send ('latest-changed', \%latest);
819              
820             require App::Chart::Annotation;
821             foreach my $symbol (keys %latest) {
822             App::Chart::Annotation::Alert::update_alert ($symbol);
823             }
824             }
825              
826             sub iso_to_MMM_YY {
827             my ($iso) = @_;
828             my ($year, $month, $day) = App::Chart::iso_to_ymd ($iso);
829             return sprintf ("%.3s %02d",
830             uc(Date::Calc::Month_to_Text ($month)),
831             $year % 100);
832             }
833              
834             #------------------------------------------------------------------------------
835              
836             my $iso_date_re = qr/^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/;
837              
838             my %date_format_to_func
839             = ('ymd' => \&App::Chart::Download::Decode_Date_YMD_to_iso,
840             'dmy' => \&App::Chart::Download::Decode_Date_EU_to_iso,
841             'mdy' => \&App::Chart::Download::Decode_Date_US_to_iso);
842             sub noop {
843             return $_[0];
844             }
845              
846             my %date_format_to_month_func
847             = ('ymd' => \&crunch_month_ymd);
848              
849             sub crunch_month_ymd {
850             my ($str) = @_;
851             my ($year, $month, $day) = Date::Calc::Decode_Date_EU ($str);
852             if (! defined $year) {
853             croak "unrecognised month string: $str";
854             }
855             if (defined $day) {
856             if ($day != 1) { croak "month with day!=1: '$str' got $day"; }
857             } else {
858             $day = 1;
859             }
860             return App::Chart::ymd_to_iso ($year, $month, $day);
861             }
862              
863             sub crunch_h {
864             my ($h) = @_;
865             my $database_symbols_hash = App::Chart::Database::database_symbols_hash();
866              
867             # ignore undef where expecting hashrefs in 'data' and 'dividends' arefs
868             foreach my $elem ('data',
869             'dividends') {
870             my $aref = $h->{$elem} // next;
871             @$aref = grep {
872             defined $_
873             # && App::Chart::Database->symbol_exists($_->{'symbol'})
874             } @$aref;
875             }
876              
877             my $suffix = $h->{'suffix'};
878             my $month_format = $h->{'month_format'};
879             my $prefer_decimals = $h->{'prefer_decimals'};
880              
881             my $date_format = delete $h->{'date_format'};
882             my $date_func = ($date_format ? $date_format_to_func{$date_format} : \&noop)
883             || croak "Unrecognised date_format '$date_format'";
884             # my $month_func = $date_format ? $date_format_to_month_func{$date_format} : \&noop;
885              
886             my %currencies; $h->{'currencies'} = \%currencies;
887             my %isins; $h->{'isins'} = \%isins;
888             my %names; $h->{'names'} = \%names;
889             my %exchanges; $h->{'exchanges'} = \%exchanges;
890              
891             foreach my $info (@{$h->{'info'}}) {
892             my $symbol = $info->{'symbol'}
893             or croak "write_daily_group: missing symbol in info record";
894             if (! exists $database_symbols_hash->{$symbol}) { next; }
895              
896             $names{$symbol} ||= delete $info->{'name'};
897             $currencies{$symbol} ||= delete $info->{'currency'};
898             $exchanges{$symbol} ||= delete $info->{'exchange'};
899             $isins{$symbol} ||= delete $info->{'isin'};
900             }
901              
902             foreach my $data (@{$h->{'data'}}) {
903             my $month = $data->{'month'};
904             if (defined $month) {
905             # $month = $data->{'month'} = $month_func->($month);
906             $month =~ $iso_date_re or croak "Bad month value '$month'";
907             }
908              
909             my $symbol = $data->{'symbol'};
910             if (! defined $symbol) {
911             # symbols built from commodity + month
912             my $commodity = $data->{'commodity'}
913             // croak "neither symbol nor commodity in 'data' element";
914             $month // croak "Group data: no 'month' to go with 'commodity' in data element";
915             $symbol = $data->{'symbol'}
916             = $commodity . ' ' . iso_to_MMM_YY($month) . $suffix;
917             }
918             # if ($month_format eq 'MMM_YY') {
919             # $month = iso_to_MMM_YY ($month);
920             # } else {
921             # croak "unrecognised month format: $month_format";
922             # }
923             }
924              
925             if ($h->{'front_month'}) {
926             my %front;
927             foreach my $data (@{$h->{'data'}}) {
928             my $symbol = $data->{'symbol'};
929             my $front_symbol
930             = App::Chart::symbol_commodity($symbol)
931             . App::Chart::symbol_suffix($symbol);
932             if (! $front{$front_symbol}
933             || $data->{'month'} gt $front{$front_symbol}->{'month'}) {
934             $front{$front_symbol} = $data;
935             }
936             }
937              
938             while (my ($symbol, $data) = each %front) {
939             $data = { %$data };
940             $data->{'symbol'} = $symbol;
941             push @{$h->{'data'}}, $data;
942             }
943             }
944              
945             foreach my $data (@{$h->{'data'}}) {
946              
947             foreach my $field ('date', 'quote_date', 'last_date') {
948             if (defined $data->{$field}) {
949             $data->{$field} = crunch_date ($data->{$field}, $date_format);
950             }
951             }
952              
953             # empty volume or openint taken to be no data (as opposed to '0' for
954             # zero volume or int)
955             foreach my $field ('volume', 'openint') {
956             if (defined $data->{$field} && $data->{$field} eq '') {
957             $data->{$field} = undef;
958             }
959             }
960              
961             if (my $sessions = delete $data->{'sessions'}) {
962             my @sessions = grep {defined} map {crunch_price($_)} @$sessions;
963             $data->{'open'} = $sessions[0];
964             ($data->{'low'}, $data->{'high'}) = List::MoreUtils::minmax (@sessions);
965             $data->{'close'} = $sessions[-1];
966             }
967              
968             if (exists $data->{'change'}) {
969             $data->{'change'} = crunch_change ($data->{'change'}, $prefer_decimals);
970             }
971             foreach my $field (qw(bid offer open high low close last)) {
972             if (exists $data->{$field}) {
973             $data->{$field} = crunch_price ($data->{$field}, $prefer_decimals);
974             }
975             }
976             foreach my $field (qw(volume openint)) {
977             if (exists $data->{$field}) {
978             $data->{$field} = crunch_number ($data->{$field});
979             }
980             }
981              
982             my $symbol = $data->{'symbol'};
983             $currencies{$symbol} ||= delete $data->{'currency'} || $h->{'currency'};
984             $names{$symbol} ||= delete $data->{'name'} || $h->{'name'};
985             $exchanges{$symbol} ||= delete $data->{'exchange'};
986             $isins{$symbol} ||= delete $data->{'isin'};
987             }
988              
989             foreach my $dividend (@{$h->{'dividends'}}) {
990             foreach my $field ('ex_date', 'record_date', 'pay_date') {
991             if (defined $dividend->{$field}) {
992             $dividend->{$field} = crunch_date ($dividend->{$field}, $date_format);
993             }
994             }
995             $dividend->{'ex_date'}
996             or croak 'Group data: missing ex_date in dividend record';
997              
998             foreach my $field (qw(amount imputation)) {
999             if (exists $dividend->{$field}) {
1000             $dividend->{$field} = crunch_price ($dividend->{$field}, $prefer_decimals);
1001             }
1002             }
1003             }
1004              
1005             foreach my $split (@{$h->{'splits'}}) {
1006             if (defined $split->{'date'}) {
1007             $split->{'date'} = crunch_date ($split->{'date'}, $date_format);
1008             }
1009             $split->{'date'}
1010             or croak "Group data: missing 'date' in split record";
1011             }
1012              
1013             # whitespace in names, and possible leading/trailing in isins
1014             foreach my $href (\%names, \%currencies, \%exchanges, \%isins) {
1015             hash_delete_undefs ($href);
1016             }
1017             foreach (values %names, values %isins) {
1018             $_ = App::Chart::collapse_whitespace ($_);
1019             }
1020              
1021             if (eval { require Business::ISIN; 1 }) {
1022             my $bi = Business::ISIN->new;
1023             while (my ($symbol, $isin) = each %isins) {
1024             $bi->set ($isin);
1025             if ($bi->is_valid) { next; }
1026             warn "$symbol ISIN '$isin' is invalid, ignoring: ", $bi->error;
1027             delete $isins{$symbol};
1028             }
1029             }
1030             }
1031              
1032             sub hash_delete_undefs {
1033             my ($href) = @_;
1034             while (my ($key, $value) = each %$href) {
1035             if (! defined $value) {
1036             delete $href->{$key};
1037             }
1038             }
1039             }
1040              
1041             sub crunch_date {
1042             my ($str, $format) = @_;
1043             $str = crunch_empty_undef ($str);
1044             if (! defined $str) { return $str; }
1045              
1046             if (defined $format) {
1047             my $func = $date_format_to_func{$format};
1048             defined $func or croak "Unrecognised date_format spec: $format";
1049             $str = &$func ($str);
1050             }
1051             $str =~ $iso_date_re or croak "Bad date '$str'";
1052             return $str;
1053             }
1054              
1055             sub crunch_price {
1056             my ($str, $prefer_decimals) = @_;
1057             $str = crunch_change ($str, $prefer_decimals) // return undef;
1058              
1059             if (str_is_zero ($str)) { return undef; }
1060             return $str;
1061             }
1062             sub crunch_change {
1063             my ($str, $prefer_decimals) = @_;
1064             $str = crunch_number ($str) // return undef;
1065              
1066             if ($str eq '') { return undef; } # empty
1067             if (uc($str) eq 'CLOSED') { return undef; } # RBA 2003-2006.xls
1068             if ($str eq 'unch') { return '0'; } # unchanged
1069             if (defined $prefer_decimals) {
1070             return App::Chart::Download::trim_decimals ($str, $prefer_decimals);
1071             } else {
1072             return $str;
1073             }
1074             }
1075              
1076             sub crunch_number {
1077             my ($str) = @_;
1078             if (! defined $str) { return undef; }
1079              
1080             $str =~ s/$RE{ws}{crop}//go; # leading and trailing whitespace
1081             $str =~ s/^\+//; # leading + sign
1082             $str =~ s/^0+([1-9]|0\.|0$)/$1/; # leading extra zeros
1083              
1084             if ($str eq ''
1085             || $str eq '-'
1086             || $str eq 'N/A'
1087             || $str eq 'n/a') {
1088             return undef;
1089             }
1090             $str =~ s/,//g; # commas for thousands
1091             return $str;
1092             }
1093              
1094             sub crunch_time {
1095             my ($str) = @_;
1096             $str = crunch_empty_undef ($str);
1097             if (! defined $str) { return undef; }
1098              
1099             $str =~ /^([0-9]?[0-9])(:[0-9][0-9])(:[0-9][0-9])?([ap]m)?$/i
1100             or croak "bad time '$str'";
1101             my $hour = $1;
1102             my $minute = $2;
1103             my $second = $3;
1104             my $am_pm = $4;
1105             $second //= ':00';
1106             if (defined $am_pm && lc $am_pm eq 'pm') { $hour += 12; }
1107             $hour = sprintf '%02d', $hour;
1108             return "$hour$minute$second";
1109             }
1110              
1111             sub crunch_empty_undef {
1112             my ($str) = @_;
1113             if (! defined $str) { return $str; }
1114             $str =~ s/$RE{ws}{crop}//go; # leading and trailing whitespace
1115              
1116             # eg. string "N/A" in dates and times from Yahoo
1117             if ($str eq '' || $str eq 'N/A') { return undef; }
1118             return $str;
1119             }
1120              
1121              
1122             #------------------------------------------------------------------------------
1123              
1124             sub set_symbol_name {
1125             my ($symbol, $name) = @_;
1126             my $dbh = App::Chart::DBI->instance;
1127             my $sth = $dbh->prepare_cached('UPDATE info SET name=? WHERE symbol=?');
1128             my $changed = $sth->execute($name, $symbol);
1129             $sth->finish();
1130             return $changed;
1131             }
1132             sub set_currency {
1133             my ($symbol, $currency) = @_;
1134             my $dbh = App::Chart::DBI->instance;
1135             my $sth = $dbh->prepare_cached('UPDATE info SET currency=? WHERE symbol=?');
1136             my $changed = $sth->execute($currency, $symbol);
1137             $sth->finish();
1138             return $changed;
1139             }
1140             sub set_exchange {
1141             my ($symbol, $exchange) = @_;
1142             my $dbh = App::Chart::DBI->instance;
1143             my $sth = $dbh->prepare_cached('UPDATE info SET exchange=? WHERE symbol=?');
1144             my $changed = $sth->execute($exchange, $symbol);
1145             $sth->finish();
1146             return $changed;
1147             }
1148             sub set_decimals {
1149             my ($symbol, $decimals) = @_;
1150             my $dbh = App::Chart::DBI->instance;
1151             my $sth = $dbh->prepare_cached('UPDATE info SET decimals=? WHERE symbol=?');
1152             my $changed = $sth->execute($decimals, $symbol);
1153             $sth->finish();
1154             return $changed;
1155             }
1156             sub set_isin {
1157             my ($symbol, $isin) = @_;
1158             my $dbh = App::Chart::DBI->instance;
1159             my $sth = $dbh->prepare_cached('UPDATE info SET isin=? WHERE symbol=?');
1160             my $changed = $sth->execute($isin, $symbol);
1161             $sth->finish();
1162             return $changed;
1163             }
1164              
1165              
1166             #------------------------------------------------------------------------------
1167              
1168             # return true if $timestamp string is within the past $seconds from now
1169             # also return true if $timestamp is some strange future value
1170             sub timestamp_within {
1171             my ($timestamp, $seconds) = @_;
1172             if (! defined $timestamp) { return 0; } # undef stamp always out of range
1173             my ($lo, $hi) = timestamp_range ($seconds);
1174             return (($timestamp ge $lo) && ($timestamp le $hi));
1175             }
1176             sub timestamp_range {
1177             my ($seconds) = @_;
1178             my $t = time();
1179             my $lo = $t - $seconds;
1180             my $hi = $t + 6*3600; # 2 hours future
1181             return (timet_to_timestamp($lo),
1182             timet_to_timestamp($hi));
1183             }
1184             sub timestamp_now {
1185             return timet_to_timestamp(time());
1186             }
1187             sub timet_to_timestamp {
1188             my ($t) = @_;
1189             return POSIX::strftime ('%Y-%m-%d %H:%M:%S+00:00', gmtime($t));
1190             }
1191             sub timestamp_to_ymdhms {
1192             my ($timestamp) = @_;
1193             return split /[- :+]/, $timestamp;
1194             }
1195             sub timestamp_to_timet {
1196             my ($timestamp) = @_;
1197             my ($year, $month, $day, $hour, $minute, $second)
1198             = timestamp_to_ymdhms($timestamp);
1199             require Time::Local;
1200             return Time::Local::timegm
1201             ($second, $minute, $hour, $day, $month-1, $year-1900);
1202             }
1203              
1204              
1205             #------------------------------------------------------------------------------
1206              
1207             sub tdate_strftime {
1208             my ($format, $tdate) = @_;
1209             my ($year, $month, $day) = App::Chart::tdate_to_ymd ($tdate);
1210             require App::Chart::Timebase;
1211             return App::Chart::Timebase::strftime_ymd ($format, $year, $month, $day);
1212             }
1213              
1214             sub tdate_range_string {
1215             my ($lo, $hi) = @_;
1216             if (@_ < 2) { $hi = $lo; }
1217             my $d_fmt = $App::Chart::option{'d_fmt'};
1218             if ($lo == $hi) {
1219             return tdate_strftime ($d_fmt, $lo);
1220             } else {
1221             return __x('{lodate} to {hidate}',
1222             lodate => tdate_strftime ($d_fmt, $lo),
1223             hidate => tdate_strftime ($d_fmt, $hi));
1224             }
1225             }
1226              
1227             sub symbol_range_string {
1228             my ($symbol_list) = @_;
1229             if (@$symbol_list == 0) {
1230             return '';
1231             } elsif (@$symbol_list == 1) {
1232             return $symbol_list->[0];
1233             } else {
1234             return __x('{start_symbol} to {end_symbol}',
1235             start_symbol => $symbol_list->[0],
1236             end_symbol => $symbol_list->[-1]);
1237             }
1238             }
1239              
1240             #------------------------------------------------------------------------------
1241              
1242             sub weekday_date_after_time {
1243             return App::Chart::tdate_to_iso (weekday_tdate_after_time (@_));
1244             }
1245             sub weekday_tdate_after_time {
1246             my ($after_hour,$after_min, $timezone, $offset) = @_;
1247              
1248             local $Tie::TZ::TZ = $timezone->tz;
1249             my ($year,$month,$day, $hour,$min,$sec, $doy,$dow,$dst)
1250             = Date::Calc::Localtime();
1251              
1252             my $tdate = App::Chart::ymd_to_tdate_floor ($year,$month,$day)
1253             + ($offset // 0);
1254              
1255             if ($dow >= 6 # Saturday or Sunday
1256             || ($hour*60+$min < $after_hour*60+$after_min)) {
1257             $tdate--;
1258             }
1259             return $tdate;
1260             }
1261              
1262             #------------------------------------------------------------------------------
1263              
1264             sub download {
1265             my (%options) = @_;
1266              
1267             my @symbol_list = ();
1268             {
1269             my $symbol_list = $options{'symbol_list'}
1270             || croak "download() missing symbol_list\n";
1271             @symbol_list = @$symbol_list;
1272             }
1273              
1274             @symbol_list = List::MoreUtils::uniq (@symbol_list);
1275             verbose_message (__('Download:'), @symbol_list);
1276              
1277             foreach my $symbol (@symbol_list) {
1278             App::Chart::symbol_setups ($symbol);
1279             }
1280             App::Chart::Database->add_symbol (@symbol_list);
1281              
1282             require App::Chart::DownloadHandler;
1283             my $all_ok = 1;
1284             foreach my $handler (@App::Chart::DownloadHandler::handler_list) {
1285             my @this_list = grep {$handler->match($_)} @symbol_list;
1286             my $ok = $handler->download (\@this_list);
1287             if (! $ok) { $all_ok = 0; }
1288             }
1289              
1290             # my %handler_result = ();
1291             # foreach my $symbol (@symbol_list) {
1292             #
1293             # my @handlers = App::Chart::DownloadHandler->handlers_for_symbol ($symbol);
1294             # foreach my $handler (@handlers) {
1295             # if (exists $handler_result{$handler}) {
1296             # if (! $handler_result{$handler}) { $all_ok = 0; }
1297             # next;
1298             # }
1299             #
1300             # my @this_list = grep { my $symbol = $_;
1301             # List::MoreUtils::all
1302             # {$_->match($symbol)} @handlers
1303             # } @symbol_list;
1304             # my $ok = $handler->download (\@this_list);
1305             # $handler_result{$handler} = $ok;
1306             # }
1307             #
1308             # }
1309             status (__('Checking historical'));
1310             if ($all_ok) {
1311             consider_historical (\@symbol_list);
1312             }
1313             }
1314              
1315             # return a list of symbols, either just ($symbol), or if $symbol has
1316             # wildcards then the result of matching that in the "all" list
1317             sub symbol_glob {
1318             my ($symbol) = @_;
1319              
1320             if ($symbol =~ /[*?]/) {
1321             require Text::Glob;
1322             require App::Chart::Gtk2::Symlist::All;
1323             my $symlist = App::Chart::Gtk2::Symlist::All->instance;
1324             my $regexp = Text::Glob::glob_to_regex ($symbol);
1325             my @list = grep {$_ =~ $regexp} $symlist->symbols;
1326             if (! @list) {
1327             print __x("Warning, pattern \"{pattern}\" doesn't match anything in the database, ignoring\n",
1328             pattern => $symbol);
1329             }
1330             return @list;
1331             } else {
1332             return ($symbol);
1333             }
1334             }
1335              
1336             sub command_line_download {
1337             my ($class, $output, $args) = @_;
1338             my $hash;
1339              
1340             if ($output eq 'tty') {
1341             if (-t STDOUT) {
1342             binmode (STDOUT, ':via(EscStatus)')
1343             or die 'Cannot push EscStatus';
1344             } else {
1345             require PerlIO::via::EscStatus::ShowNone;
1346             binmode (STDOUT, ':via(EscStatus::ShowNone)')
1347             or die 'Cannot push EscStatus::ShowNone';
1348             }
1349             } elsif ($output eq 'all-status') {
1350             require PerlIO::via::EscStatus::ShowAll;
1351             binmode (STDOUT, ':via(EscStatus::ShowAll)')
1352             or die 'Cannot push EscStatus::ShowAll';
1353             }
1354              
1355             if (! @$args) {
1356             print __"No symbols specified to download\n";
1357             return;
1358             }
1359              
1360             my @symbol_list = ();
1361             foreach my $arg (@$args) {
1362             if (ref $arg) {
1363             # only what's already in the database
1364             $hash ||= App::Chart::Database::database_symbols_hash();
1365             my @list = grep {exists $hash->{$_}} $arg->symbols;
1366             push @symbol_list, @list;
1367             if (! @list) {
1368             print __x("Warning, no symbols in \"{symlist}\" are currently in the database\n(only symbols already in the database are downloaded from lists)\n",
1369             symlist => $arg->name);
1370             }
1371             } else {
1372             push @symbol_list, symbol_glob ($arg);
1373             }
1374             }
1375              
1376             App::Chart::Download::download (symbol_list => \@symbol_list);
1377              
1378             require App::Chart::LatestHandler;
1379             App::Chart::LatestHandler->download (\@symbol_list);
1380              
1381             status ('');
1382             }
1383              
1384             #------------------------------------------------------------------------------
1385              
1386             sub iso_to_tdate_floor {
1387             my ($str) = @_;
1388             my ($year, $month, $day) = App::Chart::iso_to_ymd ($str);
1389             return App::Chart::ymd_to_tdate_floor ($year, $month, $day);
1390             }
1391              
1392             sub iso_to_tdate_ceil {
1393             my ($str) = @_;
1394             my ($year, $month, $day) = App::Chart::iso_to_ymd ($str);
1395             return App::Chart::ymd_to_tdate_ceil ($year, $month, $day);
1396             }
1397              
1398             sub tdate_today {
1399             my ($timezone) = @_;
1400             $timezone //= App::Chart::TZ->loco;
1401             my ($year, $month, $day) = $timezone->ymd;
1402             return App::Chart::ymd_to_tdate_floor ($year, $month, $day);
1403             }
1404              
1405             my $default_download_tdates = 5 * 265; # 5 years
1406              
1407             sub start_tdate_for_update {
1408             my (@symbol_list) = @_;
1409             if (! @symbol_list) { croak "start_tdate_for_update(): no symbols"; }
1410             my $ret;
1411             foreach my $symbol (@symbol_list) {
1412             my $iso = App::Chart::DBI->read_single
1413             ('SELECT date FROM daily WHERE symbol=? ORDER BY date DESC LIMIT 1',
1414             $symbol);
1415             if (! defined $iso) {
1416             return (tdate_today() - $default_download_tdates);
1417             }
1418             my $tdate = iso_to_tdate_floor ($iso) + 1;
1419             $ret = App::Chart::min_maybe ($ret, $tdate);
1420             }
1421             return $ret;
1422             }
1423              
1424             sub tdate_today_after {
1425             my ($after_hour, $after_minute, $timezone) = @_;
1426              
1427             { local $Tie::TZ::TZ = $timezone->tz;
1428             my ($year,$month,$day, $hour,$min,$sec, $doy,$dow,$dst) =
1429             Date::Calc::System_Clock();
1430              
1431             my $tdate = App::Chart::ymd_to_tdate_floor ($year, $month, $day);
1432             if ($dow <= 5 # is a weekday
1433             && (App::Chart::hms_to_seconds ($hour, $min, 0)
1434             < App::Chart::hms_to_seconds ($after_hour, $after_minute, 0))) {
1435             $tdate--;
1436             }
1437             return $tdate;
1438             }
1439             }
1440              
1441              
1442             #-----------------------------------------------------------------------------
1443             # selecting among possibly overlapping files
1444              
1445             # $files is an arrayref containing hash records with keys
1446             #
1447             # lo_tdate,hi_tdate inclusive coverage of the record
1448             # lo_year,hi_year alterative form for date range
1449             # cost size of the file in bytes
1450             #
1451             sub choose_files {
1452             my ($files, $lo_tdate, $hi_tdate) = @_;
1453             if ($lo_tdate > $hi_tdate) { return []; }
1454              
1455             if (DEBUG) { print "choose_files $lo_tdate to $hi_tdate\n"; }
1456              
1457             foreach my $f (@$files) {
1458             if (! defined $f->{'lo_tdate'}) {
1459             if (my $m = $f->{'month_iso'}) {
1460             $f->{'lo_tdate'} = App::Chart::Download::iso_to_tdate_ceil ($m);
1461             } elsif ($f->{'lo_year'}) {
1462             $f->{'lo_tdate'}
1463             = App::Chart::ymd_to_tdate_ceil ($f->{'lo_year'}, 1, 1);
1464             } else {
1465             croak 'choose_files: missing lo date';
1466             }
1467             }
1468              
1469             if (! defined $f->{'hi_tdate'}) {
1470             if (my $m = $f->{'month_iso'}) {
1471             $f->{'hi_tdate'}
1472             = tdate_end_of_month (App::Chart::Download::iso_to_tdate_ceil ($m));
1473             } elsif ($f->{'hi_year'}) {
1474             $f->{'hi_tdate'}
1475             = App::Chart::ymd_to_tdate_floor($f->{'hi_year'}, 12, 31);
1476             } else {
1477             croak 'choose_files: missing hi date';
1478             }
1479             }
1480             }
1481             if (DEBUG >= 2) { require Data::Dumper;
1482             print Data::Dumper::Dumper($files); }
1483              
1484             # restrict wanted range to what's available
1485             my $lo_available = min (map {$_->{'lo_tdate'}} @$files);
1486             my $hi_available = max (map {$_->{'hi_tdate'}} @$files);
1487             $lo_tdate = max ($lo_tdate, $lo_available);
1488             $hi_tdate = min ($hi_tdate, $hi_available);
1489             if (DEBUG) { print " available $lo_available to $hi_available\n";
1490             print " restricted range $lo_tdate to $hi_tdate\n"; }
1491             if ($lo_tdate > $hi_tdate) { return []; }
1492              
1493             # ignore file elements not covering any of the desired range
1494             $files = [ grep {App::Chart::overlap_inclusive_p ($lo_tdate, $hi_tdate,
1495             $_->{'lo_tdate'},
1496             $_->{'hi_tdate'})}
1497             @$files ];
1498              
1499             # Algorithm::ChooseSubsets would be another way to iterate, or
1500             # Math::Subset::List to get all combinations
1501             my $best_cost;
1502             my $best_files;
1503             foreach my $this_files (all_combinations ($files)) {
1504             if (! cover_p ($this_files, $lo_tdate, $hi_tdate)) { next; }
1505             my $cost = List::Util::sum (map {$_->{'cost'}||0} @$this_files);
1506             $cost += $App::Chart::option{'http_get_cost'} * scalar(@$this_files);
1507             if (! defined $best_cost || $cost < $best_cost) {
1508             $best_cost = $cost;
1509             $best_files = $this_files;
1510             }
1511             }
1512             return $best_files;
1513             }
1514              
1515             # return true if the set of file records in arrayref $files covers all of
1516             # $lo_tdate through $hi_tdate inclusive
1517             #
1518             sub cover_p {
1519             my ($files, $lo_tdate, $hi_tdate) = @_;
1520             require Set::IntSpan::Fast;
1521             my $set = Set::IntSpan::Fast->new;
1522             foreach my $f (@$files) {
1523             $set->add_range ($f->{'lo_tdate'}, $f->{'hi_tdate'});
1524             }
1525             $set->contains_all_range ($lo_tdate, $hi_tdate);
1526             }
1527              
1528             # return a list which is all the combinations of elements of @$aref
1529             # for example $aref == [ 10, 20 ] would return ([], [10], [20], [10,20])
1530             # there's 2**N combinations for aref length N
1531             #
1532             sub all_combinations {
1533             my ($aref) = @_;
1534             my @ret = ([]);
1535             foreach my $i (0 .. $#$aref) {
1536             push @ret, map {[ @$_, $aref->[$i] ]} @ret;
1537             }
1538             return @ret;
1539             }
1540              
1541             # return the last tdate in the month containing the given $tdate
1542             sub tdate_end_of_month {
1543             my ($tdate) = @_;
1544             my ($year, $month, $day) = App::Chart::tdate_to_ymd ($tdate);
1545             ($year, $month, $day) = Date::Calc::Add_Delta_YM ($year, $month, $day, 0,1);
1546             $day = 1;
1547             ($year, $month, $day) = Date::Calc::Add_Delta_Days ($year, $month, $day, -1);
1548             return App::Chart::ymd_to_tdate_floor ($year, $month, $day);
1549             }
1550              
1551             1;
1552             __END__
1553              
1554             =for stopwords url TTY LF whitespace undef YYYY-MM-DD GBP ISIN tdate
1555              
1556             =head1 NAME
1557              
1558             App::Chart::Download -- download functions
1559              
1560             =cut
1561              
1562             # =head1 HTTP FUNCTIONS
1563             #
1564             # =over 4
1565             #
1566             # =item C<< $resp = App::Chart::Download->get ($url, key=>value,...) >>
1567             #
1568             # Download the given C<$url> and return a C<HTTP::Response> object. The
1569             # following key/value options are accepted.
1570             #
1571             # method default 'GET'
1572             # data body data for the request
1573             # etag from previous get of this url
1574             # last_modified from previous get of this url
1575             #
1576             # A C<"POST"> can be done by setting C<method> accordingly and passing
1577             # C<data>. C<etag> and/or C<last_modified> can be given to avoid a
1578             # re-download of url if unchanged (response status 304).
1579             #
1580             # =item C<< App::Chart::Download::status ($str, $str, ...) >>
1581             #
1582             # Join the argument strings together, with spaces between, and print them as
1583             # the current download status. Subsequent HTTP downloads through
1584             # C<App::Chart::UserAgent> will append their progress to this status too.
1585             #
1586             # =item C<< App::Chart::Download::download_message ($str, $str, ...) >>
1587             #
1588             # Join the argument strings together, with spaces between, and print them and
1589             # a newline as a download message. This differs from an ordinary C<print> in
1590             # that on a TTY it first erases anything from C<status> above (or checks the
1591             # message itself is long enough to overwrite).
1592             #
1593             # =back
1594             #
1595             # =head1 PARSING FUNCTIONS
1596             #
1597             # =over 4
1598             #
1599             # =item App::Chart::Download::split_lines ($str)
1600             #
1601             # Return a list of the lines in C<$str> separated by CR or LF, with trailing
1602             # whitespace stripped, and blank lines (entirely whitespace) removed.
1603             #
1604             # =item App::Chart::Download::trim_decimals ($str, $want_decimals)
1605             #
1606             # Return C<$str> with trailing zero decimal places trimmed off to leave
1607             # C<$want_decimals>. If C<$str> doesn't look like a number, or is undef, then
1608             # it's returned unchanged.
1609             #
1610             # =item App::Chart::Download::str_is_zero ($str)
1611             #
1612             # Return true if C<$str> is a zero number, like "0", "00", "0.00", ".000".
1613             #
1614             # =item C<< App::Chart::Download::cents_to_dollars ($str) >>
1615             #
1616             # C<$str> is a number like "12.5" in cents. Return it with the decimal point
1617             # shifted to be expressed in dollars like "0.125".
1618             #
1619             # =back
1620             #
1621             # =head1 DATE/TIME FUNCTIONS
1622             #
1623             # =over 4
1624             #
1625             # =item App::Chart::Download::month_to_nearest_year ($month)
1626             #
1627             # C<$month> is in the range 1 to 12. Return a year, as a number like 2007, to
1628             # go with that month, so that the combination is within +/- 6 months of today.
1629             #
1630             # =item C<< App::Chart::Download::Decode_Date_EU_to_iso ($str) >>
1631             #
1632             # Decode a date in the form day/month/year using
1633             # C<Date::Calc::Decode_Date_EU>, and return an ISO format date string like
1634             # "2007-10-26".
1635             #
1636             # =item App::Chart::Download::Decode_Date_YMD ($str)
1637             #
1638             # Decode a date in the form year/month/day and return C<($year, $month,
1639             # $day)>, similar to what C<Date::Calc> does.
1640             #
1641             # The month given can be a number or a name in English and is always returned
1642             # as a number. Any separator can be used between the components and leading
1643             # and trailing whitespace is ignored. If the string is unrecognised the
1644             # return is an empty list C<()>.
1645             #
1646             # =item App::Chart::Download::Decode_Date_YMD_to_iso ($str)
1647             #
1648             # Decode a date using C<App::Chart::Download::Decode_Date_YMD> above and return
1649             # an ISO format string "YYYY-MM-DD". An error is thrown if C<$str> is
1650             # invalid.
1651             #
1652             # =item App::Chart::Download::date_parse_to_iso ($str)
1653             #
1654             # unused?
1655             #
1656             # Apply Date::Parse::strptime() to C<$str> and return an ISO format date
1657             # string like "2007-10-26" for the result. An error is thrown if C<$str> is
1658             # unrecognisable.
1659             #
1660             # =back
1661             #
1662             # =item weekday_date_after_time ($hour,$min, $timezone, [$offset])
1663             #
1664             # Return an an ISO format date string like C<"2008-08-20"> which is a weekday,
1665             # giving today on a weekday after C<$hour>,C<$min>, or the previous weekday if
1666             # before that time or any time on a weekend.
1667             #
1668             # C<$offset> is a number of weekdays to step forward (or negative for back) on
1669             # the return value.
1670             #
1671             # For example if today's trading data is available after 5pm then a call like
1672             #
1673             # weekday_date_after_time (17,0, $my_zone)
1674             #
1675             # would give yesterday until 5pm, and today after that, and give Friday all
1676             # through the weekend. If trading data is not available until 9am the
1677             # following weekday then a call like
1678             #
1679             # weekday_date_after_time (9,0, $my_zone, -1)
1680             #
1681             # would return the day before yesterday until 9am, and yesterday after that,
1682             # including returning Thursday all through the weekend.
1683              
1684             # =head1 DATABASE FUNCTIONS
1685             #
1686             # =over 4
1687             #
1688             # =item C<< App::Chart::Download::write_daily_group ($hashref) >>
1689             #
1690             # C<$hashref> is daily share price data. Write it to the database. The
1691             # fields of C<$hashref> are as follows. They are variously crunched to
1692             # normalize and validate before being written to the database.
1693             #
1694             # =over
1695             #
1696             # =item C<data>
1697             #
1698             # Arrayref containing hashref records with fields
1699             #
1700             # symbol string
1701             # open price
1702             # high price
1703             # low price
1704             # close price
1705             # volume number
1706             # openint number
1707             #
1708             # Any C<symbol> not already in the database is ignored.
1709             #
1710             # =item C<dividends>
1711             #
1712             # symbol string
1713             #
1714             # Any C<symbol> not already in the database is ignored.
1715             #
1716             # =item C<splits>
1717             #
1718             # symbol string
1719             #
1720             # Any C<symbol> not already in the database is ignored.
1721             #
1722             # =item C<names>
1723             #
1724             # =item C<currencies>
1725             #
1726             # =back
1727             #
1728             #
1729             #
1730             # =item App::Chart::Download::write_latest_group ($hashref)
1731             #
1732             # ...
1733             #
1734              
1735             # =item App::Chart::Download::crunch_number ($str)
1736             #
1737             # =item App::Chart::Download::crunch_price ($price, $prefer_decimals)
1738             #
1739             # =item App::Chart::Download::crunch_change ($change, $prefer_decimals)
1740             #
1741             # ...
1742             #
1743              
1744             # =item App::Chart::Download::set_symbol_name ($symbol, $name)
1745             #
1746             # Set the company or commodity name recorded in the database for C<$symbol>,
1747             # if C<$symbol> is already in the database.
1748             #
1749             # =item App::Chart::Download::set_currency ($symbol, $currency)
1750             #
1751             # Set the currency recorded in the database for C<$symbol>, if C<$symbol> is
1752             # already in the database. C<$currency> should be a three-letter currency
1753             # code, like "GBP" for British Pounds.
1754             #
1755             # =item App::Chart::Download::set_isin ($symbol, $isin)
1756             #
1757             # Set the ISIN recorded in the database for C<$symbol>, if C<$symbol> is
1758             # already in the database.
1759             #
1760             # =item App::Chart::Download::set_decimals ($symbol, $decimals)
1761             #
1762             # Set the number of decimals to show for prices of C<$symbol>, if C<$symbol>
1763             # is already in the database.
1764             #
1765              
1766              
1767             # =item download (key=>value, ...)
1768             #
1769             # ...
1770             #
1771             # =cut
1772              
1773             # =item C<< App::Chart::Download::start_tdate_for_update ($symbol, ...) >>
1774             #
1775             # Return the tdate to start at for a data update of all the given symbols.
1776             # This is the day after existing data of the oldest, or five years ago if any
1777             # need an initial download.
1778             #
1779             # =cut
1780