diff --git a/.gitignore b/.gitignore index e03204a..6744e9d 100644 --- a/.gitignore +++ b/.gitignore @@ -109,5 +109,9 @@ Thumbs.db #################### poetry.lock +# hypothesis generated files # +########################## +/.hypothesis + # Things specific to this project # ################################### diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 033495d..d2ede1c 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -11,7 +11,8 @@ otherwise stated. """ -from decimal import Decimal +from decimal import Decimal, DivisionByZero, InvalidOperation, Overflow +from typing import Literal, Union import numba as nb import numpy as np @@ -511,33 +512,29 @@ def ppmt(rate, per, nper, pv, fv=0, when='end'): return total - ipmt(rate, per, nper, pv, fv, when) -def pv(rate, nper, pmt, fv=0, when='end'): +def pv( + rate: Union[int, float, Decimal, np.ndarray], + nper: Union[int, float, Decimal, np.ndarray], + pmt: Union[int, float, Decimal, np.ndarray], + fv: Union[int, float, Decimal, np.ndarray] = 0, + when: Literal[0, 1, "begin", "end"] = "end", +): """Compute the present value. - Given: - * a future value, `fv` - * an interest `rate` compounded once per period, of which - there are - * `nper` total - * a (fixed) payment, `pmt`, paid either - * at the beginning (`when` = {'begin', 1}) or the end - (`when` = {'end', 0}) of each period - - Return: - the value now - Parameters ---------- rate : array_like - Rate of interest (per period) + Required. The interest rate per period. + For example, use 6%/12 for monthly payments at 6% Annual Percentage Rate (APR). nper : array_like - Number of compounding periods + Required. The total number of payment periods in an investment. pmt : array_like - Payment + Required. The payment made each period. This does not change throughout the investment. fv : array_like, optional - Future value + Optional. The future value or cash value attained after the last payment. when : {{'begin', 1}, {'end', 0}}, {string, int}, optional - When payments are due ('begin' (1) or 'end' (0)) + Optional. Indicates if payments are due at the beginning or end of the period + ('begin' (1) or 'end' (0)). The default is 'end' (0). Returns ------- @@ -601,10 +598,24 @@ def pv(rate, nper, pmt, fv=0, when='end'): """ when = _convert_when(when) (rate, nper, pmt, fv, when) = map(np.asarray, [rate, nper, pmt, fv, when]) - temp = (1 + rate) ** nper - fact = np.where(rate == 0, nper, (1 + rate * when) * (temp - 1) / rate) - return -(fv + pmt * fact) / temp + try: + temp = (1 + rate) ** nper + fact = np.where(rate == 0, nper, (1 + rate * when) * (temp - 1) / rate) + return -(fv + pmt * fact) / temp + + except ( + InvalidOperation, + TypeError, + ValueError, + DivisionByZero, + Overflow, + OverflowError, + ) as e: + return np.NaN + + + # Computed with Sage # (y + (r + 1)^n*x + p*((r + 1)^n - 1)*(r*w + 1)/r)/(n*(r + 1)^(n - 1)*x - diff --git a/pyproject.toml b/pyproject.toml index 8fe3076..f75067d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ numba = "^0.58.1" [tool.poetry.group.test.dependencies] +hypothesis = "^6.92.2" pytest = "^7.4" @@ -60,3 +61,12 @@ ruff = "^0.1.6" [tool.poetry.group.bench.dependencies] asv = "^0.6.1" +[tool.pytest.ini_options] +filterwarnings = [ + 'ignore:.*invalid value encountered.*:RuntimeWarning', + 'ignore:.*divide by zero encountered.*:RuntimeWarning', + 'ignore:.*overflow encountered.*:RuntimeWarning' +] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", +] diff --git a/tests/test_financial.py b/tests/test_financial.py index bb98724..dfc64a2 100644 --- a/tests/test_financial.py +++ b/tests/test_financial.py @@ -1,10 +1,14 @@ import math from decimal import Decimal +from typing import Literal, Union + +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 Verbosity, given, settings from numpy.testing import ( assert_, assert_allclose, @@ -151,9 +155,90 @@ def test_decimal_with_when(self): class TestPV: + # Test cases for pytest parametrized example-based tests + test_cases = { + "default_fv_and_when": { + "inputs": { + "rate": 0.05, + "nper": 10, + "pmt": 1000, + }, + "expected_result": -7721.73, + }, + "specify_fv_and_when": { + "inputs": { + "rate": 0.05, + "nper": 10, + "pmt": 1000, + "fv": 0, + "when": 0, + }, + "expected_result": -7721.73, + }, + "when_1": { + "inputs": { + "rate": 0.05, + "nper": 10, + "pmt": 1000, + "fv": 0, + "when": 1, + }, + "expected_result": -8107.82, + }, + "when_1_and_fv_1000": { + "inputs": { + "rate": 0.05, + "nper": 10, + "pmt": 1000, + "fv": 1000, + "when": 1, + }, + "expected_result": -8721.73, + }, + "fv>0": { + "inputs": { + "rate": 0.05, + "nper": 10, + "pmt": 1000, + "fv": 1000, + }, + "expected_result": -8335.65, + }, + "negative_rate": { + "inputs": { + "rate": -0.05, + "nper": 10, + "pmt": 1000, + "fv": 0, + }, + "expected_result": -13403.65, + }, + "rates_as_array": { + "inputs": { + "rate": numpy.array([0.010, 0.015, 0.020, 0.025, 0.030, 0.035]), + "nper": 10, + "pmt": 1000, + "fv": 0, + }, + "expected_result": numpy.array( + [-9471.30, -9222.18, -8982.59, -8752.06, -8530.20, -8316.61] + ), + }, + } + + # Randomized input strategies for fuzz tests & property-based tests + numeric_strategy = st.one_of( + st.decimals(), + st.floats(), + st.integers(), + ) + + when_period_strategy = st.sampled_from(["end", "begin", 1, 0]) + def test_pv(self): assert_allclose(npf.pv(0.07, 20, 12000, 0), -127128.17, rtol=1e-2) + def test_pv_decimal(self): assert_equal( npf.pv(Decimal("0.07"), Decimal("20"), Decimal("12000"), Decimal("0")), @@ -161,6 +246,63 @@ def test_pv_decimal(self): ) + @pytest.mark.parametrize("test_case", test_cases.values(), ids=test_cases.keys()) + def test_pv_examples(self, test_case): + inputs, expected_result = test_case["inputs"], test_case["expected_result"] + result = npf.pv(**inputs) + assert result == pytest.approx(expected_result) + + @pytest.mark.slow + @given( + rate=numeric_strategy, + nper=numeric_strategy, + pmt=numeric_strategy, + fv=numeric_strategy, + when=when_period_strategy, + ) + @settings(verbosity=Verbosity.verbose) + def test_pv_fuzz( + self, + rate: Union[int, float, Decimal, numpy.ndarray], + nper: Union[int, float, Decimal, numpy.ndarray], + pmt: Union[int, float, Decimal, numpy.ndarray], + fv: Union[int, float, Decimal, numpy.ndarray], + when: Literal[0, 1, "begin", "end"], + ) -> None: + """ + This fuzz test intentionally does not have any assertions. + We're deliberately feeding the function with extreme values to identify potential failures. + """ + npf.pv(rate, nper, pmt, fv, when) + + + @pytest.mark.slow + @given( + # Intentionally restricting the range of the rate to avoid overflow errors or NaNs vs Infs checks + rate=st.floats(min_value=0.01, max_value=1000, allow_infinity=False), + nper=st.floats(min_value=1, max_value=100, allow_infinity=False), + pmt=st.floats(min_value=-1000, max_value=-0.01, allow_infinity=False), + when=when_period_strategy, + ) + @settings(verbosity=Verbosity.verbose) + def test_pv_interest_rate_sensitivity( + self, + rate: float, + nper: float, + pmt: float, + when: Literal[0, 1, "begin", "end"], + ) -> None: + """ + Test that the present value is inversely proportional to the interest rate, + all other things being equal. + """ + result = npf.pv(rate=rate, nper=nper, pmt=pmt, when=when) + expected = float(npf.pv(rate=rate + 0.1, nper=nper, pmt=pmt, when=when)) + + # As interest rate increases, present value decreases + assert round(result, 4) >= round(expected, 4) + + class TestRate: def test_rate(self): assert_allclose(npf.rate(10, 0, -3500, 10000), 0.1107, rtol=1e-4)