Skip to content

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

Closed

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-time → AwareDatetime (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.py — MarginResource(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.yaml → specs/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_date → BadRequestError); 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.

Activity

  1. added this to the Perps API milestone on Jun 4, 2026
  2. added
    enhancementNew feature or request
    perpsPerps / margin (perpetual futures) API
    on Jun 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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