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
convert_rate(12.5, percent, basis-points)→ 1250 12.5 percent is 1250 basis pointsconvert_rate(1250, basis-points, percent)→ 12.5 1250 basis points is 12.5 percentconvert_rate(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.
def convert_rate(value: str, from_unit: RateUnit, to_unit: RateUnit) -> str
| value | string | a plain decimal: digits, an optional leading "-", an optional "." with digits after it |
| from_unit | RateUnit | the unit value is in |
| to_unit | RateUnit | the unit wanted |
| returns | string | the same rate in the new unit, as the shortest exact decimal |
The type it declares, generated into your project
RateUnit = Literal["percent", "basis-points", "ratio"]
Your code names it in one line, in the file that uses it
from fune.math.basis_points import convert_rate # math.basis-points@^1
import re
from .math_basis_points_types import RateUnit
# Explicit [0-9]: Python's \d also matches other scripts' digits.
DECIMAL = re.compile(r"^-?[0-9]+(\.[0-9]+)?\Z")
MAX_SAFE = 9007199254740991
def _exponent(unit: str) -> int:
"""Powers of ten between each unit and basis points."""
if unit == "basis-points":
return 0
if unit == "percent":
return 2
if unit == "ratio":
return 4
raise ValueError('unknown rate unit "%s"' % (unit,))
def convert_rate(value: str, from_unit: RateUnit, to_unit: RateUnit) -> str:
"""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.
"""
if not isinstance(value, str) or not DECIMAL.match(value):
raise ValueError('"%s" is not a decimal number' % (value,))
shift = _exponent(from_unit) - _exponent(to_unit)
negative = value.startswith("-")
body = value[1:] if negative else value
point = body.find(".")
digits = body if point < 0 else body[:point] + body[point + 1 :]
position = (len(body) if point < 0 else point) + shift
if position > len(digits):
digits = digits + "0" * (position - len(digits))
if position < 0:
digits = "0" * (-position) + digits
position = 0
whole = digits[:position].lstrip("0") or "0"
fraction = digits[position:].rstrip("0")
text = whole + "." + fraction if fraction else whole
return "-" + text if negative and text != "0" else text
def to_basis_points(value: str, unit: RateUnit) -> int:
"""An integer number of basis points, refusing a rate that is not whole."""
text = convert_rate(value, unit, "basis-points")
if "." in text:
raise ValueError("%s %s is not a whole number of basis points" % (value, unit))
bp = int(text)
if bp > MAX_SAFE or bp < -MAX_SAFE:
raise ValueError("%s %s is too large for integer basis points" % (value, unit))
return bp
def from_basis_points(basis_points: int, unit: RateUnit) -> str:
"""Render integer basis points in another unit."""
if isinstance(basis_points, bool) or not isinstance(basis_points, int):
raise TypeError("basis_points must be an integer, received %r" % (basis_points,))
return convert_rate(str(basis_points), "basis-points", unit)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, 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 math.basis-points
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./math.basis-points-1.0.0-python.fune, or fetch it from a terminal with fune pull math.basis-points@1.0.0:python.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,219 |
| impl/python.py | 2,399 |
| impl/rust.rs | 3,181 |
| impl/typescript.ts | 2,412 |
| vectors.json | 2,292 |