11"""Currency conversion"""
22
33import logging
4- from copy import copy
4+ from copy import deepcopy
55from datetime import datetime , timezone
66from 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 (
0 commit comments