Skip to content

perps: orders resource — create/get/list/cancel/decrease/amend + FCM orders #391

Description

@TexasCoding

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

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"] — spec BookSide (line 2225). Reuse the existing kalshi.models.orders.BookSideLiteral rather 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 on CreateMarginOrderRequest.time_in_force (line 1686).
  • SelfTradePreventionTypeLiteral = Literal["taker_at_cross", "maker"] — spec SelfTradePreventionType (line 1578).
  • OrderSourceLiteral = Literal["user", "system"] — spec OrderSource (line 2036).
  • LastUpdateReasonLiteral = Literal["", "Decrease", "Amend", "MarginCancel", "SelfTradeCancel", "ExpiryCancel", "Trade", "PostOnlyCrossCancel"] — spec LastUpdateReason (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: str
  • client_order_id: str (required — server uses for idempotency; keep required like CreateOrderV2Request)
  • side: BookSideLiteral
  • count: FixedPointCount — wire name is literally count (NOT count_fp here); no serialization_alias.
  • price: OrderPrice — wire name is literally price (NOT price_dollars); no serialization_alias.
  • time_in_force: TimeInForceLiteral
  • self_trade_prevention_type: SelfTradePreventionTypeLiteral
  • expiration_time: StrictInt | None = None (int64, Unix seconds per spec; optional)
  • post_only: bool | None = None
  • cancel_order_on_pause: bool | None = None
  • reduce_only: bool | None = None
  • subaccount: 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 = None
  • average_fill_price: DollarDecimal | None = None (present only when fill_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: str
  • side: BookSideLiteral
  • last_update_reason: LastUpdateReasonLiteral
  • price: DollarDecimal
  • fill_count: FixedPointCount, remaining_count: FixedPointCount
  • expiration_time: AwareDatetime | None = None
  • created_time: AwareDatetime | None = None
  • last_update_time: AwareDatetime | None = None
  • self_trade_prevention_type: SelfTradePreventionTypeLiteral | None = None
  • cancel_order_on_pause: bool | None = None
  • order_group_id: str | None = None
  • order_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 = None
  • reduce_to: FixedPointCount | None = None
  • (no subaccount/exchange_index on body — subaccount is 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: str
  • side: BookSideLiteral
  • price: OrderPrice (wire name price; no alias)
  • count: FixedPointCount (wire name count; 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 .order from GetMarginOrderResponse.
  • 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 response cursor until empty. Async returns AsyncIterator[MarginOrder] directly (async for ...). cursor is NOT a public param on list_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) -> GetMarginOrdersResponse and list_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_id is required (keyword). No subaccount.

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 CreateMarginOrderResponse with fill_count/remaining_count parsed as Decimal; assert client_order_id/side/price/count in 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.) raises ValidationError at construction; phantom kwarg rejected by extra="forbid"; assert create is not retried (single httpx call on a 503).
  • get — happy: unwraps .order into MarginOrder; parses last_update_reason="" correctly; error: 404.
  • list / list_all — happy: single page envelope incl. cursor; list_all walks two pages then stops on empty cursor; async list_all works via async for; edge: max_pages caps iteration; limit out of 1–1000 raises.
  • cancel — happy: returns CancelMarginOrderResponse with reduced_by Decimal; subaccount query 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_price absent) parse to None; error: 400; not retried.
  • list_fcm / list_all_fcm — happy: subtrader_id present in query; missing subtrader_id is a TypeError (required kwarg); pagination same as list_all; assert no subaccount param leaks.

Acceptance criteria

  • All nine public methods implemented on both MarginOrdersResource and AsyncMarginOrdersResource, wired into PerpsClient/AsyncPerpsClient as .orders.
  • POST /margin/orders, DELETE cancel, POST decrease, POST amend are never retried (verified by test).
  • Models in kalshi/perps/models/orders.py exported from kalshi/perps/__init__.py and re-exported from kalshi/__init__.py.
  • Request models use extra="forbid"; DecreaseMarginOrderRequest enforces reduce_by/reduce_to XOR; LastUpdateReasonLiteral includes the "" member.
  • mypy strict clean (builtins.list[T] inside resource classes); ruff clean.
  • pytest green including TestRequestParamDrift and TestRequestBodyDrift against specs/perps_openapi.yaml.
  • METHOD_ENDPOINT_MAP, BODY_MODEL_MAP, and the two paginator_handled EXCLUSIONS entries added.

Dependencies

  • perps: foundation — provides PerpsClient/AsyncPerpsClient, PerpsConfig, perps base URLs, the kalshi/perps/ package scaffolding + __init__ export chain, and (critically) the contract-test harness parameterized to load specs/perps_openapi.yaml for kalshi.perps.* symbols. This issue cannot land its drift-test wiring until the foundation makes the harness perps-spec-aware.

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