LordOfThePigs
LordOfThePigs

Reputation: 11300

Creating correct BigDecimals from slightly off doubles

I'm connecting to an external system that provides me with a stream of double financial prices. I know that the values I receive are meant to be decimal values, and that therefore BigDecimal would have been a better choice, but the provided client library forces me to use double instead.

My goal is to build a BigDecimal from the provided double values. The simple solution is to just use BigDecimal.valueOf(double). However, as is a common problem when working with double, the values that I receive look like 0.12000000001 instead of 0.12, or 1.15999999998 instead of 1.16. I could do some rounding by using BigDecimal.setScale(), but the problem with that is that I don't know the exact intended precision of the value.

How can I reconstruct the intended decimal value?

My solution so far relies on the fact that the value I receive is always within 1 or 2 ULP of the intended decimal value. So I just try converting the value I receive as well as the values 1-2 ulp up/down to String, and create a BigDecimal from the shortest of these strings.

    private static BigDecimal shortestBigDecimal(double inputDouble) {
        double ulp = Math.ulp(inputDouble);
        String center = Double.toString(inputDouble);
        String high = Double.toString(inputDouble + ulp);
        String low = Double.toString(inputDouble - ulp);
        String high2 = Double.toString(inputDouble + ulp + ulp);
        String low2 = Double.toString(inputDouble - ulp - ulp);

        String shortest = minLength(minLength(minLength(minLength(low2, low), center), high), high2);
        return new BigDecimal(shortest);
    }

    private static String minLength(String a, String b) {
        return a.length() < b.length() ? a : b;
    }

This code passes all of my test cases, but it creates a lot of temporary Strings.

Is there a more efficient way to do this?

Upvotes: 3

Views: 122

Answers (2)

Patricia Shanahan
Patricia Shanahan

Reputation: 26185

I have tested an alternative approach that involves no strings at all. Instead, it searches for the lowest scale factor BigDecimal that results from rounding of the original value and is in the range:

import java.math.BigDecimal;
import java.math.RoundingMode;

public strictfp class Test {
    public static void main(String[] args) {
        testit(0.1);
        testit(1.15999999998);
        testit(0.12000000000000001);
        testit(100.00001);
        testit(0.3);
        double ulp = Math.ulp(0.3);
        testit(0.3 + ulp);
        testit(0.3 + 2*ulp);
        testit(0.3 + 3*ulp);
    }

    private static void testit(double d) {
        System.out.println(d + " " + shortestBigDecimal(d) + " " + alternative(d));
    }

    private static BigDecimal shortestBigDecimal(double inputDouble) {
        double ulp = Math.ulp(inputDouble);
        String center = Double.toString(inputDouble);
        String high = Double.toString(inputDouble + ulp);
        String low = Double.toString(inputDouble - ulp);
        String high2 = Double.toString(inputDouble + ulp + ulp);
        String low2 = Double.toString(inputDouble - ulp - ulp);

        String shortest = minLength(minLength(minLength(minLength(low2, low), center), high), high2);
        return new BigDecimal(shortest);
    }

    private static String minLength(String a, String b) {
        return a.length() < b.length() ? a : b;
    }

    private static BigDecimal alternative(double d) {
        BigDecimal original = new BigDecimal(d);
        BigDecimal low = new BigDecimal(Math.nextDown(Math.nextDown(d)));
        BigDecimal high = new BigDecimal(Math.nextUp(Math.nextUp(d)));
        for(int scale = 0; scale <= original.scale(); scale++) {
            BigDecimal candidate = original.setScale(scale, RoundingMode.HALF_EVEN);
            if(candidate.compareTo(low) >= 0 && candidate.compareTo(high) <= 0) {
                return candidate;
            }
        }
        return original;
    }

}

Output:

0.1 0.1 0.1
1.15999999998 1.15999999998 1.15999999998
0.12000000000000001 0.12 0.12
100.00001 100.00001 100.00001
0.3 0.3 0.3
0.30000000000000004 0.3 0.3
0.3000000000000001 0.3 0.3
0.30000000000000016 0.3000000000000002 0.3000000000000002

Upvotes: 1

Lino
Lino

Reputation: 19911

You can use the overloaded constructor of BigDecimal where you can provide a MathContext. Using DECIMAL32 we have a rather low precision (7 decimal places), meaning everything out of the range is rounded using RoundingMode.HALF_EVEN. By using the method stripTrailingZeros, we can get only the significant digits and get rid of trailing 0.

public static BigDecimal shortestBigDecimal(double inputValue) {
    return 
        new BigDecimal( 
            inputValue, 
            MathContext.DECIMAL32
        )
        .stripTrailingZeros();
}

Using this prints:

System.out.println(shortestBigDecimal(1.15999999998));    // 1.16
System.out.println(shortestBigDecimal(0.12000000000001)); // 0.12

Upvotes: 1

Related Questions