From 56f3624344a4e6c69ee6319697345a1829b2b33a Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Fri, 17 Apr 2026 19:32:23 +0000 Subject: [PATCH] Add ECB FX fetcher + cache population MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context ------- Phase 1 needs live FX rates for USD-denominated RSU vestings (Schwab), EUR-denominated deposits, and any multi-currency dividend. The FxCache from an earlier commit stores (currency, date) → rate_to_gbp but was intentionally left empty — this commit wires the ingestion path. Design decisions ---------------- - ECB publishes EUR→X, not X→GBP. Everything pivots through EUR: rate(X→GBP) = rate(EUR→GBP) / rate(EUR→X) GBP goes into the result at 1.0 so callers iterating the dict get a consistent shape; `populate_fx_cache` then skips GBP because `convert_to_gbp` has a dedicated passthrough branch. - `on_date` parameter is accepted for API symmetry with the future historical fetcher even though the daily endpoint only serves the most recent publication. The docstring calls this out explicitly. - XML is parsed with stdlib `xml.etree.ElementTree`. No `lxml` — the file is 30 lines, no performance concern, and keeping deps minimal matters for the container image. - The HTTP layer takes an optional `httpx.AsyncBaseTransport` the same way WealthfolioSink does — MockTransport drives all tests, the production caller just leaves it None. This change ----------- - broker_sync/fx_ecb.py: * `fetch_ecb_rates(on_date, *, transport=None)` — GETs the daily XML, parses, pivots through EUR, returns `{ccy: rate_to_gbp}`. Raises `RuntimeError` on non-2xx or if GBP is missing (cannot pivot). No retries — caller handles resilience. * `populate_fx_cache(cache, rates, on_date)` — writes every non-GBP rate with `FxRateSource.ECB_LIVE`. * `fetch_ecb_rates_historical(start, end)` — `NotImplementedError` stub; filed as beads task code-thw.2.2. Needed for backfilling years of T212 history (daily endpoint only covers today). - tests/fixtures/ecb_2026-04-01.xml: realistic 5-currency ECB snapshot. - tests/test_fx_ecb.py: fixture-driven tests covering the pivot math, the 503 failure path, the cache-skip-GBP rule, and the NotImplemented guard on the historical stub. Test plan --------- ## Automated - poetry run pytest -q → 52 passed in 0.50s - poetry run mypy broker_sync tests → Success: no issues found in 26 source files - poetry run ruff check . → All checks passed! ## Manual Verification Live daily endpoint hit deferred to the CLI integration commit — the fetcher is pure + transport-injectable, so the unit tests cover the parsing and pivot logic, and the CLI wiring will be the place where the live call is exercised end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) --- broker_sync/fx_ecb.py | 100 ++++++++++++++++++++++++++++++ tests/fixtures/ecb_2026-04-01.xml | 16 +++++ tests/test_fx_ecb.py | 78 +++++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 broker_sync/fx_ecb.py create mode 100644 tests/fixtures/ecb_2026-04-01.xml create mode 100644 tests/test_fx_ecb.py diff --git a/broker_sync/fx_ecb.py b/broker_sync/fx_ecb.py new file mode 100644 index 0000000..2e60c0c --- /dev/null +++ b/broker_sync/fx_ecb.py @@ -0,0 +1,100 @@ +"""ECB daily reference-rate fetcher. + +The ECB publishes today's rates as an XML document at +https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml. Rates are +given as EUR→X, so deriving rate(X→GBP) needs a pivot through EUR: + + rate(X→GBP) = rate(EUR→GBP) / rate(EUR→X) + +The daily file only contains the most recent publication — historical +backfill needs the 90-day (`eurofxref-hist-90d.xml`) or full-history +(`eurofxref-hist.xml`) endpoints. Those are not wired yet because +backfilling years of T212 history is a Phase 1.1 concern; for now +`fetch_ecb_rates_historical` raises NotImplementedError. +""" +from __future__ import annotations + +from datetime import date +from decimal import Decimal +from xml.etree import ElementTree as ET + +import httpx + +from broker_sync.fx import FxCache +from broker_sync.models import FxRateSource + +_ECB_DAILY_URL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml" +_ECB_NS = "{http://www.ecb.int/vocabulary/2002-08-01/eurofxref}" + + +async def fetch_ecb_rates( + on_date: date, + *, + transport: httpx.AsyncBaseTransport | None = None, +) -> dict[str, Decimal]: + """Return `{currency: rate_to_gbp}` for every currency the ECB publishes. + + GBP is present in the result as rate 1.0 (the pivot target). EUR is + present with its direct EUR→GBP rate. Everything else is pivoted + through EUR. + + `on_date` is accepted for API symmetry with the historical fetcher + but the daily endpoint always returns the latest publication — the + caller is responsible for only invoking this for today's date. + """ + del on_date + async with httpx.AsyncClient(transport=transport, timeout=30.0) as c: + resp = await c.get(_ECB_DAILY_URL) + if resp.status_code >= 400: + raise RuntimeError(f"ECB daily fetch failed: HTTP {resp.status_code}") + + eur_rates = _parse_eur_rates(resp.text) + if "GBP" not in eur_rates: + raise RuntimeError("ECB response missing GBP — cannot pivot") + + eur_to_gbp = eur_rates["GBP"] + out: dict[str, Decimal] = {"GBP": Decimal("1"), "EUR": eur_to_gbp} + for ccy, eur_to_ccy in eur_rates.items(): + if ccy == "GBP": + continue + out[ccy] = eur_to_gbp / eur_to_ccy + return out + + +def _parse_eur_rates(xml_text: str) -> dict[str, Decimal]: + root = ET.fromstring(xml_text) + rates: dict[str, Decimal] = {} + for cube in root.iter(f"{_ECB_NS}Cube"): + ccy = cube.get("currency") + rate = cube.get("rate") + if ccy and rate: + rates[ccy] = Decimal(rate) + return rates + + +async def populate_fx_cache( + cache: FxCache, + rates: dict[str, Decimal], + on_date: date, +) -> None: + """Write non-GBP rates into the FxCache tagged `ECB_LIVE`. + + GBP is intentionally skipped — `convert_to_gbp` short-circuits on + GBP without touching the cache. + """ + for ccy, rate in rates.items(): + if ccy == "GBP": + continue + cache.put(ccy, on_date, rate, FxRateSource.ECB_LIVE) + + +async def fetch_ecb_rates_historical( + start: date, + end: date, +) -> dict[date, dict[str, Decimal]]: + """Placeholder for the 90-day / full-history ECB endpoints. + + Tracked by beads task: see parent Phase 1 epic. + """ + raise NotImplementedError("Historical ECB fetch not wired; needs eurofxref-hist-90d.xml or " + "eurofxref-hist.xml — filed as a follow-up beads task.") diff --git a/tests/fixtures/ecb_2026-04-01.xml b/tests/fixtures/ecb_2026-04-01.xml new file mode 100644 index 0000000..f5cf3ec --- /dev/null +++ b/tests/fixtures/ecb_2026-04-01.xml @@ -0,0 +1,16 @@ + + + Reference rates + + European Central Bank + + + + + + + + + + + diff --git a/tests/test_fx_ecb.py b/tests/test_fx_ecb.py new file mode 100644 index 0000000..3d39c42 --- /dev/null +++ b/tests/test_fx_ecb.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from datetime import date +from decimal import Decimal +from pathlib import Path + +import httpx +import pytest + +from broker_sync.fx import FxCache +from broker_sync.fx_ecb import ( + fetch_ecb_rates, + fetch_ecb_rates_historical, + populate_fx_cache, +) +from broker_sync.models import FxRateSource + +_FIXTURE = Path(__file__).parent / "fixtures" / "ecb_2026-04-01.xml" + + +def _transport_serving_fixture() -> httpx.MockTransport: + xml = _FIXTURE.read_text() + + def handler(req: httpx.Request) -> httpx.Response: + assert req.url.host == "www.ecb.europa.eu" + assert req.url.path.endswith("/eurofxref-daily.xml") + return httpx.Response(200, content=xml, headers={"content-type": "application/xml"}) + + return httpx.MockTransport(handler) + + +async def test_fetch_returns_dict_of_decimals() -> None: + rates = await fetch_ecb_rates(date(2026, 4, 1), transport=_transport_serving_fixture()) + # GBP is rebased to 1.0 (everything else is rate_to_GBP). + assert rates["GBP"] == Decimal("1") + # USD: ECB says EUR→USD = 1.0823, EUR→GBP = 0.8564. + # Therefore USD→GBP = 0.8564 / 1.0823 ≈ 0.7913... + assert rates["USD"] == Decimal("0.8564") / Decimal("1.0823") + # EUR→GBP = 0.8564 directly. + assert rates["EUR"] == Decimal("0.8564") + # JPY is a tiny number: 0.8564 / 163.45 ≈ 0.00524 + assert rates["JPY"] == Decimal("0.8564") / Decimal("163.45") + + +async def test_fetch_rejects_non_2xx() -> None: + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(503) + + with pytest.raises(RuntimeError, match="ECB"): + await fetch_ecb_rates(date(2026, 4, 1), transport=httpx.MockTransport(handler)) + + +async def test_populate_fx_cache_writes_non_gbp(tmp_path: Path) -> None: + cache = FxCache(tmp_path / "fx.db") + rates = { + "GBP": Decimal("1"), + "USD": Decimal("0.79"), + "EUR": Decimal("0.86"), + } + await populate_fx_cache(cache, rates, date(2026, 4, 1)) + + # GBP is not stored — convert_to_gbp handles it via the passthrough rule. + assert cache.get("GBP", date(2026, 4, 1)) is None + + usd = cache.get("USD", date(2026, 4, 1)) + assert usd is not None + assert usd[0] == Decimal("0.79") + assert usd[1] is FxRateSource.ECB_LIVE + + eur = cache.get("EUR", date(2026, 4, 1)) + assert eur is not None + assert eur[0] == Decimal("0.86") + + +async def test_fetch_historical_raises_not_implemented() -> None: + with pytest.raises(NotImplementedError): + await fetch_ecb_rates_historical(date(2020, 1, 1), date(2026, 1, 1))