From d6b1cd5960400a9195e9de6e118e801746ada586 Mon Sep 17 00:00:00 2001 From: "Naruto11.eth" <269052135+naruto11eth@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:22:53 -0500 Subject: [PATCH 1/2] fix(models): assume UTC for naive ISO in the shared epoch parser `_parse_epoch_or_iso_timestamp` now coerces a naive ISO string to UTC, matching how epoch inputs are parsed (`tz=UTC`). Keeps the account models' prior behavior intact when they move onto this parser, and makes `BuilderTrade` timestamps consistently tz-aware. --- src/polymarket/models/clob/_validators.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/polymarket/models/clob/_validators.py b/src/polymarket/models/clob/_validators.py index 66c18cc..92847d2 100644 --- a/src/polymarket/models/clob/_validators.py +++ b/src/polymarket/models/clob/_validators.py @@ -132,10 +132,13 @@ def _parse_epoch_or_iso_timestamp(value: object) -> object: magnitude = int(value) else: try: - return datetime.fromisoformat(value) + parsed = datetime.fromisoformat(value) except ValueError as error: msg = f"invalid timestamp: {value!r}" raise ValueError(msg) from error + # Assume a naive ISO timestamp is UTC, matching how epoch inputs are + # parsed (tz=UTC) and the prior account-model behavior. + return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC) else: msg = f"expected epoch or ISO timestamp, got {type(value).__name__}" raise ValueError(msg) From 456307c179e2d9efb7eddb5fe8c3503de2a766ef Mon Sep 17 00:00:00 2001 From: "Naruto11.eth" <269052135+naruto11eth@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:51:10 -0500 Subject: [PATCH 2/2] refactor(models): centralize account-trade timestamp parsing Drop the local `_parse_epoch`/`_parse_optional_epoch` duplicate in favor of the shared `RequiredEpochOrIsoTimestamp`/`EpochOrIsoTimestamp` validators. Behavior is preserved for all realistic inputs; negative-string epochs are now rejected (trade timestamps are never negative), locked in by a test. --- src/polymarket/models/clob/account.py | 70 +++------------------------ tests/unit/test_account_models.py | 7 +++ 2 files changed, 14 insertions(+), 63 deletions(-) diff --git a/src/polymarket/models/clob/account.py b/src/polymarket/models/clob/account.py index 2c6ca96..702bcfa 100644 --- a/src/polymarket/models/clob/account.py +++ b/src/polymarket/models/clob/account.py @@ -1,12 +1,13 @@ from __future__ import annotations -from datetime import UTC, datetime from typing import Any, Literal, TypeAlias, cast from pydantic import Field, field_validator from polymarket.models.base import BaseModel from polymarket.models.clob._validators import ( + EpochOrIsoTimestamp, + RequiredEpochOrIsoTimestamp, _DecimalFromString, # pyright: ignore[reportPrivateUsage] ) from polymarket.models.types import OrderSide, TokenId @@ -14,43 +15,6 @@ AssetType: TypeAlias = Literal["COLLATERAL", "CONDITIONAL"] -_EPOCH_MS_THRESHOLD = 100_000_000_000 - - -def _parse_epoch(value: object) -> datetime: - if isinstance(value, datetime): - return value - if isinstance(value, bool): - msg = f"expected an epoch timestamp, got bool {value!r}" - raise ValueError(msg) - if isinstance(value, str): - if value.isdigit() or (value.startswith("-") and value[1:].isdigit()): - value = int(value) - else: - try: - normalized = value.replace("Z", "+00:00") - parsed = datetime.fromisoformat(normalized) - except ValueError as error: - msg = f"invalid epoch or ISO timestamp: {value!r}" - raise ValueError(msg) from error - return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC) - if isinstance(value, int): - seconds = value / 1000 if abs(value) >= _EPOCH_MS_THRESHOLD else value - try: - return datetime.fromtimestamp(seconds, tz=UTC) - except (OverflowError, OSError, ValueError) as error: - msg = f"epoch timestamp out of range: {value!r}" - raise ValueError(msg) from error - msg = f"expected an epoch timestamp, got {type(value).__name__}" - raise ValueError(msg) - - -def _parse_optional_epoch(value: object) -> datetime | None: - if value in (None, ""): - return None - return _parse_epoch(value) - - class OpenOrder(BaseModel): """Open order owned by an account.""" @@ -67,18 +31,8 @@ class OpenOrder(BaseModel): order_type: str = Field(validation_alias="order_type") status: str associate_trades: tuple[str, ...] = Field(default=(), validation_alias="associate_trades") - created_at: datetime = Field(validation_alias="created_at") - expires_at: datetime | None = Field(default=None, validation_alias="expiration") - - @field_validator("created_at", mode="before") - @classmethod - def _parse_created_at(cls, value: object) -> datetime: - return _parse_epoch(value) - - @field_validator("expires_at", mode="before") - @classmethod - def _parse_expires_at(cls, value: object) -> datetime | None: - return _parse_optional_epoch(value) + created_at: RequiredEpochOrIsoTimestamp = Field(validation_alias="created_at") + expires_at: EpochOrIsoTimestamp = Field(default=None, validation_alias="expiration") def _repr_html_(self) -> str: from polymarket._jupyter import card, safe_html_repr, truncate_mid @@ -136,13 +90,8 @@ class ClobTrade(BaseModel): bucket_index: int = Field(validation_alias="bucket_index") transaction_hash: str = Field(validation_alias="transaction_hash") maker_orders: tuple[MakerOrder, ...] = Field(validation_alias="maker_orders") - matched_at: datetime = Field(validation_alias="match_time") - updated_at: datetime = Field(validation_alias="last_update") - - @field_validator("matched_at", "updated_at", mode="before") - @classmethod - def _parse_epoch_field(cls, value: object) -> datetime: - return _parse_epoch(value) + matched_at: RequiredEpochOrIsoTimestamp = Field(validation_alias="match_time") + updated_at: RequiredEpochOrIsoTimestamp = Field(validation_alias="last_update") def _repr_html_(self) -> str: from polymarket._jupyter import card, safe_html_repr, truncate_mid @@ -168,7 +117,7 @@ class Notification(BaseModel): owner: str type: int payload: Any = None - timestamp: datetime + timestamp: RequiredEpochOrIsoTimestamp @field_validator("id", mode="before") @classmethod @@ -185,11 +134,6 @@ def _parse_id(cls, value: object) -> int: msg = f"notification id must be an integer or numeric string, got {type(value).__name__}" raise ValueError(msg) - @field_validator("timestamp", mode="before") - @classmethod - def _parse_timestamp(cls, value: object) -> datetime: - return _parse_epoch(value) - class BalanceAllowance(BaseModel): """Balance and allowance values for an asset in base units.""" diff --git a/tests/unit/test_account_models.py b/tests/unit/test_account_models.py index 1545b52..bcc1460 100644 --- a/tests/unit/test_account_models.py +++ b/tests/unit/test_account_models.py @@ -126,6 +126,13 @@ def test_clob_trade_rejects_out_of_range_epoch_for_match_time() -> None: ClobTrade.parse_response(_clob_trade_payload(match_time=10**18)) +def test_clob_trade_rejects_negative_epoch_string_for_match_time() -> None: + # The shared epoch parser accepts only unsigned digit strings; a negative-string + # epoch is rejected (trade timestamps are never negative). + with pytest.raises(UnexpectedResponseError): + ClobTrade.parse_response(_clob_trade_payload(match_time="-1")) + + def test_clob_trade_parses_match_and_last_update() -> None: trade = ClobTrade.parse_response(_clob_trade_payload()) assert trade.matched_at == datetime.fromtimestamp(1700000000, tz=UTC)