diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 6128885..06fb750 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -948,12 +948,12 @@ def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False): Parameters ---------- - values : array_like + values : array_like, 1D or 2D Cash flows, where the first value is considered a sunk cost at time zero. It must contain at least one positive and one negative value. - finance_rate : scalar + finance_rate : scalar or 1D array Interest rate paid on the cash flows. - reinvest_rate : scalar + reinvest_rate : scalar or D array Interest rate received on the cash flows upon reinvestment. raise_exceptions: bool, optional Flag to raise an exception when the MIRR cannot be computed due to @@ -962,7 +962,7 @@ def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False): Returns ------- - out : float + out : float or 2D array Modified internal rate of return Notes @@ -992,6 +992,22 @@ def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False): >>> npf.mirr([-100, 50, -60, 70], 0.10, 0.12) -0.03909366594356467 + It is also possible to supply multiple cashflows or pairs of + finance and reinvstment rates, note that in this case the number of elements + in each of the rates arrays must match. + + >>> values = [ + ... [-4500, -800, 800, 800, 600], + ... [-120000, 39000, 30000, 21000, 37000], + ... [100, 200, -50, 300, -200], + ... ] + >>> finance_rate = [0.05, 0.08, 0.10] + >>> reinvestment_rate = [0.08, 0.10, 0.12] + >>> npf.mirr(values, finance_rate, reinvestment_rate) + array([[-0.1784449 , -0.17328716, -0.1684366 ], + [ 0.04627293, 0.05437856, 0.06252201], + [ 0.35712458, 0.40628857, 0.44435295]]) + Now, let's consider the scenario where all cash flows are negative. >>> npf.mirr([-100, -50, -60, -70], 0.10, 0.12) @@ -1010,22 +1026,31 @@ def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False): numpy_financial._financial.NoRealSolutionError: No real solution exists for MIRR since all cashflows are of the same sign. """ - values = np.asarray(values) - n = values.size - - # Without this explicit cast the 1/(n - 1) computation below - # becomes a float, which causes TypeError when using Decimal - # values. - if isinstance(finance_rate, Decimal): - n = Decimal(n) - - pos = values > 0 - neg = values < 0 - if not (pos.any() and neg.any()): + values_inner = np.atleast_2d(values).astype(np.float64) + finance_rate_inner = np.atleast_1d(finance_rate).astype(np.float64) + reinvest_rate_inner = np.atleast_1d(reinvest_rate).astype(np.float64) + n = values_inner.shape[1] + + if finance_rate_inner.size != reinvest_rate_inner.size: if raise_exceptions: - raise NoRealSolutionError('No real solution exists for MIRR since' - ' all cashflows are of the same sign.') + raise ValueError("finance_rate and reinvest_rate must have the same size") return np.nan - numer = np.abs(npv(reinvest_rate, values * pos)) - denom = np.abs(npv(finance_rate, values * neg)) - return (numer / denom) ** (1 / (n - 1)) * (1 + reinvest_rate) - 1 + + out_shape = _get_output_array_shape(values_inner, finance_rate_inner) + out = np.empty(out_shape) + + for i, v in enumerate(values_inner): + for j, (rr, fr) in enumerate(zip(reinvest_rate_inner, finance_rate_inner)): + pos = v > 0 + neg = v < 0 + + if not (pos.any() and neg.any()): + if raise_exceptions: + raise NoRealSolutionError("No real solution exists for MIRR since" + " all cashflows are of the same sign.") + out[i, j] = np.nan + else: + numer = np.abs(npv(rr, v * pos)) + denom = np.abs(npv(fr, v * neg)) + out[i, j] = (numer / denom) ** (1 / (n - 1)) * (1 + rr) - 1 + return _ufunc_like(out) diff --git a/numpy_financial/tests/strategies.py b/numpy_financial/tests/strategies.py new file mode 100644 index 0000000..6662431 --- /dev/null +++ b/numpy_financial/tests/strategies.py @@ -0,0 +1,34 @@ +import numpy as np +from hypothesis import strategies as st +from hypothesis.extra import numpy as npst + +real_scalar_dtypes = st.one_of( + npst.floating_dtypes(), + npst.integer_dtypes(), + npst.unsigned_integer_dtypes() +) +nicely_behaved_doubles = npst.from_dtype( + np.dtype("f8"), + allow_nan=False, + allow_infinity=False, + allow_subnormal=False, +) +cashflow_array_strategy = npst.arrays( + dtype=npst.floating_dtypes(sizes=64), + shape=npst.array_shapes(min_dims=1, max_dims=2, min_side=0, max_side=25), + elements=nicely_behaved_doubles, +) +cashflow_list_strategy = cashflow_array_strategy.map(lambda x: x.tolist()) +cashflow_array_like_strategy = st.one_of( + cashflow_array_strategy, + cashflow_list_strategy, +) +short_nicely_behaved_doubles = npst.arrays( + dtype=npst.floating_dtypes(sizes=64), + shape=npst.array_shapes(min_dims=0, max_dims=1, min_side=0, max_side=5), + elements=nicely_behaved_doubles, +) + +when_strategy = st.sampled_from( + ['end', 'begin', 'e', 'b', 0, 1, 'beginning', 'start', 'finish'] +) diff --git a/numpy_financial/tests/test_financial.py b/numpy_financial/tests/test_financial.py index 799d90e..7b95f3d 100644 --- a/numpy_financial/tests/test_financial.py +++ b/numpy_financial/tests/test_financial.py @@ -1,14 +1,12 @@ import math +import warnings from decimal import Decimal -import hypothesis.extra.numpy as npst -import hypothesis.strategies as st - # Don't use 'import numpy as np', to avoid accidentally testing # the versions in numpy instead of numpy_financial. import numpy import pytest -from hypothesis import given, settings +from hypothesis import assume, given from numpy.testing import ( assert_, assert_allclose, @@ -17,42 +15,11 @@ ) import numpy_financial as npf - - -def float_dtype(): - return npst.floating_dtypes(sizes=[32, 64], endianness="<") - - -def int_dtype(): - return npst.integer_dtypes(sizes=[32, 64], endianness="<") - - -def uint_dtype(): - return npst.unsigned_integer_dtypes(sizes=[32, 64], endianness="<") - - -real_scalar_dtypes = st.one_of(float_dtype(), int_dtype(), uint_dtype()) - - -cashflow_array_strategy = npst.arrays( - dtype=real_scalar_dtypes, - shape=npst.array_shapes(min_dims=1, max_dims=2, min_side=0, max_side=25), -) -cashflow_list_strategy = cashflow_array_strategy.map(lambda x: x.tolist()) - -cashflow_array_like_strategy = st.one_of( +from numpy_financial.tests.strategies import ( + cashflow_array_like_strategy, cashflow_array_strategy, - cashflow_list_strategy, -) - -short_scalar_array_strategy = npst.arrays( - dtype=real_scalar_dtypes, - shape=npst.array_shapes(min_dims=0, max_dims=1, min_side=0, max_side=5), -) - - -when_strategy = st.sampled_from( - ['end', 'begin', 'e', 'b', 0, 1, 'beginning', 'start', 'finish'] + short_nicely_behaved_doubles, + when_strategy, ) @@ -285,8 +252,7 @@ def test_npv(self): rtol=1e-2, ) - @given(rates=short_scalar_array_strategy, values=cashflow_array_strategy) - @settings(deadline=None) + @given(rates=short_nicely_behaved_doubles, values=cashflow_array_strategy) def test_fuzz(self, rates, values): npf.npv(rates, values) @@ -393,6 +359,23 @@ def test_mirr(self, values, finance_rate, reinvest_rate, expected): else: assert_(numpy.isnan(result)) + def test_mirr_broadcast(self): + values = [ + [-4500, -800, 800, 800, 600], + [-120000, 39000, 30000, 21000, 37000], + [100, 200, -50, 300, -200], + ] + finance_rate = [0.05, 0.08, 0.10] + reinvestment_rate = [0.08, 0.10, 0.12] + # Found using Google sheets + expected = numpy.array([ + [-0.1784449, -0.17328716, -0.1684366], + [0.04627293, 0.05437856, 0.06252201], + [0.35712458, 0.40628857, 0.44435295] + ]) + actual = npf.mirr(values, finance_rate, reinvestment_rate) + assert_allclose(actual, expected) + def test_mirr_no_real_solution_exception(self): # Test that if there is no solution because all the cashflows # have the same sign, then npf.mirr returns NoRealSolutionException @@ -402,6 +385,31 @@ def test_mirr_no_real_solution_exception(self): with pytest.raises(npf.NoRealSolutionError): npf.mirr(val, 0.10, 0.12, raise_exceptions=True) + @given( + values=cashflow_array_like_strategy, + finance_rate=short_nicely_behaved_doubles, + reinvestment_rate=short_nicely_behaved_doubles, + ) + def test_fuzz(self, values, finance_rate, reinvestment_rate): + assume(finance_rate.size == reinvestment_rate.size) + + # NumPy warns us of arithmetic overflow/underflow + # this only occurs when hypothesis generates extremely large values + # that are unlikely to ever occur in the real world. + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + npf.mirr(values, finance_rate, reinvestment_rate) + + @given( + values=cashflow_array_like_strategy, + finance_rate=short_nicely_behaved_doubles, + reinvestment_rate=short_nicely_behaved_doubles, + ) + def test_mismatching_rates_raise(self, values, finance_rate, reinvestment_rate): + assume(finance_rate.size != reinvestment_rate.size) + with pytest.raises(ValueError): + npf.mirr(values, finance_rate, reinvestment_rate, raise_exceptions=True) + class TestNper: def test_basic_values(self): @@ -432,10 +440,10 @@ def test_broadcast(self): ) @given( - rates=short_scalar_array_strategy, - payments=short_scalar_array_strategy, - present_values=short_scalar_array_strategy, - future_values=short_scalar_array_strategy, + rates=short_nicely_behaved_doubles, + payments=short_nicely_behaved_doubles, + present_values=short_nicely_behaved_doubles, + future_values=short_nicely_behaved_doubles, whens=when_strategy, ) def test_fuzz(self, rates, payments, present_values, future_values, whens):