Add Provider protocol and normaliser
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.
This commit is contained in:
parent
33810899c9
commit
f306dc9605
6 changed files with 141 additions and 0 deletions
35
broker_sync/normaliser.py
Normal file
35
broker_sync/normaliser.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
0
broker_sync/providers/__init__.py
Normal file
0
broker_sync/providers/__init__.py
Normal file
32
broker_sync/providers/base.py
Normal file
32
broker_sync/providers/base.py
Normal file
|
|
@ -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]:
|
||||
...
|
||||
0
broker_sync/providers/parsers/__init__.py
Normal file
0
broker_sync/providers/parsers/__init__.py
Normal file
0
broker_sync/sinks/__init__.py
Normal file
0
broker_sync/sinks/__init__.py
Normal file
74
tests/test_normaliser.py
Normal file
74
tests/test_normaliser.py
Normal file
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue