Skip to content

perps: exchange status, enabled flag & risk parameters resource #389

Description

@TexasCoding

Epic: part of #387 · Depends on: #388

Summary

Implement the three read-only PERPS "exchange health / access" endpoints on the new standalone PerpsClient / AsyncPerpsClient: GET /margin/exchange/status (is the margin exchange up and trading), GET /margin/enabled (per-member access gate — perps rolls out member-by-member, so this is the smoke test that tells a caller whether they are allowed onto perps at all), and GET /margin/risk_parameters (system-wide liquidation thresholds plus a per-market initial-margin-multiplier map). All three are GET, so all three retry on 429/502/503/504.

The shared perps primitive schemas ExchangeIndex and ExchangeInstance are defined once by the foundation issue in kalshi/perps/models/common.py. This issue (and the transfers/SCM issues) import them — do not redefine.

Endpoints / scope

Method Path operationId Notes
GET /margin/exchange/status GetMarginExchangeStatus No security block in spec — public/unauth. Returns ExchangeStatus. Spec also returns the ExchangeStatus schema on 500/503/504 (server emits the same body shape on degraded states); our transport maps non-2xx to errors as usual, so only the 200 body is parsed.
GET /margin/enabled GetMarginEnabled Auth required (kalshiAccessKey/Signature/Timestamp). Returns MarginEnabledResponse. This is the access-gate smoke test. Guard client-side with _require_auth() so an unauthenticated caller gets AuthRequiredError instead of a server 401.
GET /margin/risk_parameters GetMarginRiskParameters No security block in spec — public/unauth. Returns GetMarginRiskParametersResponse.

No pagination on any of these (no cursor in any response).

Models

Two new files.

kalshi/perps/models/exchange.py

  • ExchangeStatus(BaseModel) — margin exchange operational status. Distinct from kalshi/models/exchange.py:ExchangeStatus; the perps version is slimmer (no schedule/resume-time field). Do NOT reuse the core model.

    • exchange_active: bool (spec required) — "False if the exchange is no longer taking any state changes at all. True unless under maintenance."
    • trading_active: bool (spec required) — "True if trading is currently permitted on the exchange."
    • model_config = {"extra": "allow"} (response model; tolerate additive server fields, matching core convention).
  • MarginEnabledResponse(BaseModel) — perps access gate.

    • enabled: bool (spec required) — "Indicates whether margin trading is enabled for the user."
    • model_config = {"extra": "allow"}.
  • GetMarginRiskParametersResponse(BaseModel) — system-wide risk parameters.

    • liquidation_margin_ratio_threshold: MultiplierDecimal — spec number/format: double, "Margin ratio at which a position is liquidated." Use MultiplierDecimal (from kalshi.types), NOT bare float — the existing convention routes number/double ratio fields through MultiplierDecimal to avoid binary-float drift (see kalshi/types.py docstring and Series.fee_multiplier).
    • queue_entry_margin_ratio_threshold: MultiplierDecimal — spec number/double, "Margin ratio at which a position enters the liquidation queue."
    • initial_margin_multiplier: dict[str, MultiplierDecimal] — spec type: object, additionalProperties: {number/double}, "Map of market ticker to initial margin multiplier. The initial margin requirement is the maintenance margin multiplied by this value." These are NOT FixedPointDollars/FixedPointCount — they are doubles, hence MultiplierDecimal, not DollarDecimal.
    • All three fields are spec-required. model_config = {"extra": "allow"}.
    • No validation_alias needed — short Python names already match the wire field names exactly.

kalshi/perps/models/common.py

Shared perps primitives (owned here, imported by other perps areas):

  • ExchangeInstance(str, Enum) — spec ExchangeInstance enum (type: string). Members:
    • EVENT_CONTRACT = "event_contract"
    • MARGINED = "margined"
    • Description: "The exchange instance type."
  • ExchangeIndex — spec ExchangeIndex is type: integer, default 0, "Identifier for an exchange shard … currently only 0 supported." Expose as a documented type alias ExchangeIndex = int (mirror the lightweight-alias pattern of UnixSecondsTimestamp in kalshi/types.py); a full model is overkill for a bare int. If a future SCM-transfer issue needs it as a request field, it stays a plain int with default=0.

