From cdd99526b56a5c01125f980d37caf3b6920787c6 Mon Sep 17 00:00:00 2001 From: harley-poly Date: Sun, 4 Oct 2026 19:05:54 -0400 Subject: [PATCH] feat: add RFQ trade history support --- README.md | 48 +++++++- polymarket_us/async_client.py | 2 + polymarket_us/client.py | 2 + polymarket_us/resources/__init__.py | 3 + polymarket_us/resources/rfqs.py | 28 +++++ polymarket_us/types/__init__.py | 15 ++- polymarket_us/types/rfq.py | 30 ++++- polymarket_us/websocket/types.py | 16 ++- pyproject.toml | 2 +- tests/test_rfq_trades.py | 163 ++++++++++++++++++++++++++++ tests/types/rfq_trades.py | 84 ++++++++++++++ 11 files changed, 386 insertions(+), 7 deletions(-) create mode 100644 polymarket_us/resources/rfqs.py create mode 100644 tests/test_rfq_trades.py create mode 100644 tests/types/rfq_trades.py diff --git a/README.md b/README.md index 45b07a7..f026849 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,44 @@ Handle `None` for book `stats` and `transactTime`, and BBO `bestBid`, `bestAsk`, | `orders.preview(params)` | Preview an order | | `orders.close_position(params)` | Close a position | +### RFQ trades (Authenticated) + +`client.rfqs.trades(params=None)` returns one page of anonymous original fills +where either order originated from an RFQ, including later fills on resting +orders. API credentials and access to the retail RFQ beta are required. + +```python +from polymarket_us.types import GetRFQTradesParams + +params: GetRFQTradesParams = { + "limit": 100, + "startTime": "2026-10-01T00:00:00Z", + "endTime": "2026-10-02T00:00:00Z", +} +while True: + page = client.rfqs.trades(params) + for trade in page["trades"]: + print(trade["tradeId"], trade["price"], trade["qtyDecimal"]) + if not page["cursor"]: + break + params["cursor"] = page["cursor"] +``` + +With `AsyncPolymarketUS`, use `await client.rfqs.trades(params)`. `limit` defaults +to 100 when omitted or zero and otherwise accepts 1–100. `startTime` is inclusive; +`endTime` is exclusive. Both accept RFC 3339 timestamp strings. The optional +`symbol` filter is an exact, case-sensitive instrument symbol. + +Results are newest first. Keep the same filters and limit on subsequent pages, +and continue while `cursor` is nonempty, even if `trades` is empty. The SDK does +not paginate automatically. Prices and quantities remain exact decimal strings; +`executedTime` is a timestamp string or `None`. + +History is eventually consistent. To recover gaps in the live RFQ stream, +requery overlapping time windows and deduplicate by `tradeId`. These anonymous +prints are not account reconciliation data; later corrections and trade busts +do not amend them. + ### Portfolio (Authenticated) | Method | Description | @@ -348,7 +386,7 @@ WebSocket methods (`connect()`, `subscribe()`, `close()`) are async and must be - `position_update` - Position changes - `account_balance_snapshot` - Initial balance - `account_balance_update` - Balance changes -- `rfq_event` - RFQ and quote lifecycle events +- `rfq_event` - RFQ/quote lifecycle events and anonymous RFQ trades - `heartbeat` - Connection keepalive - `error` - Error events - `close` - Connection closed @@ -359,7 +397,7 @@ aliases are retained, and both the named callback and `message` receive the original envelope without renaming fields. An empty `error` string no longer suppresses a successful data callback. -RFQ subscriptions deliver lifecycle events through `rfq_event` without an initial +RFQ subscriptions deliver lifecycle events and trade prints through `rfq_event` without an initial snapshot. Market filters are not supported. ```python @@ -372,6 +410,10 @@ def on_rfq_event(data: RFQEvent) -> None: rfq = created["rfq"] if created is not None else None if rfq is not None: print(rfq["id"], rfq.get("qtyDecimal")) + traded = event.get("rfqTrade") + trade = traded["trade"] if traded is not None else None + if trade is not None: + print(trade["tradeId"], trade["price"], trade["qtyDecimal"]) private_ws.on("rfq_event", on_rfq_event) @@ -379,7 +421,7 @@ await private_ws.subscribe_rfq("rfqs-1") ``` Other event keys are `rfqClosed`, `quoteCreated`, `quoteDeleted`, `quoteAccepted`, -`quoteConfirmed`, and `quoteExecuted`. Timestamps and nested RFQ/quote objects may +`quoteConfirmed`, and `quoteExecuted`. Timestamps and nested RFQ/quote/trade objects may be null. Portfolio activity trades also expose `qtyDecimal` as an exact decimal string; use it instead of the rounded `qty` when fractional quantities matter. diff --git a/polymarket_us/async_client.py b/polymarket_us/async_client.py index 3f30f4f..eac7a9a 100644 --- a/polymarket_us/async_client.py +++ b/polymarket_us/async_client.py @@ -28,6 +28,7 @@ AsyncMarkets, AsyncOrders, AsyncPortfolio, + AsyncRFQs, AsyncSearch, AsyncSeries, AsyncSports, @@ -84,6 +85,7 @@ def __init__( self.series = AsyncSeries(self) self.sports = AsyncSports(self) self.search = AsyncSearch(self) + self.rfqs = AsyncRFQs(self) self.ws = _AsyncWebSocketFactory(self) async def get( diff --git a/polymarket_us/client.py b/polymarket_us/client.py index d287dc3..485bdee 100644 --- a/polymarket_us/client.py +++ b/polymarket_us/client.py @@ -27,6 +27,7 @@ Markets, Orders, Portfolio, + RFQs, Search, Series, Sports, @@ -96,6 +97,7 @@ def __init__( self.series = Series(self) self.sports = Sports(self) self.search = Search(self) + self.rfqs = RFQs(self) self.ws = _WebSocketFactory(self) def get( diff --git a/polymarket_us/resources/__init__.py b/polymarket_us/resources/__init__.py index bdd5f70..32935c1 100644 --- a/polymarket_us/resources/__init__.py +++ b/polymarket_us/resources/__init__.py @@ -5,6 +5,7 @@ from polymarket_us.resources.markets import AsyncMarkets, Markets from polymarket_us.resources.orders import AsyncOrders, Orders from polymarket_us.resources.portfolio import AsyncPortfolio, Portfolio +from polymarket_us.resources.rfqs import AsyncRFQs, RFQs from polymarket_us.resources.search import AsyncSearch, Search from polymarket_us.resources.series import AsyncSeries, Series from polymarket_us.resources.sports import AsyncSports, Sports @@ -20,6 +21,8 @@ "AsyncOrders", "Portfolio", "AsyncPortfolio", + "RFQs", + "AsyncRFQs", "Search", "AsyncSearch", "Series", diff --git a/polymarket_us/resources/rfqs.py b/polymarket_us/resources/rfqs.py new file mode 100644 index 0000000..7189f30 --- /dev/null +++ b/polymarket_us/resources/rfqs.py @@ -0,0 +1,28 @@ +"""RFQ resource.""" + +from polymarket_us.resource import APIResource, AsyncAPIResource +from polymarket_us.types import GetRFQTradesParams, GetRFQTradesResponse + + +class RFQs(APIResource): + """RFQ API resource (requires authentication and RFQ access).""" + + def trades(self, params: GetRFQTradesParams | None = None) -> GetRFQTradesResponse: + """Get a page of anonymous original RFQ fills, newest first.""" + return self._client.get( + "/v1/rfqs/trades", + query=dict(params) if params else None, + authenticated=True, + ) + + +class AsyncRFQs(AsyncAPIResource): + """RFQ API resource (async, requires authentication and RFQ access).""" + + async def trades(self, params: GetRFQTradesParams | None = None) -> GetRFQTradesResponse: + """Get a page of anonymous original RFQ fills, newest first.""" + return await self._client.get( + "/v1/rfqs/trades", + query=dict(params) if params else None, + authenticated=True, + ) diff --git a/polymarket_us/types/__init__.py b/polymarket_us/types/__init__.py index 35cda35..5cc9e3d 100644 --- a/polymarket_us/types/__init__.py +++ b/polymarket_us/types/__init__.py @@ -77,7 +77,17 @@ Trade, UserPosition, ) -from polymarket_us.types.rfq import RFQ, Quote, QuoteStatus, RFQComboLeg, RFQSide, RFQStatus +from polymarket_us.types.rfq import ( + RFQ, + GetRFQTradesParams, + GetRFQTradesResponse, + Quote, + QuoteStatus, + RFQComboLeg, + RFQSide, + RFQStatus, + RFQTrade, +) from polymarket_us.types.search import SearchParams, SearchResponse from polymarket_us.types.series import ( GetSeriesListResponse, @@ -114,6 +124,9 @@ "RFQSide", "RFQStatus", "QuoteStatus", + "RFQTrade", + "GetRFQTradesParams", + "GetRFQTradesResponse", # Events "Event", "Market", diff --git a/polymarket_us/types/rfq.py b/polymarket_us/types/rfq.py index cf3903e..506e3b4 100644 --- a/polymarket_us/types/rfq.py +++ b/polymarket_us/types/rfq.py @@ -1,4 +1,4 @@ -"""Private RFQ stream types.""" +"""RFQ history and private stream types.""" from typing import Literal, TypedDict @@ -75,3 +75,31 @@ class Quote(_OptionalQuote): confirmationDeadline: str | None executionDeadline: str | None executedTime: str | None + + +class RFQTrade(TypedDict): + """An anonymous original fill; later corrections and busts are not reflected.""" + + tradeId: str + symbol: str + price: str + qtyDecimal: str + aggressorSide: RFQSide + executedTime: str | None + + +class GetRFQTradesParams(TypedDict, total=False): + """History filters; repeat the same filters and limit with each cursor.""" + + limit: int + cursor: str + startTime: str + endTime: str + symbol: str + + +class GetRFQTradesResponse(TypedDict): + """A history page; continue nonempty cursors even when trades is empty.""" + + trades: list[RFQTrade] + cursor: str diff --git a/polymarket_us/websocket/types.py b/polymarket_us/websocket/types.py index 3d6f6be..649a798 100644 --- a/polymarket_us/websocket/types.py +++ b/polymarket_us/websocket/types.py @@ -2,7 +2,16 @@ from typing import Literal, TypedDict -from polymarket_us.types import RFQ, Amount, Execution, Order, Quote, UserBalance, UserPosition +from polymarket_us.types import ( + RFQ, + Amount, + Execution, + Order, + Quote, + RFQTrade, + UserBalance, + UserPosition, +) PrivateSubscriptionType = Literal[ "SUBSCRIPTION_TYPE_ORDER", @@ -161,6 +170,10 @@ class _QuoteExecuted(_QuoteChange): executedTime: str | None +class _RFQTradeEvent(TypedDict): + trade: RFQTrade | None + + class _RFQEventPayload(TypedDict, total=False): # The gateway emits only the selected event; unknown future events may be empty. rfqCreated: _RFQChange @@ -170,6 +183,7 @@ class _RFQEventPayload(TypedDict, total=False): quoteAccepted: _QuoteAccepted quoteConfirmed: _QuoteConfirmed quoteExecuted: _QuoteExecuted + rfqTrade: _RFQTradeEvent class RFQEvent(TypedDict): diff --git a/pyproject.toml b/pyproject.toml index 9760c23..cee8486 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "polymarket-us" -version = "2.1.0" +version = "2.3.0" description = "Polymarket US Python SDK" readme = "README.md" license = "MIT" diff --git a/tests/test_rfq_trades.py b/tests/test_rfq_trades.py new file mode 100644 index 0000000..4c1cde5 --- /dev/null +++ b/tests/test_rfq_trades.py @@ -0,0 +1,163 @@ +"""RFQ trade-history transport and live wire-contract regressions.""" + +import asyncio +import base64 +import json +from unittest.mock import Mock + +import pytest +from nacl.signing import SigningKey +from pytest_httpx import HTTPXMock +from websockets.asyncio.server import ServerConnection, serve + +from polymarket_us import AsyncPolymarketUS, PolymarketUS +from polymarket_us.errors import ( + APIStatusError, + AuthenticationError, + BadRequestError, + PermissionDeniedError, +) +from polymarket_us.types import GetRFQTradesParams, GetRFQTradesResponse +from polymarket_us.websocket import RFQEvent +from tests.types.rfq_trades import frames, params, trade + +TEST_SECRET_KEY = "nWGxne/9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A=" + + +async def get_trades( + asynchronous: bool, + query: GetRFQTradesParams | None = None, + *, + authenticated: bool = True, +) -> GetRFQTradesResponse: + key_id = "offline" if authenticated else None + secret_key = TEST_SECRET_KEY if authenticated else None + if asynchronous: + async with AsyncPolymarketUS( + key_id=key_id, secret_key=secret_key, api_base_url="https://api.test" + ) as client: + return ( + await client.rfqs.trades(query) if query is not None else await client.rfqs.trades() + ) + with PolymarketUS( + key_id=key_id, secret_key=secret_key, api_base_url="https://api.test" + ) as client: + return client.rfqs.trades(query) if query is not None else client.rfqs.trades() + + +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +async def test_trade_history_filters_signature_and_precision( + httpx_mock: HTTPXMock, asynchronous: bool +) -> None: + response: GetRFQTradesResponse = {"trades": [trade], "cursor": "next+/="} + httpx_mock.add_response(json=response) + original_params = dict(params) + + assert await get_trades(asynchronous, params) == response + assert params == original_params + requests = httpx_mock.get_requests() + assert len(requests) == 1 + request = requests[0] + assert request.method == "GET" + assert request.url.host == "api.test" + assert request.url.path == "/v1/rfqs/trades" + assert dict(request.url.params) == {key: str(value) for key, value in params.items()} + assert request.content == b"" + assert request.headers["X-PM-Access-Key"] == "offline" + timestamp = request.headers["X-PM-Timestamp"] + message = f"{timestamp}GET/v1/rfqs/trades".encode() + SigningKey(base64.b64decode(TEST_SECRET_KEY)).verify_key.verify( + message, base64.b64decode(request.headers["X-PM-Signature"]) + ) + + +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +@pytest.mark.parametrize("query", [None, {}], ids=["omitted", "empty"]) +async def test_trade_history_without_filters( + httpx_mock: HTTPXMock, asynchronous: bool, query: GetRFQTradesParams | None +) -> None: + httpx_mock.add_response(json={"trades": [], "cursor": ""}) + assert await get_trades(asynchronous, query) == {"trades": [], "cursor": ""} + assert str(httpx_mock.get_requests()[0].url) == "https://api.test/v1/rfqs/trades" + + +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +async def test_empty_page_cursor_is_available_for_manual_continuation( + httpx_mock: HTTPXMock, asynchronous: bool +) -> None: + cursor = "opaque+/=continuation" + httpx_mock.add_response(json={"trades": [], "cursor": cursor}) + httpx_mock.add_response(json={"trades": [trade], "cursor": ""}) + filters: GetRFQTradesParams = {"limit": 2, "symbol": "Combo-A"} + + first = await get_trades(asynchronous, filters) + assert first == {"trades": [], "cursor": cursor} + assert len(httpx_mock.get_requests()) == 1 + second = await get_trades(asynchronous, {**filters, "cursor": first["cursor"]}) + assert second == {"trades": [trade], "cursor": ""} + assert dict(httpx_mock.get_requests()[1].url.params) == { + "limit": "2", + "symbol": "Combo-A", + "cursor": cursor, + } + + +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +async def test_trade_history_requires_authentication( + httpx_mock: HTTPXMock, asynchronous: bool +) -> None: + with pytest.raises(AuthenticationError): + await get_trades(asynchronous, authenticated=False) + assert httpx_mock.get_requests() == [] + + +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +@pytest.mark.parametrize( + "status,error_type", [(400, BadRequestError), (403, PermissionDeniedError)] +) +async def test_trade_history_preserves_api_errors( + httpx_mock: HTTPXMock, asynchronous: bool, status: int, error_type: type[APIStatusError] +) -> None: + body = {"message": "RFQ history unavailable"} + httpx_mock.add_response( + status_code=status, json=body, headers={"x-request-id": "rfq-history-request"} + ) + with pytest.raises(error_type) as caught: + await get_trades(asynchronous, params) + assert caught.value.status_code == status + assert caught.value.body == body + assert caught.value.request_id == "rfq-history-request" + assert len(httpx_mock.get_requests()) == 1 + + +@pytest.mark.parametrize("frame", frames, ids=["trade", "null-timestamp", "null-trade"]) +async def test_live_rfq_trade_reaches_callback(frame: RFQEvent) -> None: + async def handler(peer: ServerConnection) -> None: + assert json.loads(await peer.recv()) == { + "subscribe": {"requestId": "rfqs", "subscriptionType": "SUBSCRIPTION_TYPE_RFQ"} + } + await peer.send(json.dumps(frame)) + await peer.send('{"heartbeat": {}}') + await peer.wait_closed() + + async with serve(handler, "127.0.0.1", 0, close_timeout=1) as server: + port = server.sockets[0].getsockname()[1] + with PolymarketUS( + key_id="offline", secret_key=TEST_SECRET_KEY, api_base_url=f"http://127.0.0.1:{port}" + ) as client: + ws = client.ws.private() + raw, callback, errors = Mock(), Mock(), Mock() + done = asyncio.Event() + ws.on("message", raw) + ws.on("rfq_event", callback) + ws.on("error", errors) + ws.on("heartbeat", done.set) + try: + await asyncio.wait_for(ws.connect(), 5) + await ws.subscribe_rfq("rfqs") + await asyncio.wait_for(done.wait(), 5) + callback.assert_called_once_with(frame) + assert callback.call_args.args[0] is raw.call_args_list[0].args[0] + errors.assert_not_called() + finally: + await asyncio.wait_for(ws.close(), 5) diff --git a/tests/types/rfq_trades.py b/tests/types/rfq_trades.py new file mode 100644 index 0000000..834061c --- /dev/null +++ b/tests/types/rfq_trades.py @@ -0,0 +1,84 @@ +"""Typed consumers of the RFQ trade-history and live trade contracts.""" + +from decimal import Decimal + +from polymarket_us import AsyncPolymarketUS, PolymarketUS +from polymarket_us.types import GetRFQTradesParams, GetRFQTradesResponse, RFQSide, RFQTrade +from polymarket_us.websocket import PrivateMessage, RFQEvent + +trade: RFQTrade = { + "tradeId": "trade-1", + "symbol": "Combo-A", + "price": "0.123400000000000001", + "qtyDecimal": "0.010000000000000001", + "aggressorSide": "SIDE_BUY", + "executedTime": "2026-10-04T12:00:00.123456789Z", +} + +frames: list[RFQEvent] = [ + { + "requestId": "rfqs", + "subscriptionType": "SUBSCRIPTION_TYPE_RFQ", + "rfqEvent": {"rfqTrade": {"trade": trade}}, + }, + { + "requestId": "rfqs", + "subscriptionType": "SUBSCRIPTION_TYPE_RFQ", + "rfqEvent": { + "rfqTrade": { + "trade": { + "tradeId": "", + "symbol": "", + "price": "", + "qtyDecimal": "", + "aggressorSide": "SIDE_UNDEFINED", + "executedTime": None, + } + } + }, + }, + { + "requestId": "rfqs", + "subscriptionType": "SUBSCRIPTION_TYPE_RFQ", + "rfqEvent": {"rfqTrade": {"trade": None}}, + }, +] +messages: list[PrivateMessage] = [*frames] +params: GetRFQTradesParams = { + "limit": 2, + "cursor": "opaque+/=cursor", + "startTime": "2026-10-04T00:00:00.123456789Z", + "endTime": "2026-10-05T00:00:00.123456789Z", + "symbol": "Combo-A", +} +page: GetRFQTradesResponse = {"trades": [trade], "cursor": ""} + + +def read_trade(value: RFQTrade) -> tuple[str, str, Decimal, Decimal, RFQSide, str | None]: + return ( + value["tradeId"], + value["symbol"], + Decimal(value["price"]), + Decimal(value["qtyDecimal"]), + value["aggressorSide"], + value["executedTime"], + ) + + +def live_trade(event: RFQEvent) -> RFQTrade | None: + payload = event["rfqEvent"].get("rfqTrade") + return payload["trade"] if payload is not None else None + + +def history(client: PolymarketUS) -> list[RFQTrade]: + result: GetRFQTradesResponse = client.rfqs.trades(params) + if result["cursor"]: + result = client.rfqs.trades({**params, "cursor": result["cursor"]}) + return result["trades"] + + +async def async_history(client: AsyncPolymarketUS) -> list[RFQTrade]: + result: GetRFQTradesResponse = await client.rfqs.trades(params) + if result["cursor"]: + result = await client.rfqs.trades({**params, "cursor": result["cursor"]}) + return result["trades"]