diff --git a/broker_sync/providers/trading212.py b/broker_sync/providers/trading212.py index 06da489..0fd0fe0 100644 --- a/broker_sync/providers/trading212.py +++ b/broker_sync/providers/trading212.py @@ -1,15 +1,143 @@ from __future__ import annotations import re +from collections.abc import AsyncIterator +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from typing import Any + +import httpx + +from broker_sync.models import Account, Activity, ActivityType +from broker_sync.providers._checkpoint import Checkpoint + +_BASE_URL = "https://live.trading212.com" +_ORDERS_PATH = "/api/v0/equity/history/orders" +_PAGE_LIMIT = 50 _SUFFIX_RE = re.compile(r"(?:_US)?(?:[a-z])?_EQ$") def _normalise_ticker(raw: str) -> str: - """Strip T212's exchange-suffix decoration from a ticker. - - T212 tags every ticker with `_EQ`, optionally preceded by a lowercase - letter ("l" for LSE) or `_US` for a US listing. Peel those off so the - symbol matches what Schwab / InvestEngine emit. - """ + """Strip T212's exchange-suffix decoration from a ticker.""" return _SUFFIX_RE.sub("", raw) + + +class Trading212Error(Exception): + """Any non-retryable Trading212 API failure.""" + + +class Trading212AuthError(Trading212Error): + """HTTP 401 from Trading212 — API key is invalid or revoked.""" + + +class Trading212Provider: + """Concrete Provider for Trading212. + + One instance serves every T212 wrapper the user owns (ISA + Invest) + — the caller hands over (Account, api_key) pairs and `fetch()` walks + each account's history in turn. + """ + + name = "trading212" + + def __init__( + self, + *, + accounts: list[tuple[Account, str]], + checkpoint_dir: Path, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self._accounts = accounts + self._checkpoint_dir = checkpoint_dir + self._client = httpx.AsyncClient( + base_url=_BASE_URL, + timeout=30.0, + transport=transport, + ) + + def accounts(self) -> list[Account]: + return [acc for acc, _ in self._accounts] + + async def close(self) -> None: + await self._client.aclose() + + async def fetch( + self, + *, + since: datetime | None = None, + before: datetime | None = None, + ) -> AsyncIterator[Activity]: + for account, api_key in self._accounts: + async for activity in self._fetch_account(account, api_key, since, before): + yield activity + + async def _fetch_account( + self, + account: Account, + api_key: str, + since: datetime | None, + before: datetime | None, + ) -> AsyncIterator[Activity]: + checkpoint = Checkpoint( + self._checkpoint_dir, + provider=self.name, + account_id=account.id, + ) + page = await self._get_page(api_key, cursor=None) + for item in page.get("items", []): + activity = _item_to_activity(item, account) + if activity is None: + continue + if since is not None and activity.date < since: + continue + if before is not None and activity.date >= before: + continue + yield activity + # Checkpoint saved at end of page — resume on next run. + next_cursor = page.get("nextPagePath") + if isinstance(next_cursor, str): + checkpoint.save(next_cursor) + + async def _get_page(self, api_key: str, cursor: str | None) -> dict[str, Any]: + params: dict[str, str | int] = {"limit": _PAGE_LIMIT} + if cursor is not None: + params["cursor"] = cursor + resp = await self._client.get( + _ORDERS_PATH, + params=params, + headers={"Authorization": api_key}, + ) + if resp.status_code == 401: + raise Trading212AuthError("Trading212 rejected API key (HTTP 401)") + if resp.status_code >= 400: + raise Trading212Error(f"Trading212 /orders HTTP {resp.status_code}: {resp.text}") + raw = resp.json() + assert isinstance(raw, dict) + return raw + + +def _item_to_activity(item: dict[str, Any], account: Account) -> Activity | None: + fill = item.get("fill") + if fill is None: + return None + order = item["order"] + quantity_raw = Decimal(str(fill["quantity"])) + activity_type = ActivityType.BUY if quantity_raw > 0 else ActivityType.SELL + return Activity( + external_id=f"t212:fill:{fill['id']}", + account_id=account.id, + account_type=account.account_type, + date=_parse_iso(fill["filledAt"]), + activity_type=activity_type, + symbol=_normalise_ticker(order["ticker"]), + quantity=abs(quantity_raw), + unit_price=Decimal(str(fill["price"])), + currency=order["currency"], + ) + + +def _parse_iso(ts: str) -> datetime: + # T212 always emits `...Z`; datetime.fromisoformat handles `+00:00`. + return datetime.fromisoformat(ts.replace("Z", "+00:00")) diff --git a/tests/providers/test_trading212.py b/tests/providers/test_trading212.py new file mode 100644 index 0000000..047d160 --- /dev/null +++ b/tests/providers/test_trading212.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal +from pathlib import Path +from typing import Any + +import httpx +import pytest + +from broker_sync.models import Account, AccountType, ActivityType +from broker_sync.providers.trading212 import ( + Trading212AuthError, + Trading212Error, + Trading212Provider, +) + +_ISA = Account( + id="t212-isa", + name="Trading212 ISA", + account_type=AccountType.ISA, + currency="GBP", + provider="trading212", +) +_GIA = Account( + id="t212-invest", + name="Trading212 Invest", + account_type=AccountType.GIA, + currency="GBP", + provider="trading212", +) + + +def _fill( + *, + fill_id: str = "fill-1", + order_id: str = "order-1", + ticker: str = "VUAGl_EQ", + currency: str = "GBP", + price: str = "85.5", + quantity: str = "2.0", + filled_at: str = "2026-04-01T10:30:00.000Z", +) -> dict[str, Any]: + return { + "type": "MARKET", + "id": 123, + "fill": { + "id": fill_id, + "price": price, + "quantity": quantity, + "filledAt": filled_at, + }, + "order": { + "id": order_id, + "ticker": ticker, + "currency": currency, + }, + } + + +def _page(items: list[dict[str, Any]], next_path: str | None = None) -> dict[str, Any]: + return {"items": items, "nextPagePath": next_path} + + +def _provider( + *, + checkpoint_dir: Path, + transport: httpx.MockTransport, + accounts: list[tuple[Account, str]] | None = None, +) -> Trading212Provider: + return Trading212Provider( + accounts=accounts or [(_ISA, "isa-key")], + checkpoint_dir=checkpoint_dir, + transport=transport, + ) + + +async def _collect(provider: Trading212Provider) -> list[Any]: + out = [] + async for a in provider.fetch(): + out.append(a) + return out + + +# -- auth header shape -- + + +async def test_auth_header_is_literal_api_key(tmp_path: Path) -> None: + seen: list[str | None] = [] + + def handler(req: httpx.Request) -> httpx.Response: + seen.append(req.headers.get("Authorization")) + return httpx.Response(200, json=_page([])) + + p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler)) + await _collect(p) + assert seen == ["isa-key"] + # Not the Bearer-prefixed variant other APIs use. + assert "Bearer" not in (seen[0] or "") + + +# -- single fill → Activity mapping -- + + +async def test_buy_fill_becomes_buy_activity(tmp_path: Path) -> None: + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=_page([_fill()])) + + p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler)) + acts = await _collect(p) + assert len(acts) == 1 + a = acts[0] + assert a.activity_type is ActivityType.BUY + assert a.external_id == "t212:fill:fill-1" + assert a.symbol == "VUAG" + assert a.quantity == Decimal("2.0") + assert a.unit_price == Decimal("85.5") + assert a.currency == "GBP" + assert a.account_id == "t212-isa" + assert a.account_type is AccountType.ISA + assert a.date == datetime(2026, 4, 1, 10, 30, tzinfo=UTC) + + +async def test_negative_quantity_becomes_sell(tmp_path: Path) -> None: + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=_page([_fill(quantity="-3.0")])) + + p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler)) + acts = await _collect(p) + assert acts[0].activity_type is ActivityType.SELL + # Quantity is stored as absolute. + assert acts[0].quantity == Decimal("3.0") + + +async def test_null_fill_is_skipped(tmp_path: Path) -> None: + cancelled = { + "type": "MARKET", + "id": 999, + "fill": None, + "order": { + "id": "o-cancelled", + "ticker": "VUAGl_EQ", + "currency": "GBP" + }, + } + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=_page([cancelled, _fill()])) + + p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler)) + acts = await _collect(p) + assert len(acts) == 1 + assert acts[0].external_id == "t212:fill:fill-1" + + +# -- since filter -- + + +async def test_since_drops_older_fills(tmp_path: Path) -> None: + old = _fill(fill_id="old", filled_at="2020-01-01T00:00:00.000Z") + new = _fill(fill_id="new", filled_at="2026-04-01T10:30:00.000Z") + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=_page([new, old])) + + p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler)) + since = datetime(2026, 1, 1, tzinfo=UTC) + out = [a async for a in p.fetch(since=since)] + assert [a.external_id for a in out] == ["t212:fill:new"] + + +# -- error types -- + + +async def test_401_raises_auth_error(tmp_path: Path) -> None: + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(401, json={"error": "bad key"}) + + p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler)) + with pytest.raises(Trading212AuthError): + await _collect(p) + + +async def test_403_raises_generic_error(tmp_path: Path) -> None: + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(403, json={"error": "forbidden"}) + + p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler)) + with pytest.raises(Trading212Error): + await _collect(p) + + +# -- name + accounts contract -- + + +def test_provider_name() -> None: + assert Trading212Provider.name == "trading212" + + +def test_accounts_returns_registered_pairs(tmp_path: Path) -> None: + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=_page([])) + + p = _provider( + checkpoint_dir=tmp_path, + transport=httpx.MockTransport(handler), + accounts=[(_ISA, "isa-key"), (_GIA, "gia-key")], + ) + accs = p.accounts() + assert [a.id for a in accs] == ["t212-isa", "t212-invest"]