You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 KlearClientsession-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_breakdownsmap (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.
Spec GetMarginReportsResponse. reports: builtins.list[MarginReport] (use NullableList[MarginReport] from kalshi.types so a server-side null array coerces to []).
Spec ObligationEntry is allOf: [ObligationInfo, {receives, settlement_details, maintenance_margin_details}]. Flatten into one modelObligationEntry (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.)
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.
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):
margin_reports: start_date/end_date are requiredYYYY-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):
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).
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/withdrawalamount 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.
Summary
Implement the eight Self-Clearing-Member (SCM) margin endpoints of the Kalshi Klear API (
klear-api/v1) on theKlearClient/AsyncKlearClientfrom 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 theKlearClientsession-cookie auth (no RSA-PSS) established by the dependency issue.Important
Read
/tmp/perps_scm_openapi.yamlbefore coding — the money typing in this spec is NOT what the generic perps framing assumes. Almost every monetary field on these schemas is an integerint64in centicents (the spec states1 USD = 10,000 centicents), serialized as a JSON number, with an_centicentsfield-name suffix. These are plainint— do NOT useDollarDecimal/FixedPointCounton them. The only two fixed-point dollar-string fields in this whole issue areWithdrawSettlementBalanceRequest.amountandGetSettlementBalanceWithdrawalResponse.amount(e.g."500.00"); those useDollarDecimal. All REST timestamps are RFC3339date-time→AwareDatetime(not Unix-epoch ints). Mirror the integer-cents precedent already inkalshi/models/portfolio.py(Balance.balance,Deposit.amount_cents).Endpoints / scope
Base path:
klear-api/v1(prodhttps://api.klear.kalshi.com/klear-api/v1, demohttps://demo-api.kalshi.co/klear-api/v1— set byKlearConfigin the dependency issue). All require the session cookie./margin/reportsGetMarginReportsstart_date,end_date(format: date, i.e.YYYY-MM-DD). Returns a flat array, no pagination./margin/active_obligationGetActiveMarginObligationobligationfield is nullable (null when none pending)./margin/obligation_historyGetObligationHistorylimit(default 20, max 100),cursor(format: date-time). Top-levelcursorin response./margin/settlement_estimateGetSettlementEstimatesubtrader_breakdownsmap (subtrader-id → estimate)./margin/settlement_balanceGetSettlementBalance/margin/settlement_balance_historyGetSettlementBalanceHistorylimit(default 100, max 500),cursor(opaque string). Top-levelcursorin response./margin/withdraw_settlement_balanceWithdrawSettlementBalanceWithdrawSettlementBalanceRequest). NOT retried./margin/settlement_balance_withdrawalGetSettlementBalanceWithdrawalid. Withdrawal status by id.Models
New file:
kalshi/perps/models/margin.py(newkalshi/perps/models/package per the locked layout). Response models usemodel_config = {"extra": "allow"}(forward-compat with additive spec drift, matchingkalshi/models/portfolio.py); the request model usesextra="forbid". ImportAwareDatetime,BaseModel,Field,AliasChoicesfrom pydantic andDollarDecimalfromkalshi.types.Enums (
Literalaliases, mirror portfolio.pyPaymentStatusLiteralstyle)MarginReportTypeLiteral = Literal["trade_audit", "position_snapshot", "market_price_snapshot", "funding_periods", "settlement_periods"]—MarginReport.report_type.WithdrawalStatusLiteral = Literal["pending", "processing", "processed", "failed"]—GetSettlementBalanceWithdrawalResponse.status.MarginReportSpec
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.GetMarginReportsResponseSpec
GetMarginReportsResponse.reports: builtins.list[MarginReport](useNullableList[MarginReport]fromkalshi.typesso a server-sidenullarray coerces to[]).ObligationReceiveInfoSpec
ObligationReceiveInfo. Required:id: str,type: str,amount_centicents: int,external_reference: str,created_ts: AwareDatetime.SettlementDetailSpec
SettlementDetail. Required:id: str,market_ticker: str,subtrader_id: str,pnl_centicents: int,total_fees_centicents: int,total_amount_centicents: int. (All cents are plainint.)MaintenanceMarginDetailSpec
MaintenanceMarginDetail. Required:id: str,subtrader_id: str(may be empty string),maintenance_margin_centicents: int,maintenance_margin_delta_centicents: int.ObligationEntrySpec
ObligationEntryisallOf: [ObligationInfo, {receives, settlement_details, maintenance_margin_details}]. Flatten into one modelObligationEntry(do NOT modelObligationInfoseparately — the spec only ever returns the composedObligationEntry; note in_contract_map.pythat the SDK foldsObligationInfointoObligationEntry). Fields: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.receives: builtins.list[ObligationReceiveInfo],settlement_details: builtins.list[SettlementDetail],maintenance_margin_details: builtins.list[MaintenanceMarginDetail](eachNullableList[...]).GetActiveMarginObligationResponseSpec
GetActiveMarginObligationResponse.obligation: ObligationEntry | None = None— theobligationproperty is optional and nullable (spec has norequired); null/absent both mean no pending obligation.GetObligationHistoryResponseSpec
GetObligationHistoryResponse.obligations: NullableList[ObligationEntry](required),cursor: str | None = None(RFC3339 date-time string; absent on last page). Add ahas_nextproperty mirroringPositionsResponse.has_next. (Internally this is consumed via thePage[ObligationEntry]envelope — see Resource methods.)SettlementEstimateSpec
SettlementEstimate. Required cents (all plainint):variation_margin_centicents,total_fees_centicents,maintenance_margin_delta_centicents,maintenance_margin_required_centicents,total_amount_centicents.GetSettlementEstimateResponseSpec
GetSettlementEstimateResponse.user_breakdown: SettlementEstimate(required),subtrader_breakdowns: dict[str, SettlementEstimate] | None = None(specadditionalPropertiesmap, subtrader-id → estimate; optional),settlement_balance_centicents: int(required).GetSettlementBalanceResponseSpec
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 fieldamount: DollarDecimal— a fixed-point dollar string ("500.00", must be positive). This is the one request body in this issue; it serializes viamodel.model_dump(exclude_none=True, by_alias=True, mode="json")→{"amount": "500.00"}. Wire name == Python name, so noserialization_aliasneeded. (ConsiderOrderPrice-style non-negative validation, but the spec only says "must be positive" server-side;DollarDecimalis sufficient and matches the locked decision — do not over-engineer.)WithdrawSettlementBalanceResponseSpec
WithdrawSettlementBalanceResponse.id: str(required) — id of the async withdrawal.GetSettlementBalanceWithdrawalResponseSpec
GetSettlementBalanceWithdrawalResponse. Required:id: str,amount: DollarDecimal(fixed-point dollar string"500.00", NOT centicents),status: WithdrawalStatusLiteral,created_ts: AwareDatetime.SettlementBalanceHistoryEntrySpec
SettlementBalanceHistoryEntry. Required:balance_delta_centicents: int,locked_balance_delta_centicents: int,reason: str,business_transaction_id: str,created_ts: AwareDatetime.GetSettlementBalanceHistoryResponseSpec
GetSettlementBalanceHistoryResponse.entries: NullableList[SettlementBalanceHistoryEntry](required),cursor: str | None = None(opaque string; absent on last page). Addhas_nextproperty. (Consumed viaPage[SettlementBalanceHistoryEntry].)Resource methods
New file:
kalshi/perps/resources/margin.py—MarginResource(SyncResource)andAsyncMarginResource(AsyncResource)(reuse the existingkalshi/resources/_base.pybases; theKlearClientinjects the cookie-bearing transport). The two paginated endpoints reuse the genericself._list(...)/self._list_all(...)paginator withitems_key=...and defaultcursor_key="cursor"(both responses carry a top-levelcursor, exactly the shape the paginator expects). Inside these classes annotate every list-returning signature withbuiltins.list[T](the.list-shadow rule) — though here the public surface isPage[T]/Iterator[T], so importbuiltinsonly if a method actually returns a bare list.Note
_require_authis RSA-specific (kalshi/resources/_base.pychecks for the RSA signer). KlearClient resources authenticate via session cookie, not RSA. Do not callself._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_baseto build query dicts (dropsNones).Sync signatures (async are identical with
async def/await, same names):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/AsyncMarginResourceare wired ontoKlearClient.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.yamland parameterize the contract harness so the drift suites can load it (todaytests/test_contracts.pyhard-codesSPEC_FILE = .../specs/openapi.yamlandMETHOD_ENDPOINT_MAPis 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_MAPintests/_contract_support.py(tagged to the SCM spec via whatever spec-selector the foundation introduces;path_templateis theklear-api/v1-relative path, matching how the existing map storestrade-api/v2-relative paths):Add to
BODY_MODEL_MAPintests/test_contracts.py:EXCLUSIONS(tests/_contract_support.py) — add withreason: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.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(addtests/perps/__init__.pyif needed). Userespx.mock; build aKlearClientagainst 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).int(_centicents); onlywithdraw/withdrawalamountfields areDollarDecimal; all*_ts/execution_time/last_updated_tsareAwareDatetime;dateisdatetime.date.WithdrawSettlementBalanceRequesthasextra="forbid"; serializes to{"amount": "500.00"}.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.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
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.