Context ------- FX for UK users has two lives: live ECB rates for portfolio display (available same-day), and HMRC monthly/daily rates for CGT basis (published after month-end). The plan keeps both in one cache table with an upgradable `source` column, so a later reconciliation job can replace ECB_LIVE values with HMRC_MONTHLY for the same date without schema work. This change ----------- - FxCache: SQLite table (currency, on_date) -> (rate_gbp, source) with ON CONFLICT UPDATE semantics so reconciliation is a single put(). - convert_to_gbp(): GBP short-circuits to identity; any other currency must be in the cache (network fetch is the caller's responsibility, separately implemented by the ECB and HMRC fetchers). - Explicit LookupError on cache miss — deliberate, we do NOT want a silent fallback that produces wrong cost-basis numbers. Decisions deferred to later commits: - Actual ECB daily reference-rate fetcher (eurofxref XML) — lands with the Trading212 provider in Phase 1 when non-GBP trades first appear. - HMRC monthly-rate fetcher + reconciliation CronJob — Phase 1 tail. Test plan --------- ## Automated - poetry run pytest tests/test_fx.py -v → 6 passed - poetry run mypy broker_sync tests → Success: no issues found in 8 source files - poetry run ruff check . → All checks passed! ## Manual Verification Not applicable — no network yet.
62 lines
2.4 KiB
Python
62 lines
2.4 KiB
Python
from datetime import date
|
|
from decimal import Decimal
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from broker_sync.fx import FxCache, convert_to_gbp
|
|
from broker_sync.models import FxRateSource
|
|
|
|
|
|
def test_cache_roundtrip(tmp_path: Path) -> None:
|
|
c = FxCache(tmp_path / "fx.db")
|
|
c.put("USD", date(2026, 4, 1), Decimal("0.79"), FxRateSource.ECB_LIVE)
|
|
hit = c.get("USD", date(2026, 4, 1))
|
|
assert hit is not None
|
|
assert hit[0] == Decimal("0.79")
|
|
assert hit[1] is FxRateSource.ECB_LIVE
|
|
|
|
|
|
def test_cache_miss(tmp_path: Path) -> None:
|
|
c = FxCache(tmp_path / "fx.db")
|
|
assert c.get("USD", date(2026, 4, 1)) is None
|
|
|
|
|
|
def test_put_overwrites_on_source_upgrade(tmp_path: Path) -> None:
|
|
# Reconciliation job upgrades ECB_LIVE → HMRC_MONTHLY for historical rows
|
|
c = FxCache(tmp_path / "fx.db")
|
|
c.put("USD", date(2026, 3, 1), Decimal("0.79"), FxRateSource.ECB_LIVE)
|
|
c.put("USD", date(2026, 3, 1), Decimal("0.791"), FxRateSource.HMRC_MONTHLY)
|
|
hit = c.get("USD", date(2026, 3, 1))
|
|
assert hit is not None
|
|
assert hit[0] == Decimal("0.791")
|
|
assert hit[1] is FxRateSource.HMRC_MONTHLY
|
|
|
|
|
|
def test_gbp_passthrough_is_identity() -> None:
|
|
# currency == GBP → rate is 1.0, no network needed
|
|
amount_gbp, rate, source = convert_to_gbp(amount=Decimal("100"),
|
|
currency="GBP",
|
|
on_date=date(2026, 4, 1),
|
|
cache=None)
|
|
assert amount_gbp == Decimal("100")
|
|
assert rate == Decimal("1")
|
|
assert source is FxRateSource.ECB_LIVE
|
|
|
|
|
|
def test_convert_uses_cached_rate(tmp_path: Path) -> None:
|
|
c = FxCache(tmp_path / "fx.db")
|
|
c.put("USD", date(2026, 4, 1), Decimal("0.80"), FxRateSource.ECB_LIVE)
|
|
amount_gbp, rate, source = convert_to_gbp(amount=Decimal("100"),
|
|
currency="USD",
|
|
on_date=date(2026, 4, 1),
|
|
cache=c)
|
|
assert amount_gbp == Decimal("80.00")
|
|
assert rate == Decimal("0.80")
|
|
assert source is FxRateSource.ECB_LIVE
|
|
|
|
|
|
def test_convert_raises_on_cache_miss(tmp_path: Path) -> None:
|
|
c = FxCache(tmp_path / "fx.db")
|
|
with pytest.raises(LookupError, match="USD.*2026-04-01"):
|
|
convert_to_gbp(amount=Decimal("100"), currency="USD", on_date=date(2026, 4, 1), cache=c)
|