(ExchangeIndex/ExchangeInstance are not referenced by this issue's three endpoints — they live in the foundation common.py module; listed here only for cross-reference.)

No request models in this issue — all three endpoints are GET with no body, so no extra="forbid" request models and no serialization_alias.

Resource methods

New file kalshi/perps/resources/exchange.py, mirroring kalshi/resources/exchange.py (two classes, sync + async). All return-typed against the perps models. Methods take only *, extra_headers: dict[str, str] | None = None (no path/query params).

class PerpsExchangeResource(SyncResource):
    def status(self, *, extra_headers=None) -> ExchangeStatus: ...
    def enabled(self, *, extra_headers=None) -> MarginEnabledResponse: ...
    def risk_parameters(self, *, extra_headers=None) -> GetMarginRiskParametersResponse: ...

class AsyncPerpsExchangeResource(AsyncResource):
    async def status(self, *, extra_headers=None) -> ExchangeStatus: ...
    async def enabled(self, *, extra_headers=None) -> MarginEnabledResponse: ...
    async def risk_parameters(self, *, extra_headers=None) -> GetMarginRiskParametersResponse: ...
  • statusself._get("/margin/exchange/status", ...)ExchangeStatus.model_validate(data).
  • enabled → call self._require_auth() first (spec requires auth; fail fast with AuthRequiredError), then self._get("/margin/enabled", ...)MarginEnabledResponse.model_validate(data).
  • risk_parametersself._get("/margin/risk_parameters", ...)GetMarginRiskParametersResponse.model_validate(data).

Reuse the existing SyncResource/AsyncResource base (kalshi/resources/_base.py) unchanged — they already carry _get / _require_auth and depend only on the transport, which the perps client supplies. No builtins.list[T] needed here (no list-returning methods), but keep the import builtins convention if any helper returns a list.

Wire the resource onto PerpsClient / AsyncPerpsClient as client.exchange (e.g. self.exchange = PerpsExchangeResource(self._transport)), per the foundation client scaffolding.

Contract-test wiring

Add to METHOD_ENDPOINT_MAP in tests/_contract_support.py (under a new # ── perps exchange ── block). All GET, no request_body_schema:

MethodEndpointEntry(
    sdk_method="kalshi.perps.resources.exchange.PerpsExchangeResource.status",
    http_method="GET",
    path_template="/margin/exchange/status",
),
MethodEndpointEntry(
    sdk_method="kalshi.perps.resources.exchange.PerpsExchangeResource.enabled",
    http_method="GET",
    path_template="/margin/enabled",
),
MethodEndpointEntry(
    sdk_method="kalshi.perps.resources.exchange.PerpsExchangeResource.risk_parameters",
    http_method="GET",
    path_template="/margin/risk_parameters",
),
  • These paths resolve against the perps spec (specs/perps_openapi.yaml), not the core specs/openapi.yaml. The contract harness currently loads exactly one spec via SPEC_FILE / _load_spec() in tests/test_contracts.py. The foundation issue must parameterize the drift suites to also load the perps spec and route perps METHOD_ENDPOINT_MAP entries (and BODY_MODEL_MAP entries) to it — this issue depends on that being in place.
  • No BODY_MODEL_MAP entries (no request bodies).
  • No EXCLUSIONS entries expected: the three GET methods take no path/query params and the spec declares none for them, so TestRequestParamDrift should pass with zero deviations. (If the harness flags the auth-only enabled method for having no params, do NOT add an exclusion — empty-param GETs are already handled by the existing param-drift logic.)

Tests

New file tests/perps/test_exchange.py (create tests/perps/__init__.py if the foundation issue hasn't). Use respx.mock, reuse the conftest RSA-key/auth/config fixtures (adapted to PerpsConfig / perps base URL per foundation), sync + async variants of each.

  • status: happy path — mock 200 with {"exchange_active": true, "trading_active": false}, assert both bools parse; edge case — response carries an extra additive field, assert extra="allow" keeps it and parsing succeeds.
  • enabled: happy path — mock 200 {"enabled": true} with an authed client, assert .enabled is True; error path — unauthenticated client raises AuthRequiredError without issuing an HTTP request (assert the respx route was not called); error path — 401 from server maps to the SDK's unauthorized error type.
  • risk_parameters: happy path — mock 200 with liquidation_margin_ratio_threshold, queue_entry_margin_ratio_threshold, and an initial_margin_multiplier map of 2+ tickers; assert the two thresholds parse as Decimal (not float) and the map values are Decimal keyed by ticker; edge case — empty initial_margin_multiplier map ({}) parses to an empty dict; edge case — a high-precision double like 0.12345678 round-trips without float drift (confirms MultiplierDecimal, not float).
  • Shared primitives: a small test asserting ExchangeInstance("event_contract") / ExchangeInstance("margined") are valid and an unknown value raises.

Acceptance criteria

  • PerpsExchangeResource and AsyncPerpsExchangeResource implement status, enabled, risk_parameters (sync + async).
  • enabled calls _require_auth() before any HTTP I/O.
  • Models ExchangeStatus, MarginEnabledResponse, GetMarginRiskParametersResponse, ExchangeInstance, ExchangeIndex created in kalshi/perps/models/exchange.py + kalshi/perps/models/common.py; number/double fields use MultiplierDecimal.
  • Models + resource classes exported from kalshi/perps/__init__.py and re-exported from kalshi/__init__.py.
  • Resource wired onto PerpsClient.exchange / AsyncPerpsClient.exchange.
  • METHOD_ENDPOINT_MAP updated with the three GET entries; perps spec committed at specs/perps_openapi.yaml and loaded by the (foundation-parameterized) drift suites.
  • uv run mypy kalshi/ strict clean (builtins.list[T] inside resource classes if any list method is added).
  • uv run ruff check . clean.
  • uv run pytest tests/ -v green, including TestRequestParamDrift and TestRequestBodyDrift against the perps spec.

Dependencies

  • foundation (perps package scaffolding: PerpsConfig, PerpsClient/AsyncPerpsClient, perps base URLs, reuse of KalshiAuth + SyncTransport/AsyncTransport, specs/perps_openapi.yaml committed, and the contract-test harness parameterized to load the perps spec).

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