diff --git a/CHANGELOG.md b/CHANGELOG.md index 04affb3..d7e27ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,16 @@ prediction-API surface. Additive release: no changes to `KalshiClient`. runnable `examples/perps_create_order.py` / `perps_stream_ticker.py` / `perps_balance_risk.py`. +### Changed + +- Prediction-API list endpoints `markets.candlesticks` / `bulk_candlesticks` / + `bulk_orderbooks` now validate a typed response envelope: a **missing** + spec-required array key raises `ValidationError` (surfacing spec drift instead + of silently returning `[]`), while a **null** array coerces to `[]` (Kalshi's + empty-as-null convention — the prior `data.get(...)` extraction would + `TypeError` on a null array). The equivalent perps endpoints were hardened the + same way (markets/funding list responses). + ### Internal - Vendored the three perps specs (`specs/perps_openapi.yaml`, diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index 4514658..32e2c25 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -283,6 +283,19 @@ class ContractEntry: "this array-typed schema." ), ), + # -- list-response envelopes (#404) ---------------------------------- + ContractEntry( + sdk_model="kalshi.models.markets.GetMarketCandlesticksResponse", + spec_schema="GetMarketCandlesticksResponse", + ), + ContractEntry( + sdk_model="kalshi.models.markets.BatchGetMarketCandlesticksResponse", + spec_schema="BatchGetMarketCandlesticksResponse", + ), + ContractEntry( + sdk_model="kalshi.models.markets.GetMarketOrderbooksResponse", + spec_schema="GetMarketOrderbooksResponse", + ), # -- orders V2 family (#171) ----------------------------------------- ContractEntry( sdk_model="kalshi.models.orders.AmendOrderRequest", diff --git a/kalshi/models/markets.py b/kalshi/models/markets.py index 70f0677..6932892 100644 --- a/kalshi/models/markets.py +++ b/kalshi/models/markets.py @@ -261,3 +261,43 @@ class MarketCandlesticks(BaseModel): candlesticks: NullableList[Candlestick] model_config = {"extra": "allow"} + + +class GetMarketCandlesticksResponse(BaseModel): + """Spec ``GetMarketCandlesticksResponse`` — single-market candlesticks envelope. + + ``ticker`` and ``candlesticks`` are spec-required. ``candlesticks`` uses + :data:`~kalshi.types.NullableList` so a **missing** key hard-fails (surfacing + spec drift instead of silently returning ``[]``) while a ``null`` value — + Kalshi's observed "empty-as-null" convention — coerces to ``[]`` (the prior + ``data.get(...)`` extraction would ``TypeError`` on a null array). + """ + + ticker: str + candlesticks: NullableList[Candlestick] + + model_config = {"extra": "allow"} + + +class BatchGetMarketCandlesticksResponse(BaseModel): + """Spec ``BatchGetMarketCandlesticksResponse`` — bulk-candlesticks envelope. + + ``markets`` is spec-required (NullableList: missing key -> error, null -> []). + """ + + markets: NullableList[MarketCandlesticks] + + model_config = {"extra": "allow"} + + +class GetMarketOrderbooksResponse(BaseModel): + """Spec ``GetMarketOrderbooksResponse`` — bulk-orderbooks envelope. + + ``orderbooks`` is spec-required (NullableList: missing key -> error, null -> + []). Items stay raw dicts — the resource transforms each via + ``_orderbook_from_item`` (the yes/no FixedPoint parsing it already owns). + """ + + orderbooks: NullableList[dict[str, Any]] + + model_config = {"extra": "allow"} diff --git a/kalshi/resources/markets.py b/kalshi/resources/markets.py index c22ea61..b6d41d8 100644 --- a/kalshi/resources/markets.py +++ b/kalshi/resources/markets.py @@ -12,7 +12,10 @@ from kalshi.models.common import Page from kalshi.models.historical import Trade from kalshi.models.markets import ( + BatchGetMarketCandlesticksResponse, Candlestick, + GetMarketCandlesticksResponse, + GetMarketOrderbooksResponse, Market, MarketCandlesticks, MarketStatusLiteral, @@ -337,8 +340,7 @@ def candlesticks( params=params, extra_headers=extra_headers, ) - raw = data.get("candlesticks", []) - return [Candlestick.model_validate(c) for c in raw] + return GetMarketCandlesticksResponse.model_validate(data).candlesticks def list_trades( self, @@ -442,8 +444,7 @@ def bulk_candlesticks( include_latest_before_start=include_latest_before_start, ) data = self._get("/markets/candlesticks", params=params, extra_headers=extra_headers) - raw = data.get("markets", []) - return [MarketCandlesticks.model_validate(m) for m in raw] + return BatchGetMarketCandlesticksResponse.model_validate(data).markets def bulk_orderbooks( self, *, tickers: builtins.list[str], extra_headers: dict[str, str] | None = None @@ -457,7 +458,7 @@ def bulk_orderbooks( self._require_auth() params = _bulk_orderbooks_params(tickers=tickers) data = self._get("/markets/orderbooks", params=params, extra_headers=extra_headers) - raw = data.get("orderbooks", []) + raw = GetMarketOrderbooksResponse.model_validate(data).orderbooks return [_orderbook_from_item(item) for item in raw] @@ -590,8 +591,7 @@ async def candlesticks( params=params, extra_headers=extra_headers, ) - raw = data.get("candlesticks", []) - return [Candlestick.model_validate(c) for c in raw] + return GetMarketCandlesticksResponse.model_validate(data).candlesticks async def list_trades( self, @@ -696,8 +696,7 @@ async def bulk_candlesticks( include_latest_before_start=include_latest_before_start, ) data = await self._get("/markets/candlesticks", params=params, extra_headers=extra_headers) - raw = data.get("markets", []) - return [MarketCandlesticks.model_validate(m) for m in raw] + return BatchGetMarketCandlesticksResponse.model_validate(data).markets async def bulk_orderbooks( self, *, tickers: builtins.list[str], extra_headers: dict[str, str] | None = None @@ -711,5 +710,5 @@ async def bulk_orderbooks( self._require_auth() params = _bulk_orderbooks_params(tickers=tickers) data = await self._get("/markets/orderbooks", params=params, extra_headers=extra_headers) - raw = data.get("orderbooks", []) + raw = GetMarketOrderbooksResponse.model_validate(data).orderbooks return [_orderbook_from_item(item) for item in raw] diff --git a/tests/test_async_markets.py b/tests/test_async_markets.py index 99ff5a2..ddedfef 100644 --- a/tests/test_async_markets.py +++ b/tests/test_async_markets.py @@ -282,6 +282,7 @@ async def test_returns_nested_candlesticks(self, markets: AsyncMarketsResource) return_value=httpx.Response( 200, json={ + "ticker": "MKT", "candlesticks": [ { "end_period_ts": 1700000000, @@ -329,7 +330,7 @@ async def test_candlesticks_with_include_latest_before_start_true( """v0.7.0 ADD: include_latest_before_start=True sends 'true' on wire.""" route = respx.get( "https://test.kalshi.com/trade-api/v2/series/SER/markets/MKT/candlesticks" - ).mock(return_value=httpx.Response(200, json={"candlesticks": []})) + ).mock(return_value=httpx.Response(200, json={"ticker": "MKT", "candlesticks": []})) await markets.candlesticks( "SER", "MKT", @@ -346,7 +347,7 @@ async def test_candlesticks_sends_explicit_false(self, markets: AsyncMarketsReso """Tri-state bool: False must send 'false' (opt-out survives server default flips).""" route = respx.get( "https://test.kalshi.com/trade-api/v2/series/SER/markets/MKT/candlesticks" - ).mock(return_value=httpx.Response(200, json={"candlesticks": []})) + ).mock(return_value=httpx.Response(200, json={"ticker": "MKT", "candlesticks": []})) await markets.candlesticks( "SER", "MKT", @@ -365,7 +366,7 @@ async def test_candlesticks_omits_include_latest_when_none( """Tri-state bool: None drops the param entirely.""" route = respx.get( "https://test.kalshi.com/trade-api/v2/series/SER/markets/MKT/candlesticks" - ).mock(return_value=httpx.Response(200, json={"candlesticks": []})) + ).mock(return_value=httpx.Response(200, json={"ticker": "MKT", "candlesticks": []})) await markets.candlesticks( "SER", "MKT", diff --git a/tests/test_markets.py b/tests/test_markets.py index b0190f2..6f84e90 100644 --- a/tests/test_markets.py +++ b/tests/test_markets.py @@ -7,6 +7,7 @@ import httpx import pytest import respx +from pydantic import ValidationError from kalshi._base_client import SyncTransport from kalshi.auth import KalshiAuth @@ -329,6 +330,7 @@ def test_returns_nested_candlesticks(self, markets: MarketsResource) -> None: return_value=httpx.Response( 200, json={ + "ticker": "MKT", "candlesticks": [ { "end_period_ts": 1700000000, @@ -373,13 +375,31 @@ def test_returns_nested_candlesticks(self, markets: MarketsResource) -> None: @respx.mock def test_empty_candlesticks(self, markets: MarketsResource) -> None: respx.get("https://test.kalshi.com/trade-api/v2/series/SER/markets/MKT/candlesticks").mock( - return_value=httpx.Response(200, json={"candlesticks": []}) + return_value=httpx.Response(200, json={"ticker": "MKT", "candlesticks": []}) ) candles = markets.candlesticks( "SER", "MKT", start_ts=1700000000, end_ts=1700100000, period_interval=60 ) assert candles == [] + @respx.mock + def test_missing_key_raises_but_null_tolerated(self, markets: MarketsResource) -> None: + # #404: a MISSING ticker/candlesticks key hard-fails (surfacing drift); + # a NULL candlesticks array coerces to [] (Kalshi's empty-as-null — the + # prior `data.get(...)` extraction would TypeError on a null array). + url = "https://test.kalshi.com/trade-api/v2/series/SER/markets/MKT/candlesticks" + route = respx.get(url) + route.mock(return_value=httpx.Response(200, json={"ticker": "MKT"})) # no array + with pytest.raises(ValidationError): + markets.candlesticks("SER", "MKT", start_ts=1, end_ts=2, period_interval=60) + route.mock(return_value=httpx.Response(200, json={"candlesticks": []})) # no ticker + with pytest.raises(ValidationError): + markets.candlesticks("SER", "MKT", start_ts=1, end_ts=2, period_interval=60) + route.mock( + return_value=httpx.Response(200, json={"ticker": "MKT", "candlesticks": None}) + ) + assert markets.candlesticks("SER", "MKT", start_ts=1, end_ts=2, period_interval=60) == [] + @respx.mock def test_candlesticks_with_include_latest_before_start_true( self, markets: MarketsResource @@ -387,7 +407,7 @@ def test_candlesticks_with_include_latest_before_start_true( """v0.7.0 ADD: include_latest_before_start=True sends 'true' on wire.""" route = respx.get( "https://test.kalshi.com/trade-api/v2/series/SER/markets/MKT/candlesticks" - ).mock(return_value=httpx.Response(200, json={"candlesticks": []})) + ).mock(return_value=httpx.Response(200, json={"ticker": "MKT", "candlesticks": []})) markets.candlesticks( "SER", "MKT", @@ -403,7 +423,7 @@ def test_candlesticks_sends_explicit_false(self, markets: MarketsResource) -> No """Tri-state bool: False must send 'false' (opt-out survives server default flips).""" route = respx.get( "https://test.kalshi.com/trade-api/v2/series/SER/markets/MKT/candlesticks" - ).mock(return_value=httpx.Response(200, json={"candlesticks": []})) + ).mock(return_value=httpx.Response(200, json={"ticker": "MKT", "candlesticks": []})) markets.candlesticks( "SER", "MKT", @@ -419,7 +439,7 @@ def test_candlesticks_omits_include_latest_when_none(self, markets: MarketsResou """Tri-state bool: None drops the param entirely.""" route = respx.get( "https://test.kalshi.com/trade-api/v2/series/SER/markets/MKT/candlesticks" - ).mock(return_value=httpx.Response(200, json={"candlesticks": []})) + ).mock(return_value=httpx.Response(200, json={"ticker": "MKT", "candlesticks": []})) markets.candlesticks( "SER", "MKT", @@ -552,6 +572,23 @@ def test_is_block_trade_filter_serializes(self, markets: MarketsResource) -> Non class TestMarketsBulkCandlesticks: + @respx.mock + def test_missing_markets_raises_but_null_tolerated(self, markets: MarketsResource) -> None: + # #404: a missing `markets` key hard-fails (drift); a null array -> []. + route = respx.get("https://test.kalshi.com/trade-api/v2/markets/candlesticks") + route.mock(return_value=httpx.Response(200, json={})) + with pytest.raises(ValidationError): + markets.bulk_candlesticks( + market_tickers=["A"], start_ts=1, end_ts=2, period_interval=60 + ) + route.mock(return_value=httpx.Response(200, json={"markets": None})) + assert ( + markets.bulk_candlesticks( + market_tickers=["A"], start_ts=1, end_ts=2, period_interval=60 + ) + == [] + ) + @respx.mock def test_returns_per_market_bundles(self, markets: MarketsResource) -> None: route = respx.get( @@ -733,6 +770,18 @@ def test_bulk_orderbooks_rejects_over_100( class TestMarketsBulkOrderbooks: + @respx.mock + def test_missing_orderbooks_raises_but_null_tolerated( + self, markets: MarketsResource + ) -> None: + # #404: a missing `orderbooks` key hard-fails (drift); a null array -> []. + route = respx.get("https://test.kalshi.com/trade-api/v2/markets/orderbooks") + route.mock(return_value=httpx.Response(200, json={})) + with pytest.raises(ValidationError): + markets.bulk_orderbooks(tickers=["A"]) + route.mock(return_value=httpx.Response(200, json={"orderbooks": None})) + assert markets.bulk_orderbooks(tickers=["A"]) == [] + @respx.mock def test_returns_list_of_orderbooks(self, markets: MarketsResource) -> None: route = respx.get(