Repository navigation
Description
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 fromkalshi/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— specnumber/format: double, "Margin ratio at which a position is liquidated." UseMultiplierDecimal(fromkalshi.types), NOT barefloat— the existing convention routesnumber/doubleratio fields throughMultiplierDecimalto avoid binary-float drift (seekalshi/types.pydocstring andSeries.fee_multiplier).queue_entry_margin_ratio_threshold: MultiplierDecimal— specnumber/double, "Margin ratio at which a position enters the liquidation queue."initial_margin_multiplier: dict[str, MultiplierDecimal]— spectype: 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, henceMultiplierDecimal, notDollarDecimal.- All three fields are spec-required.
model_config = {"extra": "allow"}. - No
validation_aliasneeded — 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)— specExchangeInstanceenum (type: string). Members:EVENT_CONTRACT = "event_contract"MARGINED = "margined"- Description: "The exchange instance type."
ExchangeIndex— specExchangeIndexistype: integer, default0, "Identifier for an exchange shard … currently only 0 supported." Expose as a documented type aliasExchangeIndex = int(mirror the lightweight-alias pattern ofUnixSecondsTimestampinkalshi/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 withdefault=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: ...status→self._get("/margin/exchange/status", ...)→ExchangeStatus.model_validate(data).enabled→ callself._require_auth()first (spec requires auth; fail fast withAuthRequiredError), thenself._get("/margin/enabled", ...)→MarginEnabledResponse.model_validate(data).risk_parameters→self._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 corespecs/openapi.yaml. The contract harness currently loads exactly one spec viaSPEC_FILE/_load_spec()intests/test_contracts.py. The foundation issue must parameterize the drift suites to also load the perps spec and route perpsMETHOD_ENDPOINT_MAPentries (andBODY_MODEL_MAPentries) to it — this issue depends on that being in place. - No
BODY_MODEL_MAPentries (no request bodies). - No
EXCLUSIONSentries expected: the three GET methods take no path/query params and the spec declares none for them, soTestRequestParamDriftshould pass with zero deviations. (If the harness flags the auth-onlyenabledmethod 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, assertextra="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 raisesAuthRequiredErrorwithout 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 withliquidation_margin_ratio_threshold,queue_entry_margin_ratio_threshold, and aninitial_margin_multipliermap of 2+ tickers; assert the two thresholds parse asDecimal(not float) and the map values areDecimalkeyed by ticker; edge case — emptyinitial_margin_multipliermap ({}) parses to an empty dict; edge case — a high-precision double like0.12345678round-trips without float drift (confirmsMultiplierDecimal, notfloat).- Shared primitives: a small test asserting
ExchangeInstance("event_contract")/ExchangeInstance("margined")are valid and an unknown value raises.
Acceptance criteria
-
PerpsExchangeResourceandAsyncPerpsExchangeResourceimplementstatus,enabled,risk_parameters(sync + async). -
enabledcalls_require_auth()before any HTTP I/O. - Models
ExchangeStatus,MarginEnabledResponse,GetMarginRiskParametersResponse,ExchangeInstance,ExchangeIndexcreated inkalshi/perps/models/exchange.py+kalshi/perps/models/common.py;number/doublefields useMultiplierDecimal. - Models + resource classes exported from
kalshi/perps/__init__.pyand re-exported fromkalshi/__init__.py. - Resource wired onto
PerpsClient.exchange/AsyncPerpsClient.exchange. -
METHOD_ENDPOINT_MAPupdated with the three GET entries; perps spec committed atspecs/perps_openapi.yamland 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/ -vgreen, includingTestRequestParamDriftandTestRequestBodyDriftagainst the perps spec.
Dependencies
- foundation (perps package scaffolding:
PerpsConfig,PerpsClient/AsyncPerpsClient, perps base URLs, reuse ofKalshiAuth+SyncTransport/AsyncTransport,specs/perps_openapi.yamlcommitted, and the contract-test harness parameterized to load the perps spec).
Activity