Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions quantmind/preprocess/fetch/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
CrossrefMetadata,
resolve_doi,
)
from quantmind.preprocess.fetch.fxmacrodata import (
DEFAULT_FXMACRODATA_BASE_URL,
CalendarRelease,
RawCalendar,
fetch_fxmacrodata_calendar,
)
from quantmind.preprocess.fetch.http import (
DEFAULT_USER_AGENT,
FetchAttemptsExhausted,
Expand All @@ -30,16 +36,20 @@

__all__ = [
"ArxivIdParseError",
"CalendarRelease",
"CrossrefMetadata",
"DEFAULT_FXMACRODATA_BASE_URL",
"DEFAULT_USER_AGENT",
"FeedItem",
"FetchAttemptsExhausted",
"FetchPolicy",
"Fetched",
"HttpFetcher",
"RawCalendar",
"RawFeed",
"RawPaper",
"fetch_arxiv",
"fetch_fxmacrodata_calendar",
"fetch_rss_feed",
"fetch_url",
"parse_feed",
Expand Down
140 changes: 140 additions & 0 deletions quantmind/preprocess/fetch/fxmacrodata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""FXMacroData fetch helpers for macroeconomic context."""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Optional

import httpx

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",
*,
limit: int = 50,
api_key: Optional[str] = None,
base_url: str = DEFAULT_FXMACRODATA_BASE_URL,
timeout: float = 30.0,
) -> RawCalendar:
"""Fetch official release-calendar rows from FXMacroData.

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)}

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() or not ch.isprintable() for ch in api_key):
raise ValueError(
"FXMacroData api_key must not contain whitespace or control characters"
)
# 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

# Redirects are not followed so the key header is never replayed to
# another host; a 3xx surfaces as an HTTPStatusError instead.
try:
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(
"FXMacroData returned an unexpected redirect",
request=response.request,
response=response,
)
response.raise_for_status()
try:
payload = response.json()
except ValueError:
raise ValueError(
"FXMacroData returned a non-JSON response"
) from None
except httpx.HTTPStatusError as exc:
raise httpx.HTTPStatusError(
"FXMacroData request failed (HTTP %s)" % exc.response.status_code,
request=exc.request,
response=exc.response,
) from None
except httpx.RequestError as exc:
raise httpx.RequestError(
"FXMacroData request failed", request=exc.request
) from None

if (
not isinstance(payload, dict)
or not isinstance(payload.get("data"), list)
or not all(isinstance(row, dict) for row in payload["data"])
or payload.get("error")
or payload.get("detail")
):
raise ValueError("FXMacroData returned an unexpected response")

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=row.get("release_date_confirmed") is True,
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,
)
138 changes: 138 additions & 0 deletions tests/preprocess/fetch/test_fxmacrodata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Tests for preprocess.fetch.fxmacrodata."""

import unittest
import traceback

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)

async def test_error_envelope_with_data_is_rejected(self):
with respx.mock() as router:
router.get(_URL).mock(
return_value=httpx.Response(
200, json={"error": "failed", "data": []}
)
)
with self.assertRaises(ValueError):
await fetch_fxmacrodata_calendar(api_key=_KEY)

async def test_malformed_rows_are_not_silently_dropped(self):
with respx.mock() as router:
router.get(_URL).mock(
return_value=httpx.Response(
200, json={"data": [{"release": "cpi"}, "invalid"]}
)
)
with self.assertRaises(ValueError):
await fetch_fxmacrodata_calendar(api_key=_KEY)

async def test_confirmation_requires_boolean_true(self):
with respx.mock() as router:
router.get(_URL).mock(
return_value=httpx.Response(
200,
json={
"data": [
{"release_date_confirmed": value}
for value in ["false", "true", 1, False, True]
]
},
)
)
calendar = await fetch_fxmacrodata_calendar(api_key=_KEY)
self.assertEqual(
[row.release_date_confirmed for row in calendar.releases],
[False, False, False, False, True],
)

async def test_response_details_are_not_in_error(self):
with respx.mock() as router:
router.get(_URL).mock(
return_value=httpx.Response(
200, json={"detail": "sensitive upstream text"}
)
)
with self.assertRaises(ValueError) as caught:
await fetch_fxmacrodata_calendar(api_key=_KEY)
self.assertNotIn("sensitive upstream text", str(caught.exception))

async def test_transport_traceback_is_sanitized(self):
with respx.mock() as router:
router.get(_URL).mock(
side_effect=httpx.ConnectError("sensitive upstream text")
)
try:
await fetch_fxmacrodata_calendar(api_key=_KEY)
except httpx.RequestError as error:
self.assertNotIn(
"sensitive upstream text",
"".join(traceback.format_exception(error)),
)
else:
self.fail("Expected transport failure")

async def test_control_character_key_rejected_before_request(self):
with self.assertRaises(ValueError):
await fetch_fxmacrodata_calendar(api_key="test\x00key")

async def test_non_json_response_is_sanitized(self):
with respx.mock() as router:
router.get(_URL).mock(
return_value=httpx.Response(200, content=b"invalid")
)
with self.assertRaises(ValueError) as caught:
await fetch_fxmacrodata_calendar(api_key=_KEY)
self.assertTrue(caught.exception.__suppress_context__)