You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 201CreateOrderGroupResponse.
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.
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.
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).
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._...):
deflist(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).defget(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=...defcreate(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)defdelete(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=...)defreset(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={}deftrigger(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=...defupdate_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):
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:
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.
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.
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 existingkalshi/resources/order_groups.py(which targets/portfolio/order_groups); the margin variant lives under/margin/order_groupson the perps base URL and reuses the same RSA-PSS auth + transport. Mirrorkalshi/models/order_groups.pyandkalshi/resources/order_groups.pyclosely — the schemas are near-identical (the existing event-contractCreateOrderGroupResponsealready carries a requiredsubaccount). Verify each model field against/tmp/perps_openapi.yamlrather than assuming parity; the perpsCreateOrderGroupResponserequiresorder_group_id+subaccountand adds an optionalexchange_index.Endpoints / scope
/margin/order_groupsGetMarginOrderGroupsSubaccountQuery(optionalsubaccount, int ≥0). ResponseGetOrderGroupsResponsehas no cursor → return a plainbuiltins.list[OrderGroup], nolist_all()./margin/order_groups/createCreateMarginOrderGroupCreateOrderGroupRequest(forbid). POST path is/create, not/margin/order_groups. Returns201CreateOrderGroupResponse./margin/order_groups/{order_group_id}GetMarginOrderGroupOrderGroupIdPath+SubaccountQuery. ReturnsGetOrderGroupResponse./margin/order_groups/{order_group_id}DeleteMarginOrderGroupOrderGroupIdPath+SubaccountQueryDefaultPrimary. Returns200EmptyResponse→ method returnsNone./margin/order_groups/{order_group_id}/resetResetMarginOrderGroupOrderGroupIdPath+SubaccountQueryDefaultPrimary.requestBodyis optional (EmptyResponseschema). Sendjson={}to forceContent-Type: application/json. ReturnsNone./margin/order_groups/{order_group_id}/triggerTriggerMarginOrderGroupOrderGroupIdPath+SubaccountQueryDefaultPrimary.requestBodyoptional (EmptyResponse). Sendjson={}. ReturnsNone./margin/order_groups/{order_group_id}/limitUpdateMarginOrderGroupLimitOrderGroupIdPath+SubaccountQueryDefaultPrimary. BodyUpdateOrderGroupLimitRequest(forbid, required). Returns200EmptyResponse→None.Models
New file:
kalshi/perps/models/order_groups.py(module docstring: "Perps (margin) order groups — rolling 15-second contracts-limit groups for linked orders."). ImportFixedPointCount,NullableList,StrictIntfromkalshi.types. No enums in this area.OrderGroup(list-response entry; specOrderGroup, 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; specGetOrderGroupResponse, 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(specCreateOrderGroupResponse, 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; specCreateOrderGroupRequest, 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/limitbody, forbid; specUpdateOrderGroupLimitRequest,contracts_limitoptional):contracts_limit: StrictInt = Field(..., ge=1)(specinteger minimum:1; ignore thecontracts_limit_fpstring variant)model_config = {"extra": "forbid"}Resource methods
New file:
kalshi/perps/resources/order_groups.py. Define bothOrderGroupsResource(SyncResource)andAsyncOrderGroupsResource(AsyncResource), importingSyncResource,AsyncResource,_check_request_exclusive,_params,_segfromkalshi.resources._base(reuse the existing base — do NOT fork it).import builtinsand annotate the list return asbuiltins.list[OrderGroup](thelistbuiltin 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 viarequest.model_dump(exclude_none=True, by_alias=True, mode="json"). Every method callsself._require_auth()first.Sync signatures (async mirror with
async def/await self._...):Contract-test wiring
The foundation issue must first parameterize the drift harness to load the perps spec from
specs/perps_openapi.yamland to scope perpsMETHOD_ENDPOINT_MAPentries against it. Add these entries (sync FQNs only — async siblings derive viaAsync<ClassName>substitution):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-specBODY_MODEL_MAPkeyed to the perps spec). Add there: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.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).Tests
New file
tests/perps/test_order_groups.py(createtests/perps/__init__.pyif the foundation issue hasn't). Userespx.mock; reuse the conftest RSA-key / auth / config fixtures (the foundation issue provides aperps_client/async_perps_clientfixture 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._require_auth()+ auth reuse.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).PerpsClient.order_groups/AsyncPerpsClient.order_groups.kalshi/perps/__init__.pyand re-exported fromkalshi/__init__.py.builtins.list[OrderGroup]used inside resource classes (no barelist[T]).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
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.