Skip to content

perps: balance, risk, notional risk limit & fee tiers resource #394

Description

@TexasCoding

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: DollarDecimal0 for self-clearing members.
  • maintenance_margin: DollarDecimal
  • initial_margin: DollarDecimal0 for self-clearing members.
  • resting_orders_margin: DollarDecimal0 unless compute_available_balance passed.
  • available_balance: DollarDecimal0 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

  • MarginAccountResource + AsyncMarginAccountResource implement balance, 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__.py and re-exported from kalshi/__init__.py.
  • compute_available_balance defaults to omitted (param absent unless caller passes it).
  • mypy kalshi/ strict clean (use builtins.list[T] for any list annotations inside resource classes; plain typed lists in model files are fine).
  • ruff check . clean.
  • pytest tests/ -v green, including TestRequestParamDrift / TestRequestBodyDrift against the perps spec.
  • Four METHOD_ENDPOINT_MAP entries added (perps FQNs); no BODY_MODEL_MAP entries; no unjustified EXCLUSIONS.
  • New tests cover happy/error/edge per method, sync + async, via respx.mock with conftest RSA fixtures.

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.

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