Merged PR 2142: More decimals

This PR implements foundational changes in how money amounts ara managed to allow exchange rates to have up to 8 decimal positions. Before, this was limited to 0, 2 or 3 decimal positions, depending on the currency.
This commit is contained in:
Pablo Martín 2024-06-27 16:17:24 +00:00
commit c2c3c85af9
10 changed files with 176 additions and 33 deletions

3
.gitignore vendored
View file

@ -160,3 +160,6 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear # and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
# to avoid test files being commited
*.csv

15
CHANGELOG.md Normal file
View file

@ -0,0 +1,15 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.1] - 2024-06-27
### Changed
- Rates now have up to 8 decimal positions of precision, up from the previous 0, 2 or 3 depending on the currency.
- Internally, `xexe` now uses an in-house `MoneyAmount` class as a replacement of `py-money`'s `Money` class.
- The table schema when writing to Postgresql has gone from `DECIMAL(19,4)` to `DECIMAL(23,8)` to adapt accordingly.
- `CHF` has been added to the default currencies included in the `run_xexe.sh` deployment script.

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "xexe" name = "xexe"
version = "1.0.0" version = "1.0.1"
description = "" description = ""
authors = ["Pablo Martin <pablo.martin@superhog.com>"] authors = ["Pablo Martin <pablo.martin@superhog.com>"]
readme = "README.md" readme = "README.md"

View file

@ -11,8 +11,8 @@ from xexe.constants import DEFAULT_CURRENCIES
def test_get_rates_for_hardcoded_case_returns_expected_output(): def test_get_rates_for_hardcoded_case_returns_expected_output():
""" """
Calling the CLI get-rates command to get the rates between USD, EUR and GBP Calling the CLI get-rates command to get the rates between CAD and GBP for
for 2024-01-01 to 2024-01-03 returns the expected records in a CSV. 2024-06-20 returns the expected records in a CSV.
ATTENTION! ATTENTION!
@ -35,11 +35,11 @@ def test_get_rates_for_hardcoded_case_returns_expected_output():
get_rates, get_rates,
[ [
"--start-date", "--start-date",
"2024-01-01", "2024-06-20",
"--end-date", "--end-date",
"2024-01-02", "2024-06-20",
"--currencies", "--currencies",
"USD,GBP", "CAD,EUR",
"--rates-source", "--rates-source",
"xe", "xe",
"--output", "--output",
@ -49,11 +49,30 @@ def test_get_rates_for_hardcoded_case_returns_expected_output():
assert run_result.exit_code == 0 assert run_result.exit_code == 0
# Write code here to read output file and compare it against expected expected_output = [
# output ["from_currency", "to_currency", "rate", "rate_date", "exported_at"],
assert False ["CAD", "EUR", "0.67884137", "2024-06-20", "2024-06-27T17:26:04"],
["EUR", "CAD", "1.47309820", "2024-06-20", "2024-06-27T17:26:04"],
["CAD", "CAD", "1.00000000", "2024-06-20", "2024-06-27T17:26:04"],
["EUR", "EUR", "1.00000000", "2024-06-20", "2024-06-27T17:26:04"],
]
with open("test_output.csv", newline="") as csvfile:
reader = csv.reader(csvfile)
output = list(reader)
headers_match = output[0] == expected_output[0]
assert headers_match
for i, row in enumerate(output[1:], start=1):
row_matches = row[:4] == expected_output[i][:4]
exported_at_is_valid_iso_datetime = bool(
datetime.datetime.fromisoformat(row[4])
)
assert row_matches and exported_at_is_valid_iso_datetime
""" """
# This is for when the main test is commented out
assert True assert True
@ -108,11 +127,10 @@ def test_get_rates_dry_run_always_returns_42_as_rates():
# Check that all rows have the expected rate of 42, 1/42 or 1 and the correct dates # Check that all rows have the expected rate of 42, 1/42 or 1 and the correct dates
for row in rows: for row in rows:
assert row["rate"] in ( assert row["rate"] in (
"42", "42.00000000",
"0.024", "0.02380952",
"0.02", "0.00000000",
"0", "1.00000000",
"1",
), f"Expected rate to be 42, 1/42 or 1, but got {row['rate']}" ), f"Expected rate to be 42, 1/42 or 1, but got {row['rate']}"
assert row["rate_date"] in [ assert row["rate_date"] in [
(some_random_date + datetime.timedelta(days=i)).strftime("%Y-%m-%d") (some_random_date + datetime.timedelta(days=i)).strftime("%Y-%m-%d")

View file

@ -2,7 +2,6 @@ import datetime
from decimal import Decimal from decimal import Decimal
from money.currency import Currency from money.currency import Currency
from money.money import Money
from xexe.exchange_rates import ( from xexe.exchange_rates import (
ExchangeRate, ExchangeRate,
@ -27,6 +26,20 @@ def test_exchange_rate_creation_works():
assert a_rate.rate_date == datetime.date.today() assert a_rate.rate_date == datetime.date.today()
def test_exchange_rate_can_hold_8_decimal_positions():
a_rate = ExchangeRate(
from_currency=Currency.USD,
to_currency=Currency.EUR,
rate=Decimal("1.12345678"),
rate_date=datetime.date.today(),
)
assert a_rate.from_currency.value == "USD"
assert a_rate.to_currency.value == "EUR"
assert a_rate.rate.amount == Decimal("1.12345678")
assert a_rate.rate_date == datetime.date.today()
def test_descriptor_builds_properly(): def test_descriptor_builds_properly():
a_rate = ExchangeRate( a_rate = ExchangeRate(
@ -237,13 +250,13 @@ def test_add_inverse_rates_returns_expected():
a_rate = ExchangeRate( a_rate = ExchangeRate(
from_currency=Currency.EUR, from_currency=Currency.EUR,
to_currency=Currency.USD, to_currency=Currency.USD,
rate=Decimal("1.25"), rate=Decimal("1.12345000"),
rate_date=datetime.date(2020, 3, 10), rate_date=datetime.date(2020, 3, 10),
) )
inverse_rate = ExchangeRate( inverse_rate = ExchangeRate(
from_currency=Currency.USD, from_currency=Currency.USD,
to_currency=Currency.EUR, to_currency=Currency.EUR,
rate=Decimal("0.8"), rate=Decimal("0.89011527"),
rate_date=datetime.date(2020, 3, 10), rate_date=datetime.date(2020, 3, 10),
) )
@ -256,7 +269,7 @@ def test_add_inverse_rates_returns_expected():
assert len(rates) == 2 assert len(rates) == 2
assert rates.is_rate_present(inverse_rate) assert rates.is_rate_present(inverse_rate)
assert rates[inverse_rate.descriptor].amount == Decimal("0.8") assert rates[inverse_rate.descriptor].amount == Decimal("0.89011527")
def test_add_inverse_rates_runs_on_empty_rates(): def test_add_inverse_rates_runs_on_empty_rates():

View file

@ -0,0 +1,49 @@
from decimal import Decimal
import pytest
from money.currency import Currency
from xexe.money_amount import DEFAULT_MONEY_PRECISION, MoneyAmount
def test_money_amount_simple_creation_works():
an_amount = MoneyAmount(amount=10, currency=Currency.USD)
assert an_amount.amount == 10
assert an_amount.currency == Currency.USD
def test_money_amount_takes_integer_amounts():
an_amount = MoneyAmount(amount=10, currency=Currency.USD)
assert an_amount.amount == 10
def test_money_amount_takes_decimal_amounts():
an_amount = MoneyAmount(amount=Decimal(10.5), currency=Currency.USD)
assert an_amount.amount == Decimal(10.5).quantize(DEFAULT_MONEY_PRECISION)
def test_money_amount_takes_proper_strings_amounts():
an_amount = MoneyAmount(amount="10.55", currency=Currency.USD)
assert an_amount.amount == Decimal(10.55).quantize(DEFAULT_MONEY_PRECISION)
def test_money_amount_fails_with_ugly_strings():
with pytest.raises(ValueError):
MoneyAmount(amount="not a nuuuuumber", currency=Currency.USD)
def test_money_amount_takes_string_for_currency():
an_amount = MoneyAmount(amount="10.55", currency="USD")
assert an_amount.currency == Currency.USD
def test_money_amount_works_with_8_decimal_positions():
an_amount = MoneyAmount(amount="1.12345678", currency="USD")
assert an_amount.amount == Decimal(1.12345678).quantize(DEFAULT_MONEY_PRECISION)

View file

@ -3,8 +3,9 @@ from decimal import Decimal
from numbers import Number from numbers import Number
from typing import Iterable, Set, Union from typing import Iterable, Set, Union
from money.currency import Currency, CurrencyHelper from money.currency import Currency
from money.money import Money
from xexe.money_amount import DEFAULT_MONEY_PRECISION_POSITIONS, MoneyAmount
class ExchangeRate: class ExchangeRate:
@ -13,13 +14,13 @@ class ExchangeRate:
self, self,
from_currency: Currency, from_currency: Currency,
to_currency: Currency, to_currency: Currency,
rate: Union[Money, Number, str], rate: Union[MoneyAmount, Number, str],
rate_date: datetime.date, rate_date: datetime.date,
) -> None: ) -> None:
self.from_currency = from_currency self.from_currency = from_currency
self.to_currency = to_currency self.to_currency = to_currency
if not isinstance(rate, Money): if not isinstance(rate, MoneyAmount):
rate = Money(rate, to_currency) rate = MoneyAmount(amount=rate, currency=to_currency)
self.rate = rate self.rate = rate
self.rate_date = rate_date self.rate_date = rate_date
@ -100,7 +101,7 @@ def add_equal_rates(rates: ExchangeRates, overwrite: bool = False) -> ExchangeRa
new_rate = ExchangeRate( new_rate = ExchangeRate(
from_currency=currency, from_currency=currency,
to_currency=currency, to_currency=currency,
rate=Money(1, currency), rate=MoneyAmount(1, currency),
rate_date=date, rate_date=date,
) )
if new_rate in rates and not overwrite: if new_rate in rates and not overwrite:
@ -122,7 +123,7 @@ def add_inverse_rates(rates: ExchangeRates) -> ExchangeRates:
from_currency=rate.to_currency, from_currency=rate.to_currency,
to_currency=rate.from_currency, to_currency=rate.from_currency,
rate_date=rate.rate_date, rate_date=rate.rate_date,
rate=f"{1 / rate.amount:.{CurrencyHelper.decimal_precision_for_currency(rate.from_currency)}f}", rate=f"{1 / rate.amount:.{DEFAULT_MONEY_PRECISION_POSITIONS}f}",
) )
rates.add_rate(inverse_rate) rates.add_rate(inverse_rate)

45
xexe/money_amount.py Normal file
View file

@ -0,0 +1,45 @@
from decimal import Decimal, InvalidOperation
from typing import Union
from money.currency import Currency
DEFAULT_MONEY_PRECISION_POSITIONS = 8
DEFAULT_MONEY_PRECISION = Decimal(
"0." + ("0" * (DEFAULT_MONEY_PRECISION_POSITIONS - 1)) + "1"
)
# If we have X decimal positions, we want 7 zeros and 1 one
# i.e. 0. 000 000 01
class MoneyAmount:
def __init__(
self, amount: Union[int, Decimal, str], currency: Union[str, Currency]
) -> "MoneyAmount":
self.amount = self._parse_amount(amount)
self.currency = self._parse_currency(currency)
def _parse_amount(self, amount: Union[int, Decimal, str]) -> Decimal:
if isinstance(amount, (int, Decimal)):
return Decimal(amount).quantize(DEFAULT_MONEY_PRECISION)
elif isinstance(amount, str):
try:
return Decimal(amount).quantize(DEFAULT_MONEY_PRECISION)
except InvalidOperation:
raise ValueError(f"Invalid amount: {amount}")
else:
raise TypeError(f"Amount must be int, Decimal, or str, not {type(amount)}")
def _parse_currency(self, currency: Union[str, Currency]) -> Currency:
if isinstance(currency, Currency):
return currency
if isinstance(currency, str):
return Currency[currency]
raise TypeError(
f"Currency must be a Currency object or str, not {type(currency)}"
)
def __eq__(self, other):
if isinstance(other, MoneyAmount):
return self.amount == other.amount and self.currency == other.currency
return False

View file

@ -1,13 +1,12 @@
import datetime import datetime
import os import os
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from decimal import Decimal
from money.currency import Currency, CurrencyHelper from money.currency import Currency
from money.money import Money
from xecd_rates_client import XecdClient from xecd_rates_client import XecdClient
from xexe.exchange_rates import ExchangeRate from xexe.exchange_rates import ExchangeRate
from xexe.money_amount import DEFAULT_MONEY_PRECISION_POSITIONS, MoneyAmount
class RateFetcher(ABC): class RateFetcher(ABC):
@ -35,7 +34,7 @@ class MockRateFetcher(RateFetcher):
return ExchangeRate( return ExchangeRate(
from_currency=from_currency, from_currency=from_currency,
to_currency=to_currency, to_currency=to_currency,
rate=Money(42, to_currency), rate=MoneyAmount(42, to_currency),
rate_date=rate_date, rate_date=rate_date,
) )
@ -71,7 +70,7 @@ class XERateFetcher(RateFetcher):
"Z", "+00:00" "Z", "+00:00"
) # Funny replace is necessary because of API response format ) # Funny replace is necessary because of API response format
).date() ).date()
rate = f"""{response["to"][0]["mid"]:.{CurrencyHelper.decimal_precision_for_currency(to_currency)}f}""" rate = f"""{response["to"][0]["mid"]:.{DEFAULT_MONEY_PRECISION_POSITIONS}f}"""
return ExchangeRate( return ExchangeRate(
from_currency=from_currency, from_currency=from_currency,

View file

@ -142,7 +142,7 @@ class DWHRateWriter(RateWriter):
CREATE TABLE IF NOT EXISTS {}.{} ( CREATE TABLE IF NOT EXISTS {}.{} (
from_currency CHAR(3) NOT NULL, from_currency CHAR(3) NOT NULL,
to_currency CHAR(3) NOT NULL, to_currency CHAR(3) NOT NULL,
rate DECIMAL(19, 4) NOT NULL, rate DECIMAL(23, 8) NOT NULL,
rate_date_utc DATE NOT NULL, rate_date_utc DATE NOT NULL,
exported_at_utc TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, exported_at_utc TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (from_currency, to_currency, rate_date_utc) PRIMARY KEY (from_currency, to_currency, rate_date_utc)