From 38397138fc30c237d086d2fe277a357b4c2e9f20 Mon Sep 17 00:00:00 2001 From: Cesare Naldi <3353250+cesarenaldi@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:28:45 +0200 Subject: [PATCH] feat(client): add combo market catalog --- src/polymarket/__init__.py | 6 + src/polymarket/_internal/actions/rfq.py | 87 ++++++++++++ src/polymarket/_internal/context.py | 2 + src/polymarket/_internal/dispatch.py | 12 +- src/polymarket/_internal/request.py | 4 +- src/polymarket/clients/async_public.py | 22 ++- src/polymarket/clients/async_secure.py | 19 +++ src/polymarket/clients/public.py | 22 ++- src/polymarket/clients/secure.py | 28 +++- src/polymarket/environments.py | 2 + src/polymarket/models/__init__.py | 4 + src/polymarket/models/rfq.py | 150 ++++++++++++++++++++ tests/integration/test_rfq_paginated.py | 34 +++++ tests/unit/test_builder_trades.py | 2 + tests/unit/test_rfq_models.py | 43 ++++++ tests/unit/test_rfq_paginated_specs.py | 13 ++ tests/unit/test_streams_subscribe_router.py | 1 + 17 files changed, 443 insertions(+), 8 deletions(-) create mode 100644 src/polymarket/_internal/actions/rfq.py create mode 100644 src/polymarket/models/rfq.py create mode 100644 tests/integration/test_rfq_paginated.py create mode 100644 tests/unit/test_rfq_models.py create mode 100644 tests/unit/test_rfq_paginated_specs.py diff --git a/src/polymarket/__init__.py b/src/polymarket/__init__.py index 8c50f5d..5681424 100644 --- a/src/polymarket/__init__.py +++ b/src/polymarket/__init__.py @@ -31,6 +31,9 @@ ClobTrade, ClosedPosition, ComboConditionId, + ComboMarket, + ComboMarketOutcome, + ComboMarketOutcomes, ComboPosition, ComboPositionLeg, ComboPositionMarket, @@ -188,6 +191,9 @@ "ComboPositionStatus", "CommentId", "ComboConditionId", + "ComboMarket", + "ComboMarketOutcome", + "ComboMarketOutcomes", "ConditionId", "CtfConditionId", "ConversionActivity", diff --git a/src/polymarket/_internal/actions/rfq.py b/src/polymarket/_internal/actions/rfq.py new file mode 100644 index 0000000..ea48969 --- /dev/null +++ b/src/polymarket/_internal/actions/rfq.py @@ -0,0 +1,87 @@ +from collections.abc import Callable, Sequence +from typing import Any, TypeVar, cast + +from polymarket._internal.request import KeysetPagePayload, KeysetPaginatedSpec, QueryParamValue +from polymarket.errors import UnexpectedResponseError, UserInputError +from polymarket.models import ComboMarket + +_T = TypeVar("_T") + + +def _make_keyset_parser( + items_key: str, + parse_item: Callable[[object], _T], +) -> Callable[[object], KeysetPagePayload[_T]]: + def parse(data: object) -> KeysetPagePayload[_T]: + if not isinstance(data, dict): + raise UnexpectedResponseError("Expected an object response for keyset pagination.") + data_dict = cast(dict[str, Any], data) + + if items_key not in data_dict: + raise UnexpectedResponseError( + f"Keyset response is missing required '{items_key}' field." + ) + raw = data_dict[items_key] + if not isinstance(raw, list): + raise UnexpectedResponseError(f"Expected '{items_key}' to be an array.") + items_list = cast(list[Any], raw) + items = tuple(parse_item(item) for item in items_list) + + if "next_cursor" not in data_dict: + server_cursor: str | None = None + else: + nc = data_dict["next_cursor"] + if nc is None: + server_cursor = None + elif isinstance(nc, str): + if not nc: + raise UnexpectedResponseError( + "'next_cursor' must be a non-empty string when present." + ) + server_cursor = nc + else: + raise UnexpectedResponseError( + f"'next_cursor' must be a string when present, got {type(nc).__name__}." + ) + + return KeysetPagePayload(items=items, server_next_cursor=server_cursor) + + return parse + + +def _add_optional_comma_seq( + params: dict[str, QueryParamValue], + key: str, + value: str | Sequence[str] | None, +) -> None: + if value is None: + return + if isinstance(value, bytes): + raise UserInputError(f"{key} does not accept bytes") + if isinstance(value, str): + if value: + params[key] = value + return + coerced = tuple(value) + if coerced: + params[key] = ",".join(coerced) + + +def list_combo_markets_spec( + *, + exclude: str | Sequence[str] | None = None, +) -> KeysetPaginatedSpec[ComboMarket]: + params: dict[str, QueryParamValue] = {} + _add_optional_comma_seq(params, "exclude", exclude) + + return KeysetPaginatedSpec( + service="rfq", + path="/v1/rfq/combo-markets", + parse_page=_make_keyset_parser("markets", ComboMarket.parse_response), + base_params=params or None, + cursor_param="cursor", + max_page_size=100, + ) + + +__all__ = ["list_combo_markets_spec"] diff --git a/src/polymarket/_internal/context.py b/src/polymarket/_internal/context.py index a0725c5..8bfe098 100644 --- a/src/polymarket/_internal/context.py +++ b/src/polymarket/_internal/context.py @@ -18,6 +18,7 @@ class SyncClientContext: environment: Environment gamma: SyncTransport data: SyncTransport + rfq: SyncTransport clob: SyncTransport @@ -38,6 +39,7 @@ class AsyncClientContext: environment: Environment gamma: AsyncTransport data: AsyncTransport + rfq: AsyncTransport clob: AsyncTransport diff --git a/src/polymarket/_internal/dispatch.py b/src/polymarket/_internal/dispatch.py index 2f695aa..c88d9f9 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) @@ -157,6 +161,8 @@ def sync_paginate_keyset( ) -> Paginator[T]: if 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}.") transport = _sync_transport_for(ctx, spec.service) def fetch(cursor: str | None) -> Page[T]: @@ -175,7 +181,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( @@ -198,6 +204,8 @@ def async_paginate_keyset( ) -> AsyncPaginator[T]: if 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}.") transport = _async_transport_for(ctx, spec.service) async def fetch(cursor: str | None) -> Page[T]: @@ -216,7 +224,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..faf3cb1 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,8 @@ class KeysetPaginatedSpec(Generic[T]): path: str parse_page: Callable[[object], "KeysetPagePayload[T]"] base_params: Mapping[str, QueryParamValue] | None = None + cursor_param: str = "after_cursor" + max_page_size: int | None = None @dataclass(frozen=True, slots=True) diff --git a/src/polymarket/clients/async_public.py b/src/polymarket/clients/async_public.py index ba95a96..c4ecc62 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, @@ -47,6 +48,7 @@ from polymarket.environments import PRODUCTION, Environment from polymarket.errors import RequestRejectedError from polymarket.models import ( + ComboMarket, Comment, Event, LastTradePrice, @@ -131,6 +133,7 @@ def __init__( environment=environment, gamma=AsyncTransport(base_url=environment.gamma_url, logger=logger), data=AsyncTransport(base_url=environment.data_url, logger=logger), + rfq=AsyncTransport(base_url=environment.rfq_url, logger=logger), clob=AsyncTransport(base_url=environment.clob_url, logger=logger), ) self._market_manager: ClobMarketStreamManager | 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.rfq.close() + finally: + await self._ctx.clob.close() async def get_market( self, @@ -895,6 +901,20 @@ 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. + """ + 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..95a251b 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, @@ -144,6 +145,7 @@ BuilderFeeRates, BuilderTrade, ClobTrade, + ComboMarket, Comment, Event, LastTradePrice, @@ -412,6 +414,7 @@ def _construct_for_wallet( gamma = AsyncTransport(base_url=environment.gamma_url, logger=logger) data = AsyncTransport(base_url=environment.data_url, logger=logger) + rfq = AsyncTransport(base_url=environment.rfq_url, logger=logger) clob = AsyncTransport(base_url=environment.clob_url, logger=logger) relayer_resolver = make_relayer_header_resolver(api_key) if api_key is not None else None relayer = AsyncTransport( @@ -431,6 +434,7 @@ def _construct_for_wallet( environment=environment, gamma=gamma, data=data, + rfq=rfq, clob=clob, signer=signer, credentials=credentials, @@ -684,6 +688,7 @@ async def close(self) -> None: _RfqSessionCloser(self._close_rfq_session), ctx.gamma, ctx.data, + ctx.rfq, ctx.clob, ctx.secure_clob, ctx.relayer, @@ -1324,6 +1329,20 @@ 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. + """ + 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..9b33ce3 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, @@ -45,6 +46,7 @@ from polymarket.environments import PRODUCTION, Environment from polymarket.errors import RequestRejectedError from polymarket.models import ( + ComboMarket, Comment, Event, LastTradePrice, @@ -107,6 +109,7 @@ def __init__( environment=environment, gamma=SyncTransport(base_url=environment.gamma_url, logger=logger), data=SyncTransport(base_url=environment.data_url, logger=logger), + rfq=SyncTransport(base_url=environment.rfq_url, logger=logger), clob=SyncTransport(base_url=environment.clob_url, logger=logger), ) @@ -134,7 +137,10 @@ def close(self) -> None: try: self._ctx.data.close() finally: - self._ctx.clob.close() + try: + self._ctx.rfq.close() + finally: + self._ctx.clob.close() def get_market( self, @@ -749,6 +755,20 @@ 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. + """ + 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..8b724bc 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, @@ -129,6 +130,7 @@ BalanceAllowance, BuilderFeeRates, ClobTrade, + ComboMarket, Comment, Event, LastTradePrice, @@ -357,6 +359,7 @@ def _construct_for_wallet( gamma = SyncTransport(base_url=environment.gamma_url, logger=logger) data = SyncTransport(base_url=environment.data_url, logger=logger) + rfq = SyncTransport(base_url=environment.rfq_url, logger=logger) clob = SyncTransport(base_url=environment.clob_url, logger=logger) relayer_resolver = ( make_relayer_header_resolver_sync(api_key) if api_key is not None else None @@ -377,6 +380,7 @@ def _construct_for_wallet( except BaseException: gamma.close() data.close() + rfq.close() clob.close() relayer.close() raise @@ -385,6 +389,7 @@ def _construct_for_wallet( environment=environment, gamma=gamma, data=data, + rfq=rfq, clob=clob, signer=signer, credentials=credentials, @@ -446,12 +451,15 @@ def close(self) -> None: ctx.clob.close() finally: try: - ctx.secure_clob.close() + ctx.rfq.close() finally: try: - ctx.relayer.close() + ctx.secure_clob.close() finally: - ctx.rpc.close() + try: + ctx.relayer.close() + finally: + ctx.rpc.close() def _user_or_wallet(self, user: str | None) -> str: return self._ctx.wallet if user is None else user @@ -1054,6 +1062,20 @@ 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. + """ + 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..546a4af 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, @@ -168,6 +169,9 @@ "Comment", "CommentId", "ComboConditionId", + "ComboMarket", + "ComboMarketOutcome", + "ComboMarketOutcomes", "ConditionId", "CtfConditionId", "ConversionActivity", diff --git a/src/polymarket/models/rfq.py b/src/polymarket/models/rfq.py new file mode 100644 index 0000000..f6e97d4 --- /dev/null +++ b/src/polymarket/models/rfq.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import json +from decimal import Decimal, InvalidOperation +from typing import Any, cast + +from pydantic import field_validator, model_validator + +from polymarket.models.base import BaseModel +from polymarket.models.types import ( + CtfConditionId, + MarketId, + PositionId, + validate_ctf_condition_id, +) + + +class ComboMarketOutcome(BaseModel): + """One outcome in a Combo market catalog entry.""" + + label: str + position_id: 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): + """A market available for Combos.""" + + id: MarketId + condition_id: CtfConditionId + slug: str + title: str + outcomes: ComboMarketOutcomes + image: str + volume: float + tags: tuple[str, ...] + + @model_validator(mode="before") + @classmethod + def _normalize_combo_market(cls, value: object) -> object: + if not isinstance(value, dict): + return value + + data = cast(dict[str, Any], value) + if "outcomes" in data and isinstance(data.get("outcomes"), dict): + return data + + outcomes = _parse_string_sequence(data.get("outcomes")) + position_ids = _parse_string_sequence(data.get("position_ids")) + outcome_prices = tuple( + _parse_decimal(item) for item in _parse_sequence(data.get("outcome_prices")) + ) + + if len(outcomes) != 2: + msg = f"Expected binary combo market outcomes, received {len(outcomes)}" + raise ValueError(msg) + if len(position_ids) != len(outcomes): + msg = "Expected position_ids and outcomes to have matching lengths." + raise ValueError(msg) + if len(outcome_prices) != len(outcomes): + msg = "Expected outcome_prices and outcomes to have matching lengths." + raise ValueError(msg) + + return { + "id": data.get("id"), + "condition_id": data.get("condition_id"), + "slug": data.get("slug"), + "title": data.get("title"), + "outcomes": { + "yes": { + "label": outcomes[0], + "position_id": position_ids[0], + "price": outcome_prices[0], + }, + "no": { + "label": outcomes[1], + "position_id": position_ids[1], + "price": outcome_prices[1], + }, + }, + "image": data.get("image"), + "volume": data.get("volume"), + "tags": data.get("tags"), + } + + @field_validator("id", mode="before") + @classmethod + def _coerce_id(cls, value: object) -> object: + if isinstance(value, bool): + return value + if isinstance(value, int): + return str(value) + return value + + @field_validator("condition_id", mode="before") + @classmethod + def _validate_condition_id(cls, value: object) -> CtfConditionId: + return validate_ctf_condition_id(value) + + +def _parse_sequence(value: object) -> tuple[Any, ...]: + if value is None: + return () + + if isinstance(value, str): + parsed = json.loads(value) + if not isinstance(parsed, list): + msg = "expected a JSON array" + raise ValueError(msg) + return tuple(cast(list[Any], parsed)) + + if isinstance(value, list | tuple): + return tuple(cast(list[Any] | tuple[Any, ...], value)) + + msg = "expected a sequence" + raise ValueError(msg) + + +def _parse_string_sequence(value: object) -> tuple[str, ...]: + items = _parse_sequence(value) + result: list[str] = [] + for item in items: + if not isinstance(item, str): + msg = f"expected a string, got {type(item).__name__}" + raise ValueError(msg) + result.append(item) + return tuple(result) + + +def _parse_decimal(value: object) -> Decimal: + try: + return Decimal(str(value)) + except InvalidOperation as error: + msg = f"invalid decimal: {value!r}" + raise ValueError(msg) from error + + +__all__ = ["ComboMarket", "ComboMarketOutcome", "ComboMarketOutcomes"] diff --git a/tests/integration/test_rfq_paginated.py b/tests/integration/test_rfq_paginated.py new file mode 100644 index 0000000..2793847 --- /dev/null +++ b/tests/integration/test_rfq_paginated.py @@ -0,0 +1,34 @@ +import asyncio + +import pytest + +from polymarket import AsyncPublicClient, ComboMarket, PublicClient + + +@pytest.mark.integration +def test_list_combo_markets_returns_paginator() -> None: + with PublicClient() as client: + paginator = client.list_combo_markets(page_size=1) + first = paginator.first_page() + + assert first.items + assert all(isinstance(market, ComboMarket) for market in first.items) + market = first.items[0] + assert market.outcomes.yes.position_id + assert market.outcomes.no.position_id + + +@pytest.mark.integration +def test_async_list_combo_markets_returns_paginator() -> None: + async def run() -> None: + async with AsyncPublicClient() as client: + paginator = client.list_combo_markets(page_size=1) + first = await paginator.first_page() + + assert first.items + assert all(isinstance(market, ComboMarket) for market in first.items) + market = first.items[0] + assert market.outcomes.yes.position_id + assert market.outcomes.no.position_id + + asyncio.run(run()) diff --git a/tests/unit/test_builder_trades.py b/tests/unit/test_builder_trades.py index c216e19..4244ee5 100644 --- a/tests/unit/test_builder_trades.py +++ b/tests/unit/test_builder_trades.py @@ -257,6 +257,7 @@ def test_first_page_hits_builder_trades_endpoint_with_filters(self) -> None: environment=client._ctx.environment, gamma=client._ctx.gamma, data=client._ctx.data, + rfq=client._ctx.rfq, clob=SyncTransport( base_url=PRODUCTION.clob_url, client=httpx.Client(base_url=PRODUCTION.clob_url, transport=handler), @@ -306,6 +307,7 @@ async def run() -> list[str]: environment=client._ctx.environment, gamma=client._ctx.gamma, data=client._ctx.data, + rfq=client._ctx.rfq, clob=AsyncTransport( base_url=PRODUCTION.clob_url, client=httpx.AsyncClient( diff --git a/tests/unit/test_rfq_models.py b/tests/unit/test_rfq_models.py new file mode 100644 index 0000000..60e20eb --- /dev/null +++ b/tests/unit/test_rfq_models.py @@ -0,0 +1,43 @@ +from decimal import Decimal + +import pytest + +from polymarket.errors import UnexpectedResponseError +from polymarket.models.rfq import ComboMarket + +_CONDITION_ID = "0x5c19f205507ce03ff5f3be08a8090a5969ea6870cc07b902a4ca2e61dfe48fdd" + + +def _combo_market_payload(**overrides: object) -> dict[str, object]: + payload: dict[str, object] = { + "id": "1897034", + "condition_id": _CONDITION_ID, + "position_ids": ["POSITION-YES", "POSITION-NO"], + "slug": "combo-market", + "title": "Will this market resolve Yes?", + "outcomes": ["Yes", "No"], + "outcome_prices": ["0.695", "0.305"], + "image": "https://example.test/image.png", + "volume": 524879.8786760003, + "tags": ["sports", "soccer"], + } + payload.update(overrides) + return payload + + +def test_combo_market_parses_catalog_payload() -> None: + market = ComboMarket.parse_response(_combo_market_payload()) + + assert market.id == "1897034" + assert market.condition_id == _CONDITION_ID + assert market.outcomes.yes.label == "Yes" + assert market.outcomes.yes.position_id == "POSITION-YES" + assert market.outcomes.yes.price == Decimal("0.695") + assert market.outcomes.no.label == "No" + assert market.outcomes.no.position_id == "POSITION-NO" + assert market.outcomes.no.price == Decimal("0.305") + + +def test_combo_market_requires_binary_aligned_outcomes() -> None: + with pytest.raises(UnexpectedResponseError): + ComboMarket.parse_response(_combo_market_payload(outcomes=["Yes"])) diff --git a/tests/unit/test_rfq_paginated_specs.py b/tests/unit/test_rfq_paginated_specs.py new file mode 100644 index 0000000..f4d80fd --- /dev/null +++ b/tests/unit/test_rfq_paginated_specs.py @@ -0,0 +1,13 @@ +from polymarket._internal.actions import rfq as rfq_actions +from polymarket._internal.request import KeysetPaginatedSpec + + +def test_list_combo_markets_spec_uses_rfq_cursor_param() -> None: + spec = rfq_actions.list_combo_markets_spec(exclude=["0xA", "0xB"]) + + assert isinstance(spec, KeysetPaginatedSpec) + assert spec.service == "rfq" + assert spec.path == "/v1/rfq/combo-markets" + assert spec.cursor_param == "cursor" + assert spec.max_page_size == 100 + assert spec.base_params == {"exclude": "0xA,0xB"} 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,