diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aa4be4..9d88a8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/kalshi/__init__.py b/kalshi/__init__.py index 3b2fd33..35d99c8 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -111,6 +111,7 @@ PositionsResponse, PriceDistribution, Quote, + RateLimit, Schedule, ScopeList, SelfTradePreventionTypeLiteral, @@ -246,6 +247,7 @@ "PositionsResponse", "PriceDistribution", "Quote", + "RateLimit", "Schedule", "ScopeList", "SelfTradePreventionTypeLiteral", diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index ca1c5a3..3ddf009 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -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", diff --git a/kalshi/models/__init__.py b/kalshi/models/__init__.py index 3fc6c6d..1e627e4 100644 --- a/kalshi/models/__init__.py +++ b/kalshi/models/__init__.py @@ -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, @@ -238,6 +238,7 @@ "PositionsResponse", "PriceDistribution", "Quote", + "RateLimit", "Schedule", "ScopeList", "SelfTradePreventionTypeLiteral", diff --git a/kalshi/models/account.py b/kalshi/models/account.py index 8a8f583..566861f 100644 --- a/kalshi/models/account.py +++ b/kalshi/models/account.py @@ -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"} diff --git a/kalshi/models/search.py b/kalshi/models/search.py index 363475d..cd09dee 100644 --- a/kalshi/models/search.py +++ b/kalshi/models/search.py @@ -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"} diff --git a/tests/integration/test_account.py b/tests/integration/test_account.py index 0365351..b2065a2 100644 --- a/tests/integration/test_account.py +++ b/tests/integration/test_account.py @@ -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 diff --git a/tests/test_account.py b/tests/test_account.py index 7515cbc..64dfcf2 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -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): @@ -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(