diff --git a/src/polymarket/__init__.py b/src/polymarket/__init__.py index e857d8b8..fb6ba20c 100644 --- a/src/polymarket/__init__.py +++ b/src/polymarket/__init__.py @@ -22,6 +22,7 @@ AcceptedOrder, Activity, ActivityType, + ApiKey, ApiKeyCreds, AssetType, BalanceAllowance, @@ -94,6 +95,7 @@ MetaHolder, MetaMarketPosition, Notification, + NotificationType, OpenInterest, OpenOrder, OrderBook, @@ -220,6 +222,7 @@ "AcceptedOrder", "Activity", "ActivityType", + "ApiKey", "ApiKeyCreds", "AssetType", "AsyncPaginator", @@ -311,6 +314,7 @@ "MetaHolder", "MetaMarketPosition", "Notification", + "NotificationType", "OpenInterest", "OpenOrder", "OrderBook", diff --git a/src/polymarket/models/__init__.py b/src/polymarket/models/__init__.py index 86e3b4c6..2d3fc5b1 100644 --- a/src/polymarket/models/__init__.py +++ b/src/polymarket/models/__init__.py @@ -2,12 +2,18 @@ AcceptedOrder, ApiKeyCreds, AssetType, + AutoRedeemedNotification, + AutoRedeemedNotificationPayload, BalanceAllowance, BuilderApiKeyInfo, BuilderFeeRates, BuilderTrade, CancelOrdersResponse, + ChildCommentCreatedNotification, + ChildCommentNotificationPayload, ClobTrade, + ComboAutoRedeemedNotification, + ComboAutoRedeemedNotificationPayload, CurrentReward, CurrentRewardConfig, EarningBreakdown, @@ -15,14 +21,25 @@ LastTradePrice, LastTradePriceForToken, MakerOrder, + MarketNotificationPayload, + MarketNotificationRewards, + MarketNotificationRewardsRate, + MarketNotificationToken, MarketOrderType, + MarketRegisteredNotification, + MarketResolvedNotification, MarketReward, MarketRewardConfig, MarketRewardToken, Notification, + NotificationType, OpenOrder, OrderBook, OrderBookLevel, + OrderCancellationNotification, + OrderFillFailedNotification, + OrderFillNotification, + OrderNotificationPayload, OrderPostStatus, OrderResponse, OrderResponseErrorCode, @@ -32,6 +49,8 @@ PriceRequest, RejectedOrder, RelayerTransactionState, + RewardPayoutNotification, + RewardPayoutNotificationPayload, RewardsPercentages, SignedOrder, TickSize, @@ -41,6 +60,8 @@ UserEarning, UserRewardsConfig, UserRewardsEarning, + YieldPayoutNotification, + YieldPayoutNotificationPayload, ) from polymarket.models.collateral_return import ( CollateralReturnOperation, @@ -190,6 +211,7 @@ ) from polymarket.models.rfq import ComboMarket, ComboMarketOutcome, ComboMarketOutcomes from polymarket.models.types import ( + ApiKey, ComboActivityId, ComboConditionId, CommentId, @@ -213,14 +235,21 @@ "AcceptedOrder", "Activity", "ActivityType", + "ApiKey", "ApiKeyCreds", "AssetType", + "AutoRedeemedNotification", + "AutoRedeemedNotificationPayload", "BalanceAllowance", "BuilderApiKeyInfo", "BuilderFeeRates", "BuilderTrade", "CancelOrdersResponse", + "ChildCommentCreatedNotification", + "ChildCommentNotificationPayload", "ClobTrade", + "ComboAutoRedeemedNotification", + "ComboAutoRedeemedNotificationPayload", "TradeStatus", "CurrentReward", "CurrentRewardConfig", @@ -229,12 +258,23 @@ "LastTradePrice", "LastTradePriceForToken", "MakerOrder", + "MarketNotificationPayload", + "MarketNotificationRewards", + "MarketNotificationRewardsRate", + "MarketNotificationToken", "MarketOrderType", + "MarketRegisteredNotification", + "MarketResolvedNotification", "MarketReward", "MarketRewardConfig", "MarketRewardToken", "Notification", + "NotificationType", "OpenOrder", + "OrderCancellationNotification", + "OrderFillFailedNotification", + "OrderFillNotification", + "OrderNotificationPayload", "OrderBook", "OrderBookLevel", "OrderPostStatus", @@ -247,6 +287,8 @@ "PriceRequest", "RejectedOrder", "RelayerTransactionState", + "RewardPayoutNotification", + "RewardPayoutNotificationPayload", "RewardsPercentages", "SignedOrder", "TickSize", @@ -255,6 +297,8 @@ "UserEarning", "UserRewardsConfig", "UserRewardsEarning", + "YieldPayoutNotification", + "YieldPayoutNotificationPayload", "BuilderVolumeEntry", "BuilderVolumeTimePeriod", "ClosedPosition", diff --git a/src/polymarket/models/clob/__init__.py b/src/polymarket/models/clob/__init__.py index 8ab9161f..dd47bef1 100644 --- a/src/polymarket/models/clob/__init__.py +++ b/src/polymarket/models/clob/__init__.py @@ -3,7 +3,6 @@ BalanceAllowance, ClobTrade, MakerOrder, - Notification, OpenOrder, TradeStatus, ) @@ -11,6 +10,30 @@ from polymarket.models.clob.builder import BuilderFeeRates, BuilderTrade from polymarket.models.clob.cancel import CancelOrdersResponse from polymarket.models.clob.last_trade import LastTradePrice, LastTradePriceForToken +from polymarket.models.clob.notifications import ( + AutoRedeemedNotification, + AutoRedeemedNotificationPayload, + ChildCommentCreatedNotification, + ChildCommentNotificationPayload, + ComboAutoRedeemedNotification, + ComboAutoRedeemedNotificationPayload, + MarketNotificationPayload, + MarketNotificationRewards, + MarketNotificationRewardsRate, + MarketNotificationToken, + MarketRegisteredNotification, + MarketResolvedNotification, + Notification, + NotificationType, + OrderCancellationNotification, + OrderFillFailedNotification, + OrderFillNotification, + OrderNotificationPayload, + RewardPayoutNotification, + RewardPayoutNotificationPayload, + YieldPayoutNotification, + YieldPayoutNotificationPayload, +) from polymarket.models.clob.order_book import OrderBook, OrderBookLevel from polymarket.models.clob.order_response import ( AcceptedOrder, @@ -45,12 +68,18 @@ "AcceptedOrder", "ApiKeyCreds", "AssetType", + "AutoRedeemedNotification", + "AutoRedeemedNotificationPayload", "BalanceAllowance", "BuilderApiKeyInfo", "BuilderFeeRates", "BuilderTrade", "CancelOrdersResponse", + "ChildCommentCreatedNotification", + "ChildCommentNotificationPayload", "ClobTrade", + "ComboAutoRedeemedNotification", + "ComboAutoRedeemedNotificationPayload", "TradeStatus", "CurrentReward", "CurrentRewardConfig", @@ -59,12 +88,23 @@ "LastTradePrice", "LastTradePriceForToken", "MakerOrder", + "MarketNotificationPayload", + "MarketNotificationRewards", + "MarketNotificationRewardsRate", + "MarketNotificationToken", "MarketOrderType", + "MarketRegisteredNotification", + "MarketResolvedNotification", "MarketReward", "MarketRewardConfig", "MarketRewardToken", "Notification", + "NotificationType", "OpenOrder", + "OrderCancellationNotification", + "OrderFillFailedNotification", + "OrderFillNotification", + "OrderNotificationPayload", "OrderBook", "OrderBookLevel", "OrderPostStatus", @@ -76,6 +116,8 @@ "PriceRequest", "RejectedOrder", "RelayerTransactionState", + "RewardPayoutNotification", + "RewardPayoutNotificationPayload", "RewardsPercentages", "SignedOrder", "TickSize", @@ -84,4 +126,6 @@ "UserEarning", "UserRewardsConfig", "UserRewardsEarning", + "YieldPayoutNotification", + "YieldPayoutNotificationPayload", ] diff --git a/src/polymarket/models/clob/account.py b/src/polymarket/models/clob/account.py index 1094e170..cb783a10 100644 --- a/src/polymarket/models/clob/account.py +++ b/src/polymarket/models/clob/account.py @@ -2,7 +2,7 @@ from datetime import datetime from decimal import Decimal -from typing import Any, Literal, TypeAlias, cast +from typing import Literal, TypeAlias, cast from pydantic import Field, field_validator @@ -168,35 +168,6 @@ def render(self: ClobTrade) -> str: return render(self) -class Notification(BaseModel): - """Account notification.""" - - id: int - owner: str - type: int - payload: Any = None - timestamp: datetime - - _validate_timestamp = field_validator("timestamp", mode="before")( - _require_epoch_or_iso_timestamp - ) - - @field_validator("id", mode="before") - @classmethod - def _parse_id(cls, value: object) -> int: - if isinstance(value, bool): - msg = f"notification id must be an integer, got bool {value!r}" - raise ValueError(msg) - if isinstance(value, int): - return value - if isinstance(value, str) and ( - value.isdigit() or (value.startswith("-") and value[1:].isdigit()) - ): - return int(value) - msg = f"notification id must be an integer or numeric string, got {type(value).__name__}" - raise ValueError(msg) - - class BalanceAllowance(BaseModel): """Balance and allowance values for an asset in base units.""" @@ -245,6 +216,5 @@ def _parse_base_units(value: object, name: str) -> int: "BalanceAllowance", "ClobTrade", "MakerOrder", - "Notification", "OpenOrder", ] diff --git a/src/polymarket/models/clob/notifications.py b/src/polymarket/models/clob/notifications.py new file mode 100644 index 00000000..882bae3d --- /dev/null +++ b/src/polymarket/models/clob/notifications.py @@ -0,0 +1,446 @@ +"""Account notification models.""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from enum import IntEnum +from typing import Annotated, Literal + +from pydantic import Field, field_validator + +from polymarket.models._validators import parse_decimal_string +from polymarket.models.base import BaseModel +from polymarket.models.clob._validators import ( + _coerce_decimalish, # pyright: ignore[reportPrivateUsage] + _parse_epoch_or_iso_timestamp, # pyright: ignore[reportPrivateUsage] + _require_epoch_or_iso_timestamp, # pyright: ignore[reportPrivateUsage] +) +from polymarket.models.clob.orders import OrderType +from polymarket.models.gamma.comment import CommentProfile +from polymarket.models.types import ( + ApiKey, + ComboConditionId, + CommentId, + CtfConditionId, + OrderId, + OrderSide, + PositionId, + QuestionId, + TokenId, + validate_combo_condition_id, +) +from polymarket.types import EvmAddress, TransactionHash + + +class NotificationType(IntEnum): + """Kind of account notification. + + Each kind carries a payload whose shape is tied to the kind. + """ + + ORDER_CANCELLATION = 1 + ORDER_FILL = 2 + MARKET_REGISTERED = 3 + MARKET_RESOLVED = 4 + REWARD_PAYOUT = 5 + CHILD_COMMENT_CREATED = 6 + YIELD_PAYOUT = 7 + ORDER_FILL_FAILED = 8 + AUTO_REDEEMED = 9 + COMBO_AUTO_REDEEMED = 10 + + +def _empty_string_to_none(value: object) -> object: + return None if value == "" else value + + +class OrderNotificationPayload(BaseModel): + """Payload of an order lifecycle notification (cancellation, fill, and + failed fill share this shape). + + ``transaction_hash`` and ``trade_id`` are only populated on fills. The + market-display fields (``question``, ``market_slug``, ``icon``, + ``image``, ``event_slug``, ``series_slug``) may be empty strings, and + older notifications may omit them entirely, along with ``order_type``. + """ + + token_id: TokenId = Field(validation_alias="asset_id") + condition_id: CtfConditionId = Field(validation_alias="market") + order_id: OrderId + side: OrderSide + order_type: OrderType | None = Field(default=None, validation_alias="type") + price: Decimal + original_size: Decimal + matched_size: Decimal + remaining_size: Decimal + outcome: str + outcome_index: int + transaction_hash: TransactionHash | None = None + trade_id: str | None = None + question: str | None = None + market_slug: str | None = None + icon: str | None = None + image: str | None = None + event_slug: str | None = Field(default=None, validation_alias="eventSlug") + series_slug: str | None = Field(default=None, validation_alias="seriesSlug") + + @field_validator("price", "original_size", "matched_size", "remaining_size", mode="before") + @classmethod + def _parse_decimal_fields(cls, value: object) -> object: + return parse_decimal_string(value) + + @field_validator("order_type", "transaction_hash", "trade_id", mode="before") + @classmethod + def _parse_optional_fields(cls, value: object) -> object: + return _empty_string_to_none(value) + + +class MarketNotificationToken(BaseModel): + """One outcome token inside a market lifecycle notification payload. + + On a market-resolved notification, ``winner`` marks the winning outcome. + """ + + token_id: TokenId + outcome: str + price: Decimal | None = None + winner: bool + + @field_validator("price", mode="before") + @classmethod + def _parse_price(cls, value: object) -> object: + return _coerce_decimalish(value) + + +class MarketNotificationRewardsRate(BaseModel): + """Per-asset daily reward rate on a market lifecycle notification.""" + + asset_address: str + daily_rate: Decimal = Field(validation_alias="rewards_daily_rate") + + @field_validator("daily_rate", mode="before") + @classmethod + def _parse_daily_rate(cls, value: object) -> object: + return _coerce_decimalish(value) + + +class MarketNotificationRewards(BaseModel): + """Liquidity-rewards parameters carried on a market lifecycle notification.""" + + min_size: Decimal + max_spread: float + rates: tuple[MarketNotificationRewardsRate, ...] | None = None + + @field_validator("min_size", mode="before") + @classmethod + def _parse_min_size(cls, value: object) -> object: + return _coerce_decimalish(value) + + +class MarketNotificationPayload(BaseModel): + """Payload of a market lifecycle notification (market registered and + market resolved share this shape: the market the notification is about). + + Fields that default to ``None`` may be absent on older notifications. + """ + + condition_id: CtfConditionId + question_id: QuestionId + question: str + description: str + market_slug: str + icon: str + image: str + fpmm: str + active: bool + closed: bool + archived: bool | None = None + accepting_orders: bool + accepting_orders_timestamp: datetime | None = Field( + default=None, + validation_alias="accepting_order_timestamp", + ) + enable_order_book: bool | None = None + end_date: datetime | None = Field(default=None, validation_alias="end_date_iso") + game_start_time: datetime | None = None + seconds_delay: int + minimum_order_size: Decimal + minimum_tick_size: Decimal + maker_base_fee: int | None + taker_base_fee: int | None + notifications_enabled: bool | None = None + neg_risk: bool | None = None + neg_risk_market_id: str | None = None + neg_risk_request_id: str | None = None + is_50_50_outcome: bool | None = None + rewards: MarketNotificationRewards | None = None + tokens: tuple[MarketNotificationToken, ...] + tags: tuple[str, ...] | None = None + event_slug: str | None = Field(default=None, validation_alias="eventSlug") + + @field_validator( + "accepting_orders_timestamp", + "end_date", + "game_start_time", + mode="before", + ) + @classmethod + def _parse_optional_timestamps(cls, value: object) -> object: + return _parse_epoch_or_iso_timestamp(value) + + @field_validator("minimum_order_size", "minimum_tick_size", mode="before") + @classmethod + def _parse_decimal_fields(cls, value: object) -> object: + return _coerce_decimalish(value) + + +class RewardPayoutNotificationPayload(BaseModel): + """Payload of a liquidity-reward payout notification.""" + + proxy_wallet: EvmAddress = Field(validation_alias="proxyWallet") + reward: Decimal + transaction_hash: TransactionHash = Field(validation_alias="txnHash") + + @field_validator("reward", mode="before") + @classmethod + def _parse_reward(cls, value: object) -> object: + return _coerce_decimalish(value) + + +class YieldPayoutNotificationPayload(BaseModel): + """Payload of a yield payout notification.""" + + proxy_wallet: EvmAddress = Field(validation_alias="proxyWallet") + amount: Decimal + transaction_hash: TransactionHash = Field(validation_alias="txnHash") + + @field_validator("amount", mode="before") + @classmethod + def _parse_amount(cls, value: object) -> object: + return _coerce_decimalish(value) + + +class ChildCommentNotificationPayload(BaseModel): + """Payload of a child-comment notification: the reply comment, its + author's profile, and the event or series the thread belongs to. + """ + + id: CommentId + body: str | None = None + parent_entity_type: Literal["Event", "Series"] | None = Field( + default=None, + validation_alias="parentEntityType", + ) + parent_entity_id: int | None = Field(default=None, validation_alias="parentEntityID") + parent_comment_id: CommentId | None = Field( + default=None, + validation_alias="parentCommentID", + ) + user_address: EvmAddress | None = Field(default=None, validation_alias="userAddress") + created_at: datetime | None = Field(default=None, validation_alias="createdAt") + profile: CommentProfile | None = None + event_slug: str | None = Field(default=None, validation_alias="eventSlug") + event_title: str | None = Field(default=None, validation_alias="eventTitle") + series_slug: str | None = Field(default=None, validation_alias="seriesSlug") + series_title: str | None = Field(default=None, validation_alias="seriesTitle") + image: str | None = None + + @field_validator("created_at", mode="before") + @classmethod + def _parse_created_at(cls, value: object) -> object: + return _parse_epoch_or_iso_timestamp(value) + + +class AutoRedeemedNotificationPayload(BaseModel): + """Payload of an auto-redeem notification: a winning position redeemed + on-chain on the account's behalf. + """ + + proxy_wallet: EvmAddress = Field(validation_alias="proxyWallet") + amount: Decimal + condition_id: CtfConditionId = Field(validation_alias="conditionId") + question: str + image: str + market_slug: str = Field(validation_alias="slug") + position: str | None = None + market_url: str | None = Field(default=None, validation_alias="marketUrl") + portfolio_url: str | None = Field(default=None, validation_alias="portfolioUrl") + neg_risk: bool = Field(validation_alias="negRisk") + transaction_hash: TransactionHash = Field(validation_alias="txnHash") + + @field_validator("amount", mode="before") + @classmethod + def _parse_amount(cls, value: object) -> object: + return _coerce_decimalish(value) + + +class ComboAutoRedeemedNotificationPayload(BaseModel): + """Payload of a combo auto-redeem notification: a winning combo position + redeemed on-chain on the account's behalf. ``legs`` is the combo arity. + """ + + proxy_wallet: EvmAddress = Field(validation_alias="proxyWallet") + amount: Decimal + position_id: PositionId = Field(validation_alias="positionId") + condition_id: ComboConditionId = Field(validation_alias="conditionId") + outcome_index: int = Field(validation_alias="outcomeIndex") + legs: int + portfolio_url: str | None = Field(default=None, validation_alias="portfolioUrl") + transaction_hash: TransactionHash = Field(validation_alias="txnHash") + + @field_validator("amount", mode="before") + @classmethod + def _parse_amount(cls, value: object) -> object: + return _coerce_decimalish(value) + + @field_validator("condition_id", mode="before") + @classmethod + def _validate_condition_id(cls, value: object) -> ComboConditionId: + return validate_combo_condition_id(value) + + +def _parse_notification_id(value: object) -> object: + if isinstance(value, bool): + msg = f"notification id must be an integer, got bool {value!r}" + raise ValueError(msg) + if isinstance(value, int): + return value + if isinstance(value, str) and ( + value.isdigit() or (value.startswith("-") and value[1:].isdigit()) + ): + return int(value) + msg = f"notification id must be an integer or numeric string, got {type(value).__name__}" + raise ValueError(msg) + + +class _NotificationBase(BaseModel): + id: int + owner: ApiKey + timestamp: datetime + + @field_validator("id", mode="before") + @classmethod + def _validate_id(cls, value: object) -> object: + return _parse_notification_id(value) + + @field_validator("timestamp", mode="before") + @classmethod + def _parse_timestamp(cls, value: object) -> object: + return _require_epoch_or_iso_timestamp(value) + + +class OrderCancellationNotification(_NotificationBase): + """An order owned by the account was canceled.""" + + type: Literal[NotificationType.ORDER_CANCELLATION] + payload: OrderNotificationPayload + + +class OrderFillNotification(_NotificationBase): + """An order owned by the account was filled.""" + + type: Literal[NotificationType.ORDER_FILL] + payload: OrderNotificationPayload + + +class MarketRegisteredNotification(_NotificationBase): + """A market was registered for trading.""" + + type: Literal[NotificationType.MARKET_REGISTERED] + payload: MarketNotificationPayload + + +class MarketResolvedNotification(_NotificationBase): + """A market the account holds a position in resolved.""" + + type: Literal[NotificationType.MARKET_RESOLVED] + payload: MarketNotificationPayload + + +class RewardPayoutNotification(_NotificationBase): + """The account received a liquidity-reward payout.""" + + type: Literal[NotificationType.REWARD_PAYOUT] + payload: RewardPayoutNotificationPayload + + +class ChildCommentCreatedNotification(_NotificationBase): + """Someone replied to one of the account's comments.""" + + type: Literal[NotificationType.CHILD_COMMENT_CREATED] + payload: ChildCommentNotificationPayload + + +class YieldPayoutNotification(_NotificationBase): + """The account received a yield payout.""" + + type: Literal[NotificationType.YIELD_PAYOUT] + payload: YieldPayoutNotificationPayload + + +class OrderFillFailedNotification(_NotificationBase): + """A fill on an order owned by the account failed to settle.""" + + type: Literal[NotificationType.ORDER_FILL_FAILED] + payload: OrderNotificationPayload + + +class AutoRedeemedNotification(_NotificationBase): + """A winning position was redeemed on-chain on the account's behalf.""" + + type: Literal[NotificationType.AUTO_REDEEMED] + payload: AutoRedeemedNotificationPayload + + +class ComboAutoRedeemedNotification(_NotificationBase): + """A winning combo position was redeemed on-chain on the account's behalf.""" + + type: Literal[NotificationType.COMBO_AUTO_REDEEMED] + payload: ComboAutoRedeemedNotificationPayload + + +Notification = Annotated[ + OrderCancellationNotification + | OrderFillNotification + | MarketRegisteredNotification + | MarketResolvedNotification + | RewardPayoutNotification + | ChildCommentCreatedNotification + | YieldPayoutNotification + | OrderFillFailedNotification + | AutoRedeemedNotification + | ComboAutoRedeemedNotification, + Field(discriminator="type"), +] +"""Account notification. + +Discriminated on ``type``: narrowing on it also narrows ``payload`` to the +shape carried by that notification kind. +""" + + +__all__ = [ + "AutoRedeemedNotification", + "AutoRedeemedNotificationPayload", + "ChildCommentCreatedNotification", + "ChildCommentNotificationPayload", + "ComboAutoRedeemedNotification", + "ComboAutoRedeemedNotificationPayload", + "MarketNotificationPayload", + "MarketNotificationRewards", + "MarketNotificationRewardsRate", + "MarketNotificationToken", + "MarketRegisteredNotification", + "MarketResolvedNotification", + "Notification", + "NotificationType", + "OrderCancellationNotification", + "OrderFillFailedNotification", + "OrderFillNotification", + "OrderNotificationPayload", + "RewardPayoutNotification", + "RewardPayoutNotificationPayload", + "YieldPayoutNotification", + "YieldPayoutNotificationPayload", +] diff --git a/src/polymarket/models/types.py b/src/polymarket/models/types.py index bb7df046..f3bba005 100644 --- a/src/polymarket/models/types.py +++ b/src/polymarket/models/types.py @@ -4,6 +4,7 @@ OrderSide: TypeAlias = Literal["BUY", "SELL"] +ApiKey = NewType("ApiKey", str) BestLineId = NewType("BestLineId", str) CategoryId = NewType("CategoryId", str) ChatId = NewType("ChatId", str) @@ -98,6 +99,7 @@ def _is_hex_string(value: object) -> bool: __all__ = [ + "ApiKey", "BestLineId", "CategoryId", "ChatId", diff --git a/tests/integration/test_clob_auth.py b/tests/integration/test_clob_auth.py index 5528f48e..f7849859 100644 --- a/tests/integration/test_clob_auth.py +++ b/tests/integration/test_clob_auth.py @@ -11,6 +11,7 @@ BalanceAllowance, ClobTrade, Notification, + NotificationType, OpenOrder, ) @@ -110,7 +111,7 @@ async def run() -> tuple[Notification, ...]: items = asyncio.run(run()) for item in items: - assert isinstance(item, Notification) + assert isinstance(item.type, NotificationType) @pytest.mark.integration diff --git a/tests/unit/test_account_actions.py b/tests/unit/test_account_actions.py index 913af015..7fbc5e5a 100644 --- a/tests/unit/test_account_actions.py +++ b/tests/unit/test_account_actions.py @@ -1,3 +1,4 @@ +from decimal import Decimal from typing import Any import pytest @@ -19,6 +20,12 @@ parse_open_orders_page, ) from polymarket.errors import UnexpectedResponseError, UserInputError +from polymarket.models import ( + AutoRedeemedNotification, + ComboAutoRedeemedNotification, + NotificationType, + YieldPayoutNotification, +) _OPEN_ORDER_PAYLOAD: dict[str, Any] = { "asset_id": "8501497", @@ -209,21 +216,85 @@ def test_build_notifications_request_includes_signature_type() -> None: assert params == {"signature_type": 3} -def test_parse_notifications_decodes_payload() -> None: +_NOTIFICATION_TRANSACTION_HASH = "0x" + "dd" * 32 +_NOTIFICATION_PROXY_WALLET = "0x" + "ee" * 20 + + +def test_parse_notifications_decodes_typed_payloads() -> None: + def notification(type_: int, payload: dict[str, Any]) -> dict[str, Any]: + return { + "id": type_, + "owner": "f4f247b7-4ac7-ff29-a152-04fda0a8755a", + "payload": payload, + "timestamp": 1700000000000, + "type": type_, + } + result = parse_notifications( [ - { - "id": 7, - "owner": "0xOWNER", - "type": 1, - "payload": {"order_id": "x"}, - "timestamp": 1700000000000, - } + notification( + 7, + { + "amount": 3.21, + "proxyWallet": _NOTIFICATION_PROXY_WALLET, + "txnHash": _NOTIFICATION_TRANSACTION_HASH, + }, + ), + notification( + 9, + { + "amount": 25, + "conditionId": "0x" + "cc" * 32, + "image": "https://example.com/image.png", + "marketUrl": "https://polymarket.com/market/market-slug", + "negRisk": False, + "portfolioUrl": "https://polymarket.com/portfolio", + "position": "Yes", + "proxyWallet": _NOTIFICATION_PROXY_WALLET, + "question": "Will it happen?", + "slug": "market-slug", + "txnHash": _NOTIFICATION_TRANSACTION_HASH, + }, + ), + notification( + 10, + { + "amount": 10, + "conditionId": "0x03" + "aa" * 30, + "legs": 2, + "outcomeIndex": 0, + "portfolioUrl": "https://polymarket.com/portfolio", + "positionId": "123456789", + "proxyWallet": _NOTIFICATION_PROXY_WALLET, + "txnHash": _NOTIFICATION_TRANSACTION_HASH, + }, + ), ] ) - assert len(result) == 1 - assert result[0].id == 7 - assert result[0].type == 1 + assert [item.type for item in result] == [ + NotificationType.YIELD_PAYOUT, + NotificationType.AUTO_REDEEMED, + NotificationType.COMBO_AUTO_REDEEMED, + ] + yield_payout, auto_redeemed, combo_auto_redeemed = result + assert isinstance(yield_payout, YieldPayoutNotification) + assert yield_payout.payload.amount == Decimal("3.21") + assert isinstance(auto_redeemed, AutoRedeemedNotification) + assert auto_redeemed.payload.market_slug == "market-slug" + assert isinstance(combo_auto_redeemed, ComboAutoRedeemedNotification) + assert combo_auto_redeemed.payload.legs == 2 + + +def test_parse_notifications_rejects_unknown_type() -> None: + unknown: dict[str, Any] = { + "id": 7, + "owner": "0xOWNER", + "type": 99, + "payload": {}, + "timestamp": 1700000000000, + } + with pytest.raises(UnexpectedResponseError): + parse_notifications([unknown]) def test_parse_notifications_rejects_non_list() -> None: diff --git a/tests/unit/test_account_models.py b/tests/unit/test_account_models.py index 6b9a1fa4..dbef35e7 100644 --- a/tests/unit/test_account_models.py +++ b/tests/unit/test_account_models.py @@ -4,16 +4,26 @@ from typing import get_type_hints import pytest +from pydantic import TypeAdapter, ValidationError from polymarket.errors import UnexpectedResponseError +from polymarket.models import Notification, NotificationType from polymarket.models.clob.account import ( BalanceAllowance, ClobTrade, MakerOrder, - Notification, OpenOrder, TradeStatus, ) +from polymarket.models.clob.notifications import ( + ChildCommentCreatedNotification, + MarketResolvedNotification, + OrderCancellationNotification, + OrderFillNotification, + RewardPayoutNotification, +) + +_NOTIFICATION_ADAPTER: TypeAdapter[Notification] = TypeAdapter(Notification) def _open_order_payload(**overrides: object) -> dict[str, object]: @@ -101,7 +111,7 @@ def test_account_model_annotations_are_canonical() -> None: "matched_at": datetime, "updated_at": datetime, }, - Notification: {"timestamp": datetime}, + OrderFillNotification: {"timestamp": datetime}, } for model, fields in expected.items(): @@ -133,7 +143,7 @@ def test_account_model_signatures_use_canonical_annotations() -> None: "match_time": datetime, "last_update": datetime, }, - Notification: {"timestamp": datetime}, + OrderFillNotification: {"timestamp": datetime}, } for model, fields in expected.items(): @@ -198,9 +208,13 @@ def test_clob_trade_requires_timestamps(field: str, value: object) -> None: @pytest.mark.parametrize("timestamp", [None, ""]) def test_notification_requires_timestamp(timestamp: object) -> None: - with pytest.raises(UnexpectedResponseError): - Notification.parse_response( - {"id": 1, "owner": "0xOWNER", "type": 0, "payload": None, "timestamp": timestamp} + with pytest.raises(ValidationError): + _NOTIFICATION_ADAPTER.validate_python( + _notification( + NotificationType.ORDER_FILL, + _order_notification_payload(), + timestamp=timestamp, + ) ) @@ -298,58 +312,226 @@ def test_clob_trade_parses_nested_maker_orders() -> None: assert trade.maker_orders[0].order_id == "order-1" -def test_notification_parses_epoch_ms_timestamp() -> None: - notification = Notification.parse_response( - { - "id": 1, - "owner": "0xOWNER", - "type": 0, - "payload": {"key": "value"}, - "timestamp": 1700000000000, - } +_CONDITION_ID = "0x" + "cc" * 32 +_TRANSACTION_HASH = "0x" + "dd" * 32 +_PROXY_WALLET = "0x" + "ee" * 20 + + +def _order_notification_payload(**overrides: object) -> dict[str, object]: + base: dict[str, object] = { + "asset_id": "8501497", + "eventSlug": "event-slug", + "icon": "https://example.com/icon.png", + "image": "https://example.com/image.png", + "market": _CONDITION_ID, + "market_slug": "market-slug", + "matched_size": "10", + "order_id": "0x" + "ab" * 32, + "original_size": "100", + "outcome": "YES", + "outcome_index": 0, + "owner": "f4f247b7-4ac7-ff29-a152-04fda0a8755a", + "price": "0.6", + "question": "Will it happen?", + "remaining_size": "90", + "seriesSlug": "", + "side": "SELL", + "trade_id": "trade-1", + "transaction_hash": _TRANSACTION_HASH, + "type": "GTC", + } + base.update(overrides) + return base + + +def _notification(type_: int, payload: object, **overrides: object) -> dict[str, object]: + base: dict[str, object] = { + "id": type_, + "owner": "f4f247b7-4ac7-ff29-a152-04fda0a8755a", + "payload": payload, + "timestamp": 1700000000000, + "type": type_, + } + base.update(overrides) + return base + + +def test_order_fill_notification_normalizes_payload() -> None: + notification = _NOTIFICATION_ADAPTER.validate_python( + _notification(NotificationType.ORDER_FILL, _order_notification_payload()) ) - assert notification.id == 1 + assert isinstance(notification, OrderFillNotification) + assert notification.type == NotificationType.ORDER_FILL assert notification.timestamp == datetime.fromtimestamp(1700000000, tz=UTC) - assert notification.payload == {"key": "value"} + assert notification.payload.token_id == "8501497" + assert notification.payload.condition_id == _CONDITION_ID + assert notification.payload.order_type == "GTC" + assert notification.payload.matched_size == Decimal("10") + assert notification.payload.transaction_hash == _TRANSACTION_HASH + + +def test_order_cancellation_notification_maps_empty_transaction_fields_to_none() -> None: + notification = _NOTIFICATION_ADAPTER.validate_python( + _notification( + NotificationType.ORDER_CANCELLATION, + _order_notification_payload(trade_id="", transaction_hash="", type=""), + id="42", + ) + ) + assert isinstance(notification, OrderCancellationNotification) + assert notification.id == 42 + assert notification.payload.transaction_hash is None + assert notification.payload.trade_id is None + assert notification.payload.order_type is None -def test_notification_accepts_numeric_string_id() -> None: - notification = Notification.parse_response( - { - "id": "42", - "owner": "0xOWNER", - "type": 1, - "payload": None, - "timestamp": 1700000000000, - } +def _market_notification_payload(**overrides: object) -> dict[str, object]: + base: dict[str, object] = { + "accepting_order_timestamp": None, + "accepting_orders": True, + "active": True, + "archived": False, + "closed": False, + "condition_id": _CONDITION_ID, + "description": "Resolves YES if it happens.", + "enable_order_book": True, + "end_date_iso": "2026-08-24", + "eventSlug": "event-slug", + "fpmm": "", + "game_start_time": None, + "icon": "https://example.com/icon.png", + "image": "https://example.com/image.png", + "is_50_50_outcome": False, + "maker_base_fee": 0, + "market_slug": "market-slug", + "minimum_order_size": "15", + "minimum_tick_size": "0.01", + "neg_risk": False, + "neg_risk_market_id": "", + "neg_risk_request_id": "", + "notifications_enabled": True, + "question": "Will it happen?", + "question_id": "0x" + "ab" * 32, + "rewards": { + "max_spread": 3.5, + "min_size": 50, + "rates": [ + {"asset_address": _PROXY_WALLET, "rewards_daily_rate": 5}, + ], + }, + "seconds_delay": 0, + "tags": ["Sports"], + "taker_base_fee": 0, + "tokens": [ + {"outcome": "Yes", "price": 0.6, "token_id": "1", "winner": False}, + {"outcome": "No", "price": 0.4, "token_id": "2", "winner": True}, + ], + } + base.update(overrides) + return base + + +def test_market_resolved_notification_parses_market_payload() -> None: + notification = _NOTIFICATION_ADAPTER.validate_python( + _notification(NotificationType.MARKET_RESOLVED, _market_notification_payload()) ) - assert notification.id == 42 + assert isinstance(notification, MarketResolvedNotification) + assert notification.payload.condition_id == _CONDITION_ID + assert notification.payload.end_date == datetime(2026, 8, 24, tzinfo=UTC) + assert notification.payload.tokens[1].winner is True + assert notification.payload.rewards is not None + assert notification.payload.rewards.rates is not None + assert notification.payload.rewards.rates[0].daily_rate == Decimal("5") + assert notification.payload.minimum_tick_size == Decimal("0.01") + + +@pytest.mark.parametrize("fee_field", ["maker_base_fee", "taker_base_fee"]) +def test_market_notification_requires_base_fees(fee_field: str) -> None: + payload = _market_notification_payload() + del payload[fee_field] + + with pytest.raises(ValidationError): + _NOTIFICATION_ADAPTER.validate_python( + _notification(NotificationType.MARKET_RESOLVED, payload) + ) -def test_notification_rejects_non_numeric_id() -> None: - with pytest.raises(UnexpectedResponseError): - Notification.parse_response( +def test_market_notification_allows_null_base_fees() -> None: + notification = _NOTIFICATION_ADAPTER.validate_python( + _notification( + NotificationType.MARKET_RESOLVED, + _market_notification_payload(maker_base_fee=None, taker_base_fee=None), + ) + ) + assert isinstance(notification, MarketResolvedNotification) + assert notification.payload.maker_base_fee is None + assert notification.payload.taker_base_fee is None + + +def test_reward_payout_notification_parses_payload() -> None: + notification = _NOTIFICATION_ADAPTER.validate_python( + _notification( + NotificationType.REWARD_PAYOUT, { - "id": "not-a-number", - "owner": "0xOWNER", - "type": 1, - "payload": None, - "timestamp": 1700000000000, - } + "owner": "f4f247b7-4ac7-ff29-a152-04fda0a8755a", + "proxyWallet": _PROXY_WALLET, + "reward": 12.5, + "txnHash": _TRANSACTION_HASH, + }, ) + ) + assert isinstance(notification, RewardPayoutNotification) + assert notification.payload.proxy_wallet == _PROXY_WALLET + assert notification.payload.reward == Decimal("12.5") + assert notification.payload.transaction_hash == _TRANSACTION_HASH -def test_notification_allows_null_payload() -> None: - notification = Notification.parse_response( - { - "id": 99, - "owner": "0xOWNER", - "type": 1, - "payload": None, - "timestamp": 1700000000000, - } +def test_child_comment_notification_normalizes_profile_wallet() -> None: + notification = _NOTIFICATION_ADAPTER.validate_python( + _notification( + NotificationType.CHILD_COMMENT_CREATED, + { + "body": "Nice call!", + "createdAt": "2026-07-01T10:00:00Z", + "eventSlug": "event-slug", + "eventTitle": "Event title", + "id": "123", + "image": "https://example.com/profile.png", + "parentCommentID": "99", + "parentEntityID": 42, + "parentEntityType": "Event", + "profile": { + "baseAddress": _PROXY_WALLET, + "isCreator": False, + "isMod": False, + "name": "trader", + "proxyWallet": _PROXY_WALLET, + }, + "userAddress": _PROXY_WALLET, + }, + ) ) - assert notification.payload is None + assert isinstance(notification, ChildCommentCreatedNotification) + assert notification.payload.parent_comment_id == "99" + assert notification.payload.parent_entity_type == "Event" + assert notification.payload.profile is not None + assert notification.payload.profile.wallet == _PROXY_WALLET + + +def test_notification_rejects_non_numeric_id() -> None: + with pytest.raises(ValidationError): + _NOTIFICATION_ADAPTER.validate_python( + _notification( + NotificationType.ORDER_FILL, + _order_notification_payload(), + id="not-a-number", + ) + ) + + +def test_notification_rejects_unknown_type() -> None: + with pytest.raises(ValidationError): + _NOTIFICATION_ADAPTER.validate_python(_notification(99, {})) def test_open_order_assumes_utc_for_naive_iso_string() -> None: