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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,30 @@

All notable changes to kalshi-sdk will be documented in this file.

## [Unreleased]

### Breaking

- **`Order.type` renamed to `Order.order_type`.** Wire format is unchanged
(`validation_alias=AliasChoices("type", "order_type")` accepts both names on
deserialization), but any user code reading `.type` on an `Order` instance
must migrate to `.order_type`. Rationale: matches the project's existing
builtin-shadow-avoidance convention (`milestone_type`, `target_type`,
`incentive_type`). Spec v3.13.0 still defines `type` as required, so the
field is preserved on the wire — only the Python attribute name changed
(#91).

```python
# Before
order = client.portfolio.orders.get(order_id="...")
print(order.type) # AttributeError after upgrade

# After
print(order.order_type)
```

Version-bump decision (v1.2 vs v2.0) deferred to release cut.

## 1.1.0 — 2026-05-16

Post-1.0 enhancements and polish. 17 issues closed across four parallel waves of
Expand Down
18 changes: 15 additions & 3 deletions kalshi/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import logging
import math
import random
import time
from typing import Any
Expand Down Expand Up @@ -59,6 +60,9 @@ def _map_error(response: httpx.Response) -> KalshiError:
if retry_after:
try:
retry_after_val = float(retry_after)
# Reject non-finite/negative: NaN crashes time.sleep, negative busy-loops.
if retry_after_val < 0 or not math.isfinite(retry_after_val):
retry_after_val = None
except ValueError:
retry_after_val = None # HTTP-date format, fall back to computed backoff
return KalshiRateLimitError(
Expand Down Expand Up @@ -168,8 +172,12 @@ def request(
if not should_retry:
raise error

# Use Retry-After header if available for 429
if isinstance(error, KalshiRateLimitError) and error.retry_after:
# Use Retry-After header if available for 429.
# `is not None` — not truthy — so Retry-After: 0 ("retry immediately") is honored.
if (
isinstance(error, KalshiRateLimitError)
and error.retry_after is not None
):
delay = min(error.retry_after, self._config.retry_max_delay)
else:
delay = _compute_backoff(attempt, self._config)
Expand Down Expand Up @@ -282,7 +290,11 @@ async def request(
if not should_retry:
raise error

if isinstance(error, KalshiRateLimitError) and error.retry_after:
# `is not None` so Retry-After: 0 ("retry immediately") is honored.
if (
isinstance(error, KalshiRateLimitError)
and error.retry_after is not None
):
delay = min(error.retry_after, self._config.retry_max_delay)
else:
delay = _compute_backoff(attempt, self._config)
Expand Down
44 changes: 44 additions & 0 deletions kalshi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from urllib.parse import urlparse

PRODUCTION_BASE_URL = "https://api.elections.kalshi.com/trade-api/v2"
DEMO_BASE_URL = "https://demo-api.kalshi.co/trade-api/v2"
Expand All @@ -14,6 +16,13 @@
DEFAULT_MAX_RETRIES = 3
DEFAULT_WS_MAX_RETRIES = 10

_KNOWN_HOSTS = frozenset(
{"api.elections.kalshi.com", "demo-api.kalshi.co"}
)
_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})

logger = logging.getLogger("kalshi")


@dataclass(frozen=True)
class KalshiConfig:
Expand Down Expand Up @@ -42,6 +51,41 @@ def __post_init__(self) -> None:
object.__setattr__(self, "base_url", self.base_url.rstrip("/"))
if self.ws_base_url.endswith("/"):
object.__setattr__(self, "ws_base_url", self.ws_base_url.rstrip("/"))
KalshiConfig._validate_url(self.base_url, "base_url", secure="https", plaintext="http")
KalshiConfig._validate_url(self.ws_base_url, "ws_base_url", secure="wss", plaintext="ws")

@staticmethod
def _validate_url(url: str, field_name: str, *, secure: str, plaintext: str) -> None:
"""Reject URLs that would expose credentials (bad scheme or plaintext-to-remote)."""
parsed = urlparse(url)
scheme = parsed.scheme.lower()
host = (parsed.hostname or "").lower()

if scheme not in (secure, plaintext):
raise ValueError(
f"KalshiConfig.{field_name} must use {secure}:// or {plaintext}://, "
f"got scheme={scheme!r} (url={url!r})"
)
if not host:
raise ValueError(
f"KalshiConfig.{field_name} is missing a host: {url!r}"
)
if scheme == plaintext and host not in _LOCAL_HOSTS:
raise ValueError(
f"KalshiConfig.{field_name} must use {secure}:// for non-loopback "
f"hosts; {plaintext}:// is only allowed for {sorted(_LOCAL_HOSTS)} "
f"(url={url!r}). Plaintext to a remote host would "
f"expose the KALSHI-ACCESS-KEY header and request signature."
)
if host not in _KNOWN_HOSTS and host not in _LOCAL_HOSTS:
logger.warning(
"KalshiConfig.%s host %r is not a known Kalshi "
"endpoint (%s). Requests will be signed and sent there "
"with your API key — verify this is intentional.",
field_name,
host,
sorted(_KNOWN_HOSTS),
)

@classmethod
def production(cls, **kwargs: object) -> KalshiConfig:
Expand Down
9 changes: 8 additions & 1 deletion kalshi/models/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,14 @@ class Order(BaseModel):
status: str | None = None
side: str | None = None
is_yes: bool | None = None
type: str | None = None
# Spec field is named ``type`` (enum: limit, market). Renamed to
# ``order_type`` on the SDK side to avoid shadowing the Python builtin —
# same rationale as milestone_type / target_type / incentive_type
# elsewhere. The wire still sends ``type``; validation alias accepts both.
order_type: str | None = Field(
default=None,
validation_alias=AliasChoices("type", "order_type"),
)
yes_price: DollarDecimal | None = Field(
default=None,
validation_alias=AliasChoices("yes_price_dollars", "yes_price"),
Expand Down
12 changes: 6 additions & 6 deletions kalshi/resources/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from kalshi.models.common import Page
from kalshi.models.events import Event, EventMetadata, EventStatusLiteral
from kalshi.resources._base import AsyncResource, SyncResource, _params
from kalshi.resources._base import AsyncResource, SyncResource, _bool_param, _params

# Shared param builders (issue #46).

Expand All @@ -26,8 +26,8 @@ def _list_events_params(
return _params(
status=status,
series_ticker=series_ticker,
with_nested_markets="true" if with_nested_markets else None,
with_milestones="true" if with_milestones else None,
with_nested_markets=_bool_param(with_nested_markets),
with_milestones=_bool_param(with_milestones),
min_close_ts=min_close_ts,
min_updated_ts=min_updated_ts,
limit=limit,
Expand All @@ -46,7 +46,7 @@ def _list_multivariate_events_params(
return _params(
series_ticker=series_ticker,
collection_ticker=collection_ticker,
with_nested_markets="true" if with_nested_markets else None,
with_nested_markets=_bool_param(with_nested_markets),
limit=limit,
cursor=cursor,
)
Expand Down Expand Up @@ -136,7 +136,7 @@ def get(
with_nested_markets: bool = False,
) -> Event:
params = _params(
with_nested_markets="true" if with_nested_markets else None,
with_nested_markets=_bool_param(with_nested_markets),
)
data = self._get(f"/events/{event_ticker}", params=params)
return Event.model_validate(data.get("event", data))
Expand Down Expand Up @@ -230,7 +230,7 @@ async def get(
with_nested_markets: bool = False,
) -> Event:
params = _params(
with_nested_markets="true" if with_nested_markets else None,
with_nested_markets=_bool_param(with_nested_markets),
)
data = await self._get(f"/events/{event_ticker}", params=params)
return Event.model_validate(data.get("event", data))
Expand Down
12 changes: 6 additions & 6 deletions kalshi/resources/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
Series,
SeriesFeeChange,
)
from kalshi.resources._base import AsyncResource, SyncResource, _params
from kalshi.resources._base import AsyncResource, SyncResource, _bool_param, _params

# Shared param builders (issue #46).

Expand All @@ -27,8 +27,8 @@ def _list_series_params(
return _params(
category=category,
tags=tags,
include_product_metadata="true" if include_product_metadata else None,
include_volume="true" if include_volume else None,
include_product_metadata=_bool_param(include_product_metadata),
include_volume=_bool_param(include_volume),
min_updated_ts=min_updated_ts,
)

Expand All @@ -40,7 +40,7 @@ def _fee_changes_params(
) -> dict[str, Any]:
return _params(
series_ticker=series_ticker,
show_historical="true" if show_historical else None,
show_historical=_bool_param(show_historical),
)


Expand Down Expand Up @@ -98,7 +98,7 @@ def get(
include_volume: bool | None = None,
) -> Series:
params = _params(
include_volume="true" if include_volume else None,
include_volume=_bool_param(include_volume),
)
data = self._get(f"/series/{series_ticker}", params=params)
return Series.model_validate(data.get("series", data))
Expand Down Expand Up @@ -188,7 +188,7 @@ async def get(
include_volume: bool | None = None,
) -> Series:
params = _params(
include_volume="true" if include_volume else None,
include_volume=_bool_param(include_volume),
)
data = await self._get(f"/series/{series_ticker}", params=params)
return Series.model_validate(data.get("series", data))
Expand Down
25 changes: 25 additions & 0 deletions tests/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,31 @@ async def test_get_retries_on_429(self, transport: AsyncTransport) -> None:
assert resp.status_code == 200
assert route.call_count == 2

@respx.mock
@pytest.mark.asyncio
async def test_429_retry_after_zero_is_honored_end_to_end(
self, transport: AsyncTransport, monkeypatch: pytest.MonkeyPatch
) -> None:
# Async counterpart of the sync regression: `is not None` keeps Retry-After: 0.
sleeps: list[float] = []

async def fake_sleep(d: float) -> None:
sleeps.append(d)

# _base_client imports asyncio inside the method, so patch the module attr directly.
monkeypatch.setattr("asyncio.sleep", fake_sleep)
respx.get("https://test.kalshi.com/trade-api/v2/markets").mock(
side_effect=[
httpx.Response(429, headers={"Retry-After": "0"}, json={"message": "rl"}),
httpx.Response(200, json={"markets": []}),
]
)
resp = await transport.request("GET", "/markets")
assert resp.status_code == 200
assert sleeps == [0.0], (
f"Expected one sleep of 0.0s honoring Retry-After: 0, got {sleeps!r}"
)

@respx.mock
@pytest.mark.asyncio
async def test_get_retries_on_500(self, transport: AsyncTransport) -> None:
Expand Down
Loading
Loading