Skip to content

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

Closed

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.

Activity

  1. added this to the Perps API milestone on Jun 4, 2026
  2. added
    enhancementNew feature or request
    perpsPerps / margin (perpetual futures) API
    on Jun 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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