Source code for mortgagemath._schedule

"""Amortization schedule generation."""

import calendar
import decimal
import warnings
from decimal import Decimal, localcontext

from mortgagemath._payment import _periodic_rate, _periodic_rate_for, periodic_payment
from mortgagemath._types import (
    BalanceTracking,
    DayCount,
    EarlyPayoffWarning,
    Installment,
    LoanParams,
    PaymentRounding,
    RateChange,
)

_ZERO = Decimal("0")
_ONE = Decimal("1")

_ROUNDING_MAP = {
    PaymentRounding.ROUND_UP: decimal.ROUND_UP,
    PaymentRounding.ROUND_DOWN: decimal.ROUND_DOWN,
    PaymentRounding.ROUND_HALF_UP: decimal.ROUND_HALF_UP,
    PaymentRounding.ROUND_HALF_EVEN: decimal.ROUND_HALF_EVEN,
}


def _recast_payment_pair(
    balance: Decimal,
    periodic_rate: Decimal,
    remaining_payments: int,
    payment_rounding: PaymentRounding,
    unit: Decimal = Decimal("0.01"),
) -> tuple[Decimal, Decimal]:
    """Recompute the level payment for a rate change. Returns (raw, rounded).

    Used by both 30/360 schedule paths.  ``balance`` is whatever balance
    flavor the caller is carrying (rounded for round-each, unrounded for
    carry-precision); the formula is the same.  Mirrors the closed-form
    annuity computation in :func:`periodic_payment` but with the new rate
    and the explicit remaining horizon.
    """
    rounding = _ROUNDING_MAP[payment_rounding]
    if periodic_rate == 0:
        raw = balance / remaining_payments
        return raw, raw.quantize(unit, rounding=rounding)

    with localcontext() as ctx:
        ctx.prec = 50
        factor = (_ONE + periodic_rate) ** remaining_payments
        raw = balance * periodic_rate * factor / (factor - _ONE)
    return raw, raw.quantize(unit, rounding=rounding)


def _apply_fee(inst: Installment, fee: Decimal, unit: Decimal) -> Installment:
    """Return a copy of ``inst`` with ``fee`` added to ``payment``.

    Used to apply ``LoanParams.fee_per_period`` uniformly to every
    schedule row.  The fee is treated as a flat amount in the loan's
    currency; quantize the input to the loan's currency unit so the
    resulting payment has consistent precision.
    """
    fee_cents = fee.quantize(unit)
    return Installment(
        number=inst.number,
        payment=inst.payment + fee_cents,
        interest=inst.interest,
        principal=inst.principal,
        total_interest=inst.total_interest,
        balance=inst.balance,
        fee=fee_cents,
    )


def _next_rate_change(
    rate_schedule: tuple[RateChange, ...], idx: int, payment_number: int
) -> RateChange | None:
    """Return the rate change effective at ``payment_number``, if any.

    ``idx`` is the index of the next unconsumed entry; if it matches,
    the caller should consume it (advance idx past this entry).
    """
    if idx < len(rate_schedule) and rate_schedule[idx].effective_payment_number == payment_number:
        return rate_schedule[idx]
    return None


[docs] def amortization_schedule(loan: LoanParams) -> list[Installment]: """Generate a full amortization schedule. For 30/360 loans the schedule uses bank-style round-each-balance accounting: 1. The periodic payment is computed once and rounded. 2. Each period's interest is computed on the (rounded) remaining balance and rounded to the cent. 3. Principal is the difference: ``payment - interest``. 4. The final payment is adjusted so the balance lands exactly at zero. For Actual/360 loans the schedule uses Fannie Mae §1103 conventions: 1. ``loan.start_date`` is required; period 1 covers the calendar month containing the start date. 2. The unrounded closed-form payment is carried internally; each month's interest is computed in full Decimal precision as ``balance * annual_rate * days_in_month / 360``. 3. Display values (``payment``, ``interest``, ``principal``) are rounded to the cent; the running balance internally is unrounded. 4. The final payment is adjusted to land balance at exactly zero. Both modes guarantee ``principal + interest == payment`` for every installment and a final balance of exactly ``$0.00``. For very small principals the cent-rounded periodic payment can overpay the closed-form value by enough that the loan amortizes before the requested term. In that case the schedule is truncated at the actual payoff period and an :class:`EarlyPayoffWarning` is emitted; ``len(schedule)`` will be smaller than ``total_payments + 1``. Args: loan: Loan parameters. Returns: A list of :class:`Installment` objects from period 0 (initial state showing the starting balance) through the final payment. Raises: ValueError: If principal or term_months is not positive, the day_count is unsupported, or ACTUAL_360 is requested without a start_date. Warns: EarlyPayoffWarning: When 30/360 round-each-balance accounting amortizes the loan before its scheduled end due to periodic payment overpayment from rounding. """ # All schedule paths run under an explicit 50-digit local Decimal # context so cent accuracy doesn't depend on the caller's ambient # context. periodic_payment() already does this for the closed-form # value; the schedule generator follows the same policy. Without # this wrap, a caller who lowered global precision (e.g. # decimal.getcontext().prec = 10) could silently shift Fannie Mae # §1103's published $20,885,505.83 balloon to $20,885,505.23 — a # 60-cent error on a $25 M schedule. with localcontext() as ctx: ctx.prec = 50 if loan.day_count == DayCount.THIRTY_360: sched = _schedule_thirty_360(loan) elif loan.day_count == DayCount.ACTUAL_360: if loan.start_date is None: raise ValueError( "ACTUAL_360 schedule requires loan.start_date " "(the issue date / first interest-accrual period)" ) sched = _schedule_actual_360(loan) else: # pragma: no cover raise ValueError(f"unsupported day_count: {loan.day_count}") # Apply the optional per-period fee uniformly to every installment # row (row 0 is the initial state and has no payment, so it keeps # fee=0). The fee adds to Installment.payment but does not alter # interest, principal, total_interest, or balance: the closed-form # arithmetic and balance accounting are untouched. fee = loan.fee_per_period if fee: sched = [sched[0], *(_apply_fee(inst, fee, loan.currency_unit) for inst in sched[1:])] return sched
def _schedule_thirty_360(loan: LoanParams) -> list[Installment]: if loan.balance_tracking == BalanceTracking.CARRY_PRECISION: return _schedule_thirty_360_carry_precision(loan) return _schedule_thirty_360_round_each(loan) def _schedule_thirty_360_round_each(loan: LoanParams) -> list[Installment]: pmt = periodic_payment(loan) interest_rounding = _ROUNDING_MAP[loan.interest_rounding] periodic_rate = _periodic_rate(loan) total_payments = loan._total_payments balance = loan.principal total_interest = _ZERO fully_amortizing = loan.amortization_period_months is None or ( loan.amortization_period_months == loan.term_months ) rate_schedule_idx = 0 unit = loan.currency_unit ppy = loan.payment_frequency.payments_per_year io_payments = (loan.interest_only_months * ppy) // 12 schedule: list[Installment] = [ Installment( number=0, payment=_ZERO, interest=_ZERO, principal=_ZERO, total_interest=_ZERO, balance=balance, ) ] payment_rounding = _ROUNDING_MAP[loan.payment_rounding] for i in range(1, total_payments + 1): # Apply any rate change effective at this payment, before interest accrual. rc = _next_rate_change(loan.rate_schedule, rate_schedule_idx, i) if rc is not None: periodic_rate = _periodic_rate_for( rc.new_annual_rate, loan.compounding, loan.payment_frequency ) if rc.recast: remaining = total_payments - (i - 1) _, pmt_uncapped = _recast_payment_pair( balance, periodic_rate, remaining, loan.payment_rounding, unit ) if rc.payment_cap_factor is not None: cap = (pmt * rc.payment_cap_factor).quantize(unit, rounding=payment_rounding) pmt = min(pmt_uncapped, cap) else: pmt = pmt_uncapped rate_schedule_idx += 1 elif io_payments > 0 and i == io_payments + 1: # End of interest-only period: recast the level payment. remaining = total_payments - io_payments _, pmt = _recast_payment_pair( balance, periodic_rate, remaining, loan.payment_rounding, unit ) interest = (balance * periodic_rate).quantize(unit, rounding=interest_rounding) if i <= io_payments: # Interest-only period. actual_pmt = interest principal_pmt = _ZERO else: is_scheduled_final = i == total_payments and fully_amortizing will_pay_off_early = (not is_scheduled_final) and (pmt - interest >= balance) if is_scheduled_final or will_pay_off_early: principal_pmt = balance actual_pmt = principal_pmt + interest else: actual_pmt = pmt principal_pmt = actual_pmt - interest balance -= principal_pmt total_interest += interest schedule.append( Installment( number=i, payment=actual_pmt, interest=interest, principal=principal_pmt, total_interest=total_interest, balance=balance, ) ) if i > io_payments and will_pay_off_early: warnings.warn( f"Loan paid off at period {i} of total_payments={total_payments}; " f"schedule truncated. The {loan.payment_rounding.value} periodic " f"payment of {pmt} overpays the closed-form value enough to " f"amortize the loan early. For very small principals, consider " f"PaymentRounding.ROUND_HALF_UP.", EarlyPayoffWarning, stacklevel=2, ) break return schedule def _schedule_thirty_360_carry_precision(loan: LoanParams) -> list[Installment]: """30/360 schedule with full-precision balance carried between rows. Excel-default convention used by graduate-level CRE finance textbooks: the unrounded closed-form payment and unrounded per-row interest are carried internally; per-row displayed values are rounded to cents. Per-row ``principal + interest == payment`` invariant still holds; the final payment may differ from the regular payment by 1-2 cents to land balance at exactly $0.00. When ``loan.payment_override`` is set, the override is the level payment for every full row and the final-row trueup is derived from the full-precision ``balance + interest`` rounded once — the historical "given-payment, find-term" convention used by the FHLBB March 1935 *Review* schedules. """ interest_rounding = _ROUNDING_MAP[loan.interest_rounding] periodic_rate = _periodic_rate(loan) total_payments = loan._total_payments unit = loan.currency_unit # Validate via periodic_payment (enforces guards) and reuse rounded display. pmt_disp = periodic_payment(loan) pmt_raw = pmt_disp # Initial value, will be updated if recasting after IO if loan.payment_override is not None: # Override: skip the closed-form derivation entirely and use the # pinned value as both the displayed and full-precision payment. pmt_raw = pmt_disp else: # Unrounded closed-form payment, carried internally. n = loan._amort_payments r = periodic_rate if r != 0: with localcontext() as ctx: ctx.prec = 50 factor = (_ONE + r) ** n pmt_raw = (loan.principal * r * factor) / (factor - _ONE) fully_amortizing = loan.amortization_period_months is None or ( loan.amortization_period_months == loan.term_months ) balance = loan.principal # full-precision Decimal total_interest_disp = _ZERO rate_schedule_idx = 0 payment_rounding = _ROUNDING_MAP[loan.payment_rounding] ppy = loan.payment_frequency.payments_per_year io_payments = (loan.interest_only_months * ppy) // 12 schedule: list[Installment] = [ Installment( number=0, payment=_ZERO, interest=_ZERO, principal=_ZERO, total_interest=_ZERO, balance=balance, ) ] for i in range(1, total_payments + 1): # Apply any rate change effective at this payment, before interest accrual. rc = _next_rate_change(loan.rate_schedule, rate_schedule_idx, i) if rc is not None: periodic_rate = _periodic_rate_for( rc.new_annual_rate, loan.compounding, loan.payment_frequency ) if rc.recast: remaining = total_payments - (i - 1) pmt_uncapped_raw, pmt_uncapped_disp = _recast_payment_pair( balance, periodic_rate, remaining, loan.payment_rounding, unit ) if rc.payment_cap_factor is not None: cap = (pmt_disp * rc.payment_cap_factor).quantize( unit, rounding=payment_rounding ) if cap < pmt_uncapped_disp: pmt_disp = cap pmt_raw = cap else: pmt_disp = pmt_uncapped_disp pmt_raw = pmt_uncapped_raw else: pmt_disp = pmt_uncapped_disp pmt_raw = pmt_uncapped_raw rate_schedule_idx += 1 elif io_payments > 0 and i == io_payments + 1: # End of interest-only period: recast the level payment. remaining = total_payments - io_payments pmt_raw, pmt_disp = _recast_payment_pair( balance, periodic_rate, remaining, loan.payment_rounding, unit ) interest_raw = balance * periodic_rate interest_disp = interest_raw.quantize(unit, rounding=interest_rounding) if i <= io_payments: # Interest-only period. actual_pmt = interest_disp principal_disp = _ZERO else: is_scheduled_final = i == total_payments and fully_amortizing will_pay_off_early = ( (not is_scheduled_final) and fully_amortizing and (pmt_raw - interest_raw >= balance) ) if is_scheduled_final or will_pay_off_early: if loan.payment_override is not None or will_pay_off_early: actual_pmt_raw = balance + interest_raw actual_pmt = actual_pmt_raw.quantize(unit, rounding=payment_rounding) principal_disp = actual_pmt - interest_disp else: principal_disp = balance.quantize(unit, rounding=interest_rounding) actual_pmt = principal_disp + interest_disp balance = _ZERO balance_disp = _ZERO else: actual_pmt = pmt_disp principal_disp = pmt_disp - interest_disp balance -= pmt_raw - interest_raw # carry full precision balance_disp = balance.quantize(unit, rounding=interest_rounding) total_interest_disp += interest_disp schedule.append( Installment( number=i, payment=actual_pmt, interest=interest_disp, principal=principal_disp, total_interest=total_interest_disp, balance=balance if i <= io_payments else balance_disp, ) ) if i > io_payments and will_pay_off_early: warnings.warn( f"Loan paid off at period {i} of total_payments={total_payments}; " f"schedule truncated. The level payment {pmt_disp} (or " f"payment_override) overpays the closed-form value enough to " f"amortize the loan early.", EarlyPayoffWarning, stacklevel=2, ) break return schedule def _schedule_actual_360(loan: LoanParams) -> list[Installment]: """Day-counted Actual/360 schedule with full-precision balance. Validated against Fannie Mae Multifamily Selling and Servicing Guide §1103: $25M / 5.5% / 30yr / Actual/360, issue date 2018-12-01, aggregate principal over first 120 payments = $4,114,494.17 (exact). ACTUAL_360 is restricted (in ``LoanParams.__post_init__``) to monthly compounding and monthly payments — day-counted accrual is not well-defined for non-monthly cadence, and all worked examples we validate against (§1103, §1104, §1106) are monthly + monthly. """ interest_rounding = _ROUNDING_MAP[loan.interest_rounding] annual_rate = loan.annual_rate / Decimal("100") # percent → fraction unit = loan.currency_unit # Validate via periodic_payment (which enforces rate/term/amort guards) # and reuse its rounded display value. pmt_disp = periodic_payment(loan) # Unrounded closed-form payment, carried internally; displayed value rounded. # Uses the amortization period (which may be larger than term_months for # balloon loans) — see the LoanParams docstring. r = loan.annual_rate / Decimal("1200") n = loan._amort_periods factor = (_ONE + r) ** n pmt_raw = (loan.principal * r * factor) / (factor - _ONE) fully_amortizing = loan.amortization_period_months is None or ( loan.amortization_period_months == loan.term_months ) balance = loan.principal # full-precision Decimal total_interest_disp = _ZERO ppy = loan.payment_frequency.payments_per_year io_payments = (loan.interest_only_months * ppy) // 12 schedule: list[Installment] = [ Installment( number=0, payment=_ZERO, interest=_ZERO, principal=_ZERO, total_interest=_ZERO, balance=balance, ) ] # Public dispatch in amortization_schedule() guarantees start_date is set # for ACTUAL_360; assert documents the invariant for the type checker. assert loan.start_date is not None sd = loan.start_date for i in range(1, loan.term_months + 1): # Period i covers the calendar month at offset (i-1) from start_date period_year = sd.year + (sd.month - 1 + i - 1) // 12 period_month = (sd.month - 1 + i - 1) % 12 + 1 days = calendar.monthrange(period_year, period_month)[1] if io_payments > 0 and i == io_payments + 1: # End of interest-only period: recast the level payment. remaining_n = n - io_payments if r == 0: pmt_raw = balance / Decimal(remaining_n) else: factor = (_ONE + r) ** remaining_n pmt_raw = (balance * r * factor) / (factor - _ONE) pmt_disp = pmt_raw.quantize(unit, rounding=_ROUNDING_MAP[loan.payment_rounding]) interest_raw = balance * annual_rate * Decimal(days) / Decimal(360) interest_disp = interest_raw.quantize(unit, rounding=interest_rounding) if i <= io_payments: actual_pmt = interest_disp principal_disp = _ZERO elif i == loan.term_months and fully_amortizing: # Final payment of a fully amortizing loan: zero balance exactly. principal_disp = balance.quantize(unit, rounding=interest_rounding) actual_pmt = principal_disp + interest_disp balance = _ZERO balance_disp = _ZERO else: actual_pmt = pmt_disp principal_disp = pmt_disp - interest_disp balance -= pmt_raw - interest_raw # carry full precision balance_disp = balance.quantize(unit, rounding=interest_rounding) total_interest_disp += interest_disp schedule.append( Installment( number=i, payment=actual_pmt, interest=interest_disp, principal=principal_disp, total_interest=total_interest_disp, balance=balance if i <= io_payments else balance_disp, ) ) return schedule