From 90b81cb199e18824fbf41fb029f78818fab1f0e9 Mon Sep 17 00:00:00 2001 From: harley-poly Date: Tue, 2 Jun 2026 12:25:43 -0400 Subject: [PATCH 1/3] feat: add auto-pagination iterators for list endpoints --- README.md | 23 ++++++ polymarket_us/pagination.py | 84 ++++++++++++++++++++ polymarket_us/resources/events.py | 42 +++++++++- polymarket_us/resources/markets.py | 41 ++++++++++ polymarket_us/resources/portfolio.py | 31 ++++++++ polymarket_us/resources/series.py | 49 +++++++++++- tests/test_pagination.py | 112 +++++++++++++++++++++++++++ 7 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 polymarket_us/pagination.py create mode 100644 tests/test_pagination.py diff --git a/README.md b/README.md index 88fadc3..ec45837 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,29 @@ async def main(): asyncio.run(main()) ``` +## Pagination + +List endpoints expose `iterate()` helpers that transparently page through all +results. Offset-paginated resources (`events`, `markets`, `series`) and the +cursor-paginated activity feed are both supported: + +```python +# Offset-paginated resources +for market in client.markets.iterate({"active": True}): + print(market["slug"]) + +# Cursor-paginated activity history (authenticated) +for activity in client.portfolio.iterate_activities(): + print(activity["type"]) +``` + +Async clients return async iterators: + +```python +async for market in async_client.markets.iterate({"active": True}): + print(market["slug"]) +``` + ## Authentication Polymarket US uses Ed25519 signature authentication. Generate API keys at [polymarket.us/developer](https://polymarket.us/developer). diff --git a/polymarket_us/pagination.py b/polymarket_us/pagination.py new file mode 100644 index 0000000..4a7d157 --- /dev/null +++ b/polymarket_us/pagination.py @@ -0,0 +1,84 @@ +"""Auto-pagination helpers for list endpoints. + +The API uses two pagination styles: + +- cursor-based (``next_cursor`` / ``eof``) for activities and positions, and +- offset-based (bare array responses) for events, markets, series, and tags. + +These helpers transparently walk all pages and yield individual items. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable, Iterator +from typing import Any + +DEFAULT_PAGE_SIZE = 100 + + +def paginate_offset( + fetch: Callable[[int, int], dict[str, Any]], + items_key: str, + page_size: int = DEFAULT_PAGE_SIZE, +) -> Iterator[Any]: + """Yield items across offset-paginated pages until a short page is returned.""" + offset = 0 + while True: + page = fetch(offset, page_size) + items = page.get(items_key) or [] + yield from items + if len(items) < page_size: + return + offset += page_size + + +def paginate_cursor( + fetch: Callable[[str | None], dict[str, Any]], + items_key: str, +) -> Iterator[Any]: + """Yield items across cursor-paginated pages until ``eof`` or no next cursor.""" + cursor: str | None = None + while True: + page = fetch(cursor) + items = page.get(items_key) or [] + yield from items + if page.get("eof"): + return + cursor = page.get("nextCursor") + if not cursor: + return + + +async def paginate_offset_async( + fetch: Callable[[int, int], Awaitable[dict[str, Any]]], + items_key: str, + page_size: int = DEFAULT_PAGE_SIZE, +) -> AsyncIterator[Any]: + """Async variant of :func:`paginate_offset`.""" + offset = 0 + while True: + page = await fetch(offset, page_size) + items = page.get(items_key) or [] + for item in items: + yield item + if len(items) < page_size: + return + offset += page_size + + +async def paginate_cursor_async( + fetch: Callable[[str | None], Awaitable[dict[str, Any]]], + items_key: str, +) -> AsyncIterator[Any]: + """Async variant of :func:`paginate_cursor`.""" + cursor: str | None = None + while True: + page = await fetch(cursor) + items = page.get(items_key) or [] + for item in items: + yield item + if page.get("eof"): + return + cursor = page.get("nextCursor") + if not cursor: + return diff --git a/polymarket_us/resources/events.py b/polymarket_us/resources/events.py index 03adb86..ed64249 100644 --- a/polymarket_us/resources/events.py +++ b/polymarket_us/resources/events.py @@ -1,7 +1,15 @@ """Events resource.""" +from collections.abc import AsyncIterator, Iterator +from typing import Any + +from polymarket_us.pagination import ( + DEFAULT_PAGE_SIZE, + paginate_offset, + paginate_offset_async, +) from polymarket_us.resource import APIResource, AsyncAPIResource -from polymarket_us.types import EventsListParams, GetEventResponse, GetEventsResponse +from polymarket_us.types import Event, EventsListParams, GetEventResponse, GetEventsResponse class Events(APIResource): @@ -11,6 +19,22 @@ def list(self, params: EventsListParams | None = None) -> GetEventsResponse: """List events with optional filtering.""" return self._client.get("/v1/events", query=dict(params) if params else None) + def iterate( + self, + params: EventsListParams | None = None, + *, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> Iterator[Event]: + """Iterate over all events across pages, fetching them lazily.""" + + def fetch(offset: int, limit: int) -> dict[str, Any]: + query: dict[str, Any] = dict(params) if params else {} + query["limit"] = limit + query["offset"] = offset + return self._client.get("/v1/events", query=query) + + return paginate_offset(fetch, "events", page_size) + def retrieve(self, id: int) -> GetEventResponse: """Get an event by ID.""" return self._client.get(f"/v1/events/{id}") @@ -27,6 +51,22 @@ async def list(self, params: EventsListParams | None = None) -> GetEventsRespons """List events with optional filtering.""" return await self._client.get("/v1/events", query=dict(params) if params else None) + def iterate( + self, + params: EventsListParams | None = None, + *, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> AsyncIterator[Event]: + """Iterate over all events across pages, fetching them lazily.""" + + async def fetch(offset: int, limit: int) -> dict[str, Any]: + query: dict[str, Any] = dict(params) if params else {} + query["limit"] = limit + query["offset"] = offset + return await self._client.get("/v1/events", query=query) + + return paginate_offset_async(fetch, "events", page_size) + async def retrieve(self, id: int) -> GetEventResponse: """Get an event by ID.""" return await self._client.get(f"/v1/events/{id}") diff --git a/polymarket_us/resources/markets.py b/polymarket_us/resources/markets.py index 208f2c0..b4cb89b 100644 --- a/polymarket_us/resources/markets.py +++ b/polymarket_us/resources/markets.py @@ -1,11 +1,20 @@ """Markets resource.""" +from collections.abc import AsyncIterator, Iterator +from typing import Any + +from polymarket_us.pagination import ( + DEFAULT_PAGE_SIZE, + paginate_offset, + paginate_offset_async, +) from polymarket_us.resource import APIResource, AsyncAPIResource from polymarket_us.types import ( GetMarketResponse, GetMarketsResponse, MarketBBO, MarketBook, + MarketDetail, MarketSettlement, MarketsListParams, ) @@ -18,6 +27,22 @@ def list(self, params: MarketsListParams | None = None) -> GetMarketsResponse: """List markets with optional filtering.""" return self._client.get("/v1/markets", query=dict(params) if params else None) + def iterate( + self, + params: MarketsListParams | None = None, + *, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> Iterator[MarketDetail]: + """Iterate over all markets across pages, fetching them lazily.""" + + def fetch(offset: int, limit: int) -> dict[str, Any]: + query: dict[str, Any] = dict(params) if params else {} + query["limit"] = limit + query["offset"] = offset + return self._client.get("/v1/markets", query=query) + + return paginate_offset(fetch, "markets", page_size) + def retrieve(self, id: int) -> GetMarketResponse: """Get a market by ID.""" return self._client.get(f"/v1/market/id/{id}") @@ -46,6 +71,22 @@ async def list(self, params: MarketsListParams | None = None) -> GetMarketsRespo """List markets with optional filtering.""" return await self._client.get("/v1/markets", query=dict(params) if params else None) + def iterate( + self, + params: MarketsListParams | None = None, + *, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> AsyncIterator[MarketDetail]: + """Iterate over all markets across pages, fetching them lazily.""" + + async def fetch(offset: int, limit: int) -> dict[str, Any]: + query: dict[str, Any] = dict(params) if params else {} + query["limit"] = limit + query["offset"] = offset + return await self._client.get("/v1/markets", query=query) + + return paginate_offset_async(fetch, "markets", page_size) + async def retrieve(self, id: int) -> GetMarketResponse: """Get a market by ID.""" return await self._client.get(f"/v1/market/id/{id}") diff --git a/polymarket_us/resources/portfolio.py b/polymarket_us/resources/portfolio.py index 3cc37a0..de994c9 100644 --- a/polymarket_us/resources/portfolio.py +++ b/polymarket_us/resources/portfolio.py @@ -1,7 +1,12 @@ """Portfolio resource.""" +from collections.abc import AsyncIterator, Iterator +from typing import Any + +from polymarket_us.pagination import paginate_cursor, paginate_cursor_async from polymarket_us.resource import APIResource, AsyncAPIResource from polymarket_us.types import ( + Activity, GetActivitiesParams, GetActivitiesResponse, GetUserPositionsParams, @@ -28,6 +33,17 @@ def activities(self, params: GetActivitiesParams | None = None) -> GetActivities authenticated=True, ) + def iterate_activities(self, params: GetActivitiesParams | None = None) -> Iterator[Activity]: + """Iterate over all activities, following the cursor across pages.""" + + def fetch(cursor: str | None) -> dict[str, Any]: + query: dict[str, Any] = dict(params) if params else {} + if cursor: + query["cursor"] = cursor + return self._client.get("/v1/portfolio/activities", query=query, authenticated=True) + + return paginate_cursor(fetch, "activities") + class AsyncPortfolio(AsyncAPIResource): """Portfolio API resource (async, requires authentication).""" @@ -49,3 +65,18 @@ async def activities(self, params: GetActivitiesParams | None = None) -> GetActi query=dict(params) if params else None, authenticated=True, ) + + def iterate_activities( + self, params: GetActivitiesParams | None = None + ) -> AsyncIterator[Activity]: + """Iterate over all activities, following the cursor across pages.""" + + async def fetch(cursor: str | None) -> dict[str, Any]: + query: dict[str, Any] = dict(params) if params else {} + if cursor: + query["cursor"] = cursor + return await self._client.get( + "/v1/portfolio/activities", query=query, authenticated=True + ) + + return paginate_cursor_async(fetch, "activities") diff --git a/polymarket_us/resources/series.py b/polymarket_us/resources/series.py index a80cd99..86c1786 100644 --- a/polymarket_us/resources/series.py +++ b/polymarket_us/resources/series.py @@ -1,7 +1,22 @@ """Series resource.""" +from collections.abc import AsyncIterator, Iterator +from typing import Any + +from polymarket_us.pagination import ( + DEFAULT_PAGE_SIZE, + paginate_offset, + paginate_offset_async, +) from polymarket_us.resource import APIResource, AsyncAPIResource -from polymarket_us.types import GetSeriesListResponse, GetSeriesResponse, SeriesListParams +from polymarket_us.types import ( + GetSeriesListResponse, + GetSeriesResponse, + SeriesListParams, +) +from polymarket_us.types import ( + Series as SeriesType, +) class Series(APIResource): @@ -11,6 +26,22 @@ def list(self, params: SeriesListParams | None = None) -> GetSeriesListResponse: """List series with optional filtering.""" return self._client.get("/v1/series", query=dict(params) if params else None) + def iterate( + self, + params: SeriesListParams | None = None, + *, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> Iterator[SeriesType]: + """Iterate over all series across pages, fetching them lazily.""" + + def fetch(offset: int, limit: int) -> dict[str, Any]: + query: dict[str, Any] = dict(params) if params else {} + query["limit"] = limit + query["offset"] = offset + return self._client.get("/v1/series", query=query) + + return paginate_offset(fetch, "series", page_size) + def retrieve(self, id: int) -> GetSeriesResponse: """Get a series by ID.""" return self._client.get(f"/v1/series/id/{id}") @@ -23,6 +54,22 @@ async def list(self, params: SeriesListParams | None = None) -> GetSeriesListRes """List series with optional filtering.""" return await self._client.get("/v1/series", query=dict(params) if params else None) + def iterate( + self, + params: SeriesListParams | None = None, + *, + page_size: int = DEFAULT_PAGE_SIZE, + ) -> AsyncIterator[SeriesType]: + """Iterate over all series across pages, fetching them lazily.""" + + async def fetch(offset: int, limit: int) -> dict[str, Any]: + query: dict[str, Any] = dict(params) if params else {} + query["limit"] = limit + query["offset"] = offset + return await self._client.get("/v1/series", query=query) + + return paginate_offset_async(fetch, "series", page_size) + async def retrieve(self, id: int) -> GetSeriesResponse: """Get a series by ID.""" return await self._client.get(f"/v1/series/id/{id}") diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 0000000..3056897 --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,112 @@ +"""Tests for auto-pagination iterators (offset and cursor).""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx + +from polymarket_us import AsyncPolymarketUS, PolymarketUS + +TEST_SECRET_KEY = "nWGxne/9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A=" + + +def _json(payload: dict) -> httpx.Response: + return httpx.Response(200, json=payload, request=httpx.Request("GET", "http://test")) + + +class TestOffsetPagination: + """Offset-based iterators (events, markets, series).""" + + @patch.object(httpx.Client, "request") + def test_iterates_until_short_page(self, mock_request: MagicMock) -> None: + mock_request.side_effect = [ + _json({"events": [{"id": 1}, {"id": 2}]}), + _json({"events": [{"id": 3}]}), + ] + client = PolymarketUS() + + events = list(client.events.iterate(page_size=2)) + + assert [e["id"] for e in events] == [1, 2, 3] + assert mock_request.call_count == 2 + + @patch.object(httpx.Client, "request") + def test_stops_on_empty_page(self, mock_request: MagicMock) -> None: + mock_request.side_effect = [ + _json({"markets": [{"id": 1}, {"id": 2}]}), + _json({"markets": []}), + ] + client = PolymarketUS() + + markets = list(client.markets.iterate(page_size=2)) + + assert [m["id"] for m in markets] == [1, 2] + assert mock_request.call_count == 2 + + @patch.object(httpx.Client, "request") + def test_single_short_page_stops_immediately(self, mock_request: MagicMock) -> None: + mock_request.side_effect = [_json({"series": [{"id": 1}]})] + client = PolymarketUS() + + series = list(client.series.iterate(page_size=2)) + + assert [s["id"] for s in series] == [1] + assert mock_request.call_count == 1 + + +class TestCursorPagination: + """Cursor-based iterator (activities).""" + + @patch.object(httpx.Client, "request") + def test_follows_cursor_until_eof(self, mock_request: MagicMock) -> None: + mock_request.side_effect = [ + _json({"activities": [{"type": "a"}], "nextCursor": "c1", "eof": False}), + _json({"activities": [{"type": "b"}], "eof": True}), + ] + client = PolymarketUS(key_id="k", secret_key=TEST_SECRET_KEY) + + activities = list(client.portfolio.iterate_activities()) + + assert [a["type"] for a in activities] == ["a", "b"] + assert mock_request.call_count == 2 + + @patch.object(httpx.Client, "request") + def test_stops_when_no_next_cursor(self, mock_request: MagicMock) -> None: + mock_request.side_effect = [ + _json({"activities": [{"type": "a"}], "nextCursor": "", "eof": False}), + ] + client = PolymarketUS(key_id="k", secret_key=TEST_SECRET_KEY) + + activities = list(client.portfolio.iterate_activities()) + + assert [a["type"] for a in activities] == ["a"] + assert mock_request.call_count == 1 + + +class TestAsyncPagination: + """Async iterators mirror the sync behavior.""" + + @patch.object(httpx.AsyncClient, "request", new_callable=AsyncMock) + async def test_async_offset_iterate(self, mock_request: AsyncMock) -> None: + mock_request.side_effect = [ + _json({"events": [{"id": 1}, {"id": 2}]}), + _json({"events": [{"id": 3}]}), + ] + client = AsyncPolymarketUS() + + events = [e async for e in client.events.iterate(page_size=2)] + + assert [e["id"] for e in events] == [1, 2, 3] + await client.close() + + @patch.object(httpx.AsyncClient, "request", new_callable=AsyncMock) + async def test_async_cursor_iterate(self, mock_request: AsyncMock) -> None: + mock_request.side_effect = [ + _json({"activities": [{"type": "a"}], "nextCursor": "c1", "eof": False}), + _json({"activities": [{"type": "b"}], "eof": True}), + ] + client = AsyncPolymarketUS(key_id="k", secret_key=TEST_SECRET_KEY) + + activities = [a async for a in client.portfolio.iterate_activities()] + + assert [a["type"] for a in activities] == ["a", "b"] + await client.close() From 0dcfc33bef6f1d2cf9079925c672063b2b8241e3 Mon Sep 17 00:00:00 2001 From: harley-poly Date: Tue, 2 Jun 2026 12:42:31 -0400 Subject: [PATCH 2/3] fix: use valid base64 in 64-byte key auth test for python 3.13 --- tests/test_auth.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index eb70c09..6c16d15 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -64,12 +64,15 @@ def test_signature_is_base64(self) -> None: def test_handles_64_byte_key(self) -> None: """Should handle 64-byte keys (uses first 32 bytes).""" + import base64 + # 64-byte key (seed + public key), base64 encoded - secret_key_64 = "nWGxne/9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A=" * 2 + seed = base64.b64decode("nWGxne/9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A=") + secret_key_64 = base64.b64encode(seed + seed).decode() # Should not raise headers = create_auth_headers( key_id="test", - secret_key=secret_key_64[:88], # 64 bytes in base64 + secret_key=secret_key_64, method="GET", path="/v1/test", ) From bfd4f3771e0492b98a618b242514709cfbeb981d Mon Sep 17 00:00:00 2001 From: harley-poly Date: Tue, 2 Jun 2026 12:58:42 -0400 Subject: [PATCH 3/3] fix: clamp page size to a minimum of one to prevent infinite pagination --- polymarket_us/pagination.py | 2 ++ tests/test_pagination.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/polymarket_us/pagination.py b/polymarket_us/pagination.py index 4a7d157..dd0bff2 100644 --- a/polymarket_us/pagination.py +++ b/polymarket_us/pagination.py @@ -22,6 +22,7 @@ def paginate_offset( page_size: int = DEFAULT_PAGE_SIZE, ) -> Iterator[Any]: """Yield items across offset-paginated pages until a short page is returned.""" + page_size = max(1, page_size) offset = 0 while True: page = fetch(offset, page_size) @@ -55,6 +56,7 @@ async def paginate_offset_async( page_size: int = DEFAULT_PAGE_SIZE, ) -> AsyncIterator[Any]: """Async variant of :func:`paginate_offset`.""" + page_size = max(1, page_size) offset = 0 while True: page = await fetch(offset, page_size) diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 3056897..b3971ea 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -42,6 +42,20 @@ def test_stops_on_empty_page(self, mock_request: MagicMock) -> None: assert [m["id"] for m in markets] == [1, 2] assert mock_request.call_count == 2 + @patch.object(httpx.Client, "request") + def test_non_positive_page_size_terminates(self, mock_request: MagicMock) -> None: + # page_size <= 0 must be clamped so the loop cannot spin forever. + mock_request.side_effect = [ + _json({"events": [{"id": 1}]}), + _json({"events": []}), + ] + client = PolymarketUS() + + events = list(client.events.iterate(page_size=0)) + + assert [e["id"] for e in events] == [1] + assert mock_request.call_count == 2 + @patch.object(httpx.Client, "request") def test_single_short_page_stops_immediately(self, mock_request: MagicMock) -> None: mock_request.side_effect = [_json({"series": [{"id": 1}]})]