Skip to content

Commit

Permalink
Merge pull request #125 from Kai-Striega/broadcast-rework/mirr
Browse files Browse the repository at this point in the history
ENH: mirr: Mimic broadcasting
  • Loading branch information
Kai-Striega authored Jul 6, 2024
2 parents 3f67c27 + 88b6fd6 commit 3263e71
Show file tree
Hide file tree
Showing 3 changed files with 133 additions and 66 deletions.
67 changes: 46 additions & 21 deletions numpy_financial/_financial.py
Original file line number Diff line number Diff line change
Expand Up @@ -963,12 +963,12 @@ def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False):
Parameters
----------
values : array_like
values : array_like, 1D or 2D
Cash flows, where the first value is considered a sunk cost at time zero.
It must contain at least one positive and one negative value.
finance_rate : scalar
finance_rate : scalar or 1D array
Interest rate paid on the cash flows.
reinvest_rate : scalar
reinvest_rate : scalar or D array
Interest rate received on the cash flows upon reinvestment.
raise_exceptions: bool, optional
Flag to raise an exception when the MIRR cannot be computed due to
Expand All @@ -977,7 +977,7 @@ def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False):
Returns
-------
out : float
out : float or 2D array
Modified internal rate of return
Notes
Expand Down Expand Up @@ -1007,6 +1007,22 @@ def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False):
>>> npf.mirr([-100, 50, -60, 70], 0.10, 0.12)
-0.03909366594356467
It is also possible to supply multiple cashflows or pairs of
finance and reinvstment rates, note that in this case the number of elements
in each of the rates arrays must match.
>>> values = [
... [-4500, -800, 800, 800, 600],
... [-120000, 39000, 30000, 21000, 37000],
... [100, 200, -50, 300, -200],
... ]
>>> finance_rate = [0.05, 0.08, 0.10]
>>> reinvestment_rate = [0.08, 0.10, 0.12]
>>> npf.mirr(values, finance_rate, reinvestment_rate)
array([[-0.1784449 , -0.17328716, -0.1684366 ],
[ 0.04627293, 0.05437856, 0.06252201],
[ 0.35712458, 0.40628857, 0.44435295]])
Now, let's consider the scenario where all cash flows are negative.
>>> npf.mirr([-100, -50, -60, -70], 0.10, 0.12)
Expand All @@ -1025,22 +1041,31 @@ def mirr(values, finance_rate, reinvest_rate, *, raise_exceptions=False):
numpy_financial._financial.NoRealSolutionError:
No real solution exists for MIRR since all cashflows are of the same sign.
"""
values = np.asarray(values)
n = values.size

# Without this explicit cast the 1/(n - 1) computation below
# becomes a float, which causes TypeError when using Decimal
# values.
if isinstance(finance_rate, Decimal):
n = Decimal(n)

pos = values > 0
neg = values < 0
if not (pos.any() and neg.any()):
values_inner = np.atleast_2d(values).astype(np.float64)
finance_rate_inner = np.atleast_1d(finance_rate).astype(np.float64)
reinvest_rate_inner = np.atleast_1d(reinvest_rate).astype(np.float64)
n = values_inner.shape[1]

if finance_rate_inner.size != reinvest_rate_inner.size:
if raise_exceptions:
raise NoRealSolutionError('No real solution exists for MIRR since'
' all cashflows are of the same sign.')
raise ValueError("finance_rate and reinvest_rate must have the same size")
return np.nan
numer = np.abs(npv(reinvest_rate, values * pos))
denom = np.abs(npv(finance_rate, values * neg))
return (numer / denom) ** (1 / (n - 1)) * (1 + reinvest_rate) - 1

out_shape = _get_output_array_shape(values_inner, finance_rate_inner)
out = np.empty(out_shape)

for i, v in enumerate(values_inner):
for j, (rr, fr) in enumerate(zip(reinvest_rate_inner, finance_rate_inner)):
pos = v > 0
neg = v < 0

if not (pos.any() and neg.any()):
if raise_exceptions:
raise NoRealSolutionError("No real solution exists for MIRR since"
" all cashflows are of the same sign.")
out[i, j] = np.nan
else:
numer = np.abs(npv(rr, v * pos))
denom = np.abs(npv(fr, v * neg))
out[i, j] = (numer / denom) ** (1 / (n - 1)) * (1 + rr) - 1
return _ufunc_like(out)
34 changes: 34 additions & 0 deletions numpy_financial/tests/strategies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import numpy as np
from hypothesis import strategies as st
from hypothesis.extra import numpy as npst

real_scalar_dtypes = st.one_of(
npst.floating_dtypes(),
npst.integer_dtypes(),
npst.unsigned_integer_dtypes()
)
nicely_behaved_doubles = npst.from_dtype(
np.dtype("f8"),
allow_nan=False,
allow_infinity=False,
allow_subnormal=False,
)
cashflow_array_strategy = npst.arrays(
dtype=npst.floating_dtypes(sizes=64),
shape=npst.array_shapes(min_dims=1, max_dims=2, min_side=0, max_side=25),
elements=nicely_behaved_doubles,
)
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_nicely_behaved_doubles = npst.arrays(
dtype=npst.floating_dtypes(sizes=64),
shape=npst.array_shapes(min_dims=0, max_dims=1, min_side=0, max_side=5),
elements=nicely_behaved_doubles,
)

when_strategy = st.sampled_from(
['end', 'begin', 'e', 'b', 0, 1, 'beginning', 'start', 'finish']
)
98 changes: 53 additions & 45 deletions numpy_financial/tests/test_financial.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import math
import warnings
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 hypothesis import assume, given
from numpy.testing import (
assert_,
assert_allclose,
Expand All @@ -17,42 +15,11 @@
)

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(
from numpy_financial.tests.strategies import (
cashflow_array_like_strategy,
cashflow_array_strategy,
cashflow_list_strategy,
)

short_scalar_array_strategy = npst.arrays(
dtype=real_scalar_dtypes,
shape=npst.array_shapes(min_dims=0, max_dims=1, min_side=0, max_side=5),
)


when_strategy = st.sampled_from(
['end', 'begin', 'e', 'b', 0, 1, 'beginning', 'start', 'finish']
short_nicely_behaved_doubles,
when_strategy,
)


Expand Down Expand Up @@ -285,8 +252,7 @@ def test_npv(self):
rtol=1e-2,
)

@given(rates=short_scalar_array_strategy, values=cashflow_array_strategy)
@settings(deadline=None)
@given(rates=short_nicely_behaved_doubles, values=cashflow_array_strategy)
def test_fuzz(self, rates, values):
npf.npv(rates, values)

Expand Down Expand Up @@ -393,6 +359,23 @@ def test_mirr(self, values, finance_rate, reinvest_rate, expected):
else:
assert_(numpy.isnan(result))

def test_mirr_broadcast(self):
values = [
[-4500, -800, 800, 800, 600],
[-120000, 39000, 30000, 21000, 37000],
[100, 200, -50, 300, -200],
]
finance_rate = [0.05, 0.08, 0.10]
reinvestment_rate = [0.08, 0.10, 0.12]
# Found using Google sheets
expected = numpy.array([
[-0.1784449, -0.17328716, -0.1684366],
[0.04627293, 0.05437856, 0.06252201],
[0.35712458, 0.40628857, 0.44435295]
])
actual = npf.mirr(values, finance_rate, reinvestment_rate)
assert_allclose(actual, expected)

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 All @@ -402,6 +385,31 @@ def test_mirr_no_real_solution_exception(self):
with pytest.raises(npf.NoRealSolutionError):
npf.mirr(val, 0.10, 0.12, raise_exceptions=True)

@given(
values=cashflow_array_like_strategy,
finance_rate=short_nicely_behaved_doubles,
reinvestment_rate=short_nicely_behaved_doubles,
)
def test_fuzz(self, values, finance_rate, reinvestment_rate):
assume(finance_rate.size == reinvestment_rate.size)

# NumPy warns us of arithmetic overflow/underflow
# this only occurs when hypothesis generates extremely large values
# that are unlikely to ever occur in the real world.
with warnings.catch_warnings():
warnings.simplefilter("ignore")
npf.mirr(values, finance_rate, reinvestment_rate)

@given(
values=cashflow_array_like_strategy,
finance_rate=short_nicely_behaved_doubles,
reinvestment_rate=short_nicely_behaved_doubles,
)
def test_mismatching_rates_raise(self, values, finance_rate, reinvestment_rate):
assume(finance_rate.size != reinvestment_rate.size)
with pytest.raises(ValueError):
npf.mirr(values, finance_rate, reinvestment_rate, raise_exceptions=True)


class TestNper:
def test_basic_values(self):
Expand Down Expand Up @@ -432,10 +440,10 @@ def test_broadcast(self):
)

@given(
rates=short_scalar_array_strategy,
payments=short_scalar_array_strategy,
present_values=short_scalar_array_strategy,
future_values=short_scalar_array_strategy,
rates=short_nicely_behaved_doubles,
payments=short_nicely_behaved_doubles,
present_values=short_nicely_behaved_doubles,
future_values=short_nicely_behaved_doubles,
whens=when_strategy,
)
def test_fuzz(self, rates, payments, present_values, future_values, whens):
Expand Down

0 comments on commit 3263e71

Please sign in to comment.