diff --git a/kalshi/__init__.py b/kalshi/__init__.py index 3822cea..8415a09 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -141,7 +141,9 @@ PositionsResponse, PriceDistribution, Quote, + QuoteStatusLiteral, RateLimit, + RfqStatusLiteral, Schedule, ScopeList, SelfTradePreventionTypeLiteral, @@ -309,7 +311,9 @@ "PositionsResponse", "PriceDistribution", "Quote", + "QuoteStatusLiteral", "RateLimit", + "RfqStatusLiteral", "Schedule", "ScopeList", "SelfTradePreventionTypeLiteral", diff --git a/kalshi/models/__init__.py b/kalshi/models/__init__.py index a92e616..1ca3636 100644 --- a/kalshi/models/__init__.py +++ b/kalshi/models/__init__.py @@ -29,6 +29,8 @@ GetRFQsResponse, MveSelectedLeg, Quote, + QuoteStatusLiteral, + RfqStatusLiteral, UserFilterLiteral, ) from kalshi.models.events import ( @@ -293,7 +295,9 @@ "PositionsResponse", "PriceDistribution", "Quote", + "QuoteStatusLiteral", "RateLimit", + "RfqStatusLiteral", "Schedule", "ScopeList", "SelfTradePreventionTypeLiteral", diff --git a/kalshi/models/communications.py b/kalshi/models/communications.py index 80864d3..06bb0e1 100644 --- a/kalshi/models/communications.py +++ b/kalshi/models/communications.py @@ -11,6 +11,12 @@ UserFilterLiteral = Literal["self"] """Filter for items created by the authenticated user. Spec: UserFilter enum.""" +RfqStatusLiteral = Literal["open", "closed"] +"""RFQ status filter for GET /communications/rfqs. Spec: RFQ.status enum.""" + +QuoteStatusLiteral = Literal["open", "accepted", "confirmed", "executed", "cancelled"] +"""Quote status filter for GET /communications/quotes. Spec: Quote.status enum.""" + class MveSelectedLeg(BaseModel): """A selected leg within a multivariate event collection RFQ.""" diff --git a/kalshi/resources/communications.py b/kalshi/resources/communications.py index 41f4766..95f1f98 100644 --- a/kalshi/resources/communications.py +++ b/kalshi/resources/communications.py @@ -18,6 +18,8 @@ GetQuoteResponse, GetRFQResponse, Quote, + QuoteStatusLiteral, + RfqStatusLiteral, UserFilterLiteral, ) from kalshi.resources._base import ( @@ -68,7 +70,7 @@ def _list_rfqs_params( event_ticker: str | None, market_ticker: str | None, subaccount: int | None, - status: str | None, + status: RfqStatusLiteral | None, creator_user_id: str | None, user_filter: UserFilterLiteral | None, ) -> dict[str, Any]: @@ -91,7 +93,7 @@ def _list_quotes_params( limit: int | None, event_ticker: str | None, market_ticker: str | None, - status: str | None, + status: QuoteStatusLiteral | None, quote_creator_user_id: str | None, rfq_creator_user_id: str | None, rfq_creator_subtrader_id: str | None, @@ -218,7 +220,7 @@ def list_rfqs( event_ticker: str | None = None, market_ticker: str | None = None, subaccount: int | None = None, - status: str | None = None, + status: RfqStatusLiteral | None = None, creator_user_id: str | None = None, user_filter: UserFilterLiteral | None = None, extra_headers: dict[str, str] | None = None, @@ -245,7 +247,7 @@ def list_all_rfqs( event_ticker: str | None = None, market_ticker: str | None = None, subaccount: int | None = None, - status: str | None = None, + status: RfqStatusLiteral | None = None, creator_user_id: str | None = None, user_filter: UserFilterLiteral | None = None, max_pages: int | None = None, @@ -338,7 +340,7 @@ def list_quotes( limit: int | None = None, event_ticker: str | None = None, market_ticker: str | None = None, - status: str | None = None, + status: QuoteStatusLiteral | None = None, quote_creator_user_id: str | None = None, rfq_creator_user_id: str | None = None, rfq_creator_subtrader_id: str | None = None, @@ -377,7 +379,7 @@ def list_all_quotes( limit: int | None = None, event_ticker: str | None = None, market_ticker: str | None = None, - status: str | None = None, + status: QuoteStatusLiteral | None = None, quote_creator_user_id: str | None = None, rfq_creator_user_id: str | None = None, rfq_creator_subtrader_id: str | None = None, @@ -533,7 +535,7 @@ async def list_rfqs( event_ticker: str | None = None, market_ticker: str | None = None, subaccount: int | None = None, - status: str | None = None, + status: RfqStatusLiteral | None = None, creator_user_id: str | None = None, user_filter: UserFilterLiteral | None = None, extra_headers: dict[str, str] | None = None, @@ -560,7 +562,7 @@ def list_all_rfqs( event_ticker: str | None = None, market_ticker: str | None = None, subaccount: int | None = None, - status: str | None = None, + status: RfqStatusLiteral | None = None, creator_user_id: str | None = None, user_filter: UserFilterLiteral | None = None, max_pages: int | None = None, @@ -655,7 +657,7 @@ async def list_quotes( limit: int | None = None, event_ticker: str | None = None, market_ticker: str | None = None, - status: str | None = None, + status: QuoteStatusLiteral | None = None, quote_creator_user_id: str | None = None, rfq_creator_user_id: str | None = None, rfq_creator_subtrader_id: str | None = None, @@ -694,7 +696,7 @@ def list_all_quotes( limit: int | None = None, event_ticker: str | None = None, market_ticker: str | None = None, - status: str | None = None, + status: QuoteStatusLiteral | None = None, quote_creator_user_id: str | None = None, rfq_creator_user_id: str | None = None, rfq_creator_subtrader_id: str | None = None, diff --git a/kalshi/resources/orders.py b/kalshi/resources/orders.py index 1d6d4a3..3aa37d7 100644 --- a/kalshi/resources/orders.py +++ b/kalshi/resources/orders.py @@ -167,6 +167,7 @@ def _build_batch_cancel_body( return request.model_dump_json(exclude_none=True, by_alias=True).encode() + def _build_amend_body( request: AmendOrderRequest | None, *, @@ -867,9 +868,9 @@ def batch_create_v2( self, *, request: BatchCreateOrdersV2Request, extra_headers: dict[str, str] | None = None ) -> BatchCreateOrdersV2Response: self._require_auth() - body = request.model_dump(exclude_none=True, by_alias=True, mode="json") - data = self._post( - "/portfolio/events/orders/batched", json=body, extra_headers=extra_headers + body = request.model_dump_json(exclude_none=True, by_alias=True).encode() + data = self._post_json( + "/portfolio/events/orders/batched", content=body, extra_headers=extra_headers ) return BatchCreateOrdersV2Response.model_validate(data) @@ -877,9 +878,9 @@ def batch_cancel_v2( self, *, request: BatchCancelOrdersV2Request, extra_headers: dict[str, str] | None = None ) -> BatchCancelOrdersV2Response: self._require_auth() - body = request.model_dump(exclude_none=True, by_alias=True, mode="json") - data = self._delete_with_body( - "/portfolio/events/orders/batched", json=body, extra_headers=extra_headers + body = request.model_dump_json(exclude_none=True, by_alias=True).encode() + data = self._delete_with_body_json( + "/portfolio/events/orders/batched", content=body, extra_headers=extra_headers ) if data is None: raise KalshiError("Expected BatchCancelOrdersV2Response body, got 204 No Content.") @@ -1408,9 +1409,9 @@ async def batch_create_v2( self, *, request: BatchCreateOrdersV2Request, extra_headers: dict[str, str] | None = None ) -> BatchCreateOrdersV2Response: self._require_auth() - body = request.model_dump(exclude_none=True, by_alias=True, mode="json") - data = await self._post( - "/portfolio/events/orders/batched", json=body, extra_headers=extra_headers + body = request.model_dump_json(exclude_none=True, by_alias=True).encode() + data = await self._post_json( + "/portfolio/events/orders/batched", content=body, extra_headers=extra_headers ) return BatchCreateOrdersV2Response.model_validate(data) @@ -1418,9 +1419,9 @@ async def batch_cancel_v2( self, *, request: BatchCancelOrdersV2Request, extra_headers: dict[str, str] | None = None ) -> BatchCancelOrdersV2Response: self._require_auth() - body = request.model_dump(exclude_none=True, by_alias=True, mode="json") - data = await self._delete_with_body( - "/portfolio/events/orders/batched", json=body, extra_headers=extra_headers + body = request.model_dump_json(exclude_none=True, by_alias=True).encode() + data = await self._delete_with_body_json( + "/portfolio/events/orders/batched", content=body, extra_headers=extra_headers ) if data is None: raise KalshiError("Expected BatchCancelOrdersV2Response body, got 204 No Content.") diff --git a/tests/test_async_orders.py b/tests/test_async_orders.py index b629a25..9077eda 100644 --- a/tests/test_async_orders.py +++ b/tests/test_async_orders.py @@ -1559,6 +1559,72 @@ async def test_204_raises(self, orders: AsyncOrdersResource) -> None: ) +class TestIssue329AsyncBatchV2BytesFastPath: + """Async mirror of test_orders' TestIssue329BatchV2BytesFastPath — V2 batch + endpoints must route through the bytes fast-path helpers (#223, #329). + """ + + @pytest.mark.asyncio + async def test_issue_329_v2_batch_create_uses_bytes_fast_path( + self, orders: AsyncOrdersResource, + ) -> None: + request = BatchCreateOrdersV2Request( + orders=[ + CreateOrderV2Request( + ticker="MKT-A", + client_order_id="cli-1", + side="bid", + count=Decimal("10"), + price=Decimal("0.50"), + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + ), + ], + ) + with patch.object( + AsyncOrdersResource, + "_post_json", + new=AsyncMock(return_value={"orders": []}), + ) as post_json, patch.object( + AsyncOrdersResource, "_post", new=AsyncMock(), + ) as post_dict: + await orders.batch_create_v2(request=request) + post_dict.assert_not_called() + assert post_json.call_count == 1 + kwargs = post_json.call_args.kwargs + assert "json" not in kwargs + body = kwargs["content"] + assert isinstance(body, bytes) + assert json.loads(body) == request.model_dump( + exclude_none=True, by_alias=True, mode="json", + ) + + @pytest.mark.asyncio + async def test_issue_329_v2_batch_cancel_uses_bytes_fast_path( + self, orders: AsyncOrdersResource, + ) -> None: + request = BatchCancelOrdersV2Request( + orders=[BatchCancelOrdersV2RequestOrder(order_id="ord-a")], + ) + with patch.object( + AsyncOrdersResource, + "_delete_with_body_json", + new=AsyncMock(return_value={"orders": []}), + ) as del_json, patch.object( + AsyncOrdersResource, "_delete_with_body", new=AsyncMock(), + ) as del_dict: + await orders.batch_cancel_v2(request=request) + del_dict.assert_not_called() + assert del_json.call_count == 1 + kwargs = del_json.call_args.kwargs + assert "json" not in kwargs + body = kwargs["content"] + assert isinstance(body, bytes) + assert json.loads(body) == request.model_dump( + exclude_none=True, by_alias=True, mode="json", + ) + + class TestAsyncAmendDecreaseV2QueryParams: """Regression guard: subaccount must reach the query string (not the body) on both amend_v2 and decrease_v2 — exchange_index stays in the body. diff --git a/tests/test_communications.py b/tests/test_communications.py index f272b12..bcc7dc6 100644 --- a/tests/test_communications.py +++ b/tests/test_communications.py @@ -867,3 +867,80 @@ def test_async_client_exposes_communications( assert isinstance( async_client.communications, AsyncCommunicationsResource, ) + + +class TestIssue324CommunicationsStatusLiteralNarrowing: + """``status`` on list_rfqs / list_quotes is narrowed to a closed Literal + matching the spec's RFQ.status and Quote.status enums (#324). The values + are stable: tightening the type later would be a breaking change, so we + pin them here. Static checking (mypy) is what fences typos at the call + site; we mirror the static contract at runtime via ``typing.get_args``. + """ + + def test_issue_324_communications_status_literal_narrowing(self) -> None: + from typing import get_args + + from kalshi.models.communications import ( + QuoteStatusLiteral, + RfqStatusLiteral, + ) + + # Pin the closed set of values against the OpenAPI spec. Adding a new + # status is a non-breaking expansion; removing one is breaking. + assert set(get_args(RfqStatusLiteral)) == {"open", "closed"} + assert set(get_args(QuoteStatusLiteral)) == { + "open", "accepted", "confirmed", "executed", "cancelled", + } + + def test_issue_324_status_literals_reexported_from_models_and_root(self) -> None: + import kalshi + import kalshi.models + + assert kalshi.RfqStatusLiteral is kalshi.models.RfqStatusLiteral + assert kalshi.QuoteStatusLiteral is kalshi.models.QuoteStatusLiteral + assert "RfqStatusLiteral" in kalshi.__all__ + assert "QuoteStatusLiteral" in kalshi.__all__ + + @respx.mock + def test_issue_324_valid_rfq_status_flows_through_to_query( + self, comms: CommunicationsResource, + ) -> None: + route = respx.get( + "https://test.kalshi.com/trade-api/v2/communications/rfqs", + ).mock(return_value=httpx.Response(200, json={"rfqs": []})) + comms.list_rfqs(status="closed") + assert route.calls[0].request.url.params["status"] == "closed" + + @respx.mock + def test_issue_324_valid_quote_status_flows_through_to_query( + self, comms: CommunicationsResource, + ) -> None: + route = respx.get( + "https://test.kalshi.com/trade-api/v2/communications/quotes", + ).mock(return_value=httpx.Response(200, json={"quotes": []})) + comms.list_quotes(status="executed", quote_creator_user_id="u1") + assert route.calls[0].request.url.params["status"] == "executed" + + @pytest.mark.asyncio + async def test_issue_324_valid_rfq_status_flows_through_to_query_async( + self, + async_comms: AsyncCommunicationsResource, + respx_mock: respx.MockRouter, + ) -> None: + route = respx_mock.get( + "https://test.kalshi.com/trade-api/v2/communications/rfqs", + ).mock(return_value=httpx.Response(200, json={"rfqs": []})) + await async_comms.list_rfqs(status="closed") + assert route.calls[0].request.url.params["status"] == "closed" + + @pytest.mark.asyncio + async def test_issue_324_valid_quote_status_flows_through_to_query_async( + self, + async_comms: AsyncCommunicationsResource, + respx_mock: respx.MockRouter, + ) -> None: + route = respx_mock.get( + "https://test.kalshi.com/trade-api/v2/communications/quotes", + ).mock(return_value=httpx.Response(200, json={"quotes": []})) + await async_comms.list_quotes(status="executed", quote_creator_user_id="u1") + assert route.calls[0].request.url.params["status"] == "executed" diff --git a/tests/test_orders.py b/tests/test_orders.py index 285ca74..8c4620a 100644 --- a/tests/test_orders.py +++ b/tests/test_orders.py @@ -1793,6 +1793,67 @@ def test_204_raises(self, orders: OrdersResource) -> None: ) +class TestIssue329BatchV2BytesFastPath: + """V2 batch endpoints must route through the v2.4 #223 bytes fast-path + (``_post_json`` / ``_delete_with_body_json`` with ``content=``), + not the dict-walk slow path (``_post`` / ``_delete_with_body`` with + ``json=``). See issues #223 and #329. + + We assert by patching the transport-helper layer: the fast-path helpers + forward ``content=`` bytes; the slow-path helpers forward ``json=``. + """ + + def test_issue_329_v2_batch_create_uses_bytes_fast_path( + self, orders: OrdersResource, + ) -> None: + request = BatchCreateOrdersV2Request( + orders=[ + CreateOrderV2Request( + ticker="MKT-A", + client_order_id="cli-1", + side="bid", + count=Decimal("10"), + price=Decimal("0.50"), + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + ), + ], + ) + with patch.object( + OrdersResource, "_post_json", return_value={"orders": []}, + ) as post_json, patch.object(OrdersResource, "_post") as post_dict: + orders.batch_create_v2(request=request) + post_dict.assert_not_called() + assert post_json.call_count == 1 + kwargs = post_json.call_args.kwargs + assert "json" not in kwargs + body = kwargs["content"] + assert isinstance(body, bytes) + assert json.loads(body) == request.model_dump( + exclude_none=True, by_alias=True, mode="json", + ) + + def test_issue_329_v2_batch_cancel_uses_bytes_fast_path( + self, orders: OrdersResource, + ) -> None: + request = BatchCancelOrdersV2Request( + orders=[BatchCancelOrdersV2RequestOrder(order_id="ord-a")], + ) + with patch.object( + OrdersResource, "_delete_with_body_json", return_value={"orders": []}, + ) as del_json, patch.object(OrdersResource, "_delete_with_body") as del_dict: + orders.batch_cancel_v2(request=request) + del_dict.assert_not_called() + assert del_json.call_count == 1 + kwargs = del_json.call_args.kwargs + assert "json" not in kwargs + body = kwargs["content"] + assert isinstance(body, bytes) + assert json.loads(body) == request.model_dump( + exclude_none=True, by_alias=True, mode="json", + ) + + class TestV2RequiresAuth: """Every V2 method must reject an unauthenticated client before issuing the request (matches the V1 cancel/create/etc. tests).