Liczenie pieniędzy w języku Python

Listopad 14, 2024 | #python

010100
001001
000010
111110

Liczenie pieniędzy jest łatwe kiedy liczymy zyski, trudne kiedy liczymy straty a nudne kiedy chodzi o cudze pieniądze, ale może też być frustrujące gdy kwoty nam się nie zgadzają. Wielu księgowych i biegłych rewidentów zgodzi się ze zdaniem, że niekiedy łatwiej znaleźć brakujący milion niż zagubiony grosz. Jako programista Pythona pisząc programu, który m.in. będzie dokonywał różnego rodzaju operacji finansowych także możesz spotkać się z tym problemem dlatego warto, abyś wiedział jak prawidłowo liczyć pieniądze w języku Python.

System dziesiętny i błąd reprezentacji

Na co dzień posługujemy się systemem dziesiętnym (przynajmniej w naszej kulturze - ale to już inny temat) posługując się cyframi od 0 do 9, natomiast komputery operują w systemie binarnym wszelkich obliczeń dokonując jedynie z użyciem 0 (zer) i 1 (jedynek). Niestety nie wszystkie liczby dziesiętne mogą być dokładnie zapisane w formacie binarnym, ponieważ system dwójkowy nie jest w stanie precyzyjnie reprezentować niektórych wartości dziesiętnych, tak jak system dziesiętny. Przykładem jest liczba 0.1, która w systemie binarnym staje się liczbą okresową: 0.0001100110011... (ciągnie się w nieskończoność). Dlatego wynik działania 0.1 + 0.2 w systemie binarnym wyniesie 0.30000000000000004, podczas gdy w systemie dziesiętnym spodziewalibyśmy się po prostu 0.3. Jest to "standardowy" błąd reprezentacji znany w każdym języku programowania stosującego się do standardu IEEE 754.

Kiedy w Pythonie dokonujemy obliczeń z użyciem typu float to operujemy właśnie na liczbach w systemie dwójkowym. Po wielu matematycznych operacjach i zaokrągleniu ostatecznego wyniku do np. dwóch miejsc po przecinku zostajemy wtedy często z przysłowiowym jednym groszem więcej lub mniej.

Czasem nie ma to znaczenia i wtedy float jest całkowicie wystarczający. Jeśli jednak stoi nad nami księgowa z bilansem i palcem pokazuje, że coś tu jest nie tak bo jej na kalkulatorze wyszła podobna, ale minimalnie inna kwota musimy pochylić się nad problemem i znaleźć lepsze rozwiązanie.

Mnożenie przez 100 jako sposób obejścia problemu zaokrągleń

Pomnożenie ułamków dziesiętnych przez 100 jest czasem stosowane jako sposób uniknięcia błędów zaokrągleń podczas obliczeń finansowych. Innymi słowy zamiast liczyć złotówki 2.15 zł można przecież liczyć grosze 2.15 * 100 = 215 gr. API PAYu wymaga np. aby kwoty zamówienia wyrażane były w groszach, albo ich odpowiednikach w innych walutach. Sam kiedyś pracowałem w projekcie w którym wszystkie kwoty trzymane były i obliczane w groszach i tylko na potrzeby prezentacji przeliczane były na złotówki i formatowane do dwóch miejsc po przecinku. Takie obejście jest proste, intuicyjne, szybkie i niestety nie pozbawione wad:

Multiplikacja nie jest ucieczką od zaokrągleń

Pomnożenie float przez 100 sprawi, że wynik wciąż będzie typu float. W systemie binarnym 0.29*100 niekoniecznie daje 29

x = 0.29 * 100
print(x)
# 28.999999999999996

Jeśli taki wynik przekonwertujemy do liczby całkowitej int to dostaniemy 28 ponieważ podczas konwersji nie następuje zaokrąglenie tylko po prostu odrzucenie części ułamkowej

x = int(0.29 * 100)
print(x)
# 28

Możemy zadbać o wcześniejsze zaokrąglenie int(round(0.29 * 100)) i wtedy otrzymamy to czego się spodziewamy 29, ale po podzieleniu znów możemy otrzymać wartość typu float i to z częścią ułamkową

x = 29 / 3
print(type(x))
# <class 'float'>
print(x)
# 9.666666666666666

Więc ponownie powinniśmy dokonać zaokrąglenia i konwersji do int. Pominięcie zaokrągleń i konwersji naraża nas na błąd reprezentacji, należałoby to robić za każdym razem gdy wynikiem jakiejś operacji matematycznej jest float, a już na pewno kiedy taką kwotę chcemy zapisać do bazy danych, a trzymamy ją tam w postaci liczby całkowitej.

Duże liczby mają większe wymagania

Pomnożenie kilku złotych przez 100 nie stanowi problemu jednak jeśli taką operację wykonamy na bardzo dużych kwotach to otrzymamy jeszcze większe kwoty. Dla obliczeń nie ma to większego znaczenia, chyba, że działamy na naprawdę ogromnych liczbach i wielu ich wystąpieniach wtedy może mieć to wpływ na zużycie pamięci. Nie mniej trzeba uwzględnić wielkość liczb przy definiowaniu pól w bazie danych tak, aby nie tylko pomieściły kwoty w groszach ale też np. ich wielokrotności jeśli chcemy w bazie danych trzymać wyniki jakichś obliczeń.

Pewną niedogodnością jest konieczność pamiętania czy w danym momencie pracujemy z kwotą oryginalną czy przemnożoną przez 100, a prezentując ją użytkownikowi zadbać o odpowiednie jej sformatowanie żeby nie dostał zawału kiedy zobaczy wartość swojego długu, albo kiedy się dowie, że uzyskane zyski należy jeszcze podzielić prze 100. To są zawsze negatywne zaskoczenia.

Rekomendowany Decimal

Arytmetyka dziesiętna

Do operacji finansowych zdecydowanie powinniśmy używać klasy Decimal z modułu decimal ponieważ przechowuje ona liczby dziesiętne dokładnie tak, jak są zapisane a dzięki temu, że spełnia standardy arytmetyki dziesiętnej i jest zgodny ze specyfikacją arytmetyki zmiennoprzecinkowej IEEE 854-1987 - zapewnia większą precyzję zaokrągleń.

from decimal import Decimal

x = Decimal('0.1') + Decimal('0.2')
# Decimal('0.3')

Właściwości i narzędzia sprzyjające obliczeniom monetarnym

Decimal oferuje m.in:

  • możliwość wyboru sposobu zaokrąglenia (np. ROUND_HALF_UP, ROUND_DOWN),
  • wybór precyzji czyli kontrolę nad liczbą miejsc po przecinku,
  • respektowanie miejsc znaczących czyli zachowywanie końcowych zer po przecinku
  • sygnały, które reprezentują warunki powstałe podczas obliczeń.
  • i wiele innych

Dzięki sygnałom możemy np. dowiedzieć się czy w trakcie obliczenia nastąpiło zaokrąglenie.

from decimal import Decimal, getcontext, Rounded

# Ustawienie kontekstu z precyzją do 5 miejsc dziesiętnych
context = getcontext()
context.prec = 5  # Ustawienie precyzji na 5 cyfr
context.traps[Rounded] = False  # Wyłączamy pułapkę, aby nie przerywać operacji poprzez rzucenie wyjątku, ale ustawić flagę

# Przykładowa operacja, która będzie wymagała zaokrąglenia
result = Decimal('123.456789') * Decimal('2.71828')

# Sprawdzanie, czy wystąpiło zaokrąglenie
if context.flags[Rounded]:
    print("Ostrzeżenie: wynik został zaokrąglony.")
else:
    print("Wynik nie wymagał zaokrąglenia.")

print("Wynik operacji:", result)
# Ostrzeżenie: wynik został zaokrąglony.
# Wynik operacji: 335.59

Zdecydowanie polecam zapoznać się z dokumentacją klasy Decimal ponieważ prezentuje ona wiele ciekawych możliwości także w formie gotowych do użycia przepisów jak choćby funkcji moneyfmt do formatowania pieniędzy.

Ograniczenia klasy Decimal

Klasa Decimal ma swoje ograniczenia:

Emulacja obliczeń i wydajność

Moduł decimal jest częścią standardowej biblioteki Pythona i od wersji 3.3 w większości zaimplementowana w języku C co poprawia jego wydajność. Pomimo tego wciąż mu daleko do szybkości obliczeń z użyciem typu float, które korzystają z dobrodziejstw warstwy sprzętowej w przeciwieństwie do obliczeń bazujących na systemie dziesiętnym emulowanych w warstwie oprogramowania. W tym wypadku precyzja okupiona jest szybkością obliczeń.

Skończona precyzja

Pracując z Decimal-em możesz ustawić dowolną precyzję, ale wciąż jest ona skończona, a to oznacza że wciąż możesz natrafić na błąd zaokrąglenia. Nieprawidłowo ustawiona precyzja może wypaczyć przeliczenia kursów wychodzących poza jej zakres. Jeden złoty (1PLN) na dzień pisania tego artykułu jest wart 0,0000027 BTC (bitcoin) co oznacza, że ustawienie precyzji na 4 miejsca po przecinku jest niewystarczające dla przeliczeń między tymi dwiema walutami.

Jeszcze bardziej irytujący jest kolejny przykład ograniczonej precyzji. Dzieląc 1 przez 3, uzyskujemy nieskończoną liczbę dziesiętną 0.3333..., czego konsekwencją jest:

res = Decimal('1') / Decimal('3') * Decimal('3')
print(res == Decimal('1'))
print(res)
# False
# 0.9999999999999999999999999999

Można sobie z tym poradzić stosując zaokrąglenie, ale trzeba o tym pamiętać i wiedzieć

res = (Decimal('1') / Decimal('3') * Decimal('3')).quantize(Decimal('.1'))
print(res == Decimal('1'))
print((res, Decimal('1'))
# True
# (1.0, 1)

Konwersja z float

Warto jeszcze dodać, że konwertując wartości z float do Decimal zawsze należy to robić poprzez wcześniejsze sprowadzenie jej do typu str bo w przeciwnym wypadku już na samym początku otrzymamy coś, czego się nie spodziewaliśmy

fvar = 1.2
print(Decimal(fvar))
print(Decimal(str(fvar)))
# 1.1999999999999999555910790149937383830547332763671875
# 1.2

Nieskończenie precyzyjny Fraction

Moduł fractions w Pythonie jest przeznaczony do pracy z ułamkami. Umożliwia reprezentowanie liczb w postaci ułamków zwykłych, czyli liczników i mianowników, zamiast liczb zmiennoprzecinkowych. Dzięki temu pozwala na precyzyjne obliczenia z ułamkami bez ryzyka błędów zaokrągleń, które często występują przy użyciu liczb zmiennoprzecinkowych.

from fractions import Fraction

res = Fraction(1) / Fraction(3)
print(res)
print(res == Fraction(1, 3))
# 1/3
# True

res2 = res * Fraction(3)
print(res2)
print(res2 == Fraction(1))
# True
# 1

Nieskończona precyzja

Obiekt klasy Fraction przechowuje wartości w postaci dwóch liczb całkowitych (licznika i mianownika), co oznacza, że może dokładnie reprezentować ułamek, bez zaokrągleń. W przeciwieństwie do liczb zmiennoprzecinkowych, takich jak float, które mają ograniczoną precyzję binarną, Fraction nie traci dokładności, nawet dla dużych liczb lub bardzo małych ułamków.

Kiedy mówimy, że Fraction oferuje nieskończoną precyzję, oznacza to, że obiekty Fraction mogą reprezentować liczby dokładnie, bez ograniczeń co do liczby cyfr lub miejsc dziesiętnych, pod warunkiem że liczba ta może być wyrażona jako stosunek dwóch liczb całkowitych (licznika i mianownika). W Pythonie typ int pozwala na przechowywanie liczb całkowitych o arbitralnej wielkości, dopóki starczy pamięci. Dzięki temu Fraction może reprezentować bardzo duże liczby zarówno w liczniku, jak i mianowniku, umożliwiając zapisywanie nawet bardzo małych lub bardzo dużych wartości bez utraty dokładności. Nieskończona precyzja Fraction jest zatem limitowana wielkością dostępnej pamięci.

To czyni z Fraction lepszym narzędziem do obliczeń monetarnych od Decimal-a - przynajmniej pod względem precyzji obliczeń.

Kompatybilność Fraction z float

Jej przewagą nad Decimal jest także to, że dokonując obliczeń można mieszać Fraction z typem float w przeciwieństwie do Decimal-a, który zsumowany z float-em zgłosi wyjątek

res = Fraction(1, 2) + 0.75
print(res)
# 1.25

res = Decimal('0.5') + 0.75
# TypeError: unsupported operand type(s) for +: 'decimal.Decimal' and 'float'

Wciąż jednak należy pamiętać, że konwersja z float da inny wynik niż konwersja ze string-a

fvar = 1.2
print(Fraction(fvar))
print(Fraction(str(fvar)))
print(Fraction(fvar) == Fraction(str(fvar)))
# 5404319552844595/4503599627370496
# 6/5
# False

Przewaga Decimal nad Fraction przy obliczeniach finansowych

Pomimo lepszej precyzji i możliwości łączenia go float-em, Fraction nie był projektowany do obliczeń monetarnych w związku z czym Decimal wciąż pozostaje najlepszym wyborem. Przemawiają za tym następujące argumenty.

  1. Precyzja dziesiętna dostosowana do walut Decimal pozwala na dokładne odwzorowanie liczb dziesiętnych, takich jak wartości pieniężne, z kontrolowaną liczbą miejsc po przecinku (np. dwa miejsca po przecinku dla groszy lub centów). W finansach zwykle stosuje się liczby dziesiętne z ustaloną liczbą miejsc dziesiętnych, dlatego Decimal pozwala na ustawienie takiej dokładności, jakiej wymaga konkretny przypadek użycia. Fraction natomiast reprezentuje liczby w postaci stosunku dwóch liczb całkowitych (np 1.2 jest reprezentowane w formie 5404319552844595/4503599627370496, co nie jest dokładnie tym co chcielibyśmy uzyskać na wyjściu.

  2. Kontrola nad zaokrągleniami Decimal pozwala na precyzyjną kontrolę nad sposobem zaokrąglania wyników (np. ROUND_HALF_UP, ROUND_DOWN). Jest to kluczowe w obliczeniach finansowych, ponieważ różne przepisy i standardy wymagają stosowania konkretnych metod zaokrąglania. Fraction nie oferuje wbudowanego mechanizmu zaokrąglania, więc zaokrąglenie wyników wymagałoby dodatkowych operacji.

  3. Szybsze działanie w przypadku ułamków dziesiętnych Decimal jest zoptymalizowany pod kątem operacji na liczbach dziesiętnych i działa szybciej niż Fraction w przypadku prostych operacji arytmetycznych, takich jak dodawanie, odejmowanie, mnożenie czy dzielenie liczb dziesiętnych. Fraction wymaga skracania ułamków i radzenia sobie z dużymi licznikami i mianownikami, co może być wolniejsze przy dużych zbiorach danych lub skomplikowanych obliczeniach. Nie bez znaczenia jest też fakt, że moduł Fraction napisany jest w czystym Python-ie i choć można go zastąpić kompatybilnym modułem quicktions napisanym w CPyhton-ie to wciąż ustępuje on wydajnością kompilowanej wersji Decimal-a.

  4. Mniejsze zużycie pamięci dla typowych obliczeń finansowych Fraction przechowuje wartości w postaci licznika i mianownika, co może prowadzić do dużego zapotrzebowania na pamięć przy skomplikowanych obliczeniach (np. gdy ułamki nie są łatwo skracalne). Decimal przechowuje liczby dziesiętne bezpośrednio w postaci dziesiętnej, dzięki czemu jest bardziej efektywny pamięciowo, gdy operujemy na liczbach finansowych z ustaloną liczbą miejsc po przecinku.

  5. Zgodność ze standardami finansowymi Decimal jest zgodny ze standardami finansowymi. Banki, firmy finansowe i systemy księgowe często korzystają z typów danych o stałej precyzji dziesiętnej. Decimal pozwala zachować zgodność z tymi standardami, dzięki czemu lepiej nadaje się do aplikacji finansowych.

Co musisz zapamiętać o typach float, Decimal i Fraction kiedy liczysz pieniądze?

Najlepszym wyborem do pracy z pieniędzmi jest Decimal. Choć wydaje się być kompromisem pomiędzy wydajnością float a precyzją Fraction nie jest to do końca prawdą. Klasa Decimal jest doskonale wyposażona i po zapoznaniu się z jej szczegółową dokumentacją docenimy bogactwo narzędzi, które oferuje względem dość ubogo wyekwipowanego Fraction.

Siłą tej drugiej z przytoczonych tu klas jest z kolei prostota i intuicyjność użycia, co czyni ją najlepszym wyborem, kiedy precyzja ma największe znaczenie. W obliczeniach monetarnych Fraction można łączyć z float i konwertować ją do Decimal (za pośrednictwem str i float: Decimal(str(float(Fraction(1,4))))) kiedy jest to potrzebne, natomiast pieniądze to nie wszystko. Fraction pokrywa ogromne spektrum zastosowań (obliczenia matematyczne wymagające dokładności ułamkowej, implementacja algorytmów np. związanych z teorią liczb, przeliczanie miar i wag, obliczanie proporcji, itd.). Przypadki użycia Fraction łatwo znajdziemy w internecie.

Typ float jest najlepszym wyborem tam, gdzie liczy się szybkość obliczeń i akceptowalne są niewielkie błędy zaokrągleń. Dzięki optymalizacji sprzętowej float jest szczególnie przydatny w naukach przyrodniczych, grafice, symulacjach, uczeniu maszynowym, aplikacjach czasu rzeczywistego oraz wszelkich obliczeniach inżynieryjnych, które tolerują drobne niedokładności. Może być też używany do liczenia pieniędzy w celach orientacyjnych, tam gdzie są ogromne wolumeny danych i chodzi o to aby przeliczyć coś szybko ale nie koniecznie z dokładnością co do grosza. Posługiwanie się tym typem w kontekście finansów powinno być świadome, celowe i wybiórcze ponieważ dla operacji monetarnych wymagających ścisłego zarachowania typ float nie oferuje akceptowalnego poziomu precyzji.

Akceptuję Ta strona zapisuje niewielkie pliki tekstowe, nazywane ciasteczkami (ang. cookies) na Twoim urządzeniu w celu lepszego dostosowania treści oraz dla celów statystycznych. Możesz wyłączyć możliwość ich zapisu, zmieniając ustawienia Twojej przeglądarki. Korzystanie z tej strony bez zmiany ustawień oznacza zgodę na przechowywanie cookies w Twoim urządzeniu.