From 24b88fdf51c1a4973bbf788b081b29651c933381 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Thu, 21 May 2026 08:22:05 -0500 Subject: [PATCH 1/3] fix(ws): AwareDatetime in user_orders/communications payloads; document market_lifecycle seconds unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS payload models predated #234's REST AwareDatetime rollout, so they accepted naive RFC3339 strings that REST callers couldn't. Align the two by retyping the datetime fields on UserOrdersPayload (4 fields) and the communications payloads (5 fields) to pydantic.AwareDatetime — a string without an offset now raises ValidationError at parse rather than silently producing a tz-unaware datetime that breaks arithmetic against tz-aware peers in MarketPosition/Settlement. MarketLifecyclePayload's *_ts fields are intentionally left as bare int. The AsyncAPI spec example payload uses Unix **seconds** (e.g. open_ts: 1694635200, 2023-09-13), not ms — so the lack of the _ts_ms suffix carried by sibling channels is correct. Added a comment so readers stop wondering. --- kalshi/ws/models/communications.py | 12 ++++----- kalshi/ws/models/user_orders.py | 10 +++----- tests/ws/test_models.py | 40 ++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 13 deletions(-) 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/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", + } + ) From 70c6ea8eb11268a37d19f7370cd6ad464ac4b0f5 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Thu, 21 May 2026 08:43:55 -0500 Subject: [PATCH 2/3] fix(models): V1 CreateOrderRequest Literals; document required-nullable response fields; UnixSecondsTimestamp alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V1 CreateOrderRequest tightens side/action/time_in_force/self_trade_prevention_type from bare str to the same Literal aliases V2 uses (SideLiteral, ActionLiteral, TimeInForceLiteral, SelfTradePreventionTypeLiteral). V1 keeps its yes/no side vocabulary (V2 is bid/ask). Caller passing side='foo' now fails at construction, matching V2 — closes the deprecation-window drift before V1 sunset (~May 2026). - portfolio.MarketPosition / historical.Trade keep total_traded, position, count as 'T | None' without default=None. The OpenAPI spec marks the underlying wire keys (total_traded_dollars, position_fp, count_fp) as **required**, so omission must still raise ValidationError — only the observed JSON null is admitted. Added docstring notes explaining the intentional non-default so the audit doesn't re-flag them. - types.UnixSecondsTimestamp: Annotated[int, …] alias for int64 Unix-seconds wire timestamps. Internal type-aid only (not re-exported via kalshi.__init__). Applied to Balance.updated_ts, Deposit.created_ts/finalized_ts, Withdrawal.created_ts/finalized_ts so the int-vs-AwareDatetime split is greppable when balances/positions are read side-by-side. - Drift checker (tests/test_contracts.py) now classifies pydantic AwareDatetime/NaiveDatetime and ForwardRef('datetime') as kind='datetime', matching what those models actually validate at runtime. --- kalshi/models/historical.py | 9 ++++++++- kalshi/models/orders.py | 8 ++++---- kalshi/models/portfolio.py | 23 +++++++++++++++------- kalshi/types.py | 13 +++++++++++++ tests/test_contracts.py | 24 +++++++++++++++++++++++ tests/test_orders.py | 39 +++++++++++++++++++++++++++++++++++++ 6 files changed, 104 insertions(+), 12 deletions(-) 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..424061ab 100644 --- a/kalshi/types.py +++ b/kalshi/types.py @@ -116,3 +116,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/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" + From afff78390b44cec366fe606d066f3dff4a6188db Mon Sep 17 00:00:00 2001 From: Jeff West Date: Thu, 21 May 2026 08:47:15 -0500 Subject: [PATCH 3/3] fix(types): reject non-finite Decimal at parse/serialize boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `f"{Decimal('NaN'):f}"` returns the literal string `"NaN"`, and `_coerce_decimal` previously short-circuited on the `isinstance(value, Decimal)` branch — a caller doing arithmetic that produced NaN and then constructing a `CreateOrderRequest(yes_price=that_nan)` would ship `"NaN"` straight to a real-money order placement endpoint. `_coerce_decimal` now computes the Decimal first, then `is_finite()`- guards it, raising `ValueError` on NaN/+Inf/-Inf. One branch protects every DollarDecimal / FixedPointCount / MultiplierDecimal field at both parse and serialize. Closes #270 --- kalshi/types.py | 23 ++++++++++++++++------- tests/test_types.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/kalshi/types.py b/kalshi/types.py index 424061ab..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: 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"), + ) +