From c5ebad74acf6126a003e61e8e0ef47e127c43cf8 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Tue, 19 May 2026 18:47:13 -0500 Subject: [PATCH] fix(models): relax subaccount-number request constraint (drop le=32) (#164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nightly integration test `test_transfer_between_subaccounts` failed with: pydantic_core._pydantic_core.ValidationError: 1 validation error for ApplySubaccountTransferRequest to_subaccount Input should be less than or equal to 32 [type=less_than_equal, input_value=41, input_type=int] Root cause ---------- The demo server created an ephemeral subaccount and returned `subaccount_number=41`. The test then tried to use that value as `to_subaccount` in a transfer, but the SDK refused client-side because 7 request-side model fields carried `le=32`. The `le=32` bound was added in v0.11.0 from a code-review pass that read the spec's prose description ("0 for primary, 1-32 for numbered subaccounts") as a hard limit. The actual OpenAPI schema defines no `minimum`/`maximum` constraints on any of these fields. Demo has clearly extended past 32 in practice, and response-side subaccount numbers were already unbounded — the asymmetry was the bug. Fix --- Drop `le=32` from 7 request-side model fields, keep `ge=0`: ApplySubaccountTransferRequest.{from,to}_subaccount UpdateSubaccountNettingRequest.subaccount_number CreateOrderRequest.subaccount BatchCancelOrdersV2RequestOrder.subaccount CreateRFQRequest.subaccount CreateQuoteRequest.subaccount Server is the source of truth on the upper bound. Negative values still fail-fast at the SDK boundary; out-of-range positives now round-trip cleanly. Docstrings on ApplySubaccountTransferRequest and SubaccountsResource updated to drop the "1-32" claim and explain the constraint asymmetry. Tests ----- Replaced `test_transfer_request_rejects_out_of_range_subaccount` (asserted `to_subaccount=33` failed) with `test_transfer_request_accepts_subaccount_above_32` (asserts `41` round-trips). The existing `test_transfer_request_rejects_negative_subaccount` keeps the `ge=0` coverage; the new test locks in the new contract so a future code- review pass can't re-introduce the bound from prose alone. Verification ------------ uv run pytest tests/test_subaccounts.py -> 43 passed uv run pytest tests/ --ignore=tests/integration -> 2023 passed (unchanged) uv run mypy kalshi/ -> Success uv run ruff check . -> All checks passed The failing integration test cannot be reproduced locally without hitting demo with KALSHI_KEY_ID, but the unit-test regression guard (`accepts_subaccount_above_32`) directly mirrors the failure mode from the nightly run. Closes #164. --- CHANGELOG.md | 17 +++++++++++++++++ kalshi/models/communications.py | 4 ++-- kalshi/models/orders.py | 4 ++-- kalshi/models/subaccounts.py | 14 +++++++++----- kalshi/resources/subaccounts.py | 4 +++- tests/test_subaccounts.py | 24 +++++++++++++++--------- 6 files changed, 48 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cec266..ad32c7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,23 @@ v3.18.0 / v0.14 fields and promotes additive drift to a hard CI failure. - Required-but-Optional drift stays as warning-only (~204 entries; separate policy decision). +### Fixed + +- Subaccount-number request fields no longer cap at 32. Demo allocates + ephemeral subaccount numbers above 32 (observed: 41), but the SDK was + rejecting them client-side with a ``ValidationError`` because seven + request-side model fields carried a ``le=32`` bound derived from spec + prose. The actual OpenAPI schema defines no upper bound; only the + description text mentions ``1-32``. Affected fields: + ``ApplySubaccountTransferRequest.{from,to}_subaccount``, + ``UpdateSubaccountNettingRequest.subaccount_number``, + ``CreateOrderRequest.subaccount``, + ``BatchCancelOrdersV2RequestOrder.subaccount``, + ``CreateRFQRequest.subaccount``, + ``CreateQuoteRequest.subaccount``. + The ``ge=0`` lower bound is unchanged. Unblocks the + ``test_transfer_between_subaccounts`` nightly integration test (#164). + ## 2.1.0 — 2026-05-18 OpenAPI spec sync from v3.13.0 → v3.18.0. Adds the V2 event-market diff --git a/kalshi/models/communications.py b/kalshi/models/communications.py index 21facbd..6db862c 100644 --- a/kalshi/models/communications.py +++ b/kalshi/models/communications.py @@ -158,7 +158,7 @@ class CreateRFQRequest(BaseModel): ) replace_existing: bool | None = None subtrader_id: str | None = None - subaccount: int | None = Field(default=None, ge=0, le=32) + subaccount: int | None = Field(default=None, ge=0) model_config = {"extra": "forbid"} @@ -200,7 +200,7 @@ class CreateQuoteRequest(BaseModel): yes_bid: DollarDecimal no_bid: DollarDecimal rest_remainder: bool - subaccount: int | None = Field(default=None, ge=0, le=32) + subaccount: int | 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 d84a6f7..9b7e5ca 100644 --- a/kalshi/models/orders.py +++ b/kalshi/models/orders.py @@ -414,7 +414,7 @@ class CreateOrderV2Request(BaseModel): 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, le=32) + subaccount: int | None = Field(default=None, ge=0) order_group_id: str | None = None exchange_index: int | None = None @@ -557,7 +557,7 @@ class BatchCancelOrdersV2RequestOrder(BaseModel): """Single entry in BatchCancelOrdersV2Request.orders.""" order_id: str - subaccount: int | None = Field(default=None, ge=0, le=32) + subaccount: int | None = Field(default=None, ge=0) exchange_index: int | None = None model_config = {"extra": "forbid"} diff --git a/kalshi/models/subaccounts.py b/kalshi/models/subaccounts.py index 82400cb..ed4761a 100644 --- a/kalshi/models/subaccounts.py +++ b/kalshi/models/subaccounts.py @@ -22,13 +22,17 @@ class ApplySubaccountTransferRequest(BaseModel): ``amount_cents`` is integer cents per spec (matches the ``buy_max_cost`` convention on ``CreateOrderRequest``). Pass ``500`` for $5.00, never - a Decimal. ``from_subaccount`` and ``to_subaccount`` use ``0`` for - the primary account and ``1-32`` for numbered subaccounts. + a Decimal. ``from_subaccount`` / ``to_subaccount`` use ``0`` for the + primary account and a positive integer for numbered subaccounts. The + server is the source of truth for the upper bound: spec describes + ``1-32`` in prose but defines no JSON-schema maximum, and demo has + been observed allocating values above 32. The SDK validates only the + lower bound (``ge=0``) so server-assigned numbers always round-trip. """ client_transfer_id: UUID - from_subaccount: int = Field(ge=0, le=32) - to_subaccount: int = Field(ge=0, le=32) + from_subaccount: int = Field(ge=0) + to_subaccount: int = Field(ge=0) amount_cents: int = Field(gt=0) model_config = {"extra": "forbid"} @@ -79,7 +83,7 @@ class SubaccountTransfer(BaseModel): class UpdateSubaccountNettingRequest(BaseModel): """Body for PUT /portfolio/subaccounts/netting.""" - subaccount_number: int = Field(ge=0, le=32) + subaccount_number: int = Field(ge=0) enabled: bool model_config = {"extra": "forbid"} diff --git a/kalshi/resources/subaccounts.py b/kalshi/resources/subaccounts.py index 4080daa..1ebfb83 100644 --- a/kalshi/resources/subaccounts.py +++ b/kalshi/resources/subaccounts.py @@ -90,7 +90,9 @@ def _build_update_netting_body( class SubaccountsResource(SyncResource): """Sync subaccounts API. - Subaccount 0 is the primary account; 1-32 are numbered subaccounts. + Subaccount 0 is the primary account; positive integers identify numbered + subaccounts (spec prose says ``1-32`` but defines no JSON-schema upper + bound, and demo has been observed allocating numbers above 32). POST /portfolio/subaccounts spins up the next subaccount with an empty body (spec takes no request payload). """ diff --git a/tests/test_subaccounts.py b/tests/test_subaccounts.py index a09ebb6..38bacaa 100644 --- a/tests/test_subaccounts.py +++ b/tests/test_subaccounts.py @@ -190,15 +190,21 @@ def test_transfer_request_rejects_zero_amount(self) -> None: amount_cents=0, ) - def test_transfer_request_rejects_out_of_range_subaccount(self) -> None: - with pytest.raises(ValidationError): - ApplySubaccountTransferRequest( - client_transfer_id=_TEST_XFER_ID, - from_subaccount=0, - to_subaccount=33, - amount_cents=100, - ) - + def test_transfer_request_accepts_subaccount_above_32(self) -> None: + """Regression guard for #164: demo allocates subaccount numbers above 32. + + Spec describes ``1-32`` in prose but defines no JSON-schema maximum, + and an integration test caught the SDK rejecting a server-assigned 41 + before the request could leave the client. The SDK validates only the + lower bound (``ge=0``); the server is the source of truth on the upper. + """ + req = ApplySubaccountTransferRequest( + client_transfer_id=_TEST_XFER_ID, + from_subaccount=0, + to_subaccount=41, + amount_cents=100, + ) + assert req.to_subaccount == 41 def test_update_netting_request_serializes(self) -> None: req = UpdateSubaccountNettingRequest(subaccount_number=2, enabled=True) body = req.model_dump(exclude_none=True, by_alias=True, mode="json")