Skip to content

Commit 9b2cb63

Browse files
authored
Merge pull request #25 from person142/irr
BUG: prevent underflow/overflow when finding roots in IRR
2 parents a9b73c0 + 63780b6 commit 9b2cb63

File tree

2 files changed

+118
-32
lines changed

2 files changed

+118
-32
lines changed

numpy_financial/_financial.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,35 @@ def rate(nper, pmt, pv, fv, when='end', guess=None, tol=None, maxiter=100):
666666
return rn
667667

668668

669+
def _roots(p):
670+
"""Modified version of NumPy's roots function.
671+
672+
NumPy's roots uses the companion matrix method, which divides by
673+
p[0]. This can causes overflows/underflows. Instead form a
674+
modified companion matrix that is scaled by 2^c * p[0], where the
675+
exponent c is chosen to balance the magnitudes of the
676+
coefficients. Since scaling the matrix just scales the
677+
eigenvalues, we can remove the scaling at the end.
678+
679+
Scaling by a power of 2 is chosen to avoid rounding errors.
680+
681+
"""
682+
_, e = np.frexp(p)
683+
# Balance the most extreme exponents e_max and e_min by solving
684+
# the equation
685+
#
686+
# |c + e_max| = |c + e_min|.
687+
#
688+
# Round the exponent to an integer to avoid rounding errors.
689+
c = int(-0.5 * (np.max(e) + np.min(e)))
690+
p = np.ldexp(p, c)
691+
692+
A = np.diag(np.full(p.size - 2, p[0]), k=-1)
693+
A[0,:] = -p[1:]
694+
eigenvalues = np.linalg.eigvals(A)
695+
return eigenvalues / p[0]
696+
697+
669698
def irr(values):
670699
"""
671700
Return the Internal Rate of Return (IRR).
@@ -729,12 +758,17 @@ def irr(values):
729758
0.0886
730759
731760
"""
732-
# `np.roots` call is why this function does not support Decimal type.
733-
#
734-
# Ultimately Decimal support needs to be added to np.roots, which has
735-
# greater implications on the entire linear algebra module and how it does
736-
# eigenvalue computations.
737-
res = np.roots(values[::-1])
761+
values = np.atleast_1d(values)
762+
if values.ndim != 1:
763+
raise ValueError("Cashflows must be a rank-1 array")
764+
765+
# Strip leading and trailing zeros. Since we only care about
766+
# positive roots we can neglect roots at zero.
767+
non_zero = np.nonzero(np.ravel(values))[0]
768+
values = values[int(non_zero[0]):int(non_zero[-1])+1]
769+
770+
res = _roots(values[::-1])
771+
738772
mask = (res.imag == 0) & (res.real > 0)
739773
if not mask.any():
740774
return np.nan

numpy_financial/tests/test_financial.py

Lines changed: 78 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,6 @@
1313

1414

1515
class TestFinancial(object):
16-
def test_npv_irr_congruence(self):
17-
# IRR is defined as the rate required for the present value of a
18-
# a series of cashflows to be zero i.e. NPV(IRR(x), x) = 0
19-
cashflows = numpy.array([-40000, 5000, 8000, 12000, 30000])
20-
assert_allclose(npf.npv(npf.irr(cashflows), cashflows), 0,
21-
atol=1e-10, rtol=0)
22-
2316
def test_rate(self):
2417
assert_almost_equal(npf.rate(10, 0, -3500, 10000), 0.1107, 4)
2518

@@ -28,25 +21,6 @@ def test_rate_decimal(self):
2821
Decimal('10000'))
2922
assert_equal(Decimal('0.1106908537142689284704528100'), rate)
3023

