From a62a9070761f7b07fa83de7676ea5d4c125ba620 Mon Sep 17 00:00:00 2001 From: audit-w1b Date: Wed, 20 May 2026 21:37:38 -0500 Subject: [PATCH 1/2] feat(resources): V1 batch typed responses (breaking); URL-encode paths; client-side limit validation BREAKING: orders.batch_create now returns BatchCreateOrdersResponse (was list[Order]); orders.batch_cancel now returns BatchCancelOrdersResponse (was None). Mirrors V2 shape. Fixes partial-success crash and exposes per-leg reduced_by_fp. - Add _seg() helper: URL-encodes user-supplied path segments, rejects empty/whitespace and '.'/'..'. - Add _validate_limit() helper: enforces spec-correct bounds per endpoint. - Sweep applied across all resources (~20 path-segment sites, ~25 limit kwargs). Closes #194, Closes #211, Closes #214 Refs #224 --- kalshi/__init__.py | 8 + kalshi/models/__init__.py | 8 + kalshi/models/orders.py | 68 ++++++++ kalshi/resources/_base.py | 42 +++++ kalshi/resources/api_keys.py | 5 +- kalshi/resources/communications.py | 28 +-- kalshi/resources/events.py | 12 +- kalshi/resources/fcm.py | 3 + kalshi/resources/historical.py | 13 +- kalshi/resources/incentive_programs.py | 5 + kalshi/resources/live_data.py | 14 +- kalshi/resources/markets.py | 16 +- kalshi/resources/milestones.py | 7 +- kalshi/resources/multivariate.py | 19 ++- kalshi/resources/order_groups.py | 21 +-- kalshi/resources/orders.py | 122 ++++++++------ kalshi/resources/portfolio.py | 11 ++ kalshi/resources/series.py | 14 +- kalshi/resources/structured_targets.py | 10 +- kalshi/resources/subaccounts.py | 5 + tests/test_async_orders.py | 113 ++++++++++--- tests/test_orders.py | 225 ++++++++++++++++++++++--- 22 files changed, 610 insertions(+), 159 deletions(-) diff --git a/kalshi/__init__.py b/kalshi/__init__.py index 73df6f2e..6308bb20 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -38,11 +38,15 @@ Balance, BatchCancelOrdersRequest, BatchCancelOrdersRequestOrder, + BatchCancelOrdersResponse, + BatchCancelOrdersResponseEntry, BatchCancelOrdersV2Request, BatchCancelOrdersV2RequestOrder, BatchCancelOrdersV2Response, BatchCancelOrdersV2ResponseEntry, BatchCreateOrdersRequest, + BatchCreateOrdersResponse, + BatchCreateOrdersResponseEntry, BatchCreateOrdersV2Request, BatchCreateOrdersV2Response, BatchCreateOrdersV2ResponseEntry, @@ -182,11 +186,15 @@ "Balance", "BatchCancelOrdersRequest", "BatchCancelOrdersRequestOrder", + "BatchCancelOrdersResponse", + "BatchCancelOrdersResponseEntry", "BatchCancelOrdersV2Request", "BatchCancelOrdersV2RequestOrder", "BatchCancelOrdersV2Response", "BatchCancelOrdersV2ResponseEntry", "BatchCreateOrdersRequest", + "BatchCreateOrdersResponse", + "BatchCreateOrdersResponseEntry", "BatchCreateOrdersV2Request", "BatchCreateOrdersV2Response", "BatchCreateOrdersV2ResponseEntry", diff --git a/kalshi/models/__init__.py b/kalshi/models/__init__.py index 956261d3..a92e6163 100644 --- a/kalshi/models/__init__.py +++ b/kalshi/models/__init__.py @@ -104,11 +104,15 @@ AmendOrderV2Response, BatchCancelOrdersRequest, BatchCancelOrdersRequestOrder, + BatchCancelOrdersResponse, + BatchCancelOrdersResponseEntry, BatchCancelOrdersV2Request, BatchCancelOrdersV2RequestOrder, BatchCancelOrdersV2Response, BatchCancelOrdersV2ResponseEntry, BatchCreateOrdersRequest, + BatchCreateOrdersResponse, + BatchCreateOrdersResponseEntry, BatchCreateOrdersV2Request, BatchCreateOrdersV2Response, BatchCreateOrdersV2ResponseEntry, @@ -188,11 +192,15 @@ "Balance", "BatchCancelOrdersRequest", "BatchCancelOrdersRequestOrder", + "BatchCancelOrdersResponse", + "BatchCancelOrdersResponseEntry", "BatchCancelOrdersV2Request", "BatchCancelOrdersV2RequestOrder", "BatchCancelOrdersV2Response", "BatchCancelOrdersV2ResponseEntry", "BatchCreateOrdersRequest", + "BatchCreateOrdersResponse", + "BatchCreateOrdersResponseEntry", "BatchCreateOrdersV2Request", "BatchCreateOrdersV2Response", "BatchCreateOrdersV2ResponseEntry", diff --git a/kalshi/models/orders.py b/kalshi/models/orders.py index ff609639..4106d482 100644 --- a/kalshi/models/orders.py +++ b/kalshi/models/orders.py @@ -347,6 +347,74 @@ class BatchCancelOrdersRequest(BaseModel): model_config = {"extra": "forbid"} +class BatchCreateOrdersResponseEntry(BaseModel): + """Single entry in :class:`BatchCreateOrdersResponse`. + + Spec ``components.schemas.BatchCreateOrdersIndividualResponse``: all + three fields are nullable. A failed leg comes back as + ``{"client_order_id": "x", "order": null, "error": {...}}``; a + successful leg as ``{"client_order_id": "x", "order": {...}, "error": null}``. + Pairing returned orders with the originating request requires + ``client_order_id``; surfacing per-leg failures requires ``error``. + """ + + order: Order | None = None + error: dict[str, object] | None = None + client_order_id: str | None = None + + model_config = {"extra": "allow", "populate_by_name": True} + + +class BatchCreateOrdersResponse(BaseModel): + """Response from ``POST /portfolio/orders/batched``. + + Spec ``components.schemas.BatchCreateOrdersResponse``. v3.0.0 + BREAKING: previously the SDK returned ``list[Order]`` and crashed + with ``ValidationError`` on the first failed leg + (``Order.model_validate(None)``). Now returns the typed envelope so + callers can inspect per-leg ``order``/``error``/``client_order_id``. + """ + + orders: list[BatchCreateOrdersResponseEntry] + + model_config = {"extra": "allow", "populate_by_name": True} + + +class BatchCancelOrdersResponseEntry(BaseModel): + """Single entry in :class:`BatchCancelOrdersResponse`. + + Spec ``components.schemas.BatchCancelOrdersIndividualResponse``: + ``order_id`` and ``reduced_by_fp`` are required; ``order`` and + ``error`` are nullable. ``reduced_by_fp`` is load-bearing for risk + reconciliation — it is the count of contracts that actually canceled + (``0`` when ``error`` is set, the canceled count otherwise). + """ + + order_id: str + reduced_by_fp: FixedPointCount = Field( + validation_alias=AliasChoices("reduced_by_fp", "reduced_by"), + ) + order: Order | None = None + error: dict[str, object] | None = None + + model_config = {"extra": "allow", "populate_by_name": True} + + +class BatchCancelOrdersResponse(BaseModel): + """Response from ``DELETE /portfolio/orders/batched``. + + Spec ``components.schemas.BatchCancelOrdersResponse``. v3.0.0 + BREAKING: previously the SDK declared ``-> None`` and discarded the + response body. Per-leg ``reduced_by_fp`` and any per-leg errors are + now surfaced. + """ + + orders: list[BatchCancelOrdersResponseEntry] + + model_config = {"extra": "allow", "populate_by_name": True} + + + class AmendOrderResponse(BaseModel): """Response from amending an order — contains both pre and post-amendment orders.""" diff --git a/kalshi/resources/_base.py b/kalshi/resources/_base.py index ccac58b1..fad20559 100644 --- a/kalshi/resources/_base.py +++ b/kalshi/resources/_base.py @@ -2,6 +2,7 @@ from __future__ import annotations +import urllib.parse from collections.abc import AsyncIterator, Iterator from typing import Any, TypeVar @@ -14,6 +15,47 @@ T = TypeVar("T", bound=BaseModel) +def _seg(value: str, *, name: str = "path segment") -> str: + """URL-encode a single path segment; reject empty/whitespace and ``.``/``..``. + + Caller-supplied identifiers (tickers, order ids, etc.) get interpolated + into request paths with f-strings. Without encoding, a value like + ``"foo/../admin"`` would be normalized by intermediate proxies into a + request signed for the intended endpoint but routed to a different one. + ``urllib.parse.quote(..., safe="")`` percent-encodes ``/`` and every + other reserved character. ``.``/``..`` are rejected outright because + they survive URL encoding but still trigger path normalization. + """ + if not value or not value.strip(): + raise ValueError(f"{name} must be non-empty") + if value in (".", ".."): + raise ValueError(f"{name} cannot be '.' or '..'") + return urllib.parse.quote(value, safe="") + + +def _validate_limit( + value: int | None, + *, + hi: int, + lo: int = 1, + name: str = "limit", +) -> int | None: + """Reject ``limit`` outside the spec-declared ``[lo, hi]`` window. + + Returns the value unchanged on success. Kalshi enforces these bounds + server-side; failing client-side avoids a wasted round trip and a + less-actionable 400. + """ + if value is None: + return None + if not (lo <= value <= hi): + raise ValueError( + f"{name} must be in [{lo}, {hi}], got {value}." + ) + return value + + + def _params(**kwargs: Any) -> dict[str, Any]: """Build a query-param dict, dropping None values.""" return {k: v for k, v in kwargs.items() if v is not None} diff --git a/kalshi/resources/api_keys.py b/kalshi/resources/api_keys.py index 7bc79d18..790744b9 100644 --- a/kalshi/resources/api_keys.py +++ b/kalshi/resources/api_keys.py @@ -16,6 +16,7 @@ AsyncResource, SyncResource, _check_request_exclusive, + _seg, ) # Shared request-body builders (issue #46). @@ -120,7 +121,7 @@ def generate( def delete(self, api_key: str) -> None: self._require_auth() - self._delete(f"/api_keys/{api_key}") + self._delete(f"/api_keys/{_seg(api_key, name='api_key')}") class AsyncApiKeysResource(AsyncResource): @@ -181,4 +182,4 @@ async def generate( async def delete(self, api_key: str) -> None: self._require_auth() - await self._delete(f"/api_keys/{api_key}") + await self._delete(f"/api_keys/{_seg(api_key, name='api_key')}") diff --git a/kalshi/resources/communications.py b/kalshi/resources/communications.py index fc588561..5832078b 100644 --- a/kalshi/resources/communications.py +++ b/kalshi/resources/communications.py @@ -25,6 +25,8 @@ SyncResource, _check_request_exclusive, _params, + _seg, + _validate_limit, _validate_max_pages, ) @@ -70,6 +72,7 @@ def _list_rfqs_params( creator_user_id: str | None, user_filter: UserFilterLiteral | None, ) -> dict[str, Any]: + limit = _validate_limit(limit, hi=100) return _params( cursor=cursor, limit=limit, @@ -96,6 +99,7 @@ def _list_quotes_params( user_filter: UserFilterLiteral | None, rfq_user_filter: UserFilterLiteral | None, ) -> dict[str, Any]: + limit = _validate_limit(limit, hi=500) return _params( cursor=cursor, limit=limit, @@ -254,7 +258,7 @@ def list_all_rfqs( def get_rfq(self, rfq_id: str) -> GetRFQResponse: self._require_auth() - data = self._get(f"/communications/rfqs/{rfq_id}") + data = self._get(f"/communications/rfqs/{_seg(rfq_id, name='rfq_id')}") return GetRFQResponse.model_validate(data) @overload @@ -296,7 +300,7 @@ def create_rfq( def delete_rfq(self, rfq_id: str) -> None: self._require_auth() - self._delete(f"/communications/rfqs/{rfq_id}") + self._delete(f"/communications/rfqs/{_seg(rfq_id, name='rfq_id')}") def list_quotes( self, @@ -368,7 +372,7 @@ def list_all_quotes( def get_quote(self, quote_id: str) -> GetQuoteResponse: self._require_auth() - data = self._get(f"/communications/quotes/{quote_id}") + data = self._get(f"/communications/quotes/{_seg(quote_id, name='quote_id')}") return GetQuoteResponse.model_validate(data) @overload @@ -406,7 +410,7 @@ def create_quote( def delete_quote(self, quote_id: str) -> None: self._require_auth() - self._delete(f"/communications/quotes/{quote_id}") + self._delete(f"/communications/quotes/{_seg(quote_id, name='quote_id')}") @overload def accept_quote( @@ -425,12 +429,12 @@ def accept_quote( ) -> None: self._require_auth() body = _build_accept_quote_body(request, accepted_side=accepted_side) - self._put(f"/communications/quotes/{quote_id}/accept", json=body) + self._put(f"/communications/quotes/{_seg(quote_id, name='quote_id')}/accept", json=body) def confirm_quote(self, quote_id: str) -> None: self._require_auth() # json={} forces Content-Type: application/json — demo rejects empty PUTs. - self._put(f"/communications/quotes/{quote_id}/confirm", json={}) + self._put(f"/communications/quotes/{_seg(quote_id, name='quote_id')}/confirm", json={}) class AsyncCommunicationsResource(AsyncResource): @@ -490,7 +494,7 @@ def list_all_rfqs( async def get_rfq(self, rfq_id: str) -> GetRFQResponse: self._require_auth() - data = await self._get(f"/communications/rfqs/{rfq_id}") + data = await self._get(f"/communications/rfqs/{_seg(rfq_id, name='rfq_id')}") return GetRFQResponse.model_validate(data) @overload @@ -532,7 +536,7 @@ async def create_rfq( async def delete_rfq(self, rfq_id: str) -> None: self._require_auth() - await self._delete(f"/communications/rfqs/{rfq_id}") + await self._delete(f"/communications/rfqs/{_seg(rfq_id, name='rfq_id')}") async def list_quotes( self, @@ -606,7 +610,7 @@ def list_all_quotes( async def get_quote(self, quote_id: str) -> GetQuoteResponse: self._require_auth() - data = await self._get(f"/communications/quotes/{quote_id}") + data = await self._get(f"/communications/quotes/{_seg(quote_id, name='quote_id')}") return GetQuoteResponse.model_validate(data) @overload @@ -646,7 +650,7 @@ async def create_quote( async def delete_quote(self, quote_id: str) -> None: self._require_auth() - await self._delete(f"/communications/quotes/{quote_id}") + await self._delete(f"/communications/quotes/{_seg(quote_id, name='quote_id')}") @overload async def accept_quote( @@ -665,9 +669,9 @@ async def accept_quote( ) -> None: self._require_auth() body = _build_accept_quote_body(request, accepted_side=accepted_side) - await self._put(f"/communications/quotes/{quote_id}/accept", json=body) + await self._put(f"/communications/quotes/{_seg(quote_id, name='quote_id')}/accept", json=body) # noqa: E501 async def confirm_quote(self, quote_id: str) -> None: self._require_auth() # json={} forces Content-Type: application/json — demo rejects empty PUTs. - await self._put(f"/communications/quotes/{quote_id}/confirm", json={}) + await self._put(f"/communications/quotes/{_seg(quote_id, name='quote_id')}/confirm", json={}) # noqa: E501 diff --git a/kalshi/resources/events.py b/kalshi/resources/events.py index fc85f86e..0d231e63 100644 --- a/kalshi/resources/events.py +++ b/kalshi/resources/events.py @@ -12,6 +12,8 @@ SyncResource, _bool_param, _params, + _seg, + _validate_limit, _validate_max_pages, ) @@ -29,6 +31,7 @@ def _list_events_params( limit: int | None, cursor: str | None, ) -> dict[str, Any]: + limit = _validate_limit(limit, hi=200) return _params( status=status, series_ticker=series_ticker, @@ -49,6 +52,7 @@ def _list_multivariate_events_params( limit: int | None, cursor: str | None, ) -> dict[str, Any]: + limit = _validate_limit(limit, hi=200) return _params( series_ticker=series_ticker, collection_ticker=collection_ticker, @@ -153,11 +157,11 @@ def get( params = _params( with_nested_markets=_bool_param(with_nested_markets), ) - data = self._get(f"/events/{event_ticker}", params=params) + data = self._get(f"/events/{_seg(event_ticker, name='event_ticker')}", params=params) return Event.model_validate(data.get("event", data)) def metadata(self, event_ticker: str) -> EventMetadata: - data = self._get(f"/events/{event_ticker}/metadata") + data = self._get(f"/events/{_seg(event_ticker, name='event_ticker')}/metadata") return EventMetadata.model_validate(data) @@ -256,9 +260,9 @@ async def get( params = _params( with_nested_markets=_bool_param(with_nested_markets), ) - data = await self._get(f"/events/{event_ticker}", params=params) + data = await self._get(f"/events/{_seg(event_ticker, name='event_ticker')}", params=params) return Event.model_validate(data.get("event", data)) async def metadata(self, event_ticker: str) -> EventMetadata: - data = await self._get(f"/events/{event_ticker}/metadata") + data = await self._get(f"/events/{_seg(event_ticker, name='event_ticker')}/metadata") return EventMetadata.model_validate(data) diff --git a/kalshi/resources/fcm.py b/kalshi/resources/fcm.py index 121a2291..353f6d3a 100644 --- a/kalshi/resources/fcm.py +++ b/kalshi/resources/fcm.py @@ -22,6 +22,7 @@ AsyncResource, SyncResource, _params, + _validate_limit, _validate_max_pages, ) @@ -39,6 +40,7 @@ def _fcm_orders_params( limit: int | None, cursor: str | None, ) -> dict[str, Any]: + limit = _validate_limit(limit, hi=1000) return _params( subtrader_id=subtrader_id, ticker=ticker, @@ -61,6 +63,7 @@ def _fcm_positions_params( limit: int | None, cursor: str | None, ) -> dict[str, Any]: + limit = _validate_limit(limit, hi=1000) return _params( subtrader_id=subtrader_id, ticker=ticker, diff --git a/kalshi/resources/historical.py b/kalshi/resources/historical.py index 3de7f394..ebd453fd 100644 --- a/kalshi/resources/historical.py +++ b/kalshi/resources/historical.py @@ -19,6 +19,8 @@ SyncResource, _join_tickers, _params, + _seg, + _validate_limit, _validate_max_pages, ) @@ -34,6 +36,7 @@ def _historical_markets_params( series_ticker: str | None, mve_filter: MveHistoricalFilterLiteral | None, ) -> dict[str, Any]: + limit = _validate_limit(limit, hi=1000, lo=0) return _params( limit=limit, cursor=cursor, @@ -61,6 +64,7 @@ def _historical_fills_or_orders_params( ticker: str | None, max_ts: int | None, ) -> dict[str, Any]: + limit = _validate_limit(limit, hi=1000) return _params(limit=limit, cursor=cursor, ticker=ticker, max_ts=max_ts) @@ -72,6 +76,7 @@ def _historical_trades_params( min_ts: int | None, max_ts: int | None, ) -> dict[str, Any]: + limit = _validate_limit(limit, hi=1000, lo=0) return _params( limit=limit, cursor=cursor, ticker=ticker, min_ts=min_ts, max_ts=max_ts, ) @@ -123,7 +128,7 @@ def markets_all( ) def market(self, ticker: str) -> Market: - data = self._get(f"/historical/markets/{ticker}") + data = self._get(f"/historical/markets/{_seg(ticker, name='ticker')}") raw = data.get("market", data) return Market.model_validate(raw) @@ -140,7 +145,7 @@ def candlesticks( period_interval=period_interval, ) data = self._get( - f"/historical/markets/{ticker}/candlesticks", + f"/historical/markets/{_seg(ticker, name='ticker')}/candlesticks", params=params, ) raw = data.get("candlesticks", []) @@ -290,7 +295,7 @@ def markets_all( ) async def market(self, ticker: str) -> Market: - data = await self._get(f"/historical/markets/{ticker}") + data = await self._get(f"/historical/markets/{_seg(ticker, name='ticker')}") raw = data.get("market", data) return Market.model_validate(raw) @@ -307,7 +312,7 @@ async def candlesticks( period_interval=period_interval, ) data = await self._get( - f"/historical/markets/{ticker}/candlesticks", + f"/historical/markets/{_seg(ticker, name='ticker')}/candlesticks", params=params, ) raw = data.get("candlesticks", []) diff --git a/kalshi/resources/incentive_programs.py b/kalshi/resources/incentive_programs.py index 1fa6f07b..90a157d4 100644 --- a/kalshi/resources/incentive_programs.py +++ b/kalshi/resources/incentive_programs.py @@ -19,6 +19,7 @@ AsyncResource, SyncResource, _params, + _validate_limit, _validate_max_pages, ) @@ -39,6 +40,7 @@ def list( limit: int | None = None, cursor: str | None = None, ) -> Page[IncentiveProgram]: + _validate_limit(limit, hi=10000) params = _params( status=status, type=incentive_type, @@ -64,6 +66,7 @@ def list_all( max_pages: int | None = None, ) -> Iterator[IncentiveProgram]: _validate_max_pages(max_pages) + _validate_limit(limit, hi=10000) params = _params( status=status, type=incentive_type, @@ -92,6 +95,7 @@ async def list( limit: int | None = None, cursor: str | None = None, ) -> Page[IncentiveProgram]: + _validate_limit(limit, hi=10000) params = _params( status=status, type=incentive_type, @@ -118,6 +122,7 @@ def list_all( ) -> AsyncIterator[IncentiveProgram]: """Returns an async iterator — use ``async for``.""" _validate_max_pages(max_pages) + _validate_limit(limit, hi=10000) params = _params( status=status, type=incentive_type, diff --git a/kalshi/resources/live_data.py b/kalshi/resources/live_data.py index 39141069..fef62b6c 100644 --- a/kalshi/resources/live_data.py +++ b/kalshi/resources/live_data.py @@ -15,7 +15,7 @@ GetLiveDatasResponse, LiveData, ) -from kalshi.resources._base import AsyncResource, SyncResource, _bool_param, _params +from kalshi.resources._base import AsyncResource, SyncResource, _bool_param, _params, _seg _MAX_BATCH = 100 @@ -33,7 +33,7 @@ def get( include_player_stats=_bool_param(include_player_stats), ) data = self._get( - f"/live_data/milestone/{milestone_id}", + f"/live_data/milestone/{_seg(milestone_id, name='milestone_id')}", params=params, ) return GetLiveDataResponse.model_validate(data).live_data @@ -58,7 +58,7 @@ def get_typed( include_player_stats=_bool_param(include_player_stats), ) data = self._get( - f"/live_data/{milestone_type}/milestone/{milestone_id}", + f"/live_data/{_seg(milestone_type, name='milestone_type')}/milestone/{_seg(milestone_id, name='milestone_id')}", # noqa: E501 params=params, ) return GetLiveDataResponse.model_validate(data).live_data @@ -92,7 +92,7 @@ def batch( def game_stats(self, milestone_id: str) -> GetGameStatsResponse: """Play-by-play stats. Returns ``pbp=None`` for unsupported sports.""" - data = self._get(f"/live_data/milestone/{milestone_id}/game_stats") + data = self._get(f"/live_data/milestone/{_seg(milestone_id, name='milestone_id')}/game_stats") # noqa: E501 return GetGameStatsResponse.model_validate(data) @@ -109,7 +109,7 @@ async def get( include_player_stats=_bool_param(include_player_stats), ) data = await self._get( - f"/live_data/milestone/{milestone_id}", + f"/live_data/milestone/{_seg(milestone_id, name='milestone_id')}", params=params, ) return GetLiveDataResponse.model_validate(data).live_data @@ -134,7 +134,7 @@ async def get_typed( include_player_stats=_bool_param(include_player_stats), ) data = await self._get( - f"/live_data/{milestone_type}/milestone/{milestone_id}", + f"/live_data/{_seg(milestone_type, name='milestone_type')}/milestone/{_seg(milestone_id, name='milestone_id')}", # noqa: E501 params=params, ) return GetLiveDataResponse.model_validate(data).live_data @@ -168,5 +168,5 @@ async def batch( async def game_stats(self, milestone_id: str) -> GetGameStatsResponse: """Play-by-play stats. Returns ``pbp=None`` for unsupported sports.""" - data = await self._get(f"/live_data/milestone/{milestone_id}/game_stats") + data = await self._get(f"/live_data/milestone/{_seg(milestone_id, name='milestone_id')}/game_stats") # noqa: E501 return GetGameStatsResponse.model_validate(data) diff --git a/kalshi/resources/markets.py b/kalshi/resources/markets.py index c09454d8..ad4e19f9 100644 --- a/kalshi/resources/markets.py +++ b/kalshi/resources/markets.py @@ -24,6 +24,8 @@ _bool_param, _join_tickers, _params, + _seg, + _validate_limit, _validate_max_pages, ) @@ -53,6 +55,7 @@ def _list_markets_params( limit: int | None, cursor: str | None, ) -> dict[str, Any]: + limit = _validate_limit(limit, hi=1000, lo=0) return _params( status=status, series_ticker=series_ticker, @@ -94,6 +97,7 @@ def _list_trades_params( limit: int | None, cursor: str | None, ) -> dict[str, Any]: + limit = _validate_limit(limit, hi=1000, lo=0) return _params( ticker=ticker, min_ts=min_ts, @@ -284,14 +288,14 @@ def list_all( ) def get(self, ticker: str) -> Market: - data = self._get(f"/markets/{ticker}") + data = self._get(f"/markets/{_seg(ticker, name='ticker')}") market = data.get("market", data) return Market.model_validate(market) def orderbook(self, ticker: str, *, depth: int | None = None) -> Orderbook: self._require_auth() params = _params(depth=depth) - data = self._get(f"/markets/{ticker}/orderbook", params=params) + data = self._get(f"/markets/{_seg(ticker, name='ticker')}/orderbook", params=params) return _parse_orderbook(data, ticker) def candlesticks( @@ -310,7 +314,7 @@ def candlesticks( include_latest_before_start=include_latest_before_start, ) data = self._get( - f"/series/{series_ticker}/markets/{ticker}/candlesticks", + f"/series/{_seg(series_ticker, name='series_ticker')}/markets/{_seg(ticker, name='ticker')}/candlesticks", # noqa: E501 params=params, ) raw = data.get("candlesticks", []) @@ -460,14 +464,14 @@ def list_all( ) async def get(self, ticker: str) -> Market: - data = await self._get(f"/markets/{ticker}") + data = await self._get(f"/markets/{_seg(ticker, name='ticker')}") market = data.get("market", data) return Market.model_validate(market) async def orderbook(self, ticker: str, *, depth: int | None = None) -> Orderbook: self._require_auth() params = _params(depth=depth) - data = await self._get(f"/markets/{ticker}/orderbook", params=params) + data = await self._get(f"/markets/{_seg(ticker, name='ticker')}/orderbook", params=params) return _parse_orderbook(data, ticker) async def candlesticks( @@ -486,7 +490,7 @@ async def candlesticks( include_latest_before_start=include_latest_before_start, ) data = await self._get( - f"/series/{series_ticker}/markets/{ticker}/candlesticks", + f"/series/{_seg(series_ticker, name='series_ticker')}/markets/{_seg(ticker, name='ticker')}/candlesticks", # noqa: E501 params=params, ) raw = data.get("candlesticks", []) diff --git a/kalshi/resources/milestones.py b/kalshi/resources/milestones.py index f63d0104..6d98f595 100644 --- a/kalshi/resources/milestones.py +++ b/kalshi/resources/milestones.py @@ -12,6 +12,8 @@ AsyncResource, SyncResource, _params, + _seg, + _validate_limit, _validate_max_pages, ) @@ -50,6 +52,7 @@ def _list_milestones_params( cursor: str | None, min_updated_ts: int | None, ) -> dict[str, Any]: + _validate_limit(limit, hi=500) return _params( limit=limit, minimum_start_date=_iso(minimum_start_date), @@ -125,7 +128,7 @@ def list_all( ) def get(self, milestone_id: str) -> Milestone: - data = self._get(f"/milestones/{milestone_id}") + data = self._get(f"/milestones/{_seg(milestone_id, name='milestone_id')}") return GetMilestoneResponse.model_validate(data).milestone @@ -184,5 +187,5 @@ def list_all( ) async def get(self, milestone_id: str) -> Milestone: - data = await self._get(f"/milestones/{milestone_id}") + data = await self._get(f"/milestones/{_seg(milestone_id, name='milestone_id')}") return GetMilestoneResponse.model_validate(data).milestone diff --git a/kalshi/resources/multivariate.py b/kalshi/resources/multivariate.py index 90527cdf..0531f2d2 100644 --- a/kalshi/resources/multivariate.py +++ b/kalshi/resources/multivariate.py @@ -22,6 +22,8 @@ SyncResource, _check_request_exclusive, _params, + _seg, + _validate_limit, _validate_max_pages, ) @@ -36,6 +38,7 @@ def _list_collections_params( limit: int | None, cursor: str | None, ) -> dict[str, Any]: + limit = _validate_limit(limit, hi=200) return _params( status=status, associated_event_ticker=associated_event_ticker, @@ -150,7 +153,7 @@ def list_all( ) def get(self, collection_ticker: str) -> MultivariateEventCollection: - data = self._get(f"/multivariate_event_collections/{collection_ticker}") + data = self._get(f"/multivariate_event_collections/{_seg(collection_ticker, name='collection_ticker')}") # noqa: E501 return MultivariateEventCollection.model_validate( data.get("multivariate_contract", data) ) @@ -185,7 +188,7 @@ def create_market( with_market_payload=with_market_payload, ) data = self._post( - f"/multivariate_event_collections/{collection_ticker}", + f"/multivariate_event_collections/{_seg(collection_ticker, name='collection_ticker')}", json=body, ) return CreateMarketResponse.model_validate(data) @@ -218,7 +221,7 @@ def lookup_tickers( request, selected_markets=selected_markets, ) data = self._put( - f"/multivariate_event_collections/{collection_ticker}/lookup", + f"/multivariate_event_collections/{_seg(collection_ticker, name='collection_ticker')}/lookup", # noqa: E501 json=body, ) return _parse_lookup_tickers_response(data) @@ -231,7 +234,7 @@ def lookup_history( ) -> builtins.list[LookupPoint]: params = _params(lookback_seconds=lookback_seconds) data = self._get( - f"/multivariate_event_collections/{collection_ticker}/lookup", + f"/multivariate_event_collections/{_seg(collection_ticker, name='collection_ticker')}/lookup", # noqa: E501 params=params, ) raw = data.get("lookup_points", []) @@ -288,7 +291,7 @@ def list_all( ) async def get(self, collection_ticker: str) -> MultivariateEventCollection: - data = await self._get(f"/multivariate_event_collections/{collection_ticker}") + data = await self._get(f"/multivariate_event_collections/{_seg(collection_ticker, name='collection_ticker')}") # noqa: E501 return MultivariateEventCollection.model_validate( data.get("multivariate_contract", data) ) @@ -323,7 +326,7 @@ async def create_market( with_market_payload=with_market_payload, ) data = await self._post( - f"/multivariate_event_collections/{collection_ticker}", + f"/multivariate_event_collections/{_seg(collection_ticker, name='collection_ticker')}", json=body, ) return CreateMarketResponse.model_validate(data) @@ -356,7 +359,7 @@ async def lookup_tickers( request, selected_markets=selected_markets, ) data = await self._put( - f"/multivariate_event_collections/{collection_ticker}/lookup", + f"/multivariate_event_collections/{_seg(collection_ticker, name='collection_ticker')}/lookup", # noqa: E501 json=body, ) return _parse_lookup_tickers_response(data) @@ -369,7 +372,7 @@ async def lookup_history( ) -> builtins.list[LookupPoint]: params = _params(lookback_seconds=lookback_seconds) data = await self._get( - f"/multivariate_event_collections/{collection_ticker}/lookup", + f"/multivariate_event_collections/{_seg(collection_ticker, name='collection_ticker')}/lookup", # noqa: E501 params=params, ) raw = data.get("lookup_points", []) diff --git a/kalshi/resources/order_groups.py b/kalshi/resources/order_groups.py index ab361a48..83996aae 100644 --- a/kalshi/resources/order_groups.py +++ b/kalshi/resources/order_groups.py @@ -17,6 +17,7 @@ SyncResource, _check_request_exclusive, _params, + _seg, ) # Shared body builders (issue #46). @@ -77,7 +78,7 @@ def get( ) -> GetOrderGroupResponse: self._require_auth() params = _params(subaccount=subaccount) - data = self._get(f"/portfolio/order_groups/{order_group_id}", params=params) + data = self._get(f"/portfolio/order_groups/{_seg(order_group_id, name='order_group_id')}", params=params) # noqa: E501 return GetOrderGroupResponse.model_validate(data) @overload @@ -118,14 +119,14 @@ def delete( ) -> None: self._require_auth() params = _params(subaccount=subaccount, exchange_index=exchange_index) - self._delete(f"/portfolio/order_groups/{order_group_id}", params=params) + self._delete(f"/portfolio/order_groups/{_seg(order_group_id, name='order_group_id')}", params=params) # noqa: E501 def reset(self, order_group_id: str, *, subaccount: int | None = None) -> None: self._require_auth() params = _params(subaccount=subaccount) # json={} forces Content-Type: application/json — demo rejects the PUT without it. self._put( - f"/portfolio/order_groups/{order_group_id}/reset", params=params, json={}, + f"/portfolio/order_groups/{_seg(order_group_id, name='order_group_id')}/reset", params=params, json={}, # noqa: E501 ) def trigger(self, order_group_id: str, *, subaccount: int | None = None) -> None: @@ -133,7 +134,7 @@ def trigger(self, order_group_id: str, *, subaccount: int | None = None) -> None params = _params(subaccount=subaccount) # json={} forces Content-Type: application/json — demo rejects the PUT without it. self._put( - f"/portfolio/order_groups/{order_group_id}/trigger", params=params, json={}, + f"/portfolio/order_groups/{_seg(order_group_id, name='order_group_id')}/trigger", params=params, json={}, # noqa: E501 ) @overload @@ -156,7 +157,7 @@ def update_limit( body = _build_update_limit_body( request, contracts_limit=contracts_limit, ) - self._put(f"/portfolio/order_groups/{order_group_id}/limit", json=body) + self._put(f"/portfolio/order_groups/{_seg(order_group_id, name='order_group_id')}/limit", json=body) # noqa: E501 class AsyncOrderGroupsResource(AsyncResource): @@ -175,7 +176,7 @@ async def get( self._require_auth() params = _params(subaccount=subaccount) data = await self._get( - f"/portfolio/order_groups/{order_group_id}", params=params, + f"/portfolio/order_groups/{_seg(order_group_id, name='order_group_id')}", params=params, ) return GetOrderGroupResponse.model_validate(data) @@ -216,7 +217,7 @@ async def delete( ) -> None: self._require_auth() params = _params(subaccount=subaccount, exchange_index=exchange_index) - await self._delete(f"/portfolio/order_groups/{order_group_id}", params=params) + await self._delete(f"/portfolio/order_groups/{_seg(order_group_id, name='order_group_id')}", params=params) # noqa: E501 async def reset( self, order_group_id: str, *, subaccount: int | None = None, @@ -225,7 +226,7 @@ async def reset( params = _params(subaccount=subaccount) # json={} forces Content-Type: application/json — demo rejects the PUT without it. await self._put( - f"/portfolio/order_groups/{order_group_id}/reset", params=params, json={}, + f"/portfolio/order_groups/{_seg(order_group_id, name='order_group_id')}/reset", params=params, json={}, # noqa: E501 ) async def trigger( @@ -235,7 +236,7 @@ async def trigger( params = _params(subaccount=subaccount) # json={} forces Content-Type: application/json — demo rejects the PUT without it. await self._put( - f"/portfolio/order_groups/{order_group_id}/trigger", params=params, json={}, + f"/portfolio/order_groups/{_seg(order_group_id, name='order_group_id')}/trigger", params=params, json={}, # noqa: E501 ) @overload @@ -257,4 +258,4 @@ async def update_limit( body = _build_update_limit_body( request, contracts_limit=contracts_limit, ) - await self._put(f"/portfolio/order_groups/{order_group_id}/limit", json=body) + await self._put(f"/portfolio/order_groups/{_seg(order_group_id, name='order_group_id')}/limit", json=body) # noqa: E501 diff --git a/kalshi/resources/orders.py b/kalshi/resources/orders.py index 021c2886..ffa4a06c 100644 --- a/kalshi/resources/orders.py +++ b/kalshi/resources/orders.py @@ -17,9 +17,11 @@ AmendOrderV2Response, BatchCancelOrdersRequest, BatchCancelOrdersRequestOrder, + BatchCancelOrdersResponse, BatchCancelOrdersV2Request, BatchCancelOrdersV2Response, BatchCreateOrdersRequest, + BatchCreateOrdersResponse, BatchCreateOrdersV2Request, BatchCreateOrdersV2Response, CancelOrderV2Response, @@ -43,6 +45,8 @@ _check_request_exclusive, _join_tickers, _params, + _seg, + _validate_limit, _validate_max_pages, ) from kalshi.types import to_decimal @@ -239,6 +243,7 @@ def _list_orders_params( cursor: str | None, subaccount: int | None, ) -> dict[str, Any]: + limit = _validate_limit(limit, hi=1000) return _params( ticker=ticker, event_ticker=event_ticker, @@ -261,6 +266,7 @@ def _fills_params( cursor: str | None, subaccount: int | None, ) -> dict[str, Any]: + limit = _validate_limit(limit, hi=1000) return _params( ticker=ticker, order_id=order_id, @@ -381,7 +387,7 @@ def create( def get(self, order_id: str) -> Order: self._require_auth() - data = self._get(f"/portfolio/orders/{order_id}") + data = self._get(f"/portfolio/orders/{_seg(order_id, name='order_id')}") order_data = data.get("order", data) return Order.model_validate(order_data) @@ -394,7 +400,7 @@ def cancel( ) -> None: self._require_auth() params = _params(subaccount=subaccount, exchange_index=exchange_index) - self._delete(f"/portfolio/orders/{order_id}", params=params) + self._delete(f"/portfolio/orders/{_seg(order_id, name='order_id')}", params=params) def list( self, @@ -445,41 +451,50 @@ def batch_create( self, *, request: BatchCreateOrdersRequest, - ) -> builtins.list[Order]: ... + ) -> BatchCreateOrdersResponse: ... @overload def batch_create( self, orders: Sequence[CreateOrderRequest], - ) -> builtins.list[Order]: ... + ) -> BatchCreateOrdersResponse: ... def batch_create( self, orders: Sequence[CreateOrderRequest] | None = None, *, request: BatchCreateOrdersRequest | None = None, - ) -> builtins.list[Order]: + ) -> BatchCreateOrdersResponse: + """Place a batch of orders. + + BREAKING in v3.0.0: previously returned ``list[Order]`` and would + crash with ``ValidationError`` on any partially-failed batch + (the spec marks each entry's ``order`` and ``error`` as nullable; + the old ``Order.model_validate(o.get("order", o))`` blew up the + instant the server returned ``{"order": null, "error": {...}}``). + Now returns :class:`BatchCreateOrdersResponse` so callers can + pair the per-leg ``client_order_id`` with ``order``/``error``. + """ self._require_auth() body = _build_batch_create_body(request, orders) data = self._post("/portfolio/orders/batched", json=body) - raw_orders = data.get("orders", []) - return [Order.model_validate(o.get("order", o)) for o in raw_orders] + return BatchCreateOrdersResponse.model_validate(data) @overload def batch_cancel( self, *, request: BatchCancelOrdersRequest, - ) -> None: ... + ) -> BatchCancelOrdersResponse: ... @overload def batch_cancel( self, orders: Sequence[BatchCancelOrdersRequestOrder | str], - ) -> None: ... + ) -> BatchCancelOrdersResponse: ... def batch_cancel( self, orders: Sequence[BatchCancelOrdersRequestOrder | str] | None = None, *, request: BatchCancelOrdersRequest | None = None, - ) -> None: + ) -> BatchCancelOrdersResponse: """Batch-cancel orders. Accepts a sequence of either ``BatchCancelOrdersRequestOrder`` @@ -488,16 +503,22 @@ def batch_cancel( mix of both. String entries are wrapped as ``BatchCancelOrdersRequestOrder(order_id=)`` before serialization. - BREAKING in v0.8.0: previously the method signature was - ``batch_cancel(order_ids: list[str])`` and the wire body used the - spec-deprecated ``ids`` field. v0.8.0 emits the spec-preferred - ``orders`` field and renames the kwarg. Callers passing a plain - list of order-id strings still work without code changes via the - convenience shortcut. + BREAKING in v3.0.0: previously returned ``None`` and discarded + the server's per-leg response. Now returns + :class:`BatchCancelOrdersResponse` so callers can read the + load-bearing ``reduced_by_fp`` per entry (cents canceled) plus + any per-leg ``error`` blocks. A server that returns 204 + No Content raises :class:`KalshiError` — every modern Kalshi + environment returns 200 with the typed body. """ self._require_auth() body = _build_batch_cancel_body(request, orders) - self._delete_with_body("/portfolio/orders/batched", json=body) + data = self._delete_with_body("/portfolio/orders/batched", json=body) + if data is None: + raise KalshiError( + "Expected BatchCancelOrdersResponse body, got 204 No Content." + ) + return BatchCancelOrdersResponse.model_validate(data) def fills( self, @@ -587,7 +608,7 @@ def amend( subaccount=subaccount, exchange_index=exchange_index, ) - data = self._post(f"/portfolio/orders/{order_id}/amend", json=body) + data = self._post(f"/portfolio/orders/{_seg(order_id, name='order_id')}/amend", json=body) return AmendOrderResponse.model_validate(data) @overload @@ -619,7 +640,7 @@ def decrease( request, reduce_by=reduce_by, reduce_to=reduce_to, subaccount=subaccount, exchange_index=exchange_index, ) - data = self._post(f"/portfolio/orders/{order_id}/decrease", json=body) + data = self._post(f"/portfolio/orders/{_seg(order_id, name='order_id')}/decrease", json=body) # noqa: E501 order_data = data.get("order", data) return Order.model_validate(order_data) @@ -642,7 +663,7 @@ def queue_positions( def queue_position(self, order_id: str) -> Decimal: self._require_auth() - data = self._get(f"/portfolio/orders/{order_id}/queue_position") + data = self._get(f"/portfolio/orders/{_seg(order_id, name='order_id')}/queue_position") return _parse_queue_position(data) # ------------------------------------------------------------------ @@ -666,7 +687,7 @@ def cancel_v2( self._require_auth() params = _params(subaccount=subaccount, exchange_index=exchange_index) data = self._delete( - f"/portfolio/events/orders/{order_id}", params=params, + f"/portfolio/events/orders/{_seg(order_id, name='order_id')}", params=params, ) if data is None: raise KalshiError( @@ -692,7 +713,7 @@ def amend_v2( params = _params(subaccount=subaccount) body = request.model_dump(exclude_none=True, by_alias=True, mode="json") data = self._post( - f"/portfolio/events/orders/{order_id}/amend", + f"/portfolio/events/orders/{_seg(order_id, name='order_id')}/amend", params=params, json=body, ) return AmendOrderV2Response.model_validate(data) @@ -716,7 +737,7 @@ def decrease_v2( params = _params(subaccount=subaccount) body = request.model_dump(exclude_none=True, by_alias=True, mode="json") data = self._post( - f"/portfolio/events/orders/{order_id}/decrease", + f"/portfolio/events/orders/{_seg(order_id, name='order_id')}/decrease", params=params, json=body, ) return DecreaseOrderV2Response.model_validate(data) @@ -828,7 +849,7 @@ async def create( async def get(self, order_id: str) -> Order: self._require_auth() - data = await self._get(f"/portfolio/orders/{order_id}") + data = await self._get(f"/portfolio/orders/{_seg(order_id, name='order_id')}") order_data = data.get("order", data) return Order.model_validate(order_data) @@ -841,7 +862,7 @@ async def cancel( ) -> None: self._require_auth() params = _params(subaccount=subaccount, exchange_index=exchange_index) - await self._delete(f"/portfolio/orders/{order_id}", params=params) + await self._delete(f"/portfolio/orders/{_seg(order_id, name='order_id')}", params=params) async def list( self, @@ -893,59 +914,50 @@ async def batch_create( self, *, request: BatchCreateOrdersRequest, - ) -> builtins.list[Order]: ... + ) -> BatchCreateOrdersResponse: ... @overload async def batch_create( self, orders: Sequence[CreateOrderRequest], - ) -> builtins.list[Order]: ... + ) -> BatchCreateOrdersResponse: ... async def batch_create( self, orders: Sequence[CreateOrderRequest] | None = None, *, request: BatchCreateOrdersRequest | None = None, - ) -> builtins.list[Order]: + ) -> BatchCreateOrdersResponse: + """Place a batch of orders. See :meth:`OrdersResource.batch_create`.""" self._require_auth() body = _build_batch_create_body(request, orders) data = await self._post("/portfolio/orders/batched", json=body) - raw_orders = data.get("orders", []) - return [Order.model_validate(o.get("order", o)) for o in raw_orders] + return BatchCreateOrdersResponse.model_validate(data) @overload async def batch_cancel( self, *, request: BatchCancelOrdersRequest, - ) -> None: ... + ) -> BatchCancelOrdersResponse: ... @overload async def batch_cancel( self, orders: Sequence[BatchCancelOrdersRequestOrder | str], - ) -> None: ... + ) -> BatchCancelOrdersResponse: ... async def batch_cancel( self, orders: Sequence[BatchCancelOrdersRequestOrder | str] | None = None, *, request: BatchCancelOrdersRequest | None = None, - ) -> None: - """Batch-cancel orders. - - Accepts a sequence of either ``BatchCancelOrdersRequestOrder`` - entries (for per-order ``subaccount`` routing), plain order-id - strings (convenience shortcut — each is wrapped internally), or a - mix of both. String entries are wrapped as - ``BatchCancelOrdersRequestOrder(order_id=)`` before serialization. - - BREAKING in v0.8.0: previously the method signature was - ``batch_cancel(order_ids: list[str])`` and the wire body used the - spec-deprecated ``ids`` field. v0.8.0 emits the spec-preferred - ``orders`` field and renames the kwarg. Callers passing a plain - list of order-id strings still work without code changes via the - convenience shortcut. - """ + ) -> BatchCancelOrdersResponse: + """Batch-cancel orders. See :meth:`OrdersResource.batch_cancel`.""" self._require_auth() body = _build_batch_cancel_body(request, orders) - await self._delete_with_body("/portfolio/orders/batched", json=body) + data = await self._delete_with_body("/portfolio/orders/batched", json=body) + if data is None: + raise KalshiError( + "Expected BatchCancelOrdersResponse body, got 204 No Content." + ) + return BatchCancelOrdersResponse.model_validate(data) async def fills( self, @@ -1035,7 +1047,7 @@ async def amend( subaccount=subaccount, exchange_index=exchange_index, ) - data = await self._post(f"/portfolio/orders/{order_id}/amend", json=body) + data = await self._post(f"/portfolio/orders/{_seg(order_id, name='order_id')}/amend", json=body) # noqa: E501 return AmendOrderResponse.model_validate(data) @overload @@ -1067,7 +1079,7 @@ async def decrease( request, reduce_by=reduce_by, reduce_to=reduce_to, subaccount=subaccount, exchange_index=exchange_index, ) - data = await self._post(f"/portfolio/orders/{order_id}/decrease", json=body) + data = await self._post(f"/portfolio/orders/{_seg(order_id, name='order_id')}/decrease", json=body) # noqa: E501 order_data = data.get("order", data) return Order.model_validate(order_data) @@ -1090,7 +1102,7 @@ async def queue_positions( async def queue_position(self, order_id: str) -> Decimal: self._require_auth() - data = await self._get(f"/portfolio/orders/{order_id}/queue_position") + data = await self._get(f"/portfolio/orders/{_seg(order_id, name='order_id')}/queue_position") # noqa: E501 return _parse_queue_position(data) # V2 event-market orders (spec v3.18.0). See OrdersResource counterparts. @@ -1113,7 +1125,7 @@ async def cancel_v2( self._require_auth() params = _params(subaccount=subaccount, exchange_index=exchange_index) data = await self._delete( - f"/portfolio/events/orders/{order_id}", params=params, + f"/portfolio/events/orders/{_seg(order_id, name='order_id')}", params=params, ) if data is None: raise KalshiError( @@ -1139,7 +1151,7 @@ async def amend_v2( params = _params(subaccount=subaccount) body = request.model_dump(exclude_none=True, by_alias=True, mode="json") data = await self._post( - f"/portfolio/events/orders/{order_id}/amend", + f"/portfolio/events/orders/{_seg(order_id, name='order_id')}/amend", params=params, json=body, ) return AmendOrderV2Response.model_validate(data) @@ -1163,7 +1175,7 @@ async def decrease_v2( params = _params(subaccount=subaccount) body = request.model_dump(exclude_none=True, by_alias=True, mode="json") data = await self._post( - f"/portfolio/events/orders/{order_id}/decrease", + f"/portfolio/events/orders/{_seg(order_id, name='order_id')}/decrease", params=params, json=body, ) return DecreaseOrderV2Response.model_validate(data) diff --git a/kalshi/resources/portfolio.py b/kalshi/resources/portfolio.py index 279cba4e..0ca78220 100644 --- a/kalshi/resources/portfolio.py +++ b/kalshi/resources/portfolio.py @@ -18,6 +18,7 @@ AsyncResource, SyncResource, _params, + _validate_limit, _validate_max_pages, ) @@ -33,6 +34,7 @@ def _positions_params( event_ticker: str | None, subaccount: int | None, ) -> dict[str, Any]: + limit = _validate_limit(limit, hi=1000) return _params( limit=limit, cursor=cursor, @@ -53,6 +55,7 @@ def _settlements_params( max_ts: int | None, subaccount: int | None, ) -> dict[str, Any]: + limit = _validate_limit(limit, hi=1000) return _params( limit=limit, cursor=cursor, @@ -150,6 +153,7 @@ def deposits( cursor: str | None = None, ) -> Page[Deposit]: self._require_auth() + _validate_limit(limit, hi=500) params = _params(limit=limit, cursor=cursor) return self._list( "/portfolio/deposits", Deposit, "deposits", params=params, @@ -163,6 +167,7 @@ def deposits_all( ) -> Iterator[Deposit]: self._require_auth() _validate_max_pages(max_pages) + _validate_limit(limit, hi=500) params = _params(limit=limit) return self._list_all( "/portfolio/deposits", Deposit, "deposits", @@ -176,6 +181,7 @@ def withdrawals( cursor: str | None = None, ) -> Page[Withdrawal]: self._require_auth() + _validate_limit(limit, hi=500) params = _params(limit=limit, cursor=cursor) return self._list( "/portfolio/withdrawals", Withdrawal, "withdrawals", params=params, @@ -189,6 +195,7 @@ def withdrawals_all( ) -> Iterator[Withdrawal]: self._require_auth() _validate_max_pages(max_pages) + _validate_limit(limit, hi=500) params = _params(limit=limit) return self._list_all( "/portfolio/withdrawals", Withdrawal, "withdrawals", @@ -280,6 +287,7 @@ async def deposits( cursor: str | None = None, ) -> Page[Deposit]: self._require_auth() + _validate_limit(limit, hi=500) params = _params(limit=limit, cursor=cursor) return await self._list( "/portfolio/deposits", Deposit, "deposits", params=params, @@ -294,6 +302,7 @@ def deposits_all( """Returns an async iterator — use ``async for``.""" self._require_auth() _validate_max_pages(max_pages) + _validate_limit(limit, hi=500) params = _params(limit=limit) return self._list_all( "/portfolio/deposits", Deposit, "deposits", @@ -307,6 +316,7 @@ async def withdrawals( cursor: str | None = None, ) -> Page[Withdrawal]: self._require_auth() + _validate_limit(limit, hi=500) params = _params(limit=limit, cursor=cursor) return await self._list( "/portfolio/withdrawals", Withdrawal, "withdrawals", params=params, @@ -321,6 +331,7 @@ def withdrawals_all( """Returns an async iterator — use ``async for``.""" self._require_auth() _validate_max_pages(max_pages) + _validate_limit(limit, hi=500) params = _params(limit=limit) return self._list_all( "/portfolio/withdrawals", Withdrawal, "withdrawals", diff --git a/kalshi/resources/series.py b/kalshi/resources/series.py index 98c0c312..0f97c1b4 100644 --- a/kalshi/resources/series.py +++ b/kalshi/resources/series.py @@ -11,7 +11,7 @@ Series, SeriesFeeChange, ) -from kalshi.resources._base import AsyncResource, SyncResource, _bool_param, _params +from kalshi.resources._base import AsyncResource, SyncResource, _bool_param, _params, _seg # Shared param builders (issue #46). @@ -100,7 +100,7 @@ def get( params = _params( include_volume=_bool_param(include_volume), ) - data = self._get(f"/series/{series_ticker}", params=params) + data = self._get(f"/series/{_seg(series_ticker, name='series_ticker')}", params=params) return Series.model_validate(data.get("series", data)) def fee_changes( @@ -130,7 +130,7 @@ def event_candlesticks( period_interval=period_interval, ) data = self._get( - f"/series/{series_ticker}/events/{ticker}/candlesticks", + f"/series/{_seg(series_ticker, name='series_ticker')}/events/{_seg(ticker, name='ticker')}/candlesticks", # noqa: E501 params=params, ) return EventCandlesticks.model_validate(data) @@ -152,7 +152,7 @@ def forecast_percentile_history( period_interval=period_interval, ) data = self._get( - f"/series/{series_ticker}/events/{ticker}/forecast_percentile_history", + f"/series/{_seg(series_ticker, name='series_ticker')}/events/{_seg(ticker, name='ticker')}/forecast_percentile_history", # noqa: E501 params=params, ) raw = data.get("forecast_history", []) @@ -190,7 +190,7 @@ async def get( params = _params( include_volume=_bool_param(include_volume), ) - data = await self._get(f"/series/{series_ticker}", params=params) + data = await self._get(f"/series/{_seg(series_ticker, name='series_ticker')}", params=params) # noqa: E501 return Series.model_validate(data.get("series", data)) async def fee_changes( @@ -220,7 +220,7 @@ async def event_candlesticks( period_interval=period_interval, ) data = await self._get( - f"/series/{series_ticker}/events/{ticker}/candlesticks", + f"/series/{_seg(series_ticker, name='series_ticker')}/events/{_seg(ticker, name='ticker')}/candlesticks", # noqa: E501 params=params, ) return EventCandlesticks.model_validate(data) @@ -242,7 +242,7 @@ async def forecast_percentile_history( period_interval=period_interval, ) data = await self._get( - f"/series/{series_ticker}/events/{ticker}/forecast_percentile_history", + f"/series/{_seg(series_ticker, name='series_ticker')}/events/{_seg(ticker, name='ticker')}/forecast_percentile_history", # noqa: E501 params=params, ) raw = data.get("forecast_history", []) diff --git a/kalshi/resources/structured_targets.py b/kalshi/resources/structured_targets.py index a0ce2af4..4bc5eaa5 100644 --- a/kalshi/resources/structured_targets.py +++ b/kalshi/resources/structured_targets.py @@ -14,6 +14,8 @@ AsyncResource, SyncResource, _params, + _seg, + _validate_limit, _validate_max_pages, ) @@ -36,6 +38,7 @@ def list( page_size: int | None = None, cursor: str | None = None, ) -> Page[StructuredTarget]: + _validate_limit(page_size, hi=2000, name="page_size") params = _params( ids=ids, type=target_type, @@ -60,6 +63,7 @@ def list_all( max_pages: int | None = None, ) -> Iterator[StructuredTarget]: _validate_max_pages(max_pages) + _validate_limit(page_size, hi=2000, name="page_size") params = _params( ids=ids, type=target_type, @@ -75,7 +79,7 @@ def list_all( ) def get(self, structured_target_id: str) -> StructuredTarget | None: - data = self._get(f"/structured_targets/{structured_target_id}") + data = self._get(f"/structured_targets/{_seg(structured_target_id, name='structured_target_id')}") # noqa: E501 return GetStructuredTargetResponse.model_validate(data).structured_target @@ -91,6 +95,7 @@ async def list( page_size: int | None = None, cursor: str | None = None, ) -> Page[StructuredTarget]: + _validate_limit(page_size, hi=2000, name="page_size") params = _params( ids=ids, type=target_type, @@ -116,6 +121,7 @@ def list_all( ) -> AsyncIterator[StructuredTarget]: """Returns an async iterator — use ``async for``.""" _validate_max_pages(max_pages) + _validate_limit(page_size, hi=2000, name="page_size") params = _params( ids=ids, type=target_type, @@ -131,5 +137,5 @@ def list_all( ) async def get(self, structured_target_id: str) -> StructuredTarget | None: - data = await self._get(f"/structured_targets/{structured_target_id}") + data = await self._get(f"/structured_targets/{_seg(structured_target_id, name='structured_target_id')}") # noqa: E501 return GetStructuredTargetResponse.model_validate(data).structured_target diff --git a/kalshi/resources/subaccounts.py b/kalshi/resources/subaccounts.py index 1ebfb839..97311dd7 100644 --- a/kalshi/resources/subaccounts.py +++ b/kalshi/resources/subaccounts.py @@ -20,6 +20,7 @@ SyncResource, _check_request_exclusive, _params, + _validate_limit, _validate_max_pages, ) @@ -145,6 +146,7 @@ def list_transfers( self, *, cursor: str | None = None, limit: int | None = None, ) -> Page[SubaccountTransfer]: self._require_auth() + _validate_limit(limit, hi=1000) params = _params(cursor=cursor, limit=limit) return self._list( "/portfolio/subaccounts/transfers", @@ -161,6 +163,7 @@ def list_all_transfers( ) -> Iterator[SubaccountTransfer]: self._require_auth() _validate_max_pages(max_pages) + _validate_limit(limit, hi=1000) params = _params(limit=limit) return self._list_all( "/portfolio/subaccounts/transfers", @@ -248,6 +251,7 @@ async def list_transfers( self, *, cursor: str | None = None, limit: int | None = None, ) -> Page[SubaccountTransfer]: self._require_auth() + _validate_limit(limit, hi=1000) params = _params(cursor=cursor, limit=limit) return await self._list( "/portfolio/subaccounts/transfers", @@ -266,6 +270,7 @@ def list_all_transfers( # run at call time, not when the returned AsyncIterator is awaited. self._require_auth() _validate_max_pages(max_pages) + _validate_limit(limit, hi=1000) params = _params(limit=limit) return self._list_all( "/portfolio/subaccounts/transfers", diff --git a/tests/test_async_orders.py b/tests/test_async_orders.py index c322f7ca..4f851092 100644 --- a/tests/test_async_orders.py +++ b/tests/test_async_orders.py @@ -469,8 +469,16 @@ async def test_batch_create(self, orders: AsyncOrdersResource) -> None: 200, json={ "orders": [ - order_dict(order_id="b1", ticker="A"), - order_dict(order_id="b2", ticker="B"), + { + "order": order_dict(order_id="b1", ticker="A"), + "error": None, + "client_order_id": "c1", + }, + { + "order": order_dict(order_id="b2", ticker="B"), + "error": None, + "client_order_id": "c2", + }, ] }, ) @@ -479,22 +487,97 @@ async def test_batch_create(self, orders: AsyncOrdersResource) -> None: CreateOrderRequest(ticker="A", side="yes", action="buy"), CreateOrderRequest(ticker="B", side="no", action="buy"), ] + from kalshi.models.orders import BatchCreateOrdersResponse + result = await orders.batch_create(reqs) - assert len(result) == 2 - assert result[0].order_id == "b1" + assert isinstance(result, BatchCreateOrdersResponse) + assert len(result.orders) == 2 + assert result.orders[0].order is not None + assert result.orders[0].order.order_id == "b1" + assert result.orders[0].client_order_id == "c1" # Verify serialization uses _dollars alias body = json.loads(route.calls[0].request.content) for order_body in body["orders"]: assert "yes_price" not in order_body + @respx.mock + @pytest.mark.asyncio + async def test_batch_create_partial_failure_does_not_crash( + self, orders: AsyncOrdersResource + ) -> None: + """Async mirror of #194 partial-failure regression.""" + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( + return_value=httpx.Response( + 200, + json={ + "orders": [ + { + "order": order_dict(order_id="ok", ticker="A"), + "error": None, + "client_order_id": "good", + }, + { + "order": None, + "error": {"code": "bad_price", "message": "rejected"}, + "client_order_id": "bad", + }, + ] + }, + ) + ) + result = await orders.batch_create( + [CreateOrderRequest(ticker="A", side="yes", action="buy")] + ) + assert result.orders[0].order is not None + assert result.orders[1].order is None + assert result.orders[1].error is not None + assert result.orders[1].error["code"] == "bad_price" + @respx.mock @pytest.mark.asyncio async def test_batch_cancel(self, orders: AsyncOrdersResource) -> None: respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( - return_value=httpx.Response(200, json={}) + return_value=httpx.Response( + 200, + json={ + "orders": [ + { + "order_id": "o1", + "reduced_by_fp": "2", + "order": None, + "error": None, + }, + { + "order_id": "o2", + "reduced_by_fp": "0", + "order": None, + "error": {"code": "not_found", "message": "gone"}, + }, + ] + }, + ) + ) + from kalshi.models.orders import BatchCancelOrdersResponse + + result = await orders.batch_cancel(["o1", "o2"]) + assert isinstance(result, BatchCancelOrdersResponse) + assert result.orders[0].reduced_by_fp == Decimal("2") + assert result.orders[1].error is not None + assert result.orders[1].error["code"] == "not_found" + + @respx.mock + @pytest.mark.asyncio + async def test_batch_cancel_raises_on_204( + self, orders: AsyncOrdersResource + ) -> None: + from kalshi.errors import KalshiError + + respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( + return_value=httpx.Response(204) ) - await orders.batch_cancel(["o1", "o2"]) # should not raise + with pytest.raises(KalshiError, match="Expected BatchCancelOrdersResponse"): + await orders.batch_cancel(["ord-1"]) class TestAsyncOrdersFills: @@ -846,7 +929,7 @@ class TestBatchCancelWireShapeAsync: @pytest.mark.asyncio async def test_wraps_str_ids_into_orders(self, orders: AsyncOrdersResource) -> None: route = respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( - return_value=httpx.Response(200, json={}) + return_value=httpx.Response(200, json={"orders": []}) ) await orders.batch_cancel(["ord-1", "ord-2"]) @@ -865,7 +948,7 @@ async def test_accepts_typed_order_entries(self, orders: AsyncOrdersResource) -> from kalshi.models.orders import BatchCancelOrdersRequestOrder route = respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( - return_value=httpx.Response(200, json={}) + return_value=httpx.Response(200, json={"orders": []}) ) await orders.batch_cancel( @@ -895,7 +978,7 @@ async def test_batch_cancel_uses_delete_with_body_helper( self, orders: AsyncOrdersResource ) -> None: respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( - return_value=httpx.Response(200, json={}) + return_value=httpx.Response(200, json={"orders": []}) ) # AsyncMock + wraps forwards every call to the real async method, @@ -913,18 +996,6 @@ async def test_batch_cancel_uses_delete_with_body_helper( json={"orders": [{"order_id": "ord-1"}, {"order_id": "ord-2"}]}, ) - @respx.mock - @pytest.mark.asyncio - async def test_batch_cancel_handles_204_no_content(self, orders: AsyncOrdersResource) -> None: - """Helper returns None on 204 — verifies it goes through the - shared response-handling path, not a raw ``transport.request`` call. - """ - respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( - return_value=httpx.Response(204) - ) - - await orders.batch_cancel(["ord-1"]) # must not raise on empty body - class TestAmendWireShapeAsync: """v0.8.0: async orders.amend() builds AmendOrderRequest internally and diff --git a/tests/test_orders.py b/tests/test_orders.py index fdc5147e..2a0d5a1f 100644 --- a/tests/test_orders.py +++ b/tests/test_orders.py @@ -420,8 +420,16 @@ def test_batch_create(self, orders: OrdersResource) -> None: 200, json={ "orders": [ - order_dict(order_id="b1", ticker="A"), - order_dict(order_id="b2", ticker="B"), + { + "order": order_dict(order_id="b1", ticker="A"), + "error": None, + "client_order_id": "c1", + }, + { + "order": order_dict(order_id="b2", ticker="B"), + "error": None, + "client_order_id": "c2", + }, ] }, ) @@ -430,9 +438,53 @@ def test_batch_create(self, orders: OrdersResource) -> None: CreateOrderRequest(ticker="A", side="yes", action="buy"), CreateOrderRequest(ticker="B", side="no", action="buy"), ] + from kalshi.models.orders import BatchCreateOrdersResponse + result = orders.batch_create(reqs) - assert len(result) == 2 - assert result[0].order_id == "b1" + assert isinstance(result, BatchCreateOrdersResponse) + assert len(result.orders) == 2 + assert result.orders[0].order is not None + assert result.orders[0].order.order_id == "b1" + assert result.orders[0].client_order_id == "c1" + + @respx.mock + 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 + whole result; the typed envelope must preserve the per-leg shape. + """ + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( + return_value=httpx.Response( + 200, + json={ + "orders": [ + { + "order": order_dict(order_id="ok", ticker="A"), + "error": None, + "client_order_id": "good", + }, + { + "order": None, + "error": {"code": "bad_price", "message": "rejected"}, + "client_order_id": "bad", + }, + ] + }, + ) + ) + result = orders.batch_create( + [CreateOrderRequest(ticker="A", side="yes", action="buy")] + ) + assert len(result.orders) == 2 + assert result.orders[0].order is not None + assert result.orders[0].error is None + assert result.orders[1].order is None + assert result.orders[1].error is not None + assert result.orders[1].error["code"] == "bad_price" + assert result.orders[1].client_order_id == "bad" class TestOrdersFills: @@ -802,7 +854,7 @@ def test_wraps_str_ids_into_orders(self, orders: OrdersResource) -> None: import json route = respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( - return_value=httpx.Response(200, json={}) + return_value=httpx.Response(200, json={"orders": []}) ) orders.batch_cancel(["ord-1", "ord-2"]) @@ -821,7 +873,7 @@ def test_accepts_typed_order_entries(self, orders: OrdersResource) -> None: from kalshi.models.orders import BatchCancelOrdersRequestOrder route = respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( - return_value=httpx.Response(200, json={}) + return_value=httpx.Response(200, json={"orders": []}) ) orders.batch_cancel( @@ -848,7 +900,7 @@ class TestBatchCancelRoutesThroughDeleteWithBody: @respx.mock def test_batch_cancel_uses_delete_with_body_helper(self, orders: OrdersResource) -> None: respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( - return_value=httpx.Response(200, json={}) + return_value=httpx.Response(200, json={"orders": []}) ) # patch.object + wraps forwards every call to the real method, @@ -865,28 +917,71 @@ def test_batch_cancel_uses_delete_with_body_helper(self, orders: OrdersResource) ) @respx.mock - def test_batch_cancel_parses_200_with_json_body(self, orders: OrdersResource) -> None: - """New base-class helper reads the response body on non-204; the - old local helper discarded it. Confirms the sync path parses JSON - without raising when the server returns 200 with content. + def test_batch_cancel_returns_typed_response_with_reduced_by_fp( + self, orders: OrdersResource + ) -> None: + """#194: response carries per-leg ``reduced_by_fp`` (Decimal, + load-bearing for risk reconciliation). Previous SDK discarded it. """ respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( - return_value=httpx.Response(200, json={"cancelled": 2}) + return_value=httpx.Response( + 200, + json={ + "orders": [ + { + "order_id": "ord-1", + "reduced_by_fp": "3", + "order": None, + "error": None, + }, + ] + }, + ) ) + from kalshi.models.orders import BatchCancelOrdersResponse - orders.batch_cancel(["ord-1", "ord-2"]) # must not raise on JSON body + result = orders.batch_cancel(["ord-1"]) + assert isinstance(result, BatchCancelOrdersResponse) + assert len(result.orders) == 1 + assert result.orders[0].order_id == "ord-1" + assert result.orders[0].reduced_by_fp == Decimal("3") + assert result.orders[0].error is None @respx.mock - def test_batch_cancel_handles_204_no_content(self, orders: OrdersResource) -> None: - """Helper returns None on 204 — verifies sync goes through the - shared response-handling path, not a raw ``transport.request`` call. - Mirrors the async sibling. + 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. """ respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( - return_value=httpx.Response(204) + return_value=httpx.Response( + 200, + json={ + "orders": [ + { + "order_id": "ord-bad", + "reduced_by_fp": "0", + "order": None, + "error": {"code": "not_found", "message": "gone"}, + }, + ] + }, + ) ) + result = orders.batch_cancel(["ord-bad"]) + assert result.orders[0].reduced_by_fp == Decimal("0") + assert result.orders[0].error is not None + assert result.orders[0].error["code"] == "not_found" - orders.batch_cancel(["ord-1"]) # must not raise on empty body + @respx.mock + def test_batch_cancel_raises_on_204_no_content(self, orders: OrdersResource) -> None: + """v3.0.0: typed response required — 204 indicates spec drift.""" + respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( + return_value=httpx.Response(204) + ) + with pytest.raises(KalshiError, match="Expected BatchCancelOrdersResponse"): + orders.batch_cancel(["ord-1"]) @respx.mock def test_batch_cancel_200_with_empty_body_raises(self, orders: OrdersResource) -> None: @@ -1581,3 +1676,95 @@ def test_batch_cancel_v2(self, unauth_orders: OrdersResource) -> None: orders=[BatchCancelOrdersV2RequestOrder(order_id="ord-a")], ), ) + + +class TestPathSegmentEncoding: + """#211: caller-supplied path segments must be URL-encoded so a + ticker/order_id containing ``/``, whitespace, or ``..`` cannot + misroute (silently 404 or hit a different handler). + """ + + @respx.mock + def test_order_id_with_slash_is_encoded_not_routing_attack( + self, orders: OrdersResource + ) -> 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" + ) + route = respx.get(encoded_path).mock( + return_value=httpx.Response( + 200, json={"order": order_dict(order_id="o", ticker="M")}, + ) + ) + orders.get("/../admin") + assert route.called + + @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" + ) + route = respx.get(encoded_path).mock( + return_value=httpx.Response( + 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: + with pytest.raises(ValueError, match="order_id must be non-empty"): + orders.get("") + + def test_whitespace_only_order_id_rejected( + self, orders: OrdersResource + ) -> None: + with pytest.raises(ValueError, match="order_id must be non-empty"): + orders.cancel(" ") + + def test_dotdot_order_id_rejected(self, orders: OrdersResource) -> None: + with pytest.raises(ValueError, match=r"cannot be '\.' or '\.\.'"): + orders.get("..") + + def test_single_dot_order_id_rejected(self, orders: OrdersResource) -> None: + with pytest.raises(ValueError, match=r"cannot be '\.' or '\.\.'"): + orders.get(".") + + +class TestLimitValidation: + """#214: client-side spec-bound enforcement avoids a wasted round + trip and produces a more actionable error than a server 400. + """ + + 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: + 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: + 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: + with pytest.raises(ValueError, match=r"limit must be in \[1, 1000\]"): + orders.fills(limit=10_000) From 3c4078d7249861f09d4570085df2060b77ce45d1 Mon Sep 17 00:00:00 2001 From: audit-w1b Date: Wed, 20 May 2026 21:50:28 -0500 Subject: [PATCH 2/2] test(contracts): register V1 batch response models in CONTRACT_MAP Hard-fail test_contract_map_completeness requires every Pydantic class in kalshi.models.* to have a ContractEntry. The four new V1 batch response models (added per #194) were missing entries. --- kalshi/_contract_map.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index e09831c5..ef2946cf 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -362,6 +362,24 @@ class ContractEntry: spec_schema="BatchCancelOrdersV2Response.orders.items", notes="Inline object schema (no named component)", ), + ContractEntry( + sdk_model="kalshi.models.orders.BatchCreateOrdersResponse", + spec_schema="BatchCreateOrdersResponse", + ), + ContractEntry( + sdk_model="kalshi.models.orders.BatchCreateOrdersResponseEntry", + spec_schema="BatchCreateOrdersResponse.orders.items", + notes="Inline object schema (no named component)", + ), + ContractEntry( + sdk_model="kalshi.models.orders.BatchCancelOrdersResponse", + spec_schema="BatchCancelOrdersResponse", + ), + ContractEntry( + sdk_model="kalshi.models.orders.BatchCancelOrdersResponseEntry", + spec_schema="BatchCancelOrdersResponse.orders.items", + notes="Inline object schema (no named component)", + ), # -- events sub-models (#171) ---------------------------------------- ContractEntry( sdk_model="kalshi.models.events.MarketMetadata",