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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,37 @@

All notable changes to kalshi-sdk will be documented in this file.

## 22.11.0 — 2026-11-10

Reconciles nightly spec drift (#575) against the latest AsyncAPI (new
`ebattle_scores` channel; 15 → 16 spec channels) and OpenAPI description-only
updates. Additive only. No breaking changes.

### Added

- Typed **`KalshiWebSocket.subscribe_ebattle_scores(...)`** plus
`EbattleScoresMessage` / `EbattleScoresPayload` /
`EbattleScoresMatchlistMessage` / `EbattleScoresMatchlistPayload`. Live
eSoccer/eFootball and eBasketball score updates. Auth required. Public
access is temporary and can return to individual approval. Other sports
and esports are excluded. A fresh subscription is empty until
`subscribe_matches`; pass `match_ids=["all"]` (or specific Kalshi
milestone IDs) to seed immediately. `msg.data` is a JSON-encoded string.
(Closes #575).

### Docs

- RFQ create: 429 with code `open_rfq_limit_exceeded` at the 100-open-RFQ
cap (retrying does not help until RFQs close or expire); 400 with code
`rfq_market_not_open` when the market is not open.
- `balance_breakdown`: balance for every exchange index, summed across all
subaccounts, including zeros; omitted when access is restricted to a
subaccount.
- `GET /portfolio/subaccounts/balances` is the per-subaccount view
(existing subaccount and exchange-index pairs, including zeros). Pointer
from `GET /portfolio/balance`: use the subaccount-balances endpoint for
that breakdown; entry counts can differ from `balance_breakdown`.

## 22.10.0 — 2026-11-09

Reconciles nightly spec drift (#574) against OpenAPI 3.36.0 and the latest
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ tests/
## API Reference

- OpenAPI spec: https://docs.kalshi.com/openapi.yaml (v3.35.0, 133 operations; 132 mapped in the core SDK — `POST /portfolio/intra_exchange_instance_transfer` is implemented on `PerpsClient.transfers.transfer_instance` and left unimplemented on the core client)
- AsyncAPI spec: https://docs.kalshi.com/asyncapi.yaml (15 WebSocket channels; 12 typed `subscribe_*` + escape-hatch)
- AsyncAPI spec: https://docs.kalshi.com/asyncapi.yaml (16 WebSocket channels; 13 typed `subscribe_*` + escape-hatch)
- Base URL: https://api.elections.kalshi.com/trade-api/v2
- Demo URL: https://demo-api.kalshi.co/trade-api/v2
- Auth: RSA-PSS / SHA256 / MGF1(SHA256) / salt_length=DIGEST_LENGTH / base64
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ A professional, spec-first Python SDK for the [Kalshi](https://kalshi.com) predi
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Type checked: mypy strict](https://img.shields.io/badge/mypy-strict-blue.svg)](https://mypy.readthedocs.io/)

- **Full coverage** of the Kalshi REST API (132 mapped of 133 operations across 19 resources, OpenAPI v3.35.0) and WebSocket API (12 typed `subscribe_*` channels + escape-hatch).
- **Full coverage** of the Kalshi REST API (132 mapped of 133 operations across 19 resources, OpenAPI v3.35.0) and WebSocket API (13 typed `subscribe_*` channels + escape-hatch).
- **Perps (margin) API**: standalone `PerpsClient` / `AsyncPerpsClient` + `PerpsWebSocket` for the perpetual-futures exchange (54 REST operations, 6 WS channels), plus a `KlearClient` for the Self-Clearing-Member "Klear" settlement API (26 of 27 operations). See [Perps (margin) trading](#perps-margin-trading).
- **FIX protocol**: an async-first FIX engine (FIXT.1.1 / FIX50SP2) for both products — order-entry, drop-copy, market-data, post-trade (prediction), and RFQ (prediction) sessions (plus order-group management over the order-entry session) with typed message models, sequence recovery, and order-book / settlement reassembly. `from kalshi import FixClient` / `MarginFixClient`. See [FIX protocol](#fix-protocol-low-latency-trading).
- **V2 event-market orders**: `create_v2` / `amend_v2` / `decrease_v2` / `cancel_v2` / `cancel_all_v2` plus batched variants on `/portfolio/events/orders/*` — the only order-write surface.
Expand Down Expand Up @@ -171,16 +171,16 @@ async def main() -> None:
asyncio.run(main())
```

Available channels (12 typed + escape-hatch). Twelve have dedicated
Available channels (13 typed + escape-hatch). Thirteen have dedicated
`subscribe_*` methods — `subscribe_ticker`, `subscribe_trade`,
`subscribe_orderbook_delta`, `subscribe_fill`, `subscribe_market_positions`,
`subscribe_user_orders`, `subscribe_order_group`,
`subscribe_market_lifecycle`, `subscribe_multivariate_lifecycle`,
`subscribe_communications`, `subscribe_cfbenchmarks_value`,
`subscribe_cfbenchmarks_value_5hz`. The AsyncAPI-declared `control_frames`,
`root`, and `pyth_value` channels are reachable through the generic
`subscribe(channel, ...)` escape hatch. See
[docs/websockets.md](docs/websockets.md#the-12-channels) for the full
`subscribe_cfbenchmarks_value_5hz`, `subscribe_ebattle_scores`. The
AsyncAPI-declared `control_frames`, `root`, and `pyth_value` channels are
reachable through the generic `subscribe(channel, ...)` escape hatch. See
[docs/websockets.md](docs/websockets.md#the-13-channels) for the full
channel table.

## Perps (margin) trading
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ markets API.
- **Funding + cost introspection** — `portfolio.deposits()`,
`portfolio.withdrawals()`, `portfolio.intra_exchange_transfers()`,
`account.endpoint_costs()`.
- **Full WebSocket coverage** — 12 typed channels with sequence-gap detection,
- **Full WebSocket coverage** — 13 typed channels with sequence-gap detection,
automatic reconnection (with resubscribe-window frame stashing for high-volume
channels), backpressure strategies, and an in-memory orderbook builder.
Async-only — access via `AsyncKalshiClient.ws`.
Expand Down
45 changes: 42 additions & 3 deletions docs/websockets.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# WebSocket

The SDK ships an async-only WebSocket client, `KalshiWebSocket`, that covers
the Kalshi market-data WebSocket surface (12 typed channels plus the generic
the Kalshi market-data WebSocket surface (13 typed channels plus the generic
escape hatch). It handles RSA-PSS auth on the upgrade handshake,
per-subscription sequence-gap detection, automatic reconnection with
re-subscription, and a configurable backpressure strategy on each per-channel
Expand Down Expand Up @@ -30,7 +30,7 @@ SDK's perspective on it.
on every delta.
- `on_state_change=` and `on_error=` hooks on the constructor for observability.

## The 12 channels
## The 13 channels

| SDK method | Wire channel | Message `type` field | Message class | Auth |
|---|---|---|---|---|
Expand All @@ -46,14 +46,17 @@ SDK's perspective on it.
| `subscribe_communications` | `communications` | `communications` | `CommunicationsMessage` | private |
| `subscribe_cfbenchmarks_value` | `cfbenchmarks_value` | `cfbenchmarks_value` / `cfbenchmarks_value_indexlist` | `CFBenchmarksValueMessage` / `CFBenchmarksIndexListMessage` | private |
| `subscribe_cfbenchmarks_value_5hz` | `cfbenchmarks_value_5hz` | `cfbenchmarks_value_5hz` / `cfbenchmarks_value_5hz_indexlist` | `CFBenchmarksValue5HzMessage` / `CFBenchmarks5HzIndexListMessage` | private |
| `subscribe_ebattle_scores` | `ebattle_scores` | `ebattle_scores` / `ebattle_scores_matchlist` | `EbattleScoresMessage` / `EbattleScoresMatchlistMessage` | private |

The `type` column matters when filtering raw logs — note the singular forms
for `user_order` and `market_position`. The standalone `multivariate` /
`multivariate_lookup` channel was removed from AsyncAPI (SDK v10.0.0).
The standalone `batch_communications` channel was removed from AsyncAPI
(SDK v22.4.0, 16 → 15 spec channels); RFQ-batch notifications now ride
`communications`. The SDK never shipped a typed
`subscribe_batch_communications` helper.
`subscribe_batch_communications` helper. AsyncAPI later added
`ebattle_scores` (SDK v22.11.0, 15 → 16 spec channels);
`batch_communications` remains gone.

!!! warning "Migration (v3.1.0): `event_fee_update` rides `market_lifecycle_v2`"
Since the v3.20.0 spec sync (SDK v3.1.0) the `market_lifecycle_v2` channel
Expand Down Expand Up @@ -118,6 +121,42 @@ channel does not accept `market_ticker`/`market_tickers`. The
`CFBenchmarksIndexListMessage`, and `CFBenchmarksIndexListPayload` models are
exported from `kalshi.ws.models`.

## eBattle scores

The auth-required `ebattle_scores` channel (new in v22.11.0) streams live
eSoccer/eFootball and eBasketball score updates. Other sports and esports
feeds are excluded. Public access is temporary and can return to individual
approval.

Subscribe, then request matches. A fresh subscription is empty until
`subscribe_matches` — there is no initial snapshot or replay. Seed with
`match_ids=["all"]` (or specific Kalshi milestone IDs) on
`subscribe_ebattle_scores` to send `subscribe_matches` immediately after the
subscribe ack. `matchlist` returns IDs seen in the past two hours, not a
complete fixture catalog.

The stream yields a **union** of `EbattleScoresMessage` (data) and
`EbattleScoresMatchlistMessage` (the response to a `matchlist` action), so
discriminate with `isinstance` (or `msg.type`) before reading `msg.msg`.
`msg.data` is a JSON-encoded string of the full score state — call
`json.loads(...)` to parse it.

```python
import json
from kalshi.ws.models import EbattleScoresMatchlistMessage, EbattleScoresMessage

async for msg in session.subscribe_ebattle_scores(match_ids=["all"]):
if isinstance(msg, EbattleScoresMessage):
score = json.loads(msg.msg.data) # full score state
print(msg.msg.event_id, msg.msg.match_id, score)
else: # EbattleScoresMatchlistMessage
print(msg.msg.match_ids) # IDs seen in the past two hours
```

The `EbattleScoresMessage`, `EbattleScoresPayload`,
`EbattleScoresMatchlistMessage`, and `EbattleScoresMatchlistPayload` models
are exported from `kalshi.ws.models`.

## Connect and subscribe

```python
Expand Down
2 changes: 1 addition & 1 deletion kalshi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -503,4 +503,4 @@
"Withdrawal",
]

__version__ = "22.10.0"
__version__ = "22.11.0"
10 changes: 10 additions & 0 deletions kalshi/_contract_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,16 @@ class ContractEntry:
sdk_model="kalshi.ws.models.cfbenchmarks.CFBenchmarks5HzIndexListPayload",
spec_schema="cfbenchmarks5HzIndexListPayload",
),
ContractEntry(
sdk_model="kalshi.ws.models.ebattle.EbattleScoresPayload",
spec_schema="ebattleScoresPayload",
notes="data is a JSON-encoded string of the full score state; left unparsed.",
),
ContractEntry(
sdk_model="kalshi.ws.models.ebattle.EbattleScoresMatchlistPayload",
spec_schema="ebattleScoresMatchlistPayload",
notes="matchlist response: msg.match_ids only (IDs seen in the past two hours).",
),
ContractEntry(
sdk_model="kalshi.ws.models.fill.FillPayload",
spec_schema="fillPayload",
Expand Down
45 changes: 32 additions & 13 deletions kalshi/ws/channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@
# params are not supported on this channel.
"cfbenchmarks_value": frozenset({"index_ids"}),
"cfbenchmarks_value_5hz": frozenset({"index_ids"}),
# eBattle scores: a fresh subscribe is empty. Match IDs are requested
# afterwards via update_subscription (subscribe_matches), never on the
# initial subscribe command.
"ebattle_scores": frozenset(),
}


Expand Down Expand Up @@ -370,12 +374,14 @@ async def update_subscription(
self,
client_id: int,
# markets: "add_markets"/"delete_markets"; cfbenchmarks_value:
# "subscribe_indices"/"unsubscribe_indices"/"indexlist".
# "subscribe_indices"/"unsubscribe_indices"/"indexlist";
# ebattle_scores: "subscribe_matches"/"unsubscribe_matches"/"matchlist".
action: str,
market_tickers: list[str] | None = None,
market_ids: list[str] | None = None,
send_initial_snapshot: bool | None = None,
index_ids: list[str] | None = None,
match_ids: list[str] | None = None,
) -> None:
"""Mutate an existing subscription.

Expand All @@ -384,6 +390,11 @@ async def update_subscription(
``cfbenchmarks_value`` channel
takes ``subscribe_indices``/``unsubscribe_indices`` with ``index_ids``,
or ``indexlist`` (no ids) to request the available index list.
The ``ebattle_scores`` channel takes ``subscribe_matches``/
``unsubscribe_matches`` with ``match_ids`` (use ``["all"]`` for every
match), or ``matchlist`` (no ids) to list IDs seen in the past two
hours. eBattle match actions send ``params.sid`` (scalar); markets
and CF Benchmarks keep sending ``params.sids`` (array).
"""
sub = self._subscriptions.get(client_id)
if not sub or sub.server_sid is None:
Expand All @@ -395,18 +406,26 @@ async def update_subscription(
)

msg_id = self._get_msg_id()
params: dict[str, Any] = {"sids": [sub.server_sid], "action": action}
if market_tickers:
params["market_tickers"] = market_tickers
if market_ids:
params["market_ids"] = market_ids
if send_initial_snapshot is not None:
params["send_initial_snapshot"] = send_initial_snapshot
# Truthy check (like market_tickers/market_ids above): an empty list is
# intentionally omitted — subscribe_indices/unsubscribe_indices require
# >=1 id (server returns error code 24 "Index IDs required" otherwise).
if index_ids:
params["index_ids"] = index_ids
if action in {"subscribe_matches", "unsubscribe_matches", "matchlist"}:
# eBattle schema requires params.sid (singular) and must not
# also send sids. Empty match_ids is omitted — the server
# returns error 23 "Match IDs required" otherwise.
params: dict[str, Any] = {"sid": sub.server_sid, "action": action}
if match_ids:
params["match_ids"] = match_ids
else:
params = {"sids": [sub.server_sid], "action": action}
if market_tickers:
params["market_tickers"] = market_tickers
if market_ids:
params["market_ids"] = market_ids
if send_initial_snapshot is not None:
params["send_initial_snapshot"] = send_initial_snapshot
# Truthy check (like market_tickers/market_ids above): an empty list is
# intentionally omitted — subscribe_indices/unsubscribe_indices require
# >=1 id (server returns error code 24 "Index IDs required" otherwise).
if index_ids:
params["index_ids"] = index_ids

cmd = {"id": msg_id, "cmd": "update_subscription", "params": params}
await self._connection.send(cmd)
Expand Down
57 changes: 57 additions & 0 deletions kalshi/ws/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
CFBenchmarksValueMessage,
)
from kalshi.ws.models.communications import CommunicationsMessage
from kalshi.ws.models.ebattle import EbattleScoresMatchlistMessage, EbattleScoresMessage
from kalshi.ws.models.event_fee import EventFeeUpdateMessage
from kalshi.ws.models.fill import FillMessage
from kalshi.ws.models.market_lifecycle import MarketLifecycleMessage
Expand Down Expand Up @@ -928,6 +929,62 @@ async def subscribe_cfbenchmarks_value_5hz(
overflow=OverflowStrategy.DROP_OLDEST, maxsize=maxsize,
)

async def subscribe_ebattle_scores(
self, *, match_ids: list[str] | None = None, maxsize: int = 1000,
) -> AsyncIterator[EbattleScoresMessage | EbattleScoresMatchlistMessage]:
"""Subscribe to live eSoccer/eFootball and eBasketball score updates.

Auth is required. Public access is temporary and can return to
individual approval; subscriptions without approval then end. Other
sports and esports feeds are excluded.

A fresh subscription is empty until matches are requested — there is
no initial snapshot or replay. Pass ``match_ids=["all"]`` (or specific
Kalshi milestone IDs) to send ``subscribe_matches`` immediately after
the subscribe ack. Without ``match_ids``, nothing arrives until
``subscribe_matches``.

The stream yields both ``ebattle_scores`` data messages and
``ebattle_scores_matchlist`` responses, so discriminate with
``isinstance`` (or ``msg.type``) before reading ``msg.msg``.
``msg.data`` is a JSON-encoded string of the full score state —
call ``json.loads(msg.msg.data)`` to parse it.
"""
if not self._running or self._sub_mgr is None or self._connection is None:
raise RuntimeError(
"KalshiWebSocket session is not active. The recv loop tore "
"down after a fatal error (e.g. KalshiBackpressureError); "
"exit the current `async with ws.connect()` block and start "
"a fresh session before subscribing again."
)
async with self._subscribe_lock:
await self._pause_recv_loop()
if (
not self._running
or self._sub_mgr is None
or self._connection is None
):
raise RuntimeError(
"KalshiWebSocket session is not active. The recv loop "
"tore down after a fatal error (e.g. "
"KalshiBackpressureError); exit the current `async with "
"ws.connect()` block and start a fresh session before "
"subscribing again."
)
try:
sub = await self._sub_mgr.subscribe(
"ebattle_scores",
overflow=OverflowStrategy.DROP_OLDEST,
maxsize=maxsize,
)
if match_ids:
await self._sub_mgr.update_subscription(
sub.client_id, "subscribe_matches", match_ids=match_ids,
)
finally:
self._ensure_recv_loop()
return sub.queue

# ------------------------------------------------------------------
# Generic subscribe
# ------------------------------------------------------------------
Expand Down
3 changes: 3 additions & 0 deletions kalshi/ws/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
CFBenchmarksValueMessage,
)
from kalshi.ws.models.communications import CommunicationsMessage
from kalshi.ws.models.ebattle import EbattleScoresMatchlistMessage, EbattleScoresMessage
from kalshi.ws.models.event_fee import EventFeeUpdateMessage
from kalshi.ws.models.fill import FillMessage
from kalshi.ws.models.market_lifecycle import MarketLifecycleMessage
Expand Down Expand Up @@ -49,6 +50,8 @@
"cfbenchmarks_value_indexlist": CFBenchmarksIndexListMessage,
"cfbenchmarks_value_5hz": CFBenchmarksValue5HzMessage,
"cfbenchmarks_value_5hz_indexlist": CFBenchmarks5HzIndexListMessage,
"ebattle_scores": EbattleScoresMessage,
"ebattle_scores_matchlist": EbattleScoresMatchlistMessage,
}

# Control message types (not routed to subscription queues)
Expand Down
13 changes: 12 additions & 1 deletion kalshi/ws/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""WebSocket message models for all 11 Kalshi channels."""
"""WebSocket message models for Kalshi channels."""
from kalshi.ws.models.base import (
BaseMessage,
ErrorMessage,
Expand Down Expand Up @@ -27,6 +27,12 @@
RfqCreatedPayload,
RfqDeletedPayload,
)
from kalshi.ws.models.ebattle import (
EbattleScoresMatchlistMessage,
EbattleScoresMatchlistPayload,
EbattleScoresMessage,
EbattleScoresPayload,
)
from kalshi.ws.models.event_fee import (
EventFeeUpdateMessage,
EventFeeUpdatePayload,
Expand Down Expand Up @@ -72,6 +78,11 @@
"CFBenchmarksValuePayload",
# Communications
"CommunicationsMessage",
# eBattle scores
"EbattleScoresMatchlistMessage",
"EbattleScoresMatchlistPayload",
"EbattleScoresMessage",
"EbattleScoresPayload",
"ErrorMessage",
"ErrorPayload",
# Event fee update
Expand Down
Loading
Loading