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, 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 (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: 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::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/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 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/transfer → 200 {}; 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
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.
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-contractkalshi/resources/subaccounts.pyresource but targets the perps spec and host.Endpoints / scope
/portfolio/intra_exchange_instance_transferIntraExchangeInstanceTransferevent_contractandmarginedexchange instances. Request bodyIntraExchangeInstanceTransferRequest; returnsIntraExchangeInstanceTransferResponse(200). Spec description: "This endpoint is currently not available." — implement anyway (forward-compatible), but document the not-yet-available caveat in the docstring./portfolio/margin/subaccountsCreateMarginSubaccountCreateSubaccountResponsewith HTTP 201. Max 32 subaccounts/user; numbered sequentially from 1./portfolio/margin/subaccounts/transferApplyMarginSubaccountTransfer0= primary,1–32= numbered. Request bodyApplySubaccountTransferRequest; returnsApplySubaccountTransferResponse(empty object, 200).Auth: all three require
kalshiAccessKey/kalshiAccessSignature/kalshiAccessTimestamp(the standard RSA-PSS signing the foundationPerpsClientalready wires through the reusedKalshiAuth+ transport).Models
New file:
kalshi/perps/models/transfers.pyEnums
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._dollars/_fpaliases anywhere here. NoDollarDecimal.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."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— bothTransfersResource(SyncResource)andAsyncTransfersResource(AsyncResource), mirroringkalshi/resources/subaccounts.py(overloadedrequest=vs. unpacked-kwargs signatures, shared module-level_build_*_bodyhelpers,self._require_auth()first line, body viamodel.model_dump(exclude_none=True, by_alias=True, mode="json")).Shared body builders (module-level, mirror
_build_transfer_bodyin the event-contract file; reuse_check_request_exclusivefrom_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))Async (
AsyncTransfersResource(AsyncResource))Same three methods as
async def, awaitingself._post(...).create_subaccountandtransfer_instancereturn the parsed model;transfer_subaccountreturnsNone. No paginated endpoints in this issue, so nolist_all()/AsyncIteratorhere.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).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).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.yamlfor perps method entries (flagged as a foundation dependency). Add these to the perps section ofMETHOD_ENDPOINT_MAP(tests/_contract_support.py):BODY_MODEL_MAP(tests/test_contracts.py) — map each perps request-body spec-ref to the perps model FQN:EXCLUSIONS (
tests/_contract_support.py):*_exchange_shardfields onIntraExchangeInstanceTransferRequestare real body params present in both the model and the spec — no exclusion needed (they are not extra).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(mirrortests/test_subaccounts.py; userespx.mock; reuse the RSA-key/auth/config fixtures fromtests/conftest.py, parameterized for the perps host as the foundation issue establishes). Cover sync and async for each method.transfer_instance: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.ForbiddenError(endpoint "currently not available") maps to the SDK exception; 400 maps to bad-request error.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.create_subaccount:{"subaccount_number": 5}; assert request carriedContent-Type: application/jsonand an empty{}body; assertCreateSubaccountResponse.subaccount_number == 5._require_authfires, no request recorded by respx).transfer_subaccount: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.amount_cents <= 0raises (gt=0); negative subaccount raises (ge=0);extra="forbid"rejects a phantom key when building from a dict-spread.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).kalshi/perps/models/transfers.py:IntraExchangeInstanceTransferRequest(+extra="forbid"),IntraExchangeInstanceTransferResponse,CreateSubaccountResponse,ApplySubaccountTransferRequest(+extra="forbid"), andExchangeInstanceLiteral.ApplySubaccountTransferResponsehandled asNone(no model). Amounts are integerStrictInt(centicents/cents) — noDollarDecimal.PerpsClient/AsyncPerpsClient(e.g.client.transfers) per the foundation client shape.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.Dependencies
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.