Switch FX rates to Frankfurter
This commit is contained in:
parent
a369c44ae9
commit
fc82cf280a
2 changed files with 184 additions and 12 deletions
132
agenda/fx.py
132
agenda/fx.py
|
|
@ -11,6 +11,7 @@ import httpx
|
||||||
|
|
||||||
DEFAULT_FX_CACHE_TTL_HOURS = 24
|
DEFAULT_FX_CACHE_TTL_HOURS = 24
|
||||||
DEFAULT_FX_FAILURE_RETRY_HOURS = 24
|
DEFAULT_FX_FAILURE_RETRY_HOURS = 24
|
||||||
|
FRANKFURTER_CACHE_PREFIX = "frankfurter"
|
||||||
|
|
||||||
|
|
||||||
async def get_gbpusd(config: flask.config.Config) -> Decimal:
|
async def get_gbpusd(config: flask.config.Config) -> Decimal:
|
||||||
|
|
@ -57,13 +58,47 @@ def read_cached_rates(
|
||||||
with open(filename) as file:
|
with open(filename) as file:
|
||||||
data = json.load(file, parse_float=Decimal)
|
data = json.load(file, parse_float=Decimal)
|
||||||
|
|
||||||
quotes = data.get("quotes")
|
frankfurter_rates = _frankfurter_rates(data, currencies)
|
||||||
if not isinstance(quotes, dict):
|
if frankfurter_rates:
|
||||||
|
return frankfurter_rates
|
||||||
|
|
||||||
|
if isinstance(data, list):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
return {
|
if not isinstance(data, dict):
|
||||||
cur: Decimal(quotes[f"GBP{cur}"]) for cur in currencies if f"GBP{cur}" in quotes
|
return {}
|
||||||
}
|
|
||||||
|
rates = data.get("rates")
|
||||||
|
if isinstance(rates, dict):
|
||||||
|
return {cur: Decimal(rates[cur]) for cur in currencies if cur in rates}
|
||||||
|
|
||||||
|
quotes = data.get("quotes")
|
||||||
|
if isinstance(quotes, dict):
|
||||||
|
return {
|
||||||
|
cur: Decimal(quotes[f"GBP{cur}"])
|
||||||
|
for cur in currencies
|
||||||
|
if f"GBP{cur}" in quotes
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _frankfurter_rates(data: typing.Any, currencies: list[str]) -> dict[str, Decimal]:
|
||||||
|
"""Extract rates from Frankfurter's response format."""
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
rates: dict[str, Decimal] = {}
|
||||||
|
for item in data:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
quote = item.get("quote")
|
||||||
|
rate = item.get("rate")
|
||||||
|
if isinstance(quote, str) and quote in currencies and rate is not None:
|
||||||
|
rates[quote] = Decimal(rate)
|
||||||
|
|
||||||
|
return rates
|
||||||
|
|
||||||
|
|
||||||
def _fx_cache_datetime(filename: str) -> datetime:
|
def _fx_cache_datetime(filename: str) -> datetime:
|
||||||
|
|
@ -93,8 +128,12 @@ def _latest_file(
|
||||||
return max(matching_files) if matching_files else None
|
return max(matching_files) if matching_files else None
|
||||||
|
|
||||||
|
|
||||||
def get_rates(config: flask.config.Config) -> dict[str, Decimal]:
|
def get_rates_exchangerate_host(config: flask.config.Config) -> dict[str, Decimal]:
|
||||||
"""Get current values of exchange rates for a list of currencies against GBP."""
|
"""Get exchange rates from exchangerate.host.
|
||||||
|
|
||||||
|
Kept as a fallback implementation in case we decide to switch back from
|
||||||
|
Frankfurter.
|
||||||
|
"""
|
||||||
currencies = config["CURRENCIES"]
|
currencies = config["CURRENCIES"]
|
||||||
access_key = config["EXCHANGERATE_ACCESS_KEY"]
|
access_key = config["EXCHANGERATE_ACCESS_KEY"]
|
||||||
data_dir = config["DATA_DIR"]
|
data_dir = config["DATA_DIR"]
|
||||||
|
|
@ -160,3 +199,82 @@ def get_rates(config: flask.config.Config) -> dict[str, Decimal]:
|
||||||
for cur in currencies
|
for cur in currencies
|
||||||
if f"GBP{cur}" in data["quotes"]
|
if f"GBP{cur}" in data["quotes"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_rates(config: flask.config.Config) -> dict[str, Decimal]:
|
||||||
|
"""Get current values of exchange rates for a list of currencies against GBP."""
|
||||||
|
currencies = config["CURRENCIES"]
|
||||||
|
data_dir = config["DATA_DIR"]
|
||||||
|
cache_ttl_hours = int(config.get("FX_CACHE_TTL_HOURS", DEFAULT_FX_CACHE_TTL_HOURS))
|
||||||
|
failure_retry_hours = int(
|
||||||
|
config.get("FX_FAILURE_RETRY_HOURS", DEFAULT_FX_FAILURE_RETRY_HOURS)
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
now_str = now.strftime("%Y-%m-%d_%H:%M")
|
||||||
|
fx_dir = os.path.join(data_dir, "fx")
|
||||||
|
os.makedirs(fx_dir, exist_ok=True) # Ensure the directory exists
|
||||||
|
|
||||||
|
currency_string = ",".join(sorted(currencies))
|
||||||
|
legacy_file_suffix = f"{currency_string}_to_GBP.json"
|
||||||
|
frankfurter_file_suffix = f"{FRANKFURTER_CACHE_PREFIX}_{legacy_file_suffix}"
|
||||||
|
existing_data = os.listdir(fx_dir)
|
||||||
|
valid_cache_files = [
|
||||||
|
f
|
||||||
|
for f in existing_data
|
||||||
|
if f.endswith(legacy_file_suffix) or f.endswith(frankfurter_file_suffix)
|
||||||
|
]
|
||||||
|
attempt_files = [f for f in existing_data if f.endswith(frankfurter_file_suffix)]
|
||||||
|
|
||||||
|
latest_attempt = _latest_file(fx_dir, attempt_files, currencies, valid_only=False)
|
||||||
|
latest_source_valid = _latest_file(
|
||||||
|
fx_dir, attempt_files, currencies, valid_only=True
|
||||||
|
)
|
||||||
|
latest_valid = _latest_file(fx_dir, valid_cache_files, currencies, valid_only=True)
|
||||||
|
latest_valid_path = (
|
||||||
|
os.path.join(fx_dir, latest_valid) if latest_valid is not None else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if config["OFFLINE_MODE"]:
|
||||||
|
return read_cached_rates(latest_valid_path, currencies)
|
||||||
|
|
||||||
|
if latest_source_valid is not None:
|
||||||
|
recent = _fx_cache_datetime(latest_source_valid)
|
||||||
|
delta = now - recent
|
||||||
|
|
||||||
|
if delta < timedelta(hours=cache_ttl_hours):
|
||||||
|
return read_cached_rates(
|
||||||
|
os.path.join(fx_dir, latest_source_valid), currencies
|
||||||
|
)
|
||||||
|
|
||||||
|
if latest_attempt is not None:
|
||||||
|
recent_attempt = _fx_cache_datetime(latest_attempt)
|
||||||
|
attempt_delta = now - recent_attempt
|
||||||
|
if attempt_delta < timedelta(hours=failure_retry_hours):
|
||||||
|
return read_cached_rates(latest_valid_path, currencies)
|
||||||
|
|
||||||
|
url = "https://api.frankfurter.dev/v2/rates"
|
||||||
|
params = {"base": "GBP", "quotes": currency_string}
|
||||||
|
|
||||||
|
filename = f"{now_str}_{frankfurter_file_suffix}"
|
||||||
|
try:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response = client.get(url, params=params, timeout=10)
|
||||||
|
except (httpx.ConnectError, httpx.ReadTimeout):
|
||||||
|
return read_cached_rates(latest_valid_path, currencies)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(response.text, parse_float=Decimal)
|
||||||
|
except json.decoder.JSONDecodeError:
|
||||||
|
return read_cached_rates(latest_valid_path, currencies)
|
||||||
|
|
||||||
|
frankfurter_rates = _frankfurter_rates(data, currencies)
|
||||||
|
if not frankfurter_rates:
|
||||||
|
with open(os.path.join(fx_dir, filename), "w") as file:
|
||||||
|
file.write(response.text)
|
||||||
|
return read_cached_rates(latest_valid_path, currencies)
|
||||||
|
|
||||||
|
with open(os.path.join(fx_dir, filename), "w") as file:
|
||||||
|
file.write(response.text)
|
||||||
|
|
||||||
|
return frankfurter_rates
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ class TestReadCachedRates:
|
||||||
assert result == {}
|
assert result == {}
|
||||||
|
|
||||||
def test_read_cached_rates_valid_file(self) -> None:
|
def test_read_cached_rates_valid_file(self) -> None:
|
||||||
"""Test reading valid cached rates file."""
|
"""Test reading valid exchangerate.host cached rates file."""
|
||||||
currencies = ["USD", "EUR", "JPY"]
|
currencies = ["USD", "EUR", "JPY"]
|
||||||
data = {
|
data = {
|
||||||
"quotes": {
|
"quotes": {
|
||||||
|
|
@ -46,6 +46,37 @@ class TestReadCachedRates:
|
||||||
finally:
|
finally:
|
||||||
os.unlink(filepath)
|
os.unlink(filepath)
|
||||||
|
|
||||||
|
def test_read_cached_rates_frankfurter_file(self) -> None:
|
||||||
|
"""Test reading valid Frankfurter cached rates file."""
|
||||||
|
currencies = ["USD", "EUR", "JPY"]
|
||||||
|
data = [
|
||||||
|
{"date": "2026-06-20", "base": "GBP", "quote": "USD", "rate": 1.25},
|
||||||
|
{"date": "2026-06-20", "base": "GBP", "quote": "EUR", "rate": 1.15},
|
||||||
|
{"date": "2026-06-20", "base": "GBP", "quote": "JPY", "rate": 150.0},
|
||||||
|
{
|
||||||
|
"date": "2026-06-20",
|
||||||
|
"base": "GBP",
|
||||||
|
"quote": "CAD",
|
||||||
|
"rate": 1.70,
|
||||||
|
}, # Not requested
|
||||||
|
]
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
filepath = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = read_cached_rates(filepath, currencies)
|
||||||
|
|
||||||
|
assert len(result) == 3
|
||||||
|
assert result["USD"] == Decimal("1.25")
|
||||||
|
assert result["EUR"] == Decimal("1.15")
|
||||||
|
assert result["JPY"] == Decimal("150.0")
|
||||||
|
assert "CAD" not in result # Not requested
|
||||||
|
|
||||||
|
finally:
|
||||||
|
os.unlink(filepath)
|
||||||
|
|
||||||
def test_read_cached_rates_missing_currencies(self) -> None:
|
def test_read_cached_rates_missing_currencies(self) -> None:
|
||||||
"""Test with some currencies missing from data."""
|
"""Test with some currencies missing from data."""
|
||||||
currencies = ["USD", "EUR", "CHF"] # CHF not in data
|
currencies = ["USD", "EUR", "CHF"] # CHF not in data
|
||||||
|
|
@ -158,8 +189,8 @@ class TestReadCachedRates:
|
||||||
os.unlink(filepath)
|
os.unlink(filepath)
|
||||||
|
|
||||||
|
|
||||||
def test_get_rates_uses_older_valid_cache_when_latest_attempt_is_usage_limit() -> None:
|
def test_get_rates_ignores_old_usage_limit_attempt_when_using_frankfurter() -> None:
|
||||||
"""A usage-limit API response should not replace usable cached rates."""
|
"""An old provider usage-limit response should not block Frankfurter refreshes."""
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
fx_dir = os.path.join(temp_dir, "fx")
|
fx_dir = os.path.join(temp_dir, "fx")
|
||||||
os.mkdir(fx_dir)
|
os.mkdir(fx_dir)
|
||||||
|
|
@ -200,8 +231,31 @@ def test_get_rates_uses_older_valid_cache_when_latest_attempt_is_usage_limit() -
|
||||||
) as mock_client:
|
) as mock_client:
|
||||||
mock_datetime.now.return_value = datetime(2026, 6, 20, 12, 35)
|
mock_datetime.now.return_value = datetime(2026, 6, 20, 12, 35)
|
||||||
mock_datetime.strptime.side_effect = datetime.strptime
|
mock_datetime.strptime.side_effect = datetime.strptime
|
||||||
|
mock_response = (
|
||||||
|
mock_client.return_value.__enter__.return_value.get.return_value
|
||||||
|
)
|
||||||
|
mock_response.text = json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"date": "2026-06-20",
|
||||||
|
"base": "GBP",
|
||||||
|
"quote": "EUR",
|
||||||
|
"rate": 1.16,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-20",
|
||||||
|
"base": "GBP",
|
||||||
|
"quote": "USD",
|
||||||
|
"rate": 1.26,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
result = get_rates(config)
|
result = get_rates(config)
|
||||||
|
|
||||||
assert result == {"USD": Decimal("1.25"), "EUR": Decimal("1.15")}
|
assert result == {"USD": Decimal("1.26"), "EUR": Decimal("1.16")}
|
||||||
mock_client.assert_not_called()
|
mock_client.return_value.__enter__.return_value.get.assert_called_once_with(
|
||||||
|
"https://api.frankfurter.dev/v2/rates",
|
||||||
|
params={"base": "GBP", "quotes": "EUR,USD"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue