java floating-point currency

java - Un ejemplo realista donde usar BigDecimal para la moneda es estrictamente mejor que usar el doble



floating-point currency (9)

Cuando redondeas el double price = 0.615 a dos lugares decimales, obtienes 0.61 (redondeado hacia abajo) pero probablemente esperes 0.62 (redondeado hacia arriba, debido a los 5).

Esto se debe a que el doble 0.615 es en realidad 0.6149999999999999911182158029987476766109466552734375.

know que usar el double para la moneda es propenso a errores y no se recomienda. Sin embargo, todavía tengo que ver un ejemplo realista , donde BigDecimal funciona mientras que falla double BigDecimal y no se puede arreglar simplemente con un redondeo.

Tenga en cuenta que los problemas triviales

double total = 0.0; for (int i = 0; i < 10; i++) total += 0.1; for (int i = 0; i < 10; i++) total -= 0.1; assertTrue(total == 0.0);

no cuente ya que se resuelven trivialmente redondeando (en este ejemplo haría cualquier cosa de cero a dieciséis lugares decimales).

Los cálculos que suman grandes valores pueden necesitar un enrutamiento intermedio, pero dado que la moneda total en circulación es USD 1e12 , el double Java (es decir, la precisión doble estándar IEEE ) con sus 15 dígitos decimales sigue siendo un suceso suficiente para los centavos.

Los cálculos que involucran división son en general imprecisos incluso con BigDecimal . Puedo construir un cálculo que no se puede realizar con double s, pero se puede realizar con BigDecimal usando una escala de 100, pero no es algo que puedas encontrar en la realidad.

No pretendo que un ejemplo tan realista no exista, es solo que aún no lo he visto.

También estoy de acuerdo, que usar el double es más propenso a errores.

Ejemplo

Lo que estoy buscando es un método como el siguiente (basado en la respuesta de Roland Illig)

