diff --git a/AGENTS.md b/AGENTS.md index 3883d9ba..bfb875ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ - Name request/path construction helpers with `build_*`. - Do not rename existing variables, functions, or classes unless the operator explicitly asks for it in the prompt. Keep PRs lean; if a name looks wrong, propose the rename as a separate change instead of bundling it. - Avoid reuse that does not carry semantic or domain-specific value. Do not add boolean mode flags or generic helpers that hide distinct behavior behind one function; prefer separate explicit helpers whose names describe the behavior they implement. +- Narrow exception: retain the existing `full_history` keyword on `list_trades`, `list_activity`, `list_positions`, and `get_user_volume`, together with their request construction support. It complements the existing `start`/`end` keywords without introducing a second time-window input shape. Keep endpoint-specific semantics explicit; this exception does not authorize new boolean mode flags. See `docs/sdk-direction.md`. ## Public Model Types diff --git a/docs/sdk-direction.md b/docs/sdk-direction.md index 59e7288e..39960bd2 100644 --- a/docs/sdk-direction.md +++ b/docs/sdk-direction.md @@ -55,6 +55,14 @@ async with AsyncPublicClient() as client: Clients should also expose explicit `close()` methods. Async clients should expose `await client.close()` so callers that do not use `async with` can still release async HTTP sessions, sockets, or other transport resources deterministically. +## Full-history requests + +The existing `full_history=True` keyword on `list_trades`, `list_activity`, `list_positions`, and `get_user_volume` is an intentional, narrow exception to the rule against boolean mode flags. It complements the existing `start` and `end` keywords without requiring a separate time-window object or union. It cannot be combined with either explicit bound. + +Positions have no time bounds by default. For `list_positions`, `full_history=True` preserves that behavior, including holdings without an activity timestamp. Explicit bounds filter positions by their last activity and exclude holdings without one. Other methods retain their own full-history request behavior; request construction must preserve these distinctions. + +This exception preserves the current public signature and does not establish a convention for new boolean mode flags. + ## Domain Types The SDK uses lightweight marked types for important domain values where a plain primitive would hide useful meaning in IDEs and type hints. @@ -80,7 +88,11 @@ We intentionally do not mark every primitive value. Marked types should be reser The SDK uses two patterns for string-set enums depending on the direction: -- **Inputs** use `typing.Literal`. Users pass plain strings (`time_period="DAY"`). The type drives autocomplete and static checking without forcing users to import an enum class. The type alias is exported (e.g., `BuilderVolumeTimePeriod`) so callers can annotate their own variables when they want to. -- **Outputs** use `enum.StrEnum`. Returned model fields surface the enum so users can compare against named members (`if status is UmaResolutionStatus.DISPUTED`) without typo risk on the right-hand side. +- **Inputs** use `typing.Literal`. Users pass plain strings (`window="day"`). The type drives autocomplete and static checking without forcing users to import an enum class. The type alias is exported (e.g., `LeaderboardWindow`) so callers can annotate their own variables when they want to. +- **Outputs** use `enum.StrEnum`. Returned model fields surface the enum so users can compare against named members (`if status is ResolutionStatus.DISPUTED`) without typo risk on the right-hand side. The split is principled: inputs are write-once at the call site and benefit from string ergonomics; outputs are read-many in user logic and benefit from named members. + +Some vocabularies travel in both directions, such as `PositionStatus`, which filters `list_positions` and is also a field on every returned `Position`. Those keep the `StrEnum` name for the output type and expose an input alias that accepts either the plain string literals or the enum members, for example `PositionStatusFilter` and `UserPnlIntervalInput`. The `Filter` suffix marks parameters that select rows and the `Input` suffix marks other request parameters. Callers can pass `"CLOSED"` or `PositionStatus.CLOSED`, and can feed a returned value straight back into a request. + +Arrow, pandas, and Polars exports use the enum's value. SDK fields that mix `StrEnum` members and plain strings export together as a string column, such as trade and tip sides or known and unknown activity types. The original SDK objects retain their enum members after export. diff --git a/examples/README.md b/examples/README.md index 893512b3..eec9d7bf 100644 --- a/examples/README.md +++ b/examples/README.md @@ -32,6 +32,8 @@ set -a && source .env && set +a Or pass them inline on the command (as shown in each script's header): - `list_positions` needs `POLYMARKET_DEPOSIT_WALLET` (the wallet to inspect). + It shows `current_size` in shares, `current_price` in USDC, and `asset_id`. + Use `client.list_positions(status="CLOSED", user=wallet)` for closed positions. - `create_limit_order` / `create_market_order` need `POLYMARKET_PRIVATE_KEY` and `POLYMARKET_DEPOSIT_WALLET`. diff --git a/examples/list_positions.py b/examples/list_positions.py index 493aa9bf..b2eb5341 100644 --- a/examples/list_positions.py +++ b/examples/list_positions.py @@ -23,15 +23,13 @@ def main() -> None: { "title": position.title or position.slug or position.condition_id, "outcome": position.outcome or "", - "size": position.size if position.size is not None else "0", - "currentValue": ( - position.current_value if position.current_value is not None else "0" - ), - "avgPrice": position.avg_price if position.avg_price is not None else "", - "curPrice": position.cur_price if position.cur_price is not None else "", - "redeemable": position.redeemable if position.redeemable is not None else False, - "mergeable": position.mergeable if position.mergeable is not None else False, - "assetId": position.asset_id or "", + "current_size": position.current_size, + "current_value": position.current_value, + "avg_price": position.avg_price, + "current_price": position.current_price, + "redeemable": position.redeemable, + "mergeable": position.mergeable, + "asset_id": position.asset_id, } for position in positions ] diff --git a/src/polymarket/__init__.py b/src/polymarket/__init__.py index 9653fcb5..dfd2f357 100644 --- a/src/polymarket/__init__.py +++ b/src/polymarket/__init__.py @@ -21,8 +21,6 @@ ) from polymarket.models import ( AcceptedOrder, - Activity, - ActivityType, ApiKey, ApiKeyCreds, AssetType, @@ -30,81 +28,47 @@ BuilderApiKeyInfo, BuilderFeeRates, BuilderTrade, - BuilderVolumeEntry, - BuilderVolumeTimePeriod, CancelOrdersResponse, ClobAssetId, ClobTrade, - ClosedPosition, CollateralReturnOperation, CollateralReturnOperationKind, CollateralReturnPlanResponse, CollateralReturnPositionAmount, CollateralReturnPositionSummary, CollateralReturnRouterCall, - ComboActivity, ComboActivityId, - ComboActivityType, - ComboCompressActivity, ComboConditionId, - ComboConvertActivity, ComboKnownStatus, ComboMarket, ComboMarketOutcome, ComboMarketOutcomes, - ComboMergeActivity, - ComboPosition, - ComboPositionLeg, - ComboPositionMarket, - ComboPositionMarketEvent, - ComboPositionOutcome, - ComboPositionStatus, - ComboRedeemActivity, - ComboSplitActivity, ComboStatus, - ComboTradeActivity, - ComboUnwrapActivity, - ComboWrapActivity, Comment, CommentId, ConditionId, - ConversionActivity, CtfConditionId, CurrentReward, CurrentRewardConfig, - DepositActivity, EarningBreakdown, Erc20TradingApproval, Erc1155TradingApproval, Event, EventId, GaslessTransaction, - Holder, LastTradePrice, LastTradePriceForAsset, LastTradePriceForToken, - LeaderboardCategory, - LeaderboardEntry, - LeaderboardOrderBy, - LeaderboardTimePeriod, - LiveVolume, MakerOrder, - MakerRebateActivity, Market, MarketId, MarketOrderType, - MarketPosition, MarketReward, MarketRewardConfig, MarketRewardToken, - MarketVolume, - MergeActivity, - MetaHolder, - MetaMarketPosition, MissingTradingApprovals, Notification, NotificationType, - OpenInterest, OpenOrder, OrderBook, OrderBookLevel, @@ -136,56 +100,118 @@ PerpsTpSlTrigger, PerpsTrade, PerpsWithdrawal, - PortfolioValue, - Position, PositionId, - PriceHistoryInterval, - PriceHistoryPoint, PriceRequest, Profile, ProtocolVersion, PublicProfile, QuestionId, - RedeemActivity, - ReferralRewardActivity, RejectedOrder, RelatedTag, RelayerTransactionState, ResolutionRequestId, - RewardActivity, RewardsPercentages, SearchResults, SearchTag, Series, SeriesId, SignedOrder, - SplitActivity, SportsMarketTypes, SportsMetadata, Tag, TagId, TagReference, - TakerRebateActivity, Team, TeamOrdering, TickSize, TokenId, TotalUserEarning, - Trade, - TradeActivity, - TradedMarketCount, - TraderLeaderboardEntry, TradingApprovalsState, TransactionOutcome, - UnknownActivity, UserEarning, UserRewardsConfig, UserRewardsEarning, - WithdrawalActivity, - YieldActivity, to_combo_condition_id, to_condition_id, to_ctf_condition_id, + to_market_condition_id, +) +from polymarket.models.data import ( + Activity, + ActivityType, + ActivityTypeFilter, + BiggestWinnerKind, + BuilderStanding, + BuilderVolumeInterval, + BuilderVolumePoint, + ComboActivity, + ComboActivityType, + ComboBiggestWinner, + ComboCompressActivity, + ComboConvertActivity, + ComboMergeActivity, + ComboPosition, + ComboPositionLeg, + ComboPositionMarket, + ComboPositionMarketEvent, + ComboPositionSortBy, + ComboPositionStatus, + ComboPositionStatusFilter, + ComboRedeemActivity, + ComboSplitActivity, + ComboTradeActivity, + ComboUnwrapActivity, + ComboWrapActivity, + ConversionActivity, + DepositActivity, + Holder, + LeaderboardWindow, + LiveVolume, + MakerRebateActivity, + MarketBiggestWinner, + MarketLiveVolume, + MergeActivity, + MetaHolder, + MigrationActivity, + OpenInterest, + PortfolioValue, + Position, + PositionFilterType, + PositionSortBy, + PositionStatus, + PositionStatusFilter, + PriceHistoryInterval, + PriceHistoryPoint, + RedeemActivity, + ReferralRewardActivity, + Resolution, + ResolutionMarketType, + ResolutionReporter, + ResolutionSource, + ResolutionStatus, + RewardActivity, + SortDirection, + SplitActivity, + TakerRebateActivity, + TipActivity, + TipSide, + Trade, + TradeActivity, + TradeFilterType, + TraderLeaderboardEntry, + TraderLeaderboardSort, + TraderLeaderboardStanding, + UnknownActivity, + UserPnlFidelity, + UserPnlFidelityInput, + UserPnlInterval, + UserPnlIntervalInput, + UserPnlPoint, + UserPnlSeries, + UserStats, + UserVolume, + WithdrawalActivity, + YieldActivity, ) from polymarket.pagination import AsyncPaginator, Page, Paginator from polymarket.rate_limit import RateLimitUpdate, RateLimitUpdateListener @@ -251,10 +277,83 @@ from polymarket.version import __version__ __all__ = [ - "PRODUCTION", - "AcceptedOrder", "Activity", "ActivityType", + "ActivityTypeFilter", + "BiggestWinnerKind", + "BuilderStanding", + "BuilderVolumeInterval", + "BuilderVolumePoint", + "ComboActivity", + "ComboActivityType", + "ComboBiggestWinner", + "ComboCompressActivity", + "ComboConvertActivity", + "ComboMergeActivity", + "ComboPosition", + "ComboPositionLeg", + "ComboPositionMarket", + "ComboPositionMarketEvent", + "ComboPositionSortBy", + "ComboPositionStatus", + "ComboPositionStatusFilter", + "ComboRedeemActivity", + "ComboSplitActivity", + "ComboTradeActivity", + "ComboUnwrapActivity", + "ComboWrapActivity", + "ConversionActivity", + "DepositActivity", + "Holder", + "LeaderboardWindow", + "LiveVolume", + "MakerRebateActivity", + "MarketBiggestWinner", + "MarketLiveVolume", + "MergeActivity", + "MetaHolder", + "MigrationActivity", + "OpenInterest", + "PortfolioValue", + "Position", + "PositionFilterType", + "PositionSortBy", + "PositionStatus", + "PositionStatusFilter", + "PriceHistoryInterval", + "PriceHistoryPoint", + "RedeemActivity", + "ReferralRewardActivity", + "Resolution", + "ResolutionMarketType", + "ResolutionReporter", + "ResolutionSource", + "ResolutionStatus", + "RewardActivity", + "SortDirection", + "SplitActivity", + "TakerRebateActivity", + "TipActivity", + "TipSide", + "Trade", + "TradeActivity", + "TradeFilterType", + "TraderLeaderboardEntry", + "TraderLeaderboardSort", + "TraderLeaderboardStanding", + "UnknownActivity", + "UserPnlFidelity", + "UserPnlFidelityInput", + "UserPnlInterval", + "UserPnlIntervalInput", + "UserPnlPoint", + "UserPnlSeries", + "UserStats", + "UserVolume", + "WithdrawalActivity", + "YieldActivity", + "PRODUCTION", + "AcceptedOrder", "ApiKey", "ApiKeyCreds", "AssetType", @@ -270,8 +369,6 @@ "BuilderApiKeyInfo", "BuilderFeeRates", "BuilderTrade", - "BuilderVolumeEntry", - "BuilderVolumeTimePeriod", "CancelOrdersResponse", "CancelledSigningError", "CollateralReturnOperation", @@ -282,32 +379,16 @@ "CollateralReturnRouterCall", "ConnectionLostError", "ClobTrade", - "ClosedPosition", "ComboAcceptFailureReason", - "ComboActivity", "ComboActivityId", - "ComboActivityType", - "ComboCompressActivity", - "ComboConvertActivity", "ComboFillResult", - "ComboPosition", "Comment", - "ComboPositionLeg", - "ComboPositionMarket", - "ComboPositionMarketEvent", - "ComboPositionOutcome", "ComboQuote", "ComboQuoteAcceptance", "ComboQuoteResult", "ComboQuoteUnavailableReason", - "ComboPositionStatus", "ComboKnownStatus", "ComboStatus", - "ComboRedeemActivity", - "ComboSplitActivity", - "ComboTradeActivity", - "ComboUnwrapActivity", - "ComboWrapActivity", "CommentId", "ComboConditionId", "ComboMarket", @@ -316,10 +397,8 @@ "ConditionId", "ClobAssetId", "CtfConditionId", - "ConversionActivity", "CurrentReward", "CurrentRewardConfig", - "DepositActivity", "EarningBreakdown", "Erc20TradingApproval", "Erc1155TradingApproval", @@ -331,41 +410,27 @@ "GaslessTransaction", "GaslessTransactionHandle", "HexString", - "Holder", "InsufficientAllowanceError", "InsufficientLiquidityError", "LastTradePrice", "LastTradePriceForAsset", "LastTradePriceForToken", - "LeaderboardCategory", - "LeaderboardEntry", - "LeaderboardOrderBy", - "LeaderboardTimePeriod", - "LiveVolume", "MakerOrder", - "MakerRebateActivity", "Market", "MarketId", "MarketOrderType", - "MarketPosition", "MarketReward", "MarketRewardConfig", "MarketRewardToken", - "MarketVolume", - "MergeActivity", - "ComboMergeActivity", "MergeComboPositionRequest", "MergeMarketConditionRequest", "MergeMarketIdRequest", "MergeMarketPositionRequest", "MergePositionRequest", "MergeV2PositionRequest", - "MetaHolder", - "MetaMarketPosition", "MissingTradingApprovals", "Notification", "NotificationType", - "OpenInterest", "OpenOrder", "OrderBook", "OrderBookLevel", @@ -400,11 +465,7 @@ "PerpsTrade", "PerpsWithdrawal", "PolymarketError", - "PortfolioValue", - "Position", "PositionId", - "PriceHistoryInterval", - "PriceHistoryPoint", "PriceRequest", "Profile", "ProtocolVersion", @@ -414,15 +475,12 @@ "RateLimitError", "RateLimitUpdate", "RateLimitUpdateListener", - "RedeemActivity", - "ReferralRewardActivity", "RejectedOrder", "RelatedTag", "RelayerApiKey", "RelayerTransactionState", "RequestRejectedError", "ResolutionRequestId", - "RewardActivity", "RewardsPercentages", "RfqCancelQuoteAck", "RfqCancelQuoteRejectedError", @@ -462,7 +520,6 @@ "SeriesId", "SignedOrder", "SigningError", - "SplitActivity", "SportsMarketTypes", "SportsMetadata", "SyncEoaTransactionHandle", @@ -471,7 +528,6 @@ "Tag", "TagId", "TagReference", - "TakerRebateActivity", "Team", "TeamOrdering", "TickSize", @@ -480,11 +536,8 @@ "TotalUserEarning", "to_combo_condition_id", "to_condition_id", + "to_market_condition_id", "to_ctf_condition_id", - "Trade", - "TradeActivity", - "TradedMarketCount", - "TraderLeaderboardEntry", "TradingRestriction", "TransactionCall", "TransactionFailedError", @@ -494,12 +547,9 @@ "TradingApprovalsState", "TransportError", "UnexpectedResponseError", - "UnknownActivity", "UserEarning", "UserInputError", "UserRewardsConfig", "UserRewardsEarning", - "WithdrawalActivity", - "YieldActivity", "__version__", ] diff --git a/src/polymarket/_internal/actions/clob.py b/src/polymarket/_internal/actions/clob.py index c822d53a..0e186657 100644 --- a/src/polymarket/_internal/actions/clob.py +++ b/src/polymarket/_internal/actions/clob.py @@ -5,7 +5,6 @@ from pydantic import BeforeValidator, TypeAdapter, ValidationError, field_validator from polymarket._internal.actions.exchange_asset import resolve_asset_id, resolve_asset_ids -from polymarket._internal.request import QueryParamValue from polymarket._internal.validation import require_nonempty from polymarket.errors import UnexpectedResponseError, UserInputError from polymarket.models._validators import parse_decimal_string @@ -14,8 +13,6 @@ LastTradePrice, LastTradePriceForAsset, OrderBook, - PriceHistoryInterval, - PriceHistoryPoint, PriceRequest, ) from polymarket.models.types import ClobAssetId, OrderSide @@ -58,7 +55,6 @@ def _parse_price(cls, value: object) -> object: return parse_decimal_string(value) -_PRICE_HISTORY_INTERVALS: frozenset[str] = frozenset({"max", "1w", "1d", "6h", "1h"}) _VALID_ORDER_SIDES: frozenset[str] = frozenset({"BUY", "SELL"}) _StrictDecimalValue = Annotated[Decimal, BeforeValidator(parse_decimal_string)] @@ -68,7 +64,6 @@ def _parse_price(cls, value: object) -> object: _PricesAdapter = TypeAdapter(dict[ClobAssetId, dict[OrderSide, _StrictDecimalValue]]) _OrderBookListAdapter = TypeAdapter(tuple[OrderBook, ...]) _LastTradePriceListAdapter = TypeAdapter(tuple[LastTradePriceForAsset, ...]) -_PriceHistoryListAdapter = TypeAdapter(tuple[PriceHistoryPoint, ...]) def _require_string_asset_id(asset_id: object) -> str: @@ -82,24 +77,6 @@ def _validate_side(side: object) -> None: raise UserInputError(f"side must be 'BUY' or 'SELL', got {side!r}.") -def _require_nonneg_int(name: str, value: object) -> None: - if value is None: - return - if isinstance(value, bool) or not isinstance(value, int): - raise UserInputError(f"{name} must be an integer.") - if value < 0: - raise UserInputError(f"{name} must be a non-negative integer.") - - -def _require_positive_int(name: str, value: object) -> None: - if value is None: - return - if isinstance(value, bool) or not isinstance(value, int): - raise UserInputError(f"{name} must be an integer.") - if value <= 0: - raise UserInputError(f"{name} must be a positive integer.") - - def _require_nonempty_price_requests(requests: Sequence[PriceRequest]) -> tuple[PriceRequest, ...]: if isinstance(requests, str | bytes | PriceRequest): raise UserInputError("requests must be a sequence of PriceRequest values.") @@ -246,48 +223,6 @@ def parse_last_trade_prices(data: object) -> tuple[LastTradePriceForAsset, ...]: ) from error -def build_price_history_request( - *, - asset_id: str | None = None, - token_id: str | None = None, - start_ts: int | None = None, - end_ts: int | None = None, - fidelity: int | None = None, - interval: PriceHistoryInterval | None = None, -) -> tuple[str, dict[str, QueryParamValue]]: - validated_token_id = resolve_asset_id(asset_id=asset_id, token_id=token_id) - _require_nonneg_int("start_ts", start_ts) - _require_nonneg_int("end_ts", end_ts) - _require_positive_int("fidelity", fidelity) - if interval is not None and interval not in _PRICE_HISTORY_INTERVALS: - raise UserInputError( - f"interval must be one of {sorted(_PRICE_HISTORY_INTERVALS)}, got {interval!r}." - ) - - params: dict[str, QueryParamValue] = {"market": validated_token_id} - if start_ts is not None: - params["startTs"] = start_ts - if end_ts is not None: - params["endTs"] = end_ts - if fidelity is not None: - params["fidelity"] = fidelity - if interval is not None: - params["interval"] = interval - return "/prices-history", params - - -def parse_price_history(data: object) -> tuple[PriceHistoryPoint, ...]: - if not isinstance(data, dict): - raise UnexpectedResponseError("price history response did not match expected shape") - history = cast(dict[str, object], data).get("history") - try: - return _PriceHistoryListAdapter.validate_python(history) - except ValidationError as error: - raise UnexpectedResponseError( - "price history response did not match expected shape" - ) from error - - __all__ = [ "build_last_trade_price_request", "build_last_trade_prices_request", @@ -295,7 +230,6 @@ def parse_price_history(data: object) -> tuple[PriceHistoryPoint, ...]: "build_midpoints_request", "build_order_book_request", "build_order_books_request", - "build_price_history_request", "build_price_request", "build_prices_request", "build_spread_request", @@ -307,7 +241,6 @@ def parse_price_history(data: object) -> tuple[PriceHistoryPoint, ...]: "parse_order_book", "parse_order_books", "parse_price", - "parse_price_history", "parse_prices", "parse_spread", "parse_spreads", diff --git a/src/polymarket/_internal/actions/data.py b/src/polymarket/_internal/actions/data.py index aef02d9b..e5857417 100644 --- a/src/polymarket/_internal/actions/data.py +++ b/src/polymarket/_internal/actions/data.py @@ -1,594 +1,642 @@ -from collections.abc import Callable, Sequence -from typing import Any, Literal, TypeVar, cast, get_args - -from polymarket._internal.actions._cursor import next_cursor_or_none -from polymarket._internal.data_params import build_data_params -from polymarket._internal.request import ( - KeysetPagePayload, - KeysetPaginatedSpec, - OffsetPaginatedSpec, - QueryParamValue, - RequestSpec, +from collections.abc import Iterable, Sequence +from datetime import UTC, datetime +from math import isfinite +from typing import get_args + +from polymarket._internal.data_envelope import ( + parse_data_envelope, + parse_data_page, + parse_optional_data_envelope, ) -from polymarket.errors import UnexpectedResponseError, UserInputError -from polymarket.models.base import BaseModel +from polymarket._internal.data_params import ( + build_data_params, + build_distinct_condition_ids, + build_event_ids, + to_epoch_seconds, +) +from polymarket._internal.request import KeysetPaginatedSpec, QueryParamValue, RequestSpec +from polymarket._internal.retry import DATA_READ_RETRY +from polymarket.errors import UserInputError from polymarket.models.data import ( - BuilderVolumeEntry, - BuilderVolumeTimePeriod, - ClosedPosition, + Activity, + ActivityType, + ActivityTypeFilter, + BuilderStanding, + BuilderVolumeInterval, + BuilderVolumePoint, ComboActivity, + ComboBiggestWinner, ComboPosition, + ComboPositionSortBy, ComboPositionStatus, - LeaderboardCategory, - LeaderboardEntry, - LeaderboardOrderBy, - LeaderboardTimePeriod, + ComboPositionStatusFilter, + LeaderboardWindow, LiveVolume, + MarketBiggestWinner, MetaHolder, - MetaMarketPosition, OpenInterest, PortfolioValue, Position, + PositionFilterType, + PositionSortBy, + PositionStatus, + PositionStatusFilter, + PriceHistoryInterval, + PriceHistoryPoint, + Resolution, + SortDirection, Trade, - TradedMarketCount, + TradeFilterType, TraderLeaderboardEntry, + TraderLeaderboardSort, + TraderLeaderboardStanding, + UserPnlFidelity, + UserPnlFidelityInput, + UserPnlInterval, + UserPnlIntervalInput, + UserPnlSeries, + UserStats, + UserVolume, ) -from polymarket.models.data.activity import Activity, parse_activities, parse_combo_activities -from polymarket.models.types import to_combo_condition_id - -_BUILDER_VOLUME_TIME_PERIODS: tuple[str, ...] = get_args(BuilderVolumeTimePeriod) -_LEADERBOARD_TIME_PERIODS: tuple[str, ...] = get_args(LeaderboardTimePeriod) -_LEADERBOARD_CATEGORIES: tuple[str, ...] = get_args(LeaderboardCategory) -_LEADERBOARD_ORDER_BY: tuple[str, ...] = get_args(LeaderboardOrderBy) - -ActivityTypeFilter = Literal[ - "TRADE", - "SPLIT", - "MERGE", - "REDEEM", - "REWARD", - "CONVERSION", - "DEPOSIT", - "WITHDRAWAL", - "MAKER_REBATE", - "TAKER_REBATE", - "REFERRAL_REWARD", - "YIELD", -] -_ACTIVITY_TYPES: tuple[str, ...] = get_args(ActivityTypeFilter) - -ActivitySortBy = Literal["TIMESTAMP", "TOKENS", "CASH"] -_ACTIVITY_SORT_BY: tuple[str, ...] = get_args(ActivitySortBy) - -PositionSortBy = Literal[ - "CURRENT", - "INITIAL", - "TOKENS", - "CASHPNL", - "PERCENTPNL", - "TITLE", - "RESOLVING", - "PRICE", - "AVGPRICE", -] -_POSITION_SORT_BY: tuple[str, ...] = get_args(PositionSortBy) - -ClosedPositionSortBy = Literal["REALIZEDPNL", "TITLE", "PRICE", "AVGPRICE", "TIMESTAMP"] -_CLOSED_POSITION_SORT_BY: tuple[str, ...] = get_args(ClosedPositionSortBy) - -_COMBO_POSITION_STATUS: tuple[str, ...] = get_args(ComboPositionStatus) - -ComboPositionSort = Literal[ - "current_value_desc", - "first_entry_desc", - "entry_cost_desc", - "resolved_at_desc", - "updated_asc", -] -_COMBO_POSITION_SORT: tuple[str, ...] = get_args(ComboPositionSort) - -MarketPositionStatus = Literal["OPEN", "CLOSED", "ALL"] -_MARKET_POSITION_STATUS: tuple[str, ...] = get_args(MarketPositionStatus) - -MarketPositionSortBy = Literal["TOKENS", "CASH_PNL", "REALIZED_PNL", "TOTAL_PNL"] -_MARKET_POSITION_SORT_BY: tuple[str, ...] = get_args(MarketPositionSortBy) - -SortDirection = Literal["ASC", "DESC"] -_SORT_DIRECTION: tuple[str, ...] = get_args(SortDirection) - -TradeSide = Literal["BUY", "SELL"] -_TRADE_SIDE: tuple[str, ...] = get_args(TradeSide) - -TradeFilterType = Literal["CASH", "TOKENS"] -_TRADE_FILTER_TYPE: tuple[str, ...] = get_args(TradeFilterType) - - -def get_event_live_volumes_spec(*, id: str) -> RequestSpec[tuple[LiveVolume, ...]]: - if not id: - raise UserInputError("id is required.") - return RequestSpec( - service="data", - method="GET", - path="/live-volume", - params={"id": id}, - parse=LiveVolume.parse_response_list, - ) - - -def get_open_interests_spec( - *, market: str | Sequence[str] | None = None -) -> RequestSpec[tuple[OpenInterest, ...]]: - return RequestSpec( - service="data", - method="GET", - path="/oi", - params=build_data_params({"market": market}), - parse=OpenInterest.parse_response_list, - ) +from polymarket.models.data.activity import parse_activities, parse_combo_activities +from polymarket.models.data.leaderboard import parse_biggest_winners +from polymarket.models.types import OrderSide -def get_market_holders_spec( +def list_trades_spec( *, - market: Sequence[str], - limit: int | None = None, - min_balance: int | None = None, -) -> RequestSpec[tuple[MetaHolder, ...]]: - if not list(market): - raise UserInputError("market must be a non-empty sequence of condition IDs.") - if limit is not None and limit < 1: - raise UserInputError("limit must be a positive integer.") - if min_balance is not None and min_balance < 0: - raise UserInputError("min_balance must be non-negative.") - return RequestSpec( + user: str | None = None, + condition_id: str | Sequence[str] | None = None, + event_id: int | Sequence[int] | None = None, + side: OrderSide | None = None, + taker_only: bool | None = None, + filter_type: TradeFilterType | None = None, + filter_amount: float | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + full_history: bool = False, +) -> KeysetPaginatedSpec[Trade]: + if user is not None: + _require_user(user) + _check_enum("side", side, get_args(OrderSide)) + _check_enum("filter_type", filter_type, get_args(TradeFilterType)) + _check_selectors(condition_id, event_id) + event_id = build_event_ids(event_id) + condition_id = build_distinct_condition_ids(condition_id, grammar="feed") + start, end = build_time_window(start=start, end=end, full_history=full_history) + _check_nonnegative_amount("filter_amount", filter_amount) + return KeysetPaginatedSpec( service="data", - method="GET", - path="/holders", - params=build_data_params({"market": market, "limit": limit, "minBalance": min_balance}), - parse=MetaHolder.parse_response_list, + path="/v2/trades", + base_params=build_data_params( + { + "user": user, + "condition_id": condition_id, + "event_id": event_id, + "side": side, + "taker_only": taker_only, + "filter_type": filter_type, + "filter_amount": filter_amount, + "start": start, + "end": end, + } + ), + parse_page=lambda payload: parse_data_page(payload, Trade.parse_response_list), + cursor_param="cursor", + max_page_size=1000, + retry=DATA_READ_RETRY, ) -def get_portfolio_values_spec( +def list_activity_spec( *, user: str, - market: str | Sequence[str] | None = None, -) -> RequestSpec[tuple[PortfolioValue, ...]]: - if not user: - raise UserInputError("user is required.") - return RequestSpec( - service="data", - method="GET", - path="/value", - params=build_data_params({"user": user, "market": market}), - parse=PortfolioValue.parse_response_list, - ) - - -def get_traded_market_count_spec(*, user: str) -> RequestSpec[TradedMarketCount]: - if not user: - raise UserInputError("user is required.") - return RequestSpec( + condition_id: str | Sequence[str] | None = None, + event_id: int | Sequence[int] | None = None, + activity_types: Sequence[ActivityTypeFilter] | None = None, + side: OrderSide | None = None, + sort_direction: SortDirection | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + full_history: bool = False, +) -> KeysetPaginatedSpec[Activity]: + _check_enum("side", side, get_args(OrderSide)) + _check_enum("sort_direction", sort_direction, get_args(SortDirection)) + _require_user(user) + _check_selectors(condition_id, event_id) + event_id = build_event_ids(event_id) + condition_id = build_distinct_condition_ids(condition_id, grammar="feed") + start, end = build_time_window(start=start, end=end, full_history=full_history) + if activity_types is not None: + if isinstance(activity_types, str): + raise UserInputError("activity_types must be a sequence of activity types") + for value in activity_types: + _check_enum("activity_types", value, ActivityType) + return KeysetPaginatedSpec( service="data", - method="GET", - path="/traded", - params={"user": user}, - parse=TradedMarketCount.parse_response, + path="/v2/activity", + base_params=build_data_params( + { + "user": user, + "condition_id": condition_id, + "event_id": event_id, + "type": activity_types, + "side": side, + "sort_direction": sort_direction, + "start": start, + "end": end, + "exclude_deposits_withdrawals": False, + } + ), + parse_page=lambda payload: parse_data_page(payload, parse_activities), + cursor_param="cursor", + max_page_size=1000, + retry=DATA_READ_RETRY, ) -def get_builder_volumes_spec( - *, time_period: BuilderVolumeTimePeriod | None = None -) -> RequestSpec[tuple[BuilderVolumeEntry, ...]]: - if time_period is not None and time_period not in _BUILDER_VOLUME_TIME_PERIODS: - raise UserInputError( - f"time_period must be one of {_BUILDER_VOLUME_TIME_PERIODS}, got {time_period!r}." - ) - return RequestSpec( +def list_combo_activity_spec( + *, + user: str, + condition_id: str | Sequence[str] | None = None, +) -> KeysetPaginatedSpec[ComboActivity]: + _require_user(user) + condition_id = build_distinct_condition_ids(condition_id, grammar="combo") + return KeysetPaginatedSpec( service="data", - method="GET", - path="/v1/builders/volume", - params=build_data_params({"timePeriod": time_period}), - parse=BuilderVolumeEntry.parse_response_list, + path="/v2/activity/combos", + base_params=build_data_params({"user": user, "condition_id": condition_id}), + parse_page=lambda payload: parse_data_page(payload, parse_combo_activities), + cursor_param="cursor", + max_page_size=1000, + retry=DATA_READ_RETRY, ) def list_positions_spec( *, - user: str, - market: str | Sequence[str] | None = None, + user: str | None = None, + condition_id: str | Sequence[str] | None = None, + status: PositionStatusFilter | None = None, event_id: int | Sequence[int] | None = None, - size_threshold: float | None = None, - redeemable: bool | None = None, - mergeable: bool | None = None, + filter_type: PositionFilterType | None = None, + filter_amount: float | None = None, + include_archived: bool | None = None, sort_by: PositionSortBy | None = None, sort_direction: SortDirection | None = None, - title: str | None = None, -) -> OffsetPaginatedSpec[Position]: - if not user: - raise UserInputError("user is required.") - if market is not None and event_id is not None: - raise UserInputError("Provide market or event_id, not both.") - _check_enum("sort_by", sort_by, _POSITION_SORT_BY) - _check_enum("sort_direction", sort_direction, _SORT_DIRECTION) - if title is not None and len(title) > 100: - raise UserInputError("title must be at most 100 characters.") - - return OffsetPaginatedSpec( + start: int | datetime | None = None, + end: int | datetime | None = None, + full_history: bool = False, +) -> KeysetPaginatedSpec[Position]: + if user is not None: + _require_user(user) + _check_enum("status", status, PositionStatus) + _check_enum("filter_type", filter_type, get_args(PositionFilterType)) + _check_enum("sort_by", sort_by, get_args(PositionSortBy)) + _check_enum("sort_direction", sort_direction, get_args(SortDirection)) + _check_selectors(condition_id, event_id) + event_id = build_event_ids(event_id) + condition_id = build_distinct_condition_ids(condition_id, grammar="market") + if full_history and (start is not None or end is not None): + raise UserInputError("full_history cannot be combined with start or end") + # Positions are already unbounded by default, including holdings without activity. + start, end = _check_timestamp(start), _check_timestamp(end) + if not user and (condition_id is None or len(condition_id) != 1): + raise UserInputError("Provide user or exactly one condition_id") + if event_id is not None and not user: + raise UserInputError("event_id requires user") + if status == "CLOSED" and include_archived: + raise UserInputError("include_archived is invalid with CLOSED") + _check_nonnegative_amount("filter_amount", filter_amount) + return KeysetPaginatedSpec( service="data", - path="/positions", - # Matches the upstream per-request limit cap. - max_page_size=500, + path="/v2/positions", base_params=build_data_params( { "user": user, - "market": market, - "eventId": event_id, - "sizeThreshold": size_threshold, - "redeemable": redeemable, - "mergeable": mergeable, - "sortBy": sort_by, - "sortDirection": sort_direction, - "title": title, + "condition_id": condition_id, + "status": status, + "event_id": event_id, + "filter_type": filter_type, + "filter_amount": filter_amount, + "include_archived": include_archived, + "sort_by": sort_by, + "sort_direction": sort_direction, + "start": start, + "end": end, } ), - parse_items=_parser_for(Position), + parse_page=lambda payload: parse_data_page(payload, Position.parse_response_list), + cursor_param="cursor", + max_page_size=1000, + retry=DATA_READ_RETRY, ) -def list_closed_positions_spec( +def list_combo_positions_spec( *, user: str, - market: str | Sequence[str] | None = None, - event_id: int | Sequence[int] | None = None, - title: str | None = None, - sort_by: ClosedPositionSortBy | None = None, + condition_id: str | Sequence[str] | None = None, + status: ComboPositionStatusFilter | Sequence[ComboPositionStatusFilter] | None = None, + sort_by: ComboPositionSortBy | None = None, sort_direction: SortDirection | None = None, -) -> OffsetPaginatedSpec[ClosedPosition]: - if not user: - raise UserInputError("user is required.") - if market is not None and event_id is not None: - raise UserInputError("Provide market or event_id, not both.") - _check_enum("sort_by", sort_by, _CLOSED_POSITION_SORT_BY) - _check_enum("sort_direction", sort_direction, _SORT_DIRECTION) - if title is not None and len(title) > 100: - raise UserInputError("title must be at most 100 characters.") - - return OffsetPaginatedSpec( + updated_after: int | datetime | None = None, + updated_before: int | datetime | None = None, +) -> KeysetPaginatedSpec[ComboPosition]: + _check_enum("sort_by", sort_by, get_args(ComboPositionSortBy)) + _check_enum("sort_direction", sort_direction, get_args(SortDirection)) + _require_user(user) + condition_id = build_distinct_condition_ids(condition_id, grammar="combo") + if status is not None: + statuses = (status,) if isinstance(status, str) else tuple(status) + if not statuses: + raise UserInputError("status must be non-empty") + for value in statuses: + _check_enum("status", value, ComboPositionStatus) + status = tuple(dict.fromkeys(statuses)) + if "REDEEMABLE" in status and len(status) != 1: + raise UserInputError("REDEEMABLE must be the only status") + updated_after = _check_combo_watermark(updated_after) + updated_before = _check_combo_watermark(updated_before) + if updated_after is not None and updated_before is not None and updated_before < updated_after: + raise UserInputError("updated_before must be at least updated_after") + return KeysetPaginatedSpec( service="data", - path="/closed-positions", - # Matches the upstream per-request limit cap. - max_page_size=50, + path="/v2/positions/combos", base_params=build_data_params( { "user": user, - "market": market, - "eventId": event_id, - "title": title, - "sortBy": sort_by, - "sortDirection": sort_direction, + "condition_id": condition_id, + "status": status, + "sort_by": sort_by, + "sort_direction": sort_direction, + "updated_after": updated_after, + "updated_before": updated_before, } ), - parse_items=_parser_for(ClosedPosition), + parse_page=lambda payload: parse_data_page(payload, ComboPosition.parse_response_list), + cursor_param="cursor", + max_page_size=1000, + retry=DATA_READ_RETRY, ) -def list_combo_positions_spec( +def build_get_portfolio_value_spec( *, user: str, - status: ComboPositionStatus | None = None, - sort: ComboPositionSort | None = None, - condition_id: str | Sequence[str] | None = None, - updated_after: int | None = None, - updated_before: int | None = None, -) -> KeysetPaginatedSpec[ComboPosition]: - if not user: - raise UserInputError("user is required.") - _check_enum("status", status, _COMBO_POSITION_STATUS) - _check_enum("sort", sort, _COMBO_POSITION_SORT) - if condition_id is not None: - condition_id = _normalize_combo_condition_filter(condition_id) - _check_nonnegative_int("updated_after", updated_after) - _check_nonnegative_int("updated_before", updated_before) + condition_ids: str | Sequence[str] | None = None, +) -> RequestSpec[PortfolioValue]: + _require_user(user) + condition_ids = build_distinct_condition_ids(condition_ids, grammar="market") + return RequestSpec( + service="data", + method="GET", + path="/v2/value", + params=build_data_params({"user": user, "condition_id": condition_ids}), + parse=lambda payload: parse_data_envelope(payload, PortfolioValue.parse_response), + retry=DATA_READ_RETRY, + ) + +def build_get_user_stats_spec( + *, + user: str, +) -> RequestSpec[UserStats | None]: + _require_user(user) + return RequestSpec( + service="data", + method="GET", + path="/v2/user-stats", + params=build_data_params({"user": user}), + parse=lambda payload: parse_optional_data_envelope(payload, UserStats.parse_response), + retry=DATA_READ_RETRY, + ) + + +def build_get_user_pnl_spec( + *, + user: str, + interval: UserPnlIntervalInput | None = None, + fidelity: UserPnlFidelityInput | None = None, +) -> RequestSpec[UserPnlSeries]: + _check_enum("interval", interval, UserPnlInterval) + _check_enum("fidelity", fidelity, UserPnlFidelity) + _require_user(user) + return RequestSpec( + service="data", + method="GET", + path="/v2/user-pnl", + params=build_data_params({"user": user, "interval": interval, "fidelity": fidelity}), + parse=lambda payload: parse_data_envelope(payload, UserPnlSeries.parse_response), + retry=DATA_READ_RETRY, + ) + + +def build_get_user_volume_spec( + *, + user: str, + start: int | datetime | None = None, + end: int | datetime | None = None, + full_history: bool = False, +) -> RequestSpec[UserVolume]: + _require_user(user) + start, end = build_time_window(start=start, end=end, full_history=full_history) + return RequestSpec( + service="data", + method="GET", + path="/v2/user-volume", + params=build_data_params({"user": user, "start": start, "end": end}), + parse=lambda payload: parse_data_envelope(payload, UserVolume.parse_response), + retry=DATA_READ_RETRY, + ) + + +def build_list_market_holders_spec( + *, + condition_ids: str | Sequence[str], + min_balance: float | None = None, + include_pnl: bool | None = None, +) -> KeysetPaginatedSpec[MetaHolder]: + conditions = build_distinct_condition_ids(condition_ids, grammar="market") + if not conditions: + raise UserInputError("condition_ids is required") + _check_nonnegative_amount("min_balance", min_balance) + if include_pnl and len(conditions) != 1: + raise UserInputError("include_pnl requires exactly one condition") return KeysetPaginatedSpec( service="data", - path="/v1/positions/combos", + path="/v2/holders", base_params=build_data_params( - { - "user": user, - "status": status, - "sort": sort, - "market_id": condition_id, - "updatedAfter": updated_after, - "updatedBefore": updated_before, - } + {"condition_id": conditions, "min_balance": min_balance, "include_pnl": include_pnl} ), - parse_page=_make_keyset_envelope_parser("combos", ComboPosition.parse_response_list), + parse_page=lambda payload: parse_data_page(payload, MetaHolder.parse_response_list), cursor_param="cursor", + max_page_size=100 if include_pnl else 1000, + retry=DATA_READ_RETRY, ) -def list_combo_activity_spec( +def get_open_interests_spec( *, - user: str, - condition_id: str | Sequence[str] | None = None, -) -> KeysetPaginatedSpec[ComboActivity]: - if not user: - raise UserInputError("user is required.") - if condition_id is not None: - condition_id = _normalize_combo_condition_filter(condition_id) + condition_ids: str | Sequence[str] | None = None, +) -> RequestSpec[tuple[OpenInterest, ...]]: + condition_ids = build_distinct_condition_ids(condition_ids, grammar="market") + return RequestSpec( + service="data", + method="GET", + path="/v2/oi", + params=build_data_params({"condition_id": condition_ids}), + parse=lambda payload: parse_data_envelope(payload, OpenInterest.parse_response_list), + retry=DATA_READ_RETRY, + ) - return KeysetPaginatedSpec( + +def build_get_event_live_volume_spec( + *, + event_ids: int | Sequence[int], +) -> RequestSpec[LiveVolume]: + events = build_event_ids(event_ids) + if not events: + raise UserInputError("event_ids is required") + return RequestSpec( service="data", - path="/v1/activity/combos", - base_params=build_data_params({"user": user, "market_id": condition_id}), - parse_page=_make_keyset_envelope_parser("activity", parse_combo_activities), - cursor_param="cursor", + method="GET", + path="/v2/live-volume", + params=build_data_params({"event_id": events}), + parse=lambda payload: parse_data_envelope(payload, LiveVolume.parse_response), + retry=DATA_READ_RETRY, ) -def list_market_positions_spec( +def build_list_price_history_spec( *, - market: str, - user: str | None = None, - status: MarketPositionStatus | None = None, - sort_by: MarketPositionSortBy | None = None, - sort_direction: SortDirection | None = None, -) -> OffsetPaginatedSpec[MetaMarketPosition]: - if not market: - raise UserInputError("market is required.") - _check_enum("status", status, _MARKET_POSITION_STATUS) - _check_enum("sort_by", sort_by, _MARKET_POSITION_SORT_BY) - _check_enum("sort_direction", sort_direction, _SORT_DIRECTION) - - return OffsetPaginatedSpec( + asset_id: str, + interval: PriceHistoryInterval | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + as_of: int | datetime | None = None, + bucket_seconds: int | None = None, + page_size: int | None = None, +) -> KeysetPaginatedSpec[PriceHistoryPoint]: + _check_enum("interval", interval, get_args(PriceHistoryInterval)) + if type(asset_id) is not str or not asset_id: + raise UserInputError("asset_id must be a non-empty string") + if sum(value is not None for value in (interval, start, as_of)) != 1: + raise UserInputError("Provide exactly one of interval, start, or as_of") + if end is not None and start is None: + raise UserInputError("end requires start") + start, end, as_of = (_check_timestamp(value) for value in (start, end, as_of)) + if start is not None: + until = end if end is not None else int(datetime.now(UTC).timestamp()) + if until < start or until - start > 15 * 86400: + raise UserInputError( + "Price history windows must not end before start or exceed 15 days" + ) + if as_of is not None and (bucket_seconds is not None or page_size is not None): + raise UserInputError("as_of forbids bucket_seconds and page_size") + if bucket_seconds is not None: + minimum = 600 if interval in ("max", "all", "1m") else 300 if interval == "1w" else 60 + if type(bucket_seconds) is not int or not minimum <= bucket_seconds <= 86400: + raise UserInputError(f"bucket_seconds must be between {minimum} and 86400") + return KeysetPaginatedSpec( service="data", - path="/v1/market-positions", - # Matches the upstream per-request limit cap. - max_page_size=500, + path="/v2/prices-history", base_params=build_data_params( { - "market": market, - "user": user, - "status": status, - "sortBy": sort_by, - "sortDirection": sort_direction, + "token_id": asset_id, + "interval": interval, + "start": start, + "end": end, + "as_of": as_of, + "bucket_seconds": bucket_seconds, } ), - parse_items=_parser_for(MetaMarketPosition), + parse_page=lambda payload: parse_data_page(payload, PriceHistoryPoint.parse_response_list), + cursor_param="cursor", + max_page_size=10000, + retry=DATA_READ_RETRY, ) -def list_trades_spec( +def build_get_resolutions_spec( *, - taker_only: bool | None = None, - filter_type: TradeFilterType | None = None, - filter_amount: float | None = None, - market: str | Sequence[str] | None = None, - event_id: int | Sequence[int] | None = None, - user: str | None = None, - side: TradeSide | None = None, - start: int | None = None, - end: int | None = None, -) -> OffsetPaginatedSpec[Trade]: - if market is not None and event_id is not None: - raise UserInputError("Provide market or event_id, not both.") - if (filter_type is None) != (filter_amount is None): - raise UserInputError("filter_type and filter_amount must be provided together.") - _check_enum("filter_type", filter_type, _TRADE_FILTER_TYPE) - _check_enum("side", side, _TRADE_SIDE) - _check_nonnegative_int("start", start) - _check_nonnegative_int("end", end) - - return OffsetPaginatedSpec( + question_id: str | None = None, + condition_ids: str | Sequence[str] | None = None, + event_ids: int | Sequence[int] | None = None, +) -> RequestSpec[tuple[Resolution, ...]]: + if sum(value is not None for value in (question_id, condition_ids, event_ids)) != 1: + raise UserInputError("Provide exactly one of question_id, condition_ids, or event_ids") + if question_id is not None and (type(question_id) is not str or not question_id): + raise UserInputError("question_id must be non-empty") + condition_ids = build_distinct_condition_ids(condition_ids, grammar="market") + event_ids = build_event_ids(event_ids) + if event_ids is not None and len(event_ids) > 20: + raise UserInputError("event_ids accepts at most 20 distinct values") + return RequestSpec( service="data", - path="/trades", - # Matches the upstream per-request limit cap. - max_page_size=10_000, + method="GET", + path="/v2/resolutions", + params=build_data_params( + {"question_id": question_id, "condition_id": condition_ids, "event_id": event_ids} + ), + parse=lambda payload: parse_data_envelope(payload, Resolution.parse_response_list), + retry=DATA_READ_RETRY, + ) + + +def list_trader_leaderboard_spec( + *, + category: str | None = None, + window: LeaderboardWindow | None = None, + sort_by: TraderLeaderboardSort | None = None, +) -> KeysetPaginatedSpec[TraderLeaderboardEntry]: + _check_enum("window", window, get_args(LeaderboardWindow)) + _check_enum("sort_by", sort_by, get_args(TraderLeaderboardSort)) + _check_category(category) + return KeysetPaginatedSpec( + service="data", + path="/v2/leaderboard", base_params=build_data_params( - { - "takerOnly": taker_only, - "filterType": filter_type, - "filterAmount": filter_amount, - "market": market, - "eventId": event_id, - "user": user, - "side": side, - "start": start, - "end": end, - } + {"category": category, "time_period": window, "sort_by": sort_by} + ), + parse_page=lambda payload: parse_data_page( + payload, TraderLeaderboardEntry.parse_response_list ), - parse_items=_parser_for(Trade), + cursor_param="cursor", + max_page_size=1000, + retry=DATA_READ_RETRY, ) -def list_activity_spec( +def build_get_trader_leaderboard_standing_spec( *, user: str, - market: str | Sequence[str] | None = None, - event_id: int | Sequence[int] | None = None, - activity_types: Sequence[ActivityTypeFilter] | None = None, - start: int | None = None, - end: int | None = None, - sort_by: ActivitySortBy | None = None, - sort_direction: SortDirection | None = None, - side: TradeSide | None = None, -) -> OffsetPaginatedSpec[Activity]: - if not user: - raise UserInputError("user is required.") - if market is not None and event_id is not None: - raise UserInputError("Provide market or event_id, not both.") - _check_enum("sort_by", sort_by, _ACTIVITY_SORT_BY) - _check_enum("sort_direction", sort_direction, _SORT_DIRECTION) - _check_enum("side", side, _TRADE_SIDE) - _check_nonnegative_int("start", start) - _check_nonnegative_int("end", end) - if activity_types is not None: - for value in activity_types: - if value not in _ACTIVITY_TYPES: - raise UserInputError( - f"activity_types entries must be one of {_ACTIVITY_TYPES}, got {value!r}." - ) - - # The service defaults excludeDepositsWithdrawals=true and drops DEPOSIT and - # WITHDRAWAL from the type filter even when requested explicitly, so opt out - # unconditionally and let the type filter decide which rows come back. - return OffsetPaginatedSpec( + category: str | None = None, + window: LeaderboardWindow | None = None, +) -> RequestSpec[TraderLeaderboardStanding | None]: + _check_enum("window", window, get_args(LeaderboardWindow)) + _require_user(user) + _check_category(category) + return RequestSpec( service="data", - path="/activity", - # Matches the upstream per-request limit cap. - max_page_size=500, - base_params=build_data_params( - { - "user": user, - "market": market, - "eventId": event_id, - "type": activity_types, - "excludeDepositsWithdrawals": False, - "start": start, - "end": end, - "sortBy": sort_by, - "sortDirection": sort_direction, - "side": side, - } + method="GET", + path="/v2/leaderboard", + params=build_data_params({"user": user, "category": category, "time_period": window}), + parse=lambda payload: parse_optional_data_envelope( + payload, TraderLeaderboardStanding.parse_response ), - parse_items=parse_activities, + retry=DATA_READ_RETRY, + ) + + +def build_list_biggest_winners_spec( + *, + category: str | None = None, + window: LeaderboardWindow | None = None, +) -> KeysetPaginatedSpec[MarketBiggestWinner | ComboBiggestWinner]: + _check_enum("window", window, get_args(LeaderboardWindow)) + _check_category(category) + return KeysetPaginatedSpec( + service="data", + path="/v2/biggest-winners", + base_params=build_data_params({"category": category, "time_period": window}), + parse_page=lambda payload: parse_data_page(payload, parse_biggest_winners), + cursor_param="cursor", + max_page_size=1000, + retry=DATA_READ_RETRY, ) def list_builder_leaderboard_spec( *, - time_period: LeaderboardTimePeriod | None = None, -) -> OffsetPaginatedSpec[LeaderboardEntry]: - _check_enum("time_period", time_period, _LEADERBOARD_TIME_PERIODS) - return OffsetPaginatedSpec( + window: LeaderboardWindow | None = None, +) -> KeysetPaginatedSpec[BuilderStanding]: + _check_enum("window", window, get_args(LeaderboardWindow)) + return KeysetPaginatedSpec( service="data", - path="/v1/builders/leaderboard", - # Matches the upstream per-request limit cap. - max_page_size=50, - base_params=build_data_params({"timePeriod": time_period}), - parse_items=_parser_for(LeaderboardEntry), + path="/v2/builders/leaderboard", + base_params=build_data_params({"time_period": window}), + parse_page=lambda payload: parse_data_page(payload, BuilderStanding.parse_response_list), + cursor_param="cursor", + max_page_size=1000, + retry=DATA_READ_RETRY, ) -def list_trader_leaderboard_spec( +def get_builder_volumes_spec( *, - category: LeaderboardCategory | None = None, - time_period: LeaderboardTimePeriod | None = None, - order_by: LeaderboardOrderBy | None = None, - user: str | None = None, - user_name: str | None = None, -) -> OffsetPaginatedSpec[TraderLeaderboardEntry]: - _check_enum("category", category, _LEADERBOARD_CATEGORIES) - _check_enum("time_period", time_period, _LEADERBOARD_TIME_PERIODS) - _check_enum("order_by", order_by, _LEADERBOARD_ORDER_BY) - return OffsetPaginatedSpec( + interval: BuilderVolumeInterval | None = None, + bucket_limit: int | None = None, +) -> RequestSpec[tuple[BuilderVolumePoint, ...]]: + _check_enum("interval", interval, get_args(BuilderVolumeInterval)) + if bucket_limit is not None and (type(bucket_limit) is not int or not 1 <= bucket_limit <= 90): + raise UserInputError("bucket_limit must be between 1 and 90") + return RequestSpec( service="data", - path="/v1/leaderboard", - # Matches the upstream per-request limit cap. - max_page_size=50, - base_params=build_data_params( - { - "category": category, - "timePeriod": time_period, - "orderBy": order_by, - "user": user, - "userName": user_name, - } - ), - parse_items=_parser_for(TraderLeaderboardEntry), + method="GET", + path="/v2/builders/volume", + params=build_data_params({"interval": interval, "limit": bucket_limit}), + parse=lambda payload: parse_data_envelope(payload, BuilderVolumePoint.parse_response_list), + retry=DATA_READ_RETRY, ) def build_accounting_snapshot_request(*, user: str) -> tuple[str, dict[str, QueryParamValue]]: - if not user: - raise UserInputError("user is required.") + _require_user(user) return "/v1/accounting/snapshot", {"user": user} -def _check_enum(name: str, value: object, allowed: tuple[str, ...]) -> None: +def _require_user(user: object) -> None: + if not isinstance(user, str) or not user: + raise UserInputError("user is required") + + +def _check_enum(name: str, value: object, allowed: Iterable[str]) -> None: + choices = tuple(str(choice) for choice in allowed) + if value is not None and value not in choices: + raise UserInputError(f"{name} must be one of {choices}, got {value!r}") + + +def _check_category(category: object) -> None: + if category is not None and (type(category) is not str or not category): + raise UserInputError("category must be a non-empty string") + + +def _check_selectors(condition_id: object, event_id: object) -> None: + if condition_id is not None and event_id is not None: + raise UserInputError("Provide condition_id or event_id, not both") + + +def _check_nonnegative_amount(name: str, value: float | None) -> None: + if value is not None and (isinstance(value, bool) or not isfinite(value) or value < 0): + raise UserInputError(f"{name} must be a finite non-negative amount") + + +def _check_timestamp(value: int | datetime | None) -> int | None: + if value is None: + return None + seconds = to_epoch_seconds(value) + if not 0 < seconds <= 253402300799: + raise UserInputError("Timestamp must be positive and no later than 9999-12-31T23:59:59Z") + return seconds + + +def _check_combo_watermark(value: int | datetime | None) -> int | None: if value is None: - return - if value not in allowed: - raise UserInputError(f"{name} must be one of {allowed}, got {value!r}.") - - -def _check_nonnegative_int(name: str, value: int | None) -> None: - if value is not None and value < 0: - raise UserInputError(f"{name} must be non-negative.") - - -def _normalize_combo_condition_filter(value: str | Sequence[str]) -> str | tuple[str, ...]: - values = (value,) if isinstance(value, str) else tuple(value) - if not values: - raise UserInputError("condition_id must be a non-empty sequence.") - out: list[str] = [] - for item in values: - try: - out.append(to_combo_condition_id(item)) - except TypeError as error: - raise UserInputError(str(error)) from error - return tuple(out) if not isinstance(value, str) else out[0] - - -_M = TypeVar("_M", bound=BaseModel) - - -def _parser_for(model: type[_M]) -> Callable[[object], tuple[_M, ...]]: - def parse(payload: object) -> tuple[_M, ...]: - return model.parse_response_list(payload) - - return parse - - -def _make_keyset_envelope_parser( - items_key: str, - parse_items: Callable[[object], tuple[_M, ...]], -) -> Callable[[object], KeysetPagePayload[_M]]: - def parse(payload: object) -> KeysetPagePayload[_M]: - if not isinstance(payload, dict): - raise UnexpectedResponseError("Paginated response did not match expected shape") - response = cast(dict[str, Any], payload) - if items_key not in response: - raise UnexpectedResponseError(f"Paginated response is missing '{items_key}'.") - pagination = response.get("pagination") - if not isinstance(pagination, dict): - raise UnexpectedResponseError("Paginated response is missing pagination.") - next_cursor = next_cursor_or_none(cast(dict[str, Any], pagination).get("next_cursor")) - return KeysetPagePayload( - items=parse_items(response[items_key]), - server_next_cursor=next_cursor, + return None + seconds = to_epoch_seconds(value) + if not 0 <= seconds <= 253402300799: + raise UserInputError( + "Combo watermark must be non-negative and no later than 9999-12-31T23:59:59Z" ) + return seconds + - return parse - - -__all__ = [ - "ActivitySortBy", - "ActivityTypeFilter", - "ClosedPositionSortBy", - "ComboPositionSort", - "ComboPositionStatus", - "MarketPositionSortBy", - "MarketPositionStatus", - "PositionSortBy", - "SortDirection", - "TradeFilterType", - "TradeSide", - "build_accounting_snapshot_request", - "get_builder_volumes_spec", - "get_event_live_volumes_spec", - "get_market_holders_spec", - "get_open_interests_spec", - "get_portfolio_values_spec", - "get_traded_market_count_spec", - "list_activity_spec", - "list_builder_leaderboard_spec", - "list_closed_positions_spec", - "list_combo_activity_spec", - "list_combo_positions_spec", - "list_market_positions_spec", - "list_positions_spec", - "list_trader_leaderboard_spec", - "list_trades_spec", -] +def build_time_window( + *, start: int | datetime | None, end: int | datetime | None, full_history: bool +) -> tuple[int | None, int | None]: + if full_history: + if start is not None or end is not None: + raise UserInputError("full_history cannot be combined with start or end") + return 1, None + return _check_timestamp(start), _check_timestamp(end) diff --git a/src/polymarket/_internal/actions/relayer/positions.py b/src/polymarket/_internal/actions/relayer/positions.py index f9a6c848..d174fae2 100644 --- a/src/polymarket/_internal/actions/relayer/positions.py +++ b/src/polymarket/_internal/actions/relayer/positions.py @@ -161,13 +161,12 @@ def expect_negative_risk_flag(positions: BinaryPositions) -> bool: first = yes_position if yes_position is not None else no_position assert first is not None condition_id = first.condition_id - if first.negative_risk is None: - raise UnexpectedResponseError(f"Missing negativeRisk flag for condition {condition_id}") - if yes_position is not None and no_position is not None: - if yes_position.negative_risk is None or no_position.negative_risk is None: - raise UnexpectedResponseError(f"Missing negativeRisk flag for condition {condition_id}") - if yes_position.negative_risk != no_position.negative_risk: - raise UnexpectedResponseError(f"Mixed negativeRisk flags for condition {condition_id}") + if ( + yes_position is not None + and no_position is not None + and yes_position.negative_risk != no_position.negative_risk + ): + raise UnexpectedResponseError(f"Mixed negativeRisk flags for condition {condition_id}") return first.negative_risk @@ -327,13 +326,13 @@ def _to_position_amount(position: Position | None, *, expected_outcome_index: Li raise UnexpectedResponseError( f"Expected outcomeIndex {expected_outcome_index}, got {position.outcome_index}" ) - if position.size is None: - return 0 - if not position.size.is_finite(): - raise UnexpectedResponseError(f"Position size must be a finite number, got {position.size}") - if position.size < 0: + if not position.current_size.is_finite(): + raise UnexpectedResponseError( + f"Position size must be a finite number, got {position.current_size}" + ) + if position.current_size < 0: raise UnexpectedResponseError("Position size must be non-negative") - return int(position.size * Decimal(_TOKEN_DECIMALS)) + return int(position.current_size * Decimal(_TOKEN_DECIMALS)) def _parse_position_id(position_id: str) -> int: diff --git a/src/polymarket/_internal/data_envelope.py b/src/polymarket/_internal/data_envelope.py new file mode 100644 index 00000000..0b69c0ad --- /dev/null +++ b/src/polymarket/_internal/data_envelope.py @@ -0,0 +1,38 @@ +from collections.abc import Callable +from typing import TypeVar, cast + +from polymarket._internal.request import KeysetPagePayload +from polymarket.errors import UnexpectedResponseError + +T = TypeVar("T") + + +def parse_data_envelope(payload: object, parse: Callable[[object], T]) -> T: + if not isinstance(payload, dict) or "data" not in payload: + raise UnexpectedResponseError("Response is missing data") + return parse(cast(dict[str, object], payload)["data"]) + + +def parse_optional_data_envelope(payload: object, parse: Callable[[object], T]) -> T | None: + return parse_data_envelope(payload, lambda data: None if data is None else parse(data)) + + +def parse_data_page( + payload: object, parse_items: Callable[[object], tuple[T, ...]] +) -> KeysetPagePayload[T]: + if not isinstance(payload, dict): + raise UnexpectedResponseError("Paginated response must be an object") + data = cast(dict[str, object], payload) + pagination = data.get("pagination") + if not isinstance(data.get("data"), list) or not isinstance(pagination, dict): + raise UnexpectedResponseError("Paginated response requires data and pagination") + page = cast(dict[str, object], pagination) + has_more = page.get("has_more") + cursor = page.get("next_cursor") + if not isinstance(has_more, bool) or "next_cursor" not in page: + raise UnexpectedResponseError("Invalid pagination metadata") + if cursor is not None and (not isinstance(cursor, str) or not cursor): + raise UnexpectedResponseError("Invalid next_cursor") + if has_more != (cursor is not None): + raise UnexpectedResponseError("has_more and next_cursor disagree") + return KeysetPagePayload(items=parse_items(data["data"]), server_next_cursor=cursor) diff --git a/src/polymarket/_internal/data_params.py b/src/polymarket/_internal/data_params.py index a3a888dc..859c8b4a 100644 --- a/src/polymarket/_internal/data_params.py +++ b/src/polymarket/_internal/data_params.py @@ -1,6 +1,12 @@ +import re from collections.abc import Mapping, Sequence +from datetime import datetime +from math import floor +from typing import Literal from polymarket._internal.request import QueryParamValue +from polymarket.errors import UserInputError +from polymarket.models.types import to_combo_condition_id, to_condition_id, to_market_condition_id DataParamValue = QueryParamValue | Sequence[str | int] | None @@ -12,7 +18,10 @@ def build_data_params( for key, value in values.items(): if value is None: continue - if isinstance(value, str | int | float | bool): + if isinstance(value, str): + out[key] = str(value) + continue + if isinstance(value, int | float | bool): out[key] = value continue items = list(value) @@ -22,4 +31,70 @@ def build_data_params( return out -__all__ = ["DataParamValue", "build_data_params"] +def build_distinct_condition_ids( + values: str | Sequence[str] | None, + *, + grammar: Literal["feed", "market", "combo"], + max_distinct: int = 20, +) -> tuple[str, ...] | None: + if values is None: + return None + try: + items = (values,) if isinstance(values, str) else tuple(values) + except TypeError as error: + raise UserInputError("condition_id must be a string or sequence of strings") from error + if not items: + raise UserInputError("condition_id must be non-empty") + parser = { + "feed": to_condition_id, + "market": to_market_condition_id, + "combo": to_combo_condition_id, + }[grammar] + out: dict[str, str] = {} + for value in items: + if type(value) is not str or re.fullmatch(r"0x[0-9a-fA-F]+", value) is None: + raise UserInputError("condition_id must be a hex string") + try: + parsed = parser(value) + except TypeError as error: + raise UserInputError(str(error)) from error + out.setdefault(parsed.lower(), parsed) + if len(out) > max_distinct: + raise UserInputError(f"condition_id accepts at most {max_distinct} distinct values") + return tuple(out.values()) + + +def to_epoch_seconds(value: int | datetime) -> int: + if isinstance(value, datetime): + if value.tzinfo is None or value.utcoffset() is None: + raise UserInputError("datetime must be timezone-aware") + try: + return floor(value.timestamp()) + except (OverflowError, OSError, ValueError) as error: + raise UserInputError("datetime is outside the supported timestamp range") from error + if isinstance(value, bool) or type(value) is not int: + raise UserInputError("Expected integer epoch seconds or a timezone-aware datetime") + return value + + +def build_event_ids(values: int | Sequence[int] | None) -> tuple[int, ...] | None: + if values is None: + return None + if isinstance(values, str): + raise UserInputError("event_ids must contain positive 32-bit integers") + try: + items = (values,) if isinstance(values, int) else tuple(values) + except TypeError as error: + raise UserInputError("event_ids must contain positive 32-bit integers") from error + if not items or any(type(item) is not int or not 0 < item <= 2147483647 for item in items): + raise UserInputError("event_ids must contain positive 32-bit integers") + return tuple(dict.fromkeys(items)) + + +__all__ = [ + "DataParamValue", + "build_data_params", + "build_distinct_condition_ids", + "build_event_ids", + "to_epoch_seconds", +] diff --git a/src/polymarket/_internal/dispatch.py b/src/polymarket/_internal/dispatch.py index 29a29c9d..a28467b4 100644 --- a/src/polymarket/_internal/dispatch.py +++ b/src/polymarket/_internal/dispatch.py @@ -19,6 +19,7 @@ RequestSpec, Service, ) +from polymarket._internal.retry import async_run_with_rate_limit_retry, run_with_rate_limit_retry from polymarket.clients._transport import AsyncTransport, SyncTransport from polymarket.errors import UserInputError from polymarket.pagination import AsyncPaginator, Page, Paginator @@ -54,7 +55,9 @@ def sync_dispatch(ctx: SyncClientContext, spec: RequestSpec[T]) -> T: transport = _sync_transport_for(ctx, spec.service) match spec.method: case "GET": - payload = transport.get_json(spec.path, params=spec.params) + payload = run_with_rate_limit_retry( + lambda: transport.get_json(spec.path, params=spec.params), spec.retry + ) case _ as unreachable: assert_never(unreachable) return spec.parse(payload) @@ -64,7 +67,9 @@ async def async_dispatch(ctx: AsyncClientContext, spec: RequestSpec[T]) -> T: transport = _async_transport_for(ctx, spec.service) match spec.method: case "GET": - payload = await transport.get_json(spec.path, params=spec.params) + payload = await async_run_with_rate_limit_retry( + lambda: transport.get_json(spec.path, params=spec.params), spec.retry + ) case _ as unreachable: assert_never(unreachable) return spec.parse(payload) @@ -163,7 +168,7 @@ def sync_paginate_keyset( page_size: int, initial_cursor: str | None = None, ) -> Paginator[T]: - if page_size < 1: + if type(page_size) is not int or page_size < 1: raise UserInputError("page_size must be a positive integer.") if spec.max_page_size is not None and page_size > spec.max_page_size: raise UserInputError(f"page_size must be at most {spec.max_page_size}.") @@ -186,7 +191,9 @@ def fetch(cursor: str | None) -> Page[T]: } if server_cursor is not None: params[spec.cursor_param] = server_cursor - payload = transport.get_json(spec.path, params=params) + payload = run_with_rate_limit_retry( + lambda: transport.get_json(spec.path, params=params), spec.retry + ) keyset_page = spec.parse_page(payload) return compute_keyset_page( service=spec.service, @@ -206,7 +213,7 @@ def async_paginate_keyset( page_size: int, initial_cursor: str | None = None, ) -> AsyncPaginator[T]: - if page_size < 1: + if type(page_size) is not int or page_size < 1: raise UserInputError("page_size must be a positive integer.") if spec.max_page_size is not None and page_size > spec.max_page_size: raise UserInputError(f"page_size must be at most {spec.max_page_size}.") @@ -229,7 +236,9 @@ async def fetch(cursor: str | None) -> Page[T]: } if server_cursor is not None: params[spec.cursor_param] = server_cursor - payload = await transport.get_json(spec.path, params=params) + payload = await async_run_with_rate_limit_retry( + lambda: transport.get_json(spec.path, params=params), spec.retry + ) keyset_page = spec.parse_page(payload) return compute_keyset_page( service=spec.service, diff --git a/src/polymarket/_internal/request.py b/src/polymarket/_internal/request.py index 8b8ee5a4..32d9dbdf 100644 --- a/src/polymarket/_internal/request.py +++ b/src/polymarket/_internal/request.py @@ -2,6 +2,8 @@ from dataclasses import dataclass from typing import Generic, Literal, TypeVar +from polymarket._internal.retry import RateLimitRetry + Service = Literal["gamma", "data", "rfq"] Method = Literal["GET"] @@ -18,6 +20,7 @@ class RequestSpec(Generic[T]): path: str parse: Callable[[object], T] params: Mapping[str, QueryParamValue | None] | None = None + retry: RateLimitRetry | None = None @dataclass(frozen=True, slots=True) @@ -51,6 +54,7 @@ class KeysetPaginatedSpec(Generic[T]): base_params: Mapping[str, QueryParamValue] | None = None cursor_param: str = "after_cursor" max_page_size: int | None = None + retry: RateLimitRetry | None = None @dataclass(frozen=True, slots=True) diff --git a/src/polymarket/_internal/retry.py b/src/polymarket/_internal/retry.py new file mode 100644 index 00000000..04f21c18 --- /dev/null +++ b/src/polymarket/_internal/retry.py @@ -0,0 +1,44 @@ +from asyncio import sleep as async_sleep +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from time import sleep +from typing import TypeVar + +from polymarket.errors import RateLimitError + +T = TypeVar("T") + + +@dataclass(frozen=True, slots=True) +class RateLimitRetry: + retries: int = 2 + max_delay_seconds: float = 5.0 + + +DATA_READ_RETRY = RateLimitRetry() + + +def run_with_rate_limit_retry(call: Callable[[], T], retry: RateLimitRetry | None) -> T: + for attempt in range((retry.retries if retry else 0) + 1): + try: + return call() + except RateLimitError as error: + delay = error.retry_after if error.retry_after is not None else 1 + if retry is None or attempt >= retry.retries or delay > retry.max_delay_seconds: + raise + sleep(max(0, delay)) + raise RuntimeError("Retry attempts exhausted") + + +async def async_run_with_rate_limit_retry( + call: Callable[[], Awaitable[T]], retry: RateLimitRetry | None +) -> T: + for attempt in range((retry.retries if retry else 0) + 1): + try: + return await call() + except RateLimitError as error: + delay = error.retry_after if error.retry_after is not None else 1 + if retry is None or attempt >= retry.retries or delay > retry.max_delay_seconds: + raise + await async_sleep(max(0, delay)) + raise RuntimeError("Retry attempts exhausted") diff --git a/src/polymarket/clients/async_public.py b/src/polymarket/clients/async_public.py index cb22b06f..9b9b84f0 100644 --- a/src/polymarket/clients/async_public.py +++ b/src/polymarket/clients/async_public.py @@ -3,6 +3,7 @@ import contextlib import logging from collections.abc import Sequence +from datetime import datetime from decimal import Decimal from types import TracebackType from typing import TYPE_CHECKING, Any, Literal, Self, assert_never, cast, overload @@ -13,19 +14,6 @@ from polymarket._internal.actions import gamma as _gamma_actions from polymarket._internal.actions import rewards as _rewards_actions from polymarket._internal.actions import rfq as _rfq_actions -from polymarket._internal.actions.data import ( - ActivitySortBy, - ActivityTypeFilter, - ClosedPositionSortBy, - ComboPositionSort, - ComboPositionStatus, - MarketPositionSortBy, - MarketPositionStatus, - PositionSortBy, - SortDirection, - TradeFilterType, - TradeSide, -) from polymarket._internal.actions.gamma import ( CommentParentEntityType, DateFilter, @@ -62,8 +50,6 @@ Market, OrderBook, OrderSide, - PriceHistoryInterval, - PriceHistoryPoint, PriceRequest, PublicProfile, RelatedTag, @@ -81,24 +67,39 @@ from polymarket.models.clob.rewards import CurrentReward, MarketReward from polymarket.models.data import ( Activity, - BuilderVolumeEntry, - BuilderVolumeTimePeriod, - ClosedPosition, + ActivityTypeFilter, + BuilderStanding, + BuilderVolumeInterval, + BuilderVolumePoint, ComboActivity, + ComboBiggestWinner, ComboPosition, - LeaderboardCategory, - LeaderboardEntry, - LeaderboardOrderBy, - LeaderboardTimePeriod, + ComboPositionSortBy, + ComboPositionStatusFilter, + LeaderboardWindow, LiveVolume, + MarketBiggestWinner, MetaHolder, - MetaMarketPosition, OpenInterest, PortfolioValue, Position, + PositionFilterType, + PositionSortBy, + PositionStatusFilter, + PriceHistoryInterval, + PriceHistoryPoint, + Resolution, + SortDirection, Trade, - TradedMarketCount, + TradeFilterType, TraderLeaderboardEntry, + TraderLeaderboardSort, + TraderLeaderboardStanding, + UserPnlFidelityInput, + UserPnlIntervalInput, + UserPnlSeries, + UserStats, + UserVolume, ) from polymarket.models.perps import ( PerpsBook, @@ -150,6 +151,353 @@ class AsyncPublicClient: Public methods return stable, idiomatic Python SDK objects. """ + def list_trades( + self, + *, + user: str | None = None, + condition_id: str | Sequence[str] | None = None, + event_id: int | Sequence[int] | None = None, + side: OrderSide | None = None, + taker_only: bool | None = None, + filter_type: TradeFilterType | None = None, + filter_amount: float | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + full_history: bool = False, + page_size: int = 100, + ) -> AsyncPaginator[Trade]: + """List trades. Size is shares and price is USDC per share. + + Time filters apply when a user is supplied. ``full_history`` selects all history. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_trades_spec( + user=user, + condition_id=condition_id, + event_id=event_id, + side=side, + taker_only=taker_only, + filter_type=filter_type, + filter_amount=filter_amount, + start=start, + end=end, + full_history=full_history, + ) + return async_paginate_keyset(self._ctx, spec, page_size=page_size) + + def list_activity( + self, + *, + user: str, + condition_id: str | Sequence[str] | None = None, + event_id: int | Sequence[int] | None = None, + activity_types: Sequence[ActivityTypeFilter] | None = None, + side: OrderSide | None = None, + sort_direction: SortDirection | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + full_history: bool = False, + page_size: int = 100, + ) -> AsyncPaginator[Activity]: + """List wallet activity, including deposits and withdrawals. + + Amounts are USDC and shares are outcome units. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_activity_spec( + user=user, + condition_id=condition_id, + event_id=event_id, + activity_types=activity_types, + side=side, + sort_direction=sort_direction, + start=start, + end=end, + full_history=full_history, + ) + return async_paginate_keyset(self._ctx, spec, page_size=page_size) + + def list_combo_activity( + self, + *, + user: str, + condition_id: str | Sequence[str] | None = None, + page_size: int = 100, + ) -> AsyncPaginator[ComboActivity]: + """List combo lifecycle activity. Amounts and payouts are USDC. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_combo_activity_spec(user=user, condition_id=condition_id) + return async_paginate_keyset(self._ctx, spec, page_size=page_size) + + def list_positions( + self, + *, + user: str | None = None, + condition_id: str | Sequence[str] | None = None, + status: PositionStatusFilter | None = None, + event_id: int | Sequence[int] | None = None, + filter_type: PositionFilterType | None = None, + filter_amount: float | None = None, + include_archived: bool | None = None, + sort_by: PositionSortBy | None = None, + sort_direction: SortDirection | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + full_history: bool = False, + page_size: int = 100, + ) -> AsyncPaginator[Position]: + """List positions for a wallet or a single market. + + Use ``status="CLOSED"`` for closed positions. Sizes are shares and values are USDC. + + Positions have no time bounds by default. ``full_history=True`` also includes + holdings without activity and cannot be combined with ``start`` or ``end``. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_positions_spec( + user=user, + condition_id=condition_id, + status=status, + event_id=event_id, + filter_type=filter_type, + filter_amount=filter_amount, + include_archived=include_archived, + sort_by=sort_by, + sort_direction=sort_direction, + start=start, + end=end, + full_history=full_history, + ) + return async_paginate_keyset(self._ctx, spec, page_size=page_size) + + def list_combo_positions( + self, + *, + user: str, + condition_id: str | Sequence[str] | None = None, + status: ComboPositionStatusFilter | Sequence[ComboPositionStatusFilter] | None = None, + sort_by: ComboPositionSortBy | None = None, + sort_direction: SortDirection | None = None, + updated_after: int | datetime | None = None, + updated_before: int | datetime | None = None, + page_size: int = 100, + ) -> AsyncPaginator[ComboPosition]: + """List combo positions, optionally filtering by multiple statuses. + + Update bounds are inclusive. Sizes are shares; costs and payouts are USDC. + + Zero is a valid update bound. Without a status filter, supplying either bound + selects the synchronization view, which also includes positions no longer held. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_combo_positions_spec( + user=user, + condition_id=condition_id, + status=status, + sort_by=sort_by, + sort_direction=sort_direction, + updated_after=updated_after, + updated_before=updated_before, + ) + return async_paginate_keyset(self._ctx, spec, page_size=page_size) + + async def get_portfolio_value( + self, + *, + user: str, + condition_ids: str | Sequence[str] | None = None, + ) -> PortfolioValue: + """Get current portfolio value in USDC.""" + spec = _data_actions.build_get_portfolio_value_spec(user=user, condition_ids=condition_ids) + return await async_dispatch(self._ctx, spec) + + async def get_user_stats( + self, + *, + user: str, + ) -> UserStats | None: + """Get wallet statistics, or ``None`` when the wallet has no statistics.""" + spec = _data_actions.build_get_user_stats_spec(user=user) + return await async_dispatch(self._ctx, spec) + + async def get_user_pnl( + self, + *, + user: str, + interval: UserPnlIntervalInput | None = None, + fidelity: UserPnlFidelityInput | None = None, + ) -> UserPnlSeries: + """Get cumulative wallet PnL in USDC and volume in shares.""" + spec = _data_actions.build_get_user_pnl_spec( + user=user, interval=interval, fidelity=fidelity + ) + return await async_dispatch(self._ctx, spec) + + async def get_user_volume( + self, + *, + user: str, + start: int | datetime | None = None, + end: int | datetime | None = None, + full_history: bool = False, + ) -> UserVolume: + """Get trading volume in shares and USDC. Time bounds are floored to UTC days.""" + spec = _data_actions.build_get_user_volume_spec( + user=user, start=start, end=end, full_history=full_history + ) + return await async_dispatch(self._ctx, spec) + + def list_market_holders( + self, + *, + condition_ids: str | Sequence[str], + min_balance: float | None = None, + include_pnl: bool | None = None, + page_size: int = 100, + ) -> AsyncPaginator[MetaHolder]: + """List holders grouped by outcome asset. Amounts are shares; PnL is USDC. + + Page size applies per outcome. Merge groups across pages by ``asset_id``. + With ``include_pnl=True``, the maximum page size is 100; amounts are gross per side. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.build_list_market_holders_spec( + condition_ids=condition_ids, min_balance=min_balance, include_pnl=include_pnl + ) + return async_paginate_keyset(self._ctx, spec, page_size=page_size) + + async def get_open_interests( + self, + *, + condition_ids: str | Sequence[str] | None = None, + ) -> tuple[OpenInterest, ...]: + """Get open interest in USDC. Omit conditions to request global interest.""" + spec = _data_actions.get_open_interests_spec(condition_ids=condition_ids) + return await async_dispatch(self._ctx, spec) + + async def get_event_live_volume( + self, + *, + event_ids: int | Sequence[int], + ) -> LiveVolume: + """Get combined event taker volume in shares with a market breakdown.""" + spec = _data_actions.build_get_event_live_volume_spec(event_ids=event_ids) + return await async_dispatch(self._ctx, spec) + + def list_price_history( + self, + *, + asset_id: str, + interval: PriceHistoryInterval | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + as_of: int | datetime | None = None, + bucket_seconds: int | None = None, + page_size: int | None = None, + ) -> AsyncPaginator[PriceHistoryPoint]: + """List historical prices in USDC per share, oldest first. + + Select an interval, a start/end window (end exclusive), or an exact ``as_of``. + Windows span at most 15 days. ``as_of`` forbids bucket_seconds and page_size. + The default page size is 10000. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.build_list_price_history_spec( + asset_id=asset_id, + interval=interval, + start=start, + end=end, + as_of=as_of, + bucket_seconds=bucket_seconds, + page_size=page_size, + ) + return async_paginate_keyset( + self._ctx, spec, page_size=10000 if page_size is None else page_size + ) + + async def get_resolutions( + self, + *, + question_id: str | None = None, + condition_ids: str | Sequence[str] | None = None, + event_ids: int | Sequence[int] | None = None, + ) -> tuple[Resolution, ...]: + """Get resolutions by question, conditions, or events. Missing rows are omitted.""" + spec = _data_actions.build_get_resolutions_spec( + question_id=question_id, condition_ids=condition_ids, event_ids=event_ids + ) + return await async_dispatch(self._ctx, spec) + + def list_trader_leaderboard( + self, + *, + category: str | None = None, + window: LeaderboardWindow | None = None, + sort_by: TraderLeaderboardSort | None = None, + page_size: int = 100, + ) -> AsyncPaginator[TraderLeaderboardEntry]: + """List ranked traders. PnL is USDC and volume is shares; ranks can tie and skip. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_trader_leaderboard_spec( + category=category, window=window, sort_by=sort_by + ) + return async_paginate_keyset(self._ctx, spec, page_size=page_size) + + async def get_trader_leaderboard_standing( + self, + *, + user: str, + category: str | None = None, + window: LeaderboardWindow | None = None, + ) -> TraderLeaderboardStanding | None: + """Get wallet leaderboard standings, or ``None`` when unavailable. + + PnL is USDC and volume is shares. Unranked ranks are ``None``.""" + spec = _data_actions.build_get_trader_leaderboard_standing_spec( + user=user, category=category, window=window + ) + return await async_dispatch(self._ctx, spec) + + def list_biggest_winners( + self, + *, + category: str | None = None, + window: LeaderboardWindow | None = None, + page_size: int = 100, + ) -> AsyncPaginator[MarketBiggestWinner | ComboBiggestWinner]: + """List winning market and combo positions, ordered by USDC PnL. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.build_list_biggest_winners_spec(category=category, window=window) + return async_paginate_keyset(self._ctx, spec, page_size=page_size) + + def list_builder_leaderboard( + self, + *, + window: LeaderboardWindow | None = None, + page_size: int = 100, + ) -> AsyncPaginator[BuilderStanding]: + """List ranked builders and their trading volume in shares. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_builder_leaderboard_spec(window=window) + return async_paginate_keyset(self._ctx, spec, page_size=page_size) + + async def get_builder_volumes( + self, + *, + interval: BuilderVolumeInterval | None = None, + bucket_limit: int | None = None, + ) -> tuple[BuilderVolumePoint, ...]: + """Get builder volume in shares by calendar bucket. + + ``bucket_limit`` counts dates, not rows (default 30, maximum 90). + Interval ``all`` yields calendar-year buckets.""" + spec = _data_actions.get_builder_volumes_spec(interval=interval, bucket_limit=bucket_limit) + return await async_dispatch(self._ctx, spec) + def __init__( self, environment: Environment = PRODUCTION, @@ -574,53 +922,6 @@ async def get_comment_thread( _gamma_actions.get_comment_thread_spec(id, get_positions=get_positions), ) - async def get_event_live_volumes(self, *, id: str) -> tuple[LiveVolume, ...]: - """Get live volume entries for an event.""" - return await async_dispatch(self._ctx, _data_actions.get_event_live_volumes_spec(id=id)) - - async def get_open_interests( - self, *, market: Sequence[str] | None = None - ) -> tuple[OpenInterest, ...]: - """Get open interest values, optionally filtered by market ids.""" - return await async_dispatch(self._ctx, _data_actions.get_open_interests_spec(market=market)) - - async def get_market_holders( - self, - *, - market: Sequence[str], - limit: int | None = None, - min_balance: int | None = None, - ) -> tuple[MetaHolder, ...]: - """Get holder balances for one or more markets.""" - return await async_dispatch( - self._ctx, - _data_actions.get_market_holders_spec( - market=market, limit=limit, min_balance=min_balance - ), - ) - - async def get_portfolio_values( - self, *, user: str, market: Sequence[str] | None = None - ) -> tuple[PortfolioValue, ...]: - """Get portfolio value snapshots for a user.""" - return await async_dispatch( - self._ctx, _data_actions.get_portfolio_values_spec(user=user, market=market) - ) - - async def get_traded_market_count(self, *, user: str) -> TradedMarketCount: - """Get the number of markets a user has traded.""" - return await async_dispatch( - self._ctx, _data_actions.get_traded_market_count_spec(user=user) - ) - - async def get_builder_volumes( - self, *, time_period: BuilderVolumeTimePeriod | None = None - ) -> tuple[BuilderVolumeEntry, ...]: - """Get builder volume leaderboard entries.""" - return await async_dispatch( - self._ctx, _data_actions.get_builder_volumes_spec(time_period=time_period) - ) - def list_builder_trades( self, *, @@ -654,236 +955,11 @@ async def fetch(cursor: str | None) -> Page[BuilderTrade]: return AsyncPaginator(fetch=fetch) - def list_positions( - self, - *, - user: str, - market: str | Sequence[str] | None = None, - event_id: int | Sequence[int] | None = None, - size_threshold: float | None = None, - redeemable: bool | None = None, - mergeable: bool | None = None, - sort_by: PositionSortBy | None = None, - sort_direction: SortDirection | None = None, - title: str | None = None, - page_size: int = 20, - ) -> AsyncPaginator[Position]: - """List open positions for a user. - - Returns: - An async paginator over matching positions. - """ - spec = _data_actions.list_positions_spec( - user=user, - market=market, - event_id=event_id, - size_threshold=size_threshold, - redeemable=redeemable, - mergeable=mergeable, - sort_by=sort_by, - sort_direction=sort_direction, - title=title, - ) - return async_paginate_offset(self._ctx, spec, page_size=page_size) - - def list_closed_positions( - self, - *, - user: str, - market: str | Sequence[str] | None = None, - event_id: int | Sequence[int] | None = None, - title: str | None = None, - sort_by: ClosedPositionSortBy | None = None, - sort_direction: SortDirection | None = None, - page_size: int = 20, - ) -> AsyncPaginator[ClosedPosition]: - """List closed positions for a user. - - Returns: - An async paginator over matching closed positions. - """ - spec = _data_actions.list_closed_positions_spec( - user=user, - market=market, - event_id=event_id, - title=title, - sort_by=sort_by, - sort_direction=sort_direction, - ) - return async_paginate_offset(self._ctx, spec, page_size=page_size) - - def list_combo_positions( - self, - *, - user: str, - status: ComboPositionStatus | None = None, - sort: ComboPositionSort | None = None, - condition_id: str | Sequence[str] | None = None, - updated_after: int | None = None, - updated_before: int | None = None, - page_size: int = 20, - ) -> AsyncPaginator[ComboPosition]: - """List combo positions for a user. - - Returns: - An async paginator over matching combo positions. - """ - spec = _data_actions.list_combo_positions_spec( - user=user, - status=status, - sort=sort, - condition_id=condition_id, - updated_after=updated_after, - updated_before=updated_before, - ) - return async_paginate_keyset(self._ctx, spec, page_size=page_size) - - def list_market_positions( - self, - *, - market: str, - user: str | None = None, - status: MarketPositionStatus | None = None, - sort_by: MarketPositionSortBy | None = None, - sort_direction: SortDirection | None = None, - page_size: int = 20, - ) -> AsyncPaginator[MetaMarketPosition]: - """List positions in a market. - - Returns: - An async paginator over matching market positions. - """ - spec = _data_actions.list_market_positions_spec( - market=market, - user=user, - status=status, - sort_by=sort_by, - sort_direction=sort_direction, - ) - return async_paginate_offset(self._ctx, spec, page_size=page_size) - - def list_trades( - self, - *, - user: str | None = None, - market: Sequence[str] | None = None, - event_id: Sequence[int] | None = None, - side: TradeSide | None = None, - taker_only: bool | None = None, - filter_type: TradeFilterType | None = None, - filter_amount: float | None = None, - start: int | None = None, - end: int | None = None, - page_size: int = 20, - ) -> AsyncPaginator[Trade]: - """List public trades. - - Returns: - An async paginator over matching trades. - """ - spec = _data_actions.list_trades_spec( - user=user, - market=market, - event_id=event_id, - side=side, - taker_only=taker_only, - filter_type=filter_type, - filter_amount=filter_amount, - start=start, - end=end, - ) - return async_paginate_offset(self._ctx, spec, page_size=page_size) - - def list_activity( - self, - *, - user: str, - market: str | Sequence[str] | None = None, - event_id: int | Sequence[int] | None = None, - activity_types: Sequence[ActivityTypeFilter] | None = None, - side: TradeSide | None = None, - sort_by: ActivitySortBy | None = None, - sort_direction: SortDirection | None = None, - start: int | None = None, - end: int | None = None, - page_size: int = 20, - ) -> AsyncPaginator[Activity]: - """List user activity. - - Returns: - An async paginator over matching activity entries. - """ - spec = _data_actions.list_activity_spec( - user=user, - market=market, - event_id=event_id, - activity_types=activity_types, - side=side, - sort_by=sort_by, - sort_direction=sort_direction, - start=start, - end=end, - ) - return async_paginate_offset(self._ctx, spec, page_size=page_size) - - def list_combo_activity( - self, - *, - user: str, - condition_id: str | Sequence[str] | None = None, - page_size: int = 50, - ) -> AsyncPaginator[ComboActivity]: - """List combo lifecycle activity for a user. - - Returns: - An async paginator over matching combo lifecycle activity entries. - """ - spec = _data_actions.list_combo_activity_spec(user=user, condition_id=condition_id) - return async_paginate_keyset(self._ctx, spec, page_size=page_size) - - def list_builder_leaderboard( - self, - *, - time_period: LeaderboardTimePeriod | None = None, - page_size: int = 20, - ) -> AsyncPaginator[LeaderboardEntry]: - """List builder leaderboard entries. - - Returns: - An async paginator over leaderboard rows. - """ - spec = _data_actions.list_builder_leaderboard_spec(time_period=time_period) - return async_paginate_offset(self._ctx, spec, page_size=page_size) - async def download_accounting_snapshot(self, *, user: str) -> bytes: """Download the accounting snapshot archive for a user.""" path, params = _data_actions.build_accounting_snapshot_request(user=user) return await self._ctx.data.get_bytes(path, params=params) - def list_trader_leaderboard( - self, - *, - category: LeaderboardCategory | None = None, - time_period: LeaderboardTimePeriod | None = None, - order_by: LeaderboardOrderBy | None = None, - user: str | None = None, - user_name: str | None = None, - page_size: int = 20, - ) -> AsyncPaginator[TraderLeaderboardEntry]: - """List trader leaderboard entries. - - Returns: - An async paginator over leaderboard rows. - """ - spec = _data_actions.list_trader_leaderboard_spec( - category=category, - time_period=time_period, - order_by=order_by, - user=user, - user_name=user_name, - ) - return async_paginate_offset(self._ctx, spec, page_size=page_size) - def list_events( self, *, @@ -1356,27 +1432,6 @@ async def get_last_trade_prices( await self._ctx.clob.post_json(path, json=body) ) - async def get_price_history( - self, - *, - asset_id: str | None = None, - token_id: str | None = None, - start_ts: int | None = None, - end_ts: int | None = None, - fidelity: int | None = None, - interval: PriceHistoryInterval | None = None, - ) -> tuple[PriceHistoryPoint, ...]: - """Get historical price points for a CLOB asset.""" - path, params = _clob_actions.build_price_history_request( - asset_id=asset_id, - token_id=token_id, - start_ts=start_ts, - end_ts=end_ts, - fidelity=fidelity, - interval=interval, - ) - return _clob_actions.parse_price_history(await self._ctx.clob.get_json(path, params=params)) - @overload async def estimate_market_price( self, diff --git a/src/polymarket/clients/async_secure.py b/src/polymarket/clients/async_secure.py index 9b73b33c..0826627a 100644 --- a/src/polymarket/clients/async_secure.py +++ b/src/polymarket/clients/async_secure.py @@ -3,6 +3,7 @@ import logging import time from collections.abc import Awaitable, Callable, Mapping, Sequence +from datetime import datetime from decimal import Decimal from types import TracebackType from typing import ( @@ -32,19 +33,6 @@ from polymarket._internal.actions import rewards as _rewards_actions from polymarket._internal.actions import rfq as _rfq_actions from polymarket._internal.actions import session_keys as _session_key_actions -from polymarket._internal.actions.data import ( - ActivitySortBy, - ActivityTypeFilter, - ClosedPositionSortBy, - ComboPositionSort, - ComboPositionStatus, - MarketPositionSortBy, - MarketPositionStatus, - PositionSortBy, - SortDirection, - TradeFilterType, - TradeSide, -) from polymarket._internal.actions.gamma import ( CommentParentEntityType, DateFilter, @@ -177,8 +165,6 @@ OpenOrder, OrderBook, OrderSide, - PriceHistoryInterval, - PriceHistoryPoint, PriceRequest, PublicProfile, RelatedTag, @@ -209,24 +195,39 @@ from polymarket.models.collateral_return import CollateralReturnPlanResponse from polymarket.models.data import ( Activity, - BuilderVolumeEntry, - BuilderVolumeTimePeriod, - ClosedPosition, + ActivityTypeFilter, + BuilderStanding, + BuilderVolumeInterval, + BuilderVolumePoint, ComboActivity, + ComboBiggestWinner, ComboPosition, - LeaderboardCategory, - LeaderboardEntry, - LeaderboardOrderBy, - LeaderboardTimePeriod, + ComboPositionSortBy, + ComboPositionStatusFilter, + LeaderboardWindow, LiveVolume, + MarketBiggestWinner, MetaHolder, - MetaMarketPosition, OpenInterest, PortfolioValue, Position, + PositionFilterType, + PositionSortBy, + PositionStatusFilter, + PriceHistoryInterval, + PriceHistoryPoint, + Resolution, + SortDirection, Trade, - TradedMarketCount, + TradeFilterType, TraderLeaderboardEntry, + TraderLeaderboardSort, + TraderLeaderboardStanding, + UserPnlFidelityInput, + UserPnlIntervalInput, + UserPnlSeries, + UserStats, + UserVolume, ) from polymarket.models.perps import ( PerpsBook, @@ -327,6 +328,377 @@ class AsyncSecureClient: or validate credentials before authenticated requests are made. """ + def list_trades( + self, + *, + user: str | None = None, + condition_id: str | Sequence[str] | None = None, + event_id: int | Sequence[int] | None = None, + side: OrderSide | None = None, + taker_only: bool | None = None, + filter_type: TradeFilterType | None = None, + filter_amount: float | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + full_history: bool = False, + page_size: int = 100, + ) -> AsyncPaginator[Trade]: + """List trades. Size is shares and price is USDC per share. + + Time filters apply when a user is supplied. ``full_history`` selects all history. + + Omit ``user`` to use the authenticated wallet. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_trades_spec( + user=self._user_or_wallet(user), + condition_id=condition_id, + event_id=event_id, + side=side, + taker_only=taker_only, + filter_type=filter_type, + filter_amount=filter_amount, + start=start, + end=end, + full_history=full_history, + ) + return async_paginate_keyset(self._ctx, spec, page_size=page_size) + + def list_activity( + self, + *, + user: str | None = None, + condition_id: str | Sequence[str] | None = None, + event_id: int | Sequence[int] | None = None, + activity_types: Sequence[ActivityTypeFilter] | None = None, + side: OrderSide | None = None, + sort_direction: SortDirection | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + full_history: bool = False, + page_size: int = 100, + ) -> AsyncPaginator[Activity]: + """List wallet activity, including deposits and withdrawals. + + Amounts are USDC and shares are outcome units. + + Omit ``user`` to use the authenticated wallet. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_activity_spec( + user=self._user_or_wallet(user), + condition_id=condition_id, + event_id=event_id, + activity_types=activity_types, + side=side, + sort_direction=sort_direction, + start=start, + end=end, + full_history=full_history, + ) + return async_paginate_keyset(self._ctx, spec, page_size=page_size) + + def list_combo_activity( + self, + *, + user: str | None = None, + condition_id: str | Sequence[str] | None = None, + page_size: int = 100, + ) -> AsyncPaginator[ComboActivity]: + """List combo lifecycle activity. Amounts and payouts are USDC. + + Omit ``user`` to use the authenticated wallet. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_combo_activity_spec( + user=self._user_or_wallet(user), condition_id=condition_id + ) + return async_paginate_keyset(self._ctx, spec, page_size=page_size) + + def list_positions( + self, + *, + user: str | None = None, + condition_id: str | Sequence[str] | None = None, + status: PositionStatusFilter | None = None, + event_id: int | Sequence[int] | None = None, + filter_type: PositionFilterType | None = None, + filter_amount: float | None = None, + include_archived: bool | None = None, + sort_by: PositionSortBy | None = None, + sort_direction: SortDirection | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + full_history: bool = False, + page_size: int = 100, + ) -> AsyncPaginator[Position]: + """List positions for a wallet or a single market. + + Use ``status="CLOSED"`` for closed positions. Sizes are shares and values are USDC. + + Positions have no time bounds by default. ``full_history=True`` also includes + holdings without activity and cannot be combined with ``start`` or ``end``. + + Omit ``user`` to use the authenticated wallet. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_positions_spec( + user=self._user_or_wallet(user), + condition_id=condition_id, + status=status, + event_id=event_id, + filter_type=filter_type, + filter_amount=filter_amount, + include_archived=include_archived, + sort_by=sort_by, + sort_direction=sort_direction, + start=start, + end=end, + full_history=full_history, + ) + return async_paginate_keyset(self._ctx, spec, page_size=page_size) + + def list_combo_positions( + self, + *, + user: str | None = None, + condition_id: str | Sequence[str] | None = None, + status: ComboPositionStatusFilter | Sequence[ComboPositionStatusFilter] | None = None, + sort_by: ComboPositionSortBy | None = None, + sort_direction: SortDirection | None = None, + updated_after: int | datetime | None = None, + updated_before: int | datetime | None = None, + page_size: int = 100, + ) -> AsyncPaginator[ComboPosition]: + """List combo positions, optionally filtering by multiple statuses. + + Update bounds are inclusive. Sizes are shares; costs and payouts are USDC. + + Zero is a valid update bound. Without a status filter, supplying either bound + selects the synchronization view, which also includes positions no longer held. + + Omit ``user`` to use the authenticated wallet. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_combo_positions_spec( + user=self._user_or_wallet(user), + condition_id=condition_id, + status=status, + sort_by=sort_by, + sort_direction=sort_direction, + updated_after=updated_after, + updated_before=updated_before, + ) + return async_paginate_keyset(self._ctx, spec, page_size=page_size) + + async def get_portfolio_value( + self, + *, + user: str | None = None, + condition_ids: str | Sequence[str] | None = None, + ) -> PortfolioValue: + """Get current portfolio value in USDC. + + Omit ``user`` to use the authenticated wallet.""" + spec = _data_actions.build_get_portfolio_value_spec( + user=self._user_or_wallet(user), condition_ids=condition_ids + ) + return await async_dispatch(self._ctx, spec) + + async def get_user_stats( + self, + *, + user: str | None = None, + ) -> UserStats | None: + """Get wallet statistics, or ``None`` when the wallet has no statistics. + + Omit ``user`` to use the authenticated wallet.""" + spec = _data_actions.build_get_user_stats_spec(user=self._user_or_wallet(user)) + return await async_dispatch(self._ctx, spec) + + async def get_user_pnl( + self, + *, + user: str | None = None, + interval: UserPnlIntervalInput | None = None, + fidelity: UserPnlFidelityInput | None = None, + ) -> UserPnlSeries: + """Get cumulative wallet PnL in USDC and volume in shares. + + Omit ``user`` to use the authenticated wallet.""" + spec = _data_actions.build_get_user_pnl_spec( + user=self._user_or_wallet(user), interval=interval, fidelity=fidelity + ) + return await async_dispatch(self._ctx, spec) + + async def get_user_volume( + self, + *, + user: str | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + full_history: bool = False, + ) -> UserVolume: + """Get trading volume in shares and USDC. Time bounds are floored to UTC days. + + Omit ``user`` to use the authenticated wallet.""" + spec = _data_actions.build_get_user_volume_spec( + user=self._user_or_wallet(user), start=start, end=end, full_history=full_history + ) + return await async_dispatch(self._ctx, spec) + + def list_market_holders( + self, + *, + condition_ids: str | Sequence[str], + min_balance: float | None = None, + include_pnl: bool | None = None, + page_size: int = 100, + ) -> AsyncPaginator[MetaHolder]: + """List holders grouped by outcome asset. Amounts are shares; PnL is USDC. + + Page size applies per outcome. Merge groups across pages by ``asset_id``. + With ``include_pnl=True``, the maximum page size is 100; amounts are gross per side. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.build_list_market_holders_spec( + condition_ids=condition_ids, min_balance=min_balance, include_pnl=include_pnl + ) + return async_paginate_keyset(self._ctx, spec, page_size=page_size) + + async def get_open_interests( + self, + *, + condition_ids: str | Sequence[str] | None = None, + ) -> tuple[OpenInterest, ...]: + """Get open interest in USDC. Omit conditions to request global interest.""" + spec = _data_actions.get_open_interests_spec(condition_ids=condition_ids) + return await async_dispatch(self._ctx, spec) + + async def get_event_live_volume( + self, + *, + event_ids: int | Sequence[int], + ) -> LiveVolume: + """Get combined event taker volume in shares with a market breakdown.""" + spec = _data_actions.build_get_event_live_volume_spec(event_ids=event_ids) + return await async_dispatch(self._ctx, spec) + + def list_price_history( + self, + *, + asset_id: str, + interval: PriceHistoryInterval | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + as_of: int | datetime | None = None, + bucket_seconds: int | None = None, + page_size: int | None = None, + ) -> AsyncPaginator[PriceHistoryPoint]: + """List historical prices in USDC per share, oldest first. + + Select an interval, a start/end window (end exclusive), or an exact ``as_of``. + Windows span at most 15 days. ``as_of`` forbids bucket_seconds and page_size. + The default page size is 10000. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.build_list_price_history_spec( + asset_id=asset_id, + interval=interval, + start=start, + end=end, + as_of=as_of, + bucket_seconds=bucket_seconds, + page_size=page_size, + ) + return async_paginate_keyset( + self._ctx, spec, page_size=10000 if page_size is None else page_size + ) + + async def get_resolutions( + self, + *, + question_id: str | None = None, + condition_ids: str | Sequence[str] | None = None, + event_ids: int | Sequence[int] | None = None, + ) -> tuple[Resolution, ...]: + """Get resolutions by question, conditions, or events. Missing rows are omitted.""" + spec = _data_actions.build_get_resolutions_spec( + question_id=question_id, condition_ids=condition_ids, event_ids=event_ids + ) + return await async_dispatch(self._ctx, spec) + + def list_trader_leaderboard( + self, + *, + category: str | None = None, + window: LeaderboardWindow | None = None, + sort_by: TraderLeaderboardSort | None = None, + page_size: int = 100, + ) -> AsyncPaginator[TraderLeaderboardEntry]: + """List ranked traders. PnL is USDC and volume is shares; ranks can tie and skip. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_trader_leaderboard_spec( + category=category, window=window, sort_by=sort_by + ) + return async_paginate_keyset(self._ctx, spec, page_size=page_size) + + async def get_trader_leaderboard_standing( + self, + *, + user: str | None = None, + category: str | None = None, + window: LeaderboardWindow | None = None, + ) -> TraderLeaderboardStanding | None: + """Get wallet leaderboard standings, or ``None`` when unavailable. + + PnL is USDC and volume is shares. Unranked ranks are ``None``. + + Omit ``user`` to use the authenticated wallet.""" + spec = _data_actions.build_get_trader_leaderboard_standing_spec( + user=self._user_or_wallet(user), category=category, window=window + ) + return await async_dispatch(self._ctx, spec) + + def list_biggest_winners( + self, + *, + category: str | None = None, + window: LeaderboardWindow | None = None, + page_size: int = 100, + ) -> AsyncPaginator[MarketBiggestWinner | ComboBiggestWinner]: + """List winning market and combo positions, ordered by USDC PnL. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.build_list_biggest_winners_spec(category=category, window=window) + return async_paginate_keyset(self._ctx, spec, page_size=page_size) + + def list_builder_leaderboard( + self, + *, + window: LeaderboardWindow | None = None, + page_size: int = 100, + ) -> AsyncPaginator[BuilderStanding]: + """List ranked builders and their trading volume in shares. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_builder_leaderboard_spec(window=window) + return async_paginate_keyset(self._ctx, spec, page_size=page_size) + + async def get_builder_volumes( + self, + *, + interval: BuilderVolumeInterval | None = None, + bucket_limit: int | None = None, + ) -> tuple[BuilderVolumePoint, ...]: + """Get builder volume in shares by calendar bucket. + + ``bucket_limit`` counts dates, not rows (default 30, maximum 90). + Interval ``all`` yields calendar-year buckets.""" + spec = _data_actions.get_builder_volumes_spec(interval=interval, bucket_limit=bucket_limit) + return await async_dispatch(self._ctx, spec) + def __init__( self, *, @@ -1291,58 +1663,6 @@ async def get_comment_thread( _gamma_actions.get_comment_thread_spec(id, get_positions=get_positions), ) - async def get_event_live_volumes(self, *, id: str) -> tuple[LiveVolume, ...]: - """Get live volume entries for an event.""" - return await async_dispatch(self._ctx, _data_actions.get_event_live_volumes_spec(id=id)) - - async def get_open_interests( - self, *, market: Sequence[str] | None = None - ) -> tuple[OpenInterest, ...]: - """Get open interest values, optionally filtered by market ids.""" - return await async_dispatch(self._ctx, _data_actions.get_open_interests_spec(market=market)) - - async def get_market_holders( - self, - *, - market: Sequence[str], - limit: int | None = None, - min_balance: int | None = None, - ) -> tuple[MetaHolder, ...]: - """Get holder balances for one or more markets.""" - return await async_dispatch( - self._ctx, - _data_actions.get_market_holders_spec( - market=market, limit=limit, min_balance=min_balance - ), - ) - - async def get_portfolio_values( - self, - *, - user: str | None = None, - market: Sequence[str] | None = None, - ) -> tuple[PortfolioValue, ...]: - """Get portfolio value snapshots for a user or the authenticated wallet.""" - return await async_dispatch( - self._ctx, - _data_actions.get_portfolio_values_spec(user=self._user_or_wallet(user), market=market), - ) - - async def get_traded_market_count(self, *, user: str | None = None) -> TradedMarketCount: - """Get the number of markets traded by a user or the authenticated wallet.""" - return await async_dispatch( - self._ctx, - _data_actions.get_traded_market_count_spec(user=self._user_or_wallet(user)), - ) - - async def get_builder_volumes( - self, *, time_period: BuilderVolumeTimePeriod | None = None - ) -> tuple[BuilderVolumeEntry, ...]: - """Get builder volume leaderboard entries.""" - return await async_dispatch( - self._ctx, _data_actions.get_builder_volumes_spec(time_period=time_period) - ) - def list_builder_trades( self, *, @@ -1376,209 +1696,6 @@ async def fetch(cursor: str | None) -> Page[BuilderTrade]: return AsyncPaginator(fetch=fetch) - def list_positions( - self, - *, - user: str | None = None, - market: Sequence[str] | None = None, - event_id: Sequence[int] | None = None, - size_threshold: float | None = None, - redeemable: bool | None = None, - mergeable: bool | None = None, - sort_by: PositionSortBy | None = None, - sort_direction: SortDirection | None = None, - title: str | None = None, - page_size: int = 20, - ) -> AsyncPaginator[Position]: - """List open positions for a user or the authenticated wallet. - - Returns: - An async paginator over matching positions. - """ - spec = _data_actions.list_positions_spec( - user=self._user_or_wallet(user), - market=market, - event_id=event_id, - size_threshold=size_threshold, - redeemable=redeemable, - mergeable=mergeable, - sort_by=sort_by, - sort_direction=sort_direction, - title=title, - ) - return async_paginate_offset(self._ctx, spec, page_size=page_size) - - def list_closed_positions( - self, - *, - user: str | None = None, - market: Sequence[str] | None = None, - event_id: Sequence[int] | None = None, - title: str | None = None, - sort_by: ClosedPositionSortBy | None = None, - sort_direction: SortDirection | None = None, - page_size: int = 20, - ) -> AsyncPaginator[ClosedPosition]: - """List closed positions for a user or the authenticated wallet. - - Returns: - An async paginator over matching closed positions. - """ - spec = _data_actions.list_closed_positions_spec( - user=self._user_or_wallet(user), - market=market, - event_id=event_id, - title=title, - sort_by=sort_by, - sort_direction=sort_direction, - ) - return async_paginate_offset(self._ctx, spec, page_size=page_size) - - def list_combo_positions( - self, - *, - user: str | None = None, - status: ComboPositionStatus | None = None, - sort: ComboPositionSort | None = None, - condition_id: str | Sequence[str] | None = None, - updated_after: int | None = None, - updated_before: int | None = None, - page_size: int = 20, - ) -> AsyncPaginator[ComboPosition]: - """List combo positions for a user or the authenticated wallet. - - Returns: - An async paginator over matching combo positions. - """ - spec = _data_actions.list_combo_positions_spec( - user=self._user_or_wallet(user), - status=status, - sort=sort, - condition_id=condition_id, - updated_after=updated_after, - updated_before=updated_before, - ) - return async_paginate_keyset(self._ctx, spec, page_size=page_size) - - def list_market_positions( - self, - *, - market: str, - user: str | None = None, - status: MarketPositionStatus | None = None, - sort_by: MarketPositionSortBy | None = None, - sort_direction: SortDirection | None = None, - page_size: int = 20, - ) -> AsyncPaginator[MetaMarketPosition]: - """List positions in a market. - - Returns: - An async paginator over matching market positions. - """ - spec = _data_actions.list_market_positions_spec( - market=market, - user=user, - status=status, - sort_by=sort_by, - sort_direction=sort_direction, - ) - return async_paginate_offset(self._ctx, spec, page_size=page_size) - - def list_trades( - self, - *, - user: str | None = None, - market: Sequence[str] | None = None, - event_id: Sequence[int] | None = None, - side: TradeSide | None = None, - taker_only: bool | None = None, - filter_type: TradeFilterType | None = None, - filter_amount: float | None = None, - start: int | None = None, - end: int | None = None, - page_size: int = 20, - ) -> AsyncPaginator[Trade]: - """List trades for a user or the authenticated wallet. - - Returns: - An async paginator over matching trades. - """ - spec = _data_actions.list_trades_spec( - user=self._user_or_wallet(user), - market=market, - event_id=event_id, - side=side, - taker_only=taker_only, - filter_type=filter_type, - filter_amount=filter_amount, - start=start, - end=end, - ) - return async_paginate_offset(self._ctx, spec, page_size=page_size) - - def list_activity( - self, - *, - user: str | None = None, - market: Sequence[str] | None = None, - event_id: Sequence[int] | None = None, - activity_types: Sequence[ActivityTypeFilter] | None = None, - side: TradeSide | None = None, - sort_by: ActivitySortBy | None = None, - sort_direction: SortDirection | None = None, - start: int | None = None, - end: int | None = None, - page_size: int = 20, - ) -> AsyncPaginator[Activity]: - """List activity for a user or the authenticated wallet. - - Returns: - An async paginator over matching activity entries. - """ - spec = _data_actions.list_activity_spec( - user=self._user_or_wallet(user), - market=market, - event_id=event_id, - activity_types=activity_types, - side=side, - sort_by=sort_by, - sort_direction=sort_direction, - start=start, - end=end, - ) - return async_paginate_offset(self._ctx, spec, page_size=page_size) - - def list_combo_activity( - self, - *, - user: str | None = None, - condition_id: str | Sequence[str] | None = None, - page_size: int = 50, - ) -> AsyncPaginator[ComboActivity]: - """List combo lifecycle activity for a user or the authenticated wallet. - - Returns: - An async paginator over matching combo lifecycle activity entries. - """ - spec = _data_actions.list_combo_activity_spec( - user=self._user_or_wallet(user), condition_id=condition_id - ) - return async_paginate_keyset(self._ctx, spec, page_size=page_size) - - def list_builder_leaderboard( - self, - *, - time_period: LeaderboardTimePeriod | None = None, - page_size: int = 20, - ) -> AsyncPaginator[LeaderboardEntry]: - """List builder leaderboard entries. - - Returns: - An async paginator over leaderboard rows. - """ - spec = _data_actions.list_builder_leaderboard_spec(time_period=time_period) - return async_paginate_offset(self._ctx, spec, page_size=page_size) - async def download_accounting_snapshot(self, *, user: str | None = None) -> bytes: """Download the accounting snapshot archive for a user or the authenticated wallet.""" path, params = _data_actions.build_accounting_snapshot_request( @@ -1586,30 +1703,6 @@ async def download_accounting_snapshot(self, *, user: str | None = None) -> byte ) return await self._ctx.data.get_bytes(path, params=params) - def list_trader_leaderboard( - self, - *, - category: LeaderboardCategory | None = None, - time_period: LeaderboardTimePeriod | None = None, - order_by: LeaderboardOrderBy | None = None, - user: str | None = None, - user_name: str | None = None, - page_size: int = 20, - ) -> AsyncPaginator[TraderLeaderboardEntry]: - """List trader leaderboard entries. - - Returns: - An async paginator over leaderboard rows. - """ - spec = _data_actions.list_trader_leaderboard_spec( - category=category, - time_period=time_period, - order_by=order_by, - user=user, - user_name=user_name, - ) - return async_paginate_offset(self._ctx, spec, page_size=page_size) - def list_events( self, *, @@ -2069,27 +2162,6 @@ async def get_last_trade_prices( await self._ctx.clob.post_json(path, json=body) ) - async def get_price_history( - self, - *, - asset_id: str | None = None, - token_id: str | None = None, - start_ts: int | None = None, - end_ts: int | None = None, - fidelity: int | None = None, - interval: PriceHistoryInterval | None = None, - ) -> tuple[PriceHistoryPoint, ...]: - """Get historical price points for a CLOB asset.""" - path, params = _clob_actions.build_price_history_request( - asset_id=asset_id, - token_id=token_id, - start_ts=start_ts, - end_ts=end_ts, - fidelity=fidelity, - interval=interval, - ) - return _clob_actions.parse_price_history(await self._ctx.clob.get_json(path, params=params)) - async def fetch_api_keys(self) -> tuple[str, ...]: """Fetch API key identifiers for the authenticated account.""" return await _auth_actions.fetch_api_keys(self._ctx.secure_clob) diff --git a/src/polymarket/clients/public.py b/src/polymarket/clients/public.py index 9cc7dc3d..abe091d1 100644 --- a/src/polymarket/clients/public.py +++ b/src/polymarket/clients/public.py @@ -2,6 +2,7 @@ import logging from collections.abc import Sequence +from datetime import datetime from decimal import Decimal from types import TracebackType from typing import Literal, Self, overload @@ -12,19 +13,6 @@ from polymarket._internal.actions import gamma as _gamma_actions from polymarket._internal.actions import rewards as _rewards_actions from polymarket._internal.actions import rfq as _rfq_actions -from polymarket._internal.actions.data import ( - ActivitySortBy, - ActivityTypeFilter, - ClosedPositionSortBy, - ComboPositionSort, - ComboPositionStatus, - MarketPositionSortBy, - MarketPositionStatus, - PositionSortBy, - SortDirection, - TradeFilterType, - TradeSide, -) from polymarket._internal.actions.gamma import ( CommentParentEntityType, DateFilter, @@ -59,8 +47,6 @@ Market, OrderBook, OrderSide, - PriceHistoryInterval, - PriceHistoryPoint, PriceRequest, PublicProfile, RelatedTag, @@ -77,24 +63,39 @@ from polymarket.models.clob.rewards import CurrentReward, MarketReward from polymarket.models.data import ( Activity, - BuilderVolumeEntry, - BuilderVolumeTimePeriod, - ClosedPosition, + ActivityTypeFilter, + BuilderStanding, + BuilderVolumeInterval, + BuilderVolumePoint, ComboActivity, + ComboBiggestWinner, ComboPosition, - LeaderboardCategory, - LeaderboardEntry, - LeaderboardOrderBy, - LeaderboardTimePeriod, + ComboPositionSortBy, + ComboPositionStatusFilter, + LeaderboardWindow, LiveVolume, + MarketBiggestWinner, MetaHolder, - MetaMarketPosition, OpenInterest, PortfolioValue, Position, + PositionFilterType, + PositionSortBy, + PositionStatusFilter, + PriceHistoryInterval, + PriceHistoryPoint, + Resolution, + SortDirection, Trade, - TradedMarketCount, + TradeFilterType, TraderLeaderboardEntry, + TraderLeaderboardSort, + TraderLeaderboardStanding, + UserPnlFidelityInput, + UserPnlIntervalInput, + UserPnlSeries, + UserStats, + UserVolume, ) from polymarket.models.types import ClobAssetId, CtfConditionId from polymarket.pagination import Page, Paginator @@ -106,6 +107,353 @@ class PublicClient: Public methods return stable, idiomatic Python SDK objects. """ + def list_trades( + self, + *, + user: str | None = None, + condition_id: str | Sequence[str] | None = None, + event_id: int | Sequence[int] | None = None, + side: OrderSide | None = None, + taker_only: bool | None = None, + filter_type: TradeFilterType | None = None, + filter_amount: float | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + full_history: bool = False, + page_size: int = 100, + ) -> Paginator[Trade]: + """List trades. Size is shares and price is USDC per share. + + Time filters apply when a user is supplied. ``full_history`` selects all history. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_trades_spec( + user=user, + condition_id=condition_id, + event_id=event_id, + side=side, + taker_only=taker_only, + filter_type=filter_type, + filter_amount=filter_amount, + start=start, + end=end, + full_history=full_history, + ) + return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + + def list_activity( + self, + *, + user: str, + condition_id: str | Sequence[str] | None = None, + event_id: int | Sequence[int] | None = None, + activity_types: Sequence[ActivityTypeFilter] | None = None, + side: OrderSide | None = None, + sort_direction: SortDirection | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + full_history: bool = False, + page_size: int = 100, + ) -> Paginator[Activity]: + """List wallet activity, including deposits and withdrawals. + + Amounts are USDC and shares are outcome units. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_activity_spec( + user=user, + condition_id=condition_id, + event_id=event_id, + activity_types=activity_types, + side=side, + sort_direction=sort_direction, + start=start, + end=end, + full_history=full_history, + ) + return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + + def list_combo_activity( + self, + *, + user: str, + condition_id: str | Sequence[str] | None = None, + page_size: int = 100, + ) -> Paginator[ComboActivity]: + """List combo lifecycle activity. Amounts and payouts are USDC. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_combo_activity_spec(user=user, condition_id=condition_id) + return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + + def list_positions( + self, + *, + user: str | None = None, + condition_id: str | Sequence[str] | None = None, + status: PositionStatusFilter | None = None, + event_id: int | Sequence[int] | None = None, + filter_type: PositionFilterType | None = None, + filter_amount: float | None = None, + include_archived: bool | None = None, + sort_by: PositionSortBy | None = None, + sort_direction: SortDirection | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + full_history: bool = False, + page_size: int = 100, + ) -> Paginator[Position]: + """List positions for a wallet or a single market. + + Use ``status="CLOSED"`` for closed positions. Sizes are shares and values are USDC. + + Positions have no time bounds by default. ``full_history=True`` also includes + holdings without activity and cannot be combined with ``start`` or ``end``. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_positions_spec( + user=user, + condition_id=condition_id, + status=status, + event_id=event_id, + filter_type=filter_type, + filter_amount=filter_amount, + include_archived=include_archived, + sort_by=sort_by, + sort_direction=sort_direction, + start=start, + end=end, + full_history=full_history, + ) + return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + + def list_combo_positions( + self, + *, + user: str, + condition_id: str | Sequence[str] | None = None, + status: ComboPositionStatusFilter | Sequence[ComboPositionStatusFilter] | None = None, + sort_by: ComboPositionSortBy | None = None, + sort_direction: SortDirection | None = None, + updated_after: int | datetime | None = None, + updated_before: int | datetime | None = None, + page_size: int = 100, + ) -> Paginator[ComboPosition]: + """List combo positions, optionally filtering by multiple statuses. + + Update bounds are inclusive. Sizes are shares; costs and payouts are USDC. + + Zero is a valid update bound. Without a status filter, supplying either bound + selects the synchronization view, which also includes positions no longer held. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_combo_positions_spec( + user=user, + condition_id=condition_id, + status=status, + sort_by=sort_by, + sort_direction=sort_direction, + updated_after=updated_after, + updated_before=updated_before, + ) + return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + + def get_portfolio_value( + self, + *, + user: str, + condition_ids: str | Sequence[str] | None = None, + ) -> PortfolioValue: + """Get current portfolio value in USDC.""" + spec = _data_actions.build_get_portfolio_value_spec(user=user, condition_ids=condition_ids) + return sync_dispatch(self._ctx, spec) + + def get_user_stats( + self, + *, + user: str, + ) -> UserStats | None: + """Get wallet statistics, or ``None`` when the wallet has no statistics.""" + spec = _data_actions.build_get_user_stats_spec(user=user) + return sync_dispatch(self._ctx, spec) + + def get_user_pnl( + self, + *, + user: str, + interval: UserPnlIntervalInput | None = None, + fidelity: UserPnlFidelityInput | None = None, + ) -> UserPnlSeries: + """Get cumulative wallet PnL in USDC and volume in shares.""" + spec = _data_actions.build_get_user_pnl_spec( + user=user, interval=interval, fidelity=fidelity + ) + return sync_dispatch(self._ctx, spec) + + def get_user_volume( + self, + *, + user: str, + start: int | datetime | None = None, + end: int | datetime | None = None, + full_history: bool = False, + ) -> UserVolume: + """Get trading volume in shares and USDC. Time bounds are floored to UTC days.""" + spec = _data_actions.build_get_user_volume_spec( + user=user, start=start, end=end, full_history=full_history + ) + return sync_dispatch(self._ctx, spec) + + def list_market_holders( + self, + *, + condition_ids: str | Sequence[str], + min_balance: float | None = None, + include_pnl: bool | None = None, + page_size: int = 100, + ) -> Paginator[MetaHolder]: + """List holders grouped by outcome asset. Amounts are shares; PnL is USDC. + + Page size applies per outcome. Merge groups across pages by ``asset_id``. + With ``include_pnl=True``, the maximum page size is 100; amounts are gross per side. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.build_list_market_holders_spec( + condition_ids=condition_ids, min_balance=min_balance, include_pnl=include_pnl + ) + return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + + def get_open_interests( + self, + *, + condition_ids: str | Sequence[str] | None = None, + ) -> tuple[OpenInterest, ...]: + """Get open interest in USDC. Omit conditions to request global interest.""" + spec = _data_actions.get_open_interests_spec(condition_ids=condition_ids) + return sync_dispatch(self._ctx, spec) + + def get_event_live_volume( + self, + *, + event_ids: int | Sequence[int], + ) -> LiveVolume: + """Get combined event taker volume in shares with a market breakdown.""" + spec = _data_actions.build_get_event_live_volume_spec(event_ids=event_ids) + return sync_dispatch(self._ctx, spec) + + def list_price_history( + self, + *, + asset_id: str, + interval: PriceHistoryInterval | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + as_of: int | datetime | None = None, + bucket_seconds: int | None = None, + page_size: int | None = None, + ) -> Paginator[PriceHistoryPoint]: + """List historical prices in USDC per share, oldest first. + + Select an interval, a start/end window (end exclusive), or an exact ``as_of``. + Windows span at most 15 days. ``as_of`` forbids bucket_seconds and page_size. + The default page size is 10000. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.build_list_price_history_spec( + asset_id=asset_id, + interval=interval, + start=start, + end=end, + as_of=as_of, + bucket_seconds=bucket_seconds, + page_size=page_size, + ) + return sync_paginate_keyset( + self._ctx, spec, page_size=10000 if page_size is None else page_size + ) + + def get_resolutions( + self, + *, + question_id: str | None = None, + condition_ids: str | Sequence[str] | None = None, + event_ids: int | Sequence[int] | None = None, + ) -> tuple[Resolution, ...]: + """Get resolutions by question, conditions, or events. Missing rows are omitted.""" + spec = _data_actions.build_get_resolutions_spec( + question_id=question_id, condition_ids=condition_ids, event_ids=event_ids + ) + return sync_dispatch(self._ctx, spec) + + def list_trader_leaderboard( + self, + *, + category: str | None = None, + window: LeaderboardWindow | None = None, + sort_by: TraderLeaderboardSort | None = None, + page_size: int = 100, + ) -> Paginator[TraderLeaderboardEntry]: + """List ranked traders. PnL is USDC and volume is shares; ranks can tie and skip. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_trader_leaderboard_spec( + category=category, window=window, sort_by=sort_by + ) + return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + + def get_trader_leaderboard_standing( + self, + *, + user: str, + category: str | None = None, + window: LeaderboardWindow | None = None, + ) -> TraderLeaderboardStanding | None: + """Get wallet leaderboard standings, or ``None`` when unavailable. + + PnL is USDC and volume is shares. Unranked ranks are ``None``.""" + spec = _data_actions.build_get_trader_leaderboard_standing_spec( + user=user, category=category, window=window + ) + return sync_dispatch(self._ctx, spec) + + def list_biggest_winners( + self, + *, + category: str | None = None, + window: LeaderboardWindow | None = None, + page_size: int = 100, + ) -> Paginator[MarketBiggestWinner | ComboBiggestWinner]: + """List winning market and combo positions, ordered by USDC PnL. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.build_list_biggest_winners_spec(category=category, window=window) + return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + + def list_builder_leaderboard( + self, + *, + window: LeaderboardWindow | None = None, + page_size: int = 100, + ) -> Paginator[BuilderStanding]: + """List ranked builders and their trading volume in shares. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_builder_leaderboard_spec(window=window) + return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + + def get_builder_volumes( + self, + *, + interval: BuilderVolumeInterval | None = None, + bucket_limit: int | None = None, + ) -> tuple[BuilderVolumePoint, ...]: + """Get builder volume in shares by calendar bucket. + + ``bucket_limit`` counts dates, not rows (default 30, maximum 90). + Interval ``all`` yields calendar-year buckets.""" + spec = _data_actions.get_builder_volumes_spec(interval=interval, bucket_limit=bucket_limit) + return sync_dispatch(self._ctx, spec) + def __init__( self, environment: Environment = PRODUCTION, @@ -355,51 +703,6 @@ def get_comment_thread( _gamma_actions.get_comment_thread_spec(id, get_positions=get_positions), ) - def get_event_live_volumes(self, *, id: str) -> tuple[LiveVolume, ...]: - """Get live volume entries for an event.""" - return sync_dispatch(self._ctx, _data_actions.get_event_live_volumes_spec(id=id)) - - def get_open_interests( - self, *, market: Sequence[str] | None = None - ) -> tuple[OpenInterest, ...]: - """Get open interest values, optionally filtered by market ids.""" - return sync_dispatch(self._ctx, _data_actions.get_open_interests_spec(market=market)) - - def get_market_holders( - self, - *, - market: Sequence[str], - limit: int | None = None, - min_balance: int | None = None, - ) -> tuple[MetaHolder, ...]: - """Get holder balances for one or more markets.""" - return sync_dispatch( - self._ctx, - _data_actions.get_market_holders_spec( - market=market, limit=limit, min_balance=min_balance - ), - ) - - def get_portfolio_values( - self, *, user: str, market: Sequence[str] | None = None - ) -> tuple[PortfolioValue, ...]: - """Get portfolio value snapshots for a user.""" - return sync_dispatch( - self._ctx, _data_actions.get_portfolio_values_spec(user=user, market=market) - ) - - def get_traded_market_count(self, *, user: str) -> TradedMarketCount: - """Get the number of markets a user has traded.""" - return sync_dispatch(self._ctx, _data_actions.get_traded_market_count_spec(user=user)) - - def get_builder_volumes( - self, *, time_period: BuilderVolumeTimePeriod | None = None - ) -> tuple[BuilderVolumeEntry, ...]: - """Get builder volume leaderboard entries.""" - return sync_dispatch( - self._ctx, _data_actions.get_builder_volumes_spec(time_period=time_period) - ) - def list_builder_trades( self, *, @@ -434,236 +737,11 @@ def fetch(cursor: str | None) -> Page[BuilderTrade]: return Paginator(fetch=fetch) - def list_positions( - self, - *, - user: str, - market: str | Sequence[str] | None = None, - event_id: int | Sequence[int] | None = None, - size_threshold: float | None = None, - redeemable: bool | None = None, - mergeable: bool | None = None, - sort_by: PositionSortBy | None = None, - sort_direction: SortDirection | None = None, - title: str | None = None, - page_size: int = 20, - ) -> Paginator[Position]: - """List open positions for a user. - - Returns: - A paginator over matching positions. - """ - spec = _data_actions.list_positions_spec( - user=user, - market=market, - event_id=event_id, - size_threshold=size_threshold, - redeemable=redeemable, - mergeable=mergeable, - sort_by=sort_by, - sort_direction=sort_direction, - title=title, - ) - return sync_paginate_offset(self._ctx, spec, page_size=page_size) - - def list_closed_positions( - self, - *, - user: str, - market: str | Sequence[str] | None = None, - event_id: int | Sequence[int] | None = None, - title: str | None = None, - sort_by: ClosedPositionSortBy | None = None, - sort_direction: SortDirection | None = None, - page_size: int = 20, - ) -> Paginator[ClosedPosition]: - """List closed positions for a user. - - Returns: - A paginator over matching closed positions. - """ - spec = _data_actions.list_closed_positions_spec( - user=user, - market=market, - event_id=event_id, - title=title, - sort_by=sort_by, - sort_direction=sort_direction, - ) - return sync_paginate_offset(self._ctx, spec, page_size=page_size) - - def list_combo_positions( - self, - *, - user: str, - status: ComboPositionStatus | None = None, - sort: ComboPositionSort | None = None, - condition_id: str | Sequence[str] | None = None, - updated_after: int | None = None, - updated_before: int | None = None, - page_size: int = 20, - ) -> Paginator[ComboPosition]: - """List combo positions for a user. - - Returns: - A paginator over matching combo positions. - """ - spec = _data_actions.list_combo_positions_spec( - user=user, - status=status, - sort=sort, - condition_id=condition_id, - updated_after=updated_after, - updated_before=updated_before, - ) - return sync_paginate_keyset(self._ctx, spec, page_size=page_size) - - def list_market_positions( - self, - *, - market: str, - user: str | None = None, - status: MarketPositionStatus | None = None, - sort_by: MarketPositionSortBy | None = None, - sort_direction: SortDirection | None = None, - page_size: int = 20, - ) -> Paginator[MetaMarketPosition]: - """List positions in a market. - - Returns: - A paginator over matching market positions. - """ - spec = _data_actions.list_market_positions_spec( - market=market, - user=user, - status=status, - sort_by=sort_by, - sort_direction=sort_direction, - ) - return sync_paginate_offset(self._ctx, spec, page_size=page_size) - - def list_trades( - self, - *, - user: str | None = None, - market: Sequence[str] | None = None, - event_id: Sequence[int] | None = None, - side: TradeSide | None = None, - taker_only: bool | None = None, - filter_type: TradeFilterType | None = None, - filter_amount: float | None = None, - start: int | None = None, - end: int | None = None, - page_size: int = 20, - ) -> Paginator[Trade]: - """List public trades. - - Returns: - A paginator over matching trades. - """ - spec = _data_actions.list_trades_spec( - user=user, - market=market, - event_id=event_id, - side=side, - taker_only=taker_only, - filter_type=filter_type, - filter_amount=filter_amount, - start=start, - end=end, - ) - return sync_paginate_offset(self._ctx, spec, page_size=page_size) - - def list_activity( - self, - *, - user: str, - market: str | Sequence[str] | None = None, - event_id: int | Sequence[int] | None = None, - activity_types: Sequence[ActivityTypeFilter] | None = None, - side: TradeSide | None = None, - sort_by: ActivitySortBy | None = None, - sort_direction: SortDirection | None = None, - start: int | None = None, - end: int | None = None, - page_size: int = 20, - ) -> Paginator[Activity]: - """List user activity. - - Returns: - A paginator over matching activity entries. - """ - spec = _data_actions.list_activity_spec( - user=user, - market=market, - event_id=event_id, - activity_types=activity_types, - side=side, - sort_by=sort_by, - sort_direction=sort_direction, - start=start, - end=end, - ) - return sync_paginate_offset(self._ctx, spec, page_size=page_size) - - def list_combo_activity( - self, - *, - user: str, - condition_id: str | Sequence[str] | None = None, - page_size: int = 50, - ) -> Paginator[ComboActivity]: - """List combo lifecycle activity for a user. - - Returns: - A paginator over matching combo lifecycle activity entries. - """ - spec = _data_actions.list_combo_activity_spec(user=user, condition_id=condition_id) - return sync_paginate_keyset(self._ctx, spec, page_size=page_size) - - def list_builder_leaderboard( - self, - *, - time_period: LeaderboardTimePeriod | None = None, - page_size: int = 20, - ) -> Paginator[LeaderboardEntry]: - """List builder leaderboard entries. - - Returns: - A paginator over leaderboard rows. - """ - spec = _data_actions.list_builder_leaderboard_spec(time_period=time_period) - return sync_paginate_offset(self._ctx, spec, page_size=page_size) - def download_accounting_snapshot(self, *, user: str) -> bytes: """Download the accounting snapshot archive for a user.""" path, params = _data_actions.build_accounting_snapshot_request(user=user) return self._ctx.data.get_bytes(path, params=params) - def list_trader_leaderboard( - self, - *, - category: LeaderboardCategory | None = None, - time_period: LeaderboardTimePeriod | None = None, - order_by: LeaderboardOrderBy | None = None, - user: str | None = None, - user_name: str | None = None, - page_size: int = 20, - ) -> Paginator[TraderLeaderboardEntry]: - """List trader leaderboard entries. - - Returns: - A paginator over leaderboard rows. - """ - spec = _data_actions.list_trader_leaderboard_spec( - category=category, - time_period=time_period, - order_by=order_by, - user=user, - user_name=user_name, - ) - return sync_paginate_offset(self._ctx, spec, page_size=page_size) - def list_events( self, *, @@ -1144,27 +1222,6 @@ def get_last_trade_prices( ) return _clob_actions.parse_last_trade_prices(self._ctx.clob.post_json(path, json=body)) - def get_price_history( - self, - *, - asset_id: str | None = None, - token_id: str | None = None, - start_ts: int | None = None, - end_ts: int | None = None, - fidelity: int | None = None, - interval: PriceHistoryInterval | None = None, - ) -> tuple[PriceHistoryPoint, ...]: - """Get historical price points for a CLOB asset.""" - path, params = _clob_actions.build_price_history_request( - asset_id=asset_id, - token_id=token_id, - start_ts=start_ts, - end_ts=end_ts, - fidelity=fidelity, - interval=interval, - ) - return _clob_actions.parse_price_history(self._ctx.clob.get_json(path, params=params)) - @overload def estimate_market_price( self, diff --git a/src/polymarket/clients/secure.py b/src/polymarket/clients/secure.py index e085cee7..c67c094a 100644 --- a/src/polymarket/clients/secure.py +++ b/src/polymarket/clients/secure.py @@ -3,6 +3,7 @@ import logging import time from collections.abc import Mapping, Sequence +from datetime import datetime from decimal import Decimal from types import TracebackType from typing import TYPE_CHECKING, Literal, Self, cast, overload @@ -22,19 +23,6 @@ from polymarket._internal.actions import rewards as _rewards_actions from polymarket._internal.actions import rfq as _rfq_actions from polymarket._internal.actions import session_keys as _session_key_actions -from polymarket._internal.actions.data import ( - ActivitySortBy, - ActivityTypeFilter, - ClosedPositionSortBy, - ComboPositionSort, - ComboPositionStatus, - MarketPositionSortBy, - MarketPositionStatus, - PositionSortBy, - SortDirection, - TradeFilterType, - TradeSide, -) from polymarket._internal.actions.gamma import ( CommentParentEntityType, DateFilter, @@ -159,8 +147,6 @@ OpenOrder, OrderBook, OrderSide, - PriceHistoryInterval, - PriceHistoryPoint, PriceRequest, PublicProfile, RelatedTag, @@ -189,24 +175,39 @@ from polymarket.models.collateral_return import CollateralReturnPlanResponse from polymarket.models.data import ( Activity, - BuilderVolumeEntry, - BuilderVolumeTimePeriod, - ClosedPosition, + ActivityTypeFilter, + BuilderStanding, + BuilderVolumeInterval, + BuilderVolumePoint, ComboActivity, + ComboBiggestWinner, ComboPosition, - LeaderboardCategory, - LeaderboardEntry, - LeaderboardOrderBy, - LeaderboardTimePeriod, + ComboPositionSortBy, + ComboPositionStatusFilter, + LeaderboardWindow, LiveVolume, + MarketBiggestWinner, MetaHolder, - MetaMarketPosition, OpenInterest, PortfolioValue, Position, + PositionFilterType, + PositionSortBy, + PositionStatusFilter, + PriceHistoryInterval, + PriceHistoryPoint, + Resolution, + SortDirection, Trade, - TradedMarketCount, + TradeFilterType, TraderLeaderboardEntry, + TraderLeaderboardSort, + TraderLeaderboardStanding, + UserPnlFidelityInput, + UserPnlIntervalInput, + UserPnlSeries, + UserStats, + UserVolume, ) from polymarket.models.types import ClobAssetId, CtfConditionId from polymarket.pagination import Page, Paginator @@ -255,6 +256,377 @@ class SecureClient: validate credentials before authenticated requests are made. """ + def list_trades( + self, + *, + user: str | None = None, + condition_id: str | Sequence[str] | None = None, + event_id: int | Sequence[int] | None = None, + side: OrderSide | None = None, + taker_only: bool | None = None, + filter_type: TradeFilterType | None = None, + filter_amount: float | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + full_history: bool = False, + page_size: int = 100, + ) -> Paginator[Trade]: + """List trades. Size is shares and price is USDC per share. + + Time filters apply when a user is supplied. ``full_history`` selects all history. + + Omit ``user`` to use the authenticated wallet. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_trades_spec( + user=self._user_or_wallet(user), + condition_id=condition_id, + event_id=event_id, + side=side, + taker_only=taker_only, + filter_type=filter_type, + filter_amount=filter_amount, + start=start, + end=end, + full_history=full_history, + ) + return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + + def list_activity( + self, + *, + user: str | None = None, + condition_id: str | Sequence[str] | None = None, + event_id: int | Sequence[int] | None = None, + activity_types: Sequence[ActivityTypeFilter] | None = None, + side: OrderSide | None = None, + sort_direction: SortDirection | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + full_history: bool = False, + page_size: int = 100, + ) -> Paginator[Activity]: + """List wallet activity, including deposits and withdrawals. + + Amounts are USDC and shares are outcome units. + + Omit ``user`` to use the authenticated wallet. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_activity_spec( + user=self._user_or_wallet(user), + condition_id=condition_id, + event_id=event_id, + activity_types=activity_types, + side=side, + sort_direction=sort_direction, + start=start, + end=end, + full_history=full_history, + ) + return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + + def list_combo_activity( + self, + *, + user: str | None = None, + condition_id: str | Sequence[str] | None = None, + page_size: int = 100, + ) -> Paginator[ComboActivity]: + """List combo lifecycle activity. Amounts and payouts are USDC. + + Omit ``user`` to use the authenticated wallet. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_combo_activity_spec( + user=self._user_or_wallet(user), condition_id=condition_id + ) + return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + + def list_positions( + self, + *, + user: str | None = None, + condition_id: str | Sequence[str] | None = None, + status: PositionStatusFilter | None = None, + event_id: int | Sequence[int] | None = None, + filter_type: PositionFilterType | None = None, + filter_amount: float | None = None, + include_archived: bool | None = None, + sort_by: PositionSortBy | None = None, + sort_direction: SortDirection | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + full_history: bool = False, + page_size: int = 100, + ) -> Paginator[Position]: + """List positions for a wallet or a single market. + + Use ``status="CLOSED"`` for closed positions. Sizes are shares and values are USDC. + + Positions have no time bounds by default. ``full_history=True`` also includes + holdings without activity and cannot be combined with ``start`` or ``end``. + + Omit ``user`` to use the authenticated wallet. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_positions_spec( + user=self._user_or_wallet(user), + condition_id=condition_id, + status=status, + event_id=event_id, + filter_type=filter_type, + filter_amount=filter_amount, + include_archived=include_archived, + sort_by=sort_by, + sort_direction=sort_direction, + start=start, + end=end, + full_history=full_history, + ) + return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + + def list_combo_positions( + self, + *, + user: str | None = None, + condition_id: str | Sequence[str] | None = None, + status: ComboPositionStatusFilter | Sequence[ComboPositionStatusFilter] | None = None, + sort_by: ComboPositionSortBy | None = None, + sort_direction: SortDirection | None = None, + updated_after: int | datetime | None = None, + updated_before: int | datetime | None = None, + page_size: int = 100, + ) -> Paginator[ComboPosition]: + """List combo positions, optionally filtering by multiple statuses. + + Update bounds are inclusive. Sizes are shares; costs and payouts are USDC. + + Zero is a valid update bound. Without a status filter, supplying either bound + selects the synchronization view, which also includes positions no longer held. + + Omit ``user`` to use the authenticated wallet. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_combo_positions_spec( + user=self._user_or_wallet(user), + condition_id=condition_id, + status=status, + sort_by=sort_by, + sort_direction=sort_direction, + updated_after=updated_after, + updated_before=updated_before, + ) + return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + + def get_portfolio_value( + self, + *, + user: str | None = None, + condition_ids: str | Sequence[str] | None = None, + ) -> PortfolioValue: + """Get current portfolio value in USDC. + + Omit ``user`` to use the authenticated wallet.""" + spec = _data_actions.build_get_portfolio_value_spec( + user=self._user_or_wallet(user), condition_ids=condition_ids + ) + return sync_dispatch(self._ctx, spec) + + def get_user_stats( + self, + *, + user: str | None = None, + ) -> UserStats | None: + """Get wallet statistics, or ``None`` when the wallet has no statistics. + + Omit ``user`` to use the authenticated wallet.""" + spec = _data_actions.build_get_user_stats_spec(user=self._user_or_wallet(user)) + return sync_dispatch(self._ctx, spec) + + def get_user_pnl( + self, + *, + user: str | None = None, + interval: UserPnlIntervalInput | None = None, + fidelity: UserPnlFidelityInput | None = None, + ) -> UserPnlSeries: + """Get cumulative wallet PnL in USDC and volume in shares. + + Omit ``user`` to use the authenticated wallet.""" + spec = _data_actions.build_get_user_pnl_spec( + user=self._user_or_wallet(user), interval=interval, fidelity=fidelity + ) + return sync_dispatch(self._ctx, spec) + + def get_user_volume( + self, + *, + user: str | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + full_history: bool = False, + ) -> UserVolume: + """Get trading volume in shares and USDC. Time bounds are floored to UTC days. + + Omit ``user`` to use the authenticated wallet.""" + spec = _data_actions.build_get_user_volume_spec( + user=self._user_or_wallet(user), start=start, end=end, full_history=full_history + ) + return sync_dispatch(self._ctx, spec) + + def list_market_holders( + self, + *, + condition_ids: str | Sequence[str], + min_balance: float | None = None, + include_pnl: bool | None = None, + page_size: int = 100, + ) -> Paginator[MetaHolder]: + """List holders grouped by outcome asset. Amounts are shares; PnL is USDC. + + Page size applies per outcome. Merge groups across pages by ``asset_id``. + With ``include_pnl=True``, the maximum page size is 100; amounts are gross per side. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.build_list_market_holders_spec( + condition_ids=condition_ids, min_balance=min_balance, include_pnl=include_pnl + ) + return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + + def get_open_interests( + self, + *, + condition_ids: str | Sequence[str] | None = None, + ) -> tuple[OpenInterest, ...]: + """Get open interest in USDC. Omit conditions to request global interest.""" + spec = _data_actions.get_open_interests_spec(condition_ids=condition_ids) + return sync_dispatch(self._ctx, spec) + + def get_event_live_volume( + self, + *, + event_ids: int | Sequence[int], + ) -> LiveVolume: + """Get combined event taker volume in shares with a market breakdown.""" + spec = _data_actions.build_get_event_live_volume_spec(event_ids=event_ids) + return sync_dispatch(self._ctx, spec) + + def list_price_history( + self, + *, + asset_id: str, + interval: PriceHistoryInterval | None = None, + start: int | datetime | None = None, + end: int | datetime | None = None, + as_of: int | datetime | None = None, + bucket_seconds: int | None = None, + page_size: int | None = None, + ) -> Paginator[PriceHistoryPoint]: + """List historical prices in USDC per share, oldest first. + + Select an interval, a start/end window (end exclusive), or an exact ``as_of``. + Windows span at most 15 days. ``as_of`` forbids bucket_seconds and page_size. + The default page size is 10000. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.build_list_price_history_spec( + asset_id=asset_id, + interval=interval, + start=start, + end=end, + as_of=as_of, + bucket_seconds=bucket_seconds, + page_size=page_size, + ) + return sync_paginate_keyset( + self._ctx, spec, page_size=10000 if page_size is None else page_size + ) + + def get_resolutions( + self, + *, + question_id: str | None = None, + condition_ids: str | Sequence[str] | None = None, + event_ids: int | Sequence[int] | None = None, + ) -> tuple[Resolution, ...]: + """Get resolutions by question, conditions, or events. Missing rows are omitted.""" + spec = _data_actions.build_get_resolutions_spec( + question_id=question_id, condition_ids=condition_ids, event_ids=event_ids + ) + return sync_dispatch(self._ctx, spec) + + def list_trader_leaderboard( + self, + *, + category: str | None = None, + window: LeaderboardWindow | None = None, + sort_by: TraderLeaderboardSort | None = None, + page_size: int = 100, + ) -> Paginator[TraderLeaderboardEntry]: + """List ranked traders. PnL is USDC and volume is shares; ranks can tie and skip. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_trader_leaderboard_spec( + category=category, window=window, sort_by=sort_by + ) + return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + + def get_trader_leaderboard_standing( + self, + *, + user: str | None = None, + category: str | None = None, + window: LeaderboardWindow | None = None, + ) -> TraderLeaderboardStanding | None: + """Get wallet leaderboard standings, or ``None`` when unavailable. + + PnL is USDC and volume is shares. Unranked ranks are ``None``. + + Omit ``user`` to use the authenticated wallet.""" + spec = _data_actions.build_get_trader_leaderboard_standing_spec( + user=self._user_or_wallet(user), category=category, window=window + ) + return sync_dispatch(self._ctx, spec) + + def list_biggest_winners( + self, + *, + category: str | None = None, + window: LeaderboardWindow | None = None, + page_size: int = 100, + ) -> Paginator[MarketBiggestWinner | ComboBiggestWinner]: + """List winning market and combo positions, ordered by USDC PnL. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.build_list_biggest_winners_spec(category=category, window=window) + return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + + def list_builder_leaderboard( + self, + *, + window: LeaderboardWindow | None = None, + page_size: int = 100, + ) -> Paginator[BuilderStanding]: + """List ranked builders and their trading volume in shares. + + Resuming a cursor uses the page size stored by that cursor.""" + spec = _data_actions.list_builder_leaderboard_spec(window=window) + return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + + def get_builder_volumes( + self, + *, + interval: BuilderVolumeInterval | None = None, + bucket_limit: int | None = None, + ) -> tuple[BuilderVolumePoint, ...]: + """Get builder volume in shares by calendar bucket. + + ``bucket_limit`` counts dates, not rows (default 30, maximum 90). + Interval ``all`` yields calendar-year buckets.""" + spec = _data_actions.get_builder_volumes_spec(interval=interval, bucket_limit=bucket_limit) + return sync_dispatch(self._ctx, spec) + def __init__( self, *, @@ -819,58 +1191,6 @@ def get_comment_thread( _gamma_actions.get_comment_thread_spec(id, get_positions=get_positions), ) - def get_event_live_volumes(self, *, id: str) -> tuple[LiveVolume, ...]: - """Get live volume entries for an event.""" - return sync_dispatch(self._ctx, _data_actions.get_event_live_volumes_spec(id=id)) - - def get_open_interests( - self, *, market: Sequence[str] | None = None - ) -> tuple[OpenInterest, ...]: - """Get open interest values, optionally filtered by market ids.""" - return sync_dispatch(self._ctx, _data_actions.get_open_interests_spec(market=market)) - - def get_market_holders( - self, - *, - market: Sequence[str], - limit: int | None = None, - min_balance: int | None = None, - ) -> tuple[MetaHolder, ...]: - """Get holder balances for one or more markets.""" - return sync_dispatch( - self._ctx, - _data_actions.get_market_holders_spec( - market=market, limit=limit, min_balance=min_balance - ), - ) - - def get_portfolio_values( - self, - *, - user: str | None = None, - market: Sequence[str] | None = None, - ) -> tuple[PortfolioValue, ...]: - """Get portfolio value snapshots for a user or the authenticated wallet.""" - return sync_dispatch( - self._ctx, - _data_actions.get_portfolio_values_spec(user=self._user_or_wallet(user), market=market), - ) - - def get_traded_market_count(self, *, user: str | None = None) -> TradedMarketCount: - """Get the number of markets traded by a user or the authenticated wallet.""" - return sync_dispatch( - self._ctx, - _data_actions.get_traded_market_count_spec(user=self._user_or_wallet(user)), - ) - - def get_builder_volumes( - self, *, time_period: BuilderVolumeTimePeriod | None = None - ) -> tuple[BuilderVolumeEntry, ...]: - """Get builder volume leaderboard entries.""" - return sync_dispatch( - self._ctx, _data_actions.get_builder_volumes_spec(time_period=time_period) - ) - def list_builder_trades( self, *, @@ -904,209 +1224,6 @@ def fetch(cursor: str | None) -> Page[BuilderTrade]: return Paginator(fetch=fetch) - def list_positions( - self, - *, - user: str | None = None, - market: Sequence[str] | None = None, - event_id: Sequence[int] | None = None, - size_threshold: float | None = None, - redeemable: bool | None = None, - mergeable: bool | None = None, - sort_by: PositionSortBy | None = None, - sort_direction: SortDirection | None = None, - title: str | None = None, - page_size: int = 20, - ) -> Paginator[Position]: - """List open positions for a user or the authenticated wallet. - - Returns: - A paginator over matching positions. - """ - spec = _data_actions.list_positions_spec( - user=self._user_or_wallet(user), - market=market, - event_id=event_id, - size_threshold=size_threshold, - redeemable=redeemable, - mergeable=mergeable, - sort_by=sort_by, - sort_direction=sort_direction, - title=title, - ) - return sync_paginate_offset(self._ctx, spec, page_size=page_size) - - def list_closed_positions( - self, - *, - user: str | None = None, - market: Sequence[str] | None = None, - event_id: Sequence[int] | None = None, - title: str | None = None, - sort_by: ClosedPositionSortBy | None = None, - sort_direction: SortDirection | None = None, - page_size: int = 20, - ) -> Paginator[ClosedPosition]: - """List closed positions for a user or the authenticated wallet. - - Returns: - A paginator over matching closed positions. - """ - spec = _data_actions.list_closed_positions_spec( - user=self._user_or_wallet(user), - market=market, - event_id=event_id, - title=title, - sort_by=sort_by, - sort_direction=sort_direction, - ) - return sync_paginate_offset(self._ctx, spec, page_size=page_size) - - def list_combo_positions( - self, - *, - user: str | None = None, - status: ComboPositionStatus | None = None, - sort: ComboPositionSort | None = None, - condition_id: str | Sequence[str] | None = None, - updated_after: int | None = None, - updated_before: int | None = None, - page_size: int = 20, - ) -> Paginator[ComboPosition]: - """List combo positions for a user or the authenticated wallet. - - Returns: - A paginator over matching combo positions. - """ - spec = _data_actions.list_combo_positions_spec( - user=self._user_or_wallet(user), - status=status, - sort=sort, - condition_id=condition_id, - updated_after=updated_after, - updated_before=updated_before, - ) - return sync_paginate_keyset(self._ctx, spec, page_size=page_size) - - def list_market_positions( - self, - *, - market: str, - user: str | None = None, - status: MarketPositionStatus | None = None, - sort_by: MarketPositionSortBy | None = None, - sort_direction: SortDirection | None = None, - page_size: int = 20, - ) -> Paginator[MetaMarketPosition]: - """List positions in a market. - - Returns: - A paginator over matching market positions. - """ - spec = _data_actions.list_market_positions_spec( - market=market, - user=user, - status=status, - sort_by=sort_by, - sort_direction=sort_direction, - ) - return sync_paginate_offset(self._ctx, spec, page_size=page_size) - - def list_trades( - self, - *, - user: str | None = None, - market: Sequence[str] | None = None, - event_id: Sequence[int] | None = None, - side: TradeSide | None = None, - taker_only: bool | None = None, - filter_type: TradeFilterType | None = None, - filter_amount: float | None = None, - start: int | None = None, - end: int | None = None, - page_size: int = 20, - ) -> Paginator[Trade]: - """List trades for a user or the authenticated wallet. - - Returns: - A paginator over matching trades. - """ - spec = _data_actions.list_trades_spec( - user=self._user_or_wallet(user), - market=market, - event_id=event_id, - side=side, - taker_only=taker_only, - filter_type=filter_type, - filter_amount=filter_amount, - start=start, - end=end, - ) - return sync_paginate_offset(self._ctx, spec, page_size=page_size) - - def list_activity( - self, - *, - user: str | None = None, - market: Sequence[str] | None = None, - event_id: Sequence[int] | None = None, - activity_types: Sequence[ActivityTypeFilter] | None = None, - side: TradeSide | None = None, - sort_by: ActivitySortBy | None = None, - sort_direction: SortDirection | None = None, - start: int | None = None, - end: int | None = None, - page_size: int = 20, - ) -> Paginator[Activity]: - """List activity for a user or the authenticated wallet. - - Returns: - A paginator over matching activity entries. - """ - spec = _data_actions.list_activity_spec( - user=self._user_or_wallet(user), - market=market, - event_id=event_id, - activity_types=activity_types, - side=side, - sort_by=sort_by, - sort_direction=sort_direction, - start=start, - end=end, - ) - return sync_paginate_offset(self._ctx, spec, page_size=page_size) - - def list_combo_activity( - self, - *, - user: str | None = None, - condition_id: str | Sequence[str] | None = None, - page_size: int = 50, - ) -> Paginator[ComboActivity]: - """List combo lifecycle activity for a user or the authenticated wallet. - - Returns: - A paginator over matching combo lifecycle activity entries. - """ - spec = _data_actions.list_combo_activity_spec( - user=self._user_or_wallet(user), condition_id=condition_id - ) - return sync_paginate_keyset(self._ctx, spec, page_size=page_size) - - def list_builder_leaderboard( - self, - *, - time_period: LeaderboardTimePeriod | None = None, - page_size: int = 20, - ) -> Paginator[LeaderboardEntry]: - """List builder leaderboard entries. - - Returns: - A paginator over leaderboard rows. - """ - spec = _data_actions.list_builder_leaderboard_spec(time_period=time_period) - return sync_paginate_offset(self._ctx, spec, page_size=page_size) - def download_accounting_snapshot(self, *, user: str | None = None) -> bytes: """Download the accounting snapshot archive for a user or the authenticated wallet.""" path, params = _data_actions.build_accounting_snapshot_request( @@ -1114,30 +1231,6 @@ def download_accounting_snapshot(self, *, user: str | None = None) -> bytes: ) return self._ctx.data.get_bytes(path, params=params) - def list_trader_leaderboard( - self, - *, - category: LeaderboardCategory | None = None, - time_period: LeaderboardTimePeriod | None = None, - order_by: LeaderboardOrderBy | None = None, - user: str | None = None, - user_name: str | None = None, - page_size: int = 20, - ) -> Paginator[TraderLeaderboardEntry]: - """List trader leaderboard entries. - - Returns: - A paginator over leaderboard rows. - """ - spec = _data_actions.list_trader_leaderboard_spec( - category=category, - time_period=time_period, - order_by=order_by, - user=user, - user_name=user_name, - ) - return sync_paginate_offset(self._ctx, spec, page_size=page_size) - def list_events( self, *, @@ -1589,27 +1682,6 @@ def get_last_trade_prices( ) return _clob_actions.parse_last_trade_prices(self._ctx.clob.post_json(path, json=body)) - def get_price_history( - self, - *, - asset_id: str | None = None, - token_id: str | None = None, - start_ts: int | None = None, - end_ts: int | None = None, - fidelity: int | None = None, - interval: PriceHistoryInterval | None = None, - ) -> tuple[PriceHistoryPoint, ...]: - """Get historical price points for a CLOB asset.""" - path, params = _clob_actions.build_price_history_request( - asset_id=asset_id, - token_id=token_id, - start_ts=start_ts, - end_ts=end_ts, - fidelity=fidelity, - interval=interval, - ) - return _clob_actions.parse_price_history(self._ctx.clob.get_json(path, params=params)) - @overload def estimate_market_price( self, diff --git a/src/polymarket/frames/_arrow.py b/src/polymarket/frames/_arrow.py index 2f27bd62..2f258f32 100644 --- a/src/polymarket/frames/_arrow.py +++ b/src/polymarket/frames/_arrow.py @@ -175,6 +175,9 @@ def _infer_struct_type(struct_rows: Sequence[dict[str, Any]]) -> pa.DataType: def _infer_scalar_column_type(non_null_values: Sequence[object]) -> pa.DataType: pa = _require_pyarrow() + # Infer the values written to Arrow, so enums can share a column with their primitives. + non_null_values = [_serialize_value(v) if isinstance(v, Enum) else v for v in non_null_values] + # bool subclasses int; treat them as distinct categories. types_seen = {type(v) for v in non_null_values} diff --git a/src/polymarket/models/__init__.py b/src/polymarket/models/__init__.py index 6cb478fa..d19f0048 100644 --- a/src/polymarket/models/__init__.py +++ b/src/polymarket/models/__init__.py @@ -45,8 +45,6 @@ OrderResponse, OrderResponseErrorCode, OrderType, - PriceHistoryInterval, - PriceHistoryPoint, PriceRequest, RejectedOrder, RelayerTransactionState, @@ -75,11 +73,14 @@ from polymarket.models.data import ( Activity, ActivityType, - BuilderVolumeEntry, - BuilderVolumeTimePeriod, - ClosedPosition, + ActivityTypeFilter, + BiggestWinnerKind, + BuilderStanding, + BuilderVolumeInterval, + BuilderVolumePoint, ComboActivity, ComboActivityType, + ComboBiggestWinner, ComboCompressActivity, ComboConvertActivity, ComboMergeActivity, @@ -87,8 +88,9 @@ ComboPositionLeg, ComboPositionMarket, ComboPositionMarketEvent, - ComboPositionOutcome, + ComboPositionSortBy, ComboPositionStatus, + ComboPositionStatusFilter, ComboRedeemActivity, ComboSplitActivity, ComboTradeActivity, @@ -97,30 +99,51 @@ ConversionActivity, DepositActivity, Holder, - LeaderboardCategory, - LeaderboardEntry, - LeaderboardOrderBy, - LeaderboardTimePeriod, + LeaderboardWindow, LiveVolume, MakerRebateActivity, - MarketPosition, - MarketVolume, + MarketBiggestWinner, + MarketLiveVolume, MergeActivity, MetaHolder, - MetaMarketPosition, + MigrationActivity, OpenInterest, PortfolioValue, Position, + PositionFilterType, + PositionSortBy, + PositionStatus, + PositionStatusFilter, + PriceHistoryInterval, + PriceHistoryPoint, RedeemActivity, ReferralRewardActivity, + Resolution, + ResolutionMarketType, + ResolutionReporter, + ResolutionSource, + ResolutionStatus, RewardActivity, + SortDirection, SplitActivity, TakerRebateActivity, + TipActivity, + TipSide, Trade, TradeActivity, - TradedMarketCount, + TradeFilterType, TraderLeaderboardEntry, + TraderLeaderboardSort, + TraderLeaderboardStanding, UnknownActivity, + UserPnlFidelity, + UserPnlFidelityInput, + UserPnlInterval, + UserPnlIntervalInput, + UserPnlPoint, + UserPnlSeries, + UserStats, + UserVolume, WithdrawalActivity, YieldActivity, ) @@ -242,12 +265,86 @@ to_combo_condition_id, to_condition_id, to_ctf_condition_id, + to_market_condition_id, ) __all__ = [ - "AcceptedOrder", "Activity", "ActivityType", + "ActivityTypeFilter", + "BiggestWinnerKind", + "BuilderStanding", + "BuilderVolumeInterval", + "BuilderVolumePoint", + "ComboActivity", + "ComboActivityType", + "ComboBiggestWinner", + "ComboCompressActivity", + "ComboConvertActivity", + "ComboMergeActivity", + "ComboPosition", + "ComboPositionLeg", + "ComboPositionMarket", + "ComboPositionMarketEvent", + "ComboPositionSortBy", + "ComboPositionStatus", + "ComboPositionStatusFilter", + "ComboRedeemActivity", + "ComboSplitActivity", + "ComboTradeActivity", + "ComboUnwrapActivity", + "ComboWrapActivity", + "ConversionActivity", + "DepositActivity", + "Holder", + "LeaderboardWindow", + "LiveVolume", + "MakerRebateActivity", + "MarketBiggestWinner", + "MarketLiveVolume", + "MergeActivity", + "MetaHolder", + "MigrationActivity", + "OpenInterest", + "PortfolioValue", + "Position", + "PositionFilterType", + "PositionSortBy", + "PositionStatus", + "PositionStatusFilter", + "PriceHistoryInterval", + "PriceHistoryPoint", + "RedeemActivity", + "ReferralRewardActivity", + "Resolution", + "ResolutionMarketType", + "ResolutionReporter", + "ResolutionSource", + "ResolutionStatus", + "RewardActivity", + "SortDirection", + "SplitActivity", + "TakerRebateActivity", + "TipActivity", + "TipSide", + "Trade", + "TradeActivity", + "TradeFilterType", + "TraderLeaderboardEntry", + "TraderLeaderboardSort", + "TraderLeaderboardStanding", + "UnknownActivity", + "UserPnlFidelity", + "UserPnlFidelityInput", + "UserPnlInterval", + "UserPnlIntervalInput", + "UserPnlPoint", + "UserPnlSeries", + "UserStats", + "UserVolume", + "WithdrawalActivity", + "YieldActivity", + "AcceptedOrder", "ApiKey", "ApiKeyCreds", "AssetType", @@ -299,8 +396,6 @@ "OrderResponseErrorCode", "OrderSide", "OrderType", - "PriceHistoryInterval", - "PriceHistoryPoint", "PriceRequest", "RejectedOrder", "RelayerTransactionState", @@ -316,34 +411,15 @@ "UserRewardsEarning", "YieldPayoutNotification", "YieldPayoutNotificationPayload", - "BuilderVolumeEntry", - "BuilderVolumeTimePeriod", - "ClosedPosition", "CollateralReturnOperation", "CollateralReturnOperationKind", "CollateralReturnPlanResponse", "CollateralReturnPositionAmount", "CollateralReturnPositionSummary", "CollateralReturnRouterCall", - "ComboActivity", "ComboActivityId", - "ComboActivityType", - "ComboCompressActivity", - "ComboConvertActivity", - "ComboMergeActivity", - "ComboPosition", - "ComboPositionLeg", - "ComboPositionMarket", - "ComboPositionMarketEvent", - "ComboPositionOutcome", - "ComboPositionStatus", "ComboKnownStatus", "ComboStatus", - "ComboRedeemActivity", - "ComboSplitActivity", - "ComboTradeActivity", - "ComboUnwrapActivity", - "ComboWrapActivity", "Comment", "CommentId", "ComboConditionId", @@ -352,26 +428,11 @@ "ComboMarketOutcomes", "ConditionId", "CtfConditionId", - "ConversionActivity", - "DepositActivity", "Event", "EventId", - "Holder", - "LeaderboardCategory", - "LeaderboardEntry", - "LeaderboardOrderBy", - "LeaderboardTimePeriod", - "LiveVolume", - "MakerRebateActivity", "Market", "MarketId", - "MarketPosition", - "MarketVolume", - "MergeActivity", - "MetaHolder", - "MetaMarketPosition", "MissingTradingApprovals", - "OpenInterest", "OrderId", "PerpsAccountConfig", "PerpsAccountStats", @@ -439,41 +500,28 @@ "PerpsWithdrawalId", "PerpsWithdrawalStatus", "PerpsWithdrawalUpdate", - "PortfolioValue", "PositionId", - "Position", "Profile", "ProtocolVersion", "PublicProfile", "QuestionId", - "RedeemActivity", - "ReferralRewardActivity", "RelatedTag", "ResolutionRequestId", - "RewardActivity", "SearchResults", "SearchTag", "Series", "SeriesId", - "SplitActivity", "SportsMarketTypes", "SportsMetadata", "Tag", "TagId", "TagReference", - "TakerRebateActivity", "Team", "TeamOrdering", "TokenId", "to_combo_condition_id", "to_condition_id", + "to_market_condition_id", "to_ctf_condition_id", - "Trade", - "TradeActivity", - "TradedMarketCount", - "TraderLeaderboardEntry", "TradingApprovalsState", - "UnknownActivity", - "WithdrawalActivity", - "YieldActivity", ] diff --git a/src/polymarket/models/clob/__init__.py b/src/polymarket/models/clob/__init__.py index 9d75aa5e..bcb1ae6b 100644 --- a/src/polymarket/models/clob/__init__.py +++ b/src/polymarket/models/clob/__init__.py @@ -47,7 +47,6 @@ RejectedOrder, ) from polymarket.models.clob.orders import MarketOrderType, OrderType, SignedOrder, TickSize -from polymarket.models.clob.price_history import PriceHistoryInterval, PriceHistoryPoint from polymarket.models.clob.relayer import ( GaslessTransaction, RelayerTransactionState, @@ -116,8 +115,6 @@ "OrderResponse", "OrderResponseErrorCode", "OrderType", - "PriceHistoryInterval", - "PriceHistoryPoint", "PriceRequest", "RejectedOrder", "RelayerTransactionState", diff --git a/src/polymarket/models/clob/price_history.py b/src/polymarket/models/clob/price_history.py deleted file mode 100644 index 54639b65..00000000 --- a/src/polymarket/models/clob/price_history.py +++ /dev/null @@ -1,28 +0,0 @@ -from __future__ import annotations - -from datetime import UTC, datetime -from typing import Literal, TypeAlias - -from pydantic import Field - -from polymarket.models.base import BaseModel - -PriceHistoryInterval: TypeAlias = Literal["max", "1w", "1d", "6h", "1h"] - - -class PriceHistoryPoint(BaseModel): - t: int = Field(strict=True) - p: float = Field(strict=True) - - def _repr_html_(self) -> str: - from polymarket._jupyter import card, safe_html_repr - - @safe_html_repr - def render(self: PriceHistoryPoint) -> str: - ts = datetime.fromtimestamp(self.t, tz=UTC).isoformat() - return card("PriceHistoryPoint", rows=[("t", ts), ("p", str(self.p))]) - - return render(self) - - -__all__ = ["PriceHistoryInterval", "PriceHistoryPoint"] diff --git a/src/polymarket/models/data/__init__.py b/src/polymarket/models/data/__init__.py index 53ec8502..289ac608 100644 --- a/src/polymarket/models/data/__init__.py +++ b/src/polymarket/models/data/__init__.py @@ -1,8 +1,6 @@ from polymarket.models.data.activity import ( Activity, - ActivityType, ComboActivity, - ComboActivityType, ComboCompressActivity, ComboConvertActivity, ComboMergeActivity, @@ -15,11 +13,13 @@ DepositActivity, MakerRebateActivity, MergeActivity, + MigrationActivity, RedeemActivity, ReferralRewardActivity, RewardActivity, SplitActivity, TakerRebateActivity, + TipActivity, Trade, TradeActivity, UnknownActivity, @@ -29,41 +29,74 @@ from polymarket.models.data.analytics import ( Holder, LiveVolume, - MarketVolume, + MarketLiveVolume, MetaHolder, OpenInterest, + PriceHistoryPoint, +) +from polymarket.models.data.common import ( + ActivityType, + ActivityTypeFilter, + BiggestWinnerKind, + BuilderVolumeInterval, + ComboActivityType, + ComboPositionSortBy, + ComboPositionStatus, + ComboPositionStatusFilter, + LeaderboardWindow, + PositionFilterType, + PositionSortBy, + PositionStatus, + PositionStatusFilter, + PriceHistoryInterval, + ResolutionMarketType, + ResolutionReporter, + ResolutionSource, + ResolutionStatus, + SortDirection, + TipSide, + TradeFilterType, + TraderLeaderboardSort, + UserPnlFidelity, + UserPnlFidelityInput, + UserPnlInterval, + UserPnlIntervalInput, ) from polymarket.models.data.leaderboard import ( - BuilderVolumeEntry, - BuilderVolumeTimePeriod, - LeaderboardCategory, - LeaderboardEntry, - LeaderboardOrderBy, - LeaderboardTimePeriod, + BuilderStanding, + BuilderVolumePoint, + ComboBiggestWinner, + MarketBiggestWinner, TraderLeaderboardEntry, + TraderLeaderboardStanding, ) -from polymarket.models.data.market_positions import MarketPosition, MetaMarketPosition from polymarket.models.data.portfolio import ( - ClosedPosition, ComboPosition, ComboPositionLeg, ComboPositionMarket, ComboPositionMarketEvent, - ComboPositionOutcome, - ComboPositionStatus, PortfolioValue, Position, - TradedMarketCount, + UserPnlPoint, + UserPnlSeries, + UserStats, + UserVolume, +) +from polymarket.models.data.resolutions import ( + Resolution, ) __all__ = [ "Activity", "ActivityType", - "BuilderVolumeEntry", - "BuilderVolumeTimePeriod", - "ClosedPosition", + "ActivityTypeFilter", + "BiggestWinnerKind", + "BuilderStanding", + "BuilderVolumeInterval", + "BuilderVolumePoint", "ComboActivity", "ComboActivityType", + "ComboBiggestWinner", "ComboCompressActivity", "ComboConvertActivity", "ComboMergeActivity", @@ -71,8 +104,9 @@ "ComboPositionLeg", "ComboPositionMarket", "ComboPositionMarketEvent", - "ComboPositionOutcome", + "ComboPositionSortBy", "ComboPositionStatus", + "ComboPositionStatusFilter", "ComboRedeemActivity", "ComboSplitActivity", "ComboTradeActivity", @@ -81,30 +115,51 @@ "ConversionActivity", "DepositActivity", "Holder", - "LeaderboardCategory", - "LeaderboardEntry", - "LeaderboardOrderBy", - "LeaderboardTimePeriod", + "LeaderboardWindow", "LiveVolume", "MakerRebateActivity", - "MarketPosition", - "MarketVolume", + "MarketBiggestWinner", + "MarketLiveVolume", "MergeActivity", "MetaHolder", - "MetaMarketPosition", + "MigrationActivity", "OpenInterest", "PortfolioValue", "Position", + "PositionFilterType", + "PositionSortBy", + "PositionStatus", + "PositionStatusFilter", + "PriceHistoryInterval", + "PriceHistoryPoint", "RedeemActivity", "ReferralRewardActivity", + "Resolution", + "ResolutionMarketType", + "ResolutionReporter", + "ResolutionSource", + "ResolutionStatus", "RewardActivity", + "SortDirection", "SplitActivity", "TakerRebateActivity", + "TipActivity", + "TipSide", "Trade", "TradeActivity", - "TradedMarketCount", + "TradeFilterType", "TraderLeaderboardEntry", + "TraderLeaderboardSort", + "TraderLeaderboardStanding", "UnknownActivity", + "UserPnlFidelity", + "UserPnlFidelityInput", + "UserPnlInterval", + "UserPnlIntervalInput", + "UserPnlPoint", + "UserPnlSeries", + "UserStats", + "UserVolume", "WithdrawalActivity", "YieldActivity", ] diff --git a/src/polymarket/models/data/activity.py b/src/polymarket/models/data/activity.py index 2e97b82d..f3610384 100644 --- a/src/polymarket/models/data/activity.py +++ b/src/polymarket/models/data/activity.py @@ -4,117 +4,105 @@ from decimal import Decimal from typing import Any, Literal, cast -from pydantic import AliasChoices, Field, computed_field, field_validator +from pydantic import Field, computed_field, field_validator from polymarket.errors import UnexpectedResponseError from polymarket.models.base import BaseModel -from polymarket.models.data.portfolio import ComboPositionLeg -from polymarket.models.gamma.common import ( - empty_string_to_none, - parse_epoch_seconds_optional, - parse_optional_decimal, +from polymarket.models.data.common import ( + ActivityType, + ComboActivityType, + TipSide, + datetime_from_epoch_seconds, + decimal_from_number, + optional_datetime_from_epoch_seconds, + optional_decimal_from_number, + optional_outcome_index, + optional_text, ) +from polymarket.models.data.portfolio import ComboPositionLeg from polymarket.models.types import ( ClobAssetId, ComboActivityId, ComboConditionId, ConditionId, + OrderSide, PositionId, validate_combo_condition_id, - validate_condition_id, - validate_optional_condition_id, + validate_condition_id_response, ) from polymarket.types import EvmAddress, TransactionHash class Trade(BaseModel): - wallet: EvmAddress | None = Field(default=None, validation_alias="proxyWallet") - asset_id: ClobAssetId | None = Field( - default=None, - validation_alias=AliasChoices("asset_id", "asset", "token_id"), - ) - condition_id: ConditionId | None = Field(default=None, validation_alias="conditionId") - side: Literal["BUY", "SELL"] | None = None - size: Decimal | None = None - price: Decimal | None = None - timestamp: datetime | None = None + """An executed trade; size is shares and price is USDC per share.""" + + wallet: EvmAddress = Field(validation_alias="proxy_wallet") + asset_id: ClobAssetId = Field(validation_alias="token_id") + condition_id: ConditionId + side: OrderSide + size: Decimal + price: Decimal + timestamp: datetime + transaction_hash: TransactionHash title: str | None = None slug: str | None = None icon: str | None = None - event_slug: str | None = Field(default=None, validation_alias="eventSlug") + event_slug: str | None = None outcome: str | None = None - outcome_index: int | None = Field(default=None, validation_alias="outcomeIndex") + outcome_index: int | None = None name: str | None = None pseudonym: str | None = None bio: str | None = None - profile_image: str | None = Field(default=None, validation_alias="profileImage") - profile_image_optimized: str | None = Field( - default=None, validation_alias="profileImageOptimized" - ) - transaction_hash: TransactionHash | None = Field( - default=None, validation_alias="transactionHash" - ) + profile_image: str | None = None + profile_image_optimized: str | None = None + + _decimal = field_validator("size", "price", mode="before")(decimal_from_number) + _timestamp = field_validator("timestamp", mode="before")(datetime_from_epoch_seconds) + _condition = field_validator("condition_id", mode="before")(validate_condition_id_response) + _text = field_validator( + "title", + "slug", + "icon", + "event_slug", + "outcome", + "name", + "pseudonym", + "bio", + "profile_image", + "profile_image_optimized", + mode="before", + )(optional_text) + _index = field_validator("outcome_index", mode="before")(optional_outcome_index) @computed_field @property - def token_id(self) -> ClobAssetId | None: + def token_id(self) -> ClobAssetId: """Deprecated alias for :attr:`asset_id`.""" - return self.asset_id - @field_validator("condition_id", mode="before") - @classmethod - def _validate_condition_id(cls, value: object) -> ConditionId | None: - return validate_optional_condition_id(value) - - @field_validator("size", "price", mode="before") - @classmethod - def _parse_decimal(cls, value: object) -> Decimal | None: - return parse_optional_decimal(value) - - @field_validator("timestamp", mode="before") - @classmethod - def _parse_timestamp(cls, value: object) -> datetime | None: - return parse_epoch_seconds_optional(value) - @field_validator("icon", mode="before") - @classmethod - def _normalize_icon(cls, value: object) -> object | None: - return empty_string_to_none(value) - - -ActivityType = Literal[ - "TRADE", - "SPLIT", - "MERGE", - "REDEEM", - "REWARD", - "CONVERSION", - "DEPOSIT", - "WITHDRAWAL", - "MAKER_REBATE", - "TAKER_REBATE", - "REFERRAL_REWARD", - "YIELD", -] +class _KnownActivityBase(BaseModel): + """Wallet activity with amounts in USDC and shares in outcome units. + Prices are USDC per share. + """ -class _KnownActivityBase(BaseModel): - wallet: EvmAddress = Field(validation_alias="proxyWallet") + wallet: EvmAddress = Field(validation_alias="proxy_wallet") timestamp: datetime - transaction_hash: TransactionHash = Field(validation_alias="transactionHash") + transaction_hash: TransactionHash = Field(validation_alias="transaction_hash") name: str | None = None pseudonym: str | None = None bio: str | None = None - profile_image: str | None = Field(default=None, validation_alias="profileImage") + profile_image: str | None = Field(default=None, validation_alias="profile_image") profile_image_optimized: str | None = Field( - default=None, validation_alias="profileImageOptimized" + default=None, validation_alias="profile_image_optimized" ) - @field_validator("timestamp", mode="before") - @classmethod - def _parse_timestamp(cls, value: object) -> datetime | None: - return parse_epoch_seconds_optional(value) + _profile_text = field_validator( + "name", "pseudonym", "bio", "profile_image", "profile_image_optimized", mode="before" + )(optional_text) + + _timestamp = field_validator("timestamp", mode="before")(datetime_from_epoch_seconds) def _repr_html_(self) -> str: from polymarket._jupyter import card, safe_html_repr, truncate_mid @@ -137,20 +125,25 @@ def render(self: _KnownActivityBase) -> str: class TradeActivity(_KnownActivityBase): - type: Literal["TRADE"] - is_combo: Literal[False] = Field(default=False, validation_alias="isCombo") - condition_id: ConditionId = Field(validation_alias="conditionId") - asset_id: ClobAssetId = Field(validation_alias=AliasChoices("asset_id", "asset", "token_id")) - side: Literal["BUY", "SELL"] + type: Literal[ActivityType.TRADE] + is_combo: Literal[False] = Field(default=False, validation_alias="is_combo") + condition_id: ConditionId = Field(validation_alias="condition_id") + asset_id: ClobAssetId = Field(validation_alias="token_id") + side: OrderSide shares: Decimal = Field(validation_alias="size") - amount: Decimal + amount: Decimal = Field(validation_alias="usdc_size") price: Decimal - outcome: str - outcome_index: int = Field(validation_alias="outcomeIndex") - title: str - slug: str + outcome: str | None = None + outcome_index: int | None = None + title: str | None = None + slug: str | None = None icon: str | None = None - event_slug: str = Field(validation_alias="eventSlug") + event_slug: str | None = None + + _display_text = field_validator( + "title", "slug", "icon", "event_slug", "outcome", mode="before" + )(optional_text) + _outcome_index = field_validator("outcome_index", mode="before")(optional_outcome_index) @computed_field @property @@ -162,26 +155,28 @@ def token_id(self) -> ClobAssetId: @field_validator("condition_id", mode="before") @classmethod def _validate_condition_id(cls, value: object) -> ConditionId: - return validate_condition_id(value) + return validate_condition_id_response(value) @field_validator("shares", "amount", "price", mode="before") @classmethod def _parse_decimal(cls, value: object) -> Decimal | None: - return parse_optional_decimal(value) + return optional_decimal_from_number(value) class ComboTradeActivity(_KnownActivityBase): - type: Literal["TRADE"] - is_combo: Literal[True] = Field(validation_alias="isCombo") - condition_id: ComboConditionId = Field(validation_alias="conditionId") - position_id: PositionId = Field(validation_alias="asset") - side: Literal["BUY", "SELL"] + type: Literal[ActivityType.TRADE] + is_combo: Literal[True] = Field(validation_alias="is_combo") + condition_id: ComboConditionId = Field(validation_alias="condition_id") + position_id: PositionId = Field(validation_alias="token_id") + side: OrderSide shares: Decimal = Field(validation_alias="size") - amount: Decimal + amount: Decimal = Field(validation_alias="usdc_size") price: Decimal - title: str + title: str | None = None icon: str | None = None + _display_text = field_validator("title", "icon", mode="before")(optional_text) + @field_validator("condition_id", mode="before") @classmethod def _validate_condition_id(cls, value: object) -> ComboConditionId: @@ -190,101 +185,120 @@ def _validate_condition_id(cls, value: object) -> ComboConditionId: @field_validator("shares", "amount", "price", mode="before") @classmethod def _parse_decimal(cls, value: object) -> Decimal | None: - return parse_optional_decimal(value) + return optional_decimal_from_number(value) class _MarketEventActivity(_KnownActivityBase): - condition_id: ConditionId = Field(validation_alias="conditionId") - amount: Decimal - title: str - slug: str + condition_id: ConditionId = Field(validation_alias="condition_id") + amount: Decimal = Field(validation_alias="usdc_size") + title: str | None = None + slug: str | None = None icon: str | None = None - event_slug: str = Field(validation_alias="eventSlug") + event_slug: str | None = None + + _display_text = field_validator("title", "slug", "icon", "event_slug", mode="before")( + optional_text + ) @field_validator("condition_id", mode="before") @classmethod def _validate_condition_id(cls, value: object) -> ConditionId: - return validate_condition_id(value) + return validate_condition_id_response(value) @field_validator("amount", mode="before") @classmethod def _parse_decimal(cls, value: object) -> Decimal | None: - return parse_optional_decimal(value) + return optional_decimal_from_number(value) class SplitActivity(_MarketEventActivity): - type: Literal["SPLIT"] + type: Literal[ActivityType.SPLIT] class MergeActivity(_MarketEventActivity): - type: Literal["MERGE"] + type: Literal[ActivityType.MERGE] class RedeemActivity(_MarketEventActivity): - type: Literal["REDEEM"] + type: Literal[ActivityType.REDEEM] class ConversionActivity(_MarketEventActivity): - type: Literal["CONVERSION"] + type: Literal[ActivityType.CONVERSION] class _AccountCreditActivity(_KnownActivityBase): - amount: Decimal + amount: Decimal = Field(validation_alias="usdc_size") @field_validator("amount", mode="before") @classmethod def _parse_decimal(cls, value: object) -> Decimal | None: - return parse_optional_decimal(value) + return optional_decimal_from_number(value) class RewardActivity(_AccountCreditActivity): - type: Literal["REWARD"] + type: Literal[ActivityType.REWARD] class DepositActivity(_AccountCreditActivity): - type: Literal["DEPOSIT"] + type: Literal[ActivityType.DEPOSIT] class WithdrawalActivity(_AccountCreditActivity): - type: Literal["WITHDRAWAL"] + type: Literal[ActivityType.WITHDRAWAL] class MakerRebateActivity(_AccountCreditActivity): - type: Literal["MAKER_REBATE"] + type: Literal[ActivityType.MAKER_REBATE] class TakerRebateActivity(_AccountCreditActivity): - type: Literal["TAKER_REBATE"] + type: Literal[ActivityType.TAKER_REBATE] class ReferralRewardActivity(_AccountCreditActivity): - type: Literal["REFERRAL_REWARD"] + type: Literal[ActivityType.REFERRAL_REWARD] + + +class MigrationActivity(_AccountCreditActivity): + type: Literal[ActivityType.MIGRATION] + + +class TipActivity(_AccountCreditActivity): + type: Literal[ActivityType.TIP] + side: TipSide | None = None + + _side = field_validator("side", mode="before")(optional_text) class YieldActivity(_AccountCreditActivity): - type: Literal["YIELD"] + type: Literal[ActivityType.YIELD] class UnknownActivity(BaseModel): type: str - wallet: EvmAddress | None = Field(default=None, validation_alias="proxyWallet") + wallet: EvmAddress | None = Field(default=None, validation_alias="proxy_wallet") timestamp: datetime | None = None transaction_hash: TransactionHash | None = Field( - default=None, validation_alias="transactionHash" + default=None, validation_alias="transaction_hash" ) name: str | None = None pseudonym: str | None = None bio: str | None = None - profile_image: str | None = Field(default=None, validation_alias="profileImage") + profile_image: str | None = Field(default=None, validation_alias="profile_image") profile_image_optimized: str | None = Field( - default=None, validation_alias="profileImageOptimized" + default=None, validation_alias="profile_image_optimized" ) raw: dict[str, Any] = Field(default_factory=dict) + _profile_text = field_validator( + "name", "pseudonym", "bio", "profile_image", "profile_image_optimized", mode="before" + )(optional_text) + @field_validator("timestamp", mode="before") @classmethod def _parse_timestamp(cls, value: object) -> datetime | None: - return parse_epoch_seconds_optional(value) + return optional_datetime_from_epoch_seconds(value) def _repr_html_(self) -> str: from polymarket._jupyter import card, safe_html_repr, truncate_mid @@ -317,22 +331,20 @@ def render(self: UnknownActivity) -> str: | TakerRebateActivity | ReferralRewardActivity | YieldActivity + | MigrationActivity + | TipActivity | UnknownActivity ) -ComboActivityType = Literal["SPLIT", "MERGE", "CONVERT", "COMPRESS", "WRAP", "UNWRAP", "REDEEM"] - class _ComboActivityBase(BaseModel): id: ComboActivityId - wallet: EvmAddress = Field(validation_alias="user_address") + wallet: EvmAddress = Field(validation_alias="proxy_wallet") condition_id: ComboConditionId = Field(validation_alias="combo_condition_id") - module_id: int + position_id: PositionId = Field(validation_alias="combo_position_id") amount: Decimal | None = Field(default=None, validation_alias="amount_usdc") timestamp: datetime - transaction_at: datetime = Field(validation_alias="tx_dttm") - transaction_hash: TransactionHash = Field(validation_alias="tx_hash") - log_index: int + transaction_hash: TransactionHash = Field(validation_alias="transaction_hash") block_number: int legs: tuple[ComboPositionLeg, ...] @@ -344,47 +356,43 @@ def _validate_condition_id(cls, value: object) -> ComboConditionId: @field_validator("amount", mode="before") @classmethod def _parse_decimal(cls, value: object) -> Decimal | None: - return parse_optional_decimal(value) + return optional_decimal_from_number(value) - @field_validator("timestamp", mode="before") - @classmethod - def _parse_timestamp(cls, value: object) -> datetime | None: - return parse_epoch_seconds_optional(value) + _timestamp = field_validator("timestamp", mode="before")(datetime_from_epoch_seconds) class ComboSplitActivity(_ComboActivityBase): - type: Literal["SPLIT"] + type: Literal[ComboActivityType.SPLIT] class ComboMergeActivity(_ComboActivityBase): - type: Literal["MERGE"] + type: Literal[ComboActivityType.MERGE] class ComboConvertActivity(_ComboActivityBase): - type: Literal["CONVERT"] + type: Literal[ComboActivityType.CONVERT] class ComboCompressActivity(_ComboActivityBase): - type: Literal["COMPRESS"] + type: Literal[ComboActivityType.COMPRESS] class ComboWrapActivity(_ComboActivityBase): - type: Literal["WRAP"] + type: Literal[ComboActivityType.WRAP] class ComboUnwrapActivity(_ComboActivityBase): - type: Literal["UNWRAP"] + type: Literal[ComboActivityType.UNWRAP] class ComboRedeemActivity(_ComboActivityBase): - type: Literal["REDEEM"] - position_id: PositionId = Field(validation_alias="combo_position_id") + type: Literal[ComboActivityType.REDEEM] payout: Decimal | None = Field(default=None, validation_alias="payout_usdc") @field_validator("payout", mode="before") @classmethod def _parse_payout(cls, value: object) -> Decimal | None: - return parse_optional_decimal(value) + return optional_decimal_from_number(value) ComboActivity = ( @@ -399,37 +407,39 @@ def _parse_payout(cls, value: object) -> Decimal | None: _KNOWN_ACTIVITY_TYPES: dict[str, type[_KnownActivityBase]] = { - "TRADE": TradeActivity, - "SPLIT": SplitActivity, - "MERGE": MergeActivity, - "REDEEM": RedeemActivity, - "CONVERSION": ConversionActivity, - "REWARD": RewardActivity, - "DEPOSIT": DepositActivity, - "WITHDRAWAL": WithdrawalActivity, - "MAKER_REBATE": MakerRebateActivity, - "TAKER_REBATE": TakerRebateActivity, - "REFERRAL_REWARD": ReferralRewardActivity, - "YIELD": YieldActivity, + ActivityType.TRADE: TradeActivity, + ActivityType.SPLIT: SplitActivity, + ActivityType.MERGE: MergeActivity, + ActivityType.REDEEM: RedeemActivity, + ActivityType.CONVERSION: ConversionActivity, + ActivityType.REWARD: RewardActivity, + ActivityType.DEPOSIT: DepositActivity, + ActivityType.WITHDRAWAL: WithdrawalActivity, + ActivityType.MAKER_REBATE: MakerRebateActivity, + ActivityType.TAKER_REBATE: TakerRebateActivity, + ActivityType.REFERRAL_REWARD: ReferralRewardActivity, + ActivityType.YIELD: YieldActivity, + ActivityType.MIGRATION: MigrationActivity, + ActivityType.TIP: TipActivity, } -_COMBO_ACTIVITY_TYPES: dict[ComboActivityType, type[_ComboActivityBase]] = { - "SPLIT": ComboSplitActivity, - "MERGE": ComboMergeActivity, - "CONVERT": ComboConvertActivity, - "COMPRESS": ComboCompressActivity, - "WRAP": ComboWrapActivity, - "UNWRAP": ComboUnwrapActivity, - "REDEEM": ComboRedeemActivity, +_COMBO_ACTIVITY_TYPES: dict[str, type[_ComboActivityBase]] = { + ComboActivityType.SPLIT: ComboSplitActivity, + ComboActivityType.MERGE: ComboMergeActivity, + ComboActivityType.CONVERT: ComboConvertActivity, + ComboActivityType.COMPRESS: ComboCompressActivity, + ComboActivityType.WRAP: ComboWrapActivity, + ComboActivityType.UNWRAP: ComboUnwrapActivity, + ComboActivityType.REDEEM: ComboRedeemActivity, } def parse_activity(payload: object) -> Activity: if not isinstance(payload, dict): raise UnexpectedResponseError("Activity payload must be an object.") - data = _normalize_activity_payload(cast(dict[str, Any], payload)) + data = dict(cast(dict[str, Any], payload)) activity_type = data.get("type") - if activity_type == "TRADE" and data.get("isCombo") is True: + if activity_type == "TRADE" and data.get("is_combo") is True: return ComboTradeActivity.parse_response(data) if isinstance(activity_type, str) and activity_type in _KNOWN_ACTIVITY_TYPES: cls = _KNOWN_ACTIVITY_TYPES[activity_type] @@ -461,31 +471,9 @@ def parse_combo_activities(payload: object) -> tuple[ComboActivity, ...]: return tuple(parse_combo_activity(item) for item in cast(list[object], payload)) -def _normalize_activity_payload(data: dict[str, Any]) -> dict[str, Any]: - normalized = dict(data) - - if normalized.get("outcomeIndex") == 999: - normalized.pop("outcomeIndex", None) - - for sentinel_key in ("conditionId", "asset", "side", "outcome", "icon"): - if normalized.get(sentinel_key) == "": - normalized.pop(sentinel_key, None) - - if "amount" not in normalized: - amount = normalized.get("usdcSize") - if amount is None: - amount = normalized.get("size") - if amount is not None: - normalized["amount"] = amount - - return normalized - - __all__ = [ "Activity", - "ActivityType", "ComboActivity", - "ComboActivityType", "ComboCompressActivity", "ComboConvertActivity", "ComboMergeActivity", @@ -508,6 +496,8 @@ def _normalize_activity_payload(data: dict[str, Any]) -> dict[str, Any]: "UnknownActivity", "WithdrawalActivity", "YieldActivity", + "MigrationActivity", + "TipActivity", "parse_activities", "parse_activity", "parse_combo_activities", diff --git a/src/polymarket/models/data/analytics.py b/src/polymarket/models/data/analytics.py index 2c51780d..59f8add5 100644 --- a/src/polymarket/models/data/analytics.py +++ b/src/polymarket/models/data/analytics.py @@ -1,120 +1,157 @@ from __future__ import annotations +from datetime import datetime from decimal import Decimal -from typing import Literal -from pydantic import AliasChoices, Field, computed_field, field_validator +from pydantic import Field, computed_field, field_validator from polymarket.models.base import BaseModel -from polymarket.models.gamma.common import parse_optional_decimal +from polymarket.models.data.common import ( + datetime_from_epoch_seconds, + decimal_from_number, + optional_decimal_from_number, + optional_outcome_index, + optional_text, +) from polymarket.models.types import ( ClobAssetId, ConditionId, - validate_optional_condition_id, + validate_optional_condition_id_response, ) from polymarket.types import EvmAddress -OpenInterestMarket = ConditionId | Literal["GLOBAL"] +class MarketLiveVolume(BaseModel): + """Market taker volume in shares.""" -class MarketVolume(BaseModel): - condition_id: ConditionId | None = Field(default=None, validation_alias="market") - market: ConditionId | None = Field( - default=None, validation_alias="market", description="Deprecated: use condition_id." - ) - value: Decimal | None = None + condition_id: ConditionId | None + taker_volume: Decimal - @field_validator("condition_id", "market", mode="before") + @field_validator("condition_id", mode="before") @classmethod - def _validate_market(cls, value: object) -> ConditionId | None: - return validate_optional_condition_id(value) + def _parse_condition(cls, value: object) -> ConditionId | None: + return validate_optional_condition_id_response(optional_text(value)) - @field_validator("value", mode="before") - @classmethod - def _parse_value(cls, value: object) -> Decimal | None: - return parse_optional_decimal(value) + _decimal_from_number = field_validator("taker_volume", mode="before")(decimal_from_number) class LiveVolume(BaseModel): - total: Decimal | None = None - markets: tuple[MarketVolume, ...] | None = None + """Event taker volume in shares and its market breakdown.""" - @field_validator("total", mode="before") - @classmethod - def _parse_total(cls, value: object) -> Decimal | None: - return parse_optional_decimal(value) + taker_volume_total: Decimal + markets: tuple[MarketLiveVolume, ...] = Field(validation_alias="conditions") + + _decimal_from_number = field_validator("taker_volume_total", mode="before")(decimal_from_number) class OpenInterest(BaseModel): - condition_id: OpenInterestMarket | None = Field(default=None, validation_alias="market") - market: OpenInterestMarket | None = Field( - default=None, description="Deprecated: use condition_id." - ) - value: Decimal | None = None + """Open interest in USDC; ``condition_id=None`` denotes global interest.""" - @field_validator("condition_id", "market", mode="before") - @classmethod - def _validate_market(cls, value: object) -> OpenInterestMarket | None: - if value == "GLOBAL": - return "GLOBAL" - return validate_optional_condition_id(value) + condition_id: ConditionId | None + value: Decimal + + _decimal_from_number = field_validator("value", mode="before")(decimal_from_number) - @field_validator("value", mode="before") + @field_validator("condition_id", mode="before") @classmethod - def _parse_value(cls, value: object) -> Decimal | None: - return parse_optional_decimal(value) + def _parse_condition(cls, value: object) -> ConditionId | None: + return None if value == "GLOBAL" else validate_optional_condition_id_response(value) class Holder(BaseModel): - wallet: EvmAddress | None = Field(default=None, validation_alias="proxyWallet") - asset_id: ClobAssetId | None = Field( - default=None, - validation_alias=AliasChoices("asset_id", "asset", "token_id"), - ) - amount: Decimal | None = None - outcome_index: int | None = Field(default=None, validation_alias="outcomeIndex") + """A holder. ``amount`` is shares; prices, cost, value and PnL are USDC.""" + + wallet: EvmAddress = Field(validation_alias="proxy_wallet") + asset_id: ClobAssetId = Field(validation_alias="token_id") + amount: Decimal + outcome_index: int | None = None + display_username_public: bool + verified: bool name: str | None = None pseudonym: str | None = None bio: str | None = None - display_username_public: bool | None = Field( - default=None, validation_alias="displayUsernamePublic" - ) - profile_image: str | None = Field(default=None, validation_alias="profileImage") - profile_image_optimized: str | None = Field( - default=None, validation_alias="profileImageOptimized" + profile_image: str | None = None + profile_image_optimized: str | None = None + avg_price: Decimal | None = None + entry_cost_usdc: Decimal | None = None + current_price: Decimal | None = None + current_value: Decimal | None = None + realized_pnl: Decimal | None = None + unrealized_pnl: Decimal | None = None + total_pnl: Decimal | None = None + + _decimal_from_number = field_validator("amount", mode="before")(decimal_from_number) + + _optional_outcome_index = field_validator("outcome_index", mode="before")( + optional_outcome_index ) + _optional_text = field_validator( + "name", "pseudonym", "bio", "profile_image", "profile_image_optimized", mode="before" + )(optional_text) + + _optional_decimal_from_number = field_validator( + "avg_price", + "entry_cost_usdc", + "current_price", + "current_value", + "realized_pnl", + "unrealized_pnl", + "total_pnl", + mode="before", + )(optional_decimal_from_number) + @computed_field @property - def token_id(self) -> ClobAssetId | None: + def token_id(self) -> ClobAssetId: """Deprecated alias for :attr:`asset_id`.""" - return self.asset_id - @field_validator("amount", mode="before") - @classmethod - def _parse_amount(cls, value: object) -> Decimal | None: - return parse_optional_decimal(value) - class MetaHolder(BaseModel): - asset_id: ClobAssetId | None = Field( - default=None, validation_alias=AliasChoices("asset_id", "token") - ) - holders: tuple[Holder, ...] | None = None + """Holders grouped by outcome asset.""" + + asset_id: ClobAssetId = Field(validation_alias="token_id") + holders: tuple[Holder, ...] @computed_field @property - def token(self) -> ClobAssetId | None: + def token(self) -> ClobAssetId: """Deprecated alias for :attr:`asset_id`.""" - return self.asset_id +class PriceHistoryPoint(BaseModel): + """An historical price in USDC per share; resolution 0 denotes an exact tick.""" + + timestamp: datetime + price: Decimal + resolution_seconds: int + + _datetime_from_epoch_seconds = field_validator("timestamp", mode="before")( + datetime_from_epoch_seconds + ) + + _decimal_from_number = field_validator("price", mode="before")(decimal_from_number) + + def _repr_html_(self) -> str: + from polymarket._jupyter import card, safe_html_repr + + @safe_html_repr + def render(self: PriceHistoryPoint) -> str: + return card( + "PriceHistoryPoint", + rows=[("timestamp", self.timestamp.isoformat()), ("price", str(self.price))], + ) + + return render(self) + + __all__ = [ - "Holder", + "MarketLiveVolume", "LiveVolume", - "MarketVolume", - "MetaHolder", "OpenInterest", + "Holder", + "MetaHolder", + "PriceHistoryPoint", ] diff --git a/src/polymarket/models/data/common.py b/src/polymarket/models/data/common.py new file mode 100644 index 00000000..794af4c0 --- /dev/null +++ b/src/polymarket/models/data/common.py @@ -0,0 +1,281 @@ +"""Filter vocabularies and response parsers for portfolio and trading analytics.""" + +from datetime import UTC, datetime +from decimal import Decimal, InvalidOperation +from enum import StrEnum +from typing import Literal + +SortDirection = Literal["ASC", "DESC"] +TradeFilterType = Literal["CASH", "TOKENS"] +PositionFilterType = Literal["CASH", "TOKENS"] + + +class ActivityType(StrEnum): + """Kind of wallet activity row.""" + + TRADE = "TRADE" + SPLIT = "SPLIT" + MERGE = "MERGE" + REDEEM = "REDEEM" + REWARD = "REWARD" + CONVERSION = "CONVERSION" + MIGRATION = "MIGRATION" + DEPOSIT = "DEPOSIT" + WITHDRAWAL = "WITHDRAWAL" + YIELD = "YIELD" + MAKER_REBATE = "MAKER_REBATE" + TAKER_REBATE = "TAKER_REBATE" + REFERRAL_REWARD = "REFERRAL_REWARD" + TIP = "TIP" + + +ActivityTypeFilter = ( + Literal[ + "TRADE", + "SPLIT", + "MERGE", + "REDEEM", + "REWARD", + "CONVERSION", + "MIGRATION", + "DEPOSIT", + "WITHDRAWAL", + "YIELD", + "MAKER_REBATE", + "TAKER_REBATE", + "REFERRAL_REWARD", + "TIP", + ] + | ActivityType +) +"""Activity kinds accepted by activity filters, as plain strings or :class:`ActivityType`.""" + + +class ComboActivityType(StrEnum): + """Kind of combo lifecycle activity row.""" + + SPLIT = "SPLIT" + MERGE = "MERGE" + CONVERT = "CONVERT" + COMPRESS = "COMPRESS" + WRAP = "WRAP" + UNWRAP = "UNWRAP" + REDEEM = "REDEEM" + + +class TipSide(StrEnum): + """Direction of a tip from the wallet's perspective.""" + + IN = "IN" + OUT = "OUT" + + +class PositionStatus(StrEnum): + """Lifecycle status of a position.""" + + OPEN = "OPEN" + REDEEMABLE = "REDEEMABLE" + CLOSED = "CLOSED" + + +PositionStatusFilter = Literal["OPEN", "REDEEMABLE", "CLOSED"] | PositionStatus +"""Position statuses accepted by filters, as plain strings or :class:`PositionStatus`.""" + +PositionSortBy = Literal[ + "CURRENT_VALUE", "TOKENS", "UNREALIZED_PNL", "REALIZED_PNL", "TOTAL_PNL", "TIMESTAMP" +] + + +class ComboPositionStatus(StrEnum): + """Lifecycle status of a combo position or leg.""" + + OPEN = "OPEN" + REDEEMABLE = "REDEEMABLE" + PARTIAL = "PARTIAL" + RESOLVED_PARTIAL = "RESOLVED_PARTIAL" + RESOLVED_WIN = "RESOLVED_WIN" + RESOLVED_LOSS = "RESOLVED_LOSS" + + +ComboPositionStatusFilter = ( + Literal["OPEN", "REDEEMABLE", "PARTIAL", "RESOLVED_PARTIAL", "RESOLVED_WIN", "RESOLVED_LOSS"] + | ComboPositionStatus +) +"""Combo statuses accepted by filters, as plain strings or :class:`ComboPositionStatus`.""" + +ComboPositionSortBy = Literal["FIRST_ENTRY", "ENTRY_COST", "CURRENT_VALUE", "UPDATED"] +LeaderboardWindow = Literal["day", "week", "month", "all"] +TraderLeaderboardSort = Literal["PNL", "VOLUME"] +BuilderVolumeInterval = Literal["day", "week", "month", "all"] +"""Bucket interval; ``all`` yields calendar-year buckets.""" + + +class BiggestWinnerKind(StrEnum): + """Source of a winning position.""" + + MARKET = "market" + COMBO = "combo" + + +PriceHistoryInterval = Literal["max", "all", "1m", "1w", "1d", "6h", "1h"] + + +class UserPnlInterval(StrEnum): + """Lookback window of a wallet PnL series.""" + + MAX = "max" + ALL = "all" + ONE_MONTH = "1m" + ONE_WEEK = "1w" + ONE_DAY = "1d" + TWELVE_HOURS = "12h" + SIX_HOURS = "6h" + + +UserPnlIntervalInput = Literal["max", "all", "1m", "1w", "1d", "12h", "6h"] | UserPnlInterval +"""Lookback windows accepted by PnL requests, as plain strings or :class:`UserPnlInterval`.""" + + +class UserPnlFidelity(StrEnum): + """Time step between points in a wallet PnL series.""" + + ONE_DAY = "1d" + EIGHTEEN_HOURS = "18h" + TWELVE_HOURS = "12h" + THREE_HOURS = "3h" + ONE_HOUR = "1h" + + +UserPnlFidelityInput = Literal["1d", "18h", "12h", "3h", "1h"] | UserPnlFidelity +"""Time steps accepted by PnL requests, as plain strings or :class:`UserPnlFidelity`.""" + + +class ResolutionStatus(StrEnum): + """Lifecycle stage of a market resolution.""" + + INITIALIZED = "initialized" + POSED = "posed" + PROPOSED = "proposed" + CHALLENGED = "challenged" + REPROPOSED = "reproposed" + DISPUTED = "disputed" + ACTIVE = "active" + ARBITRATION = "arbitration" + RESOLVED = "resolved" + + +class ResolutionMarketType(StrEnum): + """Market structure of a resolved condition.""" + + BINARY = "BINARY" + INCREMENTAL_NEGRISK = "INCREMENTAL_NEGRISK" + ATOMIC_NEGRISK = "ATOMIC_NEGRISK" + + +class ResolutionSource(StrEnum): + """How a condition's final payout was obtained.""" + + REPORTED = "reported" + DERIVED = "derived" + + +class ResolutionReporter(StrEnum): + """Reporter family that supplied a resolution.""" + + UMA_OPTIMISTIC_ORACLE = "UMA_OO" + CHAINLINK = "CHAINLINK" + EOA = "EOA" + + +def decimal_from_number(value: object) -> Decimal: + if isinstance(value, bool) or not isinstance(value, str | int | float | Decimal): + raise ValueError("Expected a decimal amount") + try: + return Decimal(str(value)) + except InvalidOperation as error: + raise ValueError("Expected a decimal amount") from error + + +def optional_decimal_from_number(value: object) -> Decimal | None: + return None if value is None else decimal_from_number(value) + + +def optional_text(value: object) -> object: + return None if value == "" else value + + +def optional_outcome_index(value: object) -> object: + return None if value == 999 else value + + +def datetime_from_epoch_seconds(value: object) -> datetime: + if isinstance(value, datetime): + return value + if isinstance(value, bool) or not isinstance(value, int | float | str): + raise ValueError("Expected epoch seconds") + try: + return datetime.fromtimestamp(float(value), UTC) + except (OverflowError, OSError) as error: + raise ValueError("Epoch seconds are outside the supported datetime range") from error + + +def optional_datetime_from_epoch_seconds(value: object) -> datetime | None: + return None if value in (None, 0, "0", "") else datetime_from_epoch_seconds(value) + + +def date_from_calendar_string(value: object) -> object: + return None if value in (None, "", "1970-01-01") else value + + +def optional_event_id(value: object) -> object: + return None if value in (None, "", "0", 0) else str(value) + + +def required_event_id(value: object) -> str: + if isinstance(value, bool) or not isinstance(value, int | str): + raise ValueError("Expected an event ID") + text = str(value) + if not text.isdecimal() or int(text) <= 0: + raise ValueError("Expected a positive event ID") + return text + + +def datetime_from_epoch_or_iso(value: object) -> datetime: + if isinstance(value, str) and not value.isdecimal(): + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC) + return datetime_from_epoch_seconds(value) + + +def optional_datetime_from_epoch_or_iso(value: object) -> datetime | None: + return None if value in (None, "") else datetime_from_epoch_or_iso(value) + + +__all__ = [ + "ActivityType", + "ActivityTypeFilter", + "BiggestWinnerKind", + "BuilderVolumeInterval", + "ComboActivityType", + "ComboPositionSortBy", + "ComboPositionStatus", + "ComboPositionStatusFilter", + "LeaderboardWindow", + "PositionFilterType", + "PositionSortBy", + "PositionStatus", + "PositionStatusFilter", + "PriceHistoryInterval", + "ResolutionMarketType", + "ResolutionReporter", + "ResolutionSource", + "ResolutionStatus", + "SortDirection", + "TipSide", + "TradeFilterType", + "TraderLeaderboardSort", + "UserPnlFidelity", + "UserPnlFidelityInput", + "UserPnlInterval", + "UserPnlIntervalInput", +] diff --git a/src/polymarket/models/data/leaderboard.py b/src/polymarket/models/data/leaderboard.py index 81a4318f..23a35a61 100644 --- a/src/polymarket/models/data/leaderboard.py +++ b/src/polymarket/models/data/leaderboard.py @@ -1,88 +1,202 @@ from __future__ import annotations -from datetime import datetime +from datetime import date, datetime from decimal import Decimal -from typing import Literal +from typing import Literal, cast from pydantic import Field, field_validator from polymarket.models.base import BaseModel -from polymarket.models.gamma.common import parse_optional_datetime, parse_optional_decimal -from polymarket.types import EvmAddress - -BuilderVolumeTimePeriod = Literal["DAY", "WEEK", "MONTH", "ALL"] -LeaderboardTimePeriod = Literal["DAY", "WEEK", "MONTH", "ALL"] -LeaderboardCategory = Literal[ - "OVERALL", - "POLITICS", - "SPORTS", - "CRYPTO", - "CULTURE", - "MENTIONS", - "WEATHER", - "ECONOMICS", - "TECH", - "FINANCE", -] -LeaderboardOrderBy = Literal["PNL", "VOL"] +from polymarket.models.data.common import ( + BiggestWinnerKind, + datetime_from_epoch_or_iso, + decimal_from_number, + optional_text, + required_event_id, +) +from polymarket.models.types import ( + ClobAssetId, + ComboConditionId, + ConditionId, + EventId, + PositionId, + validate_combo_condition_id, + validate_condition_id_response, +) +from polymarket.types import EvmAddress, HexString -class BuilderVolumeEntry(BaseModel): - bucket_at: datetime | None = Field(default=None, validation_alias="dt") - builder: str | None = None - builder_logo: str | None = Field(default=None, validation_alias="builderLogo") - verified: bool | None = None - volume: Decimal | None = None - active_users: int | None = Field(default=None, validation_alias="activeUsers") - rank: str | None = None +class TraderLeaderboardEntry(BaseModel): + """A ranked trader; PnL is USDC and volume is shares.""" - @field_validator("bucket_at", mode="before") - @classmethod - def _parse_datetime(cls, value: object) -> datetime | None: - return parse_optional_datetime(value) + rank: int + wallet: EvmAddress = Field(validation_alias="user_id") + pnl: Decimal + volume: Decimal + user_name: str | None = None + profile_image: str | None = None + x_username: str | None = None + verified: bool - @field_validator("volume", mode="before") - @classmethod - def _parse_volume(cls, value: object) -> Decimal | None: - return parse_optional_decimal(value) + _decimal_from_number = field_validator("pnl", "volume", mode="before")(decimal_from_number) + _optional_text = field_validator("user_name", "profile_image", "x_username", mode="before")( + optional_text + ) -class LeaderboardEntry(BaseModel): - rank: str | None = None - builder: str | None = None - volume: Decimal | None = None - active_users: int | None = Field(default=None, validation_alias="activeUsers") - verified: bool | None = None - builder_logo: str | None = Field(default=None, validation_alias="builderLogo") - @field_validator("volume", mode="before") - @classmethod - def _parse_volume(cls, value: object) -> Decimal | None: - return parse_optional_decimal(value) +class TraderLeaderboardStanding(BaseModel): + """Trader rankings; PnL is USDC and volume is shares. Unranked values are ``None``.""" + wallet: EvmAddress = Field(validation_alias="user_id") + pnl: Decimal + volume: Decimal + user_name: str | None = None + profile_image: str | None = None + x_username: str | None = None + verified: bool + pnl_rank: int | None = Field(default=None, validation_alias="rank_pnl") + volume_rank: int | None = Field(default=None, validation_alias="rank_volume") -class TraderLeaderboardEntry(BaseModel): - rank: str | None = None - wallet: EvmAddress | None = Field(default=None, validation_alias="proxyWallet") - user_name: str | None = Field(default=None, validation_alias="userName") - vol: Decimal | None = None - pnl: Decimal | None = None - profile_image: str | None = Field(default=None, validation_alias="profileImage") - x_username: str | None = Field(default=None, validation_alias="xUsername") - verified_badge: bool | None = Field(default=None, validation_alias="verifiedBadge") - - @field_validator("vol", "pnl", mode="before") + _decimal_from_number = field_validator("pnl", "volume", mode="before")(decimal_from_number) + + _optional_text = field_validator("user_name", "profile_image", "x_username", mode="before")( + optional_text + ) + + @field_validator("pnl_rank", "volume_rank", mode="before") @classmethod - def _parse_decimal(cls, value: object) -> Decimal | None: - return parse_optional_decimal(value) + def _parse_rank(cls, value: object) -> object: + return None if value == 0 else value + + +class MarketBiggestWinner(BaseModel): + """A winning market position; PnL and values are USDC.""" + + rank: int = Field(validation_alias="win_rank") + wallet: EvmAddress = Field(validation_alias="user_id") + pnl: Decimal + initial_value: Decimal + final_value: Decimal + resolved_at: datetime + user_name: str | None = None + profile_image: str | None = None + event_title: str | None = None + kind: Literal[BiggestWinnerKind.MARKET] + condition_id: ConditionId + asset_id: ClobAssetId = Field(validation_alias="position_id") + event_id: EventId + event_slug: str | None = None + + _decimal_from_number = field_validator("pnl", "initial_value", "final_value", mode="before")( + decimal_from_number + ) + + _datetime_from_epoch_or_iso = field_validator("resolved_at", mode="before")( + datetime_from_epoch_or_iso + ) + + _optional_text = field_validator( + "user_name", "profile_image", "event_title", "event_slug", mode="before" + )(optional_text) + + _validate_condition_id_response = field_validator("condition_id", mode="before")( + validate_condition_id_response + ) + + _required_event_id = field_validator("event_id", mode="before")(required_event_id) + + +class ComboBiggestWinner(BaseModel): + """A winning combo position; PnL and values are USDC.""" + + rank: int = Field(validation_alias="win_rank") + wallet: EvmAddress = Field(validation_alias="user_id") + pnl: Decimal + initial_value: Decimal + final_value: Decimal + resolved_at: datetime + user_name: str | None = None + profile_image: str | None = None + event_title: str | None = None + kind: Literal[BiggestWinnerKind.COMBO] + condition_id: ComboConditionId + position_id: PositionId + + _decimal_from_number = field_validator("pnl", "initial_value", "final_value", mode="before")( + decimal_from_number + ) + + _datetime_from_epoch_or_iso = field_validator("resolved_at", mode="before")( + datetime_from_epoch_or_iso + ) + + _optional_text = field_validator("user_name", "profile_image", "event_title", mode="before")( + optional_text + ) + + _validate_combo_condition_id = field_validator("condition_id", mode="before")( + validate_combo_condition_id + ) + + +class BuilderStanding(BaseModel): + """Builder rankings; volume is shares.""" + + rank: int + builder_name: str + builder_code: HexString + profile_image: str | None = None + verified: bool + volume: Decimal + active_users: int + + _optional_text = field_validator("profile_image", mode="before")(optional_text) + + _decimal_from_number = field_validator("volume", mode="before")(decimal_from_number) + + +class BuilderVolumePoint(BaseModel): + """Builder volume in shares for a calendar bucket.""" + + rank: int + builder_name: str + builder_code: HexString + profile_image: str | None = None + verified: bool + volume: Decimal + active_users: int + bucket_date: date = Field(validation_alias="date") + + _optional_text = field_validator("profile_image", mode="before")(optional_text) + + _decimal_from_number = field_validator("volume", mode="before")(decimal_from_number) + + +def parse_biggest_winners(payload: object) -> tuple[MarketBiggestWinner | ComboBiggestWinner, ...]: + from polymarket.errors import UnexpectedResponseError + + if not isinstance(payload, list): + raise UnexpectedResponseError("Biggest winners must be a list") + rows: list[MarketBiggestWinner | ComboBiggestWinner] = [] + for item in cast(list[object], payload): + if not isinstance(item, dict): + raise UnexpectedResponseError("Biggest winner must be an object") + kind = cast(dict[str, object], item).get("kind") + if kind == "market": + rows.append(MarketBiggestWinner.parse_response(cast(dict[str, object], item))) + elif kind == "combo": + rows.append(ComboBiggestWinner.parse_response(cast(dict[str, object], item))) + else: + raise UnexpectedResponseError("Unknown biggest winner kind") + return tuple(rows) __all__ = [ - "BuilderVolumeEntry", - "BuilderVolumeTimePeriod", - "LeaderboardCategory", - "LeaderboardEntry", - "LeaderboardOrderBy", - "LeaderboardTimePeriod", "TraderLeaderboardEntry", + "TraderLeaderboardStanding", + "MarketBiggestWinner", + "ComboBiggestWinner", + "BuilderStanding", + "BuilderVolumePoint", ] diff --git a/src/polymarket/models/data/market_positions.py b/src/polymarket/models/data/market_positions.py deleted file mode 100644 index 51baf875..00000000 --- a/src/polymarket/models/data/market_positions.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -from decimal import Decimal - -from pydantic import AliasChoices, Field, computed_field, field_validator - -from polymarket.models.base import BaseModel -from polymarket.models.gamma.common import parse_optional_decimal -from polymarket.models.types import ClobAssetId, ConditionId, validate_optional_condition_id -from polymarket.types import EvmAddress - - -class MarketPosition(BaseModel): - wallet: EvmAddress | None = Field(default=None, validation_alias="proxyWallet") - name: str | None = None - profile_image: str | None = Field(default=None, validation_alias="profileImage") - verified: bool | None = None - asset_id: ClobAssetId | None = Field( - default=None, - validation_alias=AliasChoices("asset_id", "asset", "token_id"), - ) - condition_id: ConditionId | None = Field(default=None, validation_alias="conditionId") - avg_price: Decimal | None = Field(default=None, validation_alias="avgPrice") - size: Decimal | None = None - cur_price: Decimal | None = Field(default=None, validation_alias="currPrice") - current_value: Decimal | None = Field(default=None, validation_alias="currentValue") - cash_pnl: Decimal | None = Field(default=None, validation_alias="cashPnl") - total_bought: Decimal | None = Field(default=None, validation_alias="totalBought") - realized_pnl: Decimal | None = Field(default=None, validation_alias="realizedPnl") - total_pnl: Decimal | None = Field(default=None, validation_alias="totalPnl") - outcome: str | None = None - outcome_index: int | None = Field(default=None, validation_alias="outcomeIndex") - - @computed_field - @property - def token_id(self) -> ClobAssetId | None: - """Deprecated alias for :attr:`asset_id`.""" - - return self.asset_id - - @field_validator("condition_id", mode="before") - @classmethod - def _validate_condition_id(cls, value: object) -> ConditionId | None: - return validate_optional_condition_id(value) - - @field_validator( - "avg_price", - "size", - "cur_price", - "current_value", - "cash_pnl", - "total_bought", - "realized_pnl", - "total_pnl", - mode="before", - ) - @classmethod - def _parse_decimal(cls, value: object) -> Decimal | None: - return parse_optional_decimal(value) - - -class MetaMarketPosition(BaseModel): - asset_id: ClobAssetId | None = Field( - default=None, validation_alias=AliasChoices("asset_id", "token") - ) - positions: tuple[MarketPosition, ...] | None = None - - @computed_field - @property - def token(self) -> ClobAssetId | None: - """Deprecated alias for :attr:`asset_id`.""" - - return self.asset_id - - -__all__ = ["MarketPosition", "MetaMarketPosition"] diff --git a/src/polymarket/models/data/portfolio.py b/src/polymarket/models/data/portfolio.py index 89f7d0ca..de8031e3 100644 --- a/src/polymarket/models/data/portfolio.py +++ b/src/polymarket/models/data/portfolio.py @@ -2,240 +2,183 @@ from datetime import date, datetime from decimal import Decimal -from typing import Literal -from pydantic import AliasChoices, Field, computed_field, field_validator +from pydantic import Field, computed_field, field_validator from polymarket.models.base import BaseModel -from polymarket.models.gamma.common import ( - empty_string_to_none, - parse_epoch_seconds_optional, - parse_optional_date, - parse_optional_decimal, +from polymarket.models.data.common import ( + ComboPositionStatus, + PositionStatus, + UserPnlFidelity, + UserPnlInterval, + date_from_calendar_string, + datetime_from_epoch_seconds, + decimal_from_number, + optional_datetime_from_epoch_or_iso, + optional_datetime_from_epoch_seconds, + optional_decimal_from_number, + optional_event_id, + optional_outcome_index, + optional_text, ) from polymarket.models.types import ( ClobAssetId, ComboConditionId, ConditionId, + EventId, + MarketId, PositionId, validate_combo_condition_id, - validate_condition_id, - validate_optional_condition_id, + validate_condition_id_response, ) from polymarket.types import EvmAddress -ComboPositionStatus = Literal[ - "OPEN", "PARTIAL", "RESOLVED_PARTIAL", "RESOLVED_WIN", "RESOLVED_LOSS" -] -ComboPositionOutcome = Literal["YES", "NO"] - - -class PortfolioValue(BaseModel): - """Current portfolio value for a user.""" - - user: EvmAddress | None = None - value: Decimal | None = None - - @field_validator("value", mode="before") - @classmethod - def _parse_value(cls, value: object) -> Decimal | None: - return parse_optional_decimal(value) - - -class TradedMarketCount(BaseModel): - """Number of markets traded by a user.""" - - user: EvmAddress | None = None - traded: int | None = None - class Position(BaseModel): - """Open market position held by a wallet.""" - - condition_id: ConditionId = Field(validation_alias="conditionId") - wallet: EvmAddress | None = Field(default=None, validation_alias="proxyWallet") - asset_id: ClobAssetId | None = Field( - default=None, - validation_alias=AliasChoices("asset_id", "asset", "token_id"), - ) - size: Decimal | None = None - avg_price: Decimal | None = Field(default=None, validation_alias="avgPrice") - initial_value: Decimal | None = Field(default=None, validation_alias="initialValue") - current_value: Decimal | None = Field(default=None, validation_alias="currentValue") - cash_pnl: Decimal | None = Field(default=None, validation_alias="cashPnl") - percent_pnl: float | None = Field(default=None, validation_alias="percentPnl") - total_bought: Decimal | None = Field(default=None, validation_alias="totalBought") - realized_pnl: Decimal | None = Field(default=None, validation_alias="realizedPnl") - percent_realized_pnl: float | None = Field(default=None, validation_alias="percentRealizedPnl") - cur_price: Decimal | None = Field(default=None, validation_alias="curPrice") - redeemable: bool | None = None - mergeable: bool | None = None + """A wallet position. Sizes are shares; prices, costs, values and PnL are USDC. + + ``entry_cost_usdc`` is fee-exclusive. ``entry_fees_usdc`` is disclosure only. + Percentage fields are percentages.""" + + wallet: EvmAddress = Field(validation_alias="proxy_wallet") + asset_id: ClobAssetId = Field(validation_alias="token_id") + condition_id: ConditionId + current_size: Decimal + avg_price: Decimal + entry_cost_usdc: Decimal + entry_fees_usdc: Decimal + total_cost_usdc: Decimal + current_price: Decimal + current_value: Decimal + total_size: Decimal + realized_pnl: Decimal + unrealized_pnl: Decimal + total_pnl: Decimal + percent_pnl: Decimal + percent_realized_pnl: Decimal + status: PositionStatus + redeemable: bool + mergeable: bool + negative_risk: bool + archived: bool + verified: bool title: str | None = None slug: str | None = None icon: str | None = None - event_id: str | None = Field(default=None, validation_alias="eventId") - event_slug: str | None = Field(default=None, validation_alias="eventSlug") + event_slug: str | None = None outcome: str | None = None - outcome_index: int | None = Field(default=None, validation_alias="outcomeIndex") - opposite_outcome: str | None = Field(default=None, validation_alias="oppositeOutcome") + opposite_outcome: str | None = None + name: str | None = None + profile_image: str | None = None + event_id: EventId | None = None + outcome_index: int | None = None opposite_asset_id: ClobAssetId | None = Field( - default=None, - validation_alias=AliasChoices("opposite_asset_id", "oppositeAsset", "opposite_token_id"), + default=None, validation_alias="opposite_token_id" ) - end_date: date | None = Field(default=None, validation_alias="endDate") - negative_risk: bool | None = Field(default=None, validation_alias="negativeRisk") - - @computed_field - @property - def token_id(self) -> ClobAssetId | None: - """Deprecated alias for :attr:`asset_id`.""" - - return self.asset_id + end_date: date | None = None + last_event_at: datetime | None = None - @computed_field - @property - def opposite_token_id(self) -> ClobAssetId | None: - """Deprecated alias for :attr:`opposite_asset_id`.""" - - return self.opposite_asset_id - - @field_validator("condition_id", mode="before") - @classmethod - def _validate_condition_id(cls, value: object) -> ConditionId: - return validate_condition_id(value) + _validate_condition_id_response = field_validator("condition_id", mode="before")( + validate_condition_id_response + ) - @field_validator( - "size", + _decimal_from_number = field_validator( + "current_size", "avg_price", - "initial_value", + "entry_cost_usdc", + "entry_fees_usdc", + "total_cost_usdc", + "current_price", "current_value", - "cash_pnl", - "total_bought", + "total_size", "realized_pnl", - "cur_price", + "unrealized_pnl", + "total_pnl", + "percent_pnl", + "percent_realized_pnl", mode="before", - ) - @classmethod - def _parse_decimal(cls, value: object) -> Decimal | None: - return parse_optional_decimal(value) - - @field_validator("end_date", mode="before") - @classmethod - def _parse_end_date(cls, value: object) -> date | None: - return parse_optional_date(value) - - @field_validator("icon", mode="before") - @classmethod - def _normalize_icon(cls, value: object) -> object | None: - return empty_string_to_none(value) - - def _repr_html_(self) -> str: - from polymarket._jupyter import card, safe_html_repr, truncate_mid - - @safe_html_repr - def render(self: Position) -> str: - label = self.title or truncate_mid(self.condition_id) - title = f"Position · {label}" - rows: list[tuple[str, str]] = [] - if self.outcome: - rows.append(("side", self.outcome)) - if self.size is not None: - rows.append(("size", str(self.size))) - if self.avg_price is not None: - rows.append(("avg_price", str(self.avg_price))) - if self.cur_price is not None: - rows.append(("current", str(self.cur_price))) - if self.cash_pnl is not None: - rows.append(("pnl", str(self.cash_pnl))) - return card(title, rows=rows) - - return render(self) + )(decimal_from_number) + + _optional_text = field_validator( + "title", + "slug", + "icon", + "event_slug", + "outcome", + "opposite_outcome", + "name", + "profile_image", + "opposite_asset_id", + mode="before", + )(optional_text) + _optional_event_id = field_validator("event_id", mode="before")(optional_event_id) -class ClosedPosition(BaseModel): - """Closed market position for a wallet.""" + _optional_outcome_index = field_validator("outcome_index", mode="before")( + optional_outcome_index + ) - wallet: EvmAddress | None = Field(default=None, validation_alias="proxyWallet") - asset_id: ClobAssetId | None = Field( - default=None, - validation_alias=AliasChoices("asset_id", "asset", "token_id"), + _date_from_calendar_string = field_validator("end_date", mode="before")( + date_from_calendar_string ) - condition_id: ConditionId | None = Field(default=None, validation_alias="conditionId") - avg_price: Decimal | None = Field(default=None, validation_alias="avgPrice") - total_bought: Decimal | None = Field(default=None, validation_alias="totalBought") - realized_pnl: Decimal | None = Field(default=None, validation_alias="realizedPnl") - cur_price: Decimal | None = Field(default=None, validation_alias="curPrice") - timestamp: datetime | None = None - title: str | None = None - slug: str | None = None - icon: str | None = None - event_slug: str | None = Field(default=None, validation_alias="eventSlug") - outcome: str | None = None - outcome_index: int | None = Field(default=None, validation_alias="outcomeIndex") - opposite_outcome: str | None = Field(default=None, validation_alias="oppositeOutcome") - opposite_asset_id: ClobAssetId | None = Field( - default=None, - validation_alias=AliasChoices("opposite_asset_id", "oppositeAsset", "opposite_token_id"), + + _optional_datetime_from_epoch_seconds = field_validator("last_event_at", mode="before")( + optional_datetime_from_epoch_seconds ) - end_date: date | None = Field(default=None, validation_alias="endDate") @computed_field @property - def token_id(self) -> ClobAssetId | None: + def token_id(self) -> ClobAssetId: """Deprecated alias for :attr:`asset_id`.""" - return self.asset_id @computed_field @property def opposite_token_id(self) -> ClobAssetId | None: """Deprecated alias for :attr:`opposite_asset_id`.""" - return self.opposite_asset_id - @field_validator("condition_id", mode="before") - @classmethod - def _validate_condition_id(cls, value: object) -> ConditionId | None: - return validate_optional_condition_id(value) - - @field_validator( - "avg_price", - "total_bought", - "realized_pnl", - "cur_price", - mode="before", - ) - @classmethod - def _parse_decimal(cls, value: object) -> Decimal | None: - return parse_optional_decimal(value) - - @field_validator("timestamp", mode="before") - @classmethod - def _parse_timestamp(cls, value: object) -> datetime | None: - return parse_epoch_seconds_optional(value) + def _repr_html_(self) -> str: + from polymarket._jupyter import card, safe_html_repr, truncate_mid - @field_validator("end_date", mode="before") - @classmethod - def _parse_end_date(cls, value: object) -> date | None: - return parse_optional_date(value) + @safe_html_repr + def render(self: Position) -> str: + label = self.title or truncate_mid(self.condition_id) + title = f"Position · {label}" + rows: list[tuple[str, str]] = [] + if self.outcome: + rows.append(("side", self.outcome)) + rows.append(("size", str(self.current_size))) + rows.append(("avg_price", str(self.avg_price))) + rows.append(("current", str(self.current_price))) + rows.append(("pnl", str(self.total_pnl))) + return card(title, rows=rows) - @field_validator("icon", mode="before") - @classmethod - def _normalize_icon(cls, value: object) -> object | None: - return empty_string_to_none(value) + return render(self) class ComboPositionMarketEvent(BaseModel): - event_id: str | None = None + event_id: EventId | None = None event_slug: str | None = None event_title: str | None = None event_image: str | None = None + _optional_event_id = field_validator("event_id", mode="before")(optional_event_id) + + _optional_text = field_validator("event_slug", "event_title", "event_image", mode="before")( + optional_text + ) + class ComboPositionMarket(BaseModel): - market_id: str | None = None + market_id: MarketId | None = None slug: str | None = None title: str | None = None + question: str | None = None + group_item_title: str | None = None + sports_market_type: str | None = None + line: Decimal | None = None + outcomes: tuple[str, ...] | None = None outcome: str | None = None image_url: str | None = None icon_url: str | None = None @@ -245,8 +188,32 @@ class ComboPositionMarket(BaseModel): end_date: datetime | None = None event: ComboPositionMarketEvent | None = None + _optional_text = field_validator( + "slug", + "title", + "question", + "group_item_title", + "sports_market_type", + "outcome", + "image_url", + "icon_url", + "category", + "subcategory", + mode="before", + )(optional_text) + + _optional_decimal_from_number = field_validator("line", mode="before")( + optional_decimal_from_number + ) + + _optional_datetime_from_epoch_or_iso = field_validator("end_date", mode="before")( + optional_datetime_from_epoch_or_iso + ) + class ComboPositionLeg(BaseModel): + """A combo leg; ``leg_current_price`` is USDC per share.""" + leg_index: int leg_position_id: PositionId leg_condition_id: ConditionId @@ -257,65 +224,186 @@ class ComboPositionLeg(BaseModel): leg_current_price: Decimal | None = None market: ComboPositionMarket | None = None - @field_validator("leg_condition_id", mode="before") - @classmethod - def _validate_condition_id(cls, value: object) -> ConditionId: - return validate_condition_id(value) + _validate_condition_id_response = field_validator("leg_condition_id", mode="before")( + validate_condition_id_response + ) + + _optional_text = field_validator("leg_outcome_label", mode="before")(optional_text) - @field_validator("leg_current_price", mode="before") - @classmethod - def _parse_decimal(cls, value: object) -> Decimal | None: - return parse_optional_decimal(value) + _optional_decimal_from_number = field_validator("leg_current_price", mode="before")( + optional_decimal_from_number + ) class ComboPosition(BaseModel): + """A combo position. ``current_size`` is shares; prices, costs and payouts are USDC. + + The fee-exclusive basis is ``gross_entry_cost_usdc - entry_fees_usdc``; + ``entry_cost_usdc`` is a rounded weighted-average cost.""" + condition_id: ComboConditionId = Field(validation_alias="combo_condition_id") position_id: PositionId = Field(validation_alias="combo_position_id") - outcome: ComboPositionOutcome = Field(validation_alias="side") - module_id: int = Field(validation_alias="module_id") - wallet: EvmAddress = Field(validation_alias="user_address") - shares: Decimal = Field(validation_alias="shares_balance") - entry_avg_price_usdc: Decimal | None = None - entry_cost_usdc: Decimal | None = None - realized_payout_usdc: Decimal | None = None - total_cost_usdc: Decimal | None = None + wallet: EvmAddress = Field(validation_alias="proxy_wallet") + outcome_index: int + outcome_label: str + current_size: Decimal + entry_avg_price_usdc: Decimal + entry_cost_usdc: Decimal + gross_entry_cost_usdc: Decimal + entry_fees_usdc: Decimal + realized_payout_usdc: Decimal status: ComboPositionStatus redeemable: bool first_entry_at: datetime resolved_at: datetime | None = None - updated_at: datetime | None = None + updated_at: datetime legs_total: int legs_resolved: int legs_pending: int legs: tuple[ComboPositionLeg, ...] - @field_validator("condition_id", mode="before") - @classmethod - def _validate_condition_id(cls, value: object) -> ComboConditionId: - return validate_combo_condition_id(value) + _validate_combo_condition_id = field_validator("condition_id", mode="before")( + validate_combo_condition_id + ) - @field_validator( - "shares", + _decimal_from_number = field_validator( + "current_size", "entry_avg_price_usdc", "entry_cost_usdc", + "gross_entry_cost_usdc", + "entry_fees_usdc", "realized_payout_usdc", - "total_cost_usdc", mode="before", + )(decimal_from_number) + + +class PortfolioValue(BaseModel): + """Current portfolio value in USDC.""" + + wallet: EvmAddress = Field(validation_alias="proxy_wallet") + value: Decimal + + _decimal_from_number = field_validator("value", mode="before")(decimal_from_number) + + +class UserPnlPoint(BaseModel): + """Cumulative wallet metrics. ``volume`` is shares; all monetary amounts are USDC. + + Missing cumulative amounts remain ``None``.""" + + timestamp: datetime + source_block: int + trade_count: int + realized_market_pnl: Decimal + realized_lp_pnl: Decimal + realized_combo_pnl: Decimal + realized_pnl: Decimal + volume: Decimal + volume_usdc: Decimal + unrealized_pnl: Decimal | None = None + fees_refunded: Decimal | None = None + maker_rebate: Decimal | None = None + taker_rebate: Decimal | None = None + reward_income: Decimal | None = None + yield_income: Decimal | None = None + referral_income: Decimal | None = None + sponsored_income: Decimal | None = None + deposits: Decimal | None = None + withdrawals: Decimal | None = None + cashflow_net: Decimal | None = None + wallet_income: Decimal | None = None + position_pnl: Decimal | None = None + settled_pnl: Decimal | None = None + economic_pnl: Decimal | None = None + trade_pnl: Decimal | None = None + fees: Decimal | None = None + fees_paid: Decimal | None = None + + _datetime_from_epoch_seconds = field_validator("timestamp", mode="before")( + datetime_from_epoch_seconds + ) + + _decimal_from_number = field_validator( + "realized_market_pnl", + "realized_lp_pnl", + "realized_combo_pnl", + "realized_pnl", + "volume", + "volume_usdc", + mode="before", + )(decimal_from_number) + + _optional_decimal_from_number = field_validator( + "unrealized_pnl", + "fees_refunded", + "maker_rebate", + "taker_rebate", + "reward_income", + "yield_income", + "referral_income", + "sponsored_income", + "deposits", + "withdrawals", + "cashflow_net", + "wallet_income", + "position_pnl", + "settled_pnl", + "economic_pnl", + "trade_pnl", + "fees", + "fees_paid", + mode="before", + )(optional_decimal_from_number) + + +class UserStats(BaseModel): + """Wallet statistics; ``biggest_win`` is USDC.""" + + wallet: EvmAddress = Field(validation_alias="proxy_wallet") + traded_market_count: int = Field(validation_alias="trades") + biggest_win: Decimal + views: int + join_date: datetime | None = None + all_time_pnl: UserPnlPoint | None = None + + _decimal_from_number = field_validator("biggest_win", mode="before")(decimal_from_number) + + _optional_datetime_from_epoch_seconds = field_validator("join_date", mode="before")( + optional_datetime_from_epoch_seconds + ) + + +class UserPnlSeries(BaseModel): + """A cumulative PnL time series for a wallet.""" + + wallet: EvmAddress = Field(validation_alias="proxy_wallet") + interval: UserPnlInterval + fidelity: UserPnlFidelity + source_fidelity: UserPnlFidelity + points: tuple[UserPnlPoint, ...] + + +class UserVolume(BaseModel): + """Trading volume in shares and USDC, with a trade count.""" + + volume: Decimal + volume_usdc: Decimal + trade_count: int + + _decimal_from_number = field_validator("volume", "volume_usdc", mode="before")( + decimal_from_number ) - @classmethod - def _parse_decimal(cls, value: object) -> Decimal | None: - return parse_optional_decimal(value) __all__ = [ - "ClosedPosition", - "ComboPosition", - "ComboPositionOutcome", - "ComboPositionLeg", - "ComboPositionMarket", + "Position", "ComboPositionMarketEvent", - "ComboPositionStatus", + "ComboPositionMarket", + "ComboPositionLeg", + "ComboPosition", "PortfolioValue", - "Position", - "TradedMarketCount", + "UserPnlPoint", + "UserStats", + "UserPnlSeries", + "UserVolume", ] diff --git a/src/polymarket/models/data/resolutions.py b/src/polymarket/models/data/resolutions.py new file mode 100644 index 00000000..5ee1e3b2 --- /dev/null +++ b/src/polymarket/models/data/resolutions.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Self, cast + +from pydantic import Field, field_validator + +from polymarket.errors import UnexpectedResponseError +from polymarket.models.base import BaseModel +from polymarket.models.data.common import ( + ResolutionMarketType, + ResolutionReporter, + ResolutionSource, + ResolutionStatus, + datetime_from_epoch_or_iso, + decimal_from_number, + optional_decimal_from_number, + optional_text, +) +from polymarket.models.types import ( + ConditionId, + QuestionId, + validate_optional_condition_id_response, +) +from polymarket.types import TransactionHash + + +class Resolution(BaseModel): + """Resolution lifecycle and per-outcome payouts in USDC per share.""" + + question_id: QuestionId | None = None + condition_id: ConditionId | None = None + status: ResolutionStatus + extended_review: bool + was_disputed: bool + question_rules_updated: bool = Field(validation_alias="new_version_q") + proposed_price: Decimal | None = None + reproposed_price: Decimal | None = None + price: Decimal | None = None + transaction_hash: TransactionHash | None = None + log_index: int | None = None + last_updated_at: datetime = Field(validation_alias="last_update_timestamp") + market_type: ResolutionMarketType | None = None + payouts: tuple[Decimal, Decimal] | None = None + resolution_source: ResolutionSource | None = None + reporter: ResolutionReporter | None = None + was_arbitrated: bool | None = None + resolved_block: int | None = None + resolved_at: datetime | None = None + + _validate_optional_condition_id_response = field_validator("condition_id", mode="before")( + validate_optional_condition_id_response + ) + + _optional_decimal_from_number = field_validator( + "proposed_price", "reproposed_price", "price", mode="before" + )(optional_decimal_from_number) + + _optional_text = field_validator("transaction_hash", "log_index", mode="before")(optional_text) + + _datetime_from_epoch_or_iso = field_validator("last_updated_at", mode="before")( + datetime_from_epoch_or_iso + ) + + @field_validator("proposed_price", "reproposed_price", "price", mode="before") + @classmethod + def _unset_price(cls, value: object) -> object: + return None if value in ("69", 69) else value + + @classmethod + def parse_response(cls, data: object) -> Self: + if not isinstance(data, dict): + return super().parse_response(data) + response = dict(cast(dict[str, object], data)) + if "payouts" in response: + try: + response["payouts"] = _parse_payouts_e6(response["payouts"]) + except ValueError as error: + raise UnexpectedResponseError( + f"{cls.__name__} response did not match expected shape" + ) from error + return super().parse_response(response) + + +def _parse_payouts_e6(value: object) -> tuple[Decimal, Decimal] | None: + if value is None: + return None + if not isinstance(value, list | tuple): + raise ValueError("Expected a payout pair") + items = cast(list[object] | tuple[object, ...], value) + if len(items) != 2: + raise ValueError("Expected a payout pair") + amounts = tuple(decimal_from_number(item) for item in items) + if not all(amount.is_finite() for amount in amounts): + raise ValueError("Expected finite payouts") + return amounts[0] / 1_000_000, amounts[1] / 1_000_000 + + +__all__ = ["Resolution"] diff --git a/src/polymarket/models/types.py b/src/polymarket/models/types.py index ac60c897..2c71b15c 100644 --- a/src/polymarket/models/types.py +++ b/src/polymarket/models/types.py @@ -1,5 +1,6 @@ """Model-specific Polymarket domain types.""" +import re from typing import Literal, NewType, TypeAlias OrderSide: TypeAlias = Literal["BUY", "SELL"] @@ -48,6 +49,27 @@ def to_ctf_condition_id(value: str) -> CtfConditionId: return to_condition_id(value) +def to_market_condition_id(value: str) -> ConditionId: + """Return a market condition ID, padding a structural market ID when needed.""" + if type(value) is not str or re.fullmatch(r"0x[0-9a-fA-F]+", value) is None: + raise TypeError("Expected a hex market condition ID") + normalized = value.lower() + if len(normalized) == 66: + return ConditionId(normalized) + if len(normalized) == 64 and normalized.startswith(("0x01", "0x02")): + return ConditionId(normalized + "00") + raise TypeError("Expected a 32-byte condition ID or a 31-byte structural market ID") + + +def validate_market_condition_id(value: object) -> ConditionId: + if not isinstance(value, str): + raise ValueError("Expected a market condition ID") + try: + return to_market_condition_id(value) + except TypeError as error: + raise ValueError(str(error)) from error + + def to_combo_condition_id(value: str) -> ComboConditionId: if not _is_hex_string(value): raise TypeError(f"Expected a protocol v2 combo condition ID, received: {value}") @@ -164,6 +186,8 @@ def _is_hex_string(value: object) -> bool: "TokenId", "to_combo_condition_id", "to_condition_id", + "to_market_condition_id", + "validate_market_condition_id", "to_ctf_condition_id", "validate_combo_condition_id", "validate_condition_id", diff --git a/tests/fixtures/data_v2/activity.json b/tests/fixtures/data_v2/activity.json new file mode 100644 index 00000000..68e799d2 --- /dev/null +++ b/tests/fixtures/data_v2/activity.json @@ -0,0 +1,48 @@ +[ + { + "proxy_wallet": "0x7c3db723f1d4d8cb9c550095203b686cb11e5c6b", + "timestamp": 1788886609, + "condition_id": "0x0a91645ed98ad09817e051a17466cff087b920393aefba0fe74e8735fc192f15", + "type": "TRADE", + "size": 14.285715, + "usdc_size": 13.285715, + "transaction_hash": "0xa661459fc2483ddb4e5c7d13c76347fa2b1fb2faae10eed484c810ae46c471dd", + "price": 0.9300000035, + "token_id": "98255364887669934733864645030488537034263699416357729920734267612805930214172", + "side": "BUY", + "outcome_index": 0, + "title": "LAPTOP FDV above $25M one day after launch?", + "slug": "laptop-fdv-above-25m-one-day-after-launch", + "icon": "https://polymarket-upload.s3.us-east-2.amazonaws.com/laptop-fdv-above-25m-one-day-after-launch-UQwx2GeHcu_f.jpg", + "event_slug": "laptop-fdv-above-one-day-after-launch", + "outcome": "Yes", + "name": "Car", + "pseudonym": "Peppery-Capital", + "bio": "what is your plan", + "profile_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/profile-image-501613-aa434e55-7732-41b1-9650-83a9d1d716ef.png", + "profile_image_optimized": "" + }, + { + "proxy_wallet": "0x7c3db723f1d4d8cb9c550095203b686cb11e5c6b", + "timestamp": 1788886026, + "condition_id": "0x706beb0ebd2edd12bbe8ec141a94181a8b67fb2b630c66304db8e70828a2cd7f", + "type": "TRADE", + "size": 155.03, + "usdc_size": 13.89215, + "transaction_hash": "0xe0631ac3c5899c6eafff31fde1a62c6dcb6cff3644cceae345136f725d54a5bb", + "price": 0.0864503644, + "token_id": "48806077328000909419484823670508609131899330142378469532880640080613055214499", + "side": "BUY", + "outcome_index": 0, + "title": "Will the Republican Party candidate win the 2026 Arkansas Senate election by 5%-10%?", + "slug": "will-the-republican-party-candidate-win-the-2026-arkansas-senate-election-by-5-10", + "icon": "https://polymarket-upload.s3.us-east-2.amazonaws.com/will-the-republican-party-win-the-popular-vote-in-the-2026-us-house-of-representatives-midterm-elections-by-6-or-more-KZdstWMJdb8e.png", + "event_slug": "arkansas-senate-margin-of-victory-2026", + "outcome": "Yes", + "name": "Car", + "pseudonym": "Peppery-Capital", + "bio": "what is your plan", + "profile_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/profile-image-501613-aa434e55-7732-41b1-9650-83a9d1d716ef.png", + "profile_image_optimized": "" + } +] diff --git a/tests/fixtures/data_v2/activity_combos.json b/tests/fixtures/data_v2/activity_combos.json new file mode 100644 index 00000000..0f505de3 --- /dev/null +++ b/tests/fixtures/data_v2/activity_combos.json @@ -0,0 +1,318 @@ +[ + { + "id": "0xc9ae95786f3313f11e7d6fa5baf60da44776cb549e510be4fc64cdcb2b0a5135-359", + "type": "SPLIT", + "proxy_wallet": "0x7c3db723f1d4d8cb9c550095203b686cb11e5c6b", + "combo_condition_id": "0x030b98f62eadaee25b8c04abe48fb41d480000000000000000000000000000", + "combo_position_id": "1377429565969768194445658926463361565610412667506844289284639465465251364864", + "amount_usdc": 27.393837, + "payout_usdc": null, + "block_number": 91104221, + "timestamp": 1785361352, + "transaction_hash": "0xc9ae95786f3313f11e7d6fa5baf60da44776cb549e510be4fc64cdcb2b0a5135", + "legs": [ + { + "leg_index": 0, + "leg_position_id": "537592301394906578030321356864481012104568960231674000893108985352914206721", + "leg_condition_id": "0x435620fa180dbedb59d34f164d82a447304436b57e87850ca3d1ab2070667006", + "leg_outcome_index": 1, + "leg_outcome_label": "No", + "leg_status": "RESOLVED_WIN", + "leg_resolved_at": "2026-07-29T23:01:47Z", + "leg_current_price": 1.0, + "market": { + "market_id": "3054121", + "slug": "mlb-col-sd-2026-07-29", + "title": "Colorado Rockies vs. San Diego Padres", + "question": "Colorado Rockies vs. San Diego Padres", + "group_item_title": "", + "sports_market_type": "moneyline", + "line": null, + "outcomes": [ + "Colorado Rockies", + "San Diego Padres" + ], + "outcome": "No", + "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/Repetitive-markets/MLB.jpg", + "icon_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/Repetitive-markets/MLB.jpg", + "category": "", + "subcategory": "", + "tags": [], + "end_date": "2026-08-05T20:10:00Z", + "event": { + "event_id": "738032", + "event_slug": "mlb-col-sd-2026-07-29", + "event_title": "Colorado Rockies vs. San Diego Padres", + "event_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/Repetitive-markets/MLB.jpg" + } + } + }, + { + "leg_index": 1, + "leg_position_id": "670712369311889852583401451697603096700903416546879073321645823228609822720", + "leg_condition_id": "0xdc9291fb5367d9eb43dd5f359ff74ae87b9c1874b8597e1500898a063ea925ab", + "leg_outcome_index": 0, + "leg_outcome_label": "Yes", + "leg_status": "RESOLVED_LOSS", + "leg_resolved_at": "2026-07-29T22:25:33Z", + "leg_current_price": 0.0, + "market": { + "market_id": "3134194", + "slug": "wta-navarro-cocciar-2026-07-29", + "title": "Mubadala Citi DC Open: Emma Navarro vs Elisabetta Cocciaretto", + "question": "Mubadala Citi DC Open: Emma Navarro vs Elisabetta Cocciaretto", + "group_item_title": "", + "sports_market_type": "moneyline", + "line": null, + "outcomes": [ + "Emma Navarro", + "Elisabetta Cocciaretto" + ], + "outcome": "Yes", + "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png", + "icon_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png", + "category": "", + "subcategory": "", + "tags": [], + "end_date": "2026-08-05T14:00:00Z", + "event": { + "event_id": "757507", + "event_slug": "wta-navarro-cocciar-2026-07-29", + "event_title": "Mubadala Citi DC Open: Emma Navarro vs Elisabetta Cocciaretto", + "event_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png" + } + } + }, + { + "leg_index": 2, + "leg_position_id": "865855195970414409277923152173318732766997913579482440317699896364274548737", + "leg_condition_id": "0xeb24a4ebd21a1351b50151b8213cea02ea0e822f5aee2c4d5d72c452235db1b0", + "leg_outcome_index": 1, + "leg_outcome_label": "No", + "leg_status": "RESOLVED_WIN", + "leg_resolved_at": "2026-07-29T23:06:42Z", + "leg_current_price": 1.0, + "market": { + "market_id": "3054119", + "slug": "mlb-mil-sf-2026-07-29", + "title": "Milwaukee Brewers vs. San Francisco Giants", + "question": "Milwaukee Brewers vs. San Francisco Giants", + "group_item_title": "", + "sports_market_type": "moneyline", + "line": null, + "outcomes": [ + "Milwaukee Brewers", + "San Francisco Giants" + ], + "outcome": "No", + "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/Repetitive-markets/MLB.jpg", + "icon_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/Repetitive-markets/MLB.jpg", + "category": "", + "subcategory": "", + "tags": [], + "end_date": "2026-08-05T19:45:00Z", + "event": { + "event_id": "738031", + "event_slug": "mlb-mil-sf-2026-07-29", + "event_title": "Milwaukee Brewers vs. San Francisco Giants", + "event_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/Repetitive-markets/MLB.jpg" + } + } + }, + { + "leg_index": 3, + "leg_position_id": "932347778883073360067949554423831260321490741993285763134219883824812130304", + "leg_condition_id": "0x644f180d25cbe72e0795497dc1905045cae8bd75e8cf9628f3ae36af9f05d763", + "leg_outcome_index": 0, + "leg_outcome_label": "Yes", + "leg_status": "RESOLVED_WIN", + "leg_resolved_at": "2026-07-30T01:21:42Z", + "leg_current_price": 1.0, + "market": { + "market_id": "2948606", + "slug": "arg-def-rie-2026-07-29-def", + "title": "CSyD Defensa y Justicia", + "question": "Will CSyD Defensa y Justicia win on 2026-07-29?", + "group_item_title": "CSyD Defensa y Justicia", + "sports_market_type": "moneyline", + "line": null, + "outcomes": [ + "Yes", + "No" + ], + "outcome": "Yes", + "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/argentina-primera-division-fdedea0d80.png", + "icon_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/argentina-primera-division-fdedea0d80.png", + "category": "", + "subcategory": "", + "tags": [], + "end_date": "2026-07-29T20:00:00Z", + "event": { + "event_id": "710707", + "event_slug": "arg-def-rie-2026-07-29", + "event_title": "CSyD Defensa y Justicia vs. CD Riestra", + "event_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/argentina-primera-division-fdedea0d80.png" + } + } + } + ] + }, + { + "id": "0xb1507e73fed463aa69361ab5906c71f696d8a63548f0536b35b678623a642c15-275", + "type": "SPLIT", + "proxy_wallet": "0x7c3db723f1d4d8cb9c550095203b686cb11e5c6b", + "combo_condition_id": "0x03ef28da8ebf10dee70883e098bf1e9cbe0000000000000000000000000000", + "combo_position_id": "1779496956387023451018773818174760111630377278647208440000180064737394950144", + "amount_usdc": 40.823578, + "payout_usdc": null, + "block_number": 91104213, + "timestamp": 1785361340, + "transaction_hash": "0xb1507e73fed463aa69361ab5906c71f696d8a63548f0536b35b678623a642c15", + "legs": [ + { + "leg_index": 0, + "leg_position_id": "471522926896185725944311987420306526392743232527698550880094496675279667201", + "leg_condition_id": "0x043736d0436c4816eecc5ec73f6d78560adf5d68058c9a359cff4a1aebeb6ce9", + "leg_outcome_index": 1, + "leg_outcome_label": "No", + "leg_status": "RESOLVED_WIN", + "leg_resolved_at": "2026-07-29T23:20:59Z", + "leg_current_price": 1.0, + "market": { + "market_id": "3151338", + "slug": "wta-wong-sakatsu-2026-07-28", + "title": "Vancouver: Cody Wong vs Himeno Sakatsume", + "question": "Vancouver: Cody Wong vs Himeno Sakatsume", + "group_item_title": "", + "sports_market_type": "moneyline", + "line": null, + "outcomes": [ + "Cody Wong", + "Himeno Sakatsume" + ], + "outcome": "No", + "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png", + "icon_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png", + "category": "", + "subcategory": "", + "tags": [], + "end_date": "2026-08-05T02:00:00Z", + "event": { + "event_id": "760606", + "event_slug": "wta-wong-sakatsu-2026-07-28", + "event_title": "Vancouver: Cody Wong vs Himeno Sakatsume", + "event_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png" + } + } + }, + { + "leg_index": 1, + "leg_position_id": "670712369311889852583401451697603096700903416546879073321645823228609822720", + "leg_condition_id": "0xdc9291fb5367d9eb43dd5f359ff74ae87b9c1874b8597e1500898a063ea925ab", + "leg_outcome_index": 0, + "leg_outcome_label": "Yes", + "leg_status": "RESOLVED_LOSS", + "leg_resolved_at": "2026-07-29T22:25:33Z", + "leg_current_price": 0.0, + "market": { + "market_id": "3134194", + "slug": "wta-navarro-cocciar-2026-07-29", + "title": "Mubadala Citi DC Open: Emma Navarro vs Elisabetta Cocciaretto", + "question": "Mubadala Citi DC Open: Emma Navarro vs Elisabetta Cocciaretto", + "group_item_title": "", + "sports_market_type": "moneyline", + "line": null, + "outcomes": [ + "Emma Navarro", + "Elisabetta Cocciaretto" + ], + "outcome": "Yes", + "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png", + "icon_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png", + "category": "", + "subcategory": "", + "tags": [], + "end_date": "2026-08-05T14:00:00Z", + "event": { + "event_id": "757507", + "event_slug": "wta-navarro-cocciar-2026-07-29", + "event_title": "Mubadala Citi DC Open: Emma Navarro vs Elisabetta Cocciaretto", + "event_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png" + } + } + }, + { + "leg_index": 2, + "leg_position_id": "714438888368126497495556326170948963995829842385279409111669191217384521728", + "leg_condition_id": "0x2829fabcda2d6ac528f89c9c27da42ae945bab30b4723118400c5b24234cd145", + "leg_outcome_index": 0, + "leg_outcome_label": "Yes", + "leg_status": "RESOLVED_LOSS", + "leg_resolved_at": "2026-07-29T23:50:59Z", + "leg_current_price": 0.0, + "market": { + "market_id": "3191835", + "slug": "wta-sonmez-kalieva-2026-07-29", + "title": "The Memphis Classic: Zeynep Sonmez vs Elvina Kalieva", + "question": "The Memphis Classic: Zeynep Sonmez vs Elvina Kalieva", + "group_item_title": "", + "sports_market_type": "moneyline", + "line": null, + "outcomes": [ + "Zeynep Sonmez", + "Elvina Kalieva" + ], + "outcome": "Yes", + "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png", + "icon_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png", + "category": "", + "subcategory": "", + "tags": [], + "end_date": "2026-08-05T15:00:00Z", + "event": { + "event_id": "765504", + "event_slug": "wta-sonmez-kalieva-2026-07-29", + "event_title": "The Memphis Classic: Zeynep Sonmez vs Elvina Kalieva", + "event_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png" + } + } + }, + { + "leg_index": 3, + "leg_position_id": "814822063019673274014396489695648900389052847752741169265273123402013474816", + "leg_condition_id": "0xaeedaa07a4cf3422758e7284c95ab749cd2c461424a0146e7a5eac021856ee20", + "leg_outcome_index": 0, + "leg_outcome_label": "Yes", + "leg_status": "RESOLVED_WIN", + "leg_resolved_at": "2026-07-30T00:52:57Z", + "leg_current_price": 1.0, + "market": { + "market_id": "3196295", + "slug": "wta-hontama-lu-2026-07-29", + "title": "Vancouver: Mai Hontama vs Jiajing Lu", + "question": "Vancouver: Mai Hontama vs Jiajing Lu", + "group_item_title": "", + "sports_market_type": "moneyline", + "line": null, + "outcomes": [ + "Mai Hontama", + "Jiajing Lu" + ], + "outcome": "Yes", + "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png", + "icon_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png", + "category": "", + "subcategory": "", + "tags": [], + "end_date": "2026-08-05T20:00:00Z", + "event": { + "event_id": "766502", + "event_slug": "wta-hontama-lu-2026-07-29", + "event_title": "Vancouver: Mai Hontama vs Jiajing Lu", + "event_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png" + } + } + } + ] + } +] diff --git a/tests/fixtures/data_v2/biggest-winners.json b/tests/fixtures/data_v2/biggest-winners.json new file mode 100644 index 00000000..ad1b3d8b --- /dev/null +++ b/tests/fixtures/data_v2/biggest-winners.json @@ -0,0 +1,34 @@ +[ + { + "win_rank": 1, + "kind": "market", + "user_id": "0x0f6f76ced62a911bccef92f50faaff143854d977", + "pnl": 605166.254269, + "initial_value": 342438.9272675703, + "final_value": 947605.1815365703, + "resolved_at": 1788815151, + "condition_id": "0xc49afbcf2abadda0a5e5cb65de7e9d32c694d0252657f9cf524f892f78bd12c6", + "position_id": "89987438275800490253644049803519421014246079685963440379193646937260636183942", + "event_id": 906946, + "event_slug": "sea-udi-laz-2026-09-07", + "event_title": "Udinese Calcio vs. SS Lazio", + "user_name": "totoro3miyazaki", + "profile_image": "" + }, + { + "win_rank": 2, + "kind": "market", + "user_id": "0x31e5d54aded22aa7cd80dbe9e33102abe2504879", + "pnl": 441600.333974, + "initial_value": 338410.5747567699, + "final_value": 780010.90873077, + "resolved_at": 1788833296, + "condition_id": "0xc340bd1a00f838b4d6e01ef78737525e28611b010755af341b95cc909a6909a1", + "position_id": "64250917139939360825140876006288355225510390739618522408087890757626102720007", + "event_id": 975445, + "event_slug": "atp-khachan-tien-2026-09-07", + "event_title": "US Open ATP: Karen Khachanov vs Learner Tien", + "user_name": "Noprajsk", + "profile_image": "" + } +] diff --git a/tests/fixtures/data_v2/biggest-winners_combos.json b/tests/fixtures/data_v2/biggest-winners_combos.json new file mode 100644 index 00000000..b1266df8 --- /dev/null +++ b/tests/fixtures/data_v2/biggest-winners_combos.json @@ -0,0 +1,34 @@ +[ + { + "win_rank": 1, + "kind": "combo", + "user_id": "0x52a3d09dfc47bacc18634dda9d962be530c0ebae", + "pnl": 18850.657054, + "initial_value": 6011.009993, + "final_value": 24861.667047, + "resolved_at": 1788841102, + "condition_id": "0x034ac2995e3c79456b6dc479c041840d710000000000000000000000000000", + "position_id": "1489028302130749909393502144137050574966865845692975023495969610710307045376", + "event_id": 0, + "event_slug": "", + "event_title": "Cleveland Guardians vs. Baltimore Orioles / Spread: Los Angeles Dodgers (-1.5)", + "user_name": "lebronstinks", + "profile_image": "" + }, + { + "win_rank": 2, + "kind": "combo", + "user_id": "0xee88371850f0e1332c526e1b8e6f43a7e3ef2826", + "pnl": 18433.461481, + "initial_value": 28427.617524, + "final_value": 46861.079005, + "resolved_at": 1788833404, + "condition_id": "0x034a13236e4063bf50f54c8251fd8acd3e0000000000000000000000000000", + "position_id": "1487817316932962420729822757388213655719565867999465295119740623688564736001", + "event_id": 0, + "event_slug": "", + "event_title": "US Open ATP: Karen Khachanov vs Learner Tien / US Open ATP: Francisco Cerundolo vs Alexander Blockx", + "user_name": "comboseoa", + "profile_image": "" + } +] diff --git a/tests/fixtures/data_v2/builders_leaderboard.json b/tests/fixtures/data_v2/builders_leaderboard.json new file mode 100644 index 00000000..a2293562 --- /dev/null +++ b/tests/fixtures/data_v2/builders_leaderboard.json @@ -0,0 +1,20 @@ +[ + { + "rank": 1, + "builder_name": "betmoar", + "builder_code": "0xceebf77a833b30520287ddd9478ff51abbdffa30aa90a8d655dba0e8a79ce0c1", + "profile_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/betmoar_2.png", + "verified": true, + "volume": 3420022.424486001, + "active_users": 165 + }, + { + "rank": 2, + "builder_name": "Gate", + "builder_code": "0x2c6624a23b16a2a69acd14c87b7fc03906870c851fa0a7b7f9d7be0fbbedea8a", + "profile_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/profile-image-6204678-8b397176-c77c-4533-9620-8037e82399cf.png", + "verified": true, + "volume": 1711992.9589699996, + "active_users": 2 + } +] diff --git a/tests/fixtures/data_v2/builders_volume.json b/tests/fixtures/data_v2/builders_volume.json new file mode 100644 index 00000000..7e8cc188 --- /dev/null +++ b/tests/fixtures/data_v2/builders_volume.json @@ -0,0 +1,22 @@ +[ + { + "date": "2026-09-08", + "rank": 1, + "builder_name": "betmoar", + "builder_code": "0xceebf77a833b30520287ddd9478ff51abbdffa30aa90a8d655dba0e8a79ce0c1", + "profile_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/betmoar_2.png", + "verified": true, + "volume": 3419926.8151110006, + "active_users": 165 + }, + { + "date": "2026-09-08", + "rank": 2, + "builder_name": "Gate", + "builder_code": "0x2c6624a23b16a2a69acd14c87b7fc03906870c851fa0a7b7f9d7be0fbbedea8a", + "profile_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/profile-image-6204678-8b397176-c77c-4533-9620-8037e82399cf.png", + "verified": true, + "volume": 1711987.5905479996, + "active_users": 2 + } +] diff --git a/tests/fixtures/data_v2/holders.json b/tests/fixtures/data_v2/holders.json new file mode 100644 index 00000000..8936544b --- /dev/null +++ b/tests/fixtures/data_v2/holders.json @@ -0,0 +1,52 @@ +[ + { + "token_id": "92338023949892178944669766466918011858071833335063600591564160751176113496073", + "holders": [ + { + "proxy_wallet": "0x1b5ca5e84705cd14ca9953b57434094cac726c0f", + "bio": "", + "token_id": "92338023949892178944669766466918011858071833335063600591564160751176113496073", + "pseudonym": "", + "amount": 1878.77239, + "display_username_public": false, + "outcome_index": 0, + "name": "", + "profile_image": "", + "profile_image_optimized": "", + "verified": false, + "avg_price": 0.2794, + "entry_cost_usdc": 524.991, + "current_price": 1.0, + "current_value": 1878.7723, + "realized_pnl": 0.0001, + "unrealized_pnl": 1353.7812, + "total_pnl": 1353.7813 + } + ] + }, + { + "token_id": "93730658350732768649834008472861182019192630377489777653117511245710309885595", + "holders": [ + { + "proxy_wallet": "0xe339bba7fba043520d83516ce22add16beb6e01b", + "bio": "", + "token_id": "93730658350732768649834008472861182019192630377489777653117511245710309885595", + "pseudonym": "Ignorant-Wool-Snorer", + "amount": 7493500.0, + "display_username_public": true, + "outcome_index": 1, + "name": "12Alexander12", + "profile_image": "", + "profile_image_optimized": "", + "verified": false, + "avg_price": 0.001, + "entry_cost_usdc": 7493.5, + "current_price": 0.0, + "current_value": 0.0, + "realized_pnl": 0.0, + "unrealized_pnl": -7493.5, + "total_pnl": -7493.5 + } + ] + } +] diff --git a/tests/fixtures/data_v2/leaderboard.json b/tests/fixtures/data_v2/leaderboard.json new file mode 100644 index 00000000..aa7bd2b8 --- /dev/null +++ b/tests/fixtures/data_v2/leaderboard.json @@ -0,0 +1,22 @@ +[ + { + "rank": 1, + "user_id": "0x31e5d54aded22aa7cd80dbe9e33102abe2504879", + "pnl": 966725.5781960003, + "volume": 1491184.383039, + "user_name": "Noprajsk", + "profile_image": "", + "x_username": "", + "verified": false + }, + { + "rank": 2, + "user_id": "0xf0318c32136c2db7fec88b84869aee6a1106c80c", + "pnl": 454273.13724022976, + "volume": 74566.884519, + "user_name": "BreakTheBank", + "profile_image": "", + "x_username": "", + "verified": false + } +] diff --git a/tests/fixtures/data_v2/leaderboard_standing.json b/tests/fixtures/data_v2/leaderboard_standing.json new file mode 100644 index 00000000..c928c67b --- /dev/null +++ b/tests/fixtures/data_v2/leaderboard_standing.json @@ -0,0 +1,11 @@ +{ + "user_id": "0x7c3db723f1d4d8cb9c550095203b686cb11e5c6b", + "pnl": 957.4473660823187, + "volume": 64431.760178000004, + "rank_pnl": 1045, + "rank_volume": 424, + "user_name": "Car", + "profile_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/profile-image-501613-aa434e55-7732-41b1-9650-83a9d1d716ef.png", + "x_username": "CarOnPolymarket", + "verified": true +} diff --git a/tests/fixtures/data_v2/live-volume.json b/tests/fixtures/data_v2/live-volume.json new file mode 100644 index 00000000..915a4768 --- /dev/null +++ b/tests/fixtures/data_v2/live-volume.json @@ -0,0 +1,37 @@ +{ + "taker_volume_total": 4439931.266595, + "conditions": [ + { + "condition_id": "0xe7f239d76b59c4e614f0bdb80467026da833083e38a31a27fde8e0907417ea12", + "taker_volume": 1057493.399838 + }, + { + "condition_id": "0x4d162a40c3e3f458b0e0017485d7f9e6ead0cdd0573e38b26e0d5420525ccfa4", + "taker_volume": 780331.374022 + }, + { + "condition_id": "0xc8929e80e74ae959b3ed9b9e1c5e0903ad38e02e1339462e3acf8b49bbca35a9", + "taker_volume": 588112.023677 + }, + { + "condition_id": "0x02507350fc2b81c3af36c659a805adbda9b0a81e63a27b29cb273cdd7dbc072e", + "taker_volume": 576353.148052 + }, + { + "condition_id": "0x626d0441b078eefbe2c49047960e7a3b6b4512c235507a2528ccc0f95f2474a6", + "taker_volume": 507319.025175 + }, + { + "condition_id": "0xb4022c0b2718eca7ad27195f2d48f06527fa000269d188e1d3001ff8bbc16956", + "taker_volume": 479549.81298 + }, + { + "condition_id": "0xc60022fe066abd6f96c375adb09f38d92c4931f09c10b805354581b4e5465e93", + "taker_volume": 313649.055908 + }, + { + "condition_id": "0x4092815fea8f91e60586882d45fa2f61bfca8a36d595f47fdea9eec5d2893025", + "taker_volume": 137123.426943 + } + ] +} diff --git a/tests/fixtures/data_v2/oi.json b/tests/fixtures/data_v2/oi.json new file mode 100644 index 00000000..74a4bc35 --- /dev/null +++ b/tests/fixtures/data_v2/oi.json @@ -0,0 +1,6 @@ +[ + { + "condition_id": "GLOBAL", + "value": 356037494.1056115 + } +] diff --git a/tests/fixtures/data_v2/positions.json b/tests/fixtures/data_v2/positions.json new file mode 100644 index 00000000..2e63c003 --- /dev/null +++ b/tests/fixtures/data_v2/positions.json @@ -0,0 +1,76 @@ +[ + { + "proxy_wallet": "0x7c3db723f1d4d8cb9c550095203b686cb11e5c6b", + "token_id": "94476829201604408463453426454480212459887267917122244941405244686637914508323", + "condition_id": "0x7ad403c3508f8e3912940fd1a913f227591145ca0614074208e0b962d5fcc422", + "current_size": 400000.0, + "avg_price": 0.5, + "entry_cost_usdc": 200000.0, + "entry_fees_usdc": 0.0, + "total_cost_usdc": 200000.0, + "current_price": 0.7545, + "current_value": 301800.0, + "total_size": 10386654.0, + "realized_pnl": 0.0, + "unrealized_pnl": 101800.0, + "total_pnl": 101800.0, + "percent_pnl": 50.9, + "percent_realized_pnl": -94.1886, + "status": "OPEN", + "redeemable": false, + "mergeable": true, + "negative_risk": true, + "archived": false, + "title": "Will JD Vance win the 2028 US Presidential Election?", + "slug": "will-jd-vance-win-the-2028-us-presidential-election", + "icon": "https://polymarket-upload.s3.us-east-2.amazonaws.com/will-jd-vance-win-the-2028-us-presidential-election-P-zEgXjCWbdY.png", + "event_id": "31552", + "event_slug": "presidential-election-winner-2028", + "outcome": "No", + "outcome_index": 1, + "opposite_outcome": "Yes", + "opposite_token_id": "16040015440196279900485035793550429453516625694844857319147506590755961451627", + "end_date": "2028-11-07", + "last_event_at": 1787499970, + "name": "Car", + "profile_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/profile-image-501613-aa434e55-7732-41b1-9650-83a9d1d716ef.png", + "verified": true + }, + { + "proxy_wallet": "0x7c3db723f1d4d8cb9c550095203b686cb11e5c6b", + "token_id": "16040015440196279900485035793550429453516625694844857319147506590755961451627", + "condition_id": "0x7ad403c3508f8e3912940fd1a913f227591145ca0614074208e0b962d5fcc422", + "current_size": 400000.0, + "avg_price": 0.5, + "entry_cost_usdc": 200000.0, + "entry_fees_usdc": 0.0, + "total_cost_usdc": 200000.0, + "current_price": 0.2455, + "current_value": 98200.0, + "total_size": 10386654.0, + "realized_pnl": 0.0, + "unrealized_pnl": -101800.0, + "total_pnl": -101800.0, + "percent_pnl": -50.9, + "percent_realized_pnl": -98.1091, + "status": "OPEN", + "redeemable": false, + "mergeable": true, + "negative_risk": true, + "archived": false, + "title": "Will JD Vance win the 2028 US Presidential Election?", + "slug": "will-jd-vance-win-the-2028-us-presidential-election", + "icon": "https://polymarket-upload.s3.us-east-2.amazonaws.com/will-jd-vance-win-the-2028-us-presidential-election-P-zEgXjCWbdY.png", + "event_id": "31552", + "event_slug": "presidential-election-winner-2028", + "outcome": "Yes", + "outcome_index": 0, + "opposite_outcome": "No", + "opposite_token_id": "94476829201604408463453426454480212459887267917122244941405244686637914508323", + "end_date": "2028-11-07", + "last_event_at": 1787499970, + "name": "Car", + "profile_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/profile-image-501613-aa434e55-7732-41b1-9650-83a9d1d716ef.png", + "verified": true + } +] diff --git a/tests/fixtures/data_v2/positions_combos.json b/tests/fixtures/data_v2/positions_combos.json new file mode 100644 index 00000000..7cd462de --- /dev/null +++ b/tests/fixtures/data_v2/positions_combos.json @@ -0,0 +1,340 @@ +[ + { + "combo_condition_id": "0x030b98f62eadaee25b8c04abe48fb41d480000000000000000000000000000", + "outcome_index": 0, + "outcome_label": "Yes", + "combo_position_id": "1377429565969768194445658926463361565610412667506844289284639465465251364864", + "proxy_wallet": "0x7c3db723f1d4d8cb9c550095203b686cb11e5c6b", + "current_size": 27.393837, + "entry_avg_price_usdc": 0.498563, + "entry_cost_usdc": 13.657553, + "gross_entry_cost_usdc": 14.0, + "entry_fees_usdc": 0.34242, + "realized_payout_usdc": 0.0, + "status": "RESOLVED_LOSS", + "redeemable": false, + "first_entry_at": "2026-07-29T21:42:32Z", + "resolved_at": "2026-07-29T22:25:33Z", + "legs_total": 4, + "legs_resolved": 4, + "legs_pending": 0, + "legs": [ + { + "leg_index": 0, + "leg_position_id": "537592301394906578030321356864481012104568960231674000893108985352914206721", + "leg_condition_id": "0x01304436b57e87850ca3d1ab20706670060000000000000000000000000000", + "leg_outcome_index": 1, + "leg_outcome_label": "San Diego Padres", + "leg_status": "RESOLVED_WIN", + "leg_resolved_at": "2026-07-29T23:06:30Z", + "leg_current_price": 1.0, + "market": { + "market_id": "3054121", + "slug": "mlb-col-sd-2026-07-29", + "title": "Colorado Rockies vs. San Diego Padres", + "question": "Colorado Rockies vs. San Diego Padres", + "group_item_title": "", + "sports_market_type": "moneyline", + "line": null, + "outcomes": [ + "Colorado Rockies", + "San Diego Padres" + ], + "outcome": "San Diego Padres", + "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/Repetitive-markets/MLB.jpg", + "icon_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/Repetitive-markets/MLB.jpg", + "category": "", + "subcategory": "", + "tags": [], + "end_date": "2026-08-05", + "event": { + "event_id": "738032", + "event_slug": "mlb-col-sd-2026-07-29", + "event_title": "Colorado Rockies vs. San Diego Padres", + "event_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/Repetitive-markets/MLB.jpg" + } + } + }, + { + "leg_index": 1, + "leg_position_id": "670712369311889852583401451697603096700903416546879073321645823228609822720", + "leg_condition_id": "0x017b9c1874b8597e1500898a063ea925ab0000000000000000000000000000", + "leg_outcome_index": 0, + "leg_outcome_label": "Emma Navarro", + "leg_status": "RESOLVED_LOSS", + "leg_resolved_at": "2026-07-29T22:42:21Z", + "leg_current_price": 0.0, + "market": { + "market_id": "3134194", + "slug": "wta-navarro-cocciar-2026-07-29", + "title": "Mubadala Citi DC Open: Emma Navarro vs Elisabetta Cocciaretto", + "question": "Mubadala Citi DC Open: Emma Navarro vs Elisabetta Cocciaretto", + "group_item_title": "", + "sports_market_type": "moneyline", + "line": null, + "outcomes": [ + "Emma Navarro", + "Elisabetta Cocciaretto" + ], + "outcome": "Emma Navarro", + "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png", + "icon_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png", + "category": "", + "subcategory": "", + "tags": [], + "end_date": "2026-08-05", + "event": { + "event_id": "757507", + "event_slug": "wta-navarro-cocciar-2026-07-29", + "event_title": "Mubadala Citi DC Open: Emma Navarro vs Elisabetta Cocciaretto", + "event_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png" + } + } + }, + { + "leg_index": 2, + "leg_position_id": "865855195970414409277923152173318732766997913579482440317699896364274548737", + "leg_condition_id": "0x01ea0e822f5aee2c4d5d72c452235db1b00000000000000000000000000000", + "leg_outcome_index": 1, + "leg_outcome_label": "San Francisco Giants", + "leg_status": "RESOLVED_WIN", + "leg_resolved_at": "2026-07-30T03:25:42Z", + "leg_current_price": 1.0, + "market": { + "market_id": "3054119", + "slug": "mlb-mil-sf-2026-07-29", + "title": "Milwaukee Brewers vs. San Francisco Giants", + "question": "Milwaukee Brewers vs. San Francisco Giants", + "group_item_title": "", + "sports_market_type": "moneyline", + "line": null, + "outcomes": [ + "Milwaukee Brewers", + "San Francisco Giants" + ], + "outcome": "San Francisco Giants", + "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/Repetitive-markets/MLB.jpg", + "icon_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/Repetitive-markets/MLB.jpg", + "category": "", + "subcategory": "", + "tags": [], + "end_date": "2026-08-05", + "event": { + "event_id": "738031", + "event_slug": "mlb-mil-sf-2026-07-29", + "event_title": "Milwaukee Brewers vs. San Francisco Giants", + "event_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/Repetitive-markets/MLB.jpg" + } + } + }, + { + "leg_index": 3, + "leg_position_id": "932347778883073360067949554423831260321490741993285763134219883824812130304", + "leg_condition_id": "0x020fb0ad28fe2eb51a9dedbdb39bb433000003000000000000000000000000", + "leg_outcome_index": 0, + "leg_outcome_label": "Yes", + "leg_status": "RESOLVED_WIN", + "leg_resolved_at": "2026-07-30T01:21:47Z", + "leg_current_price": 1.0, + "market": { + "market_id": "2948606", + "slug": "arg-def-rie-2026-07-29-def", + "title": "CSyD Defensa y Justicia", + "question": "Will CSyD Defensa y Justicia win on 2026-07-29?", + "group_item_title": "CSyD Defensa y Justicia", + "sports_market_type": "moneyline", + "line": null, + "outcomes": [ + "Yes", + "No" + ], + "outcome": "Yes", + "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/argentina-primera-division-fdedea0d80.png", + "icon_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/argentina-primera-division-fdedea0d80.png", + "category": "", + "subcategory": "", + "tags": [], + "end_date": "2026-07-29", + "event": { + "event_id": "710707", + "event_slug": "arg-def-rie-2026-07-29", + "event_title": "CSyD Defensa y Justicia vs. CD Riestra", + "event_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/argentina-primera-division-fdedea0d80.png" + } + } + } + ], + "first_entry_at_micros": 1785361352000000, + "updated_at": "2026-07-30T15:38:32Z", + "updated_at_micros": 1785425912234415 + }, + { + "combo_condition_id": "0x03ef28da8ebf10dee70883e098bf1e9cbe0000000000000000000000000000", + "outcome_index": 0, + "outcome_label": "Yes", + "combo_position_id": "1779496956387023451018773818174760111630377278647208440000180064737394950144", + "proxy_wallet": "0x7c3db723f1d4d8cb9c550095203b686cb11e5c6b", + "current_size": 40.823578, + "entry_avg_price_usdc": 0.355971, + "entry_cost_usdc": 14.532009, + "gross_entry_cost_usdc": 15.0, + "entry_fees_usdc": 0.46795, + "realized_payout_usdc": 0.0, + "status": "RESOLVED_LOSS", + "redeemable": false, + "first_entry_at": "2026-07-29T21:42:20Z", + "resolved_at": "2026-07-29T22:25:33Z", + "legs_total": 4, + "legs_resolved": 4, + "legs_pending": 0, + "legs": [ + { + "leg_index": 0, + "leg_position_id": "471522926896185725944311987420306526392743232527698550880094496675279667201", + "leg_condition_id": "0x010adf5d68058c9a359cff4a1aebeb6ce90000000000000000000000000000", + "leg_outcome_index": 1, + "leg_outcome_label": "Himeno Sakatsume", + "leg_status": "RESOLVED_WIN", + "leg_resolved_at": "2026-07-29T23:23:23Z", + "leg_current_price": 1.0, + "market": { + "market_id": "3151338", + "slug": "wta-wong-sakatsu-2026-07-28", + "title": "Vancouver: Cody Wong vs Himeno Sakatsume", + "question": "Vancouver: Cody Wong vs Himeno Sakatsume", + "group_item_title": "", + "sports_market_type": "moneyline", + "line": null, + "outcomes": [ + "Cody Wong", + "Himeno Sakatsume" + ], + "outcome": "Himeno Sakatsume", + "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png", + "icon_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png", + "category": "", + "subcategory": "", + "tags": [], + "end_date": "2026-08-05", + "event": { + "event_id": "760606", + "event_slug": "wta-wong-sakatsu-2026-07-28", + "event_title": "Vancouver: Cody Wong vs Himeno Sakatsume", + "event_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png" + } + } + }, + { + "leg_index": 1, + "leg_position_id": "670712369311889852583401451697603096700903416546879073321645823228609822720", + "leg_condition_id": "0x017b9c1874b8597e1500898a063ea925ab0000000000000000000000000000", + "leg_outcome_index": 0, + "leg_outcome_label": "Emma Navarro", + "leg_status": "RESOLVED_LOSS", + "leg_resolved_at": "2026-07-29T22:42:21Z", + "leg_current_price": 0.0, + "market": { + "market_id": "3134194", + "slug": "wta-navarro-cocciar-2026-07-29", + "title": "Mubadala Citi DC Open: Emma Navarro vs Elisabetta Cocciaretto", + "question": "Mubadala Citi DC Open: Emma Navarro vs Elisabetta Cocciaretto", + "group_item_title": "", + "sports_market_type": "moneyline", + "line": null, + "outcomes": [ + "Emma Navarro", + "Elisabetta Cocciaretto" + ], + "outcome": "Emma Navarro", + "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png", + "icon_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png", + "category": "", + "subcategory": "", + "tags": [], + "end_date": "2026-08-05", + "event": { + "event_id": "757507", + "event_slug": "wta-navarro-cocciar-2026-07-29", + "event_title": "Mubadala Citi DC Open: Emma Navarro vs Elisabetta Cocciaretto", + "event_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png" + } + } + }, + { + "leg_index": 2, + "leg_position_id": "714438888368126497495556326170948963995829842385279409111669191217384521728", + "leg_condition_id": "0x01945bab30b4723118400c5b24234cd1450000000000000000000000000000", + "leg_outcome_index": 0, + "leg_outcome_label": "Zeynep Sonmez", + "leg_status": "RESOLVED_LOSS", + "leg_resolved_at": "2026-07-29T23:51:33Z", + "leg_current_price": 0.0, + "market": { + "market_id": "3191835", + "slug": "wta-sonmez-kalieva-2026-07-29", + "title": "The Memphis Classic: Zeynep Sonmez vs Elvina Kalieva", + "question": "The Memphis Classic: Zeynep Sonmez vs Elvina Kalieva", + "group_item_title": "", + "sports_market_type": "moneyline", + "line": null, + "outcomes": [ + "Zeynep Sonmez", + "Elvina Kalieva" + ], + "outcome": "Zeynep Sonmez", + "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png", + "icon_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png", + "category": "", + "subcategory": "", + "tags": [], + "end_date": "2026-08-05", + "event": { + "event_id": "765504", + "event_slug": "wta-sonmez-kalieva-2026-07-29", + "event_title": "The Memphis Classic: Zeynep Sonmez vs Elvina Kalieva", + "event_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png" + } + } + }, + { + "leg_index": 3, + "leg_position_id": "814822063019673274014396489695648900389052847752741169265273123402013474816", + "leg_condition_id": "0x01cd2c461424a0146e7a5eac021856ee200000000000000000000000000000", + "leg_outcome_index": 0, + "leg_outcome_label": "Mai Hontama", + "leg_status": "RESOLVED_WIN", + "leg_resolved_at": "2026-07-30T00:53:00Z", + "leg_current_price": 1.0, + "market": { + "market_id": "3196295", + "slug": "wta-hontama-lu-2026-07-29", + "title": "Vancouver: Mai Hontama vs Jiajing Lu", + "question": "Vancouver: Mai Hontama vs Jiajing Lu", + "group_item_title": "", + "sports_market_type": "moneyline", + "line": null, + "outcomes": [ + "Mai Hontama", + "Jiajing Lu" + ], + "outcome": "Mai Hontama", + "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png", + "icon_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png", + "category": "", + "subcategory": "", + "tags": [], + "end_date": "2026-08-05", + "event": { + "event_id": "766502", + "event_slug": "wta-hontama-lu-2026-07-29", + "event_title": "Vancouver: Mai Hontama vs Jiajing Lu", + "event_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/wta-logo-41f331bdf1.png" + } + } + } + ], + "first_entry_at_micros": 1785361340000000, + "updated_at": "2026-07-30T15:38:32Z", + "updated_at_micros": 1785425912234415 + } +] diff --git a/tests/fixtures/data_v2/prices-history.json b/tests/fixtures/data_v2/prices-history.json new file mode 100644 index 00000000..b9645514 --- /dev/null +++ b/tests/fixtures/data_v2/prices-history.json @@ -0,0 +1,12 @@ +[ + { + "timestamp": 1788800640, + "price": 0.955, + "resolution_seconds": 60 + }, + { + "timestamp": 1788800700, + "price": 0.955, + "resolution_seconds": 60 + } +] diff --git a/tests/fixtures/data_v2/resolutions.json b/tests/fixtures/data_v2/resolutions.json new file mode 100644 index 00000000..2554dec5 --- /dev/null +++ b/tests/fixtures/data_v2/resolutions.json @@ -0,0 +1,30 @@ +[ + { + "question_id": "0x957f210b69549a74d3a4779e9a31cd7099d1c55948e2f24a7c281562ab609e0d", + "condition_id": "0x02507350fc2b81c3af36c659a805adbda9b0a81e63a27b29cb273cdd7dbc072e", + "status": "resolved", + "extended_review": false, + "was_disputed": false, + "new_version_q": true, + "proposed_price": "0", + "reproposed_price": "69", + "price": "0", + "transaction_hash": "0x1fc7a1df48dcac15859af515ebd77183c3487591181469a60dbf115b705f005a", + "log_index": "170", + "last_update_timestamp": "1785355919" + }, + { + "question_id": "0xe69ec507b4fd82c8a6f4eebcf849bf679eca44937c5e4b5dcdf3297cca5f5168", + "condition_id": "0x4092815fea8f91e60586882d45fa2f61bfca8a36d595f47fdea9eec5d2893025", + "status": "posed", + "extended_review": false, + "was_disputed": false, + "new_version_q": true, + "proposed_price": "69", + "reproposed_price": "69", + "price": "69", + "transaction_hash": "0x792e52190f4638b1f0629caef0727c32eec751242e3aa22c632d2b00dfe228f5", + "log_index": "139", + "last_update_timestamp": "1772065618" + } +] diff --git a/tests/fixtures/data_v2/trades.json b/tests/fixtures/data_v2/trades.json new file mode 100644 index 00000000..2efa686d --- /dev/null +++ b/tests/fixtures/data_v2/trades.json @@ -0,0 +1,44 @@ +[ + { + "proxy_wallet": "0x84cfffc3f16dcc353094de30d4a45226eccd2f63", + "side": "BUY", + "token_id": "100621704916396384184205224105871827982844371725320616822883322969092312254616", + "condition_id": "0xc8a2f17f42fa8493ebae503abec9d51efa7b57c3cc59a7b88933bffaa3618387", + "size": 78.55, + "price": 0.62, + "timestamp": 1788887014, + "title": "Will Real Madrid CF win on 2026-09-08?", + "slug": "ucl-rma-int-2026-09-08-rma", + "icon": "https://polymarket-upload.s3.us-east-2.amazonaws.com/champions-league-pic-QIUFsL8vaDdq.png", + "event_slug": "", + "outcome": "Yes", + "outcome_index": 999, + "name": "mooseborzoi", + "pseudonym": "Agitated-Bricklaying", + "bio": "", + "profile_image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/profile-image-7634413-4dea3150-84ad-4278-9e45-d96e51769b7e.png", + "profile_image_optimized": "", + "transaction_hash": "0x8b2fd0c7480cd78bd9f57e8b935cf10c6635d2c479c66ea26d358e1425f44a95" + }, + { + "proxy_wallet": "0x8998260fc9ed9fb76169c291493b77d3cfe62707", + "side": "SELL", + "token_id": "10569747745236392570963097049930476889202788711910112387314269913275253427324", + "condition_id": "0xe50512309bd9d7e537f1cec79f2d2c2634cc073081226ec11ba7e13c469c0449", + "size": 0.07, + "price": 0.84, + "timestamp": 1788887014, + "title": "Will Joe Burrow finish the 2026-27 NFL regular season with the most passing yards?", + "slug": "will-joe-burrow-finish-the-2026-27-nfl-regular-season-with-the-most-passing-yards-20260715211220968", + "icon": "", + "event_slug": "", + "outcome": "No", + "outcome_index": 999, + "name": "pmqianbaolaile", + "pseudonym": "Spicy-Neighbour", + "bio": "", + "profile_image": "", + "profile_image_optimized": "", + "transaction_hash": "0x146b03a91c743a9f8757f8c448c74edc0d898621ea4716766d38ae79eabbc52e" + } +] diff --git a/tests/fixtures/data_v2/user-pnl.json b/tests/fixtures/data_v2/user-pnl.json new file mode 100644 index 00000000..d2ea814f --- /dev/null +++ b/tests/fixtures/data_v2/user-pnl.json @@ -0,0 +1,66 @@ +{ + "proxy_wallet": "0x7c3db723f1d4d8cb9c550095203b686cb11e5c6b", + "interval": "1d", + "fidelity": "1h", + "source_fidelity": "1d", + "points": [ + { + "timestamp": 1788804000, + "source_block": 93399250, + "realized_market_pnl": 1498891.628372, + "realized_lp_pnl": 0.0, + "realized_combo_pnl": -2413.666714, + "unrealized_pnl": -4717.221184, + "fees_refunded": 23304.304408, + "maker_rebate": 2423.7697, + "taker_rebate": 415.2364, + "reward_income": 82057.219325, + "yield_income": 5818.3804, + "referral_income": 4203.2775, + "deposits": null, + "withdrawals": null, + "realized_pnl": 1496477.961658, + "wallet_income": 94917.883325, + "position_pnl": 1491760.740474, + "settled_pnl": 1591395.844983, + "economic_pnl": 1586678.623799, + "trade_pnl": 1501064.684977, + "sponsored_income": 92078.877225, + "fees": -32608.248911, + "fees_paid": -9303.944503, + "cashflow_net": null, + "volume": 181486732.642472, + "volume_usdc": 111381926.238625, + "trade_count": 248346 + }, + { + "timestamp": 1788807600, + "source_block": 93399250, + "realized_market_pnl": 1498891.628372, + "realized_lp_pnl": 0.0, + "realized_combo_pnl": -2413.666714, + "unrealized_pnl": -4717.221184, + "fees_refunded": 23304.304408, + "maker_rebate": 2423.7697, + "taker_rebate": 415.2364, + "reward_income": 82057.219325, + "yield_income": 5818.3804, + "referral_income": 4203.2775, + "deposits": null, + "withdrawals": null, + "realized_pnl": 1496477.961658, + "wallet_income": 94917.883325, + "position_pnl": 1491760.740474, + "settled_pnl": 1591395.844983, + "economic_pnl": 1586678.623799, + "trade_pnl": 1501064.684977, + "sponsored_income": 92078.877225, + "fees": -32608.248911, + "fees_paid": -9303.944503, + "cashflow_net": null, + "volume": 181486732.642472, + "volume_usdc": 111381926.238625, + "trade_count": 248346 + } + ] +} diff --git a/tests/fixtures/data_v2/user-stats.json b/tests/fixtures/data_v2/user-stats.json new file mode 100644 index 00000000..579573b4 --- /dev/null +++ b/tests/fixtures/data_v2/user-stats.json @@ -0,0 +1,36 @@ +{ + "proxy_wallet": "0x7c3db723f1d4d8cb9c550095203b686cb11e5c6b", + "trades": 8610, + "biggest_win": 45382.060625, + "views": 548360, + "join_date": 1707440911, + "all_time_pnl": { + "timestamp": 1788886825, + "source_block": 93454528, + "realized_market_pnl": 1499790.531679, + "realized_lp_pnl": 0.0, + "realized_combo_pnl": -2413.666714, + "unrealized_pnl": -4296.673533, + "fees_refunded": 23304.304408, + "maker_rebate": 2423.7697, + "taker_rebate": 415.2364, + "reward_income": 82057.219325, + "yield_income": 5818.3804, + "referral_income": 4203.2775, + "deposits": null, + "withdrawals": null, + "realized_pnl": 1497376.864965, + "wallet_income": 94917.883325, + "position_pnl": 1493080.191432, + "settled_pnl": 1592294.74829, + "economic_pnl": 1587998.074757, + "trade_pnl": 1502445.596155, + "sponsored_income": 92078.877225, + "fees": -32669.709131, + "fees_paid": -9365.404723, + "cashflow_net": null, + "volume": 181584565.810966, + "volume_usdc": 111439641.610182, + "trade_count": 248765 + } +} diff --git a/tests/fixtures/data_v2/user-volume.json b/tests/fixtures/data_v2/user-volume.json new file mode 100644 index 00000000..49afa989 --- /dev/null +++ b/tests/fixtures/data_v2/user-volume.json @@ -0,0 +1,5 @@ +{ + "volume": 181648102.305407, + "volume_usdc": 111472628.603504, + "trade_count": 248955 +} diff --git a/tests/fixtures/data_v2/value.json b/tests/fixtures/data_v2/value.json new file mode 100644 index 00000000..08f13abb --- /dev/null +++ b/tests/fixtures/data_v2/value.json @@ -0,0 +1,4 @@ +{ + "proxy_wallet": "0x7c3db723f1d4d8cb9c550095203b686cb11e5c6b", + "value": 684953.8942 +} diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index edf8e40e..77f94f04 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,6 +1,6 @@ import asyncio import os -from collections.abc import AsyncGenerator, Callable +from collections.abc import AsyncGenerator, Callable, Iterator from decimal import Decimal from pathlib import Path @@ -17,6 +17,7 @@ BuilderApiKey, Environment, Market, + PublicClient, RelayerApiKey, ) from polymarket.models.types import TokenId @@ -60,6 +61,18 @@ def get(name: str) -> str: return get +@pytest.fixture +def data_reference_wallet() -> str: + """Public wallet with portfolio, trade, and combo history for read-only tests.""" + return "0x7c3db723f1d4d8cb9c550095203b686cb11e5c6b" + + +@pytest.fixture +def data_empty_wallet() -> str: + """Public address used to check absent wallet statistics.""" + return "0x00000000000000000000000000000000000000aa" + + @pytest.fixture def combo_leg_position_ids() -> list[str] | None: _load_dotenv() @@ -165,6 +178,12 @@ async def public_client( yield client +@pytest.fixture(scope="session") +def sync_public_client(integration_environment: Environment) -> Iterator[PublicClient]: + with PublicClient(environment=integration_environment) as client: + yield client + + @pytest.fixture(scope="session") def active_clob_token(integration_environment: Environment) -> TokenId: async def find() -> TokenId: diff --git a/tests/integration/test_clob_reads.py b/tests/integration/test_clob_reads.py index 2e01fa4b..52f00c3c 100644 --- a/tests/integration/test_clob_reads.py +++ b/tests/integration/test_clob_reads.py @@ -12,7 +12,6 @@ LastTradePriceForToken, OrderBook, OrderSide, - PriceHistoryPoint, PriceRequest, ) from polymarket.models.types import ClobAssetId, TokenId @@ -173,17 +172,3 @@ async def run() -> tuple[LastTradePriceForToken, ...]: assert len(result) >= 1 assert any(point.token_id == active_clob_token for point in result) - - -@pytest.mark.integration -def test_async_get_price_history_returns_points(active_clob_token: TokenId) -> None: - async def run() -> tuple[PriceHistoryPoint, ...]: - async with AsyncPublicClient() as client: - return await client.get_price_history(token_id=active_clob_token, interval="1d") - - points = asyncio.run(run()) - - assert isinstance(points, tuple) - for point in points: - assert isinstance(point.t, int) - assert isinstance(point.p, float) diff --git a/tests/integration/test_data.py b/tests/integration/test_data.py index d628abbb..5e2d29b4 100644 --- a/tests/integration/test_data.py +++ b/tests/integration/test_data.py @@ -1,206 +1,92 @@ -import asyncio +from datetime import UTC, datetime from decimal import Decimal import pytest -from polymarket import ( - AsyncPublicClient, - BuilderVolumeEntry, - LiveVolume, - MetaHolder, - OpenInterest, - PortfolioValue, - PublicClient, - TradedMarketCount, -) - -EVENT_ID = "902661" - -WALLET = "0x7c3db723f1d4d8cb9c550095203b686cb11e5c6b" - - -def _condition_ids_for_event() -> list[str]: - with PublicClient() as client: - event = client.get_event(id=EVENT_ID) - return [m.condition_id for m in event.markets if m.condition_id is not None] - - -@pytest.mark.integration -def test_get_event_live_volumes_returns_volume() -> None: - with PublicClient() as client: - volumes = client.get_event_live_volumes(id=EVENT_ID) - - assert volumes - assert all(isinstance(v, LiveVolume) for v in volumes) - assert volumes[0].total is not None - assert isinstance(volumes[0].total, Decimal) - assert volumes[0].markets is not None - - -@pytest.mark.integration -def test_get_open_interests_with_market_filter() -> None: - condition_ids = _condition_ids_for_event() - if not condition_ids: - pytest.skip("event has no condition IDs to query") - - with PublicClient() as client: - interests = client.get_open_interests(market=condition_ids) - - assert interests - assert all(isinstance(oi, OpenInterest) for oi in interests) - assert all(oi.market in condition_ids for oi in interests if oi.market is not None) - assert all(isinstance(oi.value, Decimal) for oi in interests if oi.value is not None) - - -@pytest.mark.integration -def test_get_open_interests_without_filter() -> None: - with PublicClient() as client: - interests = client.get_open_interests() - - assert all(isinstance(oi, OpenInterest) for oi in interests) - - -@pytest.mark.integration -def test_get_market_holders_returns_holders() -> None: - condition_ids = _condition_ids_for_event() - if not condition_ids: - pytest.skip("event has no condition IDs to query") - - with PublicClient() as client: - holders = client.get_market_holders(market=condition_ids[:1], limit=5) - - assert holders - assert all(isinstance(meta, MetaHolder) for meta in holders) - assert holders[0].token is not None - assert holders[0].holders is not None - - -@pytest.mark.integration -def test_get_portfolio_values_returns_values() -> None: - with PublicClient() as client: - values = client.get_portfolio_values(user=WALLET) - - assert values - assert all(isinstance(v, PortfolioValue) for v in values) - assert values[0].user == WALLET - assert values[0].value is not None - assert isinstance(values[0].value, Decimal) - - -@pytest.mark.integration -def test_get_traded_market_count_returns_count() -> None: - with PublicClient() as client: - count = client.get_traded_market_count(user=WALLET) - - assert isinstance(count, TradedMarketCount) - assert count.user == WALLET - assert count.traded is not None - assert count.traded >= 0 - - -@pytest.mark.integration -def test_get_builder_volumes_returns_entries() -> None: - with PublicClient() as client: - volumes = client.get_builder_volumes(time_period="DAY") - - assert volumes - assert all(isinstance(entry, BuilderVolumeEntry) for entry in volumes) - assert any(entry.builder is not None for entry in volumes) - assert any(entry.bucket_at is not None for entry in volumes) - assert any(isinstance(entry.volume, Decimal) for entry in volumes if entry.volume is not None) - - -@pytest.mark.integration -def test_async_get_event_live_volumes_returns_volume() -> None: - async def run() -> None: - async with AsyncPublicClient() as client: - volumes = await client.get_event_live_volumes(id=EVENT_ID) - assert volumes - assert all(isinstance(v, LiveVolume) for v in volumes) - assert volumes[0].total is not None - - asyncio.run(run()) - - -@pytest.mark.integration -def test_async_get_open_interests_without_filter() -> None: - async def run() -> None: - async with AsyncPublicClient() as client: - interests = await client.get_open_interests() - assert all(isinstance(oi, OpenInterest) for oi in interests) - - asyncio.run(run()) - - -@pytest.mark.integration -def test_async_get_market_holders_returns_holders() -> None: - condition_ids = _condition_ids_for_event() - if not condition_ids: - pytest.skip("event has no condition IDs to query") - - async def run() -> None: - async with AsyncPublicClient() as client: - holders = await client.get_market_holders(market=condition_ids[:1], limit=5) - assert holders - assert all(isinstance(meta, MetaHolder) for meta in holders) - assert holders[0].token is not None - - asyncio.run(run()) - - -@pytest.mark.integration -def test_async_get_portfolio_values_returns_values() -> None: - async def run() -> None: - async with AsyncPublicClient() as client: - values = await client.get_portfolio_values(user=WALLET) - assert values - assert all(isinstance(v, PortfolioValue) for v in values) - assert values[0].user == WALLET - - asyncio.run(run()) - - -@pytest.mark.integration -def test_async_get_traded_market_count_returns_count() -> None: - async def run() -> None: - async with AsyncPublicClient() as client: - count = await client.get_traded_market_count(user=WALLET) - assert isinstance(count, TradedMarketCount) - assert count.user == WALLET - assert count.traded is not None and count.traded >= 0 - - asyncio.run(run()) - - -@pytest.mark.integration -def test_download_accounting_snapshot_returns_zip_archive() -> None: - with PublicClient() as client: - snapshot = client.download_accounting_snapshot(user=WALLET) - - assert isinstance(snapshot, bytes) - assert len(snapshot) > 0 - assert snapshot.startswith(b"PK\x03\x04") - - -@pytest.mark.integration -def test_async_download_accounting_snapshot_returns_zip_archive() -> None: - async def run() -> bytes: - async with AsyncPublicClient() as client: - return await client.download_accounting_snapshot(user=WALLET) - - snapshot = asyncio.run(run()) - assert isinstance(snapshot, bytes) - assert len(snapshot) > 0 - assert snapshot.startswith(b"PK\x03\x04") - - -@pytest.mark.integration -def test_async_get_builder_volumes_returns_entries() -> None: - async def run() -> None: - async with AsyncPublicClient() as client: - volumes = await client.get_builder_volumes(time_period="DAY") - assert volumes - assert all(isinstance(entry, BuilderVolumeEntry) for entry in volumes) - assert any(entry.builder is not None for entry in volumes) - - asyncio.run(run()) +from polymarket import AsyncPublicClient, AsyncSecureClient, PublicClient + +pytestmark = pytest.mark.integration +CONDITION = "0xe546672750517f62c45a5a00067481981e62b9c20fa8220203232c9dc8fd2093" + + +def test_portfolio_and_metrics( + sync_public_client: PublicClient, data_reference_wallet: str, data_empty_wallet: str +) -> None: + client = sync_public_client + portfolio = client.get_portfolio_value(user=data_reference_wallet) + assert portfolio.wallet == data_reference_wallet and isinstance(portfolio.value, Decimal) + stats = client.get_user_stats(user=data_reference_wallet) + if stats is None: + pytest.skip("reference wallet no longer has statistics") + assert stats.traded_market_count > 0 and stats.all_time_pnl is not None + assert client.get_user_stats(user=data_empty_wallet) is None + pnl = client.get_user_pnl(user=data_reference_wallet, interval="1w", fidelity="1h") + assert pnl.interval == "1w" and pnl.fidelity == "1h" and pnl.source_fidelity + assert pnl.points + volume = client.get_user_volume( + user=data_reference_wallet, + start=datetime(2026, 9, 1, tzinfo=UTC), + end=datetime(2026, 9, 2, tzinfo=UTC), + ) + assert volume.volume >= 0 and volume.volume_usdc >= 0 and volume.trade_count >= 0 + + +def test_market_analytics_and_resolutions(sync_public_client: PublicClient) -> None: + client = sync_public_client + global_oi = client.get_open_interests() + assert len(global_oi) == 1 and global_oi[0].condition_id is None + named = client.get_open_interests(condition_ids=CONDITION) + if not named: + pytest.skip("reference condition has no open interest row") + assert len(named) == 1 and named[0].condition_id == CONDITION + volume = client.get_event_live_volume(event_ids=[106884]) + assert isinstance(volume.taker_volume_total, Decimal) and isinstance(volume.markets, tuple) + rows = client.get_resolutions(event_ids=[106884]) + if not rows: + pytest.skip("reference event no longer has resolution rows") + condition = rows[0].condition_id + assert condition is not None + selected = client.get_resolutions(condition_ids=condition) + assert selected and selected[0] == rows[0] + + +def test_builder_volume_counts_buckets(sync_public_client: PublicClient) -> None: + points = sync_public_client.get_builder_volumes(interval="day", bucket_limit=2) + if not points: + pytest.skip("no builder volume buckets available") + assert len({p.bucket_date for p in points}) == 2 + assert len(points) > 2 + + +def test_accounting_snapshot(sync_public_client: PublicClient, data_reference_wallet: str) -> None: + archive = sync_public_client.download_accounting_snapshot(user=data_reference_wallet) + assert archive.startswith(b"PK") and len(archive) > 100 + + +@pytest.mark.anyio +async def test_async_portfolio( + public_client: AsyncPublicClient, data_reference_wallet: str +) -> None: + value = await public_client.get_portfolio_value(user=data_reference_wallet) + assert value.wallet == data_reference_wallet and value.value >= 0 + assert ( + await public_client.get_user_pnl(user=data_reference_wallet) + ).wallet == data_reference_wallet + + +@pytest.mark.anyio +async def test_authenticated_wallet_defaults( + deposit_wallet_client: AsyncSecureClient, deposit_wallet_address: str +) -> None: + client = deposit_wallet_client + value = await client.get_portfolio_value() + assert value.wallet.lower() == deposit_wallet_address.lower() + positions = await client.list_positions().first_page() + assert all(p.wallet.lower() == deposit_wallet_address.lower() for p in positions.items) + activity = await client.list_activity().first_page() + assert all( + p.wallet and p.wallet.lower() == deposit_wallet_address.lower() for p in activity.items + ) + standing = await client.get_trader_leaderboard_standing() + if standing is not None: + assert standing.wallet.lower() == deposit_wallet_address.lower() diff --git a/tests/integration/test_data_paginated.py b/tests/integration/test_data_paginated.py index 464a879a..81a03fa1 100644 --- a/tests/integration/test_data_paginated.py +++ b/tests/integration/test_data_paginated.py @@ -1,226 +1,170 @@ -import asyncio +from typing import TypeVar import pytest -from polymarket import ( - Activity, - AsyncPublicClient, - ClosedPosition, - ComboActivity, - ComboPosition, - LeaderboardEntry, - MetaMarketPosition, - Position, - PublicClient, - Trade, - TraderLeaderboardEntry, -) - -WALLET = "0x16c9fb76d5e12c6e35738fd92223ea603004ffa7" -COMBO_WALLET = "0x7c3db723f1d4d8cb9c550095203b686cb11e5c6b" - - -def _condition_ids_for_event(event_id: str = "902661") -> list[str]: - with PublicClient() as client: - event = client.get_event(id=event_id) - return [m.condition_id for m in event.markets if m.condition_id is not None] - - -@pytest.mark.integration -def test_list_positions_first_page() -> None: - with PublicClient() as client: - page = client.list_positions(user=WALLET, page_size=5).first_page() - assert all(isinstance(p, Position) for p in page.items) - assert len(page.items) <= 5 - - -@pytest.mark.integration -def test_list_positions_iterates_lazily() -> None: - with PublicClient() as client: - paginator = client.list_positions(user=WALLET, page_size=5) - for index, page in enumerate(paginator): - assert all(isinstance(p, Position) for p in page.items) - if index >= 1: - break - - -@pytest.mark.integration -def test_list_positions_resume_from_cursor() -> None: - with PublicClient() as client: - paginator = client.list_positions(user=WALLET, page_size=2) - first = paginator.first_page() - if first.next_cursor is None: - pytest.skip("wallet has fewer than two pages of positions") - second = paginator.from_cursor(first.next_cursor).first_page() - assert all(isinstance(p, Position) for p in second.items) - - -@pytest.mark.integration -def test_list_closed_positions_first_page() -> None: - with PublicClient() as client: - page = client.list_closed_positions(user=WALLET, page_size=5).first_page() - assert all(isinstance(p, ClosedPosition) for p in page.items) - - -@pytest.mark.integration -def test_list_combo_positions_first_page() -> None: - with PublicClient() as client: - page = client.list_combo_positions(user=COMBO_WALLET, page_size=1).first_page() - assert all(isinstance(p, ComboPosition) for p in page.items) - assert len(page.items) <= 1 - - -@pytest.mark.integration -def test_list_combo_positions_filters_by_condition_id() -> None: - with PublicClient() as client: - first = client.list_combo_positions(user=COMBO_WALLET, page_size=1).first_page() - if not first.items: - pytest.skip("wallet has no combo positions") - condition_id = first.items[0].condition_id - filtered = client.list_combo_positions( - user=COMBO_WALLET, condition_id=condition_id, page_size=1 - ).first_page() - assert filtered.items - assert filtered.items[0].condition_id == condition_id - - -@pytest.mark.integration -def test_list_market_positions_first_page() -> None: - condition_ids = _condition_ids_for_event() - if not condition_ids: - pytest.skip("event has no condition IDs") - with PublicClient() as client: - page = client.list_market_positions(market=condition_ids[0], page_size=5).first_page() - assert all(isinstance(p, MetaMarketPosition) for p in page.items) - - -@pytest.mark.integration -def test_list_trades_first_page() -> None: - with PublicClient() as client: - page = client.list_trades(user=WALLET, page_size=5).first_page() - assert all(isinstance(t, Trade) for t in page.items) - - -@pytest.mark.integration -def test_list_activity_first_page() -> None: - with PublicClient() as client: - page = client.list_activity(user=WALLET, page_size=5).first_page() - valid_activity = (Activity.__args__) if hasattr(Activity, "__args__") else () - assert all(isinstance(a, valid_activity) for a in page.items) if valid_activity else True - - -@pytest.mark.integration -def test_list_combo_activity_first_page() -> None: - with PublicClient() as client: - page = client.list_combo_activity(user=COMBO_WALLET, page_size=1).first_page() - valid_activity = (ComboActivity.__args__) if hasattr(ComboActivity, "__args__") else () - assert all(isinstance(a, valid_activity) for a in page.items) if valid_activity else True - assert len(page.items) <= 1 - - -@pytest.mark.integration -def test_list_builder_leaderboard_first_page_has_results() -> None: - with PublicClient() as client: - page = client.list_builder_leaderboard(time_period="DAY", page_size=5).first_page() - assert len(page.items) > 0 - assert all(isinstance(entry, LeaderboardEntry) for entry in page.items) - - -@pytest.mark.integration -def test_list_trader_leaderboard_first_page_has_results() -> None: - with PublicClient() as client: - page = client.list_trader_leaderboard( - category="OVERALL", - time_period="DAY", - order_by="VOL", - page_size=5, - ).first_page() - assert len(page.items) > 0 - assert all(isinstance(entry, TraderLeaderboardEntry) for entry in page.items) - - -@pytest.mark.integration -def test_async_list_positions_iterates_lazily() -> None: - async def run() -> None: - async with AsyncPublicClient() as client: - paginator = client.list_positions(user=WALLET, page_size=5) - count = 0 - async for page in paginator: - assert all(isinstance(p, Position) for p in page.items) - count += 1 - if count >= 2: - break - - asyncio.run(run()) - - -@pytest.mark.integration -def test_async_list_trades_first_page() -> None: - async def run() -> None: - async with AsyncPublicClient() as client: - page = await client.list_trades(user=WALLET, page_size=5).first_page() - assert all(isinstance(t, Trade) for t in page.items) - - asyncio.run(run()) - - -@pytest.mark.integration -def test_async_list_activity_first_page() -> None: - async def run() -> None: - async with AsyncPublicClient() as client: - page = await client.list_activity(user=WALLET, page_size=5).first_page() - assert isinstance(page.items, tuple) - - asyncio.run(run()) - - -@pytest.mark.integration -def test_async_list_builder_leaderboard_first_page() -> None: - async def run() -> None: - async with AsyncPublicClient() as client: - page = await client.list_builder_leaderboard( - time_period="DAY", page_size=5 - ).first_page() - assert len(page.items) > 0 - assert all(isinstance(entry, LeaderboardEntry) for entry in page.items) - - asyncio.run(run()) - - -@pytest.mark.integration -def test_async_list_trader_leaderboard_first_page() -> None: - async def run() -> None: - async with AsyncPublicClient() as client: - page = await client.list_trader_leaderboard( - category="OVERALL", time_period="DAY", order_by="VOL", page_size=5 - ).first_page() - assert len(page.items) > 0 - assert all(isinstance(entry, TraderLeaderboardEntry) for entry in page.items) - - asyncio.run(run()) - - -@pytest.mark.integration -def test_async_list_market_positions_first_page() -> None: - condition_ids = _condition_ids_for_event() - if not condition_ids: - pytest.skip("event has no condition IDs") - - async def run() -> None: - async with AsyncPublicClient() as client: - page = await client.list_market_positions( - market=condition_ids[0], page_size=5 - ).first_page() - assert all(isinstance(p, MetaMarketPosition) for p in page.items) - - asyncio.run(run()) - - -@pytest.mark.integration -def test_async_list_closed_positions_first_page() -> None: - async def run() -> None: - async with AsyncPublicClient() as client: - page = await client.list_closed_positions(user=WALLET, page_size=5).first_page() - assert all(isinstance(p, ClosedPosition) for p in page.items) - - asyncio.run(run()) +from polymarket import AsyncPublicClient, ComboBiggestWinner, PublicClient, TradeActivity +from polymarket.pagination import Page, Paginator + +pytestmark = pytest.mark.integration +CONDITION = "0xe546672750517f62c45a5a00067481981e62b9c20fa8220203232c9dc8fd2093" +T = TypeVar("T") + + +def two_pages(paginator: Paginator[T]) -> tuple[Page[T], Page[T]]: + first = paginator.first_page() + if not first.items: + pytest.skip("reference query no longer has rows") + if first.next_cursor is None: + pytest.skip("reference query no longer has a second page") + second = paginator.from_cursor(first.next_cursor).first_page() + assert second.items + assert second.next_cursor != first.next_cursor + return first, second + + +def test_trade_cursor_walk(sync_public_client: PublicClient) -> None: + paginator = sync_public_client.list_trades(side="BUY", page_size=5) + first, second = two_pages(paginator) + assert second.next_cursor is not None + third = paginator.from_cursor(second.next_cursor).first_page() + assert len({p.next_cursor for p in (first, second, third)}) == 3 + for page in (first, second, third): + assert len(page.items) == 5 and page.has_more + assert all(row.side == "BUY" for row in page.items) + timestamps = [row.timestamp for p in (first, second, third) for row in p.items] + assert timestamps == sorted(timestamps, reverse=True) + replay = paginator.from_cursor(first.next_cursor).first_page() + assert replay.items[0].timestamp <= first.items[-1].timestamp + + +def test_activity_and_combo_activity( + sync_public_client: PublicClient, data_reference_wallet: str +) -> None: + page = sync_public_client.list_activity( + user=data_reference_wallet, activity_types=["TRADE"] + ).first_page() + if not page.items: + pytest.skip("reference wallet no longer has trade activity") + assert all(p.type == "TRADE" and p.wallet == data_reference_wallet for p in page.items) + first, second = two_pages( + sync_public_client.list_combo_activity(user=data_reference_wallet, page_size=1) + ) + for row in first.items + second.items: + assert row.position_id and row.block_number > 0 + assert row.legs and row.legs[0].market and row.legs[0].market.question + assert ("payout" in type(row).model_fields) == (row.type == "REDEEM") + + +def test_positions_and_market_anchor( + sync_public_client: PublicClient, data_reference_wallet: str +) -> None: + two_pages(sync_public_client.list_positions(user=data_reference_wallet, page_size=100)) + closed = sync_public_client.list_positions( + user=data_reference_wallet, status="CLOSED" + ).first_page() + if not closed.items: + pytest.skip("reference wallet no longer has closed positions") + assert all(row.status == "CLOSED" for row in closed.items) + market = sync_public_client.list_positions(condition_id=CONDITION).first_page() + assert len({row.wallet for row in market.items}) > 1 + + +def test_combo_positions_and_filters( + sync_public_client: PublicClient, data_reference_wallet: str +) -> None: + first, second = two_pages( + sync_public_client.list_combo_positions(user=data_reference_wallet, page_size=1) + ) + for row in first.items + second.items: + assert row.current_size >= 0 and row.gross_entry_cost_usdc >= 0 and row.entry_fees_usdc >= 0 + condition = first.items[0].condition_id + selected = sync_public_client.list_combo_positions( + user=data_reference_wallet, condition_id=condition + ).first_page() + assert selected.items and all(row.condition_id == condition for row in selected.items) + resolved = two_pages( + sync_public_client.list_combo_positions( + user=data_reference_wallet, + status=["RESOLVED_WIN", "RESOLVED_PARTIAL", "RESOLVED_LOSS"], + page_size=1, + ) + ) + assert all( + row.status in ("RESOLVED_WIN", "RESOLVED_PARTIAL", "RESOLVED_LOSS") + for p in resolved + for row in p.items + ) + + +def test_holders_pagination(sync_public_client: PublicClient) -> None: + first, second = two_pages( + sync_public_client.list_market_holders( + condition_ids=CONDITION, include_pnl=True, min_balance=0, page_size=1 + ) + ) + for group in first.items + second.items: + assert len(group.holders) <= 1 + assert all(h.asset_id == group.asset_id and h.total_pnl is not None for h in group.holders) + + +def test_price_history_pages(sync_public_client: PublicClient, active_clob_token: str) -> None: + first, second = two_pages( + sync_public_client.list_price_history( + asset_id=active_clob_token, interval="1d", page_size=2 + ) + ) + assert second.items[0].timestamp > first.items[-1].timestamp + assert all(p.resolution_seconds >= 0 for p in first.items + second.items) + + +def test_leaderboards_and_standing(sync_public_client: PublicClient) -> None: + first, second = two_pages( + sync_public_client.list_trader_leaderboard( + category="sports", window="week", sort_by="VOLUME", page_size=1 + ) + ) + assert first.items[0].rank <= second.items[0].rank + top = sync_public_client.list_trader_leaderboard( + category="combos", window="all", page_size=1 + ).first_page() + if not top.items: + pytest.skip("no combo leaderboard rows available") + standing = sync_public_client.get_trader_leaderboard_standing( + user=top.items[0].wallet, category="combos", window="all" + ) + assert standing is not None and standing.pnl_rank is not None + winners = sync_public_client.list_biggest_winners(category="combos", page_size=2).first_page() + assert winners.items and all(isinstance(row, ComboBiggestWinner) for row in winners.items) + builders = two_pages(sync_public_client.list_builder_leaderboard(window="all", page_size=1)) + assert all( + len(row.builder_code) == 66 and row.builder_code.startswith("0x") + for p in builders + for row in p.items + ) + + +@pytest.mark.anyio +async def test_async_trade_pages( + public_client: AsyncPublicClient, data_reference_wallet: str +) -> None: + paginator = public_client.list_activity( + user=data_reference_wallet, activity_types=["TRADE"], page_size=2 + ) + first = await paginator.first_page() + if not first.items or first.next_cursor is None: + pytest.skip("reference wallet has insufficient activity") + second = await paginator.from_cursor(first.next_cursor).first_page() + assert second.items + assert all( + isinstance(row, TradeActivity) and row.wallet == data_reference_wallet + for row in first.items + second.items + ) + + +def test_frames_on_live_data(sync_public_client: PublicClient, data_reference_wallet: str) -> None: + positions = sync_public_client.list_positions(user=data_reference_wallet).first_page() + if not positions.items: + pytest.skip("reference wallet has no positions") + assert "current_size" in positions.to_pandas().columns + frame = sync_public_client.list_trades().to_polars(limit=50) + assert len(frame) == 50 and "asset_id" in frame.columns diff --git a/tests/unit/data_v2_samples.py b/tests/unit/data_v2_samples.py new file mode 100644 index 00000000..7df14fcf --- /dev/null +++ b/tests/unit/data_v2_samples.py @@ -0,0 +1,14 @@ +"""Small public production response samples captured on 2026-09-08.""" + +import json +from pathlib import Path +from typing import Any, cast + + +def sample(name: str) -> Any: + path = Path(__file__).parents[1] / "fixtures" / "data_v2" / f"{name}.json" + return json.loads(path.read_text()) + + +def position_payload(**overrides: object) -> dict[str, Any]: + return {**cast(dict[str, Any], sample("positions")[0]), **overrides} diff --git a/tests/unit/test_client_request_params.py b/tests/unit/test_client_request_params.py index 01744607..a13a9ba1 100644 --- a/tests/unit/test_client_request_params.py +++ b/tests/unit/test_client_request_params.py @@ -1,270 +1,215 @@ # pyright: reportPrivateUsage=false import asyncio import dataclasses -from typing import Any, cast -from urllib.parse import parse_qs, urlparse +from typing import Any import httpx import pytest +from data_v2_samples import position_payload, sample -from polymarket import ( - ApiKeyCreds, - AsyncPublicClient, - AsyncSecureClient, - PublicClient, - SecureClient, -) -from polymarket._internal.context import AsyncSecureClientContext, SyncSecureClientContext +from polymarket import ApiKeyCreds, AsyncPublicClient, AsyncSecureClient, PublicClient, SecureClient from polymarket.clients._transport import AsyncTransport, SyncTransport +from polymarket.errors import UserInputError PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" -SIGNER_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" -FAKE_CREDS = ApiKeyCreds(key="test-key", passphrase="test-passphrase", secret="dGVzdA==") - - -def _capture(captured: list[httpx.Request], payload: Any = ()) -> httpx.MockTransport: - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - return httpx.Response(200, json=payload, request=request) - - return httpx.MockTransport(handler) - - -def _sync_data_transport(handler: httpx.MockTransport) -> SyncTransport: - return SyncTransport( - base_url="https://example.test", - client=httpx.Client(base_url="https://example.test", transport=handler), - ) - - -def _async_data_transport(handler: httpx.MockTransport) -> AsyncTransport: - return AsyncTransport( - base_url="https://example.test", - client=httpx.AsyncClient(base_url="https://example.test", transport=handler), - ) - - -def _install_sync(client: PublicClient | SecureClient, handler: httpx.MockTransport) -> None: - transport = _sync_data_transport(handler) - # pyright loses narrowing through the union; cast to the secure shape — both - # clients accept it via subtype substitution. - client._ctx = cast(SyncSecureClientContext, dataclasses.replace(client._ctx, data=transport)) - - -def _install_async( - client: AsyncPublicClient | AsyncSecureClient, handler: httpx.MockTransport +WALLET = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" +CREDS = ApiKeyCreds(key="test", passphrase="test", secret="dGVzdA==") +CONDITION = "0x" + "ab" * 32 +COMBO = "0x03" + "ab" * 30 + + +@pytest.mark.parametrize("mode", ["public", "secure", "async_public", "async_secure"]) +@pytest.mark.parametrize( + "method,kwargs,query", + [ + ( + "list_trades", + {"condition_id": [CONDITION], "side": "BUY", "full_history": True}, + {"condition_id": CONDITION, "side": "BUY", "start": "1"}, + ), + ( + "list_activity", + {"event_id": [7, 8], "activity_types": ["TRADE", "TIP"], "full_history": True}, + { + "event_id": "7,8", + "type": "TRADE,TIP", + "exclude_deposits_withdrawals": "false", + "start": "1", + }, + ), + ("list_combo_activity", {"condition_id": COMBO}, {"condition_id": COMBO}), + ( + "list_positions", + {"condition_id": CONDITION, "full_history": True}, + {"condition_id": CONDITION}, + ), + ("list_positions", {}, {}), + ( + "list_combo_positions", + {"condition_id": COMBO, "status": ["RESOLVED_WIN", "RESOLVED_LOSS"]}, + {"condition_id": COMBO, "status": "RESOLVED_WIN,RESOLVED_LOSS"}, + ), + ("list_combo_positions", {"updated_after": 0}, {"updated_after": "0"}), + ("list_combo_positions", {"updated_before": 0}, {"updated_before": "0"}), + ( + "list_combo_positions", + {"updated_after": 0, "updated_before": 0}, + {"updated_after": "0", "updated_before": "0"}, + ), + ], +) +def test_feed_queries_cursor_replay_and_wallet_binding( + mode: str, + method: str, + kwargs: dict[str, Any], + query: dict[str, str], ) -> None: - transport = _async_data_transport(handler) - client._ctx = cast(AsyncSecureClientContext, dataclasses.replace(client._ctx, data=transport)) - - -def _qs(request: httpx.Request) -> dict[str, list[str]]: - return parse_qs(urlparse(str(request.url)).query) - - -# ---- list_trades: user is optional on Public ---- - - -def test_public_list_trades_user_is_optional() -> None: captured: list[httpx.Request] = [] - with PublicClient() as client: - _install_sync(client, _capture(captured)) - client.list_trades(market=["0xMARKET"]).first_page() - - qs = _qs(captured[0]) - assert qs.get("market") == ["0xMARKET"] - assert "user" not in qs - - -def test_async_public_list_trades_user_is_optional() -> None: - captured: list[httpx.Request] = [] - - async def run() -> None: - async with AsyncPublicClient() as client: - _install_async(client, _capture(captured)) - await client.list_trades(market=["0xMARKET"]).first_page() - - asyncio.run(run()) - qs = _qs(captured[0]) - assert qs.get("market") == ["0xMARKET"] - assert "user" not in qs - - -# ---- list_trades: event_id passthrough ---- - - -def test_public_list_trades_passes_event_id() -> None: - captured: list[httpx.Request] = [] - with PublicClient() as client: - _install_sync(client, _capture(captured)) - client.list_trades(event_id=[42, 43]).first_page() - - assert _qs(captured[0]).get("eventId") == ["42,43"] - - -def test_async_public_list_trades_passes_event_id() -> None: - captured: list[httpx.Request] = [] - - async def run() -> None: - async with AsyncPublicClient() as client: - _install_async(client, _capture(captured)) - await client.list_trades(event_id=[42]).first_page() - - asyncio.run(run()) - assert _qs(captured[0]).get("eventId") == ["42"] - - -def test_secure_list_trades_passes_event_id() -> None: - captured: list[httpx.Request] = [] - with SecureClient._create( - private_key=PRIVATE_KEY, - wallet=SIGNER_ADDRESS, - credentials=FAKE_CREDS, - validate_credentials=False, - ) as client: - _install_sync(client, _capture(captured)) - client.list_trades(event_id=[7]).first_page() - - qs = _qs(captured[0]) - assert qs.get("eventId") == ["7"] - assert qs.get("user") == [SIGNER_ADDRESS] - -def test_async_secure_list_trades_passes_event_id() -> None: - captured: list[httpx.Request] = [] + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + rows = [] + # Positions without native activity have SQL NULL timestamps and wire value 0. + # The backend excludes them whenever a time bound is supplied. + if ( + method == "list_positions" + and "start" not in request.url.params + and "end" not in request.url.params + ): + rows = [position_payload(last_event_at=0)] + return httpx.Response( + 200, json={"data": rows, "pagination": {"has_more": True, "next_cursor": "server-next"}} + ) async def run() -> None: - client = await AsyncSecureClient._create( - private_key=PRIVATE_KEY, - wallet=SIGNER_ADDRESS, - credentials=FAKE_CREDS, - validate_credentials=False, + client: Any + if mode == "public": + client = PublicClient() + elif mode == "secure": + client = SecureClient._create( + private_key=PRIVATE_KEY, + wallet=WALLET, + credentials=CREDS, + validate_credentials=False, + ) + elif mode == "async_public": + client = AsyncPublicClient() + else: + client = await AsyncSecureClient._create( + private_key=PRIVATE_KEY, + wallet=WALLET, + credentials=CREDS, + validate_credentials=False, + ) + asynchronous = mode.startswith("async") + http: Any = ( + httpx.AsyncClient( + base_url="https://example.test", transport=httpx.MockTransport(handler) + ) + if asynchronous + else httpx.Client( + base_url="https://example.test", transport=httpx.MockTransport(handler) + ) + ) + transport = ( + AsyncTransport(base_url="https://example.test", client=http) + if asynchronous + else SyncTransport(base_url="https://example.test", client=http) ) + if asynchronous: + await client._ctx.data.close() + else: + client._ctx.data.close() + client._ctx = dataclasses.replace(client._ctx, data=transport) + options = {**kwargs, **({"user": WALLET} if "secure" not in mode else {})} try: - _install_async(client, _capture(captured)) - await client.list_trades(event_id=[7]).first_page() + paginator = getattr(client, method)(**options, page_size=5) + first = await paginator.first_page() if asynchronous else paginator.first_page() + replay = paginator.from_cursor(first.next_cursor) + second = await replay.first_page() if asynchronous else replay.first_page() + if method == "list_positions": + for page in (first, second): + assert len(page.items) == 1 + assert page.items[0].last_event_at is None + other = getattr(client, method)( + **{**options, "user": "0x" + "cd" * 20}, page_size=5 + ).from_cursor(first.next_cursor) + with pytest.raises(UserInputError): + if asynchronous: + await other.first_page() + else: + other.first_page() finally: - await client.close() - - asyncio.run(run()) - qs = _qs(captured[0]) - assert qs.get("eventId") == ["7"] - assert qs.get("user") == [SIGNER_ADDRESS] - - -# ---- list_activity: event_id passthrough ---- - - -def test_public_list_activity_passes_event_id() -> None: - captured: list[httpx.Request] = [] - with PublicClient() as client: - _install_sync(client, _capture(captured)) - client.list_activity(user="0xUSER", event_id=[99]).first_page() - - assert _qs(captured[0]).get("eventId") == ["99"] - - -def test_async_public_list_activity_passes_event_id() -> None: - captured: list[httpx.Request] = [] - - async def run() -> None: - async with AsyncPublicClient() as client: - _install_async(client, _capture(captured)) - await client.list_activity(user="0xUSER", event_id=[99]).first_page() + if asynchronous: + await client.close() + await http.aclose() + else: + client.close() + http.close() asyncio.run(run()) - assert _qs(captured[0]).get("eventId") == ["99"] - - -def test_secure_list_activity_passes_event_id() -> None: + assert len(captured) == 2 + expected = {**query, "user": WALLET, "limit": "5"} + assert dict(captured[0].url.params) == expected + assert dict(captured[1].url.params) == {**expected, "cursor": "server-next"} + assert captured[0].url.path.startswith("/v2/") + + +@pytest.mark.parametrize("mode", ["sync", "async"]) +@pytest.mark.parametrize( + "method,fixture", + [ + ("get_portfolio_value", "value"), + ("get_user_stats", "user-stats"), + ("get_user_pnl", "user-pnl"), + ("get_user_volume", "user-volume"), + ("get_trader_leaderboard_standing", "leaderboard_standing"), + ], +) +def test_secure_metrics_bind_wallet(mode: str, method: str, fixture: str) -> None: captured: list[httpx.Request] = [] - with SecureClient._create( - private_key=PRIVATE_KEY, - wallet=SIGNER_ADDRESS, - credentials=FAKE_CREDS, - validate_credentials=False, - ) as client: - _install_sync(client, _capture(captured)) - client.list_activity(event_id=[12, 13]).first_page() - qs = _qs(captured[0]) - assert qs.get("eventId") == ["12,13"] - assert qs.get("user") == [SIGNER_ADDRESS] - - -def test_async_secure_list_activity_passes_event_id() -> None: - captured: list[httpx.Request] = [] + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json={"data": sample(fixture)}) async def run() -> None: - client = await AsyncSecureClient._create( - private_key=PRIVATE_KEY, - wallet=SIGNER_ADDRESS, - credentials=FAKE_CREDS, - validate_credentials=False, - ) - try: - _install_async(client, _capture(captured)) - await client.list_activity(event_id=[12]).first_page() - finally: - await client.close() + if mode == "sync": + with ( + SecureClient._create( + private_key=PRIVATE_KEY, + wallet=WALLET, + credentials=CREDS, + validate_credentials=False, + ) as client, + httpx.Client( + base_url="https://example.test", transport=httpx.MockTransport(handler) + ) as http, + ): + client._ctx.data.close() + client._ctx = dataclasses.replace( + client._ctx, data=SyncTransport(base_url="https://example.test", client=http) + ) + getattr(client, method)() + else: + async_client = await AsyncSecureClient._create( + private_key=PRIVATE_KEY, + wallet=WALLET, + credentials=CREDS, + validate_credentials=False, + ) + async with ( + async_client, + httpx.AsyncClient( + base_url="https://example.test", transport=httpx.MockTransport(handler) + ) as async_http, + ): + await async_client._ctx.data.close() + async_client._ctx = dataclasses.replace( + async_client._ctx, + data=AsyncTransport(base_url="https://example.test", client=async_http), + ) + await getattr(async_client, method)() asyncio.run(run()) - qs = _qs(captured[0]) - assert qs.get("eventId") == ["12"] - assert qs.get("user") == [SIGNER_ADDRESS] - - -# ---- list_positions: event_id passthrough (existed on Public; verify Secure) ---- - - -def test_public_list_positions_passes_event_id() -> None: - captured: list[httpx.Request] = [] - with PublicClient() as client: - _install_sync(client, _capture(captured)) - client.list_positions(user="0xUSER", event_id=[5]).first_page() - - assert _qs(captured[0]).get("eventId") == ["5"] - - -def test_secure_list_positions_passes_event_id() -> None: - captured: list[httpx.Request] = [] - with SecureClient._create( - private_key=PRIVATE_KEY, - wallet=SIGNER_ADDRESS, - credentials=FAKE_CREDS, - validate_credentials=False, - ) as client: - _install_sync(client, _capture(captured)) - client.list_positions(event_id=[5]).first_page() - - qs = _qs(captured[0]) - assert qs.get("eventId") == ["5"] - assert qs.get("user") == [SIGNER_ADDRESS] - - -# ---- mutual exclusivity check still surfaces ---- - - -def test_public_list_trades_rejects_market_and_event_id_together() -> None: - from polymarket.errors import UserInputError - - with ( - PublicClient() as client, - pytest.raises(UserInputError, match="Provide market or event_id"), - ): - client.list_trades(market=["0xM"], event_id=[1]) - - -def test_secure_list_trades_rejects_market_and_event_id_together() -> None: - from polymarket.errors import UserInputError - - with ( - SecureClient._create( - private_key=PRIVATE_KEY, - wallet=SIGNER_ADDRESS, - credentials=FAKE_CREDS, - validate_credentials=False, - ) as client, - pytest.raises(UserInputError, match="Provide market or event_id"), - ): - client.list_trades(market=["0xM"], event_id=[1]) + assert dict(captured[0].url.params) == {"user": WALLET} diff --git a/tests/unit/test_clob_actions.py b/tests/unit/test_clob_actions.py index fc036831..ac53b723 100644 --- a/tests/unit/test_clob_actions.py +++ b/tests/unit/test_clob_actions.py @@ -12,7 +12,6 @@ build_midpoints_request, build_order_book_request, build_order_books_request, - build_price_history_request, build_price_request, build_prices_request, build_spread_request, @@ -24,7 +23,6 @@ parse_order_book, parse_order_books, parse_price, - parse_price_history, parse_prices, parse_spread, parse_spreads, @@ -556,93 +554,3 @@ def test_parse_last_trade_prices_returns_tuple_of_models() -> None: assert result[0].token_id == "1" assert result[0].price == Decimal("0.5") assert result[0].side == "BUY" - - -def test_build_price_history_request_maps_token_id_to_market_param() -> None: - path, params = build_price_history_request(token_id="123") - - assert path == "/prices-history" - assert params == {"market": "123"} - - -def test_build_price_history_request_preserves_camelcase_optional_params() -> None: - path, params = build_price_history_request( - token_id="123", - start_ts=1000, - end_ts=2000, - fidelity=60, - interval="1d", - ) - - assert path == "/prices-history" - assert params == { - "market": "123", - "startTs": 1000, - "endTs": 2000, - "fidelity": 60, - "interval": "1d", - } - - -def test_build_price_history_request_rejects_empty_token_id() -> None: - with pytest.raises(UserInputError): - build_price_history_request(token_id="") - - -def test_build_price_history_request_rejects_negative_start_ts() -> None: - with pytest.raises(UserInputError): - build_price_history_request(token_id="1", start_ts=-1) - - -def test_build_price_history_request_rejects_non_positive_fidelity() -> None: - with pytest.raises(UserInputError): - build_price_history_request(token_id="1", fidelity=0) - - -def test_build_price_history_request_rejects_invalid_interval() -> None: - with pytest.raises(UserInputError, match="interval"): - build_price_history_request(token_id="1", interval="weekly") # type: ignore[arg-type] - - -def test_build_price_history_request_rejects_float_start_ts() -> None: - with pytest.raises(UserInputError, match="integer"): - build_price_history_request(token_id="1", start_ts=1.5) # type: ignore[arg-type] - - -def test_build_price_history_request_rejects_bool_fidelity() -> None: - with pytest.raises(UserInputError, match="integer"): - build_price_history_request(token_id="1", fidelity=True) # type: ignore[arg-type] - - -def test_parse_price_history_extracts_history_array() -> None: - payload = {"history": [{"t": 1000, "p": 0.5}, {"t": 1060, "p": 0.51}]} - - result = parse_price_history(payload) - - assert len(result) == 2 - assert result[0].t == 1000 - assert result[0].p == 0.5 - - -def test_parse_price_history_accepts_empty_history() -> None: - assert parse_price_history({"history": []}) == () - - -def test_parse_price_history_rejects_missing_history_field() -> None: - with pytest.raises(UnexpectedResponseError): - parse_price_history({}) - - -def test_parse_price_history_rejects_non_dict_response() -> None: - with pytest.raises(UnexpectedResponseError): - parse_price_history([]) - - -def test_parse_price_history_rejects_string_t_value() -> None: - with pytest.raises(UnexpectedResponseError): - parse_price_history({"history": [{"t": "1000", "p": 0.5}]}) - - -def test_parse_price_history_rejects_string_p_value() -> None: - with pytest.raises(UnexpectedResponseError): - parse_price_history({"history": [{"t": 1000, "p": "0.5"}]}) diff --git a/tests/unit/test_clob_transport.py b/tests/unit/test_clob_transport.py index d4d33b18..6e6635c8 100644 --- a/tests/unit/test_clob_transport.py +++ b/tests/unit/test_clob_transport.py @@ -18,7 +18,6 @@ LastTradePriceForToken, OrderBook, OrderSide, - PriceHistoryPoint, PriceRequest, ) from polymarket._internal.context import AsyncSecureClientContext @@ -285,69 +284,3 @@ async def run() -> tuple[LastTradePriceForToken, ...]: assert captured[0].method == "POST" assert urlparse(str(captured[0].url)).path == "/last-trades-prices" assert _body(captured[0]) == [{"token_id": "1"}, {"token_id": "2"}] - - -def test_async_get_price_history_maps_token_id_to_market_param() -> None: - captured: list[httpx.Request] = [] - - async def run() -> tuple[PriceHistoryPoint, ...]: - async with AsyncPublicClient() as client: - _install_async_clob( - client, _clob_handler(captured, {"history": [{"t": 1000, "p": 0.5}]}) - ) - return await client.get_price_history(token_id="123") - - result = asyncio.run(run()) - - assert len(result) == 1 - parsed = urlparse(str(captured[0].url)) - assert parsed.path == "/prices-history" - assert parse_qs(parsed.query) == {"market": ["123"]} - - -def test_async_secure_get_price_history_maps_asset_id_to_market_param() -> None: - captured: list[httpx.Request] = [] - - async def run() -> tuple[PriceHistoryPoint, ...]: - client = await AsyncSecureClient._create( - private_key=PRIVATE_KEY, - wallet=SIGNER_ADDRESS, - credentials=FAKE_CREDS, - validate_credentials=False, - ) - try: - _install_async_clob(client, _clob_handler(captured, {"history": []})) - return await client.get_price_history(asset_id="123") - finally: - await client.close() - - assert asyncio.run(run()) == () - parsed = urlparse(str(captured[0].url)) - assert parsed.path == "/prices-history" - assert parse_qs(parsed.query) == {"market": ["123"]} - - -def test_async_get_price_history_preserves_camelcase_optional_params_on_wire() -> None: - captured: list[httpx.Request] = [] - - async def run() -> tuple[PriceHistoryPoint, ...]: - async with AsyncPublicClient() as client: - _install_async_clob(client, _clob_handler(captured, {"history": []})) - return await client.get_price_history( - token_id="123", - start_ts=1000, - end_ts=2000, - fidelity=60, - interval="1d", - ) - - asyncio.run(run()) - - parsed_qs = parse_qs(urlparse(str(captured[0].url)).query) - assert parsed_qs == { - "market": ["123"], - "startTs": ["1000"], - "endTs": ["2000"], - "fidelity": ["60"], - "interval": ["1d"], - } diff --git a/tests/unit/test_clob_transport_sync.py b/tests/unit/test_clob_transport_sync.py index 6392ed90..ac1012d0 100644 --- a/tests/unit/test_clob_transport_sync.py +++ b/tests/unit/test_clob_transport_sync.py @@ -13,7 +13,6 @@ LastTradePrice, LastTradePriceForToken, OrderBook, - PriceHistoryPoint, PriceRequest, PublicClient, SecureClient, @@ -253,43 +252,6 @@ def test_posts_token_ids_at_correct_path(self) -> None: assert _body(captured[0]) == [{"token_id": "1"}] -class TestGetPriceHistory: - def test_maps_token_id_to_market_param(self) -> None: - captured: list[httpx.Request] = [] - with PublicClient() as client: - _install_sync_clob( - client, - _clob_handler(captured, {"history": [{"t": 1700000000, "p": 0.5}]}), - ) - result = client.get_price_history(token_id="abc") - - assert len(result) == 1 - assert isinstance(result[0], PriceHistoryPoint) - parsed = urlparse(str(captured[0].url)) - assert parsed.path == "/prices-history" - assert parse_qs(parsed.query) == {"market": ["abc"]} - - def test_preserves_camelcase_optional_params_on_wire(self) -> None: - captured: list[httpx.Request] = [] - with PublicClient() as client: - _install_sync_clob(client, _clob_handler(captured, {"history": []})) - client.get_price_history( - token_id="abc", - start_ts=1700000000, - end_ts=1700001000, - fidelity=60, - interval="1d", - ) - - parsed = urlparse(str(captured[0].url)) - qs = parse_qs(parsed.query) - assert qs["market"] == ["abc"] - assert qs["startTs"] == ["1700000000"] - assert qs["endTs"] == ["1700001000"] - assert qs["fidelity"] == ["60"] - assert qs["interval"] == ["1d"] - - class TestEstimateMarketPrice: def test_buy_uses_book_metadata(self) -> None: captured: list[httpx.Request] = [] diff --git a/tests/unit/test_data_activity.py b/tests/unit/test_data_activity.py index 81ddbddb..cd27ac42 100644 --- a/tests/unit/test_data_activity.py +++ b/tests/unit/test_data_activity.py @@ -1,263 +1,96 @@ +from datetime import UTC, datetime from decimal import Decimal import pytest +from data_v2_samples import sample from polymarket import ( + ActivityType, + ComboActivityType, ComboTradeActivity, - ConversionActivity, - DepositActivity, - MakerRebateActivity, - MergeActivity, - RedeemActivity, - ReferralRewardActivity, - RewardActivity, - SplitActivity, - TakerRebateActivity, + TipActivity, TradeActivity, UnknownActivity, - WithdrawalActivity, - YieldActivity, ) from polymarket.errors import UnexpectedResponseError -from polymarket.models.data.activity import Trade, parse_activities, parse_activity +from polymarket.models.data.activity import parse_activities, parse_activity, parse_combo_activities -_CONDITION_ID = "0x5c19f205507ce03ff5f3be08a8090a5969ea6870cc07b902a4ca2e61dfe48fdd" -_COMBO_CONDITION_ID = "0x0365b0e193b3bcd6f3f740f5b4e9ad85b40000000000000000000000000000" -_COMBO_POSITION_ID = "1536610888192297888380575190299871560736525977576785935254302389727433588736" - -def _trade_payload(**overrides: object) -> dict[str, object]: - base: dict[str, object] = { +def test_trade_amount_sentinels_and_combo_discriminator() -> None: + payload = { + **sample("activity")[0], "type": "TRADE", - "proxyWallet": "0xWALLET", - "timestamp": 1_700_000_000, - "transactionHash": "0xHASH", - "conditionId": _CONDITION_ID, - "asset": "TOKEN", + "is_combo": False, + "usdc_size": 4.2, + "size": 10, + "price": 0.42, "side": "BUY", - "size": "10", - "price": "0.42", - "outcome": "Yes", - "outcomeIndex": 0, - "title": "Some market", - "slug": "some-market", - "icon": "https://example.test/icon.png", - "eventSlug": "some-event", - } - base.update(overrides) - return base - - -def _market_event_payload(activity_type: str, **overrides: object) -> dict[str, object]: - base: dict[str, object] = { - "type": activity_type, - "proxyWallet": "0xWALLET", - "timestamp": 1_700_000_000, - "transactionHash": "0xHASH", - "conditionId": _CONDITION_ID, - "size": "5", - "title": "T", - "slug": "t", - "icon": "i", - "eventSlug": "e", - } - base.update(overrides) - return base - - -def _credit_payload(activity_type: str, **overrides: object) -> dict[str, object]: - base: dict[str, object] = { - "type": activity_type, - "proxyWallet": "0xWALLET", - "timestamp": 1_700_000_000, - "transactionHash": "0xHASH", - "size": "0.25", - } - base.update(overrides) - return base - - -def test_parse_trade_activity() -> None: - activity = parse_activity(_trade_payload()) - assert isinstance(activity, TradeActivity) - assert activity.wallet == "0xWALLET" - assert activity.condition_id == _CONDITION_ID - assert activity.token_id == "TOKEN" - assert activity.side == "BUY" - assert activity.shares == Decimal("10") - assert activity.price == Decimal("0.42") - assert activity.amount == Decimal("10") - assert activity.outcome_index == 0 - - -def test_parse_trade_activity_uses_usdc_size_when_present() -> None: - activity = parse_activity( - _trade_payload(side="SELL", outcomeIndex=1, usdcSize="4.20", outcome="No") - ) - assert isinstance(activity, TradeActivity) - assert activity.amount == Decimal("4.20") - - -def test_parse_combo_trade_activity() -> None: - activity = parse_activity( - _trade_payload( - isCombo=True, - conditionId=_COMBO_CONDITION_ID, - asset=_COMBO_POSITION_ID, - outcome="", - outcomeIndex=999, - slug="", - eventSlug="", - title="Combo trade", - ) - ) - assert isinstance(activity, ComboTradeActivity) - assert activity.is_combo is True - assert activity.condition_id == _COMBO_CONDITION_ID - assert activity.position_id == _COMBO_POSITION_ID - assert activity.shares == Decimal("10") - assert activity.amount == Decimal("10") - - -def test_trade_missing_required_field_raises_unexpected_response() -> None: - payload = _trade_payload() - del payload["price"] - with pytest.raises(UnexpectedResponseError): - parse_activity(payload) - - -def test_trade_missing_wallet_raises_unexpected_response() -> None: - payload = _trade_payload() - del payload["proxyWallet"] - with pytest.raises(UnexpectedResponseError): - parse_activity(payload) - - -def test_trade_missing_transaction_hash_raises_unexpected_response() -> None: - payload = _trade_payload() - del payload["transactionHash"] - with pytest.raises(UnexpectedResponseError): - parse_activity(payload) - - -def test_outcome_index_999_sentinel_is_dropped() -> None: - activity = parse_activity(_credit_payload("REWARD", outcomeIndex=999)) - assert isinstance(activity, RewardActivity) - assert activity.amount == Decimal("0.25") - - -def test_empty_string_sentinels_drop_to_none_on_trade_raises() -> None: - payload = _trade_payload(conditionId="", asset="", side="", outcome="") - with pytest.raises(UnexpectedResponseError): - parse_activity(payload) - - -def test_trade_empty_icon_normalizes_to_none() -> None: - activity = parse_activity(_trade_payload(icon="")) - assert isinstance(activity, TradeActivity) - assert activity.icon is None - - -def test_market_event_empty_icon_normalizes_to_none() -> None: - activity = parse_activity(_market_event_payload("SPLIT", icon="")) - assert isinstance(activity, SplitActivity) - assert activity.icon is None - - -def test_trade_model_empty_icon_normalizes_to_none() -> None: - trade = Trade.parse_response({"icon": ""}) - assert trade.icon is None - - -def test_trade_model_keeps_populated_icon() -> None: - trade = Trade.parse_response({"icon": "https://example.test/icon.png"}) - assert trade.icon == "https://example.test/icon.png" - - -def test_market_event_variants_parse() -> None: - for activity_type, expected_class in [ - ("SPLIT", SplitActivity), - ("MERGE", MergeActivity), - ("REDEEM", RedeemActivity), - ("CONVERSION", ConversionActivity), - ]: - activity = parse_activity(_market_event_payload(activity_type)) - assert isinstance(activity, expected_class) - assert activity.amount == Decimal("5") - - -def test_market_event_missing_condition_raises() -> None: - payload = _market_event_payload("SPLIT") - del payload["conditionId"] - with pytest.raises(UnexpectedResponseError): - parse_activity(payload) - - -def test_account_credit_variants_parse() -> None: - for activity_type, expected_class in [ - ("REWARD", RewardActivity), - ("DEPOSIT", DepositActivity), - ("WITHDRAWAL", WithdrawalActivity), - ("MAKER_REBATE", MakerRebateActivity), - ("TAKER_REBATE", TakerRebateActivity), - ("REFERRAL_REWARD", ReferralRewardActivity), - ("YIELD", YieldActivity), - ]: - activity = parse_activity(_credit_payload(activity_type)) - assert isinstance(activity, expected_class) - assert activity.amount == Decimal("0.25") - - -def test_credit_missing_amount_raises() -> None: - payload = _credit_payload("REWARD") - del payload["size"] - with pytest.raises(UnexpectedResponseError): - parse_activity(payload) - - -def test_unknown_type_falls_back() -> None: - payload = { - "type": "BRAND_NEW_TYPE", - "proxyWallet": "0xWALLET", - "timestamp": 1_700_000_000, - "transactionHash": "0xHASH", + "outcome_index": 999, + "title": "", + "outcome": "", } - activity = parse_activity(payload) - assert isinstance(activity, UnknownActivity) - assert activity.type == "BRAND_NEW_TYPE" - assert activity.raw["type"] == "BRAND_NEW_TYPE" - - -def test_unknown_activity_is_permissive() -> None: - activity = parse_activity({"type": "BRAND_NEW_TYPE"}) - assert isinstance(activity, UnknownActivity) - assert activity.wallet is None - assert activity.timestamp is None - assert activity.transaction_hash is None - - -def test_missing_type_falls_back_to_unknown() -> None: - activity = parse_activity({"proxyWallet": "0xWALLET"}) - assert isinstance(activity, UnknownActivity) - assert activity.type == "" - - -def test_non_dict_payload_raises_unexpected_response() -> None: - with pytest.raises(UnexpectedResponseError): - parse_activity("not a dict") # type: ignore[arg-type] - - -def test_parse_activities_non_list_raises_unexpected_response() -> None: + trade = parse_activity(payload) + assert isinstance(trade, TradeActivity) + assert trade.amount == Decimal("4.2") and trade.shares == Decimal(10) + assert trade.outcome_index is None and trade.title is None and trade.outcome is None + direct = TradeActivity.parse_response(payload) + assert direct == trade + assert trade.type is ActivityType.TRADE + epoch = parse_activity({**payload, "timestamp": 0}) + assert epoch.timestamp == datetime(1970, 1, 1, tzinfo=UTC) + combo = parse_activity({**payload, "is_combo": True, "condition_id": "0x03" + "ab" * 30}) + assert isinstance(combo, ComboTradeActivity) + assert combo.position_id == payload["token_id"] + for field in ("proxy_wallet", "transaction_hash", "condition_id", "usdc_size"): + broken = dict(payload) + broken.pop(field) + with pytest.raises(UnexpectedResponseError): + parse_activity(broken) + + +@pytest.mark.parametrize( + "kind", + [ + "SPLIT", + "MERGE", + "REDEEM", + "CONVERSION", + "REWARD", + "MIGRATION", + "DEPOSIT", + "WITHDRAWAL", + "YIELD", + "MAKER_REBATE", + "TAKER_REBATE", + "REFERRAL_REWARD", + "TIP", + ], +) +def test_activity_variants(kind: str) -> None: + payload = {**sample("activity")[0], "type": kind, "usdc_size": 1.25, "side": ""} + row = parse_activity(payload) + assert not isinstance(row, UnknownActivity) + assert row.type is ActivityType(kind) and row.amount == Decimal("1.25") + if isinstance(row, TipActivity): + assert row.side is None + + +def test_unknown_activity_and_bad_shapes() -> None: + unknown = parse_activity({"type": "FUTURE_EVENT", "new_field": 123, "name": ""}) + assert isinstance(unknown, UnknownActivity) and unknown.raw["new_field"] == 123 + assert unknown.name is None and unknown.raw["name"] == "" + bad_payloads: tuple[object, ...] = (None, [], 1) + for payload in bad_payloads: + with pytest.raises(UnexpectedResponseError): + parse_activity(payload) with pytest.raises(UnexpectedResponseError): - parse_activities({"not": "a list"}) + parse_activities({}) -def test_parse_activities_handles_list() -> None: - payload = [ - _trade_payload(), - _credit_payload("REWARD"), - ] - activities = parse_activities(payload) - assert len(activities) == 2 - assert isinstance(activities[0], TradeActivity) - assert isinstance(activities[1], RewardActivity) +def test_combo_activity_position_and_redeem_payout() -> None: + base = sample("activity_combos")[0] + for kind in ("SPLIT", "MERGE", "CONVERT", "COMPRESS", "WRAP", "UNWRAP", "REDEEM"): + row = parse_combo_activities([{**base, "type": kind, "payout_usdc": 1.5}])[0] + assert row.position_id == base["combo_position_id"] + assert row.type is ComboActivityType(kind) + assert ("payout" in type(row).model_fields) == (kind == "REDEEM") diff --git a/tests/unit/test_data_envelope.py b/tests/unit/test_data_envelope.py new file mode 100644 index 00000000..52120934 --- /dev/null +++ b/tests/unit/test_data_envelope.py @@ -0,0 +1,42 @@ +import pytest + +from polymarket._internal.data_envelope import ( + parse_data_envelope, + parse_data_page, + parse_optional_data_envelope, +) +from polymarket.errors import UnexpectedResponseError +from polymarket.models.data import UserVolume + + +@pytest.mark.parametrize( + "payload", + [ + {}, + {"data": []}, + {"data": {}, "pagination": {}}, + {"data": [], "pagination": {"has_more": True, "next_cursor": None}}, + {"data": [], "pagination": {"has_more": False, "next_cursor": "next"}}, + {"data": [], "pagination": {"has_more": True, "next_cursor": ""}}, + {"data": [], "pagination": {"has_more": 1, "next_cursor": "next"}}, + {"data": [], "pagination": {"has_more": False}}, + ], +) +def test_broken_page_envelopes_fail(payload: object) -> None: + with pytest.raises(UnexpectedResponseError): + parse_data_page(payload, UserVolume.parse_response_list) + + +def test_page_and_optional_envelopes() -> None: + page = parse_data_page( + {"data": [], "pagination": {"has_more": True, "next_cursor": "next"}}, + UserVolume.parse_response_list, + ) + assert page.server_next_cursor == "next" + assert parse_optional_data_envelope({"data": None}, UserVolume.parse_response) is None + volume = parse_data_envelope( + {"data": {"volume": 1, "volume_usdc": 0.25, "trade_count": 2}}, UserVolume.parse_response + ) + assert volume.trade_count == 2 + with pytest.raises(UnexpectedResponseError): + parse_optional_data_envelope({}, UserVolume.parse_response) diff --git a/tests/unit/test_data_models.py b/tests/unit/test_data_models.py index 9b5aa91a..23ecc1e9 100644 --- a/tests/unit/test_data_models.py +++ b/tests/unit/test_data_models.py @@ -1,288 +1,197 @@ -from datetime import UTC, datetime +from datetime import UTC, date, datetime from decimal import Decimal from typing import Any import pytest +from data_v2_samples import position_payload, sample -from polymarket.errors import UnexpectedResponseError -from polymarket.models.data import ( - BuilderVolumeEntry, - ClosedPosition, +from polymarket import ( + BiggestWinnerKind, + BuilderStanding, + BuilderVolumePoint, ComboPosition, - ComboRedeemActivity, Holder, LiveVolume, + MarketLiveVolume, MetaHolder, OpenInterest, PortfolioValue, Position, - TradedMarketCount, + PositionStatus, + PriceHistoryPoint, + Resolution, + ResolutionStatus, + Trade, + TraderLeaderboardEntry, + TraderLeaderboardStanding, + UserPnlSeries, + UserStats, + UserVolume, ) -from polymarket.models.data.activity import parse_combo_activity - -_COMBO_CONDITION_ID = "0x032def24bfb0c5c57fb236fac08b94236a0000000000000000000000000000" -_CTF_CONDITION_ID = "0x5c19f205507ce03ff5f3be08a8090a5969ea6870cc07b902a4ca2e61dfe48fdd" - - -def _combo_position_payload(*, condition_id: str = _COMBO_CONDITION_ID) -> dict[str, Any]: - return { - "combo_condition_id": condition_id, - "combo_position_id": "123", - "side": "YES", - "module_id": 3, - "user_address": "0x0000000000000000000000000000000000000001", - "shares_balance": "42.5", - "entry_avg_price_usdc": "0.12", - "entry_cost_usdc": "5.1", - "realized_payout_usdc": "6.25", - "total_cost_usdc": "5.1", - "status": "OPEN", - "redeemable": False, - "first_entry_at": "2026-06-01T12:00:00Z", - "resolved_at": None, - "updated_at": "2026-06-02T12:00:00Z", - "legs_total": 2, - "legs_resolved": 1, - "legs_pending": 1, - "legs": [ - { - "leg_index": 0, - "leg_position_id": "456", - "leg_condition_id": _CTF_CONDITION_ID, - "leg_outcome_index": 1, - "leg_outcome_label": "Yes", - "leg_status": "PARTIAL", - "leg_resolved_at": None, - "leg_current_price": "0.77", - "market": { - "market_id": "789", - "slug": "market-slug", - "title": "Market title", - "outcome": "Yes", - "image_url": "https://example.test/image.png", - "icon_url": "https://example.test/icon.png", - "category": "Politics", - "subcategory": None, - "tags": ["a", "b"], - "end_date": "2026-07-01T00:00:00Z", - "event": { - "event_id": "event-1", - "event_slug": "event-slug", - "event_title": "Event title", - "event_image": "https://example.test/event.png", - }, - }, - } - ], - } - - -def test_live_volume_parses_total_and_markets() -> None: - payload = { - "total": "12345.67", - "markets": [ - {"market": _CTF_CONDITION_ID, "value": "100.5"}, - {"market": _CTF_CONDITION_ID, "value": 200}, - ], - } - - volume = LiveVolume.parse_response(payload) - - assert volume.total == Decimal("12345.67") - assert volume.markets is not None - assert len(volume.markets) == 2 - assert volume.markets[0].market == _CTF_CONDITION_ID - assert volume.markets[0].value == Decimal("100.5") - assert volume.markets[1].value == Decimal("200") - - -def test_live_volume_handles_missing_fields() -> None: - volume = LiveVolume.parse_response({}) - - assert volume.total is None - assert volume.markets is None - - -def test_open_interest_parses_payload() -> None: - interest = OpenInterest.parse_response({"market": _CTF_CONDITION_ID, "value": "1500"}) - - assert interest.market == _CTF_CONDITION_ID - assert interest.value == Decimal("1500") - - -def test_open_interest_accepts_global_market() -> None: - interest = OpenInterest.parse_response({"market": "GLOBAL", "value": "1500"}) - - assert interest.market == "GLOBAL" - assert interest.value == Decimal("1500") - - -def test_holder_renames_proxy_wallet_and_asset() -> None: - holder = Holder.parse_response( - { - "proxyWallet": "0xWALLET", - "asset": "TOKEN_123", - "amount": "42.5", - "outcomeIndex": 1, - "name": "Alice", - "displayUsernamePublic": True, - } - ) - - assert holder.wallet == "0xWALLET" - assert holder.token_id == "TOKEN_123" - assert holder.amount == Decimal("42.5") - assert holder.outcome_index == 1 - assert holder.name == "Alice" - assert holder.display_username_public is True - - -def test_meta_holder_nests_holders() -> None: - payload = { - "token": "TOKEN_123", - "holders": [ - {"proxyWallet": "0xA", "asset": "TOKEN_123", "amount": "1"}, - {"proxyWallet": "0xB", "asset": "TOKEN_123", "amount": "2"}, - ], - } - - meta = MetaHolder.parse_response(payload) - - assert meta.token == "TOKEN_123" - assert meta.holders is not None - assert len(meta.holders) == 2 - assert meta.holders[0].wallet == "0xA" - - -def test_portfolio_value_parses_user_and_value() -> None: - value = PortfolioValue.parse_response({"user": "0xWALLET", "value": "9876.54"}) - - assert value.user == "0xWALLET" - assert value.value == Decimal("9876.54") - - -def test_traded_market_count_parses_payload() -> None: - count = TradedMarketCount.parse_response({"user": "0xWALLET", "traded": 42}) - - assert count.user == "0xWALLET" - assert count.traded == 42 - - -def test_position_empty_icon_normalizes_to_none() -> None: - position = Position.parse_response({"conditionId": _CTF_CONDITION_ID, "icon": ""}) - - assert position.icon is None +from polymarket.errors import UnexpectedResponseError +from polymarket.models.base import BaseModel +from polymarket.models.data.leaderboard import parse_biggest_winners + + +@pytest.mark.parametrize( + "model,name", + [ + (Trade, "trades"), + (Position, "positions"), + (ComboPosition, "positions_combos"), + (MetaHolder, "holders"), + (OpenInterest, "oi"), + (Resolution, "resolutions"), + (TraderLeaderboardEntry, "leaderboard"), + (BuilderStanding, "builders_leaderboard"), + (BuilderVolumePoint, "builders_volume"), + (PriceHistoryPoint, "prices-history"), + ], +) +def test_captured_rows_and_canonical_roundtrip(model: type[BaseModel], name: str) -> None: + rows = model.parse_response_list(sample(name)) + assert rows + for row in rows: + assert model.model_validate(row.model_dump()) == row + + +@pytest.mark.parametrize( + "model,name", + [ + (PortfolioValue, "value"), + (UserStats, "user-stats"), + (UserPnlSeries, "user-pnl"), + (UserVolume, "user-volume"), + (LiveVolume, "live-volume"), + (TraderLeaderboardStanding, "leaderboard_standing"), + ], +) +def test_captured_objects(model: type[BaseModel], name: str) -> None: + value = model.parse_response(sample(name)) + assert model.model_validate(value.model_dump()) == value -def test_position_keeps_populated_icon() -> None: +def test_position_sentinels_and_numbers() -> None: position = Position.parse_response( - {"conditionId": _CTF_CONDITION_ID, "icon": "https://example.test/icon.png"} + position_payload( + event_id="0", + end_date="1970-01-01", + last_event_at=0, + outcome_index=999, + opposite_token_id="", + title="", + current_size=0.123456, + ) ) - - assert position.icon == "https://example.test/icon.png" - - -def test_closed_position_empty_icon_normalizes_to_none() -> None: - position = ClosedPosition.parse_response({"conditionId": _CTF_CONDITION_ID, "icon": ""}) - - assert position.icon is None - - -def test_builder_volume_entry_renames_dt_to_bucket_at() -> None: - entry = BuilderVolumeEntry.parse_response( - { - "dt": "2026-05-08T00:00:00Z", - "builder": "polymarket", - "builderLogo": "https://example.test/logo.png", - "verified": True, - "volume": "1000.5", - "activeUsers": 25, - "rank": "1", - } + assert ( + position.event_id is None and position.end_date is None and position.last_event_at is None ) - - assert entry.bucket_at == datetime(2026, 5, 8, tzinfo=UTC) - assert entry.builder == "polymarket" - assert entry.builder_logo == "https://example.test/logo.png" - assert entry.verified is True - assert entry.volume == Decimal("1000.5") - assert entry.active_users == 25 - assert entry.rank == "1" - - -def test_builder_volume_entry_handles_missing_fields() -> None: - entry = BuilderVolumeEntry.parse_response({}) - - assert entry.bucket_at is None - assert entry.builder is None - assert entry.volume is None - - -def test_combo_position_parses_payload() -> None: - payload = _combo_position_payload() - - combo = ComboPosition.parse_response(payload) - - assert combo.condition_id == payload["combo_condition_id"] - assert combo.position_id == "123" - assert combo.outcome == "YES" - assert combo.wallet == "0x0000000000000000000000000000000000000001" - assert combo.shares == Decimal("42.5") - assert combo.realized_payout_usdc == Decimal("6.25") - assert combo.total_cost_usdc == Decimal("5.1") - assert combo.status == "OPEN" - assert combo.redeemable is False - assert combo.updated_at == datetime(2026, 6, 2, 12, tzinfo=UTC) - assert combo.legs[0].leg_position_id == "456" - assert combo.legs[0].leg_current_price == Decimal("0.77") - assert combo.legs[0].market is not None - assert combo.legs[0].market.market_id == "789" - assert combo.legs[0].market.event is not None - assert combo.legs[0].market.event.event_slug == "event-slug" - - -def test_combo_position_normalizes_binary_wire_condition_id() -> None: - combo = ComboPosition.parse_response( - _combo_position_payload(condition_id=f"{_COMBO_CONDITION_ID}01") + assert ( + position.outcome_index is None + and position.opposite_asset_id is None + and position.title is None ) - - assert combo.condition_id == _COMBO_CONDITION_ID - - -def test_combo_position_rejects_invalid_condition_id() -> None: - with pytest.raises(UnexpectedResponseError, match="ComboPosition response"): - ComboPosition.parse_response(_combo_position_payload(condition_id=_CTF_CONDITION_ID)) - - -def test_combo_activity_parses_api_type_and_redeem_fields() -> None: - payload = { - "id": "tx:1", - "type": "REDEEM", - "user_address": "0x0000000000000000000000000000000000000001", - "combo_condition_id": _COMBO_CONDITION_ID, - "combo_position_id": "123", - "module_id": 3, - "amount_usdc": "4.5", - "payout_usdc": "6.25", - "timestamp": 1_797_360_000, - "tx_dttm": "2026-06-01T12:00:00Z", - "tx_hash": "0xabc", - "log_index": 1, - "block_number": 123456, - "legs": _combo_position_payload()["legs"], - } - - activity = parse_combo_activity(payload) - - assert isinstance(activity, ComboRedeemActivity) - assert activity.type == "REDEEM" - assert activity.condition_id == _COMBO_CONDITION_ID - assert activity.position_id == "123" - assert activity.wallet == "0x0000000000000000000000000000000000000001" - assert activity.amount == Decimal("4.5") - assert activity.payout == Decimal("6.25") - - -def test_open_interest_rejects_malformed_condition_id() -> None: - with pytest.raises(UnexpectedResponseError, match="OpenInterest response"): - OpenInterest.parse_response({"market": "0x1234", "value": "1500"}) + assert position.current_size == Decimal("0.123456") + assert position.token_id == position.asset_id + assert position.status is PositionStatus.OPEN + assert Position.parse_response(position_payload(end_date="2026-05-31")).end_date == date( + 2026, 5, 31 + ) + for field in ("proxy_wallet", "token_id", "current_size", "condition_id"): + payload = position_payload() + payload.pop(field) + with pytest.raises(UnexpectedResponseError): + Position.parse_response(payload) + + +def test_nullable_pnl_and_unranked_standing() -> None: + payload = sample("user-pnl") + payload["points"][0]["fees_paid"] = None + assert UserPnlSeries.parse_response(payload).points[0].fees_paid is None + for value in (None, 0): + standing = TraderLeaderboardStanding.parse_response( + {**sample("leaderboard_standing"), "rank_pnl": value, "rank_volume": value} + ) + assert standing.pnl_rank is None and standing.volume_rank is None + + +def test_resolution_units_and_timestamps() -> None: + payload: dict[str, Any] = sample("resolutions")[0] + for timestamp in ("1700000000", "2023-11-14T22:13:20Z"): + row = Resolution.parse_response( + { + **payload, + "proposed_price": "69", + "reproposed_price": "69", + "price": "69", + "payouts": [500000, 500000], + "last_update_timestamp": timestamp, + } + ) + assert row.price is None and row.proposed_price is None and row.reproposed_price is None + assert row.payouts == (Decimal("0.5"), Decimal("0.5")) + assert row.status is ResolutionStatus(payload["status"]) + assert row.last_updated_at == datetime(2023, 11, 14, 22, 13, 20, tzinfo=UTC) + dated = Resolution.parse_response({**payload, "last_update_timestamp": "2023-11-14"}) + assert dated.last_updated_at == datetime(2023, 11, 14, tzinfo=UTC) + combo = ComboPosition.parse_response_list(sample("positions_combos"))[0] + market = combo.legs[0].market + assert market is not None and market.end_date == datetime(2026, 8, 5, tzinfo=UTC) + assert OpenInterest.parse_response({"condition_id": "GLOBAL", "value": 0}).condition_id is None + + +def test_winner_discriminators_and_holder_pnl() -> None: + market = parse_biggest_winners(sample("biggest-winners"))[0] + assert market.kind is BiggestWinnerKind.MARKET + assert ( + parse_biggest_winners(sample("biggest-winners_combos"))[0].kind is BiggestWinnerKind.COMBO + ) + for event_id in (None, "", "0", 0, -1, "abc"): + with pytest.raises(UnexpectedResponseError): + parse_biggest_winners([{**sample("biggest-winners")[0], "event_id": event_id}]) + holder = Holder.parse_response(sample("holders")[0]["holders"][0]) + assert holder.total_pnl is not None + + +@pytest.mark.parametrize("payouts", [[500000, 500000], [1000000, 0], None]) +def test_resolution_response_payouts_survive_canonical_roundtrips( + payouts: list[int] | None, +) -> None: + payload = {**sample("resolutions")[0], "payouts": payouts} + row = Resolution.parse_response_list([payload])[0] + expected = None if payouts is None else tuple(Decimal(value) / 1_000_000 for value in payouts) + assert row.payouts == expected + assert payload["payouts"] == payouts + assert Resolution.model_validate(row.model_dump()) == row + assert Resolution.model_validate_json(row.model_dump_json()) == row + + +@pytest.mark.parametrize("price", ["", "garbage"]) +def test_malformed_decimal_responses_raise_sdk_errors(price: str) -> None: + with pytest.raises(UnexpectedResponseError): + PriceHistoryPoint.parse_response( + {"timestamp": 1700000000, "price": price, "resolution_seconds": 60} + ) + with pytest.raises(UnexpectedResponseError): + Holder.parse_response({**sample("holders")[0]["holders"][0], "total_pnl": price}) + + +@pytest.mark.parametrize("payouts", [[], [1], [1, 2, 3], ["garbage", 0], [True, 0], ["sNaN", 0]]) +def test_malformed_resolution_payouts_raise_sdk_errors(payouts: list[object]) -> None: + with pytest.raises(UnexpectedResponseError): + Resolution.parse_response({**sample("resolutions")[0], "payouts": payouts}) + + +def test_malformed_analytics_identity_and_timestamp_raise_sdk_errors() -> None: + with pytest.raises(UnexpectedResponseError): + OpenInterest.parse_response({"value": 1}) + with pytest.raises(UnexpectedResponseError): + MarketLiveVolume.parse_response({"condition_id": "garbage", "taker_volume": 1}) + assert ( + MarketLiveVolume.parse_response({"condition_id": "", "taker_volume": 1}).condition_id + is None + ) + with pytest.raises(UnexpectedResponseError): + PriceHistoryPoint.parse_response( + {"timestamp": 10**100, "price": 0.5, "resolution_seconds": 60} + ) diff --git a/tests/unit/test_data_paginated_specs.py b/tests/unit/test_data_paginated_specs.py index 18de6c18..9aecbdba 100644 --- a/tests/unit/test_data_paginated_specs.py +++ b/tests/unit/test_data_paginated_specs.py @@ -1,319 +1,30 @@ -import pytest - -from polymarket._internal.actions import data as data_actions -from polymarket.errors import UnexpectedResponseError, UserInputError - -_COMBO_CONDITION_ID = "0x032def24bfb0c5c57fb236fac08b94236a0000000000000000000000000000" -_CTF_CONDITION_ID = "0x5c19f205507ce03ff5f3be08a8090a5969ea6870cc07b902a4ca2e61dfe48fdd" - - -def test_list_positions_spec_builds_request() -> None: - spec = data_actions.list_positions_spec(user="0xWALLET", market=["0xabc", "0xdef"]) - assert spec.service == "data" - assert spec.path == "/positions" - assert spec.base_params == {"user": "0xWALLET", "market": "0xabc,0xdef"} - - -def test_list_positions_spec_rejects_empty_user() -> None: - with pytest.raises(UserInputError, match="user is required"): - data_actions.list_positions_spec(user="") - - -def test_list_positions_spec_rejects_market_and_event_id() -> None: - with pytest.raises(UserInputError, match="not both"): - data_actions.list_positions_spec(user="0xWALLET", market=["0xabc"], event_id=[1]) - - -def test_list_positions_spec_rejects_long_title() -> None: - with pytest.raises(UserInputError, match="100 characters"): - data_actions.list_positions_spec(user="0xWALLET", title="x" * 101) - - -def test_list_positions_spec_validates_sort_by() -> None: - with pytest.raises(UserInputError, match="sort_by"): - data_actions.list_positions_spec(user="0xWALLET", sort_by="BOGUS") # type: ignore[arg-type] - - -def test_list_closed_positions_spec_builds_request() -> None: - spec = data_actions.list_closed_positions_spec( - user="0xWALLET", sort_by="REALIZEDPNL", sort_direction="DESC" - ) - assert spec.path == "/closed-positions" - assert spec.base_params == { - "user": "0xWALLET", - "sortBy": "REALIZEDPNL", - "sortDirection": "DESC", - } - - -def test_list_combo_positions_spec_builds_request() -> None: - spec = data_actions.list_combo_positions_spec( - user="0xWALLET", - status="OPEN", - sort="updated_asc", - condition_id=f"{_COMBO_CONDITION_ID}01", - updated_after=1_797_360_000, - ) - assert spec.path == "/v1/positions/combos" - assert spec.cursor_param == "cursor" - assert spec.base_params == { - "user": "0xWALLET", - "status": "OPEN", - "sort": "updated_asc", - "market_id": _COMBO_CONDITION_ID, - "updatedAfter": 1_797_360_000, - } - - -def test_list_combo_positions_spec_validates_status() -> None: - with pytest.raises(UserInputError, match="status"): - data_actions.list_combo_positions_spec(user="0xWALLET", status="CLOSED") # type: ignore[arg-type] - - -def test_list_combo_positions_spec_rejects_non_combo_condition_id() -> None: - with pytest.raises(UserInputError, match="combo condition ID"): - data_actions.list_combo_positions_spec(user="0xWALLET", condition_id=_CTF_CONDITION_ID) - - -def test_list_combo_positions_spec_rejects_empty_condition_id_sequence() -> None: - with pytest.raises(UserInputError, match="condition_id"): - data_actions.list_combo_positions_spec(user="0xWALLET", condition_id=[]) - - -def test_list_combo_activity_spec_builds_request() -> None: - spec = data_actions.list_combo_activity_spec( - user="0xWALLET", - condition_id=[f"{_COMBO_CONDITION_ID}00", f"{_COMBO_CONDITION_ID}01"], - ) - - assert spec.path == "/v1/activity/combos" - assert spec.cursor_param == "cursor" - assert spec.base_params == { - "user": "0xWALLET", - "market_id": f"{_COMBO_CONDITION_ID},{_COMBO_CONDITION_ID}", - } - - -def test_list_combo_activity_spec_rejects_empty_condition_id_sequence() -> None: - with pytest.raises(UserInputError, match="condition_id"): - data_actions.list_combo_activity_spec(user="0xWALLET", condition_id=[]) - - -def test_list_combo_positions_parser_treats_end_cursor_as_terminal() -> None: - spec = data_actions.list_combo_positions_spec(user="0xWALLET") - page = spec.parse_page( - { - "combos": [], - "pagination": { - "limit": 50, - "offset": 0, - "has_more": False, - "next_cursor": "LTE=", - }, - } - ) - - assert page.items == () - assert page.server_next_cursor is None - - -def test_list_combo_activity_parser_rejects_empty_next_cursor() -> None: - spec = data_actions.list_combo_activity_spec(user="0xWALLET") - with pytest.raises(UnexpectedResponseError, match="next_cursor"): - spec.parse_page( - { - "activity": [], - "pagination": { - "limit": 50, - "offset": 0, - "has_more": True, - "next_cursor": "", - }, - } - ) - - -def test_list_market_positions_spec_requires_market() -> None: - with pytest.raises(UserInputError, match="market is required"): - data_actions.list_market_positions_spec(market="") - - -def test_list_market_positions_spec_validates_status() -> None: - with pytest.raises(UserInputError, match="status"): - data_actions.list_market_positions_spec(market="0xabc", status="ACTIVE") # type: ignore[arg-type] - - -def test_list_trades_spec_rejects_market_and_event_id() -> None: - with pytest.raises(UserInputError, match="not both"): - data_actions.list_trades_spec(market=["0xabc"], event_id=[1]) - - -def test_list_trades_spec_requires_filter_pair() -> None: - with pytest.raises(UserInputError, match="filter_type and filter_amount"): - data_actions.list_trades_spec(filter_type="CASH") - with pytest.raises(UserInputError, match="filter_type and filter_amount"): - data_actions.list_trades_spec(filter_amount=10.0) - - -def test_list_trades_spec_accepts_paired_filter() -> None: - spec = data_actions.list_trades_spec(filter_type="CASH", filter_amount=10.0) - assert spec.base_params == {"filterType": "CASH", "filterAmount": 10.0} - - -def test_list_trades_spec_serializes_start_and_end() -> None: - spec = data_actions.list_trades_spec(start=1_797_360_000, end=1_797_446_400) - - assert spec.base_params == {"start": 1_797_360_000, "end": 1_797_446_400} - - -@pytest.mark.parametrize("field", ["start", "end"]) -def test_list_trades_spec_rejects_negative_time_bounds(field: str) -> None: - with pytest.raises(UserInputError, match=field): - if field == "start": - data_actions.list_trades_spec(start=-1) - else: - data_actions.list_trades_spec(end=-1) - - -def test_list_activity_spec_validates_type_entries() -> None: - with pytest.raises(UserInputError, match="activity_types entries"): - data_actions.list_activity_spec(user="0xWALLET", activity_types=["BOGUS"]) # type: ignore[list-item] - - -def test_list_activity_spec_accepts_cash_activity_types() -> None: - spec = data_actions.list_activity_spec( - user="0xWALLET", - activity_types=["DEPOSIT", "WITHDRAWAL", "TAKER_REBATE"], - ) - assert spec.base_params == { - "user": "0xWALLET", - "type": "DEPOSIT,WITHDRAWAL,TAKER_REBATE", - "excludeDepositsWithdrawals": False, - } - - -@pytest.mark.parametrize( - "activity_types", - [["DEPOSIT"], ["WITHDRAWAL"], ["TRADE", "DEPOSIT"]], -) -def test_list_activity_spec_disables_deposit_withdrawal_exclusion( - activity_types: list[str], -) -> None: - spec = data_actions.list_activity_spec( - user="0xWALLET", - activity_types=activity_types, # type: ignore[arg-type] - ) - assert spec.base_params == { - "user": "0xWALLET", - "type": ",".join(activity_types), - "excludeDepositsWithdrawals": False, - } - - -def test_list_activity_spec_disables_exclusion_by_default() -> None: - spec = data_actions.list_activity_spec(user="0xWALLET") - assert spec.base_params == { - "user": "0xWALLET", - "excludeDepositsWithdrawals": False, - } - - -def test_list_activity_spec_disables_exclusion_for_other_types() -> None: - spec = data_actions.list_activity_spec( - user="0xWALLET", - activity_types=["TAKER_REBATE", "MAKER_REBATE"], - ) - assert spec.base_params == { - "user": "0xWALLET", - "type": "TAKER_REBATE,MAKER_REBATE", - "excludeDepositsWithdrawals": False, - } - - -@pytest.mark.parametrize("field", ["start", "end"]) -def test_list_activity_spec_rejects_negative_time_bounds(field: str) -> None: - with pytest.raises(UserInputError, match=field): - if field == "start": - data_actions.list_activity_spec(user="0xWALLET", start=-1) - else: - data_actions.list_activity_spec(user="0xWALLET", end=-1) - - -def test_list_activity_spec_serializes_filters() -> None: - spec = data_actions.list_activity_spec( - user="0xWALLET", - activity_types=["TRADE", "REWARD"], - sort_by="TIMESTAMP", - sort_direction="DESC", - ) - assert spec.path == "/activity" - assert spec.base_params == { - "user": "0xWALLET", - "type": "TRADE,REWARD", - "excludeDepositsWithdrawals": False, - "sortBy": "TIMESTAMP", - "sortDirection": "DESC", - } - - -def test_list_builder_leaderboard_spec() -> None: - spec = data_actions.list_builder_leaderboard_spec(time_period="WEEK") - assert spec.path == "/v1/builders/leaderboard" - assert spec.base_params == {"timePeriod": "WEEK"} - - -def test_list_builder_leaderboard_spec_rejects_unknown_time_period() -> None: - with pytest.raises(UserInputError, match="time_period"): - data_actions.list_builder_leaderboard_spec(time_period="YEAR") # type: ignore[arg-type] - - -def test_list_trader_leaderboard_spec_serializes_all_filters() -> None: - spec = data_actions.list_trader_leaderboard_spec( - category="POLITICS", - time_period="MONTH", - order_by="PNL", - user="0xWALLET", - user_name="alice", - ) - assert spec.path == "/v1/leaderboard" - assert spec.base_params == { - "category": "POLITICS", - "timePeriod": "MONTH", - "orderBy": "PNL", - "user": "0xWALLET", - "userName": "alice", - } +from typing import Any +import pytest -def test_list_trader_leaderboard_spec_validates_category() -> None: - with pytest.raises(UserInputError, match="category"): - data_actions.list_trader_leaderboard_spec(category="OTHER") # type: ignore[arg-type] +from polymarket import PublicClient +from polymarket.errors import UserInputError @pytest.mark.parametrize( - ("spec", "expected_max"), + "method,kwargs,cap", [ - (data_actions.list_positions_spec(user="0xWALLET"), 500), - (data_actions.list_closed_positions_spec(user="0xWALLET"), 50), - (data_actions.list_market_positions_spec(market="0xabc"), 500), - (data_actions.list_trades_spec(), 10_000), - (data_actions.list_activity_spec(user="0xWALLET"), 500), - (data_actions.list_builder_leaderboard_spec(), 50), - (data_actions.list_trader_leaderboard_spec(), 50), - ], - ids=[ - "positions", - "closed-positions", - "market-positions", - "trades", - "activity", - "builder-leaderboard", - "trader-leaderboard", + ("list_trades", {}, 1000), + ("list_activity", {"user": "wallet"}, 1000), + ("list_combo_activity", {"user": "wallet"}, 1000), + ("list_positions", {"user": "wallet"}, 1000), + ("list_combo_positions", {"user": "wallet"}, 1000), + ("list_market_holders", {"condition_ids": "0x" + "ab" * 32}, 1000), + ("list_market_holders", {"condition_ids": "0x" + "ab" * 32, "include_pnl": True}, 100), + ("list_trader_leaderboard", {}, 1000), + ("list_biggest_winners", {}, 1000), + ("list_builder_leaderboard", {}, 1000), + ("list_price_history", {"asset_id": "1", "interval": "1d"}, 10000), ], ) -def test_offset_specs_cap_page_size_at_server_limit(spec: object, expected_max: int) -> None: - # Each cap matches the server-side limit cap. Page sizes past the cap fail - # fast instead of the server clamping or rejecting the request and - # pagination silently misbehaving. - assert isinstance(spec, data_actions.OffsetPaginatedSpec) - assert spec.max_page_size == expected_max +def test_page_caps(method: str, kwargs: dict[str, Any], cap: int) -> None: + with PublicClient() as client: + getattr(client, method)(**kwargs, page_size=cap) + for size in (0, cap + 1): + with pytest.raises(UserInputError): + getattr(client, method)(**kwargs, page_size=size) diff --git a/tests/unit/test_data_retry.py b/tests/unit/test_data_retry.py new file mode 100644 index 00000000..25346451 --- /dev/null +++ b/tests/unit/test_data_retry.py @@ -0,0 +1,96 @@ +# pyright: reportPrivateUsage=false +import asyncio +import dataclasses + +import httpx +import pytest + +from polymarket import AsyncPublicClient, PublicClient +from polymarket._internal import retry +from polymarket.clients._transport import AsyncTransport, SyncTransport +from polymarket.errors import RateLimitError, RequestRejectedError + + +@pytest.mark.parametrize( + "status,header,failures,attempts,delays", + [ + (429, "2", 1, 2, [2]), + (429, None, 1, 2, [1]), + (429, "120", 1, 1, []), + (429, "0", 4, 3, [0, 0]), + (400, None, 1, 1, []), + ], +) +@pytest.mark.parametrize("mode", ["sync", "async"]) +@pytest.mark.parametrize("method", ["get_open_interests", "list_trades"]) +def test_retry_at_dispatch_boundary( + monkeypatch: pytest.MonkeyPatch, + status: int, + header: str | None, + failures: int, + attempts: int, + delays: list[int], + mode: str, + method: str, +) -> None: + calls = 0 + slept: list[float] = [] + monkeypatch.setattr(retry, "sleep", slept.append) + + async def record(delay: float) -> None: + slept.append(delay) + + monkeypatch.setattr(retry, "async_sleep", record) + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + if calls <= failures: + return httpx.Response( + status, + headers={} if header is None else {"Retry-After": header}, + json={"error": "test rejection"}, + ) + return httpx.Response( + 200, json={"data": [], "pagination": {"has_more": False, "next_cursor": None}} + ) + + def sync_run() -> None: + with ( + PublicClient() as client, + httpx.Client( + base_url="https://example.test", transport=httpx.MockTransport(handler) + ) as http, + ): + client._ctx.data.close() + client._ctx = dataclasses.replace( + client._ctx, data=SyncTransport(base_url="https://example.test", client=http) + ) + if method == "list_trades": + client.list_trades().first_page() + else: + client.get_open_interests() + + async def async_run() -> None: + async with ( + AsyncPublicClient() as client, + httpx.AsyncClient( + base_url="https://example.test", transport=httpx.MockTransport(handler) + ) as http, + ): + await client._ctx.data.close() + client._ctx = dataclasses.replace( + client._ctx, data=AsyncTransport(base_url="https://example.test", client=http) + ) + if method == "list_trades": + await client.list_trades().first_page() + else: + await client.get_open_interests() + + if failures >= attempts: + with pytest.raises(RateLimitError if status == 429 else RequestRejectedError): + sync_run() if mode == "sync" else asyncio.run(async_run()) + else: + sync_run() if mode == "sync" else asyncio.run(async_run()) + assert calls == attempts + assert slept == delays diff --git a/tests/unit/test_data_specs.py b/tests/unit/test_data_specs.py index 9afa038d..f1a9cb4d 100644 --- a/tests/unit/test_data_specs.py +++ b/tests/unit/test_data_specs.py @@ -1,134 +1,223 @@ +from datetime import UTC, datetime +from typing import Any + import pytest -from polymarket._internal.actions import data as data_actions +from polymarket import PublicClient +from polymarket._internal.actions import data +from polymarket._internal.data_params import build_distinct_condition_ids, to_epoch_seconds from polymarket.errors import UserInputError - - -def test_get_event_live_volumes_spec_builds_request() -> None: - spec = data_actions.get_event_live_volumes_spec(id="123") - - assert spec.service == "data" - assert spec.method == "GET" - assert spec.path == "/live-volume" - assert spec.params == {"id": "123"} - - -def test_get_event_live_volumes_spec_rejects_empty_id() -> None: - with pytest.raises(UserInputError, match="id is required"): - data_actions.get_event_live_volumes_spec(id="") - - -def test_get_open_interests_spec_omits_market_when_absent() -> None: - spec = data_actions.get_open_interests_spec() - - assert spec.path == "/oi" - assert spec.params == {} - - -def test_get_open_interests_spec_joins_market_with_commas() -> None: - spec = data_actions.get_open_interests_spec(market=["0xabc", "0xdef"]) - - assert spec.params == {"market": "0xabc,0xdef"} - - -def test_get_open_interests_spec_drops_empty_market_sequence() -> None: - spec = data_actions.get_open_interests_spec(market=[]) - - assert spec.params == {} - - -def test_get_market_holders_spec_builds_request_with_filters() -> None: - spec = data_actions.get_market_holders_spec(market=["0xabc", "0xdef"], limit=5, min_balance=100) - - assert spec.path == "/holders" - assert spec.params == { - "market": "0xabc,0xdef", - "limit": 5, - "minBalance": 100, +from polymarket.models.data import ( + ActivityType, + ComboPositionStatus, + PositionStatus, + UserPnlInterval, +) +from polymarket.models.types import to_market_condition_id + +CONDITION = "0x" + "ab" * 32 +COMBO = "0x03" + "ab" * 30 +WALLET = "0x" + "12" * 20 + + +@pytest.mark.parametrize( + "method,kwargs", + [ + ("list_trades", {"condition_id": CONDITION, "event_id": 1}), + ("list_trades", {"condition_id": ["0x" + f"{n:064x}" for n in range(21)]}), + ("list_trades", {"page_size": 1001}), + ("list_trades", {"page_size": True}), + ("list_trades", {"page_size": 1.5}), + ("list_trades", {"condition_id": "0x" + "a" * 62 + "_1"}), + ("list_trades", {"filter_amount": -1}), + ("list_activity", {"user": WALLET, "start": datetime(2026, 1, 1)}), + ("list_activity", {"user": WALLET, "full_history": True, "end": 100}), + ("list_positions", {}), + ("list_positions", {"condition_id": [CONDITION, "0x" + "cd" * 32]}), + ("list_positions", {"user": WALLET, "status": "CLOSED", "include_archived": True}), + ("list_combo_positions", {"user": WALLET, "status": ["REDEEMABLE", "OPEN"]}), + ("list_combo_positions", {"user": WALLET, "status": "OPEN,PARTIAL"}), + ("list_combo_positions", {"user": WALLET, "updated_after": 100, "updated_before": 50}), + ("list_combo_positions", {"user": WALLET, "condition_id": CONDITION}), + ( + "list_market_holders", + {"condition_ids": [CONDITION, "0x" + "cd" * 32], "include_pnl": True}, + ), + ( + "list_market_holders", + {"condition_ids": CONDITION, "include_pnl": True, "page_size": 101}, + ), + ("list_price_history", {"asset_id": "1", "interval": "1w", "bucket_seconds": 60}), + ("list_price_history", {"asset_id": "1", "start": 1, "end": 16 * 86400}), + ("list_price_history", {"asset_id": "1", "start": 100, "end": 99}), + ("list_price_history", {"asset_id": "1", "as_of": 253402300800}), + ("list_price_history", {"asset_id": "1", "as_of": 100, "page_size": 10}), + ("list_price_history", {"asset_id": "1", "as_of": 100, "interval": "1d"}), + ("get_resolutions", {"condition_ids": []}), + ("get_resolutions", {"condition_ids": COMBO}), + ("get_resolutions", {"condition_ids": CONDITION, "event_ids": [1]}), + ("get_event_live_volume", {"event_ids": [1, "2"]}), + ("get_event_live_volume", {"event_ids": 1.5}), + ("list_trades", {"condition_id": 123}), + ("list_trades", {"user": ""}), + ("list_positions", {"user": WALLET, "status": "open"}), + ("list_activity", {"user": WALLET, "activity_types": "TRADE"}), + ("list_activity", {"user": WALLET, "activity_types": ActivityType.TRADE}), + ("list_trader_leaderboard", {"category": ""}), + ("list_biggest_winners", {"category": ""}), + ("get_trader_leaderboard_standing", {"user": WALLET, "category": ""}), + ("get_builder_volumes", {"bucket_limit": 91}), + ], +) +def test_validation_before_transport(method: str, kwargs: dict[str, Any]) -> None: + with PublicClient() as client, pytest.raises(UserInputError): + getattr(client, method)(**kwargs) + + +def test_query_contracts() -> None: + assert data.build_get_user_stats_spec(user=WALLET).params == {"user": WALLET} + assert data.build_get_portfolio_value_spec(user=WALLET, condition_ids=CONDITION).params == { + "user": WALLET, + "condition_id": CONDITION, + } + assert data.list_positions_spec( + user=WALLET, status="CLOSED", include_archived=False + ).base_params == {"user": WALLET, "status": "CLOSED", "include_archived": False} + assert data.build_list_market_holders_spec(condition_ids=CONDITION).base_params == { + "condition_id": CONDITION + } + assert data.build_get_resolutions_spec(condition_ids=CONDITION).params == { + "condition_id": CONDITION + } + assert data.list_trader_leaderboard_spec(category="Sports").base_params == { + "category": "Sports" + } + assert data.build_list_price_history_spec(asset_id="1", start=100, end=100).base_params == { + "token_id": "1", + "start": 100, + "end": 100, + } + assert data.build_get_trader_leaderboard_standing_spec( + user=WALLET, category="sports", window="week" + ).params == {"user": WALLET, "category": "sports", "time_period": "week"} + assert data.get_builder_volumes_spec(interval="day", bucket_limit=2).params == { + "interval": "day", + "limit": 2, + } + at = datetime(2026, 1, 1, microsecond=900000, tzinfo=UTC) + assert data.build_get_user_volume_spec(user=WALLET, start=at).params == { + "user": WALLET, + "start": 1767225600, + } + assert data.build_list_price_history_spec(asset_id="1", interval="1d").base_params == { + "token_id": "1", + "interval": "1d", + } + assert data.list_trades_spec(filter_type="TOKENS").base_params == {"filter_type": "TOKENS"} + assert data.list_trades_spec(filter_amount=0).base_params == {"filter_amount": 0} + assert data.build_accounting_snapshot_request(user=WALLET) == ( + "/v1/accounting/snapshot", + {"user": WALLET}, + ) + + +@pytest.mark.parametrize("bound", ["start", "end"]) +@pytest.mark.parametrize("value", [0, 1, datetime(2026, 1, 1, tzinfo=UTC)]) +def test_positions_full_history_rejects_explicit_bounds(bound: str, value: object) -> None: + kwargs: dict[str, Any] = {bound: value} + with pytest.raises(UserInputError, match="full_history cannot be combined"): + data.list_positions_spec(user=WALLET, full_history=True, **kwargs) + + +def test_positions_time_bounds_and_other_full_history_feeds() -> None: + at = datetime(2026, 1, 1, microsecond=999999, tzinfo=UTC) + assert data.list_positions_spec(user=WALLET, start=at, end=1767225601).base_params == { + "user": WALLET, + "start": 1767225600, + "end": 1767225601, + } + assert data.list_trades_spec(full_history=True).base_params == {"start": 1} + assert data.list_activity_spec(user=WALLET, full_history=True).base_params == { + "user": WALLET, + "exclude_deposits_withdrawals": False, + "start": 1, + } + assert data.build_get_user_volume_spec(user=WALLET, full_history=True).params == { + "user": WALLET, + "start": 1, } -def test_get_market_holders_spec_rejects_empty_market() -> None: - with pytest.raises(UserInputError, match="non-empty"): - data_actions.get_market_holders_spec(market=[]) - - -def test_get_market_holders_spec_omits_optional_filters() -> None: - spec = data_actions.get_market_holders_spec(market=["0xabc"]) - - assert spec.params == {"market": "0xabc"} - - -def test_get_portfolio_values_spec_builds_request() -> None: - spec = data_actions.get_portfolio_values_spec(user="0xWALLET", market=["0xabc", "0xdef"]) - - assert spec.path == "/value" - assert spec.params == {"user": "0xWALLET", "market": "0xabc,0xdef"} - - -def test_get_portfolio_values_spec_rejects_empty_user() -> None: - with pytest.raises(UserInputError, match="user is required"): - data_actions.get_portfolio_values_spec(user="") - - -def test_get_portfolio_values_spec_omits_market_when_absent() -> None: - spec = data_actions.get_portfolio_values_spec(user="0xWALLET") - - assert spec.params == {"user": "0xWALLET"} - - -def test_get_traded_market_count_spec_builds_request() -> None: - spec = data_actions.get_traded_market_count_spec(user="0xWALLET") - - assert spec.path == "/traded" - assert spec.params == {"user": "0xWALLET"} - - -def test_get_traded_market_count_spec_rejects_empty_user() -> None: - with pytest.raises(UserInputError, match="user is required"): - data_actions.get_traded_market_count_spec(user="") - - -def test_get_builder_volumes_spec_omits_time_period_when_absent() -> None: - spec = data_actions.get_builder_volumes_spec() - - assert spec.path == "/v1/builders/volume" - assert spec.params == {} - - -def test_get_builder_volumes_spec_includes_time_period() -> None: - spec = data_actions.get_builder_volumes_spec(time_period="WEEK") - - assert spec.params == {"timePeriod": "WEEK"} - - -def test_build_accounting_snapshot_request_builds_path_and_params() -> None: - path, params = data_actions.build_accounting_snapshot_request(user="0xWALLET") - assert path == "/v1/accounting/snapshot" - assert params == {"user": "0xWALLET"} - - -def test_build_accounting_snapshot_request_rejects_empty_user() -> None: - with pytest.raises(UserInputError, match="user is required"): - data_actions.build_accounting_snapshot_request(user="") - - -def test_get_builder_volumes_spec_rejects_unknown_time_period() -> None: - with pytest.raises(UserInputError, match="time_period must be one of"): - data_actions.get_builder_volumes_spec(time_period="YEAR") # type: ignore[arg-type] - - -def test_get_market_holders_spec_rejects_zero_limit() -> None: - with pytest.raises(UserInputError, match="limit must be a positive integer"): - data_actions.get_market_holders_spec(market=["0xabc"], limit=0) - - -def test_get_market_holders_spec_rejects_negative_min_balance() -> None: - with pytest.raises(UserInputError, match="min_balance must be non-negative"): - data_actions.get_market_holders_spec(market=["0xabc"], min_balance=-1) - +@pytest.mark.parametrize("bound", ["updated_after", "updated_before"]) +@pytest.mark.parametrize( + "value,expected", + [ + (0, 0), + (datetime(1970, 1, 1, tzinfo=UTC), 0), + (datetime(2026, 1, 1, microsecond=999999, tzinfo=UTC), 1767225600), + (253402300799, 253402300799), + (None, None), + ], +) +def test_combo_watermarks_preserve_epoch_seconds( + bound: str, value: object, expected: int | None +) -> None: + kwargs: dict[str, Any] = {bound: value} + assert data.list_combo_positions_spec(user=WALLET, **kwargs).base_params == { + "user": WALLET, + **({bound: expected} if expected is not None else {}), + } -def test_get_market_holders_spec_accepts_zero_min_balance() -> None: - spec = data_actions.get_market_holders_spec(market=["0xabc"], min_balance=0) - assert spec.params == {"market": "0xabc", "minBalance": 0} +@pytest.mark.parametrize("bound", ["updated_after", "updated_before"]) +@pytest.mark.parametrize("value", [-1, True, 0.0, "0", datetime(1970, 1, 1), 253402300800]) +def test_combo_watermarks_reject_invalid_values(bound: str, value: object) -> None: + kwargs: dict[str, Any] = {bound: value} + with pytest.raises(UserInputError): + data.list_combo_positions_spec(user=WALLET, **kwargs) + + +@pytest.mark.parametrize("after,before", [(0, 0), (0, 1), (1, 1)]) +def test_combo_watermarks_accept_ordered_bounds(after: int, before: int) -> None: + assert data.list_combo_positions_spec( + user=WALLET, updated_after=after, updated_before=before + ).base_params == {"user": WALLET, "updated_after": after, "updated_before": before} + + +def test_combo_watermarks_reject_inverted_epoch_window() -> None: + with pytest.raises(UserInputError, match="updated_before must be at least updated_after"): + data.list_combo_positions_spec(user=WALLET, updated_after=1, updated_before=0) + + +def test_enum_members_serialize_like_plain_strings() -> None: + plain = data.list_positions_spec(user=WALLET, status="CLOSED").base_params + member = data.list_positions_spec(user=WALLET, status=PositionStatus.CLOSED).base_params + assert plain == member and member is not None and type(member["status"]) is str + combo = data.list_combo_positions_spec( + user=WALLET, status=[ComboPositionStatus.OPEN, "OPEN", "PARTIAL"] + ).base_params + assert combo == {"user": WALLET, "status": "OPEN,PARTIAL"} + single = data.list_combo_positions_spec(user=WALLET, status=ComboPositionStatus.OPEN) + assert single.base_params == {"user": WALLET, "status": "OPEN"} + activity = data.list_activity_spec(user=WALLET, activity_types=[ActivityType.TRADE, "SPLIT"]) + assert activity.base_params is not None and activity.base_params["type"] == "TRADE,SPLIT" + assert data.build_list_biggest_winners_spec(category="Politics").base_params == { + "category": "Politics" + } + assert data.build_get_user_pnl_spec( + user=WALLET, interval=UserPnlInterval.ONE_WEEK, fidelity="1h" + ).params == {"user": WALLET, "interval": "1w", "fidelity": "1h"} + + +def test_condition_canonicalization_and_time_flooring() -> None: + for prefix in ("01", "02"): + value = "0x" + prefix + "AB" * 30 + assert to_market_condition_id(value) == value.lower() + "00" + assert build_distinct_condition_ids( + [CONDITION, CONDITION.upper().replace("0X", "0x")], grammar="feed" + ) == (CONDITION,) + assert to_market_condition_id(CONDITION) == CONDITION + for value in (COMBO, "0xnothex", "0x01" + " " * 60): + with pytest.raises(TypeError): + to_market_condition_id(value) + assert to_epoch_seconds(datetime(2026, 1, 1, microsecond=999999, tzinfo=UTC)) == 1767225600 diff --git a/tests/unit/test_date_field_contract.py b/tests/unit/test_date_field_contract.py index 09457f8b..ad7f26b6 100644 --- a/tests/unit/test_date_field_contract.py +++ b/tests/unit/test_date_field_contract.py @@ -4,21 +4,10 @@ import pytest from polymarket.errors import UnexpectedResponseError -from polymarket.models.data.portfolio import ClosedPosition, Position from polymarket.models.gamma.common import parse_optional_date from polymarket.models.gamma.event import EventSchedule from polymarket.models.rtds_events import CommentRemovedPayload -_POSITION_BASE = { - "conditionId": "0x" + "0" * 64, - "asset": "1", -} - -_CLOSED_POSITION_BASE = { - "conditionId": "0x" + "0" * 64, - "asset": "1", -} - _COMMENT_REMOVED_BASE = {"id": "99"} @@ -67,72 +56,6 @@ def test_rejects_boolean(self) -> None: parse_optional_date(True) -class TestPositionEndDateContract: - def test_annotation_resolves_to_date(self) -> None: - hints = get_type_hints(Position) - assert hints["end_date"] == (date | None) - - def test_parses_date_only_wire_format(self) -> None: - pos = Position.parse_response({**_POSITION_BASE, "endDate": "2026-05-31"}) - assert pos.end_date == date(2026, 5, 31) - assert isinstance(pos.end_date, date) - - def test_parses_midnight_utc_wire_format(self) -> None: - pos = Position.parse_response({**_POSITION_BASE, "endDate": "2026-04-27T00:00:00Z"}) - assert pos.end_date == date(2026, 4, 27) - - def test_none_stays_none(self) -> None: - pos = Position.parse_response({**_POSITION_BASE, "endDate": None}) - assert pos.end_date is None - - def test_missing_field_is_none(self) -> None: - pos = Position.parse_response(_POSITION_BASE) - assert pos.end_date is None - - def test_empty_string_is_none(self) -> None: - pos = Position.parse_response({**_POSITION_BASE, "endDate": ""}) - assert pos.end_date is None - - def test_invalid_string_raises(self) -> None: - with pytest.raises(UnexpectedResponseError): - Position.parse_response({**_POSITION_BASE, "endDate": "not-a-date"}) - - -class TestClosedPositionEndDateContract: - def test_annotation_resolves_to_date(self) -> None: - hints = get_type_hints(ClosedPosition) - assert hints["end_date"] == (date | None) - - def test_parses_midnight_utc_wire_format(self) -> None: - pos = ClosedPosition.parse_response( - {**_CLOSED_POSITION_BASE, "endDate": "2026-04-27T00:00:00Z"} - ) - assert pos.end_date == date(2026, 4, 27) - assert isinstance(pos.end_date, date) - - def test_parses_date_only_wire_format(self) -> None: - pos = ClosedPosition.parse_response({**_CLOSED_POSITION_BASE, "endDate": "2026-05-31"}) - assert pos.end_date == date(2026, 5, 31) - - def test_none_stays_none(self) -> None: - pos = ClosedPosition.parse_response({**_CLOSED_POSITION_BASE, "endDate": None}) - assert pos.end_date is None - - def test_invalid_string_raises(self) -> None: - with pytest.raises(UnexpectedResponseError): - ClosedPosition.parse_response({**_CLOSED_POSITION_BASE, "endDate": "garbage"}) - - -class TestPositionEndDatesAreSamePythonTypeAcrossModels: - def test_open_and_closed_end_date_are_both_date(self) -> None: - open_pos = Position.parse_response({**_POSITION_BASE, "endDate": "2026-05-31"}) - closed_pos = ClosedPosition.parse_response( - {**_CLOSED_POSITION_BASE, "endDate": "2026-05-31T00:00:00Z"} - ) - assert type(open_pos.end_date) is type(closed_pos.end_date) - assert open_pos.end_date == closed_pos.end_date - - class TestEventScheduleEventDateContract: def test_annotation_resolves_to_date(self) -> None: hints = get_type_hints(EventSchedule) @@ -211,14 +134,6 @@ def test_invalid_string_raises(self) -> None: class TestNoStringDateFieldsRemain: - def test_position_end_date_is_not_str(self) -> None: - annotation = Position.model_fields["end_date"].annotation - assert str not in (getattr(annotation, "__args__", ()) or ()) - - def test_closed_position_end_date_is_not_str(self) -> None: - annotation = ClosedPosition.model_fields["end_date"].annotation - assert str not in (getattr(annotation, "__args__", ()) or ()) - def test_event_schedule_event_date_is_not_str(self) -> None: annotation = EventSchedule.model_fields["event_date"].annotation assert str not in (getattr(annotation, "__args__", ()) or ()) diff --git a/tests/unit/test_frames_arrow.py b/tests/unit/test_frames_arrow.py index 52d82231..e553dd07 100644 --- a/tests/unit/test_frames_arrow.py +++ b/tests/unit/test_frames_arrow.py @@ -11,9 +11,12 @@ import pyarrow as pa import pytest +from data_v2_samples import sample from pydantic import BaseModel -from polymarket.frames import to_arrow +from polymarket import ActivityType, TipSide +from polymarket.frames import to_arrow, to_pandas, to_polars +from polymarket.models.data.activity import TipActivity, parse_activities from polymarket.pagination import AsyncPaginator, Page, Paginator @@ -62,6 +65,39 @@ class _AnyPayload(BaseModel): payload: Any = None +@pytest.mark.parametrize("reverse", [False, True]) +def test_mixed_activity_enums_export_as_strings(reverse: bool) -> None: + trade = sample("activity")[0] + rows = [ + trade, + {**trade, "type": "TIP", "side": "IN"}, + {**trade, "type": "FUTURE_REWARD"}, + ] + if reverse: + rows.reverse() + items = parse_activities(rows) + page = Page(items=items, has_more=False) + expected_types = [row["type"] for row in rows] + expected_sides = [None, "IN", "BUY"] if reverse else ["BUY", "IN", None] + + table = to_arrow(page) + assert table.schema.field("type").type == pa.string() + assert table.schema.field("side").type == pa.string() + assert table.column("type").to_pylist() == expected_types + assert table.column("side").to_pylist() == expected_sides + pandas = to_pandas(page) + assert pandas["type"].tolist() == expected_types + assert pandas["side"].isna().tolist() == [value is None for value in expected_sides] + assert pandas["side"].dropna().tolist() == [ + value for value in expected_sides if value is not None + ] + polars = to_polars(page) + assert polars["type"].to_list() == expected_types + assert polars["side"].to_list() == expected_sides + tip = next(item for item in items if isinstance(item, TipActivity)) + assert tip.type is ActivityType.TIP and tip.side is TipSide.IN + + def test_single_model_yields_one_row_table() -> None: m = _Inner(price=Decimal("0.5"), size=Decimal("100")) table = to_arrow(m) @@ -464,11 +500,15 @@ def test_sdk_price_history_point_round_trips() -> None: from polymarket.models import PriceHistoryPoint items = ( - PriceHistoryPoint(t=1700000000, p=0.5), - PriceHistoryPoint(t=1700000060, p=0.51), + PriceHistoryPoint.parse_response( + {"timestamp": 1700000000, "price": 0.5, "resolution_seconds": 60} + ), + PriceHistoryPoint.parse_response( + {"timestamp": 1700000060, "price": 0.51, "resolution_seconds": 60} + ), ) table = to_arrow(items) assert table.num_rows == 2 - assert set(table.column_names) == {"t", "p"} - assert pa.types.is_integer(table.schema.field("t").type) - assert pa.types.is_floating(table.schema.field("p").type) + assert set(table.column_names) == {"timestamp", "price", "resolution_seconds"} + assert pa.types.is_timestamp(table.schema.field("timestamp").type) + assert pa.types.is_decimal(table.schema.field("price").type) diff --git a/tests/unit/test_jupyter_repr.py b/tests/unit/test_jupyter_repr.py index 5b9fff78..f1c00d24 100644 --- a/tests/unit/test_jupyter_repr.py +++ b/tests/unit/test_jupyter_repr.py @@ -7,13 +7,15 @@ from decimal import Decimal from typing import Any +from data_v2_samples import position_payload + from polymarket._jupyter import card, safe_html_repr, truncate_mid from polymarket.auth import BuilderApiKey, RelayerApiKey from polymarket.models import OrderBook from polymarket.models.clob.account import ClobTrade, OpenOrder from polymarket.models.clob.api_key import ApiKeyCreds from polymarket.models.clob.order_response import AcceptedOrder, RejectedOrder -from polymarket.models.clob.price_history import PriceHistoryPoint +from polymarket.models.data import PriceHistoryPoint from polymarket.models.data.activity import TradeActivity, UnknownActivity from polymarket.models.data.portfolio import Position from polymarket.models.gamma import Event, Market @@ -71,18 +73,18 @@ def _make_book(**overrides: object) -> OrderBook: def _make_position(**overrides: object) -> Position: payload: dict[str, object] = { - "conditionId": _CONDITION_ID, - "proxyWallet": "0x" + "ab" * 20, - "asset": "TOKEN-YES", - "size": "100", - "avgPrice": "0.55", - "curPrice": "0.60", - "cashPnl": "5.00", + "condition_id": _CONDITION_ID, + "proxy_wallet": "0x" + "ab" * 20, + "token_id": "TOKEN-YES", + "current_size": "100", + "avg_price": "0.55", + "current_price": "0.60", + "total_pnl": "5.00", "outcome": "Yes", "title": "Will X happen?", } payload.update(overrides) - return Position.model_validate(payload) + return Position.model_validate(position_payload(**payload)) _OPEN_ORDER_PAYLOAD: dict[str, Any] = { @@ -254,7 +256,9 @@ def test_clob_trade_repr_html_contains_price_and_side() -> None: def test_price_history_point_repr_html_formats_timestamp() -> None: - pt = PriceHistoryPoint(t=1700000000, p=0.62) + pt = PriceHistoryPoint.parse_response( + {"timestamp": 1700000000, "price": 0.62, "resolution_seconds": 60} + ) html = pt._repr_html_() assert "PriceHistoryPoint" in html assert "0.62" in html @@ -289,21 +293,21 @@ def test_trade_activity_repr_html_uses_variant_class_name() -> None: activity = TradeActivity.model_validate( { "type": "TRADE", - "proxyWallet": "0x" + "ab" * 20, + "proxy_wallet": "0x" + "ab" * 20, "timestamp": 1700000000, - "transactionHash": "0xTX1234567890", - "conditionId": _CONDITION_ID, - "asset": "TOKEN-1", + "transaction_hash": "0xTX1234567890", + "condition_id": _CONDITION_ID, + "token_id": "TOKEN-1", "side": "BUY", "size": "5", - "amount": "2.5", + "usdc_size": "2.5", "price": "0.5", "outcome": "Yes", - "outcomeIndex": 0, + "outcome_index": 0, "title": "Some Market", "slug": "some-market", "icon": "i.png", - "eventSlug": "evt", + "event_slug": "evt", } ) html = activity._repr_html_() @@ -442,7 +446,9 @@ def test_repr_html_methods_render_pure_html_strings() -> None: _make_position()._repr_html_(), OpenOrder.model_validate(_OPEN_ORDER_PAYLOAD)._repr_html_(), ClobTrade.model_validate(_CLOB_TRADE_PAYLOAD)._repr_html_(), - PriceHistoryPoint(t=1, p=0.5)._repr_html_(), + PriceHistoryPoint.parse_response( + {"timestamp": 1, "price": 0.5, "resolution_seconds": 60} + )._repr_html_(), ] assert all(isinstance(s, str) and s for s in rendered) diff --git a/tests/unit/test_relayer_positions_helpers.py b/tests/unit/test_relayer_positions_helpers.py index c803c2a4..75aafe61 100644 --- a/tests/unit/test_relayer_positions_helpers.py +++ b/tests/unit/test_relayer_positions_helpers.py @@ -2,6 +2,7 @@ from types import SimpleNamespace import pytest +from data_v2_samples import position_payload # pyright: reportPrivateUsage=false from polymarket._internal.actions.relayer.positions import ( @@ -33,12 +34,14 @@ def _pos( condition_id: str = _CONDITION_ID, ) -> Position: return Position.parse_response( - { - "conditionId": condition_id, - "outcomeIndex": outcome_index, - "size": str(size) if size is not None else None, - "negativeRisk": negative_risk, - } + position_payload( + **{ + "condition_id": condition_id, + "outcome_index": outcome_index, + "current_size": str(size) if size is not None else "0", + "negative_risk": negative_risk, + } + ) ) @@ -136,9 +139,8 @@ def test_expect_negative_risk_flag_works_with_single_position() -> None: def test_expect_negative_risk_flag_rejects_missing_flag() -> None: - yes_pos = _pos(outcome_index=0, negative_risk=None) - with pytest.raises(UnexpectedResponseError, match="Missing negativeRisk"): - expect_negative_risk_flag((yes_pos, None)) + with pytest.raises(UnexpectedResponseError): + _pos(outcome_index=0, negative_risk=None) def test_expect_negative_risk_flag_rejects_mixed_flags() -> None: @@ -220,7 +222,7 @@ def test_derive_binary_position_amounts_rejects_non_finite_size() -> None: yes_pos = Position.model_construct( condition_id=_CONDITION_ID, outcome_index=0, - size=Decimal("NaN"), + current_size=Decimal("NaN"), negative_risk=True, ) no_pos = _pos(outcome_index=1, size=Decimal("0"), negative_risk=True) @@ -232,7 +234,7 @@ def test_derive_binary_position_amounts_rejects_infinity() -> None: yes_pos = Position.model_construct( condition_id=_CONDITION_ID, outcome_index=0, - size=Decimal("Infinity"), + current_size=Decimal("Infinity"), negative_risk=True, ) no_pos = _pos(outcome_index=1, size=Decimal("0"), negative_risk=True) diff --git a/tests/unit/test_secure_signer_defaults.py b/tests/unit/test_secure_signer_defaults.py index a2096319..cd7cd07f 100644 --- a/tests/unit/test_secure_signer_defaults.py +++ b/tests/unit/test_secure_signer_defaults.py @@ -20,7 +20,14 @@ def _capturing_handler(captured: list[httpx.Request], payload: Any) -> httpx.MockTransport: def handler(request: httpx.Request) -> httpx.Response: captured.append(request) - return httpx.Response(200, json=payload, request=request) + payload_value: dict[str, Any] + if request.url.path == "/v2/value": + payload_value = {"data": {"proxy_wallet": request.url.params["user"], "value": 0}} + elif request.url.path == "/v2/user-stats": + payload_value = {"data": None} + else: + payload_value = {"data": [], "pagination": {"has_more": False, "next_cursor": None}} + return httpx.Response(200, json=payload_value, request=request) return httpx.MockTransport(handler) @@ -50,10 +57,10 @@ def _captured() -> list[httpx.Request]: return [] -# ---- get_portfolio_values ---- +# ---- get_portfolio_value ---- -def test_secure_get_portfolio_values_defaults_to_signer(captured: list[httpx.Request]) -> None: +def test_secure_get_portfolio_value_defaults_to_signer(captured: list[httpx.Request]) -> None: with SecureClient._create( private_key=PRIVATE_KEY, wallet=SIGNER_ADDRESS, @@ -61,12 +68,12 @@ def test_secure_get_portfolio_values_defaults_to_signer(captured: list[httpx.Req validate_credentials=False, ) as client: _install_sync_data(client, _capturing_handler(captured, [])) - client.get_portfolio_values() + client.get_portfolio_value() assert _qs_user(captured[0]) == SIGNER_ADDRESS -def test_secure_get_portfolio_values_respects_explicit_user( +def test_secure_get_portfolio_value_respects_explicit_user( captured: list[httpx.Request], ) -> None: with SecureClient._create( @@ -76,15 +83,15 @@ def test_secure_get_portfolio_values_respects_explicit_user( validate_credentials=False, ) as client: _install_sync_data(client, _capturing_handler(captured, [])) - client.get_portfolio_values(user=OTHER_WALLET) + client.get_portfolio_value(user=OTHER_WALLET) assert _qs_user(captured[0]) == OTHER_WALLET -# ---- get_traded_market_count ---- +# ---- get_user_stats ---- -def test_secure_get_traded_market_count_defaults_to_signer( +def test_secure_get_user_stats_defaults_to_signer( captured: list[httpx.Request], ) -> None: with SecureClient._create( @@ -96,12 +103,12 @@ def test_secure_get_traded_market_count_defaults_to_signer( _install_sync_data( client, _capturing_handler(captured, {"user": SIGNER_ADDRESS, "traded": 0}) ) - client.get_traded_market_count() + client.get_user_stats() assert _qs_user(captured[0]) == SIGNER_ADDRESS -def test_secure_get_traded_market_count_respects_explicit_user( +def test_secure_get_user_stats_respects_explicit_user( captured: list[httpx.Request], ) -> None: with SecureClient._create( @@ -113,7 +120,7 @@ def test_secure_get_traded_market_count_respects_explicit_user( _install_sync_data( client, _capturing_handler(captured, {"user": OTHER_WALLET, "traded": 0}) ) - client.get_traded_market_count(user=OTHER_WALLET) + client.get_user_stats(user=OTHER_WALLET) assert _qs_user(captured[0]) == OTHER_WALLET @@ -199,7 +206,7 @@ def test_secure_list_closed_positions_defaults_to_signer(captured: list[httpx.Re validate_credentials=False, ) as client: _install_sync_data(client, _capturing_handler(captured, [])) - client.list_closed_positions().first_page() + client.list_positions(status="CLOSED").first_page() assert _qs_user(captured[0]) == SIGNER_ADDRESS @@ -346,7 +353,7 @@ def test_secure_list_positions_rejects_explicit_empty_user( client.list_positions(user="").first_page() -def test_secure_get_portfolio_values_rejects_explicit_empty_user( +def test_secure_get_portfolio_value_rejects_explicit_empty_user( captured: list[httpx.Request], ) -> None: from polymarket.errors import UserInputError @@ -359,4 +366,4 @@ def test_secure_get_portfolio_values_rejects_explicit_empty_user( ) as client: _install_sync_data(client, _capturing_handler(captured, [])) with pytest.raises(UserInputError, match="user is required"): - client.get_portfolio_values(user="") + client.get_portfolio_value(user="") diff --git a/tests/unit/test_sync_relayer_workflows.py b/tests/unit/test_sync_relayer_workflows.py index 2037f072..59071d9a 100644 --- a/tests/unit/test_sync_relayer_workflows.py +++ b/tests/unit/test_sync_relayer_workflows.py @@ -18,6 +18,7 @@ request_json, trading_approval_rpc_handler, ) +from data_v2_samples import position_payload from eth_abi.abi import decode as abi_decode from eth_abi.abi import encode as abi_encode @@ -478,20 +479,24 @@ def _stub_binary_positions( # type: ignore[no-untyped-def] from polymarket.pagination import Page yes = Position.parse_response( - { - "conditionId": condition_id, - "outcomeIndex": 0, - "size": yes_size, - "negativeRisk": neg_risk, - } + position_payload( + **{ + "condition_id": condition_id, + "outcome_index": 0, + "current_size": yes_size, + "negative_risk": neg_risk, + } + ) ) no = Position.parse_response( - { - "conditionId": condition_id, - "outcomeIndex": 1, - "size": no_size, - "negativeRisk": neg_risk, - } + position_payload( + **{ + "condition_id": condition_id, + "outcome_index": 1, + "current_size": no_size, + "negative_risk": neg_risk, + } + ) ) class _StubPaginator: