Skip to content

perps: SCM/Klear margin endpoints — settlement balances, obligations, margin reports #400

Description

@TexasCoding

Epic: part of #387 · Depends on: #399

Summary

Implement the eight Self-Clearing-Member (SCM) margin endpoints of the Kalshi Klear API (klear-api/v1) on the KlearClient / AsyncKlearClient from the auth-foundation issue: margin reports, active/historical settlement obligations, settlement estimate, settlement-buffer balance + its history, and settlement-balance withdrawal (initiate + status). These let an SCM see what it owes/is owed each settlement cycle, inspect its settlement buffer, and wire funds out of the buffer. All eight ride on the KlearClient session-cookie auth (no RSA-PSS) established by the dependency issue.

Important

Read /tmp/perps_scm_openapi.yaml before coding — the money typing in this spec is NOT what the generic perps framing assumes. Almost every monetary field on these schemas is an integer int64 in centicents (the spec states 1 USD = 10,000 centicents), serialized as a JSON number, with an _centicents field-name suffix. These are plain int — do NOT use DollarDecimal / FixedPointCount on them. The only two fixed-point dollar-string fields in this whole issue are WithdrawSettlementBalanceRequest.amount and GetSettlementBalanceWithdrawalResponse.amount (e.g. "500.00"); those use DollarDecimal. All REST timestamps are RFC3339 date-timeAwareDatetime (not Unix-epoch ints). Mirror the integer-cents precedent already in kalshi/models/portfolio.py (Balance.balance, Deposit.amount_cents).

Endpoints / scope

