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 bff1cb7b..80864d36 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"} @@ -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/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..00e44dd6 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"} @@ -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 ed4761a5..8553b9e2 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"} @@ -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 65d6f04a..a30e3095 100644 --- a/kalshi/types.py +++ b/kalshi/types.py @@ -138,3 +138,18 @@ 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`` 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 " + "(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)] +"""``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 4e52b992..67756035 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1460,3 +1460,226 @@ 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, + }, + ), + # 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( + 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})