from typing import Sequence from .dates_day_count_fraction import DayCountConvention, day_count_fraction from .dates_days_between import days_between from .lending_daily_interest_types import DailyInterest, DatedBalance from .math_rational import add_rational, multiply_rational, rational, rational_to_integer from .math_round_div import RoundingMode from .money_amount import money def daily_interest( balances: Sequence[DatedBalance], from_iso: str, to_iso: str, annual_rate_basis_points: int, convention: DayCountConvention, mode: RoundingMode, ) -> DailyInterest: """Interest on a balance that changes during the period. Each balance earns (or is charged) balance * rate * year fraction for the days it was held, the year fractions come from dates.day-count-fraction, and the pieces are added as exact fractions. Only the total is rounded, once, so splitting a month into more balance changes never moves the answer by a stray penny. """ b = annual_rate_basis_points if isinstance(b, bool) or not isinstance(b, int) or b < -100000 or b > 100000: raise ValueError("annualRateBasisPoints must be between -100000 and 100000, received %r" % (b,)) if len(balances) == 0: raise ValueError("balances must not be empty") days = days_between(from_iso, to_iso) if days < 0: raise ValueError("fromIso %s must not be after toIso %s" % (from_iso, to_iso)) currency = balances[0].balance.currency for i, entry in enumerate(balances): days_between(entry.date, entry.date) if entry.balance.currency != currency: raise ValueError("currency mismatch: %s and %s" % (currency, entry.balance.currency)) if i > 0 and entry.date <= balances[i - 1].date: raise ValueError("balances must be in strictly ascending date order") if balances[0].date > from_iso: raise ValueError("the first balance starts on %s, after fromIso %s" % (balances[0].date, from_iso)) rate = rational(b, 10000) total = rational(0, 1) for i, entry in enumerate(balances): start = entry.date if entry.date > from_iso else from_iso following = balances[i + 1].date if i + 1 < len(balances) else to_iso end = following if following < to_iso else to_iso if start >= end: continue fraction = day_count_fraction(start, end, convention) amount = multiply_rational(rational(entry.balance.minor, 1), rate) total = add_rational(total, multiply_rational(amount, rational(fraction.numerator, fraction.denominator))) return DailyInterest(interest=money(rational_to_integer(total, mode), currency), exact=total, days=days)