From 8a73262c63993e9c77baea2d72b4b10f1e1e341a Mon Sep 17 00:00:00 2001 From: Arsh Date: Sat, 2 May 2026 00:12:00 -0700 Subject: [PATCH 1/2] Fix WebSocket ts parsing for ISO 8601 timestamps (#18) Kalshi's WebSocket API switched the `ts` field from int milliseconds to ISO 8601 strings (e.g. '2026-04-22T18:31:59.043421Z') in April 2026. `Feed._dispatch` and `AsyncFeed.__aiter__` called `int(ts)` directly, raising ValueError on every message and triggering a reconnect storm. Adds `_parse_ts` helper that accepts both int ms and ISO 8601, plus a `TsField` annotation applied to every model with a `ts` field so typed messages still validate cleanly under the new server format. Bumps version to 1.0.5. Co-Authored-By: Claude Opus 4.7 (1M context) --- pykalshi/__init__.py | 2 +- pykalshi/afeed.py | 7 ++-- pykalshi/feed.py | 57 ++++++++++++++++++++++------ pyproject.toml | 2 +- tests/test_feed.py | 89 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 141 insertions(+), 16 deletions(-) diff --git a/pykalshi/__init__.py b/pykalshi/__init__.py index c224a4c..a5bb8bc 100644 --- a/pykalshi/__init__.py +++ b/pykalshi/__init__.py @@ -4,7 +4,7 @@ A clean, modular interface for the Kalshi trading API. """ -__version__ = "1.0.4" +__version__ = "1.0.5" import logging diff --git a/pykalshi/afeed.py b/pykalshi/afeed.py index 81ccda3..40cdfdc 100644 --- a/pykalshi/afeed.py +++ b/pykalshi/afeed.py @@ -12,6 +12,7 @@ from ._utils import normalize_tickers from .feed import ( _parse_message, + _parse_ts, _WS_SIGN_PATH, DEFAULT_WS_BASE, DEMO_WS_BASE, @@ -205,9 +206,9 @@ async def __aiter__(self) -> AsyncIterator: # Extract server timestamp payload = data.get("msg", data) if isinstance(payload, dict): - ts = payload.get("ts") - if ts is not None: - self._last_server_ts = int(ts) + parsed_ts = _parse_ts(payload.get("ts")) + if parsed_ts is not None: + self._last_server_ts = parsed_ts # Call registered handlers for handler in self._handlers.get(channel, []): diff --git a/pykalshi/feed.py b/pykalshi/feed.py index e263758..6de5317 100644 --- a/pykalshi/feed.py +++ b/pykalshi/feed.py @@ -11,9 +11,10 @@ import logging import threading import time -from typing import Any, Callable, Union, TYPE_CHECKING +from datetime import datetime +from typing import Annotated, Any, Callable, Union, TYPE_CHECKING -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, BeforeValidator, ConfigDict from ._utils import normalize_ticker, normalize_tickers @@ -28,6 +29,40 @@ _WS_SIGN_PATH = "/trade-api/ws/v2" +# --- Timestamp parsing --- + + +def _parse_ts(value: Any) -> int | None: + """Coerce a Kalshi WebSocket ``ts`` value to int milliseconds since epoch. + + Kalshi historically sent ``ts`` as an integer (ms since epoch). As of + April 2026 it is sent as an ISO 8601 string (e.g. + ``'2026-04-22T18:31:59.043421Z'``). Accept both forms and unknown values + return ``None`` rather than raising. + """ + if value is None: + return None + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + try: + return int(value) + except ValueError: + pass + try: + return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000) + except (ValueError, AttributeError): + return None + return None + + +TsField = Annotated[int | None, BeforeValidator(_parse_ts)] + + # --- WebSocket Message Models --- @@ -46,7 +81,7 @@ class TickerMessage(BaseModel): open_interest_fp: str | None = None dollar_volume_dollars: str | None = None dollar_open_interest_dollars: str | None = None - ts: int | None = None + ts: TsField = None model_config = ConfigDict(extra="ignore") @@ -95,7 +130,7 @@ class TradeMessage(BaseModel): yes_price_dollars: str | None = None no_price_dollars: str | None = None taker_side: str | None = None - ts: int | None = None + ts: TsField = None model_config = ConfigDict(extra="ignore") @@ -116,7 +151,7 @@ class FillMessage(BaseModel): yes_price_dollars: str | None = None no_price_dollars: str | None = None is_taker: bool | None = None - ts: int | None = None + ts: TsField = None model_config = ConfigDict(extra="ignore") @@ -135,7 +170,7 @@ class PositionMessage(BaseModel): total_traded_dollars: str | None = None resting_orders_count: int | None = None fees_paid_dollars: str | None = None - ts: int | None = None + ts: TsField = None model_config = ConfigDict(extra="ignore") @@ -146,7 +181,7 @@ class MarketLifecycleMessage(BaseModel): market_ticker: str status: str | None = None result: str | None = None # Settlement result ("yes" or "no") - ts: int | None = None + ts: TsField = None model_config = ConfigDict(extra="ignore") @@ -156,7 +191,7 @@ class OrderGroupUpdateMessage(BaseModel): order_group_id: str status: str | None = None # "active", "triggered", "canceled" - ts: int | None = None + ts: TsField = None model_config = ConfigDict(extra="ignore") @@ -552,10 +587,10 @@ def _dispatch(self, raw: str | bytes) -> None: payload = data.get("msg", data) if isinstance(payload, dict): - ts = payload.get("ts") - if ts is not None: + parsed_ts = _parse_ts(payload.get("ts")) + if parsed_ts is not None: with self._metrics_lock: - self._last_server_ts = int(ts) + self._last_server_ts = parsed_ts handlers = self._handlers.get(channel) if not handlers: diff --git a/pyproject.toml b/pyproject.toml index ef8d728..f1153ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pykalshi" -version = "1.0.4" +version = "1.0.5" description = "A typed Python client for the Kalshi prediction markets API with WebSocket streaming, automatic retries, and ergonomic interfaces" readme = "README.md" license = "MIT" diff --git a/tests/test_feed.py b/tests/test_feed.py index b9d785d..4c79d9c 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -14,9 +14,39 @@ PositionMessage, DEFAULT_WS_BASE, DEMO_WS_BASE, + _parse_ts, ) +class TestParseTs: + """Tests for ts coercion helper. + + Kalshi switched ts from int ms epoch to ISO 8601 string in April 2026 + (issue #18). The helper must accept both and reject garbage gracefully. + """ + + def test_int_passes_through(self): + assert _parse_ts(1776882719000) == 1776882719000 + + def test_none_returns_none(self): + assert _parse_ts(None) is None + + def test_iso_string_with_microseconds(self): + assert _parse_ts("2026-04-22T18:31:59.043421Z") == 1776882719043 + + def test_iso_string_without_microseconds(self): + assert _parse_ts("2026-04-22T18:31:59Z") == 1776882719000 + + def test_numeric_string_parsed_as_int(self): + assert _parse_ts("1776882719000") == 1776882719000 + + def test_unparseable_string_returns_none(self): + assert _parse_ts("not-a-timestamp") is None + + def test_unexpected_type_returns_none(self): + assert _parse_ts({"unexpected": "dict"}) is None + + class TestFeedCreation: """Tests for Feed initialization.""" @@ -549,6 +579,19 @@ def test_position_model(self): assert msg.realized_pnl_dollars == "2.50" assert msg.ts == 1704067200 + def test_ticker_model_accepts_iso_ts(self): + """ts field accepts ISO 8601 strings and normalizes to int ms epoch. + + Kalshi changed ts from int ms to ISO 8601 in April 2026 (issue #18). + Validation must succeed so handlers receive a typed model, not a dict. + """ + msg = TickerMessage(market_ticker="TEST", ts="2026-04-22T18:31:59Z") + assert msg.ts == 1776882719000 + + def test_trade_model_accepts_iso_ts(self): + msg = TradeMessage(market_ticker="TEST", ts="2026-04-22T18:31:59Z") + assert msg.ts == 1776882719000 + def test_models_ignore_extra_fields(self): """Models ignore unknown fields (forward compatibility).""" msg = TickerMessage( @@ -633,6 +676,52 @@ def test_server_timestamp_extracted(self, client): assert feed._last_server_ts == server_ts + def test_server_timestamp_iso_string(self, client): + """Server timestamp accepts ISO 8601 strings (Kalshi April 2026 change). + + Regression test for issue #18: ``int(ts)`` raised ValueError when Kalshi + switched ts to ISO 8601, taking down every WS connection. + """ + feed = Feed(client) + feed.on("ticker", lambda x: None) + + raw = json.dumps({ + "type": "ticker", + "msg": {"market_ticker": "TEST", "ts": "2026-04-22T18:31:59.043421Z"}, + }) + feed._dispatch(raw) + + # 2026-04-22T18:31:59.043421Z → ms since epoch + assert feed._last_server_ts == 1776882719043 + assert feed.reconnect_count == 0 + + def test_server_timestamp_iso_string_no_microseconds(self, client): + """ISO 8601 without microseconds is accepted.""" + feed = Feed(client) + feed.on("ticker", lambda x: None) + + raw = json.dumps({ + "type": "ticker", + "msg": {"market_ticker": "TEST", "ts": "2026-04-22T18:31:59Z"}, + }) + feed._dispatch(raw) + + assert feed._last_server_ts == 1776882719000 + + def test_server_timestamp_unparseable_is_ignored(self, client): + """Unrecognized ts values do not crash dispatch.""" + feed = Feed(client) + feed.on("ticker", lambda x: None) + + raw = json.dumps({ + "type": "ticker", + "msg": {"market_ticker": "TEST", "ts": "not-a-timestamp"}, + }) + feed._dispatch(raw) # must not raise + + assert feed._last_server_ts is None + assert feed.messages_received == 1 + def test_latency_calculated_from_timestamps(self, client): """Latency is calculated when server timestamp is available.""" import time From bcbb1593b744554e03645103cd44cfb22c170b79 Mon Sep 17 00:00:00 2001 From: Arsh Date: Sat, 2 May 2026 00:16:52 -0700 Subject: [PATCH 2/2] Trim _parse_ts and tests to essential cases Drop bool/float branches and numeric-string handling that Kalshi never sends. Drop redundant ISO-without-microseconds, unparseable, and non-Ticker-model tests covering identical code paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- pykalshi/feed.py | 21 ++++----------- tests/test_feed.py | 67 ++++------------------------------------------ 2 files changed, 10 insertions(+), 78 deletions(-) diff --git a/pykalshi/feed.py b/pykalshi/feed.py index 6de5317..f2fe6e4 100644 --- a/pykalshi/feed.py +++ b/pykalshi/feed.py @@ -33,29 +33,18 @@ def _parse_ts(value: Any) -> int | None: - """Coerce a Kalshi WebSocket ``ts`` value to int milliseconds since epoch. + """Coerce a Kalshi WebSocket ``ts`` to int milliseconds since epoch. - Kalshi historically sent ``ts`` as an integer (ms since epoch). As of - April 2026 it is sent as an ISO 8601 string (e.g. - ``'2026-04-22T18:31:59.043421Z'``). Accept both forms and unknown values - return ``None`` rather than raising. + Pre-April-2026 Kalshi sent int ms; since then it sends ISO 8601 strings + (e.g. ``'2026-04-22T18:31:59.043421Z'``). Accept both; unrecognized + values return None rather than raising. """ - if value is None: - return None - if isinstance(value, bool): - return None if isinstance(value, int): return value - if isinstance(value, float): - return int(value) if isinstance(value, str): - try: - return int(value) - except ValueError: - pass try: return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000) - except (ValueError, AttributeError): + except ValueError: return None return None diff --git a/tests/test_feed.py b/tests/test_feed.py index 4c79d9c..90842e2 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -19,33 +19,17 @@ class TestParseTs: - """Tests for ts coercion helper. - - Kalshi switched ts from int ms epoch to ISO 8601 string in April 2026 - (issue #18). The helper must accept both and reject garbage gracefully. - """ + """Issue #18: Kalshi switched ts from int ms to ISO 8601 in April 2026.""" def test_int_passes_through(self): assert _parse_ts(1776882719000) == 1776882719000 - def test_none_returns_none(self): - assert _parse_ts(None) is None - - def test_iso_string_with_microseconds(self): + def test_iso_string(self): assert _parse_ts("2026-04-22T18:31:59.043421Z") == 1776882719043 - def test_iso_string_without_microseconds(self): - assert _parse_ts("2026-04-22T18:31:59Z") == 1776882719000 - - def test_numeric_string_parsed_as_int(self): - assert _parse_ts("1776882719000") == 1776882719000 - - def test_unparseable_string_returns_none(self): + def test_garbage_returns_none(self): assert _parse_ts("not-a-timestamp") is None - def test_unexpected_type_returns_none(self): - assert _parse_ts({"unexpected": "dict"}) is None - class TestFeedCreation: """Tests for Feed initialization.""" @@ -580,18 +564,10 @@ def test_position_model(self): assert msg.ts == 1704067200 def test_ticker_model_accepts_iso_ts(self): - """ts field accepts ISO 8601 strings and normalizes to int ms epoch. - - Kalshi changed ts from int ms to ISO 8601 in April 2026 (issue #18). - Validation must succeed so handlers receive a typed model, not a dict. - """ + """Issue #18: TsField coerces ISO strings so typed models still validate.""" msg = TickerMessage(market_ticker="TEST", ts="2026-04-22T18:31:59Z") assert msg.ts == 1776882719000 - def test_trade_model_accepts_iso_ts(self): - msg = TradeMessage(market_ticker="TEST", ts="2026-04-22T18:31:59Z") - assert msg.ts == 1776882719000 - def test_models_ignore_extra_fields(self): """Models ignore unknown fields (forward compatibility).""" msg = TickerMessage( @@ -677,11 +653,7 @@ def test_server_timestamp_extracted(self, client): assert feed._last_server_ts == server_ts def test_server_timestamp_iso_string(self, client): - """Server timestamp accepts ISO 8601 strings (Kalshi April 2026 change). - - Regression test for issue #18: ``int(ts)`` raised ValueError when Kalshi - switched ts to ISO 8601, taking down every WS connection. - """ + """Issue #18: ISO 8601 ts must not raise (was crashing every message).""" feed = Feed(client) feed.on("ticker", lambda x: None) @@ -691,36 +663,7 @@ def test_server_timestamp_iso_string(self, client): }) feed._dispatch(raw) - # 2026-04-22T18:31:59.043421Z → ms since epoch assert feed._last_server_ts == 1776882719043 - assert feed.reconnect_count == 0 - - def test_server_timestamp_iso_string_no_microseconds(self, client): - """ISO 8601 without microseconds is accepted.""" - feed = Feed(client) - feed.on("ticker", lambda x: None) - - raw = json.dumps({ - "type": "ticker", - "msg": {"market_ticker": "TEST", "ts": "2026-04-22T18:31:59Z"}, - }) - feed._dispatch(raw) - - assert feed._last_server_ts == 1776882719000 - - def test_server_timestamp_unparseable_is_ignored(self, client): - """Unrecognized ts values do not crash dispatch.""" - feed = Feed(client) - feed.on("ticker", lambda x: None) - - raw = json.dumps({ - "type": "ticker", - "msg": {"market_ticker": "TEST", "ts": "not-a-timestamp"}, - }) - feed._dispatch(raw) # must not raise - - assert feed._last_server_ts is None - assert feed.messages_received == 1 def test_latency_calculated_from_timestamps(self, client): """Latency is calculated when server timestamp is available."""