February 28

🧮 Финансовые расчёты в Python и Go: как не потерять деньги из-за одного нуля или кавычек

Недавно я ловил баг в функции расчёта PNL. Из-за неправильного использования типов ошибка в расчетах накапливалась сначала в виде погрешности, а потом переросло в серьезную ошибку для пользователя. Для решения нужно было просто использовать правильный тип данных для финансовых расчетов. За этим багом скрывается целый мир подводных камней, о которых я давно не вспоминал и хочу рассказать.

🐍 Python: лучший вариант работать через встроенный `decimal`

Если вам нужны расчеты до копеек (2-х знаков после запятой), то самый простой вариант использовать int. Это наиболее эффективно с точки зрения производительности, точности и хранения в БД.

Если нам нужна большая точность, то определенно наш выбор - decimal.Decimal.

На тему "почему float это плохо для точных расчетов?" написано очень много материалов. Достаточно вспомнить, что в Python:

0.1 + 0.1 + 0.1 == 0.3  # False

и больше про float не вспоминать.

Инициализация Decimal

Главное правило - создавать Decimal через строку (Decimal('0.1')) или int (Decimal(1)), а не через число с плавающей точкой (Decimal(0.1)). Этот способ убивает всю магию точности еще до создания объекта.

from decimal import Decimal
price = Decimal('19.99')   # ✅ правильно
tax_rate = Decimal('0.08') # ✅ правильно
total = price * (1 + tax_rate)
print(total)  # 21.5892

price = Decimal(19.99)   # ❌ неправильно
tax_rate = Decimal(0.08) # ❌ неравильно
total = price * (1 + tax_rate)
print(total)  # 21.58919999999999834504049723

Управление округлением: метод quantize()

Главный инструмент для приведения чисел к нужной точности (например, нужно округлить до 2-х знаков после запятой). не забывайте указывать стратегию округления при необходимости.

from decimal import Decimal, ROUND_HALF_UP
value = Decimal('10.125')
value.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)  # 10.13

Контекст выполнения

Если нужно управления точностью расчетов во всем приложении, используйте getcontext().prec глобально при старте приложения.

from decimal import Decimal, getcontext
getcontext().prec = 20
print(getcontext().prec) # 20

Если тебе нужно временно повысить точность для сложного расчета, используйте менеджер контекста with localcontext(). Это безопасно изолирует изменения от остального приложения.

from decimal import Decimal, localcontext

with localcontext() as ctx:
	ctx.prec = 27
	a = Decimal('123.45')
	b = Decimal('6.789')
	result = a * b

Сравнение значений

Decimal умный и сравнение с другими типами поддерживает, но такие операции могут дать непредсказуемый результат, поэтому всегда используем с Decimal другие Decimal.

Decimal("10.0") == 10.0  # True
Decimal("10.01") == 10.01  # False
Decimal("10.01") == Decimal(10.01) # False, потому что см. про инициализацию

Для предсказуемости результатов в цепочках вычислений также важно использовать константы с тем же типом Decimal.

ZERO = Decimal('0')
ONE = Decimal('1')

🐹 Go: точность под высокими нагрузками

В Go нет встроенного decimal. Стандартный float64 такой же неточный, как в Python. Аналогично если есть возможность использовать integer scaling, то это лучший вариант. В противном случае выбираем стороннюю библиотеку. И тут выбор встает между скоростью и удобством.

shopspring/decimal

Самая популярная библиотека - shopspring/decimal. Она простая и удобная, но под капотом использует math/big, что приводит к аллокациям в куче и доп расходам на GC, что может быть критично для high-load.

govalues/decimal

Недавно наткнулся на govalues/decimal. Она создана специально для транзакционных финансовых систем. Фишки:

  • Иммутабельность - безопасна для конкурентного доступа
  • Нулевые аллокации - все операции без выделения памяти в куче
  • Без паник - все ошибки возвращаются явно.
  • Банковское округление
  • Поддержка до 19 десятичных знаков - хватит для большинства валют (и крипты)

Пример:

package main

import (
    "fmt"
    "github.com/govalues/decimal"
)

func main() {
    // Инициализация из строк — тоже безопасно
    price, _ := decimal.Parse("19.99")
    taxRate, _ := decimal.Parse("0.08")
    
    // Вычисления
    tr, _ := taxRate.Add(decimal.One)
    total, _ := price.Mul(tr)
    fmt.Println(total.Round(2)) // "21.59"
}

По бенчмаркам govalues быстрее shopspring и не создает мусора. Если в вашем сервисе много операций по расчетам - присмотритесь.

💡 Выводы

  • Используйте специализированные типы в любом языке
  • В Python - Decimal со строковой инициализацией, явным округлением и типобезопасными константами
  • В Go - выбирайте решение под нагрузку:
    • int64 если позволяет задача для максимальной скорости
    • shopspring/decimal если нужное простое и массовое решение
    • govalues/decimal для производительности