-
-
Notifications
You must be signed in to change notification settings - Fork 82
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add fuzz test, property-based test, and table test for PV function #100
base: main
Are you sure you want to change the base?
Changes from 1 commit
34eebab
9a3c1b3
3020cea
f97c713
23c5e98
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -43,6 +43,7 @@ numba = "^0.58.1" | |
|
||
|
||
[tool.poetry.group.test.dependencies] | ||
hypothesis = "^6.92.2" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think adding hypothesis is the right idea here, however I don't have much experience with it. It might take me a while to thoroughly understand this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No problem. Here are some links that I found helpful depending how far down the rabbit hole you want to go with it. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks! I've read the Hypothesis documentation and done some basic property based tests, but not much beyond that. |
||
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\"')", | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. fwiw, I would remove the test classes and rely on function naming convention (ex: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree on principle, however having them in classes makes it easier to run groups of tests for each function. |
||
# 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Providing a "strategy" limits the values that the fuzzer (Hypothesis) will supply. The strategies that we re-use are defined here. |
||
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()) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With
Without
|
||
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) | ||
paulzuradzki marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
@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: | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added type hints here, but I see that they're not conventional in the repo. NP to remove the type hints if preferred for consistency; else, can keep it as optional.
Using
Union
since the package supports Python 3.9. Starting in Python 3.10, we can doint|float|Decimal|np.ndarray
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Type hints are great. Adding type hints is actually part of what I'll be working on once I have the broadcasting done.