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]