From 1057b1ad587d0eed4efce4a9398ded2caa7833b0 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Tue, 13 Oct 2026 09:58:52 -0500 Subject: [PATCH] =?UTF-8?q?fix(perps):=20reconcile=20spec=20drift=20?= =?UTF-8?q?=E2=80=94=20bulk=20orderbooks=20endpoint,=20MarginMarket.fundin?= =?UTF-8?q?g=5Finvalid=5Fpremium=5Fhandling=20(#540)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 28 +++++ docs/migration.md | 45 +++++++ docs/perps.md | 18 ++- kalshi/_contract_map.py | 4 + kalshi/perps/models/markets.py | 19 ++- kalshi/perps/resources/markets.py | 88 +++++++++++++- specs/perps_openapi.yaml | 74 ++++++++++++ tests/_contract_support.py | 5 + tests/integration/test_perps_markets.py | 34 +++++- tests/perps/test_markets.py | 154 +++++++++++++++++++++++- 10 files changed, 453 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 990d9278..6c9fbcc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ All notable changes to kalshi-sdk will be documented in this file. +## Unreleased + +Reconciles perps spec drift after nightly contract failures (Closes #540). +**Breaking** for constructors of `MarginMarket` that omit the new required +`funding_invalid_premium_handling`. Re-vendored `specs/perps_openapi.yaml` +(hash +`0a1cf0e32c7e0bf4def095c72736593ae07d1616ab3024615fe942a516c40937`). +Core OpenAPI remains 3.34.0. + +### Changed (breaking) + +- Perps **`MarginMarket.funding_invalid_premium_handling`** (required + `"dampen"` / `"exclude"`). How funding averages a second with no + premium (no orderbook sample, no index price, or a trading halt), + configured at market creation. `dampen` counts that second as a + premium of 0; `exclude` leaves it out of the average. Live + `markets.list` / `markets.get` callers are unaffected; constructors + and fixtures must pass the new field. + +### Added + +- Perps **`markets.bulk_orderbooks(*, tickers)`** (sync + async) — + `GET /margin/markets/orderbooks`. Fetches up to 100 margin orderbooks + in one call. `tickers` is required (1–100) and serializes as a + comma-separated query string (`style: form, explode: false`). Returns + `list[MarginOrderbook]` in request order. Public market data (no + auth). A ticker that is not a margin market returns 404. + ## 21.1.0 — 2026-10-12 Widens the optional `polars` dependency to allow Polars 2.x (#538). No diff --git a/docs/migration.md b/docs/migration.md index fc2038ff..dc97427c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1,5 +1,50 @@ # Migration +## v21.1.0 → Unreleased + +Reconciles perps spec drift (Closes #540). **Breaking** for constructors +of `MarginMarket` that omit the new required +`funding_invalid_premium_handling`. + +### Response model field changes + +- **Perps `MarginMarket.funding_invalid_premium_handling`** — required + `"dampen"` or `"exclude"`. How funding averages a second with no + premium (no orderbook sample, no index price, or a trading halt), + configured at market creation. `dampen` counts that second as a + premium of 0; `exclude` leaves it out of the average. + +Live `markets.list` / `markets.get` callers are unaffected; constructors +and fixtures must pass the new field. + +```python +# Before (constructors / test fixtures): +# MarginMarket( +# ..., +# funding_deadband=0, +# funding_cap=1, +# funding_limit_unit="percentage", +# ) + +# After: +MarginMarket( + ..., + funding_deadband=0, + funding_cap=1, + funding_limit_unit="percentage", + funding_invalid_premium_handling="dampen", +) +``` + +### Added (non-breaking) + +- `perps.markets.bulk_orderbooks(*, tickers)` (sync + async) — + `GET /margin/markets/orderbooks`. Required `tickers` (1–100), + comma-joined on the wire. Returns `list[MarginOrderbook]`. + +See the [changelog](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/CHANGELOG.md) +for the full list. + ## v20.0 → v21.0.0 Reconciles OpenAPI 3.33.0 → 3.34.0 and perps spec drift (Closes #533). diff --git a/docs/perps.md b/docs/perps.md index 001a43d2..89db6029 100644 --- a/docs/perps.md +++ b/docs/perps.md @@ -59,7 +59,7 @@ async with AsyncPerpsClient.from_env(demo=True) as perps: | Attribute | Endpoints | |---|---| | `exchange` | `status()`, `enabled()` (per-member access gate), `risk_parameters()` | -| `markets` | `list()`, `get()`, `orderbook()`, `candlesticks()` | +| `markets` | `list()`, `get()`, `orderbook()`, `bulk_orderbooks(*, tickers)`, `candlesticks()` | | `orders` | `create()`, `get()`, `list()` / `list_all()`, `cancel()`, `cancel_all()`, `decrease()`, `amend()` | | `order_groups` | `list()`, `get()`, `create()`, `delete()`, `reset()`, `trigger()`, `update_limit()` | | `portfolio` | `positions()`, `fills()` / `fills_all()`, `trades()` / `trades_all()`, cross/isolated exit triggers | @@ -68,6 +68,17 @@ async with AsyncPerpsClient.from_env(demo=True) as perps: | `transfers` | `transfer_instance()`, `create_subaccount()`, `transfer_subaccount()` | | `fcm` | `create_subtrader(subtrader_suffix=..., require_category_cap=...)`; `risk_controls` / `update_risk_controls` / `delete_risk_controls`; `update_notional_risk_limit` / `delete_notional_risk_limit` | +`markets.bulk_orderbooks(*, tickers)` fetches up to 100 margin orderbooks +in one call (`GET /margin/markets/orderbooks`). `tickers` is required +(1–100) and serializes as a comma-separated query string. A ticker that +is not a margin market returns 404. + +```python +books = perps.markets.bulk_orderbooks(tickers=["BTC-PERP", "ETH-PERP"]) +for ob in books: + print(ob.ticker, len(ob.bids), len(ob.asks)) +``` + The margin order side is `bid` / `ask` (not the prediction API's `yes` / `no`). Orders create/cancel/decrease/amend are POSTs/DELETEs and are **never retried**. `orders.amend(..., expiration_time=)` is int64 Unix seconds: omit it to keep @@ -174,6 +185,11 @@ perps.portfolio.cancel_cross_exit_trigger("BTC-PERP", trig.id) (`MultiplierDecimal`) and `funding_limit_unit` (`"percentage"` / `"dollar"`) are required; they are the funding limits configured at market creation, expressed in `funding_limit_unit`. + `MarginMarket.funding_invalid_premium_handling` is required + (`"dampen"` / `"exclude"`): how funding averages a second with no + premium (no orderbook sample, no index price, or a trading halt). + `dampen` counts that second as a premium of 0; `exclude` leaves it + out of the average. - **Mark prices** (since the v3.21.0 spec sync) — `MarginMarket` carries three optional reference-price objects: `settlement_mark_price`, `liquidation_mark_price`, and `reference_price`, each a nested `TickerPrice` diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index b4f13f73..e0b14689 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -759,6 +759,10 @@ class ContractEntry: sdk_model="kalshi.perps.models.markets.GetMarginMarketsResponse", spec_schema="GetMarginMarketsResponse", ), + ContractEntry( + sdk_model="kalshi.perps.models.markets.GetMarginMarketOrderbooksResponse", + spec_schema="GetMarginMarketOrderbooksResponse", + ), ContractEntry( sdk_model="kalshi.perps.models.markets.GetMarginMarketCandlesticksResponse", spec_schema="GetMarginMarketCandlesticksResponse", diff --git a/kalshi/perps/models/markets.py b/kalshi/perps/models/markets.py index c9812895..e5745374 100644 --- a/kalshi/perps/models/markets.py +++ b/kalshi/perps/models/markets.py @@ -16,7 +16,7 @@ from __future__ import annotations -from typing import Literal +from typing import Any, Literal from pydantic import AliasChoices, BaseModel, Field @@ -99,6 +99,10 @@ class MarginMarket(BaseModel): funding_deadband: MultiplierDecimal = Field(ge=0) funding_cap: MultiplierDecimal = Field(ge=0) funding_limit_unit: Literal["percentage", "dollar"] + # How funding averages a second with no premium (no orderbook sample, no + # index price, or a trading halt), configured at market creation. + # ``dampen`` counts the second as premium 0; ``exclude`` omits it. + funding_invalid_premium_handling: Literal["dampen", "exclude"] leverage_estimate: MultiplierDecimal | None = None # Leverage (1 / margin_rate) keyed by notional position size in dollars @@ -279,6 +283,19 @@ class GetMarginMarketsResponse(BaseModel): model_config = {"extra": "allow"} +class GetMarginMarketOrderbooksResponse(BaseModel): + """Spec ``GetMarginMarketOrderbooksResponse`` — bulk-orderbooks envelope. + + ``orderbooks`` is spec-required (NullableList: missing key -> error, null -> + []). Items stay raw dicts — the resource transforms each via + ``_orderbook_from_item`` into :class:`MarginOrderbook`. + """ + + orderbooks: NullableList[dict[str, Any]] + + model_config = {"extra": "allow"} + + class GetMarginMarketCandlesticksResponse(BaseModel): """Spec ``GetMarginMarketCandlesticksResponse`` — the candlesticks envelope. diff --git a/kalshi/perps/resources/markets.py b/kalshi/perps/resources/markets.py index 787e3183..980f6fa8 100644 --- a/kalshi/perps/resources/markets.py +++ b/kalshi/perps/resources/markets.py @@ -1,6 +1,6 @@ -"""Perps markets resource — list/get, orderbook, candlesticks (#390). +"""Perps markets resource — list/get, orderbook, bulk orderbooks, candlesticks. -Four read-only ``GET`` endpoints, all public market data (the spec marks no +Five read-only ``GET`` endpoints, all public market data (the spec marks no ``security`` block on any of them) — so none call ``_require_auth()``, unlike the public-markets ``orderbook`` which does. All retry on 429/502/503/504 (GET). @@ -8,6 +8,8 @@ ``builtins.list[MarginMarket]`` (NOT a ``Page``) and has **no** ``list_all()`` — ``GetMarginMarketsResponse`` carries no cursor, so there is nothing to paginate. The orderbook is ``{bids, asks}`` (margin) rather than ``{yes, no}``. +``bulk_orderbooks`` serializes ``tickers`` as a comma-separated string +(spec ``style: form, explode: false``). """ from __future__ import annotations @@ -15,8 +17,10 @@ import builtins from typing import Any +from kalshi.errors import KalshiError from kalshi.perps.models.markets import ( GetMarginMarketCandlesticksResponse, + GetMarginMarketOrderbooksResponse, GetMarginMarketsResponse, MarginMarket, MarginMarketCandlestick, @@ -28,10 +32,13 @@ AsyncResource, SyncResource, _bool_param, + _join_tickers, _params, _seg, ) +_MAX_BULK = 100 + def _parse_orderbook(data: dict[str, Any], ticker: str) -> MarginOrderbook: """Parse the GET /margin/markets/{ticker}/orderbook envelope. @@ -53,8 +60,41 @@ def _parse_orderbook(data: dict[str, Any], ticker: str) -> MarginOrderbook: return MarginOrderbook(ticker=ticker, bids=bids, asks=asks) +def _orderbook_from_item(item: dict[str, Any]) -> MarginOrderbook: + """Parse one entry from GET /margin/markets/orderbooks into a MarginOrderbook. + + Shape is ``{"ticker": "...", "orderbook": {"bids": [...], "asks": [...]}}``. + Reuses :func:`_parse_orderbook` for the nested book. Raises + :class:`~kalshi.errors.KalshiError` if the response omits or empty-strings + the per-item ticker — silently returning ``ticker=""`` would corrupt + caller-side lookups. + """ + ticker = item.get("ticker") + if not ticker: + raise KalshiError( + "bulk orderbook item has empty or missing 'ticker' field " + f"in server response; got {item!r}" + ) + return _parse_orderbook(item, ticker) + + +def _bulk_orderbooks_params(*, tickers: builtins.list[str]) -> dict[str, Any]: + """Validate + build params for GET /margin/markets/orderbooks (bulk). + + Spec requires 1..100 tickers. Wire format is ``?tickers=a,b`` + (style: form, explode: false). + """ + if not tickers: + raise ValueError("tickers must be a non-empty list") + if len(tickers) > _MAX_BULK: + raise ValueError( + f"tickers accepts at most {_MAX_BULK} entries per spec (got {len(tickers)})" + ) + return _params(tickers=_join_tickers(tickers)) + + class PerpsMarketsResource(SyncResource): - """Sync perps markets API (``list`` / ``get`` / ``orderbook`` / ``candlesticks``).""" + """Sync perps markets API (list / get / orderbook / bulk_orderbooks / candlesticks).""" def list( self, @@ -94,6 +134,26 @@ def orderbook( ) return _parse_orderbook(data, ticker) + def bulk_orderbooks( + self, + *, + tickers: builtins.list[str], + extra_headers: dict[str, str] | None = None, + ) -> builtins.list[MarginOrderbook]: + """Fetch orderbooks for up to 100 margin tickers in a single call. + + Public market data (no auth). Spec requires at least one ticker + (max 100). ``tickers`` serializes as a comma-separated string + (spec ``style: form, explode: false``). A ticker that is not a + margin market returns 404. + """ + params = _bulk_orderbooks_params(tickers=tickers) + data = self._get( + "/margin/markets/orderbooks", params=params, extra_headers=extra_headers + ) + raw = GetMarginMarketOrderbooksResponse.model_validate(data).orderbooks + return [_orderbook_from_item(item) for item in raw] + def candlesticks( self, ticker: str, @@ -119,7 +179,7 @@ def candlesticks( class AsyncPerpsMarketsResource(AsyncResource): - """Async perps markets API (``list`` / ``get`` / ``orderbook`` / ``candlesticks``).""" + """Async perps markets API (list / get / orderbook / bulk_orderbooks / candlesticks).""" async def list( self, @@ -161,6 +221,26 @@ async def orderbook( ) return _parse_orderbook(data, ticker) + async def bulk_orderbooks( + self, + *, + tickers: builtins.list[str], + extra_headers: dict[str, str] | None = None, + ) -> builtins.list[MarginOrderbook]: + """Fetch orderbooks for up to 100 margin tickers in a single call. + + Public market data (no auth). Spec requires at least one ticker + (max 100). ``tickers`` serializes as a comma-separated string + (spec ``style: form, explode: false``). A ticker that is not a + margin market returns 404. + """ + params = _bulk_orderbooks_params(tickers=tickers) + data = await self._get( + "/margin/markets/orderbooks", params=params, extra_headers=extra_headers + ) + raw = GetMarginMarketOrderbooksResponse.model_validate(data).orderbooks + return [_orderbook_from_item(item) for item in raw] + async def candlesticks( self, ticker: str, diff --git a/specs/perps_openapi.yaml b/specs/perps_openapi.yaml index 436bf52b..7a1d7a61 100644 --- a/specs/perps_openapi.yaml +++ b/specs/perps_openapi.yaml @@ -582,6 +582,45 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /margin/markets/orderbooks: + get: + operationId: GetMarginMarketOrderbooks + summary: Get Multiple Market Orderbooks + description: Endpoint for getting the current orderbooks of several margin markets in a single request. Returns one entry per requested ticker, in request order, each carrying the same bid and ask levels as the single-market orderbook endpoint. A ticker that is not a margin market returns 404. + tags: + - market + parameters: + - name: tickers + in: query + required: true + description: Comma-separated list of margin market tickers (maximum 100) + schema: + type: array + items: + type: string + maxLength: 200 + minItems: 1 + maxItems: 100 + style: form + explode: false + x-oapi-codegen-extra-tags: + validate: required,min=1,max=100,dive,max=200 + responses: + '200': + description: Orderbooks retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginMarketOrderbooksResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '404': + $ref: '#/components/responses/NotFoundError' + '429': + $ref: '#/components/responses/RateLimitError' + '500': + $ref: '#/components/responses/InternalServerError' + /margin/markets/{ticker}: get: operationId: GetMarginMarket @@ -2918,6 +2957,29 @@ components: orderbook: $ref: '#/components/schemas/MarginOrderbookCount' + MarginMarketOrderbook: + type: object + required: + - ticker + - orderbook + properties: + ticker: + type: string + description: The requested market ticker. + orderbook: + $ref: '#/components/schemas/MarginOrderbookCount' + + GetMarginMarketOrderbooksResponse: + type: object + required: + - orderbooks + properties: + orderbooks: + type: array + description: One entry per requested ticker, in request order. + items: + $ref: '#/components/schemas/MarginMarketOrderbook' + MarginMarket: type: object required: @@ -2934,7 +2996,11 @@ components: - funding_deadband - funding_cap - funding_limit_unit + - funding_invalid_premium_handling properties: + market_id: + type: string + description: Stable market UUID. Remains unchanged when the market ticker is renamed. ticker: type: string title: @@ -3065,6 +3131,14 @@ components: enum: [percentage, dollar] x-go-type-skip-optional-pointer: true description: Unit for the funding limits configured at market creation. + funding_invalid_premium_handling: + type: string + enum: [dampen, exclude] + x-enum-varnames: [MarginMarketFundingInvalidPremiumHandlingDampen, MarginMarketFundingInvalidPremiumHandlingExclude] + x-go-type-skip-optional-pointer: true + description: >- + How funding averages a second with no premium (no orderbook sample, no index price, or a trading halt), + configured at market creation. dampen counts the second as a premium of 0; exclude leaves it out of the average. MarginMarketSchedule: type: object diff --git a/tests/_contract_support.py b/tests/_contract_support.py index e4b07bcc..9d7c4ec5 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -1466,6 +1466,11 @@ class Exclusion: http_method="GET", path_template="/margin/markets/{ticker}/orderbook", ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.markets.PerpsMarketsResource.bulk_orderbooks", + http_method="GET", + path_template="/margin/markets/orderbooks", + ), MethodEndpointEntry( sdk_method="kalshi.perps.resources.markets.PerpsMarketsResource.candlesticks", http_method="GET", diff --git a/tests/integration/test_perps_markets.py b/tests/integration/test_perps_markets.py index 2c210ab8..02c0e848 100644 --- a/tests/integration/test_perps_markets.py +++ b/tests/integration/test_perps_markets.py @@ -1,9 +1,10 @@ """Integration tests for the perps (margin) markets resource — live demo. -Covers ``list`` / ``get`` / ``orderbook`` / ``candlesticks``. All four are public -read-only GETs; no margin-enabled account is required, only that the demo server -exposes at least one margin market (guarded by the ``perps_market_ticker`` -fixture, which skips when the demo has no margin markets). +Covers ``list`` / ``get`` / ``orderbook`` / ``bulk_orderbooks`` / ``candlesticks``. +All five are public read-only GETs; no margin-enabled account is required, only +that the demo server exposes at least one margin market (guarded by the +``perps_market_ticker`` fixture, which skips when the demo has no margin +markets). Margin market prices are NOT binary [0, 1] like the prediction API — they are plain ``DollarDecimal`` and may exceed $1 — so these tests assert types/values @@ -30,7 +31,7 @@ register_perps( "PerpsMarketsResource", - ["candlesticks", "get", "list", "orderbook"], + ["bulk_orderbooks", "candlesticks", "get", "list", "orderbook"], ) @@ -65,6 +66,18 @@ def test_orderbook( assert isinstance(level.price, Decimal) assert isinstance(level.quantity, Decimal) + def test_bulk_orderbooks( + self, perps_sync_client: PerpsClient, perps_market_ticker: str + ) -> None: + books = perps_sync_client.markets.bulk_orderbooks(tickers=[perps_market_ticker]) + assert isinstance(books, list) + assert len(books) == 1 + ob = books[0] + assert isinstance(ob, MarginOrderbook) + assert ob.ticker == perps_market_ticker + assert isinstance(ob.bids, list) + assert isinstance(ob.asks, list) + def test_candlesticks( self, perps_sync_client: PerpsClient, perps_market_ticker: str ) -> None: @@ -106,3 +119,14 @@ async def test_orderbook(self, perps_async_client: AsyncPerpsClient) -> None: ob = await perps_async_client.markets.orderbook(markets[0].ticker) assert isinstance(ob, MarginOrderbook) assert ob.ticker == markets[0].ticker + + async def test_bulk_orderbooks(self, perps_async_client: AsyncPerpsClient) -> None: + markets = await perps_async_client.markets.list() + if not markets: + pytest.skip("No margin markets on demo server") + ticker = markets[0].ticker + books = await perps_async_client.markets.bulk_orderbooks(tickers=[ticker]) + assert isinstance(books, list) + assert len(books) == 1 + assert isinstance(books[0], MarginOrderbook) + assert books[0].ticker == ticker diff --git a/tests/perps/test_markets.py b/tests/perps/test_markets.py index 650319bb..a34bcb32 100644 --- a/tests/perps/test_markets.py +++ b/tests/perps/test_markets.py @@ -1,8 +1,9 @@ """Tests for the perps markets resource (#390). -All four endpoints (``list`` / ``get`` / ``orderbook`` / ``candlesticks``) are -public margin market data — the spec marks no ``security`` block on any of them, -so an unauthenticated client must reach the wire (no ``AuthRequiredError``). +All five endpoints (``list`` / ``get`` / ``orderbook`` / ``bulk_orderbooks`` / +``candlesticks``) are public margin market data — the spec marks no +``security`` block on any of them, so an unauthenticated client must reach +the wire (no ``AuthRequiredError``). """ from __future__ import annotations @@ -14,7 +15,7 @@ import respx from pydantic import ValidationError -from kalshi.errors import KalshiNotFoundError, KalshiServerError +from kalshi.errors import KalshiError, KalshiNotFoundError, KalshiServerError from kalshi.perps import AsyncPerpsClient, PerpsClient, PerpsConfig from kalshi.perps.models.markets import ( BidAskDistributionHistorical, @@ -49,6 +50,7 @@ def _market_dict(**overrides: object) -> dict[str, object]: "funding_deadband": 0.0, "funding_cap": 1.0, "funding_limit_unit": "percentage", + "funding_invalid_premium_handling": "dampen", "leverage_estimate": 2.5, "leverage_estimates": {"1000": 2.5, "10000": 2.0, "100000": 1.5}, "long_leverage_estimates": {"1000": 2.4, "10000": 1.9}, @@ -141,6 +143,7 @@ def test_happy(self, perps_client: PerpsClient) -> None: assert m.funding_deadband == Decimal("0.0") assert m.funding_cap == Decimal("1.0") assert m.funding_limit_unit == "percentage" + assert m.funding_invalid_premium_handling == "dampen" assert isinstance(m.funding_deadband, Decimal) assert isinstance(m.funding_cap, Decimal) @@ -169,12 +172,27 @@ def test_bid_ask_size_omitted_when_missing(self) -> None: assert m.ask_size is None def test_funding_fields_required(self) -> None: - for key in ("funding_deadband", "funding_cap", "funding_limit_unit"): + for key in ( + "funding_deadband", + "funding_cap", + "funding_limit_unit", + "funding_invalid_premium_handling", + ): payload = _market_dict() del payload[key] with pytest.raises(ValidationError): MarginMarket.model_validate(payload) + def test_funding_invalid_premium_handling_parse(self) -> None: + m = MarginMarket.model_validate( + _market_dict(funding_invalid_premium_handling="exclude") + ) + assert m.funding_invalid_premium_handling == "exclude" + + def test_funding_invalid_premium_handling_rejects_unknown(self) -> None: + with pytest.raises(ValidationError): + MarginMarket.model_validate(_market_dict(funding_invalid_premium_handling="skip")) + def test_funding_limit_unit_dollar(self) -> None: m = MarginMarket.model_validate( _market_dict(funding_deadband=0.25, funding_cap=5.0, funding_limit_unit="dollar") @@ -217,6 +235,7 @@ def test_null_leverage_and_missing_optionals(self, perps_client: PerpsClient) -> "funding_deadband": 0.0, "funding_cap": 1.0, "funding_limit_unit": "percentage", + "funding_invalid_premium_handling": "dampen", "leverage_estimate": None, } ] @@ -399,6 +418,131 @@ async def test_async(self, async_perps_client: AsyncPerpsClient) -> None: await async_perps_client.close() +# ── bulk orderbooks ─────────────────────────────────────────────────────────── + + +_UNSET: object = object() + + +def _bulk_orderbook_item( + ticker: str, + *, + bids: object = _UNSET, + asks: object = _UNSET, +) -> dict[str, object]: + return { + "ticker": ticker, + "orderbook": { + "bids": [["0.1500", "100.00"]] if bids is _UNSET else bids, + "asks": [["0.1600", "50.00"]] if asks is _UNSET else asks, + }, + } + + +class TestBulkOrderbooks: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/markets/orderbooks").mock( + return_value=httpx.Response( + 200, + json={ + "orderbooks": [ + _bulk_orderbook_item("BTC-PERP"), + _bulk_orderbook_item( + "ETH-PERP", + bids=[["0.2500", "10.00"]], + asks=[], + ), + ] + }, + ) + ) + books = perps_client.markets.bulk_orderbooks(tickers=["BTC-PERP", "ETH-PERP"]) + assert len(books) == 2 + assert all(isinstance(b, MarginOrderbook) for b in books) + assert books[0].ticker == "BTC-PERP" + assert books[0].bids[0].price == Decimal("0.15") + assert books[0].bids[0].quantity == Decimal("100.00") + assert books[0].asks[0].price == Decimal("0.16") + assert books[1].ticker == "ETH-PERP" + assert books[1].bids[0].price == Decimal("0.25") + assert books[1].asks == [] + assert dict(route.calls[0].request.url.params)["tickers"] == "BTC-PERP,ETH-PERP" + + @respx.mock + def test_not_found_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/markets/orderbooks").mock(return_value=httpx.Response(404)) + with pytest.raises(KalshiNotFoundError): + perps_client.markets.bulk_orderbooks(tickers=["NOPE"]) + + def test_rejects_empty_list(self, perps_client: PerpsClient) -> None: + with pytest.raises(ValueError, match="non-empty"): + perps_client.markets.bulk_orderbooks(tickers=[]) + + def test_rejects_over_100(self, perps_client: PerpsClient) -> None: + with pytest.raises(ValueError, match="at most 100"): + perps_client.markets.bulk_orderbooks(tickers=[f"T-{i}" for i in range(101)]) + + @respx.mock + def test_missing_orderbooks_raises_but_null_tolerated( + self, perps_client: PerpsClient + ) -> None: + route = respx.get(f"{BASE}/margin/markets/orderbooks") + route.mock(return_value=httpx.Response(200, json={})) + with pytest.raises(ValidationError): + perps_client.markets.bulk_orderbooks(tickers=["BTC-PERP"]) + route.mock(return_value=httpx.Response(200, json={"orderbooks": None})) + assert perps_client.markets.bulk_orderbooks(tickers=["BTC-PERP"]) == [] + + @respx.mock + def test_item_missing_ticker_raises(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/markets/orderbooks").mock( + return_value=httpx.Response( + 200, + json={"orderbooks": [{"orderbook": {"bids": [], "asks": []}}]}, + ) + ) + with pytest.raises(KalshiError, match="empty or missing 'ticker'"): + perps_client.markets.bulk_orderbooks(tickers=["BTC-PERP"]) + + @respx.mock + def test_null_bids_asks(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/markets/orderbooks").mock( + return_value=httpx.Response( + 200, + json={"orderbooks": [_bulk_orderbook_item("BTC-PERP", bids=None, asks=None)]}, + ) + ) + ob = perps_client.markets.bulk_orderbooks(tickers=["BTC-PERP"])[0] + assert ob.bids == [] + assert ob.asks == [] + + @respx.mock + async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/markets/orderbooks").mock( + return_value=httpx.Response( + 200, json={"orderbooks": [_bulk_orderbook_item("BTC-PERP")]} + ) + ) + books = await async_perps_client.markets.bulk_orderbooks(tickers=["BTC-PERP"]) + assert books[0].ticker == "BTC-PERP" + assert books[0].bids[0].price == Decimal("0.15") + await async_perps_client.close() + + @respx.mock + async def test_async_not_found(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/markets/orderbooks").mock(return_value=httpx.Response(404)) + with pytest.raises(KalshiNotFoundError): + await async_perps_client.markets.bulk_orderbooks(tickers=["NOPE"]) + await async_perps_client.close() + + @respx.mock + async def test_async_rejects_empty(self, async_perps_client: AsyncPerpsClient) -> None: + with pytest.raises(ValueError, match="non-empty"): + await async_perps_client.markets.bulk_orderbooks(tickers=[]) + await async_perps_client.close() + + # ── candlesticks ──────────────────────────────────────────────────────────────