Skip to content

Commit 51fb0f0

Browse files
authored
HDXDSYS-1997 Fix WFP rates precedence in HDX Python Country (#69)
* Switchable historic function. Allow using cached historic rates only. * Must call Currency.setup before using Currency methods * Fix tests * Needs to be a deepcopy so as not to mess with original data
1 parent 2338ae8 commit 51fb0f0

File tree

8 files changed

+188
-156
lines changed

8 files changed

+188
-156
lines changed

documentation/index.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ The code for the library is [here](https://github.com/OCHA-DAP/hdx-python-countr
4242
The library has detailed API documentation which can be found in the menu at the top.
4343

4444
## Breaking Changes
45+
From 3.9.2, must call Currency.setup before using Currency methods.
46+
4547
From 3.7.5, removed clean_name function. There is now a function normalise in
4648
HDX Python Utilities.
4749

@@ -191,9 +193,11 @@ AdminLevel objects in a list or lists of p-codes per parent admin level:
191193

192194
## Currencies
193195

194-
Various functions support the conversion of monetary amounts to USD. Note that the
195-
returned values are cached to reduce network usage which means that the library is
196-
unsuited for use where rates are expected to update while the program is running:
196+
Various functions support the conversion of monetary amounts to USD. The setup
197+
method must be called once before using any other methods. Note that the
198+
returned values are cached to reduce network usage which means that the
199+
library is unsuited for use where rates are expected to update while the
200+
program is running:
197201

198202
Currency.setup(fallback_historic_to_current=True, fallback_current_to_static=True, log_level=logging.INFO)
199203
currency = Country.get_currency_from_iso3("usa") # returns "USD"

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ ghp-import==2.1.0
4242
# via mkdocs
4343
hdx-python-utilities==3.8.5
4444
# via hdx-python-country (pyproject.toml)
45-
humanize==4.12.1
45+
humanize==4.12.2
4646
# via frictionless
4747
identify==2.6.9
4848
# via pre-commit

src/hdx/location/currency.py

Lines changed: 106 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Currency conversion"""
22

33
import logging
4-
from copy import copy
4+
from copy import deepcopy
55
from datetime import datetime, timezone
66
from typing import Dict, Optional, Union
77

@@ -33,11 +33,11 @@ class Currency:
3333
_secondary_historic_url = (
3434
"https://codeforiati.org/imf-exchangerates/imf_exchangerates.csv"
3535
)
36-
_cached_current_rates = None
37-
_cached_historic_rates = None
38-
_rates_api = None
39-
_secondary_rates = None
40-
_secondary_historic = None
36+
_cached_current_rates = {}
37+
_cached_historic_rates = {}
38+
_rates_api = ""
39+
_secondary_rates = {}
40+
_secondary_historic_rates = {}
4141
_fallback_to_current = False
4242
_no_historic = False
4343
_user_agent = "hdx-python-country-rates"
@@ -52,14 +52,16 @@ def setup(
5252
retriever: Optional[Retrieve] = None,
5353
primary_rates_url: str = _primary_rates_url,
5454
secondary_rates_url: str = _secondary_rates_url,
55-
secondary_historic_url: str = _secondary_historic_url,
55+
secondary_historic_url: Optional[str] = _secondary_historic_url,
56+
secondary_historic_rates: Optional[Dict] = None,
5657
fallback_historic_to_current: bool = False,
5758
fallback_current_to_static: bool = False,
5859
no_historic: bool = False,
5960
fixed_now: Optional[datetime] = None,
6061
log_level: int = logging.DEBUG,
6162
current_rates_cache: Dict = {"USD": 1},
6263
historic_rates_cache: Dict = {},
64+
use_secondary_historic: bool = False,
6365
) -> None:
6466
"""
6567
Setup the sources. If you wish to use a static fallback file by setting
@@ -70,24 +72,29 @@ def setup(
7072
retriever (Optional[Retrieve]): Retrieve object to use for downloading. Defaults to None (generate a new one).
7173
primary_rates_url (str): Primary rates url to use. Defaults to Yahoo API.
7274
secondary_rates_url (str): Current rates url to use. Defaults to currency-api.
73-
secondary_historic_url (str): Historic rates url to use. Defaults to IMF (via IATI).
75+
secondary_historic_url (Optional[str]): Historic rates url to use. Defaults to IMF (via IATI).
76+
secondary_historic_rates (Optional[Dict]): Historic rates to use. Defaults to None.
7477
fallback_historic_to_current (bool): If historic unavailable, fallback to current. Defaults to False.
7578
fallback_current_to_static (bool): Use static file as final fallback. Defaults to False.
7679
no_historic (bool): Do not set up historic rates. Defaults to False.
7780
fixed_now (Optional[datetime]): Use a fixed datetime for now. Defaults to None (use datetime.now()).
7881
log_level (int): Level at which to log messages. Defaults to logging.DEBUG.
7982
current_rates_cache (Dict): Pre-populate current rates cache with given values. Defaults to {"USD": 1}.
8083
historic_rates_cache (Dict): Pre-populate historic rates cache with given values. Defaults to {}.
84+
use_secondary_historic (bool): Use secondary historic first. Defaults to False.
8185
8286
Returns:
8387
None
8488
"""
8589

86-
cls._cached_current_rates = copy(current_rates_cache)
87-
cls._cached_historic_rates = copy(historic_rates_cache)
90+
cls._cached_current_rates = deepcopy(current_rates_cache)
91+
cls._cached_historic_rates = deepcopy(historic_rates_cache)
8892
cls._rates_api = primary_rates_url
89-
cls._secondary_rates = None
90-
cls._secondary_historic = None
93+
cls._secondary_rates = {}
94+
if secondary_historic_rates is not None:
95+
cls._secondary_historic_rates = secondary_historic_rates
96+
else:
97+
cls._secondary_historic_rates = {}
9198
if retriever is None:
9299
downloader = Download(user_agent=cls._user_agent)
93100
temp_dir = get_temp_dir(cls._user_agent)
@@ -110,30 +117,33 @@ def setup(
110117
cls._secondary_rates = secondary_rates["usd"]
111118
except (DownloadError, OSError):
112119
logger.exception("Error getting secondary current rates!")
113-
cls._secondary_rates = "FAIL"
114120
cls._fixed_now = fixed_now
115121
cls._log_level = log_level
116122
if no_historic:
117-
cls._no_historic = True
118-
if cls._no_historic:
119123
return
120-
try:
121-
_, iterator = retriever.get_tabular_rows(
122-
secondary_historic_url,
123-
dict_form=True,
124-
filename="historic_rates.csv",
125-
logstr="secondary historic exchange rates",
126-
)
127-
cls._secondary_historic = {}
128-
for row in iterator:
129-
currency = row["Currency"]
130-
date = get_int_timestamp(parse_date(row["Date"]))
131-
rate = float(row["Rate"])
132-
dict_of_dicts_add(cls._secondary_historic, currency, date, rate)
133-
except (DownloadError, OSError):
134-
logger.exception("Error getting secondary historic rates!")
135-
cls._secondary_historic = "FAIL"
124+
cls._no_historic = no_historic
125+
if secondary_historic_url:
126+
try:
127+
_, iterator = retriever.get_tabular_rows(
128+
secondary_historic_url,
129+
dict_form=True,
130+
filename="historic_rates.csv",
131+
logstr="secondary historic exchange rates",
132+
)
133+
for row in iterator:
134+
currency = row["Currency"]
135+
date = get_int_timestamp(parse_date(row["Date"]))
136+
rate = float(row["Rate"])
137+
dict_of_dicts_add(
138+
cls._secondary_historic_rates, currency, date, rate
139+
)
140+
except (DownloadError, OSError):
141+
logger.exception("Error getting secondary historic rates!")
136142
cls._fallback_to_current = fallback_historic_to_current
143+
if use_secondary_historic:
144+
cls._get_historic_rate = cls._get_historic_rate_secondary
145+
else:
146+
cls._get_historic_rate = cls._get_historic_rate_primary
137147

138148
@classmethod
139149
def _get_primary_rates_data(
@@ -149,8 +159,8 @@ def _get_primary_rates_data(
149159
Returns:
150160
Optional[float]: fx rate or None
151161
"""
152-
if cls._rates_api is None:
153-
Currency.setup()
162+
if not cls._rates_api:
163+
return None
154164
url = cls._rates_api.format(currency=currency, date=str(timestamp))
155165
if downloader is None:
156166
downloader = cls._retriever
@@ -289,10 +299,6 @@ def _get_secondary_current_rate(cls, currency: str) -> Optional[float]:
289299
Returns:
290300
Optional[float]: fx rate or None
291301
"""
292-
if cls._secondary_rates is None:
293-
Currency.setup()
294-
if cls._secondary_rates == "FAIL":
295-
return None
296302
return cls._secondary_rates.get(currency.lower())
297303

298304
@classmethod
@@ -307,8 +313,6 @@ def get_current_rate(cls, currency: str) -> float:
307313
float: fx rate
308314
"""
309315
currency = currency.upper()
310-
if cls._cached_current_rates is None:
311-
Currency.setup()
312316
fx_rate = cls._cached_current_rates.get(currency)
313317
if fx_rate is not None:
314318
return fx_rate
@@ -361,39 +365,13 @@ def get_current_value_in_currency(
361365
fx_rate = cls.get_current_rate(currency)
362366
return usdvalue * fx_rate
363367

364-
@classmethod
365-
def _get_interpolated_rate(
366-
cls,
367-
timestamp1: int,
368-
rate1: float,
369-
timestamp2: int,
370-
rate2: float,
371-
desired_timestamp: int,
372-
) -> float:
373-
"""
374-
Return a rate for a desired timestamp based on linearly interpolating between
375-
two timestamp/rate pairs.
376-
377-
Args:
378-
timestamp1 (int): First timestamp to use for fx conversion
379-
rate1 (float): Rate at first timestamp
380-
timestamp2 (int): Second timestamp to use for fx conversion
381-
rate2 (float): Rate at second timestamp
382-
desired_timestamp (int): Timestamp at which rate is desired
383-
384-
Returns:
385-
float: Rate at desired timestamp
386-
"""
387-
return rate1 + (desired_timestamp - timestamp1) * (
388-
(rate2 - rate1) / (timestamp2 - timestamp1)
389-
)
390-
391368
@classmethod
392369
def _get_secondary_historic_rate(
393370
cls, currency: str, timestamp: int
394371
) -> Optional[float]:
395372
"""
396-
Get the secondary fx rate for currency on a particular date
373+
Get the historic fx rate for currency on a particular date using
374+
interpolation if needed.
397375
398376
Args:
399377
currency (str): Currency
@@ -402,11 +380,7 @@ def _get_secondary_historic_rate(
402380
Returns:
403381
Optional[float]: fx rate or None
404382
"""
405-
if cls._secondary_historic is None:
406-
Currency.setup()
407-
if cls._secondary_historic == "FAIL":
408-
return None
409-
currency_data = cls._secondary_historic.get(currency)
383+
currency_data = cls._secondary_historic_rates.get(currency)
410384
if currency_data is None:
411385
return None
412386
fx_rate = currency_data.get(timestamp)
@@ -426,14 +400,67 @@ def _get_secondary_historic_rate(
426400
return currency_data[timestamp2]
427401
if timestamp2 is None:
428402
return currency_data[timestamp1]
429-
return cls._get_interpolated_rate(
430-
timestamp1,
431-
currency_data[timestamp1],
432-
timestamp2,
433-
currency_data[timestamp2],
434-
timestamp,
403+
rate1 = currency_data[timestamp1]
404+
return rate1 + (timestamp - timestamp1) * (
405+
(currency_data[timestamp2] - rate1) / (timestamp2 - timestamp1)
435406
)
436407

408+
@classmethod
409+
def _get_historic_rate_primary(cls, currency: str, timestamp: int) -> float:
410+
currency_data = cls._cached_historic_rates.get(currency)
411+
if currency_data is not None:
412+
fx_rate = currency_data.get(timestamp)
413+
if fx_rate is not None:
414+
return fx_rate
415+
fx_rate = cls._get_primary_rate(currency, timestamp)
416+
if fx_rate is not None:
417+
dict_of_dicts_add(cls._cached_historic_rates, currency, timestamp, fx_rate)
418+
return fx_rate
419+
fx_rate = cls._get_secondary_historic_rate(currency, timestamp)
420+
if fx_rate is not None:
421+
dict_of_dicts_add(cls._cached_historic_rates, currency, timestamp, fx_rate)
422+
return fx_rate
423+
if cls._fallback_to_current:
424+
fx_rate = cls.get_current_rate(currency)
425+
if fx_rate:
426+
logger.debug(
427+
f"Falling back to current rate for currency {currency} on timestamp {timestamp}!"
428+
)
429+
return fx_rate
430+
raise CurrencyError(
431+
f"Failed to get rate for currency {currency} on timestamp {timestamp}!"
432+
)
433+
434+
@classmethod
435+
def _get_historic_rate_secondary(cls, currency: str, timestamp: int) -> float:
436+
currency_data = cls._cached_historic_rates.get(currency)
437+
if currency_data is not None:
438+
fx_rate = currency_data.get(timestamp)
439+
if fx_rate is not None:
440+
return fx_rate
441+
fx_rate = cls._get_secondary_historic_rate(currency, timestamp)
442+
if fx_rate is None:
443+
fx_rate = cls._get_primary_rate(currency, timestamp)
444+
if fx_rate is None:
445+
if cls._fallback_to_current:
446+
fx_rate = cls.get_current_rate(currency)
447+
if fx_rate:
448+
logger.debug(
449+
f"Falling back to current rate for currency {currency} on timestamp {timestamp}!"
450+
)
451+
return fx_rate
452+
raise CurrencyError(
453+
f"Failed to get rate for currency {currency} on timestamp {timestamp}!"
454+
)
455+
else:
456+
dict_of_dicts_add(
457+
cls._cached_historic_rates, currency, timestamp, fx_rate
458+
)
459+
return fx_rate
460+
else:
461+
dict_of_dicts_add(cls._cached_historic_rates, currency, timestamp, fx_rate)
462+
return fx_rate
463+
437464
@classmethod
438465
def get_historic_rate(
439466
cls, currency: str, date: datetime, ignore_timeinfo: bool = True
@@ -455,38 +482,12 @@ def get_historic_rate(
455482
currency = currency.upper()
456483
if currency == "USD":
457484
return 1
458-
if cls._cached_historic_rates is None:
459-
Currency.setup()
460-
currency_data = cls._cached_historic_rates.get(currency)
461485
if ignore_timeinfo:
462486
date = date.replace(
463487
hour=0, minute=0, second=0, microsecond=0, tzinfo=timezone.utc
464488
)
465-
else:
466-
date = date.astimezone(timezone.utc)
467489
timestamp = get_int_timestamp(date)
468-
if currency_data is not None:
469-
fx_rate = currency_data.get(timestamp)
470-
if fx_rate is not None:
471-
return fx_rate
472-
fx_rate = cls._get_primary_rate(currency, timestamp)
473-
if fx_rate is not None:
474-
dict_of_dicts_add(cls._cached_historic_rates, currency, timestamp, fx_rate)
475-
return fx_rate
476-
fx_rate = cls._get_secondary_historic_rate(currency, timestamp)
477-
if fx_rate is not None:
478-
dict_of_dicts_add(cls._cached_historic_rates, currency, timestamp, fx_rate)
479-
return fx_rate
480-
if cls._fallback_to_current:
481-
fx_rate = cls.get_current_rate(currency)
482-
if fx_rate:
483-
logger.debug(
484-
f"Falling back to current rate for currency {currency} on date {date.isoformat()}!"
485-
)
486-
return fx_rate
487-
raise CurrencyError(
488-
f"Failed to get rate for currency {currency} on date {date.isoformat()}!"
489-
)
490+
return cls._get_historic_rate(currency, timestamp)
490491

491492
@classmethod
492493
def get_historic_value_in_usd(

src/hdx/location/wfp_exchangerates.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def get_currency_historic_rates(self, currency: str) -> Dict[int, float]:
5858
parameters={"currencyName": currency},
5959
)
6060
historic_rates = {}
61-
for quote in quotes:
61+
for quote in reversed(quotes):
6262
if not quote["isOfficial"]:
6363
continue
6464
date = parse_date(quote["date"])

0 commit comments

Comments
 (0)