Skip to content

Commit ff207d0

Browse files
authored
fix(models): tighten 226 spec-required fields to non-optional (#180)
Closes #172. Drops `None` defaults on 226 spec-required Pydantic 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` (REST) and `test_ws_required_drift` (WS) from `warnings.warn` to `pytest.fail`. Adds `ExclusionKind="server_omits_despite_required"` as the documented escape hatch for fields the live server omits despite spec. Ships `tests/_model_fixtures.py` with 24 builders so test fixtures stay manageable. Soft-breaking at the response-parse boundary: server omission of a spec-required field now raises `pydantic.ValidationError` instead of silently producing `field=None`. Wire format is unchanged.
1 parent 2319fee commit ff207d0

48 files changed

Lines changed: 3653 additions & 2682 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,67 @@
22

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

5+
## Unreleased
6+
7+
Required-but-optional drift closure (#172). Drops `None` defaults on 226
8+
spec-required Pydantic model fields across 34 response models (21 REST, 13
9+
WS). The SDK now matches the OpenAPI v3.18.0 / AsyncAPI v0.14 `required` set
10+
on the wire. Promotes `test_required_drift` and `test_ws_required_drift`
11+
from warning to hard CI failure, closing the regression class that allowed
12+
required-but-typed-Optional fields to drift unnoticed.
13+
14+
### Breaking (response-parse side)
15+
16+
- **226 fields are no longer `Optional[T] | None` in response models**
17+
see the full list per model in #172. Wire format is unchanged; the SDK
18+
now refuses to parse responses that omit a spec-required field, where
19+
previously the field defaulted to `None`. If the live server omits a
20+
spec-required field, `pydantic.ValidationError` is raised on parse.
21+
- **`CreateOrderRequest.action` no longer defaults to `"buy"`** — callers
22+
constructing the request model directly must pass `action` explicitly.
23+
The `OrdersResource.create(action=None, ...)` kwarg path still defaults
24+
to `"buy"` for back-compat; only the model-construction surface changed.
25+
- **Test fixtures constructing these models with partial dicts will
26+
raise `ValidationError`.** A new helper module `tests/_model_fixtures`
27+
provides complete spec-shaped builders (`market_dict`, `order_dict`,
28+
`fill_dict`, etc.) that accept `**overrides` for fields tests care about.
29+
30+
### Changed
31+
32+
- `test_required_drift` (REST) and `test_ws_required_drift` (WS) promoted
33+
from `warnings.warn` to `pytest.fail`. Future drift on these gates is
34+
CI-blocking.
35+
- New `ExclusionKind` value `"server_omits_despite_required"` registered
36+
in `tests/_contract_support.py` for fields the spec marks required but
37+
the live server omits. Entries MUST cite a demo+prod observation.
38+
39+
### Migration
40+
41+
- Code that builds these models from server responses: no change. The
42+
server-side wire shape is what it always was — the SDK type just stopped
43+
lying about which fields are guaranteed.
44+
- Code that builds these models in tests / mocks / fixtures: pass all
45+
spec-required fields, or use the `tests/_model_fixtures` builders. The
46+
builders are test-only (live under `tests/`, never shipped in the
47+
wheel) — production code does not import them.
48+
- Callers who relied on `Optional` narrowing (`if order.outcome_side is
49+
not None: ...`) can drop the guard. `mypy --strict` will now flag the
50+
redundant check.
51+
52+
### Affected models
53+
54+
21 REST (136 fields): `Market`, `Order`, `Fill`, `MultivariateEventCollection`,
55+
`Settlement`, `Trade`, `Event`, `Series`, `MarketPosition`, `EventPosition`,
56+
`EventMetadata`, `Milestone`, `SportFilterDetails`, `IncentiveProgram`,
57+
`ApiKey`, `SeriesFeeChange`, `MarketCandlesticks`, `ScopeList`,
58+
`GetOrderGroupResponse`, `CreateOrderGroupResponse`, `CreateOrderRequest`.
59+
60+
13 WS payloads (90 fields): `UserOrdersPayload`, `FillPayload`,
61+
`TickerPayload`, `TradePayload`, `MarketPositionsPayload`,
62+
`QuoteExecutedPayload`, `QuoteCreatedPayload`, `QuoteAcceptedPayload`,
63+
`MultivariatePayload`, `RfqCreatedPayload`, `RfqDeletedPayload`,
64+
`MarketLifecyclePayload`, `OrderGroupPayload`.
65+
566
## 2.2.0 — 2026-05-19
667

768
Response-side spec drift hardening stack (#157). Backfills the remaining

kalshi/models/api_keys.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class ApiKey(BaseModel):
2525

2626
api_key_id: str
2727
name: str
28-
scopes: NullableList[str] = []
28+
scopes: NullableList[str]
2929

3030
model_config = {"extra": "allow"}
3131

kalshi/models/events.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,16 @@ class Event(BaseModel):
1919
"""A Kalshi event (container for one or more markets)."""
2020

2121
event_ticker: str
22-
series_ticker: str | None = None
23-
title: str | None = None
24-
sub_title: str | None = None
25-
collateral_return_type: str | None = None
26-
mutually_exclusive: bool | None = None
22+
series_ticker: str
23+
title: str
24+
sub_title: str
25+
collateral_return_type: str
26+
mutually_exclusive: bool
2727
category: str | None = None
2828
strike_date: datetime | None = None
2929
strike_period: str | None = None
30-
available_on_brokers: bool | None = None
31-
product_metadata: dict[str, Any] | None = None
30+
available_on_brokers: bool
31+
product_metadata: dict[str, Any]
3232
last_updated_ts: datetime | None = None
3333
markets: NullableList[Market] = []
3434

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

67-
image_url: str | None = None
67+
image_url: str
6868
featured_image_url: str | None = None
69-
market_details: list[MarketMetadata] | None = None
70-
settlement_sources: list[SettlementSource] | None = None
69+
market_details: list[MarketMetadata]
70+
settlement_sources: list[SettlementSource]
7171

7272
# v3.18.0 backfill (#160).
7373
competition: str | None = None

kalshi/models/historical.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,25 +30,22 @@ class Trade(BaseModel):
3030
"""A public trade on the exchange."""
3131

3232
trade_id: str
33-
ticker: str | None = None
33+
ticker: str
3434
count: FixedPointCount | None = Field(
35-
default=None,
3635
validation_alias=AliasChoices("count_fp", "count"),
3736
)
3837
yes_price: DollarDecimal | None = Field(
39-
default=None,
4038
validation_alias=AliasChoices("yes_price_dollars", "yes_price"),
4139
)
4240
no_price: DollarDecimal | None = Field(
43-
default=None,
4441
validation_alias=AliasChoices("no_price_dollars", "no_price"),
4542
)
46-
taker_side: str | None = None
47-
created_time: datetime | None = None
43+
taker_side: str
44+
created_time: datetime
4845

4946
# v3.18.0 backfill (#160). Mirrors Order.outcome_side / book_side from #159
5047
# — canonical direction encoding superseding the deprecated `taker_side`.
51-
taker_outcome_side: SideLiteral | None = None
52-
taker_book_side: BookSideLiteral | None = None
48+
taker_outcome_side: SideLiteral
49+
taker_book_side: BookSideLiteral
5350

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

kalshi/models/incentive_programs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class IncentiveProgram(BaseModel):
3838
target_size_fp: FixedPointCount | None = None
3939

4040
# v3.18.0 backfill (#160).
41-
incentive_description: str | None = None
41+
incentive_description: str
4242

4343
model_config = {"extra": "allow"}
4444

kalshi/models/markets.py

Lines changed: 35 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -26,67 +26,55 @@ class Market(BaseModel):
2626
"""
2727

2828
ticker: str
29-
event_ticker: str | None = None
30-
market_type: str | None = None
29+
event_ticker: str
30+
market_type: str
3131
title: str | None = None
3232
subtitle: str | None = None
33-
yes_sub_title: str | None = None
34-
no_sub_title: str | None = None
35-
status: str | None = None
33+
yes_sub_title: str
34+
no_sub_title: str
35+
status: str
3636

3737
# Price fields (FixedPointDollars)
38-
yes_bid: DollarDecimal | None = Field(
39-
default=None,
38+
yes_bid: DollarDecimal = Field(
4039
validation_alias=AliasChoices("yes_bid_dollars", "yes_bid"),
4140
)
42-
yes_ask: DollarDecimal | None = Field(
43-
default=None,
41+
yes_ask: DollarDecimal = Field(
4442
validation_alias=AliasChoices("yes_ask_dollars", "yes_ask"),
4543
)
46-
no_bid: DollarDecimal | None = Field(
47-
default=None,
44+
no_bid: DollarDecimal = Field(
4845
validation_alias=AliasChoices("no_bid_dollars", "no_bid"),
4946
)
50-
no_ask: DollarDecimal | None = Field(
51-
default=None,
47+
no_ask: DollarDecimal = Field(
5248
validation_alias=AliasChoices("no_ask_dollars", "no_ask"),
5349
)
54-
last_price: DollarDecimal | None = Field(
55-
default=None,
50+
last_price: DollarDecimal = Field(
5651
validation_alias=AliasChoices("last_price_dollars", "last_price"),
5752
)
58-
previous_yes_bid: DollarDecimal | None = Field(
59-
default=None,
53+
previous_yes_bid: DollarDecimal = Field(
6054
validation_alias=AliasChoices("previous_yes_bid_dollars", "previous_yes_bid"),
6155
)
62-
previous_yes_ask: DollarDecimal | None = Field(
63-
default=None,
56+
previous_yes_ask: DollarDecimal = Field(
6457
validation_alias=AliasChoices("previous_yes_ask_dollars", "previous_yes_ask"),
6558
)
66-
previous_price: DollarDecimal | None = Field(
67-
default=None,
59+
previous_price: DollarDecimal = Field(
6860
validation_alias=AliasChoices("previous_price_dollars", "previous_price"),
6961
)
70-
notional_value: DollarDecimal | None = Field(
71-
default=None,
62+
notional_value: DollarDecimal = Field(
7263
validation_alias=AliasChoices("notional_value_dollars", "notional_value"),
7364
)
7465
settlement_value: DollarDecimal | None = Field(
7566
default=None,
7667
validation_alias=AliasChoices("settlement_value_dollars", "settlement_value"),
7768
)
78-
liquidity: DollarDecimal | None = Field(
79-
default=None,
69+
liquidity: DollarDecimal = Field(
8070
validation_alias=AliasChoices("liquidity_dollars", "liquidity"),
8171
)
8272

8373
# Size/volume fields (FixedPointCount)
84-
yes_bid_size: FixedPointCount | None = Field(
85-
default=None,
74+
yes_bid_size: FixedPointCount = Field(
8675
validation_alias=AliasChoices("yes_bid_size_fp", "yes_bid_size"),
8776
)
88-
yes_ask_size: FixedPointCount | None = Field(
89-
default=None,
77+
yes_ask_size: FixedPointCount = Field(
9078
validation_alias=AliasChoices("yes_ask_size_fp", "yes_ask_size"),
9179
)
9280
no_bid_size: FixedPointCount | None = Field(
@@ -97,43 +85,40 @@ class Market(BaseModel):
9785
default=None,
9886
validation_alias=AliasChoices("no_ask_size_fp", "no_ask_size"),
9987
)
100-
volume: FixedPointCount | None = Field(
101-
default=None,
88+
volume: FixedPointCount = Field(
10289
validation_alias=AliasChoices("volume_fp", "volume"),
10390
)
104-
volume_24h: FixedPointCount | None = Field(
105-
default=None,
91+
volume_24h: FixedPointCount = Field(
10692
validation_alias=AliasChoices("volume_24h_fp", "volume_24h"),
10793
)
108-
open_interest: FixedPointCount | None = Field(
109-
default=None,
94+
open_interest: FixedPointCount = Field(
11095
validation_alias=AliasChoices("open_interest_fp", "open_interest"),
11196
)
11297

11398
# Timestamps
114-
created_time: datetime | None = None
115-
updated_time: datetime | None = None
116-
open_time: datetime | None = None
117-
close_time: datetime | None = None
118-
latest_expiration_time: datetime | None = None
99+
created_time: datetime
100+
updated_time: datetime
101+
open_time: datetime
102+
close_time: datetime
103+
latest_expiration_time: datetime
119104
expected_expiration_time: datetime | None = None
120105
expiration_time: datetime | None = None
121106
settlement_ts: datetime | None = None
122107
occurrence_datetime: datetime | None = None
123108

124109
# Metadata
125-
settlement_timer_seconds: int | None = None
126-
result: str | None = None
127-
can_close_early: bool | None = None
128-
fractional_trading_enabled: bool | None = None
129-
expiration_value: str | None = None
110+
settlement_timer_seconds: int
111+
result: str
112+
can_close_early: bool
113+
fractional_trading_enabled: bool
114+
expiration_value: str
130115
category: str | None = None
131116
risk_limit_cents: int | None = None
132117
strike_type: str | None = None
133118
floor_strike: Decimal | None = None
134119
cap_strike: Decimal | None = None
135-
rules_primary: str | None = None
136-
rules_secondary: str | None = None
120+
rules_primary: str
121+
rules_secondary: str
137122

138123
# v3.18.0 backfill (#159). mve_selected_legs is list[MveSelectedLeg] and
139124
# price_ranges is list[PriceRange] on the wire — kept as list[dict] here;
@@ -146,8 +131,8 @@ class Market(BaseModel):
146131
is_provisional: bool | None = None
147132
mve_collection_ticker: str | None = None
148133
mve_selected_legs: list[dict[str, Any]] | None = None
149-
price_level_structure: str | None = None
150-
price_ranges: list[dict[str, Any]] | None = None
134+
price_level_structure: str
135+
price_ranges: NullableList[dict[str, Any]]
151136
primary_participant_key: str | None = None
152137

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

268253
model_config = {"extra": "allow"}

kalshi/models/milestones.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,13 @@ class Milestone(BaseModel):
2828
# Spec marks these required but Kalshi has historically returned JSON null
2929
# for required list fields (see v0.9.0 fix for Series.tags). NullableList
3030
# coerces None -> [] so demo/prod inconsistencies don't break parsing.
31-
related_event_tickers: NullableList[str] = []
31+
related_event_tickers: NullableList[str]
3232
title: str
3333
notification_message: str
3434
source_id: str | None = None
3535
source_ids: dict[str, str] | None = None
36-
details: dict[str, Any] = {}
37-
primary_event_tickers: NullableList[str] = []
36+
details: dict[str, Any]
37+
primary_event_tickers: NullableList[str]
3838
last_updated_ts: datetime
3939

4040
model_config = {"extra": "allow"}

kalshi/models/multivariate.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,21 @@ class MultivariateEventCollection(BaseModel):
3737
"""A multivariate event collection (combo contract template)."""
3838

3939
collection_ticker: str
40-
series_ticker: str = ""
41-
title: str = ""
42-
description: str = ""
43-
open_date: datetime | None = None
44-
close_date: datetime | None = None
45-
associated_events: NullableList[AssociatedEvent] = []
40+
series_ticker: str
41+
title: str
42+
description: str
43+
open_date: datetime
44+
close_date: datetime
45+
associated_events: NullableList[AssociatedEvent]
4646
# Deprecated fields — still returned by API
47-
associated_event_tickers: NullableList[str] = []
48-
is_single_market_per_event: bool = False
49-
is_all_yes: bool = False
47+
associated_event_tickers: NullableList[str]
48+
is_single_market_per_event: bool
49+
is_all_yes: bool
5050
# Active fields
51-
is_ordered: bool = False
52-
size_min: int = 0
53-
size_max: int = 0
54-
functional_description: str = ""
51+
is_ordered: bool
52+
size_min: int
53+
size_max: int
54+
functional_description: str
5555

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

kalshi/models/order_groups.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class GetOrderGroupResponse(BaseModel):
2727
"""Single-group response — omits id (path param), adds member order IDs."""
2828

2929
is_auto_cancel_enabled: bool
30-
orders: NullableList[str] = []
30+
orders: NullableList[str]
3131
contracts_limit: FixedPointCount | None = Field(
3232
default=None,
3333
validation_alias=AliasChoices("contracts_limit_fp", "contracts_limit"),
@@ -46,7 +46,7 @@ class CreateOrderGroupResponse(BaseModel):
4646

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

5252
model_config = {"extra": "allow"}

0 commit comments

Comments
 (0)