Context ------- Closes out the Trading212 provider's retry + pagination surface so the "Add Trading212Provider core fetch" commit has everything the CronJob needs: cursor-based pagination, 429 honouring Retry-After, jittered exponential backoff for 429-without-header and 5xx, bailout after _MAX_RETRIES, and checkpoint-after-page semantics so a crashed run resumes at the start of the unfinished page. Also pins click<8.2 — typer 0.12 calls Parameter.make_metavar() without a ctx argument, which click 8.2 removed; `broker-sync --help` was crashing with TypeError until this pin. typer 0.15+ would also fix it; the pin is lower friction. One test fix: test_checkpoint_advances_only_after_page_yielded had a handler that unconditionally returned a next_path → infinite loop. The assertion was always about "a cursor was saved after page 1", so I changed the handler to return page 2 as empty-with-no-next, which terminates the loop cleanly. Test plan --------- ## Automated - poetry run pytest -q → 70 passed - poetry run mypy broker_sync tests → Success: no issues found in 29 source files - poetry run ruff check . → All checks passed! - poetry run broker-sync --help → renders without crash; lists version + auth-spike ## Manual Verification End-to-end against a live T212 key is in the next commit once the CLI subcommand and pipeline land.
388 lines
12 KiB
Python
388 lines
12 KiB
Python
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"]
|
|
|
|
|
|
# -- 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]
|