Skip to content

Commit 8232cb4

Browse files
authored
Merge pull request #108 from Kai-Striega/enh/broadcast-rework
ENH: npv: Rework npv function to mimic broadcasting
2 parents 6896008 + 6b54195 commit 8232cb4

File tree

4 files changed

+114
-96
lines changed

4 files changed

+114
-96
lines changed

benchmarks/benchmarks.py

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,8 @@
1-
from decimal import Decimal
2-
31
import numpy as np
42

53
import numpy_financial as npf
64

75

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-
176
class Npv2D:
187

198
param_names = ["n_cashflows", "cashflow_lengths", "rates_lengths"]
@@ -24,26 +13,19 @@ class Npv2D:
2413
]
2514

2615
def __init__(self):
27-
self.rates_decimal = None
2816
self.rates = None
29-
self.cashflows_decimal = None
3017
self.cashflows = None
3118

3219
def setup(self, n_cashflows, cashflow_lengths, rates_lengths):
3320
rng = np.random.default_rng(0)
3421
cf_shape = (n_cashflows, cashflow_lengths)
3522
self.cashflows = rng.standard_normal(cf_shape)
3623
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)
3924

4025
def time_for_loop(self, n_cashflows, cashflow_lengths, rates_lengths):
4126
for rate in self.rates:
4227
for cashflow in self.cashflows:
4328
npf.npv(rate, cashflow)
4429

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-
30+
def time_broadcast(self, n_cashflows, cashflow_lengths, rates_lengths):
31+
npf.npv(self.rates, self.cashflows)

numpy_financial/_financial.py

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from decimal import Decimal
1515

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

1819
__all__ = ['fv', 'pmt', 'nper', 'ipmt', 'ppmt', 'pv', 'rate',
@@ -35,6 +36,19 @@ class IterationsExceededError(Exception):
3536
"""Maximum number of iterations reached."""
3637

3738

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

827841

842+
@nb.njit
843+
def _npv_native(rates, values, out):
844+
for i in range(rates.shape[0]):
845+
for j in range(values.shape[0]):
846+
acc = 0.0
847+
for t in range(values.shape[1]):
848+
if rates[i] == -1.0:
849+
acc = np.nan
850+
break
851+
else:
852+
acc += values[j, t] / ((1.0 + rates[i]) ** t)
853+
out[i, j] = acc
854+
855+
828856
def npv(rate, values):
829857
r"""Return the NPV (Net Present Value) of a cash flow series.
830858
@@ -892,16 +920,31 @@ def npv(rate, values):
892920
>>> np.round(npf.npv(rate, cashflows) + initial_cashflow, 5)
893921
3065.22267
894922
923+
The NPV calculation may be applied to several ``rates`` and ``cashflows``
924+
simulatneously. This produces an array of shape ``(len(rates), len(cashflows))``.
925+
>>> rates = [0.00, 0.05, 0.10]
926+
>>> cashflows = [[-4_000, 500, 800], [-5_000, 600, 900]]
927+
>>> npf.npv(rates, cashflows).round(2)
928+
array([[-2700. , -3500. ],
929+
[-2798.19, -3612.24],
930+
[-2884.3 , -3710.74]])
931+
895932
"""
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
933+
values_inner = np.atleast_2d(values)
934+
rate_inner = np.atleast_1d(rate)
935+
936+
if rate_inner.ndim != 1:
937+
msg = "invalid shape for rates. Rate must be either a scalar or 1d array"
938+
raise ValueError(msg)
939+
940+
if values_inner.ndim != 2:
941+
msg = "invalid shape for values. Values must be either a 1d or 2d array"
942+
raise ValueError(msg)
943+
944+
output_shape = _get_output_array_shape(rate_inner, values_inner)
945+
out = np.empty(output_shape)
946+
_npv_native(rate_inner, values_inner, out)
947+
return _ufunc_like(out)
905948

906949

907950
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: 57 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import math
22
from decimal import Decimal
33

4+
import hypothesis.extra.numpy as npst
5+
import hypothesis.strategies as st
6+
47
# Don't use 'import numpy as np', to avoid accidentally testing
58
# the versions in numpy instead of numpy_financial.
69
import numpy
710
import pytest
11+
from hypothesis import given, settings
812
from numpy.testing import (
913
assert_,
1014
assert_allclose,
@@ -15,6 +19,38 @@
1519
import numpy_financial as npf
1620

1721

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

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-
)
283+
@given(rates=short_scalar_array, values=cashflow_array_strategy)
284+
@settings(deadline=None)
285+
def test_fuzz(self, rates, values):
286+
npf.npv(rates, values)
287+
288+
@pytest.mark.parametrize("rates", ([[1, 2, 3]], numpy.empty(shape=(1, 1, 1))))
289+
def test_invalid_rates_shape(self, rates):
290+
cashflows = [1, 2, 3]
291+
with pytest.raises(ValueError):
292+
npf.npv(rates, cashflows)
293+
294+
@pytest.mark.parametrize("cf", ([[[1, 2, 3]]], numpy.empty(shape=(1, 1, 1))))
295+
def test_invalid_cashflows_shape(self, cf):
296+
rates = [1, 2, 3]
297+
with pytest.raises(ValueError):
298+
npf.npv(rates, cf)
299+
300+
@pytest.mark.parametrize("rate", (-1, -1.0))
301+
def test_rate_of_negative_one_returns_nan(self, rate):
302+
cashflow = numpy.arange(5)
303+
assert numpy.isnan(npf.npv(rate, cashflow))
252304

253305

254306
class TestPmt:
@@ -336,68 +388,6 @@ def test_mirr(self, values, finance_rate, reinvest_rate, expected):
336388
else:
337389
assert_(numpy.isnan(result))
338390

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-
401391
def test_mirr_no_real_solution_exception(self):
402392
# Test that if there is no solution because all the cashflows
403393
# have the same sign, then npf.mirr returns NoRealSolutionException

0 commit comments

Comments
 (0)