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/3] 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/3] 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 From ca4bd0cb2a97e7b49c8e07e208ac702b6ee5b5f9 Mon Sep 17 00:00:00 2001 From: franrizz15 <51216235+franrizz15@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:07:11 +0000 Subject: [PATCH 3/3] ENH: Rename cumprinc/cumipmt to cumulative_principal/cumulative_interest --- CHANGELOG.md | 2 +- doc/source/api.rst | 6 +-- numpy_financial/_financial.py | 58 +++++++++++++++---------- numpy_financial/tests/test_financial.py | 58 +++++++++++++------------ 4 files changed, 68 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82796b0..d5d725e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Enhancements -- ENH: Add `cumprinc` and `cumipmt` ([#165](https://github.com/numpy/numpy-financial/pull/165)). +- ENH: Add `cumulative_principal` and `cumulative_interest` ([#165](https://github.com/numpy/numpy-financial/pull/165)). # 1.1.0 diff --git a/doc/source/api.rst b/doc/source/api.rst index eb8f917..a3cfb73 100644 --- a/doc/source/api.rst +++ b/doc/source/api.rst @@ -6,6 +6,8 @@ API Documentation .. autosummary:: :toctree: _api_stubs/ + cumulative_interest + cumulative_principal fv ipmt irr @@ -15,6 +17,4 @@ API Documentation pmt ppmt pv - rate - cumipmt - cumprinc \ No newline at end of file + rate \ No newline at end of file diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 754af9a..a06f4ef 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', 'cumprinc', 'cumipmt', + 'irr', 'npv', 'mirr', 'cumulative_principal', 'cumulative_interest', 'NoRealSolutionError', 'IterationsExceededError'] _ArrayT = TypeVar("_ArrayT", bound=npt.NDArray[Any]) @@ -1416,7 +1416,7 @@ def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions: bool = False) @overload -def cumprinc( +def cumulative_principal( rate: _AsFloat, start: _CoFloat, end: _CoFloat, @@ -1426,7 +1426,7 @@ def cumprinc( when: _When = 'end', ) -> float: ... @overload -def cumprinc( +def cumulative_principal( rate: Decimal, start: _AsDecimal, end: _AsDecimal, @@ -1436,7 +1436,7 @@ def cumprinc( when: _When = 'end', ) -> Decimal: ... @overload -def cumprinc( +def cumulative_principal( rate: _AsFloat1D, start: _CoFloatOr1D, end: _CoFloatOr1D, @@ -1446,7 +1446,7 @@ def cumprinc( when: _When = 'end', ) -> npt.NDArray[np.float64]: ... @overload -def cumprinc( +def cumulative_principal( rate: _ArrayLike, start: _ArrayLike, end: _ArrayLike, @@ -1455,9 +1455,13 @@ def cumprinc( fv: _ArrayLike = 0, when: _When = 'end', ) -> Any: ... -def cumprinc(rate, start, end, nper, pv, fv: Any = 0, when: _When = 'end') -> Any: +def cumulative_principal( + rate, start, end, nper, pv, fv: Any = 0, when: _When = 'end' +) -> Any: """Compute the cumulative principal paid between two periods. + Corresponds to the ``CUMPRINC`` function in Excel. + Parameters ---------- rate : scalar or array_like of shape(M, ) @@ -1488,12 +1492,14 @@ def cumprinc(rate, start, end, nper, pv, fv: Any = 0, when: _When = 'end') -> An See Also -------- - ppmt, ipmt, pmt, fv + cumulative_interest, ppmt, ipmt, pmt, fv Notes ----- The total payment is made up of payment against principal plus - interest, so ``pmt * (end - start + 1) == cumprinc + cumipmt``. + interest, so:: + + pmt * (end - start + 1) == cumulative_principal + cumulative_interest The cumulative principal is obtained as the difference between two loan balances rather than by summing `ppmt` across the range. Each @@ -1523,16 +1529,16 @@ def cumprinc(rate, start, end, nper, pv, fv: Any = 0, when: _When = 'end') -> An $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.round(npf.cumulative_principal(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.round(npf.cumulative_principal(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 + ... npf.cumulative_principal(0.09 / 12, 1, 12, 30 * 12, 125000, 0, 'begin'), 2 ... ) np.float64(-1778.16) @@ -1562,7 +1568,7 @@ def cumprinc(rate, start, end, nper, pv, fv: Any = 0, when: _When = 'end') -> An return _ufunc_like(result) @overload -def cumipmt( +def cumulative_interest( rate: _AsFloat, start: _CoFloat, end: _CoFloat, @@ -1572,7 +1578,7 @@ def cumipmt( when: _When = 'end', ) -> float: ... @overload -def cumipmt( +def cumulative_interest( rate: Decimal, start: _AsDecimal, end: _AsDecimal, @@ -1582,7 +1588,7 @@ def cumipmt( when: _When = 'end', ) -> Decimal: ... @overload -def cumipmt( +def cumulative_interest( rate: _AsFloat1D, start: _CoFloatOr1D, end: _CoFloatOr1D, @@ -1592,7 +1598,7 @@ def cumipmt( when: _When = 'end', ) -> npt.NDArray[np.float64]: ... @overload -def cumipmt( +def cumulative_interest( rate: _ArrayLike, start: _ArrayLike, end: _ArrayLike, @@ -1601,9 +1607,13 @@ def cumipmt( fv: _ArrayLike = 0, when: _When = 'end', ) -> Any: ... -def cumipmt(rate, start, end, nper, pv, fv: Any = 0, when: _When = 'end') -> Any: +def cumulative_interest( + rate, start, end, nper, pv, fv: Any = 0, when: _When = 'end' +) -> Any: """Compute the cumulative interest paid between two periods. + Corresponds to the ``CUMIPMT`` function in Excel. + Parameters ---------- rate : scalar or array_like of shape(M, ) @@ -1634,20 +1644,20 @@ def cumipmt(rate, start, end, nper, pv, fv: Any = 0, when: _When = 'end') -> Any See Also -------- - cumprinc, ipmt, ppmt, pmt + cumulative_principal, 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 + pmt * (end - start + 1) == cumulative_interest + cumulative_principal 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. + separate derivation is needed; see `cumulative_principal` for how the + principal is obtained. Examples -------- @@ -1658,16 +1668,16 @@ def cumipmt(rate, start, end, nper, pv, fv: Any = 0, when: _When = 'end') -> Any $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.round(npf.cumulative_interest(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.round(npf.cumulative_interest(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 + ... npf.cumulative_interest(0.09 / 12, 1, 12, 30 * 12, 125000, 0, 'begin'), 2 ... ) np.float64(-10201.33) @@ -1680,7 +1690,7 @@ def cumipmt(rate, start, end, nper, pv, fv: Any = 0, when: _When = 'end') -> Any result = np.array( total_pmt * (end - start + 1) - - cumprinc(rate, start, end, nper, pv, fv, when) + - cumulative_principal(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 7e63277..3e357ac 100644 --- a/numpy_financial/tests/test_financial.py +++ b/numpy_financial/tests/test_financial.py @@ -884,8 +884,8 @@ 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. +def _cumulative_principal_iterative(rate, start, end, nper, pv, fv=0, when="end"): + """Sum ppmt over the range, which defines cumulative_principal. Used to check the closed-form version. The period bounds are coerced to int so that Decimal inputs can be iterated; ppmt still returns @@ -896,15 +896,15 @@ def _cumprinc_iterative(rate, start, end, nper, pv, fv=0, when="end"): 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. +def _cumulative_interest_iterative(rate, start, end, nper, pv, fv=0, when="end"): + """Sum ipmt over the range, which defines cumulative_interest. """ return sum( npf.ipmt(rate, per, nper, pv, fv, when) for per in range(int(start), int(end) + 1) ) -class TestCumprinc: +class TestCumulativePrincipal: @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 @@ -913,9 +913,9 @@ def test_sas_reference_end(self, when): # 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) + npf.cumulative_principal(**kwargs) if when is None - else npf.cumprinc(**kwargs, when=when) + else npf.cumulative_principal(**kwargs, when=when) ) assert_allclose(result, -1008.23, rtol=1e-5) @@ -924,7 +924,7 @@ 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( + result = npf.cumulative_principal( rate=0.09 / 12, start=12, end=24, nper=360, pv=125000, when=when ) assert_allclose(result, -1000.73, rtol=1e-5) @@ -942,7 +942,7 @@ 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( + result = npf.cumulative_principal( rate=0.09 / 12, start=1, end=12, nper=360, pv=125000, when=when ) assert_allclose(result, desired, rtol=1e-9) @@ -957,8 +957,8 @@ 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), + npf.cumulative_principal(*args), + _cumulative_principal_iterative(*args), rtol=1e-9, ) @@ -968,7 +968,7 @@ 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.cumulative_principal(rate, per, per, nper, pv, 0, when), npf.ppmt(rate, per, nper, pv, 0, when), rtol=1e-9, ) @@ -979,9 +979,9 @@ def test_array_start_and_end(self, when): 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) + result = npf.cumulative_principal(rate, start, end, nper, pv, 0, when) desired = [ - _cumprinc_iterative(rate, s, e, nper, pv, 0, when) + _cumulative_principal_iterative(rate, s, e, nper, pv, 0, when) for s, e in zip(start, end, strict=True) ] assert_allclose(result, desired, rtol=1e-9) @@ -995,7 +995,7 @@ def test_zero_rate(self, start, end, when): # 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) + result = npf.cumulative_principal(0.0, start, end, nper, pv, 0, when) assert_allclose(result, desired, rtol=1e-12) @@ -1007,18 +1007,20 @@ def test_decimal(self): Decimal("15000."), Decimal("0"), "end") - result = npf.cumprinc(*args) - assert_decimal_close(result, _cumprinc_iterative(*args), tol=Decimal("1e-5")) + result = npf.cumulative_principal(*args) + assert_decimal_close( + result, _cumulative_principal_iterative(*args), tol=Decimal("1e-5") + ) def test_scalar_input_returns_scalar(self): - result = npf.cumprinc( + result = npf.cumulative_principal( 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( + result = npf.cumulative_principal( rate=rates, start=1, end=12, nper=12, pv=2500, when="end" ) assert_equal(numpy.shape(result), (3,)) @@ -1030,12 +1032,12 @@ def test_broadcasting(self): 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( + result = npf.cumulative_principal( rate=0.0824 / 12, start=start, end=end, nper=12, pv=2500 ) assert_(numpy.isnan(result)) -class TestCumipmt: +class TestCumulativeInterest: @pytest.mark.parametrize( "start, end, when, desired", [ @@ -1049,7 +1051,7 @@ 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( + result = npf.cumulative_interest( rate=0.09 / 12, start=start, end=end, nper=360, pv=125000, when=when ) assert_allclose(result, desired, rtol=1e-8) @@ -1063,8 +1065,8 @@ def test_excel_reference(self, start, end, when, desired): 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), + npf.cumulative_interest(*args), + _cumulative_interest_iterative(*args), rtol=1e-9, atol=1e-9, ) @@ -1072,7 +1074,7 @@ def test_matches_iterative(self, rate, start, end, when): @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) + result = npf.cumulative_interest(0.0, start, end, 12, 2500, 0, when) assert_allclose(result, 0, atol=1e-10) @pytest.mark.parametrize("when", ["end", "begin"]) @@ -1081,7 +1083,7 @@ 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.cumulative_interest(rate, per, per, nper, pv, 0, when), npf.ipmt(rate, per, nper, pv, 0, when), rtol=1e-9, atol=1e-9, @@ -1094,9 +1096,9 @@ def test_array_start_and_end(self, when): 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) + result = npf.cumulative_interest(rate, start, end, nper, pv, 0, when) desired = [ - _cumipmt_iterative(rate, s, e, nper, pv, 0, when) + _cumulative_interest_iterative(rate, s, e, nper, pv, 0, when) for s, e in zip(start, end, strict=True) ] assert_allclose(result, desired, rtol=1e-9)