From a8041e78398042b004ae4e6f373623d07fe6b322 Mon Sep 17 00:00:00 2001 From: Lucas Morin Date: Sun, 11 Oct 2026 11:32:23 +0200 Subject: [PATCH 1/2] ENH: add descriptive compounding helpers and portfolio examples --- benchmarks/benchmarks.py | 24 ++++ doc/source/api.rst | 3 + doc/source/index.rst | 1 + doc/source/portfolio.rst | 53 +++++++++ numpy_financial/__init__.py | 1 + numpy_financial/_growth.py | 157 +++++++++++++++++++++++++++ numpy_financial/meson.build | 1 + numpy_financial/tests/test_growth.py | 82 ++++++++++++++ 8 files changed, 322 insertions(+) create mode 100644 doc/source/portfolio.rst create mode 100644 numpy_financial/_growth.py create mode 100644 numpy_financial/tests/test_growth.py diff --git a/benchmarks/benchmarks.py b/benchmarks/benchmarks.py index 8d074f3..e42ef1d 100644 --- a/benchmarks/benchmarks.py +++ b/benchmarks/benchmarks.py @@ -29,3 +29,27 @@ def time_for_loop(self, n_cashflows, cashflow_lengths, rates_lengths): def time_broadcast(self, n_cashflows, cashflow_lengths, rates_lengths): npf.npv(self.rates, self.cashflows) + + +class MortgageScenarios: + """Compare per-loan calls with existing pmt broadcasting.""" + + param_names = ["loans", "scenarios"] + params = [(100, 1000, 10000), (1, 5)] + + def setup(self, loans, scenarios): + self.principal = np.linspace(75000, 450000, loans) + self.periods = np.resize([120, 180, 240, 300, 360], loans) + annual_rates = np.linspace(0.03, 0.065, loans) + shifts = np.linspace(-0.01, 0.01, scenarios) if scenarios > 1 else [0] + self.rates = (annual_rates[:, None] + np.asarray(shifts)[None, :]) / 12 + + def time_individual_calls(self, loans, scenarios): + return np.array([ + [npf.pmt(self.rates[i, j], self.periods[i], self.principal[i]) + for j in range(scenarios)] + for i in range(loans) + ]) + + def time_broadcast(self, loans, scenarios): + return npf.pmt(self.rates, self.periods[:, None], self.principal[:, None]) diff --git a/doc/source/api.rst b/doc/source/api.rst index 2364784..41a1a8f 100644 --- a/doc/source/api.rst +++ b/doc/source/api.rst @@ -16,3 +16,6 @@ API Documentation ppmt pv rate + effective_annual_rate + nominal_annual_rate + compound_value diff --git a/doc/source/index.rst b/doc/source/index.rst index 352631f..40dfd87 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -25,6 +25,7 @@ alias is `npf`. For example, :maxdepth: 4 api + portfolio dev/index diff --git a/doc/source/portfolio.rst b/doc/source/portfolio.rst new file mode 100644 index 0000000..fc0373b --- /dev/null +++ b/doc/source/portfolio.rst @@ -0,0 +1,53 @@ +Loans and compounding +===================== + +Multiple loans and rate scenarios +--------------------------------- + +``pmt`` already broadcasts its inputs. Put loans on rows and rate scenarios on +columns. Rates below are nominal annual rates, compounded monthly; fees are +excluded. Payments are returned as negative cash flows for positive loans. + +.. code-block:: python + + import numpy as np + import numpy_financial as npf + + principal = np.array([250000, 15000, 20000]) + months = np.array([300, 48, 48]) + annual_rate = np.array([0.05, 0.08, 0.07]) + rate_shifts = np.array([-0.01, 0, 0.01]) + monthly_rates = (annual_rate[:, None] + rate_shifts[None, :]) / 12 + payments = npf.pmt(monthly_rates, months[:, None], principal[:, None]) + # payments.shape == (3, 3): three loans, three scenarios + +For one rate per loan, pass one-dimensional arrays of the same length. Do not +add a scenario axis unless the cross-product of loans and scenarios is wanted. +For very large portfolios, process slices of loans to limit temporary memory. +Keep full precision internally and round only where a contractual schedule +requires it. + +Annual rate conversions +----------------------- + +``effective_annual_rate(0.06, 12)`` converts a 6% nominal annual rate compounded +monthly into its effective annual rate. ``nominal_annual_rate`` reverses the +conversion. Neither calculates fees or regulatory APR. Frequencies must be +positive integers; zero rates and negative rates with positive compounding +factors are supported. + +Changing rates +-------------- + +``compound_value(1000, [0.03, 0.04, 0.05])`` grows an initial balance through +three periods at 3%, 4%, and 5%, producing 1124.76. Rates are per period. There +are no intermediate deposits or withdrawals. Unlike ``fv``, the initial and +final balance have the same sign. + +For an array shaped ``(accounts, periods)``, the default ``axis=-1`` compounds +each account's schedule. A one-dimensional principal array can give each +account a different starting balance. + +These helpers calculate in float64 and reject nonfinite inputs. They are +proposed NumPy-oriented interfaces, not a promise of exact spreadsheet error +handling or Decimal precision. diff --git a/numpy_financial/__init__.py b/numpy_financial/__init__.py index 64af1ae..f8f6d3d 100644 --- a/numpy_financial/__init__.py +++ b/numpy_financial/__init__.py @@ -7,3 +7,4 @@ __version__ = "1.2.0.dev0" from ._financial import * +from ._growth import * diff --git a/numpy_financial/_growth.py b/numpy_financial/_growth.py new file mode 100644 index 0000000..47ae6d8 --- /dev/null +++ b/numpy_financial/_growth.py @@ -0,0 +1,157 @@ +"""Annual rate conversions and compounding without intermediate cash flows.""" + +import numpy as np +import numpy.typing as npt + +__all__ = ["effective_annual_rate", "nominal_annual_rate", "compound_value"] + + +def _result(value: npt.NDArray[np.float64]) -> np.float64 | npt.NDArray[np.float64]: + return value[()] if value.ndim == 0 else value + + +def _annual_inputs(rate: npt.ArrayLike, periods: npt.ArrayLike): + rate_array, periods_array = np.broadcast_arrays( + np.asarray(rate, dtype=np.float64), np.asarray(periods, dtype=np.float64)) + if not np.all(np.isfinite(rate_array)): + raise ValueError("rate must be finite") + if not np.all(np.isfinite(periods_array) & (periods_array >= 1) + & (periods_array == np.floor(periods_array))): + raise ValueError("periods_per_year must contain positive integers") + return rate_array, periods_array + + +def effective_annual_rate( + nominal_rate: npt.ArrayLike, periods_per_year: npt.ArrayLike, +) -> np.float64 | npt.NDArray[np.float64]: + """Convert a nominal annual rate to an effective annual rate. + + Parameters + ---------- + nominal_rate : array_like + Nominal annual rate as a decimal fraction, excluding fees. + periods_per_year : array_like + Positive integer number of compounding periods per year. + + Returns + ------- + out : numpy.float64 or ndarray + Effective annual rate. Inputs follow NumPy broadcasting rules. + + Raises + ------ + ValueError + If inputs are nonfinite, frequencies are not positive integers, or + the nominal rate divided by the frequency is at or below -1. + + Notes + ----- + Computes ``(1 + nominal_rate / periods_per_year)**periods_per_year - 1`` + using log1p and expm1 for accuracy near zero. Zero and negative rates are + supported when the periodic compounding factor is positive. Calculations + use float64; Decimal precision is not preserved. This is a compounding + conversion, not a fee-inclusive or regulatory APR calculation. + + Examples + -------- + >>> round(float(effective_annual_rate(0.06, 12)), 6) + 0.061678 + """ + rate, periods = _annual_inputs(nominal_rate, periods_per_year) + periodic = rate / periods + if np.any(periodic <= -1): + raise ValueError("the periodic rate must be greater than -1") + return _result(np.asarray(np.expm1(periods * np.log1p(periodic)))) + + +def nominal_annual_rate( + effective_rate: npt.ArrayLike, periods_per_year: npt.ArrayLike, +) -> np.float64 | npt.NDArray[np.float64]: + """Convert an effective annual rate to a nominal annual rate. + + Parameters + ---------- + effective_rate : array_like + Effective annual rate as a decimal fraction, greater than -1. + periods_per_year : array_like + Positive integer number of compounding periods per year. + + Returns + ------- + out : numpy.float64 or ndarray + Nominal annual rate. Inputs follow NumPy broadcasting rules. + + Raises + ------ + ValueError + If inputs are nonfinite, frequencies are not positive integers, or + the effective rate is at or below -1. + + Notes + ----- + Computes ``periods_per_year * ((1 + effective_rate)**(1/periods_per_year) + - 1)`` using log1p and expm1. Zero and negative rates are supported. + Calculations use float64; Decimal precision is not preserved. Fees are + not included. The output is an annual nominal rate, not a periodic rate. + + Examples + -------- + >>> round(float(nominal_annual_rate(0.06167781186449957, 12)), 6) + 0.06 + """ + rate, periods = _annual_inputs(effective_rate, periods_per_year) + if np.any(rate <= -1): + raise ValueError("effective_rate must be greater than -1") + return _result(np.asarray(periods * np.expm1(np.log1p(rate) / periods))) + + +def compound_value( + principal: npt.ArrayLike, rates: npt.ArrayLike, *, axis: int = -1, +) -> np.float64 | npt.NDArray[np.float64]: + """Grow a balance through a sequence of periodic interest rates. + + Parameters + ---------- + principal : array_like + Initial signed balance. A positive balance produces a positive result. + rates : array_like + Array with at least one dimension containing finite periodic rates + greater than -1. Each entry applies for one period; there are no + intermediate deposits or withdrawals. + axis : int, optional + Period axis, default -1. Other axes represent loans or scenarios. + + Returns + ------- + out : numpy.float64 or ndarray + Final balance, computed as principal times the product of (1 + rates). + Principal broadcasts against the shape remaining after removing axis. + An empty schedule leaves principal unchanged. + + Raises + ------ + ValueError + If inputs are nonfinite, rates are scalar, or any rate is at or below -1. + + Notes + ----- + Rates are per period, not automatically annualized. Unlike fv's cash-flow + convention, this function preserves the sign of principal. Calculations + use float64; Decimal precision is not preserved. Logarithms are used to + accumulate growth. Results outside float64 range can overflow/underflow. + + Examples + -------- + >>> round(float(compound_value(1000, [0.03, 0.04, 0.05])), 2) + 1124.76 + """ + balances = np.asarray(principal, dtype=np.float64) + schedule = np.asarray(rates, dtype=np.float64) + if schedule.ndim == 0: + raise ValueError("rates must have a period axis; use [rate] for one period") + if not np.all(np.isfinite(balances)): + raise ValueError("principal must be finite") + if not np.all(np.isfinite(schedule) & (schedule > -1)): + raise ValueError("rates must be finite and greater than -1") + growth = np.exp(np.sum(np.log1p(schedule), axis=axis)) + return _result(np.asarray(balances * growth)) diff --git a/numpy_financial/meson.build b/numpy_financial/meson.build index 1456721..95b5413 100644 --- a/numpy_financial/meson.build +++ b/numpy_financial/meson.build @@ -8,6 +8,7 @@ py.extension_module( python_sources = [ '__init__.py', '_financial.py', + '_growth.py', '_cfinancial.pyi', 'py.typed', ] diff --git a/numpy_financial/tests/test_growth.py b/numpy_financial/tests/test_growth.py new file mode 100644 index 0000000..9ca1a33 --- /dev/null +++ b/numpy_financial/tests/test_growth.py @@ -0,0 +1,82 @@ +from decimal import Decimal, localcontext + +import numpy as np +import pytest +from numpy.testing import assert_allclose + +import numpy_financial as npf + + +@pytest.mark.parametrize('rate', [-0.03, 0, 1e-14, 0.06]) +@pytest.mark.parametrize('periods', [1, 4, 12, 365]) +def test_rate_conversions(rate, periods): + with localcontext() as context: + context.prec = 60 + expected = float((1 + Decimal(str(rate)) / periods) ** periods - 1) + actual = npf.effective_annual_rate(rate, periods) + assert_allclose(actual, expected, rtol=1e-13, atol=1e-28) + assert_allclose(npf.nominal_annual_rate(actual, periods), rate, + rtol=1e-13, atol=1e-28) + assert np.isscalar(actual) + + +def test_conversion_broadcasting(): + rates = np.array([0.02, 0.04, 0.06])[:, None] + periods = np.array([1, 4, 12, 365]) + result = npf.effective_annual_rate(rates, periods) + assert result.shape == (3, 4) + assert_allclose(result, (1 + rates / periods) ** periods - 1) + + +@pytest.mark.parametrize('function', [npf.effective_annual_rate, + npf.nominal_annual_rate]) +@pytest.mark.parametrize('periods', [0, -1, 1.5, np.nan, np.inf]) +def test_invalid_frequency(function, periods): + with pytest.raises(ValueError, match='positive integers'): + function(0.05, periods) + + +@pytest.mark.parametrize('function,rate,periods', [ + (npf.effective_annual_rate, -12, 12), + (npf.nominal_annual_rate, -1, 12), + (npf.effective_annual_rate, np.nan, 12), + (npf.nominal_annual_rate, np.inf, 12), +]) +def test_invalid_rates(function, rate, periods): + with pytest.raises(ValueError): + function(rate, periods) + + +def test_changing_rates(): + assert_allclose(npf.compound_value(1000, [0.03, 0.04, 0.05]), 1124.76) + assert_allclose(npf.compound_value(-1000, [0.03, 0.04, 0.05]), -1124.76) + + +def test_schedules_broadcast_axis(): + rates = np.array([[0.03, 0.04, 0.05], [-0.01, 0, 0.02]]) + principal = np.array([1000, 2000]) + expected = principal.copy().astype(float) + for period in range(3): + expected *= 1 + rates[:, period] + assert_allclose(npf.compound_value(principal, rates), expected) + assert_allclose(npf.compound_value(principal, rates.T, axis=0), expected) + + +def test_empty_schedule(): + assert_allclose(npf.compound_value([1000, 2000], np.empty((2, 0))), [1000, 2000]) + + +def test_constant_rate_matches_growth(): + assert_allclose(npf.compound_value(25000, np.full(120, 0.04 / 12)), + 25000 * (1 + 0.04 / 12) ** 120) + + +@pytest.mark.parametrize('rates', [[-1], [np.nan], [np.inf], 0.05]) +def test_invalid_schedule(rates): + with pytest.raises(ValueError): + npf.compound_value(1000, rates) + + +def test_invalid_principal(): + with pytest.raises(ValueError): + npf.compound_value(np.inf, [0.03]) From cfd4657459e1504b49b7245b1b6ff0cc9372d06b Mon Sep 17 00:00:00 2001 From: Lucas Morin Date: Sun, 11 Oct 2026 11:35:25 +0200 Subject: [PATCH 2/2] Split portfolio documentation and benchmarks into independent contribution --- benchmarks/benchmarks.py | 24 ----------------- doc/source/compounding.rst | 27 +++++++++++++++++++ doc/source/index.rst | 2 +- doc/source/portfolio.rst | 53 -------------------------------------- 4 files changed, 28 insertions(+), 78 deletions(-) create mode 100644 doc/source/compounding.rst delete mode 100644 doc/source/portfolio.rst diff --git a/benchmarks/benchmarks.py b/benchmarks/benchmarks.py index e42ef1d..8d074f3 100644 --- a/benchmarks/benchmarks.py +++ b/benchmarks/benchmarks.py @@ -29,27 +29,3 @@ def time_for_loop(self, n_cashflows, cashflow_lengths, rates_lengths): def time_broadcast(self, n_cashflows, cashflow_lengths, rates_lengths): npf.npv(self.rates, self.cashflows) - - -class MortgageScenarios: - """Compare per-loan calls with existing pmt broadcasting.""" - - param_names = ["loans", "scenarios"] - params = [(100, 1000, 10000), (1, 5)] - - def setup(self, loans, scenarios): - self.principal = np.linspace(75000, 450000, loans) - self.periods = np.resize([120, 180, 240, 300, 360], loans) - annual_rates = np.linspace(0.03, 0.065, loans) - shifts = np.linspace(-0.01, 0.01, scenarios) if scenarios > 1 else [0] - self.rates = (annual_rates[:, None] + np.asarray(shifts)[None, :]) / 12 - - def time_individual_calls(self, loans, scenarios): - return np.array([ - [npf.pmt(self.rates[i, j], self.periods[i], self.principal[i]) - for j in range(scenarios)] - for i in range(loans) - ]) - - def time_broadcast(self, loans, scenarios): - return npf.pmt(self.rates, self.periods[:, None], self.principal[:, None]) diff --git a/doc/source/compounding.rst b/doc/source/compounding.rst new file mode 100644 index 0000000..4161262 --- /dev/null +++ b/doc/source/compounding.rst @@ -0,0 +1,27 @@ +Rate conversion and compounding +=============================== + +Annual rate conversions +----------------------- + +``effective_annual_rate(0.06, 12)`` converts a 6% nominal annual rate compounded +monthly into its effective annual rate. ``nominal_annual_rate`` reverses the +conversion. Neither calculates fees or regulatory APR. Frequencies must be +positive integers; zero rates and negative rates with positive compounding +factors are supported. + +Changing rates +-------------- + +``compound_value(1000, [0.03, 0.04, 0.05])`` grows an initial balance through +three periods at 3%, 4%, and 5%, producing 1124.76. Rates are per period. There +are no intermediate deposits or withdrawals. Unlike ``fv``, the initial and +final balance have the same sign. + +For an array shaped ``(accounts, periods)``, the default ``axis=-1`` compounds +each account's schedule. A one-dimensional principal array can give each +account a different starting balance. + +These helpers calculate in float64 and reject nonfinite inputs. They are +proposed NumPy-oriented interfaces, not a promise of exact spreadsheet error +handling or Decimal precision. diff --git a/doc/source/index.rst b/doc/source/index.rst index 40dfd87..87c8c66 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -25,7 +25,7 @@ alias is `npf`. For example, :maxdepth: 4 api - portfolio + compounding dev/index diff --git a/doc/source/portfolio.rst b/doc/source/portfolio.rst deleted file mode 100644 index fc0373b..0000000 --- a/doc/source/portfolio.rst +++ /dev/null @@ -1,53 +0,0 @@ -Loans and compounding -===================== - -Multiple loans and rate scenarios ---------------------------------- - -``pmt`` already broadcasts its inputs. Put loans on rows and rate scenarios on -columns. Rates below are nominal annual rates, compounded monthly; fees are -excluded. Payments are returned as negative cash flows for positive loans. - -.. code-block:: python - - import numpy as np - import numpy_financial as npf - - principal = np.array([250000, 15000, 20000]) - months = np.array([300, 48, 48]) - annual_rate = np.array([0.05, 0.08, 0.07]) - rate_shifts = np.array([-0.01, 0, 0.01]) - monthly_rates = (annual_rate[:, None] + rate_shifts[None, :]) / 12 - payments = npf.pmt(monthly_rates, months[:, None], principal[:, None]) - # payments.shape == (3, 3): three loans, three scenarios - -For one rate per loan, pass one-dimensional arrays of the same length. Do not -add a scenario axis unless the cross-product of loans and scenarios is wanted. -For very large portfolios, process slices of loans to limit temporary memory. -Keep full precision internally and round only where a contractual schedule -requires it. - -Annual rate conversions ------------------------ - -``effective_annual_rate(0.06, 12)`` converts a 6% nominal annual rate compounded -monthly into its effective annual rate. ``nominal_annual_rate`` reverses the -conversion. Neither calculates fees or regulatory APR. Frequencies must be -positive integers; zero rates and negative rates with positive compounding -factors are supported. - -Changing rates --------------- - -``compound_value(1000, [0.03, 0.04, 0.05])`` grows an initial balance through -three periods at 3%, 4%, and 5%, producing 1124.76. Rates are per period. There -are no intermediate deposits or withdrawals. Unlike ``fv``, the initial and -final balance have the same sign. - -For an array shaped ``(accounts, periods)``, the default ``axis=-1`` compounds -each account's schedule. A one-dimensional principal array can give each -account a different starting balance. - -These helpers calculate in float64 and reject nonfinite inputs. They are -proposed NumPy-oriented interfaces, not a promise of exact spreadsheet error -handling or Decimal precision.