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.
pub fn convert_rate(value: &str, from_unit: &str, to_unit: &str) -> String
| 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 is a string in Rust, one of: "percent", "basis-points", "ratio".
// Parameters take it as &str and results hold it as String.
Your code names it in one line, in the file that uses it
fune!(math.basis-points@^1); // then call convert_rate(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
const MAX_SAFE: i64 = 9_007_199_254_740_991;
/// Powers of ten between each unit and basis points.
fn exponent(unit: &str) -> i64 {
match unit {
"basis-points" => 0,
"percent" => 2,
"ratio" => 4,
other => panic!("unknown rate unit \"{}\"", other),
}
}
fn is_decimal(value: &str) -> bool {
let body = value.strip_prefix('-').unwrap_or(value);
let mut parts = body.splitn(2, '.');
let whole = parts.next().unwrap_or("");
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
match parts.next() {
Some(fraction) => digits(whole) && digits(fraction),
None => digits(whole),
}
}
/// 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.
///
/// # Panics
/// Panics if `value` is not a plain decimal or a unit is unknown.
pub fn convert_rate(value: &str, from_unit: &str, to_unit: &str) -> String {
if !is_decimal(value) {
panic!("\"{}\" is not a decimal number", value);
}
let shift = exponent(from_unit) - exponent(to_unit);
let negative = value.starts_with('-');
let body = if negative { &value[1..] } else { value };
let (mut digits, point) = match body.find('.') {
Some(p) => (format!("{}{}", &body[..p], &body[p + 1..]), p as i64),
None => (body.to_string(), body.len() as i64),
};
let mut position = point + shift;
if position > digits.len() as i64 {
digits.push_str(&"0".repeat((position - digits.len() as i64) as usize));
}
if position < 0 {
digits = "0".repeat((-position) as usize) + &digits;
position = 0;
}
let (whole, fraction) = digits.split_at(position as usize);
let whole = whole.trim_start_matches('0');
let whole = if whole.is_empty() { "0" } else { whole };
let fraction = fraction.trim_end_matches('0');
let text = if fraction.is_empty() {
whole.to_string()
} else {
format!("{}.{}", whole, fraction)
};
if negative && text != "0" {
format!("-{}", text)
} else {
text
}
}
/// An integer number of basis points, refusing a rate that is not whole.
///
/// # Panics
/// Panics if the rate is not a whole number of basis points or is beyond ±(2^53 - 1).
pub fn to_basis_points(value: &str, unit: &str) -> i64 {
let text = convert_rate(value, unit, "basis-points");
if text.contains('.') {
panic!("{} {} is not a whole number of basis points", value, unit);
}
match text.parse::<i64>() {
Ok(bp) if (-MAX_SAFE..=MAX_SAFE).contains(&bp) => bp,
_ => panic!("{} {} is too large for integer basis points", value, unit),
}
}
/// Render integer basis points in another unit.
pub fn from_basis_points(basis_points: i64, unit: &str) -> String {
convert_rate(&basis_points.to_string(), "basis-points", unit)
}
pub fn fune_vector(args: &[Value]) -> Value {
Value::Str(convert_rate(
args[0].as_str(),
args[1].as_str(),
args[2].as_str(),
))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. 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 Rust implementation. Install it without the registry with fune add ./math.basis-points-1.0.0-rust.fune, or fetch it from a terminal with fune pull math.basis-points@1.0.0:rust.
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 |