diff --git a/benchmarks/benchmarks.py b/benchmarks/benchmarks.py index fffe5c6..8d074f3 100644 --- a/benchmarks/benchmarks.py +++ b/benchmarks/benchmarks.py @@ -1,19 +1,8 @@ -from decimal import Decimal - import numpy as np 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"] @@ -24,9 +13,7 @@ class Npv2D: ] def __init__(self): - self.rates_decimal = None self.rates = None - self.cashflows_decimal = None self.cashflows = None def setup(self, n_cashflows, cashflow_lengths, rates_lengths): @@ -34,16 +21,11 @@ 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 = _to_decimal_array_2d(self.cashflows) - self.rates_decimal = _to_decimal_array_1d(self.rates) def time_for_loop(self, n_cashflows, cashflow_lengths, rates_lengths): for rate in self.rates: for cashflow in self.cashflows: npf.npv(rate, cashflow) - def time_for_loop_decimal(self, n_cashflows, cashflow_lengths, rates_lengths): - for rate in self.rates_decimal: - for cashflow in self.cashflows_decimal: - npf.npv(rate, cashflow) - + 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 a24ec52..61d3723 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', @@ -35,6 +36,19 @@ class IterationsExceededError(Exception): """Maximum number of iterations reached.""" +def _get_output_array_shape(*arrays): + return tuple(array.shape[0] for array in arrays) + + +def _ufunc_like(array): + try: + # If size of array is one, return scalar + return array.item() + except ValueError: + # Otherwise, return entire array + return array.squeeze() + + def _convert_when(when): # Test to see if when has already been converted to ndarray # This will happen if one function calls another, for example ppmt @@ -825,6 +839,20 @@ def irr(values, *, guess=None, tol=1e-12, maxiter=100, raise_exceptions=False): return np.nan +@nb.njit +def _npv_native(rates, values, out): + for i in range(rates.shape[0]): + for j in range(values.shape[0]): + acc = 0.0 + for t in range(values.shape[1]): + if rates[i] == -1.0: + acc = np.nan + break + else: + acc += values[j, t] / ((1.0 + rates[i]) ** t) + out[i, j] = acc + + def npv(rate, values): r"""Return the NPV (Net Present Value) of a cash flow series. @@ -892,16 +920,31 @@ 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]]) + """ - 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 + values_inner = np.atleast_2d(values) + rate_inner = np.atleast_1d(rate) + + if rate_inner.ndim != 1: + msg = "invalid shape for rates. Rate must be either a scalar or 1d array" + raise ValueError(msg) + + if values_inner.ndim != 2: + msg = "invalid shape for values. Values must be either a 1d or 2d array" + raise ValueError(msg) + + output_shape = _get_output_array_shape(rate_inner, values_inner) + out = np.empty(output_shape) + _npv_native(rate_inner, values_inner, out) + return _ufunc_like(out) def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False): diff --git a/pyproject.toml b/pyproject.toml index a2527ce..6dedf1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,9 +39,12 @@ packages = [{include = "numpy_financial"}] [tool.poetry.dependencies] python = "^3.10" numpy = "^1.23" +numba = "^0.59.1" [tool.poetry.group.test.dependencies] pytest = "^8.0" +hypothesis = {extras = ["numpy"], version = "^6.99.11"} +pytest-xdist = {extras = ["psutil"], version = "^3.5.0"} [tool.poetry.group.docs.dependencies] diff --git a/tests/test_financial.py b/tests/test_financial.py index df3f389..7d8ecec 100644 --- a/tests/test_financial.py +++ b/tests/test_financial.py @@ -1,10 +1,14 @@ 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 given, settings from numpy.testing import ( assert_, assert_allclose, @@ -15,6 +19,38 @@ 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( + cashflow_array_strategy, + cashflow_list_strategy, +) + +short_scalar_array = npst.arrays( + dtype=real_scalar_dtypes, + shape=npst.array_shapes(min_dims=0, max_dims=1, min_side=0, max_side=5), +) + + def assert_decimal_close(actual, expected, tol=Decimal("1e-7")): # Check if both actual and expected are iterable (like arrays) if hasattr(actual, "__iter__") and hasattr(expected, "__iter__"): @@ -244,11 +280,27 @@ def test_npv(self): rtol=1e-2, ) - def test_npv_decimal(self): - assert_equal( - npf.npv(Decimal("0.05"), [-15000, 1500, 2500, 3500, 4500, 6000]), - Decimal("122.894854950942692161628715"), - ) + @given(rates=short_scalar_array, values=cashflow_array_strategy) + @settings(deadline=None) + def test_fuzz(self, rates, values): + npf.npv(rates, values) + + @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) + + @pytest.mark.parametrize("rate", (-1, -1.0)) + def test_rate_of_negative_one_returns_nan(self, rate): + cashflow = numpy.arange(5) + assert numpy.isnan(npf.npv(rate, cashflow)) class TestPmt: @@ -336,68 +388,6 @@ def test_mirr(self, values, finance_rate, reinvest_rate, expected): else: assert_(numpy.isnan(result)) - @pytest.mark.parametrize("number_type", [Decimal, float]) - @pytest.mark.parametrize( - "args, expected", - [ - ( - { - "values": [ - "-4500", - "-800", - "800", - "800", - "600", - "600", - "800", - "800", - "700", - "3000", - ], - "finance_rate": "0.08", - "reinvest_rate": "0.055", - }, - "0.066597175031553548874239618", - ), - ( - { - "values": ["-120000", "39000", "30000", "21000", "37000", "46000"], - "finance_rate": "0.10", - "reinvest_rate": "0.12", - }, - "0.126094130365905145828421880", - ), - ( - { - "values": ["100", "200", "-50", "300", "-200"], - "finance_rate": "0.05", - "reinvest_rate": "0.06", - }, - "0.342823387842176663647819868", - ), - ( - { - "values": ["39000", "30000", "21000", "37000", "46000"], - "finance_rate": "0.10", - "reinvest_rate": "0.12", - }, - numpy.nan, - ), - ], - ) - def test_mirr_decimal(self, number_type, args, expected): - values = [number_type(v) for v in args["values"]] - result = npf.mirr( - values, - number_type(args["finance_rate"]), - number_type(args["reinvest_rate"]), - ) - - if expected is not numpy.nan: - assert_decimal_close(result, number_type(expected), tol=1e-15) - else: - assert numpy.isnan(result) - 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