from typing import Dict, List, Sequence, Tuple from .lending_apr_types import AprResult, CreditFlow from .math_fractional_power import FIXED_SCALE, pow_fixed _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, )