diff --git a/CHANGELOG.md b/CHANGELOG.md index e18a89e..33720df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,54 @@ All notable changes to kalshi-sdk will be documented in this file. +## 3.1.0 — 2026-06-03 + +OpenAPI + AsyncAPI spec sync from v3.19.0 → v3.20.0 (`#385`). Adds the +new `GET /events/fee_changes` endpoint plus three smaller additive +changes upstream re-published into 3.20.0 after the drift issue was +filed (the OpenAPI checksum in `#385` is therefore stale; the AsyncAPI +checksum still matches). + +### Added + +- `events.fee_changes()` / `fee_changes_all()` (sync + async) for + `GET /events/fee_changes` — the new paginated event-level fee-override + feed (`event_ticker`, `limit`, `cursor`). Returns `Page[EventFeeChange]` + (and an auto-paginating iterator). `EventFeeChange` exposes + `fee_type_override` / `fee_multiplier_override`, both present-but-`None` + when an override is cleared. +- `is_block_trade` query param on `markets.list_trades` / + `list_all_trades` (+ deprecated `list_trades_all`) and + `historical.trades` / `trades_all`. Omit for all trades, `True` for + only block trades, `False` for only non-block. +- `Trade.is_block_trade` (`bool`) — new spec-required, non-nullable + response field; a missing key raises so a schema regression surfaces. +- WebSocket `event_fee_update` message on the existing + `market_lifecycle_v2` channel (`EventFeeUpdateMessage` / + `EventFeeUpdatePayload`). `subscribe_market_lifecycle()` now yields + `MarketLifecycleMessage | EventFeeUpdateMessage`. Channel count is + unchanged (still 11) — this is a second message type on an existing + channel. **Behavioral note for existing subscribers:** discriminate on + `.type` before reading payload fields — an `EventFeeUpdatePayload` has no + `market_ticker`, so naive access raises `AttributeError`. See the + migration callout in [`docs/websockets.md`](docs/websockets.md). + +### Internal + +- `specs/openapi.yaml` (sha256 + `b72a2aa138695d810f6ca85096bfe19e1b66ba5e9b2ed37753be284b5288d271`) + and `specs/asyncapi.yaml` (sha256 + `2f72d0a3fd25fe331210ed300f03ad4c1fedcb561b3ab425046b2dca6f4683ec`) + snapshots bumped; `kalshi/_generated/models.py` regenerated. +- Spec also relaxed `CreateOrderV2Request.client_order_id` and + `EventData.product_metadata` from required to optional, and added + `ApiKeyScope` / `FeeType` enums. No SDK-facade change: the V2 order + keeps `client_order_id` required by design, `Event.product_metadata` + already tolerated server omission, and API-key `scopes` stays `str` + for forward-compat. +- README + `docs/index.md` banners bumped to "99 operations … OpenAPI + v3.20.0". + ## 3.0.1 — 2026-05-26 OpenAPI spec sync from v3.18.0 → v3.19.0 (`#383`). Single additive diff --git a/CLAUDE.md b/CLAUDE.md index ba61123..42e1d5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,7 +122,7 @@ tests/ ## API Reference -- OpenAPI spec: https://docs.kalshi.com/openapi.yaml (v3.18.0, 98 operations) +- OpenAPI spec: https://docs.kalshi.com/openapi.yaml (v3.20.0, 99 operations) - AsyncAPI spec: https://docs.kalshi.com/asyncapi.yaml (13 WebSocket channels) - Base URL: https://api.elections.kalshi.com/trade-api/v2 - Demo URL: https://demo-api.kalshi.co/trade-api/v2 diff --git a/README.md b/README.md index ef30949..eed792c 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 (98 operations across 19 resources, OpenAPI v3.19.0) and WebSocket API (11 typed `subscribe_*` channels + 2 escape-hatch). +- **Full coverage** of the Kalshi REST API (99 operations across 19 resources, OpenAPI v3.20.0) and WebSocket API (11 typed `subscribe_*` channels + 2 escape-hatch). - **V2 event-market orders**: `create_v2` / `amend_v2` / `decrease_v2` / `cancel_v2` plus batched variants on `/portfolio/events/orders/*`. Legacy `/portfolio/orders` keeps working — deprecated no earlier than May 6, 2026. - **Funding & cost introspection**: `portfolio.deposits()`, `portfolio.withdrawals()`, `account.endpoint_costs()`. - **Sync and async** clients sharing one transport — no thread-pool wrapping. diff --git a/docs/index.md b/docs/index.md index e5e0347..007811d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,7 +3,7 @@ A professional, spec-first Python SDK for the [Kalshi](https://kalshi.com) prediction markets API. -- **Full REST coverage** — 98 operations across 19 resources (OpenAPI v3.19.0), +- **Full REST coverage** — 99 operations across 19 resources (OpenAPI v3.20.0), every kwarg drift-tested against the spec. - **V2 event-market orders** — new `create_v2` / `amend_v2` / `decrease_v2` / `cancel_v2` family on `/portfolio/events/orders/*`. Legacy `/portfolio/orders` diff --git a/docs/resources/events.md b/docs/resources/events.md index 9374b4b..ffb59ce 100644 --- a/docs/resources/events.md +++ b/docs/resources/events.md @@ -13,6 +13,8 @@ more markets that share a resolution. | `list_all_multivariate(...)` | walks `list_multivariate` | no | | `get(event_ticker, *, with_nested_markets=False)` | `GET /events/{event_ticker}` | no | | `metadata(event_ticker)` | `GET /events/{event_ticker}/metadata` | no | +| `fee_changes(...)` | `GET /events/fee_changes` | no | +| `fee_changes_all(...)` | walks `fee_changes` | no | ## List events @@ -64,16 +66,43 @@ print(md.tags, md.category) Two `EventMetadata`-adjacent fields are typed as nullable to absorb live server behavior: - - `Event.product_metadata` is typed `dict[str, Any] | None`. The OpenAPI - spec marks it `required`, but the live demo server omits the key on - most events. Defaults to `None`. + - `Event.product_metadata` is typed `dict[str, Any] | None` and defaults + to `None`. The live demo server omits the key on most events. As of + OpenAPI v3.20.0 the spec also marks it optional, so this is no longer a + spec deviation — the SDK simply keeps it nullable to match reality. - `EventMetadata.market_details: list[MarketMetadata]` — when the live - server sends JSON `null`, the SDK coerces it to `[]` so callers - always see a list. The spec contract (key present) is still + server sends JSON `null`, the SDK coerces it to `[]` (via `NullableList`) + so callers always see a list. The spec contract (key present) is still enforced. - Both are tracked under `server_omits_despite_required` in the SDK's - EXCLUSIONS map. +## Event fee changes + +`fee_changes()` returns the paginated feed of scheduled **event-level** fee +overrides (`GET /events/fee_changes`, added in v3.20.0). Event fees layer on +top of the parent series' fee structure. + +```python +page = client.events.fee_changes(event_ticker="KXPRES-24", limit=100) +for change in page: + if change.fee_type_override is None: + print(change.event_ticker, "override cleared") + else: + print(change.event_ticker, change.fee_type_override, change.fee_multiplier_override) + +# Or auto-paginate across every page: +for change in client.events.fee_changes_all(event_ticker="KXPRES-24"): + ... +``` + +`EventFeeChange.fee_type_override` and `fee_multiplier_override` are both +`None` when an override has been **cleared** (the event falls back to the +series fee). Both keys are always present in the payload — `None` is a +meaningful "cleared" signal, not a missing field. The live `event_fee_update` +WebSocket message on the `market_lifecycle_v2` channel carries the same +override shape (see [WebSocket](../websockets.md)). + +For the series-level equivalent, see +[`series.fee_changes`](series.md). ## Reference diff --git a/docs/resources/historical.md b/docs/resources/historical.md index e60a053..1fd0a55 100644 --- a/docs/resources/historical.md +++ b/docs/resources/historical.md @@ -51,6 +51,7 @@ trades = client.historical.trades( ticker="KXPRES-24-DJT", min_ts=1_600_000_000, max_ts=1_650_000_000, + is_block_trade=False, # v3.20.0: omit for all; True = only block, False = only non-block limit=1000, ) ``` diff --git a/docs/resources/markets.md b/docs/resources/markets.md index 1bff248..8debcc0 100644 --- a/docs/resources/markets.md +++ b/docs/resources/markets.md @@ -124,12 +124,16 @@ page = client.markets.list_trades( ticker="KXPRES-24-DJT", min_ts=1_700_000_000, max_ts=1_700_100_000, + is_block_trade=False, # omit for all trades; True = only block, False = only non-block limit=200, ) for trade in page: - print(trade.trade_id, trade.taker_side, trade.yes_price, trade.count) + print(trade.trade_id, trade.taker_side, trade.yes_price, trade.count, trade.is_block_trade) ``` +`is_block_trade` (v3.20.0) filters by whether a trade is a block trade. Omit it +to return all trades. Each `Trade` also carries an `is_block_trade: bool`. + !!! warning "Deprecated since v3.0.0" `list_trades_all` is the legacy name; it still works but emits `DeprecationWarning` and will be removed in a future release. Use diff --git a/docs/websockets.md b/docs/websockets.md index 0fdc1d4..ad807b7 100644 --- a/docs/websockets.md +++ b/docs/websockets.md @@ -36,7 +36,7 @@ SDK's perspective on it. | `subscribe_ticker` | `ticker` | `ticker` | `TickerMessage` | public | | `subscribe_trade` | `trade` | `trade` | `TradeMessage` | public | | `subscribe_orderbook_delta` | `orderbook_delta` | `orderbook_snapshot` → `orderbook_delta` | `OrderbookSnapshotMessage` / `OrderbookDeltaMessage` | public | -| `subscribe_market_lifecycle` | `market_lifecycle_v2` | `market_lifecycle_v2` | `MarketLifecycleMessage` | public | +| `subscribe_market_lifecycle` | `market_lifecycle_v2` | `market_lifecycle_v2` / `event_fee_update` | `MarketLifecycleMessage` / `EventFeeUpdateMessage` | public | | `subscribe_multivariate` | `multivariate` | `multivariate_lookup` | `MultivariateMessage` | public | | `subscribe_multivariate_lifecycle` | `multivariate_market_lifecycle` | `multivariate_market_lifecycle` | `MultivariateLifecycleMessage` | public | | `subscribe_fill` | `fill` | `fill` | `FillMessage` | private | @@ -49,6 +49,30 @@ The `type` column matters when filtering raw logs — note the singular forms for `user_order`, `market_position`, and the `multivariate_lookup` / `multivariate` mismatch. +!!! warning "Migration (v3.1.0): `event_fee_update` rides `market_lifecycle_v2`" + Since v3.20.0 the `market_lifecycle_v2` channel also emits + `event_fee_update` frames (event-level fee override set or cleared), so + `subscribe_market_lifecycle()` now yields + `MarketLifecycleMessage | EventFeeUpdateMessage`. **Existing consumers must + discriminate on `.type` before touching payload fields** — an + `EventFeeUpdatePayload` has no `market_ticker`, so naive access raises + `AttributeError`: + + ```python + async for msg in session.subscribe_market_lifecycle(): + if msg.type == "event_fee_update": + print(msg.msg.event_ticker, msg.msg.fee_type_override) # None when cleared + else: # market_lifecycle_v2 + print(msg.msg.market_ticker, msg.msg.event_type) + ``` + + This is a second message **type** on the same channel — the channel count + stays 11. The override payload mirrors the REST + [`EventFeeChange`](resources/events.md#event-fee-changes): + `EventFeeUpdatePayload` carries `event_ticker`, `fee_type_override`, and + `fee_multiplier_override` (the latter two `None` when the override is + cleared). + Two channels carry monotonic `seq` numbers and have built-in sequence-gap recovery: `orderbook_delta` (which delivers both snapshot and delta envelopes under one subscription) and `order_group_updates`. diff --git a/kalshi/__init__.py b/kalshi/__init__.py index e24b5e1..27712f2 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -78,6 +78,7 @@ EndpointTokenCost, Event, EventCandlesticks, + EventFeeChange, EventMetadata, EventPosition, EventStatusLiteral, @@ -236,6 +237,7 @@ "EndpointTokenCost", "Event", "EventCandlesticks", + "EventFeeChange", "EventMetadata", "EventPosition", "EventStatusLiteral", @@ -351,4 +353,4 @@ "Withdrawal", ] -__version__ = "3.0.1" +__version__ = "3.1.0" diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index ef2946c..1e23b9f 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -57,6 +57,10 @@ class ContractEntry: spec_schema="EventData", notes="Spec uses 'EventData', not 'Event'", ), + ContractEntry( + sdk_model="kalshi.models.events.EventFeeChange", + spec_schema="EventFeeChange", + ), ContractEntry( sdk_model="kalshi.models.exchange.ExchangeStatus", spec_schema="ExchangeStatus", @@ -514,6 +518,11 @@ class ContractEntry: notes="SDK conflates lifecycle + event fields. " "Spec has additional_metadata, price_level_structure not in SDK.", ), + ContractEntry( + sdk_model="kalshi.ws.models.event_fee.EventFeeUpdatePayload", + spec_schema="eventFeeUpdatePayload", + notes="New v3.20.0 message (#385); rides the market_lifecycle_v2 channel.", + ), ContractEntry( sdk_model="kalshi.ws.models.market_positions.MarketPositionsPayload", spec_schema="marketPositionPayload", diff --git a/kalshi/models/__init__.py b/kalshi/models/__init__.py index 1ca3636..01fa3b1 100644 --- a/kalshi/models/__init__.py +++ b/kalshi/models/__init__.py @@ -35,6 +35,7 @@ ) from kalshi.models.events import ( Event, + EventFeeChange, EventMetadata, EventStatusLiteral, MarketMetadata, @@ -232,6 +233,7 @@ "EndpointTokenCost", "Event", "EventCandlesticks", + "EventFeeChange", "EventMetadata", "EventPosition", "EventStatusLiteral", diff --git a/kalshi/models/events.py b/kalshi/models/events.py index c106a37..d77f487 100644 --- a/kalshi/models/events.py +++ b/kalshi/models/events.py @@ -26,9 +26,10 @@ class Event(BaseModel): strike_date: AwareDatetime | None = None strike_period: str | None = None available_on_brokers: bool - # Spec marks `product_metadata` required, but the live demo server omits - # it on most events (observed run #26141405845, 2026-05-20). Recorded in - # tests/_contract_support.py EXCLUSIONS as `server_omits_despite_required`. + # The live demo server omits `product_metadata` on most events (observed + # run #26141405845, 2026-05-20). OpenAPI v3.20.0 relaxed it to optional + # too (#385), so this is no longer a spec deviation — kept nullable to + # match server reality. product_metadata: dict[str, Any] | None = None last_updated_ts: AwareDatetime | None = None markets: NullableList[Market] = [] @@ -43,6 +44,27 @@ class Event(BaseModel): model_config = {"extra": "allow", "populate_by_name": True} +class EventFeeChange(BaseModel): + """A scheduled event-level fee override (GET /events/fee_changes). + + Event fees layer on top of the parent series' fee structure. + ``fee_type_override`` and ``fee_multiplier_override`` are both ``None`` + when the override has been cleared (the event falls back to the series + fee). Both are spec-required keys (``EventFeeChange.required``) but + nullable — the key must be present; ``None`` is a meaningful "override + cleared" signal, so neither carries a default. + """ + + id: str + event_ticker: str + series_ticker: str + fee_type_override: str | None + fee_multiplier_override: MultiplierDecimal | None + scheduled_ts: AwareDatetime + + model_config = {"extra": "allow"} + + class MarketMetadata(BaseModel): """Metadata for a market within an event.""" diff --git a/kalshi/models/historical.py b/kalshi/models/historical.py index f4f3561..863a915 100644 --- a/kalshi/models/historical.py +++ b/kalshi/models/historical.py @@ -54,4 +54,9 @@ class Trade(BaseModel): taker_outcome_side: SideLiteral taker_book_side: BookSideLiteral + # v3.20.0 (#385). Spec marks `is_block_trade` required + non-nullable + # (Trade.required). Typed strict `bool` like taker_outcome_side: a missing + # key raises ValidationError so a schema regression surfaces, not a default. + is_block_trade: bool + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/models/orders.py b/kalshi/models/orders.py index 3f549ef..188ddad 100644 --- a/kalshi/models/orders.py +++ b/kalshi/models/orders.py @@ -463,8 +463,10 @@ class CreateOrderV2Request(BaseModel): - ``side`` is ``BookSideLiteral`` (``bid``/``ask``), not ``yes``/``no``. V2 narrows the type on the model itself since there is no kwarg overload at the resource-method boundary (see model_only V2 surface). - - ``client_order_id`` is **required** in V2, unlike V1 where it is - optional. The server uses it for idempotency in V2. + - ``client_order_id`` is **required** by this model, unlike V1 where it is + optional, because the server uses it for idempotency on V2 orders. + (OpenAPI v3.20.0 relaxed it to optional server-side, but the SDK keeps + it required by design so every V2 order is idempotent.) - Price is a single ``price: DollarDecimal`` field rather than the paired ``yes_price`` / ``no_price`` from V1. """ diff --git a/kalshi/resources/events.py b/kalshi/resources/events.py index 0cafbb4..133ce56 100644 --- a/kalshi/resources/events.py +++ b/kalshi/resources/events.py @@ -6,7 +6,7 @@ from typing import Any from kalshi.models.common import Page -from kalshi.models.events import Event, EventMetadata, EventStatusLiteral +from kalshi.models.events import Event, EventFeeChange, EventMetadata, EventStatusLiteral from kalshi.resources._base import ( AsyncResource, SyncResource, @@ -62,6 +62,20 @@ def _list_multivariate_events_params( ) +def _event_fee_changes_params( + *, + event_ticker: str | None, + limit: int | None, + cursor: str | None, +) -> dict[str, Any]: + limit = _validate_limit(limit, hi=1000) + return _params( + event_ticker=event_ticker, + limit=limit, + cursor=cursor, + ) + + class EventsResource(SyncResource): """Sync events API.""" @@ -197,6 +211,50 @@ def metadata( ) return EventMetadata.model_validate(data) + def fee_changes( + self, + *, + event_ticker: str | None = None, + limit: int | None = None, + cursor: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Page[EventFeeChange]: + params = _event_fee_changes_params( + event_ticker=event_ticker, + limit=limit, + cursor=cursor, + ) + return self._list( + "/events/fee_changes", + EventFeeChange, + "event_fee_changes", + params=params, + extra_headers=extra_headers, + ) + + def fee_changes_all( + self, + *, + event_ticker: str | None = None, + limit: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Iterator[EventFeeChange]: + _validate_max_pages(max_pages) + params = _event_fee_changes_params( + event_ticker=event_ticker, + limit=limit, + cursor=None, + ) + return self._list_all( + "/events/fee_changes", + EventFeeChange, + "event_fee_changes", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) + class AsyncEventsResource(AsyncResource): """Async events API.""" @@ -334,3 +392,48 @@ async def metadata( extra_headers=extra_headers, ) return EventMetadata.model_validate(data) + + async def fee_changes( + self, + *, + event_ticker: str | None = None, + limit: int | None = None, + cursor: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Page[EventFeeChange]: + params = _event_fee_changes_params( + event_ticker=event_ticker, + limit=limit, + cursor=cursor, + ) + return await self._list( + "/events/fee_changes", + EventFeeChange, + "event_fee_changes", + params=params, + extra_headers=extra_headers, + ) + + def fee_changes_all( + self, + *, + event_ticker: str | None = None, + limit: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> AsyncIterator[EventFeeChange]: + """Returns an async iterator — use ``async for``.""" + _validate_max_pages(max_pages) + params = _event_fee_changes_params( + event_ticker=event_ticker, + limit=limit, + cursor=None, + ) + return self._list_all( + "/events/fee_changes", + EventFeeChange, + "event_fee_changes", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) diff --git a/kalshi/resources/historical.py b/kalshi/resources/historical.py index 55138a0..6606aa7 100644 --- a/kalshi/resources/historical.py +++ b/kalshi/resources/historical.py @@ -17,6 +17,7 @@ from kalshi.resources._base import ( AsyncResource, SyncResource, + _bool_param, _join_tickers, _params, _seg, @@ -78,6 +79,7 @@ def _historical_trades_params( ticker: str | None, min_ts: int | None, max_ts: int | None, + is_block_trade: bool | None, ) -> dict[str, Any]: limit = _validate_limit(limit, hi=1000, lo=0) return _params( @@ -86,6 +88,7 @@ def _historical_trades_params( ticker=ticker, min_ts=min_ts, max_ts=max_ts, + is_block_trade=_bool_param(is_block_trade), ) @@ -277,6 +280,7 @@ def trades( ticker: str | None = None, min_ts: int | None = None, max_ts: int | None = None, + is_block_trade: bool | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[Trade]: params = _historical_trades_params( @@ -285,6 +289,7 @@ def trades( ticker=ticker, min_ts=min_ts, max_ts=max_ts, + is_block_trade=is_block_trade, ) return self._list( "/historical/trades", Trade, "trades", params=params, extra_headers=extra_headers @@ -297,6 +302,7 @@ def trades_all( ticker: str | None = None, min_ts: int | None = None, max_ts: int | None = None, + is_block_trade: bool | None = None, max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Iterator[Trade]: @@ -307,6 +313,7 @@ def trades_all( ticker=ticker, min_ts=min_ts, max_ts=max_ts, + is_block_trade=is_block_trade, ) return self._list_all( "/historical/trades", @@ -506,6 +513,7 @@ async def trades( ticker: str | None = None, min_ts: int | None = None, max_ts: int | None = None, + is_block_trade: bool | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[Trade]: params = _historical_trades_params( @@ -514,6 +522,7 @@ async def trades( ticker=ticker, min_ts=min_ts, max_ts=max_ts, + is_block_trade=is_block_trade, ) return await self._list( "/historical/trades", Trade, "trades", params=params, extra_headers=extra_headers @@ -526,6 +535,7 @@ def trades_all( ticker: str | None = None, min_ts: int | None = None, max_ts: int | None = None, + is_block_trade: bool | None = None, max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> AsyncIterator[Trade]: @@ -536,6 +546,7 @@ def trades_all( ticker=ticker, min_ts=min_ts, max_ts=max_ts, + is_block_trade=is_block_trade, ) return self._list_all( "/historical/trades", diff --git a/kalshi/resources/markets.py b/kalshi/resources/markets.py index b494faa..c22ea61 100644 --- a/kalshi/resources/markets.py +++ b/kalshi/resources/markets.py @@ -98,6 +98,7 @@ def _list_trades_params( max_ts: int | None, limit: int | None, cursor: str | None, + is_block_trade: bool | None, ) -> dict[str, Any]: limit = _validate_limit(limit, hi=1000, lo=0) return _params( @@ -106,6 +107,7 @@ def _list_trades_params( max_ts=max_ts, limit=limit, cursor=cursor, + is_block_trade=_bool_param(is_block_trade), ) @@ -346,6 +348,7 @@ def list_trades( max_ts: int | None = None, limit: int | None = None, cursor: str | None = None, + is_block_trade: bool | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[Trade]: params = _list_trades_params( @@ -354,6 +357,7 @@ def list_trades( max_ts=max_ts, limit=limit, cursor=cursor, + is_block_trade=is_block_trade, ) return self._list( "/markets/trades", Trade, "trades", params=params, extra_headers=extra_headers @@ -366,6 +370,7 @@ def list_all_trades( min_ts: int | None = None, max_ts: int | None = None, limit: int | None = None, + is_block_trade: bool | None = None, max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Iterator[Trade]: @@ -376,6 +381,7 @@ def list_all_trades( max_ts=max_ts, limit=limit, cursor=None, + is_block_trade=is_block_trade, ) return self._list_all( "/markets/trades", @@ -397,6 +403,7 @@ def list_trades_all( min_ts: int | None = None, max_ts: int | None = None, limit: int | None = None, + is_block_trade: bool | None = None, max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Iterator[Trade]: @@ -406,6 +413,7 @@ def list_trades_all( min_ts=min_ts, max_ts=max_ts, limit=limit, + is_block_trade=is_block_trade, max_pages=max_pages, extra_headers=extra_headers, ) @@ -593,6 +601,7 @@ async def list_trades( max_ts: int | None = None, limit: int | None = None, cursor: str | None = None, + is_block_trade: bool | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[Trade]: params = _list_trades_params( @@ -601,6 +610,7 @@ async def list_trades( max_ts=max_ts, limit=limit, cursor=cursor, + is_block_trade=is_block_trade, ) return await self._list( "/markets/trades", Trade, "trades", params=params, extra_headers=extra_headers @@ -613,6 +623,7 @@ def list_all_trades( min_ts: int | None = None, max_ts: int | None = None, limit: int | None = None, + is_block_trade: bool | None = None, max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> AsyncIterator[Trade]: @@ -624,6 +635,7 @@ def list_all_trades( max_ts=max_ts, limit=limit, cursor=None, + is_block_trade=is_block_trade, ) return self._list_all( "/markets/trades", @@ -645,6 +657,7 @@ def list_trades_all( min_ts: int | None = None, max_ts: int | None = None, limit: int | None = None, + is_block_trade: bool | None = None, max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> AsyncIterator[Trade]: @@ -654,6 +667,7 @@ def list_trades_all( min_ts=min_ts, max_ts=max_ts, limit=limit, + is_block_trade=is_block_trade, max_pages=max_pages, extra_headers=extra_headers, ) diff --git a/kalshi/ws/client.py b/kalshi/ws/client.py index 4e788ed..4db7626 100644 --- a/kalshi/ws/client.py +++ b/kalshi/ws/client.py @@ -28,6 +28,7 @@ from kalshi.ws.dispatch import MessageDispatcher from kalshi.ws.models.base import ErrorMessage from kalshi.ws.models.communications import CommunicationsMessage +from kalshi.ws.models.event_fee import EventFeeUpdateMessage from kalshi.ws.models.fill import FillMessage from kalshi.ws.models.market_lifecycle import MarketLifecycleMessage from kalshi.ws.models.market_positions import MarketPositionsMessage @@ -829,7 +830,13 @@ async def subscribe_order_group( async def subscribe_market_lifecycle( self, *, tickers: list[str] | None = None, maxsize: int = 1000, - ) -> AsyncIterator[MarketLifecycleMessage]: + ) -> AsyncIterator[MarketLifecycleMessage | EventFeeUpdateMessage]: + """Subscribe to the market_lifecycle_v2 channel. + + Yields :class:`MarketLifecycleMessage` for lifecycle events and + :class:`EventFeeUpdateMessage` for ``event_fee_update`` frames — both + ride this channel. Discriminate on the ``.type`` field. + """ params: dict[str, Any] = {} if tickers: params["market_tickers"] = tickers diff --git a/kalshi/ws/dispatch.py b/kalshi/ws/dispatch.py index 1c77582..64485d2 100644 --- a/kalshi/ws/dispatch.py +++ b/kalshi/ws/dispatch.py @@ -10,6 +10,7 @@ from kalshi.ws.channels import SubscriptionManager from kalshi.ws.models.base import ErrorMessage, ErrorPayload from kalshi.ws.models.communications import CommunicationsMessage +from kalshi.ws.models.event_fee import EventFeeUpdateMessage from kalshi.ws.models.fill import FillMessage from kalshi.ws.models.market_lifecycle import MarketLifecycleMessage from kalshi.ws.models.market_positions import MarketPositionsMessage @@ -35,6 +36,7 @@ "user_order": UserOrdersMessage, "order_group_updates": OrderGroupMessage, "market_lifecycle_v2": MarketLifecycleMessage, + "event_fee_update": EventFeeUpdateMessage, "multivariate_lookup": MultivariateMessage, "multivariate_market_lifecycle": MultivariateLifecycleMessage, "communications": CommunicationsMessage, diff --git a/kalshi/ws/models/__init__.py b/kalshi/ws/models/__init__.py index e77b980..f3bb6b8 100644 --- a/kalshi/ws/models/__init__.py +++ b/kalshi/ws/models/__init__.py @@ -16,6 +16,10 @@ RfqCreatedPayload, RfqDeletedPayload, ) +from kalshi.ws.models.event_fee import ( + EventFeeUpdateMessage, + EventFeeUpdatePayload, +) from kalshi.ws.models.fill import FillMessage, FillPayload from kalshi.ws.models.market_lifecycle import ( MarketLifecycleMessage, @@ -52,6 +56,9 @@ "CommunicationsMessage", "ErrorMessage", "ErrorPayload", + # Event fee update + "EventFeeUpdateMessage", + "EventFeeUpdatePayload", # Fill "FillMessage", "FillPayload", diff --git a/kalshi/ws/models/event_fee.py b/kalshi/ws/models/event_fee.py new file mode 100644 index 0000000..46c68d2 --- /dev/null +++ b/kalshi/ws/models/event_fee.py @@ -0,0 +1,44 @@ +"""Event fee override update message model (market_lifecycle_v2 channel).""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + +from kalshi.types import MultiplierDecimal + + +class EventFeeUpdatePayload(BaseModel): + """Payload for ``event_fee_update`` messages (market_lifecycle_v2 channel). + + Emitted when an event-level fee override is set or cleared. + ``fee_type_override`` and ``fee_multiplier_override`` are both ``None`` + when the override has been cleared (the event falls back to the parent + series' fee structure). Both are spec-required keys but nullable — the + key is present; ``None`` is the meaningful "override cleared" signal, so + neither carries a default. + """ + + event_ticker: str + fee_type_override: str | None + fee_multiplier_override: MultiplierDecimal | None + + model_config = {"extra": "allow", "populate_by_name": True} + + +class EventFeeUpdateMessage(BaseModel): + """``event_fee_update`` message delivered on the market_lifecycle_v2 channel. + + Rides the same channel as :class:`MarketLifecycleMessage`; subscribers to + ``subscribe_market_lifecycle`` receive both message types. NO required seq. + """ + + type: Literal["event_fee_update"] = "event_fee_update" + sid: int + # The AsyncAPI envelope does not declare `seq` for this message; it is + # accepted-if-present for forward-compat and parity with the sibling + # MarketLifecycleMessage on the same channel. The server does not emit it. + seq: int | None = None + msg: EventFeeUpdatePayload + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/pyproject.toml b/pyproject.toml index ba457b2..67db9a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "3.0.1" +version = "3.1.0" description = "A professional Python SDK for the Kalshi prediction markets API" readme = "README.md" license = { text = "MIT" } diff --git a/specs/asyncapi.yaml b/specs/asyncapi.yaml index a4a7090..2e38715 100644 --- a/specs/asyncapi.yaml +++ b/specs/asyncapi.yaml @@ -320,6 +320,8 @@ channels: $ref: '#/components/messages/marketLifecycleV2' eventLifecycle: $ref: '#/components/messages/eventLifecycle' + eventFeeUpdate: + $ref: '#/components/messages/eventFeeUpdate' multivariate_market_lifecycle: address: multivariate_market_lifecycle title: Multivariate Market & Event Lifecycle @@ -697,6 +699,16 @@ operations: - $ref: '#/channels/market_lifecycle_v2/messages/eventLifecycle' tags: - name: market-data + receiveEventFeeUpdate: + action: send + title: Event Fee Override Update + summary: Receive notifications when an event-level fee override is set or cleared + channel: + $ref: '#/channels/market_lifecycle_v2' + messages: + - $ref: '#/channels/market_lifecycle_v2/messages/eventFeeUpdate' + tags: + - name: market-data receiveMultivariateMarketLifecycle: action: send title: Multivariate Market Lifecycle Event @@ -1474,6 +1486,33 @@ components: subtitle: Jan 25 at 21:50 collateral_return_type: MECNET series_ticker: KXQUICKSETTLE + eventFeeUpdate: + name: event_fee_update + title: Event Fee Override Update + summary: Emitted when an event-level fee override is set or cleared + description: Delivered on the `market_lifecycle_v2` channel only. + contentType: application/json + payload: + $ref: '#/components/schemas/eventFeeUpdatePayload' + examples: + - name: eventFeeOverrideSet + summary: Event fee override set + payload: + type: event_fee_update + sid: 5 + msg: + event_ticker: KXBTCD-26MAY2018 + fee_type_override: quadratic + fee_multiplier_override: 1 + - name: eventFeeOverrideCleared + summary: Event fee override cleared + payload: + type: event_fee_update + sid: 5 + msg: + event_ticker: KXBTCD-26MAY2018 + fee_type_override: null + fee_multiplier_override: null multivariateLookup: name: multivariate_lookup title: Multivariate Lookup (Deprecated) @@ -2770,6 +2809,45 @@ components: description: >- Optional - String to indicate the strike period of the event if there is one + eventFeeUpdatePayload: + type: object + required: + - type + - sid + - msg + properties: + type: + type: string + const: event_fee_update + sid: + $ref: '#/components/schemas/subscriptionId' + msg: + type: object + required: + - event_ticker + - fee_type_override + - fee_multiplier_override + properties: + event_ticker: + type: string + description: Unique identifier for the event + fee_type_override: + type: string + nullable: true + enum: + - quadratic + - quadratic_with_maker_fees + - flat + - null + description: >- + Event fee type override. `null` when the override has been + cleared. + fee_multiplier_override: + type: number + nullable: true + description: >- + Event fee multiplier override. `null` when the override has been + cleared. multivariateLookupPayload: type: object required: diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 9649940..9a42263 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.0 info: title: Kalshi Trade API Manual Endpoints - version: 3.19.0 + version: 3.20.0 description: >- Manually defined OpenAPI spec for endpoints being migrated to spec-first approach @@ -114,7 +114,7 @@ paths: get: operationId: GetUserDataTimestamp summary: Get User Data Timestamp - description: ' There is typically a short delay before exchange events are reflected in the API endpoints. Whenever possible, combine API responses to PUT/POST/DELETE requests with websocket data to obtain the most accurate view of the exchange state. This endpoint provides an approximate indication of when the data from the following endpoints was last validated: GetBalance, GetOrder(s), GetFills, GetPositions' + description: ' There is typically a short delay before exchange events are reflected in the API endpoints. Whenever possible, combine API responses to PUT/POST/DELETE requests with WebSocket data to obtain the most accurate view of the exchange state. This endpoint provides an approximate indication of when the data from the following endpoints was last validated: GetBalance, GetOrder(s), GetFills, GetPositions' tags: - exchange responses: @@ -224,11 +224,13 @@ paths: Endpoint for getting all trades for all markets. A trade represents a completed transaction between two users on a specific market. Each trade includes the market ticker, price, quantity, and timestamp information. - This endpoint returns a paginated response. Use the 'limit' parameter to - control page size (1-1000, defaults to 100). The response includes a - 'cursor' field - pass this value in the 'cursor' parameter of your next - request to get the next page. An empty cursor indicates no more pages - are available. + Block trades are included in the response by default and identified by + the `is_block_trade` field; use the `is_block_trade` query parameter to + filter by block / non-block. This endpoint returns a paginated response. + Use the 'limit' parameter to control page size (1-1000, defaults to + 100). The response includes a 'cursor' field - pass this value in the + 'cursor' parameter of your next request to get the next page. An empty + cursor indicates no more pages are available. tags: - market parameters: @@ -237,6 +239,7 @@ paths: - $ref: '#/components/parameters/TickerQuery' - $ref: '#/components/parameters/MinTsQuery' - $ref: '#/components/parameters/MaxTsQuery' + - $ref: '#/components/parameters/IsBlockTradeQuery' responses: '200': description: Trades retrieved successfully @@ -803,6 +806,36 @@ paths: description: Unauthorized '500': description: Internal server error + /events/fee_changes: + get: + operationId: GetEventFeeChanges + summary: Get Event Fee Changes + description: > + Event fees are an override layered on top of the parent series' fee + structure. If `fee_type_override` and `fee_multiplier_override` are + null, that indicates the override is cleared. + tags: + - events + parameters: + - name: event_ticker + in: query + required: false + schema: + type: string + x-go-type-skip-optional-pointer: true + - $ref: '#/components/parameters/LimitQuery' + - $ref: '#/components/parameters/CursorQuery' + responses: + '200': + description: Event fee changes retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetEventFeeChangesResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '500': + $ref: '#/components/responses/InternalServerError' /events/{event_ticker}: get: operationId: GetEvent @@ -1203,6 +1236,16 @@ paths: operationId: AmendOrder summary: Amend Order description: ' Endpoint for amending the max number of fillable contracts and/or price in an existing order. Max fillable contracts is `remaining_count` + `fill_count`.' + x-mint: + content: > + + + Amending a resting order preserves queue position only when the + amendment decreases size. All other amendments — like increasing size + or changing price forfeit queue position and place the order at the + back of the queue. + + tags: - orders security: @@ -1498,6 +1541,16 @@ paths: `count` is the updated total/max fillable count, equal to already filled count plus desired resting remaining count. This behavior matches the v1 amend endpoints; only the request/response shape differs. + x-mint: + content: > + + + Amending a resting order preserves queue position only when the + amendment decreases size. All other amendments — like increasing size + or changing price forfeit queue position and place the order at the + back of the queue. + + tags: - orders security: @@ -3732,7 +3785,7 @@ paths: get: operationId: GetTradesHistorical summary: Get Historical Trades - description: ' Endpoint for getting all historical trades for all markets. Trades that were filled before the historical cutoff are available via this endpoint. See [Historical Data](https://docs.kalshi.com/getting_started/historical_data) for details.' + description: ' Endpoint for getting all historical trades for all markets. Trades that were filled before the historical cutoff are available via this endpoint. Block trades are included by default and identified by the `is_block_trade` field; use the `is_block_trade` query parameter to filter by block / non-block. See [Historical Data](https://docs.kalshi.com/getting_started/historical_data) for details.' tags: - historical parameters: @@ -3741,6 +3794,7 @@ paths: - $ref: '#/components/parameters/MaxTsQuery' - $ref: '#/components/parameters/MarketLimitQuery' - $ref: '#/components/parameters/CursorQuery' + - $ref: '#/components/parameters/IsBlockTradeQuery' responses: '200': description: Historical trades retrieved successfully @@ -3952,6 +4006,15 @@ components: schema: type: string x-go-type-skip-optional-pointer: true + IsBlockTradeQuery: + name: is_block_trade + in: query + description: > + Filter trades by whether they are block trades. Omit to return all + trades. Set to `true` to return only block trades. Set to `false` to + return only non-block trades. + schema: + type: boolean SingleEventTickerQuery: name: event_ticker in: query @@ -4176,6 +4239,17 @@ components: Identifier for an exchange shard. Defaults to 0 if unspecified. Note: currently only 0 supported. example: 0 + FeeType: + type: string + enum: + - quadratic + - quadratic_with_maker_fees + - flat + x-enum-varnames: + - FeeTypeQuadratic + - FeeTypeQuadraticWithMakerFees + - FeeTypeFlat + description: Fee type for a series or scheduled fee override. GetMarketCandlesticksHistoricalResponse: type: object required: @@ -4367,6 +4441,21 @@ components: description: >- Omit or leave empty to return all results. Use `self` to filter by the authenticated user. + ApiKeyScope: + type: string + enum: + - read + - write + - write::transfer + x-enum-varnames: + - ApiKeyScopeRead + - ApiKeyScopeWrite + - ApiKeyScopeWriteTransfer + description: >- + Scope granted to an API key. Parent scopes grant broad access; for + example, `read` grants all read endpoints and `write` grants all write + endpoints. Child scopes such as `write::transfer` grant only their + specific endpoint group and can be granted without the parent scope. ApiKey: type: object required: @@ -4382,11 +4471,9 @@ components: description: User-provided name for the API key scopes: type: array - description: >- - List of scopes granted to this API key. Valid values are 'read' and - 'write'. + description: List of scopes granted to this API key. items: - type: string + $ref: '#/components/schemas/ApiKeyScope' GetApiKeysResponse: type: object required: @@ -4414,11 +4501,12 @@ components: scopes: type: array description: >- - List of scopes to grant to the API key. Valid values are 'read' and - 'write'. If 'write' is included, 'read' must also be included. - Defaults to full access (['read', 'write']) if not provided. + List of scopes to grant to the API key. If the broad `write` parent + scope is included, `read` must also be included. Child write scopes + may be granted without the broad parent scope. Defaults to full + access (`read`, `write`) if not provided. items: - type: string + $ref: '#/components/schemas/ApiKeyScope' CreateApiKeyResponse: type: object required: @@ -4438,11 +4526,12 @@ components: scopes: type: array description: >- - List of scopes to grant to the API key. Valid values are 'read' and - 'write'. If 'write' is included, 'read' must also be included. - Defaults to full access (['read', 'write']) if not provided. + List of scopes to grant to the API key. If the broad `write` parent + scope is included, `read` must also be included. Child write scopes + may be granted without the broad parent scope. Defaults to full + access (`read`, `write`) if not provided. items: - type: string + $ref: '#/components/schemas/ApiKeyScope' GenerateApiKeyResponse: type: object required: @@ -5834,6 +5923,7 @@ components: - taker_outcome_side - taker_book_side - created_time + - is_block_trade properties: trade_id: type: string @@ -5905,6 +5995,12 @@ components: type: string format: date-time description: Timestamp when this trade was executed + is_block_trade: + type: boolean + description: >- + True if this trade was matched off-book as a block trade (e.g. via + RFQ / negotiated block proposal); false for trades that filled on + the standard order book. GetIncentiveProgramsResponse: type: object required: @@ -7121,7 +7217,6 @@ components: type: object required: - ticker - - client_order_id - side - count - price @@ -7889,6 +7984,60 @@ components: description: >- Pagination cursor for the next page. Empty if there are no more results. + EventFeeChange: + type: object + required: + - id + - event_ticker + - series_ticker + - fee_type_override + - fee_multiplier_override + - scheduled_ts + properties: + id: + type: string + description: Unique identifier for this fee change + event_ticker: + type: string + description: Event ticker this fee change applies to + series_ticker: + type: string + description: Series ticker for the event + fee_type_override: + allOf: + - $ref: '#/components/schemas/FeeType' + nullable: true + example: quadratic + description: >- + New fee type override for the event. When null, the event clears any + prior override and falls back to the parent series' fee structure. + fee_multiplier_override: + type: number + format: double + nullable: true + description: >- + New fee multiplier override for the event. When null, the event + clears any prior override and falls back to the parent series' fee + multiplier. + scheduled_ts: + type: string + format: date-time + description: Timestamp when this fee change is scheduled to take effect + GetEventFeeChangesResponse: + type: object + required: + - event_fee_changes + - cursor + properties: + event_fee_changes: + type: array + items: + $ref: '#/components/schemas/EventFeeChange' + cursor: + type: string + description: >- + Pagination cursor for the next page. Empty if there are no more + results. GetEventResponse: type: object required: @@ -8023,7 +8172,6 @@ components: - collateral_return_type - mutually_exclusive - available_on_brokers - - product_metadata properties: event_ticker: type: string @@ -8144,6 +8292,7 @@ components: description: Category specifies the category which this series belongs to. tags: type: array + nullable: true items: type: string description: >- @@ -8151,6 +8300,7 @@ components: series from different categories can have the same tags. settlement_sources: type: array + nullable: true items: $ref: '#/components/schemas/SettlementSource' description: >- @@ -8173,11 +8323,8 @@ components: x-omitempty: true description: Internal product metadata of the series. fee_type: - type: string - enum: - - quadratic - - quadratic_with_maker_fees - - flat + allOf: + - $ref: '#/components/schemas/FeeType' description: >- FeeType is a string representing the series' fee structure. Fee structures can be found at @@ -8224,11 +8371,8 @@ components: type: string description: Series ticker this fee change applies to fee_type: - type: string - enum: - - quadratic - - quadratic_with_maker_fees - - flat + allOf: + - $ref: '#/components/schemas/FeeType' description: New fee type for the series fee_multiplier: type: number diff --git a/tests/_contract_support.py b/tests/_contract_support.py index e1f8c81..5f17007 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -253,6 +253,16 @@ class Exclusion: http_method="GET", path_template="/events/{event_ticker}/metadata", ), + MethodEndpointEntry( + sdk_method="kalshi.resources.events.EventsResource.fee_changes", + http_method="GET", + path_template="/events/fee_changes", + ), + MethodEndpointEntry( + sdk_method="kalshi.resources.events.EventsResource.fee_changes_all", + http_method="GET", + path_template="/events/fee_changes", + ), # ── exchange ──────────────────────────────────────────────────────────── MethodEndpointEntry( sdk_method="kalshi.resources.exchange.ExchangeResource.status", @@ -882,6 +892,10 @@ class Exclusion: reason="paginator-handled; not a caller-facing kwarg on list_all", kind="paginator_handled", ), + ("kalshi.resources.events.EventsResource.fee_changes_all", "cursor"): Exclusion( + reason="paginator-handled; not a caller-facing kwarg on fee_changes_all", + kind="paginator_handled", + ), ("kalshi.resources.historical.HistoricalResource.markets_all", "cursor"): Exclusion( reason="paginator-handled; not a caller-facing kwarg on list_all", kind="paginator_handled", @@ -1258,17 +1272,12 @@ class Exclusion: # omits in practice. Each entry MUST cite a demo+prod observation in # `reason`. Until the spec is fixed upstream, the SDK keeps the field # optional so real-world responses parse cleanly. - ("kalshi.models.events.Event", "product_metadata"): Exclusion( - reason=( - "Spec EventData.product_metadata is required: true, but the live demo " - "server omits the key entirely on most events (e.g., Mars trip, Liverpool " - "vs Manchester United, 'Bitcoin price on Jan 12'). Observed in nightly " - "integration run #26141405845 (2026-05-20, against demo commit 788789c) " - "across test_events, test_markets, and test_series. Keep `dict | None` " - "until upstream spec or server matches." - ), - kind="server_omits_despite_required", - ), + # + # (`Event.product_metadata` lived here until v3.20.0 (#385) relaxed + # `EventData.product_metadata` to optional upstream. Spec and SDK now + # agree it is optional, so there is no deviation left to record — the + # field stays `dict | None` and `test_parses_when_server_omits_product_metadata` + # still guards the server-omission behavior.) } @@ -1284,6 +1293,7 @@ class Exclusion: "kalshi.resources.milestones.MilestonesResource.list_all", "kalshi.resources.events.EventsResource.list_all", "kalshi.resources.events.EventsResource.list_all_multivariate", + "kalshi.resources.events.EventsResource.fee_changes_all", "kalshi.resources.historical.HistoricalResource.markets_all", "kalshi.resources.historical.HistoricalResource.fills_all", "kalshi.resources.historical.HistoricalResource.orders_all", @@ -1312,6 +1322,7 @@ class Exclusion: "kalshi.resources.milestones.AsyncMilestonesResource.list_all", "kalshi.resources.events.AsyncEventsResource.list_all", "kalshi.resources.events.AsyncEventsResource.list_all_multivariate", + "kalshi.resources.events.AsyncEventsResource.fee_changes_all", "kalshi.resources.historical.AsyncHistoricalResource.markets_all", "kalshi.resources.historical.AsyncHistoricalResource.fills_all", "kalshi.resources.historical.AsyncHistoricalResource.orders_all", diff --git a/tests/_model_fixtures.py b/tests/_model_fixtures.py index 69d40ff..900b78b 100644 --- a/tests/_model_fixtures.py +++ b/tests/_model_fixtures.py @@ -130,6 +130,7 @@ def trade_dict(**overrides: Any) -> dict[str, Any]: "taker_side": "yes", "taker_book_side": "bid", "taker_outcome_side": "yes", + "is_block_trade": False, } base.update(overrides) return base @@ -162,6 +163,20 @@ def event_metadata_dict(**overrides: Any) -> dict[str, Any]: return base +def event_fee_change_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped EventFeeChange response dict (GET /events/fee_changes).""" + base: dict[str, Any] = { + "id": "efc-1", + "event_ticker": "EVT-A", + "series_ticker": "SER-A", + "fee_type_override": "quadratic", + "fee_multiplier_override": 1.5, + "scheduled_ts": "2026-01-01T00:00:00Z", + } + base.update(overrides) + return base + + def series_dict(**overrides: Any) -> dict[str, Any]: """Spec-shaped Series response dict.""" base: dict[str, Any] = { diff --git a/tests/integration/test_events.py b/tests/integration/test_events.py index e58a3de..cd8ca64 100644 --- a/tests/integration/test_events.py +++ b/tests/integration/test_events.py @@ -7,13 +7,22 @@ from kalshi.async_client import AsyncKalshiClient from kalshi.client import KalshiClient from kalshi.models.common import Page -from kalshi.models.events import Event, EventMetadata +from kalshi.models.events import Event, EventFeeChange, EventMetadata from tests.integration.assertions import assert_model_fields from tests.integration.coverage_harness import register register( "EventsResource", - ["get", "list", "list_all", "list_multivariate", "list_all_multivariate", "metadata"], + [ + "get", + "list", + "list_all", + "list_multivariate", + "list_all_multivariate", + "metadata", + "fee_changes", + "fee_changes_all", + ], ) @@ -67,6 +76,27 @@ def test_metadata(self, sync_client: KalshiClient, demo_event_ticker: str) -> No assert isinstance(meta, EventMetadata) assert_model_fields(meta) + def test_fee_changes(self, sync_client: KalshiClient) -> None: + page = sync_client.events.fee_changes(limit=5) + assert isinstance(page, Page) + assert isinstance(page.items, list) + for change in page.items: + # NB: fee_type_override / fee_multiplier_override are legitimately + # None on a cleared override, so we don't run assert_model_fields + # (its required-field oracle would flag a present-but-null value). + assert isinstance(change, EventFeeChange) + assert change.id + assert change.event_ticker + + def test_fee_changes_all(self, sync_client: KalshiClient) -> None: + count = 0 + for change in sync_client.events.fee_changes_all(limit=2, max_pages=2): + assert isinstance(change, EventFeeChange) + assert change.event_ticker + count += 1 # noqa: SIM113 + if count >= 2: + break + @pytest.mark.integration class TestEventsAsync: @@ -114,3 +144,21 @@ async def test_metadata(self, async_client: AsyncKalshiClient, demo_event_ticker meta = await async_client.events.metadata(demo_event_ticker) assert isinstance(meta, EventMetadata) assert_model_fields(meta) + + async def test_fee_changes(self, async_client: AsyncKalshiClient) -> None: + page = await async_client.events.fee_changes(limit=5) + assert isinstance(page, Page) + assert isinstance(page.items, list) + for change in page.items: + assert isinstance(change, EventFeeChange) + assert change.id + assert change.event_ticker + + async def test_fee_changes_all(self, async_client: AsyncKalshiClient) -> None: + count = 0 + async for change in async_client.events.fee_changes_all(limit=2, max_pages=2): + assert isinstance(change, EventFeeChange) + assert change.event_ticker + count += 1 + if count >= 2: + break diff --git a/tests/test_events.py b/tests/test_events.py index 8cf8ae1..248b951 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -13,7 +13,12 @@ from kalshi.config import KalshiConfig from kalshi.errors import KalshiNotFoundError from kalshi.resources.events import AsyncEventsResource, EventsResource -from tests._model_fixtures import event_dict, event_metadata_dict, market_dict +from tests._model_fixtures import ( + event_dict, + event_fee_change_dict, + event_metadata_dict, + market_dict, +) @pytest.fixture @@ -227,6 +232,75 @@ def test_not_found(self, events: EventsResource) -> None: events.metadata("FAKE") +class TestEventsFeeChanges: + @respx.mock + def test_returns_page_of_fee_changes(self, events: EventsResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/events/fee_changes").mock( + return_value=httpx.Response( + 200, + json={ + "event_fee_changes": [ + event_fee_change_dict(id="efc-1", event_ticker="EVT-A"), + event_fee_change_dict( + id="efc-2", + event_ticker="EVT-B", + fee_type_override=None, + fee_multiplier_override=None, + ), + ], + "cursor": "page2", + }, + ) + ) + page = events.fee_changes() + assert len(page) == 2 + assert page.items[0].id == "efc-1" + assert page.items[0].fee_type_override == "quadratic" + assert page.items[0].fee_multiplier_override == Decimal("1.5") + # Cleared override: both fields are present-but-null. + assert page.items[1].fee_type_override is None + assert page.items[1].fee_multiplier_override is None + assert page.has_next is True + + @respx.mock + def test_passes_filters(self, events: EventsResource) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/events/fee_changes").mock( + return_value=httpx.Response(200, json={"event_fee_changes": [], "cursor": ""}) + ) + events.fee_changes(event_ticker="EVT-A", limit=50, cursor="abc") + params = dict(route.calls[0].request.url.params) + assert params["event_ticker"] == "EVT-A" + assert params["limit"] == "50" + assert params["cursor"] == "abc" + + def test_limit_out_of_range_raises(self, events: EventsResource) -> None: + with pytest.raises(ValueError, match=r"limit must be in \[1, 1000\]"): + events.fee_changes(limit=1001) + + @respx.mock + def test_fee_changes_all_paginates(self, events: EventsResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/events/fee_changes").mock( + side_effect=[ + httpx.Response( + 200, + json={ + "event_fee_changes": [event_fee_change_dict(id="efc-1")], + "cursor": "page2", + }, + ), + httpx.Response( + 200, + json={ + "event_fee_changes": [event_fee_change_dict(id="efc-2")], + "cursor": "", + }, + ), + ] + ) + ids = [fc.id for fc in events.fee_changes_all()] + assert ids == ["efc-1", "efc-2"] + + class TestEventsListMultivariate: @respx.mock def test_list_multivariate(self, events: EventsResource) -> None: @@ -416,6 +490,42 @@ async def test_list_all_multivariate(self, async_events: AsyncEventsResource) -> assert tickers == ["A", "B"] +class TestAsyncEventsFeeChanges: + @respx.mock + @pytest.mark.asyncio + async def test_returns_page(self, async_events: AsyncEventsResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/events/fee_changes").mock( + return_value=httpx.Response( + 200, + json={ + "event_fee_changes": [event_fee_change_dict(id="efc-1")], + "cursor": "", + }, + ) + ) + page = await async_events.fee_changes(event_ticker="EVT-A") + assert len(page.items) == 1 + assert page.items[0].fee_multiplier_override == Decimal("1.5") + + @respx.mock + @pytest.mark.asyncio + async def test_fee_changes_all_paginates(self, async_events: AsyncEventsResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/events/fee_changes").mock( + side_effect=[ + httpx.Response( + 200, + json={"event_fee_changes": [event_fee_change_dict(id="efc-1")], "cursor": "p2"}, + ), + httpx.Response( + 200, + json={"event_fee_changes": [event_fee_change_dict(id="efc-2")], "cursor": ""}, + ), + ] + ) + ids = [fc.id async for fc in async_events.fee_changes_all()] + assert ids == ["efc-1", "efc-2"] + + class TestBoolParamSerialization: """Regression: issue #91 F-N-08. diff --git a/tests/test_markets.py b/tests/test_markets.py index 81d8365..b0190f2 100644 --- a/tests/test_markets.py +++ b/tests/test_markets.py @@ -536,6 +536,20 @@ def test_issue_349_list_trades_all_emits_deprecation_warning( items = list(markets.list_trades_all(limit=1)) assert [t.trade_id for t in items] == ["t-1"] + @respx.mock + def test_is_block_trade_filter_serializes(self, markets: MarketsResource) -> None: + """v3.20.0 (#385): is_block_trade query param. ``False`` must be sent + explicitly (not dropped) so callers can request only non-block trades.""" + route = respx.get("https://test.kalshi.com/trade-api/v2/markets/trades").mock( + return_value=httpx.Response(200, json={"trades": [], "cursor": ""}), + ) + markets.list_trades(is_block_trade=False) + assert route.calls[0].request.url.params["is_block_trade"] == "false" + markets.list_trades(is_block_trade=True) + assert route.calls[1].request.url.params["is_block_trade"] == "true" + markets.list_trades() + assert "is_block_trade" not in route.calls[2].request.url.params + class TestMarketsBulkCandlesticks: @respx.mock diff --git a/tests/test_models.py b/tests/test_models.py index 2904444..e8fd146 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -513,6 +513,21 @@ def test_taker_outcome_side_missing_raises(self) -> None: with pytest.raises(ValidationError): Trade.model_validate(data) + def test_parses_is_block_trade(self) -> None: + """v3.20.0 (#385): required ``is_block_trade`` bool on ``Trade``.""" + assert Trade.model_validate(trade_dict(is_block_trade=True)).is_block_trade is True + assert Trade.model_validate(trade_dict()).is_block_trade is False + + def test_is_block_trade_missing_raises(self) -> None: + """Spec marks ``is_block_trade`` required + non-nullable; a missing key + must raise so a schema regression surfaces, not silently default.""" + from pydantic import ValidationError + + data = trade_dict() + data.pop("is_block_trade") + with pytest.raises(ValidationError): + Trade.model_validate(data) + class TestIncentiveProgramV3180Fields: """v3.18.0 backfill (issue #160): 1 new optional field on ``IncentiveProgram``.""" diff --git a/tests/ws/test_dispatch.py b/tests/ws/test_dispatch.py index 6239b50..d6851ab 100644 --- a/tests/ws/test_dispatch.py +++ b/tests/ws/test_dispatch.py @@ -11,6 +11,7 @@ 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.event_fee import EventFeeUpdateMessage from kalshi.ws.models.market_positions import MarketPositionsMessage from kalshi.ws.models.multivariate import MultivariateMessage from kalshi.ws.models.user_orders import UserOrdersMessage @@ -121,6 +122,51 @@ async def test_dispatch_unknown_type_no_crash(self) -> None: raw = json.dumps({"type": "future_type", "sid": 1, "msg": {}}) await dispatcher.dispatch(json.loads(raw)) # should not crash + async def test_dispatch_event_fee_update(self) -> None: + """v3.20.0 (#385): event_fee_update rides the market_lifecycle_v2 channel.""" + mgr = FakeSubManager() + sub = mgr.add(5, "market_lifecycle_v2") + dispatcher = MessageDispatcher(sub_mgr=mgr) # type: ignore[arg-type] + raw = json.dumps( + { + "type": "event_fee_update", + "sid": 5, + "msg": { + "event_ticker": "KXBTCD-26MAY2018", + "fee_type_override": "quadratic", + "fee_multiplier_override": 1, + }, + } + ) + await dispatcher.dispatch(json.loads(raw)) + msg = await sub.queue.get() + assert isinstance(msg, EventFeeUpdateMessage) + assert msg.type == "event_fee_update" + assert msg.msg.event_ticker == "KXBTCD-26MAY2018" + assert msg.msg.fee_type_override == "quadratic" + + async def test_dispatch_event_fee_update_cleared(self) -> None: + """Override cleared: both override fields arrive present-but-null.""" + mgr = FakeSubManager() + sub = mgr.add(5, "market_lifecycle_v2") + dispatcher = MessageDispatcher(sub_mgr=mgr) # type: ignore[arg-type] + raw = json.dumps( + { + "type": "event_fee_update", + "sid": 5, + "msg": { + "event_ticker": "KXBTCD-26MAY2018", + "fee_type_override": None, + "fee_multiplier_override": None, + }, + } + ) + await dispatcher.dispatch(json.loads(raw)) + msg = await sub.queue.get() + assert isinstance(msg, EventFeeUpdateMessage) + assert msg.msg.fee_type_override is None + assert msg.msg.fee_multiplier_override is None + async def test_dispatch_control_message_skipped(self) -> None: mgr = FakeSubManager() sub = mgr.add(1, "ticker") @@ -343,6 +389,7 @@ async def test_all_channel_types_have_models(self) -> None: "user_order", "order_group_updates", "market_lifecycle_v2", + "event_fee_update", "multivariate_lookup", "multivariate_market_lifecycle", "communications",