diff --git a/CHANGELOG.md b/CHANGELOG.md index e9b8c7f..213b058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,48 @@ All notable changes to kalshi-sdk will be documented in this file. +## 22.6.0 — 2026-10-31 + +Reconciles nightly spec drift (#563) against the latest Klear (SCM) spec +plus trivial optional fields on core REST/WS and perps models. No public +Python attribute renames. + +### Changed + +- **Klear settlement money fields** gain additive optional `*_fp` + attributes (`DollarDecimal | None`) beside the existing integer + `*_centicents` fields. The spec sends both: an `int64` centicents value + and the same amount as a fixed-point dollar string (6 decimal places). + There is no aliasing. `*_centicents` stays a plain `int`, a + centicents-only payload leaves `*_fp` as `None`, and serialization emits + both names. Affected attributes: `amount_fp`, `pnl_fp`, + `total_amount_fp`, `notional_value_fp`, `funding_amount_fp`, + `payment_amount_fp`, `variation_margin_fp`, `settlement_balance_fp`, + `balance_available_fp`, `locked_balance_fp`, `balance_delta_fp`, + `locked_balance_delta_fp` on `CorporateActionPaymentDetail`, + `FundingEstimate`, `FundingPaymentDetail`, `GetSettlementBalanceResponse`, + `GetSettlementEstimateByAssetClassResponse`, `MarketFundingEstimate`, + `MarketSettlementEstimate`, `MemberFundingPayment`, `ObligationEntry`, + `ObligationReceiveInfo`, `SettlementBalanceHistoryEntry`, + `SettlementDetail`, `SettlementEstimate`. (Closes #563). +- **`klear.margin.withdraw_settlement_balance`** (sync and async) now + posts to `POST /margin/settlement_balance_withdrawal` (the previous + `/margin/withdraw_settlement_balance` path was removed upstream). + Optional `mfa_code=` is forwarded on the body when set. + +### Added + +- Optional `RFQ.rfq_batch_id`, `RfqCreatedPayload.rfq_batch_id`, + `RfqDeletedPayload.rfq_batch_id`. +- Optional `OrderbookSnapshotPayload.clock` / + `OrderbookDeltaPayload.clock`. +- Optional `UserOrdersPayload.last_update_reason`. +- Optional perps `ExchangeStatus.exchange_estimated_resume_time` / + `exchange_index_statuses` / `intra_exchange_transfers_active`, plus + `ExchangeIndexStatus`. +- Optional `MarginMarket.market_id`. +- Optional `IntraExchangeInstanceTransfer.client_transfer_id`. + ## 22.5.0 — 2026-10-29 Reconciles nightly spec drift (#561) against core OpenAPI 3.35.0 and the diff --git a/docs/perps.md b/docs/perps.md index 2343769..0db482d 100644 --- a/docs/perps.md +++ b/docs/perps.md @@ -378,7 +378,9 @@ matching `*_truncated` flag is set; page the full set via the methods below. cash and split corporate-action payments already included in settlement PnL — do not add them to the obligation total. `CorporateActionPaymentDetail.payment_time` is always present and is `null` -for splits. Centicents fields on that model are plain integers; +for splits. Centicents fields on that model stay plain integers. +`payment_amount_fp` is the optional dollar-string counterpart of +`payment_amount_centicents` (separate attribute, not an alias). `position_quantity_fp` and `post_position_quantity_fp` are fixed-point contract counts. `corporate_action_payments` / `corporate_action_payments_all` page the full set when `corporate_action_payments_truncated` is true (limit @@ -409,11 +411,19 @@ klear.margin.delete_subtrader_group(created.group_id) ``` Money fields on the Klear margin schemas are integer **centicents** (`1 USD = -10,000 centicents`); the withdrawal `amount` and +10,000 centicents`), plain `int` on the Python models. Where the spec also +publishes a `*_fp` counterpart (the same amount as a fixed-point dollar +string, 6 decimal places), the SDK exposes it as a separate optional +`DollarDecimal` attribute with that `*_fp` name. `None` means the key was +absent. There is no aliasing: a payload can carry both the integer and the +dollar string, and `model_dump(by_alias=True)` emits both names. The +withdrawal `amount`, `GetSettlementBalanceWithdrawalResponse.amount`, and `MarketSettlementEstimate.session_avg_price_fp` are fixed-point dollar -strings. -`klear.margin.withdraw_settlement_balance(amount="500.00")` validates the amount as +strings that were never centicents. +`klear.margin.withdraw_settlement_balance(amount="500.00")` posts to +`POST /margin/settlement_balance_withdrawal` and validates the amount as positive at construction (the single real-money write) before any request is sent. +Pass `mfa_code=` when the clearing member requires MFA. The Bearer `access_token` is never logged and is redacted in `repr()` (only the non-secret `admin_user_id` is shown). diff --git a/kalshi/__init__.py b/kalshi/__init__.py index 7f7f19d..92ecadc 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -503,4 +503,4 @@ "Withdrawal", ] -__version__ = "22.5.0" +__version__ = "22.6.0" diff --git a/kalshi/models/communications.py b/kalshi/models/communications.py index ce7174b..c279d0a 100644 --- a/kalshi/models/communications.py +++ b/kalshi/models/communications.py @@ -63,6 +63,8 @@ class RFQ(BaseModel): creator_subaccount: int | None = None # Spec 3.30.0: True when target_cost is principal-only (taker fees on top). target_cost_excludes_fees: bool | None = None + # Set when this RFQ is a child of an RFQ batch; omitted otherwise. + rfq_batch_id: str | None = None model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/models/portfolio.py b/kalshi/models/portfolio.py index 055b4e8..649b51b 100644 --- a/kalshi/models/portfolio.py +++ b/kalshi/models/portfolio.py @@ -239,6 +239,7 @@ class IntraExchangeInstanceTransfer(BaseModel): amount: DollarDecimal status: IntraExchangeInstanceTransferStatusLiteral created_ts: int + client_transfer_id: str | None = None model_config = {"extra": "allow"} diff --git a/kalshi/perps/__init__.py b/kalshi/perps/__init__.py index 9396b3a..cc9b2f6 100644 --- a/kalshi/perps/__init__.py +++ b/kalshi/perps/__init__.py @@ -76,6 +76,7 @@ SubaccountTypeLiteral, ) from kalshi.perps.models.exchange import ( + ExchangeIndexStatus, ExchangeStatus, GetMarginRiskParametersResponse, MarginEnabledResponse, @@ -277,6 +278,7 @@ "Error", "ErrorResponse", "ExchangeIndex", + "ExchangeIndexStatus", "ExchangeInstance", "ExchangeInstanceLiteral", "ExchangeStatus", diff --git a/kalshi/perps/klear/models/margin.py b/kalshi/perps/klear/models/margin.py index 0b1fdfc..9143243 100644 --- a/kalshi/perps/klear/models/margin.py +++ b/kalshi/perps/klear/models/margin.py @@ -7,21 +7,26 @@ withdrawal (initiate + status-by-id), and FCM subtrader groups. **Money typing — read carefully.** Unlike the perps prediction/REST framing -(``FixedPointDollars`` / ``FixedPointCount`` strings), almost every monetary -field on the Klear margin schemas is an **integer ``int64`` in _centicents_** -(the spec states ``1 USD = 10,000 centicents``), serialized as a JSON number -with a ``_centicents`` field-name suffix. Those are **plain ``int``** — they are -NOT :data:`DollarDecimal` / :data:`FixedPointCount`. This mirrors the -integer-cents precedent in ``kalshi/models/portfolio.py`` (``Balance.balance``, -``Deposit.amount_cents``). Fixed-point **count** strings appear on +(``FixedPointDollars`` / ``FixedPointCount`` strings), many monetary fields on +the Klear margin schemas are an **integer ``int64`` in _centicents_** (the spec +states ``1 USD = 10,000 centicents``), serialized as a JSON number with a +``_centicents`` field-name suffix. Those are **plain ``int``** — they are NOT +:data:`DollarDecimal` / :data:`FixedPointCount`. This mirrors the integer-cents +precedent in ``kalshi/models/portfolio.py`` (``Balance.balance``, +``Deposit.amount_cents``). Where the spec also publishes a ``*_fp`` counterpart +(the same amount as a fixed-point dollar string, 6 decimal places), the SDK +exposes it as a separate optional :data:`DollarDecimal` attribute with the same +``*_fp`` name; the ``*_centicents`` integer attributes are unchanged. +Fixed-point **count** strings appear on :attr:`SettlementDetail.position_quantity_fp`, :attr:`FundingPaymentDetail.position_quantity_fp`, and :attr:`CorporateActionPaymentDetail.position_quantity_fp` / :attr:`CorporateActionPaymentDetail.post_position_quantity_fp`. The fixed-point -**dollar-string** fields are :attr:`WithdrawSettlementBalanceRequest.amount`, +**dollar-string** fields that were never centicents are +:attr:`WithdrawSettlementBalanceRequest.amount`, :attr:`GetSettlementBalanceWithdrawalResponse.amount`, and :attr:`MarketSettlementEstimate.session_avg_price_fp` (e.g. ``"500.00"``); -those use :data:`DollarDecimal`. +those also use :data:`DollarDecimal`. **Timestamps.** Every REST timestamp is RFC3339 (``format: date-time``) and uses :class:`pydantic.AwareDatetime`; the single ``format: date`` field @@ -65,6 +70,7 @@ def _require_positive_withdrawal(value: Decimal) -> Decimal: WithdrawalAmount = Annotated[DollarDecimal, AfterValidator(_require_positive_withdrawal)] """A request-side withdrawal amount: a positive fixed-point dollar value.""" + # Spec ``MarginReport.report_type`` enum — surfaced as a Literal alias (mirrors # the ``PaymentStatusLiteral`` style in kalshi/models/portfolio.py). MarginReportTypeLiteral = Literal[ @@ -129,10 +135,11 @@ class ObligationReceiveInfo(BaseModel): id: str type: str amount_centicents: int + amount_fp: DollarDecimal | None = None external_reference: str created_ts: AwareDatetime - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class SettlementDetail(BaseModel): @@ -143,10 +150,12 @@ class SettlementDetail(BaseModel): subtrader_id: str position_quantity_fp: FixedPointCount pnl_centicents: int + pnl_fp: DollarDecimal | None = None total_fees_centicents: int total_amount_centicents: int + total_amount_fp: DollarDecimal | None = None - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class MaintenanceMarginDetail(BaseModel): @@ -169,7 +178,8 @@ class FundingPaymentDetail(BaseModel): """Spec ``FundingPaymentDetail`` — per-market funding payment within an obligation. ``position_quantity_fp`` is a fixed-point contract count string (e.g. ``"1.25"``). - Centicents fields are plain ``int``. + ``notional_value_centicents`` / ``funding_amount_centicents`` are + :data:`DollarDecimal` (wire ``*_fp`` or legacy ``*_centicents``). """ id: str @@ -178,9 +188,11 @@ class FundingPaymentDetail(BaseModel): funding_time: AwareDatetime position_quantity_fp: FixedPointCount notional_value_centicents: int + notional_value_fp: DollarDecimal | None = None funding_amount_centicents: int + funding_amount_fp: DollarDecimal | None = None - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class CorporateActionPaymentDetail(BaseModel): @@ -190,8 +202,9 @@ class CorporateActionPaymentDetail(BaseModel): They are not additional amounts to add to the obligation total. ``position_quantity_fp`` / ``post_position_quantity_fp`` are fixed-point - contract counts. Centicents fields are plain ``int``. ``payment_time`` is - a required key that is ``null`` for splits (no scheduled cash time). + contract counts. ``payment_amount_centicents`` is :data:`DollarDecimal`. + ``payment_time`` is a required key that is ``null`` for splits (no + scheduled cash time). ``pre_split_price_centicents`` / ``post_split_price_centicents`` / ``exchange_ts`` are omitted when they do not apply. """ @@ -206,6 +219,7 @@ class CorporateActionPaymentDetail(BaseModel): position_quantity_fp: FixedPointCount post_position_quantity_fp: FixedPointCount payment_amount_centicents: int + payment_amount_fp: DollarDecimal | None = None # Required key; null for splits. payment_time: AwareDatetime | None settlement_execution_time: AwareDatetime @@ -214,7 +228,7 @@ class CorporateActionPaymentDetail(BaseModel): post_split_price_centicents: int | None = None exchange_ts: AwareDatetime | None = None - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class ObligationEntry(BaseModel): @@ -225,7 +239,8 @@ class ObligationEntry(BaseModel): model (the spec only ever returns the composed shape, never bare ``ObligationInfo``). ``amount_centicents`` is the net settlement amount: negative means the SCM pays Kalshi Klear, positive means Kalshi Klear pays - the SCM. All ``_centicents`` fields are plain ``int``. + the SCM. Fields with a ``*_fp`` wire counterpart are :data:`DollarDecimal`; + remaining ``_centicents`` fields are plain ``int``. Inline detail arrays are capped at 1000 rows; when truncated the matching ``*_truncated`` flag is set and the full set is available via the paged @@ -236,9 +251,11 @@ class ObligationEntry(BaseModel): id: str user_id: str amount_centicents: int + amount_fp: DollarDecimal | None = None fees_centicents: int maintenance_margin_centicents: int pnl_centicents: int + pnl_fp: DollarDecimal | None = None execution_time: AwareDatetime last_updated_ts: AwareDatetime # Spec (perps SCM) added a required ``asset_class`` on ``ObligationInfo``. @@ -254,7 +271,7 @@ class ObligationEntry(BaseModel): funding_payments_truncated: bool | None = None corporate_action_payments_truncated: bool | None = None - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class GetObligationHistoryResponse(BaseModel): @@ -331,25 +348,30 @@ class MarketSettlementEstimate(BaseModel): quantity_centicount: int variation_margin_centicents: int + variation_margin_fp: DollarDecimal | None = None notional_value_centicents: int + notional_value_fp: DollarDecimal | None = None # Optional fixed-point dollar string; omitted when position quantity is zero. session_avg_price_fp: DollarDecimal | None = None - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class SettlementEstimate(BaseModel): """Spec ``SettlementEstimate`` — estimated next-settlement amounts (centicents).""" variation_margin_centicents: int + + variation_margin_fp: DollarDecimal | None = None total_fees_centicents: int maintenance_margin_delta_centicents: int maintenance_margin_required_centicents: int total_amount_centicents: int + total_amount_fp: DollarDecimal | None = None # Spec v3.22.0: per-market breakdown map (market ticker -> estimate); optional. positions: dict[str, MarketSettlementEstimate] | None = None - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} # ── Spec sync 3.24.0 additions (perps SCM) ────────────────────────────────── @@ -401,8 +423,9 @@ class GetSettlementEstimateByAssetClassResponse(BaseModel): estimates: dict[str, AssetClassSettlementEstimate] settlement_balance_centicents: int + settlement_balance_fp: DollarDecimal | None = None - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class GetSettlementBalanceResponse(BaseModel): @@ -413,9 +436,11 @@ class GetSettlementBalanceResponse(BaseModel): user_id: str balance_available_centicents: int + balance_available_fp: DollarDecimal | None = None locked_balance_centicents: int | None = None + locked_balance_fp: DollarDecimal | None = None - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class GetGuarantyFundBalanceResponse(BaseModel): @@ -438,12 +463,15 @@ class WithdrawSettlementBalanceRequest(BaseModel): string** (``"500.00"``), NOT centicents — wire name equals the Python name, so no ``serialization_alias`` is needed. It is validated **positive at construction** (this is the single real-money write; a negative/zero amount must never reach - the endpoint). Uses ``extra="forbid"`` so a typo'd field fails at construction; + the endpoint). ``mfa_code`` is optional in the SDK (the spec marks it + required) so existing callers stay valid; omit it and the key is not sent. + Uses ``extra="forbid"`` so a typo'd field fails at construction; serializes via ``model.model_dump(exclude_none=True, by_alias=True, mode="json")`` to ``{"amount": "500.00"}``. """ amount: WithdrawalAmount + mfa_code: str | None = None model_config = {"extra": "forbid"} @@ -474,12 +502,15 @@ class SettlementBalanceHistoryEntry(BaseModel): """Spec ``SettlementBalanceHistoryEntry`` — one settlement-balance change.""" balance_delta_centicents: int + + balance_delta_fp: DollarDecimal | None = None locked_balance_delta_centicents: int + locked_balance_delta_fp: DollarDecimal | None = None reason: str business_transaction_id: str created_ts: AwareDatetime - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class GetSettlementBalanceHistoryResponse(BaseModel): @@ -720,17 +751,20 @@ class MarketFundingEstimate(BaseModel): quantity_centicount: int funding_amount_centicents: int + funding_amount_fp: DollarDecimal | None = None - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class FundingEstimate(BaseModel): """Funding amount plus optional per-market position breakdown.""" funding_amount_centicents: int + + funding_amount_fp: DollarDecimal | None = None positions: dict[str, MarketFundingEstimate] | None = None - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class AssetClassFundingEstimate(BaseModel): diff --git a/kalshi/perps/klear/resources/margin.py b/kalshi/perps/klear/resources/margin.py index 30b8f38..035b4f1 100644 --- a/kalshi/perps/klear/resources/margin.py +++ b/kalshi/perps/klear/resources/margin.py @@ -21,7 +21,7 @@ - ``guaranty_fund_balance`` — ``GET /margin/guaranty_fund_balance``. - ``settlement_balance_history`` / ``settlement_balance_history_all`` — ``GET /margin/settlement_balance_history``: cursor-paginated (limit max 500). -- ``withdraw_settlement_balance`` — ``POST /margin/withdraw_settlement_balance``: +- ``withdraw_settlement_balance`` — ``POST /margin/settlement_balance_withdrawal``: ``WithdrawSettlementBalanceRequest`` body; NOT retried (POST). - ``settlement_balance_withdrawal`` — ``GET /margin/settlement_balance_withdrawal``: withdrawal status by required ``id``. @@ -425,17 +425,20 @@ def withdraw_settlement_balance( self, *, amount: DollarDecimal | str, + mfa_code: str | None = None, extra_headers: dict[str, str] | None = None, ) -> WithdrawSettlementBalanceResponse: - """``POST /margin/withdraw_settlement_balance`` — initiate a wire withdrawal. + """``POST /margin/settlement_balance_withdrawal`` — initiate a wire withdrawal. - ``amount`` is a fixed-point dollar string (``"500.00"``, positive). The - request body is built from :class:`WithdrawSettlementBalanceRequest` and - serialized to ``{"amount": "500.00"}``. POST is never retried. + ``amount`` is a fixed-point dollar string (``"500.00"``, positive). Optional + ``mfa_code`` is included on the body when set. The request body is built + from :class:`WithdrawSettlementBalanceRequest` and serialized to + ``{"amount": "500.00"}`` (plus ``mfa_code`` when provided). POST is never + retried. """ - req = WithdrawSettlementBalanceRequest(amount=to_decimal(amount)) + req = WithdrawSettlementBalanceRequest(amount=to_decimal(amount), mfa_code=mfa_code) data = self._post( - "/margin/withdraw_settlement_balance", + "/margin/settlement_balance_withdrawal", json=req.model_dump(exclude_none=True, by_alias=True, mode="json"), extra_headers=extra_headers, ) @@ -1026,12 +1029,13 @@ async def withdraw_settlement_balance( self, *, amount: DollarDecimal | str, + mfa_code: str | None = None, extra_headers: dict[str, str] | None = None, ) -> WithdrawSettlementBalanceResponse: """Async :meth:`MarginResource.withdraw_settlement_balance`.""" - req = WithdrawSettlementBalanceRequest(amount=to_decimal(amount)) + req = WithdrawSettlementBalanceRequest(amount=to_decimal(amount), mfa_code=mfa_code) data = await self._post( - "/margin/withdraw_settlement_balance", + "/margin/settlement_balance_withdrawal", json=req.model_dump(exclude_none=True, by_alias=True, mode="json"), extra_headers=extra_headers, ) diff --git a/kalshi/perps/models/__init__.py b/kalshi/perps/models/__init__.py index 742c96f..420a462 100644 --- a/kalshi/perps/models/__init__.py +++ b/kalshi/perps/models/__init__.py @@ -22,6 +22,7 @@ SubaccountTypeLiteral, ) from kalshi.perps.models.exchange import ( + ExchangeIndexStatus, ExchangeStatus, GetMarginRiskParametersResponse, MarginEnabledResponse, @@ -139,6 +140,7 @@ "EmptyResponse", "ErrorResponse", "ExchangeIndex", + "ExchangeIndexStatus", "ExchangeInstance", "ExchangeInstanceLiteral", "ExchangeStatus", diff --git a/kalshi/perps/models/exchange.py b/kalshi/perps/models/exchange.py index 2fbfaf6..bd0832d 100644 --- a/kalshi/perps/models/exchange.py +++ b/kalshi/perps/models/exchange.py @@ -8,22 +8,39 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict +from pydantic import AwareDatetime, BaseModel, ConfigDict from kalshi.types import MultiplierDecimal +class ExchangeIndexStatus(BaseModel): + """Spec ``ExchangeIndexStatus`` — operational status of one exchange shard.""" + + model_config = ConfigDict(extra="allow") + + exchange_index: int + description: str + exchange_active: bool + trading_active: bool + intra_exchange_transfers_active: bool + + class ExchangeStatus(BaseModel): """Spec ``ExchangeStatus`` — margin exchange operational status. - Distinct from :class:`kalshi.models.exchange.ExchangeStatus`; the perps - version is slimmer (no schedule / resume-time fields). + Distinct from :class:`kalshi.models.exchange.ExchangeStatus`. The spec + documents that perps responses omit ``intra_exchange_transfers_active`` + and ``exchange_index_statuses``; the SDK keeps them optional so a payload + that does include them still parses. """ model_config = ConfigDict(extra="allow") exchange_active: bool trading_active: bool + intra_exchange_transfers_active: bool | None = None + exchange_estimated_resume_time: AwareDatetime | None = None + exchange_index_statuses: list[ExchangeIndexStatus] | None = None class MarginEnabledResponse(BaseModel): diff --git a/kalshi/perps/models/markets.py b/kalshi/perps/models/markets.py index 62edb55..8b51068 100644 --- a/kalshi/perps/models/markets.py +++ b/kalshi/perps/models/markets.py @@ -78,6 +78,8 @@ class MarginMarket(BaseModel): ticker: str title: str status: MarginMarketStatusLiteral + # Stable market UUID; remains unchanged if the ticker is renamed. + market_id: str | None = None contract_size: DollarDecimal # Spec 3.30.0: underlying units per contract-size unit (required string). underlying_multiplier: str diff --git a/kalshi/ws/models/communications.py b/kalshi/ws/models/communications.py index 6e2230c..d02aa58 100644 --- a/kalshi/ws/models/communications.py +++ b/kalshi/ws/models/communications.py @@ -36,6 +36,8 @@ class RfqCreatedPayload(BaseModel): # event_ticker/market_ticker/side/yes_settlement_value_dollars. mve_collection_ticker: str | None = None mve_selected_legs: list[dict[str, object]] | None = None + # Set when this RFQ is a child of an RFQ batch; omitted otherwise. + rfq_batch_id: str | None = None model_config = {"extra": "allow", "populate_by_name": True} @@ -61,6 +63,8 @@ class RfqDeletedPayload(BaseModel): default=None, validation_alias=AliasChoices("target_cost_dollars", "target_cost"), ) + # Set when this RFQ is a child of an RFQ batch; omitted otherwise. + rfq_batch_id: str | None = None model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/ws/models/orderbook_delta.py b/kalshi/ws/models/orderbook_delta.py index ebb4dfa..9e400e4 100644 --- a/kalshi/ws/models/orderbook_delta.py +++ b/kalshi/ws/models/orderbook_delta.py @@ -84,6 +84,7 @@ class OrderbookSnapshotPayload(BaseModel): no: PriceCountMap = Field( validation_alias=AliasChoices("no_dollars_fp", "no"), ) + clock: int | None = None model_config = {"extra": "allow", "populate_by_name": True} @@ -110,6 +111,7 @@ class OrderbookDeltaPayload(BaseModel): ts: AwareDatetime | None = None # v0.14+ backfill (#162). ts_ms (Unix ms) supersedes ts (RFC3339). ts_ms: int | None = None + clock: int | None = None model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/ws/models/user_orders.py b/kalshi/ws/models/user_orders.py index de33635..2cb066f 100644 --- a/kalshi/ws/models/user_orders.py +++ b/kalshi/ws/models/user_orders.py @@ -70,6 +70,7 @@ class UserOrdersPayload(BaseModel): last_updated_ts_ms: int | None = None expiration_ts_ms: int | None = None exchange_index: int + last_update_reason: str | None = None model_config = {"extra": "allow", "populate_by_name": True} diff --git a/pyproject.toml b/pyproject.toml index 3df7370..ffa46a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "22.5.0" +version = "22.6.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/perps_scm_openapi.yaml b/specs/perps_scm_openapi.yaml index 487bd7b..141c4de 100644 --- a/specs/perps_scm_openapi.yaml +++ b/specs/perps_scm_openapi.yaml @@ -436,7 +436,7 @@ paths: '403': { $ref: '#/components/responses/ForbiddenError' } '500': { $ref: '#/components/responses/InternalServerError' } - /margin/withdraw_settlement_balance: + /margin/settlement_balance_withdrawal: post: operationId: WithdrawSettlementBalance summary: Withdraw Settlement Balance @@ -458,7 +458,6 @@ paths: '403': { $ref: '#/components/responses/ForbiddenError' } '500': { $ref: '#/components/responses/InternalServerError' } - /margin/settlement_balance_withdrawal: get: operationId: GetSettlementBalanceWithdrawal summary: Get Settlement Balance Withdrawal @@ -785,9 +784,11 @@ components: - id - user_id - amount_centicents + - amount_fp - fees_centicents - maintenance_margin_centicents - pnl_centicents + - pnl_fp - execution_time - last_updated_ts - asset_class @@ -799,25 +800,29 @@ components: format: int64 description: | Net settlement amount in USD as centicents (1 USD = 10,000 - centicents). All monetary fields on this API are returned in - centicents. Total the clearing member owes or is owed, inclusive - of PnL, fees, and maintenance margin changes. Negative means the - clearing member pays Kalshi Klear; positive means Kalshi Klear - pays the clearing member. + centicents). Monetary fields ending in `_centicents` are integer + centicents; where an `_fp` counterpart exists, it carries the same + amount as a fixed-point dollar string with 6 decimal places. Total + the clearing member owes or is owed, inclusive of PnL, fees, and + maintenance margin changes. Negative means the clearing member pays + Kalshi Klear; positive means Kalshi Klear pays the clearing member. + amount_fp: { type: string, description: "amount_centicents as a fixed-point dollar string with 6 decimal places (e.g. \"10005.000000\")." } fees_centicents: { type: integer, format: int64, description: Trading fees incurred during the settlement period. } maintenance_margin_centicents: { type: integer, format: int64, description: Current maintenance margin requirement for this obligation. } pnl_centicents: { type: integer, format: int64, description: "Total PnL for this settlement period; the sum of the settlement details for this settlement cycle." } + pnl_fp: { type: string, description: pnl_centicents as a fixed-point dollar string with 6 decimal places. } execution_time: { type: string, format: date-time, description: Settlement cycle time this obligation belongs to. } last_updated_ts: { type: string, format: date-time, description: Timestamp of the last status change on this obligation. } asset_class: { $ref: '#/components/schemas/AssetClass' } ObligationReceiveInfo: type: object - required: [id, type, amount_centicents, external_reference, created_ts] + required: [id, type, amount_centicents, amount_fp, external_reference, created_ts] properties: id: { type: string } type: { type: string } amount_centicents: { type: integer, format: int64 } + amount_fp: { type: string, description: amount_centicents as a fixed-point dollar string with 6 decimal places. } external_reference: { type: string } created_ts: { type: string, format: date-time } @@ -829,16 +834,20 @@ components: - subtrader_id - position_quantity_fp - pnl_centicents + - pnl_fp - total_fees_centicents - total_amount_centicents + - total_amount_fp properties: id: { type: string } market_ticker: { type: string } subtrader_id: { type: string } position_quantity_fp: { type: string, description: "Signed position at settlement as a fixed-point contract count (e.g. \"1.25\")." } pnl_centicents: { type: integer, format: int64, description: "Total PnL for this market and subtrader over the settlement period, including the market's net funding (itemized per funding period in the entry's funding_payments)." } + pnl_fp: { type: string, description: pnl_centicents as a fixed-point dollar string with 6 decimal places. } total_fees_centicents: { type: integer, format: int64, description: Total fees. } total_amount_centicents: { type: integer, format: int64, description: Total amount. } + total_amount_fp: { type: string, description: total_amount_centicents as a fixed-point dollar string with 6 decimal places. } FundingPaymentDetail: type: object @@ -849,7 +858,9 @@ components: - funding_time - position_quantity_fp - notional_value_centicents + - notional_value_fp - funding_amount_centicents + - funding_amount_fp properties: id: { type: string } market_ticker: { type: string } @@ -857,7 +868,9 @@ components: funding_time: { type: string, format: date-time, description: The funding period this payment applies to. } position_quantity_fp: { type: string, description: "Signed position at funding time as a fixed-point contract count (e.g. \"1.25\")." } notional_value_centicents: { type: integer, format: int64, description: Signed notional value (position times mark price) the funding payment was computed from. } + notional_value_fp: { type: string, description: notional_value_centicents as a fixed-point dollar string with 6 decimal places. } funding_amount_centicents: { type: integer, format: int64, description: "Signed funding amount; positive when received, negative when paid." } + funding_amount_fp: { type: string, description: funding_amount_centicents as a fixed-point dollar string with 6 decimal places. } CorporateActionPaymentDetail: type: object @@ -873,6 +886,7 @@ components: - position_quantity_fp - post_position_quantity_fp - payment_amount_centicents + - payment_amount_fp - payment_time - settlement_execution_time - created_ts @@ -887,6 +901,7 @@ components: position_quantity_fp: { type: string, description: Signed position before the action as a fixed-point contract count. } post_position_quantity_fp: { type: string, description: Signed position after the action as a fixed-point contract count; unchanged for cash distributions. } payment_amount_centicents: { type: integer, format: int64, description: Signed cash amount; positive when received and negative when paid. For splits this is cash in lieu and excludes position revaluation. } + payment_amount_fp: { type: string, description: payment_amount_centicents as a fixed-point dollar string with 6 decimal places. } payment_time: { type: string, format: date-time, nullable: true, description: Scheduled cash payment time; null for splits. } settlement_execution_time: { type: string, format: date-time, description: Settlement cycle that carries this payment. } pre_split_price_centicents: { type: integer, format: int64, description: Mark price before the split; omitted when not applicable. } @@ -1139,27 +1154,35 @@ components: required: - quantity_centicount - variation_margin_centicents + - variation_margin_fp - notional_value_centicents + - notional_value_fp properties: quantity_centicount: { type: integer, format: int64, description: Position quantity in centicounts. } variation_margin_centicents: { type: integer, format: int64, description: Variation margin for this market. } + variation_margin_fp: { type: string, description: variation_margin_centicents as a fixed-point dollar string with 6 decimal places. } notional_value_centicents: { type: integer, format: int64, description: Notional value for this market. } + notional_value_fp: { type: string, description: notional_value_centicents as a fixed-point dollar string with 6 decimal places. } session_avg_price_fp: { type: string, description: "Average entry price for the current settlement session, as a fixed-point dollar string. Omitted when the position quantity is zero." } SettlementEstimate: type: object required: - variation_margin_centicents + - variation_margin_fp - total_fees_centicents - maintenance_margin_delta_centicents - maintenance_margin_required_centicents - total_amount_centicents + - total_amount_fp properties: variation_margin_centicents: { type: integer, format: int64, description: Variation margin. } + variation_margin_fp: { type: string, description: variation_margin_centicents as a fixed-point dollar string with 6 decimal places. } total_fees_centicents: { type: integer, format: int64, description: Total fees. } maintenance_margin_delta_centicents: { type: integer, format: int64, description: Change in maintenance margin requirement. } maintenance_margin_required_centicents: { type: integer, format: int64, description: Maintenance margin requirement. } total_amount_centicents: { type: integer, format: int64, description: Estimated total settlement amount. } + total_amount_fp: { type: string, description: total_amount_centicents as a fixed-point dollar string with 6 decimal places. } positions: type: object description: Map of market ticker to that market's settlement estimate. @@ -1186,26 +1209,29 @@ components: GetSettlementEstimateByAssetClassResponse: type: object - required: [estimates, settlement_balance_centicents] + required: [estimates, settlement_balance_centicents, settlement_balance_fp] properties: estimates: type: object description: Map of asset class to the estimated next settlement for that asset class. additionalProperties: { $ref: '#/components/schemas/AssetClassSettlementEstimate' } settlement_balance_centicents: { type: integer, format: int64, description: Current settlement buffer balance. } + settlement_balance_fp: { type: string, description: settlement_balance_centicents as a fixed-point dollar string with 6 decimal places. } MarketFundingEstimate: type: object - required: [quantity_centicount, funding_amount_centicents] + required: [quantity_centicount, funding_amount_centicents, funding_amount_fp] properties: quantity_centicount: { type: integer, format: int64 } funding_amount_centicents: { type: integer, format: int64 } + funding_amount_fp: { type: string, description: funding_amount_centicents as a fixed-point dollar string with 6 decimal places. } FundingEstimate: type: object - required: [funding_amount_centicents] + required: [funding_amount_centicents, funding_amount_fp] properties: funding_amount_centicents: { type: integer, format: int64 } + funding_amount_fp: { type: string, description: funding_amount_centicents as a fixed-point dollar string with 6 decimal places. } positions: type: object description: Keyed by market ticker; only present on subtrader and group breakdowns. @@ -1254,11 +1280,13 @@ components: GetSettlementBalanceResponse: type: object - required: [user_id, balance_available_centicents] + required: [user_id, balance_available_centicents, balance_available_fp] properties: user_id: { type: string } balance_available_centicents: { type: integer, format: int64, description: Settlement buffer balance. } + balance_available_fp: { type: string, description: balance_available_centicents as a fixed-point dollar string with 6 decimal places. } locked_balance_centicents: { type: integer, format: int64, description: Locked settlement balance (e.g. pending withdrawals). } + locked_balance_fp: { type: string, description: locked_balance_centicents as a fixed-point dollar string with 6 decimal places. } GetGuarantyFundBalanceResponse: type: object @@ -1279,11 +1307,14 @@ components: WithdrawSettlementBalanceRequest: type: object - required: [amount] + required: [amount, mfa_code] properties: amount: type: string - description: Amount to withdraw in USD as a fixed-point string (e.g. "500.00"). Must be positive. + description: Amount to withdraw in USD as a fixed-point string (e.g. "500.00"). Must be positive and in whole cents. + mfa_code: + type: string + description: Code from the requesting user's multi-factor authentication method. WithdrawSettlementBalanceResponse: type: object @@ -1295,10 +1326,12 @@ components: SettlementBalanceHistoryEntry: type: object - required: [balance_delta_centicents, locked_balance_delta_centicents, reason, business_transaction_id, created_ts] + required: [balance_delta_centicents, balance_delta_fp, locked_balance_delta_centicents, locked_balance_delta_fp, reason, business_transaction_id, created_ts] properties: balance_delta_centicents: { type: integer, format: int64, description: Change to the settlement buffer balance. } + balance_delta_fp: { type: string, description: balance_delta_centicents as a fixed-point dollar string with 6 decimal places. } locked_balance_delta_centicents: { type: integer, format: int64, description: Change to the locked settlement balance. Funds are locked during pending withdrawals. } + locked_balance_delta_fp: { type: string, description: locked_balance_delta_centicents as a fixed-point dollar string with 6 decimal places. } reason: { type: string, description: Reason for the balance change. } business_transaction_id: { type: string, description: Identifier of the business transaction that caused this entry. } created_ts: { type: string, format: date-time, description: Timestamp when the entry was created. } diff --git a/tests/_contract_support.py b/tests/_contract_support.py index d313612..e167607 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -1428,6 +1428,22 @@ class Exclusion: reason="spec marks deprecated; superseded by ts_ms", kind="spec_deprecated", ), + ("kalshi.ws.models.orderbook_delta.OrderbookSnapshotPayload", "clock"): Exclusion( + reason=( + "AsyncAPI marks orderbook snapshot clock required; the SDK keeps it " + "optional (None when absent) so payloads and fixtures predating the " + "field still validate and the change ships as a minor release (22.6.0)." + ), + kind="server_omits_despite_required", + ), + ("kalshi.ws.models.orderbook_delta.OrderbookDeltaPayload", "clock"): Exclusion( + reason=( + "AsyncAPI marks orderbook delta clock required; the SDK keeps it " + "optional (None when absent) so payloads and fixtures predating the " + "field still validate and the change ships as a minor release (22.6.0)." + ), + kind="server_omits_despite_required", + ), # --- server_omits_despite_required (#183, post-#172 nightly findings) --- # Fields the OpenAPI spec marks `required: true` but the live demo server # omits in practice. Each entry MUST cite a demo+prod observation in @@ -1969,7 +1985,7 @@ class Exclusion: MethodEndpointEntry( sdk_method="kalshi.perps.klear.resources.margin.MarginResource.withdraw_settlement_balance", http_method="POST", - path_template="/margin/withdraw_settlement_balance", + path_template="/margin/settlement_balance_withdrawal", request_body_schema="#/components/schemas/WithdrawSettlementBalanceRequest", ), MethodEndpointEntry( @@ -2274,6 +2290,53 @@ class Exclusion: reason="client-side page cap, no wire counterpart", kind="client_only", ), + # Spec marks each additive *_fp dollar string required next to its integer + # *_centicents field. The SDK keeps *_fp optional so a centicents-only + # payload still parses. The two attributes are separate; there is no alias. + **{ + (model, field): Exclusion( + reason=( + "Spec marks this *_fp dollar string required beside the integer " + "*_centicents field. The SDK keeps it optional (DollarDecimal | None) " + "so centicents-only payloads still parse. Separate attributes; no alias." + ), + kind="server_omits_despite_required", + ) + for model, field in ( + ("kalshi.perps.klear.models.margin.ObligationReceiveInfo", "amount_fp"), + ("kalshi.perps.klear.models.margin.ObligationEntry", "amount_fp"), + ("kalshi.perps.klear.models.margin.ObligationEntry", "pnl_fp"), + ("kalshi.perps.klear.models.margin.SettlementDetail", "pnl_fp"), + ("kalshi.perps.klear.models.margin.SettlementDetail", "total_amount_fp"), + ("kalshi.perps.klear.models.margin.FundingPaymentDetail", "notional_value_fp"), + ("kalshi.perps.klear.models.margin.FundingPaymentDetail", "funding_amount_fp"), + ("kalshi.perps.klear.models.margin.MemberFundingPayment", "notional_value_fp"), + ("kalshi.perps.klear.models.margin.MemberFundingPayment", "funding_amount_fp"), + ("kalshi.perps.klear.models.margin.CorporateActionPaymentDetail", "payment_amount_fp"), + ("kalshi.perps.klear.models.margin.MarketSettlementEstimate", "variation_margin_fp"), + ("kalshi.perps.klear.models.margin.MarketSettlementEstimate", "notional_value_fp"), + ("kalshi.perps.klear.models.margin.SettlementEstimate", "variation_margin_fp"), + ("kalshi.perps.klear.models.margin.SettlementEstimate", "total_amount_fp"), + ( + "kalshi.perps.klear.models.margin.GetSettlementEstimateByAssetClassResponse", + "settlement_balance_fp", + ), + ( + "kalshi.perps.klear.models.margin.GetSettlementBalanceResponse", + "balance_available_fp", + ), + ( + "kalshi.perps.klear.models.margin.SettlementBalanceHistoryEntry", + "balance_delta_fp", + ), + ( + "kalshi.perps.klear.models.margin.SettlementBalanceHistoryEntry", + "locked_balance_delta_fp", + ), + ("kalshi.perps.klear.models.margin.MarketFundingEstimate", "funding_amount_fp"), + ("kalshi.perps.klear.models.margin.FundingEstimate", "funding_amount_fp"), + ) + }, } diff --git a/tests/perps/klear/test_margin.py b/tests/perps/klear/test_margin.py index 3516597..03d2f86 100644 --- a/tests/perps/klear/test_margin.py +++ b/tests/perps/klear/test_margin.py @@ -7,10 +7,13 @@ there is no client-side un-logged-in guard; an invalid token surfaces as a server 401. -Money-typing invariants under test: ``_centicents`` fields stay plain ``int`` -(never ``Decimal``), only the withdraw/withdrawal ``amount`` fields are -``DollarDecimal`` dollar-strings, and the withdraw POST body wire shape is -exactly ``{"amount": "500.00"}``. POST is never retried. +Money-typing invariants under test: every ``*_centicents`` field is a plain +``int``. Where the spec also sends a ``*_fp`` dollar string, that value is a +separate optional ``DollarDecimal`` (``None`` when the key is absent). There +is no aliasing: ``model_dump(by_alias=True)`` keeps both names. The withdraw +POST body wire shape is ``{"amount": "500.00"}`` (plus optional ``mfa_code``). +POST is never retried. The withdrawal POST path is +``/margin/settlement_balance_withdrawal``. """ from __future__ import annotations @@ -31,10 +34,21 @@ ) from kalshi.perps.klear import AsyncKlearClient, KlearClient, KlearConfig from kalshi.perps.klear.models.margin import ( + CorporateActionPaymentDetail, + FundingEstimate, + FundingPaymentDetail, + GetSettlementBalanceResponse, GetSettlementBalanceWithdrawalResponse, + GetSettlementEstimateByAssetClassResponse, MarginReport, + MarketFundingEstimate, + MarketSettlementEstimate, + MemberFundingPayment, ObligationEntry, + ObligationReceiveInfo, SettlementBalanceHistoryEntry, + SettlementDetail, + SettlementEstimate, WithdrawSettlementBalanceRequest, ) @@ -283,10 +297,12 @@ def test_happy_list(self, auth_klear_client: KlearClient) -> None: assert resp.obligations[0].amount_centicents == -99999 assert isinstance(resp.obligations[0].amount_centicents, int) assert not isinstance(resp.obligations[0].amount_centicents, bool) - assert not isinstance(resp.obligations[0].amount_centicents, Decimal) + assert resp.obligations[0].amount_fp is None assert resp.obligations[0].asset_class == "Crypto" assert resp.obligations[0].settlement_details[0].position_quantity_fp == Decimal("1.25") + assert resp.obligations[0].settlement_details[0].pnl_fp is None assert resp.obligations[0].funding_payments[0].funding_amount_centicents == -50 + assert resp.obligations[0].funding_payments[0].funding_amount_fp is None assert resp.obligations[0].execution_time.tzinfo is not None auth_klear_client.close() @@ -345,12 +361,16 @@ def test_happy(self, auth_klear_client: KlearClient) -> None: ) resp = auth_klear_client.margin.settlement_estimate_by_asset_class() assert resp.settlement_balance_centicents == 123456 + assert isinstance(resp.settlement_balance_centicents, int) + assert resp.settlement_balance_fp is None crypto = resp.estimates["Crypto"] assert crypto.next_runtime.tzinfo is not None assert crypto.user_breakdown is not None assert crypto.user_breakdown.total_amount_centicents == 1630 + assert crypto.user_breakdown.total_amount_fp is None assert crypto.subtrader_breakdowns is not None assert crypto.subtrader_breakdowns["st1"].variation_margin_centicents == 1000 + assert crypto.subtrader_breakdowns["st1"].variation_margin_fp is None assert crypto.prev_settlement_prices == {"BTC-PERP": 5000} assert crypto.omitted_subtrader_count == 2 auth_klear_client.close() @@ -400,6 +420,8 @@ def test_session_avg_price_fp_optional(self) -> None: notional_value_centicents=3, ) assert bare.session_avg_price_fp is None + assert bare.variation_margin_fp is None + assert bare.notional_value_fp is None parsed = MarketSettlementEstimate.model_validate( { "quantity_centicount": 1, @@ -605,6 +627,7 @@ def test_page_parses_payment(self, auth_klear_client: KlearClient) -> None: assert row.payment_amount_centicents == 400 assert isinstance(row.payment_amount_centicents, int) assert not isinstance(row.payment_amount_centicents, bool) + assert row.payment_amount_fp is None assert row.pre_split_price_centicents == 68000 assert row.post_split_price_centicents == 34000 assert row.payment_time is not None @@ -717,8 +740,11 @@ def test_happy(self, auth_klear_client: KlearClient) -> None: resp = auth_klear_client.margin.settlement_balance() assert resp.balance_available_centicents == 1_000_000 assert isinstance(resp.balance_available_centicents, int) - assert not isinstance(resp.balance_available_centicents, Decimal) + assert not isinstance(resp.balance_available_centicents, bool) + assert resp.balance_available_fp is None assert resp.locked_balance_centicents == 250_000 + assert isinstance(resp.locked_balance_centicents, int) + assert resp.locked_balance_fp is None auth_klear_client.close() @respx.mock @@ -828,6 +854,9 @@ def test_happy_page(self, auth_klear_client: KlearClient) -> None: assert isinstance(page.items[0], SettlementBalanceHistoryEntry) assert page.items[0].balance_delta_centicents == -500 assert isinstance(page.items[0].balance_delta_centicents, int) + assert not isinstance(page.items[0].balance_delta_centicents, bool) + assert page.items[0].balance_delta_fp is None + assert page.items[0].locked_balance_delta_fp is None assert page.cursor == "C1" auth_klear_client.close() @@ -878,7 +907,7 @@ async def test_async_all_paginates(self, auth_async_klear_client: AsyncKlearClie class TestWithdrawSettlementBalance: @respx.mock def test_happy_wire_shape(self, auth_klear_client: KlearClient) -> None: - route = respx.post(f"{BASE}/margin/withdraw_settlement_balance").mock( + route = respx.post(f"{BASE}/margin/settlement_balance_withdrawal").mock( return_value=httpx.Response(200, json={"id": "wd-1"}) ) resp = auth_klear_client.margin.withdraw_settlement_balance(amount="500.00") @@ -889,6 +918,19 @@ def test_happy_wire_shape(self, auth_klear_client: KlearClient) -> None: assert body == {"amount": "500.00"} auth_klear_client.close() + @respx.mock + def test_mfa_code_serialized_when_set(self, auth_klear_client: KlearClient) -> None: + route = respx.post(f"{BASE}/margin/settlement_balance_withdrawal").mock( + return_value=httpx.Response(200, json={"id": "wd-mfa"}) + ) + resp = auth_klear_client.margin.withdraw_settlement_balance( + amount="500.00", mfa_code="123456" + ) + assert resp.id == "wd-mfa" + body = json.loads(route.calls.last.request.content) + assert body == {"amount": "500.00", "mfa_code": "123456"} + auth_klear_client.close() + def test_forbid_extra_field(self) -> None: with pytest.raises(ValidationError): WithdrawSettlementBalanceRequest(amount="5", bogus=1) # type: ignore[call-arg] @@ -897,7 +939,7 @@ def test_forbid_extra_field(self) -> None: def test_rejects_non_positive_amount_client_side(self, auth_klear_client: KlearClient) -> None: # Security: a non-positive withdrawal must be rejected at construction, # BEFORE any HTTP reaches the real-money endpoint (mirrors OrderPrice). - route = respx.post(f"{BASE}/margin/withdraw_settlement_balance").mock( + route = respx.post(f"{BASE}/margin/settlement_balance_withdrawal").mock( return_value=httpx.Response(200, json={"id": "should-not-happen"}) ) for bad in ("0.00", "-500.00"): @@ -909,7 +951,7 @@ def test_rejects_non_positive_amount_client_side(self, auth_klear_client: KlearC @respx.mock def test_server_400_on_valid_amount_maps(self, auth_klear_client: KlearClient) -> None: # A positive amount the server rejects (e.g. insufficient funds) maps cleanly. - respx.post(f"{BASE}/margin/withdraw_settlement_balance").mock( + respx.post(f"{BASE}/margin/settlement_balance_withdrawal").mock( return_value=httpx.Response(400, json={"code": "bad", "message": "insufficient funds"}) ) with pytest.raises(KalshiValidationError): @@ -918,7 +960,7 @@ def test_server_400_on_valid_amount_maps(self, auth_klear_client: KlearClient) - @respx.mock def test_post_not_retried(self) -> None: - route = respx.post(f"{BASE}/margin/withdraw_settlement_balance").mock( + route = respx.post(f"{BASE}/margin/settlement_balance_withdrawal").mock( return_value=httpx.Response(503, json={"code": "x", "message": "down"}) ) client = KlearClient( @@ -934,7 +976,7 @@ def test_post_not_retried(self) -> None: @respx.mock async def test_async_happy_wire_shape(self, auth_async_klear_client: AsyncKlearClient) -> None: - route = respx.post(f"{BASE}/margin/withdraw_settlement_balance").mock( + route = respx.post(f"{BASE}/margin/settlement_balance_withdrawal").mock( return_value=httpx.Response(200, json={"id": "wd-2"}) ) resp = await auth_async_klear_client.margin.withdraw_settlement_balance(amount="12.34") @@ -1432,6 +1474,8 @@ def test_funding_estimate_by_asset_class(self, auth_klear_client: KlearClient) - ) resp = auth_klear_client.margin.funding_estimate_by_asset_class() assert resp.estimates["Crypto"].user_breakdown.funding_amount_centicents == 100 + assert isinstance(resp.estimates["Crypto"].user_breakdown.funding_amount_centicents, int) + assert resp.estimates["Crypto"].user_breakdown.funding_amount_fp is None auth_klear_client.close() @respx.mock @@ -1453,3 +1497,352 @@ async def test_async_funding_schedule(self, auth_async_klear_client: AsyncKlearC resp = await auth_async_klear_client.margin.funding_schedule(asset_class="Crypto") assert resp.schedule == "0 */8 * * *" await auth_async_klear_client.close() + + +# --------------------------------------------------------------------------- # +# Additive optional *_fp dollar fields (no aliasing) +# --------------------------------------------------------------------------- # + + +def _receive_info_payload(**overrides: object) -> dict[str, object]: + base: dict[str, object] = { + "id": "r1", + "type": "wire", + "external_reference": "EXT-1", + "created_ts": "2026-06-01T00:30:00Z", + } + base.update(overrides) + return base + + +def _json_dump(model: object) -> dict[str, object]: + """``by_alias`` serialization. With no aliases this still emits every name.""" + dumped = model.model_dump(by_alias=True, mode="json") # type: ignore[attr-defined] + assert isinstance(dumped, dict) + return dumped + + +class TestAdditiveFpFields: + """``*_centicents`` stays a plain ``int``. Each ``*_fp`` wire field is separate. + + A payload that sends only the old centicents name parses with ``*_fp is None``. + A payload that sends both parses both. ``model_dump(by_alias=True)`` keeps + both names. An ``*_fp`` key alone does not fill the centicents attribute. + """ + + def test_obligation_receive_info_centicents_only_and_both(self) -> None: + old = ObligationReceiveInfo.model_validate( + _receive_info_payload(amount_centicents=12345) + ) + assert old.amount_centicents == 12345 + assert isinstance(old.amount_centicents, int) + assert not isinstance(old.amount_centicents, bool) + assert old.amount_fp is None + old_dump = _json_dump(old) + assert old_dump["amount_centicents"] == 12345 + assert old_dump["amount_fp"] is None + + both = ObligationReceiveInfo.model_validate( + _receive_info_payload(amount_centicents=12345, amount_fp="1.234500") + ) + assert both.amount_centicents == 12345 + assert isinstance(both.amount_centicents, int) + assert both.amount_fp == Decimal("1.2345") + dumped = _json_dump(both) + assert dumped["amount_centicents"] == 12345 + assert dumped["amount_fp"] == "1.234500" + + def test_construct_by_python_name(self) -> None: + info = ObligationReceiveInfo.model_validate( + _receive_info_payload(amount_centicents=12345, amount_fp="1.234500") + ) + by_name = ObligationReceiveInfo( + id=info.id, + type=info.type, + amount_centicents=info.amount_centicents, + amount_fp=info.amount_fp, + external_reference=info.external_reference, + created_ts=info.created_ts, + ) + assert by_name.amount_centicents == 12345 + assert by_name.amount_fp == Decimal("1.2345") + dumped = _json_dump(by_name) + assert dumped["amount_centicents"] == 12345 + assert dumped["amount_fp"] == "1.234500" + + def test_fp_alone_does_not_fill_centicents(self) -> None: + with pytest.raises(ValidationError): + ObligationReceiveInfo.model_validate(_receive_info_payload(amount_fp="1.234500")) + + def test_settlement_detail_centicents_only_and_both(self) -> None: + old = SettlementDetail.model_validate(_settlement_detail()) + assert old.pnl_centicents == -200 + assert isinstance(old.pnl_centicents, int) + assert old.pnl_fp is None + assert old.total_amount_centicents == -300 + assert old.total_amount_fp is None + assert old.total_fees_centicents == 100 + + both = SettlementDetail.model_validate( + _settlement_detail(pnl_fp="-0.020000", total_amount_fp="-0.030000") + ) + assert both.pnl_centicents == -200 + assert both.pnl_fp == Decimal("-0.02") + assert both.total_amount_centicents == -300 + assert both.total_amount_fp == Decimal("-0.03") + dumped = _json_dump(both) + assert dumped["pnl_centicents"] == -200 + assert dumped["pnl_fp"] == "-0.020000" + assert dumped["total_amount_centicents"] == -300 + assert dumped["total_amount_fp"] == "-0.030000" + assert dumped["total_fees_centicents"] == 100 + + def test_funding_payment_detail_centicents_only_and_both(self) -> None: + old = FundingPaymentDetail.model_validate(_funding_payment()) + assert old.notional_value_centicents == 100000 + assert isinstance(old.notional_value_centicents, int) + assert old.notional_value_fp is None + assert old.funding_amount_centicents == -50 + assert old.funding_amount_fp is None + + both = FundingPaymentDetail.model_validate( + _funding_payment(notional_value_fp="10.000000", funding_amount_fp="-0.005000") + ) + assert both.notional_value_centicents == 100000 + assert both.notional_value_fp == Decimal("10") + assert both.funding_amount_centicents == -50 + assert both.funding_amount_fp == Decimal("-0.005") + dumped = _json_dump(both) + assert dumped["notional_value_centicents"] == 100000 + assert dumped["notional_value_fp"] == "10.000000" + assert dumped["funding_amount_centicents"] == -50 + assert dumped["funding_amount_fp"] == "-0.005000" + + def test_corporate_action_payment_centicents_only_and_both(self) -> None: + old = CorporateActionPaymentDetail.model_validate(_corporate_action_payment()) + assert old.payment_amount_centicents == 400 + assert isinstance(old.payment_amount_centicents, int) + assert old.payment_amount_fp is None + + both = CorporateActionPaymentDetail.model_validate( + _corporate_action_payment(payment_amount_fp="0.040000") + ) + assert both.payment_amount_centicents == 400 + assert both.payment_amount_fp == Decimal("0.04") + dumped = _json_dump(both) + assert dumped["payment_amount_centicents"] == 400 + assert dumped["payment_amount_fp"] == "0.040000" + + def test_obligation_entry_centicents_only_and_both(self) -> None: + old = ObligationEntry.model_validate(_obligation()) + assert old.amount_centicents == -12345 + assert isinstance(old.amount_centicents, int) + assert old.amount_fp is None + assert old.pnl_centicents == -200 + assert old.pnl_fp is None + + both = ObligationEntry.model_validate( + _obligation(amount=12345) | {"amount_fp": "1.234500", "pnl_fp": "-0.020000"} + ) + assert both.amount_centicents == 12345 + assert both.amount_fp == Decimal("1.2345") + assert both.pnl_centicents == -200 + assert both.pnl_fp == Decimal("-0.02") + dumped = _json_dump(both) + assert dumped["amount_centicents"] == 12345 + assert dumped["amount_fp"] == "1.234500" + assert dumped["pnl_centicents"] == -200 + assert dumped["pnl_fp"] == "-0.020000" + + def test_market_settlement_estimate_centicents_only_and_both(self) -> None: + centicents = { + "quantity_centicount": 1, + "variation_margin_centicents": 1000, + "notional_value_centicents": 2000, + } + old = MarketSettlementEstimate.model_validate(centicents) + assert old.variation_margin_centicents == 1000 + assert isinstance(old.variation_margin_centicents, int) + assert old.variation_margin_fp is None + assert old.notional_value_centicents == 2000 + assert old.notional_value_fp is None + + both = MarketSettlementEstimate.model_validate( + centicents | {"variation_margin_fp": "0.100000", "notional_value_fp": "0.200000"} + ) + assert both.variation_margin_centicents == 1000 + assert both.variation_margin_fp == Decimal("0.1") + assert both.notional_value_centicents == 2000 + assert both.notional_value_fp == Decimal("0.2") + dumped = _json_dump(both) + assert dumped["variation_margin_centicents"] == 1000 + assert dumped["variation_margin_fp"] == "0.100000" + assert dumped["notional_value_centicents"] == 2000 + assert dumped["notional_value_fp"] == "0.200000" + + def test_settlement_estimate_centicents_only_and_both(self) -> None: + old = SettlementEstimate.model_validate(_estimate()) + assert old.total_amount_centicents == 1630 + assert isinstance(old.total_amount_centicents, int) + assert old.total_amount_fp is None + assert old.variation_margin_centicents == 1000 + assert old.variation_margin_fp is None + # Fees and maintenance margin have no *_fp counterpart. + assert old.total_fees_centicents == 200 + assert isinstance(old.total_fees_centicents, int) + + both = SettlementEstimate.model_validate( + _estimate() | {"variation_margin_fp": "0.100000", "total_amount_fp": "0.163000"} + ) + assert both.variation_margin_centicents == 1000 + assert both.variation_margin_fp == Decimal("0.1") + assert both.total_amount_centicents == 1630 + assert both.total_amount_fp == Decimal("0.163") + dumped = _json_dump(both) + assert dumped["variation_margin_centicents"] == 1000 + assert dumped["variation_margin_fp"] == "0.100000" + assert dumped["total_amount_centicents"] == 1630 + assert dumped["total_amount_fp"] == "0.163000" + assert dumped["total_fees_centicents"] == 200 + + def test_settlement_estimate_by_asset_class_centicents_only_and_both(self) -> None: + old = GetSettlementEstimateByAssetClassResponse.model_validate( + {"estimates": {}, "settlement_balance_centicents": 50} + ) + assert old.settlement_balance_centicents == 50 + assert isinstance(old.settlement_balance_centicents, int) + assert old.settlement_balance_fp is None + + both = GetSettlementEstimateByAssetClassResponse.model_validate( + { + "estimates": {}, + "settlement_balance_centicents": 50, + "settlement_balance_fp": "0.005000", + } + ) + assert both.settlement_balance_centicents == 50 + assert both.settlement_balance_fp == Decimal("0.005") + dumped = _json_dump(both) + assert dumped["settlement_balance_centicents"] == 50 + assert dumped["settlement_balance_fp"] == "0.005000" + + def test_settlement_balance_centicents_only_and_both(self) -> None: + old = GetSettlementBalanceResponse.model_validate( + { + "user_id": "u1", + "balance_available_centicents": 1_000_000, + "locked_balance_centicents": 250_000, + } + ) + assert old.balance_available_centicents == 1_000_000 + assert isinstance(old.balance_available_centicents, int) + assert old.balance_available_fp is None + assert old.locked_balance_centicents == 250_000 + assert old.locked_balance_fp is None + + both = GetSettlementBalanceResponse.model_validate( + { + "user_id": "u1", + "balance_available_centicents": 1_000_000, + "balance_available_fp": "100.000000", + "locked_balance_centicents": 250_000, + "locked_balance_fp": "25.000000", + } + ) + assert both.balance_available_centicents == 1_000_000 + assert both.balance_available_fp == Decimal("100") + assert both.locked_balance_centicents == 250_000 + assert both.locked_balance_fp == Decimal("25") + dumped = _json_dump(both) + assert dumped["balance_available_centicents"] == 1_000_000 + assert dumped["balance_available_fp"] == "100.000000" + assert dumped["locked_balance_centicents"] == 250_000 + assert dumped["locked_balance_fp"] == "25.000000" + + def test_settlement_balance_history_centicents_only_and_both(self) -> None: + old = SettlementBalanceHistoryEntry.model_validate(_balance_history_entry()) + assert old.balance_delta_centicents == -500 + assert isinstance(old.balance_delta_centicents, int) + assert old.balance_delta_fp is None + assert old.locked_balance_delta_centicents == 500 + assert old.locked_balance_delta_fp is None + + both = SettlementBalanceHistoryEntry.model_validate( + _balance_history_entry() + | {"balance_delta_fp": "-0.050000", "locked_balance_delta_fp": "0.050000"} + ) + assert both.balance_delta_centicents == -500 + assert both.balance_delta_fp == Decimal("-0.05") + assert both.locked_balance_delta_centicents == 500 + assert both.locked_balance_delta_fp == Decimal("0.05") + dumped = _json_dump(both) + assert dumped["balance_delta_centicents"] == -500 + assert dumped["balance_delta_fp"] == "-0.050000" + assert dumped["locked_balance_delta_centicents"] == 500 + assert dumped["locked_balance_delta_fp"] == "0.050000" + + def test_market_funding_estimate_centicents_only_and_both(self) -> None: + old = MarketFundingEstimate.model_validate( + {"quantity_centicount": 1, "funding_amount_centicents": -50} + ) + assert old.funding_amount_centicents == -50 + assert isinstance(old.funding_amount_centicents, int) + assert old.funding_amount_fp is None + + both = MarketFundingEstimate.model_validate( + { + "quantity_centicount": 1, + "funding_amount_centicents": -50, + "funding_amount_fp": "-0.005000", + } + ) + assert both.funding_amount_centicents == -50 + assert both.funding_amount_fp == Decimal("-0.005") + dumped = _json_dump(both) + assert dumped["funding_amount_centicents"] == -50 + assert dumped["funding_amount_fp"] == "-0.005000" + + def test_funding_estimate_centicents_only_and_both(self) -> None: + old = FundingEstimate.model_validate({"funding_amount_centicents": 100}) + assert old.funding_amount_centicents == 100 + assert isinstance(old.funding_amount_centicents, int) + assert old.funding_amount_fp is None + + both = FundingEstimate.model_validate( + {"funding_amount_centicents": 100, "funding_amount_fp": "0.010000"} + ) + assert both.funding_amount_centicents == 100 + assert both.funding_amount_fp == Decimal("0.01") + dumped = _json_dump(both) + assert dumped["funding_amount_centicents"] == 100 + assert dumped["funding_amount_fp"] == "0.010000" + + def test_member_funding_payment_inherits_separate_fp_fields(self) -> None: + centicents = { + "id": "p1", + "market_ticker": "BTC-PERP", + "subtrader_id": "user_desk1", + "funding_time": "2026-09-01T16:00:00Z", + "position_quantity_fp": "1.25", + "notional_value_centicents": 10000, + "funding_amount_centicents": -50, + "settlement_execution_time": "2026-09-01T16:05:00Z", + } + old = MemberFundingPayment.model_validate(centicents) + assert old.funding_amount_centicents == -50 + assert isinstance(old.funding_amount_centicents, int) + assert old.funding_amount_fp is None + assert old.notional_value_fp is None + + both = MemberFundingPayment.model_validate( + centicents | {"notional_value_fp": "1.000000", "funding_amount_fp": "-0.005000"} + ) + assert both.notional_value_centicents == 10000 + assert both.notional_value_fp == Decimal("1") + assert both.funding_amount_centicents == -50 + assert both.funding_amount_fp == Decimal("-0.005") + dumped = _json_dump(both) + assert dumped["notional_value_centicents"] == 10000 + assert dumped["notional_value_fp"] == "1.000000" + assert dumped["funding_amount_centicents"] == -50 + assert dumped["funding_amount_fp"] == "-0.005000"