Epic: part of #387 · Depends on: #388
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 GetMarginFeeTiersResponse as "a map of tickers to fee tier strings." The committed spec (/tmp/perps_openapi.yaml, schema at line 2396) instead defines two additionalProperties: number(double) maps: maker_fee_rates and taker_fee_rates (decimal fractions of notional, e.g. 0.0005 = 5 bps). This issue follows the spec. Likewise the brief's per-subaccount settled_funds is actually a single top-level settled_funds on the response (not per-subaccount), and there is no account_equity-named field on the response root — account_equity lives on each MarginSubaccountBalance. 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 — 0 for self-clearing members.
maintenance_margin: DollarDecimal
initial_margin: DollarDecimal — 0 for self-clearing members.
resting_orders_margin: DollarDecimal — 0 unless compute_available_balance passed.
available_balance: DollarDecimal — 0 for institutional users or when the flag wasn't passed.
- No
_dollars alias needed: the wire field names already match the short Python names (position_value, account_equity, etc.), unlike the core SDK's *_dollars convention. Do not add validation_alias here. (Spec confirms: these $ref FixedPointDollars under 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 — spec number/double, nullable. Use plain float (matches spec number); not a money field.
estimated_liquidation_price: DollarDecimal | None = None — nullable.
GetMarginRiskResponse (spec schema GetMarginRiskResponse, line 2366):
account_leverage: float | None = None — spec number/double, nullable (null when total maintenance margin is 0). Optional (not in required).
total_position_notional: DollarDecimal — required.
total_maintenance_margin: DollarDecimal — required.
positions: builtins.list[MarginRiskPosition] — required (use kalshi.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; spec additionalProperties: string. Typing the value as DollarDecimal gives 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; spec additionalProperties: 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
float for the values (spec is number/double, not a FixedPointDollars string). Do not use DollarDecimal/MultiplierDecimal here 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); pass compute_available_balance only when not None (httpx will render True→"true"). Default None (not False) so the param is omitted entirely unless the caller opts in — avoids the 50-token rate-limit cost noted in the spec's x-mint block.
- No pagination: none of these responses carry a
cursor, so no list_all() / AsyncIterator.
- Wire
MarginAccountResource / AsyncMarginAccountResource into PerpsClient / AsyncPerpsClient (from the foundation issue) as e.g. client.margin (final attribute name decided in foundation). Export both classes plus all six models from kalshi/perps/__init__.py, then re-export the models from kalshi/__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 is balance's compute_available_balance, which IS a real spec query param, so it won't trip TestRequestParamDrift. If the param-drift test flags the extra_headers kwarg (it's client-only and should already be globally ignored by the harness — verify), do not add a new exclusion; confirm the harness's existing extra_headers handling covers perps methods too. If async methods are enumerated and the spec maps only one operation, add a client_only/paginator_handled-style note only if genuinely required, each with a reason string.
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, assert subaccount_balances[i] fields parse to Decimal, settled_funds is Decimal); flag path (compute_available_balance=True → assert the query param compute_available_balance=true is 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 (assert positions parse, signed position negative value round-trips as Decimal, nullable maintenance_margin_required/position_leverage/estimated_liquidation_price accept null); edge case (account_leverage: null and empty positions: []); 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_rates parse to dict[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 assert respx matched the expected absolute URL.
Acceptance criteria
Dependencies
- perps: foundation — standalone PerpsClient/AsyncPerpsClient, PerpsConfig, base URLs, transport reuse, and perps contract-test harness wiring (provides
PerpsClient/AsyncPerpsClient, PerpsConfig, the SyncResource/AsyncResource reuse, the committed specs/perps_openapi.yaml, and the parameterization that makes METHOD_ENDPOINT_MAP / drift tests load the perps spec). This issue cannot land until that wiring exists.
Summary
Implement the four read-only margin account endpoints on the standalone PERPS client:
GetMarginBalance,GetMarginRisk,GetMarginNotionalRiskLimit, andGetMarginFeeTiers. 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 areGETand require RSA-PSS auth; none are paginated.Endpoints / scope
/margin/balanceGetMarginBalancecompute_available_balance(bool, defaultfalse). Whenfalse/omitted,resting_orders_marginandavailable_balanceare0. Auth required. 401/403/429/500./margin/riskGetMarginRisk/margin/notional_risk_limitGetMarginNotionalRiskLimit/margin/fee_tiersGetMarginFeeTiersModels
New file:
kalshi/perps/models/margin_account.py(mirrorkalshi/models/portfolio.pystyle: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 / noextra="forbid"(these are GETs with at most a query flag, so nothing to serialize).MarginSubaccountBalance(spec schemaMarginSubaccountBalance, 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._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 schemaGetMarginBalanceResponse, line 2306):subaccount_balances: builtins.list[MarginSubaccountBalance]— required.settled_funds: DollarDecimal— required; total settled funds across all subaccounts.MarginRiskPosition(spec schemaMarginRiskPosition, 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 schemaGetMarginRiskResponse, 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 schemaNotionalRiskLimitResponse, 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 schemaGetMarginFeeTiersResponse, 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.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— defineMarginAccountResource(SyncResource)andAsyncMarginAccountResource(AsyncResource), importing the sameSyncResource/AsyncResourcebase reused fromkalshi/resources/_base.py(per the foundation issue's transport reuse). Useimport builtinsat top (thelistbuiltin is shadowed inside resource classes by.list()peers elsewhere; not strictly needed here but keep the convention). Useself._require_auth()first in every method (all four are auth-required), andself._get(path, params=..., extra_headers=...). Build the optional query via the shared_params(...)helper (kalshi/resources/_base.py:85) soNonevalues are dropped.Sync signatures (async identical with
async def/await self._get(...)):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.cursor, so nolist_all()/ AsyncIterator.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_MAPintests/_contract_support.py(these load against the perps spec — see Dependencies: the harness must be parameterized to also readspecs/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):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). Userespx.mockfor HTTP mocking and reuse the conftest RSA key/auth/config fixtures (adapted toPerpsConfig/PerpsClientfrom 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).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.kalshi/perps/models/margin_account.py, built against the cited spec fields (note the fee-tiers/brief discrepancy resolved in favor of the spec).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.METHOD_ENDPOINT_MAPentries added (perps FQNs); noBODY_MODEL_MAPentries; no unjustifiedEXCLUSIONS.respx.mockwith conftest RSA fixtures.Dependencies
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.