Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions kalshi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,9 @@
PositionsResponse,
PriceDistribution,
Quote,
QuoteStatusLiteral,
RateLimit,
RfqStatusLiteral,
Schedule,
ScopeList,
SelfTradePreventionTypeLiteral,
Expand Down Expand Up @@ -309,7 +311,9 @@
"PositionsResponse",
"PriceDistribution",
"Quote",
"QuoteStatusLiteral",
"RateLimit",
"RfqStatusLiteral",
"Schedule",
"ScopeList",
"SelfTradePreventionTypeLiteral",
Expand Down
4 changes: 4 additions & 0 deletions kalshi/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
GetRFQsResponse,
MveSelectedLeg,
Quote,
QuoteStatusLiteral,
RfqStatusLiteral,
UserFilterLiteral,
)
from kalshi.models.events import (
Expand Down Expand Up @@ -293,7 +295,9 @@
"PositionsResponse",
"PriceDistribution",
"Quote",
"QuoteStatusLiteral",
"RateLimit",
"RfqStatusLiteral",
"Schedule",
"ScopeList",
"SelfTradePreventionTypeLiteral",
Expand Down
6 changes: 6 additions & 0 deletions kalshi/models/communications.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
UserFilterLiteral = Literal["self"]
"""Filter for items created by the authenticated user. Spec: UserFilter enum."""

RfqStatusLiteral = Literal["open", "closed"]
"""RFQ status filter for GET /communications/rfqs. Spec: RFQ.status enum."""

QuoteStatusLiteral = Literal["open", "accepted", "confirmed", "executed", "cancelled"]
"""Quote status filter for GET /communications/quotes. Spec: Quote.status enum."""


class MveSelectedLeg(BaseModel):
"""A selected leg within a multivariate event collection RFQ."""
Expand Down
22 changes: 12 additions & 10 deletions kalshi/resources/communications.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
GetQuoteResponse,
GetRFQResponse,
Quote,
QuoteStatusLiteral,
RfqStatusLiteral,
UserFilterLiteral,
)
from kalshi.resources._base import (
Expand Down Expand Up @@ -68,7 +70,7 @@ def _list_rfqs_params(
event_ticker: str | None,
market_ticker: str | None,
subaccount: int | None,
status: str | None,
status: RfqStatusLiteral | None,
creator_user_id: str | None,
user_filter: UserFilterLiteral | None,
) -> dict[str, Any]:
Expand All @@ -91,7 +93,7 @@ def _list_quotes_params(
limit: int | None,
event_ticker: str | None,
market_ticker: str | None,
status: str | None,
status: QuoteStatusLiteral | None,
quote_creator_user_id: str | None,
rfq_creator_user_id: str | None,
rfq_creator_subtrader_id: str | None,
Expand Down Expand Up @@ -218,7 +220,7 @@ def list_rfqs(
event_ticker: str | None = None,
market_ticker: str | None = None,
subaccount: int | None = None,
status: str | None = None,
status: RfqStatusLiteral | None = None,
creator_user_id: str | None = None,
user_filter: UserFilterLiteral | None = None,
extra_headers: dict[str, str] | None = None,
Expand All @@ -245,7 +247,7 @@ def list_all_rfqs(
event_ticker: str | None = None,
market_ticker: str | None = None,
subaccount: int | None = None,
status: str | None = None,
status: RfqStatusLiteral | None = None,
creator_user_id: str | None = None,
user_filter: UserFilterLiteral | None = None,
max_pages: int | None = None,
Expand Down Expand Up @@ -338,7 +340,7 @@ def list_quotes(
limit: int | None = None,
event_ticker: str | None = None,
market_ticker: str | None = None,
status: str | None = None,
status: QuoteStatusLiteral | None = None,
quote_creator_user_id: str | None = None,
rfq_creator_user_id: str | None = None,
rfq_creator_subtrader_id: str | None = None,
Expand Down Expand Up @@ -377,7 +379,7 @@ def list_all_quotes(
limit: int | None = None,
event_ticker: str | None = None,
market_ticker: str | None = None,
status: str | None = None,
status: QuoteStatusLiteral | None = None,
quote_creator_user_id: str | None = None,
rfq_creator_user_id: str | None = None,
rfq_creator_subtrader_id: str | None = None,
Expand Down Expand Up @@ -533,7 +535,7 @@ async def list_rfqs(
event_ticker: str | None = None,
market_ticker: str | None = None,
subaccount: int | None = None,
status: str | None = None,
status: RfqStatusLiteral | None = None,
creator_user_id: str | None = None,
user_filter: UserFilterLiteral | None = None,
extra_headers: dict[str, str] | None = None,
Expand All @@ -560,7 +562,7 @@ def list_all_rfqs(
event_ticker: str | None = None,
market_ticker: str | None = None,
subaccount: int | None = None,
status: str | None = None,
status: RfqStatusLiteral | None = None,
creator_user_id: str | None = None,
user_filter: UserFilterLiteral | None = None,
max_pages: int | None = None,
Expand Down Expand Up @@ -655,7 +657,7 @@ async def list_quotes(
limit: int | None = None,
event_ticker: str | None = None,
market_ticker: str | None = None,
status: str | None = None,
status: QuoteStatusLiteral | None = None,
quote_creator_user_id: str | None = None,
rfq_creator_user_id: str | None = None,
rfq_creator_subtrader_id: str | None = None,
Expand Down Expand Up @@ -694,7 +696,7 @@ def list_all_quotes(
limit: int | None = None,
event_ticker: str | None = None,
market_ticker: str | None = None,
status: str | None = None,
status: QuoteStatusLiteral | None = None,
quote_creator_user_id: str | None = None,
rfq_creator_user_id: str | None = None,
rfq_creator_subtrader_id: str | None = None,
Expand Down
25 changes: 13 additions & 12 deletions kalshi/resources/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ def _build_batch_cancel_body(
return request.model_dump_json(exclude_none=True, by_alias=True).encode()



def _build_amend_body(
request: AmendOrderRequest | None,
*,
Expand Down Expand Up @@ -867,19 +868,19 @@ def batch_create_v2(
self, *, request: BatchCreateOrdersV2Request, extra_headers: dict[str, str] | None = None
) -> BatchCreateOrdersV2Response:
self._require_auth()
body = request.model_dump(exclude_none=True, by_alias=True, mode="json")
data = self._post(
"/portfolio/events/orders/batched", json=body, extra_headers=extra_headers
body = request.model_dump_json(exclude_none=True, by_alias=True).encode()
data = self._post_json(
"/portfolio/events/orders/batched", content=body, extra_headers=extra_headers
)
return BatchCreateOrdersV2Response.model_validate(data)

def batch_cancel_v2(
self, *, request: BatchCancelOrdersV2Request, extra_headers: dict[str, str] | None = None
) -> BatchCancelOrdersV2Response:
self._require_auth()
body = request.model_dump(exclude_none=True, by_alias=True, mode="json")
data = self._delete_with_body(
"/portfolio/events/orders/batched", json=body, extra_headers=extra_headers
body = request.model_dump_json(exclude_none=True, by_alias=True).encode()
data = self._delete_with_body_json(
"/portfolio/events/orders/batched", content=body, extra_headers=extra_headers
)
if data is None:
raise KalshiError("Expected BatchCancelOrdersV2Response body, got 204 No Content.")
Expand Down Expand Up @@ -1408,19 +1409,19 @@ async def batch_create_v2(
self, *, request: BatchCreateOrdersV2Request, extra_headers: dict[str, str] | None = None
) -> BatchCreateOrdersV2Response:
self._require_auth()
body = request.model_dump(exclude_none=True, by_alias=True, mode="json")
data = await self._post(
"/portfolio/events/orders/batched", json=body, extra_headers=extra_headers
body = request.model_dump_json(exclude_none=True, by_alias=True).encode()
data = await self._post_json(
"/portfolio/events/orders/batched", content=body, extra_headers=extra_headers
)
return BatchCreateOrdersV2Response.model_validate(data)

async def batch_cancel_v2(
self, *, request: BatchCancelOrdersV2Request, extra_headers: dict[str, str] | None = None
) -> BatchCancelOrdersV2Response:
self._require_auth()
body = request.model_dump(exclude_none=True, by_alias=True, mode="json")
data = await self._delete_with_body(
"/portfolio/events/orders/batched", json=body, extra_headers=extra_headers
body = request.model_dump_json(exclude_none=True, by_alias=True).encode()
data = await self._delete_with_body_json(
"/portfolio/events/orders/batched", content=body, extra_headers=extra_headers
)
if data is None:
raise KalshiError("Expected BatchCancelOrdersV2Response body, got 204 No Content.")
Expand Down
66 changes: 66 additions & 0 deletions tests/test_async_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -1559,6 +1559,72 @@ async def test_204_raises(self, orders: AsyncOrdersResource) -> None:
)


class TestIssue329AsyncBatchV2BytesFastPath:
"""Async mirror of test_orders' TestIssue329BatchV2BytesFastPath — V2 batch
endpoints must route through the bytes fast-path helpers (#223, #329).
"""

@pytest.mark.asyncio
async def test_issue_329_v2_batch_create_uses_bytes_fast_path(
self, orders: AsyncOrdersResource,
) -> None:
request = BatchCreateOrdersV2Request(
orders=[
CreateOrderV2Request(
ticker="MKT-A",
client_order_id="cli-1",
side="bid",
count=Decimal("10"),
price=Decimal("0.50"),
time_in_force="good_till_canceled",
self_trade_prevention_type="taker_at_cross",
),
],
)
with patch.object(
AsyncOrdersResource,
"_post_json",
new=AsyncMock(return_value={"orders": []}),
) as post_json, patch.object(
AsyncOrdersResource, "_post", new=AsyncMock(),
) as post_dict:
await orders.batch_create_v2(request=request)
post_dict.assert_not_called()
assert post_json.call_count == 1
kwargs = post_json.call_args.kwargs
assert "json" not in kwargs
body = kwargs["content"]
assert isinstance(body, bytes)
assert json.loads(body) == request.model_dump(
exclude_none=True, by_alias=True, mode="json",
)

@pytest.mark.asyncio
async def test_issue_329_v2_batch_cancel_uses_bytes_fast_path(
self, orders: AsyncOrdersResource,
) -> None:
request = BatchCancelOrdersV2Request(
orders=[BatchCancelOrdersV2RequestOrder(order_id="ord-a")],
)
with patch.object(
AsyncOrdersResource,
"_delete_with_body_json",
new=AsyncMock(return_value={"orders": []}),
) as del_json, patch.object(
AsyncOrdersResource, "_delete_with_body", new=AsyncMock(),
) as del_dict:
await orders.batch_cancel_v2(request=request)
del_dict.assert_not_called()
assert del_json.call_count == 1
kwargs = del_json.call_args.kwargs
assert "json" not in kwargs
body = kwargs["content"]
assert isinstance(body, bytes)
assert json.loads(body) == request.model_dump(
exclude_none=True, by_alias=True, mode="json",
)


class TestAsyncAmendDecreaseV2QueryParams:
"""Regression guard: subaccount must reach the query string (not the body)
on both amend_v2 and decrease_v2 — exchange_index stays in the body.
Expand Down
77 changes: 77 additions & 0 deletions tests/test_communications.py
Original file line number Diff line number Diff line change
Expand Up @@ -867,3 +867,80 @@ def test_async_client_exposes_communications(
assert isinstance(
async_client.communications, AsyncCommunicationsResource,
)


class TestIssue324CommunicationsStatusLiteralNarrowing:
"""``status`` on list_rfqs / list_quotes is narrowed to a closed Literal
matching the spec's RFQ.status and Quote.status enums (#324). The values
are stable: tightening the type later would be a breaking change, so we
pin them here. Static checking (mypy) is what fences typos at the call
site; we mirror the static contract at runtime via ``typing.get_args``.
"""

def test_issue_324_communications_status_literal_narrowing(self) -> None:
from typing import get_args

from kalshi.models.communications import (
QuoteStatusLiteral,
RfqStatusLiteral,
)

# Pin the closed set of values against the OpenAPI spec. Adding a new
# status is a non-breaking expansion; removing one is breaking.
assert set(get_args(RfqStatusLiteral)) == {"open", "closed"}
assert set(get_args(QuoteStatusLiteral)) == {
"open", "accepted", "confirmed", "executed", "cancelled",
}

def test_issue_324_status_literals_reexported_from_models_and_root(self) -> None:
import kalshi
import kalshi.models

assert kalshi.RfqStatusLiteral is kalshi.models.RfqStatusLiteral
assert kalshi.QuoteStatusLiteral is kalshi.models.QuoteStatusLiteral
assert "RfqStatusLiteral" in kalshi.__all__
assert "QuoteStatusLiteral" in kalshi.__all__

@respx.mock
def test_issue_324_valid_rfq_status_flows_through_to_query(
self, comms: CommunicationsResource,
) -> None:
route = respx.get(
"https://test.kalshi.com/trade-api/v2/communications/rfqs",
).mock(return_value=httpx.Response(200, json={"rfqs": []}))
comms.list_rfqs(status="closed")
assert route.calls[0].request.url.params["status"] == "closed"

@respx.mock
def test_issue_324_valid_quote_status_flows_through_to_query(
self, comms: CommunicationsResource,
) -> None:
route = respx.get(
"https://test.kalshi.com/trade-api/v2/communications/quotes",
).mock(return_value=httpx.Response(200, json={"quotes": []}))
comms.list_quotes(status="executed", quote_creator_user_id="u1")
assert route.calls[0].request.url.params["status"] == "executed"

@pytest.mark.asyncio
async def test_issue_324_valid_rfq_status_flows_through_to_query_async(
self,
async_comms: AsyncCommunicationsResource,
respx_mock: respx.MockRouter,
) -> None:
route = respx_mock.get(
"https://test.kalshi.com/trade-api/v2/communications/rfqs",
).mock(return_value=httpx.Response(200, json={"rfqs": []}))
await async_comms.list_rfqs(status="closed")
assert route.calls[0].request.url.params["status"] == "closed"

@pytest.mark.asyncio
async def test_issue_324_valid_quote_status_flows_through_to_query_async(
self,
async_comms: AsyncCommunicationsResource,
respx_mock: respx.MockRouter,
) -> None:
route = respx_mock.get(
"https://test.kalshi.com/trade-api/v2/communications/quotes",
).mock(return_value=httpx.Response(200, json={"quotes": []}))
await async_comms.list_quotes(status="executed", quote_creator_user_id="u1")
assert route.calls[0].request.url.params["status"] == "executed"
Loading
Loading