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) 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)