File Coverage

blib/lib/App/Chart/Yahoo.pm
Criterion Covered Total %
statement 36 38 94.7
branch n/a
condition n/a
subroutine 13 13 100.0
pod n/a
total 49 51 96.0


line stmt bran cond sub pod time code
1             # Copyright 2007, 2008, 2009, 2010, 2011, 2015, 2016, 2017 Kevin Ryde
2              
3             # This file is part of Chart.
4             #
5             # Chart is free software; you can redistribute it and/or modify it under the
6             # terms of the GNU General Public License as published by the Free Software
7             # Foundation; either version 3, or (at your option) any later version.
8             #
9             # Chart is distributed in the hope that it will be useful, but WITHOUT ANY
10             # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11             # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12             # details.
13             #
14             # You should have received a copy of the GNU General Public License along
15             # with Chart. If not, see <http://www.gnu.org/licenses/>.
16              
17              
18             package App::Chart::Yahoo;
19 1     1   70654 use 5.010;
  1         11  
20 1     1   4 use strict;
  1         2  
  1         17  
21 1     1   4 use warnings;
  1         2  
  1         23  
22 1     1   5 use Carp;
  1         1  
  1         50  
23 1     1   234 use Date::Calc;
  1         5611  
  1         36  
24 1     1   249 use Date::Parse;
  1         5243  
  1         117  
25 1     1   6 use List::Util qw (min max);
  1         3  
  1         48  
26 1     1   5 use POSIX ();
  1         2  
  1         12  
27 1     1   3 use Time::Local;
  1         2  
  1         28  
28 1     1   250 use URI::Escape;
  1         1276  
  1         55  
29 1     1   349 use Locale::TextDomain ('App-Chart');
  1         16243  
  1         6  
30              
31 1     1   5605 use Tie::TZ;
  1         382  
  1         32  
32 1     1   282 use App::Chart;
  0            
  0            
33             use App::Chart::Database;
34             use App::Chart::Download;
35             use App::Chart::DownloadHandler;
36             use App::Chart::DownloadHandler::IndivChunks;
37             use App::Chart::IntradayHandler;
38             use App::Chart::Latest;
39             use App::Chart::Sympred;
40             use App::Chart::TZ;
41             use App::Chart::Weblink;
42              
43             # uncomment this to run the ### lines
44             # use Smart::Comments;
45              
46             use constant DEBUG => 0;
47              
48              
49             # .X or .XY or no suffix
50             our $yahoo_pred = App::Chart::Sympred::Proc->new
51             (sub {
52             my ($symbol) = @_;
53             return ($symbol !~ /\.(FQ|LJ)$/
54             && $symbol =~ /[.=]..?$|^[^.]+$/);
55             });
56              
57             my $download_pred = App::Chart::Sympred::Any->new ($yahoo_pred);
58             our $latest_pred = App::Chart::Sympred::Any->new ($yahoo_pred);
59             our $index_pred = App::Chart::Sympred::Regexp->new (qr/^\^|^0.*\.SS$/);
60             my $futures_pred = App::Chart::Sympred::Any->new;
61              
62             # max symbols to any /q? quotes request
63             # Finance::Quote::Yahoo uses a limit of 40 to stop the url getting too
64             # long, which apparently some servers or proxies can't handle
65             use constant MAX_QUOTES => 40;
66              
67             # overridden by specific nodes
68             App::Chart::setup_source_help
69             ($yahoo_pred, __p('manual-node','Yahoo Finance'));
70              
71              
72             #-----------------------------------------------------------------------------
73             # web link - basic quote page
74             #
75             # Eg. http://finance.yahoo.com/q?s=BHP.AX
76             #
77             # The accelerator is "_Y" so as not to clash with "_S" for stock on various
78             # stock exchange links like "ASX IRM _Stock Information"
79              
80             App::Chart::Weblink->new
81             (pred => $yahoo_pred,
82             name => __('_Yahoo Stock Page'),
83             desc => __('Open web browser at the Yahoo quote page for this stock'),
84             proc => sub {
85             my ($symbol) = @_;
86             return "http://"
87             . App::Chart::Database->preference_get ('yahoo-quote-host',
88             'finance.yahoo.com')
89             . "/q?s="
90             . URI::Escape::uri_escape($symbol);
91             });
92              
93              
94             #-----------------------------------------------------------------------------
95             # misc
96              
97              
98             # (if (and (yahoo-futures-symbol? symbol)
99             # (not (chart-symbol-mdate symbol)))
100             # (let* ((want-tdate (adate->tdate
101             # (first
102             # (yahoo-quote-adate-time symbol ""))))
103             # (mdate (or (latest-symbol-mdate-nodownload symbol
104             # want-tdate)
105             # (begin
106             # (weblink-message
107             # (_ "Finding front month ..."))
108             # (latest-symbol-mdate symbol want-tdate)))))
109             # (if mdate # might still be unknown
110             # (set! symbol
111             # (string-append (chart-symbol-commodity symbol)
112             # (mdate->MYY-str mdate)
113             # (chart-symbol-suffix symbol))))))
114              
115              
116             #-----------------------------------------------------------------------------
117             # Exchanges page for quote delays
118             #
119             # This looks at the exchanges page
120             #
121             use constant EXCHANGES_URL => 'https://help.yahoo.com/kb/SLN2310.html';
122              
123             # Exchanges page was previously http://finance.yahoo.com/exchanges, but why
124             # would they keep it the same when breaking everybody's links would be
125             # better.
126              
127             # refetch the exchanges page after this many days
128             use constant EXCHANGES_UPDATE_DAYS => 7;
129              
130             # containing arefs [$pred,'.XX']
131             my @quote_delay_aliases;
132              
133             sub setup_quote_delay_alias {
134             my ($pred, $suffix) = @_;
135             push @quote_delay_aliases, [ $pred, $suffix ];
136             }
137              
138             sub symbol_quote_delay {
139             my ($symbol) = @_;
140              
141             # indexes all in real time
142             if ($index_pred->match($symbol)) {
143             return 0;
144             }
145              
146             my $suffix = App::Chart::symbol_suffix ($symbol);
147             my $h = exchanges_data();
148             my $delay = $h->{$suffix};
149              
150             if (! defined $delay) {
151             if (my $elem = List::Util::first { $_->[0]->match ($symbol) }
152             @quote_delay_aliases) {
153             $suffix = $elem->[1];
154             $delay = $h->{$suffix};
155             }
156             }
157             if (! defined $delay) {
158             # guess default 20 minutes
159             $delay = 20;
160             }
161             return $delay;
162             }
163              
164             # return a hashref of exchange delay data like { '.AX' => 20, '.BI' => 15 }
165             sub exchanges_data {
166             require App::Chart::Pagebits;
167             return App::Chart::Pagebits::get
168             (name => __('Yahoo exchanges page'),
169             url => EXCHANGES_URL,
170             key => 'yahoo-quote-delays',
171             freq_days => EXCHANGES_UPDATE_DAYS,
172             parse => \&exchanges_parse);
173             }
174             sub exchanges_parse {
175             my ($content) = @_;
176             my $h = {};
177              
178             require HTML::TableExtract;
179             my $te = HTML::TableExtract->new (headers => ['Suffix', 'Delay']);
180             $te->parse($content);
181             if (! $te->tables) {
182             warn "Yahoo exchanges page unrecognised, assuming 15 min quote delay";
183             return $h;
184             }
185              
186             foreach my $row ($te->rows) {
187             my $suffix = $row->[0];
188             my $delay = $row->[1];
189             next if ($suffix eq 'N/A');
190              
191             # eg "15 min"
192             # or "15 min**" with footnote
193             #
194             if ($delay =~ /^(\d+) min/) {
195             $delay = $1;
196             } elsif ($delay =~ /real/i) {
197             $delay = 0;
198             } else {
199             warn "Yahoo exchanges page unrecognised delay: \"$delay\"\n";
200             next;
201             }
202              
203             $h->{$suffix} = $delay + 0;
204             }
205             return $h;
206             }
207              
208              
209             #------------------------------------------------------------------------------
210             # Quotes bits generally.
211             #
212             # This uses the csv format quotes like
213             #
214             # http://download.finance.yahoo.com/d/quotes.csv?f=snl&e=.csv&s=BHP.AX
215             #
216             # The "f" field keys can be found at the following (open an account to get
217             # to them).
218             #
219             # http://edit.my.yahoo.com/config/edit_pfview?.vk=v1
220             # http://edit.finance.yahoo.com/e9?.intl=au
221             #
222             # http://download.finance.yahoo.com/d?
223             # s= # symbol
224             # f= # format, concat of the following
225             # s # symbol
226             # n # company name
227             # l1 # last price
228             # d1 # last trade date (in home exchange's timezone)
229             # t1 # last trade time (in yahoo server timezone)
230             # c1 # change
231             # p2 # percent change
232             # v # volume
233             # a2 # average daily volume
234             # b # bid
235             # b6 # bid size
236             # a # ask
237             # a5 # ask size
238             # k1 # "time - last" (ECN), with <b> and <i> markup
239             # c6 # change (ECN)
240             # m2 # day's range (ECN)
241             # b3 # bid (ECN)
242             # b2 # ask (ECN)
243             # p # previous close
244             # o # today's open
245             # m # day's range, eg. "1.23 - 4.56"
246             # w # 52-week range, eg. "1.23 - 4.56"
247             # e # earnings per share
248             # r # p/e ratio
249             # d # div per share
250             # q # ex div date, eg. "Mar 31" or "N/A"
251             # r1 # div pay date
252             # y # div yield
253             # j1 # market cap
254             # x # stock exchange
255             # c4 # currency, eg. "AUD"
256             # i # more info links, letters
257             # # c=chart, n=news, p=profile, r=research, i=insider,
258             # # m=message board (yahoo)
259             # k # 52-week high
260             #
261             # Don't know what the distinction between b,a and b3,b2 quotes are actually
262             # meant to be.
263             # - For the Australian Stock Exchange, b,a are "N/A", and b3,b2 is the
264             # SEATS best quote.
265             # - For US stocks b,a seem to be "N/A", and b3,b2 an ECN quote. The
266             # latter has been seen a long way away from from recent trades though,
267             # eg. in BRK-A.
268             #
269             # d1,t1 are a bit odd, the time is the yahoo server's zone, but the date
270             # seems to be always GMT. The zone for the time can be seen easily by
271             # looking at a quote from the various international XX.finance.yahoo.com.
272             # For the zone for the date however you need to be watching at midnight
273             # GMT, where it ticks over (at all the international XX.finance.yahoo.com).
274              
275              
276             # quote_parse_div_date ($str) returns an iso YYYY-MM-DD date string for a
277             # dividend $str coming from quote.csv data, or undef if none. There are
278             # several different formats,
279             # "Jan 7" # finance.yahoo.com
280             # " 5 Jan" # au.finance, uk.finance
281             # "24-Sep-04" # ABB.AX on finance.yahoo.com
282             # "24 Sep, 2004" # ABB.AX on au.finance
283             # "Sep 24, 2004" # ABB.AX on ca.finance
284             #
285             # An error is thrown for an unrecognised string, don't want some new form to
286             # end up with dividends silently forgotten.
287             #
288             sub quote_parse_div_date {
289             my ($str) = @_;
290             if (DEBUG) { print "quote_parse_div_date() '$str'\n"; }
291             if (! defined $str || $str eq 'N/A' || $str eq '') {
292             return undef; # no info
293             }
294              
295             my ($ss,$mm,$hh,$day,$month,$year,$zone) = Date::Parse::strptime ($str);
296             $month++;
297             if ($year) {
298             $year += 1900;
299             if ($year < 2000) { # "04" returned as 1904, bump to 2004
300             $year += 100;
301             }
302             } else {
303             # year not given, try nearest
304             $year = App::Chart::Download::month_to_nearest_year ($month);
305             }
306             if (! Date::Calc::check_date ($year, $month, $day)) {
307             warn "Yahoo invalid dividend date '$str'";
308             }
309             return App::Chart::ymd_to_iso ($year, $month, $day);
310             }
311              
312             #------------------------------------------------------------------------------
313             # latest
314             #
315             # wget -S -O /dev/stdout 'http://download.finance.yahoo.com/d/quotes.csv?f=snc4b3b2d1t1oml1c1vqdx&e=.csv&s=GM'
316             #
317              
318             App::Chart::LatestHandler->new
319             (pred => $latest_pred,
320             proc => \&latest_download,
321             max_symbols => 1); # downloads go 1 at a time
322              
323             sub latest_download {
324             my ($symbol_list) = @_;
325              
326             foreach my $symbol (@$symbol_list) {
327             my $tdate = daily_available_tdate ($symbol);
328             App::Chart::Download::status(__('Yahoo quote'), $symbol);
329              
330             my $lo_timet = tdate_to_unix($tdate - 4);
331             my $hi_timet = tdate_to_unix($tdate + 2);
332              
333             my $events = 'history';
334             my $url = "https://query1.finance.yahoo.com/v7/finance/chart/"
335             . URI::Escape::uri_escape($symbol)
336             ."?period1=$lo_timet&period2=$hi_timet&interval=1d&events=$events";
337              
338             # unknown symbol is 404 with json error details
339             #
340             my $resp = App::Chart::Download->get ($url, allow_404 => 1,);
341             App::Chart::Download::write_latest_group
342             (latest_parse($symbol,$resp,$tdate));
343             }
344             }
345              
346             sub latest_parse {
347             my ($symbol, $resp, $tdate) = @_;
348              
349             my $content = $resp->decoded_content (raise_error => 1);
350             require JSON;
351             my $json = JSON::from_json($content);
352              
353             my %record = (symbol => $symbol,
354             );
355             my $h = { source => __PACKAGE__,
356             resp => $resp,
357             prefer_decimals => 2,
358             date_format => 'ymd',
359             data => [ \%record ],
360             };
361             if (defined (my $error = $json->{'chart'}->{'error'}->{'code'})) {
362             $record{'error'} = $error;
363             }
364              
365             if (my $result = $json->{'chart'}->{'result'}->[0]) {
366             my $meta = $result->{'meta'}
367             // die "Yahoo JSON oops, no meta";
368             $record{'currency'} = $meta->{'currency'},
369             $record{'exchange'} = $meta->{'exchangeName'},
370              
371             my $symbol_timezone = App::Chart::TZ->for_symbol ($symbol);
372             my $timestamps = $result->{'timestamp'}
373             // die "Yahoo JSON oops, no timestamp";
374              
375             if (@$timestamps) {
376              
377             # timestamps are time of last trade, as can be seen by looking at
378             # something with low enough volume, eg. RMX.AX
379             #
380             if (defined (my $timet = $timestamps->[-1])) {
381             ($record{'last_date'}, $record{'last_time'})
382             = $symbol_timezone->iso_date_time($timet);
383             }
384              
385             if (my $indicators = $result->{'indicators'}->{'quote'}->[0]) {
386             foreach my $key ('open','high','low') {
387             if (my $aref = $indicators->{$key}) {
388             $record{$key} = crunch_trailing_nines($aref->[$#$timestamps]);
389             }
390             }
391             if (my $aref = $indicators->{'volume'}) {
392             $record{'volume'} = $aref->[$#$timestamps];
393             }
394             if (my $aref = $indicators->{'close'}) {
395             my $last = $record{'last'}
396             = crunch_trailing_nines($aref->[$#$timestamps]);
397              
398             # "change" from second last timestamp, if there is one.
399             # As of Nov 17, XAUUSD=X only ever gives a single latest quote
400             # from v7, no previous day to compare.
401             #
402             if (defined $last
403             && scalar(@$timestamps) >= 2
404             && defined(my $prev = $aref->[$#$timestamps - 1])) {
405             $record{'change'}
406             = App::Chart::decimal_sub($last, crunch_trailing_nines($prev));
407             }
408             }
409             }
410             }
411              
412             if (defined $record{'last_date'}
413             && (my $splits = $result->{'events'}->{'splits'})) {
414             while (my ($timet, $href) = each %$splits) {
415             my $split_date = $symbol_timezone->iso_date($timet);
416             if ($split_date eq $record{'last_date'}) {
417             __x('Split {ratio}', ratio => $href->{'splitRatio'})
418             }
419             }
420             }
421             }
422             return $h;
423             }
424              
425             # sub latest_download {
426             # my ($symbol_list) = @_;
427             # App::Chart::Download::status (__('Yahoo quotes'));
428             #
429             # # App::Chart::Download::verbose_message ("Yahoo crumb $crumb cookies\n"
430             # # . $jar->as_string);
431             #
432             # my $crumb_errors = 0;
433             # SYMBOL: foreach my $symbol (@$symbol_list) {
434             # my $tdate = daily_available_tdate ($symbol);
435             #
436             # App::Chart::Download::status(__('Yahoo quote'), $symbol);
437             #
438             # my $lo_timet = tdate_to_unix($tdate - 4);
439             # my $hi_timet = tdate_to_unix($tdate + 2);
440             #
441             # my $data = daily_cookie_data($symbol);
442             # if (! defined $data) {
443             # print "Yahoo $symbol does not exist\n";
444             # next SYMBOL;
445             # }
446             # my $crumb = URI::Escape::uri_escape($data->{'crumb'});
447             # my $jar = http_cookies_from_string($data->{'cookies'} // '');
448             #
449             # my $events = 'history';
450             # my $url = "http://query1.finance.yahoo.com/v7/finance/download/"
451             # . URI::Escape::uri_escape($symbol)
452             # . "?period1=$lo_timet&period2=$hi_timet&interval=1d&events=$events&crumb=$crumb";
453             #
454             # my $resp = App::Chart::Download->get ($url,
455             # allow_401 => 1,
456             # allow_404 => 1,
457             # cookie_jar => $jar,
458             # );
459             # if ($resp->code == 401) {
460             # if (++$crumb_errors >= 2) { die "Yahoo: crumb authorization failed"; }
461             # App::Chart::Database->write_extra ('', 'yahoo-daily-cookies', undef);
462             # redo SYMBOL;
463             # }
464             # if ($resp->code == 404) {
465             # print "Yahoo $symbol does not exist\n";
466             # next SYMBOL;
467             # }
468             #
469             # App::Chart::Download::write_latest_group
470             # (latest_parse($symbol,$resp,$tdate));
471             # }
472             # }
473             #
474             # sub latest_parse {
475             # my ($symbol, $resp, $tdate) = @_;
476             #
477             # my $h = { source => __PACKAGE__,
478             # resp => $resp,
479             # prefer_decimals => 2,
480             # date_format => 'ymd' };
481             # daily_parse($symbol,$resp,$h);
482             #
483             # my $data = $h->{'data'};
484             # @$data = sort {$a->{'date'} cmp $b->{'date'}} @$data;
485             #
486             # my $this = (@$data ? $data->[-1] : {});
487             # $this->{'symbol'} = $symbol;
488             # $this->{'last_date'} = delete $this->{'date'};
489             # my $last = $this->{'last'} = delete $this->{'close'};
490             # if (defined $last && @$data >= 2) {
491             # my $prev = $data->[-2]->{'close'};
492             # if (defined $prev) {
493             # $this->{'change'} = decimal_subtract($last, $prev);
494             # }
495             # }
496             # @$data = ($this);
497             # return $h;
498             # }
499              
500             # Return the difference $x - $y, done as a "decimal" subtract, so retaining
501             # as many decimal places there are on $x and $y.
502             # It's done with some sprint %f fakery, not actual decimal arithmetic, but
503             # that's close enough for 4 decimal place currencies.
504             sub decimal_subtract {
505             my ($x, $y) = @_;
506             my $decimals = max (App::Chart::count_decimals($x),
507             App::Chart::count_decimals($y));
508             return sprintf ('%.*f', $decimals, $x - $y);
509             }
510              
511              
512             # use constant DEFAULT_DOWNLOAD_HOST => 'download.finance.yahoo.com';
513             #
514             # App::Chart::LatestHandler->new
515             # (pred => $latest_pred,
516             # proc => \&latest_download,
517             # max_symbols => MAX_QUOTES);
518             #
519             # sub latest_download {
520             # my ($symbol_list) = @_;
521             #
522             # App::Chart::Download::status
523             # (__x('Yahoo quotes {symbol_range}',
524             # symbol_range =>
525             # App::Chart::Download::symbol_range_string ($symbol_list)));
526             #
527             # my $host = App::Chart::Database->preference_get
528             # ('yahoo-quote-host', DEFAULT_DOWNLOAD_HOST);
529             # my $url = "http://$host/d/quotes.csv?f=snc4b3b2d1t1oml1c1vqdx&e=.csv&s="
530             # . join (',', map { URI::Escape::uri_escape($_) } @$symbol_list);
531             #
532             # my $resp = App::Chart::Download->get ($url);
533             # App::Chart::Download::write_latest_group (latest_parse ($resp));
534             # }
535             #
536             # sub latest_parse {
537             # my ($resp) = @_;
538             # my $content = $resp->decoded_content (raise_error => 1);
539             # ### Yahoo quotes: $content
540             #
541             # my @data = ();
542             # my $h = { source => __PACKAGE__,
543             # resp => $resp,
544             # prefer_decimals => 2,
545             # date_format => 'mdy', # eg. '6/26/2015'
546             # data => \@data };
547             #
548             # require Text::CSV_XS;
549             # my $csv = Text::CSV_XS->new;
550             # foreach my $line (App::Chart::Download::split_lines ($content)) {
551             # $csv->parse($line);
552             # ### csv fields: $csv->fields()
553             # my ($symbol, $name, $currency, $bid, $offer, $last_date, $last_time,
554             # $open, $range, $last, $change, $volume,
555             # $div_date, $div_amount, $exchange)
556             # = $csv->fields();
557             # if (! defined $symbol) {
558             # # blank line maybe
559             # print "Yahoo quotes blank line maybe:\n---\n$content\n---\n";
560             # next;
561             # }
562             #
563             # # for unknown stocks the name is a repeat of the symbol, which is pretty
564             # # useless
565             # if ($name eq $symbol) { $name = undef; }
566             #
567             # my ($low, $high) = split /-/, $range;
568             # my $quote_delay_minutes = symbol_quote_delay ($symbol);
569             #
570             # # have seen wildly garbage date for unknown symbols, like
571             # # GC.CMX","GC.CMX","MRA",N/A,N/A,"8/352/19019","4:58am",N/A,"N/A - N/A",0.00,N/A,N/A,"N/A",N/A,"N/A
572             # # depending what else in the same request ...
573             # #
574             #
575             # # In the past date/times were in New York timezone, for shares anywhere
576             # # in the world. The Chart database is in the timezone of the exchange.
577             # # As of June 2015 believe Yahoo is now also the exchange timezone so no
578             # # transformation.
579             # #
580             # # my $symbol_timezone = App::Chart::TZ->for_symbol ($symbol);
581             # # ($last_date, $last_time)
582             # # = quote_parse_datetime ($last_date, $last_time,
583             # # App::Chart::TZ->newyork,
584             # # $symbol_timezone);
585             #
586             # # dividend is "0.00" for various unknowns or estimates, eg. from ASX
587             # # trusts
588             # if (App::Chart::Download::str_is_zero ($div_amount)) {
589             # $div_amount = __('unknown');
590             # }
591             #
592             # # dividend shown only if it's today
593             # # don't show if no last_date, just in case have a div_date but no
594             # # last_date for some reason
595             # $div_date = quote_parse_div_date ($div_date);
596             # if (! ($div_date && $last_date && $div_date eq $last_date)) {
597             # $div_amount = undef;
598             # }
599             #
600             # push @data, { symbol => $symbol,
601             # name => $name,
602             # exchange => $exchange,
603             # currency => $currency,
604             #
605             # quote_delay_minutes => $quote_delay_minutes,
606             # bid => $bid,
607             # offer => $offer,
608             #
609             # last_date => $last_date,
610             # last_time => $last_time,
611             # open => $open,
612             # high => $high,
613             # low => $low,
614             # last => $last,
615             # change => $change,
616             # volume => $volume,
617             # dividend => $div_amount,
618             # };
619             # }
620             #
621             # ### $h
622             # return $h;
623             # }
624             #
625             # sub mktime_in_zone {
626             # my ($sec, $min, $hour, $mday, $mon, $year, $zone) = @_;
627             # my $timet;
628             #
629             # { local $Tie::TZ::TZ = $zone->tz;
630             # $timet = POSIX::mktime ($sec, $min, $hour,
631             # $mday, $mon, $year, 0,0,0);
632             # my ($Xsec,$Xmin,$Xhour,$Xmday,$Xmon,$Xyear,$wday,$yday,$isdst)
633             # = localtime ($timet);
634             # return POSIX::mktime ($sec, $min, $hour,
635             # $mday, $mon, $year, $wday,$yday,$isdst);
636             # }
637             # }
638             #
639             # # $date is dmy like 7/15/2007, in GMT
640             # # $time is h:mp like 10:05am, in $server_zone
641             # #
642             # # return ($date, $time) iso strings like ('2008-06-11', '10:55:00') in
643             # # $want_zone
644             # #
645             # sub quote_parse_datetime {
646             # my ($date, $time, $server_zone, $want_zone) = @_;
647             # if (DEBUG) { print "quote_parse_datetime $date, $time\n"; }
648             # if ($date eq 'N/A' || $time eq 'N/A') { return (undef, undef); }
649             #
650             # my ($sec,$min,$hour,$mday,$mon,$year)
651             # = Date::Parse::strptime($date . ' ' . $time);
652             # $sec //= 0; # undef if not present
653             # if (DEBUG) { print " parse $sec,$min,$hour,$mday,$mon,$year\n"; }
654             #
655             # my $timet = mktime_in_zone ($sec, $min, $hour,
656             # $mday, $mon, $year, $server_zone);
657             # if (DEBUG) {
658             # print " timet Serv ",do { local $Tie::TZ::TZ = $server_zone->tz;
659             # POSIX::ctime($timet) };
660             # print " timet GMT ",do { local $Tie::TZ::TZ = 'GMT';
661             # POSIX::ctime($timet) };
662             # }
663             #
664             # my ($gmt_sec,$gmt_min,$gmt_hour,$gmt_mday,$gmt_mon,$gmt_year,$gmt_wday,$gmt_yday,$gmt_isdst) = gmtime ($timet);
665             #
666             # if ($gmt_mday != $mday) {
667             # if (DEBUG) { print " mday $mday/$mon cf gmt_mday $gmt_mday/$gmt_mon, at $timet\n"; }
668             # if (cmp_modulo ($gmt_mday, $mday, 31) < 0) {
669             # $mday++;
670             # } else {
671             # $mday--;
672             # }
673             # $timet = mktime_in_zone ($sec, $min, $hour,
674             # $mday, $mon, $year, $server_zone);
675             # if (DEBUG) { print " switch to $mday giving $timet = $timet\n"; }
676             # if (DEBUG) {
677             # print " timet GMT ",do { local $Tie::TZ::TZ = 'GMT';
678             # POSIX::ctime($timet) };
679             # print " timet Targ ",do { local $Tie::TZ::TZ = $want_zone->tz;
680             # POSIX::ctime($timet) };
681             # }
682             # }
683             # return $want_zone->iso_date_time ($timet);
684             # }
685             #
686             # sub cmp_modulo {
687             # my ($x, $y, $modulus) = @_;
688             # my $half = int ($modulus / 2);
689             # return (($x - $y + $half) % $modulus) <=> $half;
690             # }
691             #
692             # sub decode_hms {
693             # my ($str) = @_;
694             # my ($hour, $minute, $second) = split /:/, $str;
695             # if (! defined $second) { $second = 0; }
696             # return ($hour, $minute, $second);
697             # }
698              
699              
700             #-----------------------------------------------------------------------------
701             # download
702             #
703             # This uses the historical prices page like
704             #
705             # https://finance.yahoo.com/quote/AMP.AX/history?p=AMP.AX
706             #
707             # which puts a cookie like
708             #
709             # Set-Cookie: B=fab5sl9cqn2rd&b=3&s=i3; expires=Sun, 03-Sep-2018 04:56:13 GMT; path=/; domain=.yahoo.com
710             #
711             # and contains buried within a mountain of hideous script
712             #
713             # "CrumbStore":{"crumb":"hdDX\u002FHGsZ0Q"}
714             #
715             # The \u002F is backslash character etc which is script string for "/"
716             # character. The crumb is included in a CSV download query like
717             #
718             # https://query1.finance.yahoo.com/v7/finance/download/AMP.AX?period1=1503810440&period2=1504415240&interval=1d&events=history&crumb=hdDX/HGsZ0Q
719             #
720             # period1 is the start time, period2 the end time, both as Unix seconds
721             # since 1 Jan 1970. Not sure of the timezone needed. Some experiments
722             # suggest it depends on the timezone of the symbol. http works as well as
723             # https. The result is like
724             #
725             # Date,Open,High,Low,Close,Adj Close,Volume
726             # 2017-09-07,30.299999,30.379999,30.000000,30.170000,30.170000,3451099
727             #
728             # The "9999s" are some dodgy rounding off to what should be usually at most
729             # 3 (maybe 4?) decimal places.
730             #
731             # Response is 404 if no such symbol, 401 unauthorized if no cookie or crumb.
732             #
733             # "events=div" gives dividends like
734             #
735             # Date,Dividends
736             # 2017-08-11,0.161556
737             #
738             # "events=div" gives splits like, for a consolidation (GXY.AX)
739             #
740             # Date,Stock Splits
741             # 2017-05-22,1/5
742             #
743             #----------------
744             # For reference, there's a similar further which is json format (%7C = "|")
745             #
746             # https://query2.finance.yahoo.com/v8/finance/chart/IBM?formatted=true&lang=en-US&region=US&period1=1504028419&period2=1504428419&interval=1d&events=div%7Csplit&corsDomain=finance.yahoo.com
747             #
748             # This doesn't require a cookie and crumb, has some info like symbol
749             # timezone. The numbers look like they're rounded through 32-bit floating
750             # point, for example "142.55999755859375" which is 142.55 in a 23-bit
751             # mantissa. log(14255000)/log(2) = 23.76 bits
752             #
753             # All prices look like they are split-adjusted, which is ok if that's what
754             # you want and are downloading a full data set, but bad for incremental since
755             # you don't know when a change is applied.
756             #
757              
758             App::Chart::DownloadHandler->new
759             (name => __('Yahoo'),
760             pred => $download_pred,
761             available_tdate_by_symbol => \&daily_available_tdate,
762             available_tdate_extra => 2,
763             url_and_cookiejar_func => \&daily_url_and_cookiejar,
764             proc => \&daily_download,
765             chunk_size => 150);
766              
767             sub daily_available_tdate {
768             my ($symbol) = @_;
769              
770             # Sep 2017: daily data is present for the current day's trade, during the
771             # trading session. Try reckoning it complete at 6pm.
772             return App::Chart::Download::tdate_today_after
773             (18,0, App::Chart::TZ->for_symbol ($symbol));
774              
775             # return App::Chart::Download::tdate_today_after
776             # (10,30, App::Chart::TZ->for_symbol ($symbol))
777             # - 1;
778             }
779              
780             sub daily_download {
781             my ($symbol_list) = @_;
782             App::Chart::Download::status (__('Yahoo daily data'));
783              
784             # App::Chart::Download::verbose_message ("Yahoo crumb $crumb cookies\n"
785             # . $jar->as_string);
786              
787             my $crumb_errors = 0;
788             SYMBOL: foreach my $symbol (@$symbol_list) {
789             my $lo_tdate = App::Chart::Download::start_tdate_for_update (@$symbol_list);
790             my $hi_tdate = daily_available_tdate ($symbol);
791              
792             App::Chart::Download::status
793             (__('Yahoo data'), $symbol,
794             App::Chart::Download::tdate_range_string ($lo_tdate, $hi_tdate));
795              
796             my $lo_timet = tdate_to_unix($lo_tdate - 2);
797             my $hi_timet = tdate_to_unix($hi_tdate + 2);
798              
799             my $data = daily_cookie_data($symbol);
800             if (! defined $data) {
801             print "Yahoo $symbol does not exist\n";
802             next SYMBOL;
803             }
804             my $crumb = URI::Escape::uri_escape($data->{'crumb'});
805             my $jar = http_cookies_from_string($data->{'cookies'} // '');
806              
807             my $h = { source => __PACKAGE__,
808             prefer_decimals => 2,
809             date_format => 'ymd',
810             };
811             foreach my $elem (['history',\&daily_parse],
812             ['div', \&daily_parse_div],
813             ['split', \&daily_parse_split]) {
814             my ($events,$parse) = @$elem;
815             my $url = "http://query1.finance.yahoo.com/v7/finance/download/"
816             . URI::Escape::uri_escape($symbol)
817             . "?period1=$lo_timet&period2=$hi_timet&interval=1d&events=$events&crumb=$crumb";
818              
819             my $resp = App::Chart::Download->get ($url,
820             allow_401 => 1,
821             allow_404 => 1,
822             cookie_jar => $jar,
823             );
824             if ($resp->code == 401) {
825             if (++$crumb_errors >= 2) { die "Yahoo: crumb authorization failed"; }
826             App::Chart::Database->write_extra ('', 'yahoo-daily-cookies', undef);
827             redo SYMBOL;
828             }
829             if ($resp->code == 404) {
830             print "Yahoo $symbol does not exist\n";
831             next SYMBOL;
832             }
833             $parse->($symbol,$resp,$h, $hi_tdate);
834             }
835             ### $h
836             App::Chart::Download::write_daily_group ($h);
837             }
838             }
839              
840             sub daily_parse {
841             my ($symbol, $resp, $h, $hi_tdate) = @_;
842             my @data = ();
843             $h->{'data'} = \@data;
844             my $hi_tdate_iso;
845             if (defined $hi_tdate){ $hi_tdate_iso = App::Chart::tdate_to_iso($hi_tdate); }
846              
847             my $body = $resp->decoded_content (raise_error => 1);
848             my @line_list = App::Chart::Download::split_lines($body);
849              
850             unless ($line_list[0] =~ /^Date,Open,High,Low,Close,Adj Close,Volume/) {
851             die "Yahoo: unrecognised daily data headings: " . $line_list[0];
852             }
853             shift @line_list;
854              
855             foreach my $line (@line_list) {
856             my ($date, $open, $high, $low, $close, $adj_volume, $volume)
857             = split (/,/, $line);
858              
859             $date = daily_date_fixup ($symbol, $date);
860             if (defined $hi_tdate_iso && $date gt $hi_tdate_iso) {
861             # Sep 2017: There's a daily data record during the trading day, but
862             # want to write the database only at the end of trading.
863             ### skip date after hi_tdate ...
864             next;
865             }
866              
867             # Sep 2017 have seen "null,null,null,...", maybe for non-trading days
868             foreach my $field ($open, $high, $low, $close, $adj_volume, $volume) {
869             if ($field eq 'null') {
870             $field = undef;
871             }
872             }
873              
874             if ($index_pred->match($symbol)) {
875             # In the past indexes which not calculated intraday had
876             # open==high==low==close and volume==0, eg. ^WIL5. Use the close
877             # alone in this case, with the effect of drawing line segments instead
878             # of OHLC or Candle figures with no range.
879              
880             if (defined $open && defined $high && defined $low && defined $close
881             && $open == $high && $high == $low && $low == $close && $volume == 0){
882             $open = undef;
883             $high = undef;
884             $low = undef;
885             }
886              
887             } else {
888             # In the past shares with no trades had volume==0,
889             # open==low==close==bid price, and high==offer price, from some time
890             # during the day, maybe the end of day. Zap all the prices in this
891             # case.
892             #
893             # For a public holiday it might be good to zap the volume to undef
894             # too, but don't have anything to distinguish holiday, suspension,
895             # delisting vs just no trades.
896             #
897             # On the ASX when shares are suspended the bid/offer can be crossed as
898             # usual for pre-open auction, and this gives high<low. For a part-day
899             # suspension then can have volume!=0 in this case too. Don't want to
900             # show a high<low, so massage high/low to open/close range if the high
901             # looks like a crossed offer.
902              
903             if (defined $high && defined $low && $high < $low) {
904             $high = App::Chart::max_maybe ($open, $close);
905             $low = App::Chart::min_maybe ($open, $close);
906             }
907              
908             if (defined $open && defined $low && defined $close && defined $volume
909             && $open == $low && $low == $close && $volume == 0) {
910             $open = undef;
911             $high = undef;
912             $low = undef;
913             $close = undef;
914             }
915             }
916              
917             push @data, { symbol => $symbol,
918             date => $date,
919             open => crunch_trailing_nines($open),
920             high => crunch_trailing_nines($high),
921             low => crunch_trailing_nines($low),
922             close => crunch_trailing_nines($close),
923             volume => $volume };
924             }
925             return $h;
926             }
927             sub daily_parse_div {
928             my ($symbol, $resp, $h) = @_;
929             my @dividends = ();
930             $h->{'dividends'} = \@dividends;
931              
932             my $body = $resp->decoded_content (raise_error => 1);
933             my @line_list = App::Chart::Download::split_lines($body);
934              
935             # Date,Dividends
936             # 2015-11-04,1.4143
937             # 2016-05-17,1.41428
938             # 2017-05-16,1.4143
939             # 2016-11-03,1.4143
940              
941             unless ($line_list[0] =~ /^Date,Dividends/) {
942             warn "Yahoo: unrecognised dividend headings: " . $line_list[0];
943             return;
944             }
945             shift @line_list;
946              
947             foreach my $line (@line_list) {
948             my ($date, $amount) = split (/,/, $line);
949              
950             push @dividends, { symbol => $symbol,
951             ex_date => daily_date_fixup ($symbol, $date),
952             amount => $amount };
953             }
954             return $h;
955             }
956             sub daily_parse_split {
957             my ($symbol, $resp, $h) = @_;
958             my @splits = ();
959             $h->{'splits'} = \@splits;
960              
961             my $body = $resp->decoded_content (raise_error => 1);
962             my @line_list = App::Chart::Download::split_lines($body);
963              
964             # GXY.AX split so $10 shares become $2
965             # Date,Stock Splits
966             # 2017-05-22,1/5
967              
968             unless ($line_list[0] =~ /^Date,Stock Splits/) {
969             warn "Yahoo: unrecognised split headings: " . $line_list[0];
970             return;
971             }
972             shift @line_list;
973              
974             foreach my $line (@line_list) {
975             my ($date, $ratio) = split (/,/, $line);
976             my ($old, $new) = split m{/}, $ratio;
977              
978             push @splits, { symbol => $symbol,
979             date => daily_date_fixup ($symbol, $date),
980             new => $new,
981             old => $old };
982             }
983             return $h;
984             }
985              
986             # $str is a string like "30.299999"
987             # Return it with trailing 9s turned into trailing 0s.
988             sub crunch_trailing_nines {
989             my ($str) = @_;
990             if (defined $str) {
991             $str =~ s/(\....(99|00)).*/$1/; # trailing garbage
992              
993             if ($str =~ /(.*)\.(....9+)$/) {
994             $str = decimal_add_low($str,1);
995             } elsif ($str =~ /(.*)\.(....*01)$/) {
996             $str = decimal_add_low($str,-1);
997             }
998              
999             if ($str =~ /(.*)\./) {
1000             my $ilen = length($1);
1001             my $decimals = ($ilen >= 4 ? 2
1002             : $ilen == 3 ? 3
1003             : 4);
1004             $str = round_decimals($str,$decimals);
1005             }
1006             $str = pad_decimals($str, 2);
1007             }
1008             return $str;
1009             }
1010             sub decimal_add_low {
1011             my ($str, $add) = @_;
1012             ### decimal_add_low(): "$str $add"
1013             $str =~ /(.*)\.(.+)$/ or return $str+$add;
1014             my $pre = $1;
1015             my $post = $2;
1016             ### $pre
1017             ### $post
1018             $str = $pre * 10**length($post) + $post + $add;
1019             if (length($post) >= length($str)) { $str = '0'.$str; }
1020             substr($str, -length($post),0, '.');
1021             return $str;
1022             }
1023             sub round_decimals {
1024             my ($str, $decimals) = @_;
1025             if (defined $str && $str =~ /(.*\.[0-9]{$decimals})([0-9])/) {
1026             $str = $1;
1027             if ($2 >= 5) { $str = decimal_add_low($str, 1); }
1028             }
1029             return $str;
1030             }
1031             sub pad_decimals {
1032             my ($str, $decimals) = @_;
1033             ### pad_decimals(): "$str $decimals"
1034             my $got;
1035             if ($str =~ /\.(.*)/) {
1036             $got = length($1);
1037             } else {
1038             $got = 0;
1039             $str .= '.';
1040             }
1041             if ((my $add = $decimals - $got) > 0) {
1042             $str .= '0' x $add;
1043             }
1044             return $str;
1045             }
1046              
1047             # return a hashref
1048             # { cookies => string, # in format HTTP::Cookies ->as_string()
1049             # crumb => string
1050             # }
1051             #
1052             # If no such $symbol then return undef;
1053             #
1054             # Any $symbol which exists is good enough to get a crumb for all later use.
1055             # Could hard-code something likely here, but better to go from the symbol
1056             # which is wanted.
1057             #
1058             sub daily_cookie_data {
1059             my ($symbol) = @_;
1060             require App::Chart::Pagebits;
1061             $symbol = URI::Escape::uri_escape($symbol);
1062             return App::Chart::Pagebits::get
1063             (name => __('Yahoo daily cookie'),
1064             url => "https://finance.yahoo.com/quote/$symbol/history?p=$symbol",
1065             key => 'yahoo-daily-cookies',
1066             freq_days => 3,
1067             parse => \&daily_cookie_parse,
1068             allow_404 => 1);
1069             }
1070             sub daily_cookie_parse {
1071             my ($content, $resp) = @_;
1072              
1073             # script like, with backslash escaping on "\uXXXX"
1074             #"CrumbStore":{"crumb":"hdDX\u002FHGsZ0Q"}
1075             #
1076             $content =~ /"CrumbStore":\{"crumb":"([^"]*)"}/
1077             or die "Yahoo daily data: CrumbStore not found";
1078             my $crumb = App::Chart::Yahoo::javascript_string_unquote($1);
1079              
1080             # header like
1081             # Set-Cookie: B=fab5sl9cqn2rd&b=3&s=i3; expires=Sun, 03-Sep-2018 04:56:13 GMT; path=/; domain=.yahoo.com
1082             #
1083             # Expiry time is +1 year, but dunno if would really work that long.
1084             #
1085             require HTTP::Cookies;
1086             my $jar = HTTP::Cookies->new;
1087             $jar->extract_cookies($resp);
1088             my $cookies_str = $jar->as_string;
1089              
1090             App::Chart::Download::verbose_message ("Yahoo new crumb $crumb\n"
1091             . $cookies_str);
1092             return { crumb => $crumb,
1093             cookies => $cookies_str };
1094             }
1095              
1096             # $str is an ISO date string like 2017-11-05
1097             # It is date GMT of 9:30am in the timezone of $symbol.
1098             # Return the date in the symbol timezone.
1099             #
1100             sub daily_date_fixup {
1101             my ($symbol, $str) = @_;
1102             ### daily_date_fixup: "$symbol $str"
1103             my ($year, $month, $day) = App::Chart::iso_to_ymd ($str);
1104              
1105             my $timezone = App::Chart::TZ->for_symbol($symbol);
1106             if (timezone_gmtoffset_at_ymd($timezone, $year, $month, $day+1)
1107             <= - (10*60+20)*60) {
1108             my $adate = App::Chart::ymd_to_adate ($year, $month, $day);
1109             $str = App::Chart::adate_to_iso ($adate+1);
1110             my $today = $timezone->iso_date();
1111             if ($str gt $today) {
1112             $str = $today;
1113             }
1114             }
1115             return $str;
1116             }
1117              
1118             sub timezone_gmtoffset_at_ymd {
1119             my ($timezone, $year, $month, $day) = @_;
1120             my $timet = $timezone->call(\&POSIX::mktime,
1121             0, 0, 0, $day, $month-1, $year-1900);
1122             my ($sec,$min,$hour,$gmt_day) = gmtime($timet);
1123             return $sec + 60*$min + 3600*$hour + 86400*($gmt_day - $day);
1124             }
1125              
1126             # Return seconds since 00:00:00, 1 Jan 1970 GMT.
1127             sub tdate_to_unix {
1128             my ($tdate) = @_;
1129             my ($year, $month, $day) = App::Chart::tdate_to_ymd ($tdate);
1130             require Time::Local;
1131             return Time::Local::timegm (0, 0, 0, $day, $month-1, $year-1900);
1132             }
1133              
1134             # $str is a string from previous HTTP::Cookies ->as_string()
1135             # Return a new HTTP::Cookies object with that content.
1136             sub http_cookies_from_string {
1137             my ($str) = @_;
1138             require File::Temp;
1139             my $fh = File::Temp->new (TEMPLATE => 'chart-XXXXXX',
1140             TMPDIR => 1);
1141             print $fh "#LWP-Cookies-1.0\n", $str or die;
1142             close $fh or die;
1143             require HTTP::Cookies;
1144             my $jar = HTTP::Cookies->new;
1145             $jar->load($fh->filename);
1146             return $jar;
1147             }
1148              
1149              
1150             #-----------------------------------------------------------------------------
1151             # stock info
1152             #
1153             # Eg. http://download.finance.yahoo.com/d?f=snxc4qr1d&s=TLS.AX
1154              
1155             # App::Chart::DownloadHandler->new
1156             # (name => __('Yahoo info'),
1157             # key => 'Yahoo-info',
1158             # pred => $download_pred,
1159             # proc => \&info_download,
1160             # recheck_days => 7,
1161             # max_symbols => MAX_QUOTES);
1162             #
1163             # sub info_download {
1164             # my ($symbol_list) = @_;
1165             #
1166             # App::Chart::Download::status
1167             # (__x('Yahoo info {symbolrange}',
1168             # symbolrange =>
1169             # App::Chart::Download::symbol_range_string ($symbol_list)));
1170             #
1171             # my $url = 'http://download.finance.yahoo.com/d?f=snxc4qr1d&s='
1172             # . join (',', map { URI::Escape::uri_escape($_) } @$symbol_list);
1173             # my $resp = App::Chart::Download->get ($url);
1174             # my $h = info_parse($resp);
1175             # $h->{'recheck_list'} = $symbol_list;
1176             # App::Chart::Download::write_daily_group ($h);
1177             # }
1178             #
1179             # sub info_parse {
1180             # my ($resp) = @_;
1181             #
1182             # my $content = $resp->decoded_content (raise_error => 1);
1183             # if (DEBUG >= 2) { print "Yahoo info:\n$content\n"; }
1184             #
1185             # my @info;
1186             # my @dividends;
1187             # my $h = { source => __PACKAGE__,
1188             # info => \@info,
1189             # dividends => \@dividends };
1190             #
1191             # require Text::CSV_XS;
1192             # my $csv = Text::CSV_XS->new;
1193             #
1194             # foreach my $line (App::Chart::Download::split_lines ($content)) {
1195             # $csv->parse($line);
1196             # my ($symbol, $name, $exchange, $currency, $ex_date, $pay_date, $amount)
1197             # = $csv->fields();
1198             #
1199             # $ex_date = quote_parse_div_date ($ex_date);
1200             # $pay_date = quote_parse_div_date ($pay_date);
1201             #
1202             # push @info, { symbol => $symbol,
1203             # name => $name,
1204             # currency => $currency,
1205             # exchange => $exchange };
1206             # # circa 2015 the "d" dividend amount field is "N/A" when after the
1207             # # dividend payment (with "r1" pay date "N/A" too)
1208             # if ($ex_date && $amount ne 'N/A' && $amount != 0) {
1209             # push @dividends, { symbol => $symbol,
1210             # ex_date => $ex_date,
1211             # pay_date => $pay_date,
1212             # amount => $amount };
1213             # }
1214             # }
1215             # return $h;
1216             # }
1217              
1218              
1219             #------------------------------------------------------------------------------
1220             # undo javascript string backslash quoting in STR, per
1221             #
1222             # https://developer.mozilla.org/en/JavaScript/Guide/Values,_Variables,_and_Literals#String_Literals
1223             #
1224             # Encode::JavaScript::UCS does \u, but not the rest
1225             #
1226             # cf Java as such not quite the same:
1227             # unicode: http://java.sun.com/docs/books/jls/third_edition/html/lexical.html#100850
1228             # strings: http://java.sun.com/docs/books/jls/third_edition/html/lexical.html#101089
1229             #
1230             my %javascript_backslash = ('b' => "\b", # backspace
1231             'f' => "\f", # formfeed
1232             'n' => "\n", # newline
1233             'r' => "\r",
1234             't' => "\t", # tab
1235             'v' => "\013", # vertical tab
1236             );
1237             sub javascript_string_unquote {
1238             my ($str) = @_;
1239             $str =~ s{\\(?:
1240             ((?:[0-3]?[0-7])?[0-7]) # $1 \377 octal latin-1
1241             |x([0-9a-fA-F]{2}) # $2 \xFF hex latin-1
1242             |u([0-9a-fA-F]{4}) # $3 \uFFFF hex unicode
1243             |(.) # $4 \n etc escapes
1244             )
1245             }{
1246             (defined $1 ? chr(oct($1))
1247             : defined $4 ? ($javascript_backslash{$4} || $4)
1248             : chr(hex($2||$3))) # \x,\u hex
1249             }egx;
1250             return $str;
1251             }
1252              
1253             #------------------------------------------------------------------------------
1254             1;
1255             __END__