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
17 changes: 16 additions & 1 deletion kalshi/models/markets.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from datetime import datetime
from decimal import Decimal
from typing import Literal
from typing import Any, Literal

from pydantic import AliasChoices, BaseModel, Field

Expand Down Expand Up @@ -135,6 +135,21 @@ class Market(BaseModel):
rules_primary: str | None = None
rules_secondary: str | None = None

# v3.18.0 backfill (#159). mve_selected_legs is list[MveSelectedLeg] and
# price_ranges is list[PriceRange] on the wire — kept as list[dict] here;
# no nested model classes yet (separable polish).
custom_strike: dict[str, Any] | None = None
early_close_condition: str | None = None
exchange_index: int | None = None
fee_waiver_expiration_time: datetime | None = None
functional_strike: str | None = None
is_provisional: bool | None = None
mve_collection_ticker: str | None = None
mve_selected_legs: list[dict[str, Any]] | None = None
price_level_structure: str | None = None
price_ranges: list[dict[str, Any]] | None = None
primary_participant_key: str | None = None

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


Expand Down
19 changes: 19 additions & 0 deletions kalshi/models/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,18 @@ class Order(BaseModel):
client_order_id: str | None = None
subaccount: int | None = None

# v3.18.0 backfill (#159). outcome_side/book_side are the canonical
# direction encoding going forward; deprecated action/side/is_yes stay
# for back-compat. subaccount_number is distinct from subaccount.
outcome_side: SideLiteral | None = None
book_side: BookSideLiteral | None = None
last_update_time: datetime | None = None
self_trade_prevention_type: SelfTradePreventionTypeLiteral | None = None
order_group_id: str | None = None
cancel_order_on_pause: bool | None = None
subaccount_number: int | None = None
exchange_index: int | None = None

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


Expand Down Expand Up @@ -136,6 +148,13 @@ class Fill(BaseModel):
)
created_time: datetime | None = None

# v3.18.0 backfill (#159). ts is Unix-ms int per spec — distinct from
# the typed created_time: datetime; do NOT coerce.
outcome_side: SideLiteral | None = None
book_side: BookSideLiteral | None = None
subaccount_number: int | None = None
ts: int | None = None

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


Expand Down
163 changes: 160 additions & 3 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import pytest

from kalshi.models.markets import Market
from kalshi.models.orders import Order
from kalshi.models.orders import Fill, Order
from kalshi.types import to_decimal


Expand Down Expand Up @@ -147,8 +147,6 @@ def test_order_has_no_type_field(self) -> None:
assert "order_type" in Order.model_fields

def test_fill_accepts_dollars_suffix(self) -> None:
from kalshi.models.orders import Fill

f = Fill.model_validate({
"trade_id": "t1",
"yes_price_dollars": "0.5000",
Expand Down Expand Up @@ -196,6 +194,165 @@ def test_create_order_serializes_with_dollars_alias(self) -> None:
assert "yes_price" not in data


class TestMarketV3180Fields:
"""v3.18.0 backfill (issue #159): 11 new optional fields on ``Market``.

Spec adds direction-of-evolution fields (``price_level_structure`` /
``price_ranges`` superseding the deprecated ``response_price_units``)
plus multivariate-event linkage and exchange-shard metadata.
"""

def test_parses_all_new_scalar_fields(self) -> None:
m = Market.model_validate({
"ticker": "T",
"early_close_condition": "if_settled_early",
"exchange_index": 7,
"fee_waiver_expiration_time": "2026-06-01T00:00:00Z",
"functional_strike": "x**2",
"is_provisional": True,
"mve_collection_ticker": "COLL-001",
"price_level_structure": "tick_5_cent",
"primary_participant_key": "team-a",
})
assert m.early_close_condition == "if_settled_early"
assert m.exchange_index == 7
assert m.fee_waiver_expiration_time is not None
assert m.fee_waiver_expiration_time.year == 2026
assert isinstance(m.fee_waiver_expiration_time, datetime)
assert m.functional_strike == "x**2"
assert m.is_provisional is True
assert m.mve_collection_ticker == "COLL-001"
assert m.price_level_structure == "tick_5_cent"
assert m.primary_participant_key == "team-a"

def test_parses_object_and_array_fields(self) -> None:
m = Market.model_validate({
"ticker": "T",
"custom_strike": {"yes_threshold": 50, "no_threshold": 100},
"mve_selected_legs": [
{"event_ticker": "EVT-001", "market_ticker": "MKT-001", "side": "yes"},
],
"price_ranges": [
{"start": "0.01", "end": "0.99", "step": "0.01"},
],
})
assert m.custom_strike == {"yes_threshold": 50, "no_threshold": 100}
assert m.mve_selected_legs is not None
assert m.mve_selected_legs[0]["event_ticker"] == "EVT-001"
assert m.price_ranges is not None
assert m.price_ranges[0]["step"] == "0.01"

def test_all_new_fields_default_to_none(self) -> None:
m = Market(ticker="T")
for name in (
"custom_strike",
"early_close_condition",
"exchange_index",
"fee_waiver_expiration_time",
"functional_strike",
"is_provisional",
"mve_collection_ticker",
"mve_selected_legs",
"price_level_structure",
"price_ranges",
"primary_participant_key",
):
assert getattr(m, name) is None, f"{name} should default to None"


class TestOrderV3180Fields:
"""v3.18.0 backfill (issue #159): 8 new optional fields on ``Order``.

``outcome_side`` / ``book_side`` are the canonical direction encoding
going forward; the deprecated ``action`` field (allowlisted in #158)
stays for back-compat. ``subaccount_number`` is distinct from the
existing ``subaccount`` field.
"""

def test_parses_outcome_and_book_side(self) -> None:
o = Order.model_validate({
"order_id": "x",
"outcome_side": "yes",
"book_side": "bid",
})
assert o.outcome_side == "yes"
assert o.book_side == "bid"

def test_parses_remaining_new_fields(self) -> None:
o = Order.model_validate({
"order_id": "x",
"cancel_order_on_pause": True,
"exchange_index": 3,
"last_update_time": "2026-05-01T12:00:00Z",
"order_group_id": "group-42",
"self_trade_prevention_type": "taker_at_cross",
"subaccount_number": 5,
})
assert o.cancel_order_on_pause is True
assert o.exchange_index == 3
assert o.last_update_time is not None
assert isinstance(o.last_update_time, datetime)
assert o.order_group_id == "group-42"
assert o.self_trade_prevention_type == "taker_at_cross"
assert o.subaccount_number == 5

def test_subaccount_number_is_distinct_from_subaccount(self) -> None:
"""``subaccount_number`` was added in v3.18.0 separately from the
existing ``subaccount`` field. Both must coexist."""
o = Order.model_validate({
"order_id": "x",
"subaccount": 1,
"subaccount_number": 5,
})
assert o.subaccount == 1
assert o.subaccount_number == 5

def test_all_new_fields_default_to_none(self) -> None:
o = Order(order_id="x")
for name in (
"outcome_side",
"book_side",
"last_update_time",
"self_trade_prevention_type",
"order_group_id",
"cancel_order_on_pause",
"subaccount_number",
"exchange_index",
):
assert getattr(o, name) is None, f"{name} should default to None"


class TestFillV3180Fields:
"""v3.18.0 backfill (issue #159): 4 new optional fields on ``Fill``."""

def test_parses_new_fields(self) -> None:
f = Fill.model_validate({
"trade_id": "t1",
"outcome_side": "no",
"book_side": "ask",
"subaccount_number": 2,
"ts": 1733047200000,
})
assert f.outcome_side == "no"
assert f.book_side == "ask"
assert f.subaccount_number == 2
assert f.ts == 1733047200000

def test_ts_is_int_not_datetime(self) -> None:
"""Spec declares ``ts: integer`` (Unix-ms timestamp). The SDK must NOT
coerce it to ``datetime`` — it's the legacy companion to the typed
``created_time: datetime`` field, and callers depend on the raw int."""
f = Fill.model_validate({"trade_id": "t1", "ts": 1733047200000})
assert f.ts == 1733047200000
assert isinstance(f.ts, int)
assert not isinstance(f.ts, bool) # bools are ints; guard against ambiguity

def test_all_new_fields_default_to_none(self) -> None:
f = Fill(trade_id="t1")
for name in ("outcome_side", "book_side", "subaccount_number", "ts"):
assert getattr(f, name) is None, f"{name} should default to None"


class TestErrorHierarchy:
def test_all_errors_inherit_base(self) -> None:
from kalshi.errors import (
Expand Down
Loading