diff --git a/CHANGELOG.md b/CHANGELOG.md index d8e85d6..5cec266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index 3ddf009..de65794 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -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 ] diff --git a/kalshi/models/communications.py b/kalshi/models/communications.py index f7f9e30..21facbd 100644 --- a/kalshi/models/communications.py +++ b/kalshi/models/communications.py @@ -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} @@ -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} diff --git a/kalshi/models/order_groups.py b/kalshi/models/order_groups.py index 58c909d..ecf76fe 100644 --- a/kalshi/models/order_groups.py +++ b/kalshi/models/order_groups.py @@ -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} @@ -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} @@ -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"} diff --git a/kalshi/ws/models/base.py b/kalshi/ws/models/base.py index 348b1c9..616c231 100644 --- a/kalshi/ws/models/base.py +++ b/kalshi/ws/models/base.py @@ -8,6 +8,7 @@ class SubscriptionInfo(BaseModel): """Subscription confirmation payload.""" channel: str sid: int + model_config = {"extra": "allow"} class ErrorPayload(BaseModel): @@ -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): @@ -32,6 +34,7 @@ class SubscribedMessage(BaseModel): id: int = 0 type: str = "subscribed" msg: SubscriptionInfo + model_config = {"extra": "allow"} class UnsubscribedMessage(BaseModel): @@ -40,6 +43,7 @@ class UnsubscribedMessage(BaseModel): sid: int seq: int type: str = "unsubscribed" + model_config = {"extra": "allow"} class OkMessage(BaseModel): @@ -57,3 +61,4 @@ class ErrorMessage(BaseModel): id: int = 0 type: str = "error" msg: ErrorPayload + model_config = {"extra": "allow"} diff --git a/kalshi/ws/models/communications.py b/kalshi/ws/models/communications.py index f941c8d..84493a8 100644 --- a/kalshi/ws/models/communications.py +++ b/kalshi/ws/models/communications.py @@ -3,7 +3,7 @@ from pydantic import AliasChoices, BaseModel, Field -from kalshi.types import DollarDecimal +from kalshi.types import DollarDecimal, FixedPointCount class RfqCreatedPayload(BaseModel): @@ -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"} @@ -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"} @@ -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"} @@ -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"} diff --git a/kalshi/ws/models/fill.py b/kalshi/ws/models/fill.py index 3cc460e..22ffbbe 100644 --- a/kalshi/ws/models/fill.py +++ b/kalshi/ws/models/fill.py @@ -3,6 +3,7 @@ from pydantic import AliasChoices, BaseModel, Field +from kalshi.models.orders import BookSideLiteral, SideLiteral from kalshi.types import DollarDecimal @@ -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"} @@ -48,3 +54,4 @@ class FillMessage(BaseModel): sid: int seq: int | None = None msg: FillPayload + model_config = {"extra": "allow"} diff --git a/kalshi/ws/models/market_lifecycle.py b/kalshi/ws/models/market_lifecycle.py index c49b17f..7ff5b89 100644 --- a/kalshi/ws/models/market_lifecycle.py +++ b/kalshi/ws/models/market_lifecycle.py @@ -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 @@ -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"} @@ -41,3 +52,4 @@ class MarketLifecycleMessage(BaseModel): sid: int seq: int | None = None msg: MarketLifecyclePayload + model_config = {"extra": "allow"} diff --git a/kalshi/ws/models/market_positions.py b/kalshi/ws/models/market_positions.py index fb71bbb..74d23c6 100644 --- a/kalshi/ws/models/market_positions.py +++ b/kalshi/ws/models/market_positions.py @@ -50,3 +50,4 @@ class MarketPositionsMessage(BaseModel): sid: int seq: int | None = None msg: MarketPositionsPayload + model_config = {"extra": "allow"} diff --git a/kalshi/ws/models/multivariate.py b/kalshi/ws/models/multivariate.py index e131433..fbbaa14 100644 --- a/kalshi/ws/models/multivariate.py +++ b/kalshi/ws/models/multivariate.py @@ -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): @@ -31,6 +32,7 @@ class MultivariateMessage(BaseModel): sid: int seq: int | None = None msg: MultivariatePayload + model_config = {"extra": "allow"} class MultivariateLifecycleMessage(BaseModel): @@ -40,3 +42,4 @@ class MultivariateLifecycleMessage(BaseModel): sid: int seq: int | None = None msg: MarketLifecyclePayload + model_config = {"extra": "allow"} diff --git a/kalshi/ws/models/order_group.py b/kalshi/ws/models/order_group.py index d3ec6ae..c38ba2d 100644 --- a/kalshi/ws/models/order_group.py +++ b/kalshi/ws/models/order_group.py @@ -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"} @@ -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"} diff --git a/kalshi/ws/models/orderbook_delta.py b/kalshi/ws/models/orderbook_delta.py index 16ab740..4ae3513 100644 --- a/kalshi/ws/models/orderbook_delta.py +++ b/kalshi/ws/models/orderbook_delta.py @@ -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"} @@ -58,6 +60,7 @@ class OrderbookSnapshotMessage(BaseModel): sid: int seq: int msg: OrderbookSnapshotPayload + model_config = {"extra": "allow"} class OrderbookDeltaMessage(BaseModel): @@ -66,3 +69,4 @@ class OrderbookDeltaMessage(BaseModel): sid: int seq: int msg: OrderbookDeltaPayload + model_config = {"extra": "allow"} diff --git a/kalshi/ws/models/ticker.py b/kalshi/ws/models/ticker.py index c19ad33..1b3c63e 100644 --- a/kalshi/ws/models/ticker.py +++ b/kalshi/ws/models/ticker.py @@ -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"} @@ -65,3 +72,4 @@ class TickerMessage(BaseModel): sid: int seq: int | None = None msg: TickerPayload + model_config = {"extra": "allow"} diff --git a/kalshi/ws/models/trade.py b/kalshi/ws/models/trade.py index 98e3a04..c4a2112 100644 --- a/kalshi/ws/models/trade.py +++ b/kalshi/ws/models/trade.py @@ -3,6 +3,7 @@ from pydantic import AliasChoices, BaseModel, Field +from kalshi.models.orders import BookSideLiteral, SideLiteral from kalshi.types import DollarDecimal @@ -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"} @@ -40,3 +46,4 @@ class TradeMessage(BaseModel): sid: int seq: int | None = None msg: TradePayload + model_config = {"extra": "allow"} diff --git a/kalshi/ws/models/user_orders.py b/kalshi/ws/models/user_orders.py index 9ed35b2..75f473a 100644 --- a/kalshi/ws/models/user_orders.py +++ b/kalshi/ws/models/user_orders.py @@ -3,6 +3,11 @@ from pydantic import AliasChoices, BaseModel, Field +from kalshi.models.orders import ( + BookSideLiteral, + SelfTradePreventionTypeLiteral, + SideLiteral, +) from kalshi.types import DollarDecimal @@ -61,6 +66,15 @@ class UserOrdersPayload(BaseModel): last_update_time: str | None = None expiration_time: str | None = None subaccount_number: int | None = None + # v0.14+ backfill (#162). outcome_side/book_side/self_trade_prevention_type + # mirror Order from #159. *_ts_ms (Unix ms) supersede the *_time RFC3339 + # siblings — both stay; do NOT auto-convert. + outcome_side: SideLiteral | None = None + book_side: BookSideLiteral | None = None + self_trade_prevention_type: SelfTradePreventionTypeLiteral | None = None + created_ts_ms: int | None = None + last_updated_ts_ms: int | None = None + expiration_ts_ms: int | None = None model_config = {"extra": "allow"} @@ -71,3 +85,4 @@ class UserOrdersMessage(BaseModel): sid: int seq: int | None = None msg: UserOrdersPayload + model_config = {"extra": "allow"} diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 94c2f95..0fab5df 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -1,10 +1,14 @@ -"""Contract tests: verify hand-written SDK models match OpenAPI spec schemas. +"""Contract tests: verify hand-written SDK models match OpenAPI / AsyncAPI spec schemas. Drift detection: -- Additive drift (spec has fields SDK doesn't): WARNING +- Additive drift (spec has fields SDK doesn't): **FAILURE** (#163) +- Unmapped WS payload models: **FAILURE** (#163) +- Unmapped REST models: WARNING (sub-models / V2 family — separate mapping pass) - Required mismatch (spec required, SDK optional): WARNING (SDK is intentionally permissive) - Missing schema in spec: FAILURE -- Unmapped SDK models: WARNING + +Intentional deviations require an entry in ``EXCLUSIONS`` +(``tests/_contract_support.py``) with a typed ``kind`` and ``reason``. """ from __future__ import annotations @@ -648,10 +652,9 @@ def test_additive_drift(self, entry: ContractEntry) -> None: model_class = _get_sdk_model_class(entry.sdk_model) additive, _ = _classify_drift(entry, self.spec, spec_fields, model_class) if additive: - warnings.warn( + pytest.fail( f"Additive drift in {entry.sdk_model}:\n" + "\n".join(f" - {a}" for a in additive), - stacklevel=1, ) @pytest.mark.parametrize( @@ -707,6 +710,12 @@ def test_contract_map_completeness(self) -> None: unmapped.append(fqn) if unmapped: + # REST completeness stays as warn-only pending sub-model + V2 model + # mapping work (~42 entries: nested model classes like Candlestick / + # OrderbookLevel, V2 request/response families, internal containers + # like PositionsResponse). Out of scope for #163; the WS completeness + # check IS hard-fail since ErrorPayload registration cleared it. + # Track follow-up in a separate issue if mapping these is desired. warnings.warn( "SDK models without contract map entries:\n" + "\n".join(f" - {m}" for m in unmapped), @@ -746,10 +755,9 @@ def test_ws_additive_drift(self, entry: ContractEntry) -> None: model_class = _get_sdk_model_class(entry.sdk_model) additive, _ = _classify_drift(entry, self.spec, spec_fields, model_class) if additive: - warnings.warn( + pytest.fail( f"WS additive drift in {entry.sdk_model}:\n" + "\n".join(f" - {a}" for a in additive), - stacklevel=1, ) @pytest.mark.parametrize( @@ -918,10 +926,9 @@ def test_ws_contract_map_completeness(self) -> None: unmapped.append(fqn) if unmapped: - warnings.warn( + pytest.fail( "WS payload models without contract map entries:\n" + "\n".join(f" - {m}" for m in unmapped), - stacklevel=1, ) diff --git a/tests/test_model_extra_policy.py b/tests/test_model_extra_policy.py index adbf6e6..9f0f9ee 100644 --- a/tests/test_model_extra_policy.py +++ b/tests/test_model_extra_policy.py @@ -67,3 +67,60 @@ def test_request_bodies_use_extra_forbid(name: str, cls: type[BaseModel]) -> Non f"Request body {name} has extra={extra!r}; expected 'forbid' so " f"phantom kwargs fail at call time (CLAUDE.md key convention)." ) + + +# --------------------------------------------------------------------------- +# WebSocket models — payloads, envelopes, AND helpers all use extra="allow" +# (#163). The WS surface has no request bodies, so the policy is uniform. +# This pins the rule so a future model addition that forgets the config +# fails this test instead of slipping through. +# --------------------------------------------------------------------------- + + +def _ws_model_classes() -> list[tuple[str, type[BaseModel]]]: + """Every BaseModel subclass defined in any ``kalshi.ws.models.*`` module.""" + import importlib + import inspect + import pkgutil + + import kalshi.ws.models as ws_pkg + + out: list[tuple[str, type[BaseModel]]] = [] + seen: set[type[BaseModel]] = set() + for mod_info in pkgutil.iter_modules(ws_pkg.__path__): + if mod_info.name.startswith("_"): + continue + module = importlib.import_module(f"kalshi.ws.models.{mod_info.name}") + for name, obj in inspect.getmembers(module, inspect.isclass): + if ( + issubclass(obj, BaseModel) + and obj is not BaseModel + and obj.__module__ == module.__name__ + and obj not in seen + ): + out.append((f"{mod_info.name}.{name}", obj)) + seen.add(obj) + return out + + +_WS_MODELS = _ws_model_classes() + + +@pytest.mark.parametrize( + "name,cls", _WS_MODELS, ids=[n for n, _ in _WS_MODELS], +) +def test_ws_models_use_extra_allow(name: str, cls: type[BaseModel]) -> None: + """Every WS model (payload, envelope, helper) must opt into ``extra='allow'``. + + Locks in the policy so a future WS model addition that omits the + config fails CI loudly. Closes the matching ROADMAP item — payloads + were already covered in #143; this extends it to the envelopes and + helpers. + """ + cfg = getattr(cls, "model_config", {}) + extra = cfg.get("extra") + assert extra == "allow", ( + f"WS model {name} has extra={extra!r}; expected 'allow'. WS is " + f"unidirectional and forwards-compatibility is the whole point — " + f"the spec adds fields routinely (e.g. v0.14 ts_ms backfill)." + ) diff --git a/tests/test_models.py b/tests/test_models.py index 1c317dd..05fbbe2 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -4,13 +4,20 @@ from datetime import datetime from decimal import Decimal +from typing import ClassVar import pytest +from kalshi.models.communications import RFQ, Quote from kalshi.models.events import Event, EventMetadata from kalshi.models.historical import Trade from kalshi.models.incentive_programs import IncentiveProgram from kalshi.models.markets import Market +from kalshi.models.order_groups import ( + CreateOrderGroupResponse, + GetOrderGroupResponse, + OrderGroup, +) from kalshi.models.orders import Fill, Order from kalshi.models.portfolio import Settlement from kalshi.types import to_decimal @@ -467,6 +474,111 @@ def test_incentive_description_defaults_to_none(self) -> None: assert p.incentive_description is None +class TestRFQV3180Fields: + """v3.18.0 backfill (issue #161): 1 new optional field on ``RFQ``.""" + + def test_parses_creator_subaccount(self) -> None: + r = RFQ.model_validate({ + "id": "rfq-1", + "creator_id": "user-1", + "market_ticker": "MKT", + "contracts_fp": "10.00", + "status": "open", + "created_ts": "2026-05-01T00:00:00Z", + "creator_subaccount": 3, + }) + assert r.creator_subaccount == 3 + + def test_creator_subaccount_defaults_to_none(self) -> None: + r = RFQ.model_validate({ + "id": "rfq-1", + "creator_id": "user-1", + "market_ticker": "MKT", + "contracts_fp": "10.00", + "status": "open", + "created_ts": "2026-05-01T00:00:00Z", + }) + assert r.creator_subaccount is None + + +class TestQuoteV3180Fields: + """v3.18.0 backfill (issue #161): 3 new optional fields on ``Quote``.""" + + _MINIMAL: ClassVar[dict[str, str]] = { + "id": "q-1", + "rfq_id": "rfq-1", + "creator_id": "user-1", + "rfq_creator_id": "user-2", + "market_ticker": "MKT", + "contracts_fp": "10.00", + "yes_bid_dollars": "0.5500", + "no_bid_dollars": "0.4500", + "created_ts": "2026-05-01T00:00:00Z", + "updated_ts": "2026-05-01T00:00:00Z", + "status": "open", + } + + def test_parses_all_new_fields(self) -> None: + q = Quote.model_validate( + self._MINIMAL | { + "creator_subaccount": 3, + "rfq_creator_subaccount": 5, + "post_only": True, + } + ) + assert q.creator_subaccount == 3 + assert q.rfq_creator_subaccount == 5 + assert q.post_only is True + + def test_all_new_fields_default_to_none(self) -> None: + q = Quote.model_validate(self._MINIMAL) + assert q.creator_subaccount is None + assert q.rfq_creator_subaccount is None + assert q.post_only is None + + +class TestOrderGroupV3180Fields: + """v3.18.0 backfill (issue #161): exchange_index on OrderGroup + responses.""" + + def test_order_group_parses_exchange_index(self) -> None: + g = OrderGroup.model_validate({ + "id": "g-1", + "is_auto_cancel_enabled": True, + "exchange_index": 7, + }) + assert g.exchange_index == 7 + + def test_order_group_exchange_index_defaults_to_none(self) -> None: + g = OrderGroup.model_validate({ + "id": "g-1", + "is_auto_cancel_enabled": True, + }) + assert g.exchange_index is None + + def test_get_order_group_response_parses_exchange_index(self) -> None: + r = GetOrderGroupResponse.model_validate({ + "is_auto_cancel_enabled": True, + "orders": ["ord-1"], + "exchange_index": 7, + }) + assert r.exchange_index == 7 + + def test_create_order_group_response_parses_subaccount_and_exchange_index(self) -> None: + """Server echoes the routing context (subaccount + shard) on create.""" + r = CreateOrderGroupResponse.model_validate({ + "order_group_id": "g-1", + "subaccount": 4, + "exchange_index": 7, + }) + assert r.subaccount == 4 + assert r.exchange_index == 7 + + def test_create_order_group_response_defaults_to_none(self) -> None: + r = CreateOrderGroupResponse.model_validate({"order_group_id": "g-1"}) + assert r.subaccount is None + assert r.exchange_index is None + + class TestErrorHierarchy: def test_all_errors_inherit_base(self) -> None: from kalshi.errors import ( diff --git a/tests/ws/test_models.py b/tests/ws/test_models.py index 617c23e..e00c046 100644 --- a/tests/ws/test_models.py +++ b/tests/ws/test_models.py @@ -13,6 +13,7 @@ from kalshi.ws.models.communications import ( CommunicationsMessage, QuoteAcceptedPayload, + QuoteCreatedPayload, QuoteExecutedPayload, RfqCreatedPayload, ) @@ -663,3 +664,174 @@ def test_quote_executed_payload_model(self) -> None: ) assert payload.order_id == "ord-001" assert payload.executed_ts == "2026-04-19T18:43:37Z" + + +# ---------- v0.14+ backfill (#162): one (de)serialization test per payload ---------- + + +class TestWsV0140Backfill: + """Verify the v0.14+ AsyncAPI backfill fields parse end-to-end. + + One assertion per payload covering at least one of the newly-added + fields. Defaults-to-None paths are already exercised by the existing + ``test_*_minimal`` tests in each per-payload class. + """ + + def test_ticker_price_and_ts_ms(self) -> None: + msg = TickerMessage.model_validate({ + "type": "ticker", + "sid": 1, + "msg": {"market_ticker": "T", "price_dollars": "0.5500", "ts_ms": 1700000000000}, + }) + assert msg.msg.price == Decimal("0.5500") + assert msg.msg.ts_ms == 1700000000000 + + def test_fill_outcome_book_side_and_ts_ms(self) -> None: + msg = FillMessage.model_validate({ + "type": "fill", + "sid": 1, + "msg": { + "trade_id": "t1", + "outcome_side": "yes", + "book_side": "bid", + "ts_ms": 1700000000000, + }, + }) + assert msg.msg.outcome_side == "yes" + assert msg.msg.book_side == "bid" + assert msg.msg.ts_ms == 1700000000000 + + def test_orderbook_delta_ts_ms(self) -> None: + msg = OrderbookDeltaMessage.model_validate({ + "type": "orderbook_delta", + "sid": 1, + "seq": 1, + "msg": { + "market_ticker": "T", + "market_id": "x", + "price_dollars": "0.5500", + "delta_fp": "10.00", + "side": "yes", + "ts_ms": 1700000000000, + }, + }) + assert msg.msg.ts_ms == 1700000000000 + + def test_trade_taker_outcome_book_side_and_ts_ms(self) -> None: + msg = TradeMessage.model_validate({ + "type": "trade", + "sid": 1, + "msg": { + "trade_id": "t1", + "market_ticker": "T", + "taker_outcome_side": "no", + "taker_book_side": "ask", + "ts_ms": 1700000000000, + }, + }) + assert msg.msg.taker_outcome_side == "no" + assert msg.msg.taker_book_side == "ask" + assert msg.msg.ts_ms == 1700000000000 + + def test_user_orders_outcome_book_stp_and_ts_ms_trio(self) -> None: + msg = UserOrdersMessage.model_validate({ + "type": "user_order", + "sid": 1, + "msg": { + "order_id": "o1", + "outcome_side": "yes", + "book_side": "bid", + "self_trade_prevention_type": "taker_at_cross", + "created_ts_ms": 1700000000000, + "last_updated_ts_ms": 1700000001000, + "expiration_ts_ms": 1700000002000, + }, + }) + assert msg.msg.outcome_side == "yes" + assert msg.msg.book_side == "bid" + assert msg.msg.self_trade_prevention_type == "taker_at_cross" + assert msg.msg.created_ts_ms == 1700000000000 + assert msg.msg.last_updated_ts_ms == 1700000001000 + assert msg.msg.expiration_ts_ms == 1700000002000 + + def test_market_lifecycle_metadata_strike_structure_subtitle(self) -> None: + msg = MarketLifecycleMessage.model_validate({ + "type": "market_lifecycle_v2", + "sid": 1, + "msg": { + "event_type": "metadata_updated", + "market_ticker": "T", + "additional_metadata": {"title": "Updated", "rules_primary": "rules"}, + "floor_strike": 50.5, + "price_level_structure": "linear_cent", + "yes_sub_title": "Will it happen?", + }, + }) + assert msg.msg.additional_metadata == {"title": "Updated", "rules_primary": "rules"} + assert msg.msg.floor_strike == Decimal("50.5") + assert msg.msg.price_level_structure == "linear_cent" + assert msg.msg.yes_sub_title == "Will it happen?" + + def test_order_group_ts_ms(self) -> None: + msg = OrderGroupMessage.model_validate({ + "type": "order_group_updates", + "sid": 1, + "seq": 1, + "msg": { + "event_type": "created", + "order_group_id": "g1", + "ts_ms": 1700000000000, + }, + }) + assert msg.msg.ts_ms == 1700000000000 + + def test_rfq_created_mve_fields(self) -> None: + payload = RfqCreatedPayload.model_validate({ + "id": "rfq-1", + "mve_collection_ticker": "COLL-001", + "mve_selected_legs": [ + {"event_ticker": "EVT-1", "market_ticker": "MKT-1", "side": "yes"}, + ], + }) + assert payload.mve_collection_ticker == "COLL-001" + assert payload.mve_selected_legs is not None + assert payload.mve_selected_legs[0]["event_ticker"] == "EVT-1" + + def test_rfq_deleted_rfq_context(self) -> None: + from kalshi.ws.models.communications import RfqDeletedPayload + + payload = RfqDeletedPayload.model_validate({ + "id": "rfq-1", + "event_ticker": "EVT-1", + "contracts_fp": "10.00", + "target_cost_dollars": "5.5000", + }) + assert payload.event_ticker == "EVT-1" + assert payload.contracts == Decimal("10.00") + assert payload.target_cost == Decimal("5.5000") + + def test_quote_created_rfq_context(self) -> None: + payload = QuoteCreatedPayload.model_validate({ + "quote_id": "q-1", + "event_ticker": "EVT-1", + "yes_contracts_offered_fp": "5.00", + "no_contracts_offered_fp": "3.00", + "rfq_target_cost_dollars": "1.5000", + }) + assert payload.event_ticker == "EVT-1" + assert payload.yes_contracts_offered == Decimal("5.00") + assert payload.no_contracts_offered == Decimal("3.00") + assert payload.rfq_target_cost == Decimal("1.5000") + + def test_quote_accepted_rfq_context(self) -> None: + payload = QuoteAcceptedPayload.model_validate({ + "quote_id": "q-1", + "event_ticker": "EVT-1", + "yes_contracts_offered_fp": "5.00", + "no_contracts_offered_fp": "3.00", + "rfq_target_cost_dollars": "1.5000", + }) + assert payload.event_ticker == "EVT-1" + assert payload.yes_contracts_offered == Decimal("5.00") + assert payload.no_contracts_offered == Decimal("3.00") + assert payload.rfq_target_cost == Decimal("1.5000")