diff --git a/kalshi/models/events.py b/kalshi/models/events.py index a353414d..c106a377 100644 --- a/kalshi/models/events.py +++ b/kalshi/models/events.py @@ -2,13 +2,12 @@ from __future__ import annotations -from decimal import Decimal from typing import Any, Literal from pydantic import AwareDatetime, BaseModel from kalshi.models.markets import Market -from kalshi.types import NullableList +from kalshi.types import MultiplierDecimal, NullableList EventStatusLiteral = Literal["unopened", "open", "closed", "settled"] """Event status filter for GET /events. Spec: GetEvents.status query enum.""" @@ -38,7 +37,7 @@ class Event(BaseModel): # format: double` per spec — Decimal (matches Market.floor_strike / # cap_strike precedent for the same wire shape). fee_type_override: str | None = None - fee_multiplier_override: Decimal | None = None + fee_multiplier_override: MultiplierDecimal | None = None exchange_index: int | None = None model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/models/markets.py b/kalshi/models/markets.py index 8a36cb30..70f06778 100644 --- a/kalshi/models/markets.py +++ b/kalshi/models/markets.py @@ -2,7 +2,6 @@ from __future__ import annotations -from decimal import Decimal from typing import Any, Literal from pydantic import AliasChoices, AwareDatetime, BaseModel, Field @@ -114,8 +113,8 @@ class Market(BaseModel): category: str | None = None risk_limit_cents: int | None = None strike_type: str | None = None - floor_strike: Decimal | None = None - cap_strike: Decimal | None = None + floor_strike: DollarDecimal | None = None + cap_strike: DollarDecimal | None = None rules_primary: str rules_secondary: str diff --git a/kalshi/models/series.py b/kalshi/models/series.py index 90531b4b..d326d955 100644 --- a/kalshi/models/series.py +++ b/kalshi/models/series.py @@ -7,7 +7,7 @@ from pydantic import AliasChoices, AwareDatetime, BaseModel, Field from kalshi.models.markets import Candlestick -from kalshi.types import FixedPointCount, NullableList +from kalshi.types import FixedPointCount, MultiplierDecimal, NullableList # Fee type constants (use str fields, not StrEnum, for forward-compat) FEE_TYPE_QUADRATIC = "quadratic" @@ -28,7 +28,7 @@ class Series(BaseModel): contract_terms_url: str product_metadata: dict[str, Any] | None = None fee_type: str - fee_multiplier: float + fee_multiplier: MultiplierDecimal additional_prohibitions: NullableList[str] volume: FixedPointCount | None = Field( default=None, @@ -45,7 +45,7 @@ class SeriesFeeChange(BaseModel): id: str series_ticker: str fee_type: str - fee_multiplier: float + fee_multiplier: MultiplierDecimal scheduled_ts: AwareDatetime model_config = {"extra": "allow"} diff --git a/kalshi/types.py b/kalshi/types.py index 281815e0..3b2651da 100644 --- a/kalshi/types.py +++ b/kalshi/types.py @@ -66,6 +66,22 @@ def _decimal_to_str(value: Decimal) -> str: """ +MultiplierDecimal = Annotated[ + Decimal, + BeforeValidator(_coerce_decimal), + PlainSerializer(_decimal_to_str, return_type=str, when_used="json"), +] +"""A Decimal field for fee/rate multipliers (e.g. ``Series.fee_multiplier``). + +Wire shape is ``type: number, format: double`` per spec. Using the bare +``float`` annotation commits the value to binary float at parse time +(``0.65 -> 0.65000000000000002...``); using bare ``Decimal`` skips +_coerce_decimal entirely and routes JSON numbers through float. This +alias applies the same coercion as :data:`DollarDecimal` / +:data:`FixedPointCount` so multipliers honor the #225 invariant. +""" + + def to_decimal(value: int | float | str | Decimal) -> Decimal: """Convert a user-supplied price/count value to Decimal safely. diff --git a/kalshi/ws/models/market_lifecycle.py b/kalshi/ws/models/market_lifecycle.py index e9e6af0d..ed5b02a1 100644 --- a/kalshi/ws/models/market_lifecycle.py +++ b/kalshi/ws/models/market_lifecycle.py @@ -1,7 +1,7 @@ """Market lifecycle v2 channel message models.""" + from __future__ import annotations -from decimal import Decimal from typing import Any from pydantic import BaseModel @@ -40,7 +40,7 @@ class MarketLifecyclePayload(BaseModel): # events; floor_strike/yes_sub_title for `metadata_updated`; # price_level_structure for `price_level_structure_updated` (or `created`). additional_metadata: dict[str, Any] | None = None - floor_strike: Decimal | None = None + floor_strike: DollarDecimal | 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/kalshi/ws/models/order_group.py b/kalshi/ws/models/order_group.py index 957fb161..6e05f0ed 100644 --- a/kalshi/ws/models/order_group.py +++ b/kalshi/ws/models/order_group.py @@ -1,18 +1,21 @@ """Order group updates channel message models.""" + from __future__ import annotations from pydantic import AliasChoices, BaseModel, Field +from kalshi.types import FixedPointCount + class OrderGroupPayload(BaseModel): """Payload for order_group_updates messages (private channel).""" event_type: str # created/triggered/reset/deleted/limit_updated order_group_id: str - contracts_limit: str | None = Field( + contracts_limit: FixedPointCount | None = Field( default=None, validation_alias=AliasChoices("contracts_limit_fp", "contracts_limit"), - ) # _fp format + ) # _fp format — coerced to Decimal per #225/#230 invariant # v0.14+ backfill (#162). Matching engine timestamp in Unix ms. ts_ms: int model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/ws/models/ticker.py b/kalshi/ws/models/ticker.py index 3b349215..95bb62e9 100644 --- a/kalshi/ws/models/ticker.py +++ b/kalshi/ws/models/ticker.py @@ -1,4 +1,5 @@ """Ticker channel message models.""" + from __future__ import annotations from pydantic import AliasChoices, BaseModel, Field @@ -36,8 +37,8 @@ class TickerPayload(BaseModel): open_interest: FixedPointCount = Field( validation_alias=AliasChoices("open_interest_fp", "open_interest"), ) - dollar_volume: str - dollar_open_interest: str + dollar_volume: DollarDecimal + dollar_open_interest: DollarDecimal yes_bid_size: FixedPointCount = Field( validation_alias=AliasChoices("yes_bid_size_fp", "yes_bid_size"), ) diff --git a/tests/integration/test_assertions.py b/tests/integration/test_assertions.py index 862e62e6..20472b07 100644 --- a/tests/integration/test_assertions.py +++ b/tests/integration/test_assertions.py @@ -18,11 +18,13 @@ class FakePrice(BaseModel): """Minimal model with a price-range field.""" + price: Decimal class FakeMarket(BaseModel): """Minimal model mimicking Market fields.""" + ticker: str yes_bid: Decimal | None = None created_time: datetime | None = None @@ -32,12 +34,13 @@ class FakeMarket(BaseModel): class FakeRateBearing(BaseModel): - """Model with a legitimately-float field (like Series.fee_multiplier). + """Model with a legitimately-float field (negative case for the oracle). Represents the negative case for the annotation-aware float check — when a field's annotation IS float, a float value must NOT trigger the 'DollarDecimal parsing failed' assertion. """ + ticker: str fee_multiplier: float = 0.0 optional_rate: float | None = None @@ -49,9 +52,7 @@ def test_passes_with_decimal(self) -> None: assert_model_fields(m) # should not raise def test_fails_with_float(self) -> None: - m = FakeMarket.__pydantic_validator__.validate_python( - {"ticker": "T"} - ) + m = FakeMarket.__pydantic_validator__.validate_python({"ticker": "T"}) # Manually set a float to simulate a parse failure object.__setattr__(m, "volume", 0.5) with pytest.raises(AssertionError, match=r"float.*expected Decimal"): @@ -60,8 +61,9 @@ def test_fails_with_float(self) -> None: def test_float_field_with_float_value_does_not_raise(self) -> None: """Regression: a field annotated as float with a float value is fine. - Series.fee_multiplier is a rate multiplier typed as float per spec. - Before the annotation-aware check, the oracle misfired here. + Some external models legitimately type rate fields as float; before the + annotation-aware check the oracle misfired on those. Series.fee_multiplier + itself uses MultiplierDecimal post-#259, so this is now a synthetic case. """ m = FakeRateBearing(ticker="T", fee_multiplier=1.5) assert_model_fields(m) # must not raise @@ -131,9 +133,7 @@ def test_passes_with_datetime(self) -> None: assert_model_fields(m) def test_fails_with_raw_string(self) -> None: - m = FakeMarket.__pydantic_validator__.validate_python( - {"ticker": "T"} - ) + m = FakeMarket.__pydantic_validator__.validate_python({"ticker": "T"}) object.__setattr__(m, "created_time", "2026-01-01T00:00:00Z") with pytest.raises(AssertionError, match=r"raw string.*expected datetime"): assert_model_fields(m) @@ -141,9 +141,7 @@ def test_fails_with_raw_string(self) -> None: class TestRequiredFields: def test_fails_when_required_is_none(self) -> None: - m = FakeMarket.__pydantic_validator__.validate_python( - {"ticker": "T"} - ) + m = FakeMarket.__pydantic_validator__.validate_python({"ticker": "T"}) object.__setattr__(m, "ticker", None) with pytest.raises(AssertionError, match="None but field is required"): assert_model_fields(m) diff --git a/tests/test_models.py b/tests/test_models.py index d406ae52..4e52b992 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1359,6 +1359,7 @@ def test_forbid_extra(self) -> None: # ---------- P2#4: AwareDatetime on REST response models ---------- + class TestAwareDatetimeRejectsNaive: """REST models reject naive datetimes at construction (#221 P2.4).""" @@ -1402,3 +1403,60 @@ def test_aware_datetime_accepts_rfc3339_with_z( assert ts.tzinfo is not None # UTC conversion round-trips cleanly. assert ts.astimezone(UTC).isoformat().startswith("2026-01-01T12:00:00") + + +class TestMarketStrikeDecimal: + """#259: floor_strike / cap_strike use DollarDecimal (not bare Decimal).""" + + def test_floor_strike_float_routed_through_str(self) -> None: + from tests._model_fixtures import market_dict + + m = Market.model_validate(market_dict(ticker="T", floor_strike=4487.65)) + assert isinstance(m.floor_strike, Decimal) + # Bare-Decimal path commits via float — DollarDecimal goes through str. + assert m.floor_strike == Decimal("4487.65") + assert str(m.floor_strike) == "4487.65" + + def test_cap_strike_string_parses(self) -> None: + from tests._model_fixtures import market_dict + + m = Market.model_validate(market_dict(ticker="T", cap_strike="100.50")) + assert isinstance(m.cap_strike, Decimal) + assert m.cap_strike == Decimal("100.50") + + def test_strikes_reject_bool(self) -> None: + import pytest + + from tests._model_fixtures import market_dict + + with pytest.raises(TypeError, match="bool"): + Market.model_validate(market_dict(ticker="T", floor_strike=True)) + with pytest.raises(TypeError, match="bool"): + Market.model_validate(market_dict(ticker="T", cap_strike=True)) + + def test_strikes_default_to_none(self) -> None: + from tests._model_fixtures import market_dict + + m = Market.model_validate(market_dict(ticker="T")) + assert m.floor_strike is None + assert m.cap_strike is None + + +class TestEventFeeMultiplierOverrideDecimal: + """#259: Event.fee_multiplier_override uses MultiplierDecimal.""" + + def test_float_routed_through_str(self) -> None: + from tests._model_fixtures import event_dict + + e = Event.model_validate(event_dict(fee_multiplier_override=0.65)) + assert isinstance(e.fee_multiplier_override, Decimal) + assert e.fee_multiplier_override == Decimal("0.65") + assert str(e.fee_multiplier_override) == "0.65" + + def test_rejects_bool(self) -> None: + import pytest + + from tests._model_fixtures import event_dict + + with pytest.raises(TypeError, match="bool"): + Event.model_validate(event_dict(fee_multiplier_override=True)) diff --git a/tests/test_series_models.py b/tests/test_series_models.py index 62c2aeb7..801a714f 100644 --- a/tests/test_series_models.py +++ b/tests/test_series_models.py @@ -3,7 +3,7 @@ from __future__ import annotations from decimal import Decimal -from typing import Any +from typing import Any, ClassVar from kalshi.models.series import ( EventCandlesticks, @@ -238,3 +238,50 @@ def test_parse_with_percentile_points(self) -> None: assert len(fp.percentile_points) == 2 assert fp.percentile_points[0].percentile == 2500 assert fp.percentile_points[1].formatted_forecast == "5.5%" + + +class TestSeriesFeeMultiplierDecimal: + """#259: fee_multiplier coerces to Decimal via MultiplierDecimal (not float).""" + + _BASE: ClassVar[dict[str, Any]] = { + "ticker": "T", + "frequency": "daily", + "title": "T", + "category": "T", + "tags": [], + "settlement_sources": [], + "contract_url": "", + "contract_terms_url": "", + "fee_type": "quadratic", + "additional_prohibitions": [], + } + + def test_fee_multiplier_string_coerced_to_decimal(self) -> None: + s = Series.model_validate({**self._BASE, "fee_multiplier": "0.65"}) + assert isinstance(s.fee_multiplier, Decimal) + assert s.fee_multiplier == Decimal("0.65") + # Critical: not subject to binary float drift. + assert str(s.fee_multiplier) == "0.65" + + def test_fee_multiplier_float_routed_through_str(self) -> None: + """JSON-number 0.65 must not commit to binary float (the #259 motivation).""" + s = Series.model_validate({**self._BASE, "fee_multiplier": 0.65}) + assert s.fee_multiplier == Decimal("0.65") + assert s.fee_multiplier * Decimal("100") == Decimal("65.00") + + def test_fee_multiplier_rejects_bool(self) -> None: + import pytest + + with pytest.raises(TypeError, match="bool"): + Series.model_validate({**self._BASE, "fee_multiplier": True}) + + def test_series_fee_change_multiplier_decimal(self) -> None: + fc = SeriesFeeChange.model_validate({ + "id": "fc-1", + "series_ticker": "T", + "fee_type": "flat", + "fee_multiplier": 1.5, + "scheduled_ts": "2026-01-01T00:00:00Z", + }) + assert isinstance(fc.fee_multiplier, Decimal) + assert fc.fee_multiplier == Decimal("1.5") diff --git a/tests/ws/test_models.py b/tests/ws/test_models.py index 77f6b4e8..a35933ff 100644 --- a/tests/ws/test_models.py +++ b/tests/ws/test_models.py @@ -205,6 +205,42 @@ def test_ticker_extra_fields(self) -> None: msg = TickerMessage.model_validate(raw) assert msg.msg.market_ticker == "T" + def test_dollar_volume_and_open_interest_decimalized(self) -> None: + """#258: dollar_volume/dollar_open_interest must coerce to Decimal (not str).""" + raw = { + "type": "ticker", + "sid": 1, + "msg": ticker_payload_dict( + market_ticker="T", + market_id="x", + dollar_volume="123456.7890", + dollar_open_interest="42.50", + ), + } + msg = TickerMessage.model_validate(raw) + assert isinstance(msg.msg.dollar_volume, Decimal) + assert isinstance(msg.msg.dollar_open_interest, Decimal) + assert msg.msg.dollar_volume == Decimal("123456.7890") + assert msg.msg.dollar_open_interest == Decimal("42.50") + # Arithmetic without manual Decimal(...) wrapping (#258 motivation). + total = msg.msg.dollar_volume + msg.msg.dollar_open_interest + assert total == Decimal("123499.2890") + + def test_dollar_fields_reject_bool(self) -> None: + """#258: DollarDecimal coercion rejects bool (an int subclass).""" + with pytest.raises(TypeError, match="bool"): + TickerMessage.model_validate( + { + "type": "ticker", + "sid": 1, + "msg": ticker_payload_dict( + market_ticker="T", + market_id="x", + dollar_volume=True, + ), + } + ) + # ---------- Trade ---------- @@ -459,6 +495,40 @@ def test_order_group_triggered(self) -> None: assert msg.msg.event_type == "triggered" assert msg.msg.contracts_limit is None + def test_contracts_limit_decimalized_via_fp_alias(self) -> None: + """#258: contracts_limit/_fp coerces to Decimal (was raw str pre-#258).""" + raw = { + "type": "order_group_updates", + "sid": 6, + "seq": 12, + "msg": { + "event_type": "limit_updated", + "order_group_id": "og-004", + "contracts_limit_fp": "250.50", + "ts_ms": 1700000000000, + }, + } + msg = OrderGroupMessage.model_validate(raw) + assert isinstance(msg.msg.contracts_limit, Decimal) + assert msg.msg.contracts_limit == Decimal("250.50") + assert msg.msg.contracts_limit * 2 == Decimal("501.00") + + def test_contracts_limit_rejects_bool(self) -> None: + """#258: FixedPointCount coercion rejects bool (an int subclass).""" + raw = { + "type": "order_group_updates", + "sid": 6, + "seq": 13, + "msg": { + "event_type": "created", + "order_group_id": "og-005", + "contracts_limit": True, + "ts_ms": 1700000000000, + }, + } + with pytest.raises(TypeError, match="bool"): + OrderGroupMessage.model_validate(raw) + # ---------- MarketLifecycle ---------- @@ -980,9 +1050,7 @@ def test_market_positions_position_volume_parse_as_decimal(self) -> None: { "type": "market_positions", "sid": 1, - "msg": market_positions_payload_dict( - position_fp="100", volume_fp="200" - ), + "msg": market_positions_payload_dict(position_fp="100", volume_fp="200"), } ) assert isinstance(msg.msg.position, Decimal) @@ -1189,6 +1257,8 @@ def test_orderbook_delta_payload_side_rejects_trailing_whitespace(self) -> None: "side": "yes ", } ) + + # ---------- P2#3: populate_by_name across every WS payload model ---------- _SDK_NAME_PAYLOADS: list[tuple[type, dict[str, object]]] = [ @@ -1201,8 +1271,8 @@ def test_orderbook_delta_payload_side_rejects_trailing_whitespace(self) -> None: "yes_ask": Decimal("0.51"), "volume": Decimal("100.00"), "open_interest": Decimal("200.00"), - "dollar_volume": "50.00", - "dollar_open_interest": "100.00", + "dollar_volume": Decimal("50.00"), + "dollar_open_interest": Decimal("100.00"), "yes_bid_size": Decimal("10.00"), "yes_ask_size": Decimal("10.00"), "last_trade_size": Decimal("1.00"), @@ -1288,7 +1358,7 @@ def test_orderbook_delta_payload_side_rejects_trailing_whitespace(self) -> None: { "event_type": "created", "order_group_id": "og-1", - "contracts_limit": "10.00", + "contracts_limit": Decimal("10.00"), "ts_ms": 1_700_000_000_000, }, ), @@ -1330,3 +1400,34 @@ def test_ws_payload_constructable_with_sdk_field_names( payload = payload_cls(**kwargs) for key, value in kwargs.items(): assert getattr(payload, key) == value, key + + +class TestMarketLifecycleFloorStrikeDecimal: + """#259: WS MarketLifecyclePayload.floor_strike uses DollarDecimal.""" + + def test_float_floor_strike_routed_through_str(self) -> None: + raw = { + "type": "market_lifecycle_v2", + "sid": 1, + "msg": { + "event_type": "metadata_updated", + "market_ticker": "T", + "floor_strike": 95000.65, + }, + } + msg = MarketLifecycleMessage.model_validate(raw) + assert isinstance(msg.msg.floor_strike, Decimal) + assert msg.msg.floor_strike == Decimal("95000.65") + + def test_floor_strike_rejects_bool(self) -> None: + raw = { + "type": "market_lifecycle_v2", + "sid": 1, + "msg": { + "event_type": "metadata_updated", + "market_ticker": "T", + "floor_strike": True, + }, + } + with pytest.raises(TypeError, match="bool"): + MarketLifecycleMessage.model_validate(raw)