from .finance_compound_interest_types import CompoundInterestResult from .math_round_div import round_div from .money_add import subtract_money from .money_amount import Money, money #: 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, )