Functional Weave
Code in Python

property.mortgage-affordability Unreviewed

Mortgage affordability: the loan-to-income multiple and a stress-tested repayment against income, both checked.

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

Pinned by 13 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 conveyancer or tax adviser 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 property figures from published rules. It is a software component for developers, not legal or 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 conveyancer or tax adviser review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.

What it does

The two checks a UK mortgage lender runs on how much a buyer can borrow:

1. **Income multiple (loan-to-income).** The loan divided by gross annual income, against the lender's ceiling, typically 4 to 4.5 times income (`maxIncomeMultipleBasisPoints` 45000 = 4.5×). Returned as a multiple in basis points, rounded up, and as the largest loan the multiple allows, rounded down. 2. **Stress-tested repayment.** The monthly repayment at the actual rate and at a higher stress rate, plus existing commitments, as a share of monthly income, against the lender's ceiling. This is `lending.affordability` unchanged: its full result comes back as `repayment`.

For example

  • mortgage_affordability(£54,000.00, £300.00, £200,000.00, 4.5%, 7.5%, 300, 450%, 40%) → income multiple basis points 370.38%, max loan by multiple £243,000.00, within multiple true, monthly income £4,500.00, repayment …, affordable true £200k on £54,000 a year: 3.70× income and 39.52% stressed, passes both
  • mortgage_affordability(£40,000.00, £300.00, £200,000.00, 4.5%, 7.5%, 300, 450%, 40%) → income multiple basis points 500%, max loan by multiple £180,000.00, within multiple false, monthly income £3,333.33, repayment …, affordable false £200k on £40,000 a year: 5× income and 53.34% stressed, fails both
  • mortgage_affordability(£40,000.00, £0.00, £180,000.00, 4.25%, 7.25%, 300, 450%, 40%) → income multiple basis points 450%, max loan by multiple £180,000.00, within multiple true, monthly income £3,333.33, repayment …, affordable true exactly 4.5× income is within a 4.5× multiple

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 mortgage_affordability(annual_income: Money, monthly_commitments: Money, loan: Money, annual_rate_basis_points: int, stress_rate_basis_points: int, term_months: int, max_income_multiple_basis_points: int, max_debt_to_income_basis_points: int) -> MortgageAffordability
annual_incomeMoneygross annual income the lender counts (after any haircut), greater than zero
monthly_commitmentsMoneyexisting monthly debt payments, zero or more
loanMoneythe mortgage applied for
annual_rate_basis_pointsintthe rate the mortgage will be charged at
stress_rate_basis_pointsintthe rate to test the repayment at; not below the actual rate
term_monthsintthe term, repaid monthly by capital and interest
max_income_multiple_basis_pointsintthe lender's loan-to-income ceiling; 45000 = 4.5 × income
max_debt_to_income_basis_pointsintthe lender's ceiling on repayments as a share of monthly income; 4000 = 40%
returnsMortgageAffordability

The type it declares, generated into your project

@dataclass(frozen=True)
class MortgageAffordability:
    """Both tests, their inputs, and whether the loan passes both."""

    #: loan ÷ annual income, rounded up; 37038 = 3.7038 ×
    income_multiple_basis_points: int
    #: annual income × the ceiling, rounded down
    max_loan_by_multiple: Money
    #: the loan is within the income multiple
    within_multiple: bool
    #: annual income ÷ 12, rounded down
    monthly_income: Money
    #: the stressed repayment test, from lending.affordability
    repayment: AffordabilityResult
    #: within the multiple and the stressed repayment fits
    affordable: bool

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

from fune.property.mortgage_affordability import mortgage_affordability  # property.mortgage-affordability@^1
impl/python.py · 53 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 .lending_affordability import affordability  ← from lending.affordability ^1.0.0 · built alongside by fune
from .math_round_div import round_div  ← from math.round-div ^1.0.0 · built alongside by fune
from .money_amount import Money, money  ← from money.amount ^1.0.0 · built alongside by fune
from .property_mortgage_affordability_types import MortgageAffordability

_MAX_INCOME = 10_000_000_000
_MAX_MULTIPLE = 200_000


def mortgage_affordability(
    annual_income: Money,
    monthly_commitments: Money,
    loan: Money,
    annual_rate_basis_points: int,
    stress_rate_basis_points: int,
    term_months: int,
    max_income_multiple_basis_points: int,
    max_debt_to_income_basis_points: int,
) -> MortgageAffordability:
    """Loan-to-income against the lender's multiple, and the stressed
    repayment against income via lending.affordability. Every rounding goes
    against the borrower, and the multiple comparison itself is exact."""
    if loan.currency != annual_income.currency:
        raise ValueError("currency mismatch: %s and %s" % (annual_income.currency, loan.currency))
    if annual_income.minor < 12 or annual_income.minor > _MAX_INCOME:
        raise ValueError(
            "annualIncome must be between 12 and %d minor units, received %r" % (_MAX_INCOME, annual_income.minor)
        )
    if loan.minor <= 0 or loan.minor > _MAX_INCOME * 20:
        raise ValueError("loan must be between 1 and %d minor units, received %r" % (_MAX_INCOME * 20, loan.minor))
    m = max_income_multiple_basis_points
    if isinstance(m, bool) or not isinstance(m, int) or m < 1 or m > _MAX_MULTIPLE:
        raise ValueError("maxIncomeMultipleBasisPoints must be between 1 and %d, received %r" % (_MAX_MULTIPLE, m))
    currency = annual_income.currency
    monthly_income = money(annual_income.minor // 12, currency)
    repayment = affordability(
        monthly_income,
        monthly_commitments,
        loan,
        annual_rate_basis_points,
        stress_rate_basis_points,
        term_months,
        max_debt_to_income_basis_points,
    )
    within = loan.minor * 10000 <= annual_income.minor * m
    return MortgageAffordability(
        income_multiple_basis_points=round_div(loan.minor * 10000, annual_income.minor, "up"),
        max_loan_by_multiple=money(round_div(annual_income.minor * m, 10000, "down"), currency),
        within_multiple=within,
        monthly_income=monthly_income,
        repayment=repayment,
        affordable=within and repayment.affordable,
    )

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 property.mortgage-affordability
Download for Python property.mortgage-affordability-1.0.1-python.fune · 18,942 bytes sha256 d2c312c7e34882241bae1ecb9b69ca97befb88e897bafa168b789873b72f73c0

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

The whole function, every language, is one file too: property.mortgage-affordability-1.0.1.fune, 24,858 bytes, sha256 570381df9685b75339ee22fedf93eb8ab2f7d05aba48c4de45b125cea93163aa. 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 property.mortgage-affordability

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

# fune: after property.mortgage-affordability

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 lending.affordability in property.mortgage-affordability
# fune: replace math.round-div in property.mortgage-affordability
# fune: replace money.amount in property.mortgage-affordability

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 property.mortgage-affordability --steps.

# fune: step property.mortgage-affordability 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
£200k on £54,000 a year: 3.70× income and 39.52% stressed, passes both £54,000.00, £300.00, £200,000.00, 4.5%, 7.5%, 300, 450%, 40% → income multiple basis points 370.38%, max loan by multiple £243,000.00, within multiple true, monthly income £4,500.00, repayment …, affordable true
£200k on £40,000 a year: 5× income and 53.34% stressed, fails both £40,000.00, £300.00, £200,000.00, 4.5%, 7.5%, 300, 450%, 40% → income multiple basis points 500%, max loan by multiple £180,000.00, within multiple false, monthly income £3,333.33, repayment …, affordable false
exactly 4.5× income is within a 4.5× multiple £40,000.00, £0.00, £180,000.00, 4.25%, 7.25%, 300, 450%, 40% → income multiple basis points 450%, max loan by multiple £180,000.00, within multiple true, monthly income £3,333.33, repayment …, affordable true
a penny over 4.5× fails the multiple though the repayment fits £40,000.00, £0.00, £180,000.01, 4.25%, 7.25%, 300, 450%, 40% → income multiple basis points 450.01%, max loan by multiple £180,000.00, within multiple false, monthly income £3,333.33, repayment …, affordable false
within the multiple but the stressed repayment does not fit £80,000.00, £500.00, £300,000.00, 5%, 8%, 360, 450%, 40% → income multiple basis points 375%, max loan by multiple £360,000.00, within multiple true, monthly income £6,666.66, repayment …, affordable false
a comfortable 2× income loan £100,000.00, £0.00, £200,000.00, 4.5%, 7.5%, 300, 450%, 40% → income multiple basis points 200%, max loan by multiple £450,000.00, within multiple true, monthly income £8,333.33, repayment …, affordable true
an interest-free loan still gets a stressed repayment; odd income rounds the multiple up and the maximum down £60,000.01, £0.00, £120,000.00, 0%, 3%, 240, 450%, 35% → income multiple basis points 200%, max loan by multiple £270,000.04, within multiple true, monthly income £5,000.00, repayment …, affordable true
mixed currencies are refused £54,000.00, £300.00, €200,000.00, 4.5%, 7.5%, 300, 450%, 40% → error: currency mismatch
a zero income is refused £0.00, £0.00, £200,000.00, 4.5%, 7.5%, 300, 450%, 40% → error: annualIncome must be between 12 and
a zero loan is refused £54,000.00, £0.00, £0.00, 4.5%, 7.5%, 300, 450%, 40% → error: loan must be between 1 and
Show the other 3 tests
CaseArgumentsExpected
a zero multiple is refused £54,000.00, £0.00, £200,000.00, 4.5%, 7.5%, 300, 0%, 40% → error: maxIncomeMultipleBasisPoints must be between 1 and 200000
a stress rate below the actual rate is refused by lending.affordability £54,000.00, £0.00, £200,000.00, 4.5%, 4%, 300, 450%, 40% → error: stressRateBasisPoints must not be below the actual rate
negative commitments are refused by lending.affordability £54,000.00, -£0.01, £200,000.00, 4.5%, 7.5%, 300, 450%, 40% → error: monthlyCommitments must not be negative

More from the author

`affordable` is true only when both pass.

## Everything rounds against the borrower

The monthly income is annual ÷ 12 rounded down; the multiple is rounded up; the maximum loan down; and lending.affordability rounds repayments and ratios up. A test that passes only because of a rounding is not a pass. The multiple test itself is exact: `withinMultiple` is loan × 10000 ≤ income × ceiling, with no rounding in it.

## The ceilings are the lender's

Neither ceiling is a rule in this code. The Bank of England Financial Policy Committee's loan-to-income flow limit restricts the share of a lender's new mortgages at or above 4.5× income, but it is a portfolio limit, not a cap on any one loan; the lender's own policy sets the multiple for each applicant. The stress rate is also the lender's: the FCA's MCOB 11.6.18R requires lenders to allow for likely rate rises, and the FPC's specific stress test was withdrawn from 1 August 2022 (see lending.affordability for the sources).

## What it does not do

Joint applications are the caller's to add up (pass the combined income the lender counts). Expenditure models, credit scores, deposit and loan-to-value (see lending.ltv), and interest-only lending are out of scope. Annual income may be up to £100,000,000 (10,000,000,000 minor units) and the multiple up to 20× (200000), which keeps the arithmetic exact in every language.

## Before you rely on this

**Not professional advice.** This capability calculates property figures from published rules. It is a software component for developers, not legal or 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 conveyancer or tax adviser 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 conveyancer or tax adviser 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.md3,216
impl/python.py2,344
impl/rust.rs3,378
impl/typescript.ts2,331
vectors.json8,336