From 57a3eb0cea8d953b5649f7ae540aa8cab4382ece Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sat, 31 Oct 2026 09:21:42 -0500 Subject: [PATCH 1/5] wip: sync specs for #563 --- specs/perps_scm_openapi.yaml | 63 +++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 15 deletions(-) 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. } From a0e2540442c652a90d2f4ad86f4e1089ee498253 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sat, 31 Oct 2026 09:33:56 -0500 Subject: [PATCH 2/5] wip: #563 models and resource changes --- kalshi/models/communications.py | 2 + kalshi/models/portfolio.py | 1 + kalshi/perps/__init__.py | 2 + kalshi/perps/klear/models/margin.py | 145 +++++++++++++++++-------- kalshi/perps/klear/resources/margin.py | 22 ++-- kalshi/perps/models/__init__.py | 2 + kalshi/perps/models/exchange.py | 23 +++- kalshi/perps/models/markets.py | 2 + kalshi/ws/models/communications.py | 4 + kalshi/ws/models/orderbook_delta.py | 2 + kalshi/ws/models/user_orders.py | 1 + tests/_contract_support.py | 18 ++- tests/perps/klear/test_margin.py | 29 +++-- 13 files changed, 178 insertions(+), 75 deletions(-) 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..ee127e2 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, @@ -279,6 +280,7 @@ "ExchangeIndex", "ExchangeInstance", "ExchangeInstanceLiteral", + "ExchangeIndexStatus", "ExchangeStatus", "ExitTrigger", "ExitTriggerKindLiteral", diff --git a/kalshi/perps/klear/models/margin.py b/kalshi/perps/klear/models/margin.py index 0b1fdfc..f24a0ef 100644 --- a/kalshi/perps/klear/models/margin.py +++ b/kalshi/perps/klear/models/margin.py @@ -7,21 +7,27 @@ 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 +(fixed-point dollar string, 6 decimal places), the SDK **keeps the Python +``*_centicents`` attribute name** (no public rename) and types it as +:data:`DollarDecimal`. Validation accepts both wire names via +``AliasChoices(fp_name, old_name)``; serialization emits the ``*_fp`` name. +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 @@ -37,9 +43,9 @@ import datetime from decimal import Decimal -from typing import Annotated, Literal +from typing import Annotated, Any, Literal -from pydantic import AfterValidator, AwareDatetime, BaseModel, Field, SecretStr +from pydantic import AfterValidator, AliasChoices, AwareDatetime, BaseModel, Field, SecretStr from kalshi.types import DollarDecimal, FixedPointCount, NullableList @@ -65,6 +71,15 @@ 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.""" + +def _fp_money(fp_name: str, old_name: str, **kwargs: Any) -> Any: + """Accept ``fp_name`` or ``old_name`` on the wire; serialize as ``fp_name``.""" + return Field( + validation_alias=AliasChoices(fp_name, old_name), + serialization_alias=fp_name, + **kwargs, + ) + # Spec ``MarginReport.report_type`` enum — surfaced as a Literal alias (mirrors # the ``PaymentStatusLiteral`` style in kalshi/models/portfolio.py). MarginReportTypeLiteral = Literal[ @@ -128,11 +143,11 @@ class ObligationReceiveInfo(BaseModel): id: str type: str - amount_centicents: int + amount_centicents: DollarDecimal = _fp_money("amount_fp", "amount_centicents") external_reference: str created_ts: AwareDatetime - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class SettlementDetail(BaseModel): @@ -142,11 +157,13 @@ class SettlementDetail(BaseModel): market_ticker: str subtrader_id: str position_quantity_fp: FixedPointCount - pnl_centicents: int + pnl_centicents: DollarDecimal = _fp_money("pnl_fp", "pnl_centicents") total_fees_centicents: int - total_amount_centicents: int + total_amount_centicents: DollarDecimal = _fp_money( + "total_amount_fp", "total_amount_centicents" + ) - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class MaintenanceMarginDetail(BaseModel): @@ -169,7 +186,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 @@ -177,10 +195,14 @@ class FundingPaymentDetail(BaseModel): subtrader_id: str funding_time: AwareDatetime position_quantity_fp: FixedPointCount - notional_value_centicents: int - funding_amount_centicents: int + notional_value_centicents: DollarDecimal = _fp_money( + "notional_value_fp", "notional_value_centicents" + ) + funding_amount_centicents: DollarDecimal = _fp_money( + "funding_amount_fp", "funding_amount_centicents" + ) - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class CorporateActionPaymentDetail(BaseModel): @@ -190,8 +212,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. """ @@ -205,7 +228,9 @@ class CorporateActionPaymentDetail(BaseModel): subtrader_id: str position_quantity_fp: FixedPointCount post_position_quantity_fp: FixedPointCount - payment_amount_centicents: int + payment_amount_centicents: DollarDecimal = _fp_money( + "payment_amount_fp", "payment_amount_centicents" + ) # Required key; null for splits. payment_time: AwareDatetime | None settlement_execution_time: AwareDatetime @@ -214,7 +239,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 +250,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 @@ -235,10 +261,10 @@ class ObligationEntry(BaseModel): # From ObligationInfo. id: str user_id: str - amount_centicents: int + amount_centicents: DollarDecimal = _fp_money("amount_fp", "amount_centicents") fees_centicents: int maintenance_margin_centicents: int - pnl_centicents: int + pnl_centicents: DollarDecimal = _fp_money("pnl_fp", "pnl_centicents") execution_time: AwareDatetime last_updated_ts: AwareDatetime # Spec (perps SCM) added a required ``asset_class`` on ``ObligationInfo``. @@ -254,7 +280,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): @@ -330,26 +356,34 @@ class MarketSettlementEstimate(BaseModel): """Spec ``MarketSettlementEstimate`` — per-market settlement breakdown (centi units).""" quantity_centicount: int - variation_margin_centicents: int - notional_value_centicents: int + variation_margin_centicents: DollarDecimal = _fp_money( + "variation_margin_fp", "variation_margin_centicents" + ) + notional_value_centicents: DollarDecimal = _fp_money( + "notional_value_fp", "notional_value_centicents" + ) # 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_centicents: DollarDecimal = _fp_money( + "variation_margin_fp", "variation_margin_centicents" + ) total_fees_centicents: int maintenance_margin_delta_centicents: int maintenance_margin_required_centicents: int - total_amount_centicents: int + total_amount_centicents: DollarDecimal = _fp_money( + "total_amount_fp", "total_amount_centicents" + ) # 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) ────────────────────────────────── @@ -400,9 +434,11 @@ class GetSettlementEstimateByAssetClassResponse(BaseModel): """ estimates: dict[str, AssetClassSettlementEstimate] - settlement_balance_centicents: int + settlement_balance_centicents: DollarDecimal = _fp_money( + "settlement_balance_fp", "settlement_balance_centicents" + ) - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class GetSettlementBalanceResponse(BaseModel): @@ -412,10 +448,14 @@ class GetSettlementBalanceResponse(BaseModel): """ user_id: str - balance_available_centicents: int - locked_balance_centicents: int | None = None + balance_available_centicents: DollarDecimal = _fp_money( + "balance_available_fp", "balance_available_centicents" + ) + locked_balance_centicents: DollarDecimal | None = _fp_money( + "locked_balance_fp", "locked_balance_centicents", default=None + ) - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class GetGuarantyFundBalanceResponse(BaseModel): @@ -438,12 +478,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"} @@ -473,13 +516,17 @@ class GetSettlementBalanceWithdrawalResponse(BaseModel): class SettlementBalanceHistoryEntry(BaseModel): """Spec ``SettlementBalanceHistoryEntry`` — one settlement-balance change.""" - balance_delta_centicents: int - locked_balance_delta_centicents: int + balance_delta_centicents: DollarDecimal = _fp_money( + "balance_delta_fp", "balance_delta_centicents" + ) + locked_balance_delta_centicents: DollarDecimal = _fp_money( + "locked_balance_delta_fp", "locked_balance_delta_centicents" + ) reason: str business_transaction_id: str created_ts: AwareDatetime - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class GetSettlementBalanceHistoryResponse(BaseModel): @@ -719,18 +766,22 @@ class MarketFundingEstimate(BaseModel): """Per-market funding estimate on a subtrader or group breakdown.""" quantity_centicount: int - funding_amount_centicents: int + funding_amount_centicents: DollarDecimal = _fp_money( + "funding_amount_fp", "funding_amount_centicents" + ) - 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_centicents: DollarDecimal = _fp_money( + "funding_amount_fp", "funding_amount_centicents" + ) 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..613e53c 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, @@ -141,6 +142,7 @@ "ExchangeIndex", "ExchangeInstance", "ExchangeInstanceLiteral", + "ExchangeIndexStatus", "ExchangeStatus", "ExitTrigger", "ExitTriggerKindLiteral", 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/tests/_contract_support.py b/tests/_contract_support.py index d313612..b3eb1a2 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( diff --git a/tests/perps/klear/test_margin.py b/tests/perps/klear/test_margin.py index 3516597..664807c 100644 --- a/tests/perps/klear/test_margin.py +++ b/tests/perps/klear/test_margin.py @@ -7,10 +7,11 @@ 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: ``_centicents`` fields without a ``*_fp`` +counterpart stay plain ``int``; fields that alias a ``*_fp`` wire name are +``DollarDecimal``. 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 @@ -281,9 +282,8 @@ def test_happy_list(self, auth_klear_client: KlearClient) -> None: assert len(resp.obligations) == 2 assert all(isinstance(o, ObligationEntry) for o in resp.obligations) assert resp.obligations[0].amount_centicents == -99999 - assert isinstance(resp.obligations[0].amount_centicents, int) + assert isinstance(resp.obligations[0].amount_centicents, Decimal) assert not isinstance(resp.obligations[0].amount_centicents, bool) - assert not isinstance(resp.obligations[0].amount_centicents, Decimal) assert resp.obligations[0].asset_class == "Crypto" assert resp.obligations[0].settlement_details[0].position_quantity_fp == Decimal("1.25") assert resp.obligations[0].funding_payments[0].funding_amount_centicents == -50 @@ -603,7 +603,7 @@ def test_page_parses_payment(self, auth_klear_client: KlearClient) -> None: assert row.position_quantity_fp == Decimal("1.25") assert row.post_position_quantity_fp == Decimal("2.50") assert row.payment_amount_centicents == 400 - assert isinstance(row.payment_amount_centicents, int) + assert isinstance(row.payment_amount_centicents, Decimal) assert not isinstance(row.payment_amount_centicents, bool) assert row.pre_split_price_centicents == 68000 assert row.post_split_price_centicents == 34000 @@ -716,8 +716,7 @@ 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 isinstance(resp.balance_available_centicents, Decimal) assert resp.locked_balance_centicents == 250_000 auth_klear_client.close() @@ -827,7 +826,7 @@ def test_happy_page(self, auth_klear_client: KlearClient) -> None: assert len(page.items) == 1 assert isinstance(page.items[0], SettlementBalanceHistoryEntry) assert page.items[0].balance_delta_centicents == -500 - assert isinstance(page.items[0].balance_delta_centicents, int) + assert isinstance(page.items[0].balance_delta_centicents, Decimal) assert page.cursor == "C1" auth_klear_client.close() @@ -878,7 +877,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") @@ -897,7 +896,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 +908,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 +917,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 +933,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") From 8b93e286632f49fe11d47ed55ba07f8d2748b980 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sat, 31 Oct 2026 09:44:09 -0500 Subject: [PATCH 3/5] wip: #563 tests, docs, version --- CHANGELOG.md | 37 +++++ docs/perps.md | 9 +- kalshi/__init__.py | 2 +- kalshi/perps/__init__.py | 2 +- kalshi/perps/models/__init__.py | 2 +- pyproject.toml | 2 +- tests/perps/klear/test_margin.py | 263 +++++++++++++++++++++++++++++++ 7 files changed, 311 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9b8c7f..4f00693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,43 @@ 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** accept the new `*_fp` wire names + (fixed-point dollar strings) while keeping the existing Python + `*_centicents` attribute names. Serialization emits `*_fp`. Affected + models: `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 `GetSettlementBalanceResponse.locked_balance_fp` (aliased onto + `locked_balance_centicents`). +- 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..e2a8686 100644 --- a/docs/perps.md +++ b/docs/perps.md @@ -409,11 +409,16 @@ 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`) unless the spec also publishes a `*_fp` counterpart +(fixed-point dollar string). Those dual fields keep their Python +`*_centicents` names, parse as `DollarDecimal`, accept either wire name, and +serialize as `*_fp`. The withdrawal `amount` and `MarketSettlementEstimate.session_avg_price_fp` are fixed-point dollar strings. -`klear.margin.withdraw_settlement_balance(amount="500.00")` validates the amount as +`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/perps/__init__.py b/kalshi/perps/__init__.py index ee127e2..cc9b2f6 100644 --- a/kalshi/perps/__init__.py +++ b/kalshi/perps/__init__.py @@ -278,9 +278,9 @@ "Error", "ErrorResponse", "ExchangeIndex", + "ExchangeIndexStatus", "ExchangeInstance", "ExchangeInstanceLiteral", - "ExchangeIndexStatus", "ExchangeStatus", "ExitTrigger", "ExitTriggerKindLiteral", diff --git a/kalshi/perps/models/__init__.py b/kalshi/perps/models/__init__.py index 613e53c..420a462 100644 --- a/kalshi/perps/models/__init__.py +++ b/kalshi/perps/models/__init__.py @@ -140,9 +140,9 @@ "EmptyResponse", "ErrorResponse", "ExchangeIndex", + "ExchangeIndexStatus", "ExchangeInstance", "ExchangeInstanceLiteral", - "ExchangeIndexStatus", "ExchangeStatus", "ExitTrigger", "ExitTriggerKindLiteral", 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/tests/perps/klear/test_margin.py b/tests/perps/klear/test_margin.py index 664807c..8388f9d 100644 --- a/tests/perps/klear/test_margin.py +++ b/tests/perps/klear/test_margin.py @@ -32,10 +32,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, ) @@ -888,6 +899,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] @@ -1452,3 +1476,242 @@ 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() + + +# --------------------------------------------------------------------------- # +# *_fp wire aliases (keep Python *_centicents names) +# --------------------------------------------------------------------------- # + + +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 + + +class TestCenticentsFpAliases: + """Old ``*_centicents`` keys and new ``*_fp`` keys both populate the Python name. + + Serialization emits the ``*_fp`` wire name. + """ + + def test_obligation_receive_info_old_and_fp_names(self) -> None: + old = ObligationReceiveInfo.model_validate( + _receive_info_payload(amount_centicents="500.000000") + ) + new = ObligationReceiveInfo.model_validate(_receive_info_payload(amount_fp="500.000000")) + assert old.amount_centicents == new.amount_centicents == Decimal("500.000000") + dumped = new.model_dump(by_alias=True, mode="json") + assert dumped["amount_fp"] == "500.000000" + assert "amount_centicents" not in dumped + + def test_obligation_receive_info_populate_by_name(self) -> None: + info = ObligationReceiveInfo.model_validate( + _receive_info_payload(amount_centicents="12.340000") + ) + by_name = ObligationReceiveInfo( + id=info.id, + type=info.type, + amount_centicents=info.amount_centicents, + external_reference=info.external_reference, + created_ts=info.created_ts, + ) + assert by_name.amount_centicents == Decimal("12.340000") + + def test_fp_name_wins_when_both_keys_present(self) -> None: + parsed = ObligationReceiveInfo.model_validate( + _receive_info_payload(amount_centicents=5_000_000, amount_fp="500.000000") + ) + assert parsed.amount_centicents == Decimal("500.000000") + + def test_settlement_detail_old_and_fp_names(self) -> None: + old = SettlementDetail.model_validate(_settlement_detail()) + new = SettlementDetail.model_validate( + { + "id": "sd1", + "market_ticker": "BTC-PERP", + "subtrader_id": "st1", + "position_quantity_fp": "1.25", + "pnl_fp": "-0.020000", + "total_fees_centicents": 100, + "total_amount_fp": "-0.030000", + } + ) + assert old.pnl_centicents == Decimal("-200") + assert new.pnl_centicents == Decimal("-0.020000") + dumped = new.model_dump(by_alias=True, mode="json") + assert dumped["pnl_fp"] == "-0.020000" + assert dumped["total_amount_fp"] == "-0.030000" + assert "pnl_centicents" not in dumped + assert "total_amount_centicents" not in dumped + + def test_funding_payment_detail_old_and_fp_names(self) -> None: + old = FundingPaymentDetail.model_validate(_funding_payment()) + fp_payload = { + "id": "fp1", + "market_ticker": "BTC-PERP", + "subtrader_id": "st1", + "funding_time": "2026-06-01T00:00:00Z", + "position_quantity_fp": "2.00", + "notional_value_fp": "10.000000", + "funding_amount_fp": "-0.005000", + } + new = FundingPaymentDetail.model_validate(fp_payload) + assert old.notional_value_centicents == Decimal("100000") + assert new.funding_amount_centicents == Decimal("-0.005000") + dumped = new.model_dump(by_alias=True, mode="json") + assert dumped["notional_value_fp"] == "10.000000" + assert dumped["funding_amount_fp"] == "-0.005000" + + def test_corporate_action_payment_old_and_fp_names(self) -> None: + old = CorporateActionPaymentDetail.model_validate(_corporate_action_payment()) + fp_payload = {**_corporate_action_payment(), "payment_amount_fp": "0.040000"} + del fp_payload["payment_amount_centicents"] + new = CorporateActionPaymentDetail.model_validate(fp_payload) + assert old.payment_amount_centicents == Decimal("400") + assert new.payment_amount_centicents == Decimal("0.040000") + dumped = new.model_dump(by_alias=True, mode="json") + assert dumped["payment_amount_fp"] == "0.040000" + assert "payment_amount_centicents" not in dumped + + def test_obligation_entry_old_and_fp_names(self) -> None: + old = ObligationEntry.model_validate(_obligation()) + fp_payload = {**_obligation(), "amount_fp": "-1.234500", "pnl_fp": "-0.020000"} + del fp_payload["amount_centicents"] + del fp_payload["pnl_centicents"] + new = ObligationEntry.model_validate(fp_payload) + assert old.amount_centicents == Decimal("-12345") + assert new.amount_centicents == Decimal("-1.234500") + dumped = new.model_dump(by_alias=True, mode="json") + assert dumped["amount_fp"] == "-1.234500" + assert dumped["pnl_fp"] == "-0.020000" + + def test_market_settlement_estimate_old_and_fp_names(self) -> None: + old = MarketSettlementEstimate.model_validate( + { + "quantity_centicount": 1, + "variation_margin_centicents": 1000, + "notional_value_centicents": 2000, + } + ) + new = MarketSettlementEstimate.model_validate( + { + "quantity_centicount": 1, + "variation_margin_fp": "0.100000", + "notional_value_fp": "0.200000", + } + ) + assert old.variation_margin_centicents == Decimal("1000") + assert new.notional_value_centicents == Decimal("0.200000") + dumped = new.model_dump(by_alias=True, mode="json") + assert dumped["variation_margin_fp"] == "0.100000" + assert dumped["notional_value_fp"] == "0.200000" + + def test_settlement_estimate_old_and_fp_names(self) -> None: + old = SettlementEstimate.model_validate(_estimate()) + new = SettlementEstimate.model_validate( + { + "variation_margin_fp": "0.100000", + "total_fees_centicents": 200, + "maintenance_margin_delta_centicents": 30, + "maintenance_margin_required_centicents": 400, + "total_amount_fp": "0.163000", + } + ) + assert old.total_amount_centicents == Decimal("1630") + assert new.variation_margin_centicents == Decimal("0.100000") + dumped = new.model_dump(by_alias=True, mode="json") + assert dumped["total_amount_fp"] == "0.163000" + assert dumped["variation_margin_fp"] == "0.100000" + + def test_settlement_estimate_by_asset_class_old_and_fp_names(self) -> None: + old = GetSettlementEstimateByAssetClassResponse.model_validate( + {"estimates": {}, "settlement_balance_centicents": 50} + ) + new = GetSettlementEstimateByAssetClassResponse.model_validate( + {"estimates": {}, "settlement_balance_fp": "0.005000"} + ) + assert old.settlement_balance_centicents == Decimal("50") + assert new.settlement_balance_centicents == Decimal("0.005000") + dumped = new.model_dump(by_alias=True, mode="json") + assert dumped["settlement_balance_fp"] == "0.005000" + + def test_settlement_balance_old_and_fp_names(self) -> None: + old = GetSettlementBalanceResponse.model_validate( + { + "user_id": "u1", + "balance_available_centicents": 1_000_000, + "locked_balance_centicents": 250_000, + } + ) + new = GetSettlementBalanceResponse.model_validate( + { + "user_id": "u1", + "balance_available_fp": "100.000000", + "locked_balance_fp": "25.000000", + } + ) + assert old.balance_available_centicents == Decimal("1000000") + assert new.locked_balance_centicents == Decimal("25.000000") + dumped = new.model_dump(by_alias=True, mode="json") + assert dumped["balance_available_fp"] == "100.000000" + assert dumped["locked_balance_fp"] == "25.000000" + + def test_settlement_balance_history_old_and_fp_names(self) -> None: + old = SettlementBalanceHistoryEntry.model_validate(_balance_history_entry()) + new = SettlementBalanceHistoryEntry.model_validate( + { + "balance_delta_fp": "-0.050000", + "locked_balance_delta_fp": "0.050000", + "reason": "withdrawal_initiated", + "business_transaction_id": "btx-1", + "created_ts": "2026-06-01T00:00:00Z", + } + ) + assert old.balance_delta_centicents == Decimal("-500") + assert new.locked_balance_delta_centicents == Decimal("0.050000") + dumped = new.model_dump(by_alias=True, mode="json") + assert dumped["balance_delta_fp"] == "-0.050000" + assert dumped["locked_balance_delta_fp"] == "0.050000" + + def test_market_funding_estimate_old_and_fp_names(self) -> None: + old = MarketFundingEstimate.model_validate( + {"quantity_centicount": 1, "funding_amount_centicents": -50} + ) + new = MarketFundingEstimate.model_validate( + {"quantity_centicount": 1, "funding_amount_fp": "-0.005000"} + ) + assert old.funding_amount_centicents == Decimal("-50") + assert new.funding_amount_centicents == Decimal("-0.005000") + dumped = new.model_dump(by_alias=True, mode="json") + assert dumped["funding_amount_fp"] == "-0.005000" + + def test_funding_estimate_old_and_fp_names(self) -> None: + old = FundingEstimate.model_validate({"funding_amount_centicents": 100}) + new = FundingEstimate.model_validate({"funding_amount_fp": "0.010000"}) + assert old.funding_amount_centicents == Decimal("100") + assert new.funding_amount_centicents == Decimal("0.010000") + dumped = new.model_dump(by_alias=True, mode="json") + assert dumped["funding_amount_fp"] == "0.010000" + + def test_member_funding_payment_inherits_fp_aliases(self) -> None: + payload = { + "id": "p1", + "market_ticker": "BTC-PERP", + "subtrader_id": "user_desk1", + "funding_time": "2026-09-01T16:00:00Z", + "position_quantity_fp": "1.25", + "notional_value_fp": "1.000000", + "funding_amount_fp": "-0.005000", + "settlement_execution_time": "2026-09-01T16:05:00Z", + } + parsed = MemberFundingPayment.model_validate(payload) + assert parsed.funding_amount_centicents == Decimal("-0.005000") + dumped = parsed.model_dump(by_alias=True, mode="json") + assert dumped["funding_amount_fp"] == "-0.005000" + assert dumped["notional_value_fp"] == "1.000000" From 670d0637090c2314ebae57bbbfe78b0772d38b9d Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sat, 31 Oct 2026 10:08:16 -0500 Subject: [PATCH 4/5] fix(#563): keep *_centicents as int, add *_fp as separate optional DollarDecimal fields --- kalshi/perps/klear/models/margin.py | 109 ++++++++++++---------------- 1 file changed, 46 insertions(+), 63 deletions(-) diff --git a/kalshi/perps/klear/models/margin.py b/kalshi/perps/klear/models/margin.py index f24a0ef..9143243 100644 --- a/kalshi/perps/klear/models/margin.py +++ b/kalshi/perps/klear/models/margin.py @@ -14,10 +14,9 @@ :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 -(fixed-point dollar string, 6 decimal places), the SDK **keeps the Python -``*_centicents`` attribute name** (no public rename) and types it as -:data:`DollarDecimal`. Validation accepts both wire names via -``AliasChoices(fp_name, old_name)``; serialization emits the ``*_fp`` name. +(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 @@ -43,9 +42,9 @@ import datetime from decimal import Decimal -from typing import Annotated, Any, Literal +from typing import Annotated, Literal -from pydantic import AfterValidator, AliasChoices, AwareDatetime, BaseModel, Field, SecretStr +from pydantic import AfterValidator, AwareDatetime, BaseModel, Field, SecretStr from kalshi.types import DollarDecimal, FixedPointCount, NullableList @@ -72,14 +71,6 @@ def _require_positive_withdrawal(value: Decimal) -> Decimal: """A request-side withdrawal amount: a positive fixed-point dollar value.""" -def _fp_money(fp_name: str, old_name: str, **kwargs: Any) -> Any: - """Accept ``fp_name`` or ``old_name`` on the wire; serialize as ``fp_name``.""" - return Field( - validation_alias=AliasChoices(fp_name, old_name), - serialization_alias=fp_name, - **kwargs, - ) - # Spec ``MarginReport.report_type`` enum — surfaced as a Literal alias (mirrors # the ``PaymentStatusLiteral`` style in kalshi/models/portfolio.py). MarginReportTypeLiteral = Literal[ @@ -143,7 +134,8 @@ class ObligationReceiveInfo(BaseModel): id: str type: str - amount_centicents: DollarDecimal = _fp_money("amount_fp", "amount_centicents") + amount_centicents: int + amount_fp: DollarDecimal | None = None external_reference: str created_ts: AwareDatetime @@ -157,11 +149,11 @@ class SettlementDetail(BaseModel): market_ticker: str subtrader_id: str position_quantity_fp: FixedPointCount - pnl_centicents: DollarDecimal = _fp_money("pnl_fp", "pnl_centicents") + pnl_centicents: int + pnl_fp: DollarDecimal | None = None total_fees_centicents: int - total_amount_centicents: DollarDecimal = _fp_money( - "total_amount_fp", "total_amount_centicents" - ) + total_amount_centicents: int + total_amount_fp: DollarDecimal | None = None model_config = {"extra": "allow", "populate_by_name": True} @@ -195,12 +187,10 @@ class FundingPaymentDetail(BaseModel): subtrader_id: str funding_time: AwareDatetime position_quantity_fp: FixedPointCount - notional_value_centicents: DollarDecimal = _fp_money( - "notional_value_fp", "notional_value_centicents" - ) - funding_amount_centicents: DollarDecimal = _fp_money( - "funding_amount_fp", "funding_amount_centicents" - ) + notional_value_centicents: int + notional_value_fp: DollarDecimal | None = None + funding_amount_centicents: int + funding_amount_fp: DollarDecimal | None = None model_config = {"extra": "allow", "populate_by_name": True} @@ -228,9 +218,8 @@ class CorporateActionPaymentDetail(BaseModel): subtrader_id: str position_quantity_fp: FixedPointCount post_position_quantity_fp: FixedPointCount - payment_amount_centicents: DollarDecimal = _fp_money( - "payment_amount_fp", "payment_amount_centicents" - ) + payment_amount_centicents: int + payment_amount_fp: DollarDecimal | None = None # Required key; null for splits. payment_time: AwareDatetime | None settlement_execution_time: AwareDatetime @@ -261,10 +250,12 @@ class ObligationEntry(BaseModel): # From ObligationInfo. id: str user_id: str - amount_centicents: DollarDecimal = _fp_money("amount_fp", "amount_centicents") + amount_centicents: int + amount_fp: DollarDecimal | None = None fees_centicents: int maintenance_margin_centicents: int - pnl_centicents: DollarDecimal = _fp_money("pnl_fp", "pnl_centicents") + 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``. @@ -356,12 +347,10 @@ class MarketSettlementEstimate(BaseModel): """Spec ``MarketSettlementEstimate`` — per-market settlement breakdown (centi units).""" quantity_centicount: int - variation_margin_centicents: DollarDecimal = _fp_money( - "variation_margin_fp", "variation_margin_centicents" - ) - notional_value_centicents: DollarDecimal = _fp_money( - "notional_value_fp", "notional_value_centicents" - ) + 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 @@ -371,15 +360,14 @@ class MarketSettlementEstimate(BaseModel): class SettlementEstimate(BaseModel): """Spec ``SettlementEstimate`` — estimated next-settlement amounts (centicents).""" - variation_margin_centicents: DollarDecimal = _fp_money( - "variation_margin_fp", "variation_margin_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: DollarDecimal = _fp_money( - "total_amount_fp", "total_amount_centicents" - ) + 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 @@ -434,9 +422,8 @@ class GetSettlementEstimateByAssetClassResponse(BaseModel): """ estimates: dict[str, AssetClassSettlementEstimate] - settlement_balance_centicents: DollarDecimal = _fp_money( - "settlement_balance_fp", "settlement_balance_centicents" - ) + settlement_balance_centicents: int + settlement_balance_fp: DollarDecimal | None = None model_config = {"extra": "allow", "populate_by_name": True} @@ -448,12 +435,10 @@ class GetSettlementBalanceResponse(BaseModel): """ user_id: str - balance_available_centicents: DollarDecimal = _fp_money( - "balance_available_fp", "balance_available_centicents" - ) - locked_balance_centicents: DollarDecimal | None = _fp_money( - "locked_balance_fp", "locked_balance_centicents", default=None - ) + 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", "populate_by_name": True} @@ -516,12 +501,11 @@ class GetSettlementBalanceWithdrawalResponse(BaseModel): class SettlementBalanceHistoryEntry(BaseModel): """Spec ``SettlementBalanceHistoryEntry`` — one settlement-balance change.""" - balance_delta_centicents: DollarDecimal = _fp_money( - "balance_delta_fp", "balance_delta_centicents" - ) - locked_balance_delta_centicents: DollarDecimal = _fp_money( - "locked_balance_delta_fp", "locked_balance_delta_centicents" - ) + 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 @@ -766,9 +750,8 @@ class MarketFundingEstimate(BaseModel): """Per-market funding estimate on a subtrader or group breakdown.""" quantity_centicount: int - funding_amount_centicents: DollarDecimal = _fp_money( - "funding_amount_fp", "funding_amount_centicents" - ) + funding_amount_centicents: int + funding_amount_fp: DollarDecimal | None = None model_config = {"extra": "allow", "populate_by_name": True} @@ -776,9 +759,9 @@ class MarketFundingEstimate(BaseModel): class FundingEstimate(BaseModel): """Funding amount plus optional per-market position breakdown.""" - funding_amount_centicents: DollarDecimal = _fp_money( - "funding_amount_fp", "funding_amount_centicents" - ) + funding_amount_centicents: int + + funding_amount_fp: DollarDecimal | None = None positions: dict[str, MarketFundingEstimate] | None = None model_config = {"extra": "allow", "populate_by_name": True} From e88983c5c6c3b2fd8121c6f5a8081b57f71fd4a9 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sat, 31 Oct 2026 10:26:28 -0500 Subject: [PATCH 5/5] test/docs(#563): additive *_fp fields --- CHANGELOG.md | 19 +- docs/perps.md | 17 +- tests/_contract_support.py | 47 ++++ tests/perps/klear/test_margin.py | 437 ++++++++++++++++++++----------- 4 files changed, 354 insertions(+), 166 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f00693..213b058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,18 @@ Python attribute renames. ### Changed -- **Klear settlement money fields** accept the new `*_fp` wire names - (fixed-point dollar strings) while keeping the existing Python - `*_centicents` attribute names. Serialization emits `*_fp`. Affected - models: `CorporateActionPaymentDetail`, `FundingEstimate`, - `FundingPaymentDetail`, `GetSettlementBalanceResponse`, +- **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`, @@ -31,8 +38,6 @@ Python attribute renames. - Optional `OrderbookSnapshotPayload.clock` / `OrderbookDeltaPayload.clock`. - Optional `UserOrdersPayload.last_update_reason`. -- Optional `GetSettlementBalanceResponse.locked_balance_fp` (aliased onto - `locked_balance_centicents`). - Optional perps `ExchangeStatus.exchange_estimated_resume_time` / `exchange_index_statuses` / `intra_exchange_transfers_active`, plus `ExchangeIndexStatus`. diff --git a/docs/perps.md b/docs/perps.md index e2a8686..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,12 +411,15 @@ klear.margin.delete_subtrader_group(created.group_id) ``` Money fields on the Klear margin schemas are integer **centicents** (`1 USD = -10,000 centicents`) unless the spec also publishes a `*_fp` counterpart -(fixed-point dollar string). Those dual fields keep their Python -`*_centicents` names, parse as `DollarDecimal`, accept either wire name, and -serialize as `*_fp`. 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. +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. diff --git a/tests/_contract_support.py b/tests/_contract_support.py index b3eb1a2..e167607 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -2290,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 8388f9d..03d2f86 100644 --- a/tests/perps/klear/test_margin.py +++ b/tests/perps/klear/test_margin.py @@ -7,11 +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 without a ``*_fp`` -counterpart stay plain ``int``; fields that alias a ``*_fp`` wire name are -``DollarDecimal``. 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``. +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 @@ -293,11 +295,14 @@ def test_happy_list(self, auth_klear_client: KlearClient) -> None: assert len(resp.obligations) == 2 assert all(isinstance(o, ObligationEntry) for o in resp.obligations) assert resp.obligations[0].amount_centicents == -99999 - assert isinstance(resp.obligations[0].amount_centicents, Decimal) + assert isinstance(resp.obligations[0].amount_centicents, int) assert not isinstance(resp.obligations[0].amount_centicents, bool) + 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() @@ -356,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() @@ -411,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, @@ -614,8 +625,9 @@ def test_page_parses_payment(self, auth_klear_client: KlearClient) -> None: assert row.position_quantity_fp == Decimal("1.25") assert row.post_position_quantity_fp == Decimal("2.50") assert row.payment_amount_centicents == 400 - assert isinstance(row.payment_amount_centicents, Decimal) + 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 @@ -727,8 +739,12 @@ 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, Decimal) + assert isinstance(resp.balance_available_centicents, int) + 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 @@ -837,7 +853,10 @@ def test_happy_page(self, auth_klear_client: KlearClient) -> None: assert len(page.items) == 1 assert isinstance(page.items[0], SettlementBalanceHistoryEntry) assert page.items[0].balance_delta_centicents == -500 - assert isinstance(page.items[0].balance_delta_centicents, Decimal) + 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() @@ -1455,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 @@ -1479,7 +1500,7 @@ async def test_async_funding_schedule(self, auth_async_klear_client: AsyncKlearC # --------------------------------------------------------------------------- # -# *_fp wire aliases (keep Python *_centicents names) +# Additive optional *_fp dollar fields (no aliasing) # --------------------------------------------------------------------------- # @@ -1494,154 +1515,218 @@ def _receive_info_payload(**overrides: object) -> dict[str, object]: return base -class TestCenticentsFpAliases: - """Old ``*_centicents`` keys and new ``*_fp`` keys both populate the Python name. +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 - Serialization emits the ``*_fp`` wire name. + +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_old_and_fp_names(self) -> None: + def test_obligation_receive_info_centicents_only_and_both(self) -> None: old = ObligationReceiveInfo.model_validate( - _receive_info_payload(amount_centicents="500.000000") - ) - new = ObligationReceiveInfo.model_validate(_receive_info_payload(amount_fp="500.000000")) - assert old.amount_centicents == new.amount_centicents == Decimal("500.000000") - dumped = new.model_dump(by_alias=True, mode="json") - assert dumped["amount_fp"] == "500.000000" - assert "amount_centicents" not in dumped - - def test_obligation_receive_info_populate_by_name(self) -> None: + _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="12.340000") + _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 == Decimal("12.340000") + 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_name_wins_when_both_keys_present(self) -> None: - parsed = ObligationReceiveInfo.model_validate( - _receive_info_payload(amount_centicents=5_000_000, amount_fp="500.000000") - ) - assert parsed.amount_centicents == Decimal("500.000000") + 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_old_and_fp_names(self) -> None: + def test_settlement_detail_centicents_only_and_both(self) -> None: old = SettlementDetail.model_validate(_settlement_detail()) - new = SettlementDetail.model_validate( - { - "id": "sd1", - "market_ticker": "BTC-PERP", - "subtrader_id": "st1", - "position_quantity_fp": "1.25", - "pnl_fp": "-0.020000", - "total_fees_centicents": 100, - "total_amount_fp": "-0.030000", - } - ) - assert old.pnl_centicents == Decimal("-200") - assert new.pnl_centicents == Decimal("-0.020000") - dumped = new.model_dump(by_alias=True, mode="json") + 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 "pnl_centicents" not in dumped - assert "total_amount_centicents" not in dumped + assert dumped["total_fees_centicents"] == 100 - def test_funding_payment_detail_old_and_fp_names(self) -> None: + def test_funding_payment_detail_centicents_only_and_both(self) -> None: old = FundingPaymentDetail.model_validate(_funding_payment()) - fp_payload = { - "id": "fp1", - "market_ticker": "BTC-PERP", - "subtrader_id": "st1", - "funding_time": "2026-06-01T00:00:00Z", - "position_quantity_fp": "2.00", - "notional_value_fp": "10.000000", - "funding_amount_fp": "-0.005000", - } - new = FundingPaymentDetail.model_validate(fp_payload) - assert old.notional_value_centicents == Decimal("100000") - assert new.funding_amount_centicents == Decimal("-0.005000") - dumped = new.model_dump(by_alias=True, mode="json") + 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_old_and_fp_names(self) -> None: + def test_corporate_action_payment_centicents_only_and_both(self) -> None: old = CorporateActionPaymentDetail.model_validate(_corporate_action_payment()) - fp_payload = {**_corporate_action_payment(), "payment_amount_fp": "0.040000"} - del fp_payload["payment_amount_centicents"] - new = CorporateActionPaymentDetail.model_validate(fp_payload) - assert old.payment_amount_centicents == Decimal("400") - assert new.payment_amount_centicents == Decimal("0.040000") - dumped = new.model_dump(by_alias=True, mode="json") + 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" - assert "payment_amount_centicents" not in dumped - def test_obligation_entry_old_and_fp_names(self) -> None: + def test_obligation_entry_centicents_only_and_both(self) -> None: old = ObligationEntry.model_validate(_obligation()) - fp_payload = {**_obligation(), "amount_fp": "-1.234500", "pnl_fp": "-0.020000"} - del fp_payload["amount_centicents"] - del fp_payload["pnl_centicents"] - new = ObligationEntry.model_validate(fp_payload) - assert old.amount_centicents == Decimal("-12345") - assert new.amount_centicents == Decimal("-1.234500") - dumped = new.model_dump(by_alias=True, mode="json") - assert dumped["amount_fp"] == "-1.234500" + 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_old_and_fp_names(self) -> None: - old = MarketSettlementEstimate.model_validate( - { - "quantity_centicount": 1, - "variation_margin_centicents": 1000, - "notional_value_centicents": 2000, - } - ) - new = MarketSettlementEstimate.model_validate( - { - "quantity_centicount": 1, - "variation_margin_fp": "0.100000", - "notional_value_fp": "0.200000", - } - ) - assert old.variation_margin_centicents == Decimal("1000") - assert new.notional_value_centicents == Decimal("0.200000") - dumped = new.model_dump(by_alias=True, mode="json") + 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_old_and_fp_names(self) -> None: + def test_settlement_estimate_centicents_only_and_both(self) -> None: old = SettlementEstimate.model_validate(_estimate()) - new = SettlementEstimate.model_validate( - { - "variation_margin_fp": "0.100000", - "total_fees_centicents": 200, - "maintenance_margin_delta_centicents": 30, - "maintenance_margin_required_centicents": 400, - "total_amount_fp": "0.163000", - } - ) - assert old.total_amount_centicents == Decimal("1630") - assert new.variation_margin_centicents == Decimal("0.100000") - dumped = new.model_dump(by_alias=True, mode="json") - assert dumped["total_amount_fp"] == "0.163000" + 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_old_and_fp_names(self) -> None: + def test_settlement_estimate_by_asset_class_centicents_only_and_both(self) -> None: old = GetSettlementEstimateByAssetClassResponse.model_validate( {"estimates": {}, "settlement_balance_centicents": 50} ) - new = GetSettlementEstimateByAssetClassResponse.model_validate( - {"estimates": {}, "settlement_balance_fp": "0.005000"} + 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 old.settlement_balance_centicents == Decimal("50") - assert new.settlement_balance_centicents == Decimal("0.005000") - dumped = new.model_dump(by_alias=True, mode="json") + 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_old_and_fp_names(self) -> None: + def test_settlement_balance_centicents_only_and_both(self) -> None: old = GetSettlementBalanceResponse.model_validate( { "user_id": "u1", @@ -1649,69 +1734,115 @@ def test_settlement_balance_old_and_fp_names(self) -> None: "locked_balance_centicents": 250_000, } ) - new = GetSettlementBalanceResponse.model_validate( + 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 old.balance_available_centicents == Decimal("1000000") - assert new.locked_balance_centicents == Decimal("25.000000") - dumped = new.model_dump(by_alias=True, mode="json") + 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_old_and_fp_names(self) -> None: + def test_settlement_balance_history_centicents_only_and_both(self) -> None: old = SettlementBalanceHistoryEntry.model_validate(_balance_history_entry()) - new = SettlementBalanceHistoryEntry.model_validate( - { - "balance_delta_fp": "-0.050000", - "locked_balance_delta_fp": "0.050000", - "reason": "withdrawal_initiated", - "business_transaction_id": "btx-1", - "created_ts": "2026-06-01T00:00:00Z", - } - ) - assert old.balance_delta_centicents == Decimal("-500") - assert new.locked_balance_delta_centicents == Decimal("0.050000") - dumped = new.model_dump(by_alias=True, mode="json") + 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_old_and_fp_names(self) -> None: + def test_market_funding_estimate_centicents_only_and_both(self) -> None: old = MarketFundingEstimate.model_validate( {"quantity_centicount": 1, "funding_amount_centicents": -50} ) - new = MarketFundingEstimate.model_validate( - {"quantity_centicount": 1, "funding_amount_fp": "-0.005000"} + 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 old.funding_amount_centicents == Decimal("-50") - assert new.funding_amount_centicents == Decimal("-0.005000") - dumped = new.model_dump(by_alias=True, mode="json") + 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_old_and_fp_names(self) -> None: + def test_funding_estimate_centicents_only_and_both(self) -> None: old = FundingEstimate.model_validate({"funding_amount_centicents": 100}) - new = FundingEstimate.model_validate({"funding_amount_fp": "0.010000"}) - assert old.funding_amount_centicents == Decimal("100") - assert new.funding_amount_centicents == Decimal("0.010000") - dumped = new.model_dump(by_alias=True, mode="json") + 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_fp_aliases(self) -> None: - payload = { + 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_fp": "1.000000", - "funding_amount_fp": "-0.005000", + "notional_value_centicents": 10000, + "funding_amount_centicents": -50, "settlement_execution_time": "2026-09-01T16:05:00Z", } - parsed = MemberFundingPayment.model_validate(payload) - assert parsed.funding_amount_centicents == Decimal("-0.005000") - dumped = parsed.model_dump(by_alias=True, mode="json") - assert dumped["funding_amount_fp"] == "-0.005000" + 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"