diff --git a/kalshi/models/events.py b/kalshi/models/events.py index d77f487..bd06845 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,12 @@ 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, #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] = [] @@ -75,15 +90,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 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.