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
5 changes: 2 additions & 3 deletions kalshi/models/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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}
Expand Down
5 changes: 2 additions & 3 deletions kalshi/models/markets.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

from decimal import Decimal
from typing import Any, Literal

from pydantic import AliasChoices, AwareDatetime, BaseModel, Field
Expand Down Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions kalshi/models/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand All @@ -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"}
Expand Down
16 changes: 16 additions & 0 deletions kalshi/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions kalshi/ws/models/market_lifecycle.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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}
Expand Down
7 changes: 5 additions & 2 deletions kalshi/ws/models/order_group.py
Original file line number Diff line number Diff line change
@@ -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}
Expand Down
5 changes: 3 additions & 2 deletions kalshi/ws/models/ticker.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Ticker channel message models."""

from __future__ import annotations

from pydantic import AliasChoices, BaseModel, Field
Expand Down Expand Up @@ -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"),
)
Expand Down
22 changes: 10 additions & 12 deletions tests/integration/test_assertions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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"):
Expand All @@ -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
Expand Down Expand Up @@ -131,19 +133,15 @@ 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)


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

Expand Down Expand Up @@ -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))
49 changes: 48 additions & 1 deletion tests/test_series_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Loading
Loading