Skip to content

Orders: V1 batch_create / batch_cancel must return typed per-leg responses (partial-success defect) #194

Description

@TexasCoding

Summary

The V1 batch order endpoints have two serious correctness defects that make them unsafe for production:

(1) batch_create crashes on partial-success responses

Spec BatchCreateOrdersIndividualResponse declares both order and error as nullable. A failed leg comes back as {"client_order_id": "x", "order": null, "error": {...}}. The SDK does:

return [Order.model_validate(o.get("order", o)) for o in raw_orders]

dict.get("order", o) returns None when the key is present with value None (the default is only used on missing keys). So Order.model_validate(None) raises pydantic.ValidationError and tears down the entire batch result the moment any single leg is rejected.

Even on the happy path the SDK discards client_order_id and error, so callers cannot pair returned orders with the original requests or detect a degraded fill ordering. Documented batch examples in docs/resources/orders.md show iterating for o in created — that loop blows up the moment one leg is rejected.

(2) batch_cancel discards the entire response body

Spec BatchCancelOrdersResponse.orders[] carries per-entry order_id, reduced_by_fp (required, Decimal — load-bearing for risk reconciliation), order, and a nullable error. The SDK's batch_cancel is typed -> None and never reads the body returned from _delete_with_body:

self._delete_with_body("/portfolio/orders/batched", json=body)  # return discarded

A trader who batch-cancels 20 orders has no way to tell which ones actually canceled, which were no-ops (already-filled), or how many contracts each cancel reduced.

V2 already does this correctly: batch_cancel_v2 parses and returns BatchCancelOrdersV2Response. V1 was simply left behind.

Location

  • kalshi/resources/orders.py:454-464 (sync batch_create), :902-912 (async)
  • kalshi/resources/orders.py:477-500 (sync batch_cancel), :925-948 (async)
  • specs/openapi.yaml:6876-6889 (BatchCreateOrdersIndividualResponse)
  • specs/openapi.yaml:6936-6959 (BatchCancelOrdersIndividualResponse)

Evidence

# kalshi/resources/orders.py:462-464
data = self._post("/portfolio/orders/batched", json=body)
raw_orders = data.get("orders", [])
return [Order.model_validate(o.get("order", o)) for o in raw_orders]  # Order.model_validate(None) on failed leg
# kalshi/resources/orders.py:498-500
self._require_auth()
body = _build_batch_cancel_body(request, orders)
self._delete_with_body("/portfolio/orders/batched", json=body)  # body discarded

Recommended fix

Add typed response models (mirror the V2 shape):

class BatchCreateOrdersResponseEntry(BaseModel):
    order: Order | None
    error: ErrorResponse | None
    client_order_id: str | None

class BatchCreateOrdersResponse(BaseModel):
    orders: list[BatchCreateOrdersResponseEntry]

class BatchCancelOrdersResponseEntry(BaseModel):
    order_id: str
    reduced_by_fp: FixedPointCount
    order: Order | None
    error: ErrorResponse | None

class BatchCancelOrdersResponse(BaseModel):
    orders: list[BatchCancelOrdersResponseEntry]

Change batch_create to return BatchCreateOrdersResponse and batch_cancel to return BatchCancelOrdersResponse. Wire data = self._delete_with_body(...) into batch_cancel.

Breaking change for batch_create (return type changes from list[Order] to BatchCreateOrdersResponse) and batch_cancel (return type from None to BatchCancelOrdersResponse). Land both in the same release; either bump to v3.0 or ship under a deprecation cycle with V2-style *_v2 variants for V1 endpoints.

Add regression tests:

  • test_batch_create_partial_failure_returns_per_leg_results — mock response with 2 ok / 1 error legs; assert structure preserved.
  • test_batch_cancel_returns_reduced_by_fp_per_order — mock response, assert Decimal precision preserved.

Severity & category

high / correctness, spec-drift, breaking

Metadata

Metadata

Assignees

No one assigned

    Labels

    breakingBackwards-incompatible changebugSomething isn't workingspec-driftUpstream OpenAPI/AsyncAPI spec changed since last sync

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions