Functional Weave
Code in TypeScript

math.basis-points

Convert a rate between percent, basis points and a plain ratio exactly, as decimal text or integer basis points.

1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra

Pinned by 19 tests, run in TypeScript, Python and Rust.

What it does

100 basis points is 1 percent is a ratio of 0.01. The three units differ only by powers of ten, so converting between them is moving a decimal point, and this does exactly that on the text of the number. `"0.07"` as a ratio is `"7"` percent, where `0.07 * 100` in floating point is `7.000000000000001`.

Values are decimal strings in and out, never floats, so any rate a person can write down converts without loss and without a size limit. Output is the shortest form: no leading zeros, no trailing fractional zeros, no trailing point, and zero is `"0"` (never `"-0"`).

For example

  • convertRate(12.5, percent, basis-points) → 1250 12.5 percent is 1250 basis points
  • convertRate(1250, basis-points, percent) → 12.5 1250 basis points is 12.5 percent
  • convertRate(0.125, ratio, percent) → 12.5 a ratio of 0.125 is 12.5 percent

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 convertRate(value: string, fromUnit: RateUnit, toUnit: RateUnit): string
valuestringa plain decimal: digits, an optional leading "-", an optional "." with digits after it
fromUnitRateUnitthe unit value is in
toUnitRateUnitthe unit wanted
returnsstringthe same rate in the new unit, as the shortest exact decimal

The type it declares, generated into your project

export type RateUnit = "percent" | "basis-points" | "ratio";

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

import { convertRate } from "#fune/math.basis-points@^1";
impl/typescript.ts · 68 lines · open · raw
import { type RateUnit } from "./math_basis_points_types.ts";

const DECIMAL = /^-?[0-9]+(\.[0-9]+)?$/;

/** Powers of ten between each unit and basis points. */
function exponent(unit: RateUnit): number {
  switch (unit) {
    case "basis-points":
      return 0;
    case "percent":
      return 2;
    case "ratio":
      return 4;
    default:
      throw new RangeError(`unknown rate unit "${unit}"`);
  }
}

/**
 * Convert a rate between percent, basis points and a ratio.
 *
 * The units differ by powers of ten, so this moves the decimal point in the
 * text itself: exact for any input, with no float anywhere.
 */
export function convertRate(value: string, fromUnit: RateUnit, toUnit: RateUnit): string {
  if (typeof value !== "string" || !DECIMAL.test(value)) {
    throw new RangeError(`"${value}" is not a decimal number`);
  }
  const shift = exponent(fromUnit) - exponent(toUnit);

  const negative = value.startsWith("-");
  const body = negative ? value.slice(1) : value;
  const point = body.indexOf(".");
  let digits = point < 0 ? body : body.slice(0, point) + body.slice(point + 1);
  let position = (point < 0 ? body.length : point) + shift;

  if (position > digits.length) digits = digits + "0".repeat(position - digits.length);
  if (position < 0) {
    digits = "0".repeat(-position) + digits;
    position = 0;
  }

  const whole = digits.slice(0, position).replace(/^0+/, "") || "0";
  const fraction = digits.slice(position).replace(/0+$/, "");
  const text = fraction ? `${whole}.${fraction}` : whole;
  return negative && text !== "0" ? "-" + text : text;
}

/** An integer number of basis points, refusing a rate that is not whole. */
export function toBasisPoints(value: string, unit: RateUnit): number {
  const text = convertRate(value, unit, "basis-points");
  if (text.includes(".")) {
    throw new RangeError(`${value} ${unit} is not a whole number of basis points`);
  }
  const bp = Number(text);
  if (!Number.isSafeInteger(bp)) {
    throw new RangeError(`${value} ${unit} is too large for integer basis points`);
  }
  return bp;
}

/** Render integer basis points in another unit. */
export function fromBasisPoints(basisPoints: number, unit: RateUnit): string {
  if (!Number.isSafeInteger(basisPoints)) {
    throw new TypeError(`basisPoints must be a safe integer, received ${basisPoints}`);
  }
  return convertRate(String(basisPoints), "basis-points", unit);
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, 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 math.basis-points
Download for TypeScript math.basis-points-1.0.0-typescript.fune · 8,131 bytes sha256 065485bc2d5e69457e0fa5351099c7fb50a9ea4b7667a71c4524e4d855844012

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./math.basis-points-1.0.0-typescript.fune, or fetch it from a terminal with fune pull math.basis-points@1.0.0:typescript.

The whole function, every language, is one file too: math.basis-points-1.0.0.fune, 14,007 bytes, sha256 7ee7e2aa9137812ddceea028ea4164333ce73b3305eb9db7a5ccd4a4259d0e1b. 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 math.basis-points

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

// fune: after math.basis-points

replace — it requires no other capability, so there is no dependency to replace.

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 math.basis-points --steps.

// fune: step math.basis-points 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
12.5 percent is 1250 basis points 12.5, percent, basis-points → 1250
1250 basis points is 12.5 percent 1250, basis-points, percent → 12.5
a ratio of 0.125 is 12.5 percent 0.125, ratio, percent → 12.5
20 percent is a ratio of 0.2 20, percent, ratio → 0.2
one basis point is a ratio of 0.0001 1, basis-points, ratio → 0.0001
half a basis point as a ratio needs leading zeros 0.5, basis-points, ratio → 0.00005
0.07 as a ratio is exactly 7 percent, not 7.000000000000001 0.07, ratio, percent → 7
a negative rate keeps its sign -2.75, percent, basis-points → -275
same unit gives the canonical form 007.500, percent, percent → 7.5
zero with trailing zeros is plain zero 0.00, ratio, basis-points → 0
Show the other 9 tests
CaseArgumentsExpected
negative zero is zero -0.0, percent, ratio → 0
a ratio of one is 10000 basis points 1, ratio, basis-points → 10000
numbers beyond any float or integer convert exactly 123456789012345678901.23, percent, basis-points → 12345678901234567890123
a percent sign is not part of the number 12.5%, percent, basis-points → error: is not a decimal number
exponent notation is refused 1e3, basis-points, percent → error: is not a decimal number
a bare leading point is refused .5, percent, ratio → error: is not a decimal number
a decimal comma is refused 1,5, percent, ratio → error: is not a decimal number
an empty string is refused , percent, ratio → error: is not a decimal number
an unknown unit is an error 5, permille, percent → error: unknown rate unit

More from the author

Input is strict: an optional leading `-`, digits, and optionally `.` followed by digits. `"12.5%"`, `"+5"`, `".5"`, `"5."`, `"1e3"`, `"1,5"` and spaces are all errors. Strip a `%` sign before calling; this is not a parser for free-form text.

The registry keeps rates as integer basis points, so two helpers are exported for the common edges: `toBasisPoints(value, unit)` returns an integer and refuses a rate that is not a whole number of basis points (12.345% is 1234.5 basis points, which no integer holds) or is beyond ±(2^53 - 1), and `fromBasisPoints(bp, unit)` renders an integer basis-point rate as a decimal string.

Files

PathBytes
README.md1,219
impl/python.py2,399
impl/rust.rs3,181
impl/typescript.ts2,412
vectors.json2,292