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:
commit
c2c3c85af9
10 changed files with 176 additions and 33 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -160,3 +160,6 @@ cython_debug/
|
|||
# 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.
|
||||
#.idea/
|
||||
|
||||
# to avoid test files being commited
|
||||
*.csv
|
||||
15
CHANGELOG.md
Normal file
15
CHANGELOG.md
Normal 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.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "xexe"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
description = ""
|
||||
authors = ["Pablo Martin <pablo.martin@superhog.com>"]
|
||||
readme = "README.md"
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ from xexe.constants import DEFAULT_CURRENCIES
|
|||
|
||||
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
|
||||
for 2024-01-01 to 2024-01-03 returns the expected records in a CSV.
|
||||
Calling the CLI get-rates command to get the rates between CAD and GBP for
|
||||
2024-06-20 returns the expected records in a CSV.
|
||||
|
||||
ATTENTION!
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ def test_get_rates_for_hardcoded_case_returns_expected_output():
|
|||
runs.
|
||||
"""
|
||||
|
||||
""" # Unstring this to activate test
|
||||
""" # Unstring this to activate test
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
|
|
@ -35,11 +35,11 @@ def test_get_rates_for_hardcoded_case_returns_expected_output():
|
|||
get_rates,
|
||||
[
|
||||
"--start-date",
|
||||
"2024-01-01",
|
||||
"2024-06-20",
|
||||
"--end-date",
|
||||
"2024-01-02",
|
||||
"2024-06-20",
|
||||
"--currencies",
|
||||
"USD,GBP",
|
||||
"CAD,EUR",
|
||||
"--rates-source",
|
||||
"xe",
|
||||
"--output",
|
||||
|
|
@ -49,11 +49,30 @@ def test_get_rates_for_hardcoded_case_returns_expected_output():
|
|||
|
||||
assert run_result.exit_code == 0
|
||||
|
||||
# Write code here to read output file and compare it against expected
|
||||
# output
|
||||
assert False
|
||||
"""
|
||||
expected_output = [
|
||||
["from_currency", "to_currency", "rate", "rate_date", "exported_at"],
|
||||
["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
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
for row in rows:
|
||||
assert row["rate"] in (
|
||||
"42",
|
||||
"0.024",
|
||||
"0.02",
|
||||
"0",
|
||||
"1",
|
||||
"42.00000000",
|
||||
"0.02380952",
|
||||
"0.00000000",
|
||||
"1.00000000",
|
||||
), f"Expected rate to be 42, 1/42 or 1, but got {row['rate']}"
|
||||
assert row["rate_date"] in [
|
||||
(some_random_date + datetime.timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import datetime
|
|||
from decimal import Decimal
|
||||
|
||||
from money.currency import Currency
|
||||
from money.money import Money
|
||||
|
||||
from xexe.exchange_rates import (
|
||||
ExchangeRate,
|
||||
|
|
@ -27,6 +26,20 @@ def test_exchange_rate_creation_works():
|
|||
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():
|
||||
|
||||
a_rate = ExchangeRate(
|
||||
|
|
@ -237,13 +250,13 @@ def test_add_inverse_rates_returns_expected():
|
|||
a_rate = ExchangeRate(
|
||||
from_currency=Currency.EUR,
|
||||
to_currency=Currency.USD,
|
||||
rate=Decimal("1.25"),
|
||||
rate=Decimal("1.12345000"),
|
||||
rate_date=datetime.date(2020, 3, 10),
|
||||
)
|
||||
inverse_rate = ExchangeRate(
|
||||
from_currency=Currency.USD,
|
||||
to_currency=Currency.EUR,
|
||||
rate=Decimal("0.8"),
|
||||
rate=Decimal("0.89011527"),
|
||||
rate_date=datetime.date(2020, 3, 10),
|
||||
)
|
||||
|
||||
|
|
@ -256,7 +269,7 @@ def test_add_inverse_rates_returns_expected():
|
|||
assert len(rates) == 2
|
||||
|
||||
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():
|
||||
|
|
|
|||
49
tests/tests_unit/test_money_amount.py
Normal file
49
tests/tests_unit/test_money_amount.py
Normal 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)
|
||||
|
|
@ -3,8 +3,9 @@ from decimal import Decimal
|
|||
from numbers import Number
|
||||
from typing import Iterable, Set, Union
|
||||
|
||||
from money.currency import Currency, CurrencyHelper
|
||||
from money.money import Money
|
||||
from money.currency import Currency
|
||||
|
||||
from xexe.money_amount import DEFAULT_MONEY_PRECISION_POSITIONS, MoneyAmount
|
||||
|
||||
|
||||
class ExchangeRate:
|
||||
|
|
@ -13,13 +14,13 @@ class ExchangeRate:
|
|||
self,
|
||||
from_currency: Currency,
|
||||
to_currency: Currency,
|
||||
rate: Union[Money, Number, str],
|
||||
rate: Union[MoneyAmount, Number, str],
|
||||
rate_date: datetime.date,
|
||||
) -> None:
|
||||
self.from_currency = from_currency
|
||||
self.to_currency = to_currency
|
||||
if not isinstance(rate, Money):
|
||||
rate = Money(rate, to_currency)
|
||||
if not isinstance(rate, MoneyAmount):
|
||||
rate = MoneyAmount(amount=rate, currency=to_currency)
|
||||
self.rate = rate
|
||||
self.rate_date = rate_date
|
||||
|
||||
|
|
@ -100,7 +101,7 @@ def add_equal_rates(rates: ExchangeRates, overwrite: bool = False) -> ExchangeRa
|
|||
new_rate = ExchangeRate(
|
||||
from_currency=currency,
|
||||
to_currency=currency,
|
||||
rate=Money(1, currency),
|
||||
rate=MoneyAmount(1, currency),
|
||||
rate_date=date,
|
||||
)
|
||||
if new_rate in rates and not overwrite:
|
||||
|
|
@ -122,7 +123,7 @@ def add_inverse_rates(rates: ExchangeRates) -> ExchangeRates:
|
|||
from_currency=rate.to_currency,
|
||||
to_currency=rate.from_currency,
|
||||
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)
|
||||
|
||||
|
|
|
|||
45
xexe/money_amount.py
Normal file
45
xexe/money_amount.py
Normal 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
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
import datetime
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from decimal import Decimal
|
||||
|
||||
from money.currency import Currency, CurrencyHelper
|
||||
from money.money import Money
|
||||
from money.currency import Currency
|
||||
from xecd_rates_client import XecdClient
|
||||
|
||||
from xexe.exchange_rates import ExchangeRate
|
||||
from xexe.money_amount import DEFAULT_MONEY_PRECISION_POSITIONS, MoneyAmount
|
||||
|
||||
|
||||
class RateFetcher(ABC):
|
||||
|
|
@ -35,7 +34,7 @@ class MockRateFetcher(RateFetcher):
|
|||
return ExchangeRate(
|
||||
from_currency=from_currency,
|
||||
to_currency=to_currency,
|
||||
rate=Money(42, to_currency),
|
||||
rate=MoneyAmount(42, to_currency),
|
||||
rate_date=rate_date,
|
||||
)
|
||||
|
||||
|
|
@ -71,7 +70,7 @@ class XERateFetcher(RateFetcher):
|
|||
"Z", "+00:00"
|
||||
) # Funny replace is necessary because of API response format
|
||||
).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(
|
||||
from_currency=from_currency,
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ class DWHRateWriter(RateWriter):
|
|||
CREATE TABLE IF NOT EXISTS {}.{} (
|
||||
from_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,
|
||||
exported_at_utc TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (from_currency, to_currency, rate_date_utc)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue