From 8130c934c16dbb2d85d6af6ea24d8ab3f9b0e404 Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Tue, 7 Jul 2026 23:45:08 +1000 Subject: [PATCH 1/4] feat: add FXMacroData data collector --- scripts/data_collector/README.md | 3 +- scripts/data_collector/fxmacrodata/README.md | 65 ++++ .../data_collector/fxmacrodata/collector.py | 297 ++++++++++++++++++ .../fxmacrodata/requirements.txt | 6 + 4 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 scripts/data_collector/fxmacrodata/README.md create mode 100644 scripts/data_collector/fxmacrodata/collector.py create mode 100644 scripts/data_collector/fxmacrodata/requirements.txt diff --git a/scripts/data_collector/README.md b/scripts/data_collector/README.md index d0058b33e2c..9b9203f0736 100644 --- a/scripts/data_collector/README.md +++ b/scripts/data_collector/README.md @@ -5,6 +5,7 @@ Scripts for data collection - yahoo: get *US/CN* stock data from *Yahoo Finance* +- fxmacrodata: get daily *FX spot rates* from *FXMacroData* - fund: get fund data from *http://fund.eastmoney.com* - cn_index: get *CN index* from *http://www.csindex.com.cn*, *CSI300*/*CSI100* - us_index: get *US index* from *https://en.wikipedia.org/wiki*, *SP500*/*NASDAQ100*/*DJIA*/*SP400* @@ -57,4 +58,4 @@ Scripts for data collection | Component | required data | |---------------------------------------------------|--------------------------------| | Data retrieval | Features, Calendar, Instrument | - | Backtest | **Features[Price/Volume]**, Calendar, Instruments | \ No newline at end of file + | Backtest | **Features[Price/Volume]**, Calendar, Instruments | diff --git a/scripts/data_collector/fxmacrodata/README.md b/scripts/data_collector/fxmacrodata/README.md new file mode 100644 index 00000000000..f9da2fe6c06 --- /dev/null +++ b/scripts/data_collector/fxmacrodata/README.md @@ -0,0 +1,65 @@ +# Collect Data From FXMacroData + +FXMacroData provides daily FX spot-rate series for currency pairs such as `EUR/USD`. +This collector downloads those series into qlib-compatible CSV files and then uses +qlib's existing `dump_bin.py` script to convert them into qlib binary data. + +## Requirements + +```bash +pip install -r scripts/data_collector/fxmacrodata/requirements.txt +``` + +Set an API key when you need authenticated access: + +```bash +export FXMACRODATA_API_KEY="" +``` + +`FXMD_API_KEY` is also supported. You can also pass `--api_key` to the collector. + +## Download FX Data + +```bash +python scripts/data_collector/fxmacrodata/collector.py download_data \ + --source_dir ~/.qlib/fxmacrodata/source \ + --start 2024-01-01 \ + --end 2024-03-01 \ + --pairs EURUSD,GBPUSD,USDJPY +``` + +Supported pair formats include `EURUSD`, `EUR/USD`, `EUR-USD`, `EUR_USD`, and +Yahoo-style `EURUSD=X`. The collector currently supports daily data only. + +## Normalize Data + +```bash +python scripts/data_collector/fxmacrodata/collector.py normalize_data \ + --source_dir ~/.qlib/fxmacrodata/source \ + --normalize_dir ~/.qlib/fxmacrodata/normalize +``` + +FX spot data is shaped with `open`, `high`, `low`, and `close` equal to the daily +spot rate. `volume` is set to `0`, `factor` is set to `1`, and `change` is the +daily percentage change in `close`. + +## Dump To qlib Format + +```bash +python scripts/dump_bin.py dump_all \ + --data_path ~/.qlib/fxmacrodata/normalize \ + --qlib_dir ~/.qlib/qlib_data/fxmacrodata \ + --freq day \ + --exclude_fields date,symbol \ + --file_suffix .csv +``` + +## Use The Data + +```python +import qlib +from qlib.data import D + +qlib.init(provider_uri="~/.qlib/qlib_data/fxmacrodata", region="us") +df = D.features(["eurusd", "gbpusd"], ["$close", "$change"], freq="day") +``` diff --git a/scripts/data_collector/fxmacrodata/collector.py b/scripts/data_collector/fxmacrodata/collector.py new file mode 100644 index 00000000000..95294975a88 --- /dev/null +++ b/scripts/data_collector/fxmacrodata/collector.py @@ -0,0 +1,297 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import os +import sys +from pathlib import Path +from typing import Iterable, List, Optional, Sequence, Tuple + +import fire +import pandas as pd +import requests + +CUR_DIR = Path(__file__).resolve().parent +sys.path.append(str(CUR_DIR.parent.parent)) + +from data_collector.base import BaseCollector, BaseNormalize, BaseRun + +DEFAULT_BASE_URL = "https://fxmacrodata.com/api/v1" +DEFAULT_PAIRS = ( + "EURUSD", + "GBPUSD", + "USDJPY", + "AUDUSD", + "USDCAD", + "USDCHF", + "NZDUSD", +) +API_KEY_ENV_VARS = ("FXMACRODATA_API_KEY", "FXMD_API_KEY") +OUTPUT_COLUMNS = [ + "date", + "symbol", + "open", + "close", + "high", + "low", + "volume", + "factor", + "change", +] + + +class FXMacroDataCollector(BaseCollector): + """Collect daily FX spot rates from FXMacroData.""" + + def __init__( + self, + save_dir: [str, Path], + start=None, + end=None, + interval="1d", + max_workers=1, + max_collector_count=2, + delay=0, + check_data_length: int = None, + limit_nums: int = None, + pairs: [str, Sequence[str]] = None, + api_key: Optional[str] = None, + base_url: str = DEFAULT_BASE_URL, + timeout: float = 30, + ): + if interval != self.INTERVAL_1d: + raise ValueError( + "FXMacroDataCollector supports daily data only: --interval 1d" + ) + + self.pairs = self._normalize_pairs(pairs) + self.api_key = api_key or self._get_env_api_key() + self.base_url = base_url.rstrip("/") + self.timeout = timeout + + super(FXMacroDataCollector, self).__init__( + save_dir=save_dir, + start=start, + end=end, + interval=interval, + max_workers=max_workers, + max_collector_count=max_collector_count, + delay=delay, + check_data_length=check_data_length, + limit_nums=limit_nums, + ) + + def get_instrument_list(self): + return self.pairs + + def normalize_symbol(self, symbol: str): + return self._normalize_pair(symbol).lower() + + def get_data( + self, + symbol: str, + interval: str, + start_datetime: pd.Timestamp, + end_datetime: pd.Timestamp, + ) -> pd.DataFrame: + if interval != self.INTERVAL_1d: + raise ValueError( + "FXMacroDataCollector supports daily data only: --interval 1d" + ) + + pair = self._normalize_pair(symbol) + base, quote = self._split_pair(pair) + params = { + "start_date": self._format_date(start_datetime), + "end_date": self._format_date(end_datetime), + } + headers = {} + if self.api_key: + headers["X-API-Key"] = self.api_key + + response = requests.get( + f"{self.base_url}/forex/{base}/{quote}", + params=params, + headers=headers, + timeout=self.timeout, + ) + response.raise_for_status() + rows = self._payload_rows(response.json()) + return self._rows_to_frame(pair, rows) + + @classmethod + def _normalize_pairs(cls, pairs: [str, Sequence[str], None]) -> List[str]: + if pairs is None: + return list(DEFAULT_PAIRS) + if isinstance(pairs, str): + pairs = [pair.strip() for pair in pairs.split(",") if pair.strip()] + return [cls._normalize_pair(pair) for pair in pairs] + + @staticmethod + def _normalize_pair(pair: str) -> str: + pair = pair.strip().upper() + if pair.endswith("=X"): + pair = pair[:-2] + pair = pair.replace("/", "").replace("-", "").replace("_", "") + if len(pair) != 6 or not pair.isalpha(): + raise ValueError( + "FXMacroData pairs must look like EURUSD or EUR/USD" + ) + return pair + + @staticmethod + def _split_pair(pair: str) -> Tuple[str, str]: + return pair[:3].lower(), pair[3:].lower() + + @staticmethod + def _get_env_api_key() -> Optional[str]: + for name in API_KEY_ENV_VARS: + value = os.getenv(name) + if value: + return value + return None + + @staticmethod + def _format_date(value: pd.Timestamp) -> str: + return pd.Timestamp(value).strftime("%Y-%m-%d") + + @staticmethod + def _payload_rows(payload) -> list: + if isinstance(payload, list): + return payload + if isinstance(payload, dict): + data = payload.get("data", []) + return data if isinstance(data, list) else [] + return [] + + @classmethod + def _rows_to_frame(cls, pair: str, rows: list) -> pd.DataFrame: + records = [] + for row in rows: + date = row.get("date") or row.get("timestamp") + rate = cls._extract_rate(row) + if date is None or rate is None: + continue + records.append( + { + "date": pd.Timestamp(date), + "symbol": pair.lower(), + "open": rate, + "close": rate, + "high": rate, + "low": rate, + "volume": 0.0, + "factor": 1.0, + } + ) + if not records: + return pd.DataFrame(columns=OUTPUT_COLUMNS) + df = pd.DataFrame(records) + df = ( + df.drop_duplicates("date") + .sort_values("date") + .reset_index(drop=True) + ) + df["change"] = df["close"].ffill().pct_change().fillna(0.0) + df["date"] = df["date"].dt.strftime("%Y-%m-%d") + return df[OUTPUT_COLUMNS] + + @staticmethod + def _extract_rate(row: dict) -> Optional[float]: + for key in ("val", "value", "close", "rate", "fx_rate"): + value = row.get(key) + if value is not None: + return float(value) + return None + + +class FXMacroDataNormalize(BaseNormalize): + """Normalize FXMacroData CSVs for qlib dump_bin.""" + + def _get_calendar_list(self) -> Iterable[pd.Timestamp]: + return [] + + def normalize(self, df: pd.DataFrame) -> pd.DataFrame: + if df.empty: + return df + + df = df.copy() + df[self._date_field_name] = pd.to_datetime(df[self._date_field_name]) + df = df.drop_duplicates(self._date_field_name).sort_values( + self._date_field_name + ) + df["close"] = pd.to_numeric(df["close"], errors="coerce") + df = df.dropna(subset=["close"]) + + for column in ("open", "high", "low"): + if column not in df.columns: + df[column] = df["close"] + df[column] = pd.to_numeric( + df[column], errors="coerce" + ).fillna(df["close"]) + df["volume"] = 0.0 + df["factor"] = 1.0 + df["change"] = df["close"].ffill().pct_change().fillna(0.0) + df[self._date_field_name] = df[self._date_field_name].dt.strftime( + "%Y-%m-%d" + ) + df[self._symbol_field_name] = ( + df[self._symbol_field_name].astype(str).str.lower() + ) + return df[OUTPUT_COLUMNS] + + +class Run(BaseRun): + def download_data( + self, + max_collector_count=2, + delay=0, + start=None, + end=None, + check_data_length: int = None, + limit_nums=None, + pairs: str = ",".join(DEFAULT_PAIRS), + api_key: Optional[str] = None, + base_url: str = DEFAULT_BASE_URL, + timeout: float = 30, + ): + """Download daily FX spot rates from FXMacroData.""" + + super(Run, self).download_data( + max_collector_count=max_collector_count, + delay=delay, + start=start, + end=end, + check_data_length=check_data_length, + limit_nums=limit_nums, + pairs=pairs, + api_key=api_key, + base_url=base_url, + timeout=timeout, + ) + + def normalize_data( + self, date_field_name: str = "date", symbol_field_name: str = "symbol" + ): + """Normalize FXMacroData daily data.""" + + if self.interval != "1d": + raise ValueError( + "FXMacroData collector supports daily data only: --interval 1d" + ) + super(Run, self).normalize_data(date_field_name, symbol_field_name) + + @property + def collector_class_name(self): + return "FXMacroDataCollector" + + @property + def normalize_class_name(self): + return "FXMacroDataNormalize" + + @property + def default_base_dir(self) -> [Path, str]: + return CUR_DIR + + +if __name__ == "__main__": + fire.Fire(Run) diff --git a/scripts/data_collector/fxmacrodata/requirements.txt b/scripts/data_collector/fxmacrodata/requirements.txt new file mode 100644 index 00000000000..f564f574a82 --- /dev/null +++ b/scripts/data_collector/fxmacrodata/requirements.txt @@ -0,0 +1,6 @@ +fire +joblib +loguru +pandas +requests +tqdm From 4b0b6ee4a96321b2d415b561e2ea6604514b4d87 Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Wed, 8 Jul 2026 12:14:35 +1000 Subject: [PATCH 2/4] Add FXMacroData macro feature collector --- scripts/data_collector/README.md | 2 +- scripts/data_collector/fxmacrodata/README.md | 51 ++- .../data_collector/fxmacrodata/collector.py | 322 ++++++++++++++++-- tests/misc/test_fxmacrodata_collector.py | 102 ++++++ 4 files changed, 441 insertions(+), 36 deletions(-) create mode 100644 tests/misc/test_fxmacrodata_collector.py diff --git a/scripts/data_collector/README.md b/scripts/data_collector/README.md index 9b9203f0736..daa15f29d5f 100644 --- a/scripts/data_collector/README.md +++ b/scripts/data_collector/README.md @@ -5,7 +5,7 @@ Scripts for data collection - yahoo: get *US/CN* stock data from *Yahoo Finance* -- fxmacrodata: get daily *FX spot rates* from *FXMacroData* +- fxmacrodata: get daily *FX spot rates* and *macro announcement features* from *FXMacroData* - fund: get fund data from *http://fund.eastmoney.com* - cn_index: get *CN index* from *http://www.csindex.com.cn*, *CSI300*/*CSI100* - us_index: get *US index* from *https://en.wikipedia.org/wiki*, *SP500*/*NASDAQ100*/*DJIA*/*SP400* diff --git a/scripts/data_collector/fxmacrodata/README.md b/scripts/data_collector/fxmacrodata/README.md index f9da2fe6c06..ff317931e53 100644 --- a/scripts/data_collector/fxmacrodata/README.md +++ b/scripts/data_collector/fxmacrodata/README.md @@ -1,8 +1,9 @@ # Collect Data From FXMacroData -FXMacroData provides daily FX spot-rate series for currency pairs such as `EUR/USD`. -This collector downloads those series into qlib-compatible CSV files and then uses -qlib's existing `dump_bin.py` script to convert them into qlib binary data. +FXMacroData provides daily FX spot rates plus macroeconomic announcement, +release-calendar, and forecast data for currency-driven research. This collector +downloads those series into qlib-compatible CSV files and then uses qlib's +existing `dump_bin.py` script to convert them into qlib binary data. ## Requirements @@ -43,6 +44,49 @@ FX spot data is shaped with `open`, `high`, `low`, and `close` equal to the dail spot rate. `volume` is set to `0`, `factor` is set to `1`, and `change` is the daily percentage change in `close`. +## Download Macro Announcement Features + +Realized announcement values are available through the announcements dataset: + +```bash +python scripts/data_collector/fxmacrodata/collector.py download_macro_data \ + --source_dir ~/.qlib/fxmacrodata/macro_source \ + --dataset announcements \ + --currencies usd \ + --indicators inflation,policy_rate,non_farm_payrolls \ + --start 2025-01-01 \ + --end 2026-01-01 +``` + +Upcoming official release-calendar rows and forecast groups use the same command +with a different `--dataset`: + +```bash +python scripts/data_collector/fxmacrodata/collector.py download_macro_data \ + --source_dir ~/.qlib/fxmacrodata/calendar_source \ + --dataset calendar \ + --currencies usd \ + --indicators inflation,policy_rate + +python scripts/data_collector/fxmacrodata/collector.py download_macro_data \ + --source_dir ~/.qlib/fxmacrodata/prediction_source \ + --dataset predictions \ + --currencies usd \ + --indicators inflation +``` + +Macro files use symbols such as `usd_inflation` and include numeric features +such as `value`, `actual`, `consensus`, `forecast`, `surprise`, `prediction`, +`prediction_count`, `announcement_datetime`, and `is_future`. + +Normalize the macro CSVs before dumping them: + +```bash +python scripts/data_collector/fxmacrodata/collector.py normalize_macro_data \ + --source_dir ~/.qlib/fxmacrodata/macro_source \ + --normalize_dir ~/.qlib/fxmacrodata/macro_normalize +``` + ## Dump To qlib Format ```bash @@ -62,4 +106,5 @@ from qlib.data import D qlib.init(provider_uri="~/.qlib/qlib_data/fxmacrodata", region="us") df = D.features(["eurusd", "gbpusd"], ["$close", "$change"], freq="day") +macro = D.features(["usd_inflation"], ["$value", "$forecast", "$announcement_datetime"], freq="day") ``` diff --git a/scripts/data_collector/fxmacrodata/collector.py b/scripts/data_collector/fxmacrodata/collector.py index 95294975a88..f1b2c9b8638 100644 --- a/scripts/data_collector/fxmacrodata/collector.py +++ b/scripts/data_collector/fxmacrodata/collector.py @@ -13,7 +13,7 @@ CUR_DIR = Path(__file__).resolve().parent sys.path.append(str(CUR_DIR.parent.parent)) -from data_collector.base import BaseCollector, BaseNormalize, BaseRun +from data_collector.base import BaseCollector, BaseNormalize, BaseRun, Normalize DEFAULT_BASE_URL = "https://fxmacrodata.com/api/v1" DEFAULT_PAIRS = ( @@ -25,6 +25,14 @@ "USDCHF", "NZDUSD", ) +DEFAULT_MACRO_CURRENCIES = ("usd",) +DEFAULT_MACRO_INDICATORS = ( + "inflation", + "policy_rate", + "unemployment", + "non_farm_payrolls", + "gdp", +) API_KEY_ENV_VARS = ("FXMACRODATA_API_KEY", "FXMD_API_KEY") OUTPUT_COLUMNS = [ "date", @@ -37,6 +45,25 @@ "factor", "change", ] +MACRO_DATASETS = ("announcements", "calendar", "predictions") +MACRO_OUTPUT_COLUMNS = [ + "date", + "symbol", + "value", + "actual", + "previous", + "revised_previous", + "consensus", + "forecast", + "surprise", + "surprise_zscore", + "prediction", + "prediction_count", + "announcement_datetime", + "release_confirmed", + "is_future", +] +MACRO_NUMERIC_COLUMNS = [col for col in MACRO_OUTPUT_COLUMNS if col not in {"date", "symbol"}] class FXMacroDataCollector(BaseCollector): @@ -59,9 +86,7 @@ def __init__( timeout: float = 30, ): if interval != self.INTERVAL_1d: - raise ValueError( - "FXMacroDataCollector supports daily data only: --interval 1d" - ) + raise ValueError("FXMacroDataCollector supports daily data only: --interval 1d") self.pairs = self._normalize_pairs(pairs) self.api_key = api_key or self._get_env_api_key() @@ -94,9 +119,7 @@ def get_data( end_datetime: pd.Timestamp, ) -> pd.DataFrame: if interval != self.INTERVAL_1d: - raise ValueError( - "FXMacroDataCollector supports daily data only: --interval 1d" - ) + raise ValueError("FXMacroDataCollector supports daily data only: --interval 1d") pair = self._normalize_pair(symbol) base, quote = self._split_pair(pair) @@ -133,9 +156,7 @@ def _normalize_pair(pair: str) -> str: pair = pair[:-2] pair = pair.replace("/", "").replace("-", "").replace("_", "") if len(pair) != 6 or not pair.isalpha(): - raise ValueError( - "FXMacroData pairs must look like EURUSD or EUR/USD" - ) + raise ValueError("FXMacroData pairs must look like EURUSD or EUR/USD") return pair @staticmethod @@ -186,11 +207,7 @@ def _rows_to_frame(cls, pair: str, rows: list) -> pd.DataFrame: if not records: return pd.DataFrame(columns=OUTPUT_COLUMNS) df = pd.DataFrame(records) - df = ( - df.drop_duplicates("date") - .sort_values("date") - .reset_index(drop=True) - ) + df = df.drop_duplicates("date").sort_values("date").reset_index(drop=True) df["change"] = df["close"].ffill().pct_change().fillna(0.0) df["date"] = df["date"].dt.strftime("%Y-%m-%d") return df[OUTPUT_COLUMNS] @@ -204,6 +221,190 @@ def _extract_rate(row: dict) -> Optional[float]: return None +class FXMacroDataMacroCollector(BaseCollector): + """Collect macro announcements, release calendars, or forecasts.""" + + def __init__( + self, + save_dir: [str, Path], + start=None, + end=None, + interval="1d", + max_workers=1, + max_collector_count=2, + delay=0, + check_data_length: int = None, + limit_nums: int = None, + dataset: str = "announcements", + currencies: [str, Sequence[str]] = DEFAULT_MACRO_CURRENCIES, + indicators: [str, Sequence[str]] = DEFAULT_MACRO_INDICATORS, + api_key: Optional[str] = None, + base_url: str = DEFAULT_BASE_URL, + timeout: float = 30, + ): + if interval != self.INTERVAL_1d: + raise ValueError("FXMacroDataMacroCollector supports daily data only: --interval 1d") + dataset = dataset.lower() + if dataset not in MACRO_DATASETS: + raise ValueError(f"dataset must be one of {MACRO_DATASETS}") + + self.dataset = dataset + self.currencies = self._normalize_list(currencies, lower=True) + self.indicators = self._normalize_list(indicators, lower=True) + self.api_key = api_key or FXMacroDataCollector._get_env_api_key() + self.base_url = base_url.rstrip("/") + self.timeout = timeout + + super(FXMacroDataMacroCollector, self).__init__( + save_dir=save_dir, + start=start, + end=end, + interval=interval, + max_workers=max_workers, + max_collector_count=max_collector_count, + delay=delay, + check_data_length=check_data_length, + limit_nums=limit_nums, + ) + + def get_instrument_list(self): + return [f"{currency}:{indicator}" for currency in self.currencies for indicator in self.indicators] + + def normalize_symbol(self, symbol: str): + currency, indicator = self._split_macro_symbol(symbol) + return f"{currency}_{indicator}" + + def get_data( + self, + symbol: str, + interval: str, + start_datetime: pd.Timestamp, + end_datetime: pd.Timestamp, + ) -> pd.DataFrame: + if interval != self.INTERVAL_1d: + raise ValueError("FXMacroDataMacroCollector supports daily data only: --interval 1d") + + currency, indicator = self._split_macro_symbol(symbol) + params = { + "start_date": FXMacroDataCollector._format_date(start_datetime), + "end_date": FXMacroDataCollector._format_date(end_datetime), + } + + if self.dataset == "announcements": + rows = self._request_rows(f"announcements/{currency}/{indicator}", params) + elif self.dataset == "calendar": + rows = self._request_rows(f"calendar/{currency}", {**params, "indicator": indicator}) + else: + rows = self._request_rows(f"predictions/{currency}/{indicator}", params) + return self._rows_to_macro_frame(currency, indicator, rows) + + def _request_rows(self, path: str, params: dict) -> list: + headers = {} + if self.api_key: + headers["X-API-Key"] = self.api_key + response = requests.get( + f"{self.base_url}/{path}", + params={k: v for k, v in params.items() if v is not None}, + headers=headers, + timeout=self.timeout, + ) + response.raise_for_status() + return FXMacroDataCollector._payload_rows(response.json()) + + @staticmethod + def _normalize_list(values: [str, Sequence[str], None], lower=False) -> List[str]: + if values is None: + return [] + if isinstance(values, str): + values = [value.strip() for value in values.split(",") if value.strip()] + out = [str(value).strip() for value in values if str(value).strip()] + return [value.lower() for value in out] if lower else out + + @staticmethod + def _split_macro_symbol(symbol: str) -> Tuple[str, str]: + normalized = symbol.strip().lower() + if ":" in normalized: + currency, indicator = normalized.split(":", 1) + else: + currency, indicator = normalized.split("_", 1) + return currency, indicator + + @classmethod + def _rows_to_macro_frame(cls, currency: str, indicator: str, rows: list) -> pd.DataFrame: + records = [] + symbol = f"{currency}_{indicator}" + for row in rows: + date = row.get("date") or row.get("release_date") + if date is None and row.get("announcement_datetime"): + date = pd.to_datetime(row["announcement_datetime"], unit="s", utc=True) + if date is None: + continue + prediction, prediction_count = cls._prediction_summary(row) + actual = cls._number(row.get("actual")) + value = cls._number(row.get("val") or row.get("value")) + if value is None: + value = actual + records.append( + { + "date": pd.Timestamp(date), + "symbol": symbol, + "value": value, + "actual": actual if actual is not None else value, + "previous": cls._number(row.get("previous")), + "revised_previous": cls._number(row.get("revised_previous")), + "consensus": cls._number(row.get("consensus") or row.get("expected") or row.get("estimate")), + "forecast": cls._number(row.get("forecast")), + "surprise": cls._number(row.get("surprise")), + "surprise_zscore": cls._number(row.get("surprise_zscore")), + "prediction": prediction, + "prediction_count": prediction_count, + "announcement_datetime": cls._int(row.get("announcement_datetime")), + "release_confirmed": 1.0 if row.get("release_date_confirmed") is True else 0.0, + "is_future": ( + 1.0 + if row.get("announcement_timing") == "future" or row.get("actual_available") is False + else 0.0 + ), + } + ) + if not records: + return pd.DataFrame(columns=MACRO_OUTPUT_COLUMNS) + df = pd.DataFrame(records) + df = df.drop_duplicates(["date", "symbol"]).sort_values("date") + df["date"] = df["date"].dt.strftime("%Y-%m-%d") + return df[MACRO_OUTPUT_COLUMNS] + + @classmethod + def _prediction_summary(cls, row: dict) -> Tuple[Optional[float], float]: + predictions = row.get("predictions") + if isinstance(predictions, list) and predictions: + prediction = cls._number(predictions[0].get("predicted_value")) + return prediction, float(len(predictions)) + for key in ("forecast_prediction", "consensus_prediction"): + prediction = row.get(key) + if isinstance(prediction, dict): + return cls._number(prediction.get("predicted_value")), 1.0 + return None, 0.0 + + @staticmethod + def _number(value) -> Optional[float]: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _int(value) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + class FXMacroDataNormalize(BaseNormalize): """Normalize FXMacroData CSVs for qlib dump_bin.""" @@ -216,30 +417,44 @@ def normalize(self, df: pd.DataFrame) -> pd.DataFrame: df = df.copy() df[self._date_field_name] = pd.to_datetime(df[self._date_field_name]) - df = df.drop_duplicates(self._date_field_name).sort_values( - self._date_field_name - ) + df = df.drop_duplicates(self._date_field_name).sort_values(self._date_field_name) df["close"] = pd.to_numeric(df["close"], errors="coerce") df = df.dropna(subset=["close"]) for column in ("open", "high", "low"): if column not in df.columns: df[column] = df["close"] - df[column] = pd.to_numeric( - df[column], errors="coerce" - ).fillna(df["close"]) + df[column] = pd.to_numeric(df[column], errors="coerce").fillna(df["close"]) df["volume"] = 0.0 df["factor"] = 1.0 df["change"] = df["close"].ffill().pct_change().fillna(0.0) - df[self._date_field_name] = df[self._date_field_name].dt.strftime( - "%Y-%m-%d" - ) - df[self._symbol_field_name] = ( - df[self._symbol_field_name].astype(str).str.lower() - ) + df[self._date_field_name] = df[self._date_field_name].dt.strftime("%Y-%m-%d") + df[self._symbol_field_name] = df[self._symbol_field_name].astype(str).str.lower() return df[OUTPUT_COLUMNS] +class FXMacroDataMacroNormalize(BaseNormalize): + """Normalize FXMacroData macro features for qlib dump_bin.""" + + def _get_calendar_list(self) -> Iterable[pd.Timestamp]: + return [] + + def normalize(self, df: pd.DataFrame) -> pd.DataFrame: + if df.empty: + return df + df = df.copy() + df[self._date_field_name] = pd.to_datetime(df[self._date_field_name]) + df = df.drop_duplicates([self._date_field_name, self._symbol_field_name]) + df = df.sort_values([self._date_field_name, self._symbol_field_name]) + df[self._symbol_field_name] = df[self._symbol_field_name].astype(str).str.lower() + for column in MACRO_NUMERIC_COLUMNS: + if column not in df.columns: + df[column] = None + df[column] = pd.to_numeric(df[column], errors="coerce") + df[self._date_field_name] = df[self._date_field_name].dt.strftime("%Y-%m-%d") + return df[MACRO_OUTPUT_COLUMNS] + + class Run(BaseRun): def download_data( self, @@ -269,17 +484,60 @@ def download_data( timeout=timeout, ) - def normalize_data( - self, date_field_name: str = "date", symbol_field_name: str = "symbol" + def download_macro_data( + self, + max_collector_count=2, + delay=0, + start=None, + end=None, + check_data_length: int = None, + limit_nums=None, + dataset: str = "announcements", + currencies: str = ",".join(DEFAULT_MACRO_CURRENCIES), + indicators: str = ",".join(DEFAULT_MACRO_INDICATORS), + api_key: Optional[str] = None, + base_url: str = DEFAULT_BASE_URL, + timeout: float = 30, ): + """Download FXMacroData announcements, calendars, or predictions.""" + + FXMacroDataMacroCollector( + self.source_dir, + max_workers=self.max_workers, + max_collector_count=max_collector_count, + delay=delay, + start=start, + end=end, + interval=self.interval, + check_data_length=check_data_length, + limit_nums=limit_nums, + dataset=dataset, + currencies=currencies, + indicators=indicators, + api_key=api_key, + base_url=base_url, + timeout=timeout, + ).collector_data() + + def normalize_data(self, date_field_name: str = "date", symbol_field_name: str = "symbol"): """Normalize FXMacroData daily data.""" if self.interval != "1d": - raise ValueError( - "FXMacroData collector supports daily data only: --interval 1d" - ) + raise ValueError("FXMacroData collector supports daily data only: --interval 1d") super(Run, self).normalize_data(date_field_name, symbol_field_name) + def normalize_macro_data(self, date_field_name: str = "date", symbol_field_name: str = "symbol"): + """Normalize FXMacroData macro feature CSVs.""" + + Normalize( + source_dir=self.source_dir, + target_dir=self.normalize_dir, + normalize_class=FXMacroDataMacroNormalize, + max_workers=self.max_workers, + date_field_name=date_field_name, + symbol_field_name=symbol_field_name, + ).normalize() + @property def collector_class_name(self): return "FXMacroDataCollector" diff --git a/tests/misc/test_fxmacrodata_collector.py b/tests/misc/test_fxmacrodata_collector.py new file mode 100644 index 00000000000..5381aa36fa8 --- /dev/null +++ b/tests/misc/test_fxmacrodata_collector.py @@ -0,0 +1,102 @@ +import importlib.util +import sys +import types +from pathlib import Path +from unittest.mock import patch + +import pandas as pd + +ROOT_DIR = Path(__file__).resolve().parents[2] + + +def load_fxmacrodata_collector(): + fire_module = types.ModuleType("fire") + fire_module.Fire = lambda *_args, **_kwargs: None + + base_module = types.ModuleType("data_collector.base") + + class BaseCollector: + INTERVAL_1d = "1d" + + class BaseNormalize: + def __init__(self, date_field_name="date", symbol_field_name="symbol", **kwargs): + self._date_field_name = date_field_name + self._symbol_field_name = symbol_field_name + self.kwargs = kwargs + + class BaseRun: + pass + + class Normalize: + pass + + base_module.BaseCollector = BaseCollector + base_module.BaseNormalize = BaseNormalize + base_module.BaseRun = BaseRun + base_module.Normalize = Normalize + + package_module = types.ModuleType("data_collector") + package_module.__path__ = [str(ROOT_DIR / "scripts" / "data_collector")] + stubs = { + "fire": fire_module, + "data_collector": package_module, + "data_collector.base": base_module, + } + collector_path = ROOT_DIR / "scripts" / "data_collector" / "fxmacrodata" / "collector.py" + spec = importlib.util.spec_from_file_location("fxmacrodata_collector", collector_path) + module = importlib.util.module_from_spec(spec) + with patch.dict(sys.modules, stubs): + spec.loader.exec_module(module) + return module + + +def test_macro_rows_to_frame_flattens_announcement_and_prediction_data(): + collector = load_fxmacrodata_collector() + + data = collector.FXMacroDataMacroCollector._rows_to_macro_frame( + "usd", + "inflation", + [ + { + "date": "2026-05-31", + "val": 4.2, + "announcement_datetime": 1781094600, + "consensus": 3.9, + "forecast": 4.0, + "surprise": 0.3, + "predictions": [{"predicted_value": 4.1}], + } + ], + ) + + assert list(data.columns) == collector.MACRO_OUTPUT_COLUMNS + assert data.loc[0, "symbol"] == "usd_inflation" + assert data.loc[0, "value"] == 4.2 + assert data.loc[0, "consensus"] == 3.9 + assert data.loc[0, "prediction"] == 4.1 + assert data.loc[0, "prediction_count"] == 1.0 + assert data.loc[0, "announcement_datetime"] == 1781094600 + + +def test_macro_normalize_keeps_numeric_feature_columns(): + collector = load_fxmacrodata_collector() + normalizer = collector.FXMacroDataMacroNormalize() + + data = normalizer.normalize( + pd.DataFrame( + { + "date": ["2026-05-31"], + "symbol": ["USD_INFLATION"], + "value": ["4.2"], + "actual": ["4.2"], + "prediction": ["4.1"], + "announcement_datetime": ["1781094600"], + } + ) + ) + + assert data.loc[0, "date"] == "2026-05-31" + assert data.loc[0, "symbol"] == "usd_inflation" + assert data.loc[0, "value"] == 4.2 + assert data.loc[0, "prediction"] == 4.1 + assert data.loc[0, "announcement_datetime"] == 1781094600 From d29d09750b3d73014bab720cc3293de89dd767b2 Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Wed, 8 Jul 2026 14:59:55 +1000 Subject: [PATCH 3/4] Use canonical FXMacroData API base --- scripts/data_collector/fxmacrodata/collector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/data_collector/fxmacrodata/collector.py b/scripts/data_collector/fxmacrodata/collector.py index f1b2c9b8638..b4d843c4365 100644 --- a/scripts/data_collector/fxmacrodata/collector.py +++ b/scripts/data_collector/fxmacrodata/collector.py @@ -15,7 +15,7 @@ from data_collector.base import BaseCollector, BaseNormalize, BaseRun, Normalize -DEFAULT_BASE_URL = "https://fxmacrodata.com/api/v1" +DEFAULT_BASE_URL = "https://api.fxmacrodata.com/v1" DEFAULT_PAIRS = ( "EURUSD", "GBPUSD", From 6df1c066ec16e87989c4660d32bc426423942bd8 Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Wed, 8 Jul 2026 15:46:40 +1000 Subject: [PATCH 4/4] Improve FXMacroData catalogue and access guidance --- scripts/data_collector/README.md | 2 +- scripts/data_collector/fxmacrodata/README.md | 50 ++++++ .../data_collector/fxmacrodata/collector.py | 156 ++++++++++++++++-- tests/misc/test_fxmacrodata_collector.py | 91 ++++++++++ 4 files changed, 288 insertions(+), 11 deletions(-) diff --git a/scripts/data_collector/README.md b/scripts/data_collector/README.md index daa15f29d5f..ef7958c3063 100644 --- a/scripts/data_collector/README.md +++ b/scripts/data_collector/README.md @@ -5,7 +5,7 @@ Scripts for data collection - yahoo: get *US/CN* stock data from *Yahoo Finance* -- fxmacrodata: get daily *FX spot rates* and *macro announcement features* from *FXMacroData* +- fxmacrodata: get daily *FX spot rates*, *macro announcement features*, and catalogue metadata from *FXMacroData* - fund: get fund data from *http://fund.eastmoney.com* - cn_index: get *CN index* from *http://www.csindex.com.cn*, *CSI300*/*CSI100* - us_index: get *US index* from *https://en.wikipedia.org/wiki*, *SP500*/*NASDAQ100*/*DJIA*/*SP400* diff --git a/scripts/data_collector/fxmacrodata/README.md b/scripts/data_collector/fxmacrodata/README.md index ff317931e53..ea9eca103ca 100644 --- a/scripts/data_collector/fxmacrodata/README.md +++ b/scripts/data_collector/fxmacrodata/README.md @@ -5,6 +5,9 @@ release-calendar, and forecast data for currency-driven research. This collector downloads those series into qlib-compatible CSV files and then uses qlib's existing `dump_bin.py` script to convert them into qlib binary data. +The collector targets the canonical FXMacroData API base +`https://api.fxmacrodata.com/v1`. + ## Requirements ```bash @@ -18,6 +21,26 @@ export FXMACRODATA_API_KEY="" ``` `FXMD_API_KEY` is also supported. You can also pass `--api_key` to the collector. +Calendar, data-catalogue, and some USD evaluation endpoints are public. Broader +history, higher request budgets, non-USD announcement history, COT, commodities, +and other protected datasets require a subscribed API key. See +https://fxmacrodata.com/subscribe for plans and +https://fxmacrodata.com/documentation for endpoint coverage. + +## Discover Available Macro Features + +Use the catalogue command before building a feature set. It writes one row per +currency/indicator with source and coverage metadata where the API exposes it: + +```bash +python scripts/data_collector/fxmacrodata/collector.py download_catalogue \ + --source_dir ~/.qlib/fxmacrodata/catalogue \ + --currencies usd,eur,gbp \ + --include_coverage true +``` + +The output file is `data_catalogue.csv`. Use its indicator slugs with +`download_macro_data --indicators ...`. ## Download FX Data @@ -79,6 +102,20 @@ Macro files use symbols such as `usd_inflation` and include numeric features such as `value`, `actual`, `consensus`, `forecast`, `surprise`, `prediction`, `prediction_count`, `announcement_datetime`, and `is_future`. +For broader multi-currency history, pass an authenticated API key and select the +currencies/indicators from `download_catalogue`: + +```bash +python scripts/data_collector/fxmacrodata/collector.py download_macro_data \ + --source_dir ~/.qlib/fxmacrodata/macro_source \ + --dataset announcements \ + --currencies usd,eur,gbp,jpy \ + --indicators inflation,policy_rate,gdp \ + --start 2015-01-01 \ + --end 2026-01-01 \ + --api_key "$FXMACRODATA_API_KEY" +``` + Normalize the macro CSVs before dumping them: ```bash @@ -108,3 +145,16 @@ qlib.init(provider_uri="~/.qlib/qlib_data/fxmacrodata", region="us") df = D.features(["eurusd", "gbpusd"], ["$close", "$change"], freq="day") macro = D.features(["usd_inflation"], ["$value", "$forecast", "$announcement_datetime"], freq="day") ``` + +## Point-In-Time Notes + +Use the `announcements` dataset for historical training targets and realized +macro features. `calendar` and `predictions` are useful for event-risk research, +but they may include future scheduled rows. For backtests, filter on +`$is_future == 0` or join features using `announcement_datetime` so the model +only sees information that would have been known at the simulated time. + +Subscribed users can combine FX spot data, realized announcements, release +calendars, forecasts, COT, commodities, and additional macro endpoints through +the FXMacroData API. Start at https://fxmacrodata.com/subscribe when the public +evaluation endpoints do not cover the required currencies, history, or volume. diff --git a/scripts/data_collector/fxmacrodata/collector.py b/scripts/data_collector/fxmacrodata/collector.py index b4d843c4365..96b27f29412 100644 --- a/scripts/data_collector/fxmacrodata/collector.py +++ b/scripts/data_collector/fxmacrodata/collector.py @@ -16,6 +16,8 @@ from data_collector.base import BaseCollector, BaseNormalize, BaseRun, Normalize DEFAULT_BASE_URL = "https://api.fxmacrodata.com/v1" +DOCUMENTATION_URL = "https://fxmacrodata.com/documentation" +SUBSCRIBE_URL = "https://fxmacrodata.com/subscribe" DEFAULT_PAIRS = ( "EURUSD", "GBPUSD", @@ -137,7 +139,7 @@ def get_data( headers=headers, timeout=self.timeout, ) - response.raise_for_status() + self._raise_for_status(response, f"forex/{base}/{quote}") rows = self._payload_rows(response.json()) return self._rows_to_frame(pair, rows) @@ -184,6 +186,113 @@ def _payload_rows(payload) -> list: return data if isinstance(data, list) else [] return [] + @classmethod + def _request_json( + cls, + base_url: str, + path: str, + params: Optional[dict] = None, + api_key: Optional[str] = None, + timeout: float = 30, + ): + headers = {} + if api_key: + headers["X-API-Key"] = api_key + response = requests.get( + f"{base_url.rstrip('/')}/{path.lstrip('/')}", + params={k: v for k, v in (params or {}).items() if v is not None}, + headers=headers, + timeout=timeout, + ) + cls._raise_for_status(response, path) + return response.json() + + @staticmethod + def _raise_for_status(response, path: str): + try: + response.raise_for_status() + except requests.HTTPError as exc: + detail = FXMacroDataCollector._error_detail(response) + message = f"FXMacroData request failed for {path} with HTTP {response.status_code}" + if detail: + message = f"{message}: {detail}" + if response.status_code in {401, 403}: + message = ( + f"{message}. Set FXMACRODATA_API_KEY or FXMD_API_KEY for authenticated access. " + f"Subscribe at {SUBSCRIBE_URL} for broader history, non-USD macro data, COT, " + "commodities, and higher limits." + ) + elif response.status_code == 404: + message = ( + f"{message}. Check available currencies and indicators with download_catalogue " + f"or {DOCUMENTATION_URL}." + ) + raise requests.HTTPError(message, response=response) from exc + + @staticmethod + def _error_detail(response) -> str: + try: + payload = response.json() + except ValueError: + payload = None + if isinstance(payload, dict): + for key in ("detail", "message", "error"): + value = payload.get(key) + if value is not None: + return str(value) + text = getattr(response, "text", "") or "" + return text.strip()[:300] + + @classmethod + def _catalogue_rows(cls, currency: str, payload) -> list: + if isinstance(payload, dict): + if isinstance(payload.get("data"), dict): + payload = payload["data"] + elif isinstance(payload.get("catalogue"), dict): + payload = payload["catalogue"] + if not isinstance(payload, dict): + return [] + + rows = [] + for indicator, meta in sorted(payload.items()): + if not isinstance(meta, dict): + continue + coverage = meta.get("coverage") if isinstance(meta.get("coverage"), dict) else {} + rows.append( + { + "currency": currency.lower(), + "indicator": indicator, + "name": meta.get("name"), + "unit": meta.get("unit"), + "frequency": meta.get("frequency"), + "source": meta.get("source"), + "source_series_id": meta.get("source_series_id"), + "source_series_name": meta.get("source_series_name"), + "available": coverage.get("available"), + "history_start": ( + meta.get("history_start") + or meta.get("earliest_available_date") + or meta.get("earliest_date") + or coverage.get("history_start") + or coverage.get("earliest_available_date") + or coverage.get("earliest_date") + ), + "latest_available_date": meta.get("latest_available_date") or coverage.get("latest_available_date"), + "has_official_forecast": meta.get("has_official_forecast"), + "requires_api_key": ( + meta.get("requires_api_key") + if meta.get("requires_api_key") is not None + else coverage.get("requires_api_key") + ), + "row_count": coverage.get("row_count"), + "coverage_quality": coverage.get("coverage_quality"), + "freshness_quality": coverage.get("freshness_quality"), + "usable_for_context": coverage.get("usable_for_context"), + "usable_for_signal": coverage.get("usable_for_signal"), + } + ) + return rows + @classmethod def _rows_to_frame(cls, pair: str, rows: list) -> pd.DataFrame: records = [] @@ -299,17 +408,14 @@ def get_data( return self._rows_to_macro_frame(currency, indicator, rows) def _request_rows(self, path: str, params: dict) -> list: - headers = {} - if self.api_key: - headers["X-API-Key"] = self.api_key - response = requests.get( - f"{self.base_url}/{path}", - params={k: v for k, v in params.items() if v is not None}, - headers=headers, + payload = FXMacroDataCollector._request_json( + self.base_url, + path, + params=params, + api_key=self.api_key, timeout=self.timeout, ) - response.raise_for_status() - return FXMacroDataCollector._payload_rows(response.json()) + return FXMacroDataCollector._payload_rows(payload) @staticmethod def _normalize_list(values: [str, Sequence[str], None], lower=False) -> List[str]: @@ -519,6 +625,36 @@ def download_macro_data( timeout=timeout, ).collector_data() + def download_catalogue( + self, + currencies: str = ",".join(DEFAULT_MACRO_CURRENCIES), + include_coverage: bool = True, + api_key: Optional[str] = None, + base_url: str = DEFAULT_BASE_URL, + timeout: float = 30, + output_file: str = "data_catalogue.csv", + ): + """Download FXMacroData indicator catalogue metadata to a CSV file.""" + + rows = [] + resolved_api_key = api_key or FXMacroDataCollector._get_env_api_key() + for currency in FXMacroDataMacroCollector._normalize_list(currencies, lower=True): + payload = FXMacroDataCollector._request_json( + base_url, + f"data_catalogue/{currency}", + params={"include_coverage": str(include_coverage).lower()}, + api_key=resolved_api_key, + timeout=timeout, + ) + rows.extend(FXMacroDataCollector._catalogue_rows(currency, payload)) + + catalogue = pd.DataFrame(rows) + if not catalogue.empty: + catalogue = catalogue.sort_values(["currency", "indicator"]) + output_path = self.source_dir.joinpath(output_file) + catalogue.to_csv(output_path, index=False) + print(f"Saved FXMacroData catalogue to {output_path}") + def normalize_data(self, date_field_name: str = "date", symbol_field_name: str = "symbol"): """Normalize FXMacroData daily data.""" diff --git a/tests/misc/test_fxmacrodata_collector.py b/tests/misc/test_fxmacrodata_collector.py index 5381aa36fa8..23b36feec1e 100644 --- a/tests/misc/test_fxmacrodata_collector.py +++ b/tests/misc/test_fxmacrodata_collector.py @@ -5,10 +5,23 @@ from unittest.mock import patch import pandas as pd +import pytest +import requests ROOT_DIR = Path(__file__).resolve().parents[2] +class FXMacroDataErrorResponse: + status_code = 403 + text = "" + + def json(self): + return {"detail": "Professional API key required"} + + def raise_for_status(self): + raise requests.HTTPError("403 Client Error", response=self) + + def load_fxmacrodata_collector(): fire_module = types.ModuleType("fire") fire_module.Fire = lambda *_args, **_kwargs: None @@ -50,6 +63,14 @@ class Normalize: return module +def test_public_urls_point_to_canonical_api_and_subscription_pages(): + collector = load_fxmacrodata_collector() + + assert collector.DEFAULT_BASE_URL == "https://api.fxmacrodata.com/v1" + assert collector.DOCUMENTATION_URL == "https://fxmacrodata.com/documentation" + assert collector.SUBSCRIBE_URL == "https://fxmacrodata.com/subscribe" + + def test_macro_rows_to_frame_flattens_announcement_and_prediction_data(): collector = load_fxmacrodata_collector() @@ -78,6 +99,76 @@ def test_macro_rows_to_frame_flattens_announcement_and_prediction_data(): assert data.loc[0, "announcement_datetime"] == 1781094600 +def test_catalogue_rows_flatten_indicator_metadata(): + collector = load_fxmacrodata_collector() + + rows = collector.FXMacroDataCollector._catalogue_rows( + "USD", + { + "currency": "USD", + "catalogue": { + "inflation": { + "name": "Inflation", + "unit": "%", + "frequency": "Monthly", + "source": "BLS", + "source_series_id": "BLS:CPI", + "coverage": { + "available": True, + "earliest_available_date": "2010-01-01", + "latest_available_date": "2026-06-30", + "requires_api_key": False, + "row_count": 100, + "coverage_quality": "complete", + "freshness_quality": "fresh", + "usable_for_context": True, + "usable_for_signal": True, + }, + "has_official_forecast": True, + }, + }, + }, + ) + + assert rows == [ + { + "currency": "usd", + "indicator": "inflation", + "name": "Inflation", + "unit": "%", + "frequency": "Monthly", + "source": "BLS", + "source_series_id": "BLS:CPI", + "source_series_name": None, + "available": True, + "history_start": "2010-01-01", + "latest_available_date": "2026-06-30", + "has_official_forecast": True, + "requires_api_key": False, + "row_count": 100, + "coverage_quality": "complete", + "freshness_quality": "fresh", + "usable_for_context": True, + "usable_for_signal": True, + } + ] + + +def test_authenticated_error_points_users_to_subscription(): + collector = load_fxmacrodata_collector() + + with pytest.raises(collector.requests.HTTPError) as excinfo: + collector.FXMacroDataCollector._raise_for_status( + FXMacroDataErrorResponse(), + "announcements/eur/inflation", + ) + + message = str(excinfo.value) + assert "Professional API key required" in message + assert "Set FXMACRODATA_API_KEY or FXMD_API_KEY" in message + assert "https://fxmacrodata.com/subscribe" in message + + def test_macro_normalize_keeps_numeric_feature_columns(): collector = load_fxmacrodata_collector() normalizer = collector.FXMacroDataMacroNormalize()