From e4f4dcb914e9312a75a4ae2f6dd5fbaea926d1df Mon Sep 17 00:00:00 2001 From: Jeff West Date: Thu, 21 May 2026 18:27:12 -0500 Subject: [PATCH 1/2] fix(models): reject bool on int request fields (subaccount routing, transfers, counts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds StrictInt = Annotated[int, BeforeValidator(_reject_bool_int)] in kalshi/types.py and swaps int -> StrictInt on every int field of every Request model that does not already have a dedicated validator. bool is an int subclass so plain-int annotations silently coerce True->1 / False->0; #243 fixed this for CreateOrderRequest.buy_max_cost and #295 extends the same guard to the remaining money-routing / counting ints. Affected request models: - CreateOrderRequest: expiration_ts, subaccount, exchange_index - AmendOrderRequest: subaccount, exchange_index - DecreaseOrderRequest: reduce_by, reduce_to, subaccount, exchange_index - BatchCancelOrdersRequestOrder: subaccount, exchange_index - CreateOrderGroupRequest: contracts_limit, subaccount, exchange_index - UpdateOrderGroupLimitRequest: contracts_limit - CreateRFQRequest: contracts, subaccount - ApplySubaccountTransferRequest: from_subaccount, to_subaccount, amount_cents Response models (Order, Fill, Position, ...) keep plain int — they parse server-emitted ints which are never Python bools. buy_max_cost keeps its existing dedicated validator (#243) which also rejects Decimal/float beyond bool. Adds parametrized test_strict_int_rejects_bool_on_request_models in tests/test_models.py covering every (Model, field) pair on True/False. Closes #295. --- kalshi/models/communications.py | 6 +- kalshi/models/order_groups.py | 10 +-- kalshi/models/orders.py | 24 +++--- kalshi/models/subaccounts.py | 8 +- kalshi/types.py | 28 ++++++ tests/test_models.py | 148 ++++++++++++++++++++++++++++++++ 6 files changed, 200 insertions(+), 24 deletions(-) diff --git a/kalshi/models/communications.py b/kalshi/models/communications.py index bff1cb7b..99461075 100644 --- a/kalshi/models/communications.py +++ b/kalshi/models/communications.py @@ -6,7 +6,7 @@ from pydantic import AliasChoices, AwareDatetime, BaseModel, Field -from kalshi.types import DollarDecimal, FixedPointCount +from kalshi.types import DollarDecimal, FixedPointCount, StrictInt UserFilterLiteral = Literal["self"] """Filter for items created by the authenticated user. Spec: UserFilter enum.""" @@ -150,14 +150,14 @@ class CreateRFQRequest(BaseModel): market_ticker: str rest_remainder: bool - contracts: int | None = Field(default=None, ge=1) + contracts: StrictInt | None = Field(default=None, ge=1) target_cost: DollarDecimal | None = Field( default=None, serialization_alias="target_cost_dollars", ) replace_existing: bool | None = None subtrader_id: str | None = None - subaccount: int | None = Field(default=None, ge=0) + subaccount: StrictInt | None = Field(default=None, ge=0) model_config = {"extra": "forbid"} diff --git a/kalshi/models/order_groups.py b/kalshi/models/order_groups.py index 83796aae..663cd572 100644 --- a/kalshi/models/order_groups.py +++ b/kalshi/models/order_groups.py @@ -4,7 +4,7 @@ from pydantic import AliasChoices, BaseModel, Field -from kalshi.types import FixedPointCount, NullableList +from kalshi.types import FixedPointCount, NullableList, StrictInt class OrderGroup(BaseModel): @@ -55,9 +55,9 @@ class CreateOrderGroupResponse(BaseModel): class CreateOrderGroupRequest(BaseModel): """Create body. SDK sends integer form; spec's `contracts_limit_fp` string variant unused.""" - contracts_limit: int = Field(..., ge=1) - subaccount: int | None = Field(default=None, ge=0) - exchange_index: int | None = None + contracts_limit: StrictInt = Field(..., ge=1) + subaccount: StrictInt | None = Field(default=None, ge=0) + exchange_index: StrictInt | None = None model_config = {"extra": "forbid"} @@ -65,6 +65,6 @@ class CreateOrderGroupRequest(BaseModel): class UpdateOrderGroupLimitRequest(BaseModel): """Update-limit body. No `subaccount` — spec omits SubaccountQuery on /limit.""" - contracts_limit: int = Field(..., ge=1) + contracts_limit: StrictInt = Field(..., ge=1) model_config = {"extra": "forbid"} diff --git a/kalshi/models/orders.py b/kalshi/models/orders.py index e020721b..c8956124 100644 --- a/kalshi/models/orders.py +++ b/kalshi/models/orders.py @@ -7,7 +7,7 @@ from pydantic import AliasChoices, AwareDatetime, BaseModel, Field, field_validator, model_validator -from kalshi.types import DollarDecimal, FixedPointCount +from kalshi.types import DollarDecimal, FixedPointCount, StrictInt # Literal aliases for fixed-enum kwargs on order resource methods. # Source of truth: OpenAPI spec v3.13.0 (specs/openapi.yaml). @@ -183,7 +183,7 @@ class CreateOrderRequest(BaseModel): serialization_alias="no_price_dollars", ) client_order_id: str | None = None - expiration_ts: int | None = None + expiration_ts: StrictInt | None = None buy_max_cost: int | None = None time_in_force: TimeInForceLiteral | None = None post_only: bool | None = None @@ -191,8 +191,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: int | None = None - exchange_index: int | None = None + subaccount: StrictInt | None = None + exchange_index: StrictInt | None = None model_config = {"extra": "forbid"} @@ -266,8 +266,8 @@ class AmendOrderRequest(BaseModel): ) client_order_id: str | None = None updated_client_order_id: str | None = None - subaccount: int | None = None - exchange_index: int | None = None + subaccount: StrictInt | None = None + exchange_index: StrictInt | None = None model_config = {"extra": "forbid"} @@ -286,10 +286,10 @@ class DecreaseOrderRequest(BaseModel): builds this model internally; method signature unchanged. """ - reduce_by: int | None = None - reduce_to: int | None = None - subaccount: int | None = None - exchange_index: int | None = None + reduce_by: StrictInt | None = None + reduce_to: StrictInt | None = None + subaccount: StrictInt | None = None + exchange_index: StrictInt | None = None model_config = {"extra": "forbid"} @@ -332,8 +332,8 @@ class BatchCancelOrdersRequestOrder(BaseModel): """ order_id: str - subaccount: int | None = None - exchange_index: int | None = None + subaccount: StrictInt | None = None + exchange_index: StrictInt | None = None model_config = {"extra": "forbid"} diff --git a/kalshi/models/subaccounts.py b/kalshi/models/subaccounts.py index ed4761a5..110f1603 100644 --- a/kalshi/models/subaccounts.py +++ b/kalshi/models/subaccounts.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field -from kalshi.types import DollarDecimal +from kalshi.types import DollarDecimal, StrictInt class CreateSubaccountResponse(BaseModel): @@ -31,9 +31,9 @@ class ApplySubaccountTransferRequest(BaseModel): """ client_transfer_id: UUID - from_subaccount: int = Field(ge=0) - to_subaccount: int = Field(ge=0) - amount_cents: int = Field(gt=0) + from_subaccount: StrictInt = Field(ge=0) + to_subaccount: StrictInt = Field(ge=0) + amount_cents: StrictInt = Field(gt=0) model_config = {"extra": "forbid"} diff --git a/kalshi/types.py b/kalshi/types.py index 65d6f04a..7183d767 100644 --- a/kalshi/types.py +++ b/kalshi/types.py @@ -138,3 +138,31 @@ def _none_to_empty_list(value: Any) -> Any: Callers wanting a :class:`datetime.datetime` can use ``datetime.fromtimestamp(value, tz=timezone.utc)``. """ + + +def _reject_bool_int(value: object) -> object: + """Reject ``bool`` on ``int`` fields used by request models. + + ``bool`` is an ``int`` subclass, so Pydantic would otherwise coerce + ``True`` -> 1 / ``False`` -> 0 silently. #243 hardened + ``CreateOrderRequest.buy_max_cost`` against this for money cap; #295 + extends the same guard to every other money-routing / counting int + field on Request models (subaccount, exchange_index, contracts_limit, + amount_cents, from_subaccount, to_subaccount, ...). Mirrors the + bool-rejection guard in :func:`_coerce_decimal` for the Decimal aliases. + """ + if isinstance(value, bool): + raise ValueError( + "bool is not a valid int here — pass an explicit integer " + "(e.g. 1 instead of True). bool is an int subclass so True " + "would otherwise silently become 1." + ) + return value + + +StrictInt = Annotated[int, BeforeValidator(_reject_bool_int)] +"""An ``int`` that rejects ``bool`` to avoid silent True->1 coercion on +money-routing fields (subaccount, exchange_index, contracts_limit, +amount_cents, from_subaccount, to_subaccount, ...). Use on every ``int`` +field of a Request model. Response models keep plain ``int`` — they +parse server-emitted data which is never a Python bool. See #295.""" diff --git a/tests/test_models.py b/tests/test_models.py index 4e52b992..d026bff4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1460,3 +1460,151 @@ def test_rejects_bool(self) -> None: with pytest.raises(TypeError, match="bool"): Event.model_validate(event_dict(fee_multiplier_override=True)) + + +class TestStrictIntRejectsBoolOnRequestModels: + """#295: every ``StrictInt`` field on a Request model must reject ``bool``. + + ``bool`` is an ``int`` subclass, so a plain ``int`` annotation would + silently coerce ``True``->1 / ``False``->0 — silently routing money to + subaccount 1, capping order groups at 1 contract, transferring 1 cent + between the wrong subaccounts, etc. #243 hardened ``buy_max_cost`` + against this; this test pins the same guard on every other int request + field. + """ + + @pytest.mark.parametrize( + ("model_path", "field_name", "other_kwargs"), + [ + # 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}, + ), + ( + "kalshi.models.orders:CreateOrderRequest", + "expiration_ts", + {"ticker": "MKT", "side": "yes", "action": "buy", "count": 1}, + ), + # AmendOrderRequest + ( + "kalshi.models.orders:AmendOrderRequest", + "subaccount", + {"ticker": "MKT", "side": "yes", "action": "buy"}, + ), + ( + "kalshi.models.orders:AmendOrderRequest", + "exchange_index", + {"ticker": "MKT", "side": "yes", "action": "buy"}, + ), + # DecreaseOrderRequest — reduce_by/reduce_to are XOR with each other, + # so each row pairs the StrictInt-under-test against a valid sibling + # where required. subaccount/exchange_index need a real reduce_* set. + ("kalshi.models.orders:DecreaseOrderRequest", "reduce_by", {}), + ("kalshi.models.orders:DecreaseOrderRequest", "reduce_to", {}), + ( + "kalshi.models.orders:DecreaseOrderRequest", + "subaccount", + {"reduce_by": 1}, + ), + ( + "kalshi.models.orders:DecreaseOrderRequest", + "exchange_index", + {"reduce_by": 1}, + ), + # BatchCancelOrdersRequestOrder + ( + "kalshi.models.orders:BatchCancelOrdersRequestOrder", + "subaccount", + {"order_id": "abc"}, + ), + ( + "kalshi.models.orders:BatchCancelOrdersRequestOrder", + "exchange_index", + {"order_id": "abc"}, + ), + # Order groups + ( + "kalshi.models.order_groups:CreateOrderGroupRequest", + "contracts_limit", + {}, + ), + ( + "kalshi.models.order_groups:CreateOrderGroupRequest", + "subaccount", + {"contracts_limit": 10}, + ), + ( + "kalshi.models.order_groups:CreateOrderGroupRequest", + "exchange_index", + {"contracts_limit": 10}, + ), + ( + "kalshi.models.order_groups:UpdateOrderGroupLimitRequest", + "contracts_limit", + {}, + ), + # Communications / RFQ + ( + "kalshi.models.communications:CreateRFQRequest", + "contracts", + {"market_ticker": "MKT", "rest_remainder": True}, + ), + ( + "kalshi.models.communications:CreateRFQRequest", + "subaccount", + {"market_ticker": "MKT", "rest_remainder": True}, + ), + # Subaccount transfers — the highest-risk surface in #295: a bool + # slipping through silently moves cents between subaccount 1 and 1. + ( + "kalshi.models.subaccounts:ApplySubaccountTransferRequest", + "from_subaccount", + { + "client_transfer_id": "00000000-0000-4000-8000-000000000000", + "to_subaccount": 2, + "amount_cents": 1, + }, + ), + ( + "kalshi.models.subaccounts:ApplySubaccountTransferRequest", + "to_subaccount", + { + "client_transfer_id": "00000000-0000-4000-8000-000000000000", + "from_subaccount": 1, + "amount_cents": 1, + }, + ), + ( + "kalshi.models.subaccounts:ApplySubaccountTransferRequest", + "amount_cents", + { + "client_transfer_id": "00000000-0000-4000-8000-000000000000", + "from_subaccount": 1, + "to_subaccount": 2, + }, + ), + ], + ) + def test_strict_int_rejects_bool_on_request_models( + 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) + + for bool_value in (True, False): + with pytest.raises(ValidationError, match=r"bool"): + model_cls(**{**other_kwargs, field_name: bool_value}) From c99efda20fcb6143f628c93735ea14d9fed014ca Mon Sep 17 00:00:00 2001 From: Jeff West Date: Thu, 21 May 2026 18:42:59 -0500 Subject: [PATCH 2/2] fix-up(models): trim StrictInt docstrings; export it; cover V2 request models Addresses bot review on #305: - Trim _reject_bool_int and StrictInt docstrings to 1 line each (terse style). - Export StrictInt from kalshi/__init__.py (import + __all__). - Apply StrictInt to V2 request-model int fields: - CreateOrderV2Request: expiration_time, subaccount, exchange_index - DecreaseOrderV2Request: exchange_index - AmendOrderV2Request: exchange_index - BatchCancelOrdersV2RequestOrder: subaccount, exchange_index - UpdateSubaccountNettingRequest: subaccount_number - CreateQuoteRequest: subaccount - Add 9 parametrize rows to TestStrictIntRejectsBoolOnRequestModels covering each new field. --- kalshi/__init__.py | 3 +- kalshi/models/communications.py | 2 +- kalshi/models/orders.py | 14 +++--- kalshi/models/subaccounts.py | 2 +- kalshi/types.py | 17 +------- tests/test_models.py | 75 +++++++++++++++++++++++++++++++++ 6 files changed, 88 insertions(+), 25 deletions(-) diff --git a/kalshi/__init__.py b/kalshi/__init__.py index 371627a9..af3bf7cf 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -167,7 +167,7 @@ WeeklySchedule, Withdrawal, ) -from kalshi.types import NullableList +from kalshi.types import NullableList, StrictInt __all__ = [ "RFQ", @@ -320,6 +320,7 @@ "SettlementStatusLiteral", "SideLiteral", "SportFilterDetails", + "StrictInt", "StructuredTarget", "SubaccountBalance", "SubaccountNettingConfig", diff --git a/kalshi/models/communications.py b/kalshi/models/communications.py index 99461075..80864d36 100644 --- a/kalshi/models/communications.py +++ b/kalshi/models/communications.py @@ -199,7 +199,7 @@ class CreateQuoteRequest(BaseModel): yes_bid: DollarDecimal no_bid: DollarDecimal rest_remainder: bool - subaccount: int | None = Field(default=None, ge=0) + subaccount: StrictInt | None = Field(default=None, ge=0) post_only: bool | None = None model_config = {"extra": "forbid"} diff --git a/kalshi/models/orders.py b/kalshi/models/orders.py index c8956124..00e44dd6 100644 --- a/kalshi/models/orders.py +++ b/kalshi/models/orders.py @@ -475,13 +475,13 @@ class CreateOrderV2Request(BaseModel): price: DollarDecimal time_in_force: TimeInForceLiteral self_trade_prevention_type: SelfTradePreventionTypeLiteral - expiration_time: int | None = None + expiration_time: StrictInt | None = None post_only: bool | None = None cancel_order_on_pause: bool | None = None reduce_only: bool | None = None - subaccount: int | None = Field(default=None, ge=0) + subaccount: StrictInt | None = Field(default=None, ge=0) order_group_id: str | None = None - exchange_index: int | None = None + exchange_index: StrictInt | None = None model_config = {"extra": "forbid"} @@ -520,7 +520,7 @@ class DecreaseOrderV2Request(BaseModel): reduce_by: FixedPointCount | None = None reduce_to: FixedPointCount | None = None - exchange_index: int | None = None + exchange_index: StrictInt | None = None model_config = {"extra": "forbid"} @@ -560,7 +560,7 @@ class AmendOrderV2Request(BaseModel): count: FixedPointCount client_order_id: str | None = None updated_client_order_id: str | None = None - exchange_index: int | None = None + exchange_index: StrictInt | None = None model_config = {"extra": "forbid"} @@ -622,8 +622,8 @@ class BatchCancelOrdersV2RequestOrder(BaseModel): """Single entry in BatchCancelOrdersV2Request.orders.""" order_id: str - subaccount: int | None = Field(default=None, ge=0) - exchange_index: int | None = None + subaccount: StrictInt | None = Field(default=None, ge=0) + exchange_index: StrictInt | None = None model_config = {"extra": "forbid"} diff --git a/kalshi/models/subaccounts.py b/kalshi/models/subaccounts.py index 110f1603..8553b9e2 100644 --- a/kalshi/models/subaccounts.py +++ b/kalshi/models/subaccounts.py @@ -83,7 +83,7 @@ class SubaccountTransfer(BaseModel): class UpdateSubaccountNettingRequest(BaseModel): """Body for PUT /portfolio/subaccounts/netting.""" - subaccount_number: int = Field(ge=0) + subaccount_number: StrictInt = Field(ge=0) enabled: bool model_config = {"extra": "forbid"} diff --git a/kalshi/types.py b/kalshi/types.py index 7183d767..a30e3095 100644 --- a/kalshi/types.py +++ b/kalshi/types.py @@ -141,16 +141,7 @@ def _none_to_empty_list(value: Any) -> Any: def _reject_bool_int(value: object) -> object: - """Reject ``bool`` on ``int`` fields used by request models. - - ``bool`` is an ``int`` subclass, so Pydantic would otherwise coerce - ``True`` -> 1 / ``False`` -> 0 silently. #243 hardened - ``CreateOrderRequest.buy_max_cost`` against this for money cap; #295 - extends the same guard to every other money-routing / counting int - field on Request models (subaccount, exchange_index, contracts_limit, - amount_cents, from_subaccount, to_subaccount, ...). Mirrors the - bool-rejection guard in :func:`_coerce_decimal` for the Decimal aliases. - """ + """Reject ``bool`` so True->1 / False->0 cannot silently route money (#243 pattern, #295).""" if isinstance(value, bool): raise ValueError( "bool is not a valid int here — pass an explicit integer " @@ -161,8 +152,4 @@ def _reject_bool_int(value: object) -> object: StrictInt = Annotated[int, BeforeValidator(_reject_bool_int)] -"""An ``int`` that rejects ``bool`` to avoid silent True->1 coercion on -money-routing fields (subaccount, exchange_index, contracts_limit, -amount_cents, from_subaccount, to_subaccount, ...). Use on every ``int`` -field of a Request model. Response models keep plain ``int`` — they -parse server-emitted data which is never a Python bool. See #295.""" +"""``int`` that rejects ``bool`` — use on every Request-model integer field. See #295.""" diff --git a/tests/test_models.py b/tests/test_models.py index d026bff4..67756035 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1590,6 +1590,81 @@ class TestStrictIntRejectsBoolOnRequestModels: "to_subaccount": 2, }, ), + # V2 order requests (#295 follow-up) + ( + "kalshi.models.orders:CreateOrderV2Request", + "expiration_time", + { + "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:CreateOrderV2Request", + "subaccount", + { + "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: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:DecreaseOrderV2Request", + "exchange_index", + {"reduce_by": 1}, + ), + ( + "kalshi.models.orders:AmendOrderV2Request", + "exchange_index", + {"ticker": "MKT", "side": "bid", "price": "0.50", "count": 1}, + ), + ( + "kalshi.models.orders:BatchCancelOrdersV2RequestOrder", + "subaccount", + {"order_id": "abc"}, + ), + ( + "kalshi.models.orders:BatchCancelOrdersV2RequestOrder", + "exchange_index", + {"order_id": "abc"}, + ), + ( + "kalshi.models.subaccounts:UpdateSubaccountNettingRequest", + "subaccount_number", + {"enabled": True}, + ), + ( + "kalshi.models.communications:CreateQuoteRequest", + "subaccount", + { + "rfq_id": "rfq1", + "yes_bid": "0.50", + "no_bid": "0.50", + "rest_remainder": True, + }, + ), ], ) def test_strict_int_rejects_bool_on_request_models(