From 82f39c5b47dd6fccdeeae666307973e7f12970e7 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 6 Jul 2024 11:33:55 +1000 Subject: [PATCH 01/11] MAINT: Tidy up environment.yml This changes the environment name to `npf-dev` and removes the duplicate entry for asv. --- environment.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/environment.yml b/environment.yml index 0fb344e..1b813e1 100644 --- a/environment.yml +++ b/environment.yml @@ -1,8 +1,8 @@ # To use: # $ conda env create -f environment.yml # `mamba` works too for this command -# $ conda activate numpy-financial-dev +# $ conda activate npf-dev # -name: numpy-financial-dev +name: npf-dev channels: - conda-forge dependencies: @@ -20,7 +20,6 @@ dependencies: # Tests - pytest - pytest-xdist - - asv>=0.6.0 - hypothesis # Docs - myst-parser From 9c0f26335d10bfb9866c65d4f138e3cb632cb1fc Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 6 Jul 2024 11:36:19 +1000 Subject: [PATCH 02/11] MAINT: Use correct environment name in cicd --- .github/workflows/pythonpackage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 2812b50..db8baf1 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -19,7 +19,7 @@ jobs: with: auto-update-conda: true python-version: ${{ matrix.python-version }} - activate-environment: numpy-financial-dev + activate-environment: npf-dev environment-file: environment.yml auto-activate-base: false - name: Conda metadata From b60399d285b9954c43307e40e4d4d2ab3211613d Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 6 Jul 2024 11:41:11 +1000 Subject: [PATCH 03/11] BLD: Only use NumPy version 2.0.0 or higher --- environment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/environment.yml b/environment.yml index 1b813e1..ea9c15f 100644 --- a/environment.yml +++ b/environment.yml @@ -8,7 +8,7 @@ channels: dependencies: # Runtime dependencies - python - - numpy + - numpy>=2.0.0 # Build - cython>=3.0.9 - compilers From df5af393e98eb0ce91689966df698b6252b56776 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 6 Jul 2024 11:45:45 +1000 Subject: [PATCH 04/11] MAINT: Fix doctests for NumPy 2.0 --- numpy_financial/_financial.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index c5cd231..dd6f35d 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -245,7 +245,7 @@ def pmt(rate, nper, pv, fv=0, when='end'): years at an annual interest rate of 7.5%? >>> npf.pmt(0.075/12, 12*15, 200000) - -1854.0247200054619 + np.float64(-1854.0247200054619) In order to pay-off (i.e., have a future-value of 0) the $200,000 obtained today, a monthly payment of $1,854.02 would be required. Note that this @@ -424,7 +424,7 @@ def ipmt(rate, per, nper, pv, fv=0, when='end'): >>> interestpd = np.sum(ipmt) >>> np.round(interestpd, 2) - -112.98 + np.float64(-112.98) """ when = _convert_when(when) @@ -562,7 +562,7 @@ def pv(rate, nper, pmt, fv=0, when='end'): interest rate is 5% (annually) compounded monthly. >>> npf.pv(0.05/12, 10*12, -100, 15692.93) - -100.00067131625819 + np.float64(-100.00067131625819) By convention, the negative sign represents cash flow out (i.e., money not available today). Thus, to end up with @@ -913,7 +913,7 @@ def npv(rate, values): >>> rate, cashflows = 0.08, [-40_000, 5_000, 8_000, 12_000, 30_000] >>> np.round(npf.npv(rate, cashflows), 5) - 3065.22267 + np.float64(3065.22267) It may be preferable to split the projected cashflow into an initial investment and expected future cashflows. In this case, the value of @@ -923,7 +923,7 @@ def npv(rate, values): >>> initial_cashflow = cashflows[0] >>> cashflows[0] = 0 >>> np.round(npf.npv(rate, cashflows) + initial_cashflow, 5) - 3065.22267 + np.float64(3065.22267) The NPV calculation may be applied to several ``rates`` and ``cashflows`` simulatneously. This produces an array of shape ``(len(rates), len(cashflows))``. @@ -1005,7 +1005,7 @@ def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False): The project has a finance rate of 10% and a reinvestment rate of 12%. >>> npf.mirr([-100, 50, -60, 70], 0.10, 0.12) - -0.03909366594356467 + np.float64(-0.03909366594356467) Now, let's consider the scenario where all cash flows are negative. From b20caa078f0c8e887dee488104792a4e911a278b Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sun, 5 May 2024 09:05:44 +1000 Subject: [PATCH 05/11] 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 dd6f35d..5d1dddc 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -963,12 +963,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 @@ -977,7 +977,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 @@ -1007,6 +1007,22 @@ def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False): >>> npf.mirr([-100, 50, -60, 70], 0.10, 0.12) np.float64(-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) @@ -1025,22 +1041,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 7ef5de783cac6896cdda88601201ca58ffb98f18 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sun, 5 May 2024 09:08:24 +1000 Subject: [PATCH 06/11] 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 87a4ea776bf8721fd5240a827c9e384074fb7fda Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sun, 5 May 2024 09:17:55 +1000 Subject: [PATCH 07/11] 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 83759c802e5d538fe5652deca2c6e1450b7c8f45 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sun, 5 May 2024 12:18:31 +1000 Subject: [PATCH 08/11] 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 de63975d0e4c46d1983eef658b3b74b95011a60e Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sun, 5 May 2024 12:25:22 +1000 Subject: [PATCH 09/11] 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 9917fbe8e8e094e6eaab443b8385369d8a24fda7 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sun, 5 May 2024 12:35:56 +1000 Subject: [PATCH 10/11] 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, From cb41ae422f0eacdb69fd0f7668b4658c955054f1 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 6 Jul 2024 11:55:28 +1000 Subject: [PATCH 11/11] TST: Update doc test return type for mirr --- numpy_financial/_financial.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 5d1dddc..0395962 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -1005,7 +1005,7 @@ def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False): The project has a finance rate of 10% and a reinvestment rate of 12%. >>> npf.mirr([-100, 50, -60, 70], 0.10, 0.12) - np.float64(-0.03909366594356467) + -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