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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
18 changes: 17 additions & 1 deletion docs/perps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down Expand Up @@ -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`
Expand Down
4 changes: 4 additions & 0 deletions kalshi/_contract_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 18 additions & 1 deletion kalshi/perps/models/markets.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from __future__ import annotations

from typing import Literal
from typing import Any, Literal

from pydantic import AliasChoices, BaseModel, Field

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
88 changes: 84 additions & 4 deletions kalshi/perps/resources/markets.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
"""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).

Divergence from :mod:`kalshi.resources.markets`: ``list()`` returns a plain
``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

import builtins
from typing import Any

from kalshi.errors import KalshiError
from kalshi.perps.models.markets import (
GetMarginMarketCandlesticksResponse,
GetMarginMarketOrderbooksResponse,
GetMarginMarketsResponse,
MarginMarket,
MarginMarketCandlestick,
Expand All @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading