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:
Viktor Barzin 2026-04-17 19:20:12 +00:00
parent 33810899c9
commit f306dc9605
6 changed files with 141 additions and 0 deletions

74
tests/test_normaliser.py Normal file
View 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)