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>
2026-04-17 19:34:03 +00:00
|
|
|
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"]
|
2026-04-17 19:45:23 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# -- pagination --
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_pagination_follows_next_page_path(tmp_path: Path) -> None:
|
|
|
|
|
pages = iter([
|
|
|
|
|
_page([_fill(fill_id="p1-a"), _fill(fill_id="p1-b")],
|
|
|
|
|
next_path="/api/v0/equity/history/orders?cursor=p2"),
|
|
|
|
|
_page([_fill(fill_id="p2-a")], next_path=None),
|
|
|
|
|
])
|
|
|
|
|
visited: list[str | None] = []
|
|
|
|
|
|
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
|
|
|
visited.append(req.url.params.get("cursor"))
|
|
|
|
|
return httpx.Response(200, json=next(pages))
|
|
|
|
|
|
|
|
|
|
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
|
|
|
|
|
out = await _collect(p)
|
|
|
|
|
assert [a.external_id for a in out] == [
|
|
|
|
|
"t212:fill:p1-a",
|
|
|
|
|
"t212:fill:p1-b",
|
|
|
|
|
"t212:fill:p2-a",
|
|
|
|
|
]
|
|
|
|
|
# First call has no cursor; second uses the cursor from nextPagePath.
|
|
|
|
|
assert visited == [None, "p2"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_pagination_stops_when_since_reached(tmp_path: Path) -> None:
|
|
|
|
|
# First page has one too-old fill; remaining fill is new. Provider
|
|
|
|
|
# must stop without fetching page 2 once a page has items strictly
|
|
|
|
|
# older than `since`.
|
|
|
|
|
pages = iter([
|
|
|
|
|
_page(
|
|
|
|
|
[
|
|
|
|
|
_fill(fill_id="new", filled_at="2026-04-01T10:30:00.000Z"),
|
|
|
|
|
_fill(fill_id="old", filled_at="2020-01-01T00:00:00.000Z"),
|
|
|
|
|
],
|
|
|
|
|
next_path="/api/v0/equity/history/orders?cursor=p2",
|
|
|
|
|
),
|
|
|
|
|
_page([_fill(fill_id="p2-a")], next_path=None),
|
|
|
|
|
])
|
|
|
|
|
call_count = 0
|
|
|
|
|
|
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
|
|
|
nonlocal call_count
|
|
|
|
|
call_count += 1
|
|
|
|
|
return httpx.Response(200, json=next(pages))
|
|
|
|
|
|
|
|
|
|
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"]
|
|
|
|
|
assert call_count == 1 # did NOT walk to page 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_checkpoint_advances_only_after_page_yielded(tmp_path: Path) -> None:
|
|
|
|
|
cursor_next = "/api/v0/equity/history/orders?cursor=p2"
|
|
|
|
|
calls = 0
|
|
|
|
|
|
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
|
|
|
# Page 1: one fill + next_path (triggers a checkpoint save).
|
|
|
|
|
# Page 2: empty + no next — terminates the loop cleanly.
|
|
|
|
|
nonlocal calls
|
|
|
|
|
calls += 1
|
|
|
|
|
if calls == 1:
|
|
|
|
|
return httpx.Response(200, json=_page([_fill()], next_path=cursor_next))
|
|
|
|
|
return httpx.Response(200, json=_page([], next_path=None))
|
|
|
|
|
|
|
|
|
|
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
|
|
|
|
|
await _collect(p)
|
|
|
|
|
# After a successful fetch, the checkpoint holds the cursor for the NEXT page.
|
|
|
|
|
from broker_sync.providers._checkpoint import Checkpoint
|
|
|
|
|
cp = Checkpoint(tmp_path, provider="trading212", account_id="t212-isa")
|
|
|
|
|
assert cp.load() == cursor_next
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# -- retries --
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_429_with_retry_after_sleeps_then_retries(tmp_path: Path,
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
calls = 0
|
|
|
|
|
sleeps: list[float] = []
|
|
|
|
|
|
|
|
|
|
async def fake_sleep(seconds: float) -> None:
|
|
|
|
|
sleeps.append(seconds)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("asyncio.sleep", fake_sleep)
|
|
|
|
|
|
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
|
|
|
nonlocal calls
|
|
|
|
|
calls += 1
|
|
|
|
|
if calls == 1:
|
|
|
|
|
return httpx.Response(429, headers={"Retry-After": "7"})
|
|
|
|
|
return httpx.Response(200, json=_page([_fill()]))
|
|
|
|
|
|
|
|
|
|
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
|
|
|
|
|
out = await _collect(p)
|
|
|
|
|
assert len(out) == 1
|
|
|
|
|
assert calls == 2
|
|
|
|
|
assert sleeps == [7.0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_429_without_retry_after_uses_backoff(tmp_path: Path,
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
calls = 0
|
|
|
|
|
sleeps: list[float] = []
|
|
|
|
|
|
|
|
|
|
async def fake_sleep(seconds: float) -> None:
|
|
|
|
|
sleeps.append(seconds)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("asyncio.sleep", fake_sleep)
|
|
|
|
|
# Deterministic jitter: always 0.5 of the cap.
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"broker_sync.providers.trading212._jitter",
|
|
|
|
|
lambda base: base,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
|
|
|
nonlocal calls
|
|
|
|
|
calls += 1
|
|
|
|
|
if calls < 3:
|
|
|
|
|
return httpx.Response(429)
|
|
|
|
|
return httpx.Response(200, json=_page([_fill()]))
|
|
|
|
|
|
|
|
|
|
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
|
|
|
|
|
out = await _collect(p)
|
|
|
|
|
assert len(out) == 1
|
|
|
|
|
# First backoff = 10s; second doubles to 20s.
|
|
|
|
|
assert sleeps == [10.0, 20.0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_429_gives_up_after_max_retries(tmp_path: Path,
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
|
|
|
|
|
async def noop_sleep(seconds: float) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("asyncio.sleep", noop_sleep)
|
|
|
|
|
|
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
|
|
|
return httpx.Response(429)
|
|
|
|
|
|
|
|
|
|
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
|
|
|
|
|
with pytest.raises(Trading212Error, match="429"):
|
|
|
|
|
await _collect(p)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_5xx_retries_with_backoff(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
calls = 0
|
|
|
|
|
sleeps: list[float] = []
|
|
|
|
|
|
|
|
|
|
async def fake_sleep(seconds: float) -> None:
|
|
|
|
|
sleeps.append(seconds)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("asyncio.sleep", fake_sleep)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"broker_sync.providers.trading212._jitter",
|
|
|
|
|
lambda base: base,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
|
|
|
nonlocal calls
|
|
|
|
|
calls += 1
|
|
|
|
|
if calls == 1:
|
|
|
|
|
return httpx.Response(502)
|
|
|
|
|
return httpx.Response(200, json=_page([_fill()]))
|
|
|
|
|
|
|
|
|
|
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
|
|
|
|
|
out = await _collect(p)
|
|
|
|
|
assert len(out) == 1
|
|
|
|
|
assert sleeps == [10.0]
|