79 lines
2.5 KiB
Python
79 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))
|