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
8 changes: 8 additions & 0 deletions kalshi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,15 @@
Balance,
BatchCancelOrdersRequest,
BatchCancelOrdersRequestOrder,
BatchCancelOrdersResponse,
BatchCancelOrdersResponseEntry,
BatchCancelOrdersV2Request,
BatchCancelOrdersV2RequestOrder,
BatchCancelOrdersV2Response,
BatchCancelOrdersV2ResponseEntry,
BatchCreateOrdersRequest,
BatchCreateOrdersResponse,
BatchCreateOrdersResponseEntry,
BatchCreateOrdersV2Request,
BatchCreateOrdersV2Response,
BatchCreateOrdersV2ResponseEntry,
Expand Down Expand Up @@ -182,11 +186,15 @@
"Balance",
"BatchCancelOrdersRequest",
"BatchCancelOrdersRequestOrder",
"BatchCancelOrdersResponse",
"BatchCancelOrdersResponseEntry",
"BatchCancelOrdersV2Request",
"BatchCancelOrdersV2RequestOrder",
"BatchCancelOrdersV2Response",
"BatchCancelOrdersV2ResponseEntry",
"BatchCreateOrdersRequest",
"BatchCreateOrdersResponse",
"BatchCreateOrdersResponseEntry",
"BatchCreateOrdersV2Request",
"BatchCreateOrdersV2Response",
"BatchCreateOrdersV2ResponseEntry",
Expand Down
18 changes: 18 additions & 0 deletions kalshi/_contract_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,24 @@ class ContractEntry:
spec_schema="BatchCancelOrdersV2Response.orders.items",
notes="Inline object schema (no named component)",
),
ContractEntry(
sdk_model="kalshi.models.orders.BatchCreateOrdersResponse",
spec_schema="BatchCreateOrdersResponse",
),
ContractEntry(
sdk_model="kalshi.models.orders.BatchCreateOrdersResponseEntry",
spec_schema="BatchCreateOrdersResponse.orders.items",
notes="Inline object schema (no named component)",
),
ContractEntry(
sdk_model="kalshi.models.orders.BatchCancelOrdersResponse",
spec_schema="BatchCancelOrdersResponse",
),
ContractEntry(
sdk_model="kalshi.models.orders.BatchCancelOrdersResponseEntry",
spec_schema="BatchCancelOrdersResponse.orders.items",
notes="Inline object schema (no named component)",
),
# -- events sub-models (#171) ----------------------------------------
ContractEntry(
sdk_model="kalshi.models.events.MarketMetadata",
Expand Down
8 changes: 8 additions & 0 deletions kalshi/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,15 @@
AmendOrderV2Response,
BatchCancelOrdersRequest,
BatchCancelOrdersRequestOrder,
BatchCancelOrdersResponse,
BatchCancelOrdersResponseEntry,
BatchCancelOrdersV2Request,
BatchCancelOrdersV2RequestOrder,
BatchCancelOrdersV2Response,
BatchCancelOrdersV2ResponseEntry,
BatchCreateOrdersRequest,
BatchCreateOrdersResponse,
BatchCreateOrdersResponseEntry,
BatchCreateOrdersV2Request,
BatchCreateOrdersV2Response,
BatchCreateOrdersV2ResponseEntry,
Expand Down Expand Up @@ -188,11 +192,15 @@
"Balance",
"BatchCancelOrdersRequest",
"BatchCancelOrdersRequestOrder",
"BatchCancelOrdersResponse",
"BatchCancelOrdersResponseEntry",
"BatchCancelOrdersV2Request",
"BatchCancelOrdersV2RequestOrder",
"BatchCancelOrdersV2Response",
"BatchCancelOrdersV2ResponseEntry",
"BatchCreateOrdersRequest",
"BatchCreateOrdersResponse",
"BatchCreateOrdersResponseEntry",
"BatchCreateOrdersV2Request",
"BatchCreateOrdersV2Response",
"BatchCreateOrdersV2ResponseEntry",
Expand Down
68 changes: 68 additions & 0 deletions kalshi/models/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,74 @@ class BatchCancelOrdersRequest(BaseModel):
model_config = {"extra": "forbid"}


class BatchCreateOrdersResponseEntry(BaseModel):
"""Single entry in :class:`BatchCreateOrdersResponse`.

Spec ``components.schemas.BatchCreateOrdersIndividualResponse``: all
three fields are nullable. A failed leg comes back as
``{"client_order_id": "x", "order": null, "error": {...}}``; a
successful leg as ``{"client_order_id": "x", "order": {...}, "error": null}``.
Pairing returned orders with the originating request requires
``client_order_id``; surfacing per-leg failures requires ``error``.
"""

order: Order | None = None
error: dict[str, object] | None = None
client_order_id: str | None = None

model_config = {"extra": "allow", "populate_by_name": True}


class BatchCreateOrdersResponse(BaseModel):
"""Response from ``POST /portfolio/orders/batched``.

Spec ``components.schemas.BatchCreateOrdersResponse``. v3.0.0
BREAKING: previously the SDK returned ``list[Order]`` and crashed
with ``ValidationError`` on the first failed leg
(``Order.model_validate(None)``). Now returns the typed envelope so
callers can inspect per-leg ``order``/``error``/``client_order_id``.
"""

orders: list[BatchCreateOrdersResponseEntry]

model_config = {"extra": "allow", "populate_by_name": True}


class BatchCancelOrdersResponseEntry(BaseModel):
"""Single entry in :class:`BatchCancelOrdersResponse`.

Spec ``components.schemas.BatchCancelOrdersIndividualResponse``:
``order_id`` and ``reduced_by_fp`` are required; ``order`` and
``error`` are nullable. ``reduced_by_fp`` is load-bearing for risk
reconciliation — it is the count of contracts that actually canceled
(``0`` when ``error`` is set, the canceled count otherwise).
"""

order_id: str
reduced_by_fp: FixedPointCount = Field(
validation_alias=AliasChoices("reduced_by_fp", "reduced_by"),
)
order: Order | None = None
error: dict[str, object] | None = None

model_config = {"extra": "allow", "populate_by_name": True}


class BatchCancelOrdersResponse(BaseModel):
"""Response from ``DELETE /portfolio/orders/batched``.

Spec ``components.schemas.BatchCancelOrdersResponse``. v3.0.0
BREAKING: previously the SDK declared ``-> None`` and discarded the
response body. Per-leg ``reduced_by_fp`` and any per-leg errors are
now surfaced.
"""

orders: list[BatchCancelOrdersResponseEntry]

model_config = {"extra": "allow", "populate_by_name": True}



class AmendOrderResponse(BaseModel):
"""Response from amending an order — contains both pre and post-amendment orders."""

Expand Down
42 changes: 42 additions & 0 deletions kalshi/resources/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import urllib.parse
from collections.abc import AsyncIterator, Iterator
from typing import Any, TypeVar

Expand All @@ -14,6 +15,47 @@
T = TypeVar("T", bound=BaseModel)


def _seg(value: str, *, name: str = "path segment") -> str:
"""URL-encode a single path segment; reject empty/whitespace and ``.``/``..``.

Caller-supplied identifiers (tickers, order ids, etc.) get interpolated
into request paths with f-strings. Without encoding, a value like
``"foo/../admin"`` would be normalized by intermediate proxies into a
request signed for the intended endpoint but routed to a different one.
``urllib.parse.quote(..., safe="")`` percent-encodes ``/`` and every
other reserved character. ``.``/``..`` are rejected outright because
they survive URL encoding but still trigger path normalization.
"""
if not value or not value.strip():
raise ValueError(f"{name} must be non-empty")
if value in (".", ".."):
raise ValueError(f"{name} cannot be '.' or '..'")
return urllib.parse.quote(value, safe="")


def _validate_limit(
value: int | None,
*,
hi: int,
lo: int = 1,
name: str = "limit",
) -> int | None:
"""Reject ``limit`` outside the spec-declared ``[lo, hi]`` window.

Returns the value unchanged on success. Kalshi enforces these bounds
server-side; failing client-side avoids a wasted round trip and a
less-actionable 400.
"""
if value is None:
return None
if not (lo <= value <= hi):
raise ValueError(
f"{name} must be in [{lo}, {hi}], got {value}."
)
return value



def _params(**kwargs: Any) -> dict[str, Any]:
"""Build a query-param dict, dropping None values."""
return {k: v for k, v in kwargs.items() if v is not None}
Expand Down
5 changes: 3 additions & 2 deletions kalshi/resources/api_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
AsyncResource,
SyncResource,
_check_request_exclusive,
_seg,
)

# Shared request-body builders (issue #46).
Expand Down Expand Up @@ -120,7 +121,7 @@ def generate(

def delete(self, api_key: str) -> None:
self._require_auth()
self._delete(f"/api_keys/{api_key}")
self._delete(f"/api_keys/{_seg(api_key, name='api_key')}")


class AsyncApiKeysResource(AsyncResource):
Expand Down Expand Up @@ -181,4 +182,4 @@ async def generate(

async def delete(self, api_key: str) -> None:
self._require_auth()
await self._delete(f"/api_keys/{api_key}")
await self._delete(f"/api_keys/{_seg(api_key, name='api_key')}")
28 changes: 16 additions & 12 deletions kalshi/resources/communications.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
SyncResource,
_check_request_exclusive,
_params,
_seg,
_validate_limit,
_validate_max_pages,
)

Expand Down Expand Up @@ -70,6 +72,7 @@ def _list_rfqs_params(
creator_user_id: str | None,
user_filter: UserFilterLiteral | None,
) -> dict[str, Any]:
limit = _validate_limit(limit, hi=100)
return _params(
cursor=cursor,
limit=limit,
Expand All @@ -96,6 +99,7 @@ def _list_quotes_params(
user_filter: UserFilterLiteral | None,
rfq_user_filter: UserFilterLiteral | None,
) -> dict[str, Any]:
limit = _validate_limit(limit, hi=500)
return _params(
cursor=cursor,
limit=limit,
Expand Down Expand Up @@ -254,7 +258,7 @@ def list_all_rfqs(

def get_rfq(self, rfq_id: str) -> GetRFQResponse:
self._require_auth()
data = self._get(f"/communications/rfqs/{rfq_id}")
data = self._get(f"/communications/rfqs/{_seg(rfq_id, name='rfq_id')}")
return GetRFQResponse.model_validate(data)

@overload
Expand Down Expand Up @@ -296,7 +300,7 @@ def create_rfq(

def delete_rfq(self, rfq_id: str) -> None:
self._require_auth()
self._delete(f"/communications/rfqs/{rfq_id}")
self._delete(f"/communications/rfqs/{_seg(rfq_id, name='rfq_id')}")

def list_quotes(
self,
Expand Down Expand Up @@ -368,7 +372,7 @@ def list_all_quotes(

def get_quote(self, quote_id: str) -> GetQuoteResponse:
self._require_auth()
data = self._get(f"/communications/quotes/{quote_id}")
data = self._get(f"/communications/quotes/{_seg(quote_id, name='quote_id')}")
return GetQuoteResponse.model_validate(data)

@overload
Expand Down Expand Up @@ -406,7 +410,7 @@ def create_quote(

def delete_quote(self, quote_id: str) -> None:
self._require_auth()
self._delete(f"/communications/quotes/{quote_id}")
self._delete(f"/communications/quotes/{_seg(quote_id, name='quote_id')}")

@overload
def accept_quote(
Expand All @@ -425,12 +429,12 @@ def accept_quote(
) -> None:
self._require_auth()
body = _build_accept_quote_body(request, accepted_side=accepted_side)
self._put(f"/communications/quotes/{quote_id}/accept", json=body)
self._put(f"/communications/quotes/{_seg(quote_id, name='quote_id')}/accept", json=body)

def confirm_quote(self, quote_id: str) -> None:
self._require_auth()
# json={} forces Content-Type: application/json — demo rejects empty PUTs.
self._put(f"/communications/quotes/{quote_id}/confirm", json={})
self._put(f"/communications/quotes/{_seg(quote_id, name='quote_id')}/confirm", json={})


class AsyncCommunicationsResource(AsyncResource):
Expand Down Expand Up @@ -490,7 +494,7 @@ def list_all_rfqs(

async def get_rfq(self, rfq_id: str) -> GetRFQResponse:
self._require_auth()
data = await self._get(f"/communications/rfqs/{rfq_id}")
data = await self._get(f"/communications/rfqs/{_seg(rfq_id, name='rfq_id')}")
return GetRFQResponse.model_validate(data)

@overload
Expand Down Expand Up @@ -532,7 +536,7 @@ async def create_rfq(

async def delete_rfq(self, rfq_id: str) -> None:
self._require_auth()
await self._delete(f"/communications/rfqs/{rfq_id}")
await self._delete(f"/communications/rfqs/{_seg(rfq_id, name='rfq_id')}")

async def list_quotes(
self,
Expand Down Expand Up @@ -606,7 +610,7 @@ def list_all_quotes(

async def get_quote(self, quote_id: str) -> GetQuoteResponse:
self._require_auth()
data = await self._get(f"/communications/quotes/{quote_id}")
data = await self._get(f"/communications/quotes/{_seg(quote_id, name='quote_id')}")
return GetQuoteResponse.model_validate(data)

@overload
Expand Down Expand Up @@ -646,7 +650,7 @@ async def create_quote(

async def delete_quote(self, quote_id: str) -> None:
self._require_auth()
await self._delete(f"/communications/quotes/{quote_id}")
await self._delete(f"/communications/quotes/{_seg(quote_id, name='quote_id')}")

@overload
async def accept_quote(
Expand All @@ -665,9 +669,9 @@ async def accept_quote(
) -> None:
self._require_auth()
body = _build_accept_quote_body(request, accepted_side=accepted_side)
await self._put(f"/communications/quotes/{quote_id}/accept", json=body)
await self._put(f"/communications/quotes/{_seg(quote_id, name='quote_id')}/accept", json=body) # noqa: E501

async def confirm_quote(self, quote_id: str) -> None:
self._require_auth()
# json={} forces Content-Type: application/json — demo rejects empty PUTs.
await self._put(f"/communications/quotes/{quote_id}/confirm", json={})
await self._put(f"/communications/quotes/{_seg(quote_id, name='quote_id')}/confirm", json={}) # noqa: E501
Loading
Loading