Skip to content
Draft
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
13 changes: 11 additions & 2 deletions numpy_financial/_cfinancial.pyx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from libc.math cimport NAN, INFINITY, log
from libc.math cimport NAN, INFINITY, fabs, isfinite, log, log1p
cimport cython


Expand All @@ -20,10 +20,19 @@ cdef double nper_inner_loop(
if rate_ <= -1.0:
return NAN

# Avoid forming pmt_/rate_ and rounding the log argument to one
# when the rate is small. Keep the original path near singular balances
# or when the correction approaches the logarithm's domain boundary.
denominator = rate_ * pv_ + pmt_ * (1.0 + rate_ * when_)
if denominator != 0.0 and isfinite(denominator):
correction = -rate_ * (fv_ + pv_) / denominator
if fabs(correction) <= 0.5:
return log1p(correction) / log1p(rate_)

with cython.cdivision(True):
# We know that rate_ != 0, we don't need to check for division by 0
z = pmt_ * (1.0 + rate_ * when_) / rate_
return log((-fv_ + z) / (pv_ + z)) / log(1.0 + rate_)
return log((-fv_ + z) / (pv_ + z)) / log1p(rate_)


@cython.boundscheck(False)
Expand Down
39 changes: 39 additions & 0 deletions numpy_financial/tests/test_financial.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,45 @@ def test_mismatching_rates_raise(self, values, finance_rate, reinvestment_rate):


class TestNper:
@pytest.mark.parametrize("rate", [1e-4 / 12, 1e-8, 1e-12, 1e-20,
-1e-12, -1e-20])
@pytest.mark.parametrize("when", [0, 1])
@pytest.mark.parametrize("payment,present,future", [
(-1000, 12000, 0), # Repayment of a low-interest loan.
(-200, -5000, 10000), # Savings with regular contributions.
])
def test_small_rates(self, rate, when, payment, present, future):
from decimal import localcontext

# Independent high-precision evaluation of the defining equation.
with localcontext() as context:
context.prec = 80
r = Decimal(str(rate))
z = Decimal(payment) * (1 + r * when) / r
ratio = (z - future) / (z + present)
expected = float(ratio.ln() / (1 + r).ln())
actual = npf.nper(rate, payment, present, future, when)
assert_allclose(actual, expected, rtol=1e-10, atol=1e-12)

def test_small_rates_broadcast(self):
result = npf.nper([0, 1e-20, -1e-20], -1000, 12000, 0, [0, 1])
assert_allclose(result, numpy.full((3, 2), 12.0), atol=1e-12)

@pytest.mark.parametrize("when,payment", [(0, -62.5), (1, -62.5 / 1.0625)])
def test_interest_only_unchanged(self, when, payment):
assert numpy.isnan(npf.nper(0.0625, payment, 1000, 0, when))

def test_payment_just_above_interest(self):
from decimal import localcontext

rate, payment = 0.005, -50.01
with localcontext() as context:
context.prec = 60
r, p = Decimal("0.005"), Decimal("-50.01")
z = p / r
expected = float((z / (10000 + z)).ln() / (1 + r).ln())
assert_allclose(npf.nper(rate, payment, 10000), expected, rtol=1e-10)

def test_basic_values(self):
assert_allclose(
npf.nper([0, 0.075], -2000, 0, 100000),
Expand Down