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

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

## Unreleased

Required-but-optional drift closure (#172). Drops `None` defaults on 226
spec-required Pydantic model fields across 34 response models (21 REST, 13
WS). The SDK now matches the OpenAPI v3.18.0 / AsyncAPI v0.14 `required` set
on the wire. Promotes `test_required_drift` and `test_ws_required_drift`
from warning to hard CI failure, closing the regression class that allowed
required-but-typed-Optional fields to drift unnoticed.

### Breaking (response-parse side)

- **226 fields are no longer `Optional[T] | None` in response models** —
see the full list per model in #172. Wire format is unchanged; the SDK
now refuses to parse responses that omit a spec-required field, where
previously the field defaulted to `None`. If the live server omits a
spec-required field, `pydantic.ValidationError` is raised on parse.
- **`CreateOrderRequest.action` no longer defaults to `"buy"`** — callers
constructing the request model directly must pass `action` explicitly.
The `OrdersResource.create(action=None, ...)` kwarg path still defaults
to `"buy"` for back-compat; only the model-construction surface changed.
- **Test fixtures constructing these models with partial dicts will
raise `ValidationError`.** A new helper module `tests/_model_fixtures`
provides complete spec-shaped builders (`market_dict`, `order_dict`,
`fill_dict`, etc.) that accept `**overrides` for fields tests care about.

### Changed

- `test_required_drift` (REST) and `test_ws_required_drift` (WS) promoted
from `warnings.warn` to `pytest.fail`. Future drift on these gates is
CI-blocking.
- New `ExclusionKind` value `"server_omits_despite_required"` registered
in `tests/_contract_support.py` for fields the spec marks required but
the live server omits. Entries MUST cite a demo+prod observation.

### Migration

- Code that builds these models from server responses: no change. The
server-side wire shape is what it always was — the SDK type just stopped
lying about which fields are guaranteed.
- Code that builds these models in tests / mocks / fixtures: pass all
spec-required fields, or use the `tests/_model_fixtures` builders. The
builders are test-only (live under `tests/`, never shipped in the
wheel) — production code does not import them.
- Callers who relied on `Optional` narrowing (`if order.outcome_side is
not None: ...`) can drop the guard. `mypy --strict` will now flag the
redundant check.

### Affected models

21 REST (136 fields): `Market`, `Order`, `Fill`, `MultivariateEventCollection`,
`Settlement`, `Trade`, `Event`, `Series`, `MarketPosition`, `EventPosition`,
`EventMetadata`, `Milestone`, `SportFilterDetails`, `IncentiveProgram`,
`ApiKey`, `SeriesFeeChange`, `MarketCandlesticks`, `ScopeList`,
`GetOrderGroupResponse`, `CreateOrderGroupResponse`, `CreateOrderRequest`.

13 WS payloads (90 fields): `UserOrdersPayload`, `FillPayload`,
`TickerPayload`, `TradePayload`, `MarketPositionsPayload`,
`QuoteExecutedPayload`, `QuoteCreatedPayload`, `QuoteAcceptedPayload`,
`MultivariatePayload`, `RfqCreatedPayload`, `RfqDeletedPayload`,
`MarketLifecyclePayload`, `OrderGroupPayload`.

## 2.2.0 — 2026-05-19

Response-side spec drift hardening stack (#157). Backfills the remaining
Expand Down
2 changes: 1 addition & 1 deletion kalshi/models/api_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class ApiKey(BaseModel):

api_key_id: str
name: str
scopes: NullableList[str] = []
scopes: NullableList[str]

model_config = {"extra": "allow"}

Expand Down
20 changes: 10 additions & 10 deletions kalshi/models/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,16 @@ class Event(BaseModel):
"""A Kalshi event (container for one or more markets)."""

event_ticker: str
series_ticker: str | None = None
title: str | None = None
sub_title: str | None = None
collateral_return_type: str | None = None
mutually_exclusive: bool | None = None
series_ticker: str
title: str
sub_title: str
collateral_return_type: str
mutually_exclusive: bool
category: str | None = None
strike_date: datetime | None = None
strike_period: str | None = None
available_on_brokers: bool | None = None
product_metadata: dict[str, Any] | None = None
available_on_brokers: bool
product_metadata: dict[str, Any]
last_updated_ts: datetime | None = None
markets: NullableList[Market] = []

Expand Down Expand Up @@ -64,10 +64,10 @@ class SettlementSource(BaseModel):
class EventMetadata(BaseModel):
"""Metadata for an event including images and settlement sources."""

image_url: str | None = None
image_url: str
featured_image_url: str | None = None
market_details: list[MarketMetadata] | None = None
settlement_sources: list[SettlementSource] | None = None
market_details: list[MarketMetadata]
settlement_sources: list[SettlementSource]

# v3.18.0 backfill (#160).
competition: str | None = None
Expand Down
13 changes: 5 additions & 8 deletions kalshi/models/historical.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,25 +30,22 @@ class Trade(BaseModel):
"""A public trade on the exchange."""

trade_id: str
ticker: str | None = None
ticker: str
count: FixedPointCount | None = Field(
default=None,
validation_alias=AliasChoices("count_fp", "count"),
)
yes_price: DollarDecimal | None = Field(
default=None,
validation_alias=AliasChoices("yes_price_dollars", "yes_price"),
)
no_price: DollarDecimal | None = Field(
default=None,
validation_alias=AliasChoices("no_price_dollars", "no_price"),
)
taker_side: str | None = None
created_time: datetime | None = None
taker_side: str
created_time: datetime

# v3.18.0 backfill (#160). Mirrors Order.outcome_side / book_side from #159
# — canonical direction encoding superseding the deprecated `taker_side`.
taker_outcome_side: SideLiteral | None = None
taker_book_side: BookSideLiteral | None = None
taker_outcome_side: SideLiteral
taker_book_side: BookSideLiteral

model_config = {"extra": "allow", "populate_by_name": True}
2 changes: 1 addition & 1 deletion kalshi/models/incentive_programs.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class IncentiveProgram(BaseModel):
target_size_fp: FixedPointCount | None = None

# v3.18.0 backfill (#160).
incentive_description: str | None = None
incentive_description: str

model_config = {"extra": "allow"}

Expand Down
85 changes: 35 additions & 50 deletions kalshi/models/markets.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,67 +26,55 @@ class Market(BaseModel):
"""

ticker: str
event_ticker: str | None = None
market_type: str | None = None
event_ticker: str
market_type: str
title: str | None = None
subtitle: str | None = None
yes_sub_title: str | None = None
no_sub_title: str | None = None
status: str | None = None
yes_sub_title: str
no_sub_title: str
status: str

# Price fields (FixedPointDollars)
yes_bid: DollarDecimal | None = Field(
default=None,
yes_bid: DollarDecimal = Field(
validation_alias=AliasChoices("yes_bid_dollars", "yes_bid"),
)
yes_ask: DollarDecimal | None = Field(
default=None,
yes_ask: DollarDecimal = Field(
validation_alias=AliasChoices("yes_ask_dollars", "yes_ask"),
)
no_bid: DollarDecimal | None = Field(
default=None,
no_bid: DollarDecimal = Field(
validation_alias=AliasChoices("no_bid_dollars", "no_bid"),
)
no_ask: DollarDecimal | None = Field(
default=None,
no_ask: DollarDecimal = Field(
validation_alias=AliasChoices("no_ask_dollars", "no_ask"),
)
last_price: DollarDecimal | None = Field(
default=None,
last_price: DollarDecimal = Field(
validation_alias=AliasChoices("last_price_dollars", "last_price"),
)
previous_yes_bid: DollarDecimal | None = Field(
default=None,
previous_yes_bid: DollarDecimal = Field(
validation_alias=AliasChoices("previous_yes_bid_dollars", "previous_yes_bid"),
)
previous_yes_ask: DollarDecimal | None = Field(
default=None,
previous_yes_ask: DollarDecimal = Field(
validation_alias=AliasChoices("previous_yes_ask_dollars", "previous_yes_ask"),
)
previous_price: DollarDecimal | None = Field(
default=None,
previous_price: DollarDecimal = Field(
validation_alias=AliasChoices("previous_price_dollars", "previous_price"),
)
notional_value: DollarDecimal | None = Field(
default=None,
notional_value: DollarDecimal = Field(
validation_alias=AliasChoices("notional_value_dollars", "notional_value"),
)
settlement_value: DollarDecimal | None = Field(
default=None,
validation_alias=AliasChoices("settlement_value_dollars", "settlement_value"),
)
liquidity: DollarDecimal | None = Field(
default=None,
liquidity: DollarDecimal = Field(
validation_alias=AliasChoices("liquidity_dollars", "liquidity"),
)

# Size/volume fields (FixedPointCount)
yes_bid_size: FixedPointCount | None = Field(
default=None,
yes_bid_size: FixedPointCount = Field(
validation_alias=AliasChoices("yes_bid_size_fp", "yes_bid_size"),
)
yes_ask_size: FixedPointCount | None = Field(
default=None,
yes_ask_size: FixedPointCount = Field(
validation_alias=AliasChoices("yes_ask_size_fp", "yes_ask_size"),
)
no_bid_size: FixedPointCount | None = Field(
Expand All @@ -97,43 +85,40 @@ class Market(BaseModel):
default=None,
validation_alias=AliasChoices("no_ask_size_fp", "no_ask_size"),
)
volume: FixedPointCount | None = Field(
default=None,
volume: FixedPointCount = Field(
validation_alias=AliasChoices("volume_fp", "volume"),
)
volume_24h: FixedPointCount | None = Field(
default=None,
volume_24h: FixedPointCount = Field(
validation_alias=AliasChoices("volume_24h_fp", "volume_24h"),
)
open_interest: FixedPointCount | None = Field(
default=None,
open_interest: FixedPointCount = Field(
validation_alias=AliasChoices("open_interest_fp", "open_interest"),
)

# Timestamps
created_time: datetime | None = None
updated_time: datetime | None = None
open_time: datetime | None = None
close_time: datetime | None = None
latest_expiration_time: datetime | None = None
created_time: datetime
updated_time: datetime
open_time: datetime
close_time: datetime
latest_expiration_time: datetime
expected_expiration_time: datetime | None = None
expiration_time: datetime | None = None
settlement_ts: datetime | None = None
occurrence_datetime: datetime | None = None

# Metadata
settlement_timer_seconds: int | None = None
result: str | None = None
can_close_early: bool | None = None
fractional_trading_enabled: bool | None = None
expiration_value: str | None = None
settlement_timer_seconds: int
result: str
can_close_early: bool
fractional_trading_enabled: bool
expiration_value: str
category: str | None = None
risk_limit_cents: int | None = None
strike_type: str | None = None
floor_strike: Decimal | None = None
cap_strike: Decimal | None = None
rules_primary: str | None = None
rules_secondary: str | None = None
rules_primary: str
rules_secondary: str

# v3.18.0 backfill (#159). mve_selected_legs is list[MveSelectedLeg] and
# price_ranges is list[PriceRange] on the wire — kept as list[dict] here;
Expand All @@ -146,8 +131,8 @@ class Market(BaseModel):
is_provisional: bool | None = None
mve_collection_ticker: str | None = None
mve_selected_legs: list[dict[str, Any]] | None = None
price_level_structure: str | None = None
price_ranges: list[dict[str, Any]] | None = None
price_level_structure: str
price_ranges: NullableList[dict[str, Any]]
primary_participant_key: str | None = None

model_config = {"extra": "allow", "populate_by_name": True}
Expand Down Expand Up @@ -263,6 +248,6 @@ class MarketCandlesticks(BaseModel):
# NullableList: Kalshi has returned JSON null for required list fields
# in other envelopes (v0.9.0 Series fix). Coerce None -> [] to match the
# pattern used on Orderbook.yes/no and envelope-level list fields.
candlesticks: NullableList[Candlestick] = []
candlesticks: NullableList[Candlestick]

model_config = {"extra": "allow"}
6 changes: 3 additions & 3 deletions kalshi/models/milestones.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@ class Milestone(BaseModel):
# Spec marks these required but Kalshi has historically returned JSON null
# for required list fields (see v0.9.0 fix for Series.tags). NullableList
# coerces None -> [] so demo/prod inconsistencies don't break parsing.
related_event_tickers: NullableList[str] = []
related_event_tickers: NullableList[str]
title: str
notification_message: str
source_id: str | None = None
source_ids: dict[str, str] | None = None
details: dict[str, Any] = {}
primary_event_tickers: NullableList[str] = []
details: dict[str, Any]
primary_event_tickers: NullableList[str]
last_updated_ts: datetime

model_config = {"extra": "allow"}
Expand Down
26 changes: 13 additions & 13 deletions kalshi/models/multivariate.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,21 @@ class MultivariateEventCollection(BaseModel):
"""A multivariate event collection (combo contract template)."""

collection_ticker: str
series_ticker: str = ""
title: str = ""
description: str = ""
open_date: datetime | None = None
close_date: datetime | None = None
associated_events: NullableList[AssociatedEvent] = []
series_ticker: str
title: str
description: str
open_date: datetime
close_date: datetime
associated_events: NullableList[AssociatedEvent]
# Deprecated fields — still returned by API
associated_event_tickers: NullableList[str] = []
is_single_market_per_event: bool = False
is_all_yes: bool = False
associated_event_tickers: NullableList[str]
is_single_market_per_event: bool
is_all_yes: bool
# Active fields
is_ordered: bool = False
size_min: int = 0
size_max: int = 0
functional_description: str = ""
is_ordered: bool
size_min: int
size_max: int
functional_description: str

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

Expand Down
4 changes: 2 additions & 2 deletions kalshi/models/order_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class GetOrderGroupResponse(BaseModel):
"""Single-group response — omits id (path param), adds member order IDs."""

is_auto_cancel_enabled: bool
orders: NullableList[str] = []
orders: NullableList[str]
contracts_limit: FixedPointCount | None = Field(
default=None,
validation_alias=AliasChoices("contracts_limit_fp", "contracts_limit"),
Expand All @@ -46,7 +46,7 @@ class CreateOrderGroupResponse(BaseModel):

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

model_config = {"extra": "allow"}
Expand Down
Loading
Loading