Matemáticas rápidas de punto fijo para aplicaciones financieras en Java

No es ningún secreto que la información financiera (cuentas, contabilizaciones y otra contabilidad) no es muy amigable con los números de coma flotante, y muchos artículos recomiendan usar una aritmética de punto fijo. En Java, este formato está, de hecho, representado solo por la clase BigDecimal, que no siempre se puede usar por razones de rendimiento. Tenemos que buscar alternativas. Este artículo describe una biblioteca Java autoescrita para realizar operaciones aritméticas en números de precisión fija. La biblioteca fue creada para trabajar en aplicaciones financieras de alto rendimiento y le permite trabajar con una precisión de 9 decimales manteniendo un rendimiento aceptable. Se proporciona un enlace a las fuentes y los puntos de referencia al final del artículo.


Aritmética de punto flotante


Las computadoras modernas pueden realizar operaciones aritméticas solo con precisión limitada. Estos son dispositivos discretos que pueden no funcionar con todos los números posibles, pero solo con algunos subconjuntos contables de ellos. El formato más común para trabajar con números reales en la memoria de la computadora es un punto flotante (binario): un punto flotante (binario), cuando los números se almacenan en la forma M * 2 ^ E, donde M y E son mantisa entera y el orden del número. Pero algunos números, como 0.1, no se pueden representar con precisión en este formato. Por lo tanto, en el curso de cálculos complejos, inevitablemente se acumula algún error. Es decir, el resultado del cálculo de la máquina, digamos 0.1 + 0.1 + 0.1, no coincide con el matemáticamente correcto 0.3. Dado lo anterior, al programar aritmética compleja, puede seguir varias estrategias:


Estrategia 1 - ignorar. No preste atención al error, considere todas las operaciones como matemáticas ideales y espere que la precisión disponible sea suficiente para obtener resultados aceptables. La opción más común.


Estrategia 2: calcular meticulosamente. Las fórmulas para calcular errores de máquina se conocen desde hace décadas. Permiten estimar desde arriba el error relativo de cualquier operación aritmética. Probablemente, esto es lo que tienes que hacer para una simulación numérica seria. El problema es que lleva mucho tiempo. De hecho, cada carácter + - * / en el código debe ir acompañado de un cálculo de error. Debe tener en cuenta todas las dependencias entre los cálculos y repetir el procedimiento cada vez que cambie el código.


Estrategia 3: use un punto decimal (punto decimal flotante) en lugar de binario. Es decir, almacene los números en la forma M * 10 ^ E. Esto no resuelve los problemas de error (la mantisa todavía se redondea a un número finito de dígitos significativos), pero al menos todos los números "simples" para una persona (como 1.1) ahora están representados con precisión en la memoria. La recuperación de la inversión será el rendimiento. Cualquier normalización de números (es decir, una disminución equivalente en la mantisa y un aumento en el orden) requiere una división por una potencia de 10, que no es muy rápida, a diferencia de una división por una potencia de 2. Y tiene que normalizar mucho, con cada suma o resta con diferentes órdenes.


Estrategia 4: use un punto fijo (punto decimal fijo). Simplificación de la estrategia 3, cuando fijamos el orden E. En este caso, la normalización no es necesaria para la suma / resta. Además, todos los cálculos tendrán el mismo error absoluto. Este artículo está dedicado a esta estrategia.


Aritmética de punto fijo


A diferencia de la física, donde el error relativo es importante, solo se necesita absoluto en las finanzas. Si, después de una transacción financiera compleja, el cliente recibe una factura de $ 1,000,000.23 mientras espera $ 1,000,000,18, entonces pueden surgir algunas dificultades. Explicaciones como "¿por qué necesita precisión en 8 dígitos significativos?" No puede montar. Y el punto aquí no está en 5 centavos de pérdida (errar por el contrario, "a favor" del cliente no es mucho mejor), sino en inconsistencias en la contabilidad. Por lo tanto, las reglas de cálculo y redondeo están claramente especificadas entre las partes, y los artefactos del uso de variables dobles y flotantes a veces complican la vida.


Java tiene una clase estándar para aritmética de punto fijo: BigDecimal. Hay dos problemas con él: es lento (debido a su universalidad) y no es estable. La no estabilidad significa que cualquier operación asigna un objeto en el montón. Seleccionar y liberar en términos de un objeto lleva un poco de tiempo, pero los cálculos intensivos en el código "activo" crean una carga decente en el GC, que en algunos casos es inaceptable. Puede confiar en el análisis de escape y la escalarización, pero son muy inestables en el sentido de que incluso un ligero cambio en el código o en el JIT (como la carga diferida de una nueva implementación de interfaz) puede voltear toda la estructura en línea, y el método funcionó bien hace un minuto, De repente comienza a asignar furiosamente la memoria.
UPD debido a preguntas en los comentarios: La razón principal para abandonar BigDecimal y BigInteger no es el bajo rendimiento de los cálculos, sino la falta de estabilidad y la selección de objetos.


La biblioteca descrita es el resultado de estar cansado de reescribir la aritmética sin memoria de punto fijo desde cero para cada nuevo empleador, y decidí escribir mi propia biblioteca para su posterior contratación.


Enseguida mostraré un ejemplo de uso antes de pasar a los detalles de implementación:


public class Sample { private final Decimal margin; private final Quantity cumQuantity = new Quantity(); private final Quantity contraQuantity = new Quantity(); private final Quantity cumContraQuantity = new Quantity(); private final Price priceWithMargin = new Price(); private final Price avgPrice = new Price(); public Sample(int marginBp) { // 1 + margin / 10000 this.margin = Decimal.create(marginBp).divRD(10000L).add(1); } public Price calculateAvgPrice(Quantity[] quantities, Price[] prices) { cumQuantity.set(0); contraQuantity.set(0); // avg = sum(q * p * margin) / sum(q) for (int i = 0; i < quantities.length; i++) { cumQuantity.add(quantities[i]); priceWithMargin.set(prices[i]).mulRD(margin); contraQuantity.set(quantities[i]).mulRD(priceWithMargin); cumContraQuantity.add(contraQuantity); } return avgPrice.quotientRD(cumContraQuantity, cumQuantity); } public static void main(String[] args) throws ParseException { Price p1 = Price.create("1.5"); Price p2 = Price.create(1.6); Quantity q1 = Quantity.create("100"); Quantity q2 = Quantity.create(200); // apply 0.05% margin to the prices Sample sample = new Sample(5); System.out.println(sample.calculateAvgPrice(new Quantity[]{q1, q2}, new Price[]{p1, p2})); } } 

Idea de implementación


Por lo tanto, necesitamos una envoltura mutable de un primitivo entero, más precisamente, un long'a, que nos dará casi 19 dígitos significativos (suficiente para el entero y la parte fraccional). En el largo, nos referimos a N decimales. Por ejemplo, con N = 2, el número 2.56 se almacena como 256 (binario 100000000). Los números negativos se almacenan como estándar, en código adicional:


-2,56
-256


(En adelante, las cursivas indican números y cálculos "matemáticos" y en negrita su representación interna)


También me pareció útil ingresar NaN como un valor separado, que se devuelve en caso de errores aritméticos (en lugar de una excepción o basura). NaN se representa internamente como Long.MIN_VALUE , "propagado" a través de todas las operaciones y permite determinar la inversión de signos para todos los números restantes.


Intentemos estimar los algoritmos de las operaciones aritméticas para el caso cuando N = 2.


La suma y la resta no requieren gestos adicionales, solo use los valores como son:


1.20 + 2.30 = 3.50
120 + 230 = 350


La multiplicación y la división requieren una normalización adicional, es decir, multiplicación / división por 10 ^ N (por 100 en nuestro ejemplo)


1.20 * 2.00 = 2.40
120 * 200/100 = 240


1.20 / 2.00 = 0.60
100 * 120/200 = 60


La división adicional no es la operación más rápida. Pero en este caso, esta es una división por una constante, porque previamente arreglamos N = 2 y 10 ^ N = 100. La división por constante, especialmente por "hermosa" (tipo 10), se optimiza intensamente en la CPU y mucho más rápido que la división por un número aleatorio. Hacemos muchas divisiones por 10 cada vez que convertimos cualquier número en una cadena (por ejemplo, en los registros), y los fabricantes de CPU lo saben ( para obtener más detalles sobre la optimización, consulte "División por una constante").


Para consolidar la comprensión de lo que estamos haciendo, daré una operación más: inversión unaria de un número, es decir, 1 / x. Este es un caso especial de división, solo necesita enviar 1.00 en nuestro formato y no olvide normalizar:


1.00 / 2.00 = 0.50
100 * 100/200 = 50


Bueno, si bien todo es bastante simple, tratemos de profundizar en los detalles.


Redondeo


Intentemos dibujar otro número:


1.00 / 3.00 = 0.33
100 * 100/300 = 33


Un resultado matemático honesto se encuentra entre 0,33 y 0,34, pero no podemos imaginarlo exactamente. ¿Qué forma de redondear? Generalmente redondeado a 0, y esta es la forma más rápida (compatible con hardware). Pero, volviendo a los problemas financieros reales, este no es siempre el caso. Normalmente, cuando se procesan transacciones con un cliente, el redondeo es "a favor del cliente". Es decir, el precio se redondea hacia arriba si el cliente está vendiendo y hacia abajo si el cliente está comprando. Pero se pueden requerir otras opciones, por ejemplo, redondeo aritmético al número más cercano con subtipos (mitad arriba, mitad abajo, mitad par) para minimizar las inconsistencias contables. O redondeando a ± infinito para precios negativos (para algunos instrumentos financieros). Java BigDecimal ya contiene una lista de modos de redondeo estándar, y la biblioteca descrita los admite a todos. UNNECESSARY devuelve NaN si la operación inesperadamente requiere redondeo.


En el modo redondeado, nuestro cálculo debe dar:


1.00 / 3.00 = 0.34
100 * 100/300 + 1 = 34


¿Cómo saber qué necesitas para agregar una unidad? Necesita el resto de la división 10,000% 300 = 100. Que es tan lento como la división misma. Afortunadamente, si escribe en una fila en el código "a / b; a% b", entonces JIT se dará cuenta de que no se necesitan 2 divisiones, solo un comando div de ensamblador que devuelve 2 números (cociente y resto).


Otras opciones de redondeo son un poco más complicadas, pero también se pueden calcular en función del resto y el divisor.


En la API, hice una mención intencional de redondeo donde sea que ocurra, ya sea como un parámetro o como un sufijo propio de R ound D en los métodos donde el valor predeterminado es cero.


Desbordamiento


Llegamos a la parte más difícil. Recordemos nuevamente nuestra multiplicación:


1.20 * 2.00 = 2.40
120 * 200/100 = 240


Ahora imagine que estamos en la década de 1980 y tenemos procesadores de 16 bits. Es decir, solo el corto está disponible para nosotros con un valor máximo de 65535. La primera multiplicación se desbordará y será igual a 240000 & 0xFFFF = 44392 (si no está firmado, con un signo también será negativo), lo que romperá el resultado para nosotros.


No funcionará Tenemos 2 argumentos normales (ajustados a nuestro rango de valores) y el mismo resultado normal esperado, pero nos desbordamos a la mitad. La misma situación es posible con una longitud de 64 bits, solo los números necesitan más.


En la década de 1980, necesitaríamos una multiplicación que dé un resultado de 32 bits. Hoy necesitamos una multiplicación con un resultado de 128 bits. Lo más molesto es que ambas multiplicaciones están disponibles en ensambladores 8086 y x86-64, respectivamente, ¡pero no podemos usarlos desde Java! JNI, incluso en el caso de un hack con JavaCritical rápido, proporciona una sobrecarga de decenas de nanosegundos, introduce dificultades con la implementación y la compatibilidad, congela el GC durante la duración de la llamada. Además, de alguna manera tendríamos que devolver un resultado de 128 bits del método nativo, y escribir por referencia a una matriz (en la memoria) es un retraso adicional.


En general, tuve que escribir multiplicación y división manual. Columna Necesitaba 2 operaciones auxiliares:


  1. A (64) * B (64) = T (128); T (128) / N (32) = Q (64), R (32) - como parte del punto fijo de multiplicación A * B
  2. N (32) * A (64) = T (96); T (96) / B (64) = Q (64), R (64) - como parte de la división de punto fijo A / B
    (entre paréntesis indica la dimensión de los datos en bits, T es una variable temporal que no debe desbordarse)

Ambas operaciones devuelven el cociente y el resto (uno como resultado del método, el segundo en el campo del objeto). También pueden desbordarse, pero solo en el último paso, cuando esto es inevitable. Aquí hay un ejemplo (de la década de 1980):


500.00 / 0.50 = 1000.00
100 * 50,000 / 50 = 100,000 - ¡desbordamiento!


La división de columnas a la Knut no es el algoritmo más fácil. Además, también debería ser relativamente rápido. Por lo tanto, el código de ambas operaciones es cientos de líneas de magia de bits bastante severa, me llevará mucho tiempo recordar nuevamente qué está sucediendo exactamente allí. Los metí en una clase separada y comenté en detalle como pude.


El algoritmo de multiplicación no se limita a invocar la operación 1, sino que el código restante no es tan complicado y solo agrega soporte para números negativos, redondeo y NaN.


Por lo general (excepto en casos especiales), ambas operaciones contienen 4 multiplicaciones y 2 divisiones. La operación 1 es significativamente más rápida que la 2, ya que en ella estas divisiones son constantes.


Por cierto, si alguien se dio cuenta, N (32) es nuestro 10 ^ N para la normalización. Es de 32 bits, de lo que se deduce que N puede ser un máximo de 9. En las aplicaciones reales que vi, se utilizaron 2, 4 u 8 decimales. No he visto más de 9, así que debería ser suficiente. Si crea 10 ^ N de 64 bits, el código se vuelve más complicado (y se ralentiza) aún más.


Varios precisión diferente


A veces es necesario realizar una operación en argumentos con un número diferente de lugares decimales. Como mínimo, ingrese operaciones que involucren el largo habitual.


Por ejemplo:


2.0000 (N = 4) + 3.00 (N = 2) = 5.0000 (N = 4)
20,000 + 300 * 100 = 50,000


3.00 (N = 2) + 2.0000 (N = 4) = 5.00 (N = 2)
300 + 20,000 / 100 = 500


En este caso, se requiere una normalización adicional de uno de los argumentos. Tenga en cuenta que matemáticamente ambas operaciones son equivalentes, pero debido a la precisión diferente del resultado, se calculan de manera diferente. También vale la pena señalar que la segunda operación generalmente requiere redondeo.


El número de lugares decimales NO se almacena en el objeto. En cambio, se supone una subclase separada para cada precisión. Los nombres de clase pueden estar orientados a los negocios, por ejemplo Precio (N = 8), Cantidad (N = 2). Y se pueden generalizar: Decimal1, Decimal2, Decimal3, ... Cuanto mayor es la precisión, menor es el rango de valores almacenados, el rango mínimo tiene Decimal9: ± 9223372036. Se supone que una o dos clases serán suficientes para cubrir la funcionalidad necesaria, en cuyo caso el método getScale abstracto probablemente se desvirtualizará y estará en línea. Las subclases (en lugar de un campo adicional) le permiten tipificar estrictamente la precisión de los argumentos y el resultado, así como indicar una posible redondeo en la etapa de compilación.


La biblioteca permite operaciones con un máximo de 2 (pero no 3) de precisión diferente. Es decir, la precisión de los dos argumentos debe coincidir o la precisión de uno de los argumentos y el resultado. Una vez más, admitir 3 precisión diferente ralentizaría enormemente el código y complicaría la API. Como argumentos, puede pasar un largo regular, para el cual se supone una precisión de N = 0.


2.0000 / 3.0 = 0.6667 - ok (2 precisión diferente)
2/3 = 0.6667 - ok (argumentos largos, resultado decimal)
2 / 3.0 = 0.6667 - ¡imposible! (3 precisión diferente)


Ventajas y desventajas.


Obviamente, la informática de alto bit realizada por la biblioteca es más lenta que la soportada por hardware. Sin embargo, la sobrecarga no es tan grande (ver puntos de referencia a continuación).


Además, debido a la falta de sobrecarga de operadores en Java, el uso de métodos en lugar de operadores aritméticos complica la percepción del código.


En base a esto, la biblioteca generalmente se usa en lugares donde la pérdida de precisión absoluta es crítica. Por ejemplo, calcular estadísticas financieras precisas, teniendo en cuenta los indicadores financieros actuales (posiciones de negociación, PnL, órdenes ejecutadas). En el intercambio de red de información financiera entre sistemas, también es más conveniente utilizar formatos con un punto decimal (en lugar de binario).


Los algoritmos matemáticos complejos (modelado, estadística, pronóstico) son generalmente más fáciles de llevar a cabo de manera estándar en doble, ya que su resultado en cualquier caso no es absolutamente exacto.


Código y puntos de referencia


Código


Punto de referenciaEl modoCntPuntuaciónErrorUnidades
DecimalBenchmark.controlpromedio20010.072± 0.074ns / op
DecimalBenchmark.multiplyNativepromedio20010,625± 0.142ns / op
DecimalBenchmark.multiplyMyDecimalpromedio20035,840± 0,121ns / op
DecimalBenchmark.multiplyBigDecimalpromedio200126,098± 0,408ns / op
DecimalBenchmark.quotientNativepromedio20070,728± 0.230ns / op
DecimalBenchmark.quotientMyDecimalpromedio200138.581± 7.102ns / op
DecimalBenchmark.quotientBigDecimalpromedio200179,650± 0.849ns / op

En general, la multiplicación es 4 veces más rápida que BigDecimal, la división es 1.5. La tasa de división depende en gran medida de los argumentos, de ahí la dispersión de los valores.

Source: https://habr.com/ru/post/es425565/


All Articles