fleet.vehicle-depreciation
A vehicle's current value from its cost, age and mileage, on retention curves the caller supplies, exactly.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 20 tests, run in TypeScript, Python and Rust.
What it does
A vehicle's value today from what it cost, its age and its mileage, on retention curves you supply, and how much it has lost. There is no built-in market data: residual values differ by make, model and year, and belong to whoever publishes them (CAP HPI, Glass's, your own disposals). This does the arithmetic on the curve you have, exactly.
## The curves
For example
vehicleDepreciation(£20,000.00, 36, 30,000, 80%, 85%, 88%, 97%, half-up)→ value £10,922.87, depreciation £9,077.13, retained basis points 54.61% three years and 30,000 miles: 0.8 x 0.85 x 0.88 x 0.97^3 of 20,000.00vehicleDepreciation(£20,000.00, 36, 30,000, 80%, 85%, 88%, 97%, up)→ value £10,922.88, depreciation £9,077.12, retained basis points 54.61% the same, rounded upvehicleDepreciation(£20,000.00, 0, 0, 80%, 85%, 97%, half-up)→ value £20,000.00, depreciation £0.00, retained basis points 100% brand new with no miles keeps its whole value
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 vehicleDepreciation(cost: Money, ageMonths: number, mileage: number, retainedPerYearBasisPoints: readonly number[], retainedPer10kMilesBasisPoints: number, mode: RoundingMode): VehicleValue
| cost | Money | what the vehicle cost new (or at the point the curves start from) |
| ageMonths | int | whole months since then, 0 to 600 |
| mileage | int | miles on the clock, 0 to 2,000,000 |
| retainedPerYearBasisPoints | int[] | share of value kept through each year of age, first year first: [8000, 8500, 8800]; the last repeats |
| retainedPer10kMilesBasisPoints | int | share of value kept for every 10,000 miles: 9700 = loses 3% per 10k |
| mode | RoundingMode | how the exact value is rounded to a minor unit |
| returns | VehicleValue |
The type it declares, generated into your project
/** What the vehicle is worth now and what it has lost. */
export interface VehicleValue {
readonly value: Money;
/** cost less value */
readonly depreciation: Money;
/** the exact combined share kept, rounded half-up to a basis point */
readonly retainedBasisPoints: number;
}
Your code names it in one line, in the file that uses it
import { vehicleDepreciation } from "#fune/fleet.vehicle-depreciation@^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 { roundDivBig } from "./math_round_div_big.ts"; ← from math.round-div-big ^1.0.0 · built alongside by fune
import { type Money, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { type VehicleValue } from "./fleet_vehicle_depreciation_types.ts";
function whole(name: string, value: number, max: number): void {
if (!Number.isInteger(value) || value < 0 || value > max) {
throw new RangeError(`${name} must be a whole number from 0 to ${max}, received ${value}`);
}
}
/**
* A vehicle's value from its age and mileage, on the caller's retention curves.
*
* Each whole year multiplies by that year's retention and a part year by a
* straight-line share of it; each whole 10,000 miles multiplies by the mileage
* retention and the remaining miles by a straight-line share. The product is
* kept as one exact fraction and rounded once: rounding the value year by year
* drifts by a penny or more over a long life.
*/
export function vehicleDepreciation(
cost: Money,
ageMonths: number,
mileage: number,
retainedPerYearBasisPoints: readonly number[],
retainedPer10kMilesBasisPoints: number,
mode: RoundingMode
): VehicleValue {
if (cost.minor < 0) {
throw new RangeError(`cost must not be negative, received ${cost.minor}`);
}
whole("ageMonths", ageMonths, 600);
whole("mileage", mileage, 2000000);
if (retainedPerYearBasisPoints.length === 0) {
throw new RangeError("retainedPerYearBasisPoints must have at least one year");
}
for (const bp of retainedPerYearBasisPoints) whole("a retention rate", bp, 10000);
whole("a retention rate", retainedPer10kMilesBasisPoints, 10000);
let num = 1n;
let den = 1n;
const years = Math.floor(ageMonths / 12);
const last = retainedPerYearBasisPoints.length - 1;
const rateFor = (year: number): bigint => BigInt(retainedPerYearBasisPoints[Math.min(year, last)]);
for (let year = 0; year < years; year++) {
num *= rateFor(year);
den *= 10000n;
}
const months = BigInt(ageMonths % 12);
if (months > 0n) {
num *= 120000n - (10000n - rateFor(years)) * months;
den *= 120000n;
}
const mileRate = BigInt(retainedPer10kMilesBasisPoints);
for (let band = 0; band < Math.floor(mileage / 10000); band++) {
num *= mileRate;
den *= 10000n;
}
const miles = BigInt(mileage % 10000);
if (miles > 0n) {
num *= 100000000n - (10000n - mileRate) * miles;
den *= 100000000n;
}
const value = Number(roundDivBig((BigInt(cost.minor) * num).toString(), den.toString(), mode));
return {
value: money(value, cost.currency),
depreciation: money(cost.minor - value, cost.currency),
retainedBasisPoints: Number(roundDivBig((num * 10000n).toString(), den.toString(), "half-up")),
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 4 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 fleet.vehicle-depreciation
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./fleet.vehicle-depreciation-1.0.0-typescript.fune, or fetch it from a terminal with fune pull fleet.vehicle-depreciation@1.0.0:typescript.
The whole function, every language, is one file too: fleet.vehicle-depreciation-1.0.0.fune, 20,117 bytes, sha256 b50858107abbf776025a8428d9a393149ba85037572410721642ac50a82a6752. 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 fleet.vehicle-depreciation
after — your function gets the result and the arguments, and returns the final result.
// fune: after fleet.vehicle-depreciation
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 fleet.vehicle-depreciation
// fune: replace math.round-div in fleet.vehicle-depreciation
// fune: replace math.round-div-big in fleet.vehicle-depreciation
// fune: replace money.amount in fleet.vehicle-depreciation
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 fleet.vehicle-depreciation --steps.
// fune: step fleet.vehicle-depreciation 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 | |
|---|---|---|---|
| three years and 30,000 miles: 0.8 x 0.85 x 0.88 x 0.97^3 of 20,000.00 | £20,000.00, 36, 30,000, 80%, 85%, 88%, 97%, half-up | → | value £10,922.87, depreciation £9,077.13, retained basis points 54.61% |
| the same, rounded up | £20,000.00, 36, 30,000, 80%, 85%, 88%, 97%, up | → | value £10,922.88, depreciation £9,077.12, retained basis points 54.61% |
| brand new with no miles keeps its whole value | £20,000.00, 0, 0, 80%, 85%, 97%, half-up | → | value £20,000.00, depreciation £0.00, retained basis points 100% |
| six months into a year that keeps 80% keeps 90% | £10,000.00, 6, 0, 80%, 100%, half-up | → | value £9,000.00, depreciation £1,000.00, retained basis points 90% |
| past the end of the curve the last year repeats: 0.8 x 0.85^4 | £15,000.00, 60, 0, 80%, 85%, 100%, half-up | → | value £6,264.08, depreciation £8,735.92, retained basis points 41.76% |
| the same 6264.075 rounded down | £15,000.00, 60, 0, 80%, 85%, 100%, down | → | value £6,264.07, depreciation £8,735.93, retained basis points 41.76% |
| 15,000 miles is one whole band and half of the next: 0.9 x 0.95 | £10,000.00, 0, 15,000, 80%, 90%, half-up | → | value £8,550.00, depreciation £1,450.00, retained basis points 85.5% |
| 18 months and 25,000 miles: 0.8 x 0.95 x 0.95^2 x 0.975, with a retained share of 6687.525 bp rounding up | £25,000.00, 18, 25,000, 80%, 90%, 95%, half-up | → | value £16,718.81, depreciation £8,281.19, retained basis points 66.88% |
| a vehicle written off to nothing | £12,000.00, 12, 0, 0%, 97%, half-up | → | value £0.00, depreciation £12,000.00, retained basis points 0% |
| euro cents, ten years on a flat 85% curve | €30,000.00, 120, 0, 85%, 100%, half-up | → | value €5,906.23, depreciation €24,093.77, retained basis points 19.69% |
Show the other 10 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a long-lived van: 0.9^50 x 0.99^200 of 50,000.00 is 34.525 and still divides exactly | £50,000.00, 600, 2,000,000, 90%, 99%, half-up | → | value £34.53, depreciation £49,965.47, retained basis points 0.07% |
| an empty curve is an error | £10,000.00, 12, 0, , 97%, half-up | → | error: retainedPerYearBasisPoints must have at least one year |
| a retention above 100% is an error | £10,000.00, 12, 0, 100.01%, 97%, half-up | → | error: a retention rate must be a whole number from 0 to 10000 |
| a negative mileage retention is an error | £10,000.00, 12, 0, 80%, -0.01%, half-up | → | error: a retention rate must be a whole number from 0 to 10000 |
| a negative age is an error | £10,000.00, -1, 0, 80%, 97%, half-up | → | error: ageMonths must be a whole number from 0 to 600 |
| an age over 50 years is an error | £10,000.00, 601, 0, 80%, 97%, half-up | → | error: ageMonths must be a whole number from 0 to 600 |
| a fractional age is an error | £10,000.00, 6.5, 0, 80%, 97%, half-up | → | error: ageMonths must be a whole number |
| a negative mileage is an error | £10,000.00, 12, -5, 80%, 97%, half-up | → | error: mileage must be a whole number from 0 to 2000000 |
| a negative cost is an error | -£0.01, 12, 0, 80%, 97%, half-up | → | error: cost must not be negative |
| an unknown rounding mode is an error | £10,000.00, 12, 0, 80%, 97%, nearest | → | error: unknown rounding mode |
More from the author
- `retainedPerYearBasisPoints` is the share of value kept through each year of age, first year first. `[8000, 8500, 8800]` means the vehicle keeps 80% of its value in year one, 85% of what is left in year two and 88% in year three; from year four on the last entry (88%) repeats. A part year is a straight-line share of that year's loss: six months into a year that keeps 80% keeps 90%. - `retainedPer10kMilesBasisPoints` is the share kept for every 10,000 miles, compounding per whole 10,000 and straight-line within one: at 90% per 10k, 15,000 miles keeps 0.9 x 0.95 = 85.5%.
The two multiply: value = cost x age factor x mileage factor. Build the age curve for a vehicle doing no miles (or your curve's baseline mileage) if you use both; if your age curve already assumes an average mileage, pass `10000` for the mileage rate and adjust separately.
## Exactness
Every factor is a fraction of integers, so the product is kept as one exact fraction (in big integers, via `math.round-div-big`) and rounded once to the minor unit with the `mode` you choose. Valuing year by year and rounding each year drifts. `retainedBasisPoints` is the exact combined share, rounded half-up to a basis point, for display; the value is not derived from it.
## Limits and errors
Age 0 to 600 months (50 years); mileage 0 to 2,000,000; every rate 0 to 10000 basis points (a vehicle that gains value is outside this model). A negative cost, an empty curve, a value out of range, a fractional number or an unknown rounding mode is an error.
Files
| Path | Bytes |
|---|---|
| README.md | 1,928 |
| impl/python.py | 2,610 |
| impl/rust.rs | 4,148 |
| impl/typescript.ts | 2,719 |
| vectors.json | 4,979 |