5

I am having an issue with the way Javascript is rounding numbers when hitting 0.5. I am writing levies calculators, and am noticing a 0.1c discrepancy in the results.

The problem is that the result for them is 21480.705 which my application translates into 21480.71, whereas the tariff says 21480.70.

This is what I am seeing with Javascript:

(21480.105).toFixed(2)
"21480.10"
(21480.205).toFixed(2)
"21480.21"
(21480.305).toFixed(2)
"21480.31"
(21480.405).toFixed(2)
"21480.40"
(21480.505).toFixed(2)
"21480.51"
(21480.605).toFixed(2)
"21480.60"
(21480.705).toFixed(2)
"21480.71"
(21480.805).toFixed(2)
"21480.81"
(21480.905).toFixed(2)
"21480.90"

Questions:

  • What the hell is going on with this erratic rouding?
  • What's the quickest easiest way to get a "rounded up" result (when hitting 0.5)?
Merc
  • 16,277
  • 18
  • 79
  • 122
  • 2
    [Answer to question 1](https://stackoverflow.com/questions/588004/is-floating-point-math-broken) – Robby Cornelissen Mar 11 '19 at 06:08
  • 1
    @robby `console.log(21480.105)` gets logged correctly though. – Jonas Wilms Mar 11 '19 at 06:26
  • @kaiido my point is that the number itself is accurate, this is a flaw in the rounding algorithm toFixed is using (or am I wrong completely)? – Jonas Wilms Mar 11 '19 at 06:35
  • @JonasWilms wrong completely. You're letting yourself be deceived by JavaScript's policy on how to convert floating-point values to string. It will try to display something shorter than the actual number stored in the variable. The actual number is not exactly 21480.105, yet that is what is displayed. That should be your clue that your reasoning was missing something. – kumesana Mar 11 '19 at 06:37
  • Never ever use floating point for calculations where you need an exact result, e.g., when working with money. – Joe Mar 11 '19 at 07:17
  • `(21480.105).toPrecision(18)` tells `"21480.1049999999996"`, a little bit smaller than expected. And `toFixed(2)` of this number is `21480.10`. – Wiimm Mar 11 '19 at 11:16
  • @kumesana but usually `.toString` does show the additional digits (`0.1 + 0.2 + ""`) funny that it doesn't in this case. – Jonas Wilms Mar 11 '19 at 11:24
  • @JonasWilms The policy is complex. Just like the real value taken out of a written litteral, will be the closest value to the written literal that can be represented in floating-point ; when converting to string automatically the string will be the shortest written number, of which the real value is the closest. In the examples you give, if the string conversion did not write these digits, then a value that can be represented in floating-point, will exist and be closer to the written value, than the actual value of the variable. – kumesana Mar 11 '19 at 15:25

5 Answers5

3

So as some of the others already explained the reason for the 'erratic' rounding is a floating point precision problem. You can investigate this by using the toExponential() method of a JavaScript number.

(21480.905).toExponential(20)
#>"2.14809049999999988358e+4"
(21480.805).toExponential(20)
#>"2.14808050000000002910e+4"

As you can see here 21480.905, gets a double representation that is slightly smaller than 21480.905, while 21480.805 gets a double representation slightly larger than the original value. Since the toFixed() method works with the double representation and has no idea of your original intended value, it does all it can and should do with the information it has.

One way to work around this, is to shift the decimal point to the number of decimals you require by multiplication, then use the standard Math.round(), then shift the decimal point back again, either by division or multiplication by the inverse. Then finally we call toFixed() method to make sure the output value gets correctly zero-padded.

var x1 = 21480.905;
var x2 = -21480.705;

function round_up(x,nd)
{
  var rup=Math.pow(10,nd);
  var rdwn=Math.pow(10,-nd); // Or you can just use 1/rup
  return (Math.round(x*rup)*rdwn).toFixed(nd)
}
function round_down(x,nd)
{
  var rup=Math.pow(10,nd);
  var rdwn=Math.pow(10,-nd); 
  return (Math.round(x*-rup)*-rdwn).toFixed(nd)
}

function round_tozero(x,nd)
{
   return x>0?round_down(x,nd):round_up(x,nd) 
}



console.log(x1,'up',round_up(x1,2));
console.log(x1,'down',round_down(x1,2));
console.log(x1,'to0',round_tozero(x1,2));

console.log(x2,'up',round_up(x2,2));
console.log(x2,'down',round_down(x2,2));
console.log(x2,'to0',round_tozero(x2,2));

Finally: Encountering a problem like this is usually a good time to sit down and have a long think about wether you are actually using the correct data type for your problem. Since floating point errors can accumulate with iterative calculation, and since people are sometimes strangely sensitive with regards to money magically disappearing/appearing in the CPU, maybe you would be better off keeping monetary counters in integer 'cents' (or some other well thought out structure) rather than floating point 'dollar'.

visibleman
  • 3,175
  • 1
  • 14
  • 27
  • I need to round `21480.705` to `21480.70`, rather than `21480.71` (because this is what the port calculator seems to be doing). Arghhhhh – Merc Mar 11 '19 at 23:19
  • Using negative rup, rdwn will round down for 0.5 case instead of up, but note that it will also round downward for negative numbers (not towards zero) . If you always want to round 0.5 cases towards zero you can add an if case for negative numbers – visibleman Mar 12 '19 at 00:15
  • 1
    Could you turn this into a function? If you do, I think this should be the accepted answer, as it's the most flexible one and least hacky one too – Merc Mar 12 '19 at 03:01
  • @Merc, I've edited the answer to it turn into functions. The function names are somewhat deceptive, but they describe how the 0.5 case is handled, not the general case rounding functionality. – visibleman Mar 12 '19 at 04:39
1

This works in most cases. (See note below.)

The rounding problem can be avoided by using numbers represented in exponential notation:

function round(value, decimals) {
  return Number(Math.round(value+'e'+decimals)+'e-'+decimals);
}

console.log(round(21480.105, 2).toFixed(2));

Found at http://www.jacklmoore.com/notes/rounding-in-javascript/

NOTE: As pointed out by Mark Dickinson, this is not a general solution because it returns NaN in certain cases, such as round(0.0000001, 2) and with large inputs. Edits to make this more robust are welcome.

Cat
  • 4,141
  • 2
  • 10
  • 18
  • I need to round `21480.705` to `21480.70`, rather than `21480.71` (because this is what the port calculator seems to be doing). Arghhhhh – Merc Mar 11 '19 at 23:20
  • What happens when you try this solution with `value = 0.000001`? – Mark Dickinson Mar 12 '19 at 18:20
  • @MarkDickinson I get `0.00` in the snippet. Did you get a different result? -- Or if the concern is about precision, you'd get more invoking `round` (and `toFixed`) with different arguments (`round(0.123456, 5).toFixed(5)` – Cat Mar 13 '19 at 17:42
  • @Merc To come up with a "correct" script that matches your port calculator's output, you'd need to know what rules it's following. It's easy to add an exception along the lines of `if(val%1==0.705){ return roundDownForNoGoodReason(val); }`, (although this exact logic wouldn't work for the same reason as your original code, but you get the idea) -- but unless you can foresee all such exceptions, there are going to be mismatches between the two algorithms. – Cat Mar 13 '19 at 17:53
  • @Cat: sorry, I missed out a zero. Try `0.0000001 + 'e' + 2` and you'll get `"1e-7e2"`, which isn't going to round well: I get `NaN` as a result of `Math.round(0.0000001 + 'e' + 2)`. Same issue with large inputs. So this isn't a general solution: it's got some limitations, which are probably worth documenting. – Mark Dickinson Mar 14 '19 at 13:03
1

The why -

You may have heard that in some languages, such as JavaScript, numbers with a fractional part are calling floating-point numbers, and floating-point numbers are about dealing with approximations of numeric operations. Not exact calculations, approximations. Because how exactly would you expect to compute and store 1/3 or square root of 2, with exact calculations?

If you had not, then now you've heard of it.

That means that when you type in the number literal 21480.105, the actual value that ends up stored in computer memory is not actually 21480.105, but an approximation of it. The value closest to 21480.105 that can be represented as a floating-point number.

And since this value is not exactly 21480.105, that means it is either slightly more than that, or slightly less than that. More will be rounded up, and less will be rounded down, as expected.

The solution -

Your problem comes from approximations, that it seems you cannot afford. The solution is to work with exact numbers, not approximate.

Use whole numbers. Those are exact. Add in a fractional dot when you convert your numbers to string.

kumesana
  • 2,495
  • 1
  • 9
  • 10
0

You could round to an Integer, then shift in a comma while displaying:

function round(n, digits = 2) {
  // rounding to an integer is accurate in more cases, shift left by "digits" to get the number of digits behind the comma
  const str = "" + Math.round(n * 10 ** digits);

  return str
    .padStart(digits + 1, "0") // ensure there are enough digits, 0 -> 000 -> 0.00
    .slice(0, -digits) + "." + str.slice(-digits); // add a comma at "digits" counted from the end
}
Jonas Wilms
  • 132,000
  • 20
  • 149
  • 151
0

What the hell is going on with this erratic rouding?

Please reference the cautionary Mozilla Doc, which identifies the cause for these discrepancies. "Floating point numbers cannot represent all decimals precisely in binary which can lead to unexpected results..."

Also, please reference Is floating point math broken? (Thank you Robby Cornelissen for the reference)

What's the quickest easiest way to get a "rounded up" result (when hitting 0.5)?

Use a JS library like accounting.js to round, format, and present currency.

For example...

function roundToNearestCent(rawValue) {
  return accounting.toFixed(rawValue, 2);
}

const roundedValue = roundToNearestCent(21480.105);
console.log(roundedValue);
<script src="https://combinatronics.com/openexchangerates/accounting.js/master/accounting.js"></script>

Also, consider checking out BigDecimal in JavaScript.

Hope that helps!

HumbleOne
  • 170
  • 10
  • `(Math.round(21480.105*100)/100).toPrecision(18)` produces `"21480.1100000000006"` because of rounding. This does not mean, that this works for all calculations of this type. – Wiimm Mar 11 '19 at 11:19
  • toPrecision() is causing the loss of accuracy, not the method proposed in this answer. Please see the first 2 links provided in the answer to better understand why floating point may not properly store all decimal values. – HumbleOne Mar 11 '19 at 16:02
  • And `Math.round(1.005*100)/100` produces `1`. And if displaying 2 fraction digits using `(Math.round(1.005*100)/100).toFixed(2)` it shows `1.00` again, because 1.005*100 is 100.4999 and rounded down. So it doesn't help! – Wiimm Mar 11 '19 at 20:29
  • Point well taken. I've amended my answer to account for this. – HumbleOne Mar 11 '19 at 22:38
  • Then think about this: floats are stored based on power of 2. So decimal fractions that are not n/p with p multiple of 2 are always not exact, but rounded. Each mathematical operation (add, mult, div,..) makes the result more unexact. – Wiimm Mar 11 '19 at 22:42