From b9e1588593466a44ced9bd64b0ab570a09f2ed08 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sun, 5 May 2024 09:05:44 +1000 Subject: [PATCH 1/6] ENH: mirr: Mimic broadcasting --- numpy_financial/_financial.py | 67 +++++++++++++++++-------- numpy_financial/tests/test_financial.py | 39 +++++++++++++- 2 files changed, 83 insertions(+), 23 deletions(-) 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/test_financial.py b/numpy_financial/tests/test_financial.py index 799d90e..2cd7cb5 100644 --- a/numpy_financial/tests/test_financial.py +++ b/numpy_financial/tests/test_financial.py @@ -8,7 +8,7 @@ # the versions in numpy instead of numpy_financial. import numpy import pytest -from hypothesis import given, settings +from hypothesis import given, settings, assume from numpy.testing import ( assert_, assert_allclose, @@ -18,7 +18,6 @@ import numpy_financial as npf - def float_dtype(): return npst.floating_dtypes(sizes=[32, 64], endianness="<") @@ -393,6 +392,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 +418,25 @@ 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_scalar_array_strategy, + reinvestment_rate=short_scalar_array_strategy, + ) + def test_fuzz(self, values, finance_rate, reinvestment_rate): + assume(finance_rate.size == reinvestment_rate.size) + npf.mirr(values, finance_rate, reinvestment_rate) + + @given( + values=cashflow_array_like_strategy, + finance_rate=short_scalar_array_strategy, + reinvestment_rate=short_scalar_array_strategy, + ) + def test_fuzz(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): From b3289ad87f9eac57d0a177051b743a61ecc0f561 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sun, 5 May 2024 09:08:24 +1000 Subject: [PATCH 2/6] TST: mirr: Rename test to be more descriptive --- numpy_financial/tests/test_financial.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/numpy_financial/tests/test_financial.py b/numpy_financial/tests/test_financial.py index 2cd7cb5..69012e0 100644 --- a/numpy_financial/tests/test_financial.py +++ b/numpy_financial/tests/test_financial.py @@ -432,7 +432,7 @@ def test_fuzz(self, values, finance_rate, reinvestment_rate): finance_rate=short_scalar_array_strategy, reinvestment_rate=short_scalar_array_strategy, ) - def test_fuzz(self, values, finance_rate, reinvestment_rate): + 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) From 38dc04a6ad24062a1ed5871ed660d1f1ccf670ed Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sun, 5 May 2024 09:17:55 +1000 Subject: [PATCH 3/6] STY: Sort imports --- numpy_financial/tests/test_financial.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/numpy_financial/tests/test_financial.py b/numpy_financial/tests/test_financial.py index 69012e0..152b833 100644 --- a/numpy_financial/tests/test_financial.py +++ b/numpy_financial/tests/test_financial.py @@ -8,7 +8,7 @@ # the versions in numpy instead of numpy_financial. import numpy import pytest -from hypothesis import given, settings, assume +from hypothesis import assume, given, settings from numpy.testing import ( assert_, assert_allclose, @@ -18,6 +18,7 @@ import numpy_financial as npf + def float_dtype(): return npst.floating_dtypes(sizes=[32, 64], endianness="<") From f0078aa5df9e2688f1f96756f8992677d5cb7a60 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sun, 5 May 2024 12:18:31 +1000 Subject: [PATCH 4/6] TST: Refactor hypothesis strategies These were outdated and still referred to facts used by numba. Also moved into strategies into their own file. However, the biggest change is that we now only test nicely behaved (no nan,infinities or subnormal) numbers --- numpy_financial/tests/strategies.py | 34 +++++++++++++ numpy_financial/tests/test_financial.py | 63 ++++++------------------- 2 files changed, 48 insertions(+), 49 deletions(-) create mode 100644 numpy_financial/tests/strategies.py 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 152b833..5922e57 100644 --- a/numpy_financial/tests/test_financial.py +++ b/numpy_financial/tests/test_financial.py @@ -1,14 +1,11 @@ import math 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 assume, given, settings +from hypothesis import assume, given from numpy.testing import ( assert_, assert_allclose, @@ -17,42 +14,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_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'] + cashflow_array_like_strategy, + short_nicely_behaved_doubles, + when_strategy, ) @@ -285,8 +251,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) @@ -421,8 +386,8 @@ def test_mirr_no_real_solution_exception(self): @given( values=cashflow_array_like_strategy, - finance_rate=short_scalar_array_strategy, - reinvestment_rate=short_scalar_array_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) @@ -430,8 +395,8 @@ def test_fuzz(self, values, finance_rate, reinvestment_rate): @given( values=cashflow_array_like_strategy, - finance_rate=short_scalar_array_strategy, - reinvestment_rate=short_scalar_array_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) @@ -468,10 +433,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): From d4534f283c1d809c8235ecfcf35cdfda59076e6e Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sun, 5 May 2024 12:25:22 +1000 Subject: [PATCH 5/6] STY: Sort imports --- numpy_financial/tests/test_financial.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/numpy_financial/tests/test_financial.py b/numpy_financial/tests/test_financial.py index 5922e57..656d4f1 100644 --- a/numpy_financial/tests/test_financial.py +++ b/numpy_financial/tests/test_financial.py @@ -15,8 +15,8 @@ import numpy_financial as npf from numpy_financial.tests.strategies import ( - cashflow_array_strategy, cashflow_array_like_strategy, + cashflow_array_strategy, short_nicely_behaved_doubles, when_strategy, ) From 88b6fd6c8935c6bcff52d67ffd52a26c2d2cbc3c Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sun, 5 May 2024 12:35:56 +1000 Subject: [PATCH 6/6] TST: Ignore warnings in MIRR fuzz test 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. --- numpy_financial/tests/test_financial.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/numpy_financial/tests/test_financial.py b/numpy_financial/tests/test_financial.py index 656d4f1..7b95f3d 100644 --- a/numpy_financial/tests/test_financial.py +++ b/numpy_financial/tests/test_financial.py @@ -1,4 +1,5 @@ import math +import warnings from decimal import Decimal # Don't use 'import numpy as np', to avoid accidentally testing @@ -391,7 +392,13 @@ def test_mirr_no_real_solution_exception(self): ) def test_fuzz(self, values, finance_rate, reinvestment_rate): assume(finance_rate.size == reinvestment_rate.size) - npf.mirr(values, finance_rate, reinvestment_rate) + + # 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,