Liczenie pieniędzy w języku Python
Listopad 14, 2024 | #python
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.
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, dlategoDecimal
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 (np1.2
jest reprezentowane w formie5404319552844595/4503599627370496
, co nie jest dokładnie tym co chcielibyśmy uzyskać na wyjściu.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.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 wersjiDecimal
-a.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.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.