Repository navigation
Description
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
SubaccountQueryDefaultPrimaryin the perps spec (defaults to 0). The existing portfolioupdate_limitdeliberately had nosubaccountkwarg — the perps/limitendpoint DOES listsubaccount, so the perpsupdate_limitMUST acceptsubaccount. Do not copy the portfolio "no subaccount on /limit" comment.- reset & trigger requestBody is
required: falsewith anEmptyResponseschema (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: strcontracts_limit: FixedPointCount | None—validation_alias=AliasChoices("contracts_limit_fp", "contracts_limit"),default=None. Spec exposes onlycontracts_limit_fp(aFixedPointCountstring, 2-decimal "fp"); keep the short Python name + alias for parity with the portfolio model.is_auto_cancel_enabled: boolexchange_index: int | None = None(specExchangeIndex, 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: boolorders: NullableList[str](specorders: array of order-ID strings)contracts_limit: FixedPointCount | None—validation_alias=AliasChoices("contracts_limit_fp", "contracts_limit"),default=Noneexchange_index: int | None = Nonemodel_config = {"extra": "allow", "populate_by_name": True}
CreateOrderGroupResponse (spec CreateOrderGroupResponse, required: order_group_id, subaccount):
order_group_id: strsubaccount: int— required in the perps spec (0 for primary, 1–32)exchange_index: int | None = Nonemodel_config = {"extra": "allow"}
CreateOrderGroupRequest (POST body, forbid; spec CreateOrderGroupRequest, all properties optional):
contracts_limit: StrictInt = Field(..., ge=1)— speccontracts_limitisinteger minimum:1. SDK sends the integer form; the spec's alternatecontracts_limit_fpstring variant is unused (document this in the model docstring, mirroring the portfolio model). Makecontracts_limitrequired at the SDK level even though the spec marks it optional (matches portfolio model behavior).subaccount: StrictInt | None = Field(default=None, ge=0)(specsubaccountinteger ≥0, default 0)exchange_index: StrictInt | None = None(specexchange_index, default 0)model_config = {"extra": "forbid"}
UpdateOrderGroupLimitRequest (PUT /limit body, forbid; spec UpdateOrderGroupLimitRequest, contracts_limit optional):
contracts_limit: StrictInt = Field(..., ge=1)(specinteger minimum:1; ignore thecontracts_limit_fpstring 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 returnNone.
FixedPointDollars/DollarDecimalis not used in this resource (order groups have no price fields, onlyFixedPointCount).
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.
GetOrderGroupsResponsecarries no cursor, solist()returns a plainbuiltins.list— nolist_all()/AsyncIterator.
The perps resource is wired onto
PerpsClient/AsyncPerpsClient(provided by the foundation issue) asclient.order_groups. Do NOT add it toKalshiClient.
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 integercontracts_limit; spec also defines acontracts_limit_fpstring 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_fpvariant on/limit.- If the body drift check flags
subaccount/exchange_indexonCreateOrderGroupRequestas body params on a POST, classify withkind="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
EmptyResponserequestBody (no properties) — register them WITHOUTrequest_body_schema. The body drift check skips entries withrequest_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: mockedGetOrderGroupsResponsewith 2order_groups→ returnslist[OrderGroup],contracts_limitparsed fromcontracts_limit_fp. Edge: empty/absentorder_groups→[]. Edge:subaccountpassed → asserts query param. Error: 401 →AuthenticationError(or the SDK's mapped exception).get— happy:GetOrderGroupResponsewithorderspopulated +is_auto_cancel_enabled. Edge:orders: null→NullableListcoerces 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 carriesorder_group_id+ requiredsubaccount. Happy viarequest=CreateOrderGroupRequest(...). Edge:subaccount/exchange_indexserialized. Error: passing bothrequestandcontracts_limit→_check_request_exclusiveraises; passing neither →TypeError. Error: phantom kwarg onCreateOrderGroupRequest→extra=forbidValidationError. Error: 400 → bad-request exception.delete— happy: 200{}→ returnsNone, asserts DELETE path +subaccount/exchange_indexquery. Error: 404.reset— happy: asserts PUT/reset,json={}sent (Content-Type present),subaccountquery. Error: 404.trigger— happy: asserts PUT/trigger,json={},subaccountquery. Error: 404.update_limit— happy via kwargs (contracts_limit=25) → asserts PUT/limit, body{"contracts_limit": 25},subaccountquery present. Happy viarequest=. 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_limitto exerciseAsyncOrderGroupsResource.
Acceptance criteria
-
kalshi/perps/models/order_groups.pycreated withOrderGroup,GetOrderGroupResponse,CreateOrderGroupResponse(with requiredsubaccount),CreateOrderGroupRequest(forbid),UpdateOrderGroupLimitRequest(forbid). -
kalshi/perps/resources/order_groups.pycreated withOrderGroupsResource+AsyncOrderGroupsResourceimplementinglist,get,create,delete,reset,trigger,update_limit(perpsupdate_limitacceptssubaccount). - Wired onto
PerpsClient.order_groups/AsyncPerpsClient.order_groups. - Models exported from
kalshi/perps/__init__.pyand re-exported fromkalshi/__init__.py. -
builtins.list[OrderGroup]used inside resource classes (no barelist[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) + perpsBODY_MODEL_MAPupdated; any EXCLUSIONS carry areason+kind. -
uv run mypy kalshi/strict clean. -
uv run ruff check .clean. -
uv run pytest tests/ -vgreen, includingTestRequestParamDriftandTestRequestBodyDriftover the perps spec.
Dependencies
- perps: foundation — must provide
PerpsClient/AsyncPerpsClient,PerpsConfig, perps base URLs, the committedspecs/perps_openapi.yaml, the parameterized contract-test harness (per-specMETHOD_ENDPOINT_MAPscoping + per-specBODY_MODEL_MAP+ perps EXCLUSIONS set),kalshi/perps/__init__.py,kalshi/perps/models/__init__.py,kalshi/perps/resources/__init__.py, and thetests/perps/package +perps_clientfixtures.
Activity