101 lines
3.3 KiB
Python
101 lines
3.3 KiB
Python
|
|
"""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.")
|