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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,36 @@ All notable changes to kalshi-sdk will be documented in this file.

## [Unreleased]

### Fixed

- **`/account/limits` response now parses against the live server.** The
published OpenAPI spec (v3.13.0) declares `read_limit`/`write_limit` as
ints, but the live API returns nested `read`/`write` token-bucket objects
with `bucket_capacity` + `refill_rate`. `AccountApiLimits` now matches the
server. New `RateLimit` model exposed for the bucket structure.
- **`/search/tags_by_categories` no longer crashes** when a category (e.g.
`Social`) returns `null` instead of an empty list. `tags_by_categories`
values are now `NullableList[str]`, collapsing `null` → `[]`.

### Breaking

- **`AccountApiLimits.read_limit` / `.write_limit` removed.** Replaced with
`AccountApiLimits.read` / `.write`, both of type `RateLimit`
(`bucket_capacity: int`, `refill_rate: int`). The previous int fields
never worked against the live server, so practical migration impact is
expected to be limited to code written from the spec rather than tested
against the API.

```python
# Before
limits = client.account.limits()
limits.read_limit # AttributeError after upgrade

# After
limits.read.bucket_capacity # int
limits.read.refill_rate # int
```

- **`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
Expand Down
2 changes: 2 additions & 0 deletions kalshi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
PositionsResponse,
PriceDistribution,
Quote,
RateLimit,
Schedule,
ScopeList,
SelfTradePreventionTypeLiteral,
Expand Down Expand Up @@ -246,6 +247,7 @@
"PositionsResponse",
"PriceDistribution",
"Quote",
"RateLimit",
"Schedule",
"ScopeList",
"SelfTradePreventionTypeLiteral",
Expand Down
6 changes: 5 additions & 1 deletion kalshi/_contract_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,11 @@ class ContractEntry:
ContractEntry(
sdk_model="kalshi.models.account.AccountApiLimits",
spec_schema="GetAccountApiLimitsResponse",
notes="Spec wraps tier/read/write limits in GetAccountApiLimitsResponse",
notes=(
"SPEC DRIFT: spec declares int 'read_limit'/'write_limit' but the "
"live server returns nested 'read'/'write' token-bucket objects. "
"SDK model follows the server."
),
),
ContractEntry(
sdk_model="kalshi.models.structured_targets.StructuredTarget",
Expand Down
3 changes: 2 additions & 1 deletion kalshi/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Kalshi SDK data models."""

from kalshi.models.account import AccountApiLimits
from kalshi.models.account import AccountApiLimits, RateLimit
from kalshi.models.api_keys import (
ApiKey,
CreateApiKeyRequest,
Expand Down Expand Up @@ -238,6 +238,7 @@
"PositionsResponse",
"PriceDistribution",
"Quote",
"RateLimit",
"Schedule",
"ScopeList",
"SelfTradePreventionTypeLiteral",
Expand Down
25 changes: 20 additions & 5 deletions kalshi/models/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,31 @@
from pydantic import BaseModel


class RateLimit(BaseModel):
"""Per-direction (read/write) token-bucket rate limit.

The server enforces a token bucket per direction: ``bucket_capacity``
tokens are allowed in a burst; ``refill_rate`` tokens are added per
second up to the cap. Requests above the cap return 429.
"""

bucket_capacity: int
refill_rate: int

model_config = {"extra": "allow"}


class AccountApiLimits(BaseModel):
"""Rate limits associated with the authenticated user's API tier.

``read_limit`` and ``write_limit`` are requests-per-second ceilings
the server will enforce before returning 429. ``usage_tier`` is a
human-readable label (e.g., ``standard``, ``elevated``).
NOTE: The published OpenAPI spec (v3.13.0) declares ``read_limit`` and
``write_limit`` as ints, but the live server returns nested token-bucket
objects under ``read`` and ``write``. The SDK matches the server. If the
spec is corrected upstream, the contract-drift test will flag it.
"""

usage_tier: str
read_limit: int
write_limit: int
read: RateLimit
write: RateLimit

model_config = {"extra": "allow"}
9 changes: 7 additions & 2 deletions kalshi/models/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,14 @@ class SportFilterDetails(BaseModel):


class GetTagsForSeriesCategoriesResponse(BaseModel):
"""Response from GET /search/tags_by_categories."""
"""Response from GET /search/tags_by_categories.

tags_by_categories: dict[str, list[str]] = {}
NullableList collapses server-side nulls into empty lists, so consumers
can always iterate ``tags_by_categories[category]`` without a None check.
Observed in the wild: the ``Social`` category currently returns null.
"""

tags_by_categories: dict[str, NullableList[str]] = {}

model_config = {"extra": "allow"}

Expand Down
6 changes: 4 additions & 2 deletions tests/integration/test_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ def test_limits(self, sync_client: KalshiClient) -> None:
result = sync_client.account.limits()
assert isinstance(result, AccountApiLimits)
assert_model_fields(result)
assert result.read_limit >= 0
assert result.write_limit >= 0
assert result.read.bucket_capacity > 0
assert result.read.refill_rate > 0
assert result.write.bucket_capacity > 0
assert result.write.refill_rate > 0
assert result.usage_tier


Expand Down
20 changes: 12 additions & 8 deletions tests/test_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,17 @@ def test_returns_limits(self, account: AccountResource) -> None:
200,
json={
"usage_tier": "standard",
"read_limit": 100,
"write_limit": 10,
"read": {"bucket_capacity": 200, "refill_rate": 100},
"write": {"bucket_capacity": 20, "refill_rate": 10},
},
)
)
limits = account.limits()
assert limits.usage_tier == "standard"
assert limits.read_limit == 100
assert limits.write_limit == 10
assert limits.read.bucket_capacity == 200
assert limits.read.refill_rate == 100
assert limits.write.bucket_capacity == 20
assert limits.write.refill_rate == 10

def test_requires_auth(self, unauth_account: AccountResource) -> None:
with pytest.raises(AuthRequiredError):
Expand All @@ -86,15 +88,17 @@ async def test_returns_limits(
200,
json={
"usage_tier": "elevated",
"read_limit": 500,
"write_limit": 50,
"read": {"bucket_capacity": 1000, "refill_rate": 500},
"write": {"bucket_capacity": 100, "refill_rate": 50},
},
)
)
limits = await async_account.limits()
assert limits.usage_tier == "elevated"
assert limits.read_limit == 500
assert limits.write_limit == 50
assert limits.read.bucket_capacity == 1000
assert limits.read.refill_rate == 500
assert limits.write.bucket_capacity == 100
assert limits.write.refill_rate == 50

@pytest.mark.asyncio
async def test_requires_auth(
Expand Down
Loading