Skip to content

Commit 97a9ed1

Browse files
committed
ENH: npv: Rework npv to mimic broadcasting behaviour
1 parent 6896008 commit 97a9ed1

File tree

4 files changed

+114
-94
lines changed

4 files changed

+114
-94
lines changed

benchmarks/benchmarks.py

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,6 @@
55
import numpy_financial as npf
66

77

8-
def _to_decimal_array_1d(array):
9-
return np.array([Decimal(x) for x in array.tolist()])
10-
11-
12-
def _to_decimal_array_2d(array):
13-
decimals = [Decimal(x) for row in array.tolist() for x in row]
14-
return np.array(decimals).reshape(array.shape)
15-
16-
178
class Npv2D:
189

1910
param_names = ["n_cashflows", "cashflow_lengths", "rates_lengths"]
@@ -24,26 +15,19 @@ class Npv2D:
2415
]
2516

2617
def __init__(self):
27-
self.rates_decimal = None
2818
self.rates = None
29-
self.cashflows_decimal = None
3019
self.cashflows = None
3120

3221
def setup(self, n_cashflows, cashflow_lengths, rates_lengths):
3322
rng = np.random.default_rng(0)
3423
cf_shape = (n_cashflows, cashflow_lengths)
3524
self.cashflows = rng.standard_normal(cf_shape)
3625
self.rates = rng.standard_normal(rates_lengths)
37-
self.cashflows_decimal = _to_decimal_array_2d(self.cashflows)
38-
self.rates_decimal = _to_decimal_array_1d(self.rates)
3926

4027
def time_for_loop(self, n_cashflows, cashflow_lengths, rates_lengths):
4128
for rate in self.rates:
4229
for cashflow in self.cashflows:
4330
npf.npv(rate, cashflow)
4431

45-
def time_for_loop_decimal(self, n_cashflows, cashflow_lengths, rates_lengths):
46-
for rate in self.rates_decimal:
47-
for cashflow in self.cashflows_decimal:
48-
npf.npv(rate, cashflow)
49-
32+
def time_broadcast(self, n_cashflows, cashflow_lengths, rates_lengths):
33+
npf.npv(self.rates, self.cashflows)

numpy_financial/_financial.py

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313

1414
from decimal import Decimal
1515

16+
import numba as nb
1617
import numpy as np
1718

19+
1820
__all__ = ['fv', 'pmt', 'nper', 'ipmt', 'ppmt', 'pv', 'rate',
1921
'irr', 'npv', 'mirr',
2022
'NoRealSolutionError', 'IterationsExceededError']
@@ -35,6 +37,19 @@ class IterationsExceededError(Exception):
3537
"""Maximum number of iterations reached."""
3638

3739

40+
def _get_output_array_shape(*arrays):
41+
return tuple(array.shape[0] for array in arrays)
42+
43+
44+
def _ufunc_like(array):
45+
try:
46+
# If size of array is one, return scalar
47+
return array.item()
48+
except ValueError:
49+
# Otherwise, return entire array
50+
return array.squeeze()
51+
52+
3853
def _convert_when(when):
3954
# Test to see if when has already been converted to ndarray
4055
# This will happen if one function calls another, for example ppmt
@@ -825,6 +840,20 @@ def irr(values, *, guess=None, tol=1e-12, maxiter=100, raise_exceptions=False):
825840
return np.nan
826841

827842

843+
@nb.njit
844+
def _npv_native(rates, values, out):
845+
for i in range(rates.shape[0]):
846+
for j in range(values.shape[0]):
847+
acc = 0.0
848+
for t in range(values.shape[1]):
849+
if rates[i] == -1.0:
850+
acc = np.nan
851+
break
852+
else:
853+
acc += values[j, t] / ((1.0 + rates[i]) ** t)
854+
out[i, j] = acc
855+
856+
828857
def npv(rate, values):
829858
r"""Return the NPV (Net Present Value) of a cash flow series.
830859
@@ -892,16 +921,31 @@ def npv(rate, values):
892921
>>> np.round(npf.npv(rate, cashflows) + initial_cashflow, 5)
893922
3065.22267
894923
924+
The NPV calculation may be applied to several ``rates`` and ``cashflows``
925+
simulatneously. This produces an array of shape ``(len(rates), len(cashflows))``.
926+
>>> rates = [0.00, 0.05, 0.10]
927+
>>> cashflows = [[-4_000, 500, 800], [-5_000, 600, 900]]
928+
>>> npf.npv(rates, cashflows).round(2)
929+
array([[-2700. , -3500. ],
930+
[-2798.19, -3612.24],
931+
[-2884.3 , -3710.74]])
932+
895933
"""
896-
values = np.atleast_2d(values)
897-
timestep_array = np.arange(0, values.shape[1])
898-
npv = (values / (1 + rate) ** timestep_array).sum(axis=1)
899-
try:
900-
# If size of array is one, return scalar
901-
return npv.item()
902-
except ValueError:
903-
# Otherwise, return entire array
904-
return npv
934+
values_inner = np.atleast_2d(values)
935+
rate_inner = np.atleast_1d(rate)
936+
937+
if rate_inner.ndim != 1:
938+
msg = "invalid shape for rates. Rate must be either a scalar or 1d array"
939+
raise ValueError(msg)
940+
941+
if values_inner.ndim != 2:
942+
msg = "invalid shape for values. Values must be either a 1d or 2d array"
943+
raise ValueError(msg)
944+
945+
output_shape = _get_output_array_shape(rate_inner, values_inner)
946+
out = np.empty(output_shape)
947+
_npv_native(rate_inner, values_inner, out)
948+
return _ufunc_like(out)
905949

906950

907951
def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False):

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,12 @@ packages = [{include = "numpy_financial"}]
3939
[tool.poetry.dependencies]
4040
python = "^3.10"
4141
numpy = "^1.23"
42+
numba = "^0.59.1"
4243

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

4649

4750
[tool.poetry.group.docs.dependencies]

tests/test_financial.py

Lines changed: 56 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,45 @@
1111
assert_equal,
1212
assert_raises,
1313
)
14+
from hypothesis import given, settings
15+
import hypothesis.strategies as st
16+
import hypothesis.extra.numpy as npst
1417

1518
import numpy_financial as npf
1619

1720

21+
def float_dtype():
22+
return npst.floating_dtypes(sizes=[32, 64], endianness="<")
23+
24+
25+
def int_dtype():
26+
return npst.integer_dtypes(sizes=[32, 64], endianness="<")
27+
28+
29+
def uint_dtype():
30+
return npst.unsigned_integer_dtypes(sizes=[32, 64], endianness="<")
31+
32+
33+
real_scalar_dtypes = st.one_of(float_dtype(), int_dtype(), uint_dtype())
34+
35+
36+
cashflow_array_strategy = npst.arrays(
37+
dtype=real_scalar_dtypes,
38+
shape=npst.array_shapes(min_dims=1, max_dims=2, min_side=0, max_side=25),
39+
)
40+
cashflow_list_strategy = cashflow_array_strategy.map(lambda x: x.tolist())
41+
42+
cashflow_array_like_strategy = st.one_of(
43+
cashflow_array_strategy,
44+
cashflow_list_strategy,
45+
)
46+
47+
short_scalar_array = npst.arrays(
48+
dtype=real_scalar_dtypes,
49+
shape=npst.array_shapes(min_dims=0, max_dims=1, min_side=0, max_side=5),
50+
)
51+
52+
1853
def assert_decimal_close(actual, expected, tol=Decimal("1e-7")):
1954
# Check if both actual and expected are iterable (like arrays)
2055
if hasattr(actual, "__iter__") and hasattr(expected, "__iter__"):
@@ -244,11 +279,27 @@ def test_npv(self):
244279
rtol=1e-2,
245280
)
246281

247-
def test_npv_decimal(self):
248-
assert_equal(
249-
npf.npv(Decimal("0.05"), [-15000, 1500, 2500, 3500, 4500, 6000]),
250-
Decimal("122.894854950942692161628715"),
251-
)
282+
@given(rates=short_scalar_array, values=cashflow_array_strategy)
283+
@settings(deadline=None)
284+
def test_fuzz(self, rates, values):
285+
npf.npv(rates, values)
286+
287+
@pytest.mark.parametrize("rates", ([[1, 2, 3]], numpy.empty(shape=(1, 1, 1))))
288+
def test_invalid_rates_shape(self, rates):
289+
cashflows = [1, 2, 3]
290+
with pytest.raises(ValueError):
291+
npf.npv(rates, cashflows)
292+
293+
@pytest.mark.parametrize("cf", ([[[1, 2, 3]]], numpy.empty(shape=(1, 1, 1))))
294+
def test_invalid_cashflows_shape(self, cf):
295+
rates = [1, 2, 3]
296+
with pytest.raises(ValueError):
297+
npf.npv(rates, cf)
298+
299+
@pytest.mark.parametrize("rate", (-1, -1.0))
300+
def test_rate_of_negative_one_returns_nan(self, rate):
301+
cashflow = numpy.arange(5)
302+
assert numpy.isnan(npf.npv(rate, cashflow))
252303

253304

254305
class TestPmt:
@@ -336,68 +387,6 @@ def test_mirr(self, values, finance_rate, reinvest_rate, expected):
336387
else:
337388
assert_(numpy.isnan(result))
338389

339-
@pytest.mark.parametrize("number_type", [Decimal, float])
340-
@pytest.mark.parametrize(
341-
"args, expected",
342-
[
343-
(
344-
{
345-
"values": [
346-
"-4500",
347-
"-800",
348-
"800",
349-
"800",
350-
"600",
351-
"600",
352-
"800",
353-
"800",
354-
"700",
355-
"3000",
356-
],
357-
"finance_rate": "0.08",
358-
"reinvest_rate": "0.055",
359-
},
360-
"0.066597175031553548874239618",
361-
),
362-
(
363-
{
364-
"values": ["-120000", "39000", "30000", "21000", "37000", "46000"],
365-
"finance_rate": "0.10",
366-
"reinvest_rate": "0.12",
367-
},
368-
"0.126094130365905145828421880",
369-
),
370-
(
371-
{
372-
"values": ["100", "200", "-50", "300", "-200"],
373-
"finance_rate": "0.05",
374-
"reinvest_rate": "0.06",
375-
},
376-
"0.342823387842176663647819868",
377-
),
378-
(
379-
{
380-
"values": ["39000", "30000", "21000", "37000", "46000"],
381-
"finance_rate": "0.10",
382-
"reinvest_rate": "0.12",
383-
},
384-
numpy.nan,
385-
),
386-
],
387-
)
388-
def test_mirr_decimal(self, number_type, args, expected):
389-
values = [number_type(v) for v in args["values"]]
390-
result = npf.mirr(
391-
values,
392-
number_type(args["finance_rate"]),
393-
number_type(args["reinvest_rate"]),
394-
)
395-
396-
if expected is not numpy.nan:
397-
assert_decimal_close(result, number_type(expected), tol=1e-15)
398-
else:
399-
assert numpy.isnan(result)
400-
401390
def test_mirr_no_real_solution_exception(self):
402391
# Test that if there is no solution because all the cashflows
403392
# have the same sign, then npf.mirr returns NoRealSolutionException

0 commit comments

Comments
 (0)