Pieniądze są jednym z obszarów, w których drobne niedopowiedzenia potrafią wytworzyć dług technologiczny szybciej niż inny fragment dziedziny zastosowań. Nie dlatego, że „finanse są trudne”, lecz dlatego, że przeciętny kod lubi „udawać”, iż temat jest prosty. Bankster to próba zrobienia tej rzeczy dobrze.
Bankster to biblioteka programistyczna dla języka Clojure, której napisania podjąłem się w roku 2021, a po kilku latach wprowadziłem sporo poprawek związanych z wydajnością, uogólnieniem operacji oraz adaptacją do wymogów współczesnych systemów przetwarzania danych finansowych.
Celem Bankstera jest udostępnienie tworzonym aplikacjom narzędzi do pracy z walutami i kwotami w taki sposób, żeby:
- dało się budować systemy, a nie tylko helpery,
- reguły były jawne (skala, zaokrąglanie, konflikty kodów walutowych),
- błędy były czytelne (i dało się wybrać rygor),
- a podstawowe operacje na tyle szybkie, żeby nie trzeba było uciekać w „gołe” typy podstawowe przy pierwszej okazji zwiększonego obciążenia.
Ten wpis nie jest manifestem przeciwko innym podejściom, ale raczej opisem tego, co Bankster wnosi jako rzemieślniczy klocek – do małych i dużych projektów.
Co Bankster uważa za pieniądze?
Model jest celowo prosty, ale nie prymitywny:
-
Pieniądz to waluta i liczba jej jednostek (dużych i małych).
-
Pieniądz reprezentowany jest rekordem
Money. -
Money = [currency, amount], gdzie:currencyto rekordCurrencyopisujący walutę,amountto typBigDecimalz pełną kontrolą skali i zaokrągleń.
-
Currency = [id, scale, numeric, domain, kind], gdzie:idto identyfikator w stylu:USDalbo:crypto/BTC(lub coś własnego);scaleto nominalna skala jednostki;numericmoże przechowywać numeryczny kod ISO,domainto przynależność domenowa (np.:CRYPTO,:ISO-4217),kindjest rodzajem waluty (np.:iso/fiatlub:virtual/native).
Zależnie od sytuacji możemy użyć Currency, aby wyrażać:
- waluty ISO 4217 (najczęstsze przypadki),
- kryptowaluty (inne skale, inne reguły),
- waluty systemowe (punkty, kredyty, żetony w grach, jednostki rozliczeniowe),
- waluty historyczne (ważne w archiwach i migracjach).
Wszystkie powyższe opcje mieszczą się w jednym modelu Bankstera, bez sztucznych wyjątków.
Zobacz także:
- „Data Structures”, dokumentacja biblioteki
Gdzie mieszkają waluty?
Bankster wprowadza koncepcję rejestru (rekord Registry), czyli wykazu walut
i ich właściwości. To jest ważne, bo w praktyce programy nie operują na walutach
„w próżni”; gdzieś musi znajdować się źródło prawdy.
Czasem kod USD oznacza wszędzie to samo, ale czasem mamy konflikt, gdy nasza własna
waluta (np. :nasza/USD) lub jakaś waluta wirtualna (np. hipotetyczna :crypto/USD)
ma ten sam kod. Chcemy wtedy sterować pierwszeństwem rozpoznawania walut po kodach
z użyciem jakiejś wagi.
Rejestr to w gruncie rzeczy zestaw indeksów (implementowanych z użyciem map), które pozwalają uzyskiwać:
-
walutę na podstawie:
- unikatowego identyfikatora (np.
:crypto/ETH,:PLN), - przypisanego kodu (np.
:USD,:PLN) i wagi, - opcjonalnego numeru ISO (np. 985) i wagi,
- opcjonalnie przypisanego kraju;
- unikatowego identyfikatora (np.
-
zbiór walut na podstawie:
- kodu,
- numeru ISO,
- domeny (np.
:CRYPTO), - kraju (np.
:PL);
-
i dodatkowo na podstawie identyfikatora:
- ustawienia regionalne waluty,
- cechy waluty,
- kraje waluty,
- wagę waluty.
Umieszczone wyżej cechy (ang. traits) są opcjonalnymi zbiorami kojarzonymi
z walutami, a ich elementami są słowa kluczowe, np. :peg/fiat czy
:network/distributed. Dzięki nim możemy „opowiadać” o istotnych właściwościach
walut, aby później dokonywać rozróżniania, filtrowania czy klasyfikacji.
Dla cech, rodzajów i domen utrzymywane są w rejestrze walutowym hierarchie relacji, które można aktualizować o własne zależności. Wbudowane w Bankstera predykaty czynią z nich użytek. Możemy więc na przykład odpytać walutę o to, czy jest zdecentralizowana, albo o to, czy jest fiducjarna.
Wbudowane hierarchie cech zawierają też meta-kategorie, które pozwalają aplikować te same predykaty zarówno do walut ISO, jak i wirtualnych, jeżeli zajdzie taka potrzeba.
Zobacz także:
- „Currency kinds”, dokumentacja biblioteki
- „Currency traits”, dokumentacja biblioteki
Zasięg rejestru
W zależności od architektury mamy trzy naturalne tryby, których użyją wszystkie funkcje korzystające z rejestru walutowego:
- globalny rejestr (sensowny w aplikacji lub usłudze),
- dynamiczny rejestr (np. w testach, workerach, lub instancjach ad-hoc),
- lokalny rejestr (w wąskim algorytmie lub module, przekazywany jako argument).
Ten rodzaj elastyczności daje swobodę bez utraty kontroli.
Możemy mieć jeden główny, globalny rejestr, ale na granicach naszego systemu – na przykład w bramkach wymiany danych z giełdami – korzystać z jego zmodyfikowanych kopii (np. ze skalami dostosowanymi do ograniczeń drugiej strony bądź zawężonymi zbiorami walut). Wtedy kod odpowiedzialny za obsługę wybranych partnerów będzie używał dynamicznie powiązanego, pochodnego rejestru.
Kontrakty: jak Bankster mówi, co gwarantuje
W pewnym momencie przestało mnie interesować tylko to, czy to w ogóle działa, a bardziej w jakim trybie działa. Dlatego właśnie Bankster ma jawne kontrakty zachowań, w tym:
- rozróżnienie API strict vs soft (tam gdzie ma to sens),
- model obsługi błędów i zasady walidacji,
- reguły rejestru i rozpoznawania walut,
- konsekwencje wyborów zaokrągleń i skali.
Kontrakty nie powstały wyłącznie z myślą o użytkowniku biblioteki, ale też z myślą o mnie w przyszłości. W programowaniu często sięgamy po narzędzia, które leżały na półce od dłuższego czasu i potrzebujemy wytycznych, które pozwoliłyby szybko odzyskać orientację co do zasad i zachowań poszczególnych komponentów.
Dzięki kontraktom wiem czego spodziewać się po różnych funkcjach i metodach protokołów, a także jakie kształty danych istotnie na nie wpływają.
W praktyce oznacza to, że można dobrać styl integracji i na przykład:
-
w systemie księgowym – korzystać raczej z funkcji API oznaczonych jako strict
(błędy będą sygnałem, że reguły są łamane), -
w parserach i importerach – wykorzystywać często soft
(brak komunikowany będzie zwracaniemnil, mamy fallbacki do ustawień domyślnych, rozpoznawanie walut bazujące natry-resolve), -
w narzędziach analitycznych – zwykle miks powyższych.
Zobacz także:
- „Bankster Contracts”, dokumentacja biblioteki
- „Bankster Front API”, dokumentacja biblioteki
Operacje bez gubienia groszy
Trzy klasy operacji finansowych bywają szczególnie zdradliwe:
- dzielenie i rozkład kwot (alokacja i dystrybucja),
- sumowanie wielkich list (gdzie rośnie narzut tworzenia obiektów),
- zaokrąglanie.
Bankster ma funkcje służące do akumulacji obliczeń, a także alokacji i dystrybucji, czyli rozkładów, w których:
- suma wejścia = suma wyjścia,
- różnice wynikające z zaokrągleń są w kontrolowany sposób dystrybuowane,
- nie ma „znikających groszy”.
To jest fundament dla rozliczeń, prowizji, podatków, podziałów kwot i billingów.
Poza tym już od pierwszych wydań Bankster posiada wbudowane wariadyczne warianty operacji arytmetycznych na kwotach, które są znacznie szybsze niż następcze wywoływanie dwuargumentowych wersji.
Zaokrąglanie to temat rzeka. Tworząc oprogramowanie finansowo-księgowe musimy uważać na przykład na nieskończone rozwinięcia dziesiętne podczas dzielenia i różnie reagować zależnie od rodzaju skali waluty (ustalona czy automatyczna). Co więcej, użytkownik powinien mieć możliwość świadomego wyboru, czy korzysta z zaokrąglania po każdej następującej po sobie operacji (w przypadku list działań), czy może dopiero na samym końcu. Bankster obsługuje oba tryby, ma też wbudowane mechanizmy odpowiedniego traktowania nieskończonych rozwinięć podczas dzielenia (w tym domagania się jawnej decyzji programisty, gdy jest to konieczne).
Serializacja: EDN i świat poza EDN
Dane finansowe żyją na styku systemów. Dlatego wyposażyłem bibliotekę w następujące cechy:
-
obsługa EDN w kodzie: literały znacznikowe
#moneyi#currencyoraz korzystające z nich czytniki (data readers), -
serializacja EDN/JSON,
-
sensowna reprezentacja (omawiane rekordy), która nie „rozsmarowuje” skali (niejawnie konwertując) i nie gubi semantyki.
Jeżeli budujemy system, w którym dane wędrują przez kolejki, API i storage – to powyższe są zwykle bardzo istotne.
Zobacz także:
- „Serialization”, dokumentacja biblioteki
- „Example EDN config”, dokumentacja biblioteki
Operatory: gdy Money spotyka clojure.core
Bankster ma warstwę operatorów w dwóch „smakach”, reprezentowanych dwoma przestrzeniami nazw:
-
io.randomseed.bankster.money.ops
– semantyka Money (wiesz, co dostaniesz), -
io.randomseed.bankster.money.inter-ops
– zachowuje się jakclojure.coredopóki w grze nie pojawi sięMoney.
To jest kompromis między ergonomią a bezpieczeństwem. W małych modułach wygodnie jest
korzystać z inter-ops i wszędzie używać tych samych co zwykle operatorów
arytmetycznych czy predykatów – w krytycznych punktach będą one sięgać
do implementacji Bankstera przeznaczonych dla kwot, a nie dla dowolnych liczb. Jednak
to wciąż tzw. monkey patching.
W produkcyjnym kodzie zalecam korzystanie wyłącznie z przestrzeni
io.randomseed.bankster.api.money.ops należącej do API. Jej funkcje delegują
co prawda do inter-ops, lecz dopóki nie używamy :refer :all, jesteśmy bezpieczni.
Minimalny quickstart
Wersja bez literałów i z domyślnym rejestrem:
(require
'[io.randomseed.bankster.api :as b]
'[io.randomseed.bankster.api.money :as m]
'[io.randomseed.bankster.api.currency :as c]
'[io.randomseed.bankster.api.ops :as ops])
(def usd (c/resolve "USD"))
(def pln (c/resolve "PLN"))
(def a (m/resolve 12.34 usd))
(def b (m/resolve 10 pln))
(ops/+ a (m/resolve 1.66 usd))
;; => #money[14.00 USD] (przykład reprezentacji)
(require
'[io.randomseed.bankster.api :as b]
'[io.randomseed.bankster.api.money :as m]
'[io.randomseed.bankster.api.currency :as c]
'[io.randomseed.bankster.api.ops :as ops])
(def usd (c/resolve "USD"))
(def pln (c/resolve "PLN"))
(def a (m/resolve 12.34 usd))
(def b (m/resolve 10 pln))
(ops/+ a (m/resolve 1.66 usd))
;; => #money[14.00 USD] (przykład reprezentacji)
Wersja z literałami (kiedy pasuje to do stylu projektu):
(def a #money[12.34 :USD])
(def x #money[1.66 :USD])
(ops/+ a x)
(def a #money[12.34 :USD])
(def x #money[1.66 :USD])
(ops/+ a x)
Rozpoznawanie walut w stylu soft:
(c/resolve-try "NIE-ISTNIEJĄCA")
;; => nil
(c/resolve-try "NIE-ISTNIEJĄCA")
;; => nil
Pytanie o właściwości:
(c/info :PLN)
{:id :PLN,
:numeric 985,
:scale 2,
:domain :ISO-4217,
:kind :iso/fiat,
:weight 0,
:countries #{:PL},
:localized {:pl {:name "złoty polski", :symbol "zł"}}}
(c/info :crypto/USDC)
{:id :crypto/USDC,
:numeric -1,
:scale 8,
:domain :CRYPTO,
:kind :virtual.stable.peg/fiat,
:weight 4,
:localized {:* {:name "USD Coin", :symbol "USDC"}},
:traits #{:peg/fiat :stable/coin :token/erc20}}
(c/info :PLN)
{:id :PLN,
:numeric 985,
:scale 2,
:domain :ISO-4217,
:kind :iso/fiat,
:weight 0,
:countries #{:PL},
:localized {:pl {:name "złoty polski", :symbol "zł"}}}
(c/info :crypto/USDC)
{:id :crypto/USDC,
:numeric -1,
:scale 8,
:domain :CRYPTO,
:kind :virtual.stable.peg/fiat,
:weight 4,
:localized {:* {:name "USD Coin", :symbol "USDC"}},
:traits #{:peg/fiat :stable/coin :token/erc20}}
Zastosowania bojowe
Bankster jest przydatny, kiedy potrzebujemy domeny finansowej jako komponentu, a nie jako listy pomocniczych funkcji. Co to znaczy? Najczęstsze scenariusze poniżej.
-
Billing i rozliczenia w systemach usługowych
Na przykład: subskrypcje, limity, prowizje, rabaty, plany taryfowe, fakturowanie.Kluczowe są wtedy:
- jawne zasady skalowania,
- deterministyczne zaokrąglanie,
- alokacje bez strat.
-
Importy i migracje danych
Różne źródła, różne reprezentacje, niespójne kody walut.Tu wygrywa:
- rejestr z wagami i regułami,
- soft/strict API zależnie od etapu przetwarzania,
- serializacja i czytelność danych.
-
Systemy wewnętrznych jednostek wartości
Na przykład: punkty, kredyty, tokeny, jednostki rozliczeń, waluty domenowe.Tu ważne są:
- atrybuty waluty
domain(domena) ikind(rodzaj), - własne skale lub skale automatyczne,
- możliwość współistnienia z walutami ISO 4217.
- atrybuty waluty
-
Analityka i raportowanie
Kiedy waluta reprezentowana przez rekord
Moneypowinna być „nudnym, poprawnym typem” w ETL i raportach:- warstwa operatorów ułatwia obliczenia,
- jawne kontrakty pomagają utrzymać semantykę.
Wydajność
Nie publikuję tutaj wielkich tabel, bo mikro-testy wydajnościowe mają swoje wady. Jednak z pewnością, na poziomie praktycznym Bankster nie jest „akademickim eksperymentem”.
W testach typu reduce sum of 10k i divide+sum of 10k biblioteka utrzymywała zauważalną przewagę nad podejściem opartym o Joda-Money, przy własnej obsłudze wyjątków, zachowaniu jawnej kontroli skali i reguł zaokrąglania.
Najważniejsze jest jednak, że dzięki Banksterowi można pisać czytelny kod
o przewidywalnym zachowaniu, a dopiero potem go profilować, zamiast od razu uciekać
w surowe BigDecimal i ręcznie opracowywać semantykę.
Pułapki
-
Skala to część semantyki.
Jeżeli spróbujesz używaćMoneyjak „liczby z kropką”, wcześniej czy później trafisz na różnice wynikające z zaokrągleń. -
Nie mieszaj walut bez jawnego powodu.
Bankster nie udaje, że dodawanie USD do PLN jest w porządku. Jeżeli chcemy obsługę wymiany walut, musimy dodać własną warstwę FX (kursy) – mogą w tym pomóc wbudowane w bibliotekę funkcje do konwersji. -
Rejestr jest narzędziem.
Dla prostych programów wystarczy domyślny rejestr. Dla systemów: własny. -
Soft API jest świetne na wejściu, nie w rdzeniu.
resolve-tryw parserze: tak;resolve-tryw księgowaniu: raczej nie.
Co dalej: ledger i rynki
Bankster celowo nie udaje, że jest narzędziem do księgowości, ponieważ jego zadaniem jest dostarczanie precyzyjnej, bogatej semantycznie i wydajnej warstwy do obsługi pieniądza.
Ledger, rynki, pozycje, konta, dokumenty księgowe czy zasady dekretacji – to wszystko to osobna przestrzeń, którą można budować nad Banksterem. Mam wrażenie, że tak jest bezpieczniej: biblioteka dostarcza operacji i semantyki, a system miejsca i procesu.
Jeżeli w moim ekosystemie kiedykolwiek powstanie tzw. accounting layer, Bankster będzie jego fundamentem, a nie konkurencją.
Zobacz także:
- Repozytorium projektu: https://github.com/randomseed-io/bankster
- Dokumentacja: https://randomseed.io/software/bankster/