Add ECB FX fetcher + cache population
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) <noreply@anthropic.com>
This commit is contained in:
parent
18d8241c85
commit
56f3624344
3 changed files with 194 additions and 0 deletions
100
broker_sync/fx_ecb.py
Normal file
100
broker_sync/fx_ecb.py
Normal file
|
|
@ -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.")
|
||||
16
tests/fixtures/ecb_2026-04-01.xml
vendored
Normal file
16
tests/fixtures/ecb_2026-04-01.xml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gesmes:Envelope xmlns:gesmes="http://www.gesmes.org/xml/2002-08-01" xmlns="http://www.ecb.int/vocabulary/2002-08-01/eurofxref">
|
||||
<gesmes:subject>Reference rates</gesmes:subject>
|
||||
<gesmes:Sender>
|
||||
<gesmes:name>European Central Bank</gesmes:name>
|
||||
</gesmes:Sender>
|
||||
<Cube>
|
||||
<Cube time="2026-04-01">
|
||||
<Cube currency="USD" rate="1.0823"/>
|
||||
<Cube currency="GBP" rate="0.8564"/>
|
||||
<Cube currency="JPY" rate="163.45"/>
|
||||
<Cube currency="CHF" rate="0.9654"/>
|
||||
<Cube currency="PLN" rate="4.2850"/>
|
||||
</Cube>
|
||||
</Cube>
|
||||
</gesmes:Envelope>
|
||||
78
tests/test_fx_ecb.py
Normal file
78
tests/test_fx_ecb.py
Normal file
|
|
@ -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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue