Functional Weave
Code in TypeScript

lending.apr@1.0.0

impl/python.py

4,186 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 Dict, List, Sequence, Tuple

from .lending_apr_types import AprResult, CreditFlow
from .math_fractional_power import FIXED_SCALE, pow_fixed  ← from math.fractional-power ^1.0.0 · built alongside by fune

_UNITS = (1, 2, 4, 12, 52, 365)

#: 10^10: the APR is settled to ten decimal places of the rate before the disclosure rounding.
_SETTLE = 10 ** 10

_NOT_UNIQUE = "cash flows must be advances first and repayments after: the APR would not be unique"


def _net_flows(advances: Sequence[CreditFlow], repayments: Sequence[CreditFlow]) -> List[Tuple[int, int]]:
    """Net cash flow per period (repayments minus advances), in period order,
    after checking every flow."""
    if len(advances) == 0:
        raise ValueError("advances must not be empty")
    if len(repayments) == 0:
        raise ValueError("repayments must not be empty")
    currency = advances[0].amount.currency
    net: Dict[int, int] = {}

    def add(flow: CreditFlow, sign: int) -> None:
        if flow.amount.currency != currency:
            raise ValueError("currency mismatch: %s and %s" % (currency, flow.amount.currency))
        if flow.amount.minor <= 0:
            raise ValueError("every amount must be greater than zero, received %r" % (flow.amount.minor,))
        p = flow.period
        if isinstance(p, bool) or not isinstance(p, int) or p < 0 or p > 36500:
            raise ValueError("periods must be between 0 and 36500, received %r" % (p,))
        net[p] = net.get(p, 0) + sign * flow.amount.minor

    for flow in advances:
        add(flow, -1)
    earliest = min(flow.period for flow in advances)
    for flow in repayments:
        add(flow, 1)
    if earliest != 0:
        raise ValueError("time is measured from the first drawdown: the earliest advance must be at period 0")
    return sorted((p, a) for p, a in net.items() if a != 0)


def apr(advances: Sequence[CreditFlow], repayments: Sequence[CreditFlow], periods_per_year: int) -> AprResult:
    """The APR by the total charge for credit equation (FCA Handbook CONC
    App 1.2.6R): the rate X at which the drawdowns, discounted to the first
    drawdown at (1 + X)^-t, equal the repayments discounted the same way, with
    t in years.

    Solved by bisection on the per-period discount factor
    v = (1 + X)^(-1/periods_per_year) in 18-place fixed point, which only
    needs whole powers of v, then X = v^-periods_per_year - 1, settled to ten
    decimal places and rounded to one decimal place of a percent as
    App 1.2.6(3)(f) requires.
    """
    if isinstance(periods_per_year, bool) or periods_per_year not in _UNITS:
        raise ValueError("periodsPerYear must be 1, 2, 4, 12, 52 or 365, received %r" % (periods_per_year,))
    flows = _net_flows(advances, repayments)
    # One change of sign, advances then repayments, is what makes the root unique.
    seen_positive = False
    for _, amount in flows:
        if amount > 0:
            seen_positive = True
        elif seen_positive:
            raise ValueError(_NOT_UNIQUE)
    if len(flows) == 0 or flows[0][1] > 0:
        raise ValueError(_NOT_UNIQUE)
    total = sum(amount for _, amount in flows)
    if total < 0:
        raise ValueError("the repayments total less than the credit: the APR would be negative")
    rate = 0
    if total > 0:

        def value(v: int) -> int:
            return sum(amount * pow_fixed(v, period) for period, amount in flows)

        lo, hi = 0, FIXED_SCALE
        while hi - lo > 1:
            mid = (lo + hi) // 2
            if value(mid) >= 0:
                hi = mid
            else:
                lo = mid
        growth = pow_fixed(hi, periods_per_year)
        if growth == 0:
            raise ValueError("the APR is too large to compute")
        rate = max(0, (FIXED_SCALE * FIXED_SCALE) // growth - FIXED_SCALE)
    # Settle the solver's last-digit noise, then round as the rule says.
    settled = (2 * rate * _SETTLE + FIXED_SCALE) // (2 * FIXED_SCALE)
    tenths = (2 * settled * 1000 + _SETTLE) // (2 * _SETTLE)
    precise = (2 * settled * 10000 + _SETTLE) // (2 * _SETTLE)
    return AprResult(
        basis_points=tenths * 10,
        display="%d.%d%%" % (tenths // 10, tenths % 10),
        precise_basis_points=precise,
    )