Skip to content
Merged
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ All notable changes to kalshi-sdk will be documented in this file.

## [Unreleased]

### Added

- **`max_pages: int | None` kwarg on every public `*_all()` method** — sync and
async (19 + 19 method signatures). Bounded iteration without manual pagination.
`None` (default) iterates until the server returns no cursor; the existing
cursor-repeat guard remains the safety net against infinite loops (#98).
- **`RateLimit` model** exposed via `kalshi.RateLimit` — represents the per-
direction token-bucket structure on `AccountApiLimits.read` / `.write`.

### Changed

- **`*_all()` methods are now unbounded by default.** Previously, internal
`_list_all` had an invisible 1000-page safety cap that silently truncated
callers iterating beyond ~100k items. The cap is gone; the cursor-repeat
guard (`KalshiError` on a repeating cursor) provides the runaway protection
it always did. Callers who want a cap pass `max_pages=N` explicitly. No
user-visible change for anyone iterating <1000 pages (#98).

### Fixed

- **`/account/limits` response now parses against the live server.** The
Expand Down
39 changes: 31 additions & 8 deletions kalshi/resources/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ def _check_request_exclusive(request: Any, **kwargs: Any) -> None:
)


def _validate_max_pages(max_pages: int | None) -> None:
"""Reject ``max_pages <= 0`` at the public ``*_all()`` boundary.

``None`` (unbounded — iterates until the server returns no cursor) and
positive integers are valid. Zero or negative would silently produce an
empty iterator deep inside ``_list_all``; surfacing the misuse here is
friendlier.
"""
if max_pages is not None and max_pages <= 0:
raise ValueError(
f"max_pages must be positive or None, got {max_pages}"
)


def _join_tickers(value: list[str] | tuple[str, ...] | str | None) -> str | None:
"""Serialize the `tickers` query param (spec: comma-joined string, not explode:true).

Expand Down Expand Up @@ -151,7 +165,7 @@ def _list_all(
items_key: str,
*,
params: dict[str, Any] | None = None,
max_pages: int = 1000,
max_pages: int | None = None,
cursor_key: str = "cursor",
) -> Iterator[T]:
"""Auto-paginate through all pages, yielding items.
Expand All @@ -160,18 +174,24 @@ def _list_all(
convention). ``cursor_key`` only affects how the response envelope
is parsed.

``max_pages`` is an optional hard cap on pages fetched. ``None``
(default) iterates until the server returns an empty cursor; the
cursor-repeat guard below provides the safety net against
infinite loops.

Raises ``KalshiError`` if a cursor value repeats, which indicates
a server-side pagination bug that would otherwise cause the safety
cap to silently issue ``max_pages`` duplicate requests.
a server-side pagination bug.
"""
current_params = dict(params) if params else {}
seen_cursors: set[str] = set()
for _ in range(max_pages):
pages_fetched = 0
while max_pages is None or pages_fetched < max_pages:
page = self._list(
path, model_cls, items_key,
params=current_params, cursor_key=cursor_key,
)
yield from page.items
pages_fetched += 1
if not page.cursor:
break
if page.cursor in seen_cursors:
Expand Down Expand Up @@ -264,21 +284,24 @@ async def _list_all(
items_key: str,
*,
params: dict[str, Any] | None = None,
max_pages: int = 1000,
max_pages: int | None = None,
cursor_key: str = "cursor",
) -> AsyncIterator[T]:
"""Async counterpart of ``SyncResource._list_all``. Raises
``KalshiError`` on repeated cursor; see sync docstring.
"""Async counterpart of :meth:`SyncResource._list_all`.

Raises ``KalshiError`` on repeated cursor; see sync docstring.
"""
current_params = dict(params) if params else {}
seen_cursors: set[str] = set()
for _ in range(max_pages):
pages_fetched = 0
while max_pages is None or pages_fetched < max_pages:
page = await self._list(
path, model_cls, items_key,
params=current_params, cursor_key=cursor_key,
)
for item in page.items:
yield item
pages_fetched += 1
if not page.cursor:
break
if page.cursor in seen_cursors:
Expand Down
31 changes: 23 additions & 8 deletions kalshi/resources/communications.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
SyncResource,
_check_request_exclusive,
_params,
_validate_max_pages,
)


Expand Down Expand Up @@ -212,14 +213,19 @@ def list_all_rfqs(
subaccount: int | None = None,
status: str | None = None,
creator_user_id: str | None = None,
max_pages: int | None = None,
) -> Iterator[RFQ]:
self._require_auth()
_validate_max_pages(max_pages)
params = _list_rfqs_params(
cursor=None, limit=limit, event_ticker=event_ticker,
market_ticker=market_ticker, subaccount=subaccount,
status=status, creator_user_id=creator_user_id,
)
yield from self._list_all("/communications/rfqs", RFQ, "rfqs", params=params)
return self._list_all(
"/communications/rfqs", RFQ, "rfqs",
params=params, max_pages=max_pages,
)

def get_rfq(self, rfq_id: str) -> GetRFQResponse:
self._require_auth()
Expand Down Expand Up @@ -303,9 +309,11 @@ def list_all_quotes(
rfq_creator_user_id: str | None = None,
rfq_creator_subtrader_id: str | None = None,
rfq_id: str | None = None,
max_pages: int | None = None,
) -> Iterator[Quote]:
self._require_auth()
_require_quote_filter(quote_creator_user_id, rfq_creator_user_id)
_validate_max_pages(max_pages)
params = _list_quotes_params(
cursor=None, limit=limit, event_ticker=event_ticker,
market_ticker=market_ticker, status=status,
Expand All @@ -315,7 +323,8 @@ def list_all_quotes(
rfq_id=rfq_id,
)
return self._list_all(
"/communications/quotes", Quote, "quotes", params=params,
"/communications/quotes", Quote, "quotes",
params=params, max_pages=max_pages,
)

def get_quote(self, quote_id: str) -> GetQuoteResponse:
Expand Down Expand Up @@ -409,7 +418,7 @@ async def list_rfqs(
)
return await self._list("/communications/rfqs", RFQ, "rfqs", params=params)

async def list_all_rfqs(
def list_all_rfqs(
self,
*,
limit: int | None = None,
Expand All @@ -418,17 +427,20 @@ async def list_all_rfqs(
subaccount: int | None = None,
status: str | None = None,
creator_user_id: str | None = None,
max_pages: int | None = None,
) -> AsyncIterator[RFQ]:
# Plain `def` so _require_auth + _validate_max_pages run at call time.
self._require_auth()
_validate_max_pages(max_pages)
params = _list_rfqs_params(
cursor=None, limit=limit, event_ticker=event_ticker,
market_ticker=market_ticker, subaccount=subaccount,
status=status, creator_user_id=creator_user_id,
)
async for item in self._list_all(
"/communications/rfqs", RFQ, "rfqs", params=params,
):
yield item
return self._list_all(
"/communications/rfqs", RFQ, "rfqs",
params=params, max_pages=max_pages,
)

async def get_rfq(self, rfq_id: str) -> GetRFQResponse:
self._require_auth()
Expand Down Expand Up @@ -514,9 +526,11 @@ def list_all_quotes(
rfq_creator_user_id: str | None = None,
rfq_creator_subtrader_id: str | None = None,
rfq_id: str | None = None,
max_pages: int | None = None,
) -> AsyncIterator[Quote]:
self._require_auth()
_require_quote_filter(quote_creator_user_id, rfq_creator_user_id)
_validate_max_pages(max_pages)
params = _list_quotes_params(
cursor=None, limit=limit, event_ticker=event_ticker,
market_ticker=market_ticker, status=status,
Expand All @@ -526,7 +540,8 @@ def list_all_quotes(
rfq_id=rfq_id,
)
return self._list_all(
"/communications/quotes", Quote, "quotes", params=params,
"/communications/quotes", Quote, "quotes",
params=params, max_pages=max_pages,
)

async def get_quote(self, quote_id: str) -> GetQuoteResponse:
Expand Down
34 changes: 29 additions & 5 deletions kalshi/resources/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@

from kalshi.models.common import Page
from kalshi.models.events import Event, EventMetadata, EventStatusLiteral
from kalshi.resources._base import AsyncResource, SyncResource, _bool_param, _params
from kalshi.resources._base import (
AsyncResource,
SyncResource,
_bool_param,
_params,
_validate_max_pages,
)

# Shared param builders (issue #46).

Expand Down Expand Up @@ -86,15 +92,19 @@ def list_all(
min_close_ts: int | None = None,
min_updated_ts: int | None = None,
limit: int | None = None,
max_pages: int | None = None,
) -> Iterator[Event]:
_validate_max_pages(max_pages)
params = _list_events_params(
status=status, series_ticker=series_ticker,
with_nested_markets=with_nested_markets,
with_milestones=with_milestones,
min_close_ts=min_close_ts, min_updated_ts=min_updated_ts,
limit=limit, cursor=None,
)
return self._list_all("/events", Event, "events", params=params)
return self._list_all(
"/events", Event, "events", params=params, max_pages=max_pages,
)

def list_multivariate(
self,
Expand All @@ -120,14 +130,19 @@ def list_all_multivariate(
collection_ticker: str | None = None,
with_nested_markets: bool | None = None,
limit: int | None = None,
max_pages: int | None = None,
) -> Iterator[Event]:
_validate_max_pages(max_pages)
params = _list_multivariate_events_params(
series_ticker=series_ticker,
collection_ticker=collection_ticker,
with_nested_markets=with_nested_markets,
limit=limit, cursor=None,
)
return self._list_all("/events/multivariate", Event, "events", params=params)
return self._list_all(
"/events/multivariate", Event, "events",
params=params, max_pages=max_pages,
)

def get(
self,
Expand Down Expand Up @@ -180,15 +195,19 @@ def list_all(
min_close_ts: int | None = None,
min_updated_ts: int | None = None,
limit: int | None = None,
max_pages: int | None = None,
) -> AsyncIterator[Event]:
_validate_max_pages(max_pages)
params = _list_events_params(
status=status, series_ticker=series_ticker,
with_nested_markets=with_nested_markets,
with_milestones=with_milestones,
min_close_ts=min_close_ts, min_updated_ts=min_updated_ts,
limit=limit, cursor=None,
)
return self._list_all("/events", Event, "events", params=params)
return self._list_all(
"/events", Event, "events", params=params, max_pages=max_pages,
)

async def list_multivariate(
self,
Expand All @@ -214,14 +233,19 @@ def list_all_multivariate(
collection_ticker: str | None = None,
with_nested_markets: bool | None = None,
limit: int | None = None,
max_pages: int | None = None,
) -> AsyncIterator[Event]:
_validate_max_pages(max_pages)
params = _list_multivariate_events_params(
series_ticker=series_ticker,
collection_ticker=collection_ticker,
with_nested_markets=with_nested_markets,
limit=limit, cursor=None,
)
return self._list_all("/events/multivariate", Event, "events", params=params)
return self._list_all(
"/events/multivariate", Event, "events",
params=params, max_pages=max_pages,
)

async def get(
self,
Expand Down
21 changes: 18 additions & 3 deletions kalshi/resources/fcm.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@
from kalshi.models.common import Page
from kalshi.models.orders import Order, OrderStatusLiteral
from kalshi.models.portfolio import PositionsResponse, SettlementStatusLiteral
from kalshi.resources._base import AsyncResource, SyncResource, _params
from kalshi.resources._base import (
AsyncResource,
SyncResource,
_params,
_validate_max_pages,
)

# Shared param builders (issue #46).

Expand Down Expand Up @@ -100,14 +105,19 @@ def orders_all(
min_ts: int | None = None,
max_ts: int | None = None,
limit: int | None = None,
max_pages: int | None = None,
) -> Iterator[Order]:
self._require_auth()
_validate_max_pages(max_pages)
params = _fcm_orders_params(
subtrader_id=subtrader_id, ticker=ticker,
event_ticker=event_ticker, status=status,
min_ts=min_ts, max_ts=max_ts, limit=limit, cursor=None,
)
return self._list_all("/fcm/orders", Order, "orders", params=params)
return self._list_all(
"/fcm/orders", Order, "orders",
params=params, max_pages=max_pages,
)

def positions(
self,
Expand Down Expand Up @@ -164,15 +174,20 @@ def orders_all(
min_ts: int | None = None,
max_ts: int | None = None,
limit: int | None = None,
max_pages: int | None = None,
) -> AsyncIterator[Order]:
"""Returns an async iterator — use ``async for``."""
self._require_auth()
_validate_max_pages(max_pages)
params = _fcm_orders_params(
subtrader_id=subtrader_id, ticker=ticker,
event_ticker=event_ticker, status=status,
min_ts=min_ts, max_ts=max_ts, limit=limit, cursor=None,
)
return self._list_all("/fcm/orders", Order, "orders", params=params)
return self._list_all(
"/fcm/orders", Order, "orders",
params=params, max_pages=max_pages,
)

async def positions(
self,
Expand Down
Loading
Loading