Functional Weave
Code in Python

math.basis-points@1.0.0

impl/rust.rs

3,181 bytes · the Rust implementation · view 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

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(),
    ))
}