Skip to content

perps: transfers & subaccounts resource — intra-exchange-instance transfer, create/transfer margin subaccount #396

Description

@TexasCoding

Epic: part of #387 · Depends on: #388

Summary

Add the PERPS (margin) transfers & subaccounts resource to the new standalone PerpsClient / AsyncPerpsClient: move funds between the event-contract and margined exchange instances (POST /portfolio/intra_exchange_instance_transfer), create a new margin subaccount (POST /portfolio/margin/subaccounts), and move funds between margin subaccounts 0–32 (POST /portfolio/margin/subaccounts/transfer). All three live on the perps base host under /portfolio/* and reuse the existing RSA-PSS auth + transport machinery. This mirrors the existing event-contract kalshi/resources/subaccounts.py resource but targets the perps spec and host.

Spec source: /tmp/perps_openapi.yaml → committed to specs/perps_openapi.yaml by the foundation issue. Operation/schema definitions cited below are from that file (paths at lines 944–1036; schemas at lines 1328–1539).

Important units note: Despite the project's general "prices are FixedPointDollars" convention, none of these three request bodies use DollarDecimal or FixedPointCount. The transfer amounts are plain integers: IntraExchangeInstanceTransferRequest.amount is centicents (integer, int64) and ApplySubaccountTransferRequest.amount_cents is cents (integer, int64). Do not introduce DollarDecimal here — that would be a wire mismatch. This matches the existing event-contract ApplySubaccountTransferRequest which already types amount_cents as StrictInt.

Endpoints / scope

Method Path operationId Notes
POST /portfolio/intra_exchange_instance_transfer IntraExchangeInstanceTransfer Move funds between event_contract and margined exchange instances. Request body IntraExchangeInstanceTransferRequest; returns IntraExchangeInstanceTransferResponse (200). Spec description: "This endpoint is currently not available." — implement anyway (forward-compatible), but document the not-yet-available caveat in the docstring.
POST /portfolio/margin/subaccounts CreateMarginSubaccount No request body (auth headers only). Returns CreateSubaccountResponse with HTTP 201. Max 32 subaccounts/user; numbered sequentially from 1.
POST /portfolio/margin/subaccounts/transfer ApplyMarginSubaccountTransfer Move funds between subaccounts. 0 = primary, 132 = numbered. Request body ApplySubaccountTransferRequest; returns ApplySubaccountTransferResponse (empty object, 200).

Auth: all three require kalshiAccessKey / kalshiAccessSignature / kalshiAccessTimestamp (the standard RSA-PSS signing the foundation PerpsClient already wires through the reused KalshiAuth + transport).

Models

New file: kalshi/perps/models/transfers.py

These FQNs are distinct from the existing kalshi.models.subaccounts.* (event-contract) models even though two schema names collide (CreateSubaccountResponse, ApplySubaccountTransferRequest). Keep them in the perps package — do not reuse or re-export the event-contract ones.

Enums

  • ExchangeInstance (spec ExchangeInstance, line 1447) — type: string, enum: [event_contract, margined]. Define as ExchangeInstanceLiteral = Literal["event_contract", "margined"] (mirror the SettlementStatusLiteral pattern in kalshi/models/portfolio.py).
  • ExchangeIndex (spec ExchangeIndex, line 1441) — type: integer, "Identifier for an exchange shard. Defaults to 0 if unspecified. Note: currently only 0 supported." Model as a plain int field (default 0); no separate type alias needed — annotate the shard fields directly.

Request models (model_config = {"extra": "forbid"})

IntraExchangeInstanceTransferRequest (spec line 1505; required: source, destination, amount):

  • source: ExchangeInstanceLiteral — "The source exchange instance".
  • destination: ExchangeInstanceLiteral — "The destination exchange instance".
  • amount: StrictInt = Field(gt=0) — "The amount to transfer in centicents" (integer, int64). Use StrictInt (from kalshi.types) to reject bool, matching the event-contract subaccounts model.
  • source_exchange_shard: StrictInt = Field(default=0, ge=0)ExchangeIndex, "Source exchange shard index (default 0)". Optional (spec has default: 0). serialization_alias not needed — wire name matches.
  • destination_exchange_shard: StrictInt = Field(default=0, ge=0)ExchangeIndex, "Destination exchange shard index (default 0)". Optional.
  • No _dollars/_fp aliases anywhere here. No DollarDecimal.
  • Note: because shards default to 0 and the body is serialized with exclude_none=True (not exclude_defaults), shard 0 values WILL be emitted on the wire. That is fine — spec default is 0 and only 0 is currently supported.

ApplySubaccountTransferRequest (spec line 1328; required: client_transfer_id, from_subaccount, to_subaccount, amount_cents):

  • client_transfer_id: UUIDformat: uuid, "Unique client-provided transfer ID for idempotency."
  • from_subaccount: StrictInt = Field(ge=0) — "Source subaccount number (0 for primary, 1-32 for numbered subaccounts)."
  • to_subaccount: StrictInt = Field(ge=0) — "Destination subaccount number (0 for primary, 1-32)."
  • amount_cents: StrictInt = Field(gt=0)int64, "Amount to transfer in cents."
  • Mirror the existing kalshi/models/subaccounts.py::ApplySubaccountTransferRequest exactly (same field types, same ge=0/gt=0 bounds, same extra="forbid"). Spec prose says 1-32 but defines no JSON-schema maximum; validate only the lower bound so server-assigned numbers round-trip (same rationale already documented in the event-contract model).

Response models (model_config = {"extra": "allow"})

IntraExchangeInstanceTransferResponse (spec line 1532; required: transfer_id):

  • transfer_id: str — "The ID of the transfer that was created".

CreateSubaccountResponse (spec line 1415; required: subaccount_number):

  • subaccount_number: int — "The sequential number assigned to this subaccount (1-32)."

ApplySubaccountTransferResponse (spec line 1356):

  • type: object, "Empty response indicating successful transfer." No properties. Do not define a model that parses it — the resource method should return None (mirror the event-contract transfer() which returns None). List the schema in schemas_covered for traceability but it has no Python model.

Resource methods

New file: kalshi/perps/resources/transfers.py — both TransfersResource(SyncResource) and AsyncTransfersResource(AsyncResource), mirroring kalshi/resources/subaccounts.py (overloaded request= vs. unpacked-kwargs signatures, shared module-level _build_*_body helpers, self._require_auth() first line, body via model.model_dump(exclude_none=True, by_alias=True, mode="json")).

SyncResource / AsyncResource come from kalshi/resources/_base.py (reused, not re-implemented). The foundation issue is responsible for making the perps client construct these resources against the perps transport/host.

Shared body builders (module-level, mirror _build_transfer_body in the event-contract file; reuse _check_request_exclusive from _base):

  • _build_instance_transfer_body(request, *, source, destination, amount, source_exchange_shard, destination_exchange_shard) -> dict[str, Any]
  • _build_subaccount_transfer_body(request, *, client_transfer_id, from_subaccount, to_subaccount, amount_cents) -> dict[str, Any] (coerce client_transfer_id: UUID | str to UUID before constructing the model, exactly like the event-contract builder).

Sync (TransfersResource(SyncResource))

@overload
def transfer_instance(self, *, request: IntraExchangeInstanceTransferRequest,
                      extra_headers: dict[str, str] | None = None) -> IntraExchangeInstanceTransferResponse: ...
@overload
def transfer_instance(self, *, source: ExchangeInstanceLiteral, destination: ExchangeInstanceLiteral,
                      amount: int, source_exchange_shard: int = 0, destination_exchange_shard: int = 0,
                      extra_headers: dict[str, str] | None = None) -> IntraExchangeInstanceTransferResponse: ...
def transfer_instance(self, *, request=None, source=None, destination=None, amount=None,
                      source_exchange_shard=0, destination_exchange_shard=0,
                      extra_headers=None) -> IntraExchangeInstanceTransferResponse:
    # POST /portfolio/intra_exchange_instance_transfer -> IntraExchangeInstanceTransferResponse.model_validate(data)

def create_subaccount(self, *, extra_headers: dict[str, str] | None = None) -> CreateSubaccountResponse:
    # POST /portfolio/margin/subaccounts with json={} (see Content-Type note) -> CreateSubaccountResponse.model_validate(data)

@overload
def transfer_subaccount(self, *, request: ApplySubaccountTransferRequest,
                        extra_headers: dict[str, str] | None = None) -> None: ...
@overload
def transfer_subaccount(self, *, client_transfer_id: UUID | str, from_subaccount: int, to_subaccount: int,
                        amount_cents: int, extra_headers: dict[str, str] | None = None) -> None: ...
def transfer_subaccount(self, *, request=None, client_transfer_id=None, from_subaccount=None,
                        to_subaccount=None, amount_cents=None, extra_headers=None) -> None:
    # POST /portfolio/margin/subaccounts/transfer  (returns None — empty response body)

Async (AsyncTransfersResource(AsyncResource))

Same three methods as async def, awaiting self._post(...). create_subaccount and transfer_instance return the parsed model; transfer_subaccount returns None. No paginated endpoints in this issue, so no list_all() / AsyncIterator here.

Notes:

  • create_subaccount must send json={} to force Content-Type: application/json even though the spec defines no request body — the existing event-contract SubaccountsResource.create documents that demo rejects the bodyless POST with invalid_content_type. Apply the same workaround. Response is HTTP 201; confirm _base's _post treats 201 as success (it does for 2xx; verify against _base_client.py while implementing).
  • Method naming: use transfer_instance / transfer_subaccount / create_subaccount to disambiguate the two transfer endpoints within one resource (the event-contract resource only had one transfer, so a bare transfer name would be ambiguous here).
  • Inside the resource classes, annotate any local list usage as builtins.list[T] (the .list()-method shadow rule) — though this resource defines no list methods, keep the import discipline if needed.

Contract-test wiring

The contract harness must already be parameterized by the foundation issue to load specs/perps_openapi.yaml for perps method entries (flagged as a foundation dependency). Add these to the perps section of METHOD_ENDPOINT_MAP (tests/_contract_support.py):

# ── perps: transfers & subaccounts ───────────────────────────────────────
MethodEndpointEntry(
    sdk_method="kalshi.perps.resources.transfers.TransfersResource.transfer_instance",
    http_method="POST",
    path_template="/portfolio/intra_exchange_instance_transfer",
    request_body_schema="#/components/schemas/IntraExchangeInstanceTransferRequest",
),
MethodEndpointEntry(
    sdk_method="kalshi.perps.resources.transfers.TransfersResource.create_subaccount",
    http_method="POST",
    path_template="/portfolio/margin/subaccounts",
    # no request_body_schema — CreateMarginSubaccount has no requestBody
),
MethodEndpointEntry(
    sdk_method="kalshi.perps.resources.transfers.TransfersResource.transfer_subaccount",
    http_method="POST",
    path_template="/portfolio/margin/subaccounts/transfer",
    request_body_schema="#/components/schemas/ApplySubaccountTransferRequest",
),

BODY_MODEL_MAP (tests/test_contracts.py) — map each perps request-body spec-ref to the perps model FQN:

"#/components/schemas/IntraExchangeInstanceTransferRequest": (
    "kalshi.perps.models.transfers.IntraExchangeInstanceTransferRequest"
),
"#/components/schemas/ApplySubaccountTransferRequest": (
    "kalshi.perps.models.transfers.ApplySubaccountTransferRequest"
),

Spec-ref collision risk: the event-contract spec already registers #/components/schemas/ApplySubaccountTransferRequestkalshi.models.subaccounts.ApplySubaccountTransferRequest. Because perps uses a separate spec file, the foundation-parameterized harness must key body-drift diffs by (spec file, ref), not ref alone, OR the perps body map must be a separate dict resolved against specs/perps_openapi.yaml. Confirm the foundation issue's harness shape and follow it; if a single shared BODY_MODEL_MAP is kept, the duplicate ref string is a hard conflict and the perps entries must move to the perps-specific map. Flag/verify this against the foundation implementation before adding the entries above.

EXCLUSIONS (tests/_contract_support.py):

  • The two *_exchange_shard fields on IntraExchangeInstanceTransferRequest are real body params present in both the model and the spec — no exclusion needed (they are not extra).
  • If the drift classifier flags create_subaccount's json={} (a body sent where the spec defines no requestBody), add an Exclusion(kind="client_only", reason="CreateMarginSubaccount defines no requestBody; SDK sends json={} only to force Content-Type: application/json — demo rejects bodyless POST with invalid_content_type. No wire fields."). Verify whether the harness needs this before adding it (the event-contract SubaccountsResource.create entry carries no body schema and is not excluded today, so likely none is needed).

Tests

New file: tests/perps/test_transfers.py (mirror tests/test_subaccounts.py; use respx.mock; reuse the RSA-key/auth/config fixtures from tests/conftest.py, parameterized for the perps host as the foundation issue establishes). Cover sync and async for each method.

  • transfer_instance:
    • happy path — respx mocks POST /portfolio/intra_exchange_instance_transfer{"transfer_id": "abc"}; assert request JSON body equals {"source": "event_contract", "destination": "margined", "amount": 100, "source_exchange_shard": 0, "destination_exchange_shard": 0} and that the parsed IntraExchangeInstanceTransferResponse.transfer_id == "abc".
    • request=-object overload produces identical body.
    • error path — 403 ForbiddenError (endpoint "currently not available") maps to the SDK exception; 400 maps to bad-request error.
    • edge — amount <= 0 raises ValidationError (gt=0); negative shard raises (ge=0); passing both request= and unpacked kwargs raises via _check_request_exclusive; missing required kwargs (no request, no source) raises TypeError.
  • create_subaccount:
    • happy path — mock 201 → {"subaccount_number": 5}; assert request carried Content-Type: application/json and an empty {} body; assert CreateSubaccountResponse.subaccount_number == 5.
    • error path — 401 unauthorized maps to the SDK auth exception; calling without configured auth raises before any HTTP (assert _require_auth fires, no request recorded by respx).
  • transfer_subaccount:
    • happy path — mock POST /portfolio/margin/subaccounts/transfer200 {}; assert body equals {"client_transfer_id": "<uuid>", "from_subaccount": 0, "to_subaccount": 3, "amount_cents": 500} and the method returns None.
    • client_transfer_id accepts both UUID and str (coercion); malformed str raises ValueError before the HTTP call.
    • error path — 400 maps to bad-request error.
    • edge — amount_cents <= 0 raises (gt=0); negative subaccount raises (ge=0); extra="forbid" rejects a phantom key when building from a dict-spread.
  • Drift: TestRequestParamDrift / TestRequestBodyDrift (parameterized over the perps spec) must stay green for all three new METHOD_ENDPOINT_MAP entries.

Acceptance criteria

  • TransfersResource and AsyncTransfersResource implemented in kalshi/perps/resources/transfers.py with transfer_instance, create_subaccount, transfer_subaccount (sync + async).
  • Models in kalshi/perps/models/transfers.py: IntraExchangeInstanceTransferRequest (+ extra="forbid"), IntraExchangeInstanceTransferResponse, CreateSubaccountResponse, ApplySubaccountTransferRequest (+ extra="forbid"), and ExchangeInstanceLiteral. ApplySubaccountTransferResponse handled as None (no model). Amounts are integer StrictInt (centicents/cents) — no DollarDecimal.
  • Resource wired into PerpsClient / AsyncPerpsClient (e.g. client.transfers) per the foundation client shape.
  • Public exports flow kalshi/perps/models/__init__.pykalshi/perps/__init__.pykalshi/__init__.py for the request/response models and ExchangeInstanceLiteral.
  • METHOD_ENDPOINT_MAP has the three perps entries; BODY_MODEL_MAP (or perps-specific body map) has the two request-body refs; spec-ref collision with the event-contract ApplySubaccountTransferRequest resolved per the foundation harness shape.
  • uv run mypy kalshi/ strict clean (use builtins.list[T] inside resource classes if any list annotation is added).
  • uv run ruff check . clean.
  • uv run pytest tests/ -v green, including tests/perps/test_transfers.py and the parameterized TestRequestParamDrift / TestRequestBodyDrift over the perps spec.
  • Contract map updated and consistent (no unregistered-endpoint or missing-body-model failures).

Dependencies

  • perps: foundation — provides PerpsClient/AsyncPerpsClient, PerpsConfig, the perps base host (https://external-api.kalshi.com/trade-api/v2 prod / https://external-api.demo.kalshi.co/trade-api/v2 demo), reuse of KalshiAuth + SyncTransport/AsyncTransport, the committed specs/perps_openapi.yaml, and the contract-test harness parameterized to load that perps spec (including how duplicate spec-ref strings across the two specs are keyed). This issue cannot land its contract wiring until the foundation harness change is in place.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestperpsPerps / margin (perpetual futures) API

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions