diff --git a/src/polymarket/__init__.py b/src/polymarket/__init__.py index 8c50f5d..890840a 100644 --- a/src/polymarket/__init__.py +++ b/src/polymarket/__init__.py @@ -31,6 +31,9 @@ ClobTrade, ClosedPosition, ComboConditionId, + ComboMarket, + ComboMarketOutcome, + ComboMarketOutcomes, ComboPosition, ComboPositionLeg, ComboPositionMarket, @@ -180,6 +183,9 @@ "CancelledSigningError", "ClobTrade", "ClosedPosition", + "ComboMarket", + "ComboMarketOutcome", + "ComboMarketOutcomes", "ComboPosition", "Comment", "ComboPositionLeg", diff --git a/src/polymarket/_internal/actions/rfq.py b/src/polymarket/_internal/actions/rfq.py new file mode 100644 index 0000000..ebddb2f --- /dev/null +++ b/src/polymarket/_internal/actions/rfq.py @@ -0,0 +1,69 @@ +from collections.abc import Sequence +from typing import cast + +from polymarket._internal.request import KeysetPagePayload, KeysetPaginatedSpec, QueryParamValue +from polymarket.errors import UnexpectedResponseError, UserInputError +from polymarket.models.rfq import ComboMarket +from polymarket.models.types import validate_ctf_condition_id + +_MAX_COMBO_MARKETS_PAGE_SIZE = 100 + + +def list_combo_markets_spec( + *, + exclude: str | Sequence[str] | None = None, +) -> KeysetPaginatedSpec[ComboMarket]: + params: dict[str, QueryParamValue] = {} + excluded = _coerce_excluded_condition_ids(exclude) + if excluded: + params["exclude"] = ",".join(excluded) + + return KeysetPaginatedSpec( + service="rfq", + path="/v1/rfq/combo-markets", + parse_page=_parse_combo_markets_page, + base_params=params or None, + cursor_param="cursor", + ) + + +def validate_combo_markets_page_size(page_size: int) -> None: + if type(page_size) is not int: + raise UserInputError("page_size must be an int.") + if page_size < 1 or page_size > _MAX_COMBO_MARKETS_PAGE_SIZE: + raise UserInputError(f"page_size must be between 1 and {_MAX_COMBO_MARKETS_PAGE_SIZE}.") + + +def _parse_combo_markets_page(data: object) -> KeysetPagePayload[ComboMarket]: + if not isinstance(data, dict): + raise UnexpectedResponseError("Combo market response did not match expected shape") + payload = cast(dict[str, object], data) + + raw_markets = payload.get("markets") + if not isinstance(raw_markets, list): + raise UnexpectedResponseError("Combo market response is missing markets array") + market_items = cast(list[object], raw_markets) + markets = tuple(ComboMarket.parse_response(item) for item in market_items) + + raw_cursor = payload.get("next_cursor") + if raw_cursor is None: + next_cursor = None + elif isinstance(raw_cursor, str) and raw_cursor: + next_cursor = raw_cursor + else: + raise UnexpectedResponseError("Combo market next_cursor did not match expected shape") + + return KeysetPagePayload(items=markets, server_next_cursor=next_cursor) + + +def _coerce_excluded_condition_ids(exclude: str | Sequence[str] | None) -> tuple[str, ...]: + if exclude is None: + return () + if isinstance(exclude, str): + return (validate_ctf_condition_id(exclude),) + if isinstance(exclude, bytes): + raise UserInputError("exclude does not accept bytes") + return tuple(validate_ctf_condition_id(value) for value in exclude) + + +__all__ = ["list_combo_markets_spec", "validate_combo_markets_page_size"] diff --git a/src/polymarket/_internal/context.py b/src/polymarket/_internal/context.py index a0725c5..516cbb2 100644 --- a/src/polymarket/_internal/context.py +++ b/src/polymarket/_internal/context.py @@ -19,6 +19,7 @@ class SyncClientContext: gamma: SyncTransport data: SyncTransport clob: SyncTransport + rfq: SyncTransport @dataclass(frozen=True, slots=True) @@ -39,6 +40,7 @@ class AsyncClientContext: gamma: AsyncTransport data: AsyncTransport clob: AsyncTransport + rfq: AsyncTransport @dataclass(frozen=True, slots=True) diff --git a/src/polymarket/_internal/dispatch.py b/src/polymarket/_internal/dispatch.py index 2f695aa..3985aeb 100644 --- a/src/polymarket/_internal/dispatch.py +++ b/src/polymarket/_internal/dispatch.py @@ -32,6 +32,8 @@ def _sync_transport_for(ctx: SyncClientContext, service: Service) -> SyncTranspo return ctx.gamma case "data": return ctx.data + case "rfq": + return ctx.rfq case _ as unreachable: assert_never(unreachable) @@ -42,6 +44,8 @@ def _async_transport_for(ctx: AsyncClientContext, service: Service) -> AsyncTran return ctx.gamma case "data": return ctx.data + case "rfq": + return ctx.rfq case _ as unreachable: assert_never(unreachable) @@ -175,7 +179,7 @@ def fetch(cursor: str | None) -> Page[T]: "limit": page_size, } if server_cursor is not None: - params["after_cursor"] = server_cursor + params[spec.cursor_param] = server_cursor payload = transport.get_json(spec.path, params=params) keyset_page = spec.parse_page(payload) return compute_keyset_page( @@ -216,7 +220,7 @@ async def fetch(cursor: str | None) -> Page[T]: "limit": page_size, } if server_cursor is not None: - params["after_cursor"] = server_cursor + params[spec.cursor_param] = server_cursor payload = await transport.get_json(spec.path, params=params) keyset_page = spec.parse_page(payload) return compute_keyset_page( diff --git a/src/polymarket/_internal/request.py b/src/polymarket/_internal/request.py index f1bcfa0..4413213 100644 --- a/src/polymarket/_internal/request.py +++ b/src/polymarket/_internal/request.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from typing import Generic, Literal, TypeVar -Service = Literal["gamma", "data"] +Service = Literal["gamma", "data", "rfq"] Method = Literal["GET"] QueryParamScalar = str | int | float | bool @@ -41,6 +41,7 @@ class KeysetPaginatedSpec(Generic[T]): path: str parse_page: Callable[[object], "KeysetPagePayload[T]"] base_params: Mapping[str, QueryParamValue] | None = None + cursor_param: str = "after_cursor" @dataclass(frozen=True, slots=True) diff --git a/src/polymarket/clients/async_public.py b/src/polymarket/clients/async_public.py index ba95a96..8b42a9d 100644 --- a/src/polymarket/clients/async_public.py +++ b/src/polymarket/clients/async_public.py @@ -12,6 +12,7 @@ from polymarket._internal.actions import data as _data_actions 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, @@ -90,6 +91,7 @@ TradedMarketCount, TraderLeaderboardEntry, ) +from polymarket.models.rfq import ComboMarket from polymarket.models.rtds_events import ( CommentsEvent, CryptoPricesEvent, @@ -132,6 +134,7 @@ def __init__( gamma=AsyncTransport(base_url=environment.gamma_url, logger=logger), data=AsyncTransport(base_url=environment.data_url, logger=logger), clob=AsyncTransport(base_url=environment.clob_url, logger=logger), + rfq=AsyncTransport(base_url=environment.rfq_url, logger=logger), ) self._market_manager: ClobMarketStreamManager | None = None self._sports_manager: SportsStreamManager | None = None @@ -289,7 +292,10 @@ async def close(self) -> None: try: await self._ctx.data.close() finally: - await self._ctx.clob.close() + try: + await self._ctx.clob.close() + finally: + await self._ctx.rfq.close() async def get_market( self, @@ -895,6 +901,21 @@ def list_markets( ) return async_paginate_keyset(self._ctx, spec, page_size=page_size) + def list_combo_markets( + self, + *, + exclude: str | Sequence[str] | None = None, + page_size: int = 20, + ) -> AsyncPaginator[ComboMarket]: + """List markets available for Combos. + + Returns: + An async paginator over matching Combo markets. + """ + _rfq_actions.validate_combo_markets_page_size(page_size) + spec = _rfq_actions.list_combo_markets_spec(exclude=exclude) + return async_paginate_keyset(self._ctx, spec, page_size=page_size) + def list_series( self, *, diff --git a/src/polymarket/clients/async_secure.py b/src/polymarket/clients/async_secure.py index d506d0b..5da7f00 100644 --- a/src/polymarket/clients/async_secure.py +++ b/src/polymarket/clients/async_secure.py @@ -28,6 +28,7 @@ from polymarket._internal.actions import data as _data_actions 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, @@ -200,6 +201,7 @@ TradedMarketCount, TraderLeaderboardEntry, ) +from polymarket.models.rfq import ComboMarket from polymarket.models.rtds_events import ( CommentsEvent, CryptoPricesEvent, @@ -413,6 +415,7 @@ def _construct_for_wallet( gamma = AsyncTransport(base_url=environment.gamma_url, logger=logger) data = AsyncTransport(base_url=environment.data_url, logger=logger) clob = AsyncTransport(base_url=environment.clob_url, logger=logger) + rfq = AsyncTransport(base_url=environment.rfq_url, logger=logger) relayer_resolver = make_relayer_header_resolver(api_key) if api_key is not None else None relayer = AsyncTransport( base_url=environment.relayer_url, @@ -432,6 +435,7 @@ def _construct_for_wallet( gamma=gamma, data=data, clob=clob, + rfq=rfq, signer=signer, credentials=credentials, secure_clob=secure_clob, @@ -685,6 +689,7 @@ async def close(self) -> None: ctx.gamma, ctx.data, ctx.clob, + ctx.rfq, ctx.secure_clob, ctx.relayer, ctx.rpc, @@ -1324,6 +1329,21 @@ def list_markets( ) return async_paginate_keyset(self._ctx, spec, page_size=page_size) + def list_combo_markets( + self, + *, + exclude: str | Sequence[str] | None = None, + page_size: int = 20, + ) -> AsyncPaginator[ComboMarket]: + """List markets available for Combos. + + Returns: + An async paginator over matching Combo markets. + """ + _rfq_actions.validate_combo_markets_page_size(page_size) + spec = _rfq_actions.list_combo_markets_spec(exclude=exclude) + return async_paginate_keyset(self._ctx, spec, page_size=page_size) + def list_series( self, *, diff --git a/src/polymarket/clients/public.py b/src/polymarket/clients/public.py index e3cad14..a4e8542 100644 --- a/src/polymarket/clients/public.py +++ b/src/polymarket/clients/public.py @@ -11,6 +11,7 @@ from polymarket._internal.actions import data as _data_actions 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, @@ -87,6 +88,7 @@ TradedMarketCount, TraderLeaderboardEntry, ) +from polymarket.models.rfq import ComboMarket from polymarket.models.types import CtfConditionId from polymarket.pagination import Page, Paginator @@ -108,6 +110,7 @@ def __init__( gamma=SyncTransport(base_url=environment.gamma_url, logger=logger), data=SyncTransport(base_url=environment.data_url, logger=logger), clob=SyncTransport(base_url=environment.clob_url, logger=logger), + rfq=SyncTransport(base_url=environment.rfq_url, logger=logger), ) @property @@ -134,7 +137,10 @@ def close(self) -> None: try: self._ctx.data.close() finally: - self._ctx.clob.close() + try: + self._ctx.clob.close() + finally: + self._ctx.rfq.close() def get_market( self, @@ -749,6 +755,21 @@ def list_markets( ) return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + def list_combo_markets( + self, + *, + exclude: str | Sequence[str] | None = None, + page_size: int = 20, + ) -> Paginator[ComboMarket]: + """List markets available for Combos. + + Returns: + A paginator over matching Combo markets. + """ + _rfq_actions.validate_combo_markets_page_size(page_size) + spec = _rfq_actions.list_combo_markets_spec(exclude=exclude) + return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + def list_series( self, *, diff --git a/src/polymarket/clients/secure.py b/src/polymarket/clients/secure.py index ecacb25..acd8d17 100644 --- a/src/polymarket/clients/secure.py +++ b/src/polymarket/clients/secure.py @@ -18,6 +18,7 @@ from polymarket._internal.actions import data as _data_actions 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, @@ -184,6 +185,7 @@ TradedMarketCount, TraderLeaderboardEntry, ) +from polymarket.models.rfq import ComboMarket from polymarket.models.types import CtfConditionId from polymarket.pagination import Page, Paginator from polymarket.transactions import ( @@ -358,6 +360,7 @@ def _construct_for_wallet( gamma = SyncTransport(base_url=environment.gamma_url, logger=logger) data = SyncTransport(base_url=environment.data_url, logger=logger) clob = SyncTransport(base_url=environment.clob_url, logger=logger) + rfq = SyncTransport(base_url=environment.rfq_url, logger=logger) relayer_resolver = ( make_relayer_header_resolver_sync(api_key) if api_key is not None else None ) @@ -378,6 +381,7 @@ def _construct_for_wallet( gamma.close() data.close() clob.close() + rfq.close() relayer.close() raise @@ -386,6 +390,7 @@ def _construct_for_wallet( gamma=gamma, data=data, clob=clob, + rfq=rfq, signer=signer, credentials=credentials, secure_clob=secure_clob, @@ -451,7 +456,10 @@ def close(self) -> None: try: ctx.relayer.close() finally: - ctx.rpc.close() + try: + ctx.rpc.close() + finally: + ctx.rfq.close() def _user_or_wallet(self, user: str | None) -> str: return self._ctx.wallet if user is None else user @@ -1054,6 +1062,21 @@ def list_markets( ) return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + def list_combo_markets( + self, + *, + exclude: str | Sequence[str] | None = None, + page_size: int = 20, + ) -> Paginator[ComboMarket]: + """List markets available for Combos. + + Returns: + A paginator over matching Combo markets. + """ + _rfq_actions.validate_combo_markets_page_size(page_size) + spec = _rfq_actions.list_combo_markets_spec(exclude=exclude) + return sync_paginate_keyset(self._ctx, spec, page_size=page_size) + def list_series( self, *, diff --git a/src/polymarket/environments.py b/src/polymarket/environments.py index ef22d85..3c54c8f 100644 --- a/src/polymarket/environments.py +++ b/src/polymarket/environments.py @@ -35,6 +35,7 @@ class Environment: relayer_url: str gamma_url: str data_url: str + rfq_url: str rtds_ws_url: str sports_ws_url: str rpc_url: str @@ -76,6 +77,7 @@ class Environment: relayer_url="https://relayer-v2.polymarket.com", gamma_url="https://gamma-api.polymarket.com", data_url="https://data-api.polymarket.com", + rfq_url="https://combos-rfq-api.polymarket.sh", rtds_ws_url="wss://ws-live-data.polymarket.com", sports_ws_url="wss://sports-api.polymarket.com/ws", rpc_url="https://polygon.drpc.org", diff --git a/src/polymarket/models/__init__.py b/src/polymarket/models/__init__.py index e18a32d..9be5ec7 100644 --- a/src/polymarket/models/__init__.py +++ b/src/polymarket/models/__init__.py @@ -94,6 +94,7 @@ TagReference, Team, ) +from polymarket.models.rfq import ComboMarket, ComboMarketOutcome, ComboMarketOutcomes from polymarket.models.types import ( ComboConditionId, CommentId, @@ -160,6 +161,9 @@ "BuilderVolumeEntry", "BuilderVolumeTimePeriod", "ClosedPosition", + "ComboMarket", + "ComboMarketOutcome", + "ComboMarketOutcomes", "ComboPosition", "ComboPositionLeg", "ComboPositionMarket", diff --git a/src/polymarket/models/rfq.py b/src/polymarket/models/rfq.py new file mode 100644 index 0000000..09a360f --- /dev/null +++ b/src/polymarket/models/rfq.py @@ -0,0 +1,110 @@ +"""RFQ market catalog models.""" + +from __future__ import annotations + +from decimal import Decimal +from typing import cast + +from pydantic import Field, field_validator, model_validator + +from polymarket.models.base import BaseModel +from polymarket.models.gamma.common import parse_decimal, parse_string_sequence +from polymarket.models.types import ( + CtfConditionId, + MarketId, + PositionId, + validate_ctf_condition_id, +) + + +class ComboMarketOutcome(BaseModel): + """One side of a Combo market.""" + + label: str + position_id: PositionId = Field(validation_alias="positionId") + price: Decimal + + @field_validator("price", mode="before") + @classmethod + def _parse_price(cls, value: object) -> Decimal: + return parse_decimal(value) + + +class ComboMarketOutcomes(BaseModel): + """Binary Combo market outcomes.""" + + yes: ComboMarketOutcome + no: ComboMarketOutcome + + +class ComboMarket(BaseModel): + """Market available for Combos.""" + + id: MarketId + condition_id: CtfConditionId + slug: str + title: str + outcomes: ComboMarketOutcomes + image: str + volume: Decimal + tags: tuple[str, ...] + + @model_validator(mode="before") + @classmethod + def _normalize_response(cls, data: object) -> object: + if not isinstance(data, dict): + return data + payload = cast(dict[str, object], data) + raw_outcomes = payload.get("outcomes") + if not isinstance(raw_outcomes, list): + return payload + outcomes = cast(list[object], raw_outcomes) + + raw_position_ids = payload.get("position_ids") + raw_prices = payload.get("outcome_prices") + if not isinstance(raw_position_ids, list): + raise ValueError("expected position_ids to be an array") + position_ids = cast(list[object], raw_position_ids) + if not isinstance(raw_prices, list): + raise ValueError("expected outcome_prices to be an array") + prices = cast(list[object], raw_prices) + if len(outcomes) != 2: + raise ValueError(f"expected binary combo market outcomes, got {len(outcomes)}") + if len(position_ids) != len(outcomes): + raise ValueError("expected position_ids and outcomes to have matching lengths") + if len(prices) != len(outcomes): + raise ValueError("expected outcome_prices and outcomes to have matching lengths") + + return { + **payload, + "outcomes": { + "yes": { + "label": outcomes[0], + "positionId": position_ids[0], + "price": prices[0], + }, + "no": { + "label": outcomes[1], + "positionId": position_ids[1], + "price": prices[1], + }, + }, + } + + @field_validator("condition_id", mode="before") + @classmethod + def _parse_condition_id(cls, value: object) -> CtfConditionId: + return validate_ctf_condition_id(value) + + @field_validator("volume", mode="before") + @classmethod + def _parse_volume(cls, value: object) -> Decimal: + return parse_decimal(value) + + @field_validator("tags", mode="before") + @classmethod + def _parse_tags(cls, value: object) -> tuple[str, ...]: + return parse_string_sequence(value) + + +__all__ = ["ComboMarket", "ComboMarketOutcome", "ComboMarketOutcomes"] diff --git a/tests/unit/test_builder_trades.py b/tests/unit/test_builder_trades.py index c216e19..b5b65fa 100644 --- a/tests/unit/test_builder_trades.py +++ b/tests/unit/test_builder_trades.py @@ -261,6 +261,7 @@ def test_first_page_hits_builder_trades_endpoint_with_filters(self) -> None: base_url=PRODUCTION.clob_url, client=httpx.Client(base_url=PRODUCTION.clob_url, transport=handler), ), + rfq=client._ctx.rfq, ) page = client.list_builder_trades( @@ -312,6 +313,7 @@ async def run() -> list[str]: base_url=PRODUCTION.clob_url, transport=httpx.MockTransport(handler) ), ), + rfq=client._ctx.rfq, ) ids: list[str] = [] async for trade in client.list_builder_trades( diff --git a/tests/unit/test_streams_subscribe_router.py b/tests/unit/test_streams_subscribe_router.py index d9276f4..7c2ca06 100644 --- a/tests/unit/test_streams_subscribe_router.py +++ b/tests/unit/test_streams_subscribe_router.py @@ -81,6 +81,7 @@ def _env_with( relayer_url=PRODUCTION.relayer_url, gamma_url=PRODUCTION.gamma_url, data_url=PRODUCTION.data_url, + rfq_url=PRODUCTION.rfq_url, rtds_ws_url=rtds_ws_url, sports_ws_url=sports_ws_url, rpc_url=PRODUCTION.rpc_url,