Functional Weave
Code in Rust

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

/// 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(…)
impl/rust.rs · 152 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.

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
Download for Rust lending.apr-1.0.1-rust.fune · 40,425 bytes sha256 40122891c15badb8eaa78b804be5ab8eeb3efe3789f7d98b17bfe717b4add11f

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.

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