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.
pub fn apr(advances: &[CreditFlow], repayments: &[CreditFlow], periods_per_year: i64) -> AprResult
| advances | CreditFlow[] | drawdowns of credit; the first is at period 0 |
| repayments | CreditFlow[] | every payment the borrower makes, charges and fees included, at their periods |
| periods_per_year | int | the unit of `period`: 12 months, 52 weeks, 365 days, 4 quarters, 2 halves or 1 year |
| returns | AprResult |
The types it declares, generated into your project
/// An amount paid at a time measured from the first drawdown.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreditFlow {
/// whole periods after the first drawdown, 0 or more
pub period: i64,
/// greater than zero
pub amount: Money,
}
/// The APR as disclosed, and a finer figure for checking.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AprResult {
/// the APR to one decimal place, in basis points: 1990 = 19.9%
pub basis_points: i64,
/// as disclosed: "19.9%"
pub display: String,
/// the APR to two decimal places, half-up, for audit
pub precise_basis_points: i64,
}
Your code names it in one line, in the file that uses it
fune!(lending.apr@^1); // then call apr(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_big_integer::BigInt; ← from math.big-integer ^1.0.0 · built alongside by fune
use super::math_fractional_power::{fixed_scale, pow_fixed}; ← from math.fractional-power ^1.0.0 · built alongside by fune
use super::money_amount::money_from_value; ← from money.amount ^1.0.0 · built alongside by fune
const UNITS: [i64; 6] = [1, 2, 4, 12, 52, 365];
/// 10^10: the APR is settled to ten decimal places of the rate before the disclosure rounding.
const SETTLE: i64 = 10_000_000_000;
const NOT_UNIQUE: &str = "cash flows must be advances first and repayments after: the APR would not be unique";
/// Net cash flow per period (repayments minus advances), in period order,
/// after checking every flow.
fn net_flows(advances: &[CreditFlow], repayments: &[CreditFlow]) -> Vec<(i64, i64)> {
if advances.is_empty() {
panic!("advances must not be empty");
}
if repayments.is_empty() {
panic!("repayments must not be empty");
}
let currency = advances[0].amount.currency.clone();
let mut net: Vec<(i64, i64)> = Vec::new();
let mut add = |flow: &CreditFlow, sign: i64| {
if flow.amount.currency != currency {
panic!("currency mismatch: {} and {}", currency, flow.amount.currency);
}
if flow.amount.minor <= 0 {
panic!("every amount must be greater than zero, received {}", flow.amount.minor);
}
if flow.period < 0 || flow.period > 36500 {
panic!("periods must be between 0 and 36500, received {}", flow.period);
}
match net.iter_mut().find(|(p, _)| *p == flow.period) {
Some(entry) => entry.1 += sign * flow.amount.minor,
None => net.push((flow.period, sign * flow.amount.minor)),
}
};
for flow in advances {
add(flow, -1);
}
for flow in repayments {
add(flow, 1);
}
let earliest = advances.iter().map(|f| f.period).min().unwrap();
if earliest != 0 {
panic!("time is measured from the first drawdown: the earliest advance must be at period 0");
}
net.retain(|(_, amount)| *amount != 0);
net.sort_by_key(|(p, _)| *p);
net
}
fn half_up(numerator: &BigInt, denominator: &BigInt) -> BigInt {
let two = BigInt::from_i64(2);
two.mul(numerator).add(denominator).div(&two.mul(denominator))
}
/// 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.
///
/// # Panics
/// Panics on an unsupported period unit, empty or invalid flows, flows whose
/// APR is not unique, or repayments totalling less than the credit.
pub fn apr(advances: &[CreditFlow], repayments: &[CreditFlow], periods_per_year: i64) -> AprResult {
if !UNITS.contains(&periods_per_year) {
panic!("periodsPerYear must be 1, 2, 4, 12, 52 or 365, received {}", periods_per_year);
}
let flows = net_flows(advances, repayments);
// One change of sign, advances then repayments, is what makes the root unique.
let mut seen_positive = false;
for (_, amount) in &flows {
if *amount > 0 {
seen_positive = true;
} else if seen_positive {
panic!("{}", NOT_UNIQUE);
}
}
if flows.is_empty() || flows[0].1 > 0 {
panic!("{}", NOT_UNIQUE);
}
let total: i128 = flows.iter().map(|(_, a)| *a as i128).sum();
if total < 0 {
panic!("the repayments total less than the credit: the APR would be negative");
}
let scale = fixed_scale();
let mut rate = BigInt::zero();
if total > 0 {
let value = |v: &BigInt| {
flows.iter().fold(BigInt::zero(), |sum, (period, amount)| {
sum.add(&BigInt::from_i64(*amount).mul(&pow_fixed(v, *period as u64)))
})
};
let one = BigInt::from_i64(1);
let two = BigInt::from_i64(2);
let mut lo = BigInt::zero();
let mut hi = scale.clone();
while hi.sub(&lo) > one {
let mid = lo.add(&hi).div(&two);
if value(&mid) >= BigInt::zero() {
hi = mid;
} else {
lo = mid;
}
}
let growth = pow_fixed(&hi, periods_per_year as u64);
if growth.is_zero() {
panic!("the APR is too large to compute");
}
rate = scale.mul(&scale).div(&growth).sub(&scale);
if rate.is_negative() {
rate = BigInt::zero();
}
}
// Settle the solver's last-digit noise, then round as the rule says.
let settle = BigInt::from_i64(SETTLE);
let settled = half_up(&rate.mul(&settle), &scale);
let tenths = half_up(&settled.mul(&BigInt::from_i64(1000)), &settle).to_i64();
let precise = half_up(&settled.mul(&BigInt::from_i64(10000)), &settle).to_i64();
AprResult {
basis_points: tenths * 10,
display: format!("{}.{}%", tenths / 10, tenths % 10),
precise_basis_points: precise,
}
}
pub fn credit_flow_from_value(v: &Value) -> CreditFlow {
CreditFlow {
period: v.get("period").as_i64(),
amount: money_from_value(v.get("amount")),
}
}
pub fn apr_result_to_value(result: &AprResult) -> Value {
Value::obj(vec![
("basisPoints", Value::Int(result.basis_points)),
("display", Value::str(&result.display)),
("preciseBasisPoints", Value::Int(result.precise_basis_points)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let advances: Vec<CreditFlow> = args[0].as_arr().iter().map(credit_flow_from_value).collect();
let repayments: Vec<CreditFlow> = args[1].as_arr().iter().map(credit_flow_from_value).collect();
apr_result_to_value(&apr(&advances, &repayments, args[2].as_i64()))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add lending.apr
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./lending.apr-1.0.1-rust.fune, or fetch it from a terminal with fune pull lending.apr@1.0.1:rust.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| £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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 4,825 |
| impl/python.py | 4,186 |
| impl/rust.rs | 6,006 |
| impl/typescript.ts | 4,461 |
| vectors.json | 22,762 |