From 2df3deaaf9a40d6b5468fed723b5833360a2d53a Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sun, 17 May 2026 09:36:35 -0500 Subject: [PATCH 1/4] feat(paginators): expose max_pages on every public *_all() method Plumb max_pages: int | None = None through all 19 public *_all() paginator methods (sync + async, 38 signatures) so callers can cap fetch depth from outside without subclassing the resource. Previously the cap existed only on the internal _list_all and silently defaulted to 1000 pages. - _list_all: accept max_pages=None (default 1000), positive int caps page fetches at that count. - _validate_max_pages: rejects max_pages <= 0 with ValueError at the public boundary; None and positive ints pass through. - Add "client_only" ExclusionKind for kwargs that have no wire counterpart (max_pages is purely a client-side knob). EXCLUSIONS registers max_pages for every *_all FQN so TestRequestParamDrift doesn't flag the new kwarg as REMOVE drift. - Tests cover the bare numeric cap (cap=1 / cap=3 with a fresh-cursor side effect so cursor-loop detection can't trip), empty-cursor termination (F-Q-17 regression), and ValueError on max_pages=0 at the public list_all boundary (markets). Closes #98 Co-Authored-By: Claude Opus 4.7 (1M context) --- kalshi/resources/_base.py | 33 ++++- kalshi/resources/communications.py | 23 +++- kalshi/resources/events.py | 34 +++++- kalshi/resources/fcm.py | 21 +++- kalshi/resources/historical.py | 64 ++++++++-- kalshi/resources/incentive_programs.py | 13 +- kalshi/resources/markets.py | 29 ++++- kalshi/resources/milestones.py | 17 ++- kalshi/resources/multivariate.py | 7 ++ kalshi/resources/orders.py | 29 ++++- kalshi/resources/portfolio.py | 19 ++- kalshi/resources/structured_targets.py | 13 +- kalshi/resources/subaccounts.py | 15 ++- tests/_contract_support.py | 35 ++++++ tests/test_base_helpers.py | 161 ++++++++++++++++++++++++- tests/test_contracts.py | 4 +- tests/test_markets.py | 28 +++++ 17 files changed, 498 insertions(+), 47 deletions(-) diff --git a/kalshi/resources/_base.py b/kalshi/resources/_base.py index 0e326d1..4e6a398 100644 --- a/kalshi/resources/_base.py +++ b/kalshi/resources/_base.py @@ -44,6 +44,19 @@ 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`` (use default cap) 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). @@ -151,7 +164,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. @@ -160,13 +173,19 @@ def _list_all( convention). ``cursor_key`` only affects how the response envelope is parsed. + ``max_pages`` caps the number of pages fetched as a safety net for + servers that never repeat a cursor but never return empty either. + ``None`` (default) means the built-in ceiling of 1000. Callers that + need a tighter cap (e.g. a quick preview) pass it explicitly. + 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. """ + page_cap = 1000 if max_pages is None else max_pages current_params = dict(params) if params else {} seen_cursors: set[str] = set() - for _ in range(max_pages): + for _ in range(page_cap): page = self._list( path, model_cls, items_key, params=current_params, cursor_key=cursor_key, @@ -264,15 +283,17 @@ 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 ``SyncResource._list_all``. ``max_pages`` + semantics mirror the sync version (``None`` -> 1000 default). + Raises ``KalshiError`` on repeated cursor; see sync docstring. """ + page_cap = 1000 if max_pages is None else max_pages current_params = dict(params) if params else {} seen_cursors: set[str] = set() - for _ in range(max_pages): + for _ in range(page_cap): page = await self._list( path, model_cls, items_key, params=current_params, cursor_key=cursor_key, diff --git a/kalshi/resources/communications.py b/kalshi/resources/communications.py index 14807f6..e9c9c59 100644 --- a/kalshi/resources/communications.py +++ b/kalshi/resources/communications.py @@ -24,6 +24,7 @@ SyncResource, _check_request_exclusive, _params, + _validate_max_pages, ) @@ -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) + yield from self._list_all( + "/communications/rfqs", RFQ, "rfqs", + params=params, max_pages=max_pages, + ) def get_rfq(self, rfq_id: str) -> GetRFQResponse: self._require_auth() @@ -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, @@ -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: @@ -418,15 +427,18 @@ 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]: 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, + "/communications/rfqs", RFQ, "rfqs", + params=params, max_pages=max_pages, ): yield item @@ -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, @@ -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: diff --git a/kalshi/resources/events.py b/kalshi/resources/events.py index d780272..fc85f86 100644 --- a/kalshi/resources/events.py +++ b/kalshi/resources/events.py @@ -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). @@ -86,7 +92,9 @@ 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, @@ -94,7 +102,9 @@ def list_all( 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, @@ -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, @@ -180,7 +195,9 @@ 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, @@ -188,7 +205,9 @@ def list_all( 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, @@ -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, diff --git a/kalshi/resources/fcm.py b/kalshi/resources/fcm.py index 212a689..121a229 100644 --- a/kalshi/resources/fcm.py +++ b/kalshi/resources/fcm.py @@ -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). @@ -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, @@ -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, diff --git a/kalshi/resources/historical.py b/kalshi/resources/historical.py index 549e216..3de7f39 100644 --- a/kalshi/resources/historical.py +++ b/kalshi/resources/historical.py @@ -14,7 +14,13 @@ ) from kalshi.models.markets import Candlestick, Market from kalshi.models.orders import Fill, Order -from kalshi.resources._base import AsyncResource, SyncResource, _join_tickers, _params +from kalshi.resources._base import ( + AsyncResource, + SyncResource, + _join_tickers, + _params, + _validate_max_pages, +) # Shared param builders (issue #46). @@ -103,13 +109,18 @@ def markets_all( event_ticker: str | None = None, series_ticker: str | None = None, mve_filter: MveHistoricalFilterLiteral | None = None, + max_pages: int | None = None, ) -> Iterator[Market]: + _validate_max_pages(max_pages) params = _historical_markets_params( limit=limit, cursor=None, tickers=tickers, event_ticker=event_ticker, series_ticker=series_ticker, mve_filter=mve_filter, ) - return self._list_all("/historical/markets", Market, "markets", params=params) + return self._list_all( + "/historical/markets", Market, "markets", + params=params, max_pages=max_pages, + ) def market(self, ticker: str) -> Market: data = self._get(f"/historical/markets/{ticker}") @@ -155,12 +166,17 @@ def fills_all( limit: int | None = None, ticker: str | None = None, max_ts: int | None = None, + max_pages: int | None = None, ) -> Iterator[Fill]: self._require_auth() + _validate_max_pages(max_pages) params = _historical_fills_or_orders_params( limit=limit, cursor=None, ticker=ticker, max_ts=max_ts, ) - return self._list_all("/historical/fills", Fill, "fills", params=params) + return self._list_all( + "/historical/fills", Fill, "fills", + params=params, max_pages=max_pages, + ) def orders( self, @@ -182,12 +198,17 @@ def orders_all( limit: int | None = None, ticker: str | None = None, max_ts: int | None = None, + max_pages: int | None = None, ) -> Iterator[Order]: self._require_auth() + _validate_max_pages(max_pages) params = _historical_fills_or_orders_params( limit=limit, cursor=None, ticker=ticker, max_ts=max_ts, ) - return self._list_all("/historical/orders", Order, "orders", params=params) + return self._list_all( + "/historical/orders", Order, "orders", + params=params, max_pages=max_pages, + ) def trades( self, @@ -211,12 +232,17 @@ def trades_all( ticker: str | None = None, min_ts: int | None = None, max_ts: int | None = None, + max_pages: int | None = None, ) -> Iterator[Trade]: + _validate_max_pages(max_pages) params = _historical_trades_params( limit=limit, cursor=None, ticker=ticker, min_ts=min_ts, max_ts=max_ts, ) - return self._list_all("/historical/trades", Trade, "trades", params=params) + return self._list_all( + "/historical/trades", Trade, "trades", + params=params, max_pages=max_pages, + ) class AsyncHistoricalResource(AsyncResource): """Async historical data API.""" @@ -250,13 +276,18 @@ def markets_all( event_ticker: str | None = None, series_ticker: str | None = None, mve_filter: MveHistoricalFilterLiteral | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Market]: + _validate_max_pages(max_pages) params = _historical_markets_params( limit=limit, cursor=None, tickers=tickers, event_ticker=event_ticker, series_ticker=series_ticker, mve_filter=mve_filter, ) - return self._list_all("/historical/markets", Market, "markets", params=params) + return self._list_all( + "/historical/markets", Market, "markets", + params=params, max_pages=max_pages, + ) async def market(self, ticker: str) -> Market: data = await self._get(f"/historical/markets/{ticker}") @@ -302,12 +333,17 @@ def fills_all( limit: int | None = None, ticker: str | None = None, max_ts: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Fill]: self._require_auth() + _validate_max_pages(max_pages) params = _historical_fills_or_orders_params( limit=limit, cursor=None, ticker=ticker, max_ts=max_ts, ) - return self._list_all("/historical/fills", Fill, "fills", params=params) + return self._list_all( + "/historical/fills", Fill, "fills", + params=params, max_pages=max_pages, + ) async def orders( self, @@ -329,12 +365,17 @@ def orders_all( limit: int | None = None, ticker: str | None = None, max_ts: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Order]: self._require_auth() + _validate_max_pages(max_pages) params = _historical_fills_or_orders_params( limit=limit, cursor=None, ticker=ticker, max_ts=max_ts, ) - return self._list_all("/historical/orders", Order, "orders", params=params) + return self._list_all( + "/historical/orders", Order, "orders", + params=params, max_pages=max_pages, + ) async def trades( self, @@ -358,9 +399,14 @@ def trades_all( ticker: str | None = None, min_ts: int | None = None, max_ts: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Trade]: + _validate_max_pages(max_pages) params = _historical_trades_params( limit=limit, cursor=None, ticker=ticker, min_ts=min_ts, max_ts=max_ts, ) - return self._list_all("/historical/trades", Trade, "trades", params=params) + return self._list_all( + "/historical/trades", Trade, "trades", + params=params, max_pages=max_pages, + ) diff --git a/kalshi/resources/incentive_programs.py b/kalshi/resources/incentive_programs.py index 701abd8..ecd5e0e 100644 --- a/kalshi/resources/incentive_programs.py +++ b/kalshi/resources/incentive_programs.py @@ -15,7 +15,12 @@ IncentiveProgramStatusLiteral, IncentiveProgramTypeLiteral, ) -from kalshi.resources._base import AsyncResource, SyncResource, _params +from kalshi.resources._base import ( + AsyncResource, + SyncResource, + _params, + _validate_max_pages, +) class IncentiveProgramsResource(SyncResource): @@ -53,13 +58,16 @@ def list_all( status: IncentiveProgramStatusLiteral | None = None, incentive_type: IncentiveProgramTypeLiteral | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> Iterator[IncentiveProgram]: + _validate_max_pages(max_pages) params = _params(status=status, type=incentive_type, limit=limit) yield from self._list_all( "/incentive_programs", IncentiveProgram, "incentive_programs", params=params, + max_pages=max_pages, cursor_key="next_cursor", ) @@ -95,13 +103,16 @@ def list_all( status: IncentiveProgramStatusLiteral | None = None, incentive_type: IncentiveProgramTypeLiteral | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[IncentiveProgram]: """Returns an async iterator — use ``async for``.""" + _validate_max_pages(max_pages) params = _params(status=status, type=incentive_type, limit=limit) return self._list_all( "/incentive_programs", IncentiveProgram, "incentive_programs", params=params, + max_pages=max_pages, cursor_key="next_cursor", ) diff --git a/kalshi/resources/markets.py b/kalshi/resources/markets.py index 6a61d0a..c09454d 100644 --- a/kalshi/resources/markets.py +++ b/kalshi/resources/markets.py @@ -24,6 +24,7 @@ _bool_param, _join_tickers, _params, + _validate_max_pages, ) _MAX_BULK = 100 @@ -264,7 +265,9 @@ def list_all( min_settled_ts: int | None = None, max_settled_ts: int | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> Iterator[Market]: + _validate_max_pages(max_pages) params = _list_markets_params( status=status, series_ticker=series_ticker, event_ticker=event_ticker, tickers=tickers, @@ -275,7 +278,10 @@ def list_all( min_settled_ts=min_settled_ts, max_settled_ts=max_settled_ts, limit=limit, cursor=None, ) - return self._list_all("/markets", Market, "markets", params=params) + return self._list_all( + "/markets", Market, "markets", + params=params, max_pages=max_pages, + ) def get(self, ticker: str) -> Market: data = self._get(f"/markets/{ticker}") @@ -332,12 +338,17 @@ def list_trades_all( min_ts: int | None = None, max_ts: int | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> Iterator[Trade]: + _validate_max_pages(max_pages) params = _list_trades_params( ticker=ticker, min_ts=min_ts, max_ts=max_ts, limit=limit, cursor=None, ) - return self._list_all("/markets/trades", Trade, "trades", params=params) + return self._list_all( + "/markets/trades", Trade, "trades", + params=params, max_pages=max_pages, + ) def bulk_candlesticks( self, @@ -429,8 +440,10 @@ def list_all( min_settled_ts: int | None = None, max_settled_ts: int | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Market]: """Non-async method that returns an async iterator for direct use with `async for`.""" + _validate_max_pages(max_pages) params = _list_markets_params( status=status, series_ticker=series_ticker, event_ticker=event_ticker, tickers=tickers, @@ -441,7 +454,10 @@ def list_all( min_settled_ts=min_settled_ts, max_settled_ts=max_settled_ts, limit=limit, cursor=None, ) - return self._list_all("/markets", Market, "markets", params=params) + return self._list_all( + "/markets", Market, "markets", + params=params, max_pages=max_pages, + ) async def get(self, ticker: str) -> Market: data = await self._get(f"/markets/{ticker}") @@ -498,13 +514,18 @@ def list_trades_all( min_ts: int | None = None, max_ts: int | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Trade]: """Returns an async iterator — use ``async for``.""" + _validate_max_pages(max_pages) params = _list_trades_params( ticker=ticker, min_ts=min_ts, max_ts=max_ts, limit=limit, cursor=None, ) - return self._list_all("/markets/trades", Trade, "trades", params=params) + return self._list_all( + "/markets/trades", Trade, "trades", + params=params, max_pages=max_pages, + ) async def bulk_candlesticks( self, diff --git a/kalshi/resources/milestones.py b/kalshi/resources/milestones.py index 94c4c07..c250035 100644 --- a/kalshi/resources/milestones.py +++ b/kalshi/resources/milestones.py @@ -8,7 +8,12 @@ from kalshi.models.common import Page from kalshi.models.milestones import GetMilestoneResponse, Milestone -from kalshi.resources._base import AsyncResource, SyncResource, _params +from kalshi.resources._base import ( + AsyncResource, + SyncResource, + _params, + _validate_max_pages, +) def _iso(dt: datetime | str | None) -> str | None: @@ -104,7 +109,9 @@ def list_all( milestone_type: str | None = None, related_event_ticker: str | None = None, min_updated_ts: int | None = None, + max_pages: int | None = None, ) -> Iterator[Milestone]: + _validate_max_pages(max_pages) params = _list_milestones_params( limit=limit, minimum_start_date=minimum_start_date, category=category, competition=competition, @@ -113,7 +120,8 @@ def list_all( cursor=None, min_updated_ts=min_updated_ts, ) yield from self._list_all( - "/milestones", Milestone, "milestones", params=params, + "/milestones", Milestone, "milestones", + params=params, max_pages=max_pages, ) def get(self, milestone_id: str) -> Milestone: @@ -159,8 +167,10 @@ def list_all( milestone_type: str | None = None, related_event_ticker: str | None = None, min_updated_ts: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Milestone]: """Returns an async iterator — use ``async for``.""" + _validate_max_pages(max_pages) params = _list_milestones_params( limit=limit, minimum_start_date=minimum_start_date, category=category, competition=competition, @@ -169,7 +179,8 @@ def list_all( cursor=None, min_updated_ts=min_updated_ts, ) return self._list_all( - "/milestones", Milestone, "milestones", params=params, + "/milestones", Milestone, "milestones", + params=params, max_pages=max_pages, ) async def get(self, milestone_id: str) -> Milestone: diff --git a/kalshi/resources/multivariate.py b/kalshi/resources/multivariate.py index 7591c16..90527cd 100644 --- a/kalshi/resources/multivariate.py +++ b/kalshi/resources/multivariate.py @@ -22,6 +22,7 @@ SyncResource, _check_request_exclusive, _params, + _validate_max_pages, ) # Shared param + body builders (issue #46). @@ -131,7 +132,9 @@ def list_all( associated_event_ticker: str | None = None, series_ticker: str | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> Iterator[MultivariateEventCollection]: + _validate_max_pages(max_pages) params = _list_collections_params( status=status, associated_event_ticker=associated_event_ticker, @@ -143,6 +146,7 @@ def list_all( MultivariateEventCollection, "multivariate_contracts", params=params, + max_pages=max_pages, ) def get(self, collection_ticker: str) -> MultivariateEventCollection: @@ -266,7 +270,9 @@ def list_all( associated_event_ticker: str | None = None, series_ticker: str | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[MultivariateEventCollection]: + _validate_max_pages(max_pages) params = _list_collections_params( status=status, associated_event_ticker=associated_event_ticker, @@ -278,6 +284,7 @@ def list_all( MultivariateEventCollection, "multivariate_contracts", params=params, + max_pages=max_pages, ) async def get(self, collection_ticker: str) -> MultivariateEventCollection: diff --git a/kalshi/resources/orders.py b/kalshi/resources/orders.py index 4dde72d..fe97ae3 100644 --- a/kalshi/resources/orders.py +++ b/kalshi/resources/orders.py @@ -32,6 +32,7 @@ _check_request_exclusive, _join_tickers, _params, + _validate_max_pages, ) from kalshi.types import to_decimal @@ -396,14 +397,19 @@ def list_all( max_ts: int | None = None, limit: int | None = None, subaccount: int | None = None, + max_pages: int | None = None, ) -> Iterator[Order]: self._require_auth() + _validate_max_pages(max_pages) params = _list_orders_params( ticker=ticker, event_ticker=event_ticker, status=status, min_ts=min_ts, max_ts=max_ts, limit=limit, cursor=None, subaccount=subaccount, ) - return self._list_all("/portfolio/orders", Order, "orders", params=params) + return self._list_all( + "/portfolio/orders", Order, "orders", + params=params, max_pages=max_pages, + ) @overload def batch_create( @@ -492,14 +498,19 @@ def fills_all( max_ts: int | None = None, limit: int | None = None, subaccount: int | None = None, + max_pages: int | None = None, ) -> Iterator[Fill]: self._require_auth() + _validate_max_pages(max_pages) params = _fills_params( ticker=ticker, order_id=order_id, min_ts=min_ts, max_ts=max_ts, limit=limit, cursor=None, subaccount=subaccount, ) - return self._list_all("/portfolio/fills", Fill, "fills", params=params) + return self._list_all( + "/portfolio/fills", Fill, "fills", + params=params, max_pages=max_pages, + ) @overload def amend( @@ -721,15 +732,20 @@ def list_all( max_ts: int | None = None, limit: int | None = None, subaccount: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Order]: """Non-async method that returns an async iterator for direct use with `async for`.""" self._require_auth() + _validate_max_pages(max_pages) params = _list_orders_params( ticker=ticker, event_ticker=event_ticker, status=status, min_ts=min_ts, max_ts=max_ts, limit=limit, cursor=None, subaccount=subaccount, ) - return self._list_all("/portfolio/orders", Order, "orders", params=params) + return self._list_all( + "/portfolio/orders", Order, "orders", + params=params, max_pages=max_pages, + ) @overload async def batch_create( @@ -818,14 +834,19 @@ def fills_all( max_ts: int | None = None, limit: int | None = None, subaccount: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Fill]: self._require_auth() + _validate_max_pages(max_pages) params = _fills_params( ticker=ticker, order_id=order_id, min_ts=min_ts, max_ts=max_ts, limit=limit, cursor=None, subaccount=subaccount, ) - return self._list_all("/portfolio/fills", Fill, "fills", params=params) + return self._list_all( + "/portfolio/fills", Fill, "fills", + params=params, max_pages=max_pages, + ) @overload async def amend( diff --git a/kalshi/resources/portfolio.py b/kalshi/resources/portfolio.py index 638dc89..8166e45 100644 --- a/kalshi/resources/portfolio.py +++ b/kalshi/resources/portfolio.py @@ -12,7 +12,12 @@ Settlement, TotalRestingOrderValue, ) -from kalshi.resources._base import AsyncResource, SyncResource, _params +from kalshi.resources._base import ( + AsyncResource, + SyncResource, + _params, + _validate_max_pages, +) # Shared param builders (issue #46). @@ -112,14 +117,19 @@ def settlements_all( min_ts: int | None = None, max_ts: int | None = None, subaccount: int | None = None, + max_pages: int | None = None, ) -> Iterator[Settlement]: self._require_auth() + _validate_max_pages(max_pages) params = _settlements_params( limit=limit, cursor=None, ticker=ticker, event_ticker=event_ticker, min_ts=min_ts, max_ts=max_ts, subaccount=subaccount, ) - return self._list_all("/portfolio/settlements", Settlement, "settlements", params=params) + return self._list_all( + "/portfolio/settlements", Settlement, "settlements", + params=params, max_pages=max_pages, + ) def total_resting_order_value(self) -> TotalRestingOrderValue: """Total value of resting orders in cents. FCM-members only. @@ -189,15 +199,18 @@ def settlements_all( min_ts: int | None = None, max_ts: int | None = None, subaccount: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Settlement]: self._require_auth() + _validate_max_pages(max_pages) params = _settlements_params( limit=limit, cursor=None, ticker=ticker, event_ticker=event_ticker, min_ts=min_ts, max_ts=max_ts, subaccount=subaccount, ) return self._list_all( - "/portfolio/settlements", Settlement, "settlements", params=params + "/portfolio/settlements", Settlement, "settlements", + params=params, max_pages=max_pages, ) async def total_resting_order_value(self) -> TotalRestingOrderValue: diff --git a/kalshi/resources/structured_targets.py b/kalshi/resources/structured_targets.py index 5c928b7..ea00a77 100644 --- a/kalshi/resources/structured_targets.py +++ b/kalshi/resources/structured_targets.py @@ -10,7 +10,12 @@ GetStructuredTargetResponse, StructuredTarget, ) -from kalshi.resources._base import AsyncResource, SyncResource, _params +from kalshi.resources._base import ( + AsyncResource, + SyncResource, + _params, + _validate_max_pages, +) class StructuredTargetsResource(SyncResource): @@ -52,7 +57,9 @@ def list_all( target_type: str | None = None, competition: str | None = None, page_size: int | None = None, + max_pages: int | None = None, ) -> Iterator[StructuredTarget]: + _validate_max_pages(max_pages) params = _params( ids=ids, type=target_type, @@ -64,6 +71,7 @@ def list_all( StructuredTarget, "structured_targets", params=params, + max_pages=max_pages, ) def get(self, structured_target_id: str) -> StructuredTarget | None: @@ -104,8 +112,10 @@ def list_all( target_type: str | None = None, competition: str | None = None, page_size: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[StructuredTarget]: """Returns an async iterator — use ``async for``.""" + _validate_max_pages(max_pages) params = _params( ids=ids, type=target_type, @@ -117,6 +127,7 @@ def list_all( StructuredTarget, "structured_targets", params=params, + max_pages=max_pages, ) async def get(self, structured_target_id: str) -> StructuredTarget | None: diff --git a/kalshi/resources/subaccounts.py b/kalshi/resources/subaccounts.py index c650cf6..8bb642a 100644 --- a/kalshi/resources/subaccounts.py +++ b/kalshi/resources/subaccounts.py @@ -20,6 +20,7 @@ SyncResource, _check_request_exclusive, _params, + _validate_max_pages, ) # Shared body builders (issue #46). @@ -151,15 +152,20 @@ def list_transfers( ) def list_all_transfers( - self, *, limit: int | None = None, + self, + *, + limit: int | None = None, + max_pages: int | None = None, ) -> Iterator[SubaccountTransfer]: self._require_auth() + _validate_max_pages(max_pages) params = _params(limit=limit) yield from self._list_all( "/portfolio/subaccounts/transfers", SubaccountTransfer, "transfers", params=params, + max_pages=max_pages, ) @overload @@ -249,15 +255,20 @@ async def list_transfers( ) async def list_all_transfers( - self, *, limit: int | None = None, + self, + *, + limit: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[SubaccountTransfer]: self._require_auth() + _validate_max_pages(max_pages) params = _params(limit=limit) async for item in self._list_all( "/portfolio/subaccounts/transfers", SubaccountTransfer, "transfers", params=params, + max_pages=max_pages, ): yield item diff --git a/tests/_contract_support.py b/tests/_contract_support.py index 7fb1bed..8f31611 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -24,6 +24,7 @@ "paginator_handled", "wire_normalization", "kwarg_rename", + "client_only", ] @@ -65,6 +66,9 @@ class Exclusion: shadowing a Python built-in (e.g., spec's ``type`` becomes ``milestone_type`` / ``target_type`` / ``incentive_type``). The wire value is unchanged; only the Python signature differs. + - ``client_only``: SDK kwarg with no wire counterpart — purely a + client-side knob (e.g., ``max_pages`` cap on ``*_all`` paginators). + Legitimately present in the signature; not drift. """ reason: str @@ -1034,6 +1038,37 @@ class Exclusion: } +# --- max_pages: client-side cap on every public *_all() method (#98) --- +# Not a wire param — pure paginator safety knob. Every public *_all() +# accepts it; the kwarg legitimately exists in the signature. +_MAX_PAGES_FQNS: tuple[str, ...] = ( + "kalshi.resources.markets.MarketsResource.list_all", + "kalshi.resources.markets.MarketsResource.list_trades_all", + "kalshi.resources.milestones.MilestonesResource.list_all", + "kalshi.resources.events.EventsResource.list_all", + "kalshi.resources.events.EventsResource.list_all_multivariate", + "kalshi.resources.historical.HistoricalResource.markets_all", + "kalshi.resources.historical.HistoricalResource.fills_all", + "kalshi.resources.historical.HistoricalResource.orders_all", + "kalshi.resources.historical.HistoricalResource.trades_all", + "kalshi.resources.orders.OrdersResource.list_all", + "kalshi.resources.orders.OrdersResource.fills_all", + "kalshi.resources.communications.CommunicationsResource.list_all_rfqs", + "kalshi.resources.communications.CommunicationsResource.list_all_quotes", + "kalshi.resources.subaccounts.SubaccountsResource.list_all_transfers", + "kalshi.resources.portfolio.PortfolioResource.settlements_all", + "kalshi.resources.fcm.FcmResource.orders_all", + "kalshi.resources.incentive_programs.IncentiveProgramsResource.list_all", + "kalshi.resources.structured_targets.StructuredTargetsResource.list_all", + "kalshi.resources.multivariate.MultivariateCollectionsResource.list_all", +) +for _fqn in _MAX_PAGES_FQNS: + EXCLUSIONS[(_fqn, "max_pages")] = Exclusion( + reason="client-side max_pages cap on *_all() paginator; not a wire param (#98)", + kind="client_only", + ) + + def _resolve_ref( spec: dict[str, Any], ref: str, diff --git a/tests/test_base_helpers.py b/tests/test_base_helpers.py index 2497006..56bcf77 100644 --- a/tests/test_base_helpers.py +++ b/tests/test_base_helpers.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + import httpx import pytest import respx @@ -11,7 +13,12 @@ from kalshi.auth import KalshiAuth from kalshi.config import KalshiConfig from kalshi.errors import KalshiError -from kalshi.resources._base import AsyncResource, SyncResource, _join_tickers +from kalshi.resources._base import ( + AsyncResource, + SyncResource, + _join_tickers, + _validate_max_pages, +) class _Item(BaseModel): @@ -217,3 +224,155 @@ async def test_repeated_cursor_raises( pass assert route.call_count == 2 + + +def _fresh_cursor_side_effect() -> Any: + """Return a respx side_effect that yields a fresh unique cursor per call. + + Each response has one item and a never-repeated cursor so cursor-loop + detection never trips; only ``max_pages`` can stop the iterator. + """ + counter = {"n": 0} + + def _make_response(request: httpx.Request) -> httpx.Response: + counter["n"] += 1 + n = counter["n"] + return httpx.Response( + 200, json={"items": [{"id": f"item-{n}"}], "cursor": f"cur-{n}"}, + ) + + return _make_response + + +class TestSyncListAllMaxPagesCap: + """Cover the bare numeric ``max_pages`` cap in ``_list_all`` (#98).""" + + @respx.mock + def test_cap_of_one_fetches_single_page( + self, test_auth: KalshiAuth, test_config: KalshiConfig + ) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/things").mock( + side_effect=_fresh_cursor_side_effect() + ) + resource = SyncResource(SyncTransport(test_auth, test_config)) + + collected = list( + resource._list_all("/things", _Item, "items", max_pages=1) + ) + + assert route.call_count == 1 + assert [item.id for item in collected] == ["item-1"] + + @respx.mock + def test_cap_of_three_stops_at_three( + self, test_auth: KalshiAuth, test_config: KalshiConfig + ) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/things").mock( + side_effect=_fresh_cursor_side_effect() + ) + resource = SyncResource(SyncTransport(test_auth, test_config)) + + collected = list( + resource._list_all("/things", _Item, "items", max_pages=3) + ) + + assert route.call_count == 3 + assert [item.id for item in collected] == ["item-1", "item-2", "item-3"] + + @respx.mock + def test_empty_cursor_stops_after_one_request( + self, test_auth: KalshiAuth, test_config: KalshiConfig + ) -> None: + """Regression guard (F-Q-17): empty-string cursor terminates _list_all.""" + route = respx.get("https://test.kalshi.com/trade-api/v2/things").mock( + return_value=httpx.Response( + 200, json={"items": [{"id": "a"}], "cursor": ""} + ) + ) + resource = SyncResource(SyncTransport(test_auth, test_config)) + + collected = list(resource._list_all("/things", _Item, "items")) + + assert route.call_count == 1 + assert [item.id for item in collected] == ["a"] + + +class TestAsyncListAllMaxPagesCap: + """Async sibling of TestSyncListAllMaxPagesCap (#98).""" + + @respx.mock + @pytest.mark.asyncio + async def test_cap_of_one_fetches_single_page( + self, test_auth: KalshiAuth, test_config: KalshiConfig + ) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/things").mock( + side_effect=_fresh_cursor_side_effect() + ) + resource = AsyncResource(AsyncTransport(test_auth, test_config)) + + collected: list[_Item] = [] + async for item in resource._list_all( + "/things", _Item, "items", max_pages=1 + ): + collected.append(item) + + assert route.call_count == 1 + assert [item.id for item in collected] == ["item-1"] + + @respx.mock + @pytest.mark.asyncio + async def test_cap_of_three_stops_at_three( + self, test_auth: KalshiAuth, test_config: KalshiConfig + ) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/things").mock( + side_effect=_fresh_cursor_side_effect() + ) + resource = AsyncResource(AsyncTransport(test_auth, test_config)) + + collected: list[_Item] = [] + async for item in resource._list_all( + "/things", _Item, "items", max_pages=3 + ): + collected.append(item) + + assert route.call_count == 3 + assert [item.id for item in collected] == ["item-1", "item-2", "item-3"] + + @respx.mock + @pytest.mark.asyncio + async def test_empty_cursor_stops_after_one_request( + self, test_auth: KalshiAuth, test_config: KalshiConfig + ) -> None: + """Async regression guard (F-Q-17): empty-string cursor terminates.""" + route = respx.get("https://test.kalshi.com/trade-api/v2/things").mock( + return_value=httpx.Response( + 200, json={"items": [{"id": "a"}], "cursor": ""} + ) + ) + resource = AsyncResource(AsyncTransport(test_auth, test_config)) + + collected: list[_Item] = [] + async for item in resource._list_all("/things", _Item, "items"): + collected.append(item) + + assert route.call_count == 1 + assert [item.id for item in collected] == ["a"] + + +class TestValidateMaxPages: + """``_validate_max_pages`` rejects non-positive values at the public boundary.""" + + def test_none_allowed(self) -> None: + _validate_max_pages(None) # no raise + + def test_positive_allowed(self) -> None: + _validate_max_pages(1) + _validate_max_pages(1000) + + def test_zero_rejected(self) -> None: + with pytest.raises(ValueError, match=r"max_pages must be positive"): + _validate_max_pages(0) + + def test_negative_rejected(self) -> None: + with pytest.raises(ValueError, match=r"max_pages must be positive"): + _validate_max_pages(-3) diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 450dd8c..8077af3 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -1381,7 +1381,9 @@ def test_exclusion_map_is_current() -> None: # kwarg like 'milestone_type' for spec's 'type'), in which case the # kwarg legitimately exists in the signature. Distinguish by the # typed ``kind`` field (issue #51). - sig_mismatch_kinds = {"body_param", "wire_normalization", "kwarg_rename"} + sig_mismatch_kinds = { + "body_param", "wire_normalization", "kwarg_rename", "client_only", + } allowed_in_sig = excl.kind in sig_mismatch_kinds if name in sdk_params and not allowed_in_sig: stale.append( diff --git a/tests/test_markets.py b/tests/test_markets.py index 638d470..bbfdac3 100644 --- a/tests/test_markets.py +++ b/tests/test_markets.py @@ -202,6 +202,34 @@ def test_auto_paginates(self, markets: MarketsResource) -> None: assert tickers == ["A", "B", "C"] assert route.call_count == 2 + @respx.mock + def test_max_pages_caps_fetch(self, markets: MarketsResource) -> None: + """Public ``list_all`` forwards ``max_pages`` to ``_list_all`` (#98).""" + counter = {"n": 0} + + def _make_response(request: httpx.Request) -> httpx.Response: + counter["n"] += 1 + n = counter["n"] + return httpx.Response( + 200, + json={"markets": [{"ticker": f"M-{n}"}], "cursor": f"cur-{n}"}, + ) + + route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( + side_effect=_make_response + ) + tickers = [m.ticker for m in markets.list_all(max_pages=2)] + + assert route.call_count == 2 + assert tickers == ["M-1", "M-2"] + + def test_max_pages_zero_rejected(self, markets: MarketsResource) -> None: + """``max_pages=0`` raises ValueError at the public boundary (#98).""" + with pytest.raises(ValueError, match=r"max_pages must be positive"): + # The validator runs eagerly inside list_all, before the + # generator is consumed — no list() needed. + markets.list_all(max_pages=0) + @respx.mock def test_list_all_with_all_new_filters(self, markets: MarketsResource) -> None: """v0.7.0 ADDs on list_all match list (no cursor).""" From 0fb391c52b5bb0052f2f235321d90eade2cec43d Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sun, 17 May 2026 10:20:21 -0500 Subject: [PATCH 2/4] review(#135): unbounded by default + eager validation in 7 wrapper methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per bot review on PR #135: ### Behavior change: max_pages=None is now truly unbounded Dropped the silent 1000-page cap. None iterates until the server returns no cursor; the existing cursor-repeat guard remains the safety net against infinite loops (cap was never part of the public contract). Bot's recommended path: most users expect None = unbounded and a silent truncation post-release would be a harder break to fix. ### Fix: 7 wrapper methods had lazy validation Methods using `yield from self._list_all(...)` (5 sync) or `async for item in ... yield item` (2 async) were silently generators — their body, including _validate_max_pages, did not execute until the caller advanced the iterator. milestones.list_all(max_pages=0) returned a generator without raising; the ValueError only fired on the first next(). Fixed by converting to `return self._list_all(...)`: - communications.{Sync,Async}.list_all_rfqs - incentive_programs.list_all - milestones.list_all - structured_targets.list_all - subaccounts.{Sync,Async}.list_all_transfers Async ones additionally need `def` (not `async def`) so they're plain functions returning the AsyncIterator, not coroutines. ### Async drift FQNs Added all 19 async resource FQNs to _MAX_PAGES_FQNS in tests/_contract_support.py so the drift framework recognizes the kwarg if coverage is ever extended to async classes. ### Regression tests - TestMaxPagesEagerValidation × 4 (sync + async, both styles) — asserts ValueError fires at call time, not on first __next__. - TestMaxPagesNoneIsUnbounded × 1 — server returns 1100 pages, suite iterates all 1100 (proves the 1000 cap is gone). Verify: 1577 passed (+5). ruff + mypy --strict clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- kalshi/resources/_base.py | 26 ++++----- kalshi/resources/communications.py | 10 ++-- kalshi/resources/incentive_programs.py | 2 +- kalshi/resources/milestones.py | 2 +- kalshi/resources/structured_targets.py | 2 +- kalshi/resources/subaccounts.py | 11 ++-- tests/_contract_support.py | 20 +++++++ tests/test_base_helpers.py | 76 +++++++++++++++++++++++++- 8 files changed, 119 insertions(+), 30 deletions(-) diff --git a/kalshi/resources/_base.py b/kalshi/resources/_base.py index 4e6a398..97ab497 100644 --- a/kalshi/resources/_base.py +++ b/kalshi/resources/_base.py @@ -173,24 +173,24 @@ def _list_all( convention). ``cursor_key`` only affects how the response envelope is parsed. - ``max_pages`` caps the number of pages fetched as a safety net for - servers that never repeat a cursor but never return empty either. - ``None`` (default) means the built-in ceiling of 1000. Callers that - need a tighter cap (e.g. a quick preview) pass it explicitly. + ``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. """ - page_cap = 1000 if max_pages is None else max_pages current_params = dict(params) if params else {} seen_cursors: set[str] = set() - for _ in range(page_cap): + 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: @@ -286,20 +286,18 @@ async def _list_all( max_pages: int | None = None, cursor_key: str = "cursor", ) -> AsyncIterator[T]: - """Async counterpart of ``SyncResource._list_all``. ``max_pages`` - semantics mirror the sync version (``None`` -> 1000 default). - Raises ``KalshiError`` on repeated cursor; see sync docstring. - """ - page_cap = 1000 if max_pages is None else max_pages + """Async counterpart of :meth:`SyncResource._list_all`.""" current_params = dict(params) if params else {} seen_cursors: set[str] = set() - for _ in range(page_cap): + 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: diff --git a/kalshi/resources/communications.py b/kalshi/resources/communications.py index e9c9c59..bf5296f 100644 --- a/kalshi/resources/communications.py +++ b/kalshi/resources/communications.py @@ -222,7 +222,7 @@ def list_all_rfqs( market_ticker=market_ticker, subaccount=subaccount, status=status, creator_user_id=creator_user_id, ) - yield from self._list_all( + return self._list_all( "/communications/rfqs", RFQ, "rfqs", params=params, max_pages=max_pages, ) @@ -418,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, @@ -429,6 +429,7 @@ async def list_all_rfqs( 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( @@ -436,11 +437,10 @@ async def list_all_rfqs( market_ticker=market_ticker, subaccount=subaccount, status=status, creator_user_id=creator_user_id, ) - async for item in self._list_all( + return self._list_all( "/communications/rfqs", RFQ, "rfqs", params=params, max_pages=max_pages, - ): - yield item + ) async def get_rfq(self, rfq_id: str) -> GetRFQResponse: self._require_auth() diff --git a/kalshi/resources/incentive_programs.py b/kalshi/resources/incentive_programs.py index ecd5e0e..ae8280e 100644 --- a/kalshi/resources/incentive_programs.py +++ b/kalshi/resources/incentive_programs.py @@ -62,7 +62,7 @@ def list_all( ) -> Iterator[IncentiveProgram]: _validate_max_pages(max_pages) params = _params(status=status, type=incentive_type, limit=limit) - yield from self._list_all( + return self._list_all( "/incentive_programs", IncentiveProgram, "incentive_programs", diff --git a/kalshi/resources/milestones.py b/kalshi/resources/milestones.py index c250035..f63d010 100644 --- a/kalshi/resources/milestones.py +++ b/kalshi/resources/milestones.py @@ -119,7 +119,7 @@ def list_all( related_event_ticker=related_event_ticker, cursor=None, min_updated_ts=min_updated_ts, ) - yield from self._list_all( + return self._list_all( "/milestones", Milestone, "milestones", params=params, max_pages=max_pages, ) diff --git a/kalshi/resources/structured_targets.py b/kalshi/resources/structured_targets.py index ea00a77..a0ce2af 100644 --- a/kalshi/resources/structured_targets.py +++ b/kalshi/resources/structured_targets.py @@ -66,7 +66,7 @@ def list_all( competition=competition, page_size=page_size, ) - yield from self._list_all( + return self._list_all( "/structured_targets", StructuredTarget, "structured_targets", diff --git a/kalshi/resources/subaccounts.py b/kalshi/resources/subaccounts.py index 8bb642a..4080daa 100644 --- a/kalshi/resources/subaccounts.py +++ b/kalshi/resources/subaccounts.py @@ -160,7 +160,7 @@ def list_all_transfers( self._require_auth() _validate_max_pages(max_pages) params = _params(limit=limit) - yield from self._list_all( + return self._list_all( "/portfolio/subaccounts/transfers", SubaccountTransfer, "transfers", @@ -254,23 +254,24 @@ async def list_transfers( params=params, ) - async def list_all_transfers( + def list_all_transfers( self, *, limit: int | None = None, max_pages: int | None = None, ) -> AsyncIterator[SubaccountTransfer]: + # Plain `def` (not `async def`) so _require_auth and _validate_max_pages + # run at call time, not when the returned AsyncIterator is awaited. self._require_auth() _validate_max_pages(max_pages) params = _params(limit=limit) - async for item in self._list_all( + return self._list_all( "/portfolio/subaccounts/transfers", SubaccountTransfer, "transfers", params=params, max_pages=max_pages, - ): - yield item + ) @overload async def update_netting( diff --git a/tests/_contract_support.py b/tests/_contract_support.py index 8f31611..95bebee 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -1061,6 +1061,26 @@ class Exclusion: "kalshi.resources.incentive_programs.IncentiveProgramsResource.list_all", "kalshi.resources.structured_targets.StructuredTargetsResource.list_all", "kalshi.resources.multivariate.MultivariateCollectionsResource.list_all", + # Async counterparts — same kwarg, same client-only semantics. + "kalshi.resources.markets.AsyncMarketsResource.list_all", + "kalshi.resources.markets.AsyncMarketsResource.list_trades_all", + "kalshi.resources.milestones.AsyncMilestonesResource.list_all", + "kalshi.resources.events.AsyncEventsResource.list_all", + "kalshi.resources.events.AsyncEventsResource.list_all_multivariate", + "kalshi.resources.historical.AsyncHistoricalResource.markets_all", + "kalshi.resources.historical.AsyncHistoricalResource.fills_all", + "kalshi.resources.historical.AsyncHistoricalResource.orders_all", + "kalshi.resources.historical.AsyncHistoricalResource.trades_all", + "kalshi.resources.orders.AsyncOrdersResource.list_all", + "kalshi.resources.orders.AsyncOrdersResource.fills_all", + "kalshi.resources.communications.AsyncCommunicationsResource.list_all_rfqs", + "kalshi.resources.communications.AsyncCommunicationsResource.list_all_quotes", + "kalshi.resources.subaccounts.AsyncSubaccountsResource.list_all_transfers", + "kalshi.resources.portfolio.AsyncPortfolioResource.settlements_all", + "kalshi.resources.fcm.AsyncFcmResource.orders_all", + "kalshi.resources.incentive_programs.AsyncIncentiveProgramsResource.list_all", + "kalshi.resources.structured_targets.AsyncStructuredTargetsResource.list_all", + "kalshi.resources.multivariate.AsyncMultivariateCollectionsResource.list_all", ) for _fqn in _MAX_PAGES_FQNS: EXCLUSIONS[(_fqn, "max_pages")] = Exclusion( diff --git a/tests/test_base_helpers.py b/tests/test_base_helpers.py index 56bcf77..e4332fd 100644 --- a/tests/test_base_helpers.py +++ b/tests/test_base_helpers.py @@ -154,7 +154,7 @@ class TestSyncListAllCursorLoopDetection: def test_repeated_cursor_raises( self, test_auth: KalshiAuth, test_config: KalshiConfig ) -> None: - """Server that returns the same cursor twice must bail fast, not retry 1000x.""" + """Server that returns the same cursor twice must bail fast on the 2nd request.""" route = respx.get("https://test.kalshi.com/trade-api/v2/things").mock( return_value=httpx.Response( 200, json={"items": [{"id": "x"}], "cursor": "loop"} @@ -166,8 +166,7 @@ def test_repeated_cursor_raises( list(resource._list_all("/things", _Item, "items")) # First call (no cursor) fetches cursor="loop". Second call (cursor=loop) returns - # cursor="loop" again → loop detected before a third request. Total: 2 requests, - # not 1000. + # cursor="loop" again → loop detected before a third request. assert route.call_count == 2 @respx.mock @@ -376,3 +375,74 @@ def test_zero_rejected(self) -> None: def test_negative_rejected(self) -> None: with pytest.raises(ValueError, match=r"max_pages must be positive"): _validate_max_pages(-3) + + +class TestMaxPagesEagerValidation: + """Regression: ``_validate_max_pages`` must fire at call time, not on first iteration. + + Methods that were previously written with ``yield from``/``async for ... yield`` + silently became generators — their body didn't execute until the caller advanced + the iterator, so `max_pages=0` got deferred. All `*_all()` methods must now use + `return self._list_all(...)` so the validator runs eagerly. + """ + + def test_sync_milestones_list_all_validates_eagerly( + self, test_auth: KalshiAuth, test_config: KalshiConfig, + ) -> None: + from kalshi.resources.milestones import MilestonesResource + resource = MilestonesResource(SyncTransport(test_auth, test_config)) + with pytest.raises(ValueError, match=r"max_pages must be positive"): + resource.list_all(limit=10, max_pages=0) + + def test_sync_subaccounts_list_all_transfers_validates_eagerly( + self, test_auth: KalshiAuth, test_config: KalshiConfig, + ) -> None: + from kalshi.resources.subaccounts import SubaccountsResource + resource = SubaccountsResource(SyncTransport(test_auth, test_config)) + with pytest.raises(ValueError, match=r"max_pages must be positive"): + resource.list_all_transfers(max_pages=0) + + @pytest.mark.asyncio + async def test_async_subaccounts_list_all_transfers_validates_eagerly( + self, test_auth: KalshiAuth, test_config: KalshiConfig, + ) -> None: + from kalshi.resources.subaccounts import AsyncSubaccountsResource + resource = AsyncSubaccountsResource(AsyncTransport(test_auth, test_config)) + with pytest.raises(ValueError, match=r"max_pages must be positive"): + resource.list_all_transfers(max_pages=0) + + @pytest.mark.asyncio + async def test_async_communications_list_all_rfqs_validates_eagerly( + self, test_auth: KalshiAuth, test_config: KalshiConfig, + ) -> None: + from kalshi.resources.communications import AsyncCommunicationsResource + resource = AsyncCommunicationsResource(AsyncTransport(test_auth, test_config)) + with pytest.raises(ValueError, match=r"max_pages must be positive"): + resource.list_all_rfqs(max_pages=0) + + +class TestMaxPagesNoneIsUnbounded: + """``max_pages=None`` must iterate until the server returns no cursor. + + The 1000-page default was removed: cursor-repeat guard is the real safety net. + """ + + @respx.mock + def test_sync_iterates_past_1000_pages( + self, test_auth: KalshiAuth, test_config: KalshiConfig, + ) -> None: + # Page N returns cursor str(N+1) for the first 1100 pages, then empty. + call_counter = {"n": 0} + + def responder(request: httpx.Request) -> httpx.Response: + call_counter["n"] += 1 + n = call_counter["n"] + cursor = str(n) if n < 1100 else "" + return httpx.Response( + 200, json={"items": [{"id": f"i{n}"}], "cursor": cursor}, + ) + + respx.get("https://test.kalshi.com/trade-api/v2/things").mock(side_effect=responder) + resource = SyncResource(SyncTransport(test_auth, test_config)) + items = list(resource._list_all("/things", _Item, "items")) + assert len(items) == 1100, f"Expected 1100 items, got {len(items)} (cap leaked?)" From 879afdca9a496ff110b6e935aee2136096a4d2cd Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sun, 17 May 2026 10:25:54 -0500 Subject: [PATCH 3/4] review(#135 pass 2): CHANGELOG + async docstring + trim unbounded test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per second-pass bot review on PR #135: - CHANGELOG.md [Unreleased]: added Added entry for max_pages kwarg + RateLimit export, and Changed entry calling out that *_all() is now unbounded by default (was silently capped at 1000 pages). - _base.py async _list_all docstring: restored the "Raises KalshiError on repeated cursor; see sync docstring." note that got trimmed in the previous refactor. - TestMaxPagesNoneIsUnbounded: 1100 → 1010. Still exceeds the old 1000-cap by enough to prove unboundedness; 90% fewer mock requests. Skipped: - nonlocal-vs-dict counter style nit — bot called this take-or-leave; the existing pattern is consistent across the test file. uv run ruff check . clean. uv run pytest tests/test_base_helpers.py::TestMaxPagesNoneIsUnbounded passes. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 18 ++++++++++++++++++ kalshi/resources/_base.py | 5 ++++- tests/test_base_helpers.py | 8 +++++--- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d88a8f..30efe4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/kalshi/resources/_base.py b/kalshi/resources/_base.py index 97ab497..8cfea7b 100644 --- a/kalshi/resources/_base.py +++ b/kalshi/resources/_base.py @@ -286,7 +286,10 @@ async def _list_all( max_pages: int | None = None, cursor_key: str = "cursor", ) -> AsyncIterator[T]: - """Async counterpart of :meth:`SyncResource._list_all`.""" + """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() pages_fetched = 0 diff --git a/tests/test_base_helpers.py b/tests/test_base_helpers.py index e4332fd..f2baebf 100644 --- a/tests/test_base_helpers.py +++ b/tests/test_base_helpers.py @@ -431,13 +431,15 @@ class TestMaxPagesNoneIsUnbounded: def test_sync_iterates_past_1000_pages( self, test_auth: KalshiAuth, test_config: KalshiConfig, ) -> None: - # Page N returns cursor str(N+1) for the first 1100 pages, then empty. + # 1010 just exceeds the old 1000 cap — proves the cap is gone without + # making the test 100x slower than a normal pagination test. + total = 1010 call_counter = {"n": 0} def responder(request: httpx.Request) -> httpx.Response: call_counter["n"] += 1 n = call_counter["n"] - cursor = str(n) if n < 1100 else "" + cursor = str(n) if n < total else "" return httpx.Response( 200, json={"items": [{"id": f"i{n}"}], "cursor": cursor}, ) @@ -445,4 +447,4 @@ def responder(request: httpx.Request) -> httpx.Response: respx.get("https://test.kalshi.com/trade-api/v2/things").mock(side_effect=responder) resource = SyncResource(SyncTransport(test_auth, test_config)) items = list(resource._list_all("/things", _Item, "items")) - assert len(items) == 1100, f"Expected 1100 items, got {len(items)} (cap leaked?)" + assert len(items) == total, f"Expected {total} items, got {len(items)} (cap leaked?)" From dc55905489e4e3a9c5d3859105e19ed9f7fb1e93 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sun, 17 May 2026 10:44:18 -0500 Subject: [PATCH 4/4] review(#135 pass 3): fix stale docstring + add async unbounded test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per third-pass bot review on PR #135: - _validate_max_pages docstring: "None (use default cap)" was stale after dropping the 1000-page default. Now reads "None (unbounded — iterates until the server returns no cursor)". - TestMaxPagesNoneIsUnbounded: added test_async_iterates_past_1000_pages as the async counterpart to test_sync_iterates_past_1000_pages. Prevents the cap regression from silently re-appearing in only one of the two code paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- kalshi/resources/_base.py | 7 ++++--- tests/test_base_helpers.py | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/kalshi/resources/_base.py b/kalshi/resources/_base.py index 8cfea7b..8a780c4 100644 --- a/kalshi/resources/_base.py +++ b/kalshi/resources/_base.py @@ -47,9 +47,10 @@ 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`` (use default cap) and positive integers are valid. Zero or - negative would silently produce an empty iterator deep inside - ``_list_all``; surfacing the misuse here is friendlier. + ``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( diff --git a/tests/test_base_helpers.py b/tests/test_base_helpers.py index f2baebf..7dd46b4 100644 --- a/tests/test_base_helpers.py +++ b/tests/test_base_helpers.py @@ -448,3 +448,26 @@ def responder(request: httpx.Request) -> httpx.Response: resource = SyncResource(SyncTransport(test_auth, test_config)) items = list(resource._list_all("/things", _Item, "items")) assert len(items) == total, f"Expected {total} items, got {len(items)} (cap leaked?)" + + @respx.mock + @pytest.mark.asyncio + async def test_async_iterates_past_1000_pages( + self, test_auth: KalshiAuth, test_config: KalshiConfig, + ) -> None: + # Async counterpart — same 1010-page proof, prevents the regression + # from silently re-appearing in only one of the two code paths. + total = 1010 + call_counter = {"n": 0} + + def responder(request: httpx.Request) -> httpx.Response: + call_counter["n"] += 1 + n = call_counter["n"] + cursor = str(n) if n < total else "" + return httpx.Response( + 200, json={"items": [{"id": f"i{n}"}], "cursor": cursor}, + ) + + respx.get("https://test.kalshi.com/trade-api/v2/things").mock(side_effect=responder) + resource = AsyncResource(AsyncTransport(test_auth, test_config)) + items = [item async for item in resource._list_all("/things", _Item, "items")] + assert len(items) == total, f"Expected {total} items, got {len(items)} (cap leaked?)"