Skip to content

perps: order groups resource — create/get/list/delete/reset/trigger/update-limit #392

Closed

Description

@TexasCoding

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

Summary

Implement the margin (PERPS) order-groups resource on the new standalone PerpsClient / AsyncPerpsClient. Order groups are rolling-15-second contracts-limit buckets: when the matched-contracts counter hits the limit the whole group's orders are cancelled (auto-cancel) and no new orders place until reset. This is the margin-exchange twin of the existing kalshi/resources/order_groups.py (which targets /portfolio/order_groups); the margin variant lives under /margin/order_groups on the perps base URL and reuses the same RSA-PSS auth + transport. Mirror kalshi/models/order_groups.py and kalshi/resources/order_groups.py closely — the schemas are near-identical (the existing event-contract CreateOrderGroupResponse already carries a required subaccount). Verify each model field against /tmp/perps_openapi.yaml rather than assuming parity; the perps CreateOrderGroupResponse requires order_group_id + subaccount and adds an optional exchange_index.

Endpoints / scope

Method Path operationId Notes
GET /margin/order_groups GetMarginOrderGroups SubaccountQuery (optional subaccount, int ≥0). Response GetOrderGroupsResponse has no cursor → return a plain builtins.list[OrderGroup], no list_all().
POST /margin/order_groups/create CreateMarginOrderGroup Body CreateOrderGroupRequest (forbid). POST path is /create, not /margin/order_groups. Returns 201 CreateOrderGroupResponse.
GET /margin/order_groups/{order_group_id} GetMarginOrderGroup OrderGroupIdPath + SubaccountQuery. Returns GetOrderGroupResponse.
DELETE /margin/order_groups/{order_group_id} DeleteMarginOrderGroup OrderGroupIdPath + SubaccountQueryDefaultPrimary. Returns 200 EmptyResponse → method returns None.
PUT /margin/order_groups/{order_group_id}/reset ResetMarginOrderGroup OrderGroupIdPath + SubaccountQueryDefaultPrimary. requestBody is optional (EmptyResponse schema). Send json={} to force Content-Type: application/json. Returns None.
PUT /margin/order_groups/{order_group_id}/trigger TriggerMarginOrderGroup OrderGroupIdPath + SubaccountQueryDefaultPrimary. requestBody optional (EmptyResponse). Send json={}. Returns None.
PUT /margin/order_groups/{order_group_id}/limit UpdateMarginOrderGroupLimit OrderGroupIdPath + SubaccountQueryDefaultPrimary. Body UpdateOrderGroupLimitRequest (forbid, required). Returns 200 EmptyResponse → None.

Spec-verified deltas vs. the existing portfolio resource:

  • DELETE/reset/trigger/limit all carry SubaccountQueryDefaultPrimary in the perps spec (defaults to 0). The existing portfolio update_limit deliberately had no subaccount kwarg — the perps /limit endpoint DOES list subaccount, so the perps update_limit MUST accept subaccount. Do not copy the portfolio "no subaccount on /limit" comment.
  • reset & trigger requestBody is required: false with an EmptyResponse schema (no fields). Treat as no-body PUTs; do not register a request body model for them.

Models

New file: kalshi/perps/models/order_groups.py (module docstring: "Perps (margin) order groups — rolling 15-second contracts-limit groups for linked orders."). Import FixedPointCount, NullableList, StrictInt from kalshi.types. No enums in this area.

OrderGroup (list-response entry; spec OrderGroup, required: id, is_auto_cancel_enabled):

  • id: str
  • contracts_limit: FixedPointCount | None — validation_alias=AliasChoices("contracts_limit_fp", "contracts_limit"), default=None. Spec exposes only contracts_limit_fp (a FixedPointCount string, 2-decimal "fp"); keep the short Python name + alias for parity with the portfolio model.
  • is_auto_cancel_enabled: bool
  • exchange_index: int | None = None (spec ExchangeIndex, integer, default 0)
  • model_config = {"extra": "allow", "populate_by_name": True}

