From f306dc9605301911a9487256e103cbb6d34b04d8 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Fri, 17 Apr 2026 19:20:12 +0000 Subject: [PATCH] Add Provider protocol and normaliser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context ------- Every broker connector needs a uniform shape so the orchestrator can fan out without knowing provider-specific details. Normalisation (GBP conversion) lives outside providers on purpose — keeping providers native-currency-emitters means we can re-normalise historical activity when HMRC rates land without re-fetching from the broker. This change ----------- - providers/base.py: Provider Protocol with `accounts()` and async `fetch(since, before)` iterator. No abstract base class — duck-typed Protocol so each concrete provider stays independent. - normaliser.py: takes a native Activity + FxCache, returns a copy with amount_gbp/fx_rate_gbp/fx_rate_source filled in. Two modes: qty*price for BUY/SELL, amount for DIVIDEND/DEPOSIT/etc. - Namespace packages for providers/, providers/parsers/, sinks/ so future modules slot in cleanly. Test plan --------- ## Automated - poetry run pytest -q → 23 passed - poetry run mypy broker_sync tests → Success: no issues found in 14 source files - poetry run ruff check . → All checks passed! ## Manual Verification Not applicable at this layer. --- broker_sync/normaliser.py | 35 +++++++++++ broker_sync/providers/__init__.py | 0 broker_sync/providers/base.py | 32 ++++++++++ broker_sync/providers/parsers/__init__.py | 0 broker_sync/sinks/__init__.py | 0 tests/test_normaliser.py | 74 +++++++++++++++++++++++ 6 files changed, 141 insertions(+) create mode 100644 broker_sync/normaliser.py create mode 100644 broker_sync/providers/__init__.py create mode 100644 broker_sync/providers/base.py create mode 100644 broker_sync/providers/parsers/__init__.py create mode 100644 broker_sync/sinks/__init__.py create mode 100644 tests/test_normaliser.py diff --git a/broker_sync/normaliser.py b/broker_sync/normaliser.py new file mode 100644 index 0000000..6fcca18 --- /dev/null +++ b/broker_sync/normaliser.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from dataclasses import replace +from decimal import Decimal + +from broker_sync.fx import FxCache, convert_to_gbp +from broker_sync.models import Activity, ActivityType + +_QTY_PRICE_TYPES = {ActivityType.BUY, ActivityType.SELL} + + +def normalise_to_gbp(activity: Activity, *, cache: FxCache) -> Activity: + """Return a copy of `activity` with amount_gbp/fx_rate_gbp/fx_rate_source set. + + Two cases: + - BUY/SELL: amount_gbp = quantity * unit_price * rate. + - Everything else (DIVIDEND/DEPOSIT/FEE/...): amount_gbp = amount * rate. + + Source is always the cache's source tag (ECB_LIVE or HMRC_MONTHLY). + """ + on_date = activity.date.date() + if activity.activity_type in _QTY_PRICE_TYPES: + assert activity.quantity is not None and activity.unit_price is not None + native_total: Decimal = activity.quantity * activity.unit_price + else: + assert activity.amount is not None + native_total = activity.amount + + amount_gbp, rate, source = convert_to_gbp(native_total, activity.currency, on_date, cache=cache) + return replace( + activity, + amount_gbp=amount_gbp, + fx_rate_gbp=rate, + fx_rate_source=source, + ) diff --git a/broker_sync/providers/__init__.py b/broker_sync/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/broker_sync/providers/base.py b/broker_sync/providers/base.py new file mode 100644 index 0000000..4765774 --- /dev/null +++ b/broker_sync/providers/base.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from datetime import datetime +from typing import Protocol + +from broker_sync.models import Account, Activity + + +class Provider(Protocol): + """Broker connector surface. + + Each provider implementation is responsible for fetching raw broker + data, turning it into canonical `Activity` rows (with `account_id` + matching an `Account.id` from `accounts()`), and yielding them. + + GBP conversion is performed by the shared normaliser, not here — + providers emit native currency and the caller converts. + """ + + name: str + + def accounts(self) -> list[Account]: + ... + + def fetch( + self, + *, + since: datetime | None = None, + before: datetime | None = None, + ) -> AsyncIterator[Activity]: + ... diff --git a/broker_sync/providers/parsers/__init__.py b/broker_sync/providers/parsers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/broker_sync/sinks/__init__.py b/broker_sync/sinks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_normaliser.py b/tests/test_normaliser.py new file mode 100644 index 0000000..86bbc12 --- /dev/null +++ b/tests/test_normaliser.py @@ -0,0 +1,74 @@ +from datetime import UTC, date, datetime +from decimal import Decimal +from pathlib import Path + +import pytest + +from broker_sync.fx import FxCache +from broker_sync.models import AccountType, Activity, ActivityType, FxRateSource +from broker_sync.normaliser import normalise_to_gbp + + +def _buy_usd(amount_usd: Decimal = Decimal("100")) -> Activity: + return Activity( + external_id="schwab:1", + account_id="schwab-rsu", + account_type=AccountType.GIA, + date=datetime(2026, 4, 1, tzinfo=UTC), + activity_type=ActivityType.BUY, + symbol="META", + quantity=Decimal("1"), + unit_price=amount_usd, + currency="USD", + fee=Decimal("0"), + ) + + +def test_gbp_activity_unchanged(tmp_path: Path) -> None: + cache = FxCache(tmp_path / "fx.db") + a = Activity( + external_id="t212:1", + account_id="t212-isa", + account_type=AccountType.ISA, + date=datetime(2026, 4, 1, tzinfo=UTC), + activity_type=ActivityType.BUY, + symbol="VUAG", + quantity=Decimal("1"), + unit_price=Decimal("100"), + currency="GBP", + ) + out = normalise_to_gbp(a, cache=cache) + assert out.amount_gbp == Decimal("100") + assert out.fx_rate_gbp == Decimal("1") + assert out.fx_rate_source is FxRateSource.ECB_LIVE + + +def test_usd_buy_converts_using_cache(tmp_path: Path) -> None: + cache = FxCache(tmp_path / "fx.db") + cache.put("USD", date(2026, 4, 1), Decimal("0.80"), FxRateSource.ECB_LIVE) + out = normalise_to_gbp(_buy_usd(Decimal("100")), cache=cache) + assert out.amount_gbp == Decimal("80.00") + assert out.fx_rate_gbp == Decimal("0.80") + assert out.fx_rate_source is FxRateSource.ECB_LIVE + + +def test_dividend_amount_drives_gbp(tmp_path: Path) -> None: + cache = FxCache(tmp_path / "fx.db") + cache.put("USD", date(2026, 4, 1), Decimal("0.80"), FxRateSource.ECB_LIVE) + div = Activity( + external_id="schwab:div:1", + account_id="schwab-rsu", + account_type=AccountType.GIA, + date=datetime(2026, 4, 1, tzinfo=UTC), + activity_type=ActivityType.DIVIDEND, + currency="USD", + amount=Decimal("50"), + ) + out = normalise_to_gbp(div, cache=cache) + assert out.amount_gbp == Decimal("40.00") + + +def test_missing_rate_raises(tmp_path: Path) -> None: + cache = FxCache(tmp_path / "fx.db") + with pytest.raises(LookupError): + normalise_to_gbp(_buy_usd(), cache=cache)