From 34eebab3b78738ed39bab948335c857637ef722e Mon Sep 17 00:00:00 2001 From: Paul Zuradzki Date: Sun, 7 Jan 2024 15:06:43 -0600 Subject: [PATCH 1/4] add PBT and table tests for PV func --- .gitignore | 4 + numpy_financial/_financial.py | 49 +++++----- pyproject.toml | 10 +++ tests/test_financial.py | 165 +++++++++++++++++++++++++++++++++- 4 files changed, 202 insertions(+), 26 deletions(-) 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..3c31478 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -11,7 +11,9 @@ otherwise stated. """ -from decimal import Decimal +import logging +from decimal import Decimal, DivisionByZero, InvalidOperation, Overflow +from typing import Literal, Union import numba as nb import numpy as np @@ -511,33 +513,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 +599,17 @@ 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) as e: + logging.error(f"Error in pv: {e}") + return -0.0 + # 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 2f8f63d..aa4e10e 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, @@ -15,7 +19,6 @@ import numpy_financial as npf - class TestFinancial(object): def test_when(self): # begin @@ -90,13 +93,167 @@ 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_almost_equal(npf.pv(0.07, 20, 12000, 0), -127128.17, 2) def test_pv_decimal(self): - assert_equal(npf.pv(Decimal('0.07'), Decimal('20'), Decimal('12000'), - Decimal('0')), - Decimal('-127128.1709461939327295222005')) + assert_equal( + npf.pv(Decimal("0.07"), Decimal("20"), Decimal("12000"), Decimal("0")), + Decimal("-127128.1709461939327295222005"), + ) + + @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: + npf.pv(rate, nper, pmt, fv, when) + + @pytest.mark.slow + @given( + rate=st.floats(), + nper=st.floats(), + pmt=st.floats(), + fv=st.floats(), + when=when_period_strategy, + ) + @settings(verbosity=Verbosity.verbose) + def test_pv_time_value_of_money( + self, + rate: float, + nper: float, + pmt: float, + fv: float, + when: Literal[0, 1, "begin", "end"], + ) -> None: + """ + Test that the present value is inversely proportional to number of periods, + all other things being equal. + """ + npf.pv(rate, nper, pmt, fv, when) > npf.pv( + rate, float(nper) + float(1), pmt, fv, when + ) + + @pytest.mark.slow + @given( + rate=st.floats(), + nper=st.floats(), + pmt=st.floats(), + fv=st.floats(), + when=when_period_strategy, + ) + @settings(verbosity=Verbosity.verbose) + def test_pv_interest_rate_sensitivity( + self, + rate: float, + nper: float, + pmt: float, + fv: 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. + """ + npf.pv(rate, nper, pmt, fv, when) > npf.pv(rate + 0.1, nper, pmt, fv, when) class TestRate: From 9a3c1b3229ca63fed145d6aa567d47add09982d9 Mon Sep 17 00:00:00 2001 From: Paul Zuradzki Date: Sun, 7 Jan 2024 16:59:07 -0600 Subject: [PATCH 2/4] fix missing assert; update PBT test --- numpy_financial/_financial.py | 12 +++++++++-- tests/test_financial.py | 40 ++++++++--------------------------- 2 files changed, 19 insertions(+), 33 deletions(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 3c31478..e5aaa87 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -599,16 +599,24 @@ def pv( """ when = _convert_when(when) (rate, nper, pmt, fv, when) = map(np.asarray, [rate, nper, pmt, fv, when]) - + 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) as e: + except ( + InvalidOperation, + TypeError, + ValueError, + DivisionByZero, + Overflow, + OverflowError, + ) as e: logging.error(f"Error in pv: {e}") return -0.0 + # Computed with Sage diff --git a/tests/test_financial.py b/tests/test_financial.py index aa4e10e..596ded5 100644 --- a/tests/test_financial.py +++ b/tests/test_financial.py @@ -209,35 +209,10 @@ def test_pv_fuzz( @pytest.mark.slow @given( - rate=st.floats(), - nper=st.floats(), - pmt=st.floats(), - fv=st.floats(), - when=when_period_strategy, - ) - @settings(verbosity=Verbosity.verbose) - def test_pv_time_value_of_money( - self, - rate: float, - nper: float, - pmt: float, - fv: float, - when: Literal[0, 1, "begin", "end"], - ) -> None: - """ - Test that the present value is inversely proportional to number of periods, - all other things being equal. - """ - npf.pv(rate, nper, pmt, fv, when) > npf.pv( - rate, float(nper) + float(1), pmt, fv, when - ) - - @pytest.mark.slow - @given( - rate=st.floats(), - nper=st.floats(), - pmt=st.floats(), - fv=st.floats(), + # 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) @@ -246,14 +221,17 @@ def test_pv_interest_rate_sensitivity( rate: float, nper: float, pmt: float, - fv: 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. """ - npf.pv(rate, nper, pmt, fv, when) > npf.pv(rate + 0.1, nper, pmt, fv, when) + 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: From 3020cea102a5c2c764b8889a4c07a2986b9a5806 Mon Sep 17 00:00:00 2001 From: Paul Zuradzki Date: Sun, 7 Jan 2024 17:20:30 -0600 Subject: [PATCH 3/4] PR feedback; return NaN; remove logging remove logging --- numpy_financial/_financial.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index e5aaa87..d2ede1c 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -11,7 +11,6 @@ otherwise stated. """ -import logging from decimal import Decimal, DivisionByZero, InvalidOperation, Overflow from typing import Literal, Union @@ -613,8 +612,7 @@ def pv( Overflow, OverflowError, ) as e: - logging.error(f"Error in pv: {e}") - return -0.0 + return np.NaN From f97c71340264cec64083ea9cc5601c4c6e499dcb Mon Sep 17 00:00:00 2001 From: Paul Zuradzki Date: Thu, 11 Jan 2024 20:21:48 -0600 Subject: [PATCH 4/4] PR feedback; docstring --- tests/test_financial.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_financial.py b/tests/test_financial.py index 596ded5..9b6b8b7 100644 --- a/tests/test_financial.py +++ b/tests/test_financial.py @@ -205,6 +205,10 @@ def test_pv_fuzz( 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