Repository navigation
Description
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 discardedA 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(syncbatch_create),:902-912(async)kalshi/resources/orders.py:477-500(syncbatch_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 discardedRecommended 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, assertDecimalprecision preserved.
Severity & category
high / correctness, spec-drift, breaking
Activity
test(contracts): register V1 batch response models in CONTRACT_MAP
test(integration): align with v2.4.0 client-side limit validation + V…