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 bothmortgage_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 bothmortgage_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_income | Money | gross annual income the lender counts (after any haircut), greater than zero |
| monthly_commitments | Money | existing monthly debt payments, zero or more |
| loan | Money | the mortgage applied for |
| annual_rate_basis_points | int | the rate the mortgage will be charged at |
| stress_rate_basis_points | int | the rate to test the repayment at; not below the actual rate |
| term_months | int | the term, repaid monthly by capital and interest |
| max_income_multiple_basis_points | int | the lender's loan-to-income ceiling; 45000 = 4.5 × income |
| max_debt_to_income_basis_points | int | the lender's ceiling on repayments as a share of monthly income; 4000 = 40% |
| returns | MortgageAffordability |
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
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| £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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 3,216 |
| impl/python.py | 2,344 |
| impl/rust.rs | 3,378 |
| impl/typescript.ts | 2,331 |
| vectors.json | 8,336 |