Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion sdks/python/pmxt/_hosted_mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
5 changes: 3 additions & 2 deletions sdks/python/pmxt/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
resolve_wallet_address,
)
from ._hosted_mappers import (
_format_datetime_utc,
balance_from_v0,
built_order_from_v0,
order_from_v0,
Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion sdks/python/pmxt/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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


Expand Down
135 changes: 135 additions & 0 deletions sdks/python/tests/test_datetime_serialization.py
Original file line number Diff line number Diff line change
@@ -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"