Skip to content

Commit 0a2de0d

Browse files
authored
polish(resources): communications status Literal + V2 batch bytes-fast-path (#324, #329) (#373)
* polish(resources): communications status Literal; V2 batch bytes fast-path #324: CommunicationsResource.list_rfqs / list_quotes (sync + async, plus list_all variants) declared 'status: str | None', the only paginated list on the SDK that did not narrow its status filter. The spec defines closed enums (RFQ.status: open|closed; Quote.status: open|accepted|confirmed| executed|cancelled) and the README advertises 'Literal types on fixed-enum kwargs' as a headline feature. Add RfqStatusLiteral / QuoteStatusLiteral to kalshi.models.communications, re-export from kalshi.models + kalshi, and narrow the eight status kwargs. Pure type-system fence, no runtime behavior change — typos now surface at the call site under mypy / IDE autocomplete instead of silently round-tripping. #329: batch_create_v2 / batch_cancel_v2 (sync + async) still serialized via request.model_dump(mode='json') -> _post(json=dict) / _delete_with_body(json=dict), paying the dict-walk pass that httpx's 'json=' encoder repeats — exactly the cost #223 measured and removed for V1 batch_create / batch_cancel. Add _build_batch_create_v2_body / _build_batch_cancel_v2_body next to the V1 builders (model_dump_json + .encode()) and route the V2 methods through _post_json / _delete_with_body_json with content=<bytes>. V2 is the spec-recommended surface for new code, so leaving the perf regression on it would regress #223's own contract. Closes #324, #329 * polish(resources): inline trivial V2 helpers; trim docstrings; async status flow tests
1 parent 8204366 commit 0a2de0d

8 files changed

Lines changed: 243 additions & 22 deletions

File tree

kalshi/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,9 @@
141141
PositionsResponse,
142142
PriceDistribution,
143143
Quote,
144+
QuoteStatusLiteral,
144145
RateLimit,
146+
RfqStatusLiteral,
145147
Schedule,
146148
ScopeList,
147149
SelfTradePreventionTypeLiteral,
@@ -309,7 +311,9 @@
309311
"PositionsResponse",
310312
"PriceDistribution",
311313
"Quote",
314+
"QuoteStatusLiteral",
312315
"RateLimit",
316+
"RfqStatusLiteral",
313317
"Schedule",
314318
"ScopeList",
315319
"SelfTradePreventionTypeLiteral",

kalshi/models/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
GetRFQsResponse,
3030
MveSelectedLeg,
3131
Quote,
32+
QuoteStatusLiteral,
33+
RfqStatusLiteral,
3234
UserFilterLiteral,
3335
)
3436
from kalshi.models.events import (
@@ -293,7 +295,9 @@
293295
"PositionsResponse",
294296
"PriceDistribution",
295297
"Quote",
298+
"QuoteStatusLiteral",
296299
"RateLimit",
300+
"RfqStatusLiteral",
297301
"Schedule",
298302
"ScopeList",
299303
"SelfTradePreventionTypeLiteral",

kalshi/models/communications.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111
UserFilterLiteral = Literal["self"]
1212
"""Filter for items created by the authenticated user. Spec: UserFilter enum."""
1313

14+
RfqStatusLiteral = Literal["open", "closed"]
15+
"""RFQ status filter for GET /communications/rfqs. Spec: RFQ.status enum."""
16+
17+
QuoteStatusLiteral = Literal["open", "accepted", "confirmed", "executed", "cancelled"]
18+
"""Quote status filter for GET /communications/quotes. Spec: Quote.status enum."""
19+
1420

1521
class MveSelectedLeg(BaseModel):
1622
"""A selected leg within a multivariate event collection RFQ."""

kalshi/resources/communications.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
GetQuoteResponse,
1919
GetRFQResponse,
2020
Quote,
21+
QuoteStatusLiteral,
22+
RfqStatusLiteral,
2123
UserFilterLiteral,
2224
)
2325
from kalshi.resources._base import (
@@ -68,7 +70,7 @@ def _list_rfqs_params(
6870
event_ticker: str | None,
6971
market_ticker: str | None,
7072
subaccount: int | None,
71-
status: str | None,
73+
status: RfqStatusLiteral | None,
7274
creator_user_id: str | None,
7375
user_filter: UserFilterLiteral | None,
7476
) -> dict[str, Any]:
@@ -91,7 +93,7 @@ def _list_quotes_params(
9193
limit: int | None,
9294
event_ticker: str | None,
9395
market_ticker: str | None,
94-
status: str | None,
96+
status: QuoteStatusLiteral | None,
9597
quote_creator_user_id: str | None,
9698
rfq_creator_user_id: str | None,
9799
rfq_creator_subtrader_id: str | None,
@@ -218,7 +220,7 @@ def list_rfqs(
218220
event_ticker: str | None = None,
219221
market_ticker: str | None = None,
220222
subaccount: int | None = None,
221-
status: str | None = None,
223+
status: RfqStatusLiteral | None = None,
222224
creator_user_id: str | None = None,
223225
user_filter: UserFilterLiteral | None = None,
224226
extra_headers: dict[str, str] | None = None,
@@ -245,7 +247,7 @@ def list_all_rfqs(
245247
event_ticker: str | None = None,
246248
market_ticker: str | None = None,
247249
subaccount: int | None = None,
248-
status: str | None = None,
250+
status: RfqStatusLiteral | None = None,
249251
creator_user_id: str | None = None,
250252
user_filter: UserFilterLiteral | None = None,
251253
max_pages: int | None = None,
@@ -338,7 +340,7 @@ def list_quotes(
338340
limit: int | None = None,
339341
event_ticker: str | None = None,
340342
market_ticker: str | None = None,
341-
status: str | None = None,
343+
status: QuoteStatusLiteral | None = None,
342344
quote_creator_user_id: str | None = None,
343345
rfq_creator_user_id: str | None = None,
344346
rfq_creator_subtrader_id: str | None = None,
@@ -377,7 +379,7 @@ def list_all_quotes(
377379
limit: int | None = None,
378380
event_ticker: str | None = None,
379381
market_ticker: str | None = None,
380-
status: str | None = None,
382+
status: QuoteStatusLiteral | None = None,
381383
quote_creator_user_id: str | None = None,
382384
rfq_creator_user_id: str | None = None,
383385
rfq_creator_subtrader_id: str | None = None,
@@ -533,7 +535,7 @@ async def list_rfqs(
533535
event_ticker: str | None = None,
534536
market_ticker: str | None = None,
535537
subaccount: int | None = None,
536-
status: str | None = None,
538+
status: RfqStatusLiteral | None = None,
537539
creator_user_id: str | None = None,
538540
user_filter: UserFilterLiteral | None = None,
539541
extra_headers: dict[str, str] | None = None,
@@ -560,7 +562,7 @@ def list_all_rfqs(
560562
event_ticker: str | None = None,
561563
market_ticker: str | None = None,
562564
subaccount: int | None = None,
563-
status: str | None = None,
565+
status: RfqStatusLiteral | None = None,
564566
creator_user_id: str | None = None,
565567
user_filter: UserFilterLiteral | None = None,
566568
max_pages: int | None = None,
@@ -655,7 +657,7 @@ async def list_quotes(
655657
limit: int | None = None,
656658
event_ticker: str | None = None,
657659
market_ticker: str | None = None,
658-
status: str | None = None,
660+
status: QuoteStatusLiteral | None = None,
659661
quote_creator_user_id: str | None = None,
660662
rfq_creator_user_id: str | None = None,
661663
rfq_creator_subtrader_id: str | None = None,
@@ -694,7 +696,7 @@ def list_all_quotes(
694696
limit: int | None = None,
695697
event_ticker: str | None = None,
696698
market_ticker: str | None = None,
697-
status: str | None = None,
699+
status: QuoteStatusLiteral | None = None,
698700
quote_creator_user_id: str | None = None,
699701
rfq_creator_user_id: str | None = None,
700702
rfq_creator_subtrader_id: str | None = None,

kalshi/resources/orders.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ def _build_batch_cancel_body(
167167
return request.model_dump_json(exclude_none=True, by_alias=True).encode()
168168

169169

170+
170171
def _build_amend_body(
171172
request: AmendOrderRequest | None,
172173
*,
@@ -867,19 +868,19 @@ def batch_create_v2(
867868
self, *, request: BatchCreateOrdersV2Request, extra_headers: dict[str, str] | None = None
868869
) -> BatchCreateOrdersV2Response:
869870
self._require_auth()
870-
body = request.model_dump(exclude_none=True, by_alias=True, mode="json")
871-
data = self._post(
872-
"/portfolio/events/orders/batched", json=body, extra_headers=extra_headers
871+
body = request.model_dump_json(exclude_none=True, by_alias=True).encode()
872+
data = self._post_json(
873+
"/portfolio/events/orders/batched", content=body, extra_headers=extra_headers
873874
)
874875
return BatchCreateOrdersV2Response.model_validate(data)
875876

876877
def batch_cancel_v2(
877878
self, *, request: BatchCancelOrdersV2Request, extra_headers: dict[str, str] | None = None
878879
) -> BatchCancelOrdersV2Response:
879880
self._require_auth()
880-
body = request.model_dump(exclude_none=True, by_alias=True, mode="json")
881-
data = self._delete_with_body(
882-
"/portfolio/events/orders/batched", json=body, extra_headers=extra_headers
881+
body = request.model_dump_json(exclude_none=True, by_alias=True).encode()
882+
data = self._delete_with_body_json(
883+
"/portfolio/events/orders/batched", content=body, extra_headers=extra_headers
883884
)
884885
if data is None:
885886
raise KalshiError("Expected BatchCancelOrdersV2Response body, got 204 No Content.")
@@ -1408,19 +1409,19 @@ async def batch_create_v2(
14081409
self, *, request: BatchCreateOrdersV2Request, extra_headers: dict[str, str] | None = None
14091410
) -> BatchCreateOrdersV2Response:
14101411
self._require_auth()
1411-
body = request.model_dump(exclude_none=True, by_alias=True, mode="json")
1412-
data = await self._post(
1413-
"/portfolio/events/orders/batched", json=body, extra_headers=extra_headers
1412+
body = request.model_dump_json(exclude_none=True, by_alias=True).encode()
1413+
data = await self._post_json(
1414+
"/portfolio/events/orders/batched", content=body, extra_headers=extra_headers
14141415
)
14151416
return BatchCreateOrdersV2Response.model_validate(data)
14161417

14171418
async def batch_cancel_v2(
14181419
self, *, request: BatchCancelOrdersV2Request, extra_headers: dict[str, str] | None = None
14191420
) -> BatchCancelOrdersV2Response:
14201421
self._require_auth()
1421-
body = request.model_dump(exclude_none=True, by_alias=True, mode="json")
1422-
data = await self._delete_with_body(
1423-
"/portfolio/events/orders/batched", json=body, extra_headers=extra_headers
1422+
body = request.model_dump_json(exclude_none=True, by_alias=True).encode()
1423+
data = await self._delete_with_body_json(
1424+
"/portfolio/events/orders/batched", content=body, extra_headers=extra_headers
14241425
)
14251426
if data is None:
14261427
raise KalshiError("Expected BatchCancelOrdersV2Response body, got 204 No Content.")

tests/test_async_orders.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1559,6 +1559,72 @@ async def test_204_raises(self, orders: AsyncOrdersResource) -> None:
15591559
)
15601560

15611561

1562+
class TestIssue329AsyncBatchV2BytesFastPath:
1563+
"""Async mirror of test_orders' TestIssue329BatchV2BytesFastPath — V2 batch
1564+
endpoints must route through the bytes fast-path helpers (#223, #329).
1565+
"""
1566+
1567+
@pytest.mark.asyncio
1568+
async def test_issue_329_v2_batch_create_uses_bytes_fast_path(
1569+
self, orders: AsyncOrdersResource,
1570+
) -> None:
1571+
request = BatchCreateOrdersV2Request(
1572+
orders=[
1573+
CreateOrderV2Request(
1574+
ticker="MKT-A",
1575+
client_order_id="cli-1",
1576+
side="bid",
1577+
count=Decimal("10"),
1578+
price=Decimal("0.50"),
1579+
time_in_force="good_till_canceled",
1580+
self_trade_prevention_type="taker_at_cross",
1581+
),
1582+
],
1583+
)
1584+
with patch.object(
1585+
AsyncOrdersResource,
1586+
"_post_json",
1587+
new=AsyncMock(return_value={"orders": []}),
1588+
) as post_json, patch.object(
1589+
AsyncOrdersResource, "_post", new=AsyncMock(),
1590+
) as post_dict:
1591+
await orders.batch_create_v2(request=request)
1592+
post_dict.assert_not_called()
1593+
assert post_json.call_count == 1
1594+
kwargs = post_json.call_args.kwargs
1595+
assert "json" not in kwargs
1596+
body = kwargs["content"]
1597+
assert isinstance(body, bytes)
1598+
assert json.loads(body) == request.model_dump(
1599+
exclude_none=True, by_alias=True, mode="json",
1600+
)
1601+
1602+
@pytest.mark.asyncio
1603+
async def test_issue_329_v2_batch_cancel_uses_bytes_fast_path(
1604+
self, orders: AsyncOrdersResource,
1605+
) -> None:
1606+
request = BatchCancelOrdersV2Request(
1607+
orders=[BatchCancelOrdersV2RequestOrder(order_id="ord-a")],
1608+
)
1609+
with patch.object(
1610+
AsyncOrdersResource,
1611+
"_delete_with_body_json",
1612+
new=AsyncMock(return_value={"orders": []}),
1613+
) as del_json, patch.object(
1614+
AsyncOrdersResource, "_delete_with_body", new=AsyncMock(),
1615+
) as del_dict:
1616+
await orders.batch_cancel_v2(request=request)
1617+
del_dict.assert_not_called()
1618+
assert del_json.call_count == 1
1619+
kwargs = del_json.call_args.kwargs
1620+
assert "json" not in kwargs
1621+
body = kwargs["content"]
1622+
assert isinstance(body, bytes)
1623+
assert json.loads(body) == request.model_dump(
1624+
exclude_none=True, by_alias=True, mode="json",
1625+
)
1626+
1627+
15621628
class TestAsyncAmendDecreaseV2QueryParams:
15631629
"""Regression guard: subaccount must reach the query string (not the body)
15641630
on both amend_v2 and decrease_v2 — exchange_index stays in the body.

tests/test_communications.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,3 +867,80 @@ def test_async_client_exposes_communications(
867867
assert isinstance(
868868
async_client.communications, AsyncCommunicationsResource,
869869
)
870+
871+
872+
class TestIssue324CommunicationsStatusLiteralNarrowing:
873+
"""``status`` on list_rfqs / list_quotes is narrowed to a closed Literal
874+
matching the spec's RFQ.status and Quote.status enums (#324). The values
875+
are stable: tightening the type later would be a breaking change, so we
876+
pin them here. Static checking (mypy) is what fences typos at the call
877+
site; we mirror the static contract at runtime via ``typing.get_args``.
878+
"""
879+
880+
def test_issue_324_communications_status_literal_narrowing(self) -> None:
881+
from typing import get_args
882+
883+
from kalshi.models.communications import (
884+
QuoteStatusLiteral,
885+
RfqStatusLiteral,
886+
)
887+
888+
# Pin the closed set of values against the OpenAPI spec. Adding a new
889+
# status is a non-breaking expansion; removing one is breaking.
890+
assert set(get_args(RfqStatusLiteral)) == {"open", "closed"}
891+
assert set(get_args(QuoteStatusLiteral)) == {
892+
"open", "accepted", "confirmed", "executed", "cancelled",
893+
}
894+
895+
def test_issue_324_status_literals_reexported_from_models_and_root(self) -> None:
896+
import kalshi
897+
import kalshi.models
898+
899+
assert kalshi.RfqStatusLiteral is kalshi.models.RfqStatusLiteral
900+
assert kalshi.QuoteStatusLiteral is kalshi.models.QuoteStatusLiteral
901+
assert "RfqStatusLiteral" in kalshi.__all__
902+
assert "QuoteStatusLiteral" in kalshi.__all__
903+
904+
@respx.mock
905+
def test_issue_324_valid_rfq_status_flows_through_to_query(
906+
self, comms: CommunicationsResource,
907+
) -> None:
908+
route = respx.get(
909+
"https://test.kalshi.com/trade-api/v2/communications/rfqs",
910+
).mock(return_value=httpx.Response(200, json={"rfqs": []}))
911+
comms.list_rfqs(status="closed")
912+
assert route.calls[0].request.url.params["status"] == "closed"
913+
914+
@respx.mock
915+
def test_issue_324_valid_quote_status_flows_through_to_query(
916+
self, comms: CommunicationsResource,
917+
) -> None:
918+
route = respx.get(
919+
"https://test.kalshi.com/trade-api/v2/communications/quotes",
920+
).mock(return_value=httpx.Response(200, json={"quotes": []}))
921+
comms.list_quotes(status="executed", quote_creator_user_id="u1")
922+
assert route.calls[0].request.url.params["status"] == "executed"
923+
924+
@pytest.mark.asyncio
925+
async def test_issue_324_valid_rfq_status_flows_through_to_query_async(
926+
self,
927+
async_comms: AsyncCommunicationsResource,
928+
respx_mock: respx.MockRouter,
929+
) -> None:
930+
route = respx_mock.get(
931+
"https://test.kalshi.com/trade-api/v2/communications/rfqs",
932+
).mock(return_value=httpx.Response(200, json={"rfqs": []}))
933+
await async_comms.list_rfqs(status="closed")
934+
assert route.calls[0].request.url.params["status"] == "closed"
935+
936+
@pytest.mark.asyncio
937+
async def test_issue_324_valid_quote_status_flows_through_to_query_async(
938+
self,
939+
async_comms: AsyncCommunicationsResource,
940+
respx_mock: respx.MockRouter,
941+
) -> None:
942+
route = respx_mock.get(
943+
"https://test.kalshi.com/trade-api/v2/communications/quotes",
944+
).mock(return_value=httpx.Response(200, json={"quotes": []}))
945+
await async_comms.list_quotes(status="executed", quote_creator_user_id="u1")
946+
assert route.calls[0].request.url.params["status"] == "executed"

0 commit comments

Comments
 (0)