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,
)