/** * Given an input which has three decimal places, * round it to two decimal places using HALF_EVEN. */ BigDecimal roundToTwoPlaces(BigDecimal n) { // To make sure, that the input has three decimal places. checkArgument(n.scale() == 3); return n.round(new MathContext(2, RoundingMode.HALF_EVEN)); }

junto con una prueba como

public void testRoundToTwoPlaces() { final BigDecimal n = new BigDecimal("0.615"); final BigDecimal expected = new BigDecimal("0.62"); final BigDecimal actual = roundToTwoPlaces(n); Assert.assertEquals(expected, actual); }

Cuando esto se reescribe ingenuamente con double , la prueba puede fallar (no para la entrada dada, pero sí para otros). Sin embargo, se puede hacer correctamente:

static double roundToTwoPlaces(double n) { final long m = Math.round(1000.0 * n); final double x = 0.1 * m; final long r = (long) Math.rint(x); return r / 100.0; }

Es feo y propenso a errores (y probablemente pueda simplificarse), pero se puede encapsular fácilmente en cualquier lugar. Es por eso que estoy buscando más respuestas.


El uso de BigDecimal sería más necesario cuando se trata de formas digitales de alto valor de moneda, tales como cyprtocurrency (BTC, LTC, etc.), acciones, etc. En situaciones como estas muchas veces se tratará con valores muy específicos a 7 o 8 figuras significativas. Si su código provoca accidentalmente un error de redondeo en 3 o 4 sig higos, las pérdidas podrían ser extremadamente significativas. Perder dinero debido a un error de redondeo no va a ser divertido, especialmente si es para clientes.

Claro, probablemente podría salirse con la suya usando un doble para todo si se aseguraba de hacer todo bien, pero probablemente sería mejor no correr el riesgo, especialmente si está empezando desde cero.


Lo siguiente parece ser una implementación decente de un método que necesitaba "redondear al centavo más cercano".

private static double roundDowntoPenny(double d ) { double e = d * 100; return ((int)e) / 100.0; }

Sin embargo, el resultado de lo siguiente muestra que el comportamiento no es exactamente el esperado.

public static void main(String[] args) { System.out.println(roundDowntoPenny(10.30001)); System.out.println(roundDowntoPenny(10.3000)); System.out.println(roundDowntoPenny(10.20001)); System.out.println(roundDowntoPenny(10.2000)); }

Salida:

10.3 10.3 10.2 10.19 // Not expected!

Por supuesto, se puede escribir un método que produzca el resultado que queremos. El problema es que realmente es muy difícil hacerlo (y hacerlo en todos los lugares donde se necesita manipular los precios).

Para cada sistema numérico (base-10, base-2, base-16, etc.) con un número finito de dígitos, existen racionales que no se pueden almacenar exactamente. Por ejemplo, 1/3 no se puede almacenar (con dígitos finitos) en base-10. Del mismo modo, 3/10 no se puede almacenar (con dígitos finitos) en base-2.

Si necesitáramos elegir un sistema numérico para almacenar racionales arbitrarios, no importaría qué sistema elegimos: cualquier sistema elegido tendría algunos racionales que no podrían almacenarse exactamente.

Sin embargo, los humanos comenzaron a asignar precios a las cosas mucho antes del desarrollo de los sistemas informáticos. Por lo tanto, vemos precios como 5,30 en lugar de 5 + 1/3. Por ejemplo, nuestras bolsas de valores usan precios decimales, lo que significa que aceptan pedidos y emiten cotizaciones, solo en precios que pueden representarse en base-10. Del mismo modo, significa que pueden emitir presupuestos y aceptar pedidos en precios que no se pueden representar con precisión en la base-2.

Al almacenar (transmitir, manipular) esos precios en base-2, básicamente nos basamos en la lógica de redondeo para redondear siempre correctamente nuestros números (en la exacta) base-2 (representación de) a su representación (exacta) de base-10.


Los principales problemas que enfrentas en la práctica están relacionados con el hecho de que la round(a) + round(b) no es necesariamente igual a la round(a+b) . Al utilizar BigDecimal , tiene un control fino sobre el proceso de redondeo y, por lo tanto, puede hacer que sus sumas salgan correctamente.

Cuando calcula impuestos, digamos 18% de IVA, es fácil obtener valores que tienen más de dos decimales cuando se representan exactamente. Entonces el redondeo se convierte en un problema.

Supongamos que compra 2 artículos por $ 1.3 cada uno

Article Price Price+VAT (exact) Price+VAT (rounded) A 1.3 1.534 1.53 B 1.3 1.534 1.53 sum 2.6 3.068 3.06 exact rounded 3.07

Entonces, si hace los cálculos con doble y solo una vuelta para imprimir el resultado, obtendría un total de 3.07, mientras que el monto en la factura debería ser de 3.06.


No necesitas un ejemplo. Solo necesitas matemáticas de cuarta forma. Las fracciones en coma flotante se representan en base binaria, y la base binaria es inconmensurable con base decimal. Cosas del décimo grado

Por lo tanto, siempre habrá redondeo y aproximación, y ninguno de los dos es aceptable en contabilidad de ninguna forma o forma. Los libros deben equilibrarse hasta el último centavo, por lo que FYI hace una sucursal bancaria al final de cada día, y todo el banco a intervalos regulares.

una expresión que sufre errores de redondeo no cuenta ''

Ridículo. Este es el problema. Excluir los errores de redondeo excluye todo el problema.


Supongamos que tiene 1000000000001.5 (está en el rango 1e12) de dinero. Y tienes que calcular el 117% de eso.

En el doble, se convierte en 1170000000001.7549 (este número ya es impreciso ). Luego aplique su algoritmo de ronda, y se convierte en 1170000000001.75.

En aritmética precisa, se convierte en 1170000000001.7550, que se redondea a 1170000000001.76. Ouch, perdiste 1 centavo.

Creo que es un ejemplo realista, donde el doble es inferior a la aritmética precisa.

Claro, puedes arreglar esto de alguna manera (incluso, puedes implementar BigDecimal usando arihmetic doble, por lo que de alguna manera, el doble se puede usar para todo, y será preciso), pero ¿cuál es el punto?

Puede usar el doble para la aritmética de enteros, si los números son menores que 2 ^ 53. Si puede expresar sus cálculos dentro de estas limitaciones, el cálculo será preciso (la división necesita cuidados especiales, por supuesto). Tan pronto como salga de este territorio, sus cálculos pueden ser imprecisos.

Como puede ver, 53 bits no es suficiente, el doble no es suficiente . Pero, si almacena dinero en un número de punto decimal fijo (es decir, almacena el número de money*100 , si necesita una precisión de centavos), entonces 64 bits podría ser suficiente, por lo que se puede usar un entero de 64 bits en lugar de BigDecimal .


Vamos a dar una respuesta "menos técnica, más filosófica" aquí: ¿por qué crees que "Cobol" no está usando la aritmética de punto flotante cuando se trata de la moneda?

("Cobol" entre comillas, como en: enfoques heredados existentes para resolver problemas empresariales del mundo real).

Significado: hace casi 50 años, cuando las personas comenzaron a usar computadoras para negocios, también se dieron cuenta rápidamente de que la representación de "punto flotante" no funcionaría para la industria financiera (tal vez esperen algunas esquinas de nicho raras, como se señala en la pregunta )

Y recuerde: en aquel entonces, ¡las abstracciones eran realmente caras! Era bastante caro tener un poco aquí y un registro allí; y aún se vuelve rápidamente obvio para los gigantes en cuyos hombros nos encontramos ... que usar "puntos flotantes" no resolvería sus problemas; y que tenían que depender de otra cosa; más abstracto - ¡más caro!

Nuestra industria tuvo más de 50 años para llegar a un "punto flotante que funciona por dinero", y la respuesta común sigue siendo: no lo hagas . En cambio, recurre a soluciones como BigDecimal.


Veo cuatro formas básicas que pueden doblerte cuando se trata de cálculos de divisas.

Mantissa demasiado pequeña

Con ~ 15 dígitos decimales de precisión en la mantisa, obtendrá el resultado incorrecto cada vez que maneje cantidades más grandes que eso. Si está rastreando centavos, los problemas comenzarían a ocurrir antes de 10 13 (diez billones) de dólares.

Si bien es una gran cantidad, no es tan grande . El PIB de EE. UU. De ~ 18 billones de dólares lo supera, por lo que todo lo que se relacione con el país o incluso con montos corporativos podría obtener fácilmente una respuesta incorrecta.

Además, hay muchas formas en que cantidades mucho más pequeñas podrían exceder este umbral durante el cálculo. Es posible que esté haciendo una proyección de crecimiento o durante varios años, lo que resulta en un gran valor final. Es posible que esté haciendo un análisis de escenario "qué pasa si", donde se examinan varios parámetros posibles y una combinación de parámetros puede dar como resultado valores muy grandes. Es posible que esté trabajando según las reglas financieras que permiten fracciones de un centavo que podrían cortar otros dos órdenes de magnitud o más fuera de su rango, lo que le sitúa aproximadamente en línea con la riqueza de simples individuos en USD.

Finalmente, no tomemos una visión centrada en los Estados Unidos de las cosas. ¿Qué hay de otras monedas? La rupia indonesia vale aproximadamente 13,000 USD, por lo que hay otros 2 órdenes de magnitud que necesitas para rastrear los montos en esa moneda (¡suponiendo que no haya "centavos"!). Estás casi llegando a cantidades que son de interés para simples mortales.

Aquí hay un ejemplo donde un cálculo de proyección de crecimiento que comienza desde 1e9 al 5% falla:

method year amount delta double 0 $ 1,000,000,000.00 Decimal 0 $ 1,000,000,000.00 (0.0000000000) double 10 $ 1,628,894,626.78 Decimal 10 $ 1,628,894,626.78 (0.0000004768) double 20 $ 2,653,297,705.14 Decimal 20 $ 2,653,297,705.14 (0.0000023842) double 30 $ 4,321,942,375.15 Decimal 30 $ 4,321,942,375.15 (0.0000057220) double 40 $ 7,039,988,712.12 Decimal 40 $ 7,039,988,712.12 (0.0000123978) double 50 $ 11,467,399,785.75 Decimal 50 $ 11,467,399,785.75 (0.0000247955) double 60 $ 18,679,185,894.12 Decimal 60 $ 18,679,185,894.12 (0.0000534058) double 70 $ 30,426,425,535.51 Decimal 70 $ 30,426,425,535.51 (0.0000915527) double 80 $ 49,561,441,066.84 Decimal 80 $ 49,561,441,066.84 (0.0001678467) double 90 $ 80,730,365,049.13 Decimal 90 $ 80,730,365,049.13 (0.0003051758) double 100 $ 131,501,257,846.30 Decimal 100 $ 131,501,257,846.30 (0.0005645752) double 110 $ 214,201,692,320.32 Decimal 110 $ 214,201,692,320.32 (0.0010375977) double 120 $ 348,911,985,667.20 Decimal 120 $ 348,911,985,667.20 (0.0017700195) double 130 $ 568,340,858,671.56 Decimal 130 $ 568,340,858,671.55 (0.0030517578) double 140 $ 925,767,370,868.17 Decimal 140 $ 925,767,370,868.17 (0.0053710938) double 150 $ 1,507,977,496,053.05 Decimal 150 $ 1,507,977,496,053.04 (0.0097656250) double 160 $ 2,456,336,440,622.11 Decimal 160 $ 2,456,336,440,622.10 (0.0166015625) double 170 $ 4,001,113,229,686.99 Decimal 170 $ 4,001,113,229,686.96 (0.0288085938) double 180 $ 6,517,391,840,965.27 Decimal 180 $ 6,517,391,840,965.22 (0.0498046875) double 190 $ 10,616,144,550,351.47 Decimal 190 $ 10,616,144,550,351.38 (0.0859375000)

El delta (diferencia entre los primeros éxitos double y BigDecimal > 1 centavo en el año 160, alrededor de 2 trillones (que podrían no ser tan grandes en 160 años a partir de ahora), y por supuesto, sigue empeorando.

Por supuesto, los 53 bits de Mantissa significan que es probable que el error relativo para este tipo de cálculo sea muy pequeño (ojalá no pierda su trabajo más de 1 centavo de 2 trillones). De hecho, el error relativo básicamente se mantiene bastante estable durante la mayor parte del ejemplo. Sin embargo, puedes organizarlo para que (por ejemplo) restar dos varios con pérdida de precisión en la mantisa, lo que resulta en un error arbitrariamente grande (ejercicio para el lector).

Cambiando la Semántica

Así que piensas que eres muy inteligente y lograste crear un esquema de redondeo que te permite usar el double y has probado exhaustivamente tus métodos en tu JVM local. Adelante y desplegarlo. Mañana o la próxima semana o cuando sea peor para ti, los resultados cambian y tus trucos se rompen.

A diferencia de casi todas las demás expresiones de lenguaje básicas y, ciertamente, a diferencia de las aritméticas de enteros o BigDecimal , de forma predeterminada los resultados de muchas expresiones de coma flotante no tienen un solo valor definido por estándares debido a la función strictfp . Las plataformas son libres de usar, a su criterio, intermedios de mayor precisión, lo que puede dar lugar a resultados diferentes en diferentes hardware, versiones de JVM, etc. El resultado, para las mismas entradas, puede variar incluso en tiempo de ejecución cuando el método pasa de interpretado a JIT -compilado!

Si hubiera escrito su código en los 1.2 días previos a Java, estaría bastante enojado cuando Java 1.2 presente repentinamente el comportamiento FP variable predeterminado ahora. Podrías strictfp tentado de usar strictfp todas partes y esperar que no te strictfp con ninguno de la multitud de errores relacionados , pero en algunas plataformas estarías desperdiciando mucho del rendimiento que te compró en primer lugar.

No hay nada que decir que la especificación de JVM no volverá a cambiar en el futuro para acomodar más cambios en el hardware de FP, o que los implementadores de JVM no usarán la cuerda que les da el comportamiento predeterminado no estricto para hacer algo complicado.

Representaciones inexactas

Como señaló Roland en su respuesta, un problema clave con el double es que no tiene representaciones exactas para la mayoría de los valores no enteros. Aunque un único valor no exacto como 0.1 a menudo "ida y vuelta" es correcto en algunos escenarios (por ejemplo, Double.toString(0.1).equals("0.1") ), tan pronto como haga cálculos matemáticos en estos valores imprecisos, el error puede estar compuesto y esto puede ser irrecuperable

En particular, si está "cerca" de un punto de redondeo, por ej., ~ 1.005, puede obtener un valor de 1.00499999 ... cuando el valor verdadero es 1.0050000001 ..., o viceversa . Debido a que los errores van en ambas direcciones, no hay magia de redondeo que pueda solucionar esto. No hay forma de saber si un valor de 1.004999999 ... debe aumentarse o no. Su método roundToTwoPlaces() (un tipo de doble redondeo) solo funciona porque manejó un caso en el que 1.0049999 debe aumentarse, pero nunca podrá cruzar el límite, por ejemplo, si los errores acumulativos hacen que 1.0050000000001 se convierta en 1.00499999999999 no puedo arreglarlo

No necesitas números grandes o pequeños para golpear esto. Solo necesita algunas matemáticas y para que el resultado se acerque al límite. Cuanta más matemática hagas, mayores serán las posibles desviaciones del resultado verdadero y más posibilidades de cruzar un límite.

Como se solicita aquí, una prueba de búsqueda que hace un cálculo simple: amount * tax y lo redondea a 2 decimales (es decir, dólares y centavos). Existen algunos métodos de redondeo, el que se usa actualmente, roundToTwoPlacesB es una versión roundToTwoPlacesB del tuyo 1 (al aumentar el multiplicador para n en el primer redondeo lo haces mucho más sensible: la versión original falla de inmediato) entradas triviales).

La prueba escupe las fallas que encuentra, y vienen en racimos. Por ejemplo, los primeros fracasos:

Failed for 1234.57 * 0.5000 = 617.28 vs 617.29 Raw result : 617.2850000000000000000000, Double.toString(): 617.29 Failed for 1234.61 * 0.5000 = 617.30 vs 617.31 Raw result : 617.3050000000000000000000, Double.toString(): 617.31 Failed for 1234.65 * 0.5000 = 617.32 vs 617.33 Raw result : 617.3250000000000000000000, Double.toString(): 617.33 Failed for 1234.69 * 0.5000 = 617.34 vs 617.35 Raw result : 617.3450000000000000000000, Double.toString(): 617.35

Tenga en cuenta que el "resultado bruto" (es decir, el resultado exacto no redondeado) siempre está cerca de un límite x.xx5000 . Su método de redondeo se equivoca tanto en el lado alto como en el bajo. No puedes arreglarlo genéricamente.

Cálculos imprecisos

Varios de los métodos java.lang.Math no requieren resultados redondeados correctamente, sino que permiten errores de hasta 2.5 ulp. Por supuesto, es probable que no uses las funciones hiperbólicas con moneda, pero funciones como exp() y pow() menudo encuentran su camino en los cálculos de monedas y estas solo tienen una precisión de 1 ulp. Entonces, el número ya está "mal" cuando se devuelve.

Esto interactúa con el problema de "representación inexacta", ya que este tipo de error es mucho más grave que el de las operaciones matemáticas normales que, al menos, eligen el mejor valor posible con el dominio representable de double . Significa que puede tener muchos más eventos de cruce circular cuando usa estos métodos.


Línea inferior al frente:

Ejemplo realista simple donde el double falla:

Todos los tipos numéricos más grandes se pueden simular perfectamente con tipos numéricos más pequeños usando listas de los tipos de números más pequeños y manteniendo un registro de cosas como el signo y el lugar decimal. Por lo tanto, un tipo numérico solo falla cuando se usa equivale a una mayor complejidad del código y / o una velocidad más lenta.

BigDecimal no disminuye la complejidad del código mucho cuando usted sabe cómo manejar multiplicaciones y divisiones double para evitar subdesbordamientos. Sin embargo, puede haber situaciones donde BigDecimal es potencialmente más rápido que el double .

Sin embargo, no debería haber ningún caso en el que sea strictly mejor (en el sentido matemático) que el doble. ¿Por qué? porque los cálculos double se implementan como operaciones unitarias en procesadores modernos (durante un ciclo) y, por lo tanto, cualquier cálculo eficiente de punto flotante de gran precisión, en su base, utiliza algún tipo de tipo numérico double-esque o es más lento que el óptimo.

En otras palabras, si un doble es un ladrillo, un BigDecimal es una pila de ladrillos.

Entonces, primero, definamos qué significa "malo" en el contexto de "el double es malo para el análisis financiero".

Un número double coma flotante es una lista de estados binarios. Por lo tanto, si solo tuviera acceso a clases y números enteros de 32 bits, podría "volver a crear" un double simplemente registrando la posición del decimal, el signo, etc. y manteniendo una lista de números enteros.

La desventaja de este proceso es que tendrías una base de código mucho más compleja y problemática para gestionar esto. Además, un double es igual al tamaño de palabra de un procesador de 64 bits, por lo que los cálculos serán más lentos con su clase que contiene una lista de enteros.

Ahora, las computadoras son muy rápidas. Y a menos que esté escribiendo código descuidado, no notará la diferencia entre el double y su clase con su lista de enteros para operaciones O (n) (una para bucle).

Por lo tanto, el problema principal aquí es la complejidad del código escrito (complejidad de uso, lectura, etc.).

Dado que la complejidad del código es el problema principal, considere una situación financiera en la que está multiplicando fracciones muchas veces.

Esto puede causar underflow , que es el error de redondeo de lo que está hablando.

La solución para underflow es tomar el registro:

// small numbers a and b double a = ... double b = ... double underflowed_number = a*pow(b,15); // this is potentially an inaccurate calculation. double accurate_number = pow(e,log(a) + 15*log(b)); // this is accurate

Ahora, la pregunta es: ¿es demasiada complejidad del código para que usted pueda manejarlo?

O mejor aún: ¿es demasiado compleja para sus compañeros? ¿Alguien vendrá y dirá: "wow, esto parece realmente ineficiente, lo cambiaré a a*pow(b,15) "?

Si es así, simplemente use BigDecimal ; de lo contrario: el double voluntad, con la excepción del cálculo de subdesbordamiento, será más ligero en términos de uso y sintaxis ... y la complejidad del código escrito realmente no es tan importante de todos modos.

Con una advertencia: si está haciendo cálculos significativos que implican la solución de subdesbordamiento en una configuración computacionalmente compleja, como un bucle anidado en alguna subrutina interna que se ejecuta en el back-end de un banco, entonces debe probar usando BigDecimal , ya que esto puede ser Más rápido.

Entonces la respuesta a tu pregunta:

// at some point, for some large_number this *might* be slower, // depending on hardware, and should be tested: for (i=1; i<large_number; i++){ for(j=1;j<large_number;j++){ for(k=1;k<large_number;k++){ // switched log to base 2 for speed double n = pow(2,log2(a) + 15*log2(b)); } } } // this *might* be faster: for (i=1; i<large_number; i++){ for(j=1;j<large_number;j++){ for(k=1;k<large_number;k++){ BigDecimal n = a * pow(b,15); } } }

Añadiré un gráfico asintótico si tengo tiempo.