Repository navigation
Description
Summary
Implement the four read-only margin account endpoints on the standalone PERPS client: GetMarginBalance, GetMarginRisk, GetMarginNotionalRiskLimit, and GetMarginFeeTiers. These give a direct margin user their per-subaccount balance breakdown, leverage/liquidation risk per position, the notional value risk limit (default + per-market overrides), and per-market maker/taker fee rates. All four are GET and require RSA-PSS auth; none are paginated.
Spec-vs-brief discrepancy (resolve before coding): the planning brief described
GetMarginFeeTiersResponseas "a map of tickers to fee tier strings." The committed spec (/tmp/perps_openapi.yaml, schema at line 2396) instead defines twoadditionalProperties: number(double)maps:maker_fee_ratesandtaker_fee_rates(decimal fractions of notional, e.g.0.0005= 5 bps). This issue follows the spec. Likewise the brief's per-subaccountsettled_fundsis actually a single top-levelsettled_fundson the response (not per-subaccount), and there is noaccount_equity-named field on the response root —account_equitylives on eachMarginSubaccountBalance. Build against the spec fields cited below.
Endpoints / scope
| Method | Path | operationId | Notes |
|---|---|---|---|
| GET | /margin/balance |
GetMarginBalance |
Query flag compute_available_balance (bool, default false). When false/omitted, resting_orders_margin and available_balance are 0. Auth required. 401/403/429/500. |
| GET | /margin/risk |
GetMarginRisk |
No params. Returns account leverage + per-position risk array. Auth required. 401/403/500. |
| GET | /margin/notional_risk_limit |
GetMarginNotionalRiskLimit |
No params. Default limit + per-ticker override map. Auth required. 401/500. |
| GET | /margin/fee_tiers |
GetMarginFeeTiers |
No params. Maker + taker fee-rate maps keyed by market ticker. Auth required. 401/500. |
Models
New file: kalshi/perps/models/margin_account.py (mirror kalshi/models/portfolio.py style: from __future__ import annotations, model_config = {"extra": "allow"} on every response model so additive spec fields don't break parsing).
Reuse the shared custom types from kalshi.types: DollarDecimal (FixedPointDollars → string up to 6 dp), FixedPointCount (signed contract count "fp" string). All four responses are pure response models — no request models / no extra="forbid" (these are GETs with at most a query flag, so nothing to serialize).
MarginSubaccountBalance (spec schema MarginSubaccountBalance, line 2262; all fields except none are nullable):
subaccount: int— 0 for primary, 1–32 for subaccounts.position_value: DollarDecimal— mark-to-market value of open positions.account_equity: DollarDecimal—0for self-clearing members.maintenance_margin: DollarDecimalinitial_margin: DollarDecimal—0for self-clearing members.resting_orders_margin: DollarDecimal—0unlesscompute_available_balancepassed.available_balance: DollarDecimal—0for institutional users or when the flag wasn't passed.- No
_dollarsalias needed: the wire field names already match the short Python names (position_value,account_equity, etc.), unlike the core SDK's*_dollarsconvention. Do not addvalidation_aliashere. (Spec confirms: these$refFixedPointDollarsunder their bare names.)
GetMarginBalanceResponse (spec schema GetMarginBalanceResponse, line 2306):
subaccount_balances: builtins.list[MarginSubaccountBalance]— required.settled_funds: DollarDecimal— required; total settled funds across all subaccounts.
MarginRiskPosition (spec schema MarginRiskPosition, line 2320):
subaccount: int— required.market_ticker: str— required.position: FixedPointCount— required; signed position quantity (FixedPointCount string can be negative).mark_price: DollarDecimal— required.position_notional: DollarDecimal— required;|qty| * mark_price.maintenance_margin_required: DollarDecimal | None = None— nullable in spec (null when margin config missing).position_leverage: float | None = None— specnumber/double, nullable. Use plainfloat(matches specnumber); not a money field.estimated_liquidation_price: DollarDecimal | None = None— nullable.
GetMarginRiskResponse (spec schema GetMarginRiskResponse, line 2366):
account_leverage: float | None = None— specnumber/double, nullable (null when total maintenance margin is 0). Optional (not inrequired).total_position_notional: DollarDecimal— required.total_maintenance_margin: DollarDecimal— required.positions: builtins.list[MarginRiskPosition]— required (usekalshi.types.NullableList[MarginRiskPosition]only if a null array is observed on demo; default to plain typed list and add NullableList later if a server-omits case appears).
NotionalRiskLimitResponse (spec schema NotionalRiskLimitResponse, line 2239):
default_notional_value_risk_limit: DollarDecimal— required; FixedPointDollars string (e.g."5000.0000").notional_value_risk_limits_by_market_ticker: dict[str, DollarDecimal]— required; specadditionalProperties: string. Typing the value asDollarDecimalgives callers Decimal values directly (the BeforeValidator coerces each string). Map key is the market ticker. A market-level entry overrides the default.
GetMarginFeeTiersResponse (spec schema GetMarginFeeTiersResponse, line 2396):
maker_fee_rates: dict[str, float]— required; specadditionalProperties: number(double). Decimal fraction of notional (e.g.0.0005= 5 bps). Key = margin market ticker.taker_fee_rates: dict[str, float]— required; same shape.- Use plain
floatfor the values (spec isnumber/double, not a FixedPointDollars string). Do not useDollarDecimal/MultiplierDecimalhere unless a follow-up decides rate precision matters — flag in review.
Enums: none. None of the six owned schemas define an enum.
Resource methods
New file: kalshi/perps/resources/margin_account.py — define MarginAccountResource(SyncResource) and AsyncMarginAccountResource(AsyncResource), importing the same SyncResource/AsyncResource base reused from kalshi/resources/_base.py (per the foundation issue's transport reuse). Use import builtins at top (the list builtin is shadowed inside resource classes by .list() peers elsewhere; not strictly needed here but keep the convention). Use self._require_auth() first in every method (all four are auth-required), and self._get(path, params=..., extra_headers=...). Build the optional query via the shared _params(...) helper (kalshi/resources/_base.py:85) so None values are dropped.
Sync signatures (async identical with async def / await self._get(...)):
def balance(self, *, compute_available_balance: bool | None = None,
extra_headers: dict[str, str] | None = None) -> GetMarginBalanceResponse
def risk(self, *, extra_headers: dict[str, str] | None = None) -> GetMarginRiskResponse
def notional_risk_limit(self, *, extra_headers: dict[str, str] | None = None) -> NotionalRiskLimitResponse
def fee_tiers(self, *, extra_headers: dict[str, str] | None = None) -> GetMarginFeeTiersResponse
balance:params = _params(compute_available_balance=compute_available_balance); passcompute_available_balanceonly when notNone(httpx will renderTrue→"true"). DefaultNone(notFalse) so the param is omitted entirely unless the caller opts in — avoids the 50-token rate-limit cost noted in the spec'sx-mintblock.- No pagination: none of these responses carry a
cursor, so nolist_all()/ AsyncIterator. - Wire
MarginAccountResource/AsyncMarginAccountResourceintoPerpsClient/AsyncPerpsClient(from the foundation issue) as e.g.client.margin(final attribute name decided in foundation). Export both classes plus all six models fromkalshi/perps/__init__.py, then re-export the models fromkalshi/__init__.py.
Contract-test wiring
Add to METHOD_ENDPOINT_MAP in tests/_contract_support.py (these load against the perps spec — see Dependencies: the harness must be parameterized to also read specs/perps_openapi.yaml). All four are GET → request_body_schema=None (omit it). Add both sync and async FQNs if the harness enumerates async separately (it currently maps sync methods; follow whatever the foundation issue establishes for async perps methods):
MethodEndpointEntry(
sdk_method="kalshi.perps.resources.margin_account.MarginAccountResource.balance",
http_method="GET", path_template="/margin/balance"),
MethodEndpointEntry(
sdk_method="kalshi.perps.resources.margin_account.MarginAccountResource.risk",
http_method="GET", path_template="/margin/risk"),
MethodEndpointEntry(
sdk_method="kalshi.perps.resources.margin_account.MarginAccountResource.notional_risk_limit",
http_method="GET", path_template="/margin/notional_risk_limit"),
MethodEndpointEntry(
sdk_method="kalshi.perps.resources.margin_account.MarginAccountResource.fee_tiers",
http_method="GET", path_template="/margin/fee_tiers"),
BODY_MODEL_MAP(tests/test_contracts.py): no entries — all four are GET with no request body.EXCLUSIONS: expected none. The only query param isbalance'scompute_available_balance, which IS a real spec query param, so it won't tripTestRequestParamDrift. If the param-drift test flags theextra_headerskwarg (it's client-only and should already be globally ignored by the harness — verify), do not add a new exclusion; confirm the harness's existingextra_headershandling covers perps methods too. If async methods are enumerated and the spec maps only one operation, add aclient_only/paginator_handled-style note only if genuinely required, each with areasonstring.
Tests
New file: tests/perps/test_margin_account.py (or wherever the foundation issue roots perps tests). Use respx.mock for HTTP mocking and reuse the conftest RSA key/auth/config fixtures (adapted to PerpsConfig/PerpsClient from the foundation issue). Cover sync and async for each method:
balance— happy path (multi-subaccount payload, assertsubaccount_balances[i]fields parse toDecimal,settled_fundsisDecimal); flag path (compute_available_balance=True→ assert the query paramcompute_available_balance=trueis on the outgoing request and that omitting it sends no such param); error path (401 →AuthError/mapped exception; 403 → forbidden); unauth client raises before HTTP via_require_auth.risk— happy path (assertpositionsparse, signedpositionnegative value round-trips asDecimal, nullablemaintenance_margin_required/position_leverage/estimated_liquidation_priceacceptnull); edge case (account_leverage: nulland emptypositions: []); error path (500).notional_risk_limit— happy path (default limit + per-ticker map →dict[str, Decimal]); edge case (empty override map); error path (401).fee_tiers— happy path (maker_fee_rates/taker_fee_ratesparse todict[str, float]); edge case (empty maps); error path (401).- Add a regression test asserting the perps base URL is used (prod
https://external-api.kalshi.com/trade-api/v2) — at minimum assertrespxmatched the expected absolute URL.
Acceptance criteria
-
MarginAccountResource+AsyncMarginAccountResourceimplementbalance,risk,notional_risk_limit,fee_tiers. - All six models live in
kalshi/perps/models/margin_account.py, built against the cited spec fields (note the fee-tiers/brief discrepancy resolved in favor of the spec). - Models + resources exported from
kalshi/perps/__init__.pyand re-exported fromkalshi/__init__.py. -
compute_available_balancedefaults to omitted (param absent unless caller passes it). -
mypy kalshi/strict clean (usebuiltins.list[T]for any list annotations inside resource classes; plain typed lists in model files are fine). -
ruff check .clean. -
pytest tests/ -vgreen, includingTestRequestParamDrift/TestRequestBodyDriftagainst the perps spec. - Four
METHOD_ENDPOINT_MAPentries added (perps FQNs); noBODY_MODEL_MAPentries; no unjustifiedEXCLUSIONS. - New tests cover happy/error/edge per method, sync + async, via
respx.mockwith conftest RSA fixtures.
Dependencies
- perps: foundation — standalone PerpsClient/AsyncPerpsClient, PerpsConfig, base URLs, transport reuse, and perps contract-test harness wiring (provides
PerpsClient/AsyncPerpsClient,PerpsConfig, theSyncResource/AsyncResourcereuse, the committedspecs/perps_openapi.yaml, and the parameterization that makesMETHOD_ENDPOINT_MAP/ drift tests load the perps spec). This issue cannot land until that wiring exists.
Activity