From 9213030375332588415ad46e30e4c21add56d97f Mon Sep 17 00:00:00 2001 From: Jeff West Date: Tue, 19 May 2026 06:05:20 -0500 Subject: [PATCH 1/2] feat(models): backfill v3.18.0 fields on Market / Order / Fill (#159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Response-side spec drift hardening, PR1 of the stack (#157). Adds 23 optional fields the spec gained in v3.18.0 across the three highest- traffic response models. Eliminates the three `test_additive_drift` warnings for these models without changing existing field semantics. Fields added ------------ `Market` (+11): custom_strike (dict[str, Any]), early_close_condition (str), exchange_index (int), fee_waiver_expiration_time (datetime), functional_strike (str), is_provisional (bool), mve_collection_ticker (str), mve_selected_legs (list[dict]), price_level_structure (str), price_ranges (list[dict]), primary_participant_key (str) `Order` (+8): outcome_side (SideLiteral), book_side (BookSideLiteral), last_update_time (datetime), self_trade_prevention_type (SelfTradePreventionTypeLiteral), order_group_id (str), cancel_order_on_pause (bool), subaccount_number (int), exchange_index (int) `Fill` (+4): outcome_side (SideLiteral), book_side (BookSideLiteral), subaccount_number (int), ts (int) Design decisions ---------------- - `mve_selected_legs` and `price_ranges` are typed as `list[dict[str, Any]]` rather than introducing new nested model classes (`MveSelectedLeg`, `PriceRange`). Per the issue: 'Don't invent new Literal aliases in this PR — that's separable polish'; same rationale applies to nested model classes. Spec drift detection only checks property presence, not nested-property typing. - `outcome_side` / `book_side` reuse the existing `SideLiteral` / `BookSideLiteral` aliases defined at the top of `orders.py`. Same for `self_trade_prevention_type` reusing `SelfTradePreventionTypeLiteral`. - `Fill.ts` stays `int | None` (NOT `datetime`). Spec says it's a Unix-ms timestamp; the typed companion is `created_time: datetime`. Auto-coercing would break callers depending on the raw integer. - `Order.subaccount_number` is distinct from the existing `Order.subaccount: int | None`. Both coexist per spec. - All new fields appended at the end of each model with a `v3.18.0 backfill` comment block. No existing fields reordered; append-only matches the spec's evolution pattern. Tests ----- 3 new test classes in `tests/test_models.py` (alongside the existing `TestDollarsAliasFields` / `TestMarketOccurrenceDatetime` precedent; `tests/test_orders.py` is the wrong home — it tests resource HTTP wire shapes, not pure model deserialization): - `TestMarketV3180Fields` (3 tests): scalar fields, object+array fields, defaults-to-None for all 11. - `TestOrderV3180Fields` (4 tests): outcome+book side, remaining 6 fields, subaccount_number-vs-subaccount distinction, defaults-to-None for all 8. - `TestFillV3180Fields` (3 tests): all 4 new fields, ts-is-int-not- datetime regression guard, defaults-to-None for all 4. Verification ------------ uv run pytest tests/test_contracts.py::TestSpecDrift::test_additive_drift[Market,Order,Fill] -W error -> 3 passed (was: 3 failed) uv run pytest tests/ --ignore=tests/integration -q -> 1955 passed, 54 warnings (baseline 1945 + 10 new tests; warnings dropped from 57 to 54 — 3 additive drifts gone) uv run mypy kalshi/ -> Success: no issues uv run ruff check . -> All checks passed! Remaining `Required drift in Market` warning gains two new entries (`price_ranges`, `price_level_structure`) because the spec marks them required but the SDK keeps them `Optional` per the project's pervasive required-but-optional policy. The issue explicitly defers this as separate decision (warn-only, ~204 entries). Closes #159. --- kalshi/models/markets.py | 19 ++++- kalshi/models/orders.py | 22 ++++++ tests/test_models.py | 165 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 1 deletion(-) diff --git a/kalshi/models/markets.py b/kalshi/models/markets.py index b50561b..4cb93e7 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,23 @@ class Market(BaseModel): rules_primary: str | None = None rules_secondary: str | None = None + # v3.18.0 backfill: new optional response fields (issue #159). + # See specs/openapi.yaml `Market` schema. `mve_selected_legs` is + # `list[MveSelectedLeg]` on the wire and `price_ranges` is `list[PriceRange]`; + # both are kept as `list[dict[str, Any]]` here to avoid introducing new + # nested model classes in the backfill PR (separable polish per issue). + 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..c0a86a5 100644 --- a/kalshi/models/orders.py +++ b/kalshi/models/orders.py @@ -100,6 +100,20 @@ class Order(BaseModel): client_order_id: str | None = None subaccount: int | None = None + # v3.18.0 backfill: new optional response fields (issue #159). + # `outcome_side` / `book_side` are the canonical direction encoding going + # forward; the deprecated `action` / `side` / `is_yes` triple stays for + # back-compat. `subaccount_number` is distinct from the existing + # `subaccount` field (both coexist per spec). + 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 +150,14 @@ class Fill(BaseModel): ) created_time: datetime | None = None + # v3.18.0 backfill: new optional response fields (issue #159). + # `ts` is a Unix-ms integer per spec (legacy field; distinct from + # `created_time: datetime`). Do NOT coerce to datetime. + 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..e06f4af 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -196,6 +196,171 @@ 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: + from kalshi.models.orders import Fill + + 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.""" + from kalshi.models.orders import Fill + + 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: + from kalshi.models.orders import Fill + + 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 ( From 67623d5522dd6436ce814af56edc3fd446f7e11a Mon Sep 17 00:00:00 2001 From: Jeff West Date: Tue, 19 May 2026 06:11:00 -0500 Subject: [PATCH 2/2] test(models): address PR #166 review feedback Two fixes from claude[bot]'s review (#166): 1. Hoist `Fill` to module-level imports in `tests/test_models.py`. `Market` and `Order` were already top-level; `Fill` was redundantly re-imported inside four test method bodies (one existing, three new in PR1). Now: `from kalshi.models.orders import Fill, Order` once, four inline imports removed. 2. Trim the `# v3.18.0 backfill` comment blocks in markets.py and orders.py. Per CLAUDE.md ("only document the WHY, not the what"): the verbose phrasing partially restated what the field declarations themselves convey. Trimmed each block to 2-3 lines that preserve the non-obvious WHYs: - Market: why list[dict] instead of typed MveSelectedLeg/PriceRange - Order: why outcome_side/book_side coexist with deprecated action/side/is_yes; subaccount_number distinct from subaccount - Fill: why ts stays int (Unix-ms) instead of datetime coercion Extended rationale lives in the PR description where it belongs. Verification: pytest tests/test_models.py -> 79 passed pytest test_additive_drift[Market,Order,Fill] -W error -> 3 passed mypy kalshi/ -> Success ruff check . -> All passed --- kalshi/models/markets.py | 8 +++----- kalshi/models/orders.py | 13 +++++-------- tests/test_models.py | 10 +--------- 3 files changed, 9 insertions(+), 22 deletions(-) diff --git a/kalshi/models/markets.py b/kalshi/models/markets.py index 4cb93e7..e26a242 100644 --- a/kalshi/models/markets.py +++ b/kalshi/models/markets.py @@ -135,11 +135,9 @@ class Market(BaseModel): rules_primary: str | None = None rules_secondary: str | None = None - # v3.18.0 backfill: new optional response fields (issue #159). - # See specs/openapi.yaml `Market` schema. `mve_selected_legs` is - # `list[MveSelectedLeg]` on the wire and `price_ranges` is `list[PriceRange]`; - # both are kept as `list[dict[str, Any]]` here to avoid introducing new - # nested model classes in the backfill PR (separable polish per issue). + # 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 diff --git a/kalshi/models/orders.py b/kalshi/models/orders.py index c0a86a5..d84a6f7 100644 --- a/kalshi/models/orders.py +++ b/kalshi/models/orders.py @@ -100,11 +100,9 @@ class Order(BaseModel): client_order_id: str | None = None subaccount: int | None = None - # v3.18.0 backfill: new optional response fields (issue #159). - # `outcome_side` / `book_side` are the canonical direction encoding going - # forward; the deprecated `action` / `side` / `is_yes` triple stays for - # back-compat. `subaccount_number` is distinct from the existing - # `subaccount` field (both coexist per spec). + # 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 @@ -150,9 +148,8 @@ class Fill(BaseModel): ) created_time: datetime | None = None - # v3.18.0 backfill: new optional response fields (issue #159). - # `ts` is a Unix-ms integer per spec (legacy field; distinct from - # `created_time: datetime`). Do NOT coerce to datetime. + # 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 diff --git a/tests/test_models.py b/tests/test_models.py index e06f4af..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", @@ -328,8 +326,6 @@ class TestFillV3180Fields: """v3.18.0 backfill (issue #159): 4 new optional fields on ``Fill``.""" def test_parses_new_fields(self) -> None: - from kalshi.models.orders import Fill - f = Fill.model_validate({ "trade_id": "t1", "outcome_side": "no", @@ -346,16 +342,12 @@ 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.""" - from kalshi.models.orders import Fill - 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: - from kalshi.models.orders import Fill - 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"