31-
def test_irr(self):
32-
v = [-150000, 15000, 25000, 35000, 45000, 60000]
33-
assert_almost_equal(npf.irr(v), 0.0524, 2)
34-
v = [-100, 0, 0, 74]
35-
assert_almost_equal(npf.irr(v), -0.0955, 2)
36-
v = [-100, 39, 59, 55, 20]
37-
assert_almost_equal(npf.irr(v), 0.28095, 2)
38-
v = [-100, 100, 0, -7]
39-
assert_almost_equal(npf.irr(v), -0.0833, 2)
40-
v = [-100, 100, 0, 7]
41-
assert_almost_equal(npf.irr(v), 0.06206, 2)
42-
v = [-5, 10.5, 1, -8, 1]
43-
assert_almost_equal(npf.irr(v), 0.0886, 2)
44-
45-
# Test that if there is no solution then npf.irr returns nan
46-
# Fixes gh-6744
47-
v = [-1, -2, -3]
48-
assert_equal(npf.irr(v), numpy.nan)
49-
5024
def test_pv(self):
5125
assert_almost_equal(npf.pv(0.07, 20, 12000, 0), -127128.17, 2)
5226

@@ -514,3 +488,81 @@ def test_some_rates_zero(self):
514488
[-500, -610.51], # Computed using Google Sheet's FV
515489
rtol=1e-10,
516490
)
491+
492+
493+
class TestIrr:
494+
def test_npv_irr_congruence(self):
495+
# IRR is defined as the rate required for the present value of
496+
# a a series of cashflows to be zero, so we should have
497+
#
498+
# NPV(IRR(x), x) = 0.
499+
cashflows = numpy.array([-40000, 5000, 8000, 12000, 30000])
500+
assert_allclose(
501+
npf.npv(npf.irr(cashflows), cashflows),
502+
0,
503+
atol=1e-10,
504+
rtol=0,
505+
)
506+
507+
@pytest.mark.parametrize('v, desired', [
508+
([-150000, 15000, 25000, 35000, 45000, 60000], 0.0524),
509+
([-100, 0, 0, 74], -0.0955),
510+
([-100, 39, 59, 55, 20], 0.28095),
511+
([-100, 100, 0, -7], -0.0833),
512+
([-100, 100, 0, 7], 0.06206),
513+
([-5, 10.5, 1, -8, 1], 0.0886),
514+
])
515+
def test_basic_values(self, v, desired):
516+
assert_almost_equal(npf.irr(v), desired, decimal=2)
517+
518+
def test_trailing_zeros(self):
519+
assert_almost_equal(
520+
npf.irr([-5, 10.5, 1, -8, 1, 0, 0, 0]),
521+
0.0886,
522+
decimal=2,
523+
)
524+
525+
def test_numpy_gh_6744(self):
526+
# Test that if there is no solution then npf.irr returns nan.
527+
v = [-1, -2, -3]
528+
assert numpy.isnan(npf.irr(v))
529+
530+
def test_gh_15(self):
531+
v = [
532+
-3000.0,
533+
2.3926932267015667e-07,
534+
4.1672087103345505e-16,
535+
5.3965110036378706e-25,
536+
5.1962551071806174e-34,
537+
3.7202955645436402e-43,
538+
1.9804961711632469e-52,
539+
7.8393517651814181e-62,
540+
2.3072565113911438e-71,
541+
5.0491839233308912e-81,
542+
8.2159177668499263e-91,
543+
9.9403244366963527e-101,
544+
8.942410813633967e-111,
545+
5.9816122646481191e-121,
546+
2.9750309031844241e-131,
547+
1.1002067043497954e-141,
548+
3.0252876563518021e-152,
549+
6.1854121948207909e-163,
550+
9.4032980015353301e-174,
551+
1.0629218520017728e-184,
552+
8.9337141847171845e-196,
553+
5.5830607698467935e-207,
554+
2.5943122036622652e-218,
555+
8.9635842466507006e-230,
556+
2.3027710094332358e-241,
557+
4.3987510596745562e-253,
558+
6.2476630372575209e-265,
559+
6.598046841695288e-277,
560+
5.1811095266842017e-289,
561+
3.0250999925830644e-301,
562+
1.3133070599585015e-313,
563+
]
564+
result = npf.irr(v)
565+
assert numpy.isfinite(result)
566+
# Very rough approximation taken from the issue.
567+
desired = -0.9999999990596069
568+
assert_allclose(result, desired, rtol=1e-9)

0 commit comments

Comments
 (0)