diff --git a/findatapy/market/datavendorfxmacrodata.py b/findatapy/market/datavendorfxmacrodata.py new file mode 100644 index 0000000..3937470 --- /dev/null +++ b/findatapy/market/datavendorfxmacrodata.py @@ -0,0 +1,124 @@ +__author__ = "saeedamen" # Saeed Amen + +# +# Copyright 2016 Cuemacro +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on a "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import requests + +import pandas as pd + +from findatapy.market.datavendor import DataVendor +from findatapy.util import DataConstants, LoggerManager + + +class DataVendorFXMacroData(DataVendor): + """Reads daily FX spot rates from FXMacroData into findatapy.""" + + def __init__(self): + super(DataVendorFXMacroData, self).__init__() + + def load_ticker(self, md_request): + logger = LoggerManager().getLogger(__name__) + + if md_request.freq != "daily": + logger.warning("FXMacroData currently supports daily FX spot rates") + return None + + tickers = md_request.vendor_tickers or md_request.tickers + fields = md_request.fields or ["close"] + + if tickers is None: + logger.warning("No FXMacroData tickers specified") + return None + + data_frames = [] + + for library_ticker, vendor_ticker in zip(md_request.tickers, tickers): + frame = self._download_fx_pair(md_request, vendor_ticker, fields) + + if frame is None or frame.empty: + continue + + frame.columns = [ + f"{library_ticker}.{field}" for field in frame.columns + ] + data_frames.append(frame) + + if len(data_frames) == 0: + return None + + data_frame = pd.concat(data_frames, axis=1).sort_index() + data_frame.index.name = "Date" + + logger.info( + f"Completed request from FXMacroData for {list(data_frame.columns)}") + + return data_frame + + def _download_fx_pair(self, md_request, ticker, fields): + base, quote = self._split_pair(ticker) + constants = DataConstants() + url = ( + f"{constants.fxmacrodata_base_url}/forex/" + f"{base.lower()}/{quote.lower()}" + ) + params = { + "start_date": self._format_date(md_request.start_date), + "end_date": self._format_date(md_request.finish_date), + } + + if md_request.fxmacrodata_api_key: + params["api_key"] = md_request.fxmacrodata_api_key + + response = requests.get(url, params=params, timeout=30) + response.raise_for_status() + rows = response.json().get("data", []) + + if len(rows) == 0: + return None + + data_frame = pd.DataFrame(rows) + data_frame["Date"] = pd.to_datetime(data_frame["date"]) + data_frame = data_frame.set_index("Date").sort_index() + + field_map = {} + + for field in fields: + field_map[field] = ( + data_frame["val"] + if field in ["open", "high", "low", "close"] + else data_frame.get(field) + ) + + return pd.DataFrame(field_map, index=data_frame.index) + + @staticmethod + def _split_pair(ticker): + clean_ticker = ticker.replace("/", "").replace("-", "").upper() + + if len(clean_ticker) != 6: + raise ValueError( + "FXMacroData tickers should be six-letter FX pairs " + "such as EURUSD" + ) + + return clean_ticker[:3], clean_ticker[3:] + + @staticmethod + def _format_date(value): + if hasattr(value, "strftime"): + return value.strftime("%Y-%m-%d") + + return str(value) diff --git a/findatapy/market/marketdatagenerator.py b/findatapy/market/marketdatagenerator.py index 6e91c8a..097a70d 100644 --- a/findatapy/market/marketdatagenerator.py +++ b/findatapy/market/marketdatagenerator.py @@ -121,6 +121,11 @@ def get_data_vendor(self, md_request): from findatapy.market.datavendorweb import DataVendorFXCM data_vendor = DataVendorFXCM() + elif data_source == "fxmacrodata": + from findatapy.market.datavendorfxmacrodata import \ + DataVendorFXMacroData + data_vendor = DataVendorFXMacroData() + elif data_source == "alfred": from findatapy.market.datavendorfred import DataVendorALFRED data_vendor = DataVendorALFRED() diff --git a/findatapy/market/marketdatarequest.py b/findatapy/market/marketdatarequest.py index 4d4270b..0f366cb 100644 --- a/findatapy/market/marketdatarequest.py +++ b/findatapy/market/marketdatarequest.py @@ -115,6 +115,7 @@ def __init__(self, data_source: str = None, fred_api_key: str = None, alpha_vantage_api_key: str = None, eikon_api_key: str = None, + fxmacrodata_api_key: str = None, pretransformation: str = None, vintage_as_index: bool = None, vintage_download: dict = None, @@ -152,6 +153,8 @@ def __init__(self, data_source: str = None, alpha_vantage_api_key = data_constants.alpha_vantage_api_key if eikon_api_key is None: eikon_api_key = data_constants.eikon_api_key + if fxmacrodata_api_key is None: + fxmacrodata_api_key = data_constants.fxmacrodata_api_key if data_vendor_custom is None: data_vendor_custom = data_constants.data_vendor_custom if arcticdb_dict is None: @@ -220,6 +223,8 @@ def __init__(self, data_source: str = None, self.alpha_vantage_api_key = \ copy.deepcopy(md_request.alpha_vantage_api_key) self.eikon_api_key = copy.deepcopy(md_request.eikon_api_key) + self.fxmacrodata_api_key = \ + copy.deepcopy(md_request.fxmacrodata_api_key) self.pretransformation = copy.deepcopy(md_request.pretransformation) self.vintage_as_index = copy.deepcopy(md_request.vintage_as_index) @@ -278,6 +283,7 @@ def __init__(self, data_source: str = None, self.fred_api_key = fred_api_key self.alpha_vantage_api_key = alpha_vantage_api_key self.eikon_api_key = eikon_api_key + self.fxmacrodata_api_key = fxmacrodata_api_key self.pretransformation = pretransformation self.vintage_as_index = vintage_as_index @@ -354,7 +360,7 @@ def data_source(self, data_source): try: valid_data_source = ["ats", "bloomberg", "dukascopy", "fred", "gain", "google", "quandl", "yahoo", - "boe", "eikon"] + "boe", "eikon", "fxmacrodata"] if not data_source in valid_data_source: LoggerManager().getLogger(__name__).warning( @@ -664,6 +670,12 @@ def date_parser(self, date): except: # logger.warning("Attempted to parse date") pass + + try: + date1 = datetime.datetime.strptime(date, "%Y-%m-%d") + except: + # logger.warning("Attempted to parse date") + pass else: import pandas @@ -831,6 +843,14 @@ def eikon_api_key(self): def eikon_api_key(self, eikon_api_key): self.__eikon_api_key = eikon_api_key + @property + def fxmacrodata_api_key(self): + return self.__fxmacrodata_api_key + + @fxmacrodata_api_key.setter + def fxmacrodata_api_key(self, fxmacrodata_api_key): + self.__fxmacrodata_api_key = fxmacrodata_api_key + @property def pretransformation(self): return self.__pretransformation diff --git a/findatapy/util/dataconstants.py b/findatapy/util/dataconstants.py index 9c74f36..cf94e25 100644 --- a/findatapy/util/dataconstants.py +++ b/findatapy/util/dataconstants.py @@ -190,7 +190,8 @@ class DataConstants(object): 'yahoo' : 1, # yfinance already threads requests, so don't do it twice! 'other' : 4, 'dukascopy' : 3, # do not do too many! - 'fxcm' : 4} + 'fxcm' : 4, + 'fxmacrodata' : 1} # Seconds for timeout timeout_downloader = {'dukascopy' : 120} @@ -275,6 +276,10 @@ class DataConstants(object): ####### FXCM API (contact FXCM to get this) fxcm_api_key = "x" + ####### FXMacroData settings + fxmacrodata_base_url = "https://fxmacrodata.com/api/v1" + fxmacrodata_api_key = key_store("FXMacroData") + ####### Eikon settings eikon_api_key = key_store("Eikon") diff --git a/tests/test_fxmacrodata_vendor.py b/tests/test_fxmacrodata_vendor.py new file mode 100644 index 0000000..5ef0d9b --- /dev/null +++ b/tests/test_fxmacrodata_vendor.py @@ -0,0 +1,55 @@ +import pandas as pd + +from findatapy.market.datavendorfxmacrodata import DataVendorFXMacroData +from findatapy.market.marketdatarequest import MarketDataRequest + + +class _FakeResponse: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + pass + + def json(self): + return self._payload + + +def test_fxmacrodata_vendor_builds_daily_fx_dataframe(monkeypatch): + calls = {} + + def fake_get(url, params=None, timeout=None): + calls["url"] = url + calls["params"] = params + calls["timeout"] = timeout + + return _FakeResponse({ + "data": [ + {"date": "2026-01-02", "val": 1.10}, + {"date": "2026-01-03", "val": 1.11}, + ] + }) + + monkeypatch.setattr( + "findatapy.market.datavendorfxmacrodata.requests.get", fake_get) + + md_request = MarketDataRequest( + data_source="fxmacrodata", + category="fx", + start_date="2026-01-01", + finish_date="2026-01-04", + tickers=["EURUSD"], + fields=["open", "high", "low", "close"], + fxmacrodata_api_key="test-key", + ) + + data_frame = DataVendorFXMacroData().load_ticker(md_request) + + assert isinstance(data_frame, pd.DataFrame) + assert list(data_frame.columns) == [ + "EURUSD.open", "EURUSD.high", "EURUSD.low", "EURUSD.close"] + assert data_frame.loc[pd.Timestamp("2026-01-02"), "EURUSD.close"] == 1.10 + assert calls["url"] == "https://fxmacrodata.com/api/v1/forex/eur/usd" + assert calls["params"]["api_key"] == "test-key" + assert calls["params"]["start_date"] == "2026-01-01" + assert calls["params"]["end_date"] == "2026-01-04"