From 49817f07d611a30a2377ac4dfe4a774969bc7810 Mon Sep 17 00:00:00 2001 From: Vaggelis Date: Sat, 22 Aug 2026 21:47:03 +0300 Subject: [PATCH] fix(python): normalize datetime params to UTC ISO with Z suffix fetch_ohlcv start/end and the Router updatedSince filters serialized datetimes with a bare datetime.isoformat(), while the TypeScript SDK sends Date.toISOString(): always UTC, millisecond precision, Z suffix. For aware or non-UTC inputs the two SDKs could therefore request different instants for the same wall-clock time. Add _format_datetime_utc() next to the existing timestamp helpers in _hosted_mappers.py and use it at both call sites. Naive datetimes are treated as UTC, aware datetimes are converted to UTC, and bare dates count as UTC midnight, so both SDKs now send identical values. Strings and other query values pass through unchanged. Fixes #2095 Fixes #2096 --- sdks/python/pmxt/_hosted_mappers.py | 16 ++- sdks/python/pmxt/client.py | 5 +- sdks/python/pmxt/router.py | 3 +- .../tests/test_datetime_serialization.py | 135 ++++++++++++++++++ 4 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 sdks/python/tests/test_datetime_serialization.py diff --git a/sdks/python/pmxt/_hosted_mappers.py b/sdks/python/pmxt/_hosted_mappers.py index 30f0adab..335d815f 100644 --- a/sdks/python/pmxt/_hosted_mappers.py +++ b/sdks/python/pmxt/_hosted_mappers.py @@ -8,7 +8,7 @@ from __future__ import annotations import dataclasses as _dc -from datetime import datetime, timezone +from datetime import date, datetime, timezone from decimal import Decimal from typing import Any, Mapping, TypeVar @@ -362,3 +362,17 @@ def _ms_to_timestamp(value: Any) -> str | None: dt = datetime.fromtimestamp(int(value) / 1000, tz=timezone.utc) return dt.isoformat().replace("+00:00", "Z") + +def _format_datetime_utc(value: datetime | date) -> str: + """Serialize a datetime like JavaScript ``Date.toISOString()``. + + Naive values are treated as UTC, aware values are converted to UTC, and + bare dates count as UTC midnight. The result always has millisecond + precision and a ``Z`` suffix, e.g. ``2026-01-01T00:00:00.000Z``. + """ + dt = value if isinstance(value, datetime) else datetime(value.year, value.month, value.day) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + return dt.isoformat(timespec="milliseconds").replace("+00:00", "Z") diff --git a/sdks/python/pmxt/client.py b/sdks/python/pmxt/client.py index 96e1e56d..4ed44972 100644 --- a/sdks/python/pmxt/client.py +++ b/sdks/python/pmxt/client.py @@ -80,6 +80,7 @@ resolve_wallet_address, ) from ._hosted_mappers import ( + _format_datetime_utc, balance_from_v0, built_order_from_v0, order_from_v0, @@ -2372,9 +2373,9 @@ def fetch_ohlcv( if resolution: params_dict["resolution"] = resolution if start: - params_dict["start"] = start.isoformat() + params_dict["start"] = _format_datetime_utc(start) if end: - params_dict["end"] = end.isoformat() + params_dict["end"] = _format_datetime_utc(end) if limit: params_dict["limit"] = limit diff --git a/sdks/python/pmxt/router.py b/sdks/python/pmxt/router.py index 00e0071c..09a0e95e 100644 --- a/sdks/python/pmxt/router.py +++ b/sdks/python/pmxt/router.py @@ -11,6 +11,7 @@ from typing import Any, Dict, List, Optional, Union from .client import Exchange, _convert_market, _convert_event +from ._hosted_mappers import _format_datetime_utc from .models import ( MatchResult, EventMatchResult, @@ -66,7 +67,7 @@ def _parse_match_result(raw: Dict[str, Any]) -> MatchResult: def _normalize_query_value(value: Any) -> Any: if isinstance(value, (datetime, date)): - return value.isoformat() + return _format_datetime_utc(value) return value diff --git a/sdks/python/tests/test_datetime_serialization.py b/sdks/python/tests/test_datetime_serialization.py new file mode 100644 index 00000000..28cead0e --- /dev/null +++ b/sdks/python/tests/test_datetime_serialization.py @@ -0,0 +1,135 @@ +"""Unit tests for datetime parameter serialization shared by client and router. + +The Python SDK must send the same wire values as the TypeScript SDK's +``Date.toISOString()`` for the same wall-clock input (#2095, #2096): naive +datetimes are treated as UTC, aware datetimes are converted to UTC, and the +result always carries millisecond precision with a ``Z`` suffix. + +The transport layer is mocked so no server or network access happens. +""" + +from __future__ import annotations + +import json +from datetime import date, datetime, timedelta, timezone +from typing import Any, Dict +from urllib.parse import parse_qs, urlparse + +from pmxt._exchanges import Mock +from pmxt.router import Router + + +PMXT_API_KEY = "test_pmxt_key_xxx" +BASE_URL = "https://api.example.test" + + +class _FakeResponse: + def __init__(self, payload: Dict[str, Any]) -> None: + self.data = json.dumps(payload).encode("utf-8") + + def read(self) -> None: # mirrors urllib3 HTTPResponse.read() + return None + + +# --------------------------------------------------------------------------- # +# fetch_ohlcv (client.py) # +# --------------------------------------------------------------------------- # + + +def _capture_ohlcv_params(monkeypatch, **kwargs) -> Dict[str, Any]: + exchange = Mock(auto_start_server=False) + captured: Dict[str, Any] = {} + + def fake_sidecar_read_request(method_name, query, args): + captured["params"] = args[1] + return {"success": True, "data": []} + + monkeypatch.setattr(exchange, "_sidecar_read_request", fake_sidecar_read_request) + candles = exchange.fetch_ohlcv("outcome-123", resolution="1h", **kwargs) + assert candles == [] + return captured["params"] + + +def test_fetch_ohlcv_naive_datetimes_serialized_as_utc(monkeypatch): + params = _capture_ohlcv_params( + monkeypatch, + start=datetime(2026, 1, 1), + end=datetime(2026, 1, 31, 23, 59, 59), + ) + + assert params["start"] == "2026-01-01T00:00:00.000Z" + assert params["end"] == "2026-01-31T23:59:59.000Z" + + +def test_fetch_ohlcv_aware_datetimes_converted_to_utc(monkeypatch): + pst = timezone(timedelta(hours=-8)) + params = _capture_ohlcv_params(monkeypatch, start=datetime(2026, 1, 1, tzinfo=pst)) + + # Same instant as TS: new Date("2026-01-01T00:00:00-08:00").toISOString() + assert params["start"] == "2026-01-01T08:00:00.000Z" + + +def test_fetch_ohlcv_microseconds_truncated_to_milliseconds(monkeypatch): + params = _capture_ohlcv_params( + monkeypatch, start=datetime(2026, 6, 15, 12, 30, 45, 123456) + ) + + assert params["start"] == "2026-06-15T12:30:45.123Z" + + +# --------------------------------------------------------------------------- # +# Router updatedSince (router.py _normalize_query_value) # +# --------------------------------------------------------------------------- # + + +def _captured_updated_since(monkeypatch, **kwargs) -> str: + router = Router( + pmxt_api_key=PMXT_API_KEY, base_url=BASE_URL, auto_start_server=False + ) + calls = [] + + def fake_call_api(method=None, url=None, body=None, header_params=None, **kw): + calls.append({"method": method, "url": url}) + return _FakeResponse({"data": []}) + + monkeypatch.setattr(router._api_client, "call_api", fake_call_api) + router.fetch_matched_event_clusters(**kwargs) + + query = parse_qs(urlparse(calls[0]["url"]).query) + return query["updatedSince"][0] + + +def test_router_updated_since_naive_datetime_serialized_as_utc(monkeypatch): + value = _captured_updated_since( + monkeypatch, updated_since=datetime(2026, 1, 2, 3, 4, 5) + ) + + assert value == "2026-01-02T03:04:05.000Z" + + +def test_router_updated_since_aware_datetime_converted_to_utc(monkeypatch): + jst = timezone(timedelta(hours=9)) + value = _captured_updated_since( + monkeypatch, updated_since=datetime(2026, 1, 2, 12, 0, 0, tzinfo=jst) + ) + + assert value == "2026-01-02T03:00:00.000Z" + + +def test_router_updated_since_bare_date_is_utc_midnight(monkeypatch): + value = _captured_updated_since(monkeypatch, updated_since=date(2026, 1, 1)) + + assert value == "2026-01-01T00:00:00.000Z" + + +def test_router_matches_typescript_date_toisostring_output(monkeypatch): + # router.ts sends new Date(Date.UTC(2026, 0, 1)).toISOString() for this input. + value = _captured_updated_since(monkeypatch, updated_since=datetime(2026, 1, 1)) + + assert value == "2026-01-01T00:00:00.000Z" + + +def test_router_string_values_pass_through_unchanged(monkeypatch): + value = _captured_updated_since(monkeypatch, updated_since="2026-01-02T03:04:05Z") + + assert value == "2026-01-02T03:04:05Z"