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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
13 changes: 13 additions & 0 deletions kalshi/_contract_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 40 additions & 0 deletions kalshi/models/markets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
19 changes: 9 additions & 10 deletions kalshi/resources/markets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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]


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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]
7 changes: 4 additions & 3 deletions tests/test_async_markets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
57 changes: 53 additions & 4 deletions tests/test_markets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -373,21 +375,39 @@ 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
) -> None:
"""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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down