File Coverage

blib/lib/Finance/Bank/US/ShareBuilder.pm
Criterion Covered Total %
statement 30 207 14.4
branch 0 32 0.0
condition 0 8 0.0
subroutine 10 22 45.4
pod 9 9 100.0
total 49 278 17.6


line stmt bran cond sub pod time code
1             package Finance::Bank::US::ShareBuilder;
2              
3 1     1   24254 use strict;
  1         2  
  1         35  
4              
5 1     1   5 use Carp 'croak';
  1         2  
  1         48  
6 1     1   1037 use LWP::UserAgent;
  1         95177  
  1         38  
7 1     1   2307 use HTTP::Cookies;
  1         14071  
  1         35  
8 1     1   927 use Date::Parse;
  1         7232  
  1         134  
9 1     1   7206 use DateTime;
  1         219593  
  1         44  
10 1     1   1402 use HTML::TableExtract;
  1         41340  
  1         10  
11 1     1   1492 use Data::Dumper;
  1         10175  
  1         89  
12 1     1   1058 use Finance::OFX::Parse;
  1         2531  
  1         108  
13 1     1   1331 use Locale::Currency::Format;
  1         8547  
  1         4281  
14              
15             =pod
16              
17             =head1 NAME
18              
19             Finance::Bank::US::ShareBuilder - Check positions and transactions for US ShareBuilder investment accounts
20              
21             =head1 VERSION
22              
23             Version 0.01
24              
25             =cut
26              
27             our $VERSION = '0.01';
28              
29             =head1 SYNOPSIS
30              
31             use Finance::Bank::US::ShareBuilder;
32              
33             my $sb = Finance::Bank::US::ShareBuilder->new(
34             username => 'XXXXX', # Saver ID or customer number
35             password => 'XXXXXXXXXX',
36             image => 'I*******.jpg', # The filename of your verification image
37             phrase => 'XXXXXXXXXXXXXX', # Verification phrase
38             );
39              
40             my %accounts = $sb->accounts;
41             for(keys %accounts) {
42             printf "%10s %-15s %11s\n", $_, $accounts{$_}{nickname},
43             '$'.sprintf('%.2f', $accounts{$_}{balance});
44             }
45             $sb->print_positions($sb->positions);
46              
47             =head1 DESCRIPTION
48              
49             This module provides methods to access data from US ShareBuilder accounts,
50             including positions and recent transactions, which can be provided in OFX
51             format (see Finance::OFX) or in parsed lists.
52              
53             There is no support yet for executing transactions. Code for listing sell
54             transactions was written by analogy based on the OFX spec and has not
55             been tested, due to a lack of data.
56              
57             =cut
58              
59             my $base = 'https://www.sharebuilder.com/sharebuilder';
60              
61             =pod
62              
63             =head1 METHODS
64              
65             =head2 new( username => '...', password => '...', image => '...', phrase => '...' )
66              
67             Return an object that can be used to retrieve positions and transactions.
68              
69             =cut
70              
71             sub new {
72 0     0 1   my ($class, %opts) = @_;
73 0           my $self = bless \%opts, $class;
74              
75 0   0       $self->{ua} ||= LWP::UserAgent->new(cookie_jar => HTTP::Cookies->new);
76              
77 0           $self->_login;
78 0           $self;
79             }
80              
81             sub _login {
82 0     0     my ($self) = @_;
83              
84 0           my $response = $self->{ua}->get("$base/authentication/signin.aspx");
85 0           $self->_update_asp_junk($response);
86              
87 0           $self->{ua}->default_header(Referer => "$base/authentication/signin.aspx");
88 0           $response = $self->{ua}->post("$base/authentication/signin.aspx", [
89             $self->_get_asp_junk,
90             'ctl00$ctl00$MainContent$MainContent$ucView$c$views$c$ucUsername$ctl01$txtUsername' => $self->{username},
91             'ctl00$ctl00$MainContent$MainContent$ucView$c$views$c$ucUsername$ctl01$btnSignIn' => 'ctl00$ctl00$MainContent$MainContent$ucView$c$views$c$ucUsername$ctl01$btnSignIn',
92             ]);
93 0           $self->_update_asp_junk($response);
94              
95 0           $self->{ua}->default_header(Referer => "$base/authentication/signin.aspx");
96 0           $response = $self->{ua}->post("$base/authentication/signin.aspx", [
97             __EVENTTARGET => 'ctl00$ctl00$MainContent$MainContent$ucView$c$views$c$nextViewPostBack',
98             $self->_get_asp_junk,
99             ]);
100 0           $self->_update_asp_junk($response);
101              
102 0           my @lines = split /\n/, $response->content;
103 0           my $image_check = grep { /img.*?SelectedSecurityImage.*?ii=$self->{image}/ } @lines;
  0            
104 0           my $phrase_check = grep { /\Q$self->{phrase}\E/ } @lines;
  0            
105              
106 0 0 0       $image_check && $phrase_check or croak "Couldn't verify authenticity of login page.";
107              
108 0           $response = $self->{ua}->post("$base/authentication/signin.aspx", [
109             $self->_get_asp_junk,
110             'ctl00$ctl00$MainContent$MainContent$ucView$c$views$c$ctl08$txtPassword' => $self->{password},
111             'ctl00$ctl00$MainContent$MainContent$ucView$c$views$c$btnNext' => 'ctl00$ctl00$MainContent$MainContent$uc',
112             ]);
113 0           $self->_update_asp_junk($response);
114              
115 0           $response = $self->{ua}->get("$base/account/overview.aspx");
116 0           $self->_update_asp_junk($response);
117 0           $self->{_account_screen} = $response->content;
118             }
119              
120             # Pull ASP junk from current page to use for the next HTTP POST
121             sub _update_asp_junk {
122 0     0     my ($self, $response) = @_;
123              
124 0           my @lines = split /\n/, $response->content;
125              
126 0           ($self->{_asp_junk}{__VIEWSTATE}) = grep { /id="__VIEWSTATE"/ } @lines;
  0            
127 0           ($self->{_asp_junk}{__EVENTVALIDATION}) = grep { /id="__EVENTVALIDATION"/ } @lines;
  0            
128 0 0         my %codes = map { /id="([0-9a-f]{32}|\{[0-9A-F-]{36}\})"\s+value="([^"]+)"/ ? ($1=>$2) : () }
  0            
129 0           grep { /id="[0-9a-f]{32}|\{[0-9A-F-]{36}\}"/ } @lines;
130 0           $self->{_asp_junk}{$_} = $codes{$_} for keys %codes;
131              
132 0           $self->{_asp_junk}{__VIEWSTATE} =~ s/.*id="__VIEWSTATE" value="(.*?)".*/$1/;
133 0           $self->{_asp_junk}{__EVENTVALIDATION} =~ s/.*id="__EVENTVALIDATION" value="(.*?)".*/$1/;
134             }
135              
136             # Trim down ASP junk to whatever is necessary for POSTing
137             sub _get_asp_junk {
138 0     0     my ($self) = @_;
139              
140 0           my %junk = %{$self->{_asp_junk}};
  0            
141 0           for(keys %junk) {
142 0 0         delete $junk{$_} unless $junk{$_};
143             }
144              
145 0           %junk;
146             }
147              
148             =pod
149              
150             =head2 accounts( )
151              
152             Retrieve a list of accounts:
153              
154             ( '####' => { number => '####', type => '...', nickname => '...', balance => ###.## },
155             ...
156             )
157              
158             =cut
159              
160             sub accounts {
161 0     0 1   my ($self) = @_;
162              
163 0 0         return %{$self->{_accounts}} if $self->{_accounts};
  0            
164              
165 0           my @lines = grep { /ctl00_ctl00_MainContent_MainContent_ucView_c_acctList(Invest|Retire)_acctListRepeater_ctl01_(t\d+|lnkFillBalanceFlyout)/ } split /\n/, $self->{_account_screen};
  0            
166              
167 0           my %accounts;
168 0           for(my $i=0; $i<@lines; $i++) {
169 0           my %account;
170              
171 0           $account{type} = $lines[$i];
172 0           $account{type} =~ s/.*c_acctList(Invest|Retire)_acctListRepeater.*/$1/;
173 0           $i++;
174              
175 0           $account{nickname} = $lines[$i];
176 0           $account{nickname} =~ s/.*>(.*?)<.*/$1/;
177 0           $i++;
178              
179 0           $account{number} = $lines[$i];
180 0           $account{number} =~ s/.*>(.*?)<.*/$1/;
181 0           $i++;
182 0           $i++;
183              
184 0           $account{balance} = $lines[$i];
185 0           $account{balance} =~ s/.*>(.*?)<.*/$1/;
186 0           $account{balance} =~ s/[\$,]//g;
187 0           $i++;
188              
189 0           $accounts{$account{number}} = \%account;
190             }
191              
192 0           $self->{_accounts} = \%accounts;
193              
194 0           %accounts;
195             }
196              
197             =pod
198              
199             =head2 positions( $account )
200              
201             List positions for an account:
202              
203             ( { symbol => 'PERL', description => 'Perl, Inc.', quantity => 3.1416,
204             value => 271.83, quote => 86.52, cost_per_share => 73.12,
205             basis => 229.71, change => 42.12, change_pct => 18.33 }
206             ...
207             )
208              
209             =cut
210              
211             sub positions {
212 0     0 1   my ($self, $account) = @_;
213              
214 0           my $response = $self->{ua}->get("$base/account/overview.aspx");
215 0           $self->_update_asp_junk($response);
216              
217 0           $self->{ua}->default_header(Referer => "$base/account/overview.aspx");
218 0           $response = $self->{ua}->post("$base/account/overview.aspx", [
219             __EVENTTARGET => 'ctl00$ctl00$MainContent$MainContent$ucView$c$acctQuickLinks$btnPositions',
220             'ctl00$ctl00$MainContent$MainContent$ucView$c$acctQuickLinks$hidAccountNumber' => $account,
221             $self->_get_asp_junk,
222             ]);
223              
224 0           $response = $self->{ua}->get("$base/account/portfolio/positions.aspx");
225 0 0         $response->is_success or croak "OFX download failed.";
226              
227 0           my $te = new HTML::TableExtract( headers=>['Symbol', 'Description', 'Quote',
228             'Day Change', 'Quantity', 'Market Value', 'Cost / Share',
229             'Cost Basis', 'Gain or Loss']);
230 0           $te->parse($response->content);
231              
232 0           my @positions;
233 0           for my $row ($te->rows)
234             {
235 0           my %p;
236             (
237 0           $p{symbol},
238             $p{description},
239             $p{quote},
240             $p{day_change_and_pct},
241             $p{quantity},
242             $p{value},
243             $p{cost_per_share},
244             $p{basis},
245             $p{change_and_pct},
246 0           ) = map { s/^\s*//; s/\s*$//; $_ } @$row;
  0            
  0            
247              
248 0           ($p{day_change}, $p{day_change_pct}) = split/[\s\n]+/, $p{day_change_and_pct};
249 0           delete $p{day_change_and_pct};
250 0           ($p{change}, $p{change_pct}) = split/[\s\n]+/, $p{change_and_pct};
251 0           delete $p{change_and_pct};
252              
253 0           $p{day_change} =~ s/[+\$,%()]//g;
254 0           $p{day_change_pct} =~ s/[+\$,%()]//g;
255 0           $p{change} =~ s/[+\$,%()]//g;
256 0           $p{change_pct} =~ s/[+\$,%()]//g;
257 0 0         $p{day_change_pct} = '-'.$p{day_change_pct} if $p{day_change} =~ /-/;
258 0 0         $p{change_pct} = '-'.$p{change_pct} if $p{change} =~ /-/;
259              
260 0           $p{value} =~ s/[\$,]//g;
261 0           $p{quote} =~ s/[\$,]//g;
262 0           $p{basis} =~ s/[\$,]//g;
263 0           $p{cost_per_share} =~ s/[\$,]//g;
264              
265 0 0         push @positions, \%p if $p{description};
266             }
267              
268             @positions
269 0           }
270              
271             =pod
272              
273             =head2 print_positions( @positions )
274              
275             Pretty-print a set of positions as returned by positions().
276              
277             =cut
278              
279             sub print_positions {
280 0     0 1   my ($self, @positions) = @_;
281 0           for(@positions) {
282 0 0         printf "%-8s % 9.4f * %7s = %9s ; %-4s %9s (%7s) from %9s\n",
283             $_->{symbol}, $_->{quantity}, usd($_->{quote}), usd($_->{value}),
284             $_->{change} =~ /-/ ? 'down' : 'up', usd($_->{change}), "$_->{change_pct}%", usd($_->{basis});
285             }
286             }
287              
288             =pod
289              
290             =head2 recent_transactions( $account, $days )
291              
292             Retrieve a list of transactions in OFX format for the given account
293             for the past number of days (default: 30).
294              
295             =cut
296              
297             sub recent_transactions {
298 0     0 1   my ($self, $account, $days) = @_;
299              
300 0   0       $days ||= 30;
301              
302 0           my $to = DateTime->today;
303 0           my $from = $to->clone->add(days => -$days);
304              
305 0           $self->transactions($account, $from->ymd('-'), $to->ymd('-'));
306             }
307              
308             =pod
309              
310             =head2 transactions( $account, $from, $to )
311              
312             Retrieve a list of transactions in OFX format for the given account
313             in the given time frame (default: past three months).
314              
315             =cut
316              
317             sub transactions {
318 0     0 1   my ($self, $account, $from, $to) = @_;
319              
320 0 0         $to = $to ? DateTime->from_epoch(epoch => str2time($to)) : DateTime->today;
321 0 0         $from = $from ? DateTime->from_epoch(epoch => str2time($from)) : $to->clone->add(months => -3);
322              
323 0           my $response = $self->{ua}->get("$base/Account/Records/History.aspx");
324 0           $self->_update_asp_junk($response);
325              
326 0           my $c = 'ctl00$ctl00$MainContent$MainContent$ucView$c$views$c$'; # ASP stupidity
327              
328 0           $self->{ua}->default_header(Referer => "$base/Account/Records/History.aspx");
329 0           $response = $self->{ua}->post("$base/Account/Records/History.aspx", [
330             $c.'ddlAccount' => $account,
331             $c.'txtDateRange' => $from->mdy('/').' to '.$to->mdy('/'),
332             $c.'ddlShow' => 'ALL',
333             $c.'btnView' => 'ctl00$ctl00$MainContent$MainContent$uc',
334             $self->_get_asp_junk,
335             ]);
336             #print "{{{\n" .Dumper($response). "\n}}}\n\n";
337 0           $self->_update_asp_junk($response);
338              
339 0           $response = $self->{ua}->post("$base/Account/Records/History.aspx", [
340             'ctl00$ctl00$MainContent$MainContent$ucView$c$views$c$ddlAccount' => $account,
341             'ctl00$ctl00$MainContent$MainContent$ucView$c$views$c$txtDateRange' => $from->mdy('/').' to '.$to->mdy('/'),
342             'ctl00$ctl00$MainContent$MainContent$ucView$c$views$c$ddlShow' => 'ALL',
343             'ctl00$ctl00$MainContent$MainContent$ucView$c$views$c$ddlFinancialSoftware' => 'OFX',
344             'ctl00$ctl00$MainContent$MainContent$ucView$c$views$c$btnDownload' => 'ctl00$ctl00$MainContent$MainContent$ucView$c$views$c$btnDownload',
345             $self->_get_asp_junk,
346             ]);
347             #print "{{{\n" .Dumper($response). "\n}}}\n\n";
348 0           $self->_update_asp_junk($response);
349 0 0         $response->is_success or croak "OFX download failed.";
350              
351 0           my $ofx = $response->content;
352 0           $ofx =~ s/\x0D//g;
353              
354 0 0         if($ofx =~ /Unable to process transaction/) {
355 0           croak "OFX returned, but with a failure.";
356 0           $ofx =~ s/>\n
357 0           print Dumper($ofx);
358             }
359              
360             $ofx
361 0           }
362              
363             =pod
364              
365             =head2 transaction_list( $account, $from, $to )
366              
367             Return transactions as a list instead of as OFX.
368              
369             =cut
370              
371             sub transaction_list {
372 0     0 1   my ($self, $account, $from, $to) = @_;
373              
374 0           my $ofx = $self->transactions($account, $from, $to);
375              
376 0           my $tree = Finance::OFX::Parse::parse($ofx);
377              
378 0           my %secmap = map { $_->{secinfo}{secid}{uniqueid} => $_->{secinfo}{ticker} }
  0            
379 0           @{$tree->{ofx}{seclistmsgsrsv1}{seclist}{stockinfo}};
380              
381 0           my $invlist = $tree->{ofx}{invstmtmsgsrsv1}{invstmttrnrs}{invstmtrs}{invtranlist};
382 0 0         my @buys = @{$invlist->{buystock}} if $invlist->{buystock};
  0            
383 0 0         my @sells = @{$invlist->{sellstock}} if $invlist->{sellstock};
  0            
384 0 0         my @reinvests = @{$invlist->{reinvest}} if $invlist->{reinvest};
  0            
385              
386 0           my @txns;
387              
388 0           for(@buys) {
389 0           my %txn;
390              
391 0           $txn{type} = 'buy';
392 0           $txn{symbol} = $secmap{$_->{invbuy}{secid}{uniqueid}};
393 0           $txn{date} = DateTime->from_epoch(epoch => $_->{invbuy}{invtran}{dttrade})->ymd('-');
394 0           $txn{total} = 0 - $_->{invbuy}{total};
395 0           $txn{commission} = $_->{invbuy}{commission};
396 0           $txn{cost_per_share} = $_->{invbuy}{unitprice};
397 0           $txn{quantity} = $_->{invbuy}{units};
398              
399 0           push @txns, \%txn;
400             }
401              
402 0           for(@sells) {
403 0           my %txn;
404              
405 0           $txn{type} = 'sell';
406 0           $txn{symbol} = $secmap{$_->{invsell}{secid}{uniqueid}};
407 0           $txn{date} = DateTime->from_epoch(epoch => $_->{invsell}{invtran}{dttrade})->ymd('-');
408 0           $txn{total} = 0 - $_->{invsell}{total};
409 0           $txn{commission} = $_->{invsell}{commission};
410 0           $txn{cost_per_share} = $_->{invsell}{unitprice};
411 0           $txn{quantity} = $_->{invsell}{units};
412              
413 0           push @txns, \%txn;
414             }
415              
416 0           for(@reinvests) {
417 0           my %txn;
418              
419 0           $txn{type} = 'reinvest';
420 0           $txn{symbol} = $secmap{$_->{secid}{uniqueid}};
421 0           $txn{date} = DateTime->from_epoch(epoch => $_->{invtran}{dttrade})->ymd('-');
422 0           $txn{total} = 0 - $_->{total};
423 0           $txn{commission} = $_->{commission}; # Should be zero
424 0           $txn{cost_per_share} = $_->{unitprice};
425 0           $txn{quantity} = $_->{units};
426              
427 0           push @txns, \%txn;
428             }
429              
430             @txns
431 0           }
432              
433             =pod
434              
435             =head2 print_transactions( @txns )
436              
437             Pretty-print a set of transactions as returned by transaction_list().
438              
439             =cut
440              
441             sub print_transactions {
442 0     0 1   my ($self, @txns) = @_;
443 0           for(sort { $b->{date} cmp $a->{date} } @txns) {
  0            
444 0           printf "%10s %-8s %-6s %10s - %6s = %9.4f * %9s\n", $_->{date}, $_->{type}, $_->{symbol},
445             usd($_->{total}), usd($_->{commission}), $_->{quantity}, usd($_->{cost_per_share});
446             }
447             }
448              
449             =pod
450              
451             =head2 usd( $dollars )
452              
453             Shortcut to format a floating point amount as dollars (dollar sign, commas, and two decimal places).
454              
455             =cut
456              
457 0     0 1   sub usd { currency_format('USD', $_[0], FMT_SYMBOL) }
458              
459             =pod
460              
461             =head1 AUTHOR
462              
463             This version by Steven N. Severinghaus
464              
465             =head1 COPYRIGHT
466              
467             Copyright (c) 2011 Steven N. Severinghaus. All rights reserved. This
468             program is free software; you can redistribute it and/or modify it under
469             the same terms as Perl itself.
470              
471             =head1 SEE ALSO
472              
473             Finance::Bank::US::INGDirect, Finance::OFX::Parse
474              
475             =cut
476