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
4 changes: 4 additions & 0 deletions src/polymarket/_internal/actions/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,8 @@ def list_trades_spec(
path="/trades",
# Matches the upstream per-request limit cap.
max_page_size=10_000,
# Starting offsets above this boundary are rejected.
max_offset=10_000,
base_params=build_data_params(
{
"takerOnly": taker_only,
Expand Down Expand Up @@ -438,6 +440,8 @@ def list_activity_spec(
path="/activity",
# Matches the upstream per-request limit cap.
max_page_size=500,
# Starting offsets above this boundary are rejected.
max_offset=5_000,
base_params=build_data_params(
{
"user": user,
Expand Down
10 changes: 10 additions & 0 deletions src/polymarket/_internal/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ def fetch(cursor: str | None) -> Page[T]:
if cursor is not None
else (0, page_size)
)
if spec.max_offset is not None and offset > spec.max_offset:
raise UserInputError(
f"Pagination cannot continue past the maximum offset of {spec.max_offset}. "
"Narrow the query before resuming."
)
params: dict[str, QueryParamValue] = {
**(spec.base_params or {}),
"limit": effective_size,
Expand Down Expand Up @@ -137,6 +142,11 @@ async def fetch(cursor: str | None) -> Page[T]:
if cursor is not None
else (0, page_size)
)
if spec.max_offset is not None and offset > spec.max_offset:
raise UserInputError(
f"Pagination cannot continue past the maximum offset of {spec.max_offset}. "
"Narrow the query before resuming."
)
params: dict[str, QueryParamValue] = {
**(spec.base_params or {}),
"limit": effective_size,
Expand Down
5 changes: 5 additions & 0 deletions src/polymarket/_internal/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,18 @@ class OffsetPaginatedSpec(Generic[T]):
`max_page_size` must match the endpoint's server-side limit cap. Without
it, a page size above the cap makes the server clamp or reject the request
and pagination silently skips or drops rows.

`max_offset` is the largest starting offset accepted by the endpoint.
The dispatcher rejects cursors beyond it instead of issuing a request the
endpoint is guaranteed to reject.
"""

service: Service
path: str
parse_items: Callable[[object], tuple[T, ...]]
base_params: Mapping[str, QueryParamValue] | None = None
max_page_size: int | None = None
max_offset: int | None = None


@dataclass(frozen=True, slots=True)
Expand Down
6 changes: 6 additions & 0 deletions src/polymarket/clients/async_public.py
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,9 @@ def list_trades(
) -> AsyncPaginator[Trade]:
"""List public trades.

Pagination cannot continue past a starting offset of 10,000. Use
bounded ``start`` and ``end`` values to read additional history.

Returns:
An async paginator over matching trades.
"""
Expand Down Expand Up @@ -808,6 +811,9 @@ def list_activity(
) -> AsyncPaginator[Activity]:
"""List user activity.

Pagination cannot continue past a starting offset of 5,000. Use
bounded ``start`` and ``end`` values to read additional history.

Returns:
An async paginator over matching activity entries.
"""
Expand Down
6 changes: 6 additions & 0 deletions src/polymarket/clients/async_secure.py
Original file line number Diff line number Diff line change
Expand Up @@ -1404,6 +1404,9 @@ def list_trades(
) -> AsyncPaginator[Trade]:
"""List trades for a user or the authenticated wallet.

Pagination cannot continue past a starting offset of 10,000. Use
bounded ``start`` and ``end`` values to read additional history.

Returns:
An async paginator over matching trades.
"""
Expand Down Expand Up @@ -1436,6 +1439,9 @@ def list_activity(
) -> AsyncPaginator[Activity]:
"""List activity for a user or the authenticated wallet.

Pagination cannot continue past a starting offset of 5,000. Use
bounded ``start`` and ``end`` values to read additional history.

Returns:
An async paginator over matching activity entries.
"""
Expand Down
6 changes: 6 additions & 0 deletions src/polymarket/clients/public.py
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,9 @@ def list_trades(
) -> Paginator[Trade]:
"""List public trades.

Pagination cannot continue past a starting offset of 10,000. Use
bounded ``start`` and ``end`` values to read additional history.

Returns:
A paginator over matching trades.
"""
Expand Down Expand Up @@ -588,6 +591,9 @@ def list_activity(
) -> Paginator[Activity]:
"""List user activity.

Pagination cannot continue past a starting offset of 5,000. Use
bounded ``start`` and ``end`` values to read additional history.

Returns:
A paginator over matching activity entries.
"""
Expand Down
6 changes: 6 additions & 0 deletions src/polymarket/clients/secure.py
Original file line number Diff line number Diff line change
Expand Up @@ -933,6 +933,9 @@ def list_trades(
) -> Paginator[Trade]:
"""List trades for a user or the authenticated wallet.

Pagination cannot continue past a starting offset of 10,000. Use
bounded ``start`` and ``end`` values to read additional history.

Returns:
A paginator over matching trades.
"""
Expand Down Expand Up @@ -965,6 +968,9 @@ def list_activity(
) -> Paginator[Activity]:
"""List activity for a user or the authenticated wallet.

Pagination cannot continue past a starting offset of 5,000. Use
bounded ``start`` and ``end`` values to read additional history.

Returns:
A paginator over matching activity entries.
"""
Expand Down
4 changes: 4 additions & 0 deletions src/polymarket/models/data/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,8 @@ class ComboPosition(BaseModel):
shares: Decimal = Field(validation_alias="shares_balance")
entry_avg_price_usdc: Decimal | None = None
entry_cost_usdc: Decimal | None = None
gross_entry_cost_usdc: Decimal | None = None
entry_fees_usdc: Decimal | None = None
realized_payout_usdc: Decimal | None = None
total_cost_usdc: Decimal | None = None
status: ComboPositionStatus
Expand All @@ -258,6 +260,8 @@ def _validate_condition_id(cls, value: object) -> ComboConditionId:
"shares",
"entry_avg_price_usdc",
"entry_cost_usdc",
"gross_entry_cost_usdc",
"entry_fees_usdc",
"realized_payout_usdc",
"total_cost_usdc",
mode="before",
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/test_data_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ def _combo_position_payload(*, condition_id: str = _COMBO_CONDITION_ID) -> dict[
"shares_balance": "42.5",
"entry_avg_price_usdc": "0.12",
"entry_cost_usdc": "5.1",
"gross_entry_cost_usdc": "5.123456",
"entry_fees_usdc": "0.023456",
"realized_payout_usdc": "6.25",
"total_cost_usdc": "5.1",
"status": "OPEN",
Expand Down Expand Up @@ -228,6 +230,8 @@ def test_combo_position_parses_payload() -> None:
assert combo.outcome == "YES"
assert combo.wallet == "0x0000000000000000000000000000000000000001"
assert combo.shares == Decimal("42.5")
assert combo.gross_entry_cost_usdc == Decimal("5.123456")
assert combo.entry_fees_usdc == Decimal("0.023456")
assert combo.realized_payout_usdc == Decimal("6.25")
assert combo.total_cost_usdc == Decimal("5.1")
assert combo.status == "OPEN"
Expand Down
25 changes: 15 additions & 10 deletions tests/unit/test_data_paginated_specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,15 +291,15 @@ def test_list_trader_leaderboard_spec_validates_category() -> None:


@pytest.mark.parametrize(
("spec", "expected_max"),
("spec", "expected_page_size", "expected_offset"),
[
(data_actions.list_positions_spec(user="0xWALLET"), 500),
(data_actions.list_closed_positions_spec(user="0xWALLET"), 50),
(data_actions.list_market_positions_spec(market="0xabc"), 500),
(data_actions.list_trades_spec(), 10_000),
(data_actions.list_activity_spec(user="0xWALLET"), 500),
(data_actions.list_builder_leaderboard_spec(), 50),
(data_actions.list_trader_leaderboard_spec(), 50),
(data_actions.list_positions_spec(user="0xWALLET"), 500, None),
(data_actions.list_closed_positions_spec(user="0xWALLET"), 50, None),
(data_actions.list_market_positions_spec(market="0xabc"), 500, None),
(data_actions.list_trades_spec(), 10_000, 10_000),
(data_actions.list_activity_spec(user="0xWALLET"), 500, 5_000),
(data_actions.list_builder_leaderboard_spec(), 50, None),
(data_actions.list_trader_leaderboard_spec(), 50, None),
],
ids=[
"positions",
Expand All @@ -311,9 +311,14 @@ def test_list_trader_leaderboard_spec_validates_category() -> None:
"trader-leaderboard",
],
)
def test_offset_specs_cap_page_size_at_server_limit(spec: object, expected_max: int) -> None:
def test_offset_specs_match_server_pagination_limits(
spec: object,
expected_page_size: int,
expected_offset: int | None,
) -> None:
# Each cap matches the server-side limit cap. Page sizes past the cap fail
# fast instead of the server clamping or rejecting the request and
# pagination silently misbehaving.
assert isinstance(spec, data_actions.OffsetPaginatedSpec)
assert spec.max_page_size == expected_max
assert spec.max_page_size == expected_page_size
assert spec.max_offset == expected_offset
45 changes: 45 additions & 0 deletions tests/unit/test_paginate_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@ def _spec(
path: str = "/positions",
base_params: dict[str, str] | None = None,
max_page_size: int | None = None,
max_offset: int | None = None,
):
return OffsetPaginatedSpec[int](
service="data",
path=path,
parse_items=lambda payload: tuple(payload), # type: ignore[arg-type]
base_params=base_params,
max_page_size=max_page_size,
max_offset=max_offset,
)


Expand Down Expand Up @@ -169,6 +171,26 @@ def test_sync_paginate_offset_continues_past_full_page() -> None:
assert offsets == ["0", "10", "20"]


def test_sync_paginate_offset_rejects_continuation_past_spec_max_offset() -> None:
captured: list[httpx.Request] = []
handler = _items_handler(captured, [list(range(0, 10)), list(range(10, 20))])
with PublicClient() as client:
_install_sync_data_transport(client, handler)
paginator = sync_paginate_offset(
client._ctx,
_spec(path="/trades", max_offset=10),
page_size=10,
)
pages = iter(paginator)
assert next(pages).items == tuple(range(10))
assert next(pages).items == tuple(range(10, 20))
with pytest.raises(UserInputError, match="maximum offset of 10"):
next(pages)

offsets = [parse_qs(urlparse(str(request.url)).query)["offset"][0] for request in captured]
assert offsets == ["0", "10"]


def test_sync_paginate_offset_no_more_when_partial() -> None:
captured: list[httpx.Request] = []
handler = _items_handler(captured, [list(range(3))])
Expand Down Expand Up @@ -293,6 +315,29 @@ async def run() -> None:
asyncio.run(run())


def test_async_paginate_offset_rejects_continuation_past_spec_max_offset() -> None:
async def run() -> None:
captured: list[httpx.Request] = []
handler = _items_handler(captured, [list(range(0, 10)), list(range(10, 20))])
async with AsyncPublicClient() as client:
_install_async_data_transport(client, handler)
paginator = async_paginate_offset(
client._ctx,
_spec(path="/activity", max_offset=10),
page_size=10,
)
pages = paginator.__aiter__()
assert (await anext(pages)).items == tuple(range(10))
assert (await anext(pages)).items == tuple(range(10, 20))
with pytest.raises(UserInputError, match="maximum offset of 10"):
await anext(pages)

offsets = [parse_qs(urlparse(str(request.url)).query)["offset"][0] for request in captured]
assert offsets == ["0", "10"]

asyncio.run(run())


def test_async_paginate_offset_cursor_rejects_different_endpoint() -> None:
async def run() -> None:
captured: list[httpx.Request] = []
Expand Down
Loading