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
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.
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 inkalshi/resources/orders.pybut lives under the newkalshi/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
/margin/ordersCreateMarginOrderCreateMarginOrderRequest. NOT retried (duplicate-order risk). Returns 201CreateMarginOrderResponse./margin/ordersGetMarginOrdersGetMarginOrdersResponse. Query:ticker,min_ts,max_ts,status,limit(1–1000, default 100),cursor,subaccount(≥0)./margin/orders/{order_id}GetMarginOrderGetMarginOrderResponse({order})./margin/orders/{order_id}CancelMarginOrdersubaccount(default 0). →CancelMarginOrderResponse./margin/orders/{order_id}/decreaseDecreaseMarginOrderDecreaseMarginOrderRequest(XORreduce_by/reduce_to). Querysubaccount(default 0). →DecreaseMarginOrderResponse./margin/orders/{order_id}/amendAmendMarginOrderAmendMarginOrderRequest. Querysubaccount(default 0). →AmendMarginOrderResponse. Amend that increases size or changes price forfeits queue position./margin/fcm/ordersGetMarginFCMOrderssubtrader_id; plusticker,min_ts,max_ts,status,limit,cursor. Nosubaccountparam. Reuses response schemaGetMarginOrdersResponse.Note: POST uses
subaccountin the request body (CreateMarginOrderRequest.subaccount), while cancel/decrease/amend routesubaccountas a query param (SubaccountQueryDefaultPrimary). Honor this split — the create body carries it, the others do not.Models
New file:
kalshi/perps/models/orders.pyUse
from kalshi.types import DollarDecimal, FixedPointCount, OrderPrice, StrictInt. Prices areFixedPointDollars(string, up to 6 decimals →DollarDecimalfor responses,OrderPricefor request prices to reject negatives). Counts areFixedPointCount(string "fp", 2 decimals). REST timestamps are RFC3339 date-time →pydantic.AwareDatetime.Enums / Literals (define as module-level
Literalaliases, mirroringkalshi/models/orders.pylines 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 = NoneSerialize via
model.model_dump(exclude_none=True, by_alias=True, mode="json"). Becauseprice/countwire names match field names,by_aliasis 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 = NoneMarginOrder(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 = NoneThese wire field names match the Python names, so no
validation_aliasis needed (the API does not use_dollars/_fpsuffixes here —price,fill_count,remaining_countare 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;cursordriveslist_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")(mirrorDecreaseOrderV2Request,kalshi/models/orders.pyline 517):reduce_by: FixedPointCount | None = Nonereduce_to: FixedPointCount | None = Nonesubaccount/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 = NoneAmendMarginOrderResponse(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.pywithMarginOrdersResource(SyncResource)andAsyncMarginOrdersResource(AsyncResource)(import bases fromkalshi.resources._base). Inside resource classes thelistbuiltin is shadowed by the.list()method — annotate returns withbuiltins.list[T]andimport builtins.Follow the
kalshi/resources/orders.pypattern: pure_build_*_body(...)helpers (kwargs →dict[str, Any]viamodel.model_dump(exclude_none=True, by_alias=True, mode="json")) shared by sync+async, each method accepting either discrete kwargs or a prebuiltrequest=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__asself.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 loadspecs/perps_openapi.yamlfor thesekalshi.perps.*FQNs (provided by the foundation issue; see Dependencies):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):Add to
EXCLUSIONS(tests/_contract_support.py),kind="paginator_handled", mirroring the existingOrdersResource.list_allcursor exclusion (line 915):(and the async-class equivalents if both are parametrized). No other deviations expected —
price/countfield names match wire names, so the body-drift test should pass without alias exclusions.Tests
New file
tests/perps/test_orders.py,respx.mockagainst the perps base URL, reusing the conftest RSA fixtures (foundation issue exposes aperps_client/async_perps_clientfixture; reuse the existing RSA key fixtures fromtests/conftest.py). Per public method: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)..orderintoMarginOrder; parseslast_update_reason=""correctly; error: 404.cursor;list_allwalks two pages then stops on emptycursor; asynclist_allworks viaasync for; edge:max_pagescaps iteration; limit out of 1–1000 raises.CancelMarginOrderResponsewithreduced_byDecimal;subaccountquery param emitted only when passed; error: 404; assert DELETE not retried.reduce_by/reduce_to; edge: both set →ValidationError; neither set →ValidationError; not retried.fill_count/average_fill_priceabsent) parse toNone; error: 400; not retried.subtrader_idpresent in query; missingsubtrader_idis aTypeError(required kwarg); pagination same aslist_all; assert nosubaccountparam leaks.Acceptance criteria
MarginOrdersResourceandAsyncMarginOrdersResource, wired intoPerpsClient/AsyncPerpsClientas.orders./margin/orders, DELETE cancel, POST decrease, POST amend are never retried (verified by test).kalshi/perps/models/orders.pyexported fromkalshi/perps/__init__.pyand re-exported fromkalshi/__init__.py.extra="forbid";DecreaseMarginOrderRequestenforcesreduce_by/reduce_toXOR;LastUpdateReasonLiteralincludes the""member.builtins.list[T]inside resource classes); ruff clean.TestRequestParamDriftandTestRequestBodyDriftagainstspecs/perps_openapi.yaml.METHOD_ENDPOINT_MAP,BODY_MODEL_MAP, and the twopaginator_handledEXCLUSIONSentries added.Dependencies
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.