From 9d62be6af2d17197e60472fb9d3170af35b924ed Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Wed, 8 Jul 2026 20:49:42 +1000 Subject: [PATCH 1/3] Add FXMacroData integration --- quantmind/preprocess/fetch/__init__.py | 6 ++++ quantmind/preprocess/fetch/fxmacrodata.py | 41 +++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 quantmind/preprocess/fetch/fxmacrodata.py diff --git a/quantmind/preprocess/fetch/__init__.py b/quantmind/preprocess/fetch/__init__.py index 1a91da5..9ac4f5f 100644 --- a/quantmind/preprocess/fetch/__init__.py +++ b/quantmind/preprocess/fetch/__init__.py @@ -13,6 +13,10 @@ CrossrefMetadata, resolve_doi, ) +from quantmind.preprocess.fetch.fxmacrodata import ( + DEFAULT_FXMACRODATA_BASE_URL, + fetch_fxmacrodata_calendar, +) from quantmind.preprocess.fetch.http import ( DEFAULT_USER_AGENT, FetchAttemptsExhausted, @@ -31,6 +35,7 @@ __all__ = [ "ArxivIdParseError", "CrossrefMetadata", + "DEFAULT_FXMACRODATA_BASE_URL", "DEFAULT_USER_AGENT", "FeedItem", "FetchAttemptsExhausted", @@ -40,6 +45,7 @@ "RawFeed", "RawPaper", "fetch_arxiv", + "fetch_fxmacrodata_calendar", "fetch_rss_feed", "fetch_url", "parse_feed", diff --git a/quantmind/preprocess/fetch/fxmacrodata.py b/quantmind/preprocess/fetch/fxmacrodata.py new file mode 100644 index 0000000..0719a27 --- /dev/null +++ b/quantmind/preprocess/fetch/fxmacrodata.py @@ -0,0 +1,41 @@ +"""FXMacroData fetch helpers for macroeconomic context.""" + +from __future__ import annotations + +from typing import Any, Optional + +import httpx + +DEFAULT_FXMACRODATA_BASE_URL = "https://fxmacrodata.com/api/v1" + + +async def fetch_fxmacrodata_calendar( + currency: str = "usd", + *, + limit: int = 50, + api_key: Optional[str] = None, + base_url: str = DEFAULT_FXMACRODATA_BASE_URL, + timeout: float = 30.0, +) -> dict[str, Any]: + """Fetch official release-calendar rows from FXMacroData. + + The function returns the parsed JSON payload so callers can preserve + FXMacroData metadata such as data quality, source names, and confirmed + announcement timestamps when building knowledge items. + """ + + limit_count = max(1, min(int(limit), 100)) + params: dict[str, str] = {"limit": str(limit_count)} + if api_key: + params["api_key"] = api_key + + url = f"{base_url.rstrip('/')}/calendar/{currency.lower()}" + headers = {"User-Agent": "QuantMind/0.2 fxmacrodata-fetch"} + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + response = await client.get(url, params=params, headers=headers) + response.raise_for_status() + payload: dict[str, Any] = response.json() + if isinstance(payload.get("data"), list): + payload["data"] = payload["data"][:limit_count] + + return payload From 9cb91da9e8aa0cffe01accbe7241a50121828b1c Mon Sep 17 00:00:00 2001 From: Robert Tidball <57079898+roberttidball@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:12:30 +1000 Subject: [PATCH 2/3] Use the documented FXMacroData API host Switches the base URL from the undocumented fxmacrodata.com/api/v1 alias to the published api.fxmacrodata.com/v1 host, so the client matches the public API reference. --- quantmind/preprocess/fetch/fxmacrodata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quantmind/preprocess/fetch/fxmacrodata.py b/quantmind/preprocess/fetch/fxmacrodata.py index 0719a27..4899bc7 100644 --- a/quantmind/preprocess/fetch/fxmacrodata.py +++ b/quantmind/preprocess/fetch/fxmacrodata.py @@ -6,7 +6,7 @@ import httpx -DEFAULT_FXMACRODATA_BASE_URL = "https://fxmacrodata.com/api/v1" +DEFAULT_FXMACRODATA_BASE_URL = "https://api.fxmacrodata.com/v1" async def fetch_fxmacrodata_calendar( From ee69ce87e2091e2b125e20f18a1eaa476decd285 Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Wed, 26 Aug 2026 12:34:58 +1000 Subject: [PATCH 3/3] Return a frozen dataclass from the FXMacroData fetcher The fetch layer documents that every function is async and returns a frozen dataclass, leaving interpretation to the format layer. This fetcher returned the raw JSON dict instead, so it did not satisfy that contract. It now returns a frozen RawCalendar holding frozen CalendarRelease rows, matching how fetch_rss_feed returns RawFeed and FeedItem. The response envelope (currency, timezone, data quality) is preserved as metadata so the format layer does not need a second request. Row fields now match the documented calendar response: release, name, announcement_datetime_utc, announcement_datetime_local, release_date_confirmed, event_importance, market_tier, source and source_url. The API key is sent as the X-API-Key header rather than a query parameter, so it is not captured in request or proxy access logs. --- quantmind/preprocess/fetch/__init__.py | 4 ++ quantmind/preprocess/fetch/fxmacrodata.py | 80 ++++++++++++++++++++--- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/quantmind/preprocess/fetch/__init__.py b/quantmind/preprocess/fetch/__init__.py index 9ac4f5f..e272fe4 100644 --- a/quantmind/preprocess/fetch/__init__.py +++ b/quantmind/preprocess/fetch/__init__.py @@ -15,6 +15,8 @@ ) from quantmind.preprocess.fetch.fxmacrodata import ( DEFAULT_FXMACRODATA_BASE_URL, + CalendarRelease, + RawCalendar, fetch_fxmacrodata_calendar, ) from quantmind.preprocess.fetch.http import ( @@ -34,6 +36,7 @@ __all__ = [ "ArxivIdParseError", + "CalendarRelease", "CrossrefMetadata", "DEFAULT_FXMACRODATA_BASE_URL", "DEFAULT_USER_AGENT", @@ -42,6 +45,7 @@ "FetchPolicy", "Fetched", "HttpFetcher", + "RawCalendar", "RawFeed", "RawPaper", "fetch_arxiv", diff --git a/quantmind/preprocess/fetch/fxmacrodata.py b/quantmind/preprocess/fetch/fxmacrodata.py index 4899bc7..11c7ae4 100644 --- a/quantmind/preprocess/fetch/fxmacrodata.py +++ b/quantmind/preprocess/fetch/fxmacrodata.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass, field from typing import Any, Optional import httpx @@ -9,6 +10,38 @@ DEFAULT_FXMACRODATA_BASE_URL = "https://api.fxmacrodata.com/v1" +@dataclass(frozen=True, slots=True) +class CalendarRelease: + """One scheduled macroeconomic or central-bank release.""" + + release: str + name: str + announcement_datetime_utc: str | None + announcement_datetime_local: str | None + release_date_confirmed: bool + event_importance: str | None + market_tier: int | None + source: str | None + source_url: str | None + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class RawCalendar: + """Release-calendar payload for a single currency. + + ``releases`` holds the parsed rows; ``metadata`` preserves the FXMacroData + envelope (currency, timezone, data quality) so the format layer can build + knowledge items without a second request. + """ + + currency: str + timezone: str | None + url: str + releases: tuple[CalendarRelease, ...] = () + metadata: dict[str, Any] = field(default_factory=dict) + + async def fetch_fxmacrodata_calendar( currency: str = "usd", *, @@ -16,26 +49,53 @@ async def fetch_fxmacrodata_calendar( api_key: Optional[str] = None, base_url: str = DEFAULT_FXMACRODATA_BASE_URL, timeout: float = 30.0, -) -> dict[str, Any]: +) -> RawCalendar: """Fetch official release-calendar rows from FXMacroData. - The function returns the parsed JSON payload so callers can preserve - FXMacroData metadata such as data quality, source names, and confirmed - announcement timestamps when building knowledge items. + Returns a frozen :class:`RawCalendar` in line with the fetch layer + contract. No parsing beyond splitting rows from the response envelope -- + interpreting the rows is the format layer's job. """ limit_count = max(1, min(int(limit), 100)) + currency_code = currency.lower() params: dict[str, str] = {"limit": str(limit_count)} - if api_key: - params["api_key"] = api_key - url = f"{base_url.rstrip('/')}/calendar/{currency.lower()}" + url = f"{base_url.rstrip('/')}/calendar/{currency_code}" headers = {"User-Agent": "QuantMind/0.2 fxmacrodata-fetch"} + if api_key: + # Sent as a header so the key is never captured in request logs or + # proxy access logs the way a query parameter would be. + headers["X-API-Key"] = api_key + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: response = await client.get(url, params=params, headers=headers) response.raise_for_status() payload: dict[str, Any] = response.json() - if isinstance(payload.get("data"), list): - payload["data"] = payload["data"][:limit_count] - return payload + rows = payload.get("data") + rows = rows[:limit_count] if isinstance(rows, list) else [] + releases = tuple( + CalendarRelease( + release=str(row.get("release", "")), + name=str(row.get("name", "")), + announcement_datetime_utc=row.get("announcement_datetime_utc"), + announcement_datetime_local=row.get("announcement_datetime_local"), + release_date_confirmed=bool(row.get("release_date_confirmed", False)), + event_importance=row.get("event_importance"), + market_tier=row.get("market_tier"), + source=row.get("source"), + source_url=row.get("source_url"), + raw=row, + ) + for row in rows + if isinstance(row, dict) + ) + metadata = {key: value for key, value in payload.items() if key != "data"} + return RawCalendar( + currency=str(payload.get("currency", currency_code)).upper(), + timezone=payload.get("timezone"), + url=url, + releases=releases, + metadata=metadata, + )