From 7956a92d4dacce104d440f5ba2d100c3df4668bb Mon Sep 17 00:00:00 2001 From: franrizz15 <51216235+franrizz15@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:22:28 +0100 Subject: [PATCH 1/2] ENH: Add cumprinc and cumipmt --- numpy_financial/_financial.py | 273 +++++++++++++++++++++++- numpy_financial/tests/test_financial.py | 217 +++++++++++++++++++ 2 files changed, 489 insertions(+), 1 deletion(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 1adbbea..754af9a 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -22,7 +22,7 @@ from numpy_financial import _cfinancial __all__ = ['fv', 'pmt', 'nper', 'ipmt', 'ppmt', 'pv', 'rate', - 'irr', 'npv', 'mirr', + 'irr', 'npv', 'mirr', 'cumprinc', 'cumipmt', 'NoRealSolutionError', 'IterationsExceededError'] _ArrayT = TypeVar("_ArrayT", bound=npt.NDArray[Any]) @@ -1413,3 +1413,274 @@ def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions: bool = False) denom = np.abs(npv(fr, v * neg)) out[i, j] = (numer / denom) ** (1 / (n - 1)) * (1 + rr) - 1 return _ufunc_like(out) + + +@overload +def cumprinc( + rate: _AsFloat, + start: _CoFloat, + end: _CoFloat, + nper: _CoFloat, + pv: _CoFloat, + fv: _CoFloat = 0, + when: _When = 'end', +) -> float: ... +@overload +def cumprinc( + rate: Decimal, + start: _AsDecimal, + end: _AsDecimal, + nper: _AsDecimal, + pv: _AsDecimal, + fv: _AsDecimal = 0, + when: _When = 'end', +) -> Decimal: ... +@overload +def cumprinc( + rate: _AsFloat1D, + start: _CoFloatOr1D, + end: _CoFloatOr1D, + nper: _CoFloatOr1D, + pv: _CoFloatOr1D, + fv: _CoFloatOr1D = 0, + when: _When = 'end', +) -> npt.NDArray[np.float64]: ... +@overload +def cumprinc( + rate: _ArrayLike, + start: _ArrayLike, + end: _ArrayLike, + nper: _ArrayLike, + pv: _ArrayLike, + fv: _ArrayLike = 0, + when: _When = 'end', +) -> Any: ... +def cumprinc(rate, start, end, nper, pv, fv: Any = 0, when: _When = 'end') -> Any: + """Compute the cumulative principal paid between two periods. + + Parameters + ---------- + rate : scalar or array_like of shape(M, ) + Rate of interest as decimal (not per cent) per period + start : scalar or array_like of shape(M, ) + First period included in the total. + end : scalar or array_like of shape(M, ) + Last period included in the total. Both `start` and `end` are + inclusive, so the range covers ``end - start + 1`` payments. + nper : scalar or array_like of shape(M, ) + Number of compounding periods + pv : scalar or array_like of shape(M, ) + Present value + fv : scalar or array_like of shape(M, ), optional + Future value + when : {{'begin', 1}, {'end', 0}}, {string, int}, optional + When payments are due ('begin' (1) or 'end' (0)). + Defaults to {'end', 0}. + + Returns + ------- + out : ndarray + Principal paid between `start` and `end` inclusive. If all input + is scalar, returns a scalar float. If any input is array_like, + returns the cumulative principal for each input element. ``nan`` + is returned where `start` is less than 1, `end` is before + `start`, or `end` is beyond `nper`. + + See Also + -------- + ppmt, ipmt, pmt, fv + + Notes + ----- + The total payment is made up of payment against principal plus + interest, so ``pmt * (end - start + 1) == cumprinc + cumipmt``. + + The cumulative principal is obtained as the difference between two + loan balances rather than by summing `ppmt` across the range. Each + principal payment is, by definition, the amount by which the + outstanding balance falls during that period, so summing them over + periods `start` through `end` telescopes:: + + sum(ppmt) = (b[start-1] - b[start]) + ... + (b[end-1] - b[end]) + = b[start-1] - b[end] + + Every intermediate balance appears once positively and once + negatively and cancels, leaving only the balance entering the range + and the balance leaving it. Each of those is a single closed-form + evaluation, so the cost is two evaluations however long the range. + + When ``when == 1`` the payment is made before the period's interest + accrues, so the balance at the payment instant is the period-boundary + balance discounted by one period. The first period is exempt, since + no interest has accrued when that payment is made. + + Examples + -------- + >>> import numpy as np + >>> import numpy_financial as npf + + What is the principal repaid during the first year of a 30 year + $125,000 loan at 9% interest per year compounded monthly, and over + the full term? + + >>> np.round(npf.cumprinc(0.09 / 12, 1, 12, 30 * 12, 125000), 2) + np.float64(-854.0) + >>> np.round(npf.cumprinc(0.09 / 12, 1, 30 * 12, 30 * 12, 125000), 2) + np.float64(-125000.0) + + Paying at the beginning of each period repays far more in the first + year, since the first payment accrues no interest: + + >>> np.round( + ... npf.cumprinc(0.09 / 12, 1, 12, 30 * 12, 125000, 0, 'begin'), 2 + ... ) + np.float64(-1778.16) + + """ + when = _convert_when(when) + rate, start, end, nper, pv, fv, when = np.broadcast_arrays(rate, start, end, + nper, pv, fv, when) + + total_pmt = pmt(rate, nper, pv, fv, when) + + b_start = np.array(_rbl(rate, start, total_pmt, pv, when)) + b_end = np.array(_rbl(rate, end + 1, total_pmt, pv, when)) + + begin = (when == 1) + begin_mid = begin & (start > 1) + + b_start[begin_mid] = b_start[begin_mid] / (1 + rate[begin_mid]) + b_end[begin] = b_end[begin] / (1 + rate[begin]) + + # Arithmetic on two 0-d arrays yields a scalar, which cannot be + # indexed, so re-wrap before masking. + result = np.array(b_start - b_end) + + invalid = (start < 1) | (end < start) | (end > nper) + result[invalid] = _value_like(result, np.nan) + + return _ufunc_like(result) + +@overload +def cumipmt( + rate: _AsFloat, + start: _CoFloat, + end: _CoFloat, + nper: _CoFloat, + pv: _CoFloat, + fv: _CoFloat = 0, + when: _When = 'end', +) -> float: ... +@overload +def cumipmt( + rate: Decimal, + start: _AsDecimal, + end: _AsDecimal, + nper: _AsDecimal, + pv: _AsDecimal, + fv: _AsDecimal = 0, + when: _When = 'end', +) -> Decimal: ... +@overload +def cumipmt( + rate: _AsFloat1D, + start: _CoFloatOr1D, + end: _CoFloatOr1D, + nper: _CoFloatOr1D, + pv: _CoFloatOr1D, + fv: _CoFloatOr1D = 0, + when: _When = 'end', +) -> npt.NDArray[np.float64]: ... +@overload +def cumipmt( + rate: _ArrayLike, + start: _ArrayLike, + end: _ArrayLike, + nper: _ArrayLike, + pv: _ArrayLike, + fv: _ArrayLike = 0, + when: _When = 'end', +) -> Any: ... +def cumipmt(rate, start, end, nper, pv, fv: Any = 0, when: _When = 'end') -> Any: + """Compute the cumulative interest paid between two periods. + + Parameters + ---------- + rate : scalar or array_like of shape(M, ) + Rate of interest as decimal (not per cent) per period + start : scalar or array_like of shape(M, ) + First period included in the total. + end : scalar or array_like of shape(M, ) + Last period included in the total. Both `start` and `end` are + inclusive, so the range covers ``end - start + 1`` payments. + nper : scalar or array_like of shape(M, ) + Number of compounding periods + pv : scalar or array_like of shape(M, ) + Present value + fv : scalar or array_like of shape(M, ), optional + Future value + when : {{'begin', 1}, {'end', 0}}, {string, int}, optional + When payments are due ('begin' (1) or 'end' (0)). + Defaults to {'end', 0}. + + Returns + ------- + out : ndarray + Interest paid between `start` and `end` inclusive. If all input + is scalar, returns a scalar float. If any input is array_like, + returns the cumulative interest for each input element. ``nan`` + is returned where `start` is less than 1, `end` is before + `start`, or `end` is beyond `nper`. + + See Also + -------- + cumprinc, ipmt, ppmt, pmt + + Notes + ----- + Each payment is made up of interest plus principal, so summing that + identity over the range gives:: + + pmt * (end - start + 1) == cumipmt + cumprinc + + The interest is therefore taken as the total paid over the range less + the part that reduced the balance. Since `ppmt` is defined as + ``pmt - ipmt``, this holds exactly for both values of `when`, so no + separate derivation is needed; see `cumprinc` for how the principal + is obtained. + + Examples + -------- + >>> import numpy as np + >>> import numpy_financial as npf + + How much interest is paid during the first year of a 30 year + $125,000 loan at 9% interest per year compounded monthly, and over + the full term? + + >>> np.round(npf.cumipmt(0.09 / 12, 1, 12, 30 * 12, 125000), 2) + np.float64(-11215.34) + >>> np.round(npf.cumipmt(0.09 / 12, 1, 30 * 12, 30 * 12, 125000), 2) + np.float64(-237080.18) + + Paying at the beginning of each period costs less interest, since + the first payment accrues none at all: + + >>> np.round( + ... npf.cumipmt(0.09 / 12, 1, 12, 30 * 12, 125000, 0, 'begin'), 2 + ... ) + np.float64(-10201.33) + + """ + when = _convert_when(when) + rate, start, end, nper, pv, fv, when = np.broadcast_arrays(rate, start, end, + nper, pv, fv, when) + + total_pmt = pmt(rate, nper, pv, fv, when) + + result = np.array( + total_pmt * (end - start + 1) + - cumprinc(rate, start, end, nper, pv, fv, when) + ) + + return _ufunc_like(result) \ No newline at end of file diff --git a/numpy_financial/tests/test_financial.py b/numpy_financial/tests/test_financial.py index 765e1b2..7e63277 100644 --- a/numpy_financial/tests/test_financial.py +++ b/numpy_financial/tests/test_financial.py @@ -883,3 +883,220 @@ def test_irr_no_real_solution_exception(self): with pytest.raises(npf.NoRealSolutionError): npf.irr(cashflows, raise_exceptions=True) + +def _cumprinc_iterative(rate, start, end, nper, pv, fv=0, when="end"): + """Cumulative principal by summing ppmt, the definition of cumprinc. + + Used to check the closed-form version. The period bounds are coerced + to int so that Decimal inputs can be iterated; ppmt still returns + Decimal when the remaining arguments are Decimal. + """ + return sum( + npf.ppmt(rate, per, nper, pv, fv, when) + for per in range(int(start), int(end) + 1) + ) + +def _cumipmt_iterative(rate, start, end, nper, pv, fv=0, when="end"): + """Cumulative interest by summing ipmt, the definition of cumipmt. + """ + return sum( + npf.ipmt(rate, per, nper, pv, fv, when) + for per in range(int(start), int(end) + 1) + ) + +class TestCumprinc: + @pytest.mark.parametrize("when", [None, 0, "end"]) + def test_sas_reference_end(self, when): + # SAS: CUMPRINC(0.09/12, 360, 125000, 12, 24, 0) -> $1008.23 + # https://documentation.sas.com/doc/en/edmcdc/v_031/ds2ref/p1ae3ccwcl1y4dn0zld1p5rj88yx.htm + # SAS reports the magnitude; numpy-financial signs cash paid out + # negative. The SAS doc prints to 2 d.p., hence rtol=1e-5. + kwargs = dict(rate=0.09 / 12, start=12, end=24, nper=360, pv=125000) + result = ( + npf.cumprinc(**kwargs) + if when is None + else npf.cumprinc(**kwargs, when=when) + ) + assert_allclose(result, -1008.23, rtol=1e-5) + + @pytest.mark.parametrize("when", [1, "begin"]) + def test_sas_reference_begin(self, when): + # SAS: CUMPRINC(0.09/12, 360, 125000, 12, 24, 1) -> $1000.73 + # Same source. Note 1008.23 / 1000.73 == 1 + 0.09/12, i.e. this + # pair pins the one-period discount applied when when == 1. + result = npf.cumprinc( + rate=0.09 / 12, start=12, end=24, nper=360, pv=125000, when=when + ) + assert_allclose(result, -1000.73, rtol=1e-5) + + @pytest.mark.parametrize( + "when, desired", + [ + (1, -1778.160172), + ("begin", -1778.160172), + (0, -853.996373), + ("end", -853.996373), + ], + ) + def test_excel_reference_from_first_period(self, when, desired): + # Computed using Excel's CUMPRINC: + # =CUMPRINC(0.09/12, 360, 125000, 1, 12, 1) -> -1778.160172 + # =CUMPRINC(0.09/12, 360, 125000, 1, 12, 0) -> -853.996373 + result = npf.cumprinc( + rate=0.09 / 12, start=1, end=12, nper=360, pv=125000, when=when + ) + assert_allclose(result, desired, rtol=1e-9) + + @pytest.mark.parametrize("when", ["end", "begin"]) + @pytest.mark.parametrize("rate", [0.09 / 12, 0.0824 / 12, 0.01, 0.1]) + @pytest.mark.parametrize( + "start, end", + [(1, 1), (1, 6), (1, 12), (2, 4), (5, 5), (7, 12), (12, 12)], + ) + def test_matches_iterative(self, rate, start, end, when): + # The closed form must agree with summing ppmt period by period. + args = (rate, start, end, 12, 2500, 0, when) + assert_allclose( + npf.cumprinc(*args), + _cumprinc_iterative(*args), + rtol=1e-9, + ) + + @pytest.mark.parametrize("when", ["end", "begin"]) + @pytest.mark.parametrize("per", [1, 2, 6, 12]) + def test_single_period_equals_ppmt(self, per, when): + # A one-period range is just ppmt. + rate, nper, pv = 0.0824 / 12, 12, 2500 + assert_allclose( + npf.cumprinc(rate, per, per, nper, pv, 0, when), + npf.ppmt(rate, per, nper, pv, 0, when), + rtol=1e-9, + ) + + @pytest.mark.parametrize("when", ["end", "begin"]) + def test_array_start_and_end(self, when): + # start contains both 1 and values greater than 1 in the same call. + rate, nper, pv = 0.09 / 12, 360, 125000 + start = numpy.array([1, 2, 12]) + end = numpy.array([12, 12, 24]) + result = npf.cumprinc(rate, start, end, nper, pv, 0, when) + desired = [ + _cumprinc_iterative(rate, s, e, nper, pv, 0, when) + for s, e in zip(start, end, strict=True) + ] + assert_allclose(result, desired, rtol=1e-9) + + @pytest.mark.parametrize("when", ["end", "begin"]) + @pytest.mark.parametrize( + "start, end", [(1, 12), (1, 6), (7, 12), (5, 5), (2, 9)] + ) + def test_zero_rate(self, start, end, when): + # With no interest every payment is pure principal, so the + # cumulative principal is pmt * count == -pv * count / nper. + nper, pv = 12, 2500 + desired = -pv * (end - start + 1) / nper + result = npf.cumprinc(0.0, start, end, nper, pv, 0, when) + assert_allclose(result, desired, rtol=1e-12) + + + def test_decimal(self): + args = (Decimal("0.08") / Decimal("12"), + Decimal("1"), + Decimal("12"), + Decimal("60"), + Decimal("15000."), + Decimal("0"), + "end") + result = npf.cumprinc(*args) + assert_decimal_close(result, _cumprinc_iterative(*args), tol=Decimal("1e-5")) + + def test_scalar_input_returns_scalar(self): + result = npf.cumprinc( + rate=0.0824 / 12, start=1, end=12, nper=12, pv=2500 + ) + assert_(numpy.isscalar(result) or numpy.ndim(result) == 0) + + def test_broadcasting(self): + rates = numpy.array([0.09 / 12, 0.0824 / 12, 0.01]) + result = npf.cumprinc( + rate=rates, start=1, end=12, nper=12, pv=2500, when="end" + ) + assert_equal(numpy.shape(result), (3,)) + + @pytest.mark.parametrize( + "start, end", + [(0, 6), (-1, 6), (6, 5), (1, 13)], + ) + def test_out_of_range_is_nan(self, start, end): + # per < 1, end before start, and end beyond nper are all + # meaningless; ipmt returns nan for per < 1, so match that. + result = npf.cumprinc( + rate=0.0824 / 12, start=start, end=end, nper=12, pv=2500 + ) + assert_(numpy.isnan(result)) + +class TestCumipmt: + @pytest.mark.parametrize( + "start, end, when, desired", + [ + (12, 24, 0, -12066.88311), + (12, 24, "end", -12066.88311), + (1, 12, 1, -10201.33288), + (1, 12, "begin", -10201.33288), + ], + ) + def test_excel_reference(self, start, end, when, desired): + # Computed using Excel's CUMIPMT, which reports 5 d.p. here: + # =CUMIPMT(0.09/12, 360, 125000, 12, 24, 0) -> -12066.88311 + # =CUMIPMT(0.09/12, 360, 125000, 1, 12, 1) -> -10201.33288 + result = npf.cumipmt( + rate=0.09 / 12, start=start, end=end, nper=360, pv=125000, when=when + ) + assert_allclose(result, desired, rtol=1e-8) + + @pytest.mark.parametrize("when", ["end", "begin"]) + @pytest.mark.parametrize("rate", [0.09 / 12, 0.0824 / 12, 0.01, 0.1]) + @pytest.mark.parametrize( + "start, end", + [(1, 1), (1, 6), (1, 12), (2, 4), (5, 5), (7, 12), (12, 12)], + ) + def test_matches_iterative(self, rate, start, end, when): + args = (rate, start, end, 12, 2500, 0, when) + assert_allclose( + npf.cumipmt(*args), + _cumipmt_iterative(*args), + rtol=1e-9, + atol=1e-9, + ) + + @pytest.mark.parametrize("when", ["end", "begin"]) + @pytest.mark.parametrize("start, end", [(1, 12), (1, 6), (5, 5), (7, 12)]) + def test_zero_rate_has_no_interest(self, start, end, when): + result = npf.cumipmt(0.0, start, end, 12, 2500, 0, when) + assert_allclose(result, 0, atol=1e-10) + + @pytest.mark.parametrize("when", ["end", "begin"]) + @pytest.mark.parametrize("per", [1, 2, 6, 12]) + def test_single_period_equals_ipmt(self, per, when): + # Pins the `end + 1` offset: an off-by-one returns 0, not ipmt. + rate, nper, pv = 0.0824 / 12, 12, 2500 + assert_allclose( + npf.cumipmt(rate, per, per, nper, pv, 0, when), + npf.ipmt(rate, per, nper, pv, 0, when), + rtol=1e-9, + atol=1e-9, + ) + + @pytest.mark.parametrize("when", ["end", "begin"]) + def test_array_start_and_end(self, when): + # Under when='begin' the first element takes the period 1 + # exemption and the other two do not, all in one call. + rate, nper, pv = 0.09 / 12, 360, 125000 + start = numpy.array([1, 2, 12]) + end = numpy.array([12, 12, 24]) + result = npf.cumipmt(rate, start, end, nper, pv, 0, when) + desired = [ + _cumipmt_iterative(rate, s, e, nper, pv, 0, when) + for s, e in zip(start, end, strict=True) + ] + assert_allclose(result, desired, rtol=1e-9) From fd9728edc2c592d4f1bc7c2bdf9f0e05b03cdc09 Mon Sep 17 00:00:00 2001 From: franrizz15 <51216235+franrizz15@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:32:50 +0100 Subject: [PATCH 2/2] DOC: Add cumprinc and cumipmt to the API reference and changelog --- CHANGELOG.md | 7 +++++++ doc/source/api.rst | 2 ++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 718de04..82796b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# 1.2.0 + +## Enhancements + +- ENH: Add `cumprinc` and `cumipmt` ([#165](https://github.com/numpy/numpy-financial/pull/165)). + + # 1.1.0 We're happy to announce the release of numpy-financial 1.1.0! diff --git a/doc/source/api.rst b/doc/source/api.rst index 2364784..eb8f917 100644 --- a/doc/source/api.rst +++ b/doc/source/api.rst @@ -16,3 +16,5 @@ API Documentation ppmt pv rate + cumipmt + cumprinc \ No newline at end of file