diff --git a/CHANGELOG.md b/CHANGELOG.md index 0872711..77f34de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes to kalshi-sdk will be documented in this file. +## 22.1.0 — 2026-10-17 + +Reconciles nightly spec drift (#547). Additive and optional only — no +breaking changes. + +### Added + +- Perps **`transfers.transfer_instance(..., source_subaccount=, + destination_subaccount=)`** (sync + async) and the matching optional + fields on `IntraExchangeInstanceTransferRequest` (`int | None`, `>= 0`). + Upstream only supports these for event contract to event contract + transfers. Default `None` omits the keys; an explicit `0` is sent. + +- Perps **`transfers.transfer_subaccount(..., exchange_index=)`** (sync + + async) and the matching optional field on the perps + `ApplySubaccountTransferRequest` (`int | None`, `>= 0`). Upstream notes + that perps subaccount transfers ignore this field. Default `None` omits + the key. + +### Changed + +- Re-vendored `specs/` (OpenAPI 3.34.0, perps OpenAPI, AsyncAPI, perps + AsyncAPI). Besides the fields above the upstream changes are + description, example, vendor-extension, and response-side additive + optional fields only. + ## 22.0.0 — 2026-10-16 Reconciles three rounds of nightly spec drift (#540, #542, #544). diff --git a/kalshi/__init__.py b/kalshi/__init__.py index cbd40ee..560aadb 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -431,4 +431,4 @@ "Withdrawal", ] -__version__ = "22.0.0" +__version__ = "22.1.0" diff --git a/kalshi/perps/models/transfers.py b/kalshi/perps/models/transfers.py index ea38645..c583067 100644 --- a/kalshi/perps/models/transfers.py +++ b/kalshi/perps/models/transfers.py @@ -43,6 +43,9 @@ 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. + ``source_subaccount`` / ``destination_subaccount`` default to ``None`` so + ``exclude_none`` drops them (an explicit ``0`` is still sent). Only + supported for event contract to event contract transfers. ``client_transfer_id`` is optional duplicate-prevention; omit (``None``) so ``exclude_none`` drops the key. Only supported for primary accounts. """ @@ -52,6 +55,8 @@ 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) + source_subaccount: StrictInt | None = Field(default=None, ge=0) + destination_subaccount: StrictInt | None = Field(default=None, 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 @@ -69,12 +74,15 @@ class ApplySubaccountTransferRequest(BaseModel): primary account and a positive integer for numbered subaccounts. Spec prose says ``1-63`` but defines no JSON-schema maximum, so only the lower bound is validated (``ge=0``) — server-assigned numbers always round-trip. + ``exchange_index`` is optional (spec ``ExchangeIndex``, ``ge=0``). Perps + subaccount transfers ignore this field; omit so ``exclude_none`` drops it. """ client_transfer_id: UUID from_subaccount: StrictInt = Field(ge=0) to_subaccount: StrictInt = Field(ge=0) amount_cents: StrictInt = Field(gt=0) + exchange_index: StrictInt | None = Field(default=None, ge=0) model_config = {"extra": "forbid"} diff --git a/kalshi/perps/resources/transfers.py b/kalshi/perps/resources/transfers.py index 393f1bf..11e1f58 100644 --- a/kalshi/perps/resources/transfers.py +++ b/kalshi/perps/resources/transfers.py @@ -46,11 +46,15 @@ def _build_instance_transfer_body( amount: int | None, source_exchange_shard: int | None, destination_exchange_shard: int | None, + source_subaccount: int | None = None, + destination_subaccount: int | None = 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 - # dropping the shard. They default to 0 only when building from kwargs. + # Shards and optional subaccount fields are part of the kwargs path — + # included in the exclusivity check so `request=...` together with an + # explicit value raises instead of silently dropping it. Shards default + # to 0 only when building from kwargs; subaccount fields stay None + # (exclude_none drops them) unless the caller passed an explicit int. _check_request_exclusive( request, source=source, @@ -59,6 +63,8 @@ def _build_instance_transfer_body( source_exchange_shard=source_exchange_shard, destination_exchange_shard=destination_exchange_shard, client_transfer_id=client_transfer_id, + source_subaccount=source_subaccount, + destination_subaccount=destination_subaccount, ) if request is None: if source is None or destination is None or amount is None: @@ -81,6 +87,8 @@ def _build_instance_transfer_body( amount=amount, source_exchange_shard=source_exchange_shard or 0, destination_exchange_shard=destination_exchange_shard or 0, + source_subaccount=source_subaccount, + destination_subaccount=destination_subaccount, client_transfer_id=uid, ) return request.model_dump(exclude_none=True, by_alias=True, mode="json") @@ -93,6 +101,7 @@ def _build_subaccount_transfer_body( from_subaccount: int | None, to_subaccount: int | None, amount_cents: int | None, + exchange_index: int | None = None, ) -> dict[str, Any]: _check_request_exclusive( request, @@ -100,6 +109,7 @@ def _build_subaccount_transfer_body( from_subaccount=from_subaccount, to_subaccount=to_subaccount, amount_cents=amount_cents, + exchange_index=exchange_index, ) if request is None: if ( @@ -123,6 +133,7 @@ def _build_subaccount_transfer_body( from_subaccount=from_subaccount, to_subaccount=to_subaccount, amount_cents=amount_cents, + exchange_index=exchange_index, ) return request.model_dump(exclude_none=True, by_alias=True, mode="json") @@ -146,6 +157,8 @@ def transfer_instance( amount: int, source_exchange_shard: int = 0, destination_exchange_shard: int = 0, + source_subaccount: int | None = None, + destination_subaccount: int | None = None, client_transfer_id: UUID | str | None = None, extra_headers: dict[str, str] | None = None, ) -> IntraExchangeInstanceTransferResponse: ... @@ -158,6 +171,8 @@ def transfer_instance( amount: int | None = None, source_exchange_shard: int | None = None, destination_exchange_shard: int | None = None, + source_subaccount: int | None = None, + destination_subaccount: int | None = None, client_transfer_id: UUID | str | None = None, extra_headers: dict[str, str] | None = None, ) -> IntraExchangeInstanceTransferResponse: @@ -165,6 +180,9 @@ def transfer_instance( Spec marks this endpoint "currently not available" — it is implemented forward-compatibly and may return an error until the server enables it. + ``source_subaccount`` / ``destination_subaccount`` are optional; only + supported for event contract to event contract transfers. Omit so the + keys are not sent; an explicit ``0`` is emitted. ``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. @@ -178,6 +196,8 @@ def transfer_instance( source_exchange_shard=source_exchange_shard, destination_exchange_shard=destination_exchange_shard, client_transfer_id=client_transfer_id, + source_subaccount=source_subaccount, + destination_subaccount=destination_subaccount, ) data = self._post( "/portfolio/intra_exchange_instance_transfer", @@ -213,6 +233,7 @@ def transfer_subaccount( from_subaccount: int, to_subaccount: int, amount_cents: int, + exchange_index: int | None = None, extra_headers: dict[str, str] | None = None, ) -> None: ... def transfer_subaccount( @@ -223,9 +244,14 @@ def transfer_subaccount( from_subaccount: int | None = None, to_subaccount: int | None = None, amount_cents: int | None = None, + exchange_index: int | None = None, extra_headers: dict[str, str] | None = None, ) -> None: - """Move funds between margin subaccounts (returns ``None``).""" + """Move funds between margin subaccounts (returns ``None``). + + ``exchange_index`` is optional; perps subaccount transfers ignore this + field. Omit so the key is not sent; an explicit ``0`` is emitted. + """ self._require_auth() body = _build_subaccount_transfer_body( request, @@ -233,6 +259,7 @@ def transfer_subaccount( from_subaccount=from_subaccount, to_subaccount=to_subaccount, amount_cents=amount_cents, + exchange_index=exchange_index, ) self._post( "/portfolio/margin/subaccounts/transfer", @@ -260,6 +287,8 @@ async def transfer_instance( amount: int, source_exchange_shard: int = 0, destination_exchange_shard: int = 0, + source_subaccount: int | None = None, + destination_subaccount: int | None = None, client_transfer_id: UUID | str | None = None, extra_headers: dict[str, str] | None = None, ) -> IntraExchangeInstanceTransferResponse: ... @@ -272,6 +301,8 @@ async def transfer_instance( amount: int | None = None, source_exchange_shard: int | None = None, destination_exchange_shard: int | None = None, + source_subaccount: int | None = None, + destination_subaccount: int | None = None, client_transfer_id: UUID | str | None = None, extra_headers: dict[str, str] | None = None, ) -> IntraExchangeInstanceTransferResponse: @@ -279,6 +310,9 @@ async def transfer_instance( Spec marks this endpoint "currently not available" — it is implemented forward-compatibly and may return an error until the server enables it. + ``source_subaccount`` / ``destination_subaccount`` are optional; only + supported for event contract to event contract transfers. Omit so the + keys are not sent; an explicit ``0`` is emitted. ``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. @@ -292,6 +326,8 @@ async def transfer_instance( source_exchange_shard=source_exchange_shard, destination_exchange_shard=destination_exchange_shard, client_transfer_id=client_transfer_id, + source_subaccount=source_subaccount, + destination_subaccount=destination_subaccount, ) data = await self._post( "/portfolio/intra_exchange_instance_transfer", @@ -327,6 +363,7 @@ async def transfer_subaccount( from_subaccount: int, to_subaccount: int, amount_cents: int, + exchange_index: int | None = None, extra_headers: dict[str, str] | None = None, ) -> None: ... async def transfer_subaccount( @@ -337,9 +374,14 @@ async def transfer_subaccount( from_subaccount: int | None = None, to_subaccount: int | None = None, amount_cents: int | None = None, + exchange_index: int | None = None, extra_headers: dict[str, str] | None = None, ) -> None: - """Move funds between margin subaccounts (returns ``None``).""" + """Move funds between margin subaccounts (returns ``None``). + + ``exchange_index`` is optional; perps subaccount transfers ignore this + field. Omit so the key is not sent; an explicit ``0`` is emitted. + """ self._require_auth() body = _build_subaccount_transfer_body( request, @@ -347,6 +389,7 @@ async def transfer_subaccount( from_subaccount=from_subaccount, to_subaccount=to_subaccount, amount_cents=amount_cents, + exchange_index=exchange_index, ) await self._post( "/portfolio/margin/subaccounts/transfer", diff --git a/pyproject.toml b/pyproject.toml index f56abe8..320e3b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "22.0.0" +version = "22.1.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/asyncapi.yaml b/specs/asyncapi.yaml index fb7d322..db02790 100644 --- a/specs/asyncapi.yaml +++ b/specs/asyncapi.yaml @@ -2835,6 +2835,7 @@ components: type: integer description: Number of ticks counted in the window minimum: 0 + x-rust-type: usize window_start_ts_ms: type: integer description: Window start boundary (unix ms) @@ -3091,9 +3092,13 @@ components: price_dollars: type: string description: Price level in dollars + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars delta_fp: type: string description: Fixed-point contract delta (2 decimals) + x-rust-type: i64 + x-rust-serialize-with: ws::dto::serde_money::serialize_centicount_fixed_point_count side: $ref: '#/components/schemas/marketSide' client_order_id: @@ -3138,18 +3143,28 @@ components: price_dollars: type: string description: Last traded price in dollars + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars yes_bid_dollars: type: string description: Best bid price for yes side in dollars + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars yes_ask_dollars: type: string description: Best ask price for yes side in dollars + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars volume_fp: type: string description: Fixed-point total contracts traded (2 decimals) + x-rust-type: i64 + x-rust-serialize-with: ws::dto::serde_money::serialize_centicount_fixed_point_count open_interest_fp: type: string description: Fixed-point open interest (2 decimals) + x-rust-type: i64 + x-rust-serialize-with: ws::dto::serde_money::serialize_centicount_fixed_point_count dollar_volume: type: integer description: Signed whole-dollar cumulative traded notional @@ -3159,12 +3174,18 @@ components: yes_bid_size_fp: type: string description: Fixed-point contracts at best bid (2 decimals) + x-rust-type: i64 + x-rust-serialize-with: ws::dto::serde_money::serialize_centicount_fixed_point_count yes_ask_size_fp: type: string description: Fixed-point contracts at best ask (2 decimals) + x-rust-type: i64 + x-rust-serialize-with: ws::dto::serde_money::serialize_centicount_fixed_point_count last_trade_size_fp: type: string description: Fixed-point contracts in last trade (2 decimals) + x-rust-type: common::types::Amount + x-rust-serialize-with: ws::dto::serde_money::serialize_amount_fixed_point_count ts: type: integer deprecated: true @@ -3206,12 +3227,18 @@ components: yes_price_dollars: type: string description: Yes side price in dollars + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars no_price_dollars: type: string description: No side price in dollars + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars count_fp: type: string description: Fixed-point contracts traded (2 decimals) + x-rust-type: common::types::Amount + x-rust-serialize-with: ws::dto::serde_money::serialize_amount_fixed_point_count taker_side: $ref: '#/components/schemas/marketSide' deprecated: true @@ -3276,6 +3303,7 @@ components: exchange_index: type: integer description: Identifier for the exchange shard where the fill occurred + x-rust-type: i32 is_taker: type: boolean description: If you were a taker on this fill @@ -3296,12 +3324,18 @@ components: yes_price_dollars: type: string description: Price for the yes side of the fill in dollars + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars count_fp: type: string description: Fixed-point contracts filled (2 decimals) + x-rust-type: common::types::Amount + x-rust-serialize-with: ws::dto::serde_money::serialize_amount_fixed_point_count fee_cost: type: string description: Exchange fee paid for this fill in fixed-point dollars + x-rust-type: common::types::Value + x-rust-serialize-with: ws::dto::serde_money::serialize_value_microcent_dollars action: $ref: '#/components/schemas/orderAction' deprecated: true @@ -3310,6 +3344,8 @@ components: post_position_fp: type: string description: Fixed-point position after the fill (2 decimals) + x-rust-type: common::types::Position + x-rust-serialize-with: ws::dto::serde_money::serialize_position_fixed_point_count purchased_side: $ref: '#/components/schemas/marketSide' deprecated: true @@ -3356,6 +3392,7 @@ components: exchange_index: type: integer description: Optional - This key will ONLY exist when the market is created. Identifier for the exchange shard the market lives on + x-rust-type: i32 open_ts: type: integer description: Optional - This key will ONLY exist when the market is created. Unix timestamp for when the market opened (in seconds) @@ -3378,6 +3415,8 @@ components: settlement_value: type: string description: Optional - This key will ONLY exist when the market is determined. Settlement value of the market in fixed-point dollars (e.g. "0.5000") + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_option_price_dollars is_deactivated: type: boolean description: Optional - This key will ONLY exist when the market is paused/unpaused. Boolean flag to indicate if trading is paused on an open market. This should only be interpreted for an open market @@ -3412,12 +3451,18 @@ components: start: type: string description: Starting price for this band, in dollars + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars end: type: string description: Ending price for this band, in dollars + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars step: type: string description: Tick size (minimum price increment) within this band, in dollars + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars sending_ts_ms: $ref: '#/components/schemas/sendingTimestampMs' @@ -3442,6 +3487,7 @@ components: exchange_index: type: integer description: Optional - This key will ONLY exist when the market is created. Identifier for the exchange shard the market lives on + x-rust-type: i32 open_ts: type: integer description: Optional - This key will ONLY exist when the market is created. Unix timestamp for when the market opened (in seconds) @@ -3464,6 +3510,8 @@ components: settlement_value: type: string description: Optional - This key will ONLY exist when the market is determined. Settlement value of the market in fixed-point dollars (e.g. "0.5000") + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_option_price_dollars is_deactivated: type: boolean description: Optional - This key will ONLY exist when the market is paused/unpaused. Boolean flag to indicate if trading is paused on an open market. This should only be interpreted for an open market @@ -3514,8 +3562,10 @@ components: type: string floor_strike: type: number + x-rust-type: crate::wire::JsonNumber cap_strike: type: number + x-rust-type: crate::wire::JsonNumber custom_strike: type: object @@ -3551,9 +3601,11 @@ components: floor_strike: type: number description: Optional - This key will ONLY exist for metadata_updated events. The floor (lower bound) strike value for the market + x-rust-type: crate::wire::JsonNumber cap_strike: type: number description: Optional - This key will ONLY exist for metadata_updated events. The cap (upper bound) strike value for the market + x-rust-type: crate::wire::JsonNumber custom_strike: type: object description: Optional - This key will ONLY exist for metadata_updated events with a custom or structured strike type @@ -3584,6 +3636,7 @@ components: exchange_index: type: integer description: Identifier for the exchange shard the event's markets live on + x-rust-type: i32 title: type: string description: Title of event @@ -3632,6 +3685,7 @@ components: fee_multiplier_override: type: [number, "null"] description: Event fee multiplier override. `null` when the override has been cleared. + x-rust-type: crate::wire::JsonNumber sending_ts_ms: $ref: '#/components/schemas/sendingTimestampMs' @@ -3657,21 +3711,33 @@ components: position_fp: type: string description: Fixed-point net position (2 decimals) + x-rust-type: common::types::Position + x-rust-serialize-with: ws::dto::serde_money::serialize_position_fixed_point_count position_cost_dollars: type: string description: Current cost basis of the position as a fixed-point dollar string + x-rust-type: common::types::Value + x-rust-serialize-with: ws::dto::serde_money::serialize_value_microcent_dollars realized_pnl_dollars: type: string description: Realized profit/loss as a fixed-point dollar string + x-rust-type: common::types::Value + x-rust-serialize-with: ws::dto::serde_money::serialize_value_microcent_dollars fees_paid_dollars: type: string description: Total fees paid as a fixed-point dollar string + x-rust-type: common::types::Value + x-rust-serialize-with: ws::dto::serde_money::serialize_value_microcent_dollars position_fee_cost_dollars: type: string description: Total position fee cost as a fixed-point dollar string + x-rust-type: common::types::Value + x-rust-serialize-with: ws::dto::serde_money::serialize_value_microcent_dollars volume_fp: type: string description: Fixed-point total volume traded (2 decimals) + x-rust-type: i64 + x-rust-serialize-with: ws::dto::serde_money::serialize_centicount_fixed_point_count subaccount: type: integer description: Optional subaccount number for the position @@ -3703,6 +3769,8 @@ components: contracts_limit_fp: type: string description: Updated contracts limit in fixed-point (2 decimals). Present for "created" and "limit_updated" events only. + x-rust-type: common::types::Amount + x-rust-serialize-with: ws::dto::serde_money::serialize_option_amount_fixed_point_count ts_ms: type: integer format: int64 @@ -3737,6 +3805,7 @@ components: exchange_index: type: integer description: Identifier for the exchange shard where the order resides + x-rust-type: i32 status: type: string description: Current order status @@ -3768,27 +3837,43 @@ components: yes_price_dollars: type: string description: Yes price in fixed-point dollars (4 decimals) + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars fill_count_fp: type: string description: Number of contracts filled in fixed-point (2 decimals) + x-rust-type: common::types::Amount + x-rust-serialize-with: ws::dto::serde_money::serialize_amount_fixed_point_count remaining_count_fp: type: string description: Number of contracts remaining in fixed-point (2 decimals) + x-rust-type: common::types::Amount + x-rust-serialize-with: ws::dto::serde_money::serialize_amount_fixed_point_count initial_count_fp: type: string description: Initial number of contracts in fixed-point (2 decimals) + x-rust-type: common::types::Amount + x-rust-serialize-with: ws::dto::serde_money::serialize_amount_fixed_point_count taker_fill_cost_dollars: type: string description: Taker fill cost in fixed-point dollars (6 decimals) + x-rust-type: common::types::Value + x-rust-serialize-with: ws::dto::serde_money::serialize_value_microcent_dollars maker_fill_cost_dollars: type: string description: Maker fill cost in fixed-point dollars (6 decimals) + x-rust-type: common::types::Value + x-rust-serialize-with: ws::dto::serde_money::serialize_value_microcent_dollars taker_fees_dollars: type: string description: Taker fees in fixed-point dollars (6 decimals). + x-rust-type: common::types::Value + x-rust-serialize-with: ws::dto::serde_money::serialize_value_microcent_dollars maker_fees_dollars: type: string description: Maker fees in fixed-point dollars (6 decimals). + x-rust-type: common::types::Value + x-rust-serialize-with: ws::dto::serde_money::serialize_value_microcent_dollars client_order_id: type: string description: Client-provided order identifier @@ -3865,9 +3950,13 @@ components: contracts_fp: type: string description: Fixed-point contracts requested (2 decimals) (optional) + x-rust-type: i64 + x-rust-serialize-with: ws::dto::serde_money::serialize_optional_centicount_fixed_point_count target_cost_dollars: type: string description: Target cost in dollars (optional) + x-rust-type: common::types::Value + x-rust-serialize-with: ws::dto::serde_money::serialize_option_value_dollars created_ts: type: string description: Timestamp when the RFQ was created @@ -3896,6 +3985,8 @@ components: yes_settlement_value_dollars: type: string description: Yes settlement value in dollars for the selected leg. Omitted when unavailable. + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_option_price_dollars sending_ts_ms: $ref: '#/components/schemas/sendingTimestampMs' @@ -3929,9 +4020,13 @@ components: contracts_fp: type: string description: Fixed-point contracts requested (2 decimals) (optional) + x-rust-type: i64 + x-rust-serialize-with: ws::dto::serde_money::serialize_optional_centicount_fixed_point_count target_cost_dollars: type: string description: Target cost in dollars (optional) + x-rust-type: common::types::Value + x-rust-serialize-with: ws::dto::serde_money::serialize_option_value_dollars deleted_ts: type: string description: Timestamp when the RFQ was deleted @@ -3975,18 +4070,28 @@ components: yes_bid_dollars: type: string description: Yes side bid price in dollars + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars no_bid_dollars: type: string description: No side bid price in dollars + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars yes_contracts_offered_fp: type: string description: Fixed-point yes contracts offered (2 decimals) (optional) + x-rust-type: i64 + x-rust-serialize-with: ws::dto::serde_money::serialize_optional_centicount_fixed_point_count no_contracts_offered_fp: type: string description: Fixed-point no contracts offered (2 decimals) (optional) + x-rust-type: i64 + x-rust-serialize-with: ws::dto::serde_money::serialize_optional_centicount_fixed_point_count rfq_target_cost_dollars: type: string description: Target cost from the RFQ in dollars (optional) + x-rust-type: common::types::Value + x-rust-serialize-with: ws::dto::serde_money::serialize_option_value_dollars created_ts: type: string description: Timestamp when the quote was created @@ -4035,9 +4140,13 @@ components: yes_bid_dollars: type: string description: Yes side bid price in dollars + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars no_bid_dollars: type: string description: No side bid price in dollars + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars accepted_side: type: string description: Which side was accepted (yes/no) (optional) @@ -4045,15 +4154,23 @@ components: contracts_accepted_fp: type: string description: Fixed-point contracts accepted (2 decimals) (optional) + x-rust-type: i64 + x-rust-serialize-with: ws::dto::serde_money::serialize_optional_centicount_fixed_point_count yes_contracts_offered_fp: type: string description: Fixed-point yes contracts offered (2 decimals) (optional) + x-rust-type: i64 + x-rust-serialize-with: ws::dto::serde_money::serialize_optional_centicount_fixed_point_count no_contracts_offered_fp: type: string description: Fixed-point no contracts offered (2 decimals) (optional) + x-rust-type: i64 + x-rust-serialize-with: ws::dto::serde_money::serialize_optional_centicount_fixed_point_count rfq_target_cost_dollars: type: string description: Target cost from the RFQ in dollars (optional) + x-rust-type: common::types::Value + x-rust-serialize-with: ws::dto::serde_money::serialize_option_value_dollars subaccount: type: integer description: | diff --git a/specs/openapi.yaml b/specs/openapi.yaml index d33ca5e..62e5cb7 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -1522,7 +1522,10 @@ paths: get: operationId: GetBalance summary: Get Balance - description: "Returns the balance and portfolio value for a member. Both values include all exchange indexes unless `exchange_index` is provided. Pass `subaccount` to use a subaccount instead of the primary account. This endpoint also accepts API keys with the 'read::portfolio_balance' scope." + description: | + Returns the balance and portfolio value for a member. Both values include all exchange indexes unless `exchange_index` is provided. Pass `subaccount` to use a subaccount instead of the primary account. This endpoint also accepts API keys with the 'read::portfolio_balance' scope. + + **Rate limit:** this endpoint has its own bucket, separate from the Read budget. Basic and Advanced tiers may call it **1 time per second**; all higher tiers may call it **5 times per second**. The bucket holds one second of budget, so there is no burst. Calls to this endpoint do not draw down the Read budget, and other reads do not draw down this bucket. Balance only changes on fills, settlements and transfers, so react to those (for example the user fills websocket channel) instead of polling this endpoint. tags: - portfolio security: @@ -4753,16 +4756,20 @@ components: SubaccountQuery: name: subaccount in: query + required: false description: Subaccount number (0 for primary, 1-63 for subaccounts). If omitted, defaults to all subaccounts. schema: type: integer + minimum: 0 SubaccountQueryDefaultPrimary: name: subaccount in: query - description: Subaccount number (0 for primary, 1-63 for subaccounts). Defaults to 0. + required: false + 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 ExchangeIndexQuery: name: exchange_index diff --git a/specs/perps_asyncapi.yaml b/specs/perps_asyncapi.yaml index 268484f..29f3c47 100644 --- a/specs/perps_asyncapi.yaml +++ b/specs/perps_asyncapi.yaml @@ -597,14 +597,14 @@ components: summary: Order group limit updated payload: type: order_group_updates - sending_ts_ms: 1700000000246 + sending_ts_ms: 1733047200123 sid: 21 seq: 7 msg: event_type: "limit_updated" order_group_id: "og_123" contracts_limit_fp: "150.00" - ts_ms: 1700000000123 + ts_ms: 1733047200000 schemas: sendingTimestampMs: @@ -614,23 +614,31 @@ components: commandId: type: integer + description: | + Unique ID of the command request. Generated by the client and should be unique within a WS session. + The simplest way to use it would be to start from 1 and then increment the value for every new command sent to the server. + If the id is set to 0, the server treats it the same way as if there was no id. minimum: 0 - description: Unique ID of a command within a WebSocket session subscriptionId: type: integer + description: Server-generated subscription identifier (sid) used to identify the channel minimum: 1 - description: Server-generated subscription identifier sequenceNumber: type: integer + description: Sequential number that should be checked if you want to guarantee you received all the messages. Used for snapshot/delta consistency minimum: 1 - description: Sequence number used for snapshot/delta consistency marketTicker: type: string description: Unique market identifier + marketId: + type: string + description: Unique market UUID + format: uuid + bookSide: type: string enum: ["bid", "ask"] @@ -652,6 +660,8 @@ components: price: type: string description: USD price as a fixed-point decimal string (4 decimals) + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars ts_ms: type: integer format: int64 @@ -665,6 +675,7 @@ components: type: number format: double description: Funding rate as a decimal value. + x-rust-type: f64 next_funding_time_ms: type: integer format: int64 @@ -731,6 +742,7 @@ components: properties: sids: type: array + description: List of subscription IDs to cancel items: $ref: '#/components/schemas/subscriptionId' minItems: 1 @@ -835,14 +847,14 @@ components: properties: market_tickers: type: array + description: Full list of market tickers after update items: $ref: '#/components/schemas/marketTicker' market_ids: type: array description: Full list of market IDs after update items: - type: string - format: uuid + $ref: '#/components/schemas/marketId' sending_ts_ms: $ref: '#/components/schemas/sendingTimestampMs' @@ -857,14 +869,17 @@ components: $ref: '#/components/schemas/commandId' msg: type: array + description: List of active subscriptions items: type: object required: ["channel", "sid"] properties: channel: type: string + description: Name of the subscribed channel sid: $ref: '#/components/schemas/subscriptionId' + sending_ts_ms: $ref: '#/components/schemas/sendingTimestampMs' @@ -960,8 +975,12 @@ components: $ref: '#/components/schemas/marketTicker' price: type: string + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars delta: type: string + x-rust-type: i64 + x-rust-serialize-with: ws::dto::serde_money::serialize_centicount_fixed_point_count side: $ref: '#/components/schemas/bookSide' last_update_reason: @@ -995,33 +1014,51 @@ components: price: type: string description: Last traded price in USD as a fixed-point decimal string (4 decimals). + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars bid: type: string description: USD price as a fixed-point decimal string (4 decimals). + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars ask: type: string description: USD price as a fixed-point decimal string (4 decimals). + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars bid_size_fp: type: string + x-rust-type: i64 + x-rust-serialize-with: ws::dto::serde_money::serialize_centicount_fixed_point_count ask_size_fp: type: string + x-rust-type: i64 + x-rust-serialize-with: ws::dto::serde_money::serialize_centicount_fixed_point_count last_trade_size_fp: type: string + x-rust-type: common::types::Amount + x-rust-serialize-with: ws::dto::serde_money::serialize_amount_fixed_point_count volume: type: string description: One sided total trade volume in contracts. + x-rust-type: i64 + x-rust-serialize-with: ws::dto::serde_money::serialize_centicount_fixed_point_count volume_notional_value_dollars: type: string description: Total notional value of one sided trade volume in dollars. volume_24h: type: string description: One sided trade volume in the last 24 hours in contracts. + x-rust-type: i64 + x-rust-serialize-with: ws::dto::serde_money::serialize_centicount_fixed_point_count volume_24h_notional_value_dollars: type: string description: Total notional value of one sided trade volume in the last 24 hours in dollars. open_interest: type: string description: One sided open interest in contracts. + x-rust-type: i64 + x-rust-serialize-with: ws::dto::serde_money::serialize_centicount_fixed_point_count open_interest_notional_value_dollars: type: string description: Total notional value of one sided open interest in dollars. @@ -1064,8 +1101,12 @@ components: $ref: '#/components/schemas/marketTicker' price: type: string + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars count: type: string + x-rust-type: common::types::Amount + x-rust-serialize-with: ws::dto::serde_money::serialize_amount_fixed_point_count taker_side: $ref: '#/components/schemas/bookSide' ts_ms: @@ -1108,12 +1149,20 @@ components: description: Unix timestamp in milliseconds. price: type: string + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars count: type: string + x-rust-type: common::types::Amount + x-rust-serialize-with: ws::dto::serde_money::serialize_amount_fixed_point_count fee_cost: type: string + x-rust-type: common::types::Value + x-rust-serialize-with: ws::dto::serde_money::serialize_value_dollars post_position: type: string + x-rust-type: common::types::Position + x-rust-serialize-with: ws::dto::serde_money::serialize_position_fixed_point_count subaccount: type: integer order_source: @@ -1152,10 +1201,16 @@ components: $ref: '#/components/schemas/bookSide' price: type: string + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars fill_count: type: string + x-rust-type: common::types::Amount + x-rust-serialize-with: ws::dto::serde_money::serialize_amount_fixed_point_count remaining_count: type: string + x-rust-type: common::types::Amount + x-rust-serialize-with: ws::dto::serde_money::serialize_amount_fixed_point_count self_trade_prevention_type: $ref: '#/components/schemas/selfTradePreventionType' order_group_id: @@ -1212,6 +1267,8 @@ components: contracts_limit_fp: type: string description: Updated contracts limit in fixed-point (2 decimals). Present for "created" and "limit_updated" events only. + x-rust-type: common::types::Amount + x-rust-serialize-with: ws::dto::serde_money::serialize_option_amount_fixed_point_count ts_ms: type: integer format: int64 diff --git a/specs/perps_openapi.yaml b/specs/perps_openapi.yaml index 7a1d7a6..446f858 100644 --- a/specs/perps_openapi.yaml +++ b/specs/perps_openapi.yaml @@ -1193,7 +1193,7 @@ paths: description: | Returns the estimated funding rate for the current, in-progress funding period. The value is a time-weighted average of the premium index computed over `[last_funding_time, now)`, so it continues to move as new data accumulates through the window and is only finalized at `next_funding_time`. - The returned `premium_index` is the premium for the final second evaluated by this estimate, captured from the same calculation inputs. Its observation timestamp can lag the computation time when input feeds are delayed. It is omitted when that second has no available premium. The estimate remains provisional until funding is finalized. + Funding measures the premium over clock-aligned bars of one second or longer, depending on the market. The returned `premium_index` is the premium of the final bar evaluated by this estimate, captured from the same calculation inputs. Its observation timestamp can lag the computation time when input feeds are delayed. It is omitted when that bar has no available premium. The estimate remains provisional until funding is finalized. tags: - funding parameters: @@ -1271,13 +1271,13 @@ paths: post: operationId: IntraExchangeInstanceTransfer summary: Intra Account Transfer - description: 'Endpoint for transferring funds within the same account.' + description: "Endpoint for transferring funds within the same account." tags: - portfolio security: - kalshiAccessKey: [] - - kalshiAccessSignature: [] - - kalshiAccessTimestamp: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] requestBody: required: true content: @@ -1975,6 +1975,13 @@ components: type: integer format: int64 description: Amount to transfer in cents. + exchange_index: + allOf: + - $ref: '#/components/schemas/ExchangeIndex' + description: "Identifier for an exchange shard. Perps subaccount transfers ignore this field." + x-go-type-skip-optional-pointer: true + x-oapi-codegen-extra-tags: + validate: "gte=0" ApplySubaccountTransferResponse: type: object description: Empty response indicating successful transfer. @@ -2021,6 +2028,7 @@ components: allOf: - $ref: '#/components/schemas/ExchangeIndex' x-go-type-skip-optional-pointer: true + x-omitempty: false CreateSubaccountResponse: type: object required: @@ -2483,6 +2491,7 @@ components: allOf: - $ref: '#/components/schemas/ExchangeIndex' x-go-type-skip-optional-pointer: true + x-omitempty: false GetOrderGroupsResponse: type: object properties: @@ -2532,6 +2541,20 @@ components: description: Destination exchange shard index (default 0) x-oapi-codegen-extra-tags: validate: "gte=0,lte=100" + source_subaccount: + type: integer + default: 0 + x-go-type-skip-optional-pointer: true + description: Source subaccount number (default 0 for the primary account). Only supported for event contract to event contract transfers. + x-oapi-codegen-extra-tags: + validate: "gte=0" + destination_subaccount: + type: integer + default: 0 + x-go-type-skip-optional-pointer: true + description: Destination subaccount number (default 0 for the primary account). Only supported for event contract to event contract transfers. + x-oapi-codegen-extra-tags: + validate: "gte=0" IntraExchangeInstanceTransferResponse: type: object required: @@ -2562,6 +2585,7 @@ components: allOf: - $ref: '#/components/schemas/ExchangeIndex' x-go-type-skip-optional-pointer: true + x-omitempty: false # Market Orderbook schemas PriceLevelDollarsCountFp: type: array @@ -2603,6 +2627,43 @@ components: trading_active: type: boolean description: True if trading is currently permitted on the exchange. False outside exchange hours or during pauses. + intra_exchange_transfers_active: + type: boolean + description: "True if intra-exchange transfers are currently permitted. False when transfers are temporarily blocked. The Perps exchange status never includes this field." + exchange_estimated_resume_time: + type: string + format: date-time + description: Estimated downtime for the current exchange maintenance window. However, this is not guaranteed and can be extended. + nullable: true + exchange_index_statuses: + type: array + description: "Status of each exchange index. The top-level fields above reflect the default exchange index (0). The Perps exchange status never includes this field." + items: + $ref: '#/components/schemas/ExchangeIndexStatus' + + ExchangeIndexStatus: + type: object + required: + - exchange_index + - description + - exchange_active + - trading_active + - intra_exchange_transfers_active + properties: + exchange_index: + $ref: '#/components/schemas/ExchangeIndex' + description: + type: string + description: Description of this exchange shard. + exchange_active: + type: boolean + description: False if this exchange index is no longer taking any state changes at all. True unless under maintenance. + trading_active: + type: boolean + description: True if trading is currently permitted on this exchange index. False outside exchange hours or during pauses. + intra_exchange_transfers_active: + type: boolean + description: True if intra-exchange transfers are currently permitted on this exchange index. False when transfers are temporarily blocked. GetMarginRiskParametersResponse: type: object @@ -3137,8 +3198,8 @@ components: x-enum-varnames: [MarginMarketFundingInvalidPremiumHandlingDampen, MarginMarketFundingInvalidPremiumHandlingExclude] x-go-type-skip-optional-pointer: true description: >- - How funding averages a second with no premium (no orderbook sample, no index price, or a trading halt), - configured at market creation. dampen counts the second as a premium of 0; exclude leaves it out of the average. + How funding averages a bar with no premium (no orderbook sample or no index price anywhere in the bar, or a trading + halt during it), configured at market creation. dampen counts the bar as a premium of 0; exclude leaves it out of the average. MarginMarketSchedule: type: object @@ -3722,11 +3783,11 @@ components: description: Timestamp of the next scheduled funding event premium_index: type: string - description: Premium index for the final second evaluated by this funding estimate, as a signed decimal fraction before time weighting. Captured with the estimate from the same calculation inputs. Omitted when that second has no available premium. + description: Premium index of the final bar evaluated by this funding estimate, as a signed decimal fraction before time weighting. Captured with the estimate from the same calculation inputs. Omitted when that bar has no available premium. premium_index_ts: type: string format: date-time - description: Timestamp of the final second evaluated by the funding estimate. Can precede computed_time when input feeds are delayed. + description: Timestamp of the last second of the final bar evaluated by the funding estimate. Can precede computed_time when input feeds are delayed. MarginPremiumIndexPoint: type: object diff --git a/tests/perps/test_transfers.py b/tests/perps/test_transfers.py index 58fcd73..673ddac 100644 --- a/tests/perps/test_transfers.py +++ b/tests/perps/test_transfers.py @@ -82,6 +82,66 @@ def test_instance_request_rejects_negative_shard(self) -> None: source_exchange_shard=-1, ) + def test_instance_request_serializes_subaccounts(self) -> None: + req = IntraExchangeInstanceTransferRequest( + source="event_contract", + destination="event_contract", + amount=100, + source_subaccount=1, + destination_subaccount=2, + ) + body = req.model_dump(exclude_none=True, by_alias=True, mode="json") + assert body["source_subaccount"] == 1 + assert body["destination_subaccount"] == 2 + + def test_instance_request_serializes_explicit_zero_subaccounts(self) -> None: + req = IntraExchangeInstanceTransferRequest( + source="event_contract", + destination="event_contract", + amount=100, + source_subaccount=0, + destination_subaccount=0, + ) + body = req.model_dump(exclude_none=True, by_alias=True, mode="json") + assert body["source_subaccount"] == 0 + assert body["destination_subaccount"] == 0 + + def test_instance_request_rejects_negative_source_subaccount(self) -> None: + with pytest.raises(ValidationError): + IntraExchangeInstanceTransferRequest( + source="event_contract", + destination="event_contract", + amount=1, + source_subaccount=-1, + ) + + def test_instance_request_rejects_negative_destination_subaccount(self) -> None: + with pytest.raises(ValidationError): + IntraExchangeInstanceTransferRequest( + source="event_contract", + destination="event_contract", + amount=1, + destination_subaccount=-1, + ) + + def test_instance_request_rejects_bool_source_subaccount(self) -> None: + with pytest.raises(ValidationError): + IntraExchangeInstanceTransferRequest( + source="event_contract", + destination="event_contract", + amount=1, + source_subaccount=True, # type: ignore[arg-type] + ) + + def test_instance_request_rejects_bool_destination_subaccount(self) -> None: + with pytest.raises(ValidationError): + IntraExchangeInstanceTransferRequest( + source="event_contract", + destination="event_contract", + amount=1, + destination_subaccount=False, # type: ignore[arg-type] + ) + def test_instance_request_rejects_bad_instance(self) -> None: with pytest.raises(ValidationError): IntraExchangeInstanceTransferRequest( @@ -101,6 +161,18 @@ def test_subaccount_request_serializes(self) -> None: "to_subaccount": 3, "amount_cents": 500, } + assert "exchange_index" not in body + + def test_subaccount_request_serializes_exchange_index(self) -> None: + req = ApplySubaccountTransferRequest( + client_transfer_id=_XFER_ID, + from_subaccount=0, + to_subaccount=3, + amount_cents=500, + exchange_index=0, + ) + body = req.model_dump(exclude_none=True, by_alias=True, mode="json") + assert body["exchange_index"] == 0 def test_subaccount_request_forbids_extra(self) -> None: with pytest.raises(ValidationError): @@ -124,6 +196,26 @@ def test_subaccount_request_rejects_zero_amount(self) -> None: client_transfer_id=_XFER_ID, from_subaccount=0, to_subaccount=1, amount_cents=0 ) + def test_subaccount_request_rejects_negative_exchange_index(self) -> None: + with pytest.raises(ValidationError): + ApplySubaccountTransferRequest( + client_transfer_id=_XFER_ID, + from_subaccount=0, + to_subaccount=1, + amount_cents=1, + exchange_index=-1, + ) + + def test_subaccount_request_rejects_bool_exchange_index(self) -> None: + with pytest.raises(ValidationError): + ApplySubaccountTransferRequest( + client_transfer_id=_XFER_ID, + from_subaccount=0, + to_subaccount=1, + amount_cents=1, + exchange_index=True, # type: ignore[arg-type] + ) + def test_subaccount_request_accepts_above_32(self) -> None: req = ApplySubaccountTransferRequest( client_transfer_id=_XFER_ID, from_subaccount=0, to_subaccount=41, amount_cents=1 @@ -164,6 +256,24 @@ def test_happy(self, perps_client: PerpsClient) -> None: "destination_exchange_shard": 0, } assert "client_transfer_id" not in body + assert "source_subaccount" not in body + assert "destination_subaccount" not in body + + @respx.mock + def test_subaccounts_sent_on_wire_including_zero(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="event_contract", + amount=100, + source_subaccount=0, + destination_subaccount=3, + ) + body = json.loads(route.calls[0].request.content) + assert body["source_subaccount"] == 0 + assert body["destination_subaccount"] == 3 @respx.mock def test_client_transfer_id_serialized(self, perps_client: PerpsClient) -> None: @@ -221,6 +331,25 @@ def test_request_object_overload_identical_body(self, perps_client: PerpsClient) "source_exchange_shard": 0, "destination_exchange_shard": 0, } + assert "source_subaccount" not in body + assert "destination_subaccount" not in body + + @respx.mock + def test_request_object_with_subaccounts(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"}) + ) + req = IntraExchangeInstanceTransferRequest( + source="event_contract", + destination="event_contract", + amount=100, + source_subaccount=0, + destination_subaccount=4, + ) + perps_client.transfers.transfer_instance(request=req) + body = json.loads(route.calls[0].request.content) + assert body["source_subaccount"] == 0 + assert body["destination_subaccount"] == 4 @respx.mock def test_403_not_available_maps(self, perps_client: PerpsClient) -> None: @@ -275,6 +404,24 @@ def test_edge_nonpositive_amount_raises(self, perps_client: PerpsClient) -> None source="event_contract", destination="margined", amount=0 ) + def test_edge_negative_source_subaccount_raises(self, perps_client: PerpsClient) -> None: + with pytest.raises(ValidationError): + perps_client.transfers.transfer_instance( + source="event_contract", + destination="event_contract", + amount=1, + source_subaccount=-1, + ) + + def test_edge_bool_destination_subaccount_raises(self, perps_client: PerpsClient) -> None: + with pytest.raises(ValidationError): + perps_client.transfers.transfer_instance( + source="event_contract", + destination="event_contract", + amount=1, + destination_subaccount=True, # type: ignore[arg-type] + ) + @respx.mock def test_unauthenticated_raises_before_http(self) -> None: route = respx.post(f"{BASE}/portfolio/intra_exchange_instance_transfer").mock( @@ -300,6 +447,71 @@ async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: body = json.loads(route.calls[0].request.content) assert body["amount"] == 42 assert "client_transfer_id" not in body + assert "source_subaccount" not in body + assert "destination_subaccount" not in body + await async_perps_client.close() + + @respx.mock + async def test_async_subaccounts_sent_on_wire_including_zero( + 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="event_contract", + destination="event_contract", + amount=42, + source_subaccount=2, + destination_subaccount=0, + ) + body = json.loads(route.calls[0].request.content) + assert body["source_subaccount"] == 2 + assert body["destination_subaccount"] == 0 + await async_perps_client.close() + + @respx.mock + async def test_async_request_object_with_subaccounts( + 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"}) + ) + req = IntraExchangeInstanceTransferRequest( + source="event_contract", + destination="event_contract", + amount=42, + source_subaccount=0, + destination_subaccount=1, + ) + await async_perps_client.transfers.transfer_instance(request=req) + body = json.loads(route.calls[0].request.content) + assert body["source_subaccount"] == 0 + assert body["destination_subaccount"] == 1 + await async_perps_client.close() + + async def test_async_negative_destination_subaccount_raises( + self, async_perps_client: AsyncPerpsClient + ) -> None: + with pytest.raises(ValidationError): + await async_perps_client.transfers.transfer_instance( + source="event_contract", + destination="event_contract", + amount=1, + destination_subaccount=-1, + ) + await async_perps_client.close() + + async def test_async_bool_source_subaccount_raises( + self, async_perps_client: AsyncPerpsClient + ) -> None: + with pytest.raises(ValidationError): + await async_perps_client.transfers.transfer_instance( + source="event_contract", + destination="event_contract", + amount=1, + source_subaccount=False, # type: ignore[arg-type] + ) await async_perps_client.close() @respx.mock @@ -397,6 +609,38 @@ def test_happy_returns_none(self, perps_client: PerpsClient) -> None: "to_subaccount": 3, "amount_cents": 500, } + assert "exchange_index" not in body + + @respx.mock + def test_exchange_index_sent_on_wire_including_zero(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/portfolio/margin/subaccounts/transfer").mock( + return_value=httpx.Response(200, json={}) + ) + perps_client.transfers.transfer_subaccount( + client_transfer_id=_XFER_ID, + from_subaccount=0, + to_subaccount=3, + amount_cents=500, + exchange_index=0, + ) + body = json.loads(route.calls[0].request.content) + assert body["exchange_index"] == 0 + + @respx.mock + def test_request_object_with_exchange_index(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/portfolio/margin/subaccounts/transfer").mock( + return_value=httpx.Response(200, json={}) + ) + req = ApplySubaccountTransferRequest( + client_transfer_id=_XFER_ID, + from_subaccount=0, + to_subaccount=3, + amount_cents=500, + exchange_index=2, + ) + perps_client.transfers.transfer_subaccount(request=req) + body = json.loads(route.calls[0].request.content) + assert body["exchange_index"] == 2 @respx.mock def test_accepts_uuid_and_str(self, perps_client: PerpsClient) -> None: @@ -447,6 +691,26 @@ def test_edge_negative_subaccount_raises(self, perps_client: PerpsClient) -> Non client_transfer_id=_XFER_ID, from_subaccount=-1, to_subaccount=1, amount_cents=1 ) + def test_edge_negative_exchange_index_raises(self, perps_client: PerpsClient) -> None: + with pytest.raises(ValidationError): + perps_client.transfers.transfer_subaccount( + client_transfer_id=_XFER_ID, + from_subaccount=0, + to_subaccount=1, + amount_cents=1, + exchange_index=-1, + ) + + def test_edge_bool_exchange_index_raises(self, perps_client: PerpsClient) -> None: + with pytest.raises(ValidationError): + perps_client.transfers.transfer_subaccount( + client_transfer_id=_XFER_ID, + from_subaccount=0, + to_subaccount=1, + amount_cents=1, + exchange_index=True, # type: ignore[arg-type] + ) + def test_edge_forbids_phantom_key(self) -> None: with pytest.raises(ValidationError): ApplySubaccountTransferRequest( @@ -483,6 +747,25 @@ async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: assert result is None body = json.loads(route.calls[0].request.content) assert body["amount_cents"] == 42 + assert "exchange_index" not in body + await async_perps_client.close() + + @respx.mock + async def test_async_exchange_index_sent_on_wire_including_zero( + self, async_perps_client: AsyncPerpsClient + ) -> None: + route = respx.post(f"{BASE}/portfolio/margin/subaccounts/transfer").mock( + return_value=httpx.Response(200, json={}) + ) + await async_perps_client.transfers.transfer_subaccount( + client_transfer_id=_XFER_ID, + from_subaccount=0, + to_subaccount=1, + amount_cents=42, + exchange_index=0, + ) + body = json.loads(route.calls[0].request.content) + assert body["exchange_index"] == 0 await async_perps_client.close() @respx.mock @@ -496,4 +779,50 @@ async def test_async_request_object(self, async_perps_client: AsyncPerpsClient) await async_perps_client.transfers.transfer_subaccount(request=req) body = json.loads(route.calls[0].request.content) assert body["from_subaccount"] == 2 + assert "exchange_index" not in body + await async_perps_client.close() + + @respx.mock + async def test_async_request_object_with_exchange_index( + self, async_perps_client: AsyncPerpsClient + ) -> None: + route = respx.post(f"{BASE}/portfolio/margin/subaccounts/transfer").mock( + return_value=httpx.Response(200, json={}) + ) + req = ApplySubaccountTransferRequest( + client_transfer_id=_XFER_ID, + from_subaccount=2, + to_subaccount=0, + amount_cents=7, + exchange_index=0, + ) + await async_perps_client.transfers.transfer_subaccount(request=req) + body = json.loads(route.calls[0].request.content) + assert body["exchange_index"] == 0 + await async_perps_client.close() + + async def test_async_negative_exchange_index_raises( + self, async_perps_client: AsyncPerpsClient + ) -> None: + with pytest.raises(ValidationError): + await async_perps_client.transfers.transfer_subaccount( + client_transfer_id=_XFER_ID, + from_subaccount=0, + to_subaccount=1, + amount_cents=1, + exchange_index=-1, + ) + await async_perps_client.close() + + async def test_async_bool_exchange_index_raises( + self, async_perps_client: AsyncPerpsClient + ) -> None: + with pytest.raises(ValidationError): + await async_perps_client.transfers.transfer_subaccount( + client_transfer_id=_XFER_ID, + from_subaccount=0, + to_subaccount=1, + amount_cents=1, + exchange_index=False, # type: ignore[arg-type] + ) await async_perps_client.close()