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: ...
status → self._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_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 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
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).
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), andGET /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
ExchangeIndexandExchangeInstanceare defined once by the foundation issue inkalshi/perps/models/common.py. This issue (and the transfers/SCM issues) import them — do not redefine.Endpoints / scope
/margin/exchange/statusGetMarginExchangeStatusExchangeStatus. Spec also returns theExchangeStatusschema 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./margin/enabledGetMarginEnabledkalshiAccessKey/Signature/Timestamp). ReturnsMarginEnabledResponse. This is the access-gate smoke test. Guard client-side with_require_auth()so an unauthenticated caller getsAuthRequiredErrorinstead of a server 401./margin/risk_parametersGetMarginRiskParametersGetMarginRiskParametersResponse.No pagination on any of these (no cursor in any response).
Models
Two new files.
kalshi/perps/models/exchange.pyExchangeStatus(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.model_config = {"extra": "allow"}.validation_aliasneeded — short Python names already match the wire field names exactly.kalshi/perps/models/common.pyShared perps primitives (owned here, imported by other perps areas):
ExchangeInstance(str, Enum)— specExchangeInstanceenum (type: string). Members:EVENT_CONTRACT = "event_contract"MARGINED = "margined"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/ExchangeInstanceare not referenced by this issue's three endpoints — they live in the foundationcommon.pymodule; 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 noserialization_alias.Resource methods
New file
kalshi/perps/resources/exchange.py, mirroringkalshi/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).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/AsyncResourcebase (kalshi/resources/_base.py) unchanged — they already carry_get/_require_authand depend only on the transport, which the perps client supplies. Nobuiltins.list[T]needed here (no list-returning methods), but keep theimport builtinsconvention if any helper returns a list.Wire the resource onto
PerpsClient/AsyncPerpsClientasclient.exchange(e.g.self.exchange = PerpsExchangeResource(self._transport)), per the foundation client scaffolding.Contract-test wiring
Add to
METHOD_ENDPOINT_MAPintests/_contract_support.py(under a new# ── perps exchange ──block). All GET, norequest_body_schema: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.BODY_MODEL_MAPentries (no request bodies).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(createtests/perps/__init__.pyif the foundation issue hasn't). Userespx.mock, reuse the conftest RSA-key/auth/config fixtures (adapted toPerpsConfig/ 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).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.ExchangeStatus,MarginEnabledResponse,GetMarginRiskParametersResponse,ExchangeInstance,ExchangeIndexcreated inkalshi/perps/models/exchange.py+kalshi/perps/models/common.py;number/doublefields useMultiplierDecimal.kalshi/perps/__init__.pyand re-exported fromkalshi/__init__.py.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
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).