Skip to content

Commit aaccf23

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

File tree

4 files changed

+203
-26
lines changed

4 files changed

+203
-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

+162-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,168 @@ 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+
# arrays(dtype=scalar_dtypes(), shape=array_shapes(max_dims=1)),
173+
)
174+
175+
when_period_strategy = st.sampled_from(["end", "begin", 1, 0])
176+
93177
def test_pv(self):
94178
assert_almost_equal(npf.pv(0.07, 20, 12000, 0), -127128.17, 2)
95179

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

101259

102260
class TestRate:

0 commit comments

Comments
 (0)