Repository navigation
Description
Summary
Implement the PERPS (margin) orders resource on the standalone PerpsClient / AsyncPerpsClient: create, get, list (paginated), cancel, decrease, and amend margin orders, plus the FCM-member listing endpoint (/margin/fcm/orders). This is the trade-execution surface of the perps API; it mirrors the existing event-market V2 order family in kalshi/resources/orders.py but lives under the new kalshi/perps/ packages and talks to the perps base URL. Source of truth: specs/perps_openapi.yaml (committed by the foundation issue from /tmp/perps_openapi.yaml), schemas at lines 1652–1951, paths at lines 62–308.
Endpoints / scope
| Method | Path | operationId | Notes |
|---|---|---|---|
| POST | /margin/orders |
CreateMarginOrder |
Body CreateMarginOrderRequest. NOT retried (duplicate-order risk). Returns 201 CreateMarginOrderResponse. |
| GET | /margin/orders |
GetMarginOrders |
Cursor-paginated → GetMarginOrdersResponse. Query: ticker, min_ts, max_ts, status, limit (1–1000, default 100), cursor, subaccount (≥0). |
| GET | /margin/orders/{order_id} |
GetMarginOrder |
→ GetMarginOrderResponse ({order}). |
| DELETE | /margin/orders/{order_id} |
CancelMarginOrder |
NOT retried (cancel-idempotency risk). Query: subaccount (default 0). → CancelMarginOrderResponse. |
| POST | /margin/orders/{order_id}/decrease |
DecreaseMarginOrder |
NOT retried. Body DecreaseMarginOrderRequest (XOR reduce_by/reduce_to). Query subaccount (default 0). → DecreaseMarginOrderResponse. |
| POST | /margin/orders/{order_id}/amend |
AmendMarginOrder |
NOT retried. Body AmendMarginOrderRequest. Query subaccount (default 0). → AmendMarginOrderResponse. Amend that increases size or changes price forfeits queue position. |
| GET | /margin/fcm/orders |
GetMarginFCMOrders |
FCM-member only. Required query subtrader_id; plus ticker, min_ts, max_ts, status, limit, cursor. No subaccount param. Reuses response schema GetMarginOrdersResponse. |
Note: POST uses subaccount in the request body (CreateMarginOrderRequest.subaccount), while cancel/decrease/amend route subaccount as a query param (SubaccountQueryDefaultPrimary). Honor this split — the create body carries it, the others do not.
Models
New file: kalshi/perps/models/orders.py
Use from kalshi.types import DollarDecimal, FixedPointCount, OrderPrice, StrictInt. Prices are FixedPointDollars (string, up to 6 decimals → DollarDecimal for responses, OrderPrice for request prices to reject negatives). Counts are FixedPointCount (string "fp", 2 decimals). REST timestamps are RFC3339 date-time → pydantic.AwareDatetime.
Enums / Literals (define as module-level Literal aliases, mirroring kalshi/models/orders.py lines 18–34):
BookSideLiteral = Literal["bid", "ask"]— specBookSide(line 2225). Reuse the existingkalshi.models.orders.BookSideLiteralrather than redefining if import is clean; otherwise redefine locally and note it.TimeInForceLiteral = Literal["fill_or_kill", "good_till_canceled", "immediate_or_cancel"]— inline enum onCreateMarginOrderRequest.time_in_force(line 1686).SelfTradePreventionTypeLiteral = Literal["taker_at_cross", "maker"]— specSelfTradePreventionType(line 1578).OrderSourceLiteral = Literal["user", "system"]— specOrderSource(line 2036).LastUpdateReasonLiteral = Literal["", "Decrease", "Amend", "MarginCancel", "SelfTradeCancel", "ExpiryCancel", "Trade", "PostOnlyCrossCancel"]— specLastUpdateReason(line 2044). Note the empty-string member""— it is a real enum value, keep it.
CreateMarginOrderRequest (request, model_config = {"extra": "forbid"}; spec line 1652). Required per spec: ticker, client_order_id, side, count, price, time_in_force, self_trade_prevention_type.
ticker: strclient_order_id: str(required — server uses for idempotency; keep required likeCreateOrderV2Request)side: BookSideLiteralcount: FixedPointCount— wire name is literallycount(NOTcount_fphere); no serialization_alias.price: OrderPrice— wire name is literallyprice(NOTprice_dollars); no serialization_alias.time_in_force: TimeInForceLiteralself_trade_prevention_type: SelfTradePreventionTypeLiteralexpiration_time: StrictInt | None = None(int64, Unix seconds per spec; optional)post_only: bool | None = Nonecancel_order_on_pause: bool | None = Nonereduce_only: bool | None = Nonesubaccount: StrictInt | None = Field(default=None, ge=0)(spec default 0; emit only when set)order_group_id: str | None = None
Serialize via model.model_dump(exclude_none=True, by_alias=True, mode="json"). Because price/count wire names match field names, by_alias is a no-op for them — confirm the drift test stays green.
CreateMarginOrderResponse (response; spec line 1723). model_config = {"extra": "allow"}.
order_id: str,fill_count: FixedPointCount,remaining_count: FixedPointCount(required)client_order_id: str | None = Noneaverage_fill_price: DollarDecimal | None = None(present only whenfill_count > 0)average_fee_paid: DollarDecimal | None = None
MarginOrder (response; spec line 1771). model_config = {"extra": "allow"}. Required: order_id, user_id, client_order_id, ticker, side, price, fill_count, remaining_count, last_update_reason.
order_id: str,user_id: str,client_order_id: str,ticker: strside: BookSideLiterallast_update_reason: LastUpdateReasonLiteralprice: DollarDecimalfill_count: FixedPointCount,remaining_count: FixedPointCountexpiration_time: AwareDatetime | None = Nonecreated_time: AwareDatetime | None = Nonelast_update_time: AwareDatetime | None = Noneself_trade_prevention_type: SelfTradePreventionTypeLiteral | None = Nonecancel_order_on_pause: bool | None = Noneorder_group_id: str | None = Noneorder_source: OrderSourceLiteral | None = None
These wire field names match the Python names, so no validation_alias is needed (the API does not use _dollars/_fp suffixes here — price, fill_count, remaining_count are the literal keys). Do not add AliasChoices speculatively; verify against the spec keys above.
GetMarginOrderResponse (spec line 1752): order: MarginOrder. extra="allow".
GetMarginOrdersResponse (spec line 1759): orders: builtins.list[MarginOrder], cursor: str. Both required. extra="allow". This is the paginated envelope; cursor drives list_all().
CancelMarginOrderResponse (spec line 1838): order_id: str (req), reduced_by: FixedPointCount (req — contracts canceled), client_order_id: str | None = None. extra="allow".
DecreaseMarginOrderRequest (request, extra="forbid"; spec line 1853). Both fields optional/nullable in spec but server requires exactly one — enforce XOR at construction with a @model_validator(mode="after") (mirror DecreaseOrderV2Request, kalshi/models/orders.py line 517):
reduce_by: FixedPointCount | None = Nonereduce_to: FixedPointCount | None = None- (no
subaccount/exchange_indexon body —subaccountis a query param)
DecreaseMarginOrderResponse (spec line 1868): order_id: str (req), remaining_count: FixedPointCount (req), client_order_id: str | None = None. extra="allow".
AmendMarginOrderRequest (request, extra="forbid"; spec line 1881). Required: ticker, side, price, count.
ticker: strside: BookSideLiteralprice: OrderPrice(wire nameprice; no alias)count: FixedPointCount(wire namecount; no alias)client_order_id: str | None = None(original cid to amend)updated_client_order_id: str | None = None
AmendMarginOrderResponse (spec line 1915): order_id: str (req); all others nullable: client_order_id: str | None, remaining_count: FixedPointCount | None, fill_count: FixedPointCount | None, average_fill_price: DollarDecimal | None, average_fee_paid: DollarDecimal | None. extra="allow".
Resource methods
New file: kalshi/perps/resources/orders.py with MarginOrdersResource(SyncResource) and AsyncMarginOrdersResource(AsyncResource) (import bases from kalshi.resources._base). Inside resource classes the list builtin is shadowed by the .list() method — annotate returns with builtins.list[T] and import builtins.
Follow the kalshi/resources/orders.py pattern: pure _build_*_body(...) helpers (kwargs → dict[str, Any] via model.model_dump(exclude_none=True, by_alias=True, mode="json")) shared by sync+async, each method accepting either discrete kwargs or a prebuilt request= model (use _check_request_exclusive). Path segments via _seg, query via _params/_validate_limit.
Sync signatures (async identical with async def / await / AsyncIterator):
create(self, *, ticker=None, client_order_id=None, side=None, count=None, price=None, time_in_force=None, self_trade_prevention_type=None, expiration_time=None, post_only=None, cancel_order_on_pause=None, reduce_only=None, subaccount=None, order_group_id=None, request: CreateMarginOrderRequest | None = None) -> CreateMarginOrderResponse— POST/margin/orders, not retried.get(self, order_id: str) -> MarginOrder— GET/margin/orders/{order_id}; unwrap.orderfromGetMarginOrderResponse.list(self, *, ticker=None, status=None, min_ts=None, max_ts=None, limit=None, cursor=None, subaccount=None) -> GetMarginOrdersResponse— GET/margin/orders(single page; returns envelope incl.cursor).list_all(self, *, ticker=None, status=None, min_ts=None, max_ts=None, subaccount=None, max_pages=None) -> Iterator[MarginOrder]— auto-paginate via the responsecursoruntil empty. Async returnsAsyncIterator[MarginOrder]directly (async for ...).cursoris NOT a public param onlist_all(paginator-managed) — add EXCLUSIONS entries (see below).cancel(self, order_id: str, *, subaccount: int | None = None) -> CancelMarginOrderResponse— DELETE/margin/orders/{order_id}, not retried.decrease(self, order_id: str, *, reduce_by=None, reduce_to=None, subaccount=None, request: DecreaseMarginOrderRequest | None = None) -> DecreaseMarginOrderResponse— POST/margin/orders/{order_id}/decrease, not retried; XOR enforced by the request model.amend(self, order_id: str, *, ticker=None, side=None, price=None, count=None, client_order_id=None, updated_client_order_id=None, subaccount=None, request: AmendMarginOrderRequest | None = None) -> AmendMarginOrderResponse— POST/margin/orders/{order_id}/amend, not retried.list_fcm(self, *, subtrader_id: str, ticker=None, status=None, min_ts=None, max_ts=None, limit=None, cursor=None) -> GetMarginOrdersResponseandlist_all_fcm(self, *, subtrader_id: str, ticker=None, status=None, min_ts=None, max_ts=None, max_pages=None) -> Iterator[MarginOrder]— GET/margin/fcm/orders.subtrader_idis required (keyword). Nosubaccount.
Wire both resources into PerpsClient.__init__ / AsyncPerpsClient.__init__ as self.orders = MarginOrdersResource(transport) (foundation issue provides the clients + transport).
Contract-test wiring
Add to METHOD_ENDPOINT_MAP (tests/_contract_support.py) — the harness must already load specs/perps_openapi.yaml for these kalshi.perps.* FQNs (provided by the foundation issue; see Dependencies):
MethodEndpointEntry(sdk_method="kalshi.perps.resources.orders.MarginOrdersResource.create",
http_method="POST", path_template="/margin/orders",
request_body_schema="#/components/schemas/CreateMarginOrderRequest"),
MethodEndpointEntry(sdk_method="kalshi.perps.resources.orders.MarginOrdersResource.list",
http_method="GET", path_template="/margin/orders"),
MethodEndpointEntry(sdk_method="kalshi.perps.resources.orders.MarginOrdersResource.list_all",
http_method="GET", path_template="/margin/orders"),
MethodEndpointEntry(sdk_method="kalshi.perps.resources.orders.MarginOrdersResource.get",
http_method="GET", path_template="/margin/orders/{order_id}"),
MethodEndpointEntry(sdk_method="kalshi.perps.resources.orders.MarginOrdersResource.cancel",
http_method="DELETE", path_template="/margin/orders/{order_id}"),
MethodEndpointEntry(sdk_method="kalshi.perps.resources.orders.MarginOrdersResource.decrease",
http_method="POST", path_template="/margin/orders/{order_id}/decrease",
request_body_schema="#/components/schemas/DecreaseMarginOrderRequest"),
MethodEndpointEntry(sdk_method="kalshi.perps.resources.orders.MarginOrdersResource.amend",
http_method="POST", path_template="/margin/orders/{order_id}/amend",
request_body_schema="#/components/schemas/AmendMarginOrderRequest"),
MethodEndpointEntry(sdk_method="kalshi.perps.resources.orders.MarginOrdersResource.list_fcm",
http_method="GET", path_template="/margin/fcm/orders"),
MethodEndpointEntry(sdk_method="kalshi.perps.resources.orders.MarginOrdersResource.list_all_fcm",
http_method="GET", path_template="/margin/fcm/orders"),Register the async resource FQNs (AsyncMarginOrdersResource.*) identically if the harness parametrizes over both classes (it does for the existing orders resource — match that).
Add to BODY_MODEL_MAP (tests/test_contracts.py):
"#/components/schemas/CreateMarginOrderRequest": "kalshi.perps.models.orders.CreateMarginOrderRequest",
"#/components/schemas/DecreaseMarginOrderRequest": "kalshi.perps.models.orders.DecreaseMarginOrderRequest",
"#/components/schemas/AmendMarginOrderRequest": "kalshi.perps.models.orders.AmendMarginOrderRequest",Add to EXCLUSIONS (tests/_contract_support.py), kind="paginator_handled", mirroring the existing OrdersResource.list_all cursor exclusion (line 915):
("kalshi.perps.resources.orders.MarginOrdersResource.list_all", "cursor"): Exclusion(reason="cursor is paginator-managed in list_all", kind="paginator_handled"),
("kalshi.perps.resources.orders.MarginOrdersResource.list_all_fcm", "cursor"): Exclusion(reason="cursor is paginator-managed in list_all_fcm", kind="paginator_handled"),(and the async-class equivalents if both are parametrized). No other deviations expected — price/count field names match wire names, so the body-drift test should pass without alias exclusions.
Tests
New file tests/perps/test_orders.py, respx.mock against the perps base URL, reusing the conftest RSA fixtures (foundation issue exposes a perps_client / async_perps_client fixture; reuse the existing RSA key fixtures from tests/conftest.py). Per public method:
- create — happy: 201 returns
CreateMarginOrderResponsewithfill_count/remaining_countparsed asDecimal; assertclient_order_id/side/price/countin the request JSON body; error: 400/409 maps to the right exception; edge: omitting any required field (time_in_force,self_trade_prevention_type, etc.) raisesValidationErrorat construction; phantom kwarg rejected byextra="forbid"; assert create is not retried (single httpx call on a 503). - get — happy: unwraps
.orderintoMarginOrder; parseslast_update_reason=""correctly; error: 404. - list / list_all — happy: single page envelope incl.
cursor;list_allwalks two pages then stops on emptycursor; asynclist_allworks viaasync for; edge:max_pagescaps iteration; limit out of 1–1000 raises. - cancel — happy: returns
CancelMarginOrderResponsewithreduced_byDecimal;subaccountquery param emitted only when passed; error: 404; assert DELETE not retried. - decrease — happy each of
reduce_by/reduce_to; edge: both set →ValidationError; neither set →ValidationError; not retried. - amend — happy: nullable response fields (
fill_count/average_fill_priceabsent) parse toNone; error: 400; not retried. - list_fcm / list_all_fcm — happy:
subtrader_idpresent in query; missingsubtrader_idis aTypeError(required kwarg); pagination same aslist_all; assert nosubaccountparam leaks.
Acceptance criteria
- All nine public methods implemented on both
MarginOrdersResourceandAsyncMarginOrdersResource, wired intoPerpsClient/AsyncPerpsClientas.orders. - POST
/margin/orders, DELETE cancel, POST decrease, POST amend are never retried (verified by test). - Models in
kalshi/perps/models/orders.pyexported fromkalshi/perps/__init__.pyand re-exported fromkalshi/__init__.py. - Request models use
extra="forbid";DecreaseMarginOrderRequestenforcesreduce_by/reduce_toXOR;LastUpdateReasonLiteralincludes the""member. - mypy strict clean (
builtins.list[T]inside resource classes); ruff clean. - pytest green including
TestRequestParamDriftandTestRequestBodyDriftagainstspecs/perps_openapi.yaml. -
METHOD_ENDPOINT_MAP,BODY_MODEL_MAP, and the twopaginator_handledEXCLUSIONSentries added.
Dependencies
- perps: foundation — provides
PerpsClient/AsyncPerpsClient,PerpsConfig, perps base URLs, thekalshi/perps/package scaffolding +__init__export chain, and (critically) the contract-test harness parameterized to loadspecs/perps_openapi.yamlforkalshi.perps.*symbols. This issue cannot land its drift-test wiring until the foundation makes the harness perps-spec-aware.
Activity