"""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.")