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
21 changes: 13 additions & 8 deletions kalshi/perps/models/funding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -104,21 +107,23 @@ 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"}


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"}
19 changes: 10 additions & 9 deletions kalshi/perps/models/markets.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@

from __future__ import annotations

import builtins
from typing import Literal

from pydantic import AliasChoices, BaseModel, Field
Expand Down Expand Up @@ -167,25 +166,27 @@ 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"}


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"}
23 changes: 13 additions & 10 deletions tests/perps/test_funding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
22 changes: 17 additions & 5 deletions tests/perps/test_markets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down