From adea02971f6ce80b545347f0f105b1eb2e87d5f3 Mon Sep 17 00:00:00 2001 From: sb123sb123 <152394158+sb123sb123@users.noreply.github.com> Date: Sun, 27 Sep 2026 01:49:06 +0800 Subject: [PATCH 1/4] BUG: support pandas columns of period arrays --- numpy_financial/_financial.py | 52 +++++++++++++++++++++++-- numpy_financial/tests/test_financial.py | 21 ++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 1adbbea..4f891b3 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -13,7 +13,17 @@ from collections.abc import Iterable, Mapping, Sequence from decimal import Decimal -from typing import Any, Callable, Final, Literal, Protocol, TypeAlias, TypeVar, overload +from typing import ( + Any, + Callable, + Final, + Literal, + Protocol, + TypeAlias, + TypeVar, + cast, + overload, +) import numpy as np import numpy.typing as npt @@ -490,6 +500,37 @@ def _value_like(arr: npt.NDArray[Any], value: Decimal | float) -> Any: return Decimal(value) return np.array(value, dtype=arr.dtype).item(0) +def _broadcast_payment_inputs( + rate: Any, per: Any, nper: Any, pv: Any, fv: Any, when: Any +) -> tuple[Any, Any, Any, Any, Any, Any]: + """Broadcast row parameters over nested periods in object arrays.""" + period_values = np.asarray(per) + if period_values.ndim == 1 and period_values.dtype == object: + rows = [np.asarray(value) for value in period_values] + if rows and rows[0].ndim > 0 and all( + row.shape == rows[0].shape for row in rows + ): + period_values = np.stack(rows) + row_count = period_values.shape[0] + def expand_row_parameter(value: Any) -> Any: + if np.ndim(value) == 1 and np.shape(value) == (row_count,): + return np.asarray(value)[:, np.newaxis] + return value + + rate, nper, pv, fv, when = ( + expand_row_parameter(rate), + expand_row_parameter(nper), + expand_row_parameter(pv), + expand_row_parameter(fv), + expand_row_parameter(when), + ) + + return cast( + tuple[Any, Any, Any, Any, Any, Any], + np.broadcast_arrays(rate, period_values, nper, pv, fv, when), + ) + + @overload def ipmt( rate: _AsFloat, @@ -611,9 +652,9 @@ def ipmt(rate, per, nper, pv, fv: Any = 0, when: _When = 'end') -> Any: np.float64(-112.98) """ - when = _convert_when(when) - rate, per, nper, pv, fv, when = np.broadcast_arrays(rate, per, nper, - pv, fv, when) + rate, per, nper, pv, fv, when = _broadcast_payment_inputs( + rate, per, nper, pv, fv, _convert_when(when) + ) total_pmt = pmt(rate, nper, pv, fv, when) ipmt_array = np.array(_rbl(rate, per, total_pmt, pv, when) * rate) @@ -741,6 +782,9 @@ def ppmt(rate, per, nper, pv, fv: Any = 0, when: _When = 'end'): pmt, pv, ipmt """ + rate, per, nper, pv, fv, when = _broadcast_payment_inputs( + rate, per, nper, pv, fv, _convert_when(when) + ) total = pmt(rate, nper, pv, fv, when) return total - ipmt(rate, per, nper, pv, fv, when) diff --git a/numpy_financial/tests/test_financial.py b/numpy_financial/tests/test_financial.py index 765e1b2..4136490 100644 --- a/numpy_financial/tests/test_financial.py +++ b/numpy_financial/tests/test_financial.py @@ -680,6 +680,27 @@ def test_0d_inputs(self): assert numpy.isscalar(npf.ipmt(*args)) + def test_nested_period_arrays_from_dataframe_column(self): + rate = numpy.array([0.05, 0.07]) + per = numpy.empty(2, dtype=object) + per[0] = numpy.arange(1, 5) + per[1] = numpy.arange(1, 5) + nper = numpy.array([10, 10]) + pv = numpy.array([10000, 12000]) + + per_matrix = numpy.stack(per) + expected_ipmt = npf.ipmt( + rate[:, numpy.newaxis], per_matrix, nper[:, numpy.newaxis], + pv[:, numpy.newaxis] + ) + expected_ppmt = npf.ppmt( + rate[:, numpy.newaxis], per_matrix, nper[:, numpy.newaxis], + pv[:, numpy.newaxis] + ) + assert_allclose(npf.ipmt(rate, per, nper, pv), expected_ipmt) + assert_allclose(npf.ppmt(rate, per, nper, pv), expected_ppmt) + + class TestFv: def test_float(self): assert_allclose( From a9366bda36dcd2af1c4f4f5b1045f72adeba3b0c Mon Sep 17 00:00:00 2001 From: sb123sb123 <152394158+sb123sb123@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:59:22 +0800 Subject: [PATCH 2/4] typing: clarify payment broadcast helper types --- numpy_financial/_financial.py | 36 ++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 4f891b3..80baec8 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -500,19 +500,35 @@ def _value_like(arr: npt.NDArray[Any], value: Decimal | float) -> Any: return Decimal(value) return np.array(value, dtype=arr.dtype).item(0) + def _broadcast_payment_inputs( - rate: Any, per: Any, nper: Any, pv: Any, fv: Any, when: Any -) -> tuple[Any, Any, Any, Any, Any, Any]: + rate: _ArrayLike, + per: _ArrayLike, + nper: _ArrayLike, + pv: _ArrayLike, + fv: _ArrayLike, + when: _ArrayLike, +) -> tuple[ + npt.NDArray[Any], + npt.NDArray[Any], + npt.NDArray[Any], + npt.NDArray[Any], + npt.NDArray[Any], + npt.NDArray[Any], +]: """Broadcast row parameters over nested periods in object arrays.""" period_values = np.asarray(per) if period_values.ndim == 1 and period_values.dtype == object: rows = [np.asarray(value) for value in period_values] - if rows and rows[0].ndim > 0 and all( - row.shape == rows[0].shape for row in rows + if ( + rows + and rows[0].ndim > 0 + and all(row.shape == rows[0].shape for row in rows) ): period_values = np.stack(rows) row_count = period_values.shape[0] - def expand_row_parameter(value: Any) -> Any: + + def expand_row_parameter(value: _ArrayLike) -> _ArrayLike: if np.ndim(value) == 1 and np.shape(value) == (row_count,): return np.asarray(value)[:, np.newaxis] return value @@ -526,11 +542,17 @@ def expand_row_parameter(value: Any) -> Any: ) return cast( - tuple[Any, Any, Any, Any, Any, Any], + tuple[ + npt.NDArray[Any], + npt.NDArray[Any], + npt.NDArray[Any], + npt.NDArray[Any], + npt.NDArray[Any], + npt.NDArray[Any], + ], np.broadcast_arrays(rate, period_values, nper, pv, fv, when), ) - @overload def ipmt( rate: _AsFloat, From 8fc0f2586c65e80b6d7e1704ddd7dc380104bc23 Mon Sep 17 00:00:00 2001 From: sb123sb123 <152394158+sb123sb123@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:07:56 +0800 Subject: [PATCH 3/4] Return broadcasted payment inputs as a tuple --- numpy_financial/_financial.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 80baec8..16e9b34 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -21,7 +21,6 @@ Protocol, TypeAlias, TypeVar, - cast, overload, ) @@ -541,17 +540,11 @@ def expand_row_parameter(value: _ArrayLike) -> _ArrayLike: expand_row_parameter(when), ) - return cast( - tuple[ - npt.NDArray[Any], - npt.NDArray[Any], - npt.NDArray[Any], - npt.NDArray[Any], - npt.NDArray[Any], - npt.NDArray[Any], - ], - np.broadcast_arrays(rate, period_values, nper, pv, fv, when), + rate, per, nper, pv, fv, when = np.broadcast_arrays( + rate, period_values, nper, pv, fv, when ) + return rate, per, nper, pv, fv, when + @overload def ipmt( From 59c55de900943a44ea1acd2010e8447e1c79b758 Mon Sep 17 00:00:00 2001 From: sb123sb123 <152394158+sb123sb123@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:56:57 +0800 Subject: [PATCH 4/4] Drop Any-only broadcast return type --- numpy_financial/_financial.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py index 16e9b34..9df820a 100644 --- a/numpy_financial/_financial.py +++ b/numpy_financial/_financial.py @@ -507,14 +507,7 @@ def _broadcast_payment_inputs( pv: _ArrayLike, fv: _ArrayLike, when: _ArrayLike, -) -> tuple[ - npt.NDArray[Any], - npt.NDArray[Any], - npt.NDArray[Any], - npt.NDArray[Any], - npt.NDArray[Any], - npt.NDArray[Any], -]: +): """Broadcast row parameters over nested periods in object arrays.""" period_values = np.asarray(per) if period_values.ndim == 1 and period_values.dtype == object: