From 7e2803618e645dffe32933527495c352efe76b14 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sat, 10 Oct 2026 18:18:02 -0500 Subject: [PATCH] fix(spec): reconcile OpenAPI 3.34.0 + perps drift; release 21.0.0 (Closes #533) --- CHANGELOG.md | 53 ++++++++++ CLAUDE.md | 2 +- README.md | 2 +- docs/index.md | 4 +- docs/migration.md | 48 +++++++++ docs/perps.md | 15 ++- docs/resources/fcm.md | 24 +++++ docs/resources/portfolio.md | 4 +- docs/retries.md | 5 + kalshi/__init__.py | 6 +- kalshi/_contract_map.py | 8 ++ kalshi/models/__init__.py | 4 + kalshi/models/fcm.py | 37 ++++++- kalshi/perps/models/markets.py | 14 +++ kalshi/perps/models/orders.py | 3 +- kalshi/perps/models/transfers.py | 5 + kalshi/perps/resources/orders.py | 3 +- kalshi/perps/resources/portfolio.py | 49 ++++++--- kalshi/perps/resources/transfers.py | 24 +++++ kalshi/resources/fcm.py | 142 +++++++++++++++++++++++++- pyproject.toml | 2 +- specs/openapi.yaml | 148 +++++++++++++++++++++++++++- specs/perps_openapi.yaml | 49 ++++++--- specs/perps_scm_openapi.yaml | 6 +- tests/_contract_support.py | 6 ++ tests/perps/test_markets.py | 58 +++++++++++ tests/perps/test_orders.py | 23 +++++ tests/perps/test_portfolio.py | 36 +++++-- tests/perps/test_review_fixes.py | 13 +++ tests/perps/test_transfers.py | 78 +++++++++++++++ tests/test_contracts.py | 3 + tests/test_fcm.py | 134 ++++++++++++++++++++++++- 32 files changed, 958 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ea4eb6b..cb6f6ce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,59 @@ All notable changes to kalshi-sdk will be documented in this file. +## 21.0.0 — 2026-10-10 + +Reconciles OpenAPI 3.33.0 → 3.34.0 and perps spec drift after nightly +contract failures (Closes #533). **Breaking** for constructors of +`MarginMarket` that omit the new required funding-limit fields. +Re-vendored `specs/openapi.yaml` (hash +`091f171718a0376b0c1a2e9348b02b1ce003d0786dd04d772dd11005ca414bf4`), +`specs/perps_openapi.yaml` (hash +`27e4b0b76a686f797342f1526a08df5b8f60d8104a81b65c94073d3d89b54308`), and +`specs/perps_scm_openapi.yaml` (hash +`3ac27696d43bffbb5e993daa7106e62872028b7630980a492438d1d0043202e8`). + +### Changed (breaking) + +- Perps **`MarginMarket.funding_deadband`** / **`funding_cap`** (required + `Decimal`, JSON numbers) and **`funding_limit_unit`** (required + `"percentage"` / `"dollar"`). Funding limits configured at market + creation, expressed in `funding_limit_unit`. Live `markets.list` / + `markets.get` callers are unaffected; constructors and fixtures must + pass the new fields. + +### Added + +- **`fcm.create_position_transfer(...)`** (sync + async) — + `POST /fcm/positions/transfers`. Moves a market position from a source + subtrader into a subtrader under the FCM's error account. Kalshi must + enable the endpoint for the FCM; until then the server returns 400. + Request body `CreateFCMPositionTransferRequest`: `source_subtrader_id`, + `target_subtrader_id`, `market_ticker`, `position_centicount` (contracts + × 100, non-zero; positive YES, negative NO), `price_centicents` (YES + price, 0–10000). Returns `CreateFCMPositionTransferResponse`. POST is + never retried; requests are not idempotent. +- Perps **`transfers.transfer_instance(..., client_transfer_id=)`** (sync + + async): optional UUID for duplicate prevention on + `IntraExchangeInstanceTransferRequest`. Omitted from the body when `None`. + Only supported for primary accounts. +- Perps **`MarginMarket.bid_size` / `ask_size`** (optional, `bid_size_fp` / + `ask_size_fp` on the wire): contracts resting at the best bid and ask. + +### Changed + +- Perps **`portfolio.trades` / `trades_all`**: `ticker` is now optional. Omit + it to fetch public margin trades across all markets. Existing calls that + pass `ticker` are unchanged. +- Perps fills, positions, and order `subaccount` no longer have a spec default + of `0`; the SDK already omits `subaccount` when `None`, which now means all + subaccounts (or the key's locked subaccount). Docs and tests updated. + +### Spec notes + +- Perps SCM large-trader positions rows gain `reportable_threshold`; that + surveillance endpoint is not part of the SDK surface. + ## 20.0.0 — 2026-10-07 Reconciles perps OpenAPI drift after nightly contract failures (Closes #531). diff --git a/CLAUDE.md b/CLAUDE.md index b52e4b89..586ce2c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,7 +122,7 @@ tests/ ## API Reference -- OpenAPI spec: https://docs.kalshi.com/openapi.yaml (v3.31.0, 117 operations; 116 mapped in the core SDK — `POST /portfolio/intra_exchange_instance_transfer` is implemented on `PerpsClient.transfers.transfer_instance` and left unimplemented on the core client) +- OpenAPI spec: https://docs.kalshi.com/openapi.yaml (v3.34.0, 118 operations; 117 mapped in the core SDK — `POST /portfolio/intra_exchange_instance_transfer` is implemented on `PerpsClient.transfers.transfer_instance` and left unimplemented on the core client) - AsyncAPI spec: https://docs.kalshi.com/asyncapi.yaml (15 WebSocket channels; 12 typed `subscribe_*` + escape-hatch) - Base URL: https://api.elections.kalshi.com/trade-api/v2 - Demo URL: https://demo-api.kalshi.co/trade-api/v2 diff --git a/README.md b/README.md index 3f45c37f..f1999b1a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A professional, spec-first Python SDK for the [Kalshi](https://kalshi.com) predi [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Type checked: mypy strict](https://img.shields.io/badge/mypy-strict-blue.svg)](https://mypy.readthedocs.io/) -- **Full coverage** of the Kalshi REST API (116 mapped of 117 operations across 19 resources, OpenAPI v3.31.0) and WebSocket API (12 typed `subscribe_*` channels + escape-hatch). +- **Full coverage** of the Kalshi REST API (117 mapped of 118 operations across 19 resources, OpenAPI v3.34.0) and WebSocket API (12 typed `subscribe_*` channels + escape-hatch). - **Perps (margin) API**: standalone `PerpsClient` / `AsyncPerpsClient` + `PerpsWebSocket` for the perpetual-futures exchange (50 REST operations, 6 WS channels), plus a `KlearClient` for the Self-Clearing-Member "Klear" settlement API (26 of 27 operations). See [Perps (margin) trading](#perps-margin-trading). - **FIX protocol**: an async-first FIX engine (FIXT.1.1 / FIX50SP2) for both products — order-entry, drop-copy, market-data, post-trade (prediction), and RFQ (prediction) sessions (plus order-group management over the order-entry session) with typed message models, sequence recovery, and order-book / settlement reassembly. `from kalshi import FixClient` / `MarginFixClient`. See [FIX protocol](#fix-protocol-low-latency-trading). - **V2 event-market orders**: `create_v2` / `amend_v2` / `decrease_v2` / `cancel_v2` / `cancel_all_v2` plus batched variants on `/portfolio/events/orders/*` — the only order-write surface. diff --git a/docs/index.md b/docs/index.md index 89c23cb1..e1b42569 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,8 +3,8 @@ A professional, spec-first Python SDK for the [Kalshi](https://kalshi.com) prediction markets API. -- **Full REST coverage** — 116 mapped of 117 operations across 19 resources - (OpenAPI v3.31.0), every kwarg drift-tested against the spec. +- **Full REST coverage** — 117 mapped of 118 operations across 19 resources + (OpenAPI v3.34.0), every kwarg drift-tested against the spec. - **V2 event-market orders** — new `create_v2` / `amend_v2` / `decrease_v2` / `cancel_v2` / `cancel_all_v2` family on `/portfolio/events/orders/*`. Legacy `/portfolio/orders` keeps working; deprecation no earlier than May 6, 2026. diff --git a/docs/migration.md b/docs/migration.md index 79769a19..fc2038ff 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1,5 +1,53 @@ # Migration +## v20.0 → v21.0.0 + +Reconciles OpenAPI 3.33.0 → 3.34.0 and perps spec drift (Closes #533). +**Breaking** for constructors of `MarginMarket` that omit the new required +funding-limit fields. + +### Response model field changes + +- **Perps `MarginMarket.funding_deadband`** — required `Decimal` (JSON + number). Funding deadband configured at market creation, expressed in + `funding_limit_unit`. +- **Perps `MarginMarket.funding_cap`** — required `Decimal` (JSON number). + Funding cap configured at market creation, expressed in + `funding_limit_unit`. +- **Perps `MarginMarket.funding_limit_unit`** — required `"percentage"` or + `"dollar"`. Unit for the funding limits. + +Live `markets.list` / `markets.get` callers are unaffected; constructors +and fixtures must pass the new fields. + +```python +# Before (constructors / test fixtures): +# MarginMarket(..., exchange_index=0, market_version=1) + +# After: +MarginMarket( + ..., + exchange_index=0, + market_version=1, + funding_deadband=0, + funding_cap=1, + funding_limit_unit="percentage", +) +``` + +### Added (non-breaking) + +- `fcm.create_position_transfer(...)` (sync + async) — + `POST /fcm/positions/transfers`, plus `CreateFCMPositionTransferRequest` / + `CreateFCMPositionTransferResponse`. +- Optional `IntraExchangeInstanceTransferRequest.client_transfer_id` and + `perps.transfers.transfer_instance(..., client_transfer_id=)`. +- Optional `MarginMarket.bid_size` / `ask_size`. +- Perps `portfolio.trades` / `trades_all`: `ticker` is now optional. + +See the [changelog](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/CHANGELOG.md) +for the full list. + ## v19.0 → v20.0.0 Reconciles perps OpenAPI drift (Closes #531). **Breaking** for diff --git a/docs/perps.md b/docs/perps.md index d791997e..001a43d2 100644 --- a/docs/perps.md +++ b/docs/perps.md @@ -79,6 +79,13 @@ Omit to skip the check. `orders.create(..., market_id=)` / `orders.amend(..., market_id=)` is a stable exchange market UUID. It may be supplied with `ticker` (ticker takes precedence when both are set). Omit so the key is not sent. +`orders.create(..., subaccount=)` is optional (0 = primary). Omit so the +key is not sent. +`portfolio.fills()` / `portfolio.positions()` omit `subaccount` to return +rows across all subaccounts (subaccount-restricted API keys still only +see their locked subaccount). Pass `subaccount=0` for the primary. +`portfolio.trades()` / `trades_all()` take an optional `ticker`; omit it +to retrieve public trades across all margin markets. !!! warning "Deprecated in v7.2.0 — `list_fcm` / `list_all_fcm`" Kalshi removed `GET /margin/fcm/orders` from the perps OpenAPI. The SDK @@ -160,7 +167,13 @@ perps.portfolio.cancel_cross_exit_trigger("BTC-PERP", trig.id) carries the same fields as **required** (inherent to a settled historical record). `MarginMarket.leverage_estimates` maps notional position sizes (`"1000"`, `"10000"`, …) to `MultiplierDecimal` leverage, or `None` when margin - config / price data is unavailable. + config / price data is unavailable. `MarginMarket.bid_size` / `ask_size` + (`FixedPointCount | None`) are the resting size at the best bid/ask + (`bid_size_fp` / `ask_size_fp` on the wire); omitted when market stats are + unavailable. `MarginMarket.funding_deadband` / `funding_cap` + (`MultiplierDecimal`) and `funding_limit_unit` (`"percentage"` / + `"dollar"`) are required; they are the funding limits configured at + market creation, expressed in `funding_limit_unit`. - **Mark prices** (since the v3.21.0 spec sync) — `MarginMarket` carries three optional reference-price objects: `settlement_mark_price`, `liquidation_mark_price`, and `reference_price`, each a nested `TickerPrice` diff --git a/docs/resources/fcm.md b/docs/resources/fcm.md index f2b9c98a..3cc2efb9 100644 --- a/docs/resources/fcm.md +++ b/docs/resources/fcm.md @@ -15,6 +15,7 @@ calls come back 401/403. Auth required throughout. | `fills(*, min_ts=None, max_ts=None, cursor=None)` | `GET /fcm/fills` | | `fills_all(*, min_ts=None, max_ts=None, max_pages=None)` | walks `fills` | | `positions(*, subtrader_id, ...)` | `GET /fcm/positions` | +| `create_position_transfer(*, source_subtrader_id, target_subtrader_id, market_ticker, position_centicount, price_centicents)` | `POST /fcm/positions/transfers` | | `list_subtraders()` | `GET /fcm/subtraders` | | `create_subtrader(*, subtrader_suffix)` | `POST /fcm/subtraders` | | `blocked_categories(*, subtrader_id)` | `GET /fcm/subtraders/blocked_categories` | @@ -93,6 +94,29 @@ for mp in client.fcm.positions_all(subtrader_id="st_alpha", settlement_status="u [`portfolio.positions`](portfolio.md#positions) / `positions_all` (OpenAPI 3.32.0). Both endpoints default to `unsettled` when the kwarg is omitted. +## Position transfer + +Move a position in one market from a source subtrader into a subtrader under +the FCM's error account. **Kalshi must enable this endpoint for your FCM**; +until then the server returns 400. POST is never retried. Requests are not +idempotent — after a timeout or 5xx, check the position before retrying. + +`position_centicount` is contracts × 100 (positive moves YES, negative moves +NO) and must be non-zero. `price_centicents` is the YES price per contract +in centicents (0–10000), even when moving NO. Use `0` for YES or `10000` +for NO to move no cash. + +```python +resp = client.fcm.create_position_transfer( + source_subtrader_id="acct_CUST123", + target_subtrader_id="erracct_ERR1", + market_ticker="KXEXAMPLE-26DEC31", + position_centicount=1000, # 10 YES + price_centicents=4500, # 45 cents +) +print(resp.position_transfer_id) +``` + ## Subtrader admin List and create subtraders, block event categories, and set a daily diff --git a/docs/resources/portfolio.md b/docs/resources/portfolio.md index bd8bc0d9..7fedff77 100644 --- a/docs/resources/portfolio.md +++ b/docs/resources/portfolio.md @@ -210,7 +210,9 @@ New in v10.0.0. History and detail for fund moves between the `event_contract` and `margined` exchange instances. **Creating** a transfer is still on the perps surface (`PerpsClient.transfers.transfer_instance()`); these GETs are on the core -portfolio API. +portfolio API. The perps create body accepts an optional +`client_transfer_id` (UUID) for duplicate prevention; omit so the key is +not sent. ```python page = client.portfolio.intra_exchange_transfers(limit=50) diff --git a/docs/retries.md b/docs/retries.md index 7e9e6f62..6415edaa 100644 --- a/docs/retries.md +++ b/docs/retries.md @@ -137,6 +137,11 @@ with KalshiClient.from_env() as client: Same pattern as `client_order_id`, but for inter-subaccount money movement. Reconcile via `subaccounts.list_transfers()`. +`PerpsClient.transfers.transfer_instance(..., client_transfer_id=)` is the +same UUID idempotency key for intra-exchange instance transfers. It is +optional: omit so the key is not sent and each request creates a new +transfer. Only supported for primary accounts. + ### Cancels `client.orders.cancel_v2(order_id)` and `client.orders.batch_cancel_v2(...)` diff --git a/kalshi/__init__.py b/kalshi/__init__.py index 1fbb3825..98c2ce01 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -56,6 +56,8 @@ Candlestick, CreateApiKeyRequest, CreateApiKeyResponse, + CreateFCMPositionTransferRequest, + CreateFCMPositionTransferResponse, CreateFCMSubtraderRequest, CreateFCMSubtraderResponse, CreateMarketInMultivariateEventCollectionRequest, @@ -253,6 +255,8 @@ "Candlestick", "CreateApiKeyRequest", "CreateApiKeyResponse", + "CreateFCMPositionTransferRequest", + "CreateFCMPositionTransferResponse", "CreateFCMSubtraderRequest", "CreateFCMSubtraderResponse", "CreateMarketInMultivariateEventCollectionRequest", @@ -427,4 +431,4 @@ "Withdrawal", ] -__version__ = "20.0.0" +__version__ = "21.0.0" diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index 623c6269..b4f13f73 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -134,6 +134,14 @@ class ContractEntry: sdk_model="kalshi.models.fcm.ListFCMSubtradersResponse", spec_schema="ListFCMSubtradersResponse", ), + ContractEntry( + sdk_model="kalshi.models.fcm.CreateFCMPositionTransferRequest", + spec_schema="CreateFCMPositionTransferRequest", + ), + ContractEntry( + sdk_model="kalshi.models.fcm.CreateFCMPositionTransferResponse", + spec_schema="CreateFCMPositionTransferResponse", + ), ContractEntry( sdk_model="kalshi.models.fcm.CreateFCMSubtraderRequest", spec_schema="CreateFCMSubtraderRequest", diff --git a/kalshi/models/__init__.py b/kalshi/models/__init__.py index b145d9b1..7dd099d0 100644 --- a/kalshi/models/__init__.py +++ b/kalshi/models/__init__.py @@ -62,6 +62,8 @@ WeeklySchedule, ) from kalshi.models.fcm import ( + CreateFCMPositionTransferRequest, + CreateFCMPositionTransferResponse, CreateFCMSubtraderRequest, CreateFCMSubtraderResponse, FcmFill, @@ -236,6 +238,8 @@ "Candlestick", "CreateApiKeyRequest", "CreateApiKeyResponse", + "CreateFCMPositionTransferRequest", + "CreateFCMPositionTransferResponse", "CreateFCMSubtraderRequest", "CreateFCMSubtraderResponse", "CreateMarketInMultivariateEventCollectionRequest", diff --git a/kalshi/models/fcm.py b/kalshi/models/fcm.py index 8408d434..21bf1c33 100644 --- a/kalshi/models/fcm.py +++ b/kalshi/models/fcm.py @@ -2,10 +2,43 @@ from __future__ import annotations -from pydantic import AliasChoices, AwareDatetime, BaseModel, Field +from pydantic import AliasChoices, AwareDatetime, BaseModel, Field, field_validator from kalshi.models.orders import SideLiteral -from kalshi.types import DollarDecimal, FixedPointCount +from kalshi.types import DollarDecimal, FixedPointCount, StrictInt + + +class CreateFCMPositionTransferRequest(BaseModel): + """Body for POST /fcm/positions/transfers. + + Moves a position in one market from a source subtrader into a subtrader + under the FCM's error account. ``position_centicount`` is contracts x 100 + (positive = YES, negative = NO) and must be non-zero. ``price_centicents`` + is the YES price per contract in centicents (cents x 100), 0-10000. + """ + + source_subtrader_id: str + target_subtrader_id: str + market_ticker: str + position_centicount: StrictInt + price_centicents: StrictInt = Field(ge=0, le=10000) + + model_config = {"extra": "forbid"} + + @field_validator("position_centicount") + @classmethod + def _position_centicount_nonzero(cls, value: int) -> int: + if value == 0: + raise ValueError("position_centicount must be non-zero") + return value + + +class CreateFCMPositionTransferResponse(BaseModel): + """Response from POST /fcm/positions/transfers.""" + + position_transfer_id: str + + model_config = {"extra": "allow"} class CreateFCMSubtraderRequest(BaseModel): diff --git a/kalshi/perps/models/markets.py b/kalshi/perps/models/markets.py index b80f5654..c9812895 100644 --- a/kalshi/perps/models/markets.py +++ b/kalshi/perps/models/markets.py @@ -93,6 +93,12 @@ class MarginMarket(BaseModel): # Required market version (corporate-action counter). Pass to create() as # market_version so the server rejects with 409 if the market changed. market_version: int + # Funding limits configured at market creation. Spec-required JSON + # numbers (``type: number, format: double``); ``funding_limit_unit`` + # is the unit those numbers are expressed in. + funding_deadband: MultiplierDecimal = Field(ge=0) + funding_cap: MultiplierDecimal = Field(ge=0) + funding_limit_unit: Literal["percentage", "dollar"] leverage_estimate: MultiplierDecimal | None = None # Leverage (1 / margin_rate) keyed by notional position size in dollars @@ -103,7 +109,15 @@ class MarginMarket(BaseModel): short_leverage_estimates: dict[str, MultiplierDecimal] | None = None price: DollarDecimal | None = None bid: DollarDecimal | None = None + bid_size: FixedPointCount | None = Field( + default=None, + validation_alias=AliasChoices("bid_size_fp", "bid_size"), + ) ask: DollarDecimal | None = None + ask_size: FixedPointCount | None = Field( + default=None, + validation_alias=AliasChoices("ask_size_fp", "ask_size"), + ) volume: FixedPointCount | None = Field( default=None, validation_alias=AliasChoices("volume_fp", "volume"), diff --git a/kalshi/perps/models/orders.py b/kalshi/perps/models/orders.py index c2d7f55c..2f92f931 100644 --- a/kalshi/perps/models/orders.py +++ b/kalshi/perps/models/orders.py @@ -71,7 +71,8 @@ class CreateMarginOrderRequest(BaseModel): ``price``/``count`` wire names match the field names (no ``_dollars``/``_fp`` suffix), so ``by_alias=True`` is a no-op for them — confirmed against the spec. ``subaccount`` is carried in the *body* here (the cancel/decrease/amend - endpoints route it as a query param instead). + endpoints route it as a query param instead). Omit (``None``) so + ``exclude_none`` drops the key; do not default to ``0``. """ model_config = ConfigDict(extra="forbid") diff --git a/kalshi/perps/models/transfers.py b/kalshi/perps/models/transfers.py index 2dd37e6d..ea386453 100644 --- a/kalshi/perps/models/transfers.py +++ b/kalshi/perps/models/transfers.py @@ -43,6 +43,8 @@ class IntraExchangeInstanceTransferRequest(BaseModel): Shard fields default to ``0`` (only ``0`` is currently supported); because the body is serialized with ``exclude_none=True`` (not ``exclude_defaults``), shard ``0`` values are emitted on the wire, which is fine. + ``client_transfer_id`` is optional duplicate-prevention; omit (``None``) + so ``exclude_none`` drops the key. Only supported for primary accounts. """ source: ExchangeInstanceLiteral @@ -50,6 +52,9 @@ class IntraExchangeInstanceTransferRequest(BaseModel): amount: StrictInt = Field(gt=0) source_exchange_shard: StrictInt = Field(default=0, ge=0) destination_exchange_shard: StrictInt = Field(default=0, ge=0) + # Client UUID for duplicate prevention. Omit (None) so exclude_none + # drops the key; each request then creates a new transfer. + client_transfer_id: UUID | None = None model_config = {"extra": "forbid"} diff --git a/kalshi/perps/resources/orders.py b/kalshi/perps/resources/orders.py index 85a0e568..3abf2a56 100644 --- a/kalshi/perps/resources/orders.py +++ b/kalshi/perps/resources/orders.py @@ -297,7 +297,8 @@ def create( """Place a new margin order (POST /margin/orders). Not retried. ``expiration_time`` is int64 Unix seconds per spec. ``subaccount`` - is carried in the request *body* (0 = primary). ``market_version`` + is carried in the request *body* (0 = primary); omit so the key is + not sent. ``market_version`` is the expected market version; if set and the market's current version differs, the server rejects with HTTP 409 / ``market_version_mismatch``. Omit to skip the check (server default 0). diff --git a/kalshi/perps/resources/portfolio.py b/kalshi/perps/resources/portfolio.py index 087d3655..e28dc8aa 100644 --- a/kalshi/perps/resources/portfolio.py +++ b/kalshi/perps/resources/portfolio.py @@ -3,12 +3,15 @@ Three margin read endpoints on the standalone perps client: - ``positions`` — ``GET /margin/positions``: auth required, single - non-paginated array (no ``_all()``). + non-paginated array (no ``_all()``). Omit ``subaccount`` to return + positions across all subaccounts. - ``fills`` / ``fills_all`` — ``GET /margin/fills``: auth required, - cursor-paginated (the authenticated user's margin fills). + cursor-paginated (the authenticated user's margin fills). Omit + ``subaccount`` to return fills across all subaccounts. - ``trades`` / ``trades_all`` — ``GET /margin/trades``: **public** (no spec ``security`` block, tag ``market``) — like ``MarketsResource.list_trades`` it - does **not** call ``_require_auth()``. ``ticker`` is required. Cursor-paginated. + does **not** call ``_require_auth()``. ``ticker`` is optional (omit for + trades across all margin markets). Cursor-paginated. All three are GET, so they reuse the transport's retry/error machinery unchanged (retry on 429/502/503/504). @@ -63,13 +66,17 @@ def _margin_fills_params( def _margin_trades_params( *, - ticker: str, + ticker: str | None, min_ts: int | None, max_ts: int | None, limit: int | None, cursor: str | None, ) -> dict[str, Any]: - """Build query params for ``GET /margin/trades`` (``ticker`` is required).""" + """Build query params for ``GET /margin/trades``. + + ``ticker`` is optional — omit (``None``) to retrieve trades across all + margin markets. ``_params`` drops ``None`` so the query key is not sent. + """ limit = _validate_limit(limit, hi=1000) return _params( ticker=ticker, @@ -149,14 +156,18 @@ def fills_all( def trades( self, *, - ticker: str, + ticker: str | None = None, min_ts: int | None = None, max_ts: int | None = None, limit: int | None = None, cursor: str | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[MarginTrade]: - """Public margin trades for ``ticker`` (no auth required).""" + """Public margin trades (no auth required). + + ``ticker`` filters to one market. Omit to retrieve trades across all + margin markets. + """ params = _margin_trades_params( ticker=ticker, min_ts=min_ts, @@ -171,14 +182,18 @@ def trades( def trades_all( self, *, - ticker: str, + ticker: str | None = None, min_ts: int | None = None, max_ts: int | None = None, limit: int | None = None, max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Iterator[MarginTrade]: - """Auto-paginate public margin trades for ``ticker`` (no auth required).""" + """Auto-paginate public margin trades (no auth required). + + ``ticker`` filters to one market. Omit to retrieve trades across all + margin markets. + """ _validate_max_pages(max_pages) params = _margin_trades_params( ticker=ticker, @@ -418,14 +433,18 @@ def fills_all( async def trades( self, *, - ticker: str, + ticker: str | None = None, min_ts: int | None = None, max_ts: int | None = None, limit: int | None = None, cursor: str | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[MarginTrade]: - """Public margin trades for ``ticker`` (no auth required).""" + """Public margin trades (no auth required). + + ``ticker`` filters to one market. Omit to retrieve trades across all + margin markets. + """ params = _margin_trades_params( ticker=ticker, min_ts=min_ts, @@ -440,14 +459,18 @@ async def trades( def trades_all( self, *, - ticker: str, + ticker: str | None = None, min_ts: int | None = None, max_ts: int | None = None, limit: int | None = None, max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> AsyncIterator[MarginTrade]: - """Returns an async iterator over public margin trades — use ``async for`` (no auth).""" + """Returns an async iterator over public margin trades — use ``async for`` (no auth). + + ``ticker`` filters to one market. Omit to retrieve trades across all + margin markets. + """ _validate_max_pages(max_pages) params = _margin_trades_params( ticker=ticker, diff --git a/kalshi/perps/resources/transfers.py b/kalshi/perps/resources/transfers.py index ce9d8df2..393f1bf4 100644 --- a/kalshi/perps/resources/transfers.py +++ b/kalshi/perps/resources/transfers.py @@ -46,6 +46,7 @@ def _build_instance_transfer_body( amount: int | None, source_exchange_shard: int | None, destination_exchange_shard: int | None, + client_transfer_id: UUID | str | None = None, ) -> dict[str, Any]: # Shards are part of the kwargs path — included in the exclusivity check so # `request=...` together with an explicit shard raises instead of silently @@ -57,6 +58,7 @@ def _build_instance_transfer_body( amount=amount, source_exchange_shard=source_exchange_shard, destination_exchange_shard=destination_exchange_shard, + client_transfer_id=client_transfer_id, ) if request is None: if source is None or destination is None or amount is None: @@ -64,12 +66,22 @@ def _build_instance_transfer_body( "transfer_instance() requires `source`, `destination`, and " "`amount` (or pass `request=...`)" ) + # Accept str for caller ergonomics; coerce once to surface a clean + # ValueError on malformed strings before the model validator sees them. + uid = None + if client_transfer_id is not None: + uid = ( + client_transfer_id + if isinstance(client_transfer_id, UUID) + else UUID(client_transfer_id) + ) request = IntraExchangeInstanceTransferRequest( source=source, destination=destination, amount=amount, source_exchange_shard=source_exchange_shard or 0, destination_exchange_shard=destination_exchange_shard or 0, + client_transfer_id=uid, ) return request.model_dump(exclude_none=True, by_alias=True, mode="json") @@ -134,6 +146,7 @@ def transfer_instance( amount: int, source_exchange_shard: int = 0, destination_exchange_shard: int = 0, + client_transfer_id: UUID | str | None = None, extra_headers: dict[str, str] | None = None, ) -> IntraExchangeInstanceTransferResponse: ... def transfer_instance( @@ -145,12 +158,16 @@ def transfer_instance( amount: int | None = None, source_exchange_shard: int | None = None, destination_exchange_shard: int | None = None, + client_transfer_id: UUID | str | None = None, extra_headers: dict[str, str] | None = None, ) -> IntraExchangeInstanceTransferResponse: """Move funds between exchange instances. Spec marks this endpoint "currently not available" — it is implemented forward-compatibly and may return an error until the server enables it. + ``client_transfer_id`` is optional duplicate-prevention (UUID or str). + Omit so the key is not sent; each request then creates a new transfer. + Only supported for primary accounts. """ self._require_auth() body = _build_instance_transfer_body( @@ -160,6 +177,7 @@ def transfer_instance( amount=amount, source_exchange_shard=source_exchange_shard, destination_exchange_shard=destination_exchange_shard, + client_transfer_id=client_transfer_id, ) data = self._post( "/portfolio/intra_exchange_instance_transfer", @@ -242,6 +260,7 @@ async def transfer_instance( amount: int, source_exchange_shard: int = 0, destination_exchange_shard: int = 0, + client_transfer_id: UUID | str | None = None, extra_headers: dict[str, str] | None = None, ) -> IntraExchangeInstanceTransferResponse: ... async def transfer_instance( @@ -253,12 +272,16 @@ async def transfer_instance( amount: int | None = None, source_exchange_shard: int | None = None, destination_exchange_shard: int | None = None, + client_transfer_id: UUID | str | None = None, extra_headers: dict[str, str] | None = None, ) -> IntraExchangeInstanceTransferResponse: """Move funds between exchange instances. Spec marks this endpoint "currently not available" — it is implemented forward-compatibly and may return an error until the server enables it. + ``client_transfer_id`` is optional duplicate-prevention (UUID or str). + Omit so the key is not sent; each request then creates a new transfer. + Only supported for primary accounts. """ self._require_auth() body = _build_instance_transfer_body( @@ -268,6 +291,7 @@ async def transfer_instance( amount=amount, source_exchange_shard=source_exchange_shard, destination_exchange_shard=destination_exchange_shard, + client_transfer_id=client_transfer_id, ) data = await self._post( "/portfolio/intra_exchange_instance_transfer", diff --git a/kalshi/resources/fcm.py b/kalshi/resources/fcm.py index 340f807d..a4ef4cca 100644 --- a/kalshi/resources/fcm.py +++ b/kalshi/resources/fcm.py @@ -2,9 +2,11 @@ Orders/positions filter by ``subtrader_id`` and reuse the existing Order and PositionsResponse shapes. ``fills`` lists fills across the member's -subtraders (``min_ts`` / ``max_ts`` / ``cursor``). Subtrader admin routes -(list/create, blocked categories, event-contract daily cap) live on -``/fcm/subtraders*``. +subtraders (``min_ts`` / ``max_ts`` / ``cursor``). ``create_position_transfer`` +moves a market position from a source subtrader into the FCM error account +(``POST /fcm/positions/transfers``; must be enabled by Kalshi). Subtrader +admin routes (list/create, blocked categories, event-contract daily cap) live +on ``/fcm/subtraders*``. Non-FCM accounts receive 401/403 on these routes. Demo does service them (per Path B audit 2026-04-18) but typically returns empty lists for an @@ -20,6 +22,8 @@ from kalshi.models.common import Page from kalshi.models.fcm import ( + CreateFCMPositionTransferRequest, + CreateFCMPositionTransferResponse, CreateFCMSubtraderRequest, CreateFCMSubtraderResponse, FcmFill, @@ -120,6 +124,46 @@ def _fcm_positions_params( ) +def _build_create_position_transfer_body( + request: CreateFCMPositionTransferRequest | None, + *, + source_subtrader_id: str | None, + target_subtrader_id: str | None, + market_ticker: str | None, + position_centicount: int | None, + price_centicents: int | None, +) -> dict[str, object]: + _check_request_exclusive( + request, + source_subtrader_id=source_subtrader_id, + target_subtrader_id=target_subtrader_id, + market_ticker=market_ticker, + position_centicount=position_centicount, + price_centicents=price_centicents, + ) + if request is None: + if ( + source_subtrader_id is None + or target_subtrader_id is None + or market_ticker is None + or position_centicount is None + or price_centicents is None + ): + raise TypeError( + "create_position_transfer() requires `source_subtrader_id`, " + "`target_subtrader_id`, `market_ticker`, `position_centicount`, " + "and `price_centicents` (or pass `request=...`)" + ) + request = CreateFCMPositionTransferRequest( + source_subtrader_id=source_subtrader_id, + target_subtrader_id=target_subtrader_id, + market_ticker=market_ticker, + position_centicount=position_centicount, + price_centicents=price_centicents, + ) + return request.model_dump(exclude_none=True, by_alias=True, mode="json") + + def _build_create_fcm_subtrader_body( request: CreateFCMSubtraderRequest | None, *, @@ -342,6 +386,54 @@ def positions_all( extra_headers=extra_headers, ) + @overload + def create_position_transfer( + self, + *, + request: CreateFCMPositionTransferRequest, + extra_headers: dict[str, str] | None = None, + ) -> CreateFCMPositionTransferResponse: ... + @overload + def create_position_transfer( + self, + *, + source_subtrader_id: str, + target_subtrader_id: str, + market_ticker: str, + position_centicount: int, + price_centicents: int, + extra_headers: dict[str, str] | None = None, + ) -> CreateFCMPositionTransferResponse: ... + def create_position_transfer( + self, + *, + request: CreateFCMPositionTransferRequest | None = None, + source_subtrader_id: str | None = None, + target_subtrader_id: str | None = None, + market_ticker: str | None = None, + position_centicount: int | None = None, + price_centicents: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> CreateFCMPositionTransferResponse: + """``POST /fcm/positions/transfers``. Not retried. + + Moves a position in one market from ``source_subtrader_id`` into + ``target_subtrader_id`` under the FCM's error account. Kalshi must + enable this endpoint for the FCM; until then the server returns 400. + Requests are not idempotent. + """ + self._require_auth() + body = _build_create_position_transfer_body( + request, + source_subtrader_id=source_subtrader_id, + target_subtrader_id=target_subtrader_id, + market_ticker=market_ticker, + position_centicount=position_centicount, + price_centicents=price_centicents, + ) + data = self._post("/fcm/positions/transfers", json=body, extra_headers=extra_headers) + return CreateFCMPositionTransferResponse.model_validate(data) + def list_subtraders( self, *, extra_headers: dict[str, str] | None = None ) -> ListFCMSubtradersResponse: @@ -645,6 +737,50 @@ def positions_all( extra_headers=extra_headers, ) + @overload + async def create_position_transfer( + self, + *, + request: CreateFCMPositionTransferRequest, + extra_headers: dict[str, str] | None = None, + ) -> CreateFCMPositionTransferResponse: ... + @overload + async def create_position_transfer( + self, + *, + source_subtrader_id: str, + target_subtrader_id: str, + market_ticker: str, + position_centicount: int, + price_centicents: int, + extra_headers: dict[str, str] | None = None, + ) -> CreateFCMPositionTransferResponse: ... + async def create_position_transfer( + self, + *, + request: CreateFCMPositionTransferRequest | None = None, + source_subtrader_id: str | None = None, + target_subtrader_id: str | None = None, + market_ticker: str | None = None, + position_centicount: int | None = None, + price_centicents: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> CreateFCMPositionTransferResponse: + """Async :meth:`FcmResource.create_position_transfer`.""" + self._require_auth() + body = _build_create_position_transfer_body( + request, + source_subtrader_id=source_subtrader_id, + target_subtrader_id=target_subtrader_id, + market_ticker=market_ticker, + position_centicount=position_centicount, + price_centicents=price_centicents, + ) + data = await self._post( + "/fcm/positions/transfers", json=body, extra_headers=extra_headers + ) + return CreateFCMPositionTransferResponse.model_validate(data) + async def list_subtraders( self, *, extra_headers: dict[str, str] | None = None ) -> ListFCMSubtradersResponse: diff --git a/pyproject.toml b/pyproject.toml index dea69e43..cbc33258 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "20.0.0" +version = "21.0.0" description = "A professional Python SDK for the Kalshi prediction markets and Perps (margin) APIs" readme = "README.md" license = { text = "MIT" } diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 986674c1..a18e65a9 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.0 info: title: Kalshi Trade API Manual Endpoints - version: 3.33.0 + version: 3.34.0 description: Manually defined OpenAPI spec for endpoints being migrated to spec-first approach servers: @@ -4133,6 +4133,98 @@ paths: '500': description: Internal server error + /fcm/positions/transfers: + post: + operationId: CreateFCMPositionTransfer + summary: Create FCM Position Transfer + description: | + Endpoint for FCM members to move a 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. + + - 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. + - The transfer is booked like a trade at `price_centicents`, which is always the YES price, even + when moving NO. No fee is charged and no fill is created. For YES, the error account pays the + price per contract; for NO, it pays $1 minus the price per contract. The FCM receives the same + amount. Use 0 for YES or 10000 for NO to move no cash. These amounts assume the source holds + the side being moved and the target subtrader holds no opposite position in that market; + otherwise the positions net like a trade and the balance changes differ. + - The source's current position is not checked. A request for more than the source holds, or + with the opposite sign, flips or grows the source's position. + - Requests are not idempotent. After a timeout or 5xx, check the position before retrying. + - The market must be open, neither account may be blocked, and the error account must have + enough available balance on the market's exchange index to pay for the transfer. Otherwise + the request returns 409. + + **Example: moving YES.** A customer subtrader holds 10 YES contracts. Send + `position_centicount` 1000 and `price_centicents` 4500 (YES at 45 cents): + - The customer subtrader goes from 10 YES to 0, and the error account subtrader from 0 to 10 YES. + - The error account pays 10 x $0.45 = $4.50 and the FCM receives $4.50. + - Sending `price_centicents` 0 instead moves the position with no cash. + + **Example: moving NO.** A customer subtrader holds 5 NO contracts. Send `position_centicount` + -500 and `price_centicents` 7000 (YES at 70 cents, so NO at 30 cents): + - The customer subtrader goes from 5 NO to 0, and the error account subtrader from 0 to 5 NO. + - The error account pays 5 x $0.30 = $1.50 and the FCM receives $1.50. + - Sending `price_centicents` 10000 instead moves the position with no cash. Sending 0 makes the + error account pay the full $1 per contract ($5.00). + tags: + - fcm + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateFCMPositionTransferRequest' + examples: + move_yes: + summary: Move 10 YES at 45 cents + value: + source_subtrader_id: 3f2a8c1e-5b7d-4e9a-8c6f-1d2b3a4e5f60_CUST123 + target_subtrader_id: 9b8c7d6e-5f4a-4b3c-9d2e-1f0a9b8c7d6e_ERR1 + market_ticker: KXEXAMPLE-26DEC31 + position_centicount: 1000 + price_centicents: 4500 + move_no: + summary: Move 5 NO at 30 cents (YES price 70 cents) + value: + source_subtrader_id: 3f2a8c1e-5b7d-4e9a-8c6f-1d2b3a4e5f60_CUST123 + target_subtrader_id: 9b8c7d6e-5f4a-4b3c-9d2e-1f0a9b8c7d6e_ERR1 + market_ticker: KXEXAMPLE-26DEC31 + position_centicount: -500 + price_centicents: 7000 + responses: + '201': + description: Position transferred + content: + application/json: + schema: + $ref: '#/components/schemas/CreateFCMPositionTransferResponse' + '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' + /historical/cutoff: get: operationId: GetHistoricalCutoff @@ -7192,6 +7284,54 @@ components: type: string description: The full id of the created subtrader. + CreateFCMPositionTransferRequest: + type: object + required: + - source_subtrader_id + - target_subtrader_id + - market_ticker + - position_centicount + - price_centicents + properties: + source_subtrader_id: + type: string + description: Full id of the FCM subtrader giving up the position, as returned by `GET /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 + market_ticker: + type: string + description: Ticker of the market whose position is moved. + x-oapi-codegen-extra-tags: + validate: required + position_centicount: + type: integer + format: int64 + description: Contracts to move, in centicounts (contracts x 100). Positive moves YES, negative moves NO. Must be a non-zero whole number of contracts, e.g. 1000 for 10 YES or -500 for 5 NO. + x-oapi-codegen-extra-tags: + validate: required,ne=0 + price_centicents: + type: integer + format: int64 + minimum: 0 + maximum: 10000 + description: YES price per contract in centicents (cents x 100), in whole cents, even when moving NO. For example, 4500 is 45 cents. + x-oapi-codegen-extra-tags: + validate: gte=0,lte=10000 + + CreateFCMPositionTransferResponse: + type: object + required: + - position_transfer_id + properties: + position_transfer_id: + type: string + description: Id of the executed transfer. Store it; no Trade API endpoint lists past transfers. + GetFCMEventContractDailyCapResponse: type: object required: @@ -7358,6 +7498,12 @@ components: - destination - amount properties: + client_transfer_id: + type: string + format: uuid + description: | + Client ID for duplicate prevention. Reuse only for identical requests. Scoped to the user and both exchange instances and shards. Accepted transfers cannot be repeated with the same ID on the same route. + Only supported for primary accounts. Transfers involving numbered subaccounts reject this field. If omitted, each request creates a new transfer. source: $ref: '#/components/schemas/ExchangeInstance' description: The source exchange instance diff --git a/specs/perps_openapi.yaml b/specs/perps_openapi.yaml index 34d6dd36..436bf52b 100644 --- a/specs/perps_openapi.yaml +++ b/specs/perps_openapi.yaml @@ -736,10 +736,9 @@ paths: - name: subaccount in: query required: false - description: Subaccount number (0 for primary, 1-63 for subaccounts) + description: Subaccount number (0 for primary, 1-63 for subaccounts). If omitted, returns fills across all subaccounts for the authenticated user. Subaccount-restricted API keys must omit this or pass their locked subaccount, and read only that subaccount's fills. schema: type: integer - default: 0 - name: limit in: query required: false @@ -803,7 +802,7 @@ paths: - name: subaccount in: query required: false - description: Subaccount number (0 for primary, 1-63 for subaccounts) + description: Subaccount number (0 for primary, 1-63 for subaccounts). If omitted, returns positions for all subaccounts. Subaccount-restricted API keys must omit this or pass their locked subaccount, and read only that subaccount's positions. schema: type: integer - name: ticker @@ -831,16 +830,17 @@ paths: get: operationId: GetMarginTrades summary: Get Trades - description: 'Endpoint for retrieving public margin trades for a given market ticker. Returns a paginated response. Use the cursor value from the previous response to get the next page.' + description: 'Endpoint for retrieving public margin trades across all markets or filtered by market ticker. Returns a paginated response. Use the cursor value from the previous response to get the next page.' tags: - market parameters: - name: ticker in: query - required: true + required: false schema: type: string - description: Market ticker to retrieve trades for + x-go-type-skip-optional-pointer: true + description: Optional market ticker to filter trades. Omit to retrieve trades across all margin markets. - name: limit in: query required: false @@ -2459,6 +2459,12 @@ components: - destination - amount properties: + client_transfer_id: + type: string + format: uuid + description: | + Client ID for duplicate prevention. Reuse only for identical requests. Scoped to the user and both exchange instances and shards. Accepted transfers cannot be repeated with the same ID on the same route. + Only supported for primary accounts. Transfers involving numbered subaccounts reject this field. If omitted, each request creates a new transfer. source: $ref: '#/components/schemas/ExchangeInstance' description: The source exchange instance @@ -2642,9 +2648,7 @@ components: subaccount: type: integer minimum: 0 - default: 0 - description: The subaccount number to use for this margin order. 0 is the primary subaccount. - x-go-type-skip-optional-pointer: true + description: The subaccount number to use for this margin order. 0 is the primary subaccount. Subaccount-restricted API keys must omit this field or pass their locked subaccount. order_group_id: type: string description: The order group this order is part of @@ -2927,6 +2931,9 @@ components: - schedule - exchange_index - market_version + - funding_deadband + - funding_cap + - funding_limit_unit properties: ticker: type: string @@ -3009,9 +3016,15 @@ components: bid: $ref: '#/components/schemas/FixedPointDollars' description: Best bid price in dollars. + bid_size_fp: + $ref: '#/components/schemas/FixedPointCount' + description: Total number of contracts resting at the best bid price, as a fixed-point count string. Zero when there is no bid. Omitted when market stats are unavailable. ask: $ref: '#/components/schemas/FixedPointDollars' description: Best ask price in dollars. + ask_size_fp: + $ref: '#/components/schemas/FixedPointCount' + description: Total number of contracts resting at the best ask price, as a fixed-point count string. Zero when there is no ask. Omitted when market stats are unavailable. settlement_mark_price: $ref: '#/components/schemas/TickerPrice' description: Mark price used for settlement and funding. @@ -3037,6 +3050,21 @@ components: markdown: "**Important information:** Review this market's trading schedule." schedule: $ref: '#/components/schemas/MarginMarketSchedule' + funding_deadband: + type: number + format: double + minimum: 0 + description: Funding deadband configured at market creation, expressed in funding_limit_unit. + funding_cap: + type: number + format: double + minimum: 0 + description: Funding cap configured at market creation, expressed in funding_limit_unit. + funding_limit_unit: + type: string + enum: [percentage, dollar] + x-go-type-skip-optional-pointer: true + description: Unit for the funding limits configured at market creation. MarginMarketSchedule: type: object @@ -3852,11 +3880,10 @@ components: name: subaccount in: query required: false - description: Subaccount number (0 for primary, 1-63 for subaccounts). Defaults to 0. + description: Subaccount number (0 for primary, 1-63 for subaccounts). Defaults to 0. Subaccount-restricted API keys default to their locked subaccount and must omit this parameter or pass their locked subaccount. schema: type: integer minimum: 0 - default: 0 ExitTriggerTickerPath: name: ticker in: path diff --git a/specs/perps_scm_openapi.yaml b/specs/perps_scm_openapi.yaml index 887a5d0d..ec67301c 100644 --- a/specs/perps_scm_openapi.yaml +++ b/specs/perps_scm_openapi.yaml @@ -197,8 +197,9 @@ paths: description: >- Positions of the authenticated clearing member that meet the CFTC large-trader reporting levels for a settlement day (17 CFR 15.03(b); - currently 25 contracts for crypto), plus exit-day positions that were - reportable on the prior settlement day and are now below the threshold. + each row's reportable_threshold is its market's level), plus exit-day + positions that were reportable on the prior settlement day and are now + below the threshold. Members with reportable positions are subject to their own CFTC Part 17 large-trader reporting obligations; this endpoint provides the position data needed to meet them. Empty when no positions meet the reporting @@ -903,6 +904,7 @@ components: market_ticker: { type: string } position: { type: integer, description: Signed contract count; positive long, negative short. Can be below the reportable threshold when exit_day is true. } exit_day: { type: boolean, description: True when this row is included because the same market was reportable on the prior settlement day but is now below the reportable threshold. } + reportable_threshold: { type: integer, format: int64, description: 'CFTC reporting level for this market, in contracts (17 CFR 15.03(b)). A position of at least this size in either direction is reportable.' } EstimatePortfolioMaintenanceMarginPosition: type: object required: [market_ticker, quantity, price] diff --git a/tests/_contract_support.py b/tests/_contract_support.py index 80921fae..e4b07bcc 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -857,6 +857,12 @@ class Exclusion: http_method="GET", path_template="/fcm/positions", ), + MethodEndpointEntry( + sdk_method="kalshi.resources.fcm.FcmResource.create_position_transfer", + http_method="POST", + path_template="/fcm/positions/transfers", + request_body_schema="#/components/schemas/CreateFCMPositionTransferRequest", + ), MethodEndpointEntry( sdk_method="kalshi.resources.fcm.FcmResource.list_subtraders", http_method="GET", diff --git a/tests/perps/test_markets.py b/tests/perps/test_markets.py index 53a69d9e..650319bb 100644 --- a/tests/perps/test_markets.py +++ b/tests/perps/test_markets.py @@ -46,6 +46,9 @@ def _market_dict(**overrides: object) -> dict[str, object]: }, "exchange_index": 0, "market_version": 1, + "funding_deadband": 0.0, + "funding_cap": 1.0, + "funding_limit_unit": "percentage", "leverage_estimate": 2.5, "leverage_estimates": {"1000": 2.5, "10000": 2.0, "100000": 1.5}, "long_leverage_estimates": {"1000": 2.4, "10000": 1.9}, @@ -123,6 +126,8 @@ def test_happy(self, perps_client: PerpsClient) -> None: "10000": Decimal("2.1"), } assert m.volume == Decimal("1000.00") + assert m.bid_size is None + assert m.ask_size is None assert m.volume_notional_value == Decimal("560.0000") assert m.volume_24h_notional_value == Decimal("140.0000") assert m.open_interest_notional_value == Decimal("280.0000") @@ -133,6 +138,54 @@ def test_happy(self, perps_client: PerpsClient) -> None: assert m.schedule.next_open_ts is None assert m.exchange_index == 0 assert m.market_version == 1 + assert m.funding_deadband == Decimal("0.0") + assert m.funding_cap == Decimal("1.0") + assert m.funding_limit_unit == "percentage" + assert isinstance(m.funding_deadband, Decimal) + assert isinstance(m.funding_cap, Decimal) + + @respx.mock + def test_bid_ask_size_fp_parsed(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/markets").mock( + return_value=httpx.Response( + 200, + json={"markets": [_market_dict(bid_size_fp="10.00", ask_size_fp="12.50")]}, + ) + ) + m = perps_client.markets.list()[0] + assert m.bid_size == Decimal("10.00") + assert m.ask_size == Decimal("12.50") + assert isinstance(m.bid_size, Decimal) + assert isinstance(m.ask_size, Decimal) + + def test_bid_ask_size_accepts_bare_alias(self) -> None: + m = MarginMarket.model_validate(_market_dict(bid_size="3.00", ask_size="4.00")) + assert m.bid_size == Decimal("3.00") + assert m.ask_size == Decimal("4.00") + + def test_bid_ask_size_omitted_when_missing(self) -> None: + m = MarginMarket.model_validate(_market_dict()) + assert m.bid_size is None + assert m.ask_size is None + + def test_funding_fields_required(self) -> None: + for key in ("funding_deadband", "funding_cap", "funding_limit_unit"): + payload = _market_dict() + del payload[key] + with pytest.raises(ValidationError): + MarginMarket.model_validate(payload) + + def test_funding_limit_unit_dollar(self) -> None: + m = MarginMarket.model_validate( + _market_dict(funding_deadband=0.25, funding_cap=5.0, funding_limit_unit="dollar") + ) + assert m.funding_deadband == Decimal("0.25") + assert m.funding_cap == Decimal("5.0") + assert m.funding_limit_unit == "dollar" + + def test_funding_limit_unit_rejects_unknown(self) -> None: + with pytest.raises(ValidationError): + MarginMarket.model_validate(_market_dict(funding_limit_unit="bps")) @respx.mock def test_status_filter(self, perps_client: PerpsClient) -> None: @@ -161,6 +214,9 @@ def test_null_leverage_and_missing_optionals(self, perps_client: PerpsClient) -> "schedule": None, "exchange_index": 0, "market_version": 1, + "funding_deadband": 0.0, + "funding_cap": 1.0, + "funding_limit_unit": "percentage", "leverage_estimate": None, } ] @@ -172,7 +228,9 @@ def test_null_leverage_and_missing_optionals(self, perps_client: PerpsClient) -> assert m.leverage_estimate is None assert m.price is None assert m.bid is None + assert m.bid_size is None assert m.ask is None + assert m.ask_size is None assert m.volume is None assert m.volume_24h is None assert m.open_interest is None diff --git a/tests/perps/test_orders.py b/tests/perps/test_orders.py index 71f51844..de8e415f 100644 --- a/tests/perps/test_orders.py +++ b/tests/perps/test_orders.py @@ -102,6 +102,29 @@ def test_happy(self, perps_client: PerpsClient) -> None: assert "market_version" not in body # unset optional market_id is omitted (exclude_none) assert "market_id" not in body + # unset optional subaccount is omitted (exclude_none), not sent as 0 + assert "subaccount" not in body + + @respx.mock + def test_subaccount_zero_sent_when_passed(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/margin/orders").mock( + return_value=httpx.Response( + 201, + json={"order_id": "ord-9", "fill_count": "0.00", "remaining_count": "100.00"}, + ) + ) + perps_client.orders.create( + ticker="BTC-PERP", + client_order_id="cid-9", + side="bid", + count="100", + price="0.56", + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + subaccount=0, + ) + body = json.loads(route.calls[0].request.content) + assert body["subaccount"] == 0 @respx.mock def test_market_version_serialized(self, perps_client: PerpsClient) -> None: diff --git a/tests/perps/test_portfolio.py b/tests/perps/test_portfolio.py index 51f42adf..bbc5a42d 100644 --- a/tests/perps/test_portfolio.py +++ b/tests/perps/test_portfolio.py @@ -378,7 +378,6 @@ def test_happy(self, perps_client: PerpsClient) -> None: assert trade.count == Decimal("5.00") assert isinstance(trade.created_time, datetime) assert page.has_next is False - # ticker always present on /margin/trades. assert route.calls.last.request.url.params["ticker"] == "BTC-PERP" @respx.mock @@ -393,13 +392,23 @@ def test_public_unauthenticated_client_works(self) -> None: assert route.called client.close() - def test_ticker_required(self, perps_client: PerpsClient) -> None: - with pytest.raises(TypeError): - perps_client.portfolio.trades() # type: ignore[call-arg] + @respx.mock + def test_ticker_omitted_not_sent(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/trades").mock( + return_value=httpx.Response(200, json={"trades": [_trade()], "cursor": ""}) + ) + page = perps_client.portfolio.trades() + assert len(page.items) == 1 + assert "ticker" not in route.calls.last.request.url.params - def test_trades_all_ticker_required(self, perps_client: PerpsClient) -> None: - with pytest.raises(TypeError): - list(perps_client.portfolio.trades_all()) # type: ignore[call-arg] + @respx.mock + def test_trades_all_ticker_omitted_not_sent(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/trades").mock( + return_value=httpx.Response(200, json={"trades": [_trade()], "cursor": ""}) + ) + items = list(perps_client.portfolio.trades_all()) + assert len(items) == 1 + assert "ticker" not in route.calls.last.request.url.params @respx.mock def test_query_params(self, perps_client: PerpsClient) -> None: @@ -470,10 +479,23 @@ async def test_async_public_unauthenticated_works(self) -> None: ) client = AsyncPerpsClient(config=PerpsConfig.demo()) page = await client.portfolio.trades(ticker="BTC-PERP") + assert len(page.items) == 1 assert page.items[0].taker_side == "ask" assert route.called await client.close() + @respx.mock + async def test_async_ticker_omitted_not_sent( + self, async_perps_client: AsyncPerpsClient + ) -> None: + route = respx.get(f"{BASE}/margin/trades").mock( + return_value=httpx.Response(200, json={"trades": [_trade()], "cursor": ""}) + ) + page = await async_perps_client.portfolio.trades() + assert len(page.items) == 1 + assert "ticker" not in route.calls.last.request.url.params + await async_perps_client.close() + @respx.mock async def test_async_trades_all_paginates( self, async_perps_client: AsyncPerpsClient diff --git a/tests/perps/test_review_fixes.py b/tests/perps/test_review_fixes.py index 46d09ea3..7e372743 100644 --- a/tests/perps/test_review_fixes.py +++ b/tests/perps/test_review_fixes.py @@ -117,6 +117,19 @@ def test_kwargs_path_defaults_shards_to_zero(self) -> None: ) assert body["source_exchange_shard"] == 0 assert body["destination_exchange_shard"] == 0 + assert "client_transfer_id" not in body + + def test_request_plus_client_transfer_id_raises(self) -> None: + with pytest.raises(TypeError): + _build_instance_transfer_body( + self._req(), + source=None, + destination=None, + amount=None, + source_exchange_shard=None, + destination_exchange_shard=None, + client_transfer_id="550e8400-e29b-41d4-a716-446655440000", + ) # ── partial-credential fail-fast ───────────────────────────────────────────── diff --git a/tests/perps/test_transfers.py b/tests/perps/test_transfers.py index 45dbce4c..58fcd737 100644 --- a/tests/perps/test_transfers.py +++ b/tests/perps/test_transfers.py @@ -40,6 +40,26 @@ def test_instance_request_serializes(self) -> None: "source_exchange_shard": 0, "destination_exchange_shard": 0, } + assert "client_transfer_id" not in body + + def test_instance_request_serializes_client_transfer_id(self) -> None: + req = IntraExchangeInstanceTransferRequest( + source="event_contract", + destination="margined", + amount=100, + client_transfer_id=_XFER_ID, + ) + body = req.model_dump(exclude_none=True, by_alias=True, mode="json") + assert body["client_transfer_id"] == _XFER_ID + + def test_instance_request_rejects_malformed_client_transfer_id(self) -> None: + with pytest.raises(ValidationError): + IntraExchangeInstanceTransferRequest( + source="event_contract", + destination="margined", + amount=1, + client_transfer_id="not-a-uuid", + ) def test_instance_request_forbids_extra(self) -> None: with pytest.raises(ValidationError): @@ -143,6 +163,46 @@ def test_happy(self, perps_client: PerpsClient) -> None: "source_exchange_shard": 0, "destination_exchange_shard": 0, } + assert "client_transfer_id" not in body + + @respx.mock + def test_client_transfer_id_serialized(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/portfolio/intra_exchange_instance_transfer").mock( + return_value=httpx.Response(200, json={"transfer_id": "abc"}) + ) + perps_client.transfers.transfer_instance( + source="event_contract", + destination="margined", + amount=100, + client_transfer_id=_XFER_ID, + ) + body = json.loads(route.calls[0].request.content) + assert body["client_transfer_id"] == _XFER_ID + + @respx.mock + def test_client_transfer_id_accepts_uuid(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/portfolio/intra_exchange_instance_transfer").mock( + return_value=httpx.Response(200, json={"transfer_id": "abc"}) + ) + perps_client.transfers.transfer_instance( + source="event_contract", + destination="margined", + amount=100, + client_transfer_id=UUID(_XFER_ID), + ) + body = json.loads(route.calls[0].request.content) + assert body["client_transfer_id"] == _XFER_ID + + def test_client_transfer_id_malformed_raises_before_http( + self, perps_client: PerpsClient + ) -> None: + with pytest.raises(ValueError): + perps_client.transfers.transfer_instance( + source="event_contract", + destination="margined", + amount=100, + client_transfer_id="not-a-uuid", + ) @respx.mock def test_request_object_overload_identical_body(self, perps_client: PerpsClient) -> None: @@ -239,6 +299,24 @@ async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: assert resp.transfer_id == "zzz" body = json.loads(route.calls[0].request.content) assert body["amount"] == 42 + assert "client_transfer_id" not in body + await async_perps_client.close() + + @respx.mock + async def test_async_client_transfer_id_serialized( + self, async_perps_client: AsyncPerpsClient + ) -> None: + route = respx.post(f"{BASE}/portfolio/intra_exchange_instance_transfer").mock( + return_value=httpx.Response(200, json={"transfer_id": "zzz"}) + ) + await async_perps_client.transfers.transfer_instance( + source="margined", + destination="event_contract", + amount=42, + client_transfer_id=_XFER_ID, + ) + body = json.loads(route.calls[0].request.content) + assert body["client_transfer_id"] == _XFER_ID await async_perps_client.close() @respx.mock diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 1fd8c6ea..8a1b1740 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -1450,6 +1450,9 @@ def _assert_params_match( "#/components/schemas/SetTargetBalanceAllocationRequest": ( "kalshi.models.portfolio.SetTargetBalanceAllocationRequest" ), + "#/components/schemas/CreateFCMPositionTransferRequest": ( + "kalshi.models.fcm.CreateFCMPositionTransferRequest" + ), "#/components/schemas/CreateFCMSubtraderRequest": ( "kalshi.models.fcm.CreateFCMSubtraderRequest" ), diff --git a/tests/test_fcm.py b/tests/test_fcm.py index 2fab974d..fc519bc6 100644 --- a/tests/test_fcm.py +++ b/tests/test_fcm.py @@ -8,11 +8,18 @@ import httpx import pytest import respx +from pydantic import ValidationError from kalshi._base_client import AsyncTransport, SyncTransport from kalshi.auth import KalshiAuth from kalshi.config import KalshiConfig -from kalshi.errors import AuthRequiredError, KalshiAuthError +from kalshi.errors import ( + AuthRequiredError, + KalshiAuthError, + KalshiConflictError, + KalshiValidationError, +) +from kalshi.models.fcm import CreateFCMPositionTransferRequest from kalshi.resources.fcm import AsyncFcmResource, FcmResource from tests._model_fixtures import market_position_dict, order_dict @@ -506,3 +513,128 @@ async def test_async_fills_all(self, async_fcm: AsyncFcmResource) -> None: ) ids = [fill.fill_id async for fill in async_fcm.fills_all()] assert ids == ["fill-1"] + + +_TRANSFER_KWARGS = { + "source_subtrader_id": "3f2a8c1e-5b7d-4e9a-8c6f-1d2b3a4e5f60_CUST123", + "target_subtrader_id": "9b8c7d6e-5f4a-4b3c-9d2e-1f0a9b8c7d6e_ERR1", + "market_ticker": "KXEXAMPLE-26DEC31", + "position_centicount": 1000, + "price_centicents": 4500, +} + + +class TestCreatePositionTransfer: + @respx.mock + def test_happy_path(self, fcm: FcmResource) -> None: + route = respx.post(f"{_FCM}/fcm/positions/transfers").mock( + return_value=httpx.Response(201, json={"position_transfer_id": "pt-1"}) + ) + resp = fcm.create_position_transfer(**_TRANSFER_KWARGS) + assert resp.position_transfer_id == "pt-1" + assert json.loads(route.calls[0].request.content) == _TRANSFER_KWARGS + + @respx.mock + def test_request_model_same_body_as_kwargs(self, fcm: FcmResource) -> None: + route = respx.post(f"{_FCM}/fcm/positions/transfers").mock( + return_value=httpx.Response(201, json={"position_transfer_id": "pt-1"}) + ) + fcm.create_position_transfer(**_TRANSFER_KWARGS) + kwarg_body = json.loads(route.calls[0].request.content) + fcm.create_position_transfer(request=CreateFCMPositionTransferRequest(**_TRANSFER_KWARGS)) + model_body = json.loads(route.calls[1].request.content) + assert kwarg_body == model_body + + def test_missing_required_kwargs(self, fcm: FcmResource) -> None: + with pytest.raises(TypeError, match="create_position_transfer"): + fcm.create_position_transfer() # type: ignore[call-overload] + + def test_request_and_kwargs_exclusive(self, fcm: FcmResource) -> None: + req = CreateFCMPositionTransferRequest(**_TRANSFER_KWARGS) + with pytest.raises(TypeError, match="not both"): + fcm.create_position_transfer( + request=req, + market_ticker="OTHER", + ) + + def test_requires_auth(self, unauth_fcm: FcmResource) -> None: + with pytest.raises(AuthRequiredError): + unauth_fcm.create_position_transfer(**_TRANSFER_KWARGS) + + @respx.mock + def test_server_401(self, fcm: FcmResource) -> None: + respx.post(f"{_FCM}/fcm/positions/transfers").mock( + return_value=httpx.Response(401, json={"error": "unauthorized"}) + ) + with pytest.raises(KalshiAuthError): + fcm.create_position_transfer(**_TRANSFER_KWARGS) + + @respx.mock + def test_server_400_not_enabled(self, fcm: FcmResource) -> None: + respx.post(f"{_FCM}/fcm/positions/transfers").mock( + return_value=httpx.Response(400, json={"error": "not enabled"}) + ) + with pytest.raises(KalshiValidationError): + fcm.create_position_transfer(**_TRANSFER_KWARGS) + + @respx.mock + def test_server_409_conflict(self, fcm: FcmResource) -> None: + respx.post(f"{_FCM}/fcm/positions/transfers").mock( + return_value=httpx.Response(409, json={"error": "market not open"}) + ) + with pytest.raises(KalshiConflictError): + fcm.create_position_transfer(**_TRANSFER_KWARGS) + + def test_rejects_zero_position_centicount(self) -> None: + with pytest.raises(ValidationError, match="non-zero"): + CreateFCMPositionTransferRequest( + source_subtrader_id="src", + target_subtrader_id="tgt", + market_ticker="MKT", + position_centicount=0, + price_centicents=0, + ) + + def test_rejects_price_out_of_range(self) -> None: + with pytest.raises(ValidationError): + CreateFCMPositionTransferRequest( + source_subtrader_id="src", + target_subtrader_id="tgt", + market_ticker="MKT", + position_centicount=100, + price_centicents=10001, + ) + + def test_rejects_phantom_body_key(self) -> None: + with pytest.raises(ValidationError): + CreateFCMPositionTransferRequest( + **_TRANSFER_KWARGS, + extra_field="nope", # type: ignore[call-arg] + ) + + @respx.mock + def test_move_no_negative_centicount(self, fcm: FcmResource) -> None: + route = respx.post(f"{_FCM}/fcm/positions/transfers").mock( + return_value=httpx.Response(201, json={"position_transfer_id": "pt-no"}) + ) + resp = fcm.create_position_transfer( + source_subtrader_id=_TRANSFER_KWARGS["source_subtrader_id"], + target_subtrader_id=_TRANSFER_KWARGS["target_subtrader_id"], + market_ticker=_TRANSFER_KWARGS["market_ticker"], + position_centicount=-500, + price_centicents=7000, + ) + assert resp.position_transfer_id == "pt-no" + body = json.loads(route.calls[0].request.content) + assert body["position_centicount"] == -500 + assert body["price_centicents"] == 7000 + + @respx.mock + @pytest.mark.asyncio + async def test_async_happy_path(self, async_fcm: AsyncFcmResource) -> None: + route = respx.post(f"{_FCM}/fcm/positions/transfers").mock( + return_value=httpx.Response(201, json={"position_transfer_id": "pt-a"}) + ) + resp = await async_fcm.create_position_transfer(**_TRANSFER_KWARGS) + assert resp.position_transfer_id == "pt-a" + assert json.loads(route.calls[0].request.content) == _TRANSFER_KWARGS