insurance.rating-factors
Premium from a base rate times factor-table lookups, loadings and discounts, within minimum and maximum premiums.
1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra
Pinned by 24 tests, run in TypeScript, Python and Rust.
Not professional advice. This capability calculates insurance 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 an actuary review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.
What it does
A premium from a base rate multiplied by rating factors (relativities) looked up for the risk, then by loadings and discounts, held within a minimum and a maximum premium. This is the multiplicative model most personal-lines and many commercial rating engines use.
## Inputs
For example
ratePremium(£400.00, tables ×3, driver age 25-39, area C, vehicle group 11-20, , —, —, half-up)→ factors ×3, calculated £712.80, premium £712.80, cap none 400.00 x 1.10 x 1.35 x 1.20 is 712.80ratePremium(£400.00, tables ×3, driver age 25-39, area C, vehicle group 11-20, adjustments ×1, —, —, half-up)→ factors ×4, calculated £641.52, premium £641.52, cap none a 10% multi-car discount multiplies by 0.90ratePremium(£400.00, tables ×3, driver age 25-39, area C, vehicle group 11-20, adjustments ×2, —, —, half-up)→ factors ×5, calculated £801.90, premium £801.90, cap none a 25% conviction loading and a 10% discount: 712.80 x 1.25 x 0.90
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.
export function ratePremium(basePremium: Money, tables: readonly FactorTable[], risk: Readonly<Record<string, string>>, adjustments: readonly PremiumAdjustment[], minimumPremium: Money | null, maximumPremium: Money | null, mode: RoundingMode): RatedPremium
| basePremium | Money | the base rate before any factor, 0 or more |
| tables | FactorTable[] | the rating factor tables, applied in order |
| risk | map<string> | the risk's value for each table, by table name: {"area": "C", "vehicleGroup": "11-20"} |
| adjustments | PremiumAdjustment[] | loadings (positive) and discounts (negative) applied after the tables |
| minimumPremium | Money? | the least the premium may be, or null |
| maximumPremium | Money? | the most the premium may be, or null |
| mode | RoundingMode | how the one final rounding to a minor unit goes |
| returns | RatedPremium |
The types it declares, generated into your project
/** One rating factor: a multiplier for each value the risk can take. */
export interface FactorTable {
/** the factor, e.g. area or driverAge */
readonly name: string;
readonly rows: readonly FactorRow[];
}
/** One value of a factor and its multiplier. */
export interface FactorRow {
readonly key: string;
/** multiplier in basis points: 10000 = 1.0, 13500 = 1.35 */
readonly basisPoints: number;
}
/** A loading or discount, as a change to the premium. */
export interface PremiumAdjustment {
readonly name: string;
/** 2500 = 25% loading, -1000 = 10% discount; -10000 at the least */
readonly basisPoints: number;
}
/** A multiplier used, in the order it was applied. */
export interface AppliedFactor {
readonly name: string;
/** the multiplier: 10000 = 1.0, 9000 for a 10% discount */
readonly basisPoints: number;
}
export type PremiumCap = "none" | "minimum" | "maximum";
/** The premium and how it was reached. */
export interface RatedPremium {
/** table factors then adjustments, as multipliers */
readonly factors: readonly AppliedFactor[];
/** base times every multiplier, rounded once, before the caps */
readonly calculated: Money;
/** calculated, held within the minimum and maximum premiums */
readonly premium: Money;
/** which cap, if any, set the premium */
readonly cap: PremiumCap;
}
Your code names it in one line, in the file that uses it
import { ratePremium } from "#fune/insurance.rating-factors@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { type RoundingMode } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { type Money, assertSameCurrency, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { type AppliedFactor, type FactorTable, type PremiumAdjustment, type RatedPremium } from "./insurance_rating_factors_types.ts";
function roundQuotient(numerator: bigint, denominator: bigint, mode: RoundingMode): bigint {
// Both are non-negative here: a base of 0 or more times multipliers of 0 or more.
const q = numerator / denominator;
const twice = (numerator % denominator) * 2n;
switch (mode) {
case "down":
return q;
case "up":
return twice > 0n ? q + 1n : q;
case "half-up":
return twice >= denominator ? q + 1n : q;
case "half-even":
return twice > denominator || (twice === denominator && q % 2n === 1n) ? q + 1n : q;
default:
throw new RangeError(`unknown rounding mode "${mode}"`);
}
}
/**
* Rate a premium: the base times each table's multiplier for the risk, then
* each loading or discount, with one rounding at the end, then held within
* the minimum and maximum premiums.
*
* Rounding after every factor drifts: 1.01 at 1.005 three times is 1.03
* exactly but 1.04 rounded at each step. The product is exact integer
* arithmetic of any size, so there is no limit on the number of factors.
*/
export function ratePremium(
basePremium: Money,
tables: readonly FactorTable[],
risk: Readonly<Record<string, string>>,
adjustments: readonly PremiumAdjustment[],
minimumPremium: Money | null,
maximumPremium: Money | null,
mode: RoundingMode
): RatedPremium {
if (basePremium.minor < 0) {
throw new RangeError(`basePremium must not be negative, received ${basePremium.minor}`);
}
const hasMin = minimumPremium !== null && minimumPremium !== undefined;
const hasMax = maximumPremium !== null && maximumPremium !== undefined;
if (hasMin) assertSameCurrency(basePremium, minimumPremium);
if (hasMax) assertSameCurrency(basePremium, maximumPremium);
if (hasMin && hasMax && minimumPremium.minor > maximumPremium.minor) {
throw new RangeError("minimumPremium must not be more than maximumPremium");
}
const factors: AppliedFactor[] = [];
const names = new Set<string>();
for (const table of tables) {
if (names.has(table.name)) throw new RangeError(`rating factor "${table.name}" appears twice`);
names.add(table.name);
if (!Object.prototype.hasOwnProperty.call(risk, table.name)) {
throw new RangeError(`no value for rating factor "${table.name}"`);
}
const key = risk[table.name];
let found: number | null = null;
for (const row of table.rows) {
if (row.key === key) {
if (found !== null) throw new RangeError(`rating factor "${table.name}" has two rows for "${key}"`);
if (!Number.isInteger(row.basisPoints) || row.basisPoints < 0) {
throw new RangeError(`factor basisPoints must be a whole number of 0 or more, received ${row.basisPoints}`);
}
found = row.basisPoints;
}
}
if (found === null) throw new RangeError(`rating factor "${table.name}" has no row for "${key}"`);
factors.push({ name: table.name, basisPoints: found });
}
for (const name of Object.keys(risk)) {
if (!names.has(name)) throw new RangeError(`risk has a value for "${name}", which no table rates`);
}
for (const adjustment of adjustments) {
if (!Number.isInteger(adjustment.basisPoints) || adjustment.basisPoints < -10000) {
throw new RangeError(`adjustment basisPoints must be a whole number of -10000 or more, received ${adjustment.basisPoints}`);
}
factors.push({ name: adjustment.name, basisPoints: 10000 + adjustment.basisPoints });
}
let numerator = BigInt(basePremium.minor);
let denominator = 1n;
for (const f of factors) {
numerator *= BigInt(f.basisPoints);
denominator *= 10000n;
}
const c = basePremium.currency;
const calculated = money(Number(roundQuotient(numerator, denominator, mode)), c);
let premium = calculated;
let cap: RatedPremium["cap"] = "none";
if (hasMin && premium.minor < minimumPremium.minor) {
premium = minimumPremium;
cap = "minimum";
} else if (hasMax && premium.minor > maximumPremium.minor) {
premium = maximumPremium;
cap = "maximum";
}
return { factors, calculated, premium, cap };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the TypeScript 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 insurance.rating-factors
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./insurance.rating-factors-1.0.0-typescript.fune, or fetch it from a terminal with fune pull insurance.rating-factors@1.0.0:typescript.
The whole function, every language, is one file too: insurance.rating-factors-1.0.0.fune, 45,872 bytes, sha256 fae4d827f1088f6e16dd2adb4b93e01adf5a5881e9f6b4a456ae545e13050434. 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 insurance.rating-factors
after — your function gets the result and the arguments, and returns the final result.
// fune: after insurance.rating-factors
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 insurance.rating-factors
// fune: replace math.round-div in insurance.rating-factors
// fune: replace money.amount in insurance.rating-factors
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 insurance.rating-factors --steps.
// fune: step insurance.rating-factors 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 | |
|---|---|---|---|
| 400.00 x 1.10 x 1.35 x 1.20 is 712.80 | £400.00, tables ×3, driver age 25-39, area C, vehicle group 11-20, , —, —, half-up | → | factors ×3, calculated £712.80, premium £712.80, cap none |
| a 10% multi-car discount multiplies by 0.90 | £400.00, tables ×3, driver age 25-39, area C, vehicle group 11-20, adjustments ×1, —, —, half-up | → | factors ×4, calculated £641.52, premium £641.52, cap none |
| a 25% conviction loading and a 10% discount: 712.80 x 1.25 x 0.90 | £400.00, tables ×3, driver age 25-39, area C, vehicle group 11-20, adjustments ×2, —, —, half-up | → | factors ×5, calculated £801.90, premium £801.90, cap none |
| 333.33 x 1.80 x 1.35 x 0.95 is 769.492305, rounded once | £333.33, tables ×3, driver age 17-24, area C, vehicle group 1-10, , —, —, half-up | → | factors ×3, calculated £769.49, premium £769.49, cap none |
| the same rounded up | £333.33, tables ×3, driver age 17-24, area C, vehicle group 1-10, , —, —, up | → | factors ×3, calculated £769.50, premium £769.50, cap none |
| one rounding, not three: 1.01 x 1.005^3 is 1.0252..., so 1.03, where rounding each step gives 1.04 | £1.01, tables ×3, a x, b x, c x, , —, —, half-up | → | factors ×3, calculated £1.03, premium £1.03, cap none |
| a minimum premium lifts a low result | £100.00, tables ×1, area A, , £150.00, —, half-up | → | factors ×1, calculated £90.00, premium £150.00, cap minimum |
| a maximum premium caps a high one | £400.00, tables ×3, driver age 25-39, area C, vehicle group 11-20, , —, £500.00, half-up | → | factors ×3, calculated £712.80, premium £500.00, cap maximum |
| inside both caps nothing changes | £400.00, tables ×3, driver age 25-39, area C, vehicle group 11-20, , £150.00, £1,000.00, half-up | → | factors ×3, calculated £712.80, premium £712.80, cap none |
| a result exactly the minimum is not capped | £150.00, , , , £150.00, —, half-up | → | factors , calculated £150.00, premium £150.00, cap none |
Show the other 14 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| no tables and no adjustments leave the base | £250.00, , , , —, —, half-up | → | factors , calculated £250.00, premium £250.00, cap none |
| a 100% discount gives zero, then the minimum | £250.00, , , adjustments ×1, £50.00, —, half-up | → | factors ×1, calculated £0.00, premium £50.00, cap minimum |
| half a penny rounds to even | £0.01, , , adjustments ×1, —, —, half-even | → | factors ×1, calculated £0.00, premium £0.00, cap none |
| half a penny rounds up half-up | £0.01, , , adjustments ×1, —, —, half-up | → | factors ×1, calculated £0.01, premium £0.01, cap none |
| a table without a value for the risk is refused | £400.00, tables ×3, driver age 25-39, area C, , —, —, half-up | → | error: no value for rating factor "vehicleGroup" |
| a value the table has no row for is refused | £400.00, tables ×3, driver age 25-39, area Z, vehicle group 1-10, , —, —, half-up | → | error: rating factor "area" has no row for "Z" |
| a risk value no table rates is refused | £400.00, tables ×1, area A, colour red, , —, —, half-up | → | error: risk has a value for "colour", which no table rates |
| a table named twice is refused | £400.00, tables ×2, area A, , —, —, half-up | → | error: rating factor "area" appears twice |
| a discount beyond 100% is refused | £400.00, , , adjustments ×1, —, —, half-up | → | error: adjustment basisPoints must be a whole number of -10000 or more |
| a negative factor is refused | £400.00, tables ×1, area A, , —, —, half-up | → | error: factor basisPoints must be a whole number of 0 or more |
| a fractional factor is refused | £400.00, tables ×1, area A, , —, —, half-up | → | error: factor basisPoints must be a whole number of 0 or more |
| a minimum above the maximum is refused | £400.00, , , , £200.00, £100.00, half-up | → | error: minimumPremium must not be more than maximumPremium |
| a negative base is refused | -£0.01, , , , —, —, half-up | → | error: basePremium must not be negative |
| a minimum in another currency is refused | £400.00, , , , €1.00, —, half-up | → | error: currency mismatch |
More from the author
- **tables**: one per rating factor (area, driver age band, vehicle group, occupation...), each a list of `key -> multiplier` rows. Multipliers are basis points: 10000 is 1.0, 13500 is 1.35, 9000 is 0.9. The tables are the insurer's own and change by the insurer's release, so they are arguments rather than data in this package. - **risk**: the risk's value for each table, by table name. Every table needs a value, every value needs a row, and a value for a factor no table rates is an error rather than being ignored (a misspelt factor name would otherwise silently rate at 1.0). Numeric factors are banded first, e.g. with `insurance.age-banding`. - **adjustments**: loadings and discounts after the tables, as changes: 2500 is a 25% loading, multiplying by 1.25; -1000 a 10% discount, multiplying by 0.9. They multiply, one after another, like the table factors: a 25% loading and a 10% discount come to 1.125, not 1.15. -10000 (free) is the lowest allowed.
## One rounding
The base is multiplied by every multiplier in exact integer arithmetic (math.big-integer in Rust, native big integers in TypeScript and Python), and rounded to a minor unit once, in the caller's mode. Rounding after each factor drifts: 1.01 at 1.005 three times is 1.0252, so 1.03, but 1.04 when rounded at every step. Order therefore does not change the answer.
## Minimum and maximum premium
Applied last, to the rounded result. `calculated` is the premium before them, `premium` after, and `cap` says which one, if either, set it. A result exactly equal to a cap is not reported as capped.
Not covered: additive loadings in money (a flat 25.00 policy fee), which are added after rating, and IPT, which is `insurance.ipt` on the final premium.
Files
| Path | Bytes |
|---|---|
| README.md | 2,059 |
| impl/python.py | 4,338 |
| impl/rust.rs | 7,253 |
| impl/typescript.ts | 4,338 |
| vectors.json | 19,194 |