broker-sync/broker_sync/normaliser.py
Viktor Barzin f306dc9605 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.
2026-04-17 19:20:12 +00:00

35 lines
1.2 KiB
Python

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,
)