Functional Weave
Code in Python

lending.apr Unreviewed

Consumer-credit APR from drawdowns and repayments, by the FCA CONC App 1.2.6 equation, to one decimal place.

1.0.1 · published 2026-10-03 by charlie · Anterra

Pinned by 22 tests, run in TypeScript, Python and Rust.

Unreviewed. This capability’s implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified consumer-credit compliance specialist has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.

Not professional advice. This capability calculates lending figures from published rules. It is a software component for developers, not financial advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have a consumer-credit compliance specialist review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.

What it does

**Status: needs review by a consumer-credit specialist before publishing.**

The annual percentage rate of charge for a regulated consumer credit agreement: the single rate that makes what the lender advances worth the same as everything the borrower pays, as defined by the total charge for credit rules.

For example

  • apr(advances ×1, repayments ×1, 12) → rate 10%, display 10.0%, precise basis points 10% £1,000 repaid with £1,100 a year later is 10.0%
  • apr(advances ×1, repayments ×1, 12) → rate 10%, display 10.0%, precise basis points 10% £1,000 repaid with £1,210 two years later is 10.0%
  • apr(advances ×1, repayments ×1, 1) → rate 5%, display 5.0%, precise basis points 5% the same in whole years

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

def apr(advances: Sequence[CreditFlow], repayments: Sequence[CreditFlow], periods_per_year: int) -> AprResult
advancesCreditFlow[]drawdowns of credit; the first is at period 0
repaymentsCreditFlow[]every payment the borrower makes, charges and fees included, at their periods
periods_per_yearintthe unit of `period`: 12 months, 52 weeks, 365 days, 4 quarters, 2 halves or 1 year
returnsAprResult

The types it declares, generated into your project

@dataclass(frozen=True)
class CreditFlow:
    """An amount paid at a time measured from the first drawdown."""

    #: whole periods after the first drawdown, 0 or more
    period: int
    #: greater than zero
    amount: Money

@dataclass(frozen=True)
class AprResult:
    """The APR as disclosed, and a finer figure for checking."""

    #: the APR to one decimal place, in basis points: 1990 = 19.9%
    basis_points: int
    #: as disclosed: "19.9%"
    display: str
    #: the APR to two decimal places, half-up, for audit
    precise_basis_points: int

Your code names it in one line, in the file that uses it

from fune.lending.apr import apr  # lending.apr@^1
impl/python.py · 96 lines · open · 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,
    )

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add lending.apr
Download for Python lending.apr-1.0.1-python.fune · 38,551 bytes sha256 3ac0ecabdf30f5eae38a307fadeb60693ad9cc4784a37f60a2e7f0ab749c5272

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./lending.apr-1.0.1-python.fune, or fetch it from a terminal with fune pull lending.apr@1.0.1:python.

The whole function, every language, is one file too: lending.apr-1.0.1.fune, 49,352 bytes, sha256 dd7bb6a99d431966dc31b1c9bb87c668e459af3781904ae26a062587a8944735. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

# fune: before lending.apr

after — your function gets the result and the arguments, and returns the final result.

# fune: after lending.apr

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

# fune: replace math.big-integer in lending.apr
# fune: replace math.fractional-power in lending.apr
# fune: replace money.amount in lending.apr

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show lending.apr --steps.

# fune: step lending.apr after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
£1,000 repaid with £1,100 a year later is 10.0% advances ×1, repayments ×1, 12 → rate 10%, display 10.0%, precise basis points 10%
£1,000 repaid with £1,210 two years later is 10.0% advances ×1, repayments ×1, 12 → rate 10%, display 10.0%, precise basis points 10%
the same in whole years advances ×1, repayments ×1, 1 → rate 5%, display 5.0%, precise basis points 5%
£1,000 over 12 monthly payments of £88.85 advances ×1, repayments ×12, 12 → rate 12.7%, display 12.7%, precise basis points 12.69%
an arrangement fee paid at drawdown raises the APR advances ×1, repayments ×13, 12 → rate 24.2%, display 24.2%, precise basis points 24.19%
a £25,000 car loan: 60 payments of £460.30 advances ×1, repayments ×60, 12 → rate 4.1%, display 4.1%, precise basis points 4.06%
a payday loan: £100, £124 back after 30 days advances ×1, repayments ×1, 365 → rate 1269.7%, display 1269.7%, precise basis points 1269.72%
weekly: £500 repaid by 26 payments of £21 advances ×1, repayments ×26, 52 → rate 41%, display 41.0%, precise basis points 41.02%
two drawdowns a month apart advances ×2, repayments ×11, 12 → rate 19.5%, display 19.5%, precise basis points 19.47%
interest-free credit is 0.0% advances ×1, repayments ×10, 12 → rate 0%, display 0.0%, precise basis points 0%
Show the other 12 tests
CaseArgumentsExpected
exactly 12.65% rounds up to 12.7%: the figure at the second decimal place is 5 advances ×1, repayments ×1, 1 → rate 12.7%, display 12.7%, precise basis points 12.65%
exactly 12.64% stays 12.6% advances ×1, repayments ×1, 1 → rate 12.6%, display 12.6%, precise basis points 12.64%
quarterly repayments advances ×1, repayments ×4, 4 → rate 16.6%, display 16.6%, precise basis points 16.65%
eleven level payments and a larger final one advances ×1, repayments ×12, 12 → rate 59.1%, display 59.1%, precise basis points 59.1%
no advances , repayments ×1, 12 → error: advances must not be empty
no repayments advances ×1, , 12 → error: repayments must not be empty
repayments below the credit would be a negative APR advances ×1, repayments ×1, 12 → error: the APR would be negative
the first drawdown must be at period 0 advances ×1, repayments ×1, 12 → error: the earliest advance must be at period 0
a drawdown after a repayment makes the APR not unique advances ×2, repayments ×2, 12 → error: the APR would not be unique
a period unit that is not a CONC year fraction advances ×1, repayments ×1, 24 → error: periodsPerYear must be 1, 2, 4, 12, 52 or 365
a zero amount is refused advances ×1, repayments ×1, 12 → error: every amount must be greater than zero
mixed currencies are refused advances ×1, repayments ×1, 12 → error: currency mismatch

More from the author

## The rule

FCA Handbook, CONC App 1.2.6R, "Total charge for credit rules for other agreements" (App 1.1 covers certain agreements secured on land). These rules took over from the Consumer Credit (Total Charge for Credit) Regulations 2010 when consumer credit moved to the FCA in 2014:

Σ C_k (1 + X)^(−t_k) = Σ D_l (1 + X)^(−s_l)

drawdowns C_k at t_k years, repayments and charges D_l at s_l years, both measured from the first drawdown; X is the APR. App 1.2.6(3): a year is 365 days (366 in a leap year), 52 weeks or 12 equal months; and "the result of the calculation shall be expressed with an accuracy of at least one decimal place; if the figure at the following decimal place is greater than or equal to 5, the figure at that particular decimal place shall be increased by one." Source: https://www.handbook.fca.org.uk/handbook/CONC/App/1/2.html, read 2026-09-23.

## Inputs

Each flow is a whole number of periods after the first drawdown, and `periodsPerYear` says what a period is: 12 (months), 52 (weeks), 365 (days), 4, 2 or 1. So a monthly loan is described in months and t = months / 12, and a 30-day loan in days with t = days / 365. The earliest advance must be at period 0. Fees and charges the borrower pays are repayments, at the period they are paid (an arrangement fee at drawdown is a repayment at period 0). Every amount must be positive and in one currency.

## How it is solved

With v = (1 + X)^(−1/periodsPerYear), the equation becomes a polynomial in v with whole exponents: Σ (net flow at t) × v^t = 0. It is solved by bisection on v between 0 and 1 in 18-place fixed point (math.fractional-power's `powFixed`, the same floors in the same order in every language), about 60 halvings until v is pinned to 10^-18. Then X = v^(−periodsPerYear) − 1.

Precision: the unrounded APR is accurate to around 10^-14. It is first settled to ten decimal places of the rate (10^-8 of a percentage point), which removes the solver's last-digit noise so an APR of exactly 12.65% is 12.65 and not 12.6499999, and then rounded as App 1.2.6(3)(f) says: to one decimal place, rounding up when the second decimal place is 5 or more. That is half-up, so 12.65% is 12.7% and 12.64% is 12.6%. `preciseBasisPoints` gives the settled APR to two decimal places (half-up) for checking.

The root is unique when the flows change sign once: all drawdowns (net) come before all repayments (net). Flows that interleave, such as a second drawdown after a repayment, can have more than one solution and are refused rather than answered with whichever root bisection finds. Repayments that total less than the credit (a negative APR) are refused; exactly equal totals are 0.0%.

## What a specialist should check

- The leap-year rule: "366 days for leap years" is not applied. Day-based flows use t = days / 365 throughout, the common industry reading; a specialist should confirm that for agreements spanning 29 February. - Which charges go into the total charge for credit, and the assumptions of CONC App 1.2 (for example for running-account credit and the assumed drawdown and repayment patterns of App 1.2.7 onwards) are the caller's. This computes the rate from the flows it is given. - Whether whole periods are fine enough: a first payment 45 days after drawdown on a monthly loan must be described in days, with every flow in days.

## Before you rely on this

**Not professional advice.** This capability calculates lending figures from published rules. It is a software component for developers, not financial advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed above, and have a consumer-credit compliance specialist review how you use it, before anyone relies on the output. Provided "as is" under its licence, without warranty.

**Unreviewed.** This capability's implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified consumer-credit compliance specialist has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.

1.0.1 marks it unreviewed. The code and the tests are unchanged.

Files

PathBytes
README.md4,825
impl/python.py4,186
impl/rust.rs6,006
impl/typescript.ts4,461
vectors.json22,762