Functional Weave
Code in Python

lending.daily-interest@1.0.1

impl/python.py

2,712 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 typing import Sequence

from .dates_day_count_fraction import DayCountConvention, day_count_fraction  ← from dates.day-count-fraction ^1.0.0 · built alongside by fune
from .dates_days_between import days_between  ← from dates.days-between ^1.0.0 · built alongside by fune
from .lending_daily_interest_types import DailyInterest, DatedBalance
from .math_rational import add_rational, multiply_rational, rational, rational_to_integer  ← from math.rational ^1.0.0 · built alongside by fune
from .math_round_div import RoundingMode  ← from math.round-div ^1.0.0 · built alongside by fune
from .money_amount import money  ← from money.amount ^1.0.0 · built alongside by fune


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)