280 lines
9.6 KiB
Python
280 lines
9.6 KiB
Python
"""Currency exchange rates."""
|
|
|
|
import json
|
|
import os
|
|
import typing
|
|
from datetime import datetime, timedelta
|
|
from decimal import Decimal
|
|
|
|
import flask
|
|
import httpx
|
|
|
|
DEFAULT_FX_CACHE_TTL_HOURS = 24
|
|
DEFAULT_FX_FAILURE_RETRY_HOURS = 24
|
|
FRANKFURTER_CACHE_PREFIX = "frankfurter"
|
|
|
|
|
|
async def get_gbpusd(config: flask.config.Config) -> Decimal:
|
|
"""Get the current value for GBPUSD, with caching."""
|
|
access_key = config["EXCHANGERATE_ACCESS_KEY"]
|
|
data_dir = config["DATA_DIR"]
|
|
|
|
now = datetime.now()
|
|
now_str = now.strftime("%Y-%m-%d_%H:%M")
|
|
fx_dir = os.path.join(data_dir, "fx")
|
|
existing_data = os.listdir(fx_dir)
|
|
existing = [f for f in existing_data if f.endswith("_GBPUSD.json")]
|
|
if existing:
|
|
recent_filename = max(existing)
|
|
recent = datetime.strptime(recent_filename, "%Y-%m-%d_%H:%M_GBPUSD.json")
|
|
delta = now - recent
|
|
|
|
if existing and delta < timedelta(hours=6):
|
|
full = os.path.join(fx_dir, recent_filename)
|
|
data = json.load(open(full), parse_float=Decimal)
|
|
if "quotes" in data and "USDGBP" in data["quotes"]:
|
|
return typing.cast(Decimal, 1 / data["quotes"]["USDGBP"])
|
|
|
|
url = "http://api.exchangerate.host/live"
|
|
params = {"currencies": "GBP,USD", "access_key": access_key}
|
|
|
|
filename = f"{fx_dir}/{now_str}_GBPUSD.json"
|
|
async with httpx.AsyncClient() as client:
|
|
r = await client.get(url, params=params)
|
|
|
|
open(filename, "w").write(r.text)
|
|
data = json.loads(r.text, parse_float=Decimal)
|
|
|
|
return typing.cast(Decimal, 1 / data["quotes"]["USDGBP"])
|
|
|
|
|
|
def read_cached_rates(
|
|
filename: str | None, currencies: list[str]
|
|
) -> dict[str, Decimal]:
|
|
"""Read FX rates from cache."""
|
|
if filename is None:
|
|
return {}
|
|
|
|
with open(filename) as file:
|
|
data = json.load(file, parse_float=Decimal)
|
|
|
|
frankfurter_rates = _frankfurter_rates(data, currencies)
|
|
if frankfurter_rates:
|
|
return frankfurter_rates
|
|
|
|
if isinstance(data, list):
|
|
return {}
|
|
|
|
if not isinstance(data, dict):
|
|
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:
|
|
"""Extract the cache timestamp from an FX cache filename."""
|
|
return datetime.strptime(filename[:16], "%Y-%m-%d_%H:%M")
|
|
|
|
|
|
def _has_required_quotes(filename: str, currencies: list[str]) -> bool:
|
|
"""Return true if the cache file contains the requested GBP quotes."""
|
|
try:
|
|
return len(read_cached_rates(filename, currencies)) == len(currencies)
|
|
except (OSError, json.JSONDecodeError):
|
|
return False
|
|
|
|
|
|
def _latest_file(
|
|
fx_dir: str, filenames: list[str], currencies: list[str], *, valid_only: bool
|
|
) -> str | None:
|
|
"""Return the newest matching FX cache filename."""
|
|
matching_files = [
|
|
filename
|
|
for filename in filenames
|
|
if not valid_only
|
|
or _has_required_quotes(os.path.join(fx_dir, filename), currencies)
|
|
]
|
|
|
|
return max(matching_files) if matching_files else None
|
|
|
|
|
|
def get_rates_exchangerate_host(config: flask.config.Config) -> dict[str, Decimal]:
|
|
"""Get exchange rates from exchangerate.host.
|
|
|
|
Kept as a fallback implementation in case we decide to switch back from
|
|
Frankfurter.
|
|
"""
|
|
currencies = config["CURRENCIES"]
|
|
access_key = config["EXCHANGERATE_ACCESS_KEY"]
|
|
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))
|
|
file_suffix = f"{currency_string}_to_GBP.json"
|
|
existing_data = os.listdir(fx_dir)
|
|
existing_files = [f for f in existing_data if f.endswith(file_suffix)]
|
|
|
|
latest_attempt = _latest_file(fx_dir, existing_files, currencies, valid_only=False)
|
|
latest_valid = _latest_file(fx_dir, existing_files, currencies, valid_only=True)
|
|
latest_valid_path = (
|
|
os.path.join(fx_dir, latest_valid) if latest_valid is not None else None
|
|
)
|
|
|
|
if latest_valid is not None:
|
|
recent = _fx_cache_datetime(latest_valid)
|
|
delta = now - recent
|
|
|
|
if delta < timedelta(hours=cache_ttl_hours) or config["OFFLINE_MODE"]:
|
|
return read_cached_rates(latest_valid_path, 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 = "http://api.exchangerate.host/live"
|
|
params = {"currencies": currency_string, "source": "GBP", "access_key": access_key}
|
|
|
|
filename = f"{now_str}_{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)
|
|
|
|
if not data.get("success", True) or not isinstance(data.get("quotes"), dict):
|
|
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 {
|
|
cur: Decimal(data["quotes"][f"GBP{cur}"])
|
|
for cur in currencies
|
|
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
|