Skip to content
Closed
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
41 changes: 18 additions & 23 deletions benchmarks/benchmarks.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,34 @@
from decimal import Decimal

import numpy as np
import numpy_financial as npf


class Npv1DCashflow:

param_names = ["cashflow_length"]
params = [
(1, 10, 100, 1000),
]

def __init__(self):
self.cashflows = None

def setup(self, cashflow_length):
rng = np.random.default_rng(0)
self.cashflows = rng.standard_normal(cashflow_length)

def time_1d_cashflow(self, cashflow_length):
npf.npv(0.08, self.cashflows)


class Npv2DCashflows:

param_names = ["n_cashflows", "cashflow_lengths"]
param_names = ["n_cashflows", "cashflow_lengths", "rates_lengths"]
params = [
(1, 10, 100, 1000),
(1, 10, 100, 1000),
(1, 10, 100, 1000),
]

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

def setup(self, n_cashflows, cashflow_lengths):
def setup(self, n_cashflows, cashflow_lengths, rates_lengths):
rng = np.random.default_rng(0)
self.cashflows = rng.standard_normal((n_cashflows, cashflow_lengths))
cf_shape = (n_cashflows, cashflow_lengths)
self.cashflows = rng.standard_normal(cf_shape)
self.rates = rng.standard_normal(rates_lengths)
self.cashflows_decimal = rng.standard_normal(cf_shape, dtype=Decimal)
self.rates_decimal = rng.standard_normal(rates_lengths, dtype=Decimal)

def time_2d_cashflow(self, n_cashflows, cashflow_lengths, rates_lengths):
npf.npv(self.rates, self.cashflows)

def time_2d_cashflow(self, n_cashflows, cashflow_lengths):
npf.npv(0.08, self.cashflows)
def time_2d_cashflow_decimal(self, n_cashflows, cashflow_lengths, rates_lengths):
npf.npv(self.rates_decimal, self.cashflows_decimal)
57 changes: 48 additions & 9 deletions numpy_financial/_financial.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

from decimal import Decimal

import numba as nb
from numba import prange
import numpy as np

__all__ = ['fv', 'pmt', 'nper', 'ipmt', 'ppmt', 'pv', 'rate',
Expand Down Expand Up @@ -825,6 +827,40 @@ def irr(values, *, guess=None, tol=1e-12, maxiter=100, raise_exceptions=False):
return np.nan


@nb.njit(parallel=True)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if it's worth parallelising this function.

def _npv(rates, cashflows, result):
r"""Native version of the ``npv`` function.

Warnings
--------
For internal use only, note that this function performs no error checking.
Comment on lines +832 to +836
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation still needs to be improved; this is the workhorse powering the npv function.

"""

# TODO: Is using `prange` actually faster here?
for i in prange(rates.shape[0]):
for j in prange(cashflows.shape[0]):
acc = 0.0
for t in range(cashflows.shape[1]):
acc += cashflows[j, t] / ((1.0 + rates[i]) ** t)
result[i, j] = acc


@nb.jit(forceobj=True) # Need ``forceobj`` to support decimal.Decimal
def _npv_decimal(rates, cashflows, result):
r"""Version of the ``npv`` function supporting ``decimal.Decimal`` types

Warnings
--------
For internal use only, note that this function performs no error checking.
"""
for i in range(rates.shape[0]):
for j in range(cashflows.shape[0]):
acc = Decimal("0.0")
for t in range(cashflows.shape[1]):
acc += cashflows[j, t] / ((Decimal("1.0") + rates[i]) ** t)
result[i, j] = acc
Comment on lines +848 to +861
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is essentially a copy of _npv with the constants replaced with decimal.Decimals. I've tried doing something along the lines of dtype = Decimal if arr.dtype = np.dtype("O") else arr.dtype but this wouldn't compile in nopython mode. I'm trying to think of a neater way to implement this but haven't been able to think of one yet



def npv(rate, values):
r"""Return the NPV (Net Present Value) of a cash flow series.

Expand Down Expand Up @@ -892,15 +928,18 @@ def npv(rate, values):
3065.22267

"""
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

r = np.atleast_1d(rate)
v = np.atleast_2d(values)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: Add checking of array shapes if they are compatible.

if r.dtype == np.dtype("O") or v.dtype == np.dtype("O"):
out = np.empty(shape=(r.shape[0], v.shape[0]), dtype=Decimal)
_npv_decimal(r, v, out)
else:
out = np.empty(shape=(r.shape[0], v.shape[0]))
_npv(r, v, out)

return out


def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False):
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ packages = [{include = "numpy_financial"}]
[tool.poetry.dependencies]
python = "^3.9"
numpy = "^1.23"
numba = "^0.58.1"


[tool.poetry.group.test.dependencies]
Expand Down
36 changes: 29 additions & 7 deletions tests/test_financial.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# Don't use 'import numpy as np', to avoid accidentally testing
# the versions in numpy instead of numpy_financial.
import numpy
import numpy as np
import pytest
from numpy.testing import (
assert_,
Expand Down Expand Up @@ -164,27 +165,48 @@ def test_rate_maximum_iterations_exception_array(self):
class TestNpv:
def test_npv(self):
assert_almost_equal(
npf.npv(0.05, [-15000, 1500, 2500, 3500, 4500, 6000]),
npf.npv(0.05, [-15000.0, 1500.0, 2500.0, 3500.0, 4500.0, 6000.0]),
122.89, 2)

def test_npv_decimal(self):
assert_equal(
npf.npv(Decimal('0.05'), [-15000, 1500, 2500, 3500, 4500, 6000]),
Decimal('122.894854950942692161628715'))

def test_npv_broadcast(self):
cashflows = [
def test_npv_broadcast_cashflows(self):
cashflows = np.array([
[-15000, 1500, 2500, 3500, 4500, 6000],
[-15000, 1500, 2500, 3500, 4500, 6000],
[-15000, 1500, 2500, 3500, 4500, 6000],
[-15000, 1500, 2500, 3500, 4500, 6000],
]
expected_npvs = [
122.8948549, 122.8948549, 122.8948549, 122.8948549
]
])
expected_npvs = numpy.array([
[122.8948549, 122.8948549, 122.8948549, 122.8948549]
])
actual_npvs = npf.npv(0.05, cashflows)
assert_allclose(actual_npvs, expected_npvs)

@pytest.mark.parametrize("dtype", [Decimal, float])
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: check with other dtypes e.g. float32

def test_npv_broadcast_equals_for_loop(self, dtype):
cashflows_str = [
["-15000.0", "1500.0", "2500.0", "3500.0", "4500.0", "6000.0"],
["-25000.0", "1500.0", "2500.0", "3500.0", "4500.0", "6000.0"],
["-35000.0", "1500.0", "2500.0", "3500.0", "4500.0", "6000.0"],
["-45000.0", "1500.0", "2500.0", "3500.0", "4500.0", "6000.0"],
]
rates_str = ["-0.05", "0.00", "0.05", "0.10", "0.15"]

cashflows = np.array([[dtype(x) for x in cf] for cf in cashflows_str])
rates = np.array([dtype(x) for x in rates_str])

expected = np.empty((len(rates), len(cashflows)), dtype=dtype)
for i, r in enumerate(rates):
for j, cf in enumerate(cashflows):
expected[i, j] = npf.npv(r, cf).item()
Comment on lines +203 to +205
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we're using a for loop to calculate the equivalent to the "broadcasting" operation. This is slow, however I am more confident that it is correct.


actual = npf.npv(rates, cashflows)
assert_equal(actual, expected)


class TestPmt:
def test_pmt_simple(self):
Expand Down