Add Trading212Provider core fetch

Context
-------
The Provider protocol is satisfied. This commit adds the first cut of
the concrete Trading212 implementation: one page of fills, mapped to
canonical Activities. Pagination, retries, and checkpointing on
resume are deliberately deferred to the next commit so this one stays
focused on the raw shape translation.

Design decisions
----------------
- One provider instance serves every T212 wrapper (ISA + Invest). T212
  exposes one API key per wrapper, so the caller hands over a list of
  (Account, api_key) pairs. `accounts()` returns only the Accounts —
  the keys never escape the provider.
- Auth: literal `Authorization: <api_key>`, NOT `Bearer <api_key>`.
  T212 quietly returns 401 for Bearer-prefixed keys. The test locks
  that in.
- Sell detection: T212 signs quantity (negative means closing a long
  or opening a short). We flip on the sign and store `abs(quantity)`,
  matching the Wealthfolio BUY/SELL convention.
- Null fills (cancelled orders) are silently dropped at parse time
  rather than surfacing to the caller.
- `external_id = t212:fill:<fill.id>` — the fill ID is stable per
  T212 docs and survives order cancellation/modification semantics.
- Ticker normalisation runs on ingress so downstream dedup + Wealthfolio
  see `VUAG` even though T212 reports `VUAGl_EQ`.
- `since` / `before` filter on `filledAt`. `before` is half-open
  (`< before`) so CronJobs can chain adjacent windows without
  double-counting the boundary.

Explicitly NOT in this change:
- Pagination (nextPagePath walk)
- 429 / 5xx retry
- Dividend / deposit endpoints (deferred — Phase 1.1, filed as
  beads follow-up if needed)

This change
-----------
- broker_sync/providers/trading212.py: `Trading212Provider` class +
  `Trading212Error` / `Trading212AuthError` exception hierarchy.
  `_item_to_activity` is pure and returns Optional so cancelled
  fills short-circuit without raising.
- tests/providers/test_trading212.py: MockTransport-driven tests for
  auth header shape, fill→Activity mapping (buy + sell sign flip),
  null-fill skip, since-filter, and both error types.

Test plan
---------
## Automated
- poetry run pytest -q  →  61 passed in 0.60s
- poetry run mypy broker_sync tests  →  Success: no issues found in 27 source files
- poetry run ruff check .  →  All checks passed!

## Manual Verification
Deferred to the CLI wiring commit — the live endpoint is 6 calls/min
and the full-volume dry run belongs with the env-driven command, not
the unit-level commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-04-17 19:34:03 +00:00
parent 56f3624344
commit 7d2c1199a9
2 changed files with 349 additions and 6 deletions

View file

@ -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"))

View file

@ -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"]