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)