diff --git a/kalshi/models/markets.py b/kalshi/models/markets.py index b50561b..e26a242 100644 --- a/kalshi/models/markets.py +++ b/kalshi/models/markets.py @@ -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 @@ -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} diff --git a/kalshi/models/orders.py b/kalshi/models/orders.py index cc17dde..d84a6f7 100644 --- a/kalshi/models/orders.py +++ b/kalshi/models/orders.py @@ -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} @@ -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} diff --git a/tests/test_models.py b/tests/test_models.py index 41ebeef..5419555 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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 @@ -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", @@ -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 (