Repository navigation
Description
Summary
bool is an int subclass in Python, so every int-typed field on the SDK's request models silently coerces True→1 and False→0 at Pydantic validation time. v2.5's #243 hardened CreateOrderRequest.buy_max_cost against this with an explicit isinstance(v, bool) rejector, but the same coercion still slips through on every other integer field — including ones that route money to a specific account or set a transfer amount.
Affected fields
| Model | Field | Risk if True slips through |
|---|---|---|
CreateOrderRequest |
subaccount |
Routes the order to subaccount 1 instead of the primary (0) |
CreateOrderRequest |
exchange_index |
Routes to exchange 1 silently |
CreateOrderRequest |
expiration_ts |
Posts an order expiring at Unix epoch second 1 |
AmendOrderRequest |
subaccount, exchange_index |
Same routing risk |
DecreaseOrderRequest |
reduce_by, reduce_to, subaccount, exchange_index |
Decreases by 1 contract; routes to subaccount 1 |
CreateOrderGroupRequest |
contracts_limit |
Order group cap of 1 contract |
UpdateOrderGroupLimitRequest |
contracts_limit |
Same |
CreateRFQRequest |
contracts, subaccount |
RFQ for 1 contract; subaccount 1 routing |
ApplySubaccountTransferRequest |
from_subaccount, to_subaccount, amount_cents |
Transfers 1 cent from subaccount 1 to subaccount 1 (sources and destinations both silently 1 if both passed as True) |
CancelOrderRequest etc. |
subaccount |
Routing |
The transfer case (ApplySubaccountTransferRequest) is the most concerning: bool slipping through on from_subaccount / to_subaccount means a buggy caller (e.g., a config loader that produces bool for a missing integer) moves real cents to / from the wrong account with no SDK-side error.
Reproducer
from kalshi import CreateOrderRequest
from kalshi.models.subaccounts import ApplySubaccountTransferRequest
from uuid import uuid4
# Silently routes to subaccount 1 — no error
order = CreateOrderRequest(
ticker="EX-25-T", side="yes", action="buy",
count=10, yes_price="0.50", subaccount=True,
)
print(order.subaccount, type(order.subaccount)) # 1 <class 'int'>
# Silently transfers 1 cent from subaccount 1 to subaccount 1
xfer = ApplySubaccountTransferRequest(
client_transfer_id=uuid4(),
from_subaccount=True, # → 1
to_subaccount=True, # → 1
amount_cents=True, # → 1
)
print(xfer.from_subaccount, xfer.to_subaccount, xfer.amount_cents) # 1 1 1For contrast, buy_max_cost=True raises a clear ValidationError thanks to #243:
CreateOrderRequest(ticker=..., side=..., action="buy", count=1, buy_max_cost=True)
# ValidationError: buy_max_cost must be int (cents), not bool — bool is an int subclass...Suggested fix
Introduce a one-line annotated type in kalshi/types.py that mirrors the _coerce_decimal/DollarDecimal pattern but for plain ints:
def _reject_bool_int(value: object) -> object:
"""Reject ``bool`` on ``int`` fields. ``bool`` is an ``int`` subclass, so
``Pydantic`` coerces ``True``→1 / ``False``→0 silently — see #243 for the
money-risk this caused on ``buy_max_cost`` before the explicit guard.
"""
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, …). Use on every ``int`` field of a Request model."""Then swap int → StrictInt on every field listed above. The existing buy_max_cost validator stays (it also rejects float/Decimal, which is a separate invariant).
Add a parametrized test in tests/test_models.py that walks every Request model + field combo and asserts ValidationError on True/False — same pattern as the existing test_buy_max_cost_rejects_bool.
Behavioral impact
Callers that today pass True/False for an integer field are doing something wrong, but they get a clear error rather than silent misrouting. This is the same trade we made in #243 for buy_max_cost. Worth tagging bug (the silent coercion was already a bug) rather than breaking since no documented path uses bool for these fields.
Discovered by
Post-v2.5 multi-reviewer audit (Models surface). Verified empirically:
from pydantic import BaseModel
class M(BaseModel):
x: int
print(M(x=True).x) # → 1
Activity
fix(models): reject bool on int request fields (subaccount routing, t…
fix(models): reject bool on int request fields (subaccount routing, t…
release: 2.6.0 — post-v2.5 independent reviewer SDK audit closure (#295-
fix(models): AmendOrderRequest Literal narrowing + V1 ge=0 + create()…
deps: bump pydantic floor to >=2.4 for money-safety invariants (#346) (…