broker-sync/tests/test_fx_ecb.py
Viktor Barzin 56f3624344 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>
2026-04-17 19:32:23 +00:00

78 lines
2.5 KiB
Python

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