Skip to content

Commit db5ab0a

Browse files
authored
Merge pull request #96 from numpy/feature/move-npv-to-numba-2
ENH: NPV: Support calculation for vectors of rates and cashflows
2 parents f202ec3 + 0db86ec commit db5ab0a

File tree

5 files changed

+186
-43
lines changed

5 files changed

+186
-43
lines changed

.github/workflows/pythonpackage.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ jobs:
88
strategy:
99
matrix:
1010
os: [ubuntu-latest, macos-latest, windows-latest]
11-
python-version: ["3.9", "3.10", "3.11", "3.12"]
11+
python-version: ["3.9", "3.10", "3.11"]
1212
steps:
1313
- uses: actions/checkout@v3
1414
- name: Set up Python ${{ matrix.python-version }}

benchmarks/benchmarks.py

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,55 @@
1+
from decimal import Decimal
2+
13
import numpy as np
24

35
import numpy_financial as npf
46

57

6-
class Npv1DCashflow:
7-
8-
param_names = ["cashflow_length"]
9-
params = [
10-
(1, 10, 100, 1000),
11-
]
12-
13-
def __init__(self):
14-
self.cashflows = None
8+
def _to_decimal_array_1d(array):
9+
return np.array([Decimal(x) for x in array.tolist()])
1510

16-
def setup(self, cashflow_length):
17-
rng = np.random.default_rng(0)
18-
self.cashflows = rng.standard_normal(cashflow_length)
1911

20-
def time_1d_cashflow(self, cashflow_length):
21-
npf.npv(0.08, self.cashflows)
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)
2215

2316

24-
class Npv2DCashflows:
17+
class Npv2D:
2518

26-
param_names = ["n_cashflows", "cashflow_lengths"]
19+
param_names = ["n_cashflows", "cashflow_lengths", "rates_lengths"]
2720
params = [
28-
(1, 10, 100, 1000),
29-
(1, 10, 100, 1000),
21+
(1, 10, 100),
22+
(1, 10, 100),
23+
(1, 10, 100),
3024
]
3125

3226
def __init__(self):
27+
self.rates_decimal = None
28+
self.rates = None
29+
self.cashflows_decimal = None
3330
self.cashflows = None
3431

35-
def setup(self, n_cashflows, cashflow_lengths):
32+
def setup(self, n_cashflows, cashflow_lengths, rates_lengths):
3633
rng = np.random.default_rng(0)
37-
self.cashflows = rng.standard_normal((n_cashflows, cashflow_lengths))
34+
cf_shape = (n_cashflows, cashflow_lengths)
35+
self.cashflows = rng.standard_normal(cf_shape)
36+
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)
39+
40+
def time_broadcast(self, n_cashflows, cashflow_lengths, rates_lengths):
41+
npf.npv(self.rates, self.cashflows)
42+
43+
def time_for_loop(self, n_cashflows, cashflow_lengths, rates_lengths):
44+
for rate in self.rates:
45+
for cashflow in self.cashflows:
46+
npf.npv(rate, cashflow)
47+
48+
def time_broadcast_decimal(self, n_cashflows, cashflow_lengths, rates_lengths):
49+
npf.npv(self.rates_decimal, self.cashflows_decimal)
50+
51+
def time_for_loop_decimal(self, n_cashflows, cashflow_lengths, rates_lengths):
52+
for rate in self.rates_decimal:
53+
for cashflow in self.cashflows_decimal:
54+
npf.npv(rate, cashflow)
3855

39-
def time_2d_cashflow(self, n_cashflows, cashflow_lengths):
40-
npf.npv(0.08, self.cashflows)

numpy_financial/_financial.py

Lines changed: 107 additions & 12 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',
@@ -46,6 +47,36 @@ def _convert_when(when):
4647
return [_when_to_num[x] for x in when]
4748

4849

50+
def _return_ufunc_like(array):
51+
try:
52+
# If size of array is one, return scalar
53+
return array.item()
54+
except ValueError:
55+
# Otherwise, return entire array
56+
return array
57+
58+
59+
def _is_object_array(array):
60+
return array.dtype == np.dtype("O")
61+
62+
63+
def _use_decimal_dtype(*arrays):
64+
return any(_is_object_array(array) for array in arrays)
65+
66+
67+
def _to_decimal_array_1d(array):
68+
return np.array([Decimal(x) for x in array.tolist()])
69+
70+
71+
def _to_decimal_array_2d(array):
72+
decimals = [Decimal(x) for row in array.tolist() for x in row]
73+
return np.array(decimals).reshape(array.shape)
74+
75+
76+
def _get_output_array_shape(*arrays):
77+
return tuple(array.shape[0] for array in arrays)
78+
79+
4980
def fv(rate, nper, pmt, pv, when='end'):
5081
"""Compute the future value.
5182
@@ -825,14 +856,35 @@ def irr(values, *, guess=None, tol=1e-12, maxiter=100, raise_exceptions=False):
825856
return np.nan
826857

827858

859+
@nb.njit(parallel=True)
860+
def _npv_native(rates, values, out):
861+
for i in nb.prange(rates.shape[0]):
862+
for j in nb.prange(values.shape[0]):
863+
acc = 0.0
864+
for t in range(values.shape[1]):
865+
acc += values[j, t] / ((1.0 + rates[i]) ** t)
866+
out[i, j] = acc
867+
868+
869+
# We require ``forceobj=True`` here to support decimal.Decimal types
870+
@nb.jit(forceobj=True)
871+
def _npv_decimal(rates, values, out):
872+
for i in range(rates.shape[0]):
873+
for j in range(values.shape[0]):
874+
acc = Decimal("0.0")
875+
for t in range(values.shape[1]):
876+
acc += values[j, t] / ((Decimal("1.0") + rates[i]) ** t)
877+
out[i, j] = acc
878+
879+
828880
def npv(rate, values):
829881
r"""Return the NPV (Net Present Value) of a cash flow series.
830882
831883
Parameters
832884
----------
833-
rate : scalar
885+
rate : scalar or array_like shape(K, )
834886
The discount rate.
835-
values : array_like, shape(M, )
887+
values : array_like, shape(M, ) or shape(M, N)
836888
The values of the time series of cash flows. The (fixed) time
837889
interval between cash flow "events" must be the same as that for
838890
which `rate` is given (i.e., if `rate` is per year, then precisely
@@ -843,9 +895,10 @@ def npv(rate, values):
843895
844896
Returns
845897
-------
846-
out : float
898+
out : float or array shape(K, M)
847899
The NPV of the input cash flow series `values` at the discount
848-
`rate`.
900+
`rate`. `out` follows the ufunc convention of returning scalars
901+
instead of single element arrays.
849902
850903
Warnings
851904
--------
@@ -891,16 +944,58 @@ def npv(rate, values):
891944
>>> np.round(npf.npv(rate, cashflows) + initial_cashflow, 5)
892945
3065.22267
893946
947+
The NPV calculation may be applied to several ``rates`` and ``cashflows``
948+
simulatneously. This produces an array of shape
949+
``(len(rates), len(cashflows))``.
950+
951+
>>> rates = [0.00, 0.05, 0.10]
952+
>>> cashflows = [[-4_000, 500, 800], [-5_000, 600, 900]]
953+
>>> npf.npv(rates, cashflows).round(2)
954+
array([[-2700. , -3500. ],
955+
[-2798.19, -3612.24],
956+
[-2884.3 , -3710.74]])
957+
958+
The NPV calculation also supports `decimal.Decimal` types, for example
959+
if using Decimal ``rates``:
960+
961+
>>> rates = [Decimal("0.00"), Decimal("0.05"), Decimal("0.10")]
962+
>>> cashflows = [[-4_000, 500, 800], [-5_000, 600, 900]]
963+
>>> npf.npv(rates, cashflows)
964+
array([[Decimal('-2700.0'), Decimal('-3500.0')],
965+
[Decimal('-2798.185941043083900226757370'),
966+
Decimal('-3612.244897959183673469387756')],
967+
[Decimal('-2884.297520661157024793388430'),
968+
Decimal('-3710.743801652892561983471074')]], dtype=object)
969+
970+
This also works for Decimal cashflows.
971+
894972
"""
973+
rates = np.atleast_1d(rate)
895974
values = np.atleast_2d(values)
896-
timestep_array = np.arange(0, values.shape[1])
897-
npv = (values / (1 + rate) ** timestep_array).sum(axis=1)
898-
try:
899-
# If size of array is one, return scalar
900-
return npv.item()
901-
except ValueError:
902-
# Otherwise, return entire array
903-
return npv
975+
976+
if rates.ndim != 1:
977+
msg = "invalid shape for rates. Rate must be either a scalar or 1d array"
978+
raise ValueError(msg)
979+
980+
if values.ndim != 2:
981+
msg = "invalid shape for values. Values must be either a 1d or 2d array"
982+
raise ValueError(msg)
983+
984+
dtype = Decimal if _use_decimal_dtype(rates, values) else np.float64
985+
986+
if dtype == Decimal:
987+
rates = _to_decimal_array_1d(rates)
988+
values = _to_decimal_array_2d(values)
989+
990+
shape = _get_output_array_shape(rates, values)
991+
out = np.empty(shape=shape, dtype=dtype)
992+
993+
if dtype == Decimal:
994+
_npv_decimal(rates, values, out)
995+
else:
996+
_npv_native(rates, values, out)
997+
998+
return _return_ufunc_like(out)
904999

9051000

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

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ classifiers = [
2424
"Programming Language :: Python :: 3.9",
2525
"Programming Language :: Python :: 3.10",
2626
"Programming Language :: Python :: 3.11",
27-
"Programming Language :: Python :: 3.12",
2827
"Programming Language :: Python :: 3 :: Only",
2928
"Topic :: Software Development",
3029
"Topic :: Office/Business :: Financial :: Accounting",
@@ -38,8 +37,9 @@ classifiers = [
3837
packages = [{include = "numpy_financial"}]
3938

4039
[tool.poetry.dependencies]
41-
python = "^3.9"
40+
python = "^3.9,<3.12"
4241
numpy = "^1.23"
42+
numba = "^0.58.1"
4343

4444

4545
[tool.poetry.group.test.dependencies]

tests/test_financial.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ def test_rate_maximum_iterations_exception_array(self):
164164
class TestNpv:
165165
def test_npv(self):
166166
assert_almost_equal(
167-
npf.npv(0.05, [-15000, 1500, 2500, 3500, 4500, 6000]),
167+
npf.npv(0.05, [-15000.0, 1500.0, 2500.0, 3500.0, 4500.0, 6000.0]),
168168
122.89, 2)
169169

170170
def test_npv_decimal(self):
@@ -174,17 +174,50 @@ def test_npv_decimal(self):
174174

175175
def test_npv_broadcast(self):
176176
cashflows = [
177-
[-15000, 1500, 2500, 3500, 4500, 6000],
178-
[-15000, 1500, 2500, 3500, 4500, 6000],
179-
[-15000, 1500, 2500, 3500, 4500, 6000],
180-
[-15000, 1500, 2500, 3500, 4500, 6000],
177+
[-15000.0, 1500.0, 2500.0, 3500.0, 4500.0, 6000.0],
178+
[-15000.0, 1500.0, 2500.0, 3500.0, 4500.0, 6000.0],
179+
[-15000.0, 1500.0, 2500.0, 3500.0, 4500.0, 6000.0],
180+
[-15000.0, 1500.0, 2500.0, 3500.0, 4500.0, 6000.0],
181181
]
182182
expected_npvs = [
183-
122.8948549, 122.8948549, 122.8948549, 122.8948549
183+
[122.8948549, 122.8948549, 122.8948549, 122.8948549]
184184
]
185185
actual_npvs = npf.npv(0.05, cashflows)
186186
assert_allclose(actual_npvs, expected_npvs)
187187

188+
@pytest.mark.parametrize("dtype", [Decimal, float])
189+
def test_npv_broadcast_equals_for_loop(self, dtype):
190+
cashflows_str = [
191+
["-15000.0", "1500.0", "2500.0", "3500.0", "4500.0", "6000.0"],
192+
["-25000.0", "1500.0", "2500.0", "3500.0", "4500.0", "6000.0"],
193+
["-35000.0", "1500.0", "2500.0", "3500.0", "4500.0", "6000.0"],
194+
["-45000.0", "1500.0", "2500.0", "3500.0", "4500.0", "6000.0"],
195+
]
196+
rates_str = ["-0.05", "0.00", "0.05", "0.10", "0.15"]
197+
198+
cashflows = numpy.array([[dtype(x) for x in cf] for cf in cashflows_str])
199+
rates = numpy.array([dtype(x) for x in rates_str])
200+
201+
expected = numpy.empty((len(rates), len(cashflows)), dtype=dtype)
202+
for i, r in enumerate(rates):
203+
for j, cf in enumerate(cashflows):
204+
expected[i, j] = npf.npv(r, cf)
205+
206+
actual = npf.npv(rates, cashflows)
207+
assert_equal(actual, expected)
208+
209+
@pytest.mark.parametrize("rates", ([[1, 2, 3]], numpy.empty(shape=(1,1,1))))
210+
def test_invalid_rates_shape(self, rates):
211+
cashflows = [1, 2, 3]
212+
with pytest.raises(ValueError):
213+
npf.npv(rates, cashflows)
214+
215+
@pytest.mark.parametrize("cf", ([[[1, 2, 3]]], numpy.empty(shape=(1, 1, 1))))
216+
def test_invalid_cashflows_shape(self, cf):
217+
rates = [1, 2, 3]
218+
with pytest.raises(ValueError):
219+
npf.npv(rates, cf)
220+
188221

189222
class TestPmt:
190223
def test_pmt_simple(self):

0 commit comments

Comments
 (0)