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/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 352631f..87c8c66 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -25,6 +25,7 @@ alias is `npf`. For example, :maxdepth: 4 api + compounding dev/index 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])