GetOrderGroupResponse (single-group; spec GetOrderGroupResponse, required: is_auto_cancel_enabled, orders):

  • is_auto_cancel_enabled: bool
  • orders: NullableList[str] (spec orders: array of order-ID strings)
  • contracts_limit: FixedPointCount | None — validation_alias=AliasChoices("contracts_limit_fp", "contracts_limit"), default=None
  • exchange_index: int | None = None
  • model_config = {"extra": "allow", "populate_by_name": True}

CreateOrderGroupResponse (spec CreateOrderGroupResponse, required: order_group_id, subaccount):

  • order_group_id: str
  • subaccount: int — required in the perps spec (0 for primary, 1–32)
  • exchange_index: int | None = None
  • model_config = {"extra": "allow"}

CreateOrderGroupRequest (POST body, forbid; spec CreateOrderGroupRequest, all properties optional):

  • contracts_limit: StrictInt = Field(..., ge=1) — spec contracts_limit is integer minimum:1. SDK sends the integer form; the spec's alternate contracts_limit_fp string variant is unused (document this in the model docstring, mirroring the portfolio model). Make contracts_limit required at the SDK level even though the spec marks it optional (matches portfolio model behavior).
  • subaccount: StrictInt | None = Field(default=None, ge=0) (spec subaccount integer ≥0, default 0)
  • exchange_index: StrictInt | None = None (spec exchange_index, default 0)
  • model_config = {"extra": "forbid"}

UpdateOrderGroupLimitRequest (PUT /limit body, forbid; spec UpdateOrderGroupLimitRequest, contracts_limit optional):

  • contracts_limit: StrictInt = Field(..., ge=1) (spec integer minimum:1; ignore the contracts_limit_fp string variant)
  • model_config = {"extra": "forbid"}

EmptyResponse (spec) has no fields and is the response for delete/reset/trigger/limit — do not create a Pydantic model for it; those methods return None.

FixedPointDollars / DollarDecimal is not used in this resource (order groups have no price fields, only FixedPointCount).

Resource methods

New file: kalshi/perps/resources/order_groups.py. Define both OrderGroupsResource(SyncResource) and AsyncOrderGroupsResource(AsyncResource), importing SyncResource, AsyncResource, _check_request_exclusive, _params, _seg from kalshi.resources._base (reuse the existing base — do NOT fork it). import builtins and annotate the list return as builtins.list[OrderGroup] (the list builtin is shadowed by the .list() method). Reuse the two shared body-builder helpers from the portfolio module's pattern (_build_create_order_group_body, _build_update_limit_body), defined locally in this file; both serialize via request.model_dump(exclude_none=True, by_alias=True, mode="json"). Every method calls self._require_auth() first.

Sync signatures (async mirror with async def / await self._...):

def list(self, *, subaccount: int | None = None,
         extra_headers: dict[str, str] | None = None) -> builtins.list[OrderGroup]
# GET /margin/order_groups; reads data["order_groups"], validates each into OrderGroup. No Page/list_all (response has no cursor).

def get(self, order_group_id: str, *, subaccount: int | None = None,
        extra_headers: dict[str, str] | None = None) -> GetOrderGroupResponse
# GET /margin/order_groups/{_seg(order_group_id, name="order_group_id")}

@overload  # request=...
@overload  # contracts_limit=..., subaccount=..., exchange_index=...
def create(self, *, request: CreateOrderGroupRequest | None = None,
           contracts_limit: int | None = None, subaccount: int | None = None,
           exchange_index: int | None = None,
           extra_headers: dict[str, str] | None = None) -> CreateOrderGroupResponse
# POST /margin/order_groups/create  (note the /create suffix)

def delete(self, order_group_id: str, *, subaccount: int | None = None,
           exchange_index: int | None = None,
           extra_headers: dict[str, str] | None = None) -> None
# DELETE /margin/order_groups/{order_group_id}; params = _params(subaccount=..., exchange_index=...)

def reset(self, order_group_id: str, *, subaccount: int | None = None,
          extra_headers: dict[str, str] | None = None) -> None
# PUT /margin/order_groups/{order_group_id}/reset; params=_params(subaccount=...), json={}

def trigger(self, order_group_id: str, *, subaccount: int | None = None,
            extra_headers: dict[str, str] | None = None) -> None
# PUT /margin/order_groups/{order_group_id}/trigger; params=_params(subaccount=...), json={}

@overload  # request=...
@overload  # contracts_limit=...
def update_limit(self, order_group_id: str, *,
                 request: UpdateOrderGroupLimitRequest | None = None,
                 contracts_limit: int | None = None,
                 subaccount: int | None = None,
                 extra_headers: dict[str, str] | None = None) -> None
# PUT /margin/order_groups/{order_group_id}/limit; json=body, params=_params(subaccount=...)
# NOTE: unlike the portfolio resource, perps /limit DOES carry SubaccountQueryDefaultPrimary — include subaccount.

Pagination: none. GetOrderGroupsResponse carries no cursor, so list() returns a plain builtins.list — no list_all() / AsyncIterator.

The perps resource is wired onto PerpsClient / AsyncPerpsClient (provided by the foundation issue) as client.order_groups. Do NOT add it to KalshiClient.

Contract-test wiring

The foundation issue must first parameterize the drift harness to load the perps spec from specs/perps_openapi.yaml and to scope perps METHOD_ENDPOINT_MAP entries against it. Add these entries (sync FQNs only — async siblings derive via Async<ClassName> substitution):

# ── perps order groups ──────────────────────────────────────────────
MethodEndpointEntry(
    sdk_method="kalshi.perps.resources.order_groups.OrderGroupsResource.list",
    http_method="GET", path_template="/margin/order_groups"),
MethodEndpointEntry(
    sdk_method="kalshi.perps.resources.order_groups.OrderGroupsResource.create",
    http_method="POST", path_template="/margin/order_groups/create",
    request_body_schema="#/components/schemas/CreateOrderGroupRequest"),
MethodEndpointEntry(
    sdk_method="kalshi.perps.resources.order_groups.OrderGroupsResource.get",
    http_method="GET", path_template="/margin/order_groups/{order_group_id}"),
MethodEndpointEntry(
    sdk_method="kalshi.perps.resources.order_groups.OrderGroupsResource.delete",
    http_method="DELETE", path_template="/margin/order_groups/{order_group_id}"),
MethodEndpointEntry(
    sdk_method="kalshi.perps.resources.order_groups.OrderGroupsResource.reset",
    http_method="PUT", path_template="/margin/order_groups/{order_group_id}/reset"),
MethodEndpointEntry(
    sdk_method="kalshi.perps.resources.order_groups.OrderGroupsResource.trigger",
    http_method="PUT", path_template="/margin/order_groups/{order_group_id}/trigger"),
MethodEndpointEntry(
    sdk_method="kalshi.perps.resources.order_groups.OrderGroupsResource.update_limit",
    http_method="PUT", path_template="/margin/order_groups/{order_group_id}/limit",
    request_body_schema="#/components/schemas/UpdateOrderGroupLimitRequest"),

BODY_MODEL_MAP (tests/test_contracts.py) — perps schema refs collide by name with the portfolio refs (#/components/schemas/CreateOrderGroupRequest), so the perps-spec entries MUST live in a perps-scoped body map (the foundation issue introduces a per-spec BODY_MODEL_MAP keyed to the perps spec). Add there:

"#/components/schemas/CreateOrderGroupRequest":
    "kalshi.perps.models.order_groups.CreateOrderGroupRequest",
"#/components/schemas/UpdateOrderGroupLimitRequest":
    "kalshi.perps.models.order_groups.UpdateOrderGroupLimitRequest",

EXCLUSIONS (tests/_contract_support.py), in the perps-scoped exclusion set:

  • ("kalshi.perps.models.order_groups.CreateOrderGroupRequest", "contracts_limit") → kind="wire_normalization", reason: "SDK sends integer contracts_limit; spec also defines a contracts_limit_fp string variant the SDK does not emit. Mirrors portfolio order-groups handling."
  • ("kalshi.perps.models.order_groups.UpdateOrderGroupLimitRequest", "contracts_limit") → kind="wire_normalization", same reason as above for the _fp variant on /limit.
  • If the body drift check flags subaccount/exchange_index on CreateOrderGroupRequest as body params on a POST, classify with kind="body_param" per existing portfolio precedent (only if the check actually fires — verify against the run, don't add speculatively).

reset & trigger have an optional EmptyResponse requestBody (no properties) — register them WITHOUT request_body_schema. The body drift check skips entries with request_body_schema=None, so an empty body produces no drift.

Tests

New file tests/perps/test_order_groups.py (create tests/perps/__init__.py if the foundation issue hasn't). Use respx.mock; reuse the conftest RSA-key / auth / config fixtures (the foundation issue provides a perps_client / async_perps_client fixture pointed at the demo perps base URL). Per public method:

  • list — happy: mocked GetOrderGroupsResponse with 2 order_groups → returns list[OrderGroup], contracts_limit parsed from contracts_limit_fp. Edge: empty/absent order_groups → []. Edge: subaccount passed → asserts query param. Error: 401 → AuthenticationError (or the SDK's mapped exception).
  • get — happy: GetOrderGroupResponse with orders populated + is_auto_cancel_enabled. Edge: orders: null → NullableList coerces to []. Error: 404 → not-found exception.
  • create — happy via kwargs (contracts_limit=10) → asserts POST hits /margin/order_groups/create, body {"contracts_limit": 10}, response carries order_group_id + required subaccount. Happy via request=CreateOrderGroupRequest(...). Edge: subaccount/exchange_index serialized. Error: passing both request and contracts_limit → _check_request_exclusive raises; passing neither → TypeError. Error: phantom kwarg on CreateOrderGroupRequest → extra=forbid ValidationError. Error: 400 → bad-request exception.
  • delete — happy: 200 {} → returns None, asserts DELETE path + subaccount/exchange_index query. Error: 404.
  • reset — happy: asserts PUT /reset, json={} sent (Content-Type present), subaccount query. Error: 404.
  • trigger — happy: asserts PUT /trigger, json={}, subaccount query. Error: 404.
  • update_limit — happy via kwargs (contracts_limit=25) → asserts PUT /limit, body {"contracts_limit": 25}, subaccount query present. Happy via request=. Error: both/neither → raises. Error: 400.
  • For each, assert the request was signed (KALSHI-ACCESS-* headers present) to confirm _require_auth() + auth reuse.
  • Add an async test for at least list, create, update_limit to exercise AsyncOrderGroupsResource.

Acceptance criteria

  • kalshi/perps/models/order_groups.py created with OrderGroup, GetOrderGroupResponse, CreateOrderGroupResponse (with required subaccount), CreateOrderGroupRequest (forbid), UpdateOrderGroupLimitRequest (forbid).
  • kalshi/perps/resources/order_groups.py created with OrderGroupsResource + AsyncOrderGroupsResource implementing list, get, create, delete, reset, trigger, update_limit (perps update_limit accepts subaccount).
  • Wired onto PerpsClient.order_groups / AsyncPerpsClient.order_groups.
  • Models exported from kalshi/perps/__init__.py and re-exported from kalshi/__init__.py.
  • builtins.list[OrderGroup] used inside resource classes (no bare list[T]).
  • No inline dict bodies — create & update_limit serialize a Pydantic model via model_dump(exclude_none=True, by_alias=True, mode="json").
  • METHOD_ENDPOINT_MAP (perps-scoped) + perps BODY_MODEL_MAP updated; any EXCLUSIONS carry a reason + kind.
  • uv run mypy kalshi/ strict clean.
  • uv run ruff check . clean.
  • uv run pytest tests/ -v green, including TestRequestParamDrift and TestRequestBodyDrift over the perps spec.

Dependencies

  • perps: foundation — must provide PerpsClient/AsyncPerpsClient, PerpsConfig, perps base URLs, the committed specs/perps_openapi.yaml, the parameterized contract-test harness (per-spec METHOD_ENDPOINT_MAP scoping + per-spec BODY_MODEL_MAP + perps EXCLUSIONS set), kalshi/perps/__init__.py, kalshi/perps/models/__init__.py, kalshi/perps/resources/__init__.py, and the tests/perps/ package + perps_client fixtures.

Activity

  1. added this to the Perps API milestone on Jun 4, 2026
  2. added
    enhancementNew feature or request
    perpsPerps / margin (perpetual futures) API
    on Jun 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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