Functional Weave
Code in TypeScript

finance.compound-interest@1.0.0

impl/python.py

2,525 bytes · the Python implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from .finance_compound_interest_types import CompoundInterestResult
from .math_round_div import round_div  ← from math.round-div ^1.0.0 · built alongside by fune
from .money_add import subtract_money  ← from money.add ^1.0.0 · built alongside by fune
from .money_amount import Money, money  ← from money.amount ^1.0.0 · built alongside by fune


#: A thousand years of daily compounding is already past the point where this
#: is modelling anything; beyond it a caller has passed the wrong unit and is
#: asking for a loop that will not finish.
MAX_PERIODS = 120000


def compound_interest(
    principal: Money,
    annual_rate_basis_points: int,
    periods_per_year: int,
    years: int,
) -> CompoundInterestResult:
    """Compound interest accrued period by period, in whole minor units.

    The loop is the point. A statement is built by posting a real, rounded entry
    each period and letting the next period earn on the posted balance, so that
    is what happens here. ``principal * (1 + r/n) ** (n*y)`` evaluated once at
    the end gives a slightly different answer and cannot be reconciled against
    the statement it disagrees with.

    The division by ``periods_per_year`` stays inside the same rounding step as
    the interest itself: a nominal 4.5% compounded monthly is 37.5 basis points
    per period, and rounding that rate to 37 or 38 first would distort the
    result far more than rounding the accrued pennies ever does.
    """
    if isinstance(annual_rate_basis_points, bool) or not isinstance(annual_rate_basis_points, int):
        raise TypeError(
            "annual_rate_basis_points must be an integer, received %r" % (annual_rate_basis_points,)
        )
    if isinstance(periods_per_year, bool) or not isinstance(periods_per_year, int) or periods_per_year < 1:
        raise ValueError("periods_per_year must be at least 1, received %r" % (periods_per_year,))
    if isinstance(years, bool) or not isinstance(years, int) or years < 0:
        raise ValueError("years must not be negative, received %r" % (years,))

    periods = periods_per_year * years
    if periods > MAX_PERIODS:
        raise ValueError("%d periods is beyond the %d period limit" % (periods, MAX_PERIODS))

    balance = money(principal.minor, principal.currency)
    for _ in range(periods):
        accrued = round_div(balance.minor * annual_rate_basis_points, 10000 * periods_per_year, "half-up")
        balance = money(balance.minor + accrued, balance.currency)

    return CompoundInterestResult(
        principal=money(principal.minor, principal.currency),
        interest=subtract_money(balance, principal),
        total=balance,
        periods=periods,
    )