Repository navigation
Description
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 tospecs/perps_openapi.yamlby 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
DollarDecimalorFixedPointCount. The transfer amounts are plain integers:IntraExchangeInstanceTransferRequest.amountis centicents (integer,int64) andApplySubaccountTransferRequest.amount_centsis cents (integer,int64). Do not introduceDollarDecimalhere — that would be a wire mismatch. This matches the existing event-contractApplySubaccountTransferRequestwhich already typesamount_centsasStrictInt.
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, 1–32 = 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(specExchangeInstance, line 1447) —type: string,enum: [event_contract, margined]. Define asExchangeInstanceLiteral = Literal["event_contract", "margined"](mirror theSettlementStatusLiteralpattern inkalshi/models/portfolio.py).ExchangeIndex(specExchangeIndex, line 1441) —type: integer, "Identifier for an exchange shard. Defaults to 0 if unspecified. Note: currently only 0 supported." Model as a plainintfield (default0); 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). UseStrictInt(fromkalshi.types) to rejectbool, matching the event-contract subaccounts model.source_exchange_shard: StrictInt = Field(default=0, ge=0)—ExchangeIndex, "Source exchange shard index (default 0)". Optional (spec hasdefault: 0).serialization_aliasnot needed — wire name matches.destination_exchange_shard: StrictInt = Field(default=0, ge=0)—ExchangeIndex, "Destination exchange shard index (default 0)". Optional.- No
_dollars/_fpaliases anywhere here. NoDollarDecimal. - Note: because shards default to
0and the body is serialized withexclude_none=True(notexclude_defaults), shard0values WILL be emitted on the wire. That is fine — spec default is0and only0is currently supported.
ApplySubaccountTransferRequest (spec line 1328; required: client_transfer_id, from_subaccount, to_subaccount, amount_cents):
client_transfer_id: UUID—format: 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::ApplySubaccountTransferRequestexactly (same field types, samege=0/gt=0bounds, sameextra="forbid"). Spec prose says1-32but 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 returnNone(mirror the event-contracttransfer()which returnsNone). List the schema inschemas_coveredfor 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/AsyncResourcecome fromkalshi/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](coerceclient_transfer_id: UUID | strtoUUIDbefore 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_subaccountmust sendjson={}to forceContent-Type: application/jsoneven though the spec defines no request body — the existing event-contractSubaccountsResource.createdocuments that demo rejects the bodyless POST withinvalid_content_type. Apply the same workaround. Response is HTTP 201; confirm_base's_posttreats 201 as success (it does for 2xx; verify against_base_client.pywhile implementing).- Method naming: use
transfer_instance/transfer_subaccount/create_subaccountto disambiguate the two transfer endpoints within one resource (the event-contract resource only had onetransfer, so a baretransfername would be ambiguous here). - Inside the resource classes, annotate any local
listusage asbuiltins.list[T](the.list()-method shadow rule) — though this resource defines nolistmethods, 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/ApplySubaccountTransferRequest→kalshi.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 againstspecs/perps_openapi.yaml. Confirm the foundation issue's harness shape and follow it; if a single sharedBODY_MODEL_MAPis 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_shardfields onIntraExchangeInstanceTransferRequestare 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'sjson={}(a body sent where the spec defines norequestBody), add anExclusion(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-contractSubaccountsResource.createentry 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 —
respxmocksPOST /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 parsedIntraExchangeInstanceTransferResponse.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 <= 0raisesValidationError(gt=0); negative shard raises (ge=0); passing bothrequest=and unpacked kwargs raises via_check_request_exclusive; missing required kwargs (norequest, nosource) raisesTypeError.
- happy path —
create_subaccount:- happy path — mock 201 →
{"subaccount_number": 5}; assert request carriedContent-Type: application/jsonand an empty{}body; assertCreateSubaccountResponse.subaccount_number == 5. - error path — 401 unauthorized maps to the SDK auth exception; calling without configured auth raises before any HTTP (assert
_require_authfires, no request recorded by respx).
- happy path — mock 201 →
transfer_subaccount:- happy path — mock
POST /portfolio/margin/subaccounts/transfer→200 {}; assert body equals{"client_transfer_id": "<uuid>", "from_subaccount": 0, "to_subaccount": 3, "amount_cents": 500}and the method returnsNone. client_transfer_idaccepts bothUUIDandstr(coercion); malformedstrraisesValueErrorbefore the HTTP call.- error path — 400 maps to bad-request error.
- edge —
amount_cents <= 0raises (gt=0); negative subaccount raises (ge=0);extra="forbid"rejects a phantom key when building from a dict-spread.
- happy path — mock
- Drift:
TestRequestParamDrift/TestRequestBodyDrift(parameterized over the perps spec) must stay green for all three newMETHOD_ENDPOINT_MAPentries.
Acceptance criteria
-
TransfersResourceandAsyncTransfersResourceimplemented inkalshi/perps/resources/transfers.pywithtransfer_instance,create_subaccount,transfer_subaccount(sync + async). - Models in
kalshi/perps/models/transfers.py:IntraExchangeInstanceTransferRequest(+extra="forbid"),IntraExchangeInstanceTransferResponse,CreateSubaccountResponse,ApplySubaccountTransferRequest(+extra="forbid"), andExchangeInstanceLiteral.ApplySubaccountTransferResponsehandled asNone(no model). Amounts are integerStrictInt(centicents/cents) — noDollarDecimal. - Resource wired into
PerpsClient/AsyncPerpsClient(e.g.client.transfers) per the foundation client shape. - Public exports flow
kalshi/perps/models/__init__.py→kalshi/perps/__init__.py→kalshi/__init__.pyfor the request/response models andExchangeInstanceLiteral. -
METHOD_ENDPOINT_MAPhas the three perps entries;BODY_MODEL_MAP(or perps-specific body map) has the two request-body refs; spec-ref collision with the event-contractApplySubaccountTransferRequestresolved per the foundation harness shape. -
uv run mypy kalshi/strict clean (usebuiltins.list[T]inside resource classes if anylistannotation is added). -
uv run ruff check .clean. -
uv run pytest tests/ -vgreen, includingtests/perps/test_transfers.pyand the parameterizedTestRequestParamDrift/TestRequestBodyDriftover 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/v2prod /https://external-api.demo.kalshi.co/trade-api/v2demo), reuse ofKalshiAuth+SyncTransport/AsyncTransport, the committedspecs/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.
Activity