From 704ce331e3dc7757a21101aaf11993d7ceef2c25 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 22 May 2026 07:38:02 -0500 Subject: [PATCH 1/4] fix(models): tighten amend/V1 request schemas + create() overload Three integrity fixes closing the V1/V2 request-model parity gap and the orders.create() overload looseness. AmendOrderRequest.side and .action are narrowed to SideLiteral / ActionLiteral, mirroring the v2.5 #270 narrowing on CreateOrderRequest. A typo like side='yess' now fails at construction instead of being signed and 400'd by the server. The stale module-level comment implying request models are kept as bare str is removed; both V1 and V2 create paths plus amend are now narrowed. V1 CreateOrderRequest, AmendOrderRequest, DecreaseOrderRequest, and BatchCancelOrdersRequestOrder gain Field(default=None, ge=0) on subaccount and exchange_index, matching the V2 + communications + order_groups + subaccount-transfer surface that already enforced the lower bound. The two V2 exchange_index slots missed by #295 (CreateOrderV2Request, BatchCancelOrdersV2RequestOrder) are swept in the same pass. OrdersResource.create / AsyncOrdersResource.create kwarg overloads require action: ActionLiteral and count: int with no None and no default. Runtime already required them since v2.5 #242; this aligns the static contract so mypy --strict refuses the missing-arg shape that previously deferred to a runtime TypeError. Two existing auth-required-guard tests are updated to satisfy the tightened overload; the implementation signatures keep None defaults for overload dispatch and runtime error quality. Closes #312, #326, #350 --- kalshi/models/orders.py | 33 ++++----- kalshi/resources/orders.py | 8 +-- tests/test_async_client.py | 2 +- tests/test_client.py | 50 ++++++++++++- tests/test_models.py | 142 +++++++++++++++++++++++++++++++++++++ 5 files changed, 213 insertions(+), 22 deletions(-) diff --git a/kalshi/models/orders.py b/kalshi/models/orders.py index dcef055e..a78595b7 100644 --- a/kalshi/models/orders.py +++ b/kalshi/models/orders.py @@ -9,11 +9,12 @@ from kalshi.types import DollarDecimal, FixedPointCount, StrictInt -# Literal aliases for fixed-enum kwargs on order resource methods. +# Literal aliases for fixed-enum kwargs on order resource methods and the +# matching V1 / V2 request models. # Source of truth: OpenAPI spec v3.13.0 (specs/openapi.yaml). -# The Pydantic request models leave these fields as ``str`` to remain tolerant -# of spec drift; the static-type narrowing happens at the resource-method -# boundary where users actually pass values. +# V1 ``CreateOrderRequest`` (#270) and ``AmendOrderRequest`` (#312) plus V2 +# ``CreateOrderV2Request`` all carry these narrowed types so a typo fails +# at construction rather than as a 400 from the server. SideLiteral = Literal["yes", "no"] """Order side. Spec: CreateOrderRequest.side / AmendOrderRequest.side enum.""" @@ -191,8 +192,8 @@ class CreateOrderRequest(BaseModel): self_trade_prevention_type: SelfTradePreventionTypeLiteral | None = None order_group_id: str | None = None cancel_order_on_pause: bool | None = None - subaccount: StrictInt | None = None - exchange_index: StrictInt | None = None + subaccount: StrictInt | None = Field(default=None, ge=0) + exchange_index: StrictInt | None = Field(default=None, ge=0) model_config = {"extra": "forbid"} @@ -250,8 +251,8 @@ class AmendOrderRequest(BaseModel): """ ticker: str - side: str - action: str + side: SideLiteral + action: ActionLiteral yes_price: DollarDecimal | None = Field( default=None, serialization_alias="yes_price_dollars", @@ -266,8 +267,8 @@ class AmendOrderRequest(BaseModel): ) client_order_id: str | None = None updated_client_order_id: str | None = None - subaccount: StrictInt | None = None - exchange_index: StrictInt | None = None + subaccount: StrictInt | None = Field(default=None, ge=0) + exchange_index: StrictInt | None = Field(default=None, ge=0) model_config = {"extra": "forbid"} @@ -288,8 +289,8 @@ class DecreaseOrderRequest(BaseModel): reduce_by: StrictInt | None = None reduce_to: StrictInt | None = None - subaccount: StrictInt | None = None - exchange_index: StrictInt | None = None + subaccount: StrictInt | None = Field(default=None, ge=0) + exchange_index: StrictInt | None = Field(default=None, ge=0) model_config = {"extra": "forbid"} @@ -332,8 +333,8 @@ class BatchCancelOrdersRequestOrder(BaseModel): """ order_id: str - subaccount: StrictInt | None = None - exchange_index: StrictInt | None = None + subaccount: StrictInt | None = Field(default=None, ge=0) + exchange_index: StrictInt | None = Field(default=None, ge=0) model_config = {"extra": "forbid"} @@ -481,7 +482,7 @@ class CreateOrderV2Request(BaseModel): reduce_only: bool | None = None subaccount: StrictInt | None = Field(default=None, ge=0) order_group_id: str | None = None - exchange_index: StrictInt | None = None + exchange_index: StrictInt | None = Field(default=None, ge=0) model_config = {"extra": "forbid"} @@ -623,7 +624,7 @@ class BatchCancelOrdersV2RequestOrder(BaseModel): order_id: str subaccount: StrictInt | None = Field(default=None, ge=0) - exchange_index: StrictInt | None = None + exchange_index: StrictInt | None = Field(default=None, ge=0) model_config = {"extra": "forbid"} diff --git a/kalshi/resources/orders.py b/kalshi/resources/orders.py index bb24b2cc..1d6d4a31 100644 --- a/kalshi/resources/orders.py +++ b/kalshi/resources/orders.py @@ -333,8 +333,8 @@ def create( *, ticker: str, side: SideLiteral, - action: ActionLiteral | None = ..., - count: int | None = ..., + action: ActionLiteral, + count: int, yes_price: float | str | int | None = ..., no_price: float | str | int | None = ..., client_order_id: str | None = ..., @@ -899,8 +899,8 @@ async def create( *, ticker: str, side: SideLiteral, - action: ActionLiteral | None = ..., - count: int | None = ..., + action: ActionLiteral, + count: int, yes_price: float | str | int | None = ..., no_price: float | str | int | None = ..., client_order_id: str | None = ..., diff --git a/tests/test_async_client.py b/tests/test_async_client.py index eb54d594..7965bac7 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -473,7 +473,7 @@ async def test_orders_create_raises_auth_required(self) -> None: resource = AsyncOrdersResource(transport) with pytest.raises(AuthRequiredError): - await resource.create(ticker="TEST", side="yes") + await resource.create(ticker="TEST", side="yes", action="buy", count=1) @pytest.mark.asyncio async def test_portfolio_balance_raises_auth_required(self) -> None: diff --git a/tests/test_client.py b/tests/test_client.py index fed5ba2c..2ecdb2f0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -872,7 +872,7 @@ def test_orders_create_raises_auth_required(self) -> None: resource = OrdersResource(transport) with pytest.raises(AuthRequiredError): - resource.create(ticker="TEST", side="yes") + resource.create(ticker="TEST", side="yes", action="buy", count=1) def test_orders_list_raises_auth_required(self) -> None: config = KalshiConfig( @@ -1467,3 +1467,51 @@ def test_sync_transport_close_is_idempotent(self, transport: SyncTransport) -> N transport.close() transport.close() # triple-close OK assert transport._closed is True +class TestIssue350OrdersCreateOverloadRequiresActionCount: + """#350: orders.create() kwarg overload requires ``action`` and ``count``. + + v2.5 (#242) removed the silent ``count=1`` / ``action="buy"`` defaults + at runtime, raising ``TypeError`` when either is missing. The kwarg + overload still advertised them as ``... | None = ...``, so mypy + accepted the missing-arg shape silently. This test pins both the + runtime guard and the type-system fence: removing ``action`` or + ``count`` triggers a ``call-overload`` mypy error and a runtime + ``TypeError`` before any HTTP traffic. + """ + + def test_issue_350_orders_create_overload_requires_action_count( + self, test_auth: KalshiAuth + ) -> None: + config = KalshiConfig( + base_url="https://test.kalshi.com/trade-api/v2", + timeout=5.0, + max_retries=0, + ) + transport = SyncTransport(test_auth, config) + from kalshi.resources.orders import OrdersResource + + resource = OrdersResource(transport) + + # Runtime guard from #242: missing ``action`` raises before HTTP. + with pytest.raises(TypeError, match=r"action"): + resource.create( # type: ignore[call-overload] + ticker="TEST", + side="yes", + count=1, + ) + + # Runtime guard: missing ``count`` raises before HTTP. + with pytest.raises(TypeError, match=r"count"): + resource.create( # type: ignore[call-overload] + ticker="TEST", + side="yes", + action="buy", + ) + + # The ``# type: ignore[call-overload]`` markers above demonstrate the + # static fence: mypy --strict refuses the missing-arg shapes; if the + # overload were re-loosened to ``ActionLiteral | None = ...`` again, + # mypy would emit ``unused-ignore`` on these lines and fail the + # repo-wide strict check. + + transport.close() diff --git a/tests/test_models.py b/tests/test_models.py index 67756035..a7027cd0 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1683,3 +1683,145 @@ def test_strict_int_rejects_bool_on_request_models( for bool_value in (True, False): with pytest.raises(ValidationError, match=r"bool"): model_cls(**{**other_kwargs, field_name: bool_value}) + + +class TestIssue312AmendOrderRequestLiteralNarrowing: + """#312: AmendOrderRequest.side/.action narrowed to Literal aliases. + + Mirrors the v2.5 #270 narrowing on CreateOrderRequest. A typo like + ``side="yess"`` now fails at construction rather than at the server's + 400 response after a signed HTTP round-trip. + """ + + def test_issue_312_amend_order_request_rejects_invalid_side(self) -> None: + from pydantic import ValidationError + + from kalshi.models.orders import AmendOrderRequest + + with pytest.raises(ValidationError): + AmendOrderRequest( + ticker="MKT", + side="yess", # type: ignore[arg-type] + action="buy", + count=Decimal(1), + ) + + def test_issue_312_amend_order_request_rejects_invalid_action(self) -> None: + from pydantic import ValidationError + + from kalshi.models.orders import AmendOrderRequest + + with pytest.raises(ValidationError): + AmendOrderRequest( + ticker="MKT", + side="yes", + action="buyy", # type: ignore[arg-type] + count=Decimal(1), + ) + + def test_issue_312_amend_order_request_accepts_valid_literals(self) -> None: + from kalshi.models.orders import AmendOrderRequest + + req = AmendOrderRequest(ticker="MKT", side="no", action="sell") + assert req.side == "no" + assert req.action == "sell" + + +class TestIssue326V1SubaccountGeZero: + """#326: V1 order request models gain ``ge=0`` on subaccount-shaped ints. + + Achieves parity with V2 + communications + order_groups + subaccount- + transfer. Negative ``subaccount``/``exchange_index`` previously survived + construction, was signed by the SDK, and was 400'd by the server. + + Also sweeps the V2 ``exchange_index`` slots missed by the original + V2 hardening. + """ + + @pytest.mark.parametrize( + ("model_path", "field_name", "other_kwargs"), + [ + # V1 CreateOrderRequest + ( + "kalshi.models.orders:CreateOrderRequest", + "subaccount", + {"ticker": "MKT", "side": "yes", "action": "buy", "count": 1}, + ), + ( + "kalshi.models.orders:CreateOrderRequest", + "exchange_index", + {"ticker": "MKT", "side": "yes", "action": "buy", "count": 1}, + ), + # V1 AmendOrderRequest + ( + "kalshi.models.orders:AmendOrderRequest", + "subaccount", + {"ticker": "MKT", "side": "yes", "action": "buy"}, + ), + ( + "kalshi.models.orders:AmendOrderRequest", + "exchange_index", + {"ticker": "MKT", "side": "yes", "action": "buy"}, + ), + # V1 DecreaseOrderRequest + ( + "kalshi.models.orders:DecreaseOrderRequest", + "subaccount", + {"reduce_by": 1}, + ), + ( + "kalshi.models.orders:DecreaseOrderRequest", + "exchange_index", + {"reduce_by": 1}, + ), + # V1 BatchCancelOrdersRequestOrder + ( + "kalshi.models.orders:BatchCancelOrdersRequestOrder", + "subaccount", + {"order_id": "abc"}, + ), + ( + "kalshi.models.orders:BatchCancelOrdersRequestOrder", + "exchange_index", + {"order_id": "abc"}, + ), + # V2 exchange_index slots that were missed in #295 + ( + "kalshi.models.orders:CreateOrderV2Request", + "exchange_index", + { + "ticker": "MKT", + "client_order_id": "c1", + "side": "bid", + "count": 1, + "price": "0.50", + "time_in_force": "good_till_canceled", + "self_trade_prevention_type": "maker", + }, + ), + ( + "kalshi.models.orders:BatchCancelOrdersV2RequestOrder", + "exchange_index", + {"order_id": "abc"}, + ), + ], + ) + def test_issue_326_v1_subaccount_ge_zero( + self, + model_path: str, + field_name: str, + other_kwargs: dict, + ) -> None: + import importlib + + from pydantic import ValidationError + + module_name, class_name = model_path.split(":") + model_cls = getattr(importlib.import_module(module_name), class_name) + + with pytest.raises(ValidationError, match=r"greater than or equal to 0"): + model_cls(**{**other_kwargs, field_name: -1}) + + # Zero (primary subaccount / first shard) remains valid. + instance = model_cls(**{**other_kwargs, field_name: 0}) + assert getattr(instance, field_name) == 0 From a1c6a2101374873c450c3ce459072d076db71ef4 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 22 May 2026 07:47:51 -0500 Subject: [PATCH 2/4] polish(tests): update amend validation tests for AmendOrderRequest Literal narrowing The amend validation tests asserted the server-400 -> KalshiValidationError mapping path but used side='invalid'/'bad' as the trigger. After the W1-D AmendOrderRequest Literal narrowing (#312), those values now raise pydantic.ValidationError client-side before the mocked HTTP call, so the KalshiValidationError assertion never fires. Switch the trigger to a valid Literal (side='yes') so the request reaches the mocked 400 response and the SDK's server-error mapping is still exercised. The new pre-HTTP ValidationError path is already covered by the W1-D regression tests in tests/test_models.py (test_issue_312_amend_order_request_rejects_invalid_side and friends). --- tests/test_async_orders.py | 2 +- tests/test_orders.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_async_orders.py b/tests/test_async_orders.py index ca6d6a8d..b629a257 100644 --- a/tests/test_async_orders.py +++ b/tests/test_async_orders.py @@ -836,7 +836,7 @@ async def test_amend_validation_error(self, orders: AsyncOrdersResource) -> None return_value=httpx.Response(400, json={"message": "invalid"}) ) with pytest.raises(KalshiValidationError): - await orders.amend("ord-123", ticker="T", side="bad", action="buy", yes_price=0.50) + await orders.amend("ord-123", ticker="T", side="yes", action="buy", yes_price=0.50) @pytest.mark.asyncio async def test_amend_requires_price_or_count(self, orders: AsyncOrdersResource) -> None: diff --git a/tests/test_orders.py b/tests/test_orders.py index ad43ef88..285ca749 100644 --- a/tests/test_orders.py +++ b/tests/test_orders.py @@ -760,7 +760,7 @@ def test_amend_validation_error(self, orders: OrdersResource) -> None: return_value=httpx.Response(400, json={"message": "invalid side"}) ) with pytest.raises(KalshiValidationError): - orders.amend("ord-300", ticker="T", side="invalid", action="buy", yes_price=0.50) + orders.amend("ord-300", ticker="T", side="yes", action="buy", yes_price=0.50) def test_amend_requires_price_or_count(self, orders: OrdersResource) -> None: with pytest.raises(ValueError, match="requires at least one"): From 26ccd327c7a663e87e5a843b40b17f26366d3603 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 22 May 2026 07:58:09 -0500 Subject: [PATCH 3/4] polish(tests): hoist ValidationError import; type bare dict; PEP 8 blank lines --- tests/test_client.py | 2 ++ tests/test_models.py | 7 ++----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 2ecdb2f0..2fa95fc2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1467,6 +1467,8 @@ def test_sync_transport_close_is_idempotent(self, transport: SyncTransport) -> N transport.close() transport.close() # triple-close OK assert transport._closed is True + + class TestIssue350OrdersCreateOverloadRequiresActionCount: """#350: orders.create() kwarg overload requires ``action`` and ``count``. diff --git a/tests/test_models.py b/tests/test_models.py index a7027cd0..f7d99d20 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,6 +7,7 @@ from typing import ClassVar import pytest +from pydantic import ValidationError from kalshi.models.communications import RFQ, Quote from kalshi.models.events import Event, EventMetadata @@ -1694,8 +1695,6 @@ class TestIssue312AmendOrderRequestLiteralNarrowing: """ def test_issue_312_amend_order_request_rejects_invalid_side(self) -> None: - from pydantic import ValidationError - from kalshi.models.orders import AmendOrderRequest with pytest.raises(ValidationError): @@ -1707,8 +1706,6 @@ def test_issue_312_amend_order_request_rejects_invalid_side(self) -> None: ) def test_issue_312_amend_order_request_rejects_invalid_action(self) -> None: - from pydantic import ValidationError - from kalshi.models.orders import AmendOrderRequest with pytest.raises(ValidationError): @@ -1810,7 +1807,7 @@ def test_issue_326_v1_subaccount_ge_zero( self, model_path: str, field_name: str, - other_kwargs: dict, + other_kwargs: dict[str, object], ) -> None: import importlib From 32eec67e029426e240a7fecde0c1316b7e08c6f0 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 22 May 2026 08:06:10 -0500 Subject: [PATCH 4/4] polish(tests): drop redundant inline ValidationError import in TestIssue326V1SubaccountGeZero --- tests/test_models.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index f7d99d20..49d9d419 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1811,8 +1811,6 @@ def test_issue_326_v1_subaccount_ge_zero( ) -> None: import importlib - from pydantic import ValidationError - module_name, class_name = model_path.split(":") model_cls = getattr(importlib.import_module(module_name), class_name)