From fc5483c348350f337a6678666f799990ba5ed6f9 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Mon, 9 Nov 2026 09:01:43 -0600 Subject: [PATCH 1/3] chore(spec): sync specs for #574 (WIP) --- specs/openapi.yaml | 35 ++++--- specs/perps_asyncapi.yaml | 2 +- specs/perps_openapi.yaml | 192 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 210 insertions(+), 19 deletions(-) diff --git a/specs/openapi.yaml b/specs/openapi.yaml index abf97f8..0d00a26 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -1993,9 +1993,12 @@ paths: operationId: GetPositions summary: Get Positions description: | - Returns unsettled positions by default. Set `settlement_status=settled` to page through settled positions that are still in the live data set, or `settlement_status=all` for both live states. Positions already archived are available via `GET /historical/positions`. - Settled and all-state positions are paginated by event ticker to keep large histories bounded. Cursors are specific to the selected settlement status. - `count_filter` restricts positions to those with a non-zero position or total traded count. + Returns unsettled positions by default, including closed markets awaiting determination or settlement. These may retain non-zero quantities and cost basis. Check the market's `status` via `GET /markets/{ticker}` for trading status. + + Set `settlement_status=settled` for unarchived settled positions or `all` for both states. These results paginate by event ticker; cursors are specific to settlement status. Archived positions are available via `GET /historical/positions`. + + Use `count_filter=position` for non-zero net holdings, including closed, unsettled markets. Without a count filter, results may include fully exited positions with trading history, realized P&L or fees. + Registered partners may also use a user OAuth access token with the explicitly granted read::compliance_partner scope. tags: - portfolio @@ -2031,13 +2034,16 @@ paths: get: operationId: GetSettlements summary: Get Settlements - description: 'Returns settlement records still in the live data set. Archived positions are available via `GET /historical/positions`, which has different fields; this endpoint does not return archived settlement records.' + description: | + Returns settlement records still in the live data set. Archived positions are available via `GET /historical/positions`, which has different fields; this endpoint does not return archived settlement records. + Resource-bound MCP OAuth tokens may read the owner's settlements with read::mcp_portfolio. tags: - portfolio security: - kalshiAccessKey: [] kalshiAccessSignature: [] kalshiAccessTimestamp: [] + - kalshiOauthAccessToken: [] parameters: - $ref: '#/components/parameters/LimitQuery' - $ref: '#/components/parameters/CursorQuery' @@ -2347,7 +2353,7 @@ paths: post: operationId: AcceptBlockTradeProposal summary: Accept Block Trade Proposal - description: ' Endpoint for accepting a block trade proposal.' + description: ' Endpoint for accepting a block trade proposal. The caller accepts as whichever side it is named on, and may book that side onto any of its own subaccounts, including one the proposal did not name. Without a subaccount, the side keeps the one the proposal named. A side that has already accepted may accept again to move to another subaccount until the trade executes.' tags: - communications security: @@ -5447,14 +5453,15 @@ components: CountFilterQuery: name: count_filter in: query - description: Restricts the positions to those with any of following fields with non-zero values, as a comma separated list. The following values are accepted - position, total_traded + description: | + Comma-separated filters combined with OR: `position` (non-zero net contracts), `total_traded` (any contracts traded, including fully exited positions). Omit for no count filter. schema: type: string PositionSettlementStatusQuery: name: settlement_status in: query - description: Return unsettled positions by default, settled positions that have not yet been archived, or all positions in the live data set. + description: 'Filters settlement state, not trading status: `unsettled` (default), `settled` (not yet archived), or `all` (both live states).' schema: type: string enum: [unsettled, settled, all] @@ -5943,6 +5950,10 @@ components: name: type: string description: User-provided name for the API key + key_type: + allOf: + - $ref: '#/components/schemas/ApiKeyType' + description: Signing algorithm of this key, derived from its stored public key. Present for RSA and Ed25519 keys, including existing keys. Omitted when the algorithm is unknown. scopes: type: array description: List of scopes granted to this API key. @@ -7893,13 +7904,13 @@ components: $ref: '#/components/schemas/ExchangeIndex' total_traded_dollars: $ref: '#/components/schemas/FixedPointDollars' - description: Total spent on this market in dollars + description: Total spent trading this market in dollars, including past trades; not the remaining position's cost or value. position_fp: $ref: '#/components/schemas/FixedPointCount' - description: String representation of the number of contracts bought in this market. Negative means NO contracts and positive means YES contracts + description: 'Signed net contracts as a fixed-point string: positive for YES, negative for NO, zero for no net position.' market_exposure_dollars: $ref: '#/components/schemas/FixedPointDollars' - description: Cost of the aggregate market position in dollars + description: Cost basis of the remaining position in dollars, excluding fees; not current market value or realized loss. realized_pnl_dollars: $ref: '#/components/schemas/FixedPointDollars' description: Locked in profit and loss, in dollars @@ -9048,13 +9059,13 @@ components: properties: subtrader_id: type: string - description: Subtrader ID to accept as. Provide either this or subaccount, not both. + description: Subtrader ID to book your side onto. It may differ from the one the proposal named. Omit both this and subaccount to keep the proposal's. Provide either this or subaccount, not both. x-go-type-skip-optional-pointer: true subaccount: type: integer minimum: 0 maximum: 63 - description: User-managed subaccount number to accept as (0 for primary, 1-63 for numbered subaccounts). Provide either this or subtrader_id, not both. + description: User-managed subaccount number to book your side onto (0 for primary, 1-63 for numbered subaccounts). It may differ from the one the proposal named. Omit both this and subtrader_id to keep the proposal's. Provide either this or subtrader_id, not both. RFQ: type: object diff --git a/specs/perps_asyncapi.yaml b/specs/perps_asyncapi.yaml index aa2a05f..c7f5df8 100644 --- a/specs/perps_asyncapi.yaml +++ b/specs/perps_asyncapi.yaml @@ -1246,7 +1246,7 @@ components: enum: ["activated", "deactivated"] is_deactivated: type: boolean - description: Whether trading on the market is paused. + description: Whether trading on the market is paused, because it is halted or outside its trading hours. sending_ts_ms: $ref: '#/components/schemas/sendingTimestampMs' diff --git a/specs/perps_openapi.yaml b/specs/perps_openapi.yaml index b221679..3328b2a 100644 --- a/specs/perps_openapi.yaml +++ b/specs/perps_openapi.yaml @@ -235,6 +235,115 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /margin/fcm/positions/transfers: + post: + operationId: CreateMarginFCMPositionTransfer + summary: Create Margin FCM Position Transfer + description: | + Endpoint for FCM members to move a perps position in one market from one of their subtraders + into a subtrader under their FCM's error account. + + **This endpoint must be enabled for your FCM by Kalshi.** Kalshi enables it by configuring the + error account that receives transferred positions and gives you that account's user id, which + prefixes `target_subtrader_id`. Contact Kalshi to enable it; until then, requests return 400. + Position transfers are also switched on per exchange; while they are off, requests return 409. + + - The destination is always your FCM's error account. `target_subtrader_id` picks the subtrader + under it, which is created on first use. + - Each request moves one market for one source subtrader. To move many positions, send one + request per subtrader and market. + - No cash moves when a transfer is booked. Both sides' positions and cost basis change as for a + fee-free trade at `price`: the source closes at `price` and the error account opens at + `price`. Both settle in the next settlement cycle: the source realizes the difference + between `price` and the market's last settlement mark, and the error account's variation + margin runs from `price` to the next settlement mark. To make a customer whole, use the + price of the fill being reversed. + - `side` and `count` describe the position being moved: `long` moves a long of `count` + contracts, `short` moves a short. `count` must be a whole number of contracts. The source's + current position is not checked; moving more than it holds, or the other side, flips or + grows its position. + - `client_transfer_id` makes the request idempotent per source subtrader: once a transfer + with that id has been booked from `source_subtrader_id`, a later request with the same id + from that subtrader, to any target, books nothing unless the exchange has restarted in + between. The exchange checks the id only after it re-checks the transfer switch, trading + status and both accounts' current risk, so a repeat returns 409 with error code + `transfer_already_applied` only when it passes those checks. Otherwise it gets the error + for the check it failed, such as 400 `insufficient_balance` once the earlier transfer has + used the error account's margin, or 409 while trading is paused. + - After a timeout or 5xx, retry with the same id. `transfer_already_applied` confirms that the + earlier attempt was booked. Any other error refuses only the retry and leaves the earlier + attempt's outcome unknown: check the source subtrader's position before you send the + transfer again under a new id. + - The exchange does not keep transfer ids across a restart, such as a maintenance window, so a + retry sent after one can book the transfer a second time. If the exchange may have + restarted since the first attempt, check the source subtrader's position before retrying, + even with the same id. + - The market must have traded at least once and trading must not be paused; otherwise the + request returns 409. Both accounts must also pass the exchange's risk checks for their + post-transfer positions (available margin, FCM initial-margin caps, notional risk limits); + a failed risk check returns 400 with the exchange's reason, such as `insufficient_balance`, + and a blocked account returns 409. + - Transfers appear on the daily statement and in the `position_transfer_audit` report. They + never appear as fills or in `trade_audit`. + + **Example.** A customer subtrader holds 10 long in `KXBTCPERP`, filled at $63,000. Send + `side` `long`, `count` `10`, `price` `63000.00`: the customer goes from 10 long to flat and the + error account subtrader from flat to 10 long, both at $63,000, with no cash moving. + tags: + - fcm + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateMarginFCMPositionTransferRequest' + examples: + move_long: + summary: Move 10 long at $63,000 + value: + source_subtrader_id: 3f2a8c1e-5b7d-4e9a-8c6f-1d2b3a4e5f60_CUST123 + target_subtrader_id: 9b8c7d6e-5f4a-4b3c-9d2e-1f0a9b8c7d6e_ERR1 + ticker: KXBTCPERP + side: long + count: "10" + price: "63000.00" + client_transfer_id: 6d1f0c5a-2b3e-4f7a-9c8d-0e1f2a3b4c5d + move_short_by_market_id: + summary: Move 5 short at $4,500, selecting the market by id + value: + source_subtrader_id: 3f2a8c1e-5b7d-4e9a-8c6f-1d2b3a4e5f60_CUST123 + target_subtrader_id: 9b8c7d6e-5f4a-4b3c-9d2e-1f0a9b8c7d6e_ERR1 + market_id: 0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d + side: short + count: "5" + price: "4500.00" + client_transfer_id: 7e2a1d6b-3c4f-4a8b-9d0e-1f2a3b4c5d6e + responses: + '201': + description: Position transferred + content: + application/json: + schema: + $ref: '#/components/schemas/CreateMarginFCMPositionTransferResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '404': + $ref: '#/components/responses/NotFoundError' + '409': + $ref: '#/components/responses/ConflictError' + '429': + $ref: '#/components/responses/RateLimitError' + '500': + $ref: '#/components/responses/InternalServerError' + /margin/fcm/notional_risk_limit: put: operationId: UpdateFCMNotionalRiskLimit @@ -377,13 +486,16 @@ paths: get: operationId: GetMarginOrders summary: Get Orders - description: 'Endpoint for listing margin orders with optional filtering.' + description: | + Endpoint for listing margin orders with optional filtering. + Resource-bound MCP OAuth tokens may read the owner's orders with read::mcp_portfolio. tags: - orders security: - kalshiAccessKey: [] kalshiAccessSignature: [] kalshiAccessTimestamp: [] + - kalshiOauthAccessToken: [] parameters: - $ref: '#/components/parameters/TickerQuery' - $ref: '#/components/parameters/MinTsQuery' @@ -847,6 +959,7 @@ paths: summary: Get Fills description: | Endpoint for retrieving the authenticated user's margin fills. + Resource-bound MCP OAuth tokens may read the owner's data with read::mcp_portfolio. Registered partners may also use a user OAuth access token with the explicitly granted read::compliance_partner scope. tags: - portfolio @@ -913,6 +1026,7 @@ paths: summary: Get Positions description: | Endpoint for retrieving the authenticated user's margin positions. + Resource-bound MCP OAuth tokens may read the owner's data with read::mcp_portfolio. Registered partners may also use a user OAuth access token with the explicitly granted read::compliance_partner scope. tags: - portfolio @@ -1064,7 +1178,9 @@ paths: get: operationId: GetMarginBalance summary: Get Balance - description: 'Endpoint for retrieving the balance breakdown for the authenticated direct margin user. Returns cash balance (aggregate and per-subaccount), position value, total balance, and maintenance margin requirement.' + description: | + Endpoint for retrieving the balance breakdown for the authenticated direct margin user. Returns cash balance (aggregate and per-subaccount), position value, total balance, and maintenance margin requirement. + Resource-bound MCP OAuth tokens may read the owner's balance with read::mcp_portfolio. x-mint: content: | @@ -1076,6 +1192,7 @@ paths: - kalshiAccessKey: [] kalshiAccessSignature: [] kalshiAccessTimestamp: [] + - kalshiOauthAccessToken: [] parameters: - name: compute_available_balance in: query @@ -1105,13 +1222,16 @@ paths: get: operationId: GetMarginRisk summary: Get Risk - description: 'Endpoint for retrieving leverage and liquidation price data for the authenticated direct margin user. Returns account-level leverage plus per-position leverage and liquidation prices, grouped by subaccount and market.' + description: | + Endpoint for retrieving leverage and liquidation price data for the authenticated direct margin user. Returns account-level leverage plus per-position leverage and liquidation prices, grouped by subaccount and market. + Resource-bound MCP OAuth tokens may read the owner's risk data with read::mcp_portfolio. tags: - risk security: - kalshiAccessKey: [] kalshiAccessSignature: [] kalshiAccessTimestamp: [] + - kalshiOauthAccessToken: [] responses: '200': description: Margin risk data retrieved successfully @@ -2125,6 +2245,66 @@ components: subaccount_number: type: integer description: The sequential number assigned to this subaccount (1-63). + CreateMarginFCMPositionTransferRequest: + type: object + required: + - source_subtrader_id + - target_subtrader_id + - side + - count + - price + - client_transfer_id + properties: + source_subtrader_id: + type: string + description: Full id of the FCM subtrader giving up the position, `{user_id}_{subtrader_suffix}`, as returned by `POST /margin/fcm/subtraders`. + x-oapi-codegen-extra-tags: + validate: required + target_subtrader_id: + type: string + description: Subtrader under your error account that receives the position, `{error_account_id}_{suffix}` with a 1-16 character alphanumeric suffix. Created if it does not exist. + x-oapi-codegen-extra-tags: + validate: required + ticker: + type: string + description: Market ticker. Supply ticker or market_id; ticker takes precedence when both are provided. + x-go-type-skip-optional-pointer: true + market_id: + type: string + description: Stable exchange market UUID. May be supplied instead of ticker. + x-go-type-skip-optional-pointer: true + side: + type: string + enum: [long, short] + description: Side of the position being moved. `long` moves a long position, `short` moves a short one. + x-oapi-codegen-extra-tags: + validate: required,oneof=long short + count: + allOf: + - $ref: '#/components/schemas/FixedPointCount' + description: Contracts to move, as a whole number of exchange contracts (e.g. "10"). + x-oapi-codegen-extra-tags: + validate: required + price: + allOf: + - $ref: '#/components/schemas/FixedPointDollars' + description: Transfer price in dollars per contract with 2-4 decimal places (e.g. "63000.00"), greater than zero. Both sides' cost basis moves at this price. + x-oapi-codegen-extra-tags: + validate: required + client_transfer_id: + type: string + format: uuid + description: Client-chosen UUID that makes the request idempotent for the source subtrader. A repeat from the same source, to any target, returns 409 with code `transfer_already_applied`. + x-oapi-codegen-extra-tags: + validate: required,uuid + CreateMarginFCMPositionTransferResponse: + type: object + required: + - position_transfer_id + properties: + position_transfer_id: + type: string + description: Id of the executed transfer, as it appears in the `position_transfer_audit` report. UpdateFCMNotionalRiskLimitRequest: type: object required: @@ -3404,9 +3584,9 @@ components: SubaccountType: type: string - enum: ['cross', 'isolated', 'auto_rebalance'] - x-enum-varnames: [SubaccountTypeCross, SubaccountTypeIsolated, SubaccountTypeAutoRebalance] - description: How the subaccount is margined. `cross` for the primary account and numbered subaccounts, `isolated` for a market's isolated subaccount, and `auto_rebalance` for an auto-rebalanced isolated position, which shares its market's isolated subaccount number. + enum: ['cross', 'isolated', 'auto_rebalance', 'managed_isolated'] + x-enum-varnames: [SubaccountTypeCross, SubaccountTypeIsolated, SubaccountTypeAutoRebalance, SubaccountTypeManagedIsolated] + description: How the subaccount is margined. `cross` for the primary account and numbered subaccounts, `isolated` for a market's isolated subaccount, `auto_rebalance` for an auto-rebalanced isolated position, which shares its market's isolated subaccount number, and `managed_isolated` for a managed isolated position, whose subaccount number is its slot number rather than the market's isolated subaccount number. LastUpdateReason: type: string From 24135b104400ccc91590edf9efccb85ccae5d2f3 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Mon, 9 Nov 2026 09:11:06 -0600 Subject: [PATCH 2/3] wip(#574): fcm position transfer, key_type, managed_isolated --- kalshi/_contract_map.py | 8 + kalshi/models/api_keys.py | 4 + kalshi/perps/__init__.py | 6 + kalshi/perps/models/__init__.py | 6 + kalshi/perps/models/common.py | 8 +- kalshi/perps/models/fcm.py | 40 ++++- kalshi/perps/resources/fcm.py | 176 +++++++++++++++++++++- tests/_contract_support.py | 6 + tests/perps/test_fcm.py | 252 +++++++++++++++++++++++++++++++- tests/perps/test_portfolio.py | 7 + tests/test_api_keys.py | 18 +++ tests/test_contracts.py | 3 + 12 files changed, 528 insertions(+), 6 deletions(-) diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index ce5402e..a9c3524 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -1046,6 +1046,14 @@ class ContractEntry: sdk_model="kalshi.perps.models.fcm.UpdateFCMNotionalRiskLimitRequest", spec_schema="UpdateFCMNotionalRiskLimitRequest", ), + ContractEntry( + sdk_model="kalshi.perps.models.fcm.CreateMarginFCMPositionTransferRequest", + spec_schema="CreateMarginFCMPositionTransferRequest", + ), + ContractEntry( + sdk_model="kalshi.perps.models.fcm.CreateMarginFCMPositionTransferResponse", + spec_schema="CreateMarginFCMPositionTransferResponse", + ), ContractEntry( sdk_model="kalshi.perps.models.portfolio.ExitTrigger", spec_schema="ExitTrigger", diff --git a/kalshi/models/api_keys.py b/kalshi/models/api_keys.py index 3131a8d..f6d07de 100644 --- a/kalshi/models/api_keys.py +++ b/kalshi/models/api_keys.py @@ -35,6 +35,10 @@ class ApiKey(BaseModel): api_key_id: str name: str + # Signing algorithm of this key, derived from its stored public key. + # Present for RSA and Ed25519 keys, including existing keys. Omitted + # when the algorithm is unknown. + key_type: ApiKeyTypeLiteral | None = None scopes: NullableList[str] # Spec v3.23.0: if set, the key is restricted to this single subaccount # (0-63). Optional — omitted for account-wide keys and by older servers. diff --git a/kalshi/perps/__init__.py b/kalshi/perps/__init__.py index cc9b2f6..e1c39d4 100644 --- a/kalshi/perps/__init__.py +++ b/kalshi/perps/__init__.py @@ -82,7 +82,10 @@ MarginEnabledResponse, ) from kalshi.perps.models.fcm import ( + CreateMarginFCMPositionTransferRequest, + CreateMarginFCMPositionTransferResponse, FCMAssetClassLiteral, + FCMPositionTransferSideLiteral, FCMSubtraderNotionalRiskLimit, FCMSubtraderRiskControls, GetFCMSubtraderRiskControlsResponse, @@ -267,6 +270,8 @@ "CancelMarginOrderResponse", "ConnectionState", "CorporateActionPaymentDetail", + "CreateMarginFCMPositionTransferRequest", + "CreateMarginFCMPositionTransferResponse", "CreateMarginOrderRequest", "CreateMarginOrderResponse", "CreateOrderGroupRequest", @@ -288,6 +293,7 @@ "ExitTriggerReasonLiteral", "ExitTriggerStatusLiteral", "FCMAssetClassLiteral", + "FCMPositionTransferSideLiteral", "FCMSubtraderNotionalRiskLimit", "FCMSubtraderRiskControls", "FeeScheduleLiteral", diff --git a/kalshi/perps/models/__init__.py b/kalshi/perps/models/__init__.py index 420a462..3a2bdc3 100644 --- a/kalshi/perps/models/__init__.py +++ b/kalshi/perps/models/__init__.py @@ -28,7 +28,10 @@ MarginEnabledResponse, ) from kalshi.perps.models.fcm import ( + CreateMarginFCMPositionTransferRequest, + CreateMarginFCMPositionTransferResponse, FCMAssetClassLiteral, + FCMPositionTransferSideLiteral, FCMSubtraderNotionalRiskLimit, FCMSubtraderRiskControls, GetFCMSubtraderRiskControlsResponse, @@ -130,6 +133,8 @@ "BookSide", "BookSideLiteral", "CancelMarginOrderResponse", + "CreateMarginFCMPositionTransferRequest", + "CreateMarginFCMPositionTransferResponse", "CreateMarginOrderRequest", "CreateMarginOrderResponse", "CreateOrderGroupRequest", @@ -150,6 +155,7 @@ "ExitTriggerReasonLiteral", "ExitTriggerStatusLiteral", "FCMAssetClassLiteral", + "FCMPositionTransferSideLiteral", "FCMSubtraderNotionalRiskLimit", "FCMSubtraderRiskControls", "FeeScheduleLiteral", diff --git a/kalshi/perps/models/common.py b/kalshi/perps/models/common.py index 994451e..318623d 100644 --- a/kalshi/perps/models/common.py +++ b/kalshi/perps/models/common.py @@ -103,12 +103,14 @@ class ExchangeInstance(StrEnum): MARGINED = "margined" -SubaccountTypeLiteral = Literal["cross", "isolated", "auto_rebalance"] +SubaccountTypeLiteral = Literal["cross", "isolated", "auto_rebalance", "managed_isolated"] """Spec ``SubaccountType`` — how a subaccount is margined. ``cross`` for the primary account and numbered subaccounts, ``isolated`` for -a market's isolated subaccount, and ``auto_rebalance`` for an auto-rebalanced -isolated position (shares its market's isolated subaccount number). +a market's isolated subaccount, ``auto_rebalance`` for an auto-rebalanced +isolated position (shares its market's isolated subaccount number), and +``managed_isolated`` for a managed isolated position (subaccount number is +its slot number rather than the market's isolated subaccount number). """ diff --git a/kalshi/perps/models/fcm.py b/kalshi/perps/models/fcm.py index 95480d9..8012f28 100644 --- a/kalshi/perps/models/fcm.py +++ b/kalshi/perps/models/fcm.py @@ -3,10 +3,11 @@ from __future__ import annotations from typing import Literal +from uuid import UUID from pydantic import BaseModel, Field -from kalshi.types import DollarDecimal, OrderPrice +from kalshi.types import DollarDecimal, FixedPointCount, OrderPrice FCMAssetClassLiteral = Literal[ "Crypto", @@ -115,3 +116,40 @@ class UpdateFCMNotionalRiskLimitRequest(BaseModel): notional_value_risk_limit: OrderPrice model_config = {"extra": "forbid"} + + +FCMPositionTransferSideLiteral = Literal["long", "short"] +"""Side of a position being moved. Spec inline enum on +``CreateMarginFCMPositionTransferRequest.side``. +""" + + +class CreateMarginFCMPositionTransferRequest(BaseModel): + """Body for POST /margin/fcm/positions/transfers. + + Moves a perps position in one market from a source FCM subtrader into a + subtrader under the FCM's error account. Supply ``ticker`` or + ``market_id`` (ticker wins if both). ``count`` is a FixedPointCount + string of whole contracts; ``price`` is a FixedPointDollars transfer + price per contract (greater than zero). ``client_transfer_id`` is a + required UUID idempotency key per source subtrader. + """ + + source_subtrader_id: str + target_subtrader_id: str + side: FCMPositionTransferSideLiteral + count: FixedPointCount = Field(gt=0) + price: OrderPrice = Field(gt=0) + client_transfer_id: UUID + ticker: str | None = None + market_id: str | None = None + + model_config = {"extra": "forbid"} + + +class CreateMarginFCMPositionTransferResponse(BaseModel): + """Response from POST /margin/fcm/positions/transfers.""" + + position_transfer_id: str + + model_config = {"extra": "allow"} diff --git a/kalshi/perps/resources/fcm.py b/kalshi/perps/resources/fcm.py index 9f024ee..c4ddfb4 100644 --- a/kalshi/perps/resources/fcm.py +++ b/kalshi/perps/resources/fcm.py @@ -1,18 +1,24 @@ """Perps FCM resource — create margin FCM subtraders and manage IM caps. ``POST /margin/fcm/subtraders`` creates a new FCM subtrader under the -authenticated member. Auth required; POST/PUT/DELETE are never retried. +authenticated member. ``POST /margin/fcm/positions/transfers`` moves a +perps position into the FCM error account. Auth required; POST/PUT/DELETE +are never retried. """ from __future__ import annotations from decimal import Decimal from typing import overload +from uuid import UUID from kalshi.perps.models.fcm import ( + CreateMarginFCMPositionTransferRequest, + CreateMarginFCMPositionTransferResponse, CreateMarginFCMSubtraderRequest, CreateMarginFCMSubtraderResponse, FCMAssetClassLiteral, + FCMPositionTransferSideLiteral, GetFCMSubtraderRiskControlsResponse, UpdateFCMNotionalRiskLimitRequest, UpdateFCMSubtraderRiskControlsRequest, @@ -23,9 +29,11 @@ _check_request_exclusive, _params, ) +from kalshi.types import to_decimal _RISK_CONTROLS_PATH = "/margin/fcm/subtraders/risk_controls" _NOTIONAL_RISK_LIMIT_PATH = "/margin/fcm/notional_risk_limit" +_POSITION_TRANSFER_PATH = "/margin/fcm/positions/transfers" def _build_create_subtrader_body( @@ -100,6 +108,61 @@ def _build_update_notional_risk_limit_body( return request.model_dump(exclude_none=True, by_alias=True, mode="json") +def _build_transfer_position_body( + request: CreateMarginFCMPositionTransferRequest | None, + *, + source_subtrader_id: str | None, + target_subtrader_id: str | None, + side: FCMPositionTransferSideLiteral | None, + count: Decimal | int | float | str | None, + price: Decimal | int | float | str | None, + client_transfer_id: UUID | str | None, + ticker: str | None, + market_id: str | None, +) -> dict[str, object]: + _check_request_exclusive( + request, + source_subtrader_id=source_subtrader_id, + target_subtrader_id=target_subtrader_id, + side=side, + count=count, + price=price, + client_transfer_id=client_transfer_id, + ticker=ticker, + market_id=market_id, + ) + if request is None: + if ( + source_subtrader_id is None + or target_subtrader_id is None + or side is None + or count is None + or price is None + or client_transfer_id is None + ): + raise TypeError( + "transfer_position() requires `source_subtrader_id`, " + "`target_subtrader_id`, `side`, `count`, `price`, and " + "`client_transfer_id` (or pass `request=...`)" + ) + uid = ( + client_transfer_id + if isinstance(client_transfer_id, UUID) + else UUID(client_transfer_id) + ) + request = CreateMarginFCMPositionTransferRequest( + source_subtrader_id=source_subtrader_id, + target_subtrader_id=target_subtrader_id, + side=side, + count=to_decimal(count), + price=to_decimal(price), + client_transfer_id=uid, + ticker=ticker, + market_id=market_id, + ) + return request.model_dump(exclude_none=True, by_alias=True, mode="json") + + class FcmResource(SyncResource): """Sync perps FCM API.""" @@ -255,6 +318,64 @@ def delete_notional_risk_limit( self._require_auth() self._delete(_NOTIONAL_RISK_LIMIT_PATH, extra_headers=extra_headers) + @overload + def transfer_position( + self, + *, + request: CreateMarginFCMPositionTransferRequest, + extra_headers: dict[str, str] | None = None, + ) -> CreateMarginFCMPositionTransferResponse: ... + @overload + def transfer_position( + self, + *, + source_subtrader_id: str, + target_subtrader_id: str, + side: FCMPositionTransferSideLiteral, + count: Decimal | int | float | str, + price: Decimal | int | float | str, + client_transfer_id: UUID | str, + ticker: str | None = None, + market_id: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> CreateMarginFCMPositionTransferResponse: ... + def transfer_position( + self, + *, + request: CreateMarginFCMPositionTransferRequest | None = None, + source_subtrader_id: str | None = None, + target_subtrader_id: str | None = None, + side: FCMPositionTransferSideLiteral | None = None, + count: Decimal | int | float | str | None = None, + price: Decimal | int | float | str | None = None, + client_transfer_id: UUID | str | None = None, + ticker: str | None = None, + market_id: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> CreateMarginFCMPositionTransferResponse: + """``POST /margin/fcm/positions/transfers`` — move a perps position. + + FCM members only. Kalshi must enable the endpoint for the member + (400 until then; 409 while transfers are off for the exchange). + Supply ``ticker`` or ``market_id`` (ticker wins if both). + ``client_transfer_id`` is a required UUID idempotency key. + POST is never retried. + """ + self._require_auth() + body = _build_transfer_position_body( + request, + source_subtrader_id=source_subtrader_id, + target_subtrader_id=target_subtrader_id, + side=side, + count=count, + price=price, + client_transfer_id=client_transfer_id, + ticker=ticker, + market_id=market_id, + ) + data = self._post(_POSITION_TRANSFER_PATH, json=body, extra_headers=extra_headers) + return CreateMarginFCMPositionTransferResponse.model_validate(data) + class AsyncFcmResource(AsyncResource): """Async perps FCM API.""" @@ -399,3 +520,56 @@ async def delete_notional_risk_limit( """Async :meth:`FcmResource.delete_notional_risk_limit`.""" self._require_auth() await self._delete(_NOTIONAL_RISK_LIMIT_PATH, extra_headers=extra_headers) + + @overload + async def transfer_position( + self, + *, + request: CreateMarginFCMPositionTransferRequest, + extra_headers: dict[str, str] | None = None, + ) -> CreateMarginFCMPositionTransferResponse: ... + @overload + async def transfer_position( + self, + *, + source_subtrader_id: str, + target_subtrader_id: str, + side: FCMPositionTransferSideLiteral, + count: Decimal | int | float | str, + price: Decimal | int | float | str, + client_transfer_id: UUID | str, + ticker: str | None = None, + market_id: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> CreateMarginFCMPositionTransferResponse: ... + async def transfer_position( + self, + *, + request: CreateMarginFCMPositionTransferRequest | None = None, + source_subtrader_id: str | None = None, + target_subtrader_id: str | None = None, + side: FCMPositionTransferSideLiteral | None = None, + count: Decimal | int | float | str | None = None, + price: Decimal | int | float | str | None = None, + client_transfer_id: UUID | str | None = None, + ticker: str | None = None, + market_id: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> CreateMarginFCMPositionTransferResponse: + """Async :meth:`FcmResource.transfer_position`.""" + self._require_auth() + body = _build_transfer_position_body( + request, + source_subtrader_id=source_subtrader_id, + target_subtrader_id=target_subtrader_id, + side=side, + count=count, + price=price, + client_transfer_id=client_transfer_id, + ticker=ticker, + market_id=market_id, + ) + data = await self._post( + _POSITION_TRANSFER_PATH, json=body, extra_headers=extra_headers + ) + return CreateMarginFCMPositionTransferResponse.model_validate(data) diff --git a/tests/_contract_support.py b/tests/_contract_support.py index 808aba4..f20db80 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -1842,6 +1842,12 @@ class Exclusion: http_method="DELETE", path_template="/margin/fcm/notional_risk_limit", ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.fcm.FcmResource.transfer_position", + http_method="POST", + path_template="/margin/fcm/positions/transfers", + request_body_schema="#/components/schemas/CreateMarginFCMPositionTransferRequest", + ), # ── perps exit triggers ── MethodEndpointEntry( sdk_method="kalshi.perps.resources.portfolio.PerpsPortfolioResource.cross_exit_triggers", diff --git a/tests/perps/test_fcm.py b/tests/perps/test_fcm.py index fe27319..2542bad 100644 --- a/tests/perps/test_fcm.py +++ b/tests/perps/test_fcm.py @@ -4,15 +4,23 @@ import json from decimal import Decimal +from uuid import UUID import httpx import pytest import respx from pydantic import ValidationError -from kalshi.errors import AuthRequiredError +from kalshi.errors import ( + AuthRequiredError, + KalshiConflictError, + KalshiServerError, + KalshiValidationError, +) from kalshi.perps import AsyncPerpsClient, PerpsClient, PerpsConfig from kalshi.perps.models.fcm import ( + CreateMarginFCMPositionTransferRequest, + CreateMarginFCMPositionTransferResponse, CreateMarginFCMSubtraderRequest, CreateMarginFCMSubtraderResponse, UpdateFCMNotionalRiskLimitRequest, @@ -483,3 +491,245 @@ async def test_async_roundtrip(self, async_perps_client: AsyncPerpsClient) -> No "notional_value_risk_limit": "2.00", } assert delete.called + + +_TRANSFER_ID = "6d1f0c5a-2b3e-4f7a-9c8d-0e1f2a3b4c5d" +_TRANSFER_PATH = f"{BASE}/margin/fcm/positions/transfers" +_SOURCE = "3f2a8c1e-5b7d-4e9a-8c6f-1d2b3a4e5f60_CUST123" +_TARGET = "9b8c7d6e-5f4a-4b3c-9d2e-1f0a9b8c7d6e_ERR1" + + +def _happy_kwargs() -> dict[str, object]: + return { + "source_subtrader_id": _SOURCE, + "target_subtrader_id": _TARGET, + "ticker": "KXBTCPERP", + "side": "long", + "count": "10", + "price": "63000.00", + "client_transfer_id": _TRANSFER_ID, + } + + +def _happy_body() -> dict[str, object]: + return { + "source_subtrader_id": _SOURCE, + "target_subtrader_id": _TARGET, + "ticker": "KXBTCPERP", + "side": "long", + "count": "10", + "price": "63000.00", + "client_transfer_id": _TRANSFER_ID, + } + + +class TestCreateMarginFCMPositionTransferRequest: + def test_serializes(self) -> None: + req = CreateMarginFCMPositionTransferRequest( + source_subtrader_id=_SOURCE, + target_subtrader_id=_TARGET, + ticker="KXBTCPERP", + side="long", + count="10", + price="63000.00", + client_transfer_id=_TRANSFER_ID, + ) + assert req.model_dump(exclude_none=True, by_alias=True, mode="json") == _happy_body() + + def test_serializes_decimal_count_and_price(self) -> None: + req = CreateMarginFCMPositionTransferRequest( + source_subtrader_id=_SOURCE, + target_subtrader_id=_TARGET, + ticker="KXBTCPERP", + side="long", + count=Decimal("10"), + price=Decimal("63000.00"), + client_transfer_id=_TRANSFER_ID, + ) + body = req.model_dump(exclude_none=True, by_alias=True, mode="json") + assert body["count"] == "10" + assert body["price"] == "63000.00" + + def test_market_id_omits_ticker(self) -> None: + req = CreateMarginFCMPositionTransferRequest( + source_subtrader_id=_SOURCE, + target_subtrader_id=_TARGET, + market_id="0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d", + side="short", + count="5", + price="4500.00", + client_transfer_id=_TRANSFER_ID, + ) + body = req.model_dump(exclude_none=True, by_alias=True, mode="json") + assert "ticker" not in body + assert body["market_id"] == "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d" + assert body["side"] == "short" + + def test_forbids_extra(self) -> None: + with pytest.raises(ValidationError): + CreateMarginFCMPositionTransferRequest( # type: ignore[call-arg] + source_subtrader_id=_SOURCE, + target_subtrader_id=_TARGET, + ticker="KXBTCPERP", + side="long", + count="10", + price="63000.00", + client_transfer_id=_TRANSFER_ID, + phantom=1, + ) + + def test_rejects_bad_side(self) -> None: + with pytest.raises(ValidationError): + CreateMarginFCMPositionTransferRequest( + source_subtrader_id=_SOURCE, + target_subtrader_id=_TARGET, + ticker="KXBTCPERP", + side="bid", # type: ignore[arg-type] + count="10", + price="63000.00", + client_transfer_id=_TRANSFER_ID, + ) + + +class TestFcmTransferPosition: + @respx.mock + def test_happy_path_sends_exact_body(self, perps_client: PerpsClient) -> None: + route = respx.post(_TRANSFER_PATH).mock( + return_value=httpx.Response( + 201, json={"position_transfer_id": "pt-1"} + ) + ) + resp = perps_client.fcm.transfer_position(**_happy_kwargs()) # type: ignore[arg-type] + assert isinstance(resp, CreateMarginFCMPositionTransferResponse) + assert resp.position_transfer_id == "pt-1" + assert route.calls[0].request.method == "POST" + assert str(route.calls[0].request.url.path).endswith( + "/margin/fcm/positions/transfers" + ) + assert json.loads(route.calls[0].request.content) == _happy_body() + + @respx.mock + def test_with_request_model(self, perps_client: PerpsClient) -> None: + route = respx.post(_TRANSFER_PATH).mock( + return_value=httpx.Response(201, json={"position_transfer_id": "pt-2"}) + ) + req = CreateMarginFCMPositionTransferRequest( + source_subtrader_id=_SOURCE, + target_subtrader_id=_TARGET, + ticker="KXBTCPERP", + side="long", + count="10", + price="63000.00", + client_transfer_id=_TRANSFER_ID, + ) + resp = perps_client.fcm.transfer_position(request=req) + assert resp.position_transfer_id == "pt-2" + assert json.loads(route.calls[0].request.content) == _happy_body() + + @respx.mock + def test_market_id_instead_of_ticker(self, perps_client: PerpsClient) -> None: + route = respx.post(_TRANSFER_PATH).mock( + return_value=httpx.Response(201, json={"position_transfer_id": "pt-3"}) + ) + perps_client.fcm.transfer_position( + source_subtrader_id=_SOURCE, + target_subtrader_id=_TARGET, + market_id="0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d", + side="short", + count="5", + price="4500.00", + client_transfer_id=_TRANSFER_ID, + ) + assert json.loads(route.calls[0].request.content) == { + "source_subtrader_id": _SOURCE, + "target_subtrader_id": _TARGET, + "market_id": "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d", + "side": "short", + "count": "5", + "price": "4500.00", + "client_transfer_id": _TRANSFER_ID, + } + + @respx.mock + def test_decimal_count_and_price_serialize(self, perps_client: PerpsClient) -> None: + route = respx.post(_TRANSFER_PATH).mock( + return_value=httpx.Response(201, json={"position_transfer_id": "pt-4"}) + ) + perps_client.fcm.transfer_position( + source_subtrader_id=_SOURCE, + target_subtrader_id=_TARGET, + ticker="KXBTCPERP", + side="long", + count=Decimal("10"), + price=Decimal("63000.00"), + client_transfer_id=UUID(_TRANSFER_ID), + ) + body = json.loads(route.calls[0].request.content) + assert body["count"] == "10" + assert body["price"] == "63000.00" + assert body["client_transfer_id"] == _TRANSFER_ID + + def test_requires_args(self, perps_client: PerpsClient) -> None: + with pytest.raises(TypeError, match="transfer_position"): + perps_client.fcm.transfer_position() # type: ignore[call-overload] + + @respx.mock + def test_409_maps_conflict(self, perps_client: PerpsClient) -> None: + respx.post(_TRANSFER_PATH).mock( + return_value=httpx.Response( + 409, json={"error": {"code": "transfer_already_applied"}} + ) + ) + with pytest.raises(KalshiConflictError): + perps_client.fcm.transfer_position(**_happy_kwargs()) # type: ignore[arg-type] + + @respx.mock + def test_400_maps_validation(self, perps_client: PerpsClient) -> None: + respx.post(_TRANSFER_PATH).mock( + return_value=httpx.Response(400, json={"message": "not enabled"}) + ) + with pytest.raises(KalshiValidationError): + perps_client.fcm.transfer_position(**_happy_kwargs()) # type: ignore[arg-type] + + @respx.mock + def test_not_retried_on_5xx(self, perps_client: PerpsClient) -> None: + route = respx.post(_TRANSFER_PATH).mock( + return_value=httpx.Response(503, json={"error": {"code": "unavailable"}}) + ) + with pytest.raises(KalshiServerError): + perps_client.fcm.transfer_position(**_happy_kwargs()) # type: ignore[arg-type] + assert route.call_count == 1 + + def test_unauthenticated_raises(self) -> None: + client = PerpsClient(config=PerpsConfig.demo(max_retries=0)) + with pytest.raises(AuthRequiredError): + client.fcm.transfer_position(**_happy_kwargs()) # type: ignore[arg-type] + + +class TestAsyncFcmTransferPosition: + @respx.mock + @pytest.mark.asyncio + async def test_happy_path_sends_exact_body( + self, async_perps_client: AsyncPerpsClient + ) -> None: + route = respx.post(_TRANSFER_PATH).mock( + return_value=httpx.Response( + 201, json={"position_transfer_id": "pt-async"} + ) + ) + resp = await async_perps_client.fcm.transfer_position(**_happy_kwargs()) # type: ignore[arg-type] + assert resp.position_transfer_id == "pt-async" + assert route.calls[0].request.method == "POST" + assert json.loads(route.calls[0].request.content) == _happy_body() + + @respx.mock + @pytest.mark.asyncio + async def test_not_retried_on_5xx( + self, async_perps_client: AsyncPerpsClient + ) -> None: + route = respx.post(_TRANSFER_PATH).mock( + return_value=httpx.Response(500, json={"error": {"code": "boom"}}) + ) + with pytest.raises(KalshiServerError): + await async_perps_client.fcm.transfer_position(**_happy_kwargs()) # type: ignore[arg-type] + assert route.call_count == 1 diff --git a/tests/perps/test_portfolio.py b/tests/perps/test_portfolio.py index 2e4a4e7..498a3fa 100644 --- a/tests/perps/test_portfolio.py +++ b/tests/perps/test_portfolio.py @@ -791,6 +791,13 @@ def test_parses_when_present(self) -> None: payload["subaccount_type"] = "cross" assert MarginPosition.model_validate(payload).subaccount_type == "cross" + def test_parses_managed_isolated(self) -> None: + payload = _long_position() + payload["subaccount_type"] = "managed_isolated" + assert ( + MarginPosition.model_validate(payload).subaccount_type == "managed_isolated" + ) + def test_invalid_raises(self) -> None: payload = _long_position() payload["subaccount_type"] = "hedged" diff --git a/tests/test_api_keys.py b/tests/test_api_keys.py index f8e1b32..64052aa 100644 --- a/tests/test_api_keys.py +++ b/tests/test_api_keys.py @@ -72,6 +72,24 @@ def test_api_key_parses(self) -> None: assert k.name == "bot" assert k.scopes == ["read", "write"] + @pytest.mark.parametrize("key_type", ["rsa", "ed25519"]) + def test_api_key_parses_key_type(self, key_type: str) -> None: + k = ApiKey.model_validate( + { + "api_key_id": "k-1", + "name": "bot", + "scopes": ["read"], + "key_type": key_type, + }, + ) + assert k.key_type == key_type + + def test_api_key_key_type_absent_is_none(self) -> None: + k = ApiKey.model_validate( + {"api_key_id": "k-1", "name": "bot", "scopes": ["read"]}, + ) + assert k.key_type is None + def test_get_api_keys_response_wraps_list(self) -> None: resp = GetApiKeysResponse.model_validate( { diff --git a/tests/test_contracts.py b/tests/test_contracts.py index c3c162e..dc0168a 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -1555,6 +1555,9 @@ def _assert_params_match( "#/components/schemas/UpdateFCMNotionalRiskLimitRequest": ( "kalshi.perps.models.fcm.UpdateFCMNotionalRiskLimitRequest" ), + "#/components/schemas/CreateMarginFCMPositionTransferRequest": ( + "kalshi.perps.models.fcm.CreateMarginFCMPositionTransferRequest" + ), "#/components/schemas/SetCrossExitTriggerRequest": ( "kalshi.perps.models.portfolio.SetCrossExitTriggerRequest" ), From a9d7492a23fca733884cc0716e6a19f4d545f8d7 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Mon, 9 Nov 2026 09:20:36 -0600 Subject: [PATCH 3/3] wip(#574): docs, changelog, version 22.10.0 --- CHANGELOG.md | 35 ++++++++++++++++++++ docs/perps.md | 38 ++++++++++++++++++---- docs/resources/api-keys.md | 5 ++- docs/resources/communications.md | 6 ++-- docs/resources/portfolio.md | 13 +++++--- kalshi/__init__.py | 2 +- kalshi/models/api_keys.py | 2 ++ kalshi/perps/ws/models/market_lifecycle.py | 4 ++- pyproject.toml | 2 +- 9 files changed, 91 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37674d1..e2b6301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ All notable changes to kalshi-sdk will be documented in this file. +## 22.10.0 — 2026-11-09 + +Reconciles nightly spec drift (#574) against OpenAPI 3.36.0 and the latest +perps spec. Additive only. No breaking changes. + +### Added + +- Optional **`ApiKey.key_type`** (`"rsa"` / `"ed25519"` / `None`). Present + for RSA and Ed25519 keys; omitted when the algorithm is unknown. +- Perps **`SubaccountTypeLiteral`** gains `"managed_isolated"` (a managed + isolated position whose subaccount number is its slot number rather than + the market's isolated subaccount number). +- **`perps.fcm.transfer_position(...)`** (sync and async) — + `POST /margin/fcm/positions/transfers`, 201 + `CreateMarginFCMPositionTransferResponse`. FCM members only; Kalshi must + enable the endpoint (400 until then; 409 while transfers are off). + Required: `source_subtrader_id`, `target_subtrader_id`, `side` + (`"long"` / `"short"`), `count`, `price`, `client_transfer_id` (UUID). + Supply `ticker` or `market_id` (ticker wins if both). POST is never + retried. (Closes #574). + +### Docs + +- Positions / settlement: unsettled includes closed markets awaiting + determination; `count_filter` and `settlement_status` descriptions + clarified. +- Block-trade `accept` may book the caller's side onto a different + subaccount than the proposal named, and a side that has already accepted + may accept again to move until the trade executes. +- Resource-bound MCP OAuth tokens may read owner data with + `read::mcp_portfolio` on settlements and perps orders / fills / + positions / balance / risk. +- Perps `market_lifecycle_v2.is_deactivated` is true when trading is + paused because the market is halted or outside its trading hours. + ## 22.9.0 — 2026-11-05 Reconciles nightly spec drift (#571) against OpenAPI 3.36.0 and the latest diff --git a/docs/perps.md b/docs/perps.md index af7a98b..e5d07b3 100644 --- a/docs/perps.md +++ b/docs/perps.md @@ -66,7 +66,7 @@ async with AsyncPerpsClient.from_env(demo=True) as perps: | `margin` | `balance()`, `risk()`, `notional_risk_limit()`, `fee_tiers()`, `fee_tier_rates()`, `api_limits()`, `corporate_actions()` | | `funding` | `rate_estimate()`, `historical_rates()`, `premium_index()`, `history()` | | `transfers` | `transfer_instance()`, `create_subaccount()`, `transfer_subaccount()` | -| `fcm` | `create_subtrader(subtrader_suffix=..., require_category_cap=...)`; `risk_controls` / `update_risk_controls` / `delete_risk_controls`; `update_notional_risk_limit` / `delete_notional_risk_limit` | +| `fcm` | `create_subtrader(subtrader_suffix=..., require_category_cap=...)`; `risk_controls` / `update_risk_controls` / `delete_risk_controls`; `update_notional_risk_limit` / `delete_notional_risk_limit`; `transfer_position(...)` | `markets.bulk_orderbooks(*, tickers)` fetches up to 100 margin orderbooks in one call (`GET /margin/markets/orderbooks`). `tickers` is required @@ -228,10 +228,11 @@ perps.portfolio.cancel_cross_exit_trigger("BTC-PERP", trig.id) - **Subaccounts** (since the v3.21.0 spec sync) — `MarginPosition` (the `portfolio.positions()` rows) carries a **required** `subaccount` (`int`) identifying which subaccount holds the position. `subaccount_type` - (`"cross"` / `"isolated"` / `"auto_rebalance"`, or `None` when the key is - absent) is optional on `MarginPosition`, `MarginSubaccountBalance`, - `MarginRiskPosition`, and `MarginFundingHistoryEntry`. The spec marks it - required; the SDK keeps it optional so older payloads still parse. + (`"cross"` / `"isolated"` / `"auto_rebalance"` / `"managed_isolated"`, or + `None` when the key is absent) is optional on `MarginPosition`, + `MarginSubaccountBalance`, `MarginRiskPosition`, and + `MarginFundingHistoryEntry`. The spec marks it required; the SDK keeps it + optional so older payloads still parse. - **Portfolio hedging** (since the v3.23.0 spec sync) — `MarginPosition` and `MarginRiskPosition` carry a **required** `is_portfolio` (`bool`), `True` when the position is hedged within a portfolio so its margin is computed at the @@ -318,7 +319,32 @@ REST-only); subscribe with `session.subscribe(channel="corporate_actions")`. Upcoming and recent notices are listed by `margin.corporate_actions()`. `subscribe_market_lifecycle()` yields `MarginMarketLifecycleV2Message` for activation and deactivation of every margin market (`event_type` is -`activated` or `deactivated`). Ticker filters are not supported on that channel. +`activated` or `deactivated`). `is_deactivated` is true when trading is +paused because the market is halted or outside its trading hours. Ticker +filters are not supported on that channel. + +## FCM position transfer + +FCM members can move a perps position in one market from a customer +subtrader into a subtrader under the FCM's error account. **Kalshi must +enable this endpoint for your FCM**; until then the server returns 400. +While transfers are switched off for the exchange, requests return 409. +POST is never retried. `client_transfer_id` is a required UUID +idempotency key per source subtrader. Supply `ticker` or `market_id` +(ticker wins if both). + +```python +resp = perps.fcm.transfer_position( + source_subtrader_id="user_CUST123", + target_subtrader_id="err_ERR1", + ticker="KXBTCPERP", + side="long", + count="10", + price="63000.00", + client_transfer_id="6d1f0c5a-2b3e-4f7a-9c8d-0e1f2a3b4c5d", +) +print(resp.position_transfer_id) +``` ## Self-Clearing-Member "Klear" API diff --git a/docs/resources/api-keys.md b/docs/resources/api-keys.md index 392f6c7..6e87128 100644 --- a/docs/resources/api-keys.md +++ b/docs/resources/api-keys.md @@ -34,9 +34,12 @@ Auth required throughout (you need an existing key to manage keys). ```python resp = client.api_keys.list() for key in resp.api_keys: - print(key.api_key, key.name, key.scopes, key.created_ts) + print(key.api_key_id, key.name, key.scopes, key.key_type) ``` +`key_type` is `"rsa"` or `"ed25519"` when the server knows the algorithm, +and `None` when it is omitted (unknown). + ## Server-minted pair (`generate`) The simplest path — Kalshi mints the keypair, you store the private key once: diff --git a/docs/resources/communications.md b/docs/resources/communications.md index 71ed3d7..9a062ab 100644 --- a/docs/resources/communications.md +++ b/docs/resources/communications.md @@ -210,8 +210,10 @@ for proposal in client.communications.block_trade_proposals.list_all( ): print(proposal.id, proposal.buyer_accepted, proposal.seller_accepted) -# Accept (the counterparty side). The body is optional — pass subtrader_id -# or subaccount only if you trade through a sub-trader / subaccount. +# Accept as the named side. You may book that side onto any of your own +# subaccounts (including one the proposal did not name). Omit both +# subtrader_id and subaccount to keep the proposal's. A side that has +# already accepted may accept again to move until the trade executes. client.communications.block_trade_proposals.accept(proposal_id) # Cancel (proposer only). 204, no body. diff --git a/docs/resources/portfolio.md b/docs/resources/portfolio.md index 7eb1d3e..32a6bdc 100644 --- a/docs/resources/portfolio.md +++ b/docs/resources/portfolio.md @@ -111,12 +111,17 @@ for ep in resp.event_positions: cursor = resp.cursor ``` -`count_filter` filters which fields the response **includes a row for** — -filtering by `"position"` returns only markets where your position is non-zero. +`count_filter` is a comma-separated OR list: `"position"` (non-zero net +contracts, including closed markets awaiting determination) or +`"total_traded"` (any contracts traded, including fully exited positions). +Omit it for no count filter. `settlement_status` (`SettlementStatusLiteral`: `"unsettled"`, `"settled"`, -`"all"`) selects which live positions to return. Omit it to keep the server -default (`unsettled`). Archived positions stay on `GET /historical/positions`. +`"all"`) filters settlement state, not trading status. Unsettled (the +server default) includes closed markets awaiting determination or +settlement; those rows may still have non-zero quantities and cost basis. +`"settled"` is unarchived settled positions; `"all"` is both live states. +Archived positions stay on `GET /historical/positions`. ## Settlements diff --git a/kalshi/__init__.py b/kalshi/__init__.py index 79b4341..d38404a 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -503,4 +503,4 @@ "Withdrawal", ] -__version__ = "22.9.0" +__version__ = "22.10.0" diff --git a/kalshi/models/api_keys.py b/kalshi/models/api_keys.py index f6d07de..c498545 100644 --- a/kalshi/models/api_keys.py +++ b/kalshi/models/api_keys.py @@ -31,6 +31,8 @@ class ApiKey(BaseModel): fields do — Kalshi has historically returned JSON null for required list fields, and silently coercing None -> [] is safer than raising a Pydantic ValidationError on a field most callers don't use. + ``key_type`` is ``rsa`` or ``ed25519`` when the server knows the + algorithm, and omitted (``None``) when unknown. """ api_key_id: str diff --git a/kalshi/perps/ws/models/market_lifecycle.py b/kalshi/perps/ws/models/market_lifecycle.py index 2b28021..25d5fad 100644 --- a/kalshi/perps/ws/models/market_lifecycle.py +++ b/kalshi/perps/ws/models/market_lifecycle.py @@ -18,7 +18,9 @@ class MarginMarketLifecycleV2Payload(BaseModel): """Payload for ``market_lifecycle_v2`` (``marginMarketLifecycleV2Payload.msg``). All three fields are required: ``market_ticker``, ``event_type`` - (``activated`` / ``deactivated``), and ``is_deactivated``. + (``activated`` / ``deactivated``), and ``is_deactivated`` (true when + trading is paused because the market is halted or outside its trading + hours). """ market_ticker: str diff --git a/pyproject.toml b/pyproject.toml index 860e7ef..505043d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "22.9.0" +version = "22.10.0" description = "A professional Python SDK for the Kalshi prediction markets and Perps (margin) APIs" readme = "README.md" license = { text = "MIT" }