From 057389faa94c8d6cf86bf4f19b2003957e380f8a Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Wed, 8 Jul 2026 20:49:42 +1000 Subject: [PATCH 1/4] 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 92d72565f1253493c874a2135a3136f5d861874b Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Wed, 26 Aug 2026 12:12:30 +1000 Subject: [PATCH 2/4] 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 bb4922c1d91dec324322304740b24ae422a78afb Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Wed, 26 Aug 2026 12:34:58 +1000 Subject: [PATCH 3/4] 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, + ) From 1a8b6c8b8f2db306b42f056406cfb8c1f8aba5a0 Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Mon, 5 Oct 2026 20:51:28 +1100 Subject: [PATCH 4/4] fix(fetch): keep FXMacroData key off redirects and reject malformed payloads --- quantmind/preprocess/fetch/fxmacrodata.py | 37 ++++++++++++-- tests/preprocess/fetch/test_fxmacrodata.py | 59 ++++++++++++++++++++++ 2 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 tests/preprocess/fetch/test_fxmacrodata.py diff --git a/quantmind/preprocess/fetch/fxmacrodata.py b/quantmind/preprocess/fetch/fxmacrodata.py index 11c7ae4..0d0288c 100644 --- a/quantmind/preprocess/fetch/fxmacrodata.py +++ b/quantmind/preprocess/fetch/fxmacrodata.py @@ -56,22 +56,49 @@ async def fetch_fxmacrodata_calendar( 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)} url = f"{base_url.rstrip('/')}/calendar/{currency_code}" headers = {"User-Agent": "QuantMind/0.2 fxmacrodata-fetch"} + api_key = (api_key or "").strip() if api_key: + if any(ch.isspace() for ch in api_key): + raise ValueError("FXMacroData api_key must not contain whitespace") # 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: + # Redirects are not followed so the key header is never replayed to + # another host; a 3xx surfaces as an HTTPStatusError instead. + async with httpx.AsyncClient( + timeout=timeout, follow_redirects=False + ) as client: response = await client.get(url, params=params, headers=headers) + if response.is_redirect: + raise httpx.HTTPStatusError( + f"FXMacroData returned an unexpected redirect " + f"({response.status_code}) for {url}", + request=response.request, + response=response, + ) response.raise_for_status() - payload: dict[str, Any] = response.json() + try: + payload = response.json() + except ValueError as exc: + raise ValueError( + f"FXMacroData returned a non-JSON response for {url}" + ) from exc + + if not isinstance(payload, dict) or not isinstance( + payload.get("data"), list + ): + detail = payload.get("detail") if isinstance(payload, dict) else None + raise ValueError( + f"FXMacroData returned an unexpected response for {url}" + + (f": {detail}" if detail else "") + ) rows = payload.get("data") rows = rows[:limit_count] if isinstance(rows, list) else [] @@ -81,7 +108,9 @@ async def fetch_fxmacrodata_calendar( 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)), + 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"), diff --git a/tests/preprocess/fetch/test_fxmacrodata.py b/tests/preprocess/fetch/test_fxmacrodata.py new file mode 100644 index 0000000..2ed10d6 --- /dev/null +++ b/tests/preprocess/fetch/test_fxmacrodata.py @@ -0,0 +1,59 @@ +"""Tests for preprocess.fetch.fxmacrodata.""" + +import unittest + +import httpx +import respx + +from quantmind.preprocess.fetch.fxmacrodata import fetch_fxmacrodata_calendar + +_URL = "https://api.fxmacrodata.com/v1/calendar/usd" +_KEY = "test-key" + + +class FetchFxmacrodataCalendarTests(unittest.IsolatedAsyncioTestCase): + async def test_parses_rows_and_sends_key_header(self): + with respx.mock(assert_all_called=True) as router: + route = router.get(_URL).mock( + return_value=httpx.Response( + 200, + json={ + "currency": "usd", + "data": [{"release": "cpi", "name": "CPI"}], + }, + ) + ) + calendar = await fetch_fxmacrodata_calendar(api_key=_KEY) + + self.assertEqual(route.calls[0].request.headers["X-API-Key"], _KEY) + self.assertEqual(calendar.currency, "USD") + self.assertEqual(calendar.releases[0].release, "cpi") + + async def test_redirect_is_not_followed_with_key(self): + with respx.mock(assert_all_called=False) as router: + router.get(_URL).mock( + return_value=httpx.Response( + 302, headers={"Location": "https://other.example/x"} + ) + ) + other = router.get("https://other.example/x").mock( + return_value=httpx.Response(200, json={"data": []}) + ) + with self.assertRaises(httpx.HTTPStatusError): + await fetch_fxmacrodata_calendar(api_key=_KEY) + + self.assertFalse(other.called) + + async def test_key_with_whitespace_is_rejected_without_echo(self): + with self.assertRaises(ValueError) as ctx: + await fetch_fxmacrodata_calendar(api_key="test key") + self.assertNotIn("test key", str(ctx.exception)) + + async def test_error_body_with_200_raises_clean_error(self): + for body in ({"detail": "Invalid API key"}, [], {"data": "x"}): + with respx.mock() as router: + router.get(_URL).mock( + return_value=httpx.Response(200, json=body) + ) + with self.assertRaises(ValueError): + await fetch_fxmacrodata_calendar(api_key=_KEY)