Skip to content

bug: int-typed request fields silently coerce bool (subaccount routing, transfers, counts) #295

Closed

Description

@TexasCoding

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 1

For 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions