Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,39 @@

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

## Unreleased

Response-side spec drift hardening stack (#157). Backfills the remaining
v3.18.0 / v0.14 fields and promotes additive drift to a hard CI failure.

### Added

- `Market` gains 11 spec fields, `Order` gains 8, `Fill` gains 4 (#159).
- `Event` gains 3, `EventMetadata` 2, `Settlement` 1, `Trade` 2,
`IncentiveProgram` 1 (#160).
- `RFQ` gains 1, `Quote` 3, `OrderGroup` 1, `GetOrderGroupResponse` 1,
`CreateOrderGroupResponse` 2 (#161).
- 33 fields across 11 WebSocket payload models (#162) — Unix-ms
timestamps (`*_ts_ms`), `outcome_side` / `book_side` direction
encoding, MVE linkage, and RFQ/Quote context echoes.
- `ErrorPayload` registered in `WS_CONTRACT_MAP` so WS contract coverage
is complete.

### Changed

- Response-side additive spec drift now **hard-fails CI** (was a
warning). Closes the regression path that allowed
`Balance.balance_dollars` to ship missing in v2.1.0 across five
rounds of review. Intentional deviations require an entry in
`EXCLUSIONS` (`tests/_contract_support.py`) with a typed `kind` and
`reason`.
- Unmapped SDK models (REST + WS) also now hard-fail — same regression
surface.
- WS envelope and helper models now use `extra="allow"`, matching the
WS payload policy from #143. Closes the matching ROADMAP item.
- Required-but-Optional drift stays as warning-only (~204 entries;
separate policy decision).

## 2.1.0 — 2026-05-18

OpenAPI spec sync from v3.13.0 → v3.18.0. Adds the V2 event-market
Expand Down
10 changes: 9 additions & 1 deletion kalshi/_contract_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,8 +353,16 @@ class ContractEntry:
sdk_model="kalshi.ws.models.communications.QuoteExecutedPayload",
spec_schema="quoteExecutedPayload",
),
ContractEntry(
sdk_model="kalshi.ws.models.base.ErrorPayload",
spec_schema="errorResponsePayload",
notes=(
"WS error-frame payload surfaced via KalshiSubscriptionError. "
"Spec name is `errorResponsePayload`; SDK class is `ErrorPayload`."
),
),
# Intentionally excluded:
# - eventLifecyclePayload: SDK reuses MarketLifecyclePayload for both channels
# - multivariateMarketLifecyclePayload: allOf with marketLifecycleV2Payload, covered by base
# - Control messages (ErrorPayload, SubscriptionInfo, OkMessage): structural, low drift risk
# - Control envelopes (SubscriptionInfo, OkMessage): structural, low drift risk
]
9 changes: 9 additions & 0 deletions kalshi/models/communications.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ class RFQ(BaseModel):
cancelled_ts: datetime | None = None
updated_ts: datetime | None = None

# v3.18.0 backfill (#161).
creator_subaccount: int | None = None

model_config = {"extra": "allow", "populate_by_name": True}


Expand Down Expand Up @@ -99,6 +102,12 @@ class Quote(BaseModel):
validation_alias=AliasChoices("no_contracts_fp", "no_contracts"),
)

# v3.18.0 backfill (#161). post_only mirrors CreateQuoteRequest.post_only —
# server echoes the value on Quote when the caller is the creator.
creator_subaccount: int | None = None
rfq_creator_subaccount: int | None = None
post_only: bool | None = None

model_config = {"extra": "allow", "populate_by_name": True}


Expand Down
11 changes: 11 additions & 0 deletions kalshi/models/order_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ class OrderGroup(BaseModel):
)
is_auto_cancel_enabled: bool

# v3.18.0 backfill (#161). Mirrors exchange_index on Market/Order/Event.
exchange_index: int | None = None

model_config = {"extra": "allow", "populate_by_name": True}


Expand All @@ -30,6 +33,9 @@ class GetOrderGroupResponse(BaseModel):
validation_alias=AliasChoices("contracts_limit_fp", "contracts_limit"),
)

# v3.18.0 backfill (#161).
exchange_index: int | None = None

model_config = {"extra": "allow", "populate_by_name": True}


Expand All @@ -38,6 +44,11 @@ class CreateOrderGroupResponse(BaseModel):

order_group_id: str

# v3.18.0 backfill (#161). Server echoes the routing context (subaccount +
# exchange shard) on the create response.
subaccount: int | None = None
exchange_index: int | None = None

model_config = {"extra": "allow"}


Expand Down
5 changes: 5 additions & 0 deletions kalshi/ws/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class SubscriptionInfo(BaseModel):
"""Subscription confirmation payload."""
channel: str
sid: int
model_config = {"extra": "allow"}


class ErrorPayload(BaseModel):
Expand All @@ -16,6 +17,7 @@ class ErrorPayload(BaseModel):
msg: str
market_ticker: str | None = None
market_id: str | None = None
model_config = {"extra": "allow"}


class BaseMessage(BaseModel):
Expand All @@ -32,6 +34,7 @@ class SubscribedMessage(BaseModel):
id: int = 0
type: str = "subscribed"
msg: SubscriptionInfo
model_config = {"extra": "allow"}


class UnsubscribedMessage(BaseModel):
Expand All @@ -40,6 +43,7 @@ class UnsubscribedMessage(BaseModel):
sid: int
seq: int
type: str = "unsubscribed"
model_config = {"extra": "allow"}


class OkMessage(BaseModel):
Expand All @@ -57,3 +61,4 @@ class ErrorMessage(BaseModel):
id: int = 0
type: str = "error"
msg: ErrorPayload
model_config = {"extra": "allow"}
52 changes: 51 additions & 1 deletion kalshi/ws/models/communications.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from pydantic import AliasChoices, BaseModel, Field

from kalshi.types import DollarDecimal
from kalshi.types import DollarDecimal, FixedPointCount


class RfqCreatedPayload(BaseModel):
Expand Down Expand Up @@ -35,6 +35,12 @@ class RfqCreatedPayload(BaseModel):
default=None,
validation_alias=AliasChoices("target_cost_dollars", "target_cost"),
)

# v0.14+ backfill (#162). MVE linkage fields when the RFQ targets a
# multivariate event collection. Element shape: object with
# event_ticker/market_ticker/side/yes_settlement_value_dollars.
mve_collection_ticker: str | None = None
mve_selected_legs: list[dict[str, object]] | None = None
model_config = {"extra": "allow"}


Expand All @@ -49,6 +55,18 @@ class RfqDeletedPayload(BaseModel):
creator_id: str | None = None
market_ticker: str | None = None
deleted_ts: str | None = None

# v0.14+ backfill (#162). Same RFQ context as RfqCreatedPayload —
# surfaced again on delete for clients that subscribed mid-RFQ.
event_ticker: str | None = None
contracts: FixedPointCount | None = Field(
default=None,
validation_alias=AliasChoices("contracts_fp", "contracts"),
)
target_cost: DollarDecimal | None = Field(
default=None,
validation_alias=AliasChoices("target_cost_dollars", "target_cost"),
)
model_config = {"extra": "allow"}


Expand All @@ -68,6 +86,22 @@ class QuoteCreatedPayload(BaseModel):
validation_alias=AliasChoices("no_bid_dollars", "no_bid"),
)
created_ts: str | None = None

# v0.14+ backfill (#162). Event linkage + RFQ offer/cost context echoed
# on the quote so subscribers don't need to look up the parent RFQ.
event_ticker: str | None = None
yes_contracts_offered: FixedPointCount | None = Field(
default=None,
validation_alias=AliasChoices("yes_contracts_offered_fp", "yes_contracts_offered"),
)
no_contracts_offered: FixedPointCount | None = Field(
default=None,
validation_alias=AliasChoices("no_contracts_offered_fp", "no_contracts_offered"),
)
rfq_target_cost: DollarDecimal | None = Field(
default=None,
validation_alias=AliasChoices("rfq_target_cost_dollars", "rfq_target_cost"),
)
model_config = {"extra": "allow"}


Expand All @@ -91,6 +125,22 @@ class QuoteAcceptedPayload(BaseModel):
default=None,
validation_alias=AliasChoices("contracts_accepted_fp", "contracts_accepted"),
) # _fp format

# v0.14+ backfill (#162). Mirrors QuoteCreatedPayload — same RFQ context
# echoed on accept.
event_ticker: str | None = None
yes_contracts_offered: FixedPointCount | None = Field(
default=None,
validation_alias=AliasChoices("yes_contracts_offered_fp", "yes_contracts_offered"),
)
no_contracts_offered: FixedPointCount | None = Field(
default=None,
validation_alias=AliasChoices("no_contracts_offered_fp", "no_contracts_offered"),
)
rfq_target_cost: DollarDecimal | None = Field(
default=None,
validation_alias=AliasChoices("rfq_target_cost_dollars", "rfq_target_cost"),
)
model_config = {"extra": "allow"}


Expand Down
7 changes: 7 additions & 0 deletions kalshi/ws/models/fill.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from pydantic import AliasChoices, BaseModel, Field

from kalshi.models.orders import BookSideLiteral, SideLiteral
from kalshi.types import DollarDecimal


Expand Down Expand Up @@ -38,6 +39,11 @@ class FillPayload(BaseModel):
purchased_side: str | None = None
client_order_id: str | None = None
subaccount: int | None = None
# v0.14+ backfill (#162). outcome_side/book_side are the canonical
# direction encoding; ts_ms supersedes ts. action/side stay for compat.
outcome_side: SideLiteral | None = None
book_side: BookSideLiteral | None = None
ts_ms: int | None = None
model_config = {"extra": "allow"}


