diff --git a/kalshi/resources/_base.py b/kalshi/resources/_base.py index b3b364ab..31ff8bb3 100644 --- a/kalshi/resources/_base.py +++ b/kalshi/resources/_base.py @@ -96,14 +96,23 @@ def _validate_max_pages(max_pages: int | None) -> None: 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). - - List/tuple elements must be non-empty and comma-free; pre-joined strings pass through. +def _join_tickers( + value: list[str] | tuple[str, ...] | str | None, + *, + max_items: int | None = None, +) -> str | None: + """Serialize a comma-joined ticker query param (spec: not explode:true). + + List/tuple elements must be non-empty and comma-free; pre-joined strings + pass through. ``max_items`` (when set) caps the list length per spec — + e.g. ``MultipleEventTickerQuery`` declares a max of 10. Pre-joined string + inputs skip the cap (caller already opted into wire-format ownership). """ if not value: return None if isinstance(value, (list, tuple)): + if max_items is not None and len(value) > max_items: + raise ValueError(f"too many tickers: {len(value)} > spec max {max_items}") for i, elem in enumerate(value): if not elem: raise ValueError( diff --git a/kalshi/resources/fcm.py b/kalshi/resources/fcm.py index ad113ce9..cff53f34 100644 --- a/kalshi/resources/fcm.py +++ b/kalshi/resources/fcm.py @@ -17,7 +17,7 @@ from kalshi.models.common import Page from kalshi.models.orders import Order, OrderStatusLiteral -from kalshi.models.portfolio import PositionsResponse, SettlementStatusLiteral +from kalshi.models.portfolio import MarketPosition, PositionsResponse, SettlementStatusLiteral from kalshi.resources._base import ( AsyncResource, SyncResource, @@ -165,6 +165,44 @@ def positions( data = self._get("/fcm/positions", params=params, extra_headers=extra_headers) return PositionsResponse.model_validate(data) + def positions_all( + self, + *, + subtrader_id: str, + ticker: str | None = None, + event_ticker: str | None = None, + count_filter: str | None = None, + settlement_status: SettlementStatusLiteral | None = None, + limit: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Iterator[MarketPosition]: + """Auto-paginate ``/fcm/positions``, yielding each ``MarketPosition``. + + Mirrors :meth:`PortfolioResource.positions_all`. ``event_positions`` + from the response envelope are intentionally not yielded; see that + docstring for the rationale. + """ + self._require_auth() + _validate_max_pages(max_pages) + params = _fcm_positions_params( + subtrader_id=subtrader_id, + ticker=ticker, + event_ticker=event_ticker, + count_filter=count_filter, + settlement_status=settlement_status, + limit=limit, + cursor=None, + ) + return self._list_all( + "/fcm/positions", + MarketPosition, + "market_positions", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) + class AsyncFcmResource(AsyncResource): """Async FCM API.""" @@ -256,3 +294,36 @@ async def positions( ) data = await self._get("/fcm/positions", params=params, extra_headers=extra_headers) return PositionsResponse.model_validate(data) + + def positions_all( + self, + *, + subtrader_id: str, + ticker: str | None = None, + event_ticker: str | None = None, + count_filter: str | None = None, + settlement_status: SettlementStatusLiteral | None = None, + limit: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> AsyncIterator[MarketPosition]: + """Async counterpart of :meth:`FcmResource.positions_all`. Use ``async for``.""" + self._require_auth() + _validate_max_pages(max_pages) + params = _fcm_positions_params( + subtrader_id=subtrader_id, + ticker=ticker, + event_ticker=event_ticker, + count_filter=count_filter, + settlement_status=settlement_status, + limit=limit, + cursor=None, + ) + return self._list_all( + "/fcm/positions", + MarketPosition, + "market_positions", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) diff --git a/kalshi/resources/multivariate.py b/kalshi/resources/multivariate.py index 55d5089a..384ecfde 100644 --- a/kalshi/resources/multivariate.py +++ b/kalshi/resources/multivariate.py @@ -6,6 +6,8 @@ from collections.abc import AsyncIterator, Iterator from typing import Any, overload +from typing_extensions import deprecated + from kalshi.models.common import Page from kalshi.models.multivariate import ( CreateMarketInMultivariateEventCollectionRequest, @@ -29,6 +31,28 @@ # Shared param + body builders (issue #46). +# Spec /multivariate_event_collections/{ticker}/lookup (GET): +# lookback_seconds enum is {10, 60, 300, 3600}. +_VALID_LOOKBACK_SECONDS: frozenset[int] = frozenset({10, 60, 300, 3600}) + +# Spec marks these endpoints as deprecated (`deprecated: true`) with the +# message reproduced below. Applied to both sync and async classes via +# `typing_extensions.deprecated`, which emits DeprecationWarning on call +# and is recognised by type checkers (PEP 702). +_DEPRECATION_MSG = "This endpoint predates RFQs and should not be used for new integrations." + + +def _validate_lookback_seconds(value: int) -> int: + """Reject ``lookback_seconds`` outside the spec enum {10, 60, 300, 3600}. + + Server returns 400 for anything else; failing client-side saves the + round trip and surfaces a more actionable error (mirrors the limit + bounds check added in #229). + """ + if value not in _VALID_LOOKBACK_SECONDS: + raise ValueError(f"lookback_seconds must be one of {{10, 60, 300, 3600}}, got {value}.") + return value + def _list_collections_params( *, @@ -178,6 +202,7 @@ def create_market( with_market_payload: bool | None = ..., extra_headers: dict[str, str] | None = None, ) -> CreateMarketResponse: ... + @deprecated(_DEPRECATION_MSG) def create_market( self, collection_ticker: str, @@ -216,6 +241,7 @@ def lookup_tickers( selected_markets: builtins.list[TickerPair], extra_headers: dict[str, str] | None = None, ) -> LookupTickersResponse: ... + @deprecated(_DEPRECATION_MSG) def lookup_tickers( self, collection_ticker: str, @@ -236,6 +262,7 @@ def lookup_tickers( ) return _parse_lookup_tickers_response(data) + @deprecated(_DEPRECATION_MSG) def lookup_history( self, collection_ticker: str, @@ -243,7 +270,7 @@ def lookup_history( lookback_seconds: int, extra_headers: dict[str, str] | None = None, ) -> builtins.list[LookupPoint]: - params = _params(lookback_seconds=lookback_seconds) + params = _params(lookback_seconds=_validate_lookback_seconds(lookback_seconds)) data = self._get( f"/multivariate_event_collections/{_seg(collection_ticker, name='collection_ticker')}/lookup", # noqa: E501 params=params, @@ -334,6 +361,7 @@ async def create_market( with_market_payload: bool | None = ..., extra_headers: dict[str, str] | None = None, ) -> CreateMarketResponse: ... + @deprecated(_DEPRECATION_MSG) async def create_market( self, collection_ticker: str, @@ -372,6 +400,7 @@ async def lookup_tickers( selected_markets: builtins.list[TickerPair], extra_headers: dict[str, str] | None = None, ) -> LookupTickersResponse: ... + @deprecated(_DEPRECATION_MSG) async def lookup_tickers( self, collection_ticker: str, @@ -392,6 +421,7 @@ async def lookup_tickers( ) return _parse_lookup_tickers_response(data) + @deprecated(_DEPRECATION_MSG) async def lookup_history( self, collection_ticker: str, @@ -399,7 +429,7 @@ async def lookup_history( lookback_seconds: int, extra_headers: dict[str, str] | None = None, ) -> builtins.list[LookupPoint]: - params = _params(lookback_seconds=lookback_seconds) + params = _params(lookback_seconds=_validate_lookback_seconds(lookback_seconds)) data = await self._get( f"/multivariate_event_collections/{_seg(collection_ticker, name='collection_ticker')}/lookup", # noqa: E501 params=params, diff --git a/kalshi/resources/orders.py b/kalshi/resources/orders.py index 7b2277a0..5615af4d 100644 --- a/kalshi/resources/orders.py +++ b/kalshi/resources/orders.py @@ -251,7 +251,7 @@ def _build_decrease_body( def _list_orders_params( *, ticker: str | None, - event_ticker: str | None, + event_ticker: builtins.list[str] | str | None, status: OrderStatusLiteral | None, min_ts: int | None, max_ts: int | None, @@ -262,7 +262,8 @@ def _list_orders_params( limit = _validate_limit(limit, hi=1000) return _params( ticker=ticker, - event_ticker=event_ticker, + # Spec MultipleEventTickerQuery: comma-joined, max 10. + event_ticker=_join_tickers(event_ticker, max_items=10), status=status, min_ts=min_ts, max_ts=max_ts, @@ -447,7 +448,7 @@ def list( self, *, ticker: str | None = None, - event_ticker: str | None = None, + event_ticker: builtins.list[str] | str | None = None, status: OrderStatusLiteral | None = None, min_ts: int | None = None, max_ts: int | None = None, @@ -475,7 +476,7 @@ def list_all( self, *, ticker: str | None = None, - event_ticker: str | None = None, + event_ticker: builtins.list[str] | str | None = None, status: OrderStatusLiteral | None = None, min_ts: int | None = None, max_ts: int | None = None, @@ -1013,7 +1014,7 @@ async def list( self, *, ticker: str | None = None, - event_ticker: str | None = None, + event_ticker: builtins.list[str] | str | None = None, status: OrderStatusLiteral | None = None, min_ts: int | None = None, max_ts: int | None = None, @@ -1041,7 +1042,7 @@ def list_all( self, *, ticker: str | None = None, - event_ticker: str | None = None, + event_ticker: builtins.list[str] | str | None = None, status: OrderStatusLiteral | None = None, min_ts: int | None = None, max_ts: int | None = None, diff --git a/kalshi/resources/portfolio.py b/kalshi/resources/portfolio.py index d99c37a4..574fbb66 100644 --- a/kalshi/resources/portfolio.py +++ b/kalshi/resources/portfolio.py @@ -9,6 +9,7 @@ from kalshi.models.portfolio import ( Balance, Deposit, + MarketPosition, PositionsResponse, Settlement, TotalRestingOrderValue, @@ -101,6 +102,45 @@ def positions( data = self._get("/portfolio/positions", params=params, extra_headers=extra_headers) return PositionsResponse.model_validate(data) + def positions_all( + self, + *, + limit: int | None = None, + count_filter: str | None = None, + ticker: str | None = None, + event_ticker: str | None = None, + subaccount: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Iterator[MarketPosition]: + """Auto-paginate ``/portfolio/positions``, yielding each ``MarketPosition``. + + Mirrors :meth:`settlements_all`. The endpoint response also carries + ``event_positions`` (aggregate roll-ups over the same underlying + markets); those are *not* surfaced here because page boundaries cut + the aggregate arbitrarily and concatenating across pages would not + recompute a meaningful event-level total. Callers that need the + event view should iterate :meth:`positions` page-by-page. + """ + self._require_auth() + _validate_max_pages(max_pages) + params = _positions_params( + limit=limit, + cursor=None, + count_filter=count_filter, + ticker=ticker, + event_ticker=event_ticker, + subaccount=subaccount, + ) + return self._list_all( + "/portfolio/positions", + MarketPosition, + "market_positions", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) + def settlements( self, *, @@ -284,6 +324,37 @@ async def positions( data = await self._get("/portfolio/positions", params=params, extra_headers=extra_headers) return PositionsResponse.model_validate(data) + def positions_all( + self, + *, + limit: int | None = None, + count_filter: str | None = None, + ticker: str | None = None, + event_ticker: str | None = None, + subaccount: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> AsyncIterator[MarketPosition]: + """Async counterpart of :meth:`PortfolioResource.positions_all`. Use ``async for``.""" + self._require_auth() + _validate_max_pages(max_pages) + params = _positions_params( + limit=limit, + cursor=None, + count_filter=count_filter, + ticker=ticker, + event_ticker=event_ticker, + subaccount=subaccount, + ) + return self._list_all( + "/portfolio/positions", + MarketPosition, + "market_positions", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) + async def settlements( self, *, diff --git a/pyproject.toml b/pyproject.toml index 785fdfa2..013fa069 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "pydantic>=2.0,<3", "cryptography>=43,<49", "websockets>=14,<17", + "typing-extensions>=4.5", ] [project.optional-dependencies] @@ -74,6 +75,13 @@ markers = [ "integration: tests that hit the real Kalshi demo API (require credentials)", "integration_real_api_only: endpoints the demo server cannot service (auth-gated roles, demo-broken). Skipped under the default integration run; enable explicitly with -m integration_real_api_only against a prod-like account.", ] +filterwarnings = [ + # `kalshi.resources.multivariate.{lookup_tickers,lookup_history,create_market}` + # are flagged `@deprecated` per spec (the endpoints predate RFQs). Tests + # that exercise them on purpose would drown CI in noise; user callers + # still see the warning in their own code. + "ignore:This endpoint predates RFQs.*:DeprecationWarning", +] [dependency-groups] dev = [ diff --git a/tests/_contract_support.py b/tests/_contract_support.py index 5cd94591..5e57e67c 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -586,6 +586,11 @@ class Exclusion: http_method="GET", path_template="/portfolio/positions", ), + MethodEndpointEntry( + sdk_method="kalshi.resources.portfolio.PortfolioResource.positions_all", + http_method="GET", + path_template="/portfolio/positions", + ), MethodEndpointEntry( sdk_method="kalshi.resources.portfolio.PortfolioResource.settlements", http_method="GET", @@ -637,21 +642,15 @@ class Exclusion: http_method="GET", path_template="/series/fee_changes", ), - # NOTE: SDK currently sends {event_ticker} but spec uses {ticker}. - # Map stores the SPEC path (source of truth); SDK is RENAME target in AUDIT. MethodEndpointEntry( sdk_method="kalshi.resources.series.SeriesResource.event_candlesticks", http_method="GET", path_template="/series/{series_ticker}/events/{ticker}/candlesticks", ), MethodEndpointEntry( - sdk_method=( - "kalshi.resources.series.SeriesResource.forecast_percentile_history" - ), + sdk_method=("kalshi.resources.series.SeriesResource.forecast_percentile_history"), http_method="GET", - path_template=( - "/series/{series_ticker}/events/{ticker}/forecast_percentile_history" - ), + path_template=("/series/{series_ticker}/events/{ticker}/forecast_percentile_history"), ), # ── fcm ───────────────────────────────────────────────────────────────── MethodEndpointEntry( @@ -669,6 +668,11 @@ class Exclusion: http_method="GET", path_template="/fcm/positions", ), + MethodEndpointEntry( + sdk_method="kalshi.resources.fcm.FcmResource.positions_all", + http_method="GET", + path_template="/fcm/positions", + ), # ── incentive programs ────────────────────────────────────────────────── MethodEndpointEntry( sdk_method="kalshi.resources.incentive_programs.IncentiveProgramsResource.list", @@ -709,49 +713,34 @@ class Exclusion: ), # ── multivariate ──────────────────────────────────────────────────────── MethodEndpointEntry( - sdk_method=( - "kalshi.resources.multivariate.MultivariateCollectionsResource.list" - ), + sdk_method=("kalshi.resources.multivariate.MultivariateCollectionsResource.list"), http_method="GET", path_template="/multivariate_event_collections", ), MethodEndpointEntry( - sdk_method=( - "kalshi.resources.multivariate.MultivariateCollectionsResource.list_all" - ), + sdk_method=("kalshi.resources.multivariate.MultivariateCollectionsResource.list_all"), http_method="GET", path_template="/multivariate_event_collections", ), MethodEndpointEntry( - sdk_method=( - "kalshi.resources.multivariate.MultivariateCollectionsResource.get" - ), + sdk_method=("kalshi.resources.multivariate.MultivariateCollectionsResource.get"), http_method="GET", path_template="/multivariate_event_collections/{collection_ticker}", ), MethodEndpointEntry( - sdk_method=( - "kalshi.resources.multivariate.MultivariateCollectionsResource." - "create_market" - ), + sdk_method=("kalshi.resources.multivariate.MultivariateCollectionsResource.create_market"), http_method="POST", path_template="/multivariate_event_collections/{collection_ticker}", request_body_schema="#/components/schemas/CreateMarketInMultivariateEventCollectionRequest", ), MethodEndpointEntry( - sdk_method=( - "kalshi.resources.multivariate.MultivariateCollectionsResource." - "lookup_tickers" - ), + sdk_method=("kalshi.resources.multivariate.MultivariateCollectionsResource.lookup_tickers"), http_method="PUT", path_template="/multivariate_event_collections/{collection_ticker}/lookup", request_body_schema="#/components/schemas/LookupTickersForMarketInMultivariateEventCollectionRequest", ), MethodEndpointEntry( - sdk_method=( - "kalshi.resources.multivariate.MultivariateCollectionsResource." - "lookup_history" - ), + sdk_method=("kalshi.resources.multivariate.MultivariateCollectionsResource.lookup_history"), http_method="GET", path_template="/multivariate_event_collections/{collection_ticker}/lookup", ), @@ -789,8 +778,7 @@ class Exclusion: ), ("kalshi.models.orders.CreateOrderRequest", "no_price"): Exclusion( reason=( - "cent form redundant with no_price_dollars; caller passes dollars, " - "wire carries dollars" + "cent form redundant with no_price_dollars; caller passes dollars, wire carries dollars" ), kind="wire_normalization", ), @@ -847,6 +835,14 @@ class Exclusion: reason="paginator-handled; not a caller-facing kwarg on list_all", kind="paginator_handled", ), + ("kalshi.resources.portfolio.PortfolioResource.positions_all", "cursor"): Exclusion( + reason="paginator-handled; not a caller-facing kwarg on list_all", + kind="paginator_handled", + ), + ("kalshi.resources.fcm.FcmResource.positions_all", "cursor"): Exclusion( + reason="paginator-handled; not a caller-facing kwarg on list_all", + kind="paginator_handled", + ), ("kalshi.resources.portfolio.PortfolioResource.withdrawals_all", "cursor"): Exclusion( reason="paginator-handled; not a caller-facing kwarg on list_all", kind="paginator_handled", @@ -885,8 +881,7 @@ class Exclusion: ), ("kalshi.models.orders.AmendOrderRequest", "no_price"): Exclusion( reason=( - "cent form redundant with no_price_dollars; caller passes dollars, " - "wire carries dollars" + "cent form redundant with no_price_dollars; caller passes dollars, wire carries dollars" ), kind="wire_normalization", ), @@ -1199,7 +1194,9 @@ class Exclusion: "kalshi.resources.portfolio.PortfolioResource.settlements_all", "kalshi.resources.portfolio.PortfolioResource.deposits_all", "kalshi.resources.portfolio.PortfolioResource.withdrawals_all", + "kalshi.resources.portfolio.PortfolioResource.positions_all", "kalshi.resources.fcm.FcmResource.orders_all", + "kalshi.resources.fcm.FcmResource.positions_all", "kalshi.resources.incentive_programs.IncentiveProgramsResource.list_all", "kalshi.resources.structured_targets.StructuredTargetsResource.list_all", "kalshi.resources.multivariate.MultivariateCollectionsResource.list_all", @@ -1221,7 +1218,9 @@ class Exclusion: "kalshi.resources.portfolio.AsyncPortfolioResource.settlements_all", "kalshi.resources.portfolio.AsyncPortfolioResource.deposits_all", "kalshi.resources.portfolio.AsyncPortfolioResource.withdrawals_all", + "kalshi.resources.portfolio.AsyncPortfolioResource.positions_all", "kalshi.resources.fcm.AsyncFcmResource.orders_all", + "kalshi.resources.fcm.AsyncFcmResource.positions_all", "kalshi.resources.incentive_programs.AsyncIncentiveProgramsResource.list_all", "kalshi.resources.structured_targets.AsyncStructuredTargetsResource.list_all", "kalshi.resources.multivariate.AsyncMultivariateCollectionsResource.list_all", @@ -1253,9 +1252,7 @@ def _resolve_ref( "check spec for circular $ref" ) if not ref.startswith("#/"): - raise ValueError( - f"_resolve_ref only supports local refs starting with '#/', got {ref!r}" - ) + raise ValueError(f"_resolve_ref only supports local refs starting with '#/', got {ref!r}") # JSON Pointer escape decoding: ~1 → /, ~0 → ~ (RFC 6901 §4). # Order matters: decode ~1 first, then ~0. # Use [2:] not lstrip("#/"): lstrip treats arg as a char set, so "#///foo" @@ -1355,9 +1352,7 @@ def _resolve_request_body_schema( op_key = http_method.lower() path_entry = paths[path_template] if op_key not in path_entry: - raise KeyError( - f"operation {http_method!r} not defined on path {path_template!r}" - ) + raise KeyError(f"operation {http_method!r} not defined on path {path_template!r}") op_entry = path_entry[op_key] request_body = op_entry.get("requestBody") diff --git a/tests/test_base_helpers.py b/tests/test_base_helpers.py index 32c7707d..c3b4df50 100644 --- a/tests/test_base_helpers.py +++ b/tests/test_base_helpers.py @@ -65,6 +65,23 @@ def test_non_string_int_element_raises_unhelpful_type_error(self) -> None: with pytest.raises(TypeError, match=r"argument of type 'int' is not iterable"): _join_tickers((1, "A")) # type: ignore[arg-type] + def test_max_items_rejects_oversize_list(self) -> None: + with pytest.raises(ValueError, match=r"too many tickers: 11 > spec max 10"): + _join_tickers([f"T{i}" for i in range(11)], max_items=10) + + def test_max_items_allows_exact_boundary(self) -> None: + joined = _join_tickers([f"T{i}" for i in range(10)], max_items=10) + assert joined is not None and joined.count(",") == 9 + + def test_max_items_skipped_for_prejoined_string(self) -> None: + # Pre-joined strings are caller-owned wire format — cap does not apply. + joined = _join_tickers(",".join(f"T{i}" for i in range(20)), max_items=10) + assert joined is not None and joined.count(",") == 19 + + def test_max_items_default_unbounded(self) -> None: + joined = _join_tickers([f"T{i}" for i in range(50)]) + assert joined is not None and joined.count(",") == 49 + class TestSyncListNullItemsCoercion: @respx.mock @@ -151,14 +168,10 @@ async def test_missing_items_key_still_returns_empty( class TestSyncListAllCursorLoopDetection: @respx.mock - def test_repeated_cursor_raises( - self, test_auth: KalshiAuth, test_config: KalshiConfig - ) -> None: + def test_repeated_cursor_raises(self, test_auth: KalshiAuth, test_config: KalshiConfig) -> None: """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"} - ) + return_value=httpx.Response(200, json={"items": [{"id": "x"}], "cursor": "loop"}) ) resource = SyncResource(SyncTransport(test_auth, test_config)) @@ -186,9 +199,7 @@ def test_list_all_cursor_loop_detection_uses_last_cursor_only_o1( httpx.Response(200, json={"items": [{"id": "3"}], "cursor": "A"}), httpx.Response(200, json={"items": [{"id": "4"}], "cursor": ""}), ] - respx.get("https://test.kalshi.com/trade-api/v2/things").mock( - side_effect=responses - ) + respx.get("https://test.kalshi.com/trade-api/v2/things").mock(side_effect=responses) resource = SyncResource(SyncTransport(test_auth, test_config)) collected = list(resource._list_all("/things", _Item, "items")) @@ -205,9 +216,7 @@ def test_list_all_cursor_loop_detection_catches_adjacent_replay( httpx.Response(200, json={"items": [{"id": "1"}], "cursor": "A"}), httpx.Response(200, json={"items": [{"id": "2"}], "cursor": "A"}), ] - respx.get("https://test.kalshi.com/trade-api/v2/things").mock( - side_effect=responses - ) + respx.get("https://test.kalshi.com/trade-api/v2/things").mock(side_effect=responses) resource = SyncResource(SyncTransport(test_auth, test_config)) with pytest.raises(KalshiError, match=r"[Cc]ursor loop.*'A'"): @@ -222,9 +231,7 @@ def test_normal_pagination_does_not_trip( httpx.Response(200, json={"items": [{"id": "1"}], "cursor": "A"}), httpx.Response(200, json={"items": [{"id": "2"}], "cursor": ""}), ] - respx.get("https://test.kalshi.com/trade-api/v2/things").mock( - side_effect=responses - ) + respx.get("https://test.kalshi.com/trade-api/v2/things").mock(side_effect=responses) resource = SyncResource(SyncTransport(test_auth, test_config)) collected = list(resource._list_all("/things", _Item, "items")) @@ -238,9 +245,7 @@ async def test_repeated_cursor_raises( self, test_auth: KalshiAuth, test_config: KalshiConfig ) -> None: route = respx.get("https://test.kalshi.com/trade-api/v2/things").mock( - return_value=httpx.Response( - 200, json={"items": [{"id": "x"}], "cursor": "loop"} - ) + return_value=httpx.Response(200, json={"items": [{"id": "x"}], "cursor": "loop"}) ) resource = AsyncResource(AsyncTransport(test_auth, test_config)) @@ -263,7 +268,8 @@ 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}"}, + 200, + json={"items": [{"id": f"item-{n}"}], "cursor": f"cur-{n}"}, ) return _make_response @@ -281,9 +287,7 @@ def test_cap_of_one_fetches_single_page( ) resource = SyncResource(SyncTransport(test_auth, test_config)) - collected = list( - resource._list_all("/things", _Item, "items", max_pages=1) - ) + 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"] @@ -297,9 +301,7 @@ def test_cap_of_three_stops_at_three( ) resource = SyncResource(SyncTransport(test_auth, test_config)) - collected = list( - resource._list_all("/things", _Item, "items", max_pages=3) - ) + 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"] @@ -310,9 +312,7 @@ def test_empty_cursor_stops_after_one_request( ) -> 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": ""} - ) + return_value=httpx.Response(200, json={"items": [{"id": "a"}], "cursor": ""}) ) resource = SyncResource(SyncTransport(test_auth, test_config)) @@ -336,9 +336,7 @@ async def test_cap_of_one_fetches_single_page( resource = AsyncResource(AsyncTransport(test_auth, test_config)) collected: list[_Item] = [] - async for item in resource._list_all( - "/things", _Item, "items", max_pages=1 - ): + async for item in resource._list_all("/things", _Item, "items", max_pages=1): collected.append(item) assert route.call_count == 1 @@ -355,9 +353,7 @@ async def test_cap_of_three_stops_at_three( resource = AsyncResource(AsyncTransport(test_auth, test_config)) collected: list[_Item] = [] - async for item in resource._list_all( - "/things", _Item, "items", max_pages=3 - ): + async for item in resource._list_all("/things", _Item, "items", max_pages=3): collected.append(item) assert route.call_count == 3 @@ -370,9 +366,7 @@ async def test_empty_cursor_stops_after_one_request( ) -> 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": ""} - ) + return_value=httpx.Response(200, json={"items": [{"id": "a"}], "cursor": ""}) ) resource = AsyncResource(AsyncTransport(test_auth, test_config)) @@ -413,35 +407,47 @@ class TestMaxPagesEagerValidation: """ def test_sync_milestones_list_all_validates_eagerly( - self, test_auth: KalshiAuth, test_config: KalshiConfig, + 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, + 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, + 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, + 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) @@ -455,7 +461,9 @@ class TestMaxPagesNoneIsUnbounded: @respx.mock def test_sync_iterates_past_1000_pages( - self, test_auth: KalshiAuth, test_config: KalshiConfig, + self, + test_auth: KalshiAuth, + test_config: KalshiConfig, ) -> None: # 1010 just exceeds the old 1000 cap — proves the cap is gone without # making the test 100x slower than a normal pagination test. @@ -467,7 +475,8 @@ def responder(request: httpx.Request) -> httpx.Response: n = call_counter["n"] cursor = str(n) if n < total else "" return httpx.Response( - 200, json={"items": [{"id": f"i{n}"}], "cursor": cursor}, + 200, + json={"items": [{"id": f"i{n}"}], "cursor": cursor}, ) respx.get("https://test.kalshi.com/trade-api/v2/things").mock(side_effect=responder) @@ -478,7 +487,9 @@ def responder(request: httpx.Request) -> httpx.Response: @respx.mock @pytest.mark.asyncio async def test_async_iterates_past_1000_pages( - self, test_auth: KalshiAuth, test_config: KalshiConfig, + 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. @@ -490,7 +501,8 @@ def responder(request: httpx.Request) -> httpx.Response: n = call_counter["n"] cursor = str(n) if n < total else "" return httpx.Response( - 200, json={"items": [{"id": f"i{n}"}], "cursor": cursor}, + 200, + json={"items": [{"id": f"i{n}"}], "cursor": cursor}, ) respx.get("https://test.kalshi.com/trade-api/v2/things").mock(side_effect=responder) diff --git a/tests/test_fcm.py b/tests/test_fcm.py index 49d5fe2c..6ce1fd2b 100644 --- a/tests/test_fcm.py +++ b/tests/test_fcm.py @@ -11,7 +11,7 @@ from kalshi.config import KalshiConfig from kalshi.errors import AuthRequiredError, KalshiAuthError from kalshi.resources.fcm import AsyncFcmResource, FcmResource -from tests._model_fixtures import order_dict +from tests._model_fixtures import market_position_dict, order_dict @pytest.fixture @@ -151,6 +151,64 @@ def test_requires_auth(self, unauth_fcm: FcmResource) -> None: unauth_fcm.positions(subtrader_id="sub-1") +class TestPositionsAll: + @respx.mock + def test_positions_all_paginates(self, fcm: FcmResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/fcm/positions").mock( + side_effect=[ + httpx.Response( + 200, + json={ + "market_positions": [ + market_position_dict(ticker="A"), + market_position_dict(ticker="B"), + ], + "event_positions": [], + "cursor": "page2", + }, + ), + httpx.Response( + 200, + json={ + "market_positions": [market_position_dict(ticker="C")], + "event_positions": [], + "cursor": "", + }, + ), + ] + ) + tickers = [p.ticker for p in fcm.positions_all(subtrader_id="sub-1")] + assert tickers == ["A", "B", "C"] + + @respx.mock + def test_positions_all_forwards_filters(self, fcm: FcmResource) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/fcm/positions").mock( + return_value=httpx.Response( + 200, json={"market_positions": [], "event_positions": [], "cursor": ""} + ) + ) + list( + fcm.positions_all( + subtrader_id="sub-1", + ticker="MKT-A", + event_ticker="EVT-X", + count_filter="position", + settlement_status="unsettled", + limit=50, + ) + ) + url = route.calls.last.request.url + assert url.params["subtrader_id"] == "sub-1" + assert url.params["ticker"] == "MKT-A" + assert url.params["event_ticker"] == "EVT-X" + assert url.params["settlement_status"] == "unsettled" + assert "cursor" not in url.params + + def test_positions_all_requires_auth(self, unauth_fcm: FcmResource) -> None: + with pytest.raises(AuthRequiredError): + list(unauth_fcm.positions_all(subtrader_id="sub-1")) + + class TestAsyncFcm: @respx.mock @pytest.mark.asyncio @@ -172,3 +230,31 @@ async def test_positions(self, async_fcm: AsyncFcmResource) -> None: ) result = await async_fcm.positions(subtrader_id="sub-1") assert result.market_positions == [] + + +class TestAsyncFcmPositionsAll: + @pytest.mark.asyncio + @respx.mock + async def test_positions_all_paginates(self, async_fcm: AsyncFcmResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/fcm/positions").mock( + side_effect=[ + httpx.Response( + 200, + json={ + "market_positions": [market_position_dict(ticker="A")], + "event_positions": [], + "cursor": "next", + }, + ), + httpx.Response( + 200, + json={ + "market_positions": [market_position_dict(ticker="B")], + "event_positions": [], + "cursor": "", + }, + ), + ] + ) + tickers = [p.ticker async for p in async_fcm.positions_all(subtrader_id="sub-1")] + assert tickers == ["A", "B"] diff --git a/tests/test_multivariate.py b/tests/test_multivariate.py index 0b1fdcc9..da2bf8fc 100644 --- a/tests/test_multivariate.py +++ b/tests/test_multivariate.py @@ -196,6 +196,48 @@ def test_lookup_history(self, mv: MultivariateCollectionsResource) -> None: assert len(result) == 1 assert result[0].event_ticker == "EVT-1" + @pytest.mark.parametrize("bad", [0, 1, 30, 600, 3601, -10]) + def test_lookup_history_rejects_off_enum_lookback( + self, mv: MultivariateCollectionsResource, bad: int + ) -> None: + with pytest.raises(ValueError, match=r"lookback_seconds must be one of"): + mv.lookup_history("MVC-1", lookback_seconds=bad) + + def test_lookup_history_emits_deprecation_warning( + self, mv: MultivariateCollectionsResource + ) -> None: + with respx.mock(base_url=BASE) as router: + router.get("/multivariate_event_collections/MVC-1/lookup").mock( + return_value=httpx.Response(200, json={"lookup_points": []}) + ) + with pytest.warns(DeprecationWarning, match=r"predates RFQs"): + mv.lookup_history("MVC-1", lookback_seconds=10) + + def test_lookup_tickers_emits_deprecation_warning( + self, mv: MultivariateCollectionsResource + ) -> None: + with respx.mock(base_url=BASE) as router: + router.put("/multivariate_event_collections/MVC-1/lookup").mock( + return_value=httpx.Response( + 200, json={"event_ticker": "EVT-1", "market_ticker": "MKT-1"} + ) + ) + with pytest.warns(DeprecationWarning, match=r"predates RFQs"): + mv.lookup_tickers("MVC-1", selected_markets=[]) + + def test_create_market_emits_deprecation_warning( + self, mv: MultivariateCollectionsResource + ) -> None: + with respx.mock(base_url=BASE) as router: + router.post("/multivariate_event_collections/MVC-1").mock( + return_value=httpx.Response( + 200, + json={"event_ticker": "EVT-1", "market_ticker": "MKT-1"}, + ) + ) + with pytest.warns(DeprecationWarning, match=r"predates RFQs"): + mv.create_market("MVC-1", selected_markets=[]) + class TestAsyncMultivariateCollectionsResource: @pytest.fixture diff --git a/tests/test_orders.py b/tests/test_orders.py index a8f624e9..a9b7d724 100644 --- a/tests/test_orders.py +++ b/tests/test_orders.py @@ -218,9 +218,9 @@ def test_missing_count_and_action_raises_before_http( ``TypeError`` BEFORE any HTTP request is dispatched. Pre-#242 the SDK silently defaulted to count=1, action="buy" — converting a missing-arg bug into a real 1-contract BUY fill (money risk).""" - route = respx_mock.post( - "https://demo-api.kalshi.co/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER})) + route = respx_mock.post("https://demo-api.kalshi.co/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER}) + ) with pytest.raises(TypeError, match=r"count.*action"): client.orders.create(ticker="X", side="yes") @@ -238,9 +238,9 @@ def test_explicit_count_action_kwargs_still_work( ) -> None: """#242: regression — explicit ``count`` + ``action`` kwargs still build and dispatch normally.""" - route = respx_mock.post( - "https://demo-api.kalshi.co/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER})) + route = respx_mock.post("https://demo-api.kalshi.co/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER}) + ) client.orders.create(ticker="X", side="yes", count=10, action="buy", yes_price="0.5") @@ -258,9 +258,9 @@ def test_request_overload_unaffected_by_242( """#242: the ``request=CreateOrderRequest(...)`` path is unaffected by the kwarg-overload guard — the model itself now declares count/action required, so a fully-populated request still dispatches.""" - route = respx_mock.post( - "https://demo-api.kalshi.co/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER})) + route = respx_mock.post("https://demo-api.kalshi.co/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER}) + ) client.orders.create( request=CreateOrderRequest( @@ -435,6 +435,26 @@ def test_list_with_all_new_filters(self, orders: OrdersResource) -> None: assert params["cursor"] == "abc" assert params["subaccount"] == "7" + @respx.mock + def test_list_accepts_event_ticker_list(self, orders: OrdersResource) -> None: + """Spec MultipleEventTickerQuery: comma-joined, server caps at 10.""" + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json={"orders": []}) + ) + orders.list(event_ticker=["EVT-A", "EVT-B", "EVT-C"]) + params = dict(route.calls[0].request.url.params) + # Asserts wire is ?event_ticker=A,B,C (NOT explode:true). + assert params["event_ticker"] == "EVT-A,EVT-B,EVT-C" + + def test_list_rejects_event_ticker_list_over_spec_max(self, orders: OrdersResource) -> None: + with pytest.raises(ValueError, match=r"too many tickers: 11 > spec max 10"): + orders.list(event_ticker=[f"E-{i}" for i in range(11)]) + + def test_list_all_rejects_event_ticker_list_over_spec_max(self, orders: OrdersResource) -> None: + # Eager validation — must fire at call time, not on iteration. + with pytest.raises(ValueError, match=r"too many tickers"): + orders.list_all(event_ticker=[f"E-{i}" for i in range(11)]) + @respx.mock def test_empty_string_ticker_passes_through(self, orders: OrdersResource) -> None: """Regression: pre-v0.7.0 the `if ticker:` truthiness check silently dropped @@ -524,9 +544,7 @@ def test_batch_create(self, orders: OrdersResource) -> None: assert result.orders[0].client_order_id == "c1" @respx.mock - def test_batch_create_partial_failure_does_not_crash( - self, orders: OrdersResource - ) -> None: + def test_batch_create_partial_failure_does_not_crash(self, orders: OrdersResource) -> None: """#194: a failed leg returns ``{"order": null, "error": {...}}``. The old SDK called ``Order.model_validate(None)`` and tore down the @@ -976,7 +994,8 @@ class TestBatchCancelRoutesThroughDeleteWithBodyJson: @respx.mock def test_batch_cancel_uses_delete_with_body_json_helper( - self, orders: OrdersResource, + self, + orders: OrdersResource, ) -> None: respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( return_value=httpx.Response(200, json={"orders": []}) @@ -1033,9 +1052,7 @@ def test_batch_cancel_returns_typed_response_with_reduced_by_fp( assert result.orders[0].error is None @respx.mock - def test_batch_cancel_per_entry_error_surfaced( - self, orders: OrdersResource - ) -> None: + def test_batch_cancel_per_entry_error_surfaced(self, orders: OrdersResource) -> None: """#194: per-leg ``error`` is preserved; ``reduced_by_fp`` is ``0`` when the leg failed. """ @@ -1294,7 +1311,6 @@ def test_wraps_orders_key(self, orders: OrdersResource) -> None: assert set(body.keys()) == {"orders"} - class TestBatchCreateUsesBytesPath: """P4.2: batch_create / batch_cancel serialize via model_dump_json directly to bytes and hand them to httpx as ``content=`` (not ``json=``). This @@ -1311,11 +1327,11 @@ def test_batch_create_uses_bytes_path(self, orders: OrdersResource) -> None: ) with patch.object( - orders._transport, "request", wraps=orders._transport.request, + orders._transport, + "request", + wraps=orders._transport.request, ) as spy: - orders.batch_create( - [CreateOrderRequest(ticker="A", side="yes", action="buy", count=1)] - ) + orders.batch_create([CreateOrderRequest(ticker="A", side="yes", action="buy", count=1)]) spy.assert_called_once() args, kwargs = spy.call_args assert args == ("POST", "/portfolio/orders/batched") @@ -1332,7 +1348,9 @@ def test_batch_cancel_uses_bytes_path(self, orders: OrdersResource) -> None: ) with patch.object( - orders._transport, "request", wraps=orders._transport.request, + orders._transport, + "request", + wraps=orders._transport.request, ) as spy: orders.batch_cancel(["ord-1"]) spy.assert_called_once() @@ -1344,27 +1362,34 @@ def test_batch_cancel_uses_bytes_path(self, orders: OrdersResource) -> None: @respx.mock def test_batch_create_bytes_path_preserves_decimal_precision( - self, orders: OrdersResource, + self, + orders: OrdersResource, ) -> None: """P4.2: the bytes path uses ``model_dump_json`` which round-trips Decimals via the configured DollarDecimal serializer. Round-trip ``Decimal("0.5600")`` through the wire and confirm the exact string is preserved (no float-conversion drift).""" - route = respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/batched" - ).mock(return_value=httpx.Response(200, json={"orders": []})) + route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( + return_value=httpx.Response(200, json={"orders": []}) + ) - orders.batch_create([ - CreateOrderRequest( - ticker="A", side="yes", action="buy", - count=10, yes_price=Decimal("0.5600"), - ), - ]) + orders.batch_create( + [ + CreateOrderRequest( + ticker="A", + side="yes", + action="buy", + count=10, + yes_price=Decimal("0.5600"), + ), + ] + ) raw = route.calls[0].request.content body = json.loads(raw) assert body["orders"][0]["yes_price_dollars"] == "0.5600" + # ── V2 event-market orders (spec v3.18.0) ─────────────────── @@ -1846,13 +1871,11 @@ def test_order_id_with_slash_is_encoded_not_routing_attack( ) -> None: # ``/`` is encoded as ``%2F`` so the server receives the encoded # segment, never a path-traversal-adjacent URL. - encoded_path = ( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/" - "%2F..%2Fadmin" - ) + encoded_path = "https://test.kalshi.com/trade-api/v2/portfolio/orders/%2F..%2Fadmin" route = respx.get(encoded_path).mock( return_value=httpx.Response( - 200, json={"order": order_dict(order_id="o", ticker="M")}, + 200, + json={"order": order_dict(order_id="o", ticker="M")}, ) ) orders.get("/../admin") @@ -1860,26 +1883,21 @@ def test_order_id_with_slash_is_encoded_not_routing_attack( @respx.mock def test_order_id_with_space_encoded(self, orders: OrdersResource) -> None: - encoded_path = ( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord%20a" - ) + encoded_path = "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord%20a" route = respx.get(encoded_path).mock( return_value=httpx.Response( - 200, json={"order": order_dict(order_id="o", ticker="M")}, + 200, + json={"order": order_dict(order_id="o", ticker="M")}, ) ) orders.get("ord a") assert route.called - def test_empty_string_order_id_raises_value_error( - self, orders: OrdersResource - ) -> None: + def test_empty_string_order_id_raises_value_error(self, orders: OrdersResource) -> None: with pytest.raises(ValueError, match="order_id must be non-empty"): orders.get("") - def test_whitespace_only_order_id_rejected( - self, orders: OrdersResource - ) -> None: + def test_whitespace_only_order_id_rejected(self, orders: OrdersResource) -> None: with pytest.raises(ValueError, match="order_id must be non-empty"): orders.cancel(" ") @@ -1897,30 +1915,22 @@ class TestLimitValidation: trip and produces a more actionable error than a server 400. """ - def test_orders_list_rejects_limit_below_1( - self, orders: OrdersResource - ) -> None: + def test_orders_list_rejects_limit_below_1(self, orders: OrdersResource) -> None: with pytest.raises(ValueError, match=r"limit must be in \[1, 1000\]"): orders.list(limit=0) - def test_orders_list_rejects_limit_above_1000( - self, orders: OrdersResource - ) -> None: + def test_orders_list_rejects_limit_above_1000(self, orders: OrdersResource) -> None: with pytest.raises(ValueError, match=r"limit must be in \[1, 1000\]"): orders.list(limit=1001) @respx.mock - def test_orders_list_accepts_limit_at_boundaries( - self, orders: OrdersResource - ) -> None: + def test_orders_list_accepts_limit_at_boundaries(self, orders: OrdersResource) -> None: respx.get("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( return_value=httpx.Response(200, json={"orders": []}) ) orders.list(limit=1) orders.list(limit=1000) - def test_fills_rejects_limit_above_1000( - self, orders: OrdersResource - ) -> None: + def test_fills_rejects_limit_above_1000(self, orders: OrdersResource) -> None: with pytest.raises(ValueError, match=r"limit must be in \[1, 1000\]"): orders.fills(limit=10_000) diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index a8b02145..2bdad964 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -272,6 +272,70 @@ def test_positions_with_all_new_filters(self, portfolio: PortfolioResource) -> N assert params["subaccount"] == "7" +class TestPortfolioPositionsAll: + @respx.mock + def test_positions_all_paginates(self, portfolio: PortfolioResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/positions").mock( + side_effect=[ + httpx.Response( + 200, + json={ + "market_positions": [ + market_position_dict(ticker="A"), + market_position_dict(ticker="B"), + ], + "event_positions": [], + "cursor": "page2", + }, + ), + httpx.Response( + 200, + json={ + "market_positions": [market_position_dict(ticker="C")], + "event_positions": [], + "cursor": "", + }, + ), + ] + ) + tickers = [p.ticker for p in portfolio.positions_all()] + assert tickers == ["A", "B", "C"] + + @respx.mock + def test_positions_all_forwards_filters_and_omits_cursor( + self, portfolio: PortfolioResource + ) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/positions").mock( + return_value=httpx.Response( + 200, json={"market_positions": [], "event_positions": [], "cursor": ""} + ) + ) + list( + portfolio.positions_all( + limit=100, + count_filter="position", + ticker="MKT-A", + event_ticker="EVT-X", + subaccount=3, + ) + ) + params = dict(route.calls[0].request.url.params) + assert params["limit"] == "100" + assert params["count_filter"] == "position" + assert params["ticker"] == "MKT-A" + assert params["event_ticker"] == "EVT-X" + assert params["subaccount"] == "3" + assert "cursor" not in params + + def test_positions_all_requires_auth(self, unauth_portfolio: PortfolioResource) -> None: + with pytest.raises(AuthRequiredError): + list(unauth_portfolio.positions_all()) + + def test_positions_all_rejects_zero_max_pages(self, portfolio: PortfolioResource) -> None: + with pytest.raises(ValueError, match="max_pages must be positive"): + list(portfolio.positions_all(max_pages=0)) + + class TestPortfolioSettlements: @respx.mock def test_returns_settlements(self, portfolio: PortfolioResource) -> None: @@ -1030,3 +1094,39 @@ def test_deposits_all_requires_auth( ) -> None: with pytest.raises(AuthRequiredError): unauth_async_portfolio.deposits_all() + + +class TestAsyncPortfolioPositionsAll: + @pytest.mark.asyncio + @respx.mock + async def test_positions_all_paginates(self, async_portfolio: AsyncPortfolioResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/positions").mock( + side_effect=[ + httpx.Response( + 200, + json={ + "market_positions": [market_position_dict(ticker="A")], + "event_positions": [], + "cursor": "page2", + }, + ), + httpx.Response( + 200, + json={ + "market_positions": [market_position_dict(ticker="B")], + "event_positions": [], + "cursor": "", + }, + ), + ] + ) + tickers = [p.ticker async for p in async_portfolio.positions_all()] + assert tickers == ["A", "B"] + + @pytest.mark.asyncio + async def test_positions_all_requires_auth( + self, unauth_async_portfolio: AsyncPortfolioResource + ) -> None: + with pytest.raises(AuthRequiredError): + async for _ in unauth_async_portfolio.positions_all(): + pass