Base path: klear-api/v1 (prod https://api.klear.kalshi.com/klear-api/v1, demo https://demo-api.kalshi.co/klear-api/v1 — set by KlearConfig in the dependency issue). All require the session cookie.

Method Path operationId Notes
GET /margin/reports GetMarginReports Required query start_date, end_date (format: date, i.e. YYYY-MM-DD). Returns a flat array, no pagination.
GET /margin/active_obligation GetActiveMarginObligation No params. obligation field is nullable (null when none pending).
GET /margin/obligation_history GetObligationHistory Cursor-paginated. Query limit (default 20, max 100), cursor (format: date-time). Top-level cursor in response.
GET /margin/settlement_estimate GetSettlementEstimate No params. Carries a subtrader_breakdowns map (subtrader-id → estimate).
GET /margin/settlement_balance GetSettlementBalance No params.
GET /margin/settlement_balance_history GetSettlementBalanceHistory Cursor-paginated. Query limit (default 100, max 500), cursor (opaque string). Top-level cursor in response.
POST /margin/withdraw_settlement_balance WithdrawSettlementBalance forbid request body (WithdrawSettlementBalanceRequest). NOT retried.
GET /margin/settlement_balance_withdrawal GetSettlementBalanceWithdrawal Required query id. Withdrawal status by id.

Models

New file: kalshi/perps/models/margin.py (new kalshi/perps/models/ package per the locked layout). Response models use model_config = {"extra": "allow"} (forward-compat with additive spec drift, matching kalshi/models/portfolio.py); the request model uses extra="forbid". Import AwareDatetime, BaseModel, Field, AliasChoices from pydantic and DollarDecimal from kalshi.types.

Enums (Literal aliases, mirror portfolio.py PaymentStatusLiteral style)

  • MarginReportTypeLiteral = Literal["trade_audit", "position_snapshot", "market_price_snapshot", "funding_periods", "settlement_periods"]MarginReport.report_type.
  • WithdrawalStatusLiteral = Literal["pending", "processing", "processed", "failed"]GetSettlementBalanceWithdrawalResponse.status.

MarginReport

Spec MarginReport. Fields (all required): report_type: MarginReportTypeLiteral, url: str (presigned download URL), date: datetime.date (format: date), created_ts: AwareDatetime (format: date-time), is_end_of_day: bool.

GetMarginReportsResponse

Spec GetMarginReportsResponse. reports: builtins.list[MarginReport] (use NullableList[MarginReport] from kalshi.types so a server-side null array coerces to []).

ObligationReceiveInfo

Spec ObligationReceiveInfo. Required: id: str, type: str, amount_centicents: int, external_reference: str, created_ts: AwareDatetime.

SettlementDetail

Spec SettlementDetail. Required: id: str, market_ticker: str, subtrader_id: str, pnl_centicents: int, total_fees_centicents: int, total_amount_centicents: int. (All cents are plain int.)

MaintenanceMarginDetail

Spec MaintenanceMarginDetail. Required: id: str, subtrader_id: str (may be empty string), maintenance_margin_centicents: int, maintenance_margin_delta_centicents: int.

ObligationEntry

Spec ObligationEntry is allOf: [ObligationInfo, {receives, settlement_details, maintenance_margin_details}]. Flatten into one model ObligationEntry (do NOT model ObligationInfo separately — the spec only ever returns the composed ObligationEntry; note in _contract_map.py that the SDK folds ObligationInfo into ObligationEntry). Fields:

  • From ObligationInfo (all required): id: str, user_id: str, amount_centicents: int (net settlement; negative = SCM pays Klear, positive = Klear pays SCM), fees_centicents: int, maintenance_margin_centicents: int, pnl_centicents: int, execution_time: AwareDatetime, last_updated_ts: AwareDatetime.
  • From the inline object (all required): receives: builtins.list[ObligationReceiveInfo], settlement_details: builtins.list[SettlementDetail], maintenance_margin_details: builtins.list[MaintenanceMarginDetail] (each NullableList[...]).

GetActiveMarginObligationResponse

Spec GetActiveMarginObligationResponse. obligation: ObligationEntry | None = None — the obligation property is optional and nullable (spec has no required); null/absent both mean no pending obligation.

GetObligationHistoryResponse

Spec GetObligationHistoryResponse. obligations: NullableList[ObligationEntry] (required), cursor: str | None = None (RFC3339 date-time string; absent on last page). Add a has_next property mirroring PositionsResponse.has_next. (Internally this is consumed via the Page[ObligationEntry] envelope — see Resource methods.)

SettlementEstimate

Spec SettlementEstimate. Required cents (all plain int): variation_margin_centicents, total_fees_centicents, maintenance_margin_delta_centicents, maintenance_margin_required_centicents, total_amount_centicents.

GetSettlementEstimateResponse

Spec GetSettlementEstimateResponse. user_breakdown: SettlementEstimate (required), subtrader_breakdowns: dict[str, SettlementEstimate] | None = None (spec additionalProperties map, subtrader-id → estimate; optional), settlement_balance_centicents: int (required).

GetSettlementBalanceResponse

Spec GetSettlementBalanceResponse. user_id: str (required), balance_available_centicents: int (required, settlement-buffer balance), locked_balance_centicents: int | None = None (optional; locked e.g. by pending withdrawals).

WithdrawSettlementBalanceRequest (request body — extra="forbid")

Spec WithdrawSettlementBalanceRequest. model_config = {"extra": "forbid"}. Single required field amount: DollarDecimal — a fixed-point dollar string ("500.00", must be positive). This is the one request body in this issue; it serializes via model.model_dump(exclude_none=True, by_alias=True, mode="json"){"amount": "500.00"}. Wire name == Python name, so no serialization_alias needed. (Consider OrderPrice-style non-negative validation, but the spec only says "must be positive" server-side; DollarDecimal is sufficient and matches the locked decision — do not over-engineer.)

WithdrawSettlementBalanceResponse

Spec WithdrawSettlementBalanceResponse. id: str (required) — id of the async withdrawal.

GetSettlementBalanceWithdrawalResponse

Spec GetSettlementBalanceWithdrawalResponse. Required: id: str, amount: DollarDecimal (fixed-point dollar string "500.00", NOT centicents), status: WithdrawalStatusLiteral, created_ts: AwareDatetime.

SettlementBalanceHistoryEntry

Spec SettlementBalanceHistoryEntry. Required: balance_delta_centicents: int, locked_balance_delta_centicents: int, reason: str, business_transaction_id: str, created_ts: AwareDatetime.

GetSettlementBalanceHistoryResponse

Spec GetSettlementBalanceHistoryResponse. entries: NullableList[SettlementBalanceHistoryEntry] (required), cursor: str | None = None (opaque string; absent on last page). Add has_next property. (Consumed via Page[SettlementBalanceHistoryEntry].)

Resource methods

New file: kalshi/perps/resources/margin.pyMarginResource(SyncResource) and AsyncMarginResource(AsyncResource) (reuse the existing kalshi/resources/_base.py bases; the KlearClient injects the cookie-bearing transport). The two paginated endpoints reuse the generic self._list(...) / self._list_all(...) paginator with items_key=... and default cursor_key="cursor" (both responses carry a top-level cursor, exactly the shape the paginator expects). Inside these classes annotate every list-returning signature with builtins.list[T] (the .list-shadow rule) — though here the public surface is Page[T] / Iterator[T], so import builtins only if a method actually returns a bare list.

Note

_require_auth is RSA-specific (kalshi/resources/_base.py checks for the RSA signer). KlearClient resources authenticate via session cookie, not RSA. Do not call self._require_auth() here; the auth-foundation issue must provide a Klear-appropriate auth guard (e.g. _require_session() on the Klear base, or the cookie is enforced at the transport). Flag/confirm this contract with the dependency. Use _params(...) from _base to build query dicts (drops Nones).

Sync signatures (async are identical with async def / await, same names):

def margin_reports(self, *, start_date: str, end_date: str,
                   extra_headers: dict[str, str] | None = None) -> GetMarginReportsResponse
def active_obligation(self, *, extra_headers: dict[str, str] | None = None
                   ) -> GetActiveMarginObligationResponse
def obligation_history(self, *, limit: int | None = None, cursor: str | None = None,
                   extra_headers: dict[str, str] | None = None) -> Page[ObligationEntry]
def obligation_history_all(self, *, limit: int | None = None, max_pages: int | None = None,
                   extra_headers: dict[str, str] | None = None) -> Iterator[ObligationEntry]
def settlement_estimate(self, *, extra_headers: dict[str, str] | None = None
                   ) -> GetSettlementEstimateResponse
def settlement_balance(self, *, extra_headers: dict[str, str] | None = None
                   ) -> GetSettlementBalanceResponse
def settlement_balance_history(self, *, limit: int | None = None, cursor: str | None = None,
                   extra_headers: dict[str, str] | None = None) -> Page[SettlementBalanceHistoryEntry]
def settlement_balance_history_all(self, *, limit: int | None = None, max_pages: int | None = None,
                   extra_headers: dict[str, str] | None = None) -> Iterator[SettlementBalanceHistoryEntry]
def withdraw_settlement_balance(self, *, amount: DollarDecimal | str,
                   extra_headers: dict[str, str] | None = None) -> WithdrawSettlementBalanceResponse
def settlement_balance_withdrawal(self, *, id: str,
                   extra_headers: dict[str, str] | None = None) -> GetSettlementBalanceWithdrawalResponse

Notes:

  • margin_reports: start_date/end_date are required YYYY-MM-DD strings; pass straight through _params.
  • obligation_history / settlement_balance_history: validate limit against the spec ceilings via _validate_limit(limit, hi=100) and hi=500 respectively (helper in _base). The *_all variants pass cursor=None and _validate_max_pages(max_pages), then return self._list_all(path, ModelCls, items_key, params=params, max_pages=max_pages, cursor_key="cursor", extra_headers=...) with items_key="obligations" / "entries". Async *_all returns an AsyncIterator[...] directly (not async def) so async for works — mirror AsyncPortfolioResource.settlements_all.
  • withdraw_settlement_balance: build WithdrawSettlementBalanceRequest(amount=amount) internally, then self._post("/margin/withdraw_settlement_balance", json=req.model_dump(exclude_none=True, by_alias=True, mode="json"), ...). Never build an inline dict. POST is not retried (the shared transport already enforces this).
  • settlement_balance_withdrawal: id is a required query param (?id=...). The kwarg is named id to match the spec param; it shadows the builtin only as a local name, which is fine (no .id() method).

The new MarginResource / AsyncMarginResource are wired onto KlearClient.margin / AsyncKlearClient.margin (the dependency issue owns the client __init__); this issue should add the attribute assignment there or coordinate it.

Contract-test wiring

Per the locked decision, perps uses its own spec file. The auth-foundation (dependency) issue must commit /tmp/perps_scm_openapi.yamlspecs/perps_scm_openapi.yaml and parameterize the contract harness so the drift suites can load it (today tests/test_contracts.py hard-codes SPEC_FILE = .../specs/openapi.yaml and METHOD_ENDPOINT_MAP is a single flat list keyed to that one spec). This issue's entries assume that multi-spec plumbing exists — call it out as a hard blocker on the dependency.

Add these to METHOD_ENDPOINT_MAP in tests/_contract_support.py (tagged to the SCM spec via whatever spec-selector the foundation introduces; path_template is the klear-api/v1-relative path, matching how the existing map stores trade-api/v2-relative paths):

MethodEndpointEntry(sdk_method="kalshi.perps.resources.margin.MarginResource.margin_reports",
    http_method="GET", path_template="/margin/reports"),
MethodEndpointEntry(sdk_method="kalshi.perps.resources.margin.MarginResource.active_obligation",
    http_method="GET", path_template="/margin/active_obligation"),
MethodEndpointEntry(sdk_method="kalshi.perps.resources.margin.MarginResource.obligation_history",
    http_method="GET", path_template="/margin/obligation_history"),
MethodEndpointEntry(sdk_method="kalshi.perps.resources.margin.MarginResource.settlement_estimate",
    http_method="GET", path_template="/margin/settlement_estimate"),
MethodEndpointEntry(sdk_method="kalshi.perps.resources.margin.MarginResource.settlement_balance",
    http_method="GET", path_template="/margin/settlement_balance"),
MethodEndpointEntry(sdk_method="kalshi.perps.resources.margin.MarginResource.settlement_balance_history",
    http_method="GET", path_template="/margin/settlement_balance_history"),
MethodEndpointEntry(sdk_method="kalshi.perps.resources.margin.MarginResource.withdraw_settlement_balance",
    http_method="POST", path_template="/margin/withdraw_settlement_balance",
    request_body_schema="#/components/schemas/WithdrawSettlementBalanceRequest"),
MethodEndpointEntry(sdk_method="kalshi.perps.resources.margin.MarginResource.settlement_balance_withdrawal",
    http_method="GET", path_template="/margin/settlement_balance_withdrawal"),

Add to BODY_MODEL_MAP in tests/test_contracts.py:

"#/components/schemas/WithdrawSettlementBalanceRequest":
    "kalshi.perps.models.margin.WithdrawSettlementBalanceRequest",

EXCLUSIONS (tests/_contract_support.py) — add with reason:

  • obligation_history / settlement_balance_history: cursor kwarg → kind="paginator_handled" on the _all variants (cursor is internal to the paginator), reason "cursor is consumed by _list_all, not a caller kwarg on *_all". Mirror existing paginator_handled entries.
  • The flattened ObligationEntry (folds spec ObligationInfo + the inline allOf object): if the body/response drift check can't resolve the allOf, add a kind="wire_normalization" exclusion with reason "ObligationEntry flattens spec allOf[ObligationInfo, inline] into one model". Verify against the harness — only add if drift actually reds; do not add speculative exclusions.
  • withdraw_settlement_balance: amount kwarg is a body field → kind="body_param", reason "amount is the WithdrawSettlementBalanceRequest body, not a query param".

Tests

New file tests/perps/test_margin.py (add tests/perps/__init__.py if needed). Use respx.mock; build a KlearClient against the demo base URL with a stubbed session cookie (reuse the Klear auth fixtures the dependency issue adds; reuse conftest RSA fixtures only where a shared transport fixture needs them). Cover sync and async for each method. Per public method:

  • margin_reports: happy path (one report per report_type enum value, assert MarginReportTypeLiteral parses + date/created_ts typed correctly); error path (400 on bad/missing start_dateBadRequestError); edge (empty reports array, and server null → coerced to [] via NullableList).
  • active_obligation: happy path (full ObligationEntry with non-empty receives/settlement_details/maintenance_margin_details, assert negative amount_centicents stays a plain int, not Decimal); edge (obligation: null.obligation is None); error path (401 → UnauthorizedError).
  • obligation_history / _all: happy path (page with cursor); pagination (_all walks two pages then stops on absent cursor; assert outbound cursor query param sent on page 2); error path (limit > 100 raises ValueError client-side before any HTTP); edge (cursor-loop guard fires → KalshiError, reuse the existing paginator-loop test pattern).
  • settlement_estimate: happy path with populated subtrader_breakdowns map (assert dict[str, SettlementEstimate]); edge (subtrader_breakdowns absent → None); 500 → InternalServerError.
  • settlement_balance: happy path (assert balance_available_centicents int); edge (locked_balance_centicents absent → None); 403 → ForbiddenError.
  • settlement_balance_history / _all: happy + pagination + limit>500 ValueError + empty-entries edge.
  • withdraw_settlement_balance: happy path (assert the request body wire shape is exactly {"amount": "500.00"} via respx request inspection — confirms DollarDecimal dollar-string serialization, by_alias, no centicents); forbid path (constructing WithdrawSettlementBalanceRequest(amount="5", bogus=1) raises ValidationError); error path (400 on non-positive amount → BadRequestError); assert POST is not retried (respx route called once on a 503-then-anything scenario, mirror existing no-retry-on-POST test).
  • settlement_balance_withdrawal: happy path per WithdrawalStatusLiteral value (assert amount is DollarDecimal, status parses); error path (400 on missing id); edge (status="failed").

Acceptance criteria

  • kalshi/perps/models/margin.py implements all 17 owned schemas (with ObligationInfo folded into ObligationEntry) and both Literal enums.
  • kalshi/perps/resources/margin.py implements MarginResource + AsyncMarginResource with all 8 methods plus the two *_all paginators (10 public methods each), reusing _list/_list_all; no inline dict bodies; no RSA _require_auth call (uses the Klear session guard from the dependency).
  • Money fields are plain int (_centicents); only withdraw/withdrawal amount fields are DollarDecimal; all *_ts / execution_time / last_updated_ts are AwareDatetime; date is datetime.date.
  • WithdrawSettlementBalanceRequest has extra="forbid"; serializes to {"amount": "500.00"}.
  • New models exported from kalshi/perps/__init__.py and re-exported from kalshi/__init__.py; MarginResource/AsyncMarginResource wired onto KlearClient.margin/AsyncKlearClient.margin.
  • METHOD_ENDPOINT_MAP (8 entries) + BODY_MODEL_MAP (1 entry) + any EXCLUSIONS updated; SCM contract-drift suites green against specs/perps_scm_openapi.yaml.
  • mypy strict clean (builtins.list[T] inside resources where a bare list is returned); ruff clean.
  • uv run pytest tests/ -v green, including TestRequestParamDrift, TestRequestBodyDrift, and the SCM response-side drift checks.

Dependencies

  • perps: SCM/Klear auth foundation — KlearClient with session-cookie + MFA login — provides KlearClient/AsyncKlearClient, KlearConfig (base URLs), the cookie-bearing transport + session auth guard, the kalshi/perps/ package scaffold and __init__ export chain, commits specs/perps_scm_openapi.yaml, and parameterizes the contract-test harness (SPEC_FILE/METHOD_ENDPOINT_MAP spec-selector) to load the SCM spec. This issue cannot land until that multi-spec plumbing and the .margin client attribute exist.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestperpsPerps / margin (perpetual futures) API

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions