diff --git a/CHANGELOG.md b/CHANGELOG.md index e2b6301..bcc2e49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 073fa20..0315165 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 07fa7ba..9f58f25 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/docs/index.md b/docs/index.md index e5c7113..a2378f0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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`. diff --git a/docs/websockets.md b/docs/websockets.md index 31e945c..84731b6 100644 --- a/docs/websockets.md +++ b/docs/websockets.md @@ -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 @@ -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 | |---|---|---|---|---| @@ -46,6 +46,7 @@ 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` / @@ -53,7 +54,9 @@ for `user_order` and `market_position`. The standalone `multivariate` / 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 @@ -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 diff --git a/kalshi/__init__.py b/kalshi/__init__.py index d38404a..630d50d 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -503,4 +503,4 @@ "Withdrawal", ] -__version__ = "22.10.0" +__version__ = "22.11.0" diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index a9c3524..9bb8213 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -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", diff --git a/kalshi/ws/channels.py b/kalshi/ws/channels.py index 9355596..d332aae 100644 --- a/kalshi/ws/channels.py +++ b/kalshi/ws/channels.py @@ -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(), } @@ -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. @@ -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: @@ -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) diff --git a/kalshi/ws/client.py b/kalshi/ws/client.py index 972281c..87c3e67 100644 --- a/kalshi/ws/client.py +++ b/kalshi/ws/client.py @@ -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 @@ -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 # ------------------------------------------------------------------ diff --git a/kalshi/ws/dispatch.py b/kalshi/ws/dispatch.py index a34ee38..9b93d4e 100644 --- a/kalshi/ws/dispatch.py +++ b/kalshi/ws/dispatch.py @@ -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 @@ -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) diff --git a/kalshi/ws/models/__init__.py b/kalshi/ws/models/__init__.py index e98423d..e05d794 100644 --- a/kalshi/ws/models/__init__.py +++ b/kalshi/ws/models/__init__.py @@ -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, @@ -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, @@ -72,6 +78,11 @@ "CFBenchmarksValuePayload", # Communications "CommunicationsMessage", + # eBattle scores + "EbattleScoresMatchlistMessage", + "EbattleScoresMatchlistPayload", + "EbattleScoresMessage", + "EbattleScoresPayload", "ErrorMessage", "ErrorPayload", # Event fee update diff --git a/kalshi/ws/models/ebattle.py b/kalshi/ws/models/ebattle.py new file mode 100644 index 0000000..6381655 --- /dev/null +++ b/kalshi/ws/models/ebattle.py @@ -0,0 +1,68 @@ +"""eBattle scores channel message models (``ebattle_scores``). + +The auth-required ``ebattle_scores`` channel 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. + +A new subscription is empty until ``subscribe_matches`` is sent. There is no +initial snapshot or replay. Two inbound message types arrive on the same +subscription (both carry ``sid``): ``ebattle_scores`` (data) and +``ebattle_scores_matchlist`` (the response to a ``matchlist`` +``update_subscription`` action). +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + + +class EbattleScoresPayload(BaseModel): + """``ebattle_scores.msg`` — one match's current full score state. + + ``data`` is a JSON-encoded string of the full score state (upstream-defined + and sport-specific, so it is left unparsed — call ``json.loads(msg.data)`` + to read it). ``match_id`` is a Kalshi milestone ID. ``received_at`` is the + publication timestamp in milliseconds. + """ + + event_id: str + match_id: str + received_at: int + data: str + model_config = {"extra": "allow", "populate_by_name": True} + + +class EbattleScoresMessage(BaseModel): + """``ebattle_scores`` data message envelope.""" + + type: Literal["ebattle_scores"] = "ebattle_scores" + id: int | None = None + sid: int + seq: int | None = None + msg: EbattleScoresPayload + model_config = {"extra": "allow", "populate_by_name": True} + + +class EbattleScoresMatchlistPayload(BaseModel): + """``ebattle_scores_matchlist.msg`` — match IDs seen in the past two hours.""" + + match_ids: list[str] + model_config = {"extra": "allow", "populate_by_name": True} + + +class EbattleScoresMatchlistMessage(BaseModel): + """``ebattle_scores_matchlist`` — response to the ``matchlist`` action. + + ``id`` echoes the command id that requested the list; it is absent on + unsolicited frames, so it is optional. ``match_ids`` is not a complete + fixture catalog — only IDs seen within the past two hours. + """ + + type: Literal["ebattle_scores_matchlist"] = "ebattle_scores_matchlist" + id: int | None = None + sid: int + seq: int | None = None + msg: EbattleScoresMatchlistPayload + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/pyproject.toml b/pyproject.toml index 505043d..96dbaf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "22.10.0" +version = "22.11.0" description = "A professional Python SDK for the Kalshi prediction markets and Perps (margin) APIs" readme = "README.md" license = { text = "MIT" } diff --git a/specs/asyncapi.yaml b/specs/asyncapi.yaml index 005d60c..6017847 100644 --- a/specs/asyncapi.yaml +++ b/specs/asyncapi.yaml @@ -38,6 +38,7 @@ info: | 20 | shard_factor is required when shard_key is set | Missing shard_factor when shard_key is set | | 21 | shard_key must be >= 0 and < shard_factor | Invalid shard_key | | 22 | shard_factor must be <= 100 | shard_factor too large | + | 23 | Match IDs required | Missing match_ids for subscribe_matches/unsubscribe_matches on ebattle_scores | | 26 | Subscription market limit exceeded | Adding markets would exceed the per-subscription market limit | | 27 | Too many requests | The subscription exceeded its command rate limit | | 28 | Underlying tickers required | Missing underlying_tickers for subscribe_underlyings/unsubscribe_underlyings on pyth_value | @@ -105,6 +106,8 @@ channels: $ref: '#/components/messages/updateSubscriptionDeleteCommand' updateSubscriptionSingleSidCommand: $ref: '#/components/messages/updateSubscriptionSingleSidCommand' + ebattleUpdateSubscriptionCommand: + $ref: '#/components/messages/ebattleUpdateSubscriptionCommand' cfbenchmarksUpdateSubscriptionCommand: $ref: '#/components/messages/cfbenchmarksUpdateSubscriptionCommand' pythUpdateSubscriptionCommand: @@ -118,6 +121,8 @@ channels: $ref: '#/components/messages/listSubscriptionsResponse' unsubscribedResponse: $ref: '#/components/messages/unsubscribedResponse' + subscribedMatchesResponse: + $ref: '#/components/messages/subscribedMatchesResponse' subscribedIndicesResponse: $ref: '#/components/messages/subscribedIndicesResponse' subscribedUnderlyingsResponse: @@ -381,6 +386,38 @@ channels: userOrder: $ref: '#/components/messages/userOrder' + ebattle_scores: + address: ebattle_scores + title: eBattle Scores + description: | + Live eSoccer/eFootball and eBasketball score updates. Authentication is required. + Public access is temporary and can return to individual approval; subscriptions + without approval then end. Other sports and esports feeds are excluded. + + Subscribe with `{"id":1,"cmd":"subscribe","params":{"channels":["ebattle_scores"]}}`. + After receiving the assigned `sid`, request matches with + `{"id":2,"cmd":"update_subscription","params":{"sid":1,"action":"subscribe_matches","match_ids":["all"]}}`. + Replace `1` with the assigned subscription ID. Match IDs are Kalshi milestone IDs. + Use specific IDs instead of `all` to filter; `unsubscribe_matches` removes IDs. + `matchlist` returns IDs seen within the past two hours, not a complete fixture catalog. + A new subscription is empty until `subscribe_matches` is sent. There is no initial + snapshot or replay; subsequent updates contain the current full score state. + + `msg.data` is a JSON-encoded string with `game`, `status`, and available score/clock + fields: `home_score`, `away_score`, `home_periods`, `away_periods`, `score_format`, + `period_unit`, `clock_ticking`, `duration_seconds`, `current_game_duration_seconds`, + `last_updated_ts`, and `live_data_delayed`. A home/away side follows the milestone's orientation. + `live_data_delayed: true` means the score source has stopped updating and the scores + may be stale; the same scores can be resent with only this flag changed. + eBasketball scores are points per quarter; eSoccer/eFootball scores are goals per half. + Period maps use keys such as `period_1`. Missing fields mean unavailable data. + `received_at` is the publication timestamp in milliseconds; it is not a vendor timestamp. + messages: + ebattleScores: + $ref: '#/components/messages/ebattleScores' + ebattleScoresMatchlist: + $ref: '#/components/messages/ebattleScoresMatchlist' + cfbenchmarks_value: address: cfbenchmarks_value title: CF Benchmarks Value Feed @@ -691,6 +728,30 @@ channels: $ref: '#/components/messages/pythUnderlyingList' operations: + sendEbattleUpdateSubscription: + action: receive + title: Update eBattle Match Subscription + channel: + $ref: '#/channels/root' + messages: + - $ref: '#/channels/root/messages/ebattleUpdateSubscriptionCommand' + + receiveEbattleScores: + action: send + title: ebattleScores + channel: + $ref: '#/channels/ebattle_scores' + messages: + - $ref: '#/channels/ebattle_scores/messages/ebattleScores' + + receiveEbattleScoresMatchlist: + action: send + title: ebattleScoresMatchlist + channel: + $ref: '#/channels/ebattle_scores' + messages: + - $ref: '#/channels/ebattle_scores/messages/ebattleScoresMatchlist' + sendPing: action: receive title: Send Ping @@ -856,6 +917,7 @@ operations: - $ref: '#/channels/root/messages/okResponse' - $ref: '#/channels/root/messages/subscribedUnderlyingsResponse' - $ref: '#/channels/root/messages/subscribedIndicesResponse' + - $ref: '#/channels/root/messages/subscribedMatchesResponse' tags: - name: responses @@ -1230,6 +1292,27 @@ operations: components: messages: + ebattleScores: + name: ebattle_scores + title: ebattleScores + contentType: application/json + payload: + $ref: '#/components/schemas/ebattleScoresPayload' + + ebattleScoresMatchlist: + name: ebattle_scores_matchlist + title: ebattleScoresMatchlist + contentType: application/json + payload: + $ref: '#/components/schemas/ebattleScoresMatchlistPayload' + + ebattleUpdateSubscriptionCommand: + name: update_subscription_ebattle + title: ebattleUpdateSubscriptionCommand + contentType: application/json + payload: + $ref: '#/components/schemas/ebattleUpdateSubscriptionCommandPayload' + # Control Frame Messages (Client -> Server) incomingPing: name: ping @@ -1595,6 +1678,14 @@ components: type: unsubscribed sending_ts_ms: 1669149841234 + subscribedMatchesResponse: + name: ok + title: Subscribed Matches + summary: Current eBattle match filter after an update; all-mode is ["all"] + contentType: application/json + payload: + $ref: '#/components/schemas/subscribedMatchesResponsePayload' + subscribedIndicesResponse: name: ok title: Subscribed Indices @@ -2715,6 +2806,85 @@ components: subaccount: 2 schemas: + ebattleUpdateSubscriptionCommandPayload: + type: object + required: [id, cmd, params] + properties: + id: + $ref: '#/components/schemas/commandId' + cmd: + type: string + const: update_subscription + params: + type: object + required: [sid, action] + properties: + sid: + $ref: '#/components/schemas/subscriptionId' + action: + type: string + enum: [subscribe_matches, unsubscribe_matches, matchlist] + match_ids: + type: array + description: Required for subscribe_matches and unsubscribe_matches. Use ["all"] to select all matches. + items: + type: string + + ebattleScoresPayload: + type: object + required: [type, sid, seq, msg] + properties: + type: + type: string + const: ebattle_scores + id: + $ref: '#/components/schemas/commandId' + sid: + $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' + msg: + type: object + required: [event_id, match_id, received_at, data] + properties: + event_id: + type: string + match_id: + type: string + description: Kalshi milestone ID. + received_at: + type: integer + format: int64 + data: + type: string + description: JSON-encoded full score state, as described by the channel. + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' + + ebattleScoresMatchlistPayload: + type: object + required: [type, sid, seq, msg] + properties: + type: + type: string + const: ebattle_scores_matchlist + id: + $ref: '#/components/schemas/commandId' + sid: + $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' + msg: + type: object + required: [match_ids] + properties: + match_ids: + type: array + items: + type: string + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' + sendingTimestampMs: type: integer format: int64 @@ -2790,7 +2960,7 @@ components: description: List of channels to subscribe to items: type: string - enum: ["orderbook_delta", "ticker", "trade", "fill", "market_positions", "market_lifecycle_v2", "multivariate_market_lifecycle", "communications", "order_group_updates", "user_orders", "cfbenchmarks_value", "cfbenchmarks_value_5hz", "pyth_value"] + enum: ["orderbook_delta", "ticker", "trade", "fill", "market_positions", "market_lifecycle_v2", "multivariate_market_lifecycle", "communications", "order_group_updates", "user_orders", "cfbenchmarks_value", "cfbenchmarks_value_5hz", "pyth_value", "ebattle_scores"] minItems: 1 market_ticker: description: 'Subscribe to a single market. Type: string. Example: "KXBTCD-25AUG0517-T114999.99" (mutually exclusive with market_tickers)' @@ -3055,6 +3225,31 @@ components: sending_ts_ms: $ref: '#/components/schemas/sendingTimestampMs' + subscribedMatchesResponsePayload: + type: object + required: ["type", "sid", "seq", "msg"] + properties: + type: + type: string + const: "ok" + id: + $ref: '#/components/schemas/commandId' + sid: + $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' + msg: + type: object + required: ["match_ids"] + properties: + match_ids: + type: array + description: Current eBattle match filter after an update; all-mode is ["all"] + items: + type: string + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' + subscribedIndicesResponsePayload: type: object required: ["type", "sid", "seq", "msg"] diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 0d00a26..9b8e2d4 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -1701,6 +1701,8 @@ paths: description: | Returns the balance and portfolio value for a member. Both values include all exchange indexes unless `exchange_index` is provided. Pass `subaccount` to use a subaccount instead of the primary account. This endpoint also accepts API keys with the 'read::portfolio_balance' scope. + For per-subaccount balances, use `GET /portfolio/subaccounts/balances`. It returns existing subaccount and exchange index pairs, so its entry count can differ from `balance_breakdown`. The breakdown uses portfolio valuation snapshots; separate requests may reflect different stages of transfer processing. + **Rate limit:** this endpoint has its own bucket, separate from the Read budget. Basic and Advanced tiers may call it **1 time per second**; all higher tiers may call it **5 times per second**. The bucket holds one second of budget, so there is no burst. Calls to this endpoint do not draw down the Read budget, and other reads do not draw down this bucket. Balance only changes on fills, settlements and transfers, so react to those (for example the user fills websocket channel) instead of polling this endpoint. tags: - portfolio @@ -1895,7 +1897,7 @@ paths: get: operationId: GetSubaccountBalances summary: Get All Subaccount Balances - description: "Gets balances for all subaccounts including the primary account. For an API key restricted to a subaccount, returns only that subaccount's balances across exchange indices." + description: "Returns balances for all subaccounts, including the primary account. Only existing subaccount and exchange index pairs are returned, including pairs with a zero balance. A pair can exist before it is funded. For an API key restricted to a subaccount, returns only that subaccount's balances." tags: - portfolio security: @@ -2481,7 +2483,7 @@ paths: post: operationId: CreateRFQ summary: Create RFQ - description: ' Endpoint for creating a new RFQ. You can have a maximum of 100 open RFQs at a time.' + description: ' Endpoint for creating a new RFQ. You can have a maximum of 100 open RFQs at a time. Creating one more returns 429 with code `open_rfq_limit_exceeded`; unlike request-rate limiting, retrying does not help until some of your RFQs close or expire. An RFQ on a market that is not open returns 400 with code `rfq_market_not_open`.' tags: - communications security: @@ -2507,6 +2509,8 @@ paths: $ref: '#/components/responses/UnauthorizedError' '409': $ref: '#/components/responses/ConflictError' + '429': + $ref: '#/components/responses/RateLimitError' '500': $ref: '#/components/responses/InternalServerError' @@ -7178,7 +7182,7 @@ components: type: array items: $ref: '#/components/schemas/IndexedBalance' - description: 'User balance breakdown per exchange instance, omitted only when using a subaccount-restricted API key.' + description: 'Balance for every exchange index, summed across all subaccounts, including zero balances. Unaffected by the `subaccount` and `exchange_index` parameters. Omitted when access is restricted to a subaccount.' CreateSubaccountRequest: type: object diff --git a/tests/test_contracts.py b/tests/test_contracts.py index dc0168a..6effa99 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -1093,16 +1093,15 @@ def test_ws_schema_coverage(self) -> None: ) def test_batch_communications_channel_removed(self) -> None: - """#556: AsyncAPI dropped ``batch_communications`` (16 → 15). - - RFQ-batch notifications now ride ``communications``. The SDK never - shipped a typed subscribe helper for the removed channel, so the - public ``subscribe_communications`` surface stays. + """#556 dropped ``batch_communications`` (16 → 15); #575 added + ``ebattle_scores`` (15 → 16). ``batch_communications`` stays gone; + ``communications`` and the new ``ebattle_scores`` channel are present. """ channels = self.spec.get("channels", {}) assert "batch_communications" not in channels assert "communications" in channels - assert len(channels) == 15 + assert "ebattle_scores" in channels + assert len(channels) == 16 def test_ws_envelope_type_drift(self) -> None: """Warn if spec type const values differ from SDK message envelope type defaults.""" diff --git a/tests/ws/test_channels.py b/tests/ws/test_channels.py index c2d8870..43b2bed 100644 --- a/tests/ws/test_channels.py +++ b/tests/ws/test_channels.py @@ -286,6 +286,54 @@ async def test_update_subscription_empty_index_ids_omitted( ) assert "index_ids" not in cmd["params"] + async def test_update_subscription_subscribe_matches_uses_sid( + self, + sub_mgr: SubscriptionManager, + fake_ws, # type: ignore[no-untyped-def] + ) -> None: + sub = await sub_mgr.subscribe("ebattle_scores") + await sub_mgr.update_subscription( + sub.client_id, "subscribe_matches", match_ids=["milestone-abc", "all"] + ) + cmd = next( + c for c in fake_ws.received_commands if c.get("cmd") == "update_subscription" + ) + assert cmd["params"]["action"] == "subscribe_matches" + assert cmd["params"]["sid"] == sub.server_sid + assert "sids" not in cmd["params"] + assert cmd["params"]["match_ids"] == ["milestone-abc", "all"] + + async def test_update_subscription_matchlist_omits_match_ids( + self, + sub_mgr: SubscriptionManager, + fake_ws, # type: ignore[no-untyped-def] + ) -> None: + sub = await sub_mgr.subscribe("ebattle_scores") + await sub_mgr.update_subscription(sub.client_id, "matchlist") + cmd = next( + c for c in fake_ws.received_commands if c.get("cmd") == "update_subscription" + ) + assert cmd["params"]["action"] == "matchlist" + assert cmd["params"]["sid"] == sub.server_sid + assert "sids" not in cmd["params"] + assert "match_ids" not in cmd["params"] + + async def test_update_subscription_empty_match_ids_omitted( + self, + sub_mgr: SubscriptionManager, + fake_ws, # type: ignore[no-untyped-def] + ) -> None: + sub = await sub_mgr.subscribe("ebattle_scores") + await sub_mgr.update_subscription( + sub.client_id, "subscribe_matches", match_ids=[] + ) + cmd = next( + c for c in fake_ws.received_commands if c.get("cmd") == "update_subscription" + ) + assert "match_ids" not in cmd["params"] + assert cmd["params"]["sid"] == sub.server_sid + assert "sids" not in cmd["params"] + # --------------------------------------------------------------------------- # SubscriptionManager — resubscribe_all diff --git a/tests/ws/test_client.py b/tests/ws/test_client.py index 95bc6f3..16f744c 100644 --- a/tests/ws/test_client.py +++ b/tests/ws/test_client.py @@ -14,6 +14,10 @@ from kalshi.errors import KalshiConnectionError, KalshiSubscriptionError from kalshi.ws.client import KalshiWebSocket, _WebSocketSession from kalshi.ws.connection import ConnectionState +from kalshi.ws.models.ebattle import ( + EbattleScoresMatchlistMessage, + EbattleScoresMessage, +) from tests._model_fixtures import ( fill_payload_dict, ticker_payload_dict, @@ -240,6 +244,97 @@ async def test_receives_value_message(self, fake_ws, test_auth) -> None: # type assert msg.msg.avg_60s_data.value == Decimal("65000.12345678") +class TestSubscribeEbattleScores: + async def test_subscribe_without_match_ids_sends_only_subscribe( # type: ignore[no-untyped-def] + self, fake_ws, test_auth + ) -> None: + config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) + ws = KalshiWebSocket(auth=test_auth, config=config) + async with ws.connect() as session: + await session.subscribe_ebattle_scores() + assert len(fake_ws.received_commands) == 1 + cmd = fake_ws.received_commands[0] + assert cmd["cmd"] == "subscribe" + assert cmd["params"]["channels"] == ["ebattle_scores"] + assert "match_ids" not in cmd["params"] + + async def test_subscribe_all_sends_subscribe_then_update(self, fake_ws, test_auth) -> None: # type: ignore[no-untyped-def] + config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) + ws = KalshiWebSocket(auth=test_auth, config=config) + async with ws.connect() as session: + await session.subscribe_ebattle_scores(match_ids=["all"]) + assert [c["cmd"] for c in fake_ws.received_commands] == [ + "subscribe", + "update_subscription", + ] + sub_cmd = fake_ws.received_commands[0] + assert sub_cmd["params"]["channels"] == ["ebattle_scores"] + assert "match_ids" not in sub_cmd["params"] + upd = fake_ws.received_commands[1] + assert upd["params"]["sid"] == 1 + assert "sids" not in upd["params"] + assert isinstance(upd["params"]["sid"], int) + assert upd["params"]["action"] == "subscribe_matches" + assert upd["params"]["match_ids"] == ["all"] + + async def test_subscribe_specific_ids_passthrough(self, fake_ws, test_auth) -> None: # type: ignore[no-untyped-def] + config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) + ws = KalshiWebSocket(auth=test_auth, config=config) + async with ws.connect() as session: + await session.subscribe_ebattle_scores( + match_ids=["milestone-abc", "milestone-def"] + ) + upd = fake_ws.received_commands[1] + assert upd["cmd"] == "update_subscription" + assert upd["params"]["match_ids"] == ["milestone-abc", "milestone-def"] + assert upd["params"]["sid"] == 1 + assert "sids" not in upd["params"] + + async def test_receives_scores_message(self, fake_ws, test_auth) -> None: # type: ignore[no-untyped-def] + config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) + ws = KalshiWebSocket(auth=test_auth, config=config) + async with ws.connect() as session: + stream = await session.subscribe_ebattle_scores(match_ids=["all"]) + await fake_ws.send_to_all( + { + "type": "ebattle_scores", + "sid": 1, + "seq": 1, + "msg": { + "event_id": "KXESOCCER-26", + "match_id": "milestone-abc", + "received_at": 1715793600000, + "data": '{"home_score": 1}', + }, + } + ) + msg = await asyncio.wait_for(stream.__anext__(), timeout=2.0) + assert isinstance(msg, EbattleScoresMessage) + assert msg.msg.event_id == "KXESOCCER-26" + assert msg.msg.match_id == "milestone-abc" + assert msg.msg.received_at == 1715793600000 + assert msg.msg.data == '{"home_score": 1}' + assert isinstance(msg.msg.data, str) + + async def test_receives_matchlist_message(self, fake_ws, test_auth) -> None: # type: ignore[no-untyped-def] + config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) + ws = KalshiWebSocket(auth=test_auth, config=config) + async with ws.connect() as session: + stream = await session.subscribe_ebattle_scores() + await fake_ws.send_to_all( + { + "type": "ebattle_scores_matchlist", + "id": 2, + "sid": 1, + "seq": 1, + "msg": {"match_ids": ["milestone-abc"]}, + } + ) + msg = await asyncio.wait_for(stream.__anext__(), timeout=2.0) + assert isinstance(msg, EbattleScoresMatchlistMessage) + assert msg.msg.match_ids == ["milestone-abc"] + + class TestSubscribeOrderbookDelta: async def test_sets_snapshot_flag(self, fake_ws, test_auth) -> None: # type: ignore[no-untyped-def] config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) diff --git a/tests/ws/test_dispatch.py b/tests/ws/test_dispatch.py index 7ff2f0f..5324a12 100644 --- a/tests/ws/test_dispatch.py +++ b/tests/ws/test_dispatch.py @@ -11,6 +11,10 @@ from kalshi.ws.backpressure import MessageQueue from kalshi.ws.channels import Subscription from kalshi.ws.dispatch import CONTROL_TYPES, MESSAGE_MODELS, MessageDispatcher +from kalshi.ws.models.ebattle import ( + EbattleScoresMatchlistMessage, + EbattleScoresMessage, +) from kalshi.ws.models.event_fee import EventFeeUpdateMessage from kalshi.ws.models.market_positions import MarketPositionsMessage from kalshi.ws.models.user_orders import UserOrdersMessage @@ -395,9 +399,44 @@ async def test_all_channel_types_have_models(self) -> None: "cfbenchmarks_value_indexlist", "cfbenchmarks_value_5hz", "cfbenchmarks_value_5hz_indexlist", + "ebattle_scores", + "ebattle_scores_matchlist", } assert expected == set(MESSAGE_MODELS.keys()) + async def test_dispatch_ebattle_scores(self) -> None: + mgr = FakeSubManager() + sub = mgr.add(1, "ebattle_scores") + dispatcher = MessageDispatcher(sub_mgr=mgr) # type: ignore[arg-type] + await dispatcher.dispatch( + { + "type": "ebattle_scores", + "sid": 1, + "seq": 1, + "msg": { + "event_id": "e1", + "match_id": "m1", + "received_at": 1, + "data": "{}", + }, + } + ) + msg = await sub.queue.get() + assert isinstance(msg, EbattleScoresMessage) + assert msg.msg.match_id == "m1" + + await dispatcher.dispatch( + { + "type": "ebattle_scores_matchlist", + "sid": 1, + "seq": 2, + "msg": {"match_ids": ["m1"]}, + } + ) + listed = await sub.queue.get() + assert isinstance(listed, EbattleScoresMatchlistMessage) + assert listed.msg.match_ids == ["m1"] + async def test_control_types(self) -> None: assert {"subscribed", "unsubscribed", "ok", "error"} == CONTROL_TYPES diff --git a/tests/ws/test_models.py b/tests/ws/test_models.py index 5d30b37..de5fc18 100644 --- a/tests/ws/test_models.py +++ b/tests/ws/test_models.py @@ -29,6 +29,10 @@ QuoteExecutedPayload, RfqCreatedPayload, ) +from kalshi.ws.models.ebattle import ( + EbattleScoresMatchlistMessage, + EbattleScoresMessage, +) from kalshi.ws.models.fill import FillMessage, FillPayload from kalshi.ws.models.market_lifecycle import MarketLifecycleMessage from kalshi.ws.models.market_positions import MarketPositionsMessage, MarketPositionsPayload @@ -379,6 +383,82 @@ def test_parse_5hz_index_list(self) -> None: assert msg.msg.index_ids == ["BRTI", "ETHUSD_RTI"] +# ---------- eBattle scores ---------- + + +class TestEbattleScoresModels: + def test_parse_scores_envelope_leaves_data_as_str(self) -> None: + raw = { + "type": "ebattle_scores", + "id": 7, + "sid": 3, + "seq": 12, + "sending_ts_ms": 1715793600123, + "msg": { + "event_id": "KXESOCCER-26", + "match_id": "milestone-abc", + "received_at": 1715793600000, + "data": '{"game":"esoccer","status":"live","home_score":1}', + }, + } + msg = EbattleScoresMessage.model_validate(raw) + assert msg.type == "ebattle_scores" + assert msg.id == 7 + assert msg.sid == 3 + assert msg.seq == 12 + assert msg.msg.event_id == "KXESOCCER-26" + assert msg.msg.match_id == "milestone-abc" + assert msg.msg.received_at == 1715793600000 + assert msg.msg.data == '{"game":"esoccer","status":"live","home_score":1}' + assert isinstance(msg.msg.data, str) + + def test_parse_matchlist(self) -> None: + raw = { + "type": "ebattle_scores_matchlist", + "id": 2, + "sid": 3, + "seq": 1, + "msg": {"match_ids": ["milestone-abc", "milestone-def"]}, + } + msg = EbattleScoresMatchlistMessage.model_validate(raw) + assert msg.type == "ebattle_scores_matchlist" + assert msg.id == 2 + assert msg.sid == 3 + assert msg.msg.match_ids == ["milestone-abc", "milestone-def"] + + def test_missing_required_field_raises(self) -> None: + raw = { + "type": "ebattle_scores", + "sid": 1, + "seq": 1, + "msg": { + "event_id": "e1", + "match_id": "m1", + "received_at": 1, + }, + } + with pytest.raises(ValidationError): + EbattleScoresMessage.model_validate(raw) + + def test_extra_unknown_fields_allowed(self) -> None: + raw = { + "type": "ebattle_scores", + "sid": 1, + "seq": 1, + "unknown_envelope": True, + "msg": { + "event_id": "e1", + "match_id": "m1", + "received_at": 1, + "data": "{}", + "unknown_inner": 1, + }, + } + msg = EbattleScoresMessage.model_validate(raw) + assert msg.msg.event_id == "e1" + assert msg.msg.data == "{}" + + # ---------- Trade ----------