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
Decimalpozwala 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, dlategoDecimalpozwala na ustawienie takiej dokładności, jakiej wymaga konkretny przypadek użycia.Fractionnatomiast reprezentuje liczby w postaci stosunku dwóch liczb całkowitych (np1.2jest reprezentowane w formie5404319552844595/4503599627370496, co nie jest dokładnie tym co chcielibyśmy uzyskać na wyjściu.Kontrola nad zaokrągleniami
Decimalpozwala 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.Fractionnie 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
Decimaljest zoptymalizowany pod kątem operacji na liczbach dziesiętnych i działa szybciej niżFractionw przypadku prostych operacji arytmetycznych, takich jak dodawanie, odejmowanie, mnożenie czy dzielenie liczb dziesiętnych.Fractionwymaga 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łFractionnapisany 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
Fractionprzechowuje 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).Decimalprzechowuje 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
Decimaljest zgodny ze standardami finansowymi. Banki, firmy finansowe i systemy księgowe często korzystają z typów danych o stałej precyzji dziesiętnej.Decimalpozwala 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.