Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2153b60
[TST/CI] Add automated documentation testing when building
MashyBasker Dec 5, 2023
a518eac
BENCH: NPV: Benchmark 2d broadcasting
Kai-Striega Dec 6, 2023
3ea3a6e
TST: NPV: Add tests for NPV
Kai-Striega Dec 6, 2023
f1c2e54
ENH: NPV: Support "gufunc" like behaviour
Kai-Striega Dec 6, 2023
6561c6d
ENH: NPV: Move private array conversion functions to top of file
Kai-Striega Dec 9, 2023
553647c
BENCH: NPV: Reduce benchmark size
Kai-Striega Dec 9, 2023
2d08783
ENH: NPV: Make hot path for native types
Kai-Striega Dec 9, 2023
8c723d2
ENH: NPV: Parallelize native hot-path
Kai-Striega Dec 9, 2023
f58c8df
DOC: NPV: Document new "broadcasting" behaviour
Kai-Striega Dec 9, 2023
e99ed93
REV: Remove Python 3.12 dependency
Kai-Striega Dec 9, 2023
4d329ac
MAINT: Make linting happy and add numba to poetry
Kai-Striega Dec 9, 2023
b126521
MAINT: Refactor output array shape creation into own function
Kai-Striega Dec 9, 2023
04af33f
DOC: NPV: Update documentation to support array arguments
Kai-Striega Dec 9, 2023
b8592cf
MAINT: NPV: Remove typed constants
Kai-Striega Dec 9, 2023
4afacd4
BENCH: NPV: Actually create decimal arrays in benchmarks
Kai-Striega Dec 9, 2023
38e6819
[DOC] Fix doctest failure issue. Resolves issue #94
MashyBasker Dec 9, 2023
3311612
Change round() to np.round()
MashyBasker Dec 9, 2023
0db86ec
DOC: NPV: Document with decimal.Decimal
Kai-Striega Dec 9, 2023
db5ab0a
Merge pull request #96 from numpy/feature/move-npv-to-numba-2
Kai-Striega Dec 9, 2023
8fa019f
[TST/CI] Add automated documentation testing when building
MashyBasker Dec 5, 2023
d95c032
[DOC] Fix doctest failure issue. Resolves issue #94
MashyBasker Dec 9, 2023
7737d4c
Fix linting issue in doctests for PR 94
MashyBasker Dec 10, 2023
8c5e6e7
Fix linting issue after merging changes
MashyBasker Dec 10, 2023
6e8af4c
Fix linting errors and build errors
MashyBasker Dec 10, 2023
996adfb
Fix line too long error
MashyBasker Dec 10, 2023
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
4 changes: 2 additions & 2 deletions .github/workflows/pythonpackage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.9", "3.10", "3.11", "3.12"]
python-version: ["3.9", "3.10", "3.11"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
Expand All @@ -22,4 +22,4 @@ jobs:
poetry install --with=test
- name: Test with pytest
run: |
poetry run pytest
poetry run pytest --doctest-modules
59 changes: 37 additions & 22 deletions benchmarks/benchmarks.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,55 @@
from decimal import Decimal

import numpy as np

import numpy_financial as npf


class Npv1DCashflow:

param_names = ["cashflow_length"]
params = [
(1, 10, 100, 1000),
]

def __init__(self):
self.cashflows = None
def _to_decimal_array_1d(array):
return np.array([Decimal(x) for x in array.tolist()])

def setup(self, cashflow_length):
rng = np.random.default_rng(0)
self.cashflows = rng.standard_normal(cashflow_length)

def time_1d_cashflow(self, cashflow_length):
npf.npv(0.08, self.cashflows)
def _to_decimal_array_2d(array):
decimals = [Decimal(x) for row in array.tolist() for x in row]
return np.array(decimals).reshape(array.shape)


class Npv2DCashflows:
class Npv2D:

param_names = ["n_cashflows", "cashflow_lengths"]
param_names = ["n_cashflows", "cashflow_lengths", "rates_lengths"]
params = [
(1, 10, 100, 1000),
(1, 10, 100, 1000),
(1, 10, 100),
(1, 10, 100),
(1, 10, 100),
]

def __init__(self):
self.rates_decimal = None
self.rates = None
self.cashflows_decimal = None
self.cashflows = None

def setup(self, n_cashflows, cashflow_lengths):
def setup(self, n_cashflows, cashflow_lengths, rates_lengths):
rng = np.random.default_rng(0)
self.cashflows = rng.standard_normal((n_cashflows, cashflow_lengths))
cf_shape = (n_cashflows, cashflow_lengths)
self.cashflows = rng.standard_normal(cf_shape)
self.rates = rng.standard_normal(rates_lengths)
self.cashflows_decimal = _to_decimal_array_2d(self.cashflows)
self.rates_decimal = _to_decimal_array_1d(self.rates)

def time_broadcast(self, n_cashflows, cashflow_lengths, rates_lengths):
npf.npv(self.rates, self.cashflows)

def time_for_loop(self, n_cashflows, cashflow_lengths, rates_lengths):
for rate in self.rates:
for cashflow in self.cashflows:
npf.npv(rate, cashflow)

def time_broadcast_decimal(self, n_cashflows, cashflow_lengths, rates_lengths):
npf.npv(self.rates_decimal, self.cashflows_decimal)

def time_for_loop_decimal(self, n_cashflows, cashflow_lengths, rates_lengths):
for rate in self.rates_decimal:
for cashflow in self.cashflows_decimal:
npf.npv(rate, cashflow)

def time_2d_cashflow(self, n_cashflows, cashflow_lengths):
npf.npv(0.08, self.cashflows)
142 changes: 121 additions & 21 deletions numpy_financial/_financial.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from decimal import Decimal

import numba as nb
import numpy as np

__all__ = ['fv', 'pmt', 'nper', 'ipmt', 'ppmt', 'pv', 'rate',
Expand Down Expand Up @@ -46,6 +47,36 @@ def _convert_when(when):
return [_when_to_num[x] for x in when]


def _return_ufunc_like(array):
try:
# If size of array is one, return scalar
return array.item()
except ValueError:
# Otherwise, return entire array
return array


def _is_object_array(array):
return array.dtype == np.dtype("O")


def _use_decimal_dtype(*arrays):
return any(_is_object_array(array) for array in arrays)


def _to_decimal_array_1d(array):
return np.array([Decimal(x) for x in array.tolist()])


def _to_decimal_array_2d(array):
decimals = [Decimal(x) for row in array.tolist() for x in row]
return np.array(decimals).reshape(array.shape)


def _get_output_array_shape(*arrays):
return tuple(array.shape[0] for array in arrays)


def fv(rate, nper, pmt, pv, when='end'):
"""Compute the future value.

Expand Down Expand Up @@ -115,7 +146,7 @@ def fv(rate, nper, pmt, pv, when='end'):
5% (annually) compounded monthly?

>>> npf.fv(0.05/12, 10*12, -100, -100)
15692.928894335748
15692.92889433575

By convention, the negative sign represents cash flow out (i.e. money not
available today). Thus, saving $100 a month at 5% annual interest leads
Expand All @@ -126,7 +157,7 @@ def fv(rate, nper, pmt, pv, when='end'):

>>> a = np.array((0.05, 0.06, 0.07))/12
>>> npf.fv(a, 10*12, -100, -100)
array([ 15692.92889434, 16569.87435405, 17509.44688102]) # may vary
array([15692.92889434, 16569.87435405, 17509.44688102])

"""
when = _convert_when(when)
Expand Down Expand Up @@ -296,9 +327,9 @@ def nper(rate, pmt, pv, fv=0, when='end'):
... 8000 : 9001 : 1000]))
array([[[ 64.07334877, 74.06368256],
[108.07548412, 127.99022654]],
<BLANKLINE>
[[ 66.12443902, 76.87897353],
[114.70165583, 137.90124779]]])

"""
when = _convert_when(when)
rate, pmt, pv, fv, when = np.broadcast_arrays(rate, pmt, pv, fv, when)
Expand Down Expand Up @@ -561,7 +592,7 @@ def pv(rate, nper, pmt, fv=0, when='end'):

>>> a = np.array((0.05, 0.04, 0.03))/12
>>> npf.pv(a, 10*12, -100, 15692.93)
array([ -100.00067132, -649.26771385, -1273.78633713]) # may vary
array([ -100.00067132, -649.26771385, -1273.78633713])

So, to end up with the same $15692.93 under the same $100 per month
"savings plan," for annual interest rates of 4% and 3%, one would
Expand Down Expand Up @@ -825,14 +856,35 @@ def irr(values, *, guess=None, tol=1e-12, maxiter=100, raise_exceptions=False):
return np.nan


@nb.njit(parallel=True)
def _npv_native(rates, values, out):
for i in nb.prange(rates.shape[0]):
for j in nb.prange(values.shape[0]):
acc = 0.0
for t in range(values.shape[1]):
acc += values[j, t] / ((1.0 + rates[i]) ** t)
out[i, j] = acc


# We require ``forceobj=True`` here to support decimal.Decimal types
@nb.jit(forceobj=True)
def _npv_decimal(rates, values, out):
for i in range(rates.shape[0]):
for j in range(values.shape[0]):
acc = Decimal("0.0")
for t in range(values.shape[1]):
acc += values[j, t] / ((Decimal("1.0") + rates[i]) ** t)
out[i, j] = acc


def npv(rate, values):
r"""Return the NPV (Net Present Value) of a cash flow series.

Parameters
----------
rate : scalar
rate : scalar or array_like shape(K, )
The discount rate.
values : array_like, shape(M, )
values : array_like, shape(M, ) or shape(M, N)
The values of the time series of cash flows. The (fixed) time
interval between cash flow "events" must be the same as that for
which `rate` is given (i.e., if `rate` is per year, then precisely
Expand All @@ -843,9 +895,10 @@ def npv(rate, values):

Returns
-------
out : float
out : float or array shape(K, M)
The NPV of the input cash flow series `values` at the discount
`rate`.
`rate`. `out` follows the ufunc convention of returning scalars
instead of single element arrays.

Warnings
--------
Expand Down Expand Up @@ -878,7 +931,7 @@ def npv(rate, values):
net present value:

>>> rate, cashflows = 0.08, [-40_000, 5_000, 8_000, 12_000, 30_000]
>>> npf.npv(rate, cashflows).round(5)
>>> np.round(npf.npv(rate, cashflows), 5)
3065.22267

It may be preferable to split the projected cashflow into an initial
Expand All @@ -891,16 +944,58 @@ def npv(rate, values):
>>> np.round(npf.npv(rate, cashflows) + initial_cashflow, 5)
3065.22267

The NPV calculation may be applied to several ``rates`` and ``cashflows``
simulatneously. This produces an array of shape
``(len(rates), len(cashflows))``.

>>> rates = [0.00, 0.05, 0.10]
>>> cashflows = [[-4_000, 500, 800], [-5_000, 600, 900]]
>>> npf.npv(rates, cashflows).round(2)
array([[-2700. , -3500. ],
[-2798.19, -3612.24],
[-2884.3 , -3710.74]])

The NPV calculation also supports `decimal.Decimal` types, for example
if using Decimal ``rates``:

>>> rates = [Decimal("0.00"), Decimal("0.05"), Decimal("0.10")]
>>> cashflows = [[-4_000, 500, 800], [-5_000, 600, 900]]
>>> npf.npv(rates, cashflows)
array([[Decimal('-2700.0'), Decimal('-3500.0')],
[Decimal('-2798.185941043083900226757370'),
Decimal('-3612.244897959183673469387756')],
[Decimal('-2884.297520661157024793388430'),
Decimal('-3710.743801652892561983471074')]], dtype=object)

This also works for Decimal cashflows.

"""
rates = np.atleast_1d(rate)
values = np.atleast_2d(values)
timestep_array = np.arange(0, values.shape[1])
npv = (values / (1 + rate) ** timestep_array).sum(axis=1)
try:
# If size of array is one, return scalar
return npv.item()
except ValueError:
# Otherwise, return entire array
return npv

if rates.ndim != 1:
msg = "invalid shape for rates. Rate must be either a scalar or 1d array"
raise ValueError(msg)

if values.ndim != 2:
msg = "invalid shape for values. Values must be either a 1d or 2d array"
raise ValueError(msg)

dtype = Decimal if _use_decimal_dtype(rates, values) else np.float64

if dtype == Decimal:
rates = _to_decimal_array_1d(rates)
values = _to_decimal_array_2d(values)

shape = _get_output_array_shape(rates, values)
out = np.empty(shape=shape, dtype=dtype)

if dtype == Decimal:
_npv_decimal(rates, values, out)
else:
_npv_native(rates, values, out)

return _return_ufunc_like(out)


def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False):
Expand Down Expand Up @@ -966,10 +1061,15 @@ def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False):
Finally, let's explore the situation where all cash flows are positive,
and the `raise_exceptions` parameter is set to True.

>>> npf.mirr([100, 50, 60, 70], 0.10, 0.12, raise_exceptions=True)
NoRealSolutionError: No real solution exists for MIRR since all
cashflows are of the same sign.

>>> npf.mirr([
... 100, 50, 60, 70],
... 0.10, 0.12,
... raise_exceptions=True
... ) #doctest: +NORMALIZE_WHITESPACE
Traceback (most recent call last):
...
numpy_financial._financial.NoRealSolutionError:
No real solution exists for MIRR since all cashflows are of the same sign.
"""
values = np.asarray(values)
n = values.size
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ classifiers = [
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3 :: Only",
"Topic :: Software Development",
"Topic :: Office/Business :: Financial :: Accounting",
Expand All @@ -38,8 +37,9 @@ classifiers = [
packages = [{include = "numpy_financial"}]

[tool.poetry.dependencies]
python = "^3.9"
python = "^3.9,<3.12"
numpy = "^1.23"
numba = "^0.58.1"


[tool.poetry.group.test.dependencies]
Expand Down
Loading