Skip to content

perps: client foundation — PerpsClient/AsyncPerpsClient, PerpsConfig, spec sync & contract-test harness #388

Description

@TexasCoding

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) — spec BookSide, values bid, ask. Perps uses bid/ask (NOT the main SDK's yes/no); do not alias to the main Side.
  • SelfTradePreventionType (str, Enum) — spec SelfTradePreventionType, values taker_at_cross, maker.
  • LastUpdateReason (str, Enum) — spec LastUpdateReason, values: "" (empty string — define as NONE = ""), 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) — spec OrderSource, values user, system (system = system-generated liquidation order).
  • MarginMarketStatus (str, Enum) — spec MarginMarketStatus, values inactive, active, closed.
  • ExchangeInstance (str, Enum) — spec ExchangeInstance, values event_contract, margined.

Value-objects / type aliases:

  • ExchangeIndex — spec ExchangeIndex is type: integer, default 0, "only 0 currently supported". Model as a plain int type alias (or bare field default 0); not an enum.
  • PriceLevelDollarsCountFp — spec PriceLevelDollarsCountFp is a positional 2-tuple [dollars_string, fp] where element 0 is a FixedPointDollars price string and element 1 is a FixedPointCount contract-count string. Model as tuple[DollarDecimal, FixedPointCount]. (The existing _get_schema_fields returns {} for non-object schemas, so this positional shape has no field-drift comparison — matches main SDK treatment.)
  • EmptyResponse — spec EmptyResponse, an empty object body. A model with model_config = ConfigDict(extra="allow") and no required fields. Used as the success response for DeleteMarginOrderGroup, ResetMarginOrderGroup, TriggerMarginOrderGroup, UpdateMarginOrderGroupLimit.
  • ErrorResponse — spec ErrorResponse, optional fields code: str, message: str, details: str, service: str. Parsed by the transport's _map_error path 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. countcount_fp).

Resource methods

kalshi/perps/client.pyPerpsClient

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 (not KalshiConfig) when config is None; default base_url to PERPS_PRODUCTION_BASE_URL, demo to PERPS_DEMO_BASE_URL.
  • from_env reads 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_url plumbing in this issue (perps WS is a later issue); PerpsConfig may 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)                # GetMarginFCMOrders

Each 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.pyAsyncPerpsClient

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

  1. 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
  2. Add PERPS_METHOD_ENDPOINT_MAP: list[MethodEndpointEntry] and PERPS_BODY_MODEL_MAP: dict[str, str] to tests/_contract_support.py / tests/test_contracts.py respectively (empty in this issue — the per-resource issues append their entries). Add a parallel PERPS_EXCLUSIONS allowlist with the same Exclusion(reason, kind) shape.
  3. Add parametrized drift-test classes TestPerpsRequestParamDrift, TestPerpsRequestBodyDrift, TestPerpsSpecDrift that consume PERPS_METHOD_ENDPOINT_MAP + PERPS_BODY_MODEL_MAP + _load_perps_spec(), structurally identical to the existing TestRequestParamDrift / 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.
  4. SCM ("Klear") endpoints validate against specs/perps_scm_openapi.yaml via PERPS_SCM_SPEC_FILE / _load_perps_scm_spec(). Provide the loader + a TestPerpsScmSpecDrift skeleton; the SCM per-resource issue populates its map.
  5. Guard with pytest.skip when the perps spec files are absent (mirror _load_spec's if 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

  1. 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 how specs/openapi.yaml is handled — same directory, raw upstream bytes, no reformatting.
  2. Extend scripts/sync_spec.py's SPECS dict to fetch the three new URLs:
    SPECS = {
        "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",
    }
    The existing sync_specs() loop already hashes each file and reports {filename: changed}, so the three new specs are covered with no further code change.
  3. Extend .github/workflows/spec-sync.yml to snapshot/diff/checksum the three perps specs alongside the existing two: add had_old_perps_openapi etc. snapshot guards, extend the cmp -s diff step, and fold the perps sha256s into the drift fingerprint so a perps-spec change opens a spec-drift issue. Preserve the workflow's contents: read + issues: write security 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/v2 path accepted.
    • error: a non-perps host (e.g. api.elections.kalshi.com) raises unless allow_unknown_host=True; a missing /trade-api/v2 path component raises; http:// to a remote perps host raises (plaintext-to-remote guard); KALSHI-ACCESS-* in extra_headers raises.
    • edge: allow_unknown_host=True / KALSHI_PERPS_ALLOW_UNKNOWN_HOST=1 env escape hatch permits a mock host with only a warning.
  • PerpsClient / AsyncPerpsClient construction
    • happy: key_id + private_key_path builds an owned KalshiAuth; 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_path and private_key raises; empty key_id raises; demo=True with a conflicting explicit non-demo base_url raises (mirror kalshi/client.py:124).
    • edge: from_env reads KALSHI_PERPS_* vars and sets _auth_owned=True when it built the auth; close() closes the transport and owned auth; context-manager enter/exit; async aclose path.
  • common models (tests/perps/test_models_common.py)
    • happy: each enum parses its exact wire values (incl. LastUpdateReason("") == LastUpdateReason.NONE and PascalCase members; BookSide bid/ask); PriceLevelDollarsCountFp parses ["0.1500", "100.00"] into (Decimal("0.1500"), Decimal("100.00")); EmptyResponse parses {}; ErrorResponse parses a full error body.
    • error: an unknown enum value raises ValidationError.
    • edge: confirm DollarDecimal/FixedPointCount round-trip (parse "0.560000" and "10.00", serialize back via mode="json").
  • contract harness (tests/perps/test_contract_harness.py or extend tests/test_contracts.py)
    • _load_perps_spec() loads specs/perps_openapi.yaml and parses; _load_perps_scm_spec() loads the SCM spec.
    • PERPS_METHOD_ENDPOINT_MAP is empty-but-defined; the new TestPerps*Drift classes collect and pass (vacuously) so dependent PRs can append entries; skip cleanly when spec files are absent.

Acceptance criteria

  • PerpsClient and AsyncPerpsClient implemented in kalshi/perps/client.py / kalshi/perps/async_client.py, mirroring KalshiClient construction and reusing KalshiAuth + SyncTransport/AsyncTransport unchanged.
  • PerpsConfig implemented 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 as KalshiConfig.
  • from_env reads the separate KALSHI_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 + FixedPointCount confirmed reusable from kalshi/types.py.
  • PerpsClient, AsyncPerpsClient, PerpsConfig exported from kalshi/perps/__init__.py and re-exported from kalshi/__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.py SPECS extended; .github/workflows/spec-sync.yml snapshots/diffs/checksums them while preserving contents: read + issues: write.
  • Contract-test harness parameterized: _load_perps_spec() / _load_perps_scm_spec(), empty-but-defined PERPS_METHOD_ENDPOINT_MAP / PERPS_BODY_MODEL_MAP / PERPS_EXCLUSIONS, and TestPerps*Drift classes that dependent issues populate; shared spec-walk helpers reused (not duplicated).
  • mypy kalshi/ (strict) clean — builtins.list[T] used inside any resource class; no Any leaks.
  • ruff check . clean.
  • pytest tests/ -v green, including the existing drift suites and the new vacuous perps drift classes.

Dependencies

None. This is the dependency root for all perps issues.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestinfraInfrastructure/toolingperpsPerps / margin (perpetual futures) API

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions