Skip to content

Commit d02edfb

Browse files
authored
Merge pull request #47 from Kai-Striega/iterative_irr
ENH: Use Newton's method to calculate IRR
2 parents 2e6ab00 + 68a2fb8 commit d02edfb

File tree

2 files changed

+44
-55
lines changed

2 files changed

+44
-55
lines changed

numpy_financial/_financial.py

Lines changed: 38 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -675,36 +675,7 @@ def rate(nper, pmt, pv, fv, when='end', guess=None, tol=None, maxiter=100):
675675
return rn
676676

677677

678-
def _roots(p):
679-
"""Modified version of NumPy's roots function.
680-
681-
NumPy's roots uses the companion matrix method, which divides by
682-
p[0]. This can causes overflows/underflows. Instead form a
683-
modified companion matrix that is scaled by 2^c * p[0], where the
684-
exponent c is chosen to balance the magnitudes of the
685-
coefficients. Since scaling the matrix just scales the
686-
eigenvalues, we can remove the scaling at the end.
687-
688-
Scaling by a power of 2 is chosen to avoid rounding errors.
689-
690-
"""
691-
_, e = np.frexp(p)
692-
# Balance the most extreme exponents e_max and e_min by solving
693-
# the equation
694-
#
695-
# |c + e_max| = |c + e_min|.
696-
#
697-
# Round the exponent to an integer to avoid rounding errors.
698-
c = int(-0.5 * (np.max(e) + np.min(e)))
699-
p = np.ldexp(p, c)
700-
701-
A = np.diag(np.full(p.size - 2, p[0]), k=-1)
702-
A[0,:] = -p[1:]
703-
eigenvalues = np.linalg.eigvals(A)
704-
return eigenvalues / p[0]
705-
706-
707-
def irr(values):
678+
def irr(values, guess=0.1, tol=1e-12, maxiter=100):
708679
"""
709680
Return the Internal Rate of Return (IRR).
710681
@@ -721,6 +692,13 @@ def irr(values):
721692
are negative and net "withdrawals" are positive. Thus, for
722693
example, at least the first element of `values`, which represents
723694
the initial investment, will typically be negative.
695+
guess : float, optional
696+
Initial guess of the IRR for the iterative solver. If no guess is
697+
given an initial guess of 0.1 (i.e. 10%) is assumed instead.
698+
tol : float, optional
699+
Required tolerance to accept solution. Default is 1e-12.
700+
maxiter : int, optional
701+
Maximum iterations to perform in finding a solution. Default is 100.
724702
725703
Returns
726704
-------
@@ -730,7 +708,7 @@ def irr(values):
730708
Notes
731709
-----
732710
The IRR is perhaps best understood through an example (illustrated
733-
using np.irr in the Examples section below). Suppose one invests 100
711+
using np.irr in the Examples section below). Suppose one invests 100
734712
units and then makes the following withdrawals at regular (fixed)
735713
intervals: 39, 59, 55, 20. Assuming the ending value is 0, one's 100
736714
unit investment yields 173 units; however, due to the combination of
@@ -771,28 +749,36 @@ def irr(values):
771749
if values.ndim != 1:
772750
raise ValueError("Cashflows must be a rank-1 array")
773751

774-
# Strip leading and trailing zeros. Since we only care about
775-
# positive roots we can neglect roots at zero.
776-
non_zero = np.nonzero(np.ravel(values))[0]
777-
values = values[int(non_zero[0]):int(non_zero[-1])+1]
778-
779-
res = _roots(values[::-1])
780-
781-
mask = (res.imag == 0) & (res.real > 0)
782-
if not mask.any():
752+
# If all values are of the same sign no solution exists
753+
# we don't perform any further calculations and exit early
754+
same_sign = np.all(values > 0) if values[0] > 0 else np.all(values < 0)
755+
if same_sign:
783756
return np.nan
784-
res = res[mask].real
785-
# NPV(rate) = 0 can have more than one solution so we return
786-
# only the solution closest to zero.
787-
rate = 1/res - 1
788-
789-
# If there are any positive solutions prefer those over negative
790-
# rates.
791-
if (rate > 0).any():
792-
rate = np.where(rate > 0, rate, np.inf)
793-
794-
rate = rate.item(np.argmin(np.abs(rate)))
795-
return rate
757+
758+
# We aim to solve eirr such that NPV is exactly zero. This can be framed as
759+
# simply finding the closest root of a polynomial to a given initial guess
760+
# as follows:
761+
# V0 V1 V2 V3
762+
# NPV = ---------- + ---------- + ---------- + ---------- + ...
763+
# (1+eirr)^0 (1+eirr)^1 (1+eirr)^2 (1+eirr)^3
764+
#
765+
# by letting x = 1 / (1+eirr), we substitute to get
766+
#
767+
# NPV = V0 * x^0 + V1 * x^1 + V2 * x^2 + V3 * x^3 + ...
768+
#
769+
# which we solve using Newton-Raphson and then reverse out the solution
770+
# as eirr = 1/x - 1 (if we are close enough to a solution)
771+
npv_ = np.polynomial.Polynomial(values)
772+
d_npv = npv_.deriv()
773+
x = 1 / (1 + guess)
774+
775+
for _ in range(maxiter):
776+
x_new = x - (npv_(x) / d_npv(x))
777+
if abs(x_new - x) < tol:
778+
return 1 / x_new - 1
779+
x = x_new
780+
781+
return np.nan
796782

797783

798784
def npv(rate, values):

numpy_financial/tests/test_financial.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -564,7 +564,7 @@ def test_some_rates_zero(self):
564564
class TestIrr:
565565
def test_npv_irr_congruence(self):
566566
# IRR is defined as the rate required for the present value of
567-
# a a series of cashflows to be zero, so we should have
567+
# a series of cashflows to be zero, so we should have
568568
#
569569
# NPV(IRR(x), x) = 0.
570570
cashflows = numpy.array([-40000, 5000, 8000, 12000, 30000])
@@ -593,9 +593,12 @@ def test_trailing_zeros(self):
593593
decimal=2,
594594
)
595595

596-
def test_numpy_gh_6744(self):
596+
@pytest.mark.parametrize('v', [
597+
(1, 2, 3),
598+
(-1, -2, -3),
599+
])
600+
def test_numpy_gh_6744(self, v):
597601
# Test that if there is no solution then npf.irr returns nan.
598-
v = [-1, -2, -3]
599602
assert numpy.isnan(npf.irr(v))
600603

601604
def test_gh_15(self):

0 commit comments

Comments
 (0)