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
5 changes: 4 additions & 1 deletion src/polymarket/models/clob/_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
70 changes: 7 additions & 63 deletions src/polymarket/models/clob/account.py
Original file line number Diff line number Diff line change
@@ -1,56 +1,20 @@
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

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."""

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -168,7 +117,7 @@ class Notification(BaseModel):
owner: str
type: int
payload: Any = None
timestamp: datetime
timestamp: RequiredEpochOrIsoTimestamp

@field_validator("id", mode="before")
@classmethod
Expand All @@ -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."""
Expand Down
7 changes: 7 additions & 0 deletions tests/unit/test_account_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading