Functional Weave
Code in Python

insurance.rating-factors Unreviewed

Premium from a base rate times factor-table lookups, loadings and discounts, within minimum and maximum premiums.

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

Pinned by 24 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 actuary 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 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

  • rate_premium(£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.80
  • rate_premium(£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.90
  • rate_premium(£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.

def rate_premium(base_premium: Money, tables: Sequence[FactorTable], risk: Mapping[str, str], adjustments: Sequence[PremiumAdjustment], minimum_premium: Optional[Money], maximum_premium: Optional[Money], mode: RoundingMode) -> RatedPremium
base_premiumMoneythe base rate before any factor, 0 or more
tablesFactorTable[]the rating factor tables, applied in order
riskmap<string>the risk's value for each table, by table name: {"area": "C", "vehicleGroup": "11-20"}
adjustmentsPremiumAdjustment[]loadings (positive) and discounts (negative) applied after the tables
minimum_premiumMoney?the least the premium may be, or null
maximum_premiumMoney?the most the premium may be, or null
modeRoundingModehow the one final rounding to a minor unit goes
returnsRatedPremium

The types it declares, generated into your project

@dataclass(frozen=True)
class FactorTable:
    """One rating factor: a multiplier for each value the risk can take."""

    #: the factor, e.g. area or driverAge
    name: str
    rows: List[FactorRow]

@dataclass(frozen=True)
class FactorRow:
    """One value of a factor and its multiplier."""

    key: str
    #: multiplier in basis points: 10000 = 1.0, 13500 = 1.35
    basis_points: int

@dataclass(frozen=True)
class PremiumAdjustment:
    """A loading or discount, as a change to the premium."""

    name: str
    #: 2500 = 25% loading, -1000 = 10% discount; -10000 at the least
    basis_points: int

@dataclass(frozen=True)
class AppliedFactor:
    """A multiplier used, in the order it was applied."""

    name: str
    #: the multiplier: 10000 = 1.0, 9000 for a 10% discount
    basis_points: int

PremiumCap = Literal["none", "minimum", "maximum"]

@dataclass(frozen=True)
class RatedPremium:
    """The premium and how it was reached."""

    #: table factors then adjustments, as multipliers
    factors: List[AppliedFactor]
    #: base times every multiplier, rounded once, before the caps
    calculated: Money
    #: calculated, held within the minimum and maximum premiums
    premium: Money
    #: which cap, if any, set the premium
    cap: PremiumCap

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

from fune.insurance.rating_factors import rate_premium  # insurance.rating-factors@^1
impl/python.py · 99 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 typing import List, Mapping, Optional, Sequence

from .insurance_rating_factors_types import AppliedFactor, FactorTable, PremiumAdjustment, RatedPremium
from .math_round_div import RoundingMode  ← from math.round-div ^1.0.0 · built alongside by fune
from .money_amount import Money, assert_same_currency, money  ← from money.amount ^1.0.0 · built alongside by fune


def _is_int(value: object) -> bool:
    return isinstance(value, int) and not isinstance(value, bool)


def _round_quotient(numerator: int, denominator: int, mode: str) -> int:
    # Both are non-negative here: a base of 0 or more times multipliers of 0 or more.
    q, r = divmod(numerator, denominator)
    twice = r * 2
    if mode == "down":
        return q
    if mode == "up":
        return q + 1 if twice > 0 else q
    if mode == "half-up":
        return q + 1 if twice >= denominator else q
    if mode == "half-even":
        return q + 1 if twice > denominator or (twice == denominator and q % 2 == 1) else q
    raise ValueError('unknown rounding mode "%s"' % (mode,))


def rate_premium(
    base_premium: Money,
    tables: Sequence[FactorTable],
    risk: Mapping[str, str],
    adjustments: Sequence[PremiumAdjustment],
    minimum_premium: Optional[Money],
    maximum_premium: Optional[Money],
    mode: RoundingMode,
) -> RatedPremium:
    """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. Python integers are exact at any
    size, so there is no limit on the number of factors.
    """
    if base_premium.minor < 0:
        raise ValueError("basePremium must not be negative, received %d" % (base_premium.minor,))
    if minimum_premium is not None:
        assert_same_currency(base_premium, minimum_premium)
    if maximum_premium is not None:
        assert_same_currency(base_premium, maximum_premium)
    if minimum_premium is not None and maximum_premium is not None and minimum_premium.minor > maximum_premium.minor:
        raise ValueError("minimumPremium must not be more than maximumPremium")

    factors: List[AppliedFactor] = []
    names = set()
    for table in tables:
        if table.name in names:
            raise ValueError('rating factor "%s" appears twice' % (table.name,))
        names.add(table.name)
        if table.name not in risk:
            raise ValueError('no value for rating factor "%s"' % (table.name,))
        key = risk[table.name]
        found = None
        for row in table.rows:
            if row.key == key:
                if found is not None:
                    raise ValueError('rating factor "%s" has two rows for "%s"' % (table.name, key))
                if not _is_int(row.basis_points) or row.basis_points < 0:
                    raise ValueError(
                        "factor basisPoints must be a whole number of 0 or more, received %s" % (row.basis_points,)
                    )
                found = row.basis_points
        if found is None:
            raise ValueError('rating factor "%s" has no row for "%s"' % (table.name, key))
        factors.append(AppliedFactor(name=table.name, basis_points=found))
    for name in risk:
        if name not in names:
            raise ValueError('risk has a value for "%s", which no table rates' % (name,))
    for adjustment in adjustments:
        bp = adjustment.basis_points
        if not _is_int(bp) or bp < -10000:
            raise ValueError("adjustment basisPoints must be a whole number of -10000 or more, received %s" % (bp,))
        factors.append(AppliedFactor(name=adjustment.name, basis_points=10000 + bp))

    numerator = base_premium.minor
    denominator = 1
    for f in factors:
        numerator *= f.basis_points
        denominator *= 10000
    c = base_premium.currency
    calculated = money(_round_quotient(numerator, denominator, mode), c)
    premium = calculated
    cap = "none"
    if minimum_premium is not None and premium.minor < minimum_premium.minor:
        premium = minimum_premium
        cap = "minimum"
    elif maximum_premium is not None and premium.minor > maximum_premium.minor:
        premium = maximum_premium
        cap = "maximum"
    return RatedPremium(factors=factors, calculated=calculated, premium=premium, cap=cap)

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 insurance.rating-factors
Download for Python insurance.rating-factors-1.0.1-python.fune · 34,922 bytes sha256 9d84d5f08dd3d6de10dd7c4bb880549a112e977a6c1470988febf350d860b38f

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

The whole function, every language, is one file too: insurance.rating-factors-1.0.1.fune, 46,983 bytes, sha256 ce4334fca52247b4674c4c0994edd03dd8a34c79973ffbe7f84a108accd00508. 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.

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

## Before you rely on this

**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 above, and have an actuary 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 actuary 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,132
impl/python.py4,338
impl/rust.rs7,253
impl/typescript.ts4,338
vectors.json19,194