From 0d19da3c20153a7d7ba7855d11d0604be6fb0b3e Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 5 Jun 2026 13:10:47 -0500 Subject: [PATCH] perps: align list-envelope null-handling with prediction (NullableList) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #418/#419. The perps list-response envelopes (markets/funding) used a non-nullable list — hard-failing on a null array per #404's strict wording — while the prediction envelopes (#419) use NullableList (null -> []). This restores the pre-#418 `or []` null tolerance and makes null-handling identical across both surfaces. `GetMarginMarketsResponse.markets`, `GetMarginMarketCandlesticksResponse.candlesticks`, `GetMarginHistoricalFundingRatesResponse.funding_rates`, and `GetMarginFundingHistoryResponse.funding_history` now use NullableList: a MISSING key still hard-fails (surfacing drift), a NULL array coerces to []. 3.2.0 is unreleased, so this refines the envelopes before release. Removed the now-unused `import builtins` from both model files. Tests updated: null -> [] instead of raising. mypy strict + ruff + 1140 perps/contract tests green. --- kalshi/perps/models/funding.py | 21 +++++++++++++-------- kalshi/perps/models/markets.py | 19 ++++++++++--------- tests/perps/test_funding.py | 23 +++++++++++++---------- tests/perps/test_markets.py | 22 +++++++++++++++++----- 4 files changed, 53 insertions(+), 32 deletions(-) diff --git a/kalshi/perps/models/funding.py b/kalshi/perps/models/funding.py index e2c947c..2dbfe8d 100644 --- a/kalshi/perps/models/funding.py +++ b/kalshi/perps/models/funding.py @@ -27,11 +27,14 @@ from __future__ import annotations -import builtins - from pydantic import AliasChoices, AwareDatetime, BaseModel, Field -from kalshi.types import DollarDecimal, FixedPointCount, MultiplierDecimal +from kalshi.types import ( + DollarDecimal, + FixedPointCount, + MultiplierDecimal, + NullableList, +) class MarginFundingRate(BaseModel): @@ -104,11 +107,12 @@ class MarginFundingRateEstimate(BaseModel): class GetMarginHistoricalFundingRatesResponse(BaseModel): """Spec ``GetMarginHistoricalFundingRatesResponse`` — historical-rates envelope. - ``funding_rates`` is spec-required; modeled non-optional so a missing/``null`` - array hard-fails rather than being silently coerced to ``[]``. + ``funding_rates`` is spec-required and uses + :data:`~kalshi.types.NullableList`: a missing key raises ``ValidationError`` + (surfacing drift), while a ``null`` array coerces to ``[]``. """ - funding_rates: builtins.list[MarginFundingRate] + funding_rates: NullableList[MarginFundingRate] model_config = {"extra": "allow"} @@ -116,9 +120,10 @@ class GetMarginHistoricalFundingRatesResponse(BaseModel): class GetMarginFundingHistoryResponse(BaseModel): """Spec ``GetMarginFundingHistoryResponse`` — funding-payment-history envelope. - ``funding_history`` is spec-required; modeled non-optional (see above). + ``funding_history`` is spec-required (NullableList: missing key -> error, + ``null`` -> ``[]``). """ - funding_history: builtins.list[MarginFundingHistoryEntry] + funding_history: NullableList[MarginFundingHistoryEntry] model_config = {"extra": "allow"} diff --git a/kalshi/perps/models/markets.py b/kalshi/perps/models/markets.py index e7d6a15..aa6e71b 100644 --- a/kalshi/perps/models/markets.py +++ b/kalshi/perps/models/markets.py @@ -16,7 +16,6 @@ from __future__ import annotations -import builtins from typing import Literal from pydantic import AliasChoices, BaseModel, Field @@ -167,12 +166,13 @@ class MarginMarketCandlestick(BaseModel): class GetMarginMarketsResponse(BaseModel): """Spec ``GetMarginMarketsResponse`` — the ``GET /margin/markets`` envelope. - ``markets`` is spec-required, so it is modeled non-optional: a missing or - ``null`` array surfaces as a ``ValidationError`` rather than being silently - coerced to ``[]`` (which would mask server-side spec drift). + ``markets`` is spec-required and uses :data:`~kalshi.types.NullableList`: a + **missing** key raises ``ValidationError`` (surfacing spec drift instead of a + silent ``[]``), while a ``null`` array coerces to ``[]`` (Kalshi's + empty-as-null convention). Mirrors the prediction-API list envelopes. """ - markets: builtins.list[MarginMarket] + markets: NullableList[MarginMarket] model_config = {"extra": "allow"} @@ -180,12 +180,13 @@ class GetMarginMarketsResponse(BaseModel): class GetMarginMarketCandlesticksResponse(BaseModel): """Spec ``GetMarginMarketCandlesticksResponse`` — the candlesticks envelope. - Both ``ticker`` and ``candlesticks`` are spec-required; validating the - envelope keeps the required ``ticker`` from being silently dropped and - hard-fails on a missing/``null`` ``candlesticks`` array. + Both ``ticker`` and ``candlesticks`` are spec-required. Validating the + envelope keeps the required ``ticker`` from being silently dropped; the + ``candlesticks`` array uses :data:`~kalshi.types.NullableList` (missing key + -> error, ``null`` -> ``[]``). """ ticker: str - candlesticks: builtins.list[MarginMarketCandlestick] + candlesticks: NullableList[MarginMarketCandlestick] model_config = {"extra": "allow"} diff --git a/tests/perps/test_funding.py b/tests/perps/test_funding.py index 5d0b0f7..5bd743e 100644 --- a/tests/perps/test_funding.py +++ b/tests/perps/test_funding.py @@ -167,15 +167,15 @@ def test_edge_empty_array(self, perps_client: PerpsClient) -> None: assert perps_client.funding.historical_rates() == [] @respx.mock - def test_edge_missing_or_null_key_raises(self, perps_client: PerpsClient) -> None: - # #404: `funding_rates` is spec-required — missing/null hard-fails. + def test_missing_key_raises_but_null_tolerated(self, perps_client: PerpsClient) -> None: + # `funding_rates` is spec-required: MISSING hard-fails, NULL -> [] + # (NullableList; matches the prediction-API envelopes). route = respx.get(f"{BASE}/margin/funding_rates/historical") route.mock(return_value=httpx.Response(200, json={})) with pytest.raises(ValidationError): perps_client.funding.historical_rates() route.mock(return_value=httpx.Response(200, json={"funding_rates": None})) - with pytest.raises(ValidationError): - perps_client.funding.historical_rates() + assert perps_client.funding.historical_rates() == [] @respx.mock def test_error_500_maps(self, perps_client: PerpsClient) -> None: @@ -225,13 +225,16 @@ def _entry(*, funding_amount: str, subaccount_number: int | None) -> dict[str, o class TestHistory: @respx.mock - def test_missing_or_null_funding_history_raises(self, perps_client: PerpsClient) -> None: - # #404: funding_history is spec-required — missing/null hard-fails. + def test_missing_history_raises_but_null_tolerated(self, perps_client: PerpsClient) -> None: + # funding_history is spec-required: MISSING hard-fails, NULL -> []. route = respx.get(f"{BASE}/margin/funding_history") - for payload in ({}, {"funding_history": None}): - route.mock(return_value=httpx.Response(200, json=payload)) - with pytest.raises(ValidationError): - perps_client.funding.history(start_date="2026-06-01", end_date="2026-06-04") + route.mock(return_value=httpx.Response(200, json={})) + with pytest.raises(ValidationError): + perps_client.funding.history(start_date="2026-06-01", end_date="2026-06-04") + route.mock(return_value=httpx.Response(200, json={"funding_history": None})) + assert ( + perps_client.funding.history(start_date="2026-06-01", end_date="2026-06-04") == [] + ) @respx.mock def test_happy(self, perps_client: PerpsClient) -> None: diff --git a/tests/perps/test_markets.py b/tests/perps/test_markets.py index ac63039..f41dfe7 100644 --- a/tests/perps/test_markets.py +++ b/tests/perps/test_markets.py @@ -136,16 +136,16 @@ def test_empty_list(self, perps_client: PerpsClient) -> None: assert perps_client.markets.list() == [] @respx.mock - def test_missing_or_null_markets_raises(self, perps_client: PerpsClient) -> None: - # #404: `markets` is spec-required — a missing/null array hard-fails - # (surfacing drift) instead of being silently coerced to []. + def test_missing_markets_raises_but_null_tolerated(self, perps_client: PerpsClient) -> None: + # `markets` is spec-required: a MISSING key hard-fails (surfacing drift), + # while a NULL array coerces to [] via NullableList (Kalshi's + # empty-as-null convention; matches the prediction-API envelopes). route = respx.get(f"{BASE}/margin/markets") route.mock(return_value=httpx.Response(200, json={})) with pytest.raises(ValidationError): perps_client.markets.list() route.mock(return_value=httpx.Response(200, json={"markets": None})) - with pytest.raises(ValidationError): - perps_client.markets.list() + assert perps_client.markets.list() == [] @respx.mock def test_server_error_maps(self, perps_client: PerpsClient) -> None: @@ -370,6 +370,18 @@ def test_missing_ticker_or_candlesticks_raises(self, perps_client: PerpsClient) route.mock(return_value=httpx.Response(200, json={"ticker": "BTC-PERP"})) # no array with pytest.raises(ValidationError): perps_client.markets.candlesticks("BTC-PERP", start_ts=1, end_ts=2, period_interval=1) + # A null candlesticks array coerces to [] (NullableList). + route.mock( + return_value=httpx.Response( + 200, json={"ticker": "BTC-PERP", "candlesticks": None} + ) + ) + assert ( + perps_client.markets.candlesticks( + "BTC-PERP", start_ts=1, end_ts=2, period_interval=1 + ) + == [] + ) @respx.mock def test_required_and_optional_params(self, perps_client: PerpsClient) -> None: