Skip to content

Commit 34eebab

Browse files
committed
add PBT and table tests for PV func
1 parent 1656639 commit 34eebab

File tree

4 files changed

+202
-26
lines changed

4 files changed

+202
-26
lines changed

.gitignore

+4
Original file line numberDiff line numberDiff line change
@@ -109,5 +109,9 @@ Thumbs.db
109109
####################
110110
poetry.lock
111111

112+
# hypothesis generated files #
113+
##########################
114+
/.hypothesis
115+
112116
# Things specific to this project #
113117
###################################

numpy_financial/_financial.py

+27-22
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
otherwise stated.
1212
"""
1313

14-
from decimal import Decimal
14+
import logging
15+
from decimal import Decimal, DivisionByZero, InvalidOperation, Overflow
16+
from typing import Literal, Union
1517

1618
import numba as nb
1719
import numpy as np
@@ -511,33 +513,29 @@ def ppmt(rate, per, nper, pv, fv=0, when='end'):
511513
return total - ipmt(rate, per, nper, pv, fv, when)
512514

513515

514-
def pv(rate, nper, pmt, fv=0, when='end'):
516+
def pv(
517+
rate: Union[int, float, Decimal, np.ndarray],
518+
nper: Union[int, float, Decimal, np.ndarray],
519+
pmt: Union[int, float, Decimal, np.ndarray],
520+
fv: Union[int, float, Decimal, np.ndarray] = 0,
521+
when: Literal[0, 1, "begin", "end"] = "end",
522+
):
515523
"""Compute the present value.
516524
517-
Given:
518-
* a future value, `fv`
519-
* an interest `rate` compounded once per period, of which
520-
there are
521-
* `nper` total
522-
* a (fixed) payment, `pmt`, paid either
523-
* at the beginning (`when` = {'begin', 1}) or the end
524-
(`when` = {'end', 0}) of each period
525-
526-
Return:
527-
the value now
528-
529525
Parameters
530526
----------
531527
rate : array_like
532-
Rate of interest (per period)
528+
Required. The interest rate per period.
529+
For example, use 6%/12 for monthly payments at 6% Annual Percentage Rate (APR).
533530
nper : array_like
534-
Number of compounding periods
531+
Required. The total number of payment periods in an investment.
535532
pmt : array_like
536-
Payment
533+
Required. The payment made each period. This does not change throughout the investment.
537534
fv : array_like, optional
538-
Future value
535+
Optional. The future value or cash value attained after the last payment.
539536
when : {{'begin', 1}, {'end', 0}}, {string, int}, optional
540-
When payments are due ('begin' (1) or 'end' (0))
537+
Optional. Indicates if payments are due at the beginning or end of the period
538+
('begin' (1) or 'end' (0)). The default is 'end' (0).
541539
542540
Returns
543541
-------
@@ -601,10 +599,17 @@ def pv(rate, nper, pmt, fv=0, when='end'):
601599
"""
602600
when = _convert_when(when)
603601
(rate, nper, pmt, fv, when) = map(np.asarray, [rate, nper, pmt, fv, when])
604-
temp = (1 + rate) ** nper
605-
fact = np.where(rate == 0, nper, (1 + rate * when) * (temp - 1) / rate)
606-
return -(fv + pmt * fact) / temp
602+
603+
try:
604+
temp = (1 + rate) ** nper
605+
fact = np.where(rate == 0, nper, (1 + rate * when) * (temp - 1) / rate)
606+
return -(fv + pmt * fact) / temp
607+
608+
except (InvalidOperation, TypeError, ValueError, DivisionByZero, Overflow) as e:
609+
logging.error(f"Error in pv: {e}")
610+
return -0.0
607611

612+
608613

609614
# Computed with Sage
610615
# (y + (r + 1)^n*x + p*((r + 1)^n - 1)*(r*w + 1)/r)/(n*(r + 1)^(n - 1)*x -

pyproject.toml

+10
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ numba = "^0.58.1"
4343

4444

4545
[tool.poetry.group.test.dependencies]
46+
hypothesis = "^6.92.2"
4647
pytest = "^7.4"
4748

4849

@@ -60,3 +61,12 @@ ruff = "^0.1.6"
6061
[tool.poetry.group.bench.dependencies]
6162
asv = "^0.6.1"
6263

64+
[tool.pytest.ini_options]
65+
filterwarnings = [
66+
'ignore:.*invalid value encountered.*:RuntimeWarning',
67+
'ignore:.*divide by zero encountered.*:RuntimeWarning',
68+
'ignore:.*overflow encountered.*:RuntimeWarning'
69+
]
70+
markers = [
71+
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
72+
]

tests/test_financial.py

+161-4
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import math
22
from decimal import Decimal
3+
from typing import Literal, Union
4+
5+
import hypothesis.strategies as st
36

47
# Don't use 'import numpy as np', to avoid accidentally testing
58
# the versions in numpy instead of numpy_financial.
69
import numpy
710
import pytest
11+
from hypothesis import Verbosity, given, settings
812
from numpy.testing import (
913
assert_,
1014
assert_allclose,
@@ -15,7 +19,6 @@
1519

1620
import numpy_financial as npf
1721

18-
1922
class TestFinancial(object):
2023
def test_when(self):
2124
# begin
@@ -90,13 +93,167 @@ def test_decimal_with_when(self):
9093

9194

9295
class TestPV:
96+
# Test cases for pytest parametrized example-based tests
97+
test_cases = {
98+
"default_fv_and_when": {
99+
"inputs": {
100+
"rate": 0.05,
101+
"nper": 10,
102+
"pmt": 1000,
103+
},
104+
"expected_result": -7721.73,
105+
},
106+
"specify_fv_and_when": {
107+
"inputs": {
108+
"rate": 0.05,
109+
"nper": 10,
110+
"pmt": 1000,
111+
"fv": 0,
112+
"when": 0,
113+
},
114+
"expected_result": -7721.73,
115+
},
116+
"when_1": {
117+
"inputs": {
118+
"rate": 0.05,
119+
"nper": 10,
120+
"pmt": 1000,
121+
"fv": 0,
122+
"when": 1,
123+
},
124+
"expected_result": -8107.82,
125+
},
126+
"when_1_and_fv_1000": {
127+
"inputs": {
128+
"rate": 0.05,
129+
"nper": 10,
130+
"pmt": 1000,
131+
"fv": 1000,
132+
"when": 1,
133+
},
134+
"expected_result": -8721.73,
135+
},
136+
"fv>0": {
137+
"inputs": {
138+
"rate": 0.05,
139+
"nper": 10,
140+
"pmt": 1000,
141+
"fv": 1000,
142+
},
143+
"expected_result": -8335.65,
144+
},
145+
"negative_rate": {
146+
"inputs": {
147+
"rate": -0.05,
148+
"nper": 10,
149+
"pmt": 1000,
150+
"fv": 0,
151+
},
152+
"expected_result": -13403.65,
153+
},
154+
"rates_as_array": {
155+
"inputs": {
156+
"rate": numpy.array([0.010, 0.015, 0.020, 0.025, 0.030, 0.035]),
157+
"nper": 10,
158+
"pmt": 1000,
159+
"fv": 0,
160+
},
161+
"expected_result": numpy.array(
162+
[-9471.30, -9222.18, -8982.59, -8752.06, -8530.20, -8316.61]
163+
),
164+
},
165+
}
166+
167+
# Randomized input strategies for fuzz tests & property-based tests
168+
numeric_strategy = st.one_of(
169+
st.decimals(),
170+
st.floats(),
171+
st.integers(),
172+
)
173+
174+
when_period_strategy = st.sampled_from(["end", "begin", 1, 0])
175+
93176
def test_pv(self):
94177
assert_almost_equal(npf.pv(0.07, 20, 12000, 0), -127128.17, 2)
95178

96179
def test_pv_decimal(self):
97-
assert_equal(npf.pv(Decimal('0.07'), Decimal('20'), Decimal('12000'),
98-
Decimal('0')),
99-
Decimal('-127128.1709461939327295222005'))
180+
assert_equal(
181+
npf.pv(Decimal("0.07"), Decimal("20"), Decimal("12000"), Decimal("0")),
182+
Decimal("-127128.1709461939327295222005"),
183+
)
184+
185+
@pytest.mark.parametrize("test_case", test_cases.values(), ids=test_cases.keys())
186+
def test_pv_examples(self, test_case):
187+
inputs, expected_result = test_case["inputs"], test_case["expected_result"]
188+
result = npf.pv(**inputs)
189+
assert result == pytest.approx(expected_result)
190+
191+
@pytest.mark.slow
192+
@given(
193+
rate=numeric_strategy,
194+
nper=numeric_strategy,
195+
pmt=numeric_strategy,
196+
fv=numeric_strategy,
197+
when=when_period_strategy,
198+
)
199+
@settings(verbosity=Verbosity.verbose)
200+
def test_pv_fuzz(
201+
self,
202+
rate: Union[int, float, Decimal, numpy.ndarray],
203+
nper: Union[int, float, Decimal, numpy.ndarray],
204+
pmt: Union[int, float, Decimal, numpy.ndarray],
205+
fv: Union[int, float, Decimal, numpy.ndarray],
206+
when: Literal[0, 1, "begin", "end"],
207+
) -> None:
208+
npf.pv(rate, nper, pmt, fv, when)
209+
210+
@pytest.mark.slow
211+
@given(
212+
rate=st.floats(),
213+
nper=st.floats(),
214+
pmt=st.floats(),
215+
fv=st.floats(),
216+
when=when_period_strategy,
217+
)
218+
@settings(verbosity=Verbosity.verbose)
219+
def test_pv_time_value_of_money(
220+
self,
221+
rate: float,
222+
nper: float,
223+
pmt: float,
224+
fv: float,
225+
when: Literal[0, 1, "begin", "end"],
226+
) -> None:
227+
"""
228+
Test that the present value is inversely proportional to number of periods,
229+
all other things being equal.
230+
"""
231+
npf.pv(rate, nper, pmt, fv, when) > npf.pv(
232+
rate, float(nper) + float(1), pmt, fv, when
233+
)
234+
235+
@pytest.mark.slow
236+
@given(
237+
rate=st.floats(),
238+
nper=st.floats(),
239+
pmt=st.floats(),
240+
fv=st.floats(),
241+
when=when_period_strategy,
242+
)
243+
@settings(verbosity=Verbosity.verbose)
244+
def test_pv_interest_rate_sensitivity(
245+
self,
246+
rate: float,
247+
nper: float,
248+
pmt: float,
249+
fv: float,
250+
when: Literal[0, 1, "begin", "end"],
251+
) -> None:
252+
"""
253+
Test that the present value is inversely proportional to the interest rate,
254+
all other things being equal.
255+
"""
256+
npf.pv(rate, nper, pmt, fv, when) > npf.pv(rate + 0.1, nper, pmt, fv, when)
100257

101258

102259
class TestRate:

0 commit comments

Comments
 (0)