Skip to content

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

Closed

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: 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

  • 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.

Activity

  1. added this to the Perps API milestone on Jun 4, 2026
  2. added
    enhancementNew feature or request
    perpsPerps / margin (perpetual futures) API
    on Jun 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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