rustdecimal

Why is my test not losing precision by using floats for percentages, versus whole-integer basis points, for interest in Rust?


This is absolutely not a duplicate of Why not use Double or Float to represent currency? - I have edited the question further to make this even more explicit.

Why is my test not losing precision by using floats for percentages, versus whole-integer basis points, for interest in Rust?

I am aware that computers should not handle decimals using floating points.

Eg, 0.02 is 1.010001111010111000010100011110101110... in binary. I've worked on other financial software and have generally used integers and basis points (eg 200 instead of 0.02) to avoid computers having issues processing decimal values.

I've written the following code though and both tests are passing:

use std::f64::consts::E;

pub fn get_compounded_value_using_ints(
    initial_amount: u64,
    elapsed_periods: u64,
    interest_rate_basis_points: u64,
) -> u64 {
    let multiplier =
        E.powf(interest_rate_basis_points as f64 * elapsed_periods as f64 / 10_000 as f64);
    (initial_amount as f64 * multiplier) as u64
}

pub fn get_compounded_value_using_floats(
    initial_amount: u64,
    elapsed_periods: u64,
    interest_rate: f64,
) -> u64 {
    let multiplier = E.powf(interest_rate * elapsed_periods as f64);
    (initial_amount as f64 * multiplier) as u64
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_get_compounded_value_using_ints() {
        // Start with 100_000, 12 periods, 2% interest
        assert_eq!(get_compounded_value_using_ints(100_000, 12, 200), 127_124);
    }

    #[test]
    fn test_get_compounded_value_using_floats() {
        // Start with 100_000, 12 periods, 2% interest
        assert_eq!(
            get_compounded_value_using_floats(100_000, 12, 0.02),
            127_124
        );
    }
}

I've even modified both tests to use much larger numbers (towards the u64) maximums and they still pass.

Since both tests are passing, this makes it seem like one can use floats for percentage values happily. I wonder though if there's something wrong with the tests and at some point the float version will start to be imprecise.

Why is my test not losing precision by using floats for percentages, versus whole-integer basis points, for interest in Rust?


Solution

  • Yes, you need to worry about loss of precision with floats. It looks like both your tests end up using floats, the numbers are small, there are limited places for rounding, and you ignore cents in the output. It's not surprising that they pass.

    Floats have several weaknesses for money:

    See more here: Why not use Double or Float to represent currency?

    Luckily, floats vs. "integers" is a false choice. For money, you should use a decimal type like fpdec::Decimal for fixed-point decimal, rust_decimal::Decimal for floating-point decimal, rusty-money for fixed-point decimal optimized for money, or bigdecimal::Decimal for arbitrary magnitudes.

    Here is a similar test that demonstrates the difference. By compounding at many discrete times, floats struggle to yield an accurate answer. I've commented a few places where float-related issues occur.

    use rust_decimal::{Decimal, RoundingStrategy};
    
    pub fn get_compounded_value_using_decimals(
        initial_amount: Decimal,
        elapsed_periods: u64,
        interest_rate: Decimal,
    ) -> Decimal {
        let mut amount = initial_amount;
        for _ in 0..elapsed_periods {
            amount = amount
                .checked_add(
                    amount
                        .checked_mul(interest_rate)
                        .unwrap()
                        .round_dp_with_strategy(2, RoundingStrategy::MidpointNearestEven),
                )
                .unwrap();
        }
        amount
    }
    
    pub fn get_compounded_value_using_floats(
        initial_amount: f64,
        elapsed_periods: u64,
        interest_rate: f64,
    ) -> f64 {
        let mut amount = initial_amount;
        // CON: Floats build up rounding error with each iteration.
        for _ in 0..elapsed_periods {
            // CON: Rounding is trickier.
            amount += (amount * interest_rate * 100.0).round_ties_even() * 0.01;
        }
        amount
    }
    
    fn compare(initial: u64, periods: u64, percent: u64) {
        let with_decimals = get_compounded_value_using_decimals(
            Decimal::from(initial),
            periods,
            Decimal::from(percent) / Decimal::from(100),
        );
        // CON: 0.02 isn't precisely representable by `f64`, instead becoming 0.0199999995529651641845703125.
        let with_floats =
            get_compounded_value_using_floats(initial as f64, periods, percent as f64 / 100.0);
    
        println!("${initial}, {periods} periods, {percent}%");
        println!("decimals ${with_decimals}");
    
        // CON: added cent-precision numbers and got rounding errors, need to truncate.
        println!("floats   ${with_floats:.2}");
        println!();
    }
    
    pub fn main() {
        // Note: for the purpose of the comments, 2% means
        // 2% monthly or 24% annually (equivalent of 26.824%
        // compounded annually). This might be unrealistically
        // high for an investment, but is typical of certain loans.
    
        // 1 year compounded every month -> $0.01 difference
        // decimals $126824.17
        // floats   $126824.18
        compare(100_000, 12, 2);
    
        // ~83 years compounded every month -> $28 difference
        // decimals $19502.56
        // floats   $19530.56
        compare(1, 1000, 1);
    }