Skip to content

Commit

Permalink
Merge pull request #108 from Kai-Striega/enh/broadcast-rework
Browse files Browse the repository at this point in the history
ENH: npv: Rework npv function to mimic broadcasting
  • Loading branch information
Kai-Striega authored Mar 21, 2024
2 parents 6896008 + 6b54195 commit 8232cb4
Show file tree
Hide file tree
Showing 4 changed files with 114 additions and 96 deletions.
22 changes: 2 additions & 20 deletions benchmarks/benchmarks.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,8 @@
from decimal import Decimal

import numpy as np

import numpy_financial as npf


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)


class Npv2D:

param_names = ["n_cashflows", "cashflow_lengths", "rates_lengths"]
Expand All @@ -24,26 +13,19 @@ class Npv2D:
]

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

def setup(self, n_cashflows, cashflow_lengths, rates_lengths):
rng = np.random.default_rng(0)
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_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_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_broadcast(self, n_cashflows, cashflow_lengths, rates_lengths):
npf.npv(self.rates, self.cashflows)
61 changes: 52 additions & 9 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 All @@ -35,6 +36,19 @@ class IterationsExceededError(Exception):
"""Maximum number of iterations reached."""


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


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


def _convert_when(when):
# Test to see if when has already been converted to ndarray
# This will happen if one function calls another, for example ppmt
Expand Down Expand Up @@ -825,6 +839,20 @@ def irr(values, *, guess=None, tol=1e-12, maxiter=100, raise_exceptions=False):
return np.nan


@nb.njit
def _npv_native(rates, values, out):
for i in range(rates.shape[0]):
for j in range(values.shape[0]):
acc = 0.0
for t in range(values.shape[1]):
if rates[i] == -1.0:
acc = np.nan
break
else:
acc += values[j, t] / ((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.
Expand Down Expand Up @@ -892,16 +920,31 @@ 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]])
"""
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
values_inner = np.atleast_2d(values)
rate_inner = np.atleast_1d(rate)

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

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

output_shape = _get_output_array_shape(rate_inner, values_inner)
out = np.empty(output_shape)
_npv_native(rate_inner, values_inner, out)
return _ufunc_like(out)


def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False):
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,12 @@ packages = [{include = "numpy_financial"}]
[tool.poetry.dependencies]
python = "^3.10"
numpy = "^1.23"
numba = "^0.59.1"

[tool.poetry.group.test.dependencies]
pytest = "^8.0"
hypothesis = {extras = ["numpy"], version = "^6.99.11"}
pytest-xdist = {extras = ["psutil"], version = "^3.5.0"}


[tool.poetry.group.docs.dependencies]
Expand Down
124 changes: 57 additions & 67 deletions tests/test_financial.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import math
from decimal import Decimal

import hypothesis.extra.numpy as npst
import hypothesis.strategies as st

# Don't use 'import numpy as np', to avoid accidentally testing
# the versions in numpy instead of numpy_financial.
import numpy
import pytest
from hypothesis import given, settings
from numpy.testing import (
assert_,
assert_allclose,
Expand All @@ -15,6 +19,38 @@
import numpy_financial as npf


def float_dtype():
return npst.floating_dtypes(sizes=[32, 64], endianness="<")


def int_dtype():
return npst.integer_dtypes(sizes=[32, 64], endianness="<")


def uint_dtype():
return npst.unsigned_integer_dtypes(sizes=[32, 64], endianness="<")


real_scalar_dtypes = st.one_of(float_dtype(), int_dtype(), uint_dtype())


cashflow_array_strategy = npst.arrays(
dtype=real_scalar_dtypes,
shape=npst.array_shapes(min_dims=1, max_dims=2, min_side=0, max_side=25),
)
cashflow_list_strategy = cashflow_array_strategy.map(lambda x: x.tolist())

cashflow_array_like_strategy = st.one_of(
cashflow_array_strategy,
cashflow_list_strategy,
)

short_scalar_array = npst.arrays(
dtype=real_scalar_dtypes,
shape=npst.array_shapes(min_dims=0, max_dims=1, min_side=0, max_side=5),
)


def assert_decimal_close(actual, expected, tol=Decimal("1e-7")):
# Check if both actual and expected are iterable (like arrays)
if hasattr(actual, "__iter__") and hasattr(expected, "__iter__"):
Expand Down Expand Up @@ -244,11 +280,27 @@ def test_npv(self):
rtol=1e-2,
)

def test_npv_decimal(self):
assert_equal(
npf.npv(Decimal("0.05"), [-15000, 1500, 2500, 3500, 4500, 6000]),
Decimal("122.894854950942692161628715"),
)
@given(rates=short_scalar_array, values=cashflow_array_strategy)
@settings(deadline=None)
def test_fuzz(self, rates, values):
npf.npv(rates, values)

@pytest.mark.parametrize("rates", ([[1, 2, 3]], numpy.empty(shape=(1, 1, 1))))
def test_invalid_rates_shape(self, rates):
cashflows = [1, 2, 3]
with pytest.raises(ValueError):
npf.npv(rates, cashflows)

@pytest.mark.parametrize("cf", ([[[1, 2, 3]]], numpy.empty(shape=(1, 1, 1))))
def test_invalid_cashflows_shape(self, cf):
rates = [1, 2, 3]
with pytest.raises(ValueError):
npf.npv(rates, cf)

@pytest.mark.parametrize("rate", (-1, -1.0))
def test_rate_of_negative_one_returns_nan(self, rate):
cashflow = numpy.arange(5)
assert numpy.isnan(npf.npv(rate, cashflow))


class TestPmt:
Expand Down Expand Up @@ -336,68 +388,6 @@ def test_mirr(self, values, finance_rate, reinvest_rate, expected):
else:
assert_(numpy.isnan(result))

@pytest.mark.parametrize("number_type", [Decimal, float])
@pytest.mark.parametrize(
"args, expected",
[
(
{
"values": [
"-4500",
"-800",
"800",
"800",
"600",
"600",
"800",
"800",
"700",
"3000",
],
"finance_rate": "0.08",
"reinvest_rate": "0.055",
},
"0.066597175031553548874239618",
),
(
{
"values": ["-120000", "39000", "30000", "21000", "37000", "46000"],
"finance_rate": "0.10",
"reinvest_rate": "0.12",
},
"0.126094130365905145828421880",
),
(
{
"values": ["100", "200", "-50", "300", "-200"],
"finance_rate": "0.05",
"reinvest_rate": "0.06",
},
"0.342823387842176663647819868",
),
(
{
"values": ["39000", "30000", "21000", "37000", "46000"],
"finance_rate": "0.10",
"reinvest_rate": "0.12",
},
numpy.nan,
),
],
)
def test_mirr_decimal(self, number_type, args, expected):
values = [number_type(v) for v in args["values"]]
result = npf.mirr(
values,
number_type(args["finance_rate"]),
number_type(args["reinvest_rate"]),
)

if expected is not numpy.nan:
assert_decimal_close(result, number_type(expected), tol=1e-15)
else:
assert numpy.isnan(result)

def test_mirr_no_real_solution_exception(self):
# Test that if there is no solution because all the cashflows
# have the same sign, then npf.mirr returns NoRealSolutionException
Expand Down

0 comments on commit 8232cb4

Please sign in to comment.