From 4cfbaf1777256e48b2fdfdadda114fb740e6258a Mon Sep 17 00:00:00 2001 From: audit-w1d Date: Wed, 20 May 2026 21:09:11 -0500 Subject: [PATCH 1/2] fix(ws/models): type count/size as FixedPointCount; timestamps as datetime - TickerPayload, TradePayload, FillPayload, UserOrdersPayload, MarketPositionsPayload count/size/volume fields now FixedPointCount instead of str (matches REST models; eliminates silent str+int TypeErrors in consumer code). - OrderbookSnapshotPayload.yes/.no now list[tuple[DollarDecimal, FixedPointCount]] (matches OrderbookDeltaPayload). - RfqCreatedPayload.contracts + QuoteAcceptedPayload.contracts_accepted now FixedPointCount (matches RfqDeletedPayload.contracts). - UserOrdersPayload + CommunicationsMessage timestamps now datetime instead of str. - Simplified OrderbookManager.apply_snapshot: dropped redundant Decimal() wrappers now that payloads return Decimal. Closes #198 Refs #224 --- kalshi/ws/models/communications.py | 27 ++- kalshi/ws/models/fill.py | 10 +- kalshi/ws/models/market_positions.py | 10 +- kalshi/ws/models/orderbook_delta.py | 4 +- kalshi/ws/models/ticker.py | 22 +-- kalshi/ws/models/trade.py | 6 +- kalshi/ws/models/user_orders.py | 22 +-- kalshi/ws/orderbook.py | 4 +- tests/ws/test_models.py | 247 +++++++++++++++++++++++++-- 9 files changed, 287 insertions(+), 65 deletions(-) diff --git a/kalshi/ws/models/communications.py b/kalshi/ws/models/communications.py index 04908949..7fd495ae 100644 --- a/kalshi/ws/models/communications.py +++ b/kalshi/ws/models/communications.py @@ -1,6 +1,8 @@ """Communications channel message models (RFQ and quote notifications).""" from __future__ import annotations +from datetime import datetime + from pydantic import AliasChoices, BaseModel, Field from kalshi.types import DollarDecimal, FixedPointCount @@ -12,25 +14,17 @@ class RfqCreatedPayload(BaseModel): Wire format per AsyncAPI spec: ``created_ts`` is an RFC3339 date-time string; ``target_cost_dollars`` is a dollar string; ``contracts_fp`` is a 2-decimal fixed-point count string. - - TODO(v0.15.1): ``created_ts`` is spec-aligned but lacks live capture. - The v0.14.0 ``user_orders`` capture showed demo emitting - ``created_ts_ms`` as integer milliseconds instead of the spec's ISO - string. If Communications follows the same pattern, ``created_ts: str`` - will reject frames that previously parsed as ``int``. Capture a live - frame on demo when the channel is active and confirm or adjust. - ``extra="allow"`` provides a soft landing for unexpected extras. """ id: str creator_id: str market_ticker: str - created_ts: str + created_ts: datetime event_ticker: str | None = None - contracts: str | None = Field( + contracts: FixedPointCount | None = Field( default=None, validation_alias=AliasChoices("contracts_fp", "contracts"), - ) # _fp format + ) target_cost: DollarDecimal | None = Field( default=None, validation_alias=AliasChoices("target_cost_dollars", "target_cost"), @@ -48,13 +42,12 @@ class RfqDeletedPayload(BaseModel): """RFQ deleted notification payload. ``deleted_ts`` is an RFC3339 date-time string per AsyncAPI spec. - Same ``created_ts_ms``-precedent caveat as :class:`RfqCreatedPayload`. """ id: str creator_id: str market_ticker: str - deleted_ts: str + deleted_ts: datetime # v0.14+ backfill (#162). Same RFQ context as RfqCreatedPayload — # surfaced again on delete for clients that subscribed mid-RFQ. @@ -83,7 +76,7 @@ class QuoteCreatedPayload(BaseModel): no_bid: DollarDecimal = Field( validation_alias=AliasChoices("no_bid_dollars", "no_bid"), ) - created_ts: str + created_ts: datetime # 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. @@ -117,10 +110,10 @@ class QuoteAcceptedPayload(BaseModel): validation_alias=AliasChoices("no_bid_dollars", "no_bid"), ) accepted_side: str | None = None - contracts_accepted: str | None = Field( + contracts_accepted: FixedPointCount | None = Field( default=None, validation_alias=AliasChoices("contracts_accepted_fp", "contracts_accepted"), - ) # _fp format + ) # v0.14+ backfill (#162). Mirrors QuoteCreatedPayload — same RFQ context # echoed on accept. @@ -150,7 +143,7 @@ class QuoteExecutedPayload(BaseModel): order_id: str client_order_id: str market_ticker: str - executed_ts: str + executed_ts: datetime model_config = {"extra": "allow"} diff --git a/kalshi/ws/models/fill.py b/kalshi/ws/models/fill.py index 121c84f3..6a3f4f33 100644 --- a/kalshi/ws/models/fill.py +++ b/kalshi/ws/models/fill.py @@ -4,7 +4,7 @@ from pydantic import AliasChoices, BaseModel, Field from kalshi.models.orders import BookSideLiteral, SideLiteral -from kalshi.types import DollarDecimal +from kalshi.types import DollarDecimal, FixedPointCount class FillPayload(BaseModel): @@ -24,15 +24,15 @@ class FillPayload(BaseModel): yes_price: DollarDecimal = Field( validation_alias=AliasChoices("yes_price_dollars", "yes_price"), ) - count: str = Field( + count: FixedPointCount = Field( validation_alias=AliasChoices("count_fp", "count"), - ) # _fp format + ) fee_cost: DollarDecimal action: str # buy/sell ts: int - post_position: str = Field( + post_position: FixedPointCount = Field( validation_alias=AliasChoices("post_position_fp", "post_position"), - ) # _fp format + ) purchased_side: str client_order_id: str | None = None subaccount: int | None = None diff --git a/kalshi/ws/models/market_positions.py b/kalshi/ws/models/market_positions.py index b714d292..7a39e69b 100644 --- a/kalshi/ws/models/market_positions.py +++ b/kalshi/ws/models/market_positions.py @@ -3,7 +3,7 @@ from pydantic import AliasChoices, BaseModel, Field -from kalshi.types import DollarDecimal +from kalshi.types import DollarDecimal, FixedPointCount class MarketPositionsPayload(BaseModel): @@ -15,9 +15,9 @@ class MarketPositionsPayload(BaseModel): user_id: str market_ticker: str - position: str = Field( + position: FixedPointCount = Field( validation_alias=AliasChoices("position_fp", "position"), - ) # _fp format + ) position_cost: DollarDecimal = Field( validation_alias=AliasChoices("position_cost_dollars", "position_cost"), ) @@ -30,9 +30,9 @@ class MarketPositionsPayload(BaseModel): position_fee_cost: DollarDecimal = Field( validation_alias=AliasChoices("position_fee_cost_dollars", "position_fee_cost"), ) - volume: str = Field( + volume: FixedPointCount = Field( validation_alias=AliasChoices("volume_fp", "volume"), - ) # _fp format + ) subaccount: int | None = None model_config = {"extra": "allow"} diff --git a/kalshi/ws/models/orderbook_delta.py b/kalshi/ws/models/orderbook_delta.py index 4ae35138..4bee6934 100644 --- a/kalshi/ws/models/orderbook_delta.py +++ b/kalshi/ws/models/orderbook_delta.py @@ -17,11 +17,11 @@ class OrderbookSnapshotPayload(BaseModel): market_ticker: str market_id: str - yes: list[tuple[str, str]] = Field( + yes: list[tuple[DollarDecimal, FixedPointCount]] = Field( default=[], validation_alias=AliasChoices("yes_dollars_fp", "yes"), ) - no: list[tuple[str, str]] = Field( + no: list[tuple[DollarDecimal, FixedPointCount]] = Field( default=[], validation_alias=AliasChoices("no_dollars_fp", "no"), ) diff --git a/kalshi/ws/models/ticker.py b/kalshi/ws/models/ticker.py index eec6caf5..f1b83c11 100644 --- a/kalshi/ws/models/ticker.py +++ b/kalshi/ws/models/ticker.py @@ -3,7 +3,7 @@ from pydantic import AliasChoices, BaseModel, Field -from kalshi.types import DollarDecimal +from kalshi.types import DollarDecimal, FixedPointCount class TickerPayload(BaseModel): @@ -30,23 +30,23 @@ class TickerPayload(BaseModel): default=None, validation_alias=AliasChoices("no_ask_dollars", "no_ask"), ) - volume: str = Field( + volume: FixedPointCount = Field( validation_alias=AliasChoices("volume_fp", "volume"), - ) # _fp format - open_interest: str = Field( + ) + open_interest: FixedPointCount = Field( validation_alias=AliasChoices("open_interest_fp", "open_interest"), - ) # _fp format + ) dollar_volume: str dollar_open_interest: str - yes_bid_size: str = Field( + yes_bid_size: FixedPointCount = Field( validation_alias=AliasChoices("yes_bid_size_fp", "yes_bid_size"), - ) # _fp format - yes_ask_size: str = Field( + ) + yes_ask_size: FixedPointCount = Field( validation_alias=AliasChoices("yes_ask_size_fp", "yes_ask_size"), - ) # _fp format - last_trade_size: str = Field( + ) + last_trade_size: FixedPointCount = Field( validation_alias=AliasChoices("last_trade_size_fp", "last_trade_size"), - ) # _fp format + ) ts: int # v0.14+ backfill (#162). Spec promotes ts_ms (Unix ms) as the primary # timestamp; ts (seconds) stays for compat. Do NOT auto-convert. diff --git a/kalshi/ws/models/trade.py b/kalshi/ws/models/trade.py index b8617d5f..11706d2f 100644 --- a/kalshi/ws/models/trade.py +++ b/kalshi/ws/models/trade.py @@ -4,7 +4,7 @@ from pydantic import AliasChoices, BaseModel, Field from kalshi.models.orders import BookSideLiteral, SideLiteral -from kalshi.types import DollarDecimal +from kalshi.types import DollarDecimal, FixedPointCount class TradePayload(BaseModel): @@ -23,9 +23,9 @@ class TradePayload(BaseModel): no_price: DollarDecimal = Field( validation_alias=AliasChoices("no_price_dollars", "no_price"), ) - count: str = Field( + count: FixedPointCount = Field( validation_alias=AliasChoices("count_fp", "count"), - ) # _fp format + ) taker_side: str ts: int # v0.14+ backfill (#162). taker_outcome_side/taker_book_side are the diff --git a/kalshi/ws/models/user_orders.py b/kalshi/ws/models/user_orders.py index 6e68edf9..8a2478cb 100644 --- a/kalshi/ws/models/user_orders.py +++ b/kalshi/ws/models/user_orders.py @@ -1,6 +1,8 @@ """User orders channel message models.""" from __future__ import annotations +from datetime import datetime + from pydantic import AliasChoices, BaseModel, Field from kalshi.models.orders import ( @@ -8,7 +10,7 @@ SelfTradePreventionTypeLiteral, SideLiteral, ) -from kalshi.types import DollarDecimal +from kalshi.types import DollarDecimal, FixedPointCount class UserOrdersPayload(BaseModel): @@ -31,15 +33,15 @@ class UserOrdersPayload(BaseModel): yes_price: DollarDecimal = Field( validation_alias=AliasChoices("yes_price_dollars", "yes_price"), ) - fill_count: str = Field( + fill_count: FixedPointCount = Field( validation_alias=AliasChoices("fill_count_fp", "fill_count"), - ) # _fp format - remaining_count: str = Field( + ) + remaining_count: FixedPointCount = Field( validation_alias=AliasChoices("remaining_count_fp", "remaining_count"), - ) # _fp format - initial_count: str = Field( + ) + initial_count: FixedPointCount = Field( validation_alias=AliasChoices("initial_count_fp", "initial_count"), - ) # _fp format + ) taker_fill_cost: DollarDecimal = Field( validation_alias=AliasChoices("taker_fill_cost_dollars", "taker_fill_cost"), ) @@ -53,10 +55,10 @@ class UserOrdersPayload(BaseModel): validation_alias=AliasChoices("maker_fees_dollars", "maker_fees"), ) client_order_id: str - created_time: str + created_time: datetime order_group_id: str | None = None - last_update_time: str | None = None - expiration_time: str | None = None + last_update_time: datetime | None = None + expiration_time: datetime | 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/kalshi/ws/orderbook.py b/kalshi/ws/orderbook.py index 16b5638a..7b186319 100644 --- a/kalshi/ws/orderbook.py +++ b/kalshi/ws/orderbook.py @@ -76,8 +76,8 @@ def __init__(self) -> None: def apply_snapshot(self, msg: OrderbookSnapshotMessage) -> Orderbook: """Initialize (or reset) a book from a full snapshot.""" ticker = msg.msg.market_ticker - yes_levels = {Decimal(p): Decimal(q) for p, q in msg.msg.yes} - no_levels = {Decimal(p): Decimal(q) for p, q in msg.msg.no} + yes_levels = dict(msg.msg.yes) + no_levels = dict(msg.msg.no) state = _BookState(ticker=ticker, yes=yes_levels, no=no_levels) self._books[ticker] = state logger.debug( diff --git a/tests/ws/test_models.py b/tests/ws/test_models.py index 1af87c13..d5acd0a4 100644 --- a/tests/ws/test_models.py +++ b/tests/ws/test_models.py @@ -2,11 +2,13 @@ from __future__ import annotations +from datetime import UTC, datetime from decimal import Decimal import pytest from pydantic import ValidationError +from kalshi.models.orders import Fill, Order from kalshi.ws.models.base import ( BaseMessage, ErrorMessage, @@ -21,7 +23,7 @@ QuoteExecutedPayload, RfqCreatedPayload, ) -from kalshi.ws.models.fill import FillMessage +from kalshi.ws.models.fill import FillMessage, FillPayload from kalshi.ws.models.market_lifecycle import MarketLifecycleMessage from kalshi.ws.models.market_positions import MarketPositionsMessage from kalshi.ws.models.multivariate import ( @@ -35,7 +37,7 @@ ) from kalshi.ws.models.ticker import TickerMessage from kalshi.ws.models.trade import TradeMessage -from kalshi.ws.models.user_orders import UserOrdersMessage +from kalshi.ws.models.user_orders import UserOrdersMessage, UserOrdersPayload from tests._model_fixtures import ( fill_payload_dict, market_positions_payload_dict, @@ -97,7 +99,7 @@ def test_parse_snapshot(self) -> None: assert msg.seq == 1 assert msg.msg.market_ticker == "ECON-GDP-25Q1" assert len(msg.msg.yes) == 2 - assert msg.msg.yes[0] == ("0.50", "100.00") + assert msg.msg.yes[0] == (Decimal("0.50"), Decimal("100.00")) def test_parse_delta(self) -> None: raw = { @@ -175,7 +177,7 @@ def test_parse_ticker(self) -> None: assert msg.msg.market_ticker == "ECON-GDP-25Q1" assert msg.msg.yes_bid == Decimal("0.55") assert msg.msg.yes_ask == Decimal("0.60") - assert msg.msg.volume == "1000" + assert msg.msg.volume == Decimal("1000") def test_ticker_no_seq(self) -> None: raw = { @@ -227,7 +229,7 @@ def test_parse_trade(self) -> None: assert msg.sid == 2 assert msg.msg.trade_id == "trade-001" assert msg.msg.yes_price == Decimal("0.55") - assert msg.msg.count == "10" + assert msg.msg.count == Decimal("10") def test_trade_no_seq(self) -> None: raw = { @@ -322,7 +324,7 @@ def test_parse_market_positions(self) -> None: msg = MarketPositionsMessage.model_validate(raw) assert msg.type == "market_positions" assert msg.msg.market_ticker == "ECON-GDP-25Q1" - assert msg.msg.position == "100" + assert msg.msg.position == Decimal("100") assert msg.msg.realized_pnl == Decimal("10.50") def test_market_positions_no_seq(self) -> None: @@ -379,7 +381,7 @@ def test_parse_user_orders(self) -> None: assert msg.msg.status == "resting" assert msg.msg.is_yes is True assert msg.msg.yes_price == Decimal("0.55") - assert msg.msg.fill_count == "3" + assert msg.msg.fill_count == Decimal("3") def test_user_orders_no_seq(self) -> None: raw = { @@ -403,7 +405,7 @@ def test_user_orders_canceled(self) -> None: } msg = UserOrdersMessage.model_validate(raw) assert msg.msg.status == "canceled" - assert msg.msg.remaining_count == "0" + assert msg.msg.remaining_count == Decimal("0") # ---------- OrderGroup ---------- @@ -657,7 +659,7 @@ def test_rfq_created_payload_model(self) -> None: } ) assert payload.id == "rfq-001" - assert payload.contracts == "50" + assert payload.contracts == Decimal("50") def test_quote_accepted_payload_model(self) -> None: payload = QuoteAcceptedPayload.model_validate( @@ -689,7 +691,7 @@ def test_quote_executed_payload_model(self) -> None: } ) assert payload.order_id == "ord-001" - assert payload.executed_ts == "2026-04-19T18:43:37Z" + assert payload.executed_ts == datetime(2026, 4, 19, 18, 43, 37, tzinfo=UTC) # ---------- v0.14+ backfill (#162): one (de)serialization test per payload ---------- @@ -902,3 +904,228 @@ def test_quote_accepted_rfq_context(self) -> None: assert payload.yes_contracts_offered == Decimal("5.00") assert payload.no_contracts_offered == Decimal("3.00") assert payload.rfq_target_cost == Decimal("1.5000") + + +# ---------- #198: count/size/volume/timestamp type coercion ---------- + + +class TestWsPayloadDecimalCoercion: + """Counts/sizes/volumes parse as Decimal (FixedPointCount), not str. + + The wire format is unchanged — values still arrive as JSON strings — but + the Pydantic BeforeValidator coerces them into Decimal so consumers + don't have to manually wrap. Regression guard for #198. + """ + + def test_ticker_volume_parses_as_decimal_not_str(self) -> None: + msg = TickerMessage.model_validate( + { + "type": "ticker", + "sid": 1, + "msg": ticker_payload_dict( + volume_fp="1000", + open_interest_fp="500", + yes_bid_size_fp="40", + yes_ask_size_fp="60", + last_trade_size_fp="7", + ), + } + ) + assert isinstance(msg.msg.volume, Decimal) + assert isinstance(msg.msg.open_interest, Decimal) + assert isinstance(msg.msg.yes_bid_size, Decimal) + assert isinstance(msg.msg.yes_ask_size, Decimal) + assert isinstance(msg.msg.last_trade_size, Decimal) + assert msg.msg.volume == Decimal("1000") + + def test_trade_count_parses_as_decimal(self) -> None: + msg = TradeMessage.model_validate( + {"type": "trade", "sid": 1, "msg": trade_payload_dict(count_fp="10")} + ) + assert isinstance(msg.msg.count, Decimal) + assert msg.msg.count == Decimal("10") + + def test_fill_count_post_position_parse_as_decimal(self) -> None: + msg = FillMessage.model_validate( + { + "type": "fill", + "sid": 1, + "msg": fill_payload_dict(count_fp="5", post_position_fp="12"), + } + ) + assert isinstance(msg.msg.count, Decimal) + assert isinstance(msg.msg.post_position, Decimal) + assert msg.msg.count == Decimal("5") + assert msg.msg.post_position == Decimal("12") + + def test_user_orders_counts_parse_as_decimal(self) -> None: + msg = UserOrdersMessage.model_validate( + { + "type": "user_orders", + "sid": 1, + "msg": user_orders_payload_dict( + fill_count_fp="3", + remaining_count_fp="7", + initial_count_fp="10", + ), + } + ) + assert isinstance(msg.msg.fill_count, Decimal) + assert isinstance(msg.msg.remaining_count, Decimal) + assert isinstance(msg.msg.initial_count, Decimal) + assert msg.msg.fill_count + msg.msg.remaining_count == msg.msg.initial_count + + def test_market_positions_position_volume_parse_as_decimal(self) -> None: + msg = MarketPositionsMessage.model_validate( + { + "type": "market_positions", + "sid": 1, + "msg": market_positions_payload_dict( + position_fp="100", volume_fp="200" + ), + } + ) + assert isinstance(msg.msg.position, Decimal) + assert isinstance(msg.msg.volume, Decimal) + assert msg.msg.position == Decimal("100") + assert msg.msg.volume == Decimal("200") + + def test_orderbook_snapshot_yes_no_parse_as_decimal_tuples(self) -> None: + msg = OrderbookSnapshotMessage.model_validate( + { + "type": "orderbook_snapshot", + "sid": 1, + "seq": 1, + "msg": { + "market_ticker": "T", + "market_id": "x", + "yes": [["0.50", "100.00"], ["0.55", "200.00"]], + "no": [["0.45", "150.00"]], + }, + } + ) + assert msg.msg.yes[0] == (Decimal("0.50"), Decimal("100.00")) + assert isinstance(msg.msg.yes[0][0], Decimal) + assert isinstance(msg.msg.yes[0][1], Decimal) + assert isinstance(msg.msg.no[0][0], Decimal) + assert isinstance(msg.msg.no[0][1], Decimal) + + def test_rfq_created_contracts_parses_as_decimal(self) -> None: + payload = RfqCreatedPayload.model_validate( + { + "id": "rfq-1", + "creator_id": "u1", + "market_ticker": "T", + "created_ts": "2026-01-01T00:00:00Z", + "contracts_fp": "50", + } + ) + assert isinstance(payload.contracts, Decimal) + assert payload.contracts == Decimal("50") + + def test_quote_accepted_contracts_accepted_parses_as_decimal(self) -> None: + payload = QuoteAcceptedPayload.model_validate( + { + "quote_id": "q-1", + "rfq_id": "rfq-1", + "quote_creator_id": "u2", + "market_ticker": "T", + "yes_bid_dollars": "0.55", + "no_bid_dollars": "0.45", + "contracts_accepted_fp": "10", + } + ) + assert isinstance(payload.contracts_accepted, Decimal) + assert payload.contracts_accepted == Decimal("10") + + +class TestWsPayloadDatetimeCoercion: + """RFC3339 timestamps parse into tz-aware datetime, matching REST. Regression guard for #198.""" + + def test_user_orders_timestamps_parse_as_datetime(self) -> None: + msg = UserOrdersMessage.model_validate( + { + "type": "user_orders", + "sid": 1, + "msg": user_orders_payload_dict( + created_time="2026-01-01T00:00:00Z", + last_update_time="2026-01-02T00:00:00Z", + expiration_time="2026-01-03T00:00:00Z", + ), + } + ) + assert isinstance(msg.msg.created_time, datetime) + assert msg.msg.created_time == datetime(2026, 1, 1, tzinfo=UTC) + assert isinstance(msg.msg.last_update_time, datetime) + assert isinstance(msg.msg.expiration_time, datetime) + + def test_communications_timestamps_parse_as_datetime(self) -> None: + rfq_created = RfqCreatedPayload.model_validate( + { + "id": "rfq-1", + "creator_id": "u1", + "market_ticker": "T", + "created_ts": "2026-04-19T18:43:37Z", + } + ) + assert isinstance(rfq_created.created_ts, datetime) + + quote_created = QuoteCreatedPayload.model_validate( + { + "quote_id": "q-1", + "rfq_id": "rfq-1", + "quote_creator_id": "u2", + "market_ticker": "T", + "yes_bid_dollars": "0.55", + "no_bid_dollars": "0.45", + "created_ts": "2026-04-19T18:43:37Z", + } + ) + assert isinstance(quote_created.created_ts, datetime) + + quote_executed = QuoteExecutedPayload.model_validate( + { + "quote_id": "q-1", + "rfq_id": "rfq-1", + "order_id": "o1", + "quote_creator_id": "u2", + "rfq_creator_id": "u1", + "client_order_id": "c1", + "market_ticker": "T", + "executed_ts": "2026-04-19T18:43:37Z", + } + ) + assert isinstance(quote_executed.executed_ts, datetime) + + +@pytest.mark.parametrize( + "rest_field, ws_field", + [ + # FixedPointCount symmetry on count fields + (Fill.model_fields["count"].annotation, FillPayload.model_fields["count"].annotation), + ( + Order.model_fields["initial_count"].annotation, + UserOrdersPayload.model_fields["initial_count"].annotation, + ), + ( + Order.model_fields["remaining_count"].annotation, + UserOrdersPayload.model_fields["remaining_count"].annotation, + ), + ( + Order.model_fields["fill_count"].annotation, + UserOrdersPayload.model_fields["fill_count"].annotation, + ), + # DollarDecimal symmetry on price fields + ( + Order.model_fields["yes_price"].annotation, + UserOrdersPayload.model_fields["yes_price"].annotation, + ), + ( + Fill.model_fields["yes_price"].annotation, + FillPayload.model_fields["yes_price"].annotation, + ), + ], +) +def test_rest_ws_field_type_symmetry(rest_field: object, ws_field: object) -> None: + """REST and WS payloads MUST agree on the annotation for shared logical fields (#198).""" + assert rest_field == ws_field From df9e93e397101bfb90757f465913f87f89e38c03 Mon Sep 17 00:00:00 2001 From: audit-w1d Date: Wed, 20 May 2026 21:50:29 -0500 Subject: [PATCH 2/2] test(contracts): allow datetime for spec string (date-time) fields in WS drift check _sdk_type_kind now recognizes datetime; _ws_field_type_violations Rule 2 accepts 'str' or 'datetime' for spec string + format=date-time fields. Pydantic auto-coerces RFC3339 strings into tz-aware datetime, matching the REST model convention that has been used for these fields all along. Fixes a gap exposed by W1-D's WS timestamp retyping (#198). --- tests/test_contracts.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/test_contracts.py b/tests/test_contracts.py index d7dff0c2..cdba9585 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -16,6 +16,7 @@ import importlib import inspect import typing +from datetime import datetime from decimal import Decimal from pathlib import Path from types import UnionType @@ -562,10 +563,12 @@ def _unwrap_annotation(ann: Any) -> Any: def _sdk_type_kind(ann: Any) -> str: """Classify a Pydantic field annotation into a spec-aligned kind label. - Returns: ``'int'``, ``'str'``, ``'bool'``, ``'decimal'``, ``'list[]'``, - or ``'unknown'``. Decimals (DollarDecimal, FixedPointCount) count as - ``'decimal'`` — a Decimal-typed field is considered compatible with a - spec-declared dollar string. + Returns: ``'int'``, ``'str'``, ``'bool'``, ``'decimal'``, ``'datetime'``, + ``'list[]'``, or ``'unknown'``. Decimals (DollarDecimal, + FixedPointCount) count as ``'decimal'`` — a Decimal-typed field is + considered compatible with a spec-declared dollar string. ``datetime`` is + compatible with spec ``string`` + ``format=date-time`` (Pydantic + auto-coerces RFC3339 to tz-aware datetime). """ base = _unwrap_annotation(ann) if base is int: @@ -576,6 +579,8 @@ def _sdk_type_kind(ann: Any) -> str: return "bool" if base is Decimal: return "decimal" + if base is datetime: + return "datetime" origin = typing.get_origin(base) if origin is list: args = typing.get_args(base) @@ -645,10 +650,14 @@ def _ws_field_type_violations( # Rule 2: spec string with format=date-time (ISO timestamp) must be str # on the SDK. An int-typed SDK field rejects the wire string # "2026-04-19T18:43:37.662364Z". - if spec_type == "string" and spec_format == "date-time" and sdk_kind not in ("str",): + if ( + spec_type == "string" + and spec_format == "date-time" + and sdk_kind not in ("str", "datetime") + ): problems.append( f"{sdk_name!r}: spec '{spec_name}' is string (date-time), " - f"SDK typed as {sdk_kind}. Use str." + f"SDK typed as {sdk_kind}. Use str or datetime." ) # Rule 3: spec array of strings (e.g. orderbook snapshot rows of