Repository navigation
Description
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](eachNullableList[...]).
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_dateare requiredYYYY-MM-DDstrings; pass straight through_params.obligation_history/settlement_balance_history: validatelimitagainst the spec ceilings via_validate_limit(limit, hi=100)andhi=500respectively (helper in_base). The*_allvariants passcursor=Noneand_validate_max_pages(max_pages), then returnself._list_all(path, ModelCls, items_key, params=params, max_pages=max_pages, cursor_key="cursor", extra_headers=...)withitems_key="obligations"/"entries". Async*_allreturns anAsyncIterator[...]directly (notasync def) soasync forworks — mirrorAsyncPortfolioResource.settlements_all.withdraw_settlement_balance: buildWithdrawSettlementBalanceRequest(amount=amount)internally, thenself._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:idis a required query param (?id=...). The kwarg is namedidto 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:cursorkwarg →kind="paginator_handled"on the_allvariants (cursor is internal to the paginator), reason"cursor is consumed by _list_all, not a caller kwarg on *_all". Mirror existingpaginator_handledentries.- The flattened
ObligationEntry(folds specObligationInfo+ the inlineallOfobject): if the body/response drift check can't resolve theallOf, add akind="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:amountkwarg 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 perreport_typeenum value, assertMarginReportTypeLiteralparses +date/created_tstyped correctly); error path (400 on bad/missingstart_date→BadRequestError); edge (emptyreportsarray, and servernull→ coerced to[]viaNullableList).active_obligation: happy path (fullObligationEntrywith non-emptyreceives/settlement_details/maintenance_margin_details, assert negativeamount_centicentsstays a plainint, not Decimal); edge (obligation: null→.obligation is None); error path (401 →UnauthorizedError).obligation_history/_all: happy path (page withcursor); pagination (_allwalks two pages then stops on absent cursor; assert outboundcursorquery param sent on page 2); error path (limit > 100 raisesValueErrorclient-side before any HTTP); edge (cursor-loop guard fires →KalshiError, reuse the existing paginator-loop test pattern).settlement_estimate: happy path with populatedsubtrader_breakdownsmap (assert dict[str, SettlementEstimate]); edge (subtrader_breakdownsabsent →None); 500 →InternalServerError.settlement_balance: happy path (assertbalance_available_centicentsint); edge (locked_balance_centicentsabsent →None); 403 →ForbiddenError.settlement_balance_history/_all: happy + pagination + limit>500ValueError+ empty-entries edge.withdraw_settlement_balance: happy path (assert the request body wire shape is exactly{"amount": "500.00"}viarespxrequest inspection — confirmsDollarDecimaldollar-string serialization, by_alias, no centicents); forbid path (constructingWithdrawSettlementBalanceRequest(amount="5", bogus=1)raisesValidationError); 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 perWithdrawalStatusLiteralvalue (assertamountisDollarDecimal,statusparses); error path (400 on missingid); edge (status="failed").
Acceptance criteria
-
kalshi/perps/models/margin.pyimplements all 17 owned schemas (withObligationInfofolded intoObligationEntry) and bothLiteralenums. -
kalshi/perps/resources/margin.pyimplementsMarginResource+AsyncMarginResourcewith all 8 methods plus the two*_allpaginators (10 public methods each), reusing_list/_list_all; no inline dict bodies; no RSA_require_authcall (uses the Klear session guard from the dependency). - Money fields are plain
int(_centicents); onlywithdraw/withdrawalamountfields areDollarDecimal; all*_ts/execution_time/last_updated_tsareAwareDatetime;dateisdatetime.date. -
WithdrawSettlementBalanceRequesthasextra="forbid"; serializes to{"amount": "500.00"}. - New models exported from
kalshi/perps/__init__.pyand re-exported fromkalshi/__init__.py;MarginResource/AsyncMarginResourcewired ontoKlearClient.margin/AsyncKlearClient.margin. -
METHOD_ENDPOINT_MAP(8 entries) +BODY_MODEL_MAP(1 entry) + anyEXCLUSIONSupdated; SCM contract-drift suites green againstspecs/perps_scm_openapi.yaml. - mypy strict clean (
builtins.list[T]inside resources where a bare list is returned); ruff clean. -
uv run pytest tests/ -vgreen, includingTestRequestParamDrift,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, thekalshi/perps/package scaffold and__init__export chain, commitsspecs/perps_scm_openapi.yaml, and parameterizes the contract-test harness (SPEC_FILE/METHOD_ENDPOINT_MAPspec-selector) to load the SCM spec. This issue cannot land until that multi-spec plumbing and the.marginclient attribute exist.
Activity