From e8cf7691a9483872118cae152fea04758cc06f61 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Wed, 29 Nov 2023 13:35:09 +0800 Subject: [PATCH 01/15] ENH: First attempt at cython rework --- numpy_financial/_cfinancial.pyx | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 numpy_financial/_cfinancial.pyx diff --git a/numpy_financial/_cfinancial.pyx b/numpy_financial/_cfinancial.pyx new file mode 100644 index 0000000..69e2ba2 --- /dev/null +++ b/numpy_financial/_cfinancial.pyx @@ -0,0 +1,30 @@ +from libc.math cimport NAN +import numpy as np +cimport numpy as np +cimport cython + +np.import_array() + + +@cython.boundscheck(False) +@cython.wraparound(False) +@cython.cdivision(True) +@cython.cpow(True) +def npv(const double[::1] rates not None, const double[:, ::1] cashflows not None): + cdef: + long rate_len = rates.shape[0] + long no_of_cashflows = cashflows.shape[0] + long cashflows_len = cashflows.shape[1] + long i, j, t + double acc + double[:, ::1] out + + out = np.empty(shape=(rate_len, no_of_cashflows)) + for i in range(rate_len): + for j in range(no_of_cashflows): + acc = 0.0 + for t in range(cashflows_len): + acc += cashflows[j, t] / ((1.0 + rates[i]) ** t) + out[i, j] = acc + return out + From 87173a4783f9bd6b5d374c38e91b019ceec357ef Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Fri, 1 Dec 2023 08:11:08 +0800 Subject: [PATCH 02/15] ENH: Make Cython's NPV function cpdef --- numpy_financial/_cfinancial.pyx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/numpy_financial/_cfinancial.pyx b/numpy_financial/_cfinancial.pyx index 69e2ba2..9b7099d 100644 --- a/numpy_financial/_cfinancial.pyx +++ b/numpy_financial/_cfinancial.pyx @@ -10,7 +10,7 @@ np.import_array() @cython.wraparound(False) @cython.cdivision(True) @cython.cpow(True) -def npv(const double[::1] rates not None, const double[:, ::1] cashflows not None): +cpdef double[:, ::1] npv(const double[::1] rates, const double[:, ::1] cashflows): cdef: long rate_len = rates.shape[0] long no_of_cashflows = cashflows.shape[0] From 941af840be1b92ac2b775e24dc0d3c77fdf75dc7 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 2 Dec 2023 11:22:52 +0800 Subject: [PATCH 03/15] ENH: Use fused types for NPV calculations --- numpy_financial/_cfinancial.pyx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/numpy_financial/_cfinancial.pyx b/numpy_financial/_cfinancial.pyx index 9b7099d..ce04664 100644 --- a/numpy_financial/_cfinancial.pyx +++ b/numpy_financial/_cfinancial.pyx @@ -1,7 +1,7 @@ -from libc.math cimport NAN import numpy as np cimport numpy as np cimport cython +from cython cimport floating np.import_array() @@ -10,14 +10,14 @@ np.import_array() @cython.wraparound(False) @cython.cdivision(True) @cython.cpow(True) -cpdef double[:, ::1] npv(const double[::1] rates, const double[:, ::1] cashflows): +cpdef floating[:, ::1] npv(const floating[::1] rates, const floating[:, ::1] cashflows): cdef: long rate_len = rates.shape[0] long no_of_cashflows = cashflows.shape[0] long cashflows_len = cashflows.shape[1] long i, j, t - double acc - double[:, ::1] out + floating acc + floating[:, ::1] out out = np.empty(shape=(rate_len, no_of_cashflows)) for i in range(rate_len): From 8acfb90d3f0f16c4584d455bfd0989ef4fb82b14 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 2 Dec 2023 14:42:04 +0800 Subject: [PATCH 04/15] ENH: Optimize cy_npv implementation --- numpy_financial/_cfinancial.pyx | 41 +++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/numpy_financial/_cfinancial.pyx b/numpy_financial/_cfinancial.pyx index ce04664..32ac561 100644 --- a/numpy_financial/_cfinancial.pyx +++ b/numpy_financial/_cfinancial.pyx @@ -1,30 +1,37 @@ -import numpy as np -cimport numpy as np cimport cython from cython cimport floating - -np.import_array() +from cython.parallel cimport prange @cython.boundscheck(False) @cython.wraparound(False) @cython.cdivision(True) @cython.cpow(True) -cpdef floating[:, ::1] npv(const floating[::1] rates, const floating[:, ::1] cashflows): +cdef floating npv_inner_loop(const floating rate, const floating[::1] cashflow) noexcept nogil: cdef: - long rate_len = rates.shape[0] - long no_of_cashflows = cashflows.shape[0] - long cashflows_len = cashflows.shape[1] - long i, j, t + long cashflow_len = cashflow.shape[0] + long t floating acc + + acc = 0.0 + for t in range(cashflow_len): + acc += cashflow[t] / ((1.0 + rate) ** t) + return acc + + +@cython.boundscheck(False) +@cython.wraparound(False) +cpdef void cy_npv( + const floating[::1] rates, + const floating[:, ::1] cashflows, floating[:, ::1] out +) noexcept nogil: + cdef: + long rate_len = rates.shape[0] + long no_of_cashflows = cashflows.shape[0] + long i, j - out = np.empty(shape=(rate_len, no_of_cashflows)) - for i in range(rate_len): - for j in range(no_of_cashflows): - acc = 0.0 - for t in range(cashflows_len): - acc += cashflows[j, t] / ((1.0 + rates[i]) ** t) - out[i, j] = acc - return out + for i in prange(rate_len): + for j in prange(no_of_cashflows): + out[i, j] = npv_inner_loop(rates[i], cashflows[j]) From 3fef3291dab8f1623209c85a04619078afd6f0f9 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 2 Dec 2023 16:25:16 +0800 Subject: [PATCH 05/15] ENH: Fix tests for NPV --- .../{_cfinancial.pyx => _cy_financial.pyx} | 0 numpy_financial/_financial.py | 21 ++++++++++++------- tests/test_financial.py | 14 ++++++------- 3 files changed, 20 insertions(+), 15 deletions(-) rename numpy_financial/{_cfinancial.pyx => _cy_financial.pyx} (100%) diff --git a/numpy_financial/_cfinancial.pyx b/numpy_financial/_cy_financial.pyx similarity index 100% rename from numpy_financial/_cfinancial.pyx rename to numpy_financial/_cy_financial.pyx diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 8bd280e..6c65974 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -15,6 +15,9 @@ import numpy as np +from numpy_financial._cy_financial import cy_npv + + __all__ = ['fv', 'pmt', 'nper', 'ipmt', 'ppmt', 'pv', 'rate', 'irr', 'npv', 'mirr', 'NoRealSolutionError', 'IterationsExceededError'] @@ -892,15 +895,17 @@ def npv(rate, values): 3065.22267 """ + rates = np.atleast_1d(rate) values = np.atleast_2d(values) - timestep_array = np.arange(0, values.shape[1]) - npv = (values / (1 + rate) ** timestep_array).sum(axis=1) - try: - # If size of array is one, return scalar - return npv.item() - except ValueError: - # Otherwise, return entire array - return npv + + if rates.dtype == np.dtype("O") or values.dtype == np.dtype("O"): + raise NotImplementedError + # out = np.empty(shape=(rates.shape[0], values.shape[0]), dtype=Decimal) + # _npv_decimal(rates, values, out) + else: + out = np.empty(shape=(rates.shape[0], values.shape[0])) + cy_npv(rates, values, out) + return out def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False): diff --git a/tests/test_financial.py b/tests/test_financial.py index ad01952..7c54f56 100644 --- a/tests/test_financial.py +++ b/tests/test_financial.py @@ -164,7 +164,7 @@ def test_rate_maximum_iterations_exception_array(self): class TestNpv: def test_npv(self): assert_almost_equal( - npf.npv(0.05, [-15000, 1500, 2500, 3500, 4500, 6000]), + npf.npv(0.05, [-15000.0, 1500.0, 2500.0, 3500.0, 4500.0, 6000.0]), 122.89, 2) def test_npv_decimal(self): @@ -174,13 +174,13 @@ def test_npv_decimal(self): def test_npv_broadcast(self): cashflows = [ - [-15000, 1500, 2500, 3500, 4500, 6000], - [-15000, 1500, 2500, 3500, 4500, 6000], - [-15000, 1500, 2500, 3500, 4500, 6000], - [-15000, 1500, 2500, 3500, 4500, 6000], + [-15000.0, 1500.0, 2500.0, 3500.0, 4500.0, 6000.0], + [-15000.0, 1500.0, 2500.0, 3500.0, 4500.0, 6000.0], + [-15000.0, 1500.0, 2500.0, 3500.0, 4500.0, 6000.0], + [-15000.0, 1500.0, 2500.0, 3500.0, 4500.0, 6000.0], ] expected_npvs = [ - 122.8948549, 122.8948549, 122.8948549, 122.8948549 + [122.8948549, 122.8948549, 122.8948549, 122.8948549] ] actual_npvs = npf.npv(0.05, cashflows) assert_allclose(actual_npvs, expected_npvs) @@ -632,7 +632,7 @@ def test_npv_irr_congruence(self): # a series of cashflows to be zero, so we should have # # NPV(IRR(x), x) = 0. - cashflows = numpy.array([-40000, 5000, 8000, 12000, 30000]) + cashflows = numpy.array([-40000.0, 5000.0, 8000.0, 12000.0, 30000.0]) assert_allclose( npf.npv(npf.irr(cashflows), cashflows), 0, From 1299ed575ef39e3b555e1eefce2b1eac8699fce6 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 2 Dec 2023 16:27:08 +0800 Subject: [PATCH 06/15] ENH: Add path for decimal support --- numpy_financial/_financial.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 6c65974..594ef9e 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -828,6 +828,21 @@ def irr(values, *, guess=None, tol=1e-12, maxiter=100, raise_exceptions=False): return np.nan +def _npv_decimal(rates, cashflows, result): + r"""Version of the ``npv`` function supporting ``decimal.Decimal`` types + + Warnings + -------- + For internal use only, note that this function performs no error checking. + """ + for i in range(rates.shape[0]): + for j in range(cashflows.shape[0]): + acc = Decimal("0.0") + for t in range(cashflows.shape[1]): + acc += cashflows[j, t] / ((Decimal("1.0") + rates[i]) ** t) + result[i, j] = acc + + def npv(rate, values): r"""Return the NPV (Net Present Value) of a cash flow series. @@ -899,9 +914,8 @@ def npv(rate, values): values = np.atleast_2d(values) if rates.dtype == np.dtype("O") or values.dtype == np.dtype("O"): - raise NotImplementedError - # out = np.empty(shape=(rates.shape[0], values.shape[0]), dtype=Decimal) - # _npv_decimal(rates, values, out) + out = np.empty(shape=(rates.shape[0], values.shape[0]), dtype=Decimal) + _npv_decimal(rates, values, out) else: out = np.empty(shape=(rates.shape[0], values.shape[0])) cy_npv(rates, values, out) From cf43c8f10320fccb9e83470b789254e60b4794ae Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 2 Dec 2023 16:32:01 +0800 Subject: [PATCH 07/15] TST: NPV: Add test for broadcasting --- tests/test_financial.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_financial.py b/tests/test_financial.py index 7c54f56..93c4a72 100644 --- a/tests/test_financial.py +++ b/tests/test_financial.py @@ -185,6 +185,27 @@ def test_npv_broadcast(self): actual_npvs = npf.npv(0.05, cashflows) assert_allclose(actual_npvs, expected_npvs) + @pytest.mark.parametrize("dtype", [Decimal, float]) + def test_npv_broadcast_equals_for_loop(self, dtype): + cashflows_str = [ + ["-15000.0", "1500.0", "2500.0", "3500.0", "4500.0", "6000.0"], + ["-25000.0", "1500.0", "2500.0", "3500.0", "4500.0", "6000.0"], + ["-35000.0", "1500.0", "2500.0", "3500.0", "4500.0", "6000.0"], + ["-45000.0", "1500.0", "2500.0", "3500.0", "4500.0", "6000.0"], + ] + rates_str = ["-0.05", "0.00", "0.05", "0.10", "0.15"] + + cashflows = numpy.array([[dtype(x) for x in cf] for cf in cashflows_str]) + rates = numpy.array([dtype(x) for x in rates_str]) + + expected = numpy.empty((len(rates), len(cashflows)), dtype=dtype) + for i, r in enumerate(rates): + for j, cf in enumerate(cashflows): + expected[i, j] = npf.npv(r, cf).item() + + actual = npf.npv(rates, cashflows) + assert_equal(actual, expected) + class TestPmt: def test_pmt_simple(self): From db9cc0a36352c443f3b73493248438b99e20aa51 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 2 Dec 2023 16:33:11 +0800 Subject: [PATCH 08/15] BENCH: NPV: Add benchmark for broadcasting --- benchmarks/benchmarks.py | 42 +++++++++++++++++----------------------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/benchmarks/benchmarks.py b/benchmarks/benchmarks.py index c026937..d26a9c5 100644 --- a/benchmarks/benchmarks.py +++ b/benchmarks/benchmarks.py @@ -1,40 +1,34 @@ -import numpy as np +from decimal import Decimal +import numpy as np import numpy_financial as npf -class Npv1DCashflow: - - param_names = ["cashflow_length"] - params = [ - (1, 10, 100, 1000), - ] - - def __init__(self): - self.cashflows = None - - def setup(self, cashflow_length): - rng = np.random.default_rng(0) - self.cashflows = rng.standard_normal(cashflow_length) - - def time_1d_cashflow(self, cashflow_length): - npf.npv(0.08, self.cashflows) - - class Npv2DCashflows: - param_names = ["n_cashflows", "cashflow_lengths"] + param_names = ["n_cashflows", "cashflow_lengths", "rates_lengths"] params = [ (1, 10, 100, 1000), (1, 10, 100, 1000), + (1, 10, 100, 1000), ] def __init__(self): + self.rates_decimal = None + self.rates = None + self.cashflows_decimal = None self.cashflows = None - def setup(self, n_cashflows, cashflow_lengths): + def setup(self, n_cashflows, cashflow_lengths, rates_lengths): rng = np.random.default_rng(0) - self.cashflows = rng.standard_normal((n_cashflows, cashflow_lengths)) + cf_shape = (n_cashflows, cashflow_lengths) + self.cashflows = rng.standard_normal(cf_shape) + self.rates = rng.standard_normal(rates_lengths) + self.cashflows_decimal = rng.standard_normal(cf_shape, dtype=Decimal) + self.rates_decimal = rng.standard_normal(rates_lengths, dtype=Decimal) + + def time_2d_cashflow(self, n_cashflows, cashflow_lengths, rates_lengths): + npf.npv(self.rates, self.cashflows) - def time_2d_cashflow(self, n_cashflows, cashflow_lengths): - npf.npv(0.08, self.cashflows) + def time_2d_cashflow_decimal(self, n_cashflows, cashflow_lengths, rates_lengths): + npf.npv(self.rates_decimal, self.cashflows_decimal) From adc8145fdfbab2e8694f94b4b27212f9e87d76b4 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 2 Dec 2023 16:49:31 +0800 Subject: [PATCH 09/15] REV: NPV: Only support doubles for fast path --- numpy_financial/_cy_financial.pyx | 12 +++++------- numpy_financial/_financial.py | 4 ++-- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/numpy_financial/_cy_financial.pyx b/numpy_financial/_cy_financial.pyx index 32ac561..6510dc8 100644 --- a/numpy_financial/_cy_financial.pyx +++ b/numpy_financial/_cy_financial.pyx @@ -1,5 +1,4 @@ cimport cython -from cython cimport floating from cython.parallel cimport prange @@ -7,11 +6,11 @@ from cython.parallel cimport prange @cython.wraparound(False) @cython.cdivision(True) @cython.cpow(True) -cdef floating npv_inner_loop(const floating rate, const floating[::1] cashflow) noexcept nogil: +cdef double npv_inner_loop(const double rate, const double[::1] cashflow) noexcept nogil: cdef: long cashflow_len = cashflow.shape[0] long t - floating acc + double acc acc = 0.0 for t in range(cashflow_len): @@ -22,9 +21,9 @@ cdef floating npv_inner_loop(const floating rate, const floating[::1] cashflow) @cython.boundscheck(False) @cython.wraparound(False) cpdef void cy_npv( - const floating[::1] rates, - const floating[:, ::1] cashflows, - floating[:, ::1] out + const double[::1] rates, + const double[:, ::1] cashflows, + double[:, ::1] out ) noexcept nogil: cdef: long rate_len = rates.shape[0] @@ -34,4 +33,3 @@ cpdef void cy_npv( for i in prange(rate_len): for j in prange(no_of_cashflows): out[i, j] = npv_inner_loop(rates[i], cashflows[j]) - diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 594ef9e..81a28ae 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -917,8 +917,8 @@ def npv(rate, values): out = np.empty(shape=(rates.shape[0], values.shape[0]), dtype=Decimal) _npv_decimal(rates, values, out) else: - out = np.empty(shape=(rates.shape[0], values.shape[0])) - cy_npv(rates, values, out) + out = np.empty(shape=(rates.shape[0], values.shape[0]), dtype=np.float64) + cy_npv(rates.astype(np.float64), values.astype(np.float64), out) return out From 2baae26e0c2431fb8322829b6e2adab49dd8c126 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sun, 3 Dec 2023 07:53:37 +0800 Subject: [PATCH 10/15] DOC: NPV: Make names more descriptive and update docs Documentation now shows "broadcasting" behaviour --- benchmarks/benchmarks.py | 4 ++-- numpy_financial/_financial.py | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/benchmarks/benchmarks.py b/benchmarks/benchmarks.py index d26a9c5..a078f11 100644 --- a/benchmarks/benchmarks.py +++ b/benchmarks/benchmarks.py @@ -27,8 +27,8 @@ def setup(self, n_cashflows, cashflow_lengths, rates_lengths): self.cashflows_decimal = rng.standard_normal(cf_shape, dtype=Decimal) self.rates_decimal = rng.standard_normal(rates_lengths, dtype=Decimal) - def time_2d_cashflow(self, n_cashflows, cashflow_lengths, rates_lengths): + def time_broadcast(self, n_cashflows, cashflow_lengths, rates_lengths): npf.npv(self.rates, self.cashflows) - def time_2d_cashflow_decimal(self, n_cashflows, cashflow_lengths, rates_lengths): + def time_broadcast_decimal(self, n_cashflows, cashflow_lengths, rates_lengths): npf.npv(self.rates_decimal, self.cashflows_decimal) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 81a28ae..e5fa16f 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -848,9 +848,9 @@ def npv(rate, values): Parameters ---------- - rate : scalar + rate : scalar or array_like, shape(K, ) The discount rate. - values : array_like, shape(M, ) + values : array_like, shape(M, ) or shape(M, N) The values of the time series of cash flows. The (fixed) time interval between cash flow "events" must be the same as that for which `rate` is given (i.e., if `rate` is per year, then precisely @@ -861,7 +861,7 @@ def npv(rate, values): Returns ------- - out : float + out : shape(K, M) The NPV of the input cash flow series `values` at the discount `rate`. @@ -909,6 +909,17 @@ def npv(rate, values): >>> np.round(npf.npv(rate, cashflows) + initial_cashflow, 5) 3065.22267 + The NPV calculation may be applied to several ``rates`` and ``cashflows`` + simulatneously. This produces an array of shape + ``(len(rates), len(cashflows))``. + + >>> rates = np.array([0.00, 0.05, 0.10]) + >>> cashflows = np.array([[-4_000, 500, 800], [-5_000, 600, 900]]) + >>> npf.npv(rates, cashflows).round(2) + array([[-2700. , -3500. ], + [-2798.19, -3612.24], + [-2884.3 , -3710.74]]) + """ rates = np.atleast_1d(rate) values = np.atleast_2d(values) From 98838257721b65b2766274a989d63604d4bbde36 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sun, 3 Dec 2023 10:19:19 +0800 Subject: [PATCH 11/15] TST: NPV: Provide error messages when given wrong shape --- numpy_financial/_financial.py | 6 ++++++ tests/test_financial.py | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index e5fa16f..2c9be35 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -924,6 +924,12 @@ def npv(rate, values): rates = np.atleast_1d(rate) values = np.atleast_2d(values) + if rates.ndim != 1: + raise ValueError("invalid shape for rates. Rate must be either a scalar or 1d array") + + if values.ndim != 2: + raise ValueError("invalid shape for values. Values must be either a 1d or 2d array") + if rates.dtype == np.dtype("O") or values.dtype == np.dtype("O"): out = np.empty(shape=(rates.shape[0], values.shape[0]), dtype=Decimal) _npv_decimal(rates, values, out) diff --git a/tests/test_financial.py b/tests/test_financial.py index 93c4a72..0603577 100644 --- a/tests/test_financial.py +++ b/tests/test_financial.py @@ -4,6 +4,7 @@ # Don't use 'import numpy as np', to avoid accidentally testing # the versions in numpy instead of numpy_financial. import numpy +import numpy as np import pytest from numpy.testing import ( assert_, @@ -206,6 +207,18 @@ def test_npv_broadcast_equals_for_loop(self, dtype): actual = npf.npv(rates, cashflows) assert_equal(actual, expected) + @pytest.mark.parametrize("rates", ([[1, 2, 3]], np.empty(shape=(1,1,1)))) + def test_invalid_rates_shape(self, rates): + cashflows = [1, 2, 3] + with pytest.raises(ValueError): + npf.npv(rates, cashflows) + + @pytest.mark.parametrize("cashflows", ([[[1, 2, 3]]], np.empty(shape=(1, 1, 1)))) + def test_invalid_cashflows_shape(self, cashflows): + rates = [1, 2, 3] + with pytest.raises(ValueError): + npf.npv(rates, cashflows) + class TestPmt: def test_pmt_simple(self): From 6c0167ba2e821318e2fc817e4287f61398738072 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sun, 3 Dec 2023 11:24:34 +0800 Subject: [PATCH 12/15] ENH: NPV: Return scalars for size 1 arrays This follows the existing behaviour of NPV and the precedence set by ufuncs. --- numpy_financial/_financial.py | 12 ++++++++++-- tests/test_financial.py | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 2c9be35..bfef84b 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -49,6 +49,13 @@ def _convert_when(when): return [_when_to_num[x] for x in when] +def _return_ufunc_like(array): + """Follow the ufunc convention of returning scalars for size 1 arrays""" + if array.size == 1: + return array.item() + return array + + def fv(rate, nper, pmt, pv, when='end'): """Compute the future value. @@ -861,7 +868,7 @@ def npv(rate, values): Returns ------- - out : shape(K, M) + out : scalar or array_like, shape(K, M) The NPV of the input cash flow series `values` at the discount `rate`. @@ -936,7 +943,8 @@ def npv(rate, values): else: out = np.empty(shape=(rates.shape[0], values.shape[0]), dtype=np.float64) cy_npv(rates.astype(np.float64), values.astype(np.float64), out) - return out + + return _return_ufunc_like(out) def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False): diff --git a/tests/test_financial.py b/tests/test_financial.py index 0603577..38f025c 100644 --- a/tests/test_financial.py +++ b/tests/test_financial.py @@ -202,7 +202,7 @@ def test_npv_broadcast_equals_for_loop(self, dtype): expected = numpy.empty((len(rates), len(cashflows)), dtype=dtype) for i, r in enumerate(rates): for j, cf in enumerate(cashflows): - expected[i, j] = npf.npv(r, cf).item() + expected[i, j] = npf.npv(r, cf) actual = npf.npv(rates, cashflows) assert_equal(actual, expected) From 22304a556aa0f4bfa043a02943adf836231c7629 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sun, 3 Dec 2023 11:49:44 +0800 Subject: [PATCH 13/15] REV: NPV: Avoid importing numpy as np This avoids the potential bug of testing the functions in NumPy's namespace, not NumPy-Financial's. --- tests/test_financial.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/test_financial.py b/tests/test_financial.py index 38f025c..868eef2 100644 --- a/tests/test_financial.py +++ b/tests/test_financial.py @@ -4,7 +4,6 @@ # Don't use 'import numpy as np', to avoid accidentally testing # the versions in numpy instead of numpy_financial. import numpy -import numpy as np import pytest from numpy.testing import ( assert_, @@ -207,17 +206,17 @@ def test_npv_broadcast_equals_for_loop(self, dtype): actual = npf.npv(rates, cashflows) assert_equal(actual, expected) - @pytest.mark.parametrize("rates", ([[1, 2, 3]], np.empty(shape=(1,1,1)))) + @pytest.mark.parametrize("rates", ([[1, 2, 3]], numpy.empty(shape=(1,1,1)))) def test_invalid_rates_shape(self, rates): cashflows = [1, 2, 3] with pytest.raises(ValueError): npf.npv(rates, cashflows) - @pytest.mark.parametrize("cashflows", ([[[1, 2, 3]]], np.empty(shape=(1, 1, 1)))) - def test_invalid_cashflows_shape(self, cashflows): + @pytest.mark.parametrize("cf", ([[[1, 2, 3]]], numpy.empty(shape=(1, 1, 1)))) + def test_invalid_cashflows_shape(self, cf): rates = [1, 2, 3] with pytest.raises(ValueError): - npf.npv(rates, cashflows) + npf.npv(rates, cf) class TestPmt: From 8fbc349e870ddca29526ac27796f1662d3986459 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sun, 3 Dec 2023 16:06:37 +0800 Subject: [PATCH 14/15] ENH: NPV: Refactor array creation logic into own function --- numpy_financial/_financial.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index bfef84b..60fe934 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -56,6 +56,23 @@ def _return_ufunc_like(array): return array +def _make_out_array(*arrays): + """Make an ``out`` array + + Output arrays have the following properties: + + * Are of type decimal if any of the input arrays are object arrays + * Have shape of the first dimension of each input array + """ + def _is_object_dtype(array): + return array.dtype == np.dtype("O") + + shape = tuple(array.shape[0] for array in arrays) + if any(_is_object_dtype(array) for array in arrays): + return np.empty(shape, dtype=Decimal) + return np.empty(shape) + + def fv(rate, nper, pmt, pv, when='end'): """Compute the future value. @@ -937,11 +954,10 @@ def npv(rate, values): if values.ndim != 2: raise ValueError("invalid shape for values. Values must be either a 1d or 2d array") - if rates.dtype == np.dtype("O") or values.dtype == np.dtype("O"): - out = np.empty(shape=(rates.shape[0], values.shape[0]), dtype=Decimal) + out = _make_out_array(rates, values) + if out.dtype == np.dtype("O"): _npv_decimal(rates, values, out) else: - out = np.empty(shape=(rates.shape[0], values.shape[0]), dtype=np.float64) cy_npv(rates.astype(np.float64), values.astype(np.float64), out) return _return_ufunc_like(out) From a1f07437ff563894116b53838c912799afd11f24 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Mon, 4 Dec 2023 07:49:34 +0800 Subject: [PATCH 15/15] BENCH: NPV: Benchmark naive for loop --- benchmarks/benchmarks.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/benchmarks/benchmarks.py b/benchmarks/benchmarks.py index a078f11..381174a 100644 --- a/benchmarks/benchmarks.py +++ b/benchmarks/benchmarks.py @@ -4,7 +4,7 @@ import numpy_financial as npf -class Npv2DCashflows: +class Npv2D: param_names = ["n_cashflows", "cashflow_lengths", "rates_lengths"] params = [ @@ -30,5 +30,17 @@ def setup(self, n_cashflows, cashflow_lengths, rates_lengths): def time_broadcast(self, n_cashflows, cashflow_lengths, rates_lengths): npf.npv(self.rates, self.cashflows) + def time_for_loop(self, n_cashflows, cashflow_lengths, rates_lengths): + for i, rate in enumerate(self.rates): + for j, cashflow in enumerate(self.cashflows): + npf.npv(rate, cashflow) + def time_broadcast_decimal(self, n_cashflows, cashflow_lengths, rates_lengths): npf.npv(self.rates_decimal, self.cashflows_decimal) + + def time_for_loop_decimal(self, n_cashflows, cashflow_lengths, rates_lengths): + for i, rate in enumerate(self.rates_decimal): + for j, cashflow in enumerate(self.cashflows_decimal): + npf.npv(rate, cashflow) + +