Skip to content

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

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 EmptyResponseNone.

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 | Nonevalidation_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 | Nonevalidation_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: intrequired 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: nullNullableList 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 CreateOrderGroupRequestextra=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.

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