Expand All @@ -48,3 +54,4 @@ class FillMessage(BaseModel):
sid: int
seq: int | None = None
msg: FillPayload
model_config = {"extra": "allow"}
12 changes: 12 additions & 0 deletions kalshi/ws/models/market_lifecycle.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""Market lifecycle v2 channel message models."""
from __future__ import annotations

from decimal import Decimal
from typing import Any

from pydantic import BaseModel

from kalshi.types import DollarDecimal
Expand Down Expand Up @@ -31,6 +34,14 @@ class MarketLifecyclePayload(BaseModel):
title: str | None = None
subtitle: str | None = None
series_ticker: str | None = None

# v0.14+ backfill (#162). additional_metadata is emitted for `created`
# events; floor_strike/yes_sub_title for `metadata_updated`;
# price_level_structure for `price_level_structure_updated` (or `created`).
additional_metadata: dict[str, Any] | None = None
floor_strike: Decimal | None = None
price_level_structure: str | None = None
yes_sub_title: str | None = None
model_config = {"extra": "allow"}


Expand All @@ -41,3 +52,4 @@ class MarketLifecycleMessage(BaseModel):
sid: int
seq: int | None = None
msg: MarketLifecyclePayload
model_config = {"extra": "allow"}
1 change: 1 addition & 0 deletions kalshi/ws/models/market_positions.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ class MarketPositionsMessage(BaseModel):
sid: int
seq: int | None = None
msg: MarketPositionsPayload
model_config = {"extra": "allow"}
3 changes: 3 additions & 0 deletions kalshi/ws/models/multivariate.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class SelectedMarket(BaseModel):
event_ticker: str | None = None
market_ticker: str | None = None
side: str | None = None
model_config = {"extra": "allow"}


class MultivariatePayload(BaseModel):
Expand All @@ -31,6 +32,7 @@ class MultivariateMessage(BaseModel):
sid: int
seq: int | None = None
msg: MultivariatePayload
model_config = {"extra": "allow"}


class MultivariateLifecycleMessage(BaseModel):
Expand All @@ -40,3 +42,4 @@ class MultivariateLifecycleMessage(BaseModel):
sid: int
seq: int | None = None
msg: MarketLifecyclePayload
model_config = {"extra": "allow"}
3 changes: 3 additions & 0 deletions kalshi/ws/models/order_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class OrderGroupPayload(BaseModel):
default=None,
validation_alias=AliasChoices("contracts_limit_fp", "contracts_limit"),
) # _fp format
# v0.14+ backfill (#162). Matching engine timestamp in Unix ms.
ts_ms: int | None = None
model_config = {"extra": "allow"}


Expand All @@ -23,3 +25,4 @@ class OrderGroupMessage(BaseModel):
sid: int
seq: int # Required — one of few channels with required seq
msg: OrderGroupPayload
model_config = {"extra": "allow"}
4 changes: 4 additions & 0 deletions kalshi/ws/models/orderbook_delta.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ class OrderbookDeltaPayload(BaseModel):
client_order_id: str | None = None
subaccount: int | None = None
ts: str | None = None
# v0.14+ backfill (#162). ts_ms (Unix ms) supersedes ts (RFC3339).
ts_ms: int | None = None
model_config = {"extra": "allow"}


Expand All @@ -58,6 +60,7 @@ class OrderbookSnapshotMessage(BaseModel):
sid: int
seq: int
msg: OrderbookSnapshotPayload
model_config = {"extra": "allow"}


class OrderbookDeltaMessage(BaseModel):
Expand All @@ -66,3 +69,4 @@ class OrderbookDeltaMessage(BaseModel):
sid: int
seq: int
msg: OrderbookDeltaPayload
model_config = {"extra": "allow"}
8 changes: 8 additions & 0 deletions kalshi/ws/models/ticker.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ class TickerPayload(BaseModel):
validation_alias=AliasChoices("last_trade_size_fp", "last_trade_size"),
) # _fp format
ts: int | None = None
# v0.14+ backfill (#162). Spec promotes ts_ms (Unix ms) as the primary
# timestamp; ts (seconds) stays for compat. Do NOT auto-convert.
price: DollarDecimal | None = Field(
default=None,
validation_alias=AliasChoices("price_dollars", "price"),
)
ts_ms: int | None = None
model_config = {"extra": "allow"}


Expand All @@ -65,3 +72,4 @@ class TickerMessage(BaseModel):
sid: int
seq: int | None = None
msg: TickerPayload
model_config = {"extra": "allow"}
7 changes: 7 additions & 0 deletions kalshi/ws/models/trade.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from pydantic import AliasChoices, BaseModel, Field

from kalshi.models.orders import BookSideLiteral, SideLiteral
from kalshi.types import DollarDecimal


Expand Down Expand Up @@ -30,6 +31,11 @@ class TradePayload(BaseModel):
) # _fp format
taker_side: str | None = None
ts: int | None = None
# v0.14+ backfill (#162). taker_outcome_side/taker_book_side are the
# canonical direction encoding; ts_ms supersedes ts. taker_side stays.
taker_outcome_side: SideLiteral | None = None
taker_book_side: BookSideLiteral | None = None
ts_ms: int | None = None
model_config = {"extra": "allow"}


Expand All @@ -40,3 +46,4 @@ class TradeMessage(BaseModel):
sid: int
seq: int | None = None
msg: TradePayload
model_config = {"extra": "allow"}
Loading
Loading