From a518eaca00e6c35d01dc15fca17326a37777a7e0 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Wed, 6 Dec 2023 16:21:33 +0800 Subject: [PATCH 01/15] BENCH: NPV: Benchmark 2d broadcasting --- benchmarks/benchmarks.py | 49 ++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/benchmarks/benchmarks.py b/benchmarks/benchmarks.py index c026937..23e5dc1 100644 --- a/benchmarks/benchmarks.py +++ b/benchmarks/benchmarks.py @@ -1,40 +1,45 @@ -import numpy as np +from decimal import Decimal +import numpy as np import numpy_financial as npf -class Npv1DCashflow: +class Npv2D: - param_names = ["cashflow_length"] + 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, cashflow_length): + def setup(self, n_cashflows, cashflow_lengths, rates_lengths): rng = np.random.default_rng(0) - self.cashflows = rng.standard_normal(cashflow_length) + 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).asdtype(Decimal) + self.rates_decimal = rng.standard_normal(rates_lengths).asdtype(Decimal) - def time_1d_cashflow(self, cashflow_length): - npf.npv(0.08, self.cashflows) + 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) -class Npv2DCashflows: + def time_broadcast_decimal(self, n_cashflows, cashflow_lengths, rates_lengths): + npf.npv(self.rates_decimal, self.cashflows_decimal) - param_names = ["n_cashflows", "cashflow_lengths"] - params = [ - (1, 10, 100, 1000), - (1, 10, 100, 1000), - ] - - def __init__(self): - self.cashflows = None - - def setup(self, n_cashflows, cashflow_lengths): - rng = np.random.default_rng(0) - self.cashflows = rng.standard_normal((n_cashflows, cashflow_lengths)) + 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) - def time_2d_cashflow(self, n_cashflows, cashflow_lengths): - npf.npv(0.08, self.cashflows) From 3ea3a6e75d0eb7d3f868ef8cd3128eb54c5e5c05 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Wed, 6 Dec 2023 16:23:19 +0800 Subject: [PATCH 02/15] TST: NPV: Add tests for NPV --- tests/test_financial.py | 45 +++++++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/tests/test_financial.py b/tests/test_financial.py index ad01952..2f8f63d 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,17 +174,50 @@ 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) + @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) + + actual = npf.npv(rates, cashflows) + assert_equal(actual, expected) + + @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("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, cf) + class TestPmt: def test_pmt_simple(self): From f1c2e54ece5b4b6b538005032580930b6309606b Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Wed, 6 Dec 2023 16:37:08 +0800 Subject: [PATCH 03/15] ENH: NPV: Support "gufunc" like behaviour --- benchmarks/benchmarks.py | 4 +-- numpy_financial/_financial.py | 63 ++++++++++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/benchmarks/benchmarks.py b/benchmarks/benchmarks.py index 23e5dc1..3fd3523 100644 --- a/benchmarks/benchmarks.py +++ b/benchmarks/benchmarks.py @@ -24,8 +24,8 @@ def setup(self, n_cashflows, cashflow_lengths, rates_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).asdtype(Decimal) - self.rates_decimal = rng.standard_normal(rates_lengths).asdtype(Decimal) + self.cashflows_decimal = rng.standard_normal(cf_shape).astype(Decimal) + self.rates_decimal = rng.standard_normal(rates_lengths).astype(Decimal) def time_broadcast(self, n_cashflows, cashflow_lengths, rates_lengths): npf.npv(self.rates, self.cashflows) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 50728d8..6639a12 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -46,6 +46,23 @@ def _convert_when(when): return [_when_to_num[x] for x in when] +def _return_ufunc_like(array): + try: + # If size of array is one, return scalar + return array.item() + except ValueError: + # Otherwise, return entire array + return array + + +def _is_object_array(array): + return array.dtype == np.dtype("O") + + +def _use_decimal_dtype(*arrays): + return any(_is_object_array(array) for array in arrays) + + def fv(rate, nper, pmt, pv, when='end'): """Compute the future value. @@ -825,6 +842,15 @@ def irr(values, *, guess=None, tol=1e-12, maxiter=100, raise_exceptions=False): return np.nan +def _to_decimal_array_1d(array): + return np.array([Decimal(x) for x in array.tolist()]) + + +def _to_decimal_array_2d(array): + l = [Decimal(x) for row in array.tolist() for x in row] + return np.array(l).reshape(array.shape) + + def npv(rate, values): r"""Return the NPV (Net Present Value) of a cash flow series. @@ -892,15 +918,36 @@ 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.ndim != 1: + msg = "invalid shape for rates. Rate must be either a scalar or 1d array" + raise ValueError(msg) + + if values.ndim != 2: + msg = "invalid shape for values. Values must be either a 1d or 2d array" + raise ValueError(msg) + + dtype = Decimal if _use_decimal_dtype(rates, values) else np.float64 + + if dtype == Decimal: + rates = _to_decimal_array_1d(rates) + values = _to_decimal_array_2d(values) + zero = dtype("0.0") + one = dtype("1.0") + + shape = tuple(array.shape[0] for array in (rates, values)) + out = np.empty(shape=shape, dtype=dtype) + + for i in range(rates.shape[0]): + for j in range(values.shape[0]): + acc = zero + for t in range(values.shape[1]): + acc += values[j, t] / ((one + rates[i]) ** t) + out[i, j] = acc + + return _return_ufunc_like(out) def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False): From 6561c6d37f935fcd530e3988cd7c324d4bb82d11 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 9 Dec 2023 11:53:15 +0800 Subject: [PATCH 04/15] ENH: NPV: Move private array conversion functions to top of file --- numpy_financial/_financial.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 6639a12..0bd50ee 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -63,6 +63,15 @@ def _use_decimal_dtype(*arrays): return any(_is_object_array(array) for array in arrays) +def _to_decimal_array_1d(array): + return np.array([Decimal(x) for x in array.tolist()]) + + +def _to_decimal_array_2d(array): + decimals = [Decimal(x) for row in array.tolist() for x in row] + return np.array(decimals).reshape(array.shape) + + def fv(rate, nper, pmt, pv, when='end'): """Compute the future value. @@ -842,15 +851,6 @@ def irr(values, *, guess=None, tol=1e-12, maxiter=100, raise_exceptions=False): return np.nan -def _to_decimal_array_1d(array): - return np.array([Decimal(x) for x in array.tolist()]) - - -def _to_decimal_array_2d(array): - l = [Decimal(x) for row in array.tolist() for x in row] - return np.array(l).reshape(array.shape) - - def npv(rate, values): r"""Return the NPV (Net Present Value) of a cash flow series. From 553647c0e78e51cdb2abd03132585c4d8ddc208e Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 9 Dec 2023 12:02:26 +0800 Subject: [PATCH 05/15] BENCH: NPV: Reduce benchmark size This was taking too long and needs to be reduced --- benchmarks/benchmarks.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/benchmarks/benchmarks.py b/benchmarks/benchmarks.py index 3fd3523..166ec70 100644 --- a/benchmarks/benchmarks.py +++ b/benchmarks/benchmarks.py @@ -8,9 +8,9 @@ class Npv2D: param_names = ["n_cashflows", "cashflow_lengths", "rates_lengths"] params = [ - (1, 10, 100, 1000), - (1, 10, 100, 1000), - (1, 10, 100, 1000), + (1, 10, 100), + (1, 10, 100), + (1, 10, 100), ] def __init__(self): From 2d0878382c8d6e0c72040b8920e3446f38fa11bd Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 9 Dec 2023 12:04:58 +0800 Subject: [PATCH 06/15] ENH: NPV: Make hot path for native types This commit introduces a native hot path using numba. This leads to an ~50x speed up on the `time_broadcast` benchmark with 100x100x100 dimension --- numpy_financial/_financial.py | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 0bd50ee..a849884 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -13,6 +13,7 @@ from decimal import Decimal +import numba as nb import numpy as np __all__ = ['fv', 'pmt', 'nper', 'ipmt', 'ppmt', 'pv', 'rate', @@ -851,6 +852,26 @@ def irr(values, *, guess=None, tol=1e-12, maxiter=100, raise_exceptions=False): return np.nan +@nb.njit +def _npv_native(rates, values, out, zero, one): + for i in range(rates.shape[0]): + for j in range(values.shape[0]): + acc = zero + for t in range(values.shape[1]): + acc += values[j, t] / ((one + rates[i]) ** t) + out[i, j] = acc + + +@nb.jit(forceobj=True) +def _npv_decimal(rates, values, out, zero, one): + for i in range(rates.shape[0]): + for j in range(values.shape[0]): + acc = zero + for t in range(values.shape[1]): + acc += values[j, t] / ((one + rates[i]) ** t) + out[i, j] = acc + + def npv(rate, values): r"""Return the NPV (Net Present Value) of a cash flow series. @@ -940,12 +961,10 @@ def npv(rate, values): shape = tuple(array.shape[0] for array in (rates, values)) out = np.empty(shape=shape, dtype=dtype) - for i in range(rates.shape[0]): - for j in range(values.shape[0]): - acc = zero - for t in range(values.shape[1]): - acc += values[j, t] / ((one + rates[i]) ** t) - out[i, j] = acc + if dtype == Decimal: + _npv_decimal(rates, values, out, zero, one) + else: + _npv_native(rates, values, out, zero, one) return _return_ufunc_like(out) From 8c723d25770fce597175b9d34acffde4560eb521 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 9 Dec 2023 12:13:34 +0800 Subject: [PATCH 07/15] ENH: NPV: Parallelize native hot-path This leads to the following improvements on the `time_broadcast` benchmark with dimension 100x100x100: * raw Python: 197 ms * numba only: 4.08 ms * numba + prange 0.3 ms --- numpy_financial/_financial.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index a849884..1e391a8 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -852,10 +852,10 @@ def irr(values, *, guess=None, tol=1e-12, maxiter=100, raise_exceptions=False): return np.nan -@nb.njit +@nb.njit(parallel=True) def _npv_native(rates, values, out, zero, one): - for i in range(rates.shape[0]): - for j in range(values.shape[0]): + for i in nb.prange(rates.shape[0]): + for j in nb.prange(values.shape[0]): acc = zero for t in range(values.shape[1]): acc += values[j, t] / ((one + rates[i]) ** t) From f58c8df31107e9694c749eb9b318d40191447fb5 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 9 Dec 2023 13:59:13 +0800 Subject: [PATCH 08/15] DOC: NPV: Document new "broadcasting" behaviour --- numpy_financial/_financial.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 1e391a8..dfa4850 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -862,6 +862,7 @@ def _npv_native(rates, values, out, zero, one): out[i, j] = acc +# We require ``forceobj=True`` here to support decimal.Decimal types @nb.jit(forceobj=True) def _npv_decimal(rates, values, out, zero, one): for i in range(rates.shape[0]): @@ -938,6 +939,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 = [0.00, 0.05, 0.10] + >>> cashflows = [[-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 e99ed937dc69072cf797fa7f655bc639c6f6ee93 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 9 Dec 2023 14:09:51 +0800 Subject: [PATCH 09/15] REV: Remove Python 3.12 dependency The newest released numba version is 0.58.1. This version does not support Python 3.12. Since we are using it we also cannot support Python 3.12. This should be added once numba version 0.59 is released. --- .github/workflows/pythonpackage.yml | 2 +- pyproject.toml | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 5d32bea..ff67b07 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/pyproject.toml b/pyproject.toml index 62b758e..5d2ebb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,6 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3 :: Only", "Topic :: Software Development", "Topic :: Office/Business :: Financial :: Accounting", @@ -38,7 +37,7 @@ classifiers = [ packages = [{include = "numpy_financial"}] [tool.poetry.dependencies] -python = "^3.9" +python = "^3.9,<3.12" numpy = "^1.23" From 4d329accf472089e02e6f2a7daf1597c951e5886 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 9 Dec 2023 14:21:08 +0800 Subject: [PATCH 10/15] MAINT: Make linting happy and add numba to poetry --- benchmarks/benchmarks.py | 9 +++++---- pyproject.toml | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/benchmarks/benchmarks.py b/benchmarks/benchmarks.py index 166ec70..a76856a 100644 --- a/benchmarks/benchmarks.py +++ b/benchmarks/benchmarks.py @@ -1,6 +1,7 @@ from decimal import Decimal import numpy as np + import numpy_financial as npf @@ -31,15 +32,15 @@ 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): + for rate in self.rates: + for cashflow in 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): + for rate in self.rates_decimal: + for cashflow in self.cashflows_decimal: npf.npv(rate, cashflow) diff --git a/pyproject.toml b/pyproject.toml index 5d2ebb5..8fe3076 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ packages = [{include = "numpy_financial"}] [tool.poetry.dependencies] python = "^3.9,<3.12" numpy = "^1.23" +numba = "^0.58.1" [tool.poetry.group.test.dependencies] From b126521346f54312702f3de8918c019bf18f4953 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 9 Dec 2023 14:57:47 +0800 Subject: [PATCH 11/15] MAINT: Refactor output array shape creation into own function --- numpy_financial/_financial.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index dfa4850..7a23cac 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -73,6 +73,10 @@ def _to_decimal_array_2d(array): return np.array(decimals).reshape(array.shape) +def _get_output_array_shape(*arrays): + return tuple(array.shape[0] for array in arrays) + + def fv(rate, nper, pmt, pv, when='end'): """Compute the future value. @@ -970,7 +974,7 @@ def npv(rate, values): zero = dtype("0.0") one = dtype("1.0") - shape = tuple(array.shape[0] for array in (rates, values)) + shape = _get_output_array_shape(rates, values) out = np.empty(shape=shape, dtype=dtype) if dtype == Decimal: From 04af33f04458a9ceb0a2630038f92656bbf7d5f2 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 9 Dec 2023 15:00:28 +0800 Subject: [PATCH 12/15] DOC: NPV: Update documentation to support array arguments --- numpy_financial/_financial.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 7a23cac..734e7c3 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -882,9 +882,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 @@ -895,9 +895,10 @@ def npv(rate, values): Returns ------- - out : float + out : float or array shape(K, M) The NPV of the input cash flow series `values` at the discount - `rate`. + `rate`. `out` follows the ufunc convention of returning scalars + instead of single element arrays. Warnings -------- From b8592cfa1d16fb838701731e47749eaa1936ee87 Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 9 Dec 2023 15:15:23 +0800 Subject: [PATCH 13/15] MAINT: NPV: Remove typed constants These were originally added when the code was written as one function without numba. As numba is now being used, and the functions are therefore seperated. This is no longer required and makes the code more complex. --- numpy_financial/_financial.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 734e7c3..5bcc693 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -857,23 +857,23 @@ def irr(values, *, guess=None, tol=1e-12, maxiter=100, raise_exceptions=False): @nb.njit(parallel=True) -def _npv_native(rates, values, out, zero, one): +def _npv_native(rates, values, out): for i in nb.prange(rates.shape[0]): for j in nb.prange(values.shape[0]): - acc = zero + acc = 0.0 for t in range(values.shape[1]): - acc += values[j, t] / ((one + rates[i]) ** t) + acc += values[j, t] / ((1.0 + rates[i]) ** t) out[i, j] = acc # We require ``forceobj=True`` here to support decimal.Decimal types @nb.jit(forceobj=True) -def _npv_decimal(rates, values, out, zero, one): +def _npv_decimal(rates, values, out): for i in range(rates.shape[0]): for j in range(values.shape[0]): - acc = zero + acc = Decimal("0.0") for t in range(values.shape[1]): - acc += values[j, t] / ((one + rates[i]) ** t) + acc += values[j, t] / ((Decimal("1.0") + rates[i]) ** t) out[i, j] = acc @@ -972,16 +972,14 @@ def npv(rate, values): if dtype == Decimal: rates = _to_decimal_array_1d(rates) values = _to_decimal_array_2d(values) - zero = dtype("0.0") - one = dtype("1.0") shape = _get_output_array_shape(rates, values) out = np.empty(shape=shape, dtype=dtype) if dtype == Decimal: - _npv_decimal(rates, values, out, zero, one) + _npv_decimal(rates, values, out) else: - _npv_native(rates, values, out, zero, one) + _npv_native(rates, values, out) return _return_ufunc_like(out) From 4afacd47b814480ee28bb76174dab9679b1b8e6f Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sat, 9 Dec 2023 15:34:22 +0800 Subject: [PATCH 14/15] BENCH: NPV: Actually create decimal arrays in benchmarks This fixes a bug where we weren't creating decimal arrays in the benchmark. Instead of creating an array and setting the dtype we manually make a list of Decimals and create an array from that list. --- benchmarks/benchmarks.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/benchmarks/benchmarks.py b/benchmarks/benchmarks.py index a76856a..23250cd 100644 --- a/benchmarks/benchmarks.py +++ b/benchmarks/benchmarks.py @@ -5,6 +5,15 @@ import numpy_financial as npf +def _to_decimal_array_1d(array): + return np.array([Decimal(x) for x in array.tolist()]) + + +def _to_decimal_array_2d(array): + decimals = [Decimal(x) for row in array.tolist() for x in row] + return np.array(decimals).reshape(array.shape) + + class Npv2D: param_names = ["n_cashflows", "cashflow_lengths", "rates_lengths"] @@ -25,8 +34,8 @@ def setup(self, n_cashflows, cashflow_lengths, rates_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).astype(Decimal) - self.rates_decimal = rng.standard_normal(rates_lengths).astype(Decimal) + self.cashflows_decimal = _to_decimal_array_2d(self.cashflows) + self.rates_decimal = _to_decimal_array_1d(self.rates) def time_broadcast(self, n_cashflows, cashflow_lengths, rates_lengths): npf.npv(self.rates, self.cashflows) From 0db86eced83531ee4b906be8792eac3fcda05b9a Mon Sep 17 00:00:00 2001 From: Kai Striega Date: Sun, 10 Dec 2023 07:41:14 +0800 Subject: [PATCH 15/15] DOC: NPV: Document with decimal.Decimal --- numpy_financial/_financial.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 5bcc693..26e31fe 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -955,6 +955,20 @@ def npv(rate, values): [-2798.19, -3612.24], [-2884.3 , -3710.74]]) + The NPV calculation also supports `decimal.Decimal` types, for example + if using Decimal ``rates``: + + >>> rates = [Decimal("0.00"), Decimal("0.05"), Decimal("0.10")] + >>> cashflows = [[-4_000, 500, 800], [-5_000, 600, 900]] + >>> npf.npv(rates, cashflows) + array([[Decimal('-2700.0'), Decimal('-3500.0')], + [Decimal('-2798.185941043083900226757370'), + Decimal('-3612.244897959183673469387756')], + [Decimal('-2884.297520661157024793388430'), + Decimal('-3710.743801652892561983471074')]], dtype=object) + + This also works for Decimal cashflows. + """ rates = np.atleast_1d(rate) values = np.atleast_2d(values)