From 59e3c69abe4618fc92c104bff67a3c25e963acb6 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 19 Jun 2026 16:26:33 -0500 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20spec=20drift=20=E2=80=94=20settlemen?= =?UTF-8?q?t=5Fsources=20on=20Event=20+=20lifecycle=20strike=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live v3.21.0 spec gained fields after #449 synced it, redding the nightly strict contract run (#451): - EventData.settlement_sources (spec-required, nullable) → Event.settlement_sources, typed NullableList[SettlementSource] so a JSON null coerces to []. Mirrors EventMetadata/Series. SettlementSource moved above Event so the forward ref resolves under `from __future__ import annotations`. event_dict() fixture gains the required key. - marketLifecycleV2Payload.{strike_type,cap_strike,custom_strike} → MarketLifecyclePayload (all optional; present only on metadata_updated). specs/openapi.yaml: the freshly-fetched live spec also *transiently* dropped CreateOrder / BatchCreateOrders / AmendOrder (104→101 ops), which would red the body-drift tests for those endpoints. That is an upstream publishing glitch, not a real API change — so the committed openapi was kept at its known-good 104-op state and settlement_sources added surgically to EventData. specs/asyncapi.yaml was synced normally (additive strike fields + WS error-doc refinements; no dropped channels). Verified: ruff clean, mypy clean, strict nightly contract suite (-W error::UserWarning) green, full unit suite 4315 passed / 3 skipped. Closes #451 Co-Authored-By: Claude Opus 4.8 (1M context) --- kalshi/models/events.py | 23 +++++++----- kalshi/ws/models/market_lifecycle.py | 7 ++++ specs/asyncapi.yaml | 52 ++++++++++++++++++++-------- specs/openapi.yaml | 7 ++++ tests/_model_fixtures.py | 1 + 5 files changed, 66 insertions(+), 24 deletions(-) diff --git a/kalshi/models/events.py b/kalshi/models/events.py index d77f487..f600520 100644 --- a/kalshi/models/events.py +++ b/kalshi/models/events.py @@ -13,6 +13,15 @@ """Event status filter for GET /events. Spec: GetEvents.status query enum.""" +class SettlementSource(BaseModel): + """A settlement source for an event.""" + + url: str | None = None + name: str | None = None + + model_config = {"extra": "allow"} + + class Event(BaseModel): """A Kalshi event (container for one or more markets).""" @@ -31,6 +40,11 @@ class Event(BaseModel): # too (#385), so this is no longer a spec deviation — kept nullable to # match server reality. product_metadata: dict[str, Any] | None = None + # Spec-required on EventData but `nullable: true` (added to the live + # v3.21.0 spec post-#449). NullableList coerces a JSON null -> [] so the + # key-present contract holds while callers always see a list. Mirrors + # EventMetadata.settlement_sources / Series.settlement_sources. + settlement_sources: NullableList[SettlementSource] last_updated_ts: AwareDatetime | None = None markets: NullableList[Market] = [] @@ -75,15 +89,6 @@ class MarketMetadata(BaseModel): model_config = {"extra": "allow"} -class SettlementSource(BaseModel): - """A settlement source for an event.""" - - url: str | None = None - name: str | None = None - - model_config = {"extra": "allow"} - - class EventMetadata(BaseModel): """Metadata for an event including images and settlement sources.""" diff --git a/kalshi/ws/models/market_lifecycle.py b/kalshi/ws/models/market_lifecycle.py index 1a92473..672ebbb 100644 --- a/kalshi/ws/models/market_lifecycle.py +++ b/kalshi/ws/models/market_lifecycle.py @@ -41,6 +41,13 @@ class MarketLifecyclePayload(BaseModel): # price_level_structure for `price_level_structure_updated` (or `created`). additional_metadata: dict[str, Any] | None = None floor_strike: DollarDecimal | None = None + # metadata_updated only (added to the live asyncapi spec post-#449). + # strike_type decides how floor_strike/cap_strike are read ("between" uses + # both, "greater" floor only, "less" cap only); custom_strike carries + # structured strikes. All optional — present only on metadata_updated. + strike_type: str | None = None + cap_strike: DollarDecimal | None = None + custom_strike: dict[str, Any] | None = None price_level_structure: str | None = None yes_sub_title: str | None = None model_config = {"extra": "allow", "populate_by_name": True} diff --git a/specs/asyncapi.yaml b/specs/asyncapi.yaml index 3015a5d..f7b137c 100644 --- a/specs/asyncapi.yaml +++ b/specs/asyncapi.yaml @@ -81,7 +81,22 @@ info: | 27 | Too many requests | The subscription exceeded its command rate limit | - | 28 | Markets not found | Unknown tickers were not subscribed | + + ## Terminal Errors + + + The following channel errors are terminal and the user must resubscribe. + + + | Code | Error | + + |------|-------| + + | 10 | Channel error | + + | 17 | Internal error | + + | 25 | Subscription buffer overflow | contact: name: Kalshi Support url: https://kalshi.com @@ -1391,8 +1406,6 @@ components: | 27 | Too many requests | The subscription exceeded its command rate limit | - - | 28 | Markets not found | Unknown tickers were not subscribed | contentType: application/json payload: $ref: '#/components/schemas/errorResponsePayload' @@ -1694,7 +1707,9 @@ components: msg: market_ticker: KXBTC-25APR30-T0915-B95000 event_type: metadata_updated + strike_type: between floor_strike: 95000 + cap_strike: 95250 - name: metadataUpdatedSubtitle summary: Market metadata updated event (subtitle) payload: @@ -2471,10 +2486,8 @@ components: - 27: Too many requests - The subscription exceeded its command rate limit - - - 28: Markets not found - Unknown tickers were not subscribed minimum: 1 - maximum: 28 + maximum: 27 msg: type: string description: Human-readable error message @@ -2484,11 +2497,6 @@ components: market_ticker: type: string description: Market ticker if error is market-specific (optional) - market_tickers: - type: array - items: - type: string - description: Market tickers if error is market-specific (optional) listSubscriptionsCommandPayload: type: object required: @@ -3165,11 +3173,28 @@ components: - linear_cent - deci_cent - tapered_deci_cent + strike_type: + type: string + description: >- + Optional - This key will ONLY exist for metadata_updated events. + Determines how floor_strike / cap_strike are interpreted (e.g. + "between" uses both, "greater" uses floor_strike only, "less" + uses cap_strike only) floor_strike: type: number description: >- Optional - This key will ONLY exist for metadata_updated events. - The updated floor strike value for the market + The floor (lower bound) strike value for the market + cap_strike: + type: number + description: >- + Optional - This key will ONLY exist for metadata_updated events. + The cap (upper bound) strike value for the market + custom_strike: + type: object + description: >- + Optional - This key will ONLY exist for metadata_updated events + with a custom or structured strike type yes_sub_title: type: string description: >- @@ -3985,6 +4010,3 @@ x-error-codes: - code: 27 name: Too many requests description: The subscription exceeded its command rate limit - - code: 28 - name: Markets not found - description: Unknown tickers were not subscribed diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 8486382..72c714d 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -8072,6 +8072,7 @@ components: - collateral_return_type - mutually_exclusive - available_on_brokers + - settlement_sources properties: event_ticker: type: string @@ -8123,6 +8124,12 @@ components: x-omitempty: true description: Additional metadata for the event. x-go-type-skip-optional-pointer: true + settlement_sources: + type: array + nullable: true + items: + $ref: '#/components/schemas/SettlementSource' + description: The official sources used for the determination of markets within this event. Methodology is defined in the rulebook. last_updated_ts: type: string format: date-time diff --git a/tests/_model_fixtures.py b/tests/_model_fixtures.py index 900b78b..c268bc3 100644 --- a/tests/_model_fixtures.py +++ b/tests/_model_fixtures.py @@ -147,6 +147,7 @@ def event_dict(**overrides: Any) -> dict[str, Any]: "collateral_return_type": "self_collateralized", "available_on_brokers": False, "product_metadata": {}, + "settlement_sources": [], } base.update(overrides) return base From 1577e8104aff03006d69b16dc7d2e9ca481003a8 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 19 Jun 2026 16:32:44 -0500 Subject: [PATCH 2/2] test: cover settlement_sources coercion + lifecycle strike fields Addresses the PR #452 review (claude[bot]): add unit coverage for the new fields rather than relying on the contract suite alone. - TestEventSettlementSources: null -> [] coercion, typed SettlementSource list parsing, and required-key enforcement. - TestMarketLifecycleStrikeFields: the asyncapi 'between' example (strike_type + floor_strike + cap_strike + custom_strike) and the default-None path on non-metadata events. - Tighten the Event.settlement_sources comment: only Series mirrors the nullable shape; EventMetadata's field is a plain list (not nullable). Co-Authored-By: Claude Opus 4.8 (1M context) --- kalshi/models/events.py | 7 ++++--- tests/test_models.py | 36 +++++++++++++++++++++++++++++++++++- tests/ws/test_models.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/kalshi/models/events.py b/kalshi/models/events.py index f600520..bd06845 100644 --- a/kalshi/models/events.py +++ b/kalshi/models/events.py @@ -41,9 +41,10 @@ class Event(BaseModel): # match server reality. product_metadata: dict[str, Any] | None = None # Spec-required on EventData but `nullable: true` (added to the live - # v3.21.0 spec post-#449). NullableList coerces a JSON null -> [] so the - # key-present contract holds while callers always see a list. Mirrors - # EventMetadata.settlement_sources / Series.settlement_sources. + # v3.21.0 spec post-#449, #451). NullableList coerces a JSON null -> [] so + # the key-present contract holds while callers always see a list. Same + # nullable shape as Series.settlement_sources; EventMetadata's is a plain + # list because its spec field isn't nullable. settlement_sources: NullableList[SettlementSource] last_updated_ts: AwareDatetime | None = None markets: NullableList[Market] = [] diff --git a/tests/test_models.py b/tests/test_models.py index e8fd146..4c44f6b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -10,7 +10,7 @@ from pydantic import BaseModel, ValidationError from kalshi.models.communications import RFQ, Quote -from kalshi.models.events import Event, EventMetadata +from kalshi.models.events import Event, EventMetadata, SettlementSource from kalshi.models.historical import Trade from kalshi.models.incentive_programs import IncentiveProgram from kalshi.models.markets import Market @@ -437,6 +437,40 @@ def test_parses_when_server_omits_product_metadata(self) -> None: assert e.product_metadata is None +class TestEventSettlementSources: + """#451 spec drift: ``EventData.settlement_sources`` (required, nullable). + + Typed ``NullableList[SettlementSource]`` so a JSON ``null`` coerces to ``[]`` + while the key-present contract still holds (same shape as + ``Series.settlement_sources``). + """ + + def test_null_coerces_to_empty_list(self) -> None: + e = Event.model_validate(event_dict(settlement_sources=None)) + assert e.settlement_sources == [] + + def test_parses_list_of_sources(self) -> None: + e = Event.model_validate( + event_dict( + settlement_sources=[ + {"url": "https://example.com", "name": "NWS"}, + {"url": None, "name": None}, + ] + ) + ) + assert len(e.settlement_sources) == 2 + assert all(isinstance(s, SettlementSource) for s in e.settlement_sources) + assert e.settlement_sources[0].url == "https://example.com" + assert e.settlement_sources[0].name == "NWS" + + def test_field_is_required(self) -> None: + """Spec marks the field required — the key must be present.""" + data = event_dict() + data.pop("settlement_sources") + with pytest.raises(ValidationError): + Event.model_validate(data) + + class TestEventMetadataV3180Fields: """v3.18.0 backfill (issue #160): 2 new optional fields on ``EventMetadata``.""" diff --git a/tests/ws/test_models.py b/tests/ws/test_models.py index 884b696..42ee73a 100644 --- a/tests/ws/test_models.py +++ b/tests/ws/test_models.py @@ -1548,6 +1548,42 @@ def test_floor_strike_rejects_bool(self) -> None: MarketLifecycleMessage.model_validate(raw) +class TestMarketLifecycleStrikeFields: + """#451 spec drift: ``metadata_updated`` lifecycle payloads gained + ``strike_type`` / ``cap_strike`` / ``custom_strike`` (all optional).""" + + def test_parses_between_strike(self) -> None: + """The asyncapi 'between' example: strike_type + floor + cap together.""" + raw = { + "type": "market_lifecycle_v2", + "sid": 1, + "msg": { + "event_type": "metadata_updated", + "market_ticker": "T", + "strike_type": "between", + "floor_strike": 95000, + "cap_strike": 95250.5, + "custom_strike": {"team": "A"}, + }, + } + msg = MarketLifecycleMessage.model_validate(raw) + assert msg.msg.strike_type == "between" + assert isinstance(msg.msg.cap_strike, Decimal) + assert msg.msg.cap_strike == Decimal("95250.5") + assert msg.msg.custom_strike == {"team": "A"} + + def test_strike_fields_default_to_none(self) -> None: + raw = { + "type": "market_lifecycle_v2", + "sid": 1, + "msg": {"event_type": "created", "market_ticker": "T"}, + } + msg = MarketLifecycleMessage.model_validate(raw) + assert msg.msg.strike_type is None + assert msg.msg.cap_strike is None + assert msg.msg.custom_strike is None + + class TestWsPayloadsRejectNaiveDatetime: """#270 Item 1: WS payloads with datetime fields must reject naive RFC3339 strings.