diff --git a/kalshi/models/historical.py b/kalshi/models/historical.py index 9097419c..f4f35617 100644 --- a/kalshi/models/historical.py +++ b/kalshi/models/historical.py @@ -26,7 +26,14 @@ class HistoricalCutoff(BaseModel): class Trade(BaseModel): - """A public trade on the exchange.""" + """A public trade on the exchange. + + ``count`` is typed ``FixedPointCount | None`` without ``default=None`` + because the OpenAPI spec (``Trade.required``) marks ``count_fp`` as a + **required** response key. The ``| None`` admits server-side ``null`` + on rare degenerate trades while a missing key still raises + ValidationError — schema regression, not a default. + """ trade_id: str ticker: str diff --git a/kalshi/models/orders.py b/kalshi/models/orders.py index 8b7e3c10..e020721b 100644 --- a/kalshi/models/orders.py +++ b/kalshi/models/orders.py @@ -171,8 +171,8 @@ class CreateOrderRequest(BaseModel): """ ticker: str - side: str - action: str + side: SideLiteral + action: ActionLiteral count: FixedPointCount = Field(serialization_alias="count_fp") yes_price: DollarDecimal | None = Field( default=None, @@ -185,10 +185,10 @@ class CreateOrderRequest(BaseModel): client_order_id: str | None = None expiration_ts: int | None = None buy_max_cost: int | None = None - time_in_force: str | None = None + time_in_force: TimeInForceLiteral | None = None post_only: bool | None = None reduce_only: bool | None = None - self_trade_prevention_type: str | None = None + self_trade_prevention_type: SelfTradePreventionTypeLiteral | None = None order_group_id: str | None = None cancel_order_on_pause: bool | None = None subaccount: int | None = None diff --git a/kalshi/models/portfolio.py b/kalshi/models/portfolio.py index 62b312cd..2f33b93a 100644 --- a/kalshi/models/portfolio.py +++ b/kalshi/models/portfolio.py @@ -6,7 +6,7 @@ from pydantic import AliasChoices, AwareDatetime, BaseModel, Field -from kalshi.types import DollarDecimal, FixedPointCount, NullableList +from kalshi.types import DollarDecimal, FixedPointCount, NullableList, UnixSecondsTimestamp SettlementStatusLiteral = Literal["all", "unsettled", "settled"] """Position settlement status filter for GET /fcm/positions. Spec: settlement_status query enum.""" @@ -44,7 +44,7 @@ class Balance(BaseModel): balance: int balance_dollars: DollarDecimal portfolio_value: int - updated_ts: int + updated_ts: UnixSecondsTimestamp balance_breakdown: list[IndexedBalance] | None = None model_config = {"extra": "allow"} @@ -63,7 +63,16 @@ class TotalRestingOrderValue(BaseModel): class MarketPosition(BaseModel): - """A position in a single market.""" + """A position in a single market. + + ``total_traded`` and ``position`` are typed ``DollarDecimal | None`` / + ``FixedPointCount | None`` without ``default=None`` because the OpenAPI + spec (``MarketPosition.required``) marks ``total_traded_dollars`` and + ``position_fp`` as **required** response keys. The ``| None`` admits the + server's observed ``null`` on flat positions while a missing key still + raises ValidationError — silent omission is treated as a schema regression, + not a default. + """ ticker: str total_traded: DollarDecimal | None = Field( @@ -142,8 +151,8 @@ class Deposit(BaseModel): type: PaymentTypeLiteral amount_cents: int fee_cents: int - created_ts: int - finalized_ts: int | None = None + created_ts: UnixSecondsTimestamp + finalized_ts: UnixSecondsTimestamp | None = None model_config = {"extra": "allow"} @@ -156,8 +165,8 @@ class Withdrawal(BaseModel): type: PaymentTypeLiteral amount_cents: int fee_cents: int - created_ts: int - finalized_ts: int | None = None + created_ts: UnixSecondsTimestamp + finalized_ts: UnixSecondsTimestamp | None = None model_config = {"extra": "allow"} diff --git a/kalshi/types.py b/kalshi/types.py index 3b2651da..65d6f04a 100644 --- a/kalshi/types.py +++ b/kalshi/types.py @@ -18,19 +18,28 @@ def _coerce_decimal(value: Any) -> Decimal: differ only in their canonical wire name and intent; the decimal coercion is the same. Going through ``str(value)`` for ``int`` / ``float`` avoids the binary-float representation drift (``Decimal(0.65) == Decimal('0.65000000000000002...')``). + + Non-finite values (``NaN``, ``Infinity``, ``-Infinity``) are rejected after coercion. + ``f"{Decimal('NaN'):f}"`` returns the literal string ``"NaN"``; without this guard + a caller doing arithmetic that produces NaN and then constructing a Dollar/FP-typed + request would ship the string ``"NaN"`` straight to a real-money endpoint. """ - if isinstance(value, Decimal): - return value if isinstance(value, bool): raise TypeError( "Cannot convert bool to Decimal — bool is an int subclass, " "so this is almost always a typo (did you mean count=1?)." ) - if isinstance(value, (int, float)): - return Decimal(str(value)) - if isinstance(value, str): - return Decimal(value) - raise TypeError(f"Cannot convert {type(value).__name__} to Decimal") + if isinstance(value, Decimal): + result = value + elif isinstance(value, (int, float)): + result = Decimal(str(value)) + elif isinstance(value, str): + result = Decimal(value) + else: + raise TypeError(f"Cannot convert {type(value).__name__} to Decimal") + if not result.is_finite(): + raise ValueError("Decimal must be finite (got NaN/Infinity)") + return result def _decimal_to_str(value: Decimal) -> str: @@ -116,3 +125,16 @@ def _none_to_empty_list(value: Any) -> Any: the live API has been observed to return null. Do NOT use on request bodies — those should enforce strict validation. """ + +# Internal type aid — not re-exported via kalshi.__init__. The bare int +# wire shape is preserved; this alias only documents intent and gives mypy/IDE +# a greppable name when balance/deposit/withdrawal timestamps are read +# alongside the AwareDatetime peers on MarketPosition/Settlement. +UnixSecondsTimestamp = Annotated[int, "Unix epoch seconds (int64) per OpenAPI spec"] +"""Server-side ``format: int64`` Unix-seconds timestamp. + +Same wire shape as plain ``int``; the alias exists to make it obvious at the +field site that the value is *seconds* (not milliseconds, not a datetime). +Callers wanting a :class:`datetime.datetime` can use +``datetime.fromtimestamp(value, tz=timezone.utc)``. +""" diff --git a/kalshi/ws/models/communications.py b/kalshi/ws/models/communications.py index dea71786..b8a270fc 100644 --- a/kalshi/ws/models/communications.py +++ b/kalshi/ws/models/communications.py @@ -1,9 +1,7 @@ """Communications channel message models (RFQ and quote notifications).""" from __future__ import annotations -from datetime import datetime - -from pydantic import AliasChoices, BaseModel, Field +from pydantic import AliasChoices, AwareDatetime, BaseModel, Field from kalshi.types import DollarDecimal, FixedPointCount @@ -19,7 +17,7 @@ class RfqCreatedPayload(BaseModel): id: str creator_id: str market_ticker: str - created_ts: datetime + created_ts: AwareDatetime event_ticker: str | None = None contracts: FixedPointCount | None = Field( default=None, @@ -47,7 +45,7 @@ class RfqDeletedPayload(BaseModel): id: str creator_id: str market_ticker: str - deleted_ts: datetime + deleted_ts: AwareDatetime # v0.14+ backfill (#162). Same RFQ context as RfqCreatedPayload — # surfaced again on delete for clients that subscribed mid-RFQ. @@ -76,7 +74,7 @@ class QuoteCreatedPayload(BaseModel): no_bid: DollarDecimal = Field( validation_alias=AliasChoices("no_bid_dollars", "no_bid"), ) - created_ts: datetime + created_ts: AwareDatetime # v0.14+ backfill (#162). Event linkage + RFQ offer/cost context echoed # on the quote so subscribers don't need to look up the parent RFQ. @@ -143,7 +141,7 @@ class QuoteExecutedPayload(BaseModel): order_id: str client_order_id: str market_ticker: str - executed_ts: datetime + executed_ts: AwareDatetime model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/ws/models/user_orders.py b/kalshi/ws/models/user_orders.py index 3b2f52b5..d24dc28f 100644 --- a/kalshi/ws/models/user_orders.py +++ b/kalshi/ws/models/user_orders.py @@ -1,9 +1,7 @@ """User orders channel message models.""" from __future__ import annotations -from datetime import datetime - -from pydantic import AliasChoices, BaseModel, Field +from pydantic import AliasChoices, AwareDatetime, BaseModel, Field from kalshi.models.orders import ( BookSideLiteral, @@ -55,10 +53,10 @@ class UserOrdersPayload(BaseModel): validation_alias=AliasChoices("maker_fees_dollars", "maker_fees"), ) client_order_id: str - created_time: datetime + created_time: AwareDatetime order_group_id: str | None = None - last_update_time: datetime | None = None - expiration_time: datetime | None = None + last_update_time: AwareDatetime | None = None + expiration_time: AwareDatetime | None = None subaccount_number: int | None = None # v0.14+ backfill (#162). outcome_side/book_side/self_trade_prevention_type # mirror Order from #159. *_ts_ms (Unix ms) supersede the *_time RFC3339 diff --git a/tests/test_contracts.py b/tests/test_contracts.py index bd7fe229..46b1e28b 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -30,7 +30,9 @@ yaml = None # type: ignore[assignment] from pydantic import AliasChoices +from pydantic import AwareDatetime as _PydAware from pydantic import BaseModel as PydanticBase +from pydantic import NaiveDatetime as _PydNaive from pydantic.fields import FieldInfo from kalshi._contract_map import CONTRACT_MAP, WS_CONTRACT_MAP, ContractEntry @@ -43,6 +45,12 @@ _resolve_request_body_schema, ) +# pydantic v2 ships AwareDatetime / NaiveDatetime as concrete classes whose +# validation produces a stdlib datetime. The drift checker treats them as +# equivalent to ``datetime`` for kind-classification purposes (spec is just +# ``string`` + ``format: date-time``). +_PYDANTIC_DATETIME_TYPES: tuple[type, ...] = (_PydAware, _PydNaive) + SPEC_FILE = Path(__file__).parent.parent / "specs" / "openapi.yaml" @@ -581,6 +589,22 @@ def _sdk_type_kind(ann: Any) -> str: return "decimal" if base is datetime: return "datetime" + # Pydantic AwareDatetime / NaiveDatetime are concrete classes whose + # validation produces a ``datetime``; treat them as the same kind for + # spec-drift purposes (spec is just ``string`` + ``format: date-time``). + if base in _PYDANTIC_DATETIME_TYPES: + return "datetime" + # Pydantic stores ``AwareDatetime``/``NaiveDatetime`` field annotations + # as ``ForwardRef('datetime')`` (or ``'datetime | None'``) until the + # first model_validate triggers eager resolution. The drift suite runs + # before any frame parses, so resolve the forward-ref class-name here + # so a date-time field is classified ``datetime``, not ``unknown``. + if isinstance(base, typing.ForwardRef) and base.__forward_arg__ in ( + "datetime", "datetime | None", + "AwareDatetime", "AwareDatetime | None", + "NaiveDatetime", "NaiveDatetime | None", + ): + return "datetime" origin = typing.get_origin(base) if origin is list: args = typing.get_args(base) diff --git a/tests/test_orders.py b/tests/test_orders.py index a8f624e9..7a31426f 100644 --- a/tests/test_orders.py +++ b/tests/test_orders.py @@ -9,6 +9,7 @@ import httpx import pytest import respx +from pydantic import ValidationError from kalshi._base_client import SyncTransport from kalshi.auth import KalshiAuth @@ -1924,3 +1925,41 @@ def test_fills_rejects_limit_above_1000( ) -> None: with pytest.raises(ValueError, match=r"limit must be in \[1, 1000\]"): orders.fills(limit=10_000) + + +class TestCreateOrderRequestLiteralEnforcement: + """#270 Item 2: V1 CreateOrderRequest narrows enum-style fields to Literals. + + Before this change V1 callers could pass ``side="foo"`` and only fail server- + side. V2 already enforces literals at construction; aligning V1 closes the + deprecation-window inconsistency. + """ + + def test_rejects_invalid_side(self) -> None: + with pytest.raises(ValidationError): + CreateOrderRequest(ticker="T", side="foo", action="buy", count=Decimal("1")) # type: ignore[arg-type] + + def test_rejects_invalid_action(self) -> None: + with pytest.raises(ValidationError): + CreateOrderRequest(ticker="T", side="yes", action="trade", count=Decimal("1")) # type: ignore[arg-type] + + def test_rejects_invalid_time_in_force(self) -> None: + with pytest.raises(ValidationError): + CreateOrderRequest( + ticker="T", side="yes", action="buy", count=Decimal("1"), + time_in_force="forever", # type: ignore[arg-type] + ) + + def test_rejects_invalid_self_trade_prevention_type(self) -> None: + with pytest.raises(ValidationError): + CreateOrderRequest( + ticker="T", side="yes", action="buy", count=Decimal("1"), + self_trade_prevention_type="never", # type: ignore[arg-type] + ) + + def test_accepts_valid_v1_yes_no_side(self) -> None: + # V1 uses yes/no (NOT bid/ask like V2) + req = CreateOrderRequest(ticker="T", side="no", action="sell", count=Decimal("1")) + assert req.side == "no" + assert req.action == "sell" + diff --git a/tests/test_types.py b/tests/test_types.py index c59024e7..b1672ce7 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -90,3 +90,42 @@ class M(BaseModel): result = m.model_dump(mode="python") assert isinstance(result["count"], Decimal) assert result["count"] == Decimal("42") + + +class TestCoerceDecimalRejectsNonFinite: + """#270 Item 5: NaN/Infinity rejected so they never reach the wire. + + ``f"{Decimal('NaN'):f}"`` returns the literal string ``"NaN"``, and before this + guard ``_coerce_decimal`` short-circuited on the ``isinstance(value, Decimal)`` + branch — a caller doing arithmetic that produced NaN would silently ship + ``"NaN"`` to a real-money order placement endpoint. Same for Infinity. + """ + + def test_rejects_nan_decimal_on_dollar_field(self) -> None: + with pytest.raises(ValueError, match="finite"): + _DollarModel.model_validate({"x": Decimal("NaN")}) + + def test_rejects_infinity_decimal_on_dollar_field(self) -> None: + with pytest.raises(ValueError, match="finite"): + _DollarModel.model_validate({"x": Decimal("Infinity")}) + + def test_rejects_negative_infinity_decimal_on_count_field(self) -> None: + with pytest.raises(ValueError, match="finite"): + _CountModel.model_validate({"x": Decimal("-Infinity")}) + + def test_rejects_nan_string_on_dollar_field(self) -> None: + with pytest.raises(ValueError, match="finite"): + _DollarModel.model_validate({"x": "NaN"}) + + def test_accepts_finite_decimal(self) -> None: + m = _DollarModel.model_validate({"x": Decimal("0.5")}) + assert m.x == Decimal("0.5") + + def test_create_order_request_rejects_nan_yes_price(self) -> None: + from kalshi.models.orders import CreateOrderRequest + with pytest.raises(ValueError, match="finite"): + CreateOrderRequest( + ticker="T", side="yes", action="buy", + count=Decimal("1"), yes_price=Decimal("NaN"), + ) + diff --git a/tests/ws/test_models.py b/tests/ws/test_models.py index 5e73f4a4..f9da9b68 100644 --- a/tests/ws/test_models.py +++ b/tests/ws/test_models.py @@ -1438,3 +1438,43 @@ def test_floor_strike_rejects_bool(self) -> None: } with pytest.raises(TypeError, match="bool"): MarketLifecycleMessage.model_validate(raw) + + +class TestWsPayloadsRejectNaiveDatetime: + """#270 Item 1: WS payloads with datetime fields must reject naive RFC3339 strings. + + REST models standardised on AwareDatetime in #234. WS payloads must match — + a naive datetime string (no offset) must raise ValidationError, not silently + parse into a tz-unaware datetime that breaks arithmetic against tz-aware peers. + """ + + def test_user_orders_payload_rejects_naive_created_time(self) -> None: + payload = user_orders_payload_dict(created_time="2026-01-01T00:00:00") + with pytest.raises(ValidationError, match="timezone"): + UserOrdersPayload.model_validate(payload) + + def test_rfq_created_payload_rejects_naive_created_ts(self) -> None: + with pytest.raises(ValidationError, match="timezone"): + RfqCreatedPayload.model_validate( + { + "id": "rfq-001", + "creator_id": "u-1", + "market_ticker": "T", + "created_ts": "2026-01-01T00:00:00", + } + ) + + def test_quote_executed_payload_rejects_naive_executed_ts(self) -> None: + with pytest.raises(ValidationError, match="timezone"): + QuoteExecutedPayload.model_validate( + { + "quote_id": "q-001", + "rfq_id": "rfq-001", + "quote_creator_id": "u-2", + "rfq_creator_id": "u-1", + "order_id": "ord-001", + "client_order_id": "cli-001", + "market_ticker": "T", + "executed_ts": "2026-04-19T18:43:37", + } + )