Prices

operacje finansowe w Pythonie

Michał Ociepka / @michalociepka

Agenda

  1. Dlaczego nie float?
  2. Decimal na ratunek.
  3. Na deser Prices.

Dlaczego nie float?

>>> 0.1
0.1
>>> 0.1 + 0.1
0.2
>>> 0.1 + 0.1 + 0.10.30000000000000004
>>> '%.53f' % 0.1
'0.10000000000000000555111512312578270211815834045410156'
>>> 1.1 + 2.2 - 3.34.440892098500626e-16
0.1 * 10 == sum(0.1 for i in range(10))
False

Dlaczego tak się dzieje?

  • ograniczona ilość pamięci
  • większość ułamków dziesiętnych nie da się przedstawić za pomocą ułamków binarnych

Moduł decimal w pythonie

  • IBM’s General Decimal Arithmetic Specification
  • Kontekst:
    >>> from decimal import getcontext
    >>> getcontext()
    Context(
        prec=28, rounding=ROUND_HALF_EVEN, Emin=-999999,
        Emax=999999, capitals=1, clamp=0, flags=[],
        traps=[InvalidOperation, DivisionByZero, Overflow])

Rounding

ROUND_HALF_EVEN

>>> Decimal('1.5').quantize(Decimal('0'))Decimal('2')
>>> Decimal('2.5').quantize(Decimal('0'))Decimal('2')

ROUND_HALF_UP

>>> Decimal('1.5').quantize(Decimal('0'), ROUND_HALF_UP)Decimal('2')
>>> Decimal('2.5').quantize(Decimal('0'), ROUND_HALF_UP)Decimal('3')

Dlaczego decimal nie traci na wartości

$$W = m \times p^c$$

  • $W$ - wartość liczby
  • $m$ - mantysa
  • $p$ - podstawa
  • $c$ - cecha

Zapis liczby

c = -1
m = -2

$$W = -2 \times 10^{-1}$$

$$W = -0.2$$

W = (znak, mantysa, cecha)
W = (1, 2, -1)

Arytmetyka

$$x = 0.1 + 0.01$$

x = (0, 1, -1) + (0, 1, -2)
x = (0, 10, -2) + (0, 1, -2)
x = (0, 11, -2)

$$x = 0.11$$

decimal.py

def __add__(self, other, context=None):
"""Returns self + other.

-INF + INF (or the reverse) cause InvalidOperation errors.
"""
other = _convert_other(other)
if other is NotImplemented:
    return other

if context is None:
    context = getcontext()

if self._is_special or other._is_special:
    ans = self._check_nans(other, context)
    if ans:
        return ans

    if self._isinfinity():
        # If both INF, same sign => same as both, opposite => error.
        if self._sign != other._sign and other._isinfinity():
            return context._raise_error(InvalidOperation, '-INF + INF')
        return Decimal(self)
    if other._isinfinity():
        return Decimal(other)  # Can't both be infinity here

exp = min(self._exp, other._exp)
negativezero = 0
if context.rounding == ROUND_FLOOR and self._sign != other._sign:
    # If the answer is 0, the sign should be negative, in this case.
    negativezero = 1

if not self and not other:
    sign = min(self._sign, other._sign)
    if negativezero:
        sign = 1
    ans = _dec_from_triple(sign, '0', exp)
    ans = ans._fix(context)
    return ans
if not self:
    exp = max(exp, other._exp - context.prec-1)
    ans = other._rescale(exp, context.rounding)
    ans = ans._fix(context)
    return ans
if not other:
    exp = max(exp, self._exp - context.prec-1)
    ans = self._rescale(exp, context.rounding)
    ans = ans._fix(context)
    return ans

op1 = _WorkRep(self)
op2 = _WorkRep(other)
op1, op2 = _normalize(op1, op2, context.prec)

result = _WorkRep()
if op1.sign != op2.sign:
    # Equal and opposite
    if op1.int == op2.int:
        ans = _dec_from_triple(negativezero, '0', exp)
        ans = ans._fix(context)
        return ans
    if op1.int < op2.int:
        op1, op2 = op2, op1
        # OK, now abs(op1) > abs(op2)
    if op1.sign == 1:
        result.sign = 1
        op1.sign, op2.sign = op2.sign, op1.sign
    else:
        result.sign = 0
        # So we know the sign, and op1 > 0.
elif op1.sign == 1:
    result.sign = 1
    op1.sign, op2.sign = (0, 0)
else:
    result.sign = 0
# Now, op1 > abs(op2) > 0

if op2.sign == 0:
    result.int = op1.int + op2.int
else:
    result.int = op1.int - op2.int

result.exp = op1.exp
ans = Decimal(result)
ans = ans._fix(context)
return ans

Python price handling for humans

https://github.com/mirumee/prices

  • obsługa ceny netto, brutto
  • obsługa zniżek oraz podatków
  • obsługa wielu walut
  • proste debugowanie dzięki historii operacji na obiekcie
  • możliwość definiowania przedziałów cenowych

ceny netto, brutto

>>> from prices import Price
>>> Price('1.99') + Price(50)
Price('51.99', currency=None)
>>> from prices import Price
>>> price = Price(net='1', gross='1.2') + Price(net=2, gross=4)
>>> price
Price(net='3', gross='5.2', currency=None)
>>> price.net
Decimal('3')
>>> price.gross
Decimal('5.2')
>>> price.tax
Decimal('2.2')

zniżki oraz podatki

>>> from prices import Price, FixedDiscount
>>> price = Price('1.99')
>>> discount = FixedDiscount(Price(1))
>>> discount.apply(price)
Price('0.99', currency=None)
>>> from prices import Price, LinearTax
>>> price = Price('1.99')
>>> tax = LinearTax('0.23', '23% VAT')
>>> tax.apply(price)
Price(net='1.99', gross='2.4477', currency=None)

wiele walut

>>> from prices import Price
>>> Price('1.99', currency='PLN')
Price('1.99', currency='PLN')

histora operacji

>>> from prices import Price, LinearTax
>>> price = Price('1.99') + Price(50) | LinearTax('0.23', '23% VAT')
>>> inspect_price(price)
((Price('1.99', currency=None) + Price('50', currency=None)) | LinearTax('0.23', name='23% VAT'))
        

możliwość definiowania przedziałów cenowych

>>> from prices import Price, PriceRange
>>> price = Price(70)
>>> price_range = PriceRange(Price(50), Price(100))
>>> price in price_range
True

Prices na straży

>>> Price(1.2)
__main__:1: RuntimeWarning: You should never use floats when dealing with prices!
Price('1.1999999999999999555910790149937383830547332763671875', currency=None)
>>> Decimal('2.5').quantize(Decimal('0'))
Decimal('2')
>>> Price('2.5').quantize(Decimal('0'))
Price('3', currency=None)
>>> Price(10, currency='USD') + Price(10, currency='PLN')
ValueError: Cannot add price in 'USD' to 'PLN'
>>> Price(10, currency='USD') < Price(10, currency='PLN')
ValueError: Cannot compare prices in 'USD' and 'PLN'

Django prices

https://github.com/mirumee/django-prices

  • field oraz model field które działają na prices
  • templatetagi do prostej manipulacji obiektem price w templateach
  • integracja z biblioteką babel

ModelField

from django.db import models

from django_prices.models import PriceField

class Product(models.Model):
    name = models.CharField('Name')
    price = PriceField('Price', currency='BTC')

FormField

from django import forms

from django_prices.forms import PriceField

class ProductForm(forms.Form):
    name = forms.CharField(label='Name')
    price = PriceField(label='Price', currency='PLN')

Templatetags

{% load prices %}

Price: {% gross foo.price %} ({% net foo.price %} {% tax foo.price %} tax)

{% load prices_i18n %}

Price: {% gross foo.price %} ({% net foo.price %} {% tax foo.price %} tax)

django prices openexchangerates

https://github.com/mirumee/django-prices-openexchangerates

  • synchronizacja kursu walut z openexchangerates.org
  • proste API do przeliczania obiektów price
  • templatetagi do prostej manipulacji obiektem price w templateach

Price converting

>>> from prices import Price, inspect_price
>>> from django_prices_openexchangerates import exchange_currency
>>> converted_price = exchange_currency(Price(10, currency='USD'), 'EUR')
>>> converted_price
Price('8.84040', currency='EUR')
>>> inspect_price(converted_price)
(Price('10', currency='USD') | CurrencyConversion('USD', 'EUR', rate=Decimal('0.88404')))

Templatetags

{% load prices_multicurrency %}

Price: {% gross_in_currency foo.price 'USD' %} ({% net_in_currency foo.price 'USD' %} {% tax_in_currency foo.price 'USD' %} tax)

https://mirumee.com
https://github.com/mirumee