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..f2fe6e4 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,29 @@ _WS_SIGN_PATH = "/trade-api/ws/v2" +# --- Timestamp parsing --- + + +def _parse_ts(value: Any) -> int | None: + """Coerce a Kalshi WebSocket ``ts`` to int milliseconds since epoch. + + 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 isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000) + except ValueError: + return None + return None + + +TsField = Annotated[int | None, BeforeValidator(_parse_ts)] + + # --- WebSocket Message Models --- @@ -46,7 +70,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 +119,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 +140,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 +159,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 +170,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 +180,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 +576,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..90842e2 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -14,9 +14,23 @@ PositionMessage, DEFAULT_WS_BASE, DEMO_WS_BASE, + _parse_ts, ) +class TestParseTs: + """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_iso_string(self): + assert _parse_ts("2026-04-22T18:31:59.043421Z") == 1776882719043 + + def test_garbage_returns_none(self): + assert _parse_ts("not-a-timestamp") is None + + class TestFeedCreation: """Tests for Feed initialization.""" @@ -549,6 +563,11 @@ def test_position_model(self): assert msg.realized_pnl_dollars == "2.50" assert msg.ts == 1704067200 + def test_ticker_model_accepts_iso_ts(self): + """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_models_ignore_extra_fields(self): """Models ignore unknown fields (forward compatibility).""" msg = TickerMessage( @@ -633,6 +652,19 @@ def test_server_timestamp_extracted(self, client): assert feed._last_server_ts == server_ts + def test_server_timestamp_iso_string(self, client): + """Issue #18: ISO 8601 ts must not raise (was crashing every message).""" + 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) + + assert feed._last_server_ts == 1776882719043 + def test_latency_calculated_from_timestamps(self, client): """Latency is calculated when server timestamp is available.""" import time