| 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®ion=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__ |