Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions numpy_financial/_financial.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,15 @@ def _ufunc_like(array: np.generic | npt.NDArray[Any]) -> Any:
# Otherwise, return entire array
return array.squeeze()


def _float_power_base(base: Any) -> Any:
# Integer ``**`` wraps around on overflow and rejects negative
# exponents, so evaluate integer and boolean bases in float64.
# Other bases, including Decimal and object arrays, are unchanged.
if isinstance(base, (np.ndarray, np.generic)) and base.dtype.kind in "biu":
return base.astype(np.float64)
return base

@overload
def _convert_when(when: _ArrayT) -> _ArrayT: ...
@overload
Expand Down Expand Up @@ -253,7 +262,7 @@ def fv(rate, nper, pmt, pv, when: _When = 'end'):
fv_array[zero] = -(pv[zero] + pmt[zero] * nper[zero])

rate_nonzero = rate[nonzero]
temp = (1 + rate_nonzero) ** nper[nonzero]
temp = _float_power_base(1 + rate_nonzero) ** nper[nonzero]
fv_array[nonzero] = (
- pv[nonzero] * temp
- pmt[nonzero] * (1 + rate_nonzero * when[nonzero]) / rate_nonzero
Expand Down Expand Up @@ -393,7 +402,7 @@ def pmt(rate, nper, pv, fv: Any = 0, when: _When = 'end'):
"""
when = _convert_when(when)
(rate, nper, pv, fv, when) = map(np.array, [rate, nper, pv, fv, when])
temp = (1 + rate) ** nper
temp = _float_power_base(1 + rate) ** nper
mask = (rate == 0)
masked_rate = np.where(mask, 1, rate)
fact = np.where(mask != 0, nper,
Expand Down Expand Up @@ -880,7 +889,7 @@ def pv(rate, nper, pmt, fv: Any = 0, when: _When = 'end'):
"""
when = _convert_when(when)
rate, nper, pmt, fv, when = map(np.asarray, [rate, nper, pmt, fv, when])
temp = (1 + rate) ** nper
temp = _float_power_base(1 + rate) ** nper
fact = np.where(rate == 0, nper, (1 + rate * when) * (temp - 1) / rate)
return -(fv + pmt * fact) / temp

Expand Down
31 changes: 31 additions & 0 deletions numpy_financial/tests/test_financial.py
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,37 @@ def test_some_rates_zero(self):
)


class TestIntegerRates:
# (1 + rate) ** nper must not be evaluated with integer arithmetic,
# which wraps around on overflow. 2**64 wraps to 0 in int64.
@pytest.mark.parametrize(
("func", "args", "desired"),
[
(npf.fv, (64, 0, -(2.0**-64)), 1.0),
(npf.pv, (64, -1, 0), 1 - 2.0**-64),
(npf.pmt, (64, 1), -1 / (1 - 2.0**-64)),
],
)
def test_no_overflow(self, func, args, desired):
assert_allclose(func(1, *args), desired, rtol=1e-15)
assert_allclose(func(1, *args), func(1.0, *args), rtol=1e-15)

@pytest.mark.parametrize("dtype", [numpy.int32, numpy.int64])
def test_integer_arrays(self, dtype):
rate = numpy.array([1, 2], dtype=dtype)
nper = numpy.array([64, 41], dtype=dtype)
assert_allclose(npf.pv(rate, nper, -1, 0), [1.0, 0.5], rtol=1e-15)
assert_allclose(npf.pmt(rate, nper, 1), [-1.0, -2.0], rtol=1e-15)
assert_allclose(npf.fv(rate[:1], nper[:1], 0, -(2.0**-64)), [1.0])

@pytest.mark.parametrize(
("func", "args"), [(npf.pv, (-1, 0, -1)), (npf.pmt, (-2, 1))]
)
def test_negative_integer_nper(self, func, args):
# Integer arrays reject negative integer powers.
assert_allclose(func(1, *args), func(1.0, *args), rtol=1e-15)


class TestIrr:
def test_npv_irr_congruence(self):
# IRR is defined as the rate required for the present value of
Expand Down