Pular para o conteúdo
ZYNQORALABS

Aritmética decimal em sistemas financeiros: centavos que não somem

Ponto flutuante binário não representa 0,10 exatamente. Como representar dinheiro, escolher regras de arredondamento e dividir valores sem perder ou criar centavos.

Por ZynqoraLabs · · 3 min de leitura

CompartilharLinkedInXWhatsApp

Em quase toda linguagem, 0.1 + 0.2 não resulta em 0.3, e sim em 0.30000000000000004. Isso não é um defeito: números de ponto flutuante binário (o padrão IEEE 754) não conseguem representar exatamente a maioria das frações decimais. Em um gráfico, a diferença é irrelevante. Em um extrato, uma conciliação ou uma checagem de limite, ela vira centavos que aparecem ou somem sem explicação.

Regra zero: dinheiro não é float

Existem duas representações seguras para valores monetários:

  1. Inteiros na menor unidade da moeda. R$ 12,34 vira 1234 centavos. Somas e comparações são exatas, e qualquer linguagem as executa rápido.
  2. Tipos decimais de precisão definida. Decimal em Python, BigDecimal em Java, rust_decimal em Rust, numeric no PostgreSQL. Representam frações decimais exatamente e permitem controlar o arredondamento.

Inteiros são ótimos para valores finais em uma única moeda. Decimais são necessários quando há taxas, preços com mais casas que a moeda, conversões e cálculos de juros.

A menor unidade depende da moeda

Nem toda moeda tem duas casas decimais. A norma ISO 4217 define a quantidade de casas de cada uma: o real e o dólar têm 2, o iene japonês tem 0, e o dinar do Bahrein tem 3. Em cripto, tokens ERC-20 costumam usar 18 casas.

Por isso, um valor monetário nunca deve viajar sem a moeda. Um Money com valor e moeda evita somar reais com dólares e saber quantas casas usar ao arredondar.

Arredondamento é regra de negócio

Sempre que um cálculo produz mais casas do que a moeda permite, é preciso arredondar, e a regra escolhida muda o resultado:

ValorMeio para cimaMeio para o par (bancário)
2,3452,352,34
2,3552,362,36
2,3652,372,36

O arredondamento "meio para o par" evita o viés sistemático para cima quando muitos valores são arredondados, e é o padrão de várias bibliotecas. Mas a regra correta é a que o contrato, o regulador ou o parceiro exige. Ela deve ser explícita no código e coberta por testes, nunca herdada de um padrão implícito da linguagem.

money.py
from decimal import Decimal, ROUND_HALF_EVEN, ROUND_HALF_UP
 
CURRENCY_EXPONENT = {"BRL": 2, "USD": 2, "JPY": 0, "BHD": 3}
 
 
def quantize(amount: Decimal, currency: str, rounding=ROUND_HALF_EVEN) -> Decimal:
    exp = Decimal(1).scaleb(-CURRENCY_EXPONENT[currency])  # 0.01 for BRL
    return amount.quantize(exp, rounding=rounding)
 
 
fee = Decimal("1234.56") * Decimal("0.0175")   # Decimal("21.604800"), exact
quantize(fee, "BRL")                           # Decimal('21.60')
quantize(Decimal("2.345"), "BRL")              # Decimal('2.34')  (half-even)
quantize(Decimal("2.345"), "BRL", ROUND_HALF_UP)  # Decimal('2.35')

Note que os valores são criados a partir de texto (Decimal("1234.56")). Criar um Decimal a partir de um float herda o erro binário que se queria evitar.

Dividir sem perder centavos

Dividir R$ 100,00 em três parcelas iguais dá 33,333... Arredondar cada parcela para 33,33 soma R$ 99,99, e um centavo desaparece. Em escala, esses centavos viram divergências de conciliação.

A solução é alocar: calcular as parcelas na menor unidade e distribuir o resto, um centavo por vez, de forma determinística.

allocate.py
def allocate(total_minor: int, weights: list[int]) -> list[int]:
    """Split an integer amount by weights; the parts always sum to the total."""
    weight_sum = sum(weights)
    shares = [total_minor * w // weight_sum for w in weights]
    remainders = [total_minor * w % weight_sum for w in weights]
    leftover = total_minor - sum(shares)
    # Give the leftover cents to the largest remainders (ties: earliest first).
    for i in sorted(range(len(weights)), key=lambda i: -remainders[i])[:leftover]:
        shares[i] += 1
    return shares
 
 
allocate(10_000, [1, 1, 1])   # [3334, 3333, 3333] -> sums to 10000
allocate(10_000, [70, 30])    # [7000, 3000]

A mesma técnica serve para rateio de taxas entre participantes, distribuição de juros e divisão de uma ordem entre várias contas.

Fronteiras: banco, APIs e JSON

A precisão costuma se perder nas bordas do sistema:

  • Banco de dados: use numeric(p, s) ou inteiros. Colunas float ou double precision reintroduzem o problema.
  • JSON: números em JSON costumam ser lidos como double em JavaScript, que só representa inteiros exatos até 2^53. Para valores grandes ou decimais, trafegue o valor como string e a moeda em um campo próprio.
  • Planilhas e relatórios: exportações para CSV devem formatar com a precisão da moeda, não com a representação interna do número.
POST /transfers
{ "amount": "1234.56", "currency": "BRL" }

Testes que valem a pena

  • Valores na fronteira do arredondamento (x,xx5) para cada regra usada.
  • Propriedade: a soma das parcelas alocadas é sempre igual ao total, para qualquer total e quaisquer pesos.
  • Conversões ida e volta entre moedas não podem gerar valores com mais casas do que a moeda permite.
  • Serialização e desserialização preservam o valor exato.

Resumo

Valores monetários exigem inteiros na menor unidade ou tipos decimais, sempre acompanhados da moeda. O arredondamento é uma regra de negócio explícita, a divisão de valores é feita por alocação para não perder centavos, e a precisão precisa ser preservada nas bordas: banco, APIs e exportações. São decisões pequenas que evitam as divergências mais difíceis de explicar em uma auditoria.