Repository navigation
Description
Epic: part of #387 · Depends on: none (dependency root)
Summary
This is the dependency root for the Kalshi PERPS (margin) API implementation. It introduces standalone PerpsClient / AsyncPerpsClient classes (NOT a namespace on KalshiClient) targeting the separate perps host, a new PerpsConfig, the kalshi/perps/ package skeleton with empty resource stubs, the shared common models module, the three committed perps spec snapshots, weekly spec-sync coverage for them, and — critically — a parameterized contract-test harness so perps endpoints validate against specs/perps_openapi.yaml instead of the existing specs/openapi.yaml. This issue owns no business endpoints; it unblocks every per-resource perps issue.
The decisive reason PerpsConfig is a new class (not KalshiConfig with perps defaults): KalshiConfig.__post_init__ hard-fails on hosts outside _KNOWN_HOSTS = {"api.elections.kalshi.com", "demo-api.kalshi.co"} (kalshi/config.py:27, raises in _validate_url). The perps hosts external-api.kalshi.com / external-api.demo.kalshi.co are not in that set, so reusing KalshiConfig would raise at construction. The /trade-api/v2 path component is identical, so that check is reused unchanged.
Endpoints / scope
This issue implements no REST/WS operations. It builds infrastructure only. The per-resource issues (markets, orders, order_groups, portfolio, funding, risk, exchange, transfers, subaccounts, fcm, fills, positions, trades) depend on this one.
| Method | Path | operationId | Notes |
|---|---|---|---|
| — | — | — | None. Foundation only. Resource attributes are wired as empty stubs to be filled by dependent issues. |
Models
kalshi/perps/models/common.py — shared enums and value-objects
Mirror the alias/DollarDecimal/FixedPointCount conventions in kalshi/models/common.py. Response models use model_config = ConfigDict(extra="allow", populate_by_name=True) (so server-added fields don't break parsing); enums are plain str, Enum. Confirm DollarDecimal and FixedPointCount from kalshi/types.py are reused as-is — the perps spec defines FixedPointDollars (string, up to 6 decimals; spec line 1464) and FixedPointCount (string, 2 decimals, "fp" suffix; spec line 1453) with identical semantics to the main SDK, so no new custom type is needed.
Enums (cite exact spec enum values):
BookSide(str, Enum) — specBookSide, valuesbid,ask. Perps usesbid/ask(NOT the main SDK'syes/no); do not alias to the mainSide.SelfTradePreventionType(str, Enum) — specSelfTradePreventionType, valuestaker_at_cross,maker.LastUpdateReason(str, Enum) — specLastUpdateReason, values:""(empty string — define asNONE = ""),Decrease,Amend,MarginCancel,SelfTradeCancel,ExpiryCancel,Trade,PostOnlyCrossCancel. Note the empty-string member and the PascalCase wire values (no snake_case rename — these are the literal wire strings).OrderSource(str, Enum) — specOrderSource, valuesuser,system(system= system-generated liquidation order).MarginMarketStatus(str, Enum) — specMarginMarketStatus, valuesinactive,active,closed.ExchangeInstance(str, Enum) — specExchangeInstance, valuesevent_contract,margined.
Value-objects / type aliases:
ExchangeIndex— specExchangeIndexistype: integer, default0, "only 0 currently supported". Model as a plaininttype alias (or bare field default0); not an enum.PriceLevelDollarsCountFp— specPriceLevelDollarsCountFpis a positional 2-tuple[dollars_string, fp]where element 0 is aFixedPointDollarsprice string and element 1 is aFixedPointCountcontract-count string. Model astuple[DollarDecimal, FixedPointCount]. (The existing_get_schema_fieldsreturns{}for non-object schemas, so this positional shape has no field-drift comparison — matches main SDK treatment.)EmptyResponse— specEmptyResponse, an empty object body. A model withmodel_config = ConfigDict(extra="allow")and no required fields. Used as the success response forDeleteMarginOrderGroup,ResetMarginOrderGroup,TriggerMarginOrderGroup,UpdateMarginOrderGroupLimit.ErrorResponse— specErrorResponse, optional fieldscode: str,message: str,details: str,service: str. Parsed by the transport's_map_errorpath already; included here for completeness/typed access.
Page/cursor helper: the perps list responses carry a top-level cursor: str field on the response wrapper (e.g. GetMarginOrdersResponse.cursor, GetMarginFillsResponse.cursor, GetMarginTradesResponse.cursor). The main SDK's list_all pagination reads a cursor off the response. Confirm whether the existing paginator helper in kalshi/resources/_base.py can be reused directly by perps resources or whether a thin perps-side wrapper is needed; document the decision here so dependent issues (orders/fills/trades) inherit it. Do NOT build a speculative new paginator if the existing one fits.
No request-body models live in this issue (no endpoints). Per-resource issues create their own Create*Request / Amend*Request / etc. models with model_config = {"extra": "forbid"} and serialization_alias for wire-name mismatches (e.g. count → count_fp).
Resource methods
kalshi/perps/client.py — PerpsClient
Mirror KalshiClient.__init__ (kalshi/client.py:73-166) exactly: same constructor keyword surface (key_id, private_key_path, private_key, auth, config, demo, base_url, timeout, max_retries, transport), same _auth / _auth_owned ownership rules, same from_env / ClientInitKwargs TypedDict, same close() / __enter__ / __exit__. Reuse KalshiAuth and SyncTransport unchanged — the RSA-PSS signer and transport are environment-agnostic (they sign str(ts_ms) + METHOD + path; the path component /trade-api/v2/... is identical for perps).
class PerpsClient:
def __init__(self, *, key_id: str | None = None, private_key_path: str | Path | None = None,
private_key: str | bytes | None = None, auth: KalshiAuth | None = None,
config: PerpsConfig | None = None, demo: bool = False,
base_url: str | None = None, timeout: float | None = None,
max_retries: int | None = None, transport: httpx.BaseTransport | None = None) -> None: ...
@classmethod
def from_env(cls, **kwargs: Unpack[ClientInitKwargs]) -> PerpsClient: ...
def close(self) -> None: ...Differences from KalshiClient:
- Build a
PerpsConfig(notKalshiConfig) whenconfig is None; defaultbase_urltoPERPS_PRODUCTION_BASE_URL, demo toPERPS_DEMO_BASE_URL. from_envreads a distinct env var set so perps credentials are separate from main-SDK credentials per Kalshi's "separate host + separate API keys" recommendation:KALSHI_PERPS_KEY_ID,KALSHI_PERPS_PRIVATE_KEY/KALSHI_PERPS_PRIVATE_KEY_PATH,KALSHI_PERPS_API_BASE_URL,KALSHI_PERPS_DEMO. Document in the docstring that perps requires keys issued for the perps exchange.- No
ws_base_urlplumbing in this issue (perps WS is a later issue);PerpsConfigmay carry a perps WS URL field but the client need not expose a WS client yet.
Wire resource attributes as empty stubs (placeholder classes constructed with self._transport, to be implemented by dependent issues). The attribute names, matching the spec tags and locked decision list:
self.markets = PerpsMarketsResource(self._transport) # filled by: perps markets issue
self.orders = PerpsOrdersResource(self._transport) # perps orders issue
self.order_groups = PerpsOrderGroupsResource(self._transport)
self.portfolio = PerpsPortfolioResource(self._transport) # balance, fills, positions
self.funding = PerpsFundingResource(self._transport)
self.risk = PerpsRiskResource(self._transport) # risk_parameters, risk, notional_risk_limit, fee_tiers
self.exchange = PerpsExchangeResource(self._transport) # exchange status, margin enabled
self.transfers = PerpsTransfersResource(self._transport) # intra-exchange + subaccount transfers
self.subaccounts = PerpsSubaccountsResource(self._transport)
self.fcm = PerpsFcmResource(self._transport) # GetMarginFCMOrdersEach stub is a minimal class PerpsXResource(SyncResource): ... (and AsyncPerpsXResource(AsyncResource)) with pass or a single __init__. Use builtins.list[T] annotations inside any resource method added later. The stub files live at kalshi/perps/resources/<area>.py; this issue creates them empty/minimal so the client imports resolve and mypy --strict passes.
kalshi/perps/async_client.py — AsyncPerpsClient
Mirror AsyncKalshiClient (kalshi/async_client.py): same surface, AsyncTransport, Async* resource stubs, async/await close().
Contract-test wiring
This is the enabling change every perps REST issue depends on. Today tests/test_contracts.py hard-codes a single SPEC_FILE = .../specs/openapi.yaml (line 54) and ASYNCAPI_FILE (line 481); _load_spec() (line 253) loads only that file; METHOD_ENDPOINT_MAP / BODY_MODEL_MAP (tests/_contract_support.py:100, tests/test_contracts.py:1156) assume one spec.
Approach — parallel perps map + a second drift-test class (lower-risk than threading a per-entry spec selector through every existing assertion):
- Add to
tests/test_contracts.py:PERPS_SPEC_FILE = Path(__file__).parent.parent / "specs" / "perps_openapi.yaml" PERPS_SCM_SPEC_FILE = Path(__file__).parent.parent / "specs" / "perps_scm_openapi.yaml" def _load_perps_spec() -> dict[str, Any]: ... # same body as _load_spec, PERPS_SPEC_FILE
- Add
PERPS_METHOD_ENDPOINT_MAP: list[MethodEndpointEntry]andPERPS_BODY_MODEL_MAP: dict[str, str]totests/_contract_support.py/tests/test_contracts.pyrespectively (empty in this issue — the per-resource issues append their entries). Add a parallelPERPS_EXCLUSIONSallowlist with the sameExclusion(reason, kind)shape. - Add parametrized drift-test classes
TestPerpsRequestParamDrift,TestPerpsRequestBodyDrift,TestPerpsSpecDriftthat consumePERPS_METHOD_ENDPOINT_MAP+PERPS_BODY_MODEL_MAP+_load_perps_spec(), structurally identical to the existingTestRequestParamDrift/TestRequestBodyDrift/TestSpecDrift. Refactor the shared spec-walking helpers (_resolve_ref,_resolve_schema,_get_schema_fields) to take the spec dict as a parameter (they already do) so both the main and perps test classes reuse them — no logic duplication. - SCM ("Klear") endpoints validate against
specs/perps_scm_openapi.yamlviaPERPS_SCM_SPEC_FILE/_load_perps_scm_spec(). Provide the loader + aTestPerpsScmSpecDriftskeleton; the SCM per-resource issue populates its map. - Guard with
pytest.skipwhen the perps spec files are absent (mirror_load_spec'sif not SPEC_FILE.exists(): pytest.skip(...)).
METHOD_ENDPOINT_MAP entries to add in THIS issue: none (no endpoints). The structure and an empty PERPS_METHOD_ENDPOINT_MAP = [] are what this issue ships, plus a doc comment showing dependent issues the exact entry shape, e.g.:
# Dependent perps issues append entries here, e.g.:
# MethodEndpointEntry(
# sdk_method="kalshi.perps.resources.orders.PerpsOrdersResource.create",
# http_method="POST",
# path_template="/margin/orders",
# request_body_schema="#/components/schemas/CreateMarginOrderRequest",
# ),BODY_MODEL_MAP entries: none. Ship PERPS_BODY_MODEL_MAP: dict[str, str] = {} with a comment showing the spec-ref → model-FQN shape, e.g. "#/components/schemas/CreateMarginOrderRequest": "kalshi.perps.models.orders.CreateMarginOrderRequest".
EXCLUSIONS: none required by this issue.
Spec snapshots & weekly sync
- Commit the three downloaded snapshots to
specs/perps_openapi.yaml,specs/perps_asyncapi.yaml,specs/perps_scm_openapi.yaml(copy from/tmp/perps_openapi.yaml,/tmp/perps_asyncapi.yaml,/tmp/perps_scm_openapi.yaml). Match exactly howspecs/openapi.yamlis handled — same directory, raw upstream bytes, no reformatting. - Extend
scripts/sync_spec.py'sSPECSdict to fetch the three new URLs:The existingSPECS = { "openapi.yaml": "https://docs.kalshi.com/openapi.yaml", "asyncapi.yaml": "https://docs.kalshi.com/asyncapi.yaml", "perps_openapi.yaml": "https://docs.kalshi.com/perps_openapi.yaml", "perps_asyncapi.yaml": "https://docs.kalshi.com/perps_asyncapi.yaml", "perps_scm_openapi.yaml": "https://docs.kalshi.com/perps_scm_openapi.yaml", }
sync_specs()loop already hashes each file and reports{filename: changed}, so the three new specs are covered with no further code change. - Extend
.github/workflows/spec-sync.ymlto snapshot/diff/checksum the three perps specs alongside the existing two: addhad_old_perps_openapietc. snapshot guards, extend thecmp -sdiff step, and fold the perps sha256s into the drift fingerprint so a perps-spec change opens aspec-driftissue. Preserve the workflow'scontents: read+issues: writesecurity model — do NOT add write/push scope.
Tests
tests/perps/test_client.py and tests/perps/test_config.py (new test package mirroring tests/). Reuse the conftest RSA-key fixtures from tests/conftest.py. Use respx.mock for any HTTP assertions.
PerpsConfig- happy:
PerpsConfig.production()/.demo()yield the correct perps base URLs; trailing slash stripped;/trade-api/v2path accepted. - error: a non-perps host (e.g.
api.elections.kalshi.com) raises unlessallow_unknown_host=True; a missing/trade-api/v2path component raises;http://to a remote perps host raises (plaintext-to-remote guard);KALSHI-ACCESS-*inextra_headersraises. - edge:
allow_unknown_host=True/KALSHI_PERPS_ALLOW_UNKNOWN_HOST=1env escape hatch permits a mock host with only a warning.
- happy:
PerpsClient/AsyncPerpsClientconstruction- happy:
key_id+private_key_pathbuilds an ownedKalshiAuth;auth=is caller-owned; no creds → unauthenticated client; all resource stub attributes (markets,orders,order_groups,portfolio,funding,risk,exchange,transfers,subaccounts,fcm) exist and are non-None. - error: passing both
private_key_pathandprivate_keyraises; emptykey_idraises;demo=Truewith a conflicting explicit non-demobase_urlraises (mirror kalshi/client.py:124). - edge:
from_envreadsKALSHI_PERPS_*vars and sets_auth_owned=Truewhen it built the auth;close()closes the transport and owned auth; context-manager enter/exit; asyncaclosepath.
- happy:
- common models (
tests/perps/test_models_common.py)- happy: each enum parses its exact wire values (incl.
LastUpdateReason("") == LastUpdateReason.NONEand PascalCase members;BookSidebid/ask);PriceLevelDollarsCountFpparses["0.1500", "100.00"]into(Decimal("0.1500"), Decimal("100.00"));EmptyResponseparses{};ErrorResponseparses a full error body. - error: an unknown enum value raises
ValidationError. - edge: confirm
DollarDecimal/FixedPointCountround-trip (parse"0.560000"and"10.00", serialize back viamode="json").
- happy: each enum parses its exact wire values (incl.
- contract harness (
tests/perps/test_contract_harness.pyor extendtests/test_contracts.py)_load_perps_spec()loadsspecs/perps_openapi.yamland parses;_load_perps_scm_spec()loads the SCM spec.PERPS_METHOD_ENDPOINT_MAPis empty-but-defined; the newTestPerps*Driftclasses collect and pass (vacuously) so dependent PRs can append entries; skip cleanly when spec files are absent.
Acceptance criteria
-
PerpsClientandAsyncPerpsClientimplemented inkalshi/perps/client.py/kalshi/perps/async_client.py, mirroringKalshiClientconstruction and reusingKalshiAuth+SyncTransport/AsyncTransportunchanged. -
PerpsConfigimplemented with perps prod/demo base URLs (https://external-api.kalshi.com/trade-api/v2,https://external-api.demo.kalshi.co/trade-api/v2), its own known-hosts set,.production()/.demo()factories, and the same security guards asKalshiConfig. -
from_envreads the separateKALSHI_PERPS_*credential env vars; docstrings document Kalshi's separate-host + separate-keys recommendation. - Package skeleton created:
kalshi/perps/__init__.py,kalshi/perps/models/__init__.py,kalshi/perps/models/common.py,kalshi/perps/resources/__init__.py, plus empty/minimal resource stub modules (markets,orders,order_groups,portfolio,funding,risk,exchange,transfers,subaccounts,fcm) with both sync and async stub classes wired onto the clients. - Common enums/value-objects implemented in
kalshi/perps/models/common.py:BookSide,SelfTradePreventionType,LastUpdateReason,OrderSource,MarginMarketStatus,ExchangeInstance,ExchangeIndex,PriceLevelDollarsCountFp,EmptyResponse,ErrorResponse;DollarDecimal+FixedPointCountconfirmed reusable fromkalshi/types.py. -
PerpsClient,AsyncPerpsClient,PerpsConfigexported fromkalshi/perps/__init__.pyand re-exported fromkalshi/__init__.py(__all__updated). - Three perps specs committed under
specs/perps_openapi.yaml,specs/perps_asyncapi.yaml,specs/perps_scm_openapi.yaml;scripts/sync_spec.pySPECSextended;.github/workflows/spec-sync.ymlsnapshots/diffs/checksums them while preservingcontents: read+issues: write. - Contract-test harness parameterized:
_load_perps_spec()/_load_perps_scm_spec(), empty-but-definedPERPS_METHOD_ENDPOINT_MAP/PERPS_BODY_MODEL_MAP/PERPS_EXCLUSIONS, andTestPerps*Driftclasses that dependent issues populate; shared spec-walk helpers reused (not duplicated). -
mypy kalshi/(strict) clean —builtins.list[T]used inside any resource class; noAnyleaks. -
ruff check .clean. -
pytest tests/ -vgreen, including the existing drift suites and the new vacuous perps drift classes.
Dependencies
None. This is the dependency root for all perps issues.
Activity