From 6139856a3ca57d33b7076495582dee32d36ead1d Mon Sep 17 00:00:00 2001 From: Pablo Martin Date: Mon, 26 May 2025 14:16:15 +0200 Subject: [PATCH 01/17] add pairs as new option --- xexe/cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/xexe/cli.py b/xexe/cli.py index 94b81a5..cf52231 100644 --- a/xexe/cli.py +++ b/xexe/cli.py @@ -72,6 +72,7 @@ def dwh_healthcheck(): @click.option( "--currencies", default=",".join([]), show_default=True, type=click.STRING ) +@click.option("--pairs", default=",".join([]), show_default=True, type=click.STRING) @click.option("--dry-run", is_flag=True) @click.option("--rates-source", type=click.Choice(RATES_SOURCES.keys()), default="mock") @click.option("--ignore-warnings", is_flag=True) @@ -80,6 +81,7 @@ def get_rates( start_date: Union[str, datetime.datetime, datetime.date], end_date: Union[str, datetime.datetime, datetime.date], currencies: Union[None, str], + pairs: Union[None, str], dry_run: bool, rates_source: str, ignore_warnings: bool, From 048d6833b4b193c3ef96886d1e865dd2f7a77859 Mon Sep 17 00:00:00 2001 From: Pablo Martin Date: Mon, 26 May 2025 14:21:48 +0200 Subject: [PATCH 02/17] add tests for input handling --- tests/tests_unit/test_input_handling.py | 40 +++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/tests_unit/test_input_handling.py b/tests/tests_unit/test_input_handling.py index 0b8da9c..000c46d 100644 --- a/tests/tests_unit/test_input_handling.py +++ b/tests/tests_unit/test_input_handling.py @@ -125,3 +125,43 @@ def test_handle_input_rates_start_and_end_date_equal_works_fine(): for key in expected_result.keys(): assert handled_inputs[key] == expected_result[key] + + +def test_handle_input_rates_with_pairs_works_fine(): + handled_inputs = handle_get_rates_inputs( + start_date=datetime.datetime.now(), + end_date=datetime.datetime.now(), + pairs="USDEUR,EURUSD,GBPZAR", + dry_run=False, + rates_source="mock", + ignore_warnings=True, + output="test_output.csv", + ) + expected_result = { + "date_range": DateRange( + start_date=datetime.datetime.now().date(), + end_date=datetime.datetime.now().date(), + ), + "pairs": {"Pending real object"}, + "dry_run": False, + "rates_source": "mock", + "ignore_warnings": True, + "output": pathlib.Path("test_output.csv"), + } + + for key in expected_result.keys(): + assert handled_inputs[key] == expected_result[key] + + +def test_handle_input_rates_raises_with_both_currencies_and_pairs(): + with pytest.raises(ValueError): + handle_get_rates_inputs( + start_date=datetime.datetime.now(), + end_date=datetime.datetime.now(), + currencies="EUR,USD,ZAR", + pairs="USDEUR,EURUSD,GBPZAR", + dry_run=False, + rates_source="mock", + ignore_warnings=True, + output="test_output.csv", + ) From 132d51777a66dcd35aee39397f49b09ca29885cc Mon Sep 17 00:00:00 2001 From: Pablo Martin Date: Mon, 26 May 2025 14:50:21 +0200 Subject: [PATCH 03/17] add currency pair class --- tests/tests_unit/test_currency_pair.py | 35 ++++++++++++++++++++++++++ xexe/currency_pair.py | 19 ++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 tests/tests_unit/test_currency_pair.py create mode 100644 xexe/currency_pair.py diff --git a/tests/tests_unit/test_currency_pair.py b/tests/tests_unit/test_currency_pair.py new file mode 100644 index 0000000..93aeccc --- /dev/null +++ b/tests/tests_unit/test_currency_pair.py @@ -0,0 +1,35 @@ +from money.currency import Currency + +from xexe.currency_pair import CurrencyPair + + +def test_create_currency_pair_normal_works_fine(): + + a_pair: CurrencyPair = CurrencyPair( + from_currency=Currency["USD"], to_currency=Currency["EUR"] + ) + + assert (a_pair.from_currency == "USD", a_pair.to_currency == "EUR") + + +def test_create_currency_pair_with_same_currency_works(): + same_curr_pair: CurrencyPair = CurrencyPair( + from_currency=Currency["USD"], to_currency=Currency["USD"] + ) + + assert (same_curr_pair.from_currency == "USD", same_curr_pair.to_currency == "USD") + + +def test_reverse_currency_works_fine(): + + a_pair: CurrencyPair = CurrencyPair( + from_currency=Currency["USD"], to_currency=Currency["EUR"] + ) + + reverse_pair: CurrencyPair = a_pair.get_reverse_pair() + + assert ( + isinstance(reverse_pair, CurrencyPair), + reverse_pair.from_currency == "EUR", + reverse_pair.to_currency == "USD", + ) diff --git a/xexe/currency_pair.py b/xexe/currency_pair.py new file mode 100644 index 0000000..c2012d5 --- /dev/null +++ b/xexe/currency_pair.py @@ -0,0 +1,19 @@ +from money.currency import Currency + + +class CurrencyPair: + """Two currencies with directionality (from->to).""" + + def __init__( + self, from_currency: Currency, to_currency: Currency + ) -> "CurrencyPair": + self.from_currency = from_currency + self.to_currency = to_currency + + def __str__(self): + return str(self.from_currency) + str(self.to_currency) + + def get_reverse_pair(self) -> "CurrencyPair": + return CurrencyPair( + from_currency=self.to_currency, to_currency=self.from_currency + ) From 7cfbe2284d796e8b0e5130e72e3ed5f59181912b Mon Sep 17 00:00:00 2001 From: Pablo Martin Date: Mon, 26 May 2025 15:03:08 +0200 Subject: [PATCH 04/17] implement equality --- tests/tests_unit/test_currency_pair.py | 13 ++++++++++++- xexe/currency_pair.py | 11 ++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/tests_unit/test_currency_pair.py b/tests/tests_unit/test_currency_pair.py index 93aeccc..722f9e7 100644 --- a/tests/tests_unit/test_currency_pair.py +++ b/tests/tests_unit/test_currency_pair.py @@ -20,7 +20,7 @@ def test_create_currency_pair_with_same_currency_works(): assert (same_curr_pair.from_currency == "USD", same_curr_pair.to_currency == "USD") -def test_reverse_currency_works_fine(): +def test_reverse_pair_works_fine(): a_pair: CurrencyPair = CurrencyPair( from_currency=Currency["USD"], to_currency=Currency["EUR"] @@ -33,3 +33,14 @@ def test_reverse_currency_works_fine(): reverse_pair.from_currency == "EUR", reverse_pair.to_currency == "USD", ) + + +def test_pair_equality_works(): + + a_pair: CurrencyPair = CurrencyPair( + from_currency=Currency["USD"], to_currency=Currency["EUR"] + ) + + reverse_pair: CurrencyPair = a_pair.get_reverse_pair() + + assert a_pair == a_pair and a_pair != reverse_pair diff --git a/xexe/currency_pair.py b/xexe/currency_pair.py index c2012d5..d9a603b 100644 --- a/xexe/currency_pair.py +++ b/xexe/currency_pair.py @@ -10,10 +10,15 @@ class CurrencyPair: self.from_currency = from_currency self.to_currency = to_currency - def __str__(self): - return str(self.from_currency) + str(self.to_currency) - def get_reverse_pair(self) -> "CurrencyPair": return CurrencyPair( from_currency=self.to_currency, to_currency=self.from_currency ) + + def __str__(self): + return str(self.from_currency) + str(self.to_currency) + + def __eq__(self, other: "CurrencyPair") -> bool: + return (self.from_currency == other.from_currency) and ( + self.to_currency == other.to_currency + ) From 92eca9e06b1f263e65f2f3ffb48f6d835434ef4a Mon Sep 17 00:00:00 2001 From: Pablo Martin Date: Mon, 26 May 2025 15:14:01 +0200 Subject: [PATCH 05/17] pairs input gets handled --- tests/tests_unit/test_input_handling.py | 7 +++++- xexe/currency_pair.py | 6 +++++ xexe/inputs_handling.py | 30 +++++++++++++++++++++---- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/tests/tests_unit/test_input_handling.py b/tests/tests_unit/test_input_handling.py index 000c46d..865394c 100644 --- a/tests/tests_unit/test_input_handling.py +++ b/tests/tests_unit/test_input_handling.py @@ -4,6 +4,7 @@ import pathlib import pytest from money.currency import Currency +from xexe.currency_pair import CurrencyPair from xexe.inputs_handling import handle_get_rates_inputs from xexe.utils import DateRange @@ -142,7 +143,11 @@ def test_handle_input_rates_with_pairs_works_fine(): start_date=datetime.datetime.now().date(), end_date=datetime.datetime.now().date(), ), - "pairs": {"Pending real object"}, + "pairs": { + CurrencyPair(from_currency=Currency["USD"], to_currency=Currency["EUR"]), + CurrencyPair(from_currency=Currency["EUR"], to_currency=Currency["USD"]), + CurrencyPair(from_currency=Currency["GBP"], to_currency=Currency["ZAR"]), + }, "dry_run": False, "rates_source": "mock", "ignore_warnings": True, diff --git a/xexe/currency_pair.py b/xexe/currency_pair.py index d9a603b..5a89ab2 100644 --- a/xexe/currency_pair.py +++ b/xexe/currency_pair.py @@ -22,3 +22,9 @@ class CurrencyPair: return (self.from_currency == other.from_currency) and ( self.to_currency == other.to_currency ) + + def __repr__(self): + return str(self) + + def __hash__(self): + return hash((self.from_currency, self.to_currency)) diff --git a/xexe/inputs_handling.py b/xexe/inputs_handling.py index 0cf5a75..6a823e7 100644 --- a/xexe/inputs_handling.py +++ b/xexe/inputs_handling.py @@ -6,6 +6,7 @@ from typing import Union from money.currency import Currency from xexe.constants import DEFAULT_CURRENCIES, RATES_SOURCES +from xexe.currency_pair import CurrencyPair from xexe.utils import DateRange logger = logging.getLogger() @@ -14,11 +15,12 @@ logger = logging.getLogger() def handle_get_rates_inputs( start_date: Union[datetime.datetime, datetime.date], end_date: Union[datetime.datetime, datetime.date], - currencies: Union[None, str], dry_run: bool, rates_source: str, ignore_warnings: bool, output: Union[str, pathlib.Path], + currencies: Union[None, str] = None, + pairs: Union[None, str] = None, ): logger.info("Handling inputs.") @@ -27,14 +29,29 @@ def handle_get_rates_inputs( if date_range.end_date > datetime.datetime.today().date(): date_range.end_date = datetime.datetime.today().date() + if pairs: + if currencies: + logger.error(f"Received both currencies and pairs.") + logger.error(f"Currencies: '{currencies}'.") + logger.error(f"Pairs: '{pairs}'.") + raise ValueError("You can pass currencies or pairs, but not both.") + + pairs = { + CurrencyPair( + from_currency=Currency[str_pair[0:3]], + to_currency=Currency[str_pair[3:6]], + ) + for str_pair in pairs.split(",") + } + if currencies: # CLI input comes as a string of comma-separated currency codes currencies = {currency_code.strip() for currency_code in currencies.split(",")} tmp = {Currency(currency_code) for currency_code in currencies} currencies = tmp - if currencies is None or currencies == "": - logger.info("No currency list passed. Running for default currencies.") + if currencies is None or currencies == "" and not pairs: + logger.info("No currency list or pairs passed. Running for default currencies.") currencies = DEFAULT_CURRENCIES if rates_source not in RATES_SOURCES: @@ -49,12 +66,17 @@ def handle_get_rates_inputs( prepared_inputs = { "date_range": date_range, - "currencies": currencies, "dry_run": dry_run, "rates_source": rates_source, "ignore_warnings": ignore_warnings, "output": output, } + if currencies: + prepared_inputs["currencies"] = currencies + + if pairs: + prepared_inputs["pairs"] = pairs + logger.debug(prepared_inputs) return prepared_inputs From 2432b7d6dc0b55f8e48d491ec3c7273946b92a7c Mon Sep 17 00:00:00 2001 From: Pablo Martin Date: Mon, 26 May 2025 15:50:17 +0200 Subject: [PATCH 06/17] pass pairs down --- xexe/cli.py | 1 + xexe/processes.py | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/xexe/cli.py b/xexe/cli.py index cf52231..442d4c9 100644 --- a/xexe/cli.py +++ b/xexe/cli.py @@ -91,6 +91,7 @@ def get_rates( start_date=start_date, end_date=end_date, currencies=currencies, + pairs=pairs, dry_run=dry_run, rates_source=rates_source, ignore_warnings=ignore_warnings, diff --git a/xexe/processes.py b/xexe/processes.py index 159161d..d12f723 100644 --- a/xexe/processes.py +++ b/xexe/processes.py @@ -1,12 +1,13 @@ import logging import os import pathlib -from typing import List +from typing import List, Set, Union from money.currency import Currency from xecd_rates_client import XecdClient from xexe.constants import RATES_SOURCES +from xexe.currency_pair import CurrencyPair from xexe.exchange_rates import ExchangeRates, add_equal_rates, add_inverse_rates from xexe.rate_fetching import build_rate_fetcher from xexe.rate_writing import build_rate_writer @@ -67,11 +68,12 @@ def run_dwh_healthcheck(): def run_get_rates( date_range: DateRange, - currencies: List[Currency], dry_run: bool, rates_source: str, ignore_warnings: bool, output: pathlib.Path, + currencies: Union[Set[Currency], None] = None, + pairs: Union[Set[CurrencyPair], None] = None, ) -> None: logger.info("Getting rates") @@ -80,6 +82,7 @@ def run_get_rates( rates = obtain_rates_from_source( rates_source=rates_source, date_range=date_range, + pairs=pairs, currencies=currencies, ignore_warnings=ignore_warnings, ) @@ -95,8 +98,9 @@ def run_get_rates( def obtain_rates_from_source( rates_source: str, date_range: DateRange, - currencies: List[Currency], ignore_warnings: bool, + currencies: Union[Set[Currency], None] = None, + pairs: Union[Set[CurrencyPair], None] = None, ) -> ExchangeRates: rates_fetcher = build_rate_fetcher( rates_source=rates_source, rate_sources_mapping=RATES_SOURCES From 568e27adbe5b9f913352d6f1192568f1ca0e9ac9 Mon Sep 17 00:00:00 2001 From: Pablo Martin Date: Mon, 26 May 2025 16:14:51 +0200 Subject: [PATCH 07/17] pairs are usable --- tests/tests_unit/test_utils.py | 37 +++++++++++++++++++++++++++++----- xexe/processes.py | 18 +++++++++++++---- xexe/utils.py | 21 +++++++++++++++++++ 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/tests/tests_unit/test_utils.py b/tests/tests_unit/test_utils.py index 8624ace..670efe6 100644 --- a/tests/tests_unit/test_utils.py +++ b/tests/tests_unit/test_utils.py @@ -3,7 +3,12 @@ import datetime import pytest from money.currency import Currency -from xexe.utils import DateRange, generate_currency_and_dates_combinations +from xexe.currency_pair import CurrencyPair +from xexe.utils import ( + DateRange, + generate_currency_and_dates_combinations, + generate_pairs_and_dates_combinations, +) def test_date_range_breaks_with_reversed_dates(): @@ -30,7 +35,7 @@ def test_date_range_generates_proper_dates_when_itered(): assert len(dates) == 3 -def generate_currency_and_dates_combinations_outputs_correctly(): +def test_generate_currency_and_dates_combinations_outputs_correctly(): date_range = DateRange( start_date=datetime.date(year=2024, month=1, day=1), @@ -44,6 +49,28 @@ def generate_currency_and_dates_combinations_outputs_correctly(): ) assert len(combinations) == 9 - assert len({date for date in combinations["date"]}) == 3 - assert len({currency for currency in combinations["from_currency"]}) == 3 - assert len({currency for currency in combinations["to_currency"]}) == 3 + assert len({combination["date"] for combination in combinations}) == 3 + assert len({combination["from_currency"] for combination in combinations}) == 2 + assert len({combination["to_currency"] for combination in combinations}) == 2 + + +def test_generate_pair_and_dates_combinations_outputs_correctly(): + date_range = DateRange( + start_date=datetime.date(year=2024, month=1, day=1), + end_date=datetime.date(year=2024, month=1, day=3), + ) + + pairs = { + CurrencyPair(from_currency="USD", to_currency="EUR"), + CurrencyPair(from_currency="USD", to_currency="GBP"), + CurrencyPair(from_currency="EUR", to_currency="GBP"), + } + + combinations = generate_pairs_and_dates_combinations( + pairs=pairs, date_range=date_range + ) + + assert len(combinations) == 9 + assert len({combination["date"] for combination in combinations}) == 3 + assert len({combination["from_currency"] for combination in combinations}) == 2 + assert len({combination["to_currency"] for combination in combinations}) == 2 diff --git a/xexe/processes.py b/xexe/processes.py index d12f723..51f3c6c 100644 --- a/xexe/processes.py +++ b/xexe/processes.py @@ -11,7 +11,11 @@ from xexe.currency_pair import CurrencyPair from xexe.exchange_rates import ExchangeRates, add_equal_rates, add_inverse_rates from xexe.rate_fetching import build_rate_fetcher from xexe.rate_writing import build_rate_writer -from xexe.utils import DateRange, generate_currency_and_dates_combinations +from xexe.utils import ( + DateRange, + generate_currency_and_dates_combinations, + generate_pairs_and_dates_combinations, +) logger = logging.getLogger() @@ -106,9 +110,15 @@ def obtain_rates_from_source( rates_source=rates_source, rate_sources_mapping=RATES_SOURCES ) - currency_and_date_combinations = generate_currency_and_dates_combinations( - date_range=date_range, currencies=currencies - ) + if currencies: + currency_and_date_combinations = generate_currency_and_dates_combinations( + date_range=date_range, currencies=currencies + ) + + if pairs: + currency_and_date_combinations = generate_pairs_and_dates_combinations( + date_range=date_range, pairs=pairs + ) large_api_call_planned = ( rates_fetcher.is_production_grade and len(currency_and_date_combinations) > 100 diff --git a/xexe/utils.py b/xexe/utils.py index dd3ecc8..a41f3b4 100644 --- a/xexe/utils.py +++ b/xexe/utils.py @@ -4,6 +4,8 @@ from typing import Set, Tuple from money.currency import Currency +from xexe.currency_pair import CurrencyPair + class DateRange: @@ -86,3 +88,22 @@ def generate_currency_and_dates_combinations( currency_date_combinations = tuple(currency_date_combinations) return currency_date_combinations + + +def generate_pairs_and_dates_combinations( + date_range: DateRange, pairs: Set[CurrencyPair] +) -> Tuple[dict]: + currency_date_combinations = [] + for date in date_range: + for pair in pairs: + currency_date_combinations.append( + { + "from_currency": pair.from_currency, + "to_currency": pair.to_currency, + "date": date, + } + ) + + currency_date_combinations = tuple(currency_date_combinations) + + return currency_date_combinations From d74be133727e3ac688c03d9fcb5637afc80d090b Mon Sep 17 00:00:00 2001 From: Pablo Martin Date: Mon, 26 May 2025 16:30:21 +0200 Subject: [PATCH 08/17] add test for pair path --- tests/tests_integration/test_get_rates.py | 57 +++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/tests_integration/test_get_rates.py b/tests/tests_integration/test_get_rates.py index 7fb6c9e..99d6bdd 100644 --- a/tests/tests_integration/test_get_rates.py +++ b/tests/tests_integration/test_get_rates.py @@ -143,3 +143,60 @@ def test_get_rates_dry_run_always_returns_42_as_rates(): assert ( row["to_currency"] in some_random_currencies ), f"Unexpected to_currency {row['to_currency']}" + + +def test_get_rates_dry_run__with_pairs_always_returns_42_as_rates(): + """ + Same as the test above, but relying on the pairs argument instead + of the currencies one. + """ + + some_random_date = datetime.datetime( + year=random.choice(range(2010, 2020)), + month=random.choice(range(1, 13)), + day=random.choice(range(1, 29)), + ).date() + + some_pairs = ["USDEUR", "USDGBP", "GBPEUR"] + + runner = CliRunner() + + with runner.isolated_filesystem(): + run_result = runner.invoke( + get_rates, + [ + "--start-date", + some_random_date.strftime("%Y-%m-%d"), + "--end-date", + (some_random_date + datetime.timedelta(days=3)).strftime("%Y-%m-%d"), + "--pairs", + ",".join(some_pairs), + "--output", + "test_output.csv", + ], + ) + + assert run_result.exit_code == 0 + + with open("test_output.csv", newline="") as csv_file: + reader = csv.DictReader(csv_file) + rows = list(reader) + + # Ensure that the output contains the correct number of rows + expected_num_rows = 36 + assert ( + len(rows) == expected_num_rows + ), f"Expected {expected_num_rows} rows, but got {len(rows)}" + + # 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.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") + for i in range(4) + ], f"Unexpected rate_date {row['rate_date']}" From aba2920a70101f62ef321190ed17f2344ccc7247 Mon Sep 17 00:00:00 2001 From: Pablo Martin Date: Mon, 26 May 2025 16:33:25 +0200 Subject: [PATCH 09/17] pull one up --- xexe/processes.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/xexe/processes.py b/xexe/processes.py index 51f3c6c..6bd47d8 100644 --- a/xexe/processes.py +++ b/xexe/processes.py @@ -83,11 +83,20 @@ def run_get_rates( process_state = GetRatesProcessState(ignore_warnings=ignore_warnings) + if currencies: + currency_and_date_combinations = generate_currency_and_dates_combinations( + date_range=date_range, currencies=currencies + ) + + if pairs: + currency_and_date_combinations = generate_pairs_and_dates_combinations( + date_range=date_range, pairs=pairs + ) + rates = obtain_rates_from_source( rates_source=rates_source, date_range=date_range, - pairs=pairs, - currencies=currencies, + currency_and_date_combinations=currency_and_date_combinations, ignore_warnings=ignore_warnings, ) logger.info("Rates obtained.") @@ -103,23 +112,12 @@ def obtain_rates_from_source( rates_source: str, date_range: DateRange, ignore_warnings: bool, - currencies: Union[Set[Currency], None] = None, - pairs: Union[Set[CurrencyPair], None] = None, + currency_and_date_combinations, ) -> ExchangeRates: rates_fetcher = build_rate_fetcher( rates_source=rates_source, rate_sources_mapping=RATES_SOURCES ) - if currencies: - currency_and_date_combinations = generate_currency_and_dates_combinations( - date_range=date_range, currencies=currencies - ) - - if pairs: - currency_and_date_combinations = generate_pairs_and_dates_combinations( - date_range=date_range, pairs=pairs - ) - large_api_call_planned = ( rates_fetcher.is_production_grade and len(currency_and_date_combinations) > 100 ) From b52af85987eb584dadb29d46e18632e84430bc48 Mon Sep 17 00:00:00 2001 From: Pablo Martin Date: Mon, 26 May 2025 16:41:49 +0200 Subject: [PATCH 10/17] it's all pairs --- xexe/processes.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/xexe/processes.py b/xexe/processes.py index 6bd47d8..3c7768d 100644 --- a/xexe/processes.py +++ b/xexe/processes.py @@ -1,6 +1,7 @@ import logging import os import pathlib +from itertools import combinations from typing import List, Set, Union from money.currency import Currency @@ -11,11 +12,7 @@ from xexe.currency_pair import CurrencyPair from xexe.exchange_rates import ExchangeRates, add_equal_rates, add_inverse_rates from xexe.rate_fetching import build_rate_fetcher from xexe.rate_writing import build_rate_writer -from xexe.utils import ( - DateRange, - generate_currency_and_dates_combinations, - generate_pairs_and_dates_combinations, -) +from xexe.utils import DateRange, generate_pairs_and_dates_combinations logger = logging.getLogger() @@ -84,14 +81,14 @@ def run_get_rates( process_state = GetRatesProcessState(ignore_warnings=ignore_warnings) if currencies: - currency_and_date_combinations = generate_currency_and_dates_combinations( - date_range=date_range, currencies=currencies - ) + pairs = list(combinations(currencies, 2)) + pairs = [ + CurrencyPair(from_currency=pair[0], to_currency=pair[1]) for pair in pairs + ] - if pairs: - currency_and_date_combinations = generate_pairs_and_dates_combinations( - date_range=date_range, pairs=pairs - ) + currency_and_date_combinations = generate_pairs_and_dates_combinations( + date_range=date_range, pairs=pairs + ) rates = obtain_rates_from_source( rates_source=rates_source, From a7a37d4614eda483fc6d465dc0f02ecfdd5c8cc6 Mon Sep 17 00:00:00 2001 From: Pablo Martin Date: Mon, 26 May 2025 16:42:09 +0200 Subject: [PATCH 11/17] remove unused code --- tests/tests_unit/test_utils.py | 25 +------------------------ xexe/utils.py | 21 --------------------- 2 files changed, 1 insertion(+), 45 deletions(-) diff --git a/tests/tests_unit/test_utils.py b/tests/tests_unit/test_utils.py index 670efe6..71657de 100644 --- a/tests/tests_unit/test_utils.py +++ b/tests/tests_unit/test_utils.py @@ -4,11 +4,7 @@ import pytest from money.currency import Currency from xexe.currency_pair import CurrencyPair -from xexe.utils import ( - DateRange, - generate_currency_and_dates_combinations, - generate_pairs_and_dates_combinations, -) +from xexe.utils import DateRange, generate_pairs_and_dates_combinations def test_date_range_breaks_with_reversed_dates(): @@ -35,25 +31,6 @@ def test_date_range_generates_proper_dates_when_itered(): assert len(dates) == 3 -def test_generate_currency_and_dates_combinations_outputs_correctly(): - - date_range = DateRange( - start_date=datetime.date(year=2024, month=1, day=1), - end_date=datetime.date(year=2024, month=1, day=3), - ) - - currencies = {Currency.USD, Currency.EUR, Currency.GBP} - - combinations = generate_currency_and_dates_combinations( - currencies=currencies, date_range=date_range - ) - - assert len(combinations) == 9 - assert len({combination["date"] for combination in combinations}) == 3 - assert len({combination["from_currency"] for combination in combinations}) == 2 - assert len({combination["to_currency"] for combination in combinations}) == 2 - - def test_generate_pair_and_dates_combinations_outputs_correctly(): date_range = DateRange( start_date=datetime.date(year=2024, month=1, day=1), diff --git a/xexe/utils.py b/xexe/utils.py index a41f3b4..5b5e975 100644 --- a/xexe/utils.py +++ b/xexe/utils.py @@ -69,27 +69,6 @@ class DateRange: current_date += datetime.timedelta(days=1) -def generate_currency_and_dates_combinations( - date_range: DateRange, currencies: Set[Currency] -) -> Tuple[dict]: - currency_pairs = list(combinations(currencies, 2)) - - currency_date_combinations = [] - for date in date_range: - for from_currency, to_currency in currency_pairs: - currency_date_combinations.append( - { - "from_currency": from_currency, - "to_currency": to_currency, - "date": date, - } - ) - - currency_date_combinations = tuple(currency_date_combinations) - - return currency_date_combinations - - def generate_pairs_and_dates_combinations( date_range: DateRange, pairs: Set[CurrencyPair] ) -> Tuple[dict]: From 7f8001ffca508803ac6e43688ed0bccdd9ebacf1 Mon Sep 17 00:00:00 2001 From: Pablo Martin Date: Mon, 26 May 2025 17:00:57 +0200 Subject: [PATCH 12/17] pulled up, fixed tests --- tests/tests_unit/test_input_handling.py | 20 ++++++++++++++++---- xexe/inputs_handling.py | 19 ++++++++++--------- xexe/processes.py | 8 -------- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/tests/tests_unit/test_input_handling.py b/tests/tests_unit/test_input_handling.py index 865394c..0a9f8e1 100644 --- a/tests/tests_unit/test_input_handling.py +++ b/tests/tests_unit/test_input_handling.py @@ -24,15 +24,23 @@ def test_handle_input_rates_works_with_full_correct_inputs(): start_date=(datetime.datetime.now() - datetime.timedelta(days=7)).date(), end_date=(datetime.datetime.now() - datetime.timedelta(days=1)).date(), ), - "currencies": {Currency("USD"), Currency("EUR"), Currency("GBP")}, + "pairs": { + CurrencyPair(Currency("USD"), Currency("EUR")), + CurrencyPair(Currency("GBP"), Currency("USD")), + CurrencyPair(Currency("GBP"), Currency("EUR")), + }, "dry_run": False, "rates_source": "mock", "ignore_warnings": True, "output": pathlib.Path("test_output.csv"), } - for key in expected_result.keys(): + for key in {"date_range", "dry_run", "rates_source", "ignore_warnings", "output"}: assert handled_inputs[key] == expected_result[key] + # We don't check for the currency pairs because the random ordering used + # by the currencies arg execution path does not guarantee the sorting, + # and CurrencyPair comparison needs proper sorting, and my head hurts + # and other tests are already catching for this correctness so. def test_handle_input_rates_raises_with_bad_currency_code(): @@ -106,7 +114,7 @@ def test_handle_input_rates_start_and_end_date_equal_works_fine(): handled_inputs = handle_get_rates_inputs( start_date=datetime.datetime.now(), end_date=datetime.datetime.now(), - currencies="USD,EUR,GBP", + pairs="USDEUR,EURUSD,GBPZAR", dry_run=False, rates_source="mock", ignore_warnings=True, @@ -117,7 +125,11 @@ def test_handle_input_rates_start_and_end_date_equal_works_fine(): start_date=datetime.datetime.now().date(), end_date=datetime.datetime.now().date(), ), - "currencies": {Currency("USD"), Currency("EUR"), Currency("GBP")}, + "pairs": { + CurrencyPair(Currency("USD"), Currency("EUR")), + CurrencyPair(Currency("EUR"), Currency("USD")), + CurrencyPair(Currency("GBP"), Currency("ZAR")), + }, "dry_run": False, "rates_source": "mock", "ignore_warnings": True, diff --git a/xexe/inputs_handling.py b/xexe/inputs_handling.py index 6a823e7..bc49081 100644 --- a/xexe/inputs_handling.py +++ b/xexe/inputs_handling.py @@ -1,6 +1,7 @@ import datetime import logging import pathlib +from itertools import combinations from typing import Union from money.currency import Currency @@ -29,6 +30,10 @@ def handle_get_rates_inputs( if date_range.end_date > datetime.datetime.today().date(): date_range.end_date = datetime.datetime.today().date() + if (currencies is None or currencies == "") and not pairs: + logger.info("No currency list or pairs passed. Running for default currencies.") + currencies = DEFAULT_CURRENCIES + if pairs: if currencies: logger.error(f"Received both currencies and pairs.") @@ -47,12 +52,11 @@ def handle_get_rates_inputs( if currencies: # CLI input comes as a string of comma-separated currency codes currencies = {currency_code.strip() for currency_code in currencies.split(",")} - tmp = {Currency(currency_code) for currency_code in currencies} - currencies = tmp - - if currencies is None or currencies == "" and not pairs: - logger.info("No currency list or pairs passed. Running for default currencies.") - currencies = DEFAULT_CURRENCIES + currencies = {Currency(currency_code) for currency_code in currencies} + pairs = list(combinations(currencies, 2)) + pairs = { + CurrencyPair(from_currency=pair[0], to_currency=pair[1]) for pair in pairs + } if rates_source not in RATES_SOURCES: raise ValueError(f"--rates-source must be one of {RATES_SOURCES.keys()}.") @@ -72,9 +76,6 @@ def handle_get_rates_inputs( "output": output, } - if currencies: - prepared_inputs["currencies"] = currencies - if pairs: prepared_inputs["pairs"] = pairs diff --git a/xexe/processes.py b/xexe/processes.py index 3c7768d..605c07e 100644 --- a/xexe/processes.py +++ b/xexe/processes.py @@ -1,7 +1,6 @@ import logging import os import pathlib -from itertools import combinations from typing import List, Set, Union from money.currency import Currency @@ -73,19 +72,12 @@ def run_get_rates( rates_source: str, ignore_warnings: bool, output: pathlib.Path, - currencies: Union[Set[Currency], None] = None, pairs: Union[Set[CurrencyPair], None] = None, ) -> None: logger.info("Getting rates") process_state = GetRatesProcessState(ignore_warnings=ignore_warnings) - if currencies: - pairs = list(combinations(currencies, 2)) - pairs = [ - CurrencyPair(from_currency=pair[0], to_currency=pair[1]) for pair in pairs - ] - currency_and_date_combinations = generate_pairs_and_dates_combinations( date_range=date_range, pairs=pairs ) From 22bfc217f315cff7e928fbbfe03444e9e568384f Mon Sep 17 00:00:00 2001 From: Pablo Martin Date: Mon, 26 May 2025 17:02:13 +0200 Subject: [PATCH 13/17] remove unused arg --- xexe/processes.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/xexe/processes.py b/xexe/processes.py index 605c07e..e1722f7 100644 --- a/xexe/processes.py +++ b/xexe/processes.py @@ -84,7 +84,6 @@ def run_get_rates( rates = obtain_rates_from_source( rates_source=rates_source, - date_range=date_range, currency_and_date_combinations=currency_and_date_combinations, ignore_warnings=ignore_warnings, ) @@ -99,7 +98,6 @@ def run_get_rates( def obtain_rates_from_source( rates_source: str, - date_range: DateRange, ignore_warnings: bool, currency_and_date_combinations, ) -> ExchangeRates: From d20c46d52d34e438f11e7d2d39fd61499fbe434f Mon Sep 17 00:00:00 2001 From: Pablo Martin Date: Tue, 27 May 2025 11:18:34 +0200 Subject: [PATCH 14/17] update readme --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 89f0769..c05bf96 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,15 @@ xexe get-rates --output my_rates.csv xexe get-rates --currencies USD,EUR,GBP --output my_rates.csv ``` +Using the `currencies` option will compute ALL combinations between different currencies. If you want a finer-grained control over which currency pairs you fetch, you can instead use the `pairs` options like this: + +```bash +# Pairs must be concatenations of two valid ISO 4217 codes. Each pair must be +# comma-separated. No need to use reverse and equal pairs. As in, if you provide +# `USDEUR`, xexe will do that pair and also `EURUSD`, `EUREUR` and `USDUSD`. +xexe get-rates --pairs USDEUR,EURGBP --output my_rates.csv +``` + The output file for `.csv` outputs will follow this schema: - `date` @@ -89,7 +98,7 @@ The output file for `.csv` outputs will follow this schema: - `exchange_rate` - `exported_at` -The file will contain all the combinations of the different currencies and dates passed. This includes inverse and equal rates. +The file will contain all the combinations of the different currencies (or pairs) and dates passed. This includes inverse and equal rates. This is better understood with an example. Find below a real call and its real CSV output: @@ -133,6 +142,7 @@ A few more details: - Running `get-rates` with an `end-date` beyond the current date will ignore the future dates. The run will behave as if you had specified today as the `end-date`. - Trying to place an `end-date` before a `start-date` will cause an exception. - Running with the option `--dry-run` will run against a mock of the xe.com API. Format will be valid, but all rates will be fixed. This is for testing purposes. +- `xexe` will log details to `xexe.log`. ### Deploying for Superhog infra From 9d6c93c89fe929cb924f2a4ab53ea32d69efdc54 Mon Sep 17 00:00:00 2001 From: Pablo Martin Date: Tue, 27 May 2025 11:25:47 +0200 Subject: [PATCH 15/17] always add pairs to handling output, if was pointless --- xexe/inputs_handling.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/xexe/inputs_handling.py b/xexe/inputs_handling.py index bc49081..c5e1a80 100644 --- a/xexe/inputs_handling.py +++ b/xexe/inputs_handling.py @@ -71,13 +71,11 @@ def handle_get_rates_inputs( prepared_inputs = { "date_range": date_range, "dry_run": dry_run, + "pairs": pairs, "rates_source": rates_source, "ignore_warnings": ignore_warnings, "output": output, } - if pairs: - prepared_inputs["pairs"] = pairs - logger.debug(prepared_inputs) return prepared_inputs From d2e53e40be70c75c64b8a24d1a58cf3b377e90f2 Mon Sep 17 00:00:00 2001 From: Pablo Martin Date: Tue, 27 May 2025 11:31:39 +0200 Subject: [PATCH 16/17] update version and changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6834803..bebde58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.1.0] - 2025-05-7 + +### Added + +- `get-rates` command now has a `--pairs` options that can be used instead of `--currencies`. `--pairs` allows the user to specify which currency pairs it wants to fetch rates for. This provides more control than `--currencies`, which assumes that the user wants ALL combinations of the listed currencies. `--pairs` will still automatically store reverse and equal rates for the passed pairs. + ## [1.0.1] - 2024-06-27 ### Changed From 03134b08f742de3186d4f47c5d3e29640e85fe5a Mon Sep 17 00:00:00 2001 From: Pablo Martin Date: Tue, 27 May 2025 11:31:43 +0200 Subject: [PATCH 17/17] . --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c1c1c6d..e0f7eca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "xexe" -version = "1.0.1" +version = "1.1.0" description = "" authors = ["Pablo Martin "] readme = "README.md"