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
24 changes: 15 additions & 9 deletions kalshi/models/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""

Expand All @@ -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] = []

Expand Down Expand Up @@ -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."""

Expand Down
7 changes: 7 additions & 0 deletions kalshi/ws/models/market_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
52 changes: 37 additions & 15 deletions specs/asyncapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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: >-
Expand Down Expand Up @@ -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
7 changes: 7 additions & 0 deletions specs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8072,6 +8072,7 @@ components:
- collateral_return_type
- mutually_exclusive
- available_on_brokers
- settlement_sources
properties:
event_ticker:
type: string
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/_model_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 35 additions & 1 deletion tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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``."""

Expand Down
36 changes: 36 additions & 0 deletions tests/ws/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down