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
16 changes: 16 additions & 0 deletions kalshi/_contract_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions kalshi/perps/models/funding.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@

from __future__ import annotations

import builtins

from pydantic import AliasChoices, AwareDatetime, BaseModel, Field

from kalshi.types import DollarDecimal, FixedPointCount, MultiplierDecimal
Expand Down Expand Up @@ -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"}
28 changes: 28 additions & 0 deletions kalshi/perps/models/markets.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from __future__ import annotations

import builtins
from typing import Literal

from pydantic import AliasChoices, BaseModel, Field
Expand Down Expand Up @@ -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"}
14 changes: 6 additions & 8 deletions kalshi/perps/resources/funding.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
from typing import Any

from kalshi.perps.models.funding import (
GetMarginFundingHistoryResponse,
GetMarginHistoricalFundingRatesResponse,
MarginFundingHistoryEntry,
MarginFundingRate,
MarginFundingRateEstimate,
Expand Down Expand Up @@ -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,
Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand All @@ -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
14 changes: 6 additions & 8 deletions kalshi/perps/resources/markets.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
from typing import Any

from kalshi.perps.models.markets import (
GetMarginMarketCandlesticksResponse,
GetMarginMarketsResponse,
MarginMarket,
MarginMarketCandlestick,
MarginMarketStatusLiteral,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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
8 changes: 6 additions & 2 deletions kalshi/perps/resources/order_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
23 changes: 18 additions & 5 deletions tests/perps/test_funding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
26 changes: 23 additions & 3 deletions tests/perps/test_markets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 7 additions & 4 deletions tests/perps/test_order_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down