diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 08220b2..2a38531 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -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 @@ -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 @@ -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, @@ -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 diff --git a/numpy_financial/tests/test_financial.py b/numpy_financial/tests/test_financial.py index 765e1b2..c245f52 100644 --- a/numpy_financial/tests/test_financial.py +++ b/numpy_financial/tests/test_financial.py @@ -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