Functional Weave
Code in Rust

math.basis-points

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

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

Pinned by 45 tests, run in TypeScript, Python and Rust.convertRate 19 · toBasisPoints 13 · fromBasisPoints 13

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 `convertRate` 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"`).

The functions

A group: 3 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.

  1. convert_rate (value: string, fromUnit: RateUnit, toUnit: RateUnit) -> string
  2. to_basis_points (value: string, unit: RateUnit) -> int
  3. from_basis_points (basisPoints: int, unit: RateUnit) -> string

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.

Once installed, your code imports each one from the group's module.

convert_rate throws on bad input 19 tests

pub fn convert_rate(value: &str, from_unit: &str, to_unit: &str) -> String
valuestringa plain decimal: digits, an optional leading "-", an optional "." with digits after it
from_unitRateUnitthe unit value is in
to_unitRateUnitthe unit wanted
returnsstringthe same rate in the new unit, as the shortest exact decimal

For example

  • convert_rate(12.5, percent, basis-points) → 1250 12.5 percent is 1250 basis points
  • convert_rate(1250, basis-points, percent) → 12.5 1250 basis points is 12.5 percent
  • convert_rate(0.125, ratio, percent) → 12.5 a ratio of 0.125 is 12.5 percent
fune!(math.basis-points@^2);  // then call convert_rate(…)
impl/rust/convert_rate.rs · 75 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.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one

/// 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
    }
}

pub fn fune_vector(args: &[Value]) -> Value {
    Value::Str(convert_rate(
        args[0].as_str(),
        args[1].as_str(),
        args[2].as_str(),
    ))
}

to_basis_points throws on bad input 13 tests

pub fn to_basis_points(value: &str, unit: &str) -> i64
valuestringa plain decimal, as convertRate takes it
unitRateUnitthe unit value is in
returnsintwhole basis points; an error if the rate is not a whole number of them or is beyond ±(2^53 - 1)

For example

  • to_basis_points(20, percent) → 2,000 20 percent is 2000 basis points
  • to_basis_points(12.5, percent) → 1,250 12.5 percent is 1250 basis points
  • to_basis_points(0.0525, ratio) → 525 a ratio of 0.0525 is 525 basis points
fune!(math.basis-points@^2);  // then call to_basis_points(…)
impl/rust/to_basis_points.rs · 24 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.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_basis_points_convert_rate::convert_rate;  ← convertRate, another function of this group · built into the same file, even by a slim install

/// A rate as whole basis points, the unit the registry keeps rates in.
/// Refuses a rate that is not a whole number of basis points (12.345% is
/// 1234.5) rather than round it: which way to round a rate is the caller's
/// policy, not a conversion's.
///
/// # 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 (-9_007_199_254_740_991..=9_007_199_254_740_991).contains(&bp) => bp,
        _ => panic!("{} {} is too large for integer basis points", value, unit),
    }
}

pub fn fune_vector(args: &[Value]) -> Value {
    Value::Int(to_basis_points(args[0].as_str(), args[1].as_str()))
}

from_basis_points throws on bad input 13 tests

pub fn from_basis_points(basis_points: i64, unit: &str) -> String
basis_pointsintan integer within ±(2^53 - 1)
unitRateUnitthe unit wanted
returnsstringthe rate as the shortest exact decimal

For example

  • from_basis_points(20%, percent) → 20 2000 basis points is 20 percent
  • from_basis_points(12.5%, percent) → 12.5 1250 basis points is 12.5 percent
  • from_basis_points(12.5%, ratio) → 0.125 1250 basis points is a ratio of 0.125
fune!(math.basis-points@^2);  // then call from_basis_points(…)
impl/rust/from_basis_points.rs · 26 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.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_basis_points_convert_rate::convert_rate;  ← convertRate, another function of this group · built into the same file, even by a slim install

/// Integer basis points as the shortest exact decimal in another unit.
///
/// # Panics
/// Panics if `basis_points` is beyond ±(2^53 - 1) or the unit is unknown.
pub fn from_basis_points(basis_points: i64, unit: &str) -> String {
    // i64 could go further, but TypeScript cannot, and the three languages
    // must give the same answer or the same error.
    if !(-9_007_199_254_740_991..=9_007_199_254_740_991).contains(&basis_points) {
        panic!("basisPoints is outside the safe integer range (±9007199254740991)");
    }
    convert_rate(&basis_points.to_string(), "basis-points", unit)
}

pub fn fune_vector(args: &[Value]) -> Value {
    // Refuse what the typed signature cannot hold, with the wording TypeScript
    // and Python use, rather than let the conversion quietly truncate it.
    if let Value::Float(f) = args[0] {
        if f.fract() != 0.0 {
            panic!("basisPoints must be an integer, received {}", f);
        }
    }
    Value::Str(from_basis_points(args[0].as_i64(), args[1].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

That builds the whole group. To build only what you call, and whatever it uses inside the group:

fune add math.basis-points --only convertRate
Download for Rust math.basis-points-2.0.0-rust.fune · 18,395 bytes sha256 0006affb1eefe91691e1c586b71cc83f7c54572c7af43aec51d2aca776e25161

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

The whole function, every language, is one file too: math.basis-points-2.0.0.fune, 25,143 bytes, sha256 8f577c1f8da63586073a8df9632faa5df78e65aec48b6eace456101dc5c12935. 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.convertRate
// fune: before math.basis-points.toBasisPoints
// fune: before math.basis-points.fromBasisPoints

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

// fune: after math.basis-points.convertRate
// fune: after math.basis-points.toBasisPoints
// fune: after math.basis-points.fromBasisPoints

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

step — your function runs at a numbered point inside a 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.<fn> 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.

convertRate 19 tests

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

toBasisPoints 13 tests

CaseArgumentsExpected
20 percent is 2000 basis points 20, percent → 2,000
12.5 percent is 1250 basis points 12.5, percent → 1,250
a ratio of 0.0525 is 525 basis points 0.0525, ratio → 525
basis points given as basis points, trailing zeros allowed 75.000, basis-points → 75
a negative rate keeps its sign -0.75, percent → -75
zero percent is zero 0.00, percent → 0
negative zero is zero -0, ratio → 0
the largest safe number of basis points 9007199254740991, basis-points → 9,007,199,254,740,991
12.345 percent is 1234.5 basis points, which no integer holds 12.345, percent → error: is not a whole number of basis points
a ratio finer than a basis point is refused, not rounded 0.00001, ratio → error: is not a whole number of basis points
Show the other 3 tests
CaseArgumentsExpected
one past 2^53 - 1 basis points is too large 9007199254740992, basis-points → error: too large for integer basis points
a percent sign is not part of the number 20%, percent → error: is not a decimal number
an unknown unit is an error 5, permille → error: unknown rate unit

fromBasisPoints 13 tests

CaseArgumentsExpected
2000 basis points is 20 percent 20%, percent → 20
1250 basis points is 12.5 percent 12.5%, percent → 12.5
1250 basis points is a ratio of 0.125 12.5%, ratio → 0.125
one basis point is 0.01 percent 0.01%, percent → 0.01
one basis point is a ratio of 0.0001 0.01%, ratio → 0.0001
basis points to basis points is the same number -0.4%, basis-points → -40
a negative rate keeps its sign -2.75%, percent → -2.75
zero is plain zero 0%, ratio → 0
10000 basis points is a ratio of exactly one 100%, ratio → 1
the largest safe number of basis points as a ratio 90071992547409.9%, ratio → 900719925474.0991
Show the other 3 tests
CaseArgumentsExpected
2^53 basis points is outside the safe range 90071992547409.92%, percent → error: outside the safe integer range
a fractional number of basis points is refused 0.125%, percent → error: must be an integer
an unknown unit is an error 1%, permille → 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 functions cover 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; rounding it is the caller's policy) or is beyond ±(2^53 - 1). `fromBasisPoints(bp, unit)` renders an integer basis-point rate as the shortest decimal string in another unit.

This is a group of three functions, each in its own file. `toBasisPoints` and `fromBasisPoints` are built on `convertRate`, so installing either with `only=` brings `convertRate` too.

## What changed from 1.0.0

1.0.0 was one function, `convertRate`, with `toBasisPoints` and `fromBasisPoints` exported beside it but unpinned: no signature in the manifest and no vectors. 2.0.0 is a group in which both are published functions with their own signatures and vectors. `convertRate` is unchanged and keeps every 1.0.0 vector.

`fromBasisPoints` changed behaviour, because the three languages did not agree on it in 1.0.0: TypeScript refused a value beyond ±(2^53 - 1) and a fraction ("basisPoints must be a safe integer"), Python refused only a non-integer (with a different message) and converted any size, and Rust converted any `i64`. In 2.0.0 all three refuse a fraction with "basisPoints must be an integer" and a value beyond ±(2^53 - 1) with "basisPoints is outside the safe integer range". `toBasisPoints` gives the same answers and errors as before.

That behaviour change, and the move to one module per function (the group module `math_basis_points` still re-exports all three; the Python module no longer exports its `MAX_SAFE` constant), make this a major version. Nothing in the registry depended on 1.0.0.

Files

PathBytes
README.md2,600
impl/python/convert_rate.py1,558
impl/python/from_basis_points.py794
impl/python/to_basis_points.py781
impl/rust/convert_rate.rs2,361
impl/rust/from_basis_points.rs1,119
impl/rust/to_basis_points.rs996
impl/typescript/convert_rate.ts1,590
impl/typescript/from_basis_points.ts623
impl/typescript/to_basis_points.ts879
vectors.json6,119