diff --git a/.gitignore b/.gitignore index 82f9275..7e6ff83 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6834803 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index e7616b4..c1c1c6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "xexe" -version = "1.0.0" +version = "1.0.1" description = "" authors = ["Pablo Martin "] readme = "README.md" diff --git a/tests/tests_integration/test_get_rates.py b/tests/tests_integration/test_get_rates.py index c41ec72..7fb6c9e 100644 --- a/tests/tests_integration/test_get_rates.py +++ b/tests/tests_integration/test_get_rates.py @@ -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") diff --git a/tests/tests_unit/test_exchange_rates.py b/tests/tests_unit/test_exchange_rates.py index b5080af..ae3413f 100644 --- a/tests/tests_unit/test_exchange_rates.py +++ b/tests/tests_unit/test_exchange_rates.py @@ -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(): diff --git a/tests/tests_unit/test_money_amount.py b/tests/tests_unit/test_money_amount.py new file mode 100644 index 0000000..5089cca --- /dev/null +++ b/tests/tests_unit/test_money_amount.py @@ -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) diff --git a/xexe/exchange_rates.py b/xexe/exchange_rates.py index 209acf7..ecbc9b3 100644 --- a/xexe/exchange_rates.py +++ b/xexe/exchange_rates.py @@ -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) diff --git a/xexe/money_amount.py b/xexe/money_amount.py new file mode 100644 index 0000000..f617569 --- /dev/null +++ b/xexe/money_amount.py @@ -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 diff --git a/xexe/rate_fetching.py b/xexe/rate_fetching.py index 705f0f0..37076d5 100644 --- a/xexe/rate_fetching.py +++ b/xexe/rate_fetching.py @@ -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, diff --git a/xexe/rate_writing.py b/xexe/rate_writing.py index 29f57a3..722c0bb 100644 --- a/xexe/rate_writing.py +++ b/xexe/rate_writing.py @@ -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)