File Coverage

blib/lib/Finance/Math/IRR.pm
Criterion Covered Total %
statement 90 100 90.0
branch 31 38 81.5
condition 16 21 76.1
subroutine 11 12 91.6
pod 1 1 100.0
total 149 172 86.6


line stmt bran cond sub pod time code
1             #################################################################
2             #
3             # Finance::Math::IRR - Calculate the internal rate of return of a cash flow
4             #
5             # $Id: IRR.pm,v 1.5 2007/07/12 12:35:46 erwan_lemonnier Exp $
6             #
7             # 061215 erwan Started implementation
8             # 061218 erwan Differentiate bugs from failures when calling secant() and brent()
9             # 061218 erwan Handle precision correctly
10             # 061218 erwan Support cashflows with only 0 amounts
11             # 070220 erwan Support when secant converges toward a non root value
12             # 070404 erwan Cleanup cashflow from transactions of amount 0
13             # 070404 erwan Error if last transaction is a positive amount. Added $DEBUG
14             # 070411 erwan Return undef when cashflow has only 1 non zero transaction
15             # 070418 erwan Update license
16             # 070711 erwan Removed the restriction requiring the last transaction to be negative
17             #
18              
19             package Finance::Math::IRR;
20              
21 4     4   33685 use 5.006;
  4         16  
  4         169  
22 4     4   31 use strict;
  4         8  
  4         139  
23 4     4   32 use warnings;
  4         8  
  4         408  
24 4     4   24 use Carp qw(confess croak);
  4         7  
  4         441  
25 4     4   8042 use Data::Dumper;
  4         39884  
  4         326  
26 4     4   3986 use Math::Polynom;
  4         81051  
  4         260  
27 4     4   3627 use Date::Calc qw(Delta_Days);
  4         151501  
  4         3783  
28 4     4   220 use Scalar::Util qw(looks_like_number);
  4         8  
  4         1977  
29 4     4   215 use base qw(Exporter);
  4         10  
  4         5768  
30              
31             our @EXPORT = qw(xirr);
32             our $VERSION = '0.10';
33             our $DEBUG = 0;
34              
35             #----------------------------------------------------------------
36             #
37             # parameters for secant and brent methods
38             #
39              
40             my %ARGS_SECANT = ( p0 => 0.5,
41             p1 => 1,
42             max_depth => 100,
43             );
44              
45             my %ARGS_BRENT = ( max_depth => 50 );
46              
47             # how many couple of points to search for positive and negative values
48             my $MAX_POS_NEG_POINTS = 1024;
49              
50             #----------------------------------------------------------------
51             #
52             # _crash - die with a usable error description
53             #
54              
55             sub _crash {
56 0     0   0 my($method,$poly,$args,$err) = @_;
57              
58 0         0 croak "BUG: something went wrong while calling Math::Polynom::$method with the arguments:\n".
59             Dumper($args)."on the polynomial:\n".
60             Dumper($poly)."the error was: [$err]\n".
61             "Please email all this output to erwan\@cpan.org\n";
62             }
63              
64             #----------------------------------------------------------------
65             #
66             # _debug
67             #
68              
69             sub _debug {
70 386     386   102071 my $msg = shift;
71 386 50       1421 print STDOUT "Finance::Math::IRR: $msg\n" if ($DEBUG);
72             }
73              
74             #----------------------------------------------------------------
75             #
76             # xirr - calculate the internal rate of return of a cash flow
77             #
78              
79             sub xirr {
80 195     195 1 205607 my $precision = 0.001; # default precision seeked on irr, ie 0.1%
81 195         5858 my $guess = 0.1;
82 195         296 my %cashflow;
83             my $root;
84              
85             #
86             # Parse input arguments and build the cashflow's polynomial
87             #
88              
89 195 100 100     1938 croak("ERROR: xirr() got an odd number of arguments. this can not be correct") if (!scalar(@_) || scalar(@_) % 2);
90              
91 193         6647 %cashflow = @_;
92              
93 193         1143 _debug("xirr() called with arguments:\n".Dumper(\%cashflow));
94              
95             # parse arguments
96 193 100       6572 if (exists $cashflow{precision}) {
97 192         443 $precision = $cashflow{precision};
98 192         534 delete $cashflow{precision};
99             }
100              
101 193 100 66     1221 if (!defined $precision || !looks_like_number($precision)) {
102 1         131 croak "ERROR: precision is not a valid number";
103             }
104              
105             # remove intermediary transactions with 0 amount from cashflow
106 192         7472 my @sorted_dates = sort keys %cashflow;
107 192 100       1387 croak "ERROR: you provided an empty cash flow" if (scalar @sorted_dates == 0);
108              
109 191         383 my $date_end = $sorted_dates[-1];
110              
111 191         451 foreach my $date (@sorted_dates) {
112 11576         17188 my $amount = $cashflow{$date};
113 11576 100 33     32413 croak "ERROR: the provided cashflow contains undefined values" if (!defined $date || !defined $amount);
114 11575 100       39999 croak "ERROR: invalid date in the provided cashflow [$date]" if ($date !~ /^\d\d\d\d-\d\d-\d\d$/);
115 11574 100       31073 croak "ERROR: invalid amount in the provided cashflow at date [$date]" if (!looks_like_number($amount));
116              
117             # remove transaction from cashflow if it has a 0 amount
118 11573 100 100     32427 if ($amount == 0 && $date ne $date_end) {
119 19         30 delete $cashflow{$date};
120             }
121             }
122              
123 188 100       662 if ($cashflow{$date_end} == 0) {
124             # the last value is 0: we may be able to handle it
125             # was the whole cashflow made of transactions with amount 0?
126 4 100       14 if (scalar keys %cashflow == 1) {
127 3         24 _debug("all transactions in the cashflow have 0 in amount. IRR=0.");
128 3         13 return 0;
129             }
130             }
131              
132 185 100       547 if (scalar keys %cashflow < 2) {
133             # we got a cashflow with only 1 entry and can't calculate an irr on it
134 1         5 return undef;
135             }
136              
137             # TODO: what if all transactions have the same sign?
138              
139             # we want $precision on the irr, but can only steer the precision of 1/(1+irr), hence this ratio, that
140             # should insure us the given precision even on the irr for irrs up to 1000%
141 184         331 $precision = $precision / 1000;
142              
143             # build the polynomial whose solution is x=1/(1+IRR)
144 184         8293 @sorted_dates = sort keys %cashflow;
145 184         1905 my @date_start = split(/-/,$sorted_dates[0]);
146 184 50       721 croak "BUG: expected 3 arguments after splitting [".$sorted_dates[0]."]" if (scalar @date_start != 3);
147              
148 184         440 my %coeffs;
149              
150 184         735 while (my($date,$amount) = each %cashflow) {
151 11550         43042 my $ddays = Delta_Days(@date_start, split(/-/,$date));
152 11550         729450 $coeffs{$ddays/365} = $amount;
153             }
154              
155 184         3414 my $poly = Math::Polynom->new(%coeffs);
156              
157             #
158             # Find a real root of the polynomial
159             #
160              
161 184         74210 $ARGS_SECANT{precision} = $precision;
162              
163 184         2442 _debug("trying secant method on interval [".$ARGS_SECANT{p0}."-".$ARGS_SECANT{p1}."] with precision ".
164             $ARGS_SECANT{precision}." and max ".$ARGS_SECANT{max_depth}." iterations");
165              
166             # try finding the IRR with the secant metho
167 184         324 eval {
168 184         2949 $root = $poly->secant(%ARGS_SECANT);
169             };
170              
171 184 100       1251763 if ($@) {
172             # secant failed. let's make sure it was not a bug
173 3         15 my $error = $poly->error;
174 3 50       102 if ( grep( /^$error$/,
175             Math::Polynom::ERROR_NAN,
176             Math::Polynom::ERROR_DIVIDE_BY_ZERO,
177             Math::Polynom::ERROR_MAX_DEPTH,
178             Math::Polynom::ERROR_NOT_A_ROOT ) ) {
179 3         12 _debug("secant failed on with error code $error");
180             } else {
181             # ok, the method did not fail, something else did
182 0         0 _crash("secant", $poly, \%ARGS_SECANT, $@);
183             }
184              
185             # let's find two points where the polynomial is positive respectively negative
186 3         5 my $i = 1;
187 3   66     12 while ( (!defined $poly->xneg || !defined $poly->xpos) && $i <= $MAX_POS_NEG_POINTS ) {
      100        
188 1026         9087 $poly->eval( $i );
189 1026         50938 $poly->eval( -1+10/($i+9) );
190 1026         46807 $i++;
191             }
192              
193             # if we did not find 2 points where the polynomial is >0 and <0, we can't use Brent's method (nor the bisection)
194 3 100 66     39 if ( !defined $poly->xneg || !defined $poly->xpos ) {
195 1         11 _debug("failed to find an interval on which polynomial is >0 and <0 at the boundaries");
196 1         18 return undef;
197             }
198              
199             # try finding the IRR with Brent's method
200 2         22 $ARGS_BRENT{precision} = $precision;
201 2         5 $ARGS_BRENT{a} = $poly->xneg;
202 2         21 $ARGS_BRENT{b} = $poly->xpos;
203              
204 2         26 _debug("trying Brent's method on interval [".$ARGS_BRENT{a}."-".$ARGS_BRENT{b}."] with precision ".
205             $ARGS_BRENT{precision}." and max ".$ARGS_BRENT{max_depth}." iterations");
206              
207 2         4 eval {
208 2         9 $root = $poly->brent(%ARGS_BRENT);
209             };
210              
211 2 50       51366 if ($@) {
212             # Brent's method failed
213 0         0 $error = $poly->error;
214 0 0       0 if ( grep( /^$error$/,
215             Math::Polynom::ERROR_NAN,
216             Math::Polynom::ERROR_MAX_DEPTH,
217             Math::Polynom::ERROR_NOT_A_ROOT )) {
218             # Brent's method was unable to approximate the root
219 0         0 _debug("brent failed with error code: $error");
220 0         0 return undef;
221             } else {
222             # looks like a bug, either in Math::Polynom's implementation of Brent of in the arguments we sent to it
223 0         0 _crash("brent", $poly, \%ARGS_BRENT, $@);
224             }
225             }
226             }
227              
228 183 50       712 if ($root == 0) {
229             # that would mean IRR = infinity, which is kind of not plausible
230 0         0 _debug("got 0 as the root, meaning infinite IRR. impossible.");
231 0         0 return undef;
232             }
233              
234             # TODO: verify IRR against cashflow
235             # TODO: is the IRR impossibly large?
236             # TODO: try secant with other intervals
237             # TODO: calculate the number of real roots of the polynomial, find them all and choose the most relevant? or die if more than 1?
238              
239 183         10426 return -1 + 1/$root;
240             }
241              
242             1;
243              
244             __END__