Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: npv: Rework npv function to mimic broadcasting #108

Merged
merged 3 commits into from
Mar 21, 2024
Merged
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
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
Loading