Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
86 changes: 86 additions & 0 deletions polymarket_us/pagination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""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."""
page_size = max(1, page_size)
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`."""
page_size = max(1, page_size)
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
42 changes: 41 additions & 1 deletion polymarket_us/resources/events.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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}")
Expand All @@ -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}")
Expand Down
41 changes: 41 additions & 0 deletions polymarket_us/resources/markets.py
Original file line number Diff line number Diff line change
@@ -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,
)
Expand All @@ -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}")
Expand Down Expand Up @@ -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}")
Expand Down
31 changes: 31 additions & 0 deletions polymarket_us/resources/portfolio.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)."""
Expand All @@ -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")
49 changes: 48 additions & 1 deletion polymarket_us/resources/series.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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}")
Expand All @@ -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}")
7 changes: 5 additions & 2 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
Expand Down
Loading
Loading