Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions doc/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ API Documentation
ppmt
pv
rate
effective_annual_rate
nominal_annual_rate
compound_value
27 changes: 27 additions & 0 deletions doc/source/compounding.rst
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions doc/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ alias is `npf`. For example,
:maxdepth: 4

api
compounding
dev/index


Expand Down
1 change: 1 addition & 0 deletions numpy_financial/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
__version__ = "1.2.0.dev0"

from ._financial import *
from ._growth import *
157 changes: 157 additions & 0 deletions numpy_financial/_growth.py
Original file line number Diff line number Diff line change
@@ -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))
1 change: 1 addition & 0 deletions numpy_financial/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ py.extension_module(
python_sources = [
'__init__.py',
'_financial.py',
'_growth.py',
'_cfinancial.pyi',
'py.typed',
]
Expand Down
82 changes: 82 additions & 0 deletions numpy_financial/tests/test_growth.py
Original file line number Diff line number Diff line change
@@ -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])