diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index 635c940..4514658 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -625,6 +625,14 @@ class ContractEntry: sdk_model="kalshi.perps.models.markets.PriceDistributionHistorical", spec_schema="PriceDistributionHistorical", ), + ContractEntry( + sdk_model="kalshi.perps.models.markets.GetMarginMarketsResponse", + spec_schema="GetMarginMarketsResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.models.markets.GetMarginMarketCandlesticksResponse", + spec_schema="GetMarginMarketCandlesticksResponse", + ), # ── perps orders (#391) ── ContractEntry( sdk_model="kalshi.perps.models.orders.CreateMarginOrderResponse", @@ -727,6 +735,14 @@ class ContractEntry: sdk_model="kalshi.perps.models.funding.MarginFundingRateEstimate", spec_schema="GetMarginFundingRateEstimateResponse", ), + ContractEntry( + sdk_model="kalshi.perps.models.funding.GetMarginHistoricalFundingRatesResponse", + spec_schema="GetMarginHistoricalFundingRatesResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.models.funding.GetMarginFundingHistoryResponse", + spec_schema="GetMarginFundingHistoryResponse", + ), # ── perps transfers (#396) ── ContractEntry( sdk_model="kalshi.perps.models.transfers.IntraExchangeInstanceTransferResponse", diff --git a/kalshi/perps/models/funding.py b/kalshi/perps/models/funding.py index 9016dad..e2c947c 100644 --- a/kalshi/perps/models/funding.py +++ b/kalshi/perps/models/funding.py @@ -27,6 +27,8 @@ from __future__ import annotations +import builtins + from pydantic import AliasChoices, AwareDatetime, BaseModel, Field from kalshi.types import DollarDecimal, FixedPointCount, MultiplierDecimal @@ -97,3 +99,26 @@ class MarginFundingRateEstimate(BaseModel): validation_alias=AliasChoices("mark_price_dollars", "mark_price"), ) next_funding_time: AwareDatetime + + +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: builtins.list[MarginFundingRate] + + model_config = {"extra": "allow"} + + +class GetMarginFundingHistoryResponse(BaseModel): + """Spec ``GetMarginFundingHistoryResponse`` — funding-payment-history envelope. + + ``funding_history`` is spec-required; modeled non-optional (see above). + """ + + funding_history: builtins.list[MarginFundingHistoryEntry] + + model_config = {"extra": "allow"} diff --git a/kalshi/perps/models/markets.py b/kalshi/perps/models/markets.py index 7278977..e7d6a15 100644 --- a/kalshi/perps/models/markets.py +++ b/kalshi/perps/models/markets.py @@ -16,6 +16,7 @@ from __future__ import annotations +import builtins from typing import Literal from pydantic import AliasChoices, BaseModel, Field @@ -161,3 +162,30 @@ class MarginMarketCandlestick(BaseModel): ) model_config = {"extra": "allow", "populate_by_name": True} + + +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: builtins.list[MarginMarket] + + model_config = {"extra": "allow"} + + +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. + """ + + ticker: str + candlesticks: builtins.list[MarginMarketCandlestick] + + model_config = {"extra": "allow"} diff --git a/kalshi/perps/resources/funding.py b/kalshi/perps/resources/funding.py index 3d334fe..4913db1 100644 --- a/kalshi/perps/resources/funding.py +++ b/kalshi/perps/resources/funding.py @@ -19,6 +19,8 @@ from typing import Any from kalshi.perps.models.funding import ( + GetMarginFundingHistoryResponse, + GetMarginHistoricalFundingRatesResponse, MarginFundingHistoryEntry, MarginFundingRate, MarginFundingRateEstimate, @@ -77,7 +79,7 @@ def historical_rates( data = self._get( "/margin/funding_rates/historical", params=params, extra_headers=extra_headers ) - return [MarginFundingRate.model_validate(r) for r in data.get("funding_rates", [])] + return GetMarginHistoricalFundingRatesResponse.model_validate(data).funding_rates def history( self, @@ -96,9 +98,7 @@ def history( subaccount=subaccount, ) data = self._get("/margin/funding_history", params=params, extra_headers=extra_headers) - return [ - MarginFundingHistoryEntry.model_validate(e) for e in data.get("funding_history", []) - ] + return GetMarginFundingHistoryResponse.model_validate(data).funding_history class AsyncFundingResource(AsyncResource): @@ -126,7 +126,7 @@ async def historical_rates( data = await self._get( "/margin/funding_rates/historical", params=params, extra_headers=extra_headers ) - return [MarginFundingRate.model_validate(r) for r in data.get("funding_rates", [])] + return GetMarginHistoricalFundingRatesResponse.model_validate(data).funding_rates async def history( self, @@ -147,6 +147,4 @@ async def history( data = await self._get( "/margin/funding_history", params=params, extra_headers=extra_headers ) - return [ - MarginFundingHistoryEntry.model_validate(e) for e in data.get("funding_history", []) - ] + return GetMarginFundingHistoryResponse.model_validate(data).funding_history diff --git a/kalshi/perps/resources/markets.py b/kalshi/perps/resources/markets.py index bef3856..787e318 100644 --- a/kalshi/perps/resources/markets.py +++ b/kalshi/perps/resources/markets.py @@ -16,6 +16,8 @@ from typing import Any from kalshi.perps.models.markets import ( + GetMarginMarketCandlesticksResponse, + GetMarginMarketsResponse, MarginMarket, MarginMarketCandlestick, MarginMarketStatusLiteral, @@ -67,8 +69,7 @@ def list( """ params = _params(status=status) data = self._get("/margin/markets", params=params, extra_headers=extra_headers) - raw = data.get("markets") or [] - return [MarginMarket.model_validate(m) for m in raw] + return GetMarginMarketsResponse.model_validate(data).markets def get(self, ticker: str, *, extra_headers: dict[str, str] | None = None) -> MarginMarket: data = self._get( @@ -114,8 +115,7 @@ def candlesticks( params=params, extra_headers=extra_headers, ) - raw = data.get("candlesticks") or [] - return [MarginMarketCandlestick.model_validate(c) for c in raw] + return GetMarginMarketCandlesticksResponse.model_validate(data).candlesticks class AsyncPerpsMarketsResource(AsyncResource): @@ -134,8 +134,7 @@ async def list( """ params = _params(status=status) data = await self._get("/margin/markets", params=params, extra_headers=extra_headers) - raw = data.get("markets") or [] - return [MarginMarket.model_validate(m) for m in raw] + return GetMarginMarketsResponse.model_validate(data).markets async def get( self, ticker: str, *, extra_headers: dict[str, str] | None = None @@ -183,5 +182,4 @@ async def candlesticks( params=params, extra_headers=extra_headers, ) - raw = data.get("candlesticks") or [] - return [MarginMarketCandlestick.model_validate(c) for c in raw] + return GetMarginMarketCandlesticksResponse.model_validate(data).candlesticks diff --git a/kalshi/perps/resources/order_groups.py b/kalshi/perps/resources/order_groups.py index 537a873..f0e5a07 100644 --- a/kalshi/perps/resources/order_groups.py +++ b/kalshi/perps/resources/order_groups.py @@ -83,7 +83,9 @@ def list( self._require_auth() params = _params(subaccount=subaccount) data = self._get("/margin/order_groups", params=params, extra_headers=extra_headers) - raw = data.get("order_groups", []) + # order_groups is OPTIONAL per spec (no `required` block), so stay + # tolerant of a missing OR null array — `or []` covers both (#404). + raw = data.get("order_groups") or [] return [OrderGroup.model_validate(item) for item in raw] def get( @@ -236,7 +238,9 @@ async def list( self._require_auth() params = _params(subaccount=subaccount) data = await self._get("/margin/order_groups", params=params, extra_headers=extra_headers) - raw = data.get("order_groups", []) + # order_groups is OPTIONAL per spec (no `required` block), so stay + # tolerant of a missing OR null array — `or []` covers both (#404). + raw = data.get("order_groups") or [] return [OrderGroup.model_validate(item) for item in raw] async def get( diff --git a/tests/perps/test_funding.py b/tests/perps/test_funding.py index 0e00b0a..5d0b0f7 100644 --- a/tests/perps/test_funding.py +++ b/tests/perps/test_funding.py @@ -167,11 +167,15 @@ def test_edge_empty_array(self, perps_client: PerpsClient) -> None: assert perps_client.funding.historical_rates() == [] @respx.mock - def test_edge_missing_key(self, perps_client: PerpsClient) -> None: - respx.get(f"{BASE}/margin/funding_rates/historical").mock( - return_value=httpx.Response(200, json={}) - ) - assert perps_client.funding.historical_rates() == [] + def test_edge_missing_or_null_key_raises(self, perps_client: PerpsClient) -> None: + # #404: `funding_rates` is spec-required — missing/null hard-fails. + 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() @respx.mock def test_error_500_maps(self, perps_client: PerpsClient) -> None: @@ -220,6 +224,15 @@ 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. + 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") + @respx.mock def test_happy(self, perps_client: PerpsClient) -> None: route = respx.get(f"{BASE}/margin/funding_history").mock( diff --git a/tests/perps/test_markets.py b/tests/perps/test_markets.py index eed7363..ac63039 100644 --- a/tests/perps/test_markets.py +++ b/tests/perps/test_markets.py @@ -12,6 +12,7 @@ import httpx import pytest import respx +from pydantic import ValidationError from kalshi.errors import KalshiNotFoundError, KalshiServerError from kalshi.perps import AsyncPerpsClient, PerpsClient, PerpsConfig @@ -135,9 +136,16 @@ def test_empty_list(self, perps_client: PerpsClient) -> None: assert perps_client.markets.list() == [] @respx.mock - def test_missing_markets_key(self, perps_client: PerpsClient) -> None: - respx.get(f"{BASE}/margin/markets").mock(return_value=httpx.Response(200, json={})) - assert perps_client.markets.list() == [] + 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 []. + 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() @respx.mock def test_server_error_maps(self, perps_client: PerpsClient) -> None: @@ -351,6 +359,18 @@ def test_all_null_trade_prices(self, perps_client: PerpsClient) -> None: # bid/ask OHLC are still required + present. assert c.bid.open == Decimal("0.5500") + @respx.mock + def test_missing_ticker_or_candlesticks_raises(self, perps_client: PerpsClient) -> None: + # #404: both `ticker` and `candlesticks` are spec-required on this + # response — the envelope validates the previously-discarded `ticker`. + route = respx.get(f"{BASE}/margin/markets/BTC-PERP/candlesticks") + route.mock(return_value=httpx.Response(200, json={"candlesticks": []})) # no ticker + with pytest.raises(ValidationError): + perps_client.markets.candlesticks("BTC-PERP", start_ts=1, end_ts=2, period_interval=1) + 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) + @respx.mock def test_required_and_optional_params(self, perps_client: PerpsClient) -> None: route = respx.get(f"{BASE}/margin/markets/BTC-PERP/candlesticks").mock( diff --git a/tests/perps/test_order_groups.py b/tests/perps/test_order_groups.py index dffed5e..8499c41 100644 --- a/tests/perps/test_order_groups.py +++ b/tests/perps/test_order_groups.py @@ -74,10 +74,13 @@ def test_happy(self, perps_client: PerpsClient) -> None: assert _signed(route.calls.last.request) @respx.mock - def test_empty_when_absent(self, perps_client: PerpsClient) -> None: - respx.get(f"{BASE}/margin/order_groups").mock( - return_value=httpx.Response(200, json={}) - ) + def test_empty_when_absent_or_null(self, perps_client: PerpsClient) -> None: + # #404: order_groups is spec-OPTIONAL, so it stays tolerant — a missing + # OR null array yields [] (unlike the spec-required perps list endpoints). + route = respx.get(f"{BASE}/margin/order_groups") + route.mock(return_value=httpx.Response(200, json={})) + assert perps_client.order_groups.list() == [] + route.mock(return_value=httpx.Response(200, json={"order_groups": None})) assert perps_client.order_groups.list() == [] @respx.mock