From 8d3e8baac7457a7f342874f551852fd948746f54 Mon Sep 17 00:00:00 2001 From: harley-poly Date: Sun, 4 Oct 2026 16:47:13 -0400 Subject: [PATCH 1/2] feat: add batch order helpers --- README.md | 55 +++++++++ polymarket_us/resources/orders.py | 54 ++++++++ polymarket_us/types/__init__.py | 16 +++ polymarket_us/types/orders.py | 48 ++++++++ pyproject.toml | 2 +- tests/test_batch_orders.py | 197 ++++++++++++++++++++++++++++++ tests/types/batch_orders.py | 48 ++++++++ 7 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 tests/test_batch_orders.py create mode 100644 tests/types/batch_orders.py diff --git a/README.md b/README.md index 45b07a7..1c2fe37 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,61 @@ Handle `None` for book `stats` and `transactTime`, and BBO `bestBid`, `bestAsk`, | `orders.cancel_all(params?)` | Cancel all open orders | | `orders.preview(params)` | Preview an order | | `orders.close_position(params)` | Close a position | +| `orders.create_many(params)` | Create up to 20 orders | +| `orders.cancel_many(params)` | Submit up to 20 cancellations | +| `orders.modify_many(params)` | Submit up to 20 modifications | + +#### Batch orders + +Use `orders.create_many(params)`, `orders.cancel_many(params)`, and +`orders.modify_many(params)` for up to 20 orders per request. Each accepts an +`orders` list; cancel and modify entries include `orderId` and `marketSlug`. + +```python +created = client.orders.create_many( + { + "orders": [ + { + "marketSlug": "btc-100k", + "intent": "ORDER_INTENT_BUY_LONG", + "type": "ORDER_TYPE_LIMIT", + "price": {"value": "0.55", "currency": "USD"}, + "quantity": 10, + }, + ] + } +) +# For existing open orders: +modified = client.orders.modify_many( + { + "orders": [ + { + "orderId": "existing-order-id", + "marketSlug": "btc-100k", + "price": {"value": "0.50", "currency": "USD"}, + "quantity": 10, + }, + ] + } +) +canceled = client.orders.cancel_many( + { + "orders": [ + {"orderId": "existing-order-id", "marketSlug": "btc-100k"}, + ] + } +) +``` + +With `AsyncPolymarketUS`, await each call. Batches are sent as one request and +are never automatically retried or split. Invalid or oversized batches are +reported through the existing API exceptions. + +`createdOrderIds` contains exchange-assigned IDs. `canceledOrderIds` and +`modifiedOrderIds` echo the IDs submitted, and do **not** confirm each order +was canceled or modified; unknown IDs can be ignored by the exchange. Follow +`order_update` events for actual acceptance, fills, cancellations, replacements +and rejections. ### Portfolio (Authenticated) diff --git a/polymarket_us/resources/orders.py b/polymarket_us/resources/orders.py index 70557a3..8fea4d8 100644 --- a/polymarket_us/resources/orders.py +++ b/polymarket_us/resources/orders.py @@ -4,14 +4,20 @@ from polymarket_us.types import ( CancelAllOrdersParams, CancelAllOrdersResponse, + CancelOrderListParams, + CancelOrderListResponse, CancelOrderParams, ClosePositionParams, ClosePositionResponse, + CreateOrderListParams, + CreateOrderListResponse, CreateOrderParams, CreateOrderResponse, GetOpenOrdersParams, GetOpenOrdersResponse, GetOrderResponse, + ModifyOrderListParams, + ModifyOrderListResponse, ModifyOrderParams, PreviewOrderParams, PreviewOrderResponse, @@ -29,6 +35,30 @@ def create(self, params: CreateOrderParams) -> CreateOrderResponse: authenticated=True, ) + def create_many(self, params: CreateOrderListParams) -> CreateOrderListResponse: + """Create up to 20 orders in one request.""" + return self._client.post( + "/v1/orders/batched", + body=dict(params), + authenticated=True, + ) + + def cancel_many(self, params: CancelOrderListParams) -> CancelOrderListResponse: + """Submit up to 20 cancellations; returned IDs do not confirm success.""" + return self._client.post( + "/v1/orders/batched/cancel", + body=dict(params), + authenticated=True, + ) + + def modify_many(self, params: ModifyOrderListParams) -> ModifyOrderListResponse: + """Submit up to 20 modifications; returned IDs do not confirm success.""" + return self._client.post( + "/v1/orders/batched/modify", + body=dict(params), + authenticated=True, + ) + def list(self, params: GetOpenOrdersParams | None = None) -> GetOpenOrdersResponse: """List open orders.""" return self._client.get( @@ -93,6 +123,30 @@ async def create(self, params: CreateOrderParams) -> CreateOrderResponse: authenticated=True, ) + async def create_many(self, params: CreateOrderListParams) -> CreateOrderListResponse: + """Create up to 20 orders in one request.""" + return await self._client.post( + "/v1/orders/batched", + body=dict(params), + authenticated=True, + ) + + async def cancel_many(self, params: CancelOrderListParams) -> CancelOrderListResponse: + """Submit up to 20 cancellations; returned IDs do not confirm success.""" + return await self._client.post( + "/v1/orders/batched/cancel", + body=dict(params), + authenticated=True, + ) + + async def modify_many(self, params: ModifyOrderListParams) -> ModifyOrderListResponse: + """Submit up to 20 modifications; returned IDs do not confirm success.""" + return await self._client.post( + "/v1/orders/batched/modify", + body=dict(params), + authenticated=True, + ) + async def list(self, params: GetOpenOrdersParams | None = None) -> GetOpenOrdersResponse: """List open orders.""" return await self._client.get( diff --git a/polymarket_us/types/__init__.py b/polymarket_us/types/__init__.py index 35cda35..82b95e1 100644 --- a/polymarket_us/types/__init__.py +++ b/polymarket_us/types/__init__.py @@ -39,9 +39,14 @@ from polymarket_us.types.orders import ( CancelAllOrdersParams, CancelAllOrdersResponse, + CancelOrderListItem, + CancelOrderListParams, + CancelOrderListResponse, CancelOrderParams, ClosePositionParams, ClosePositionResponse, + CreateOrderListParams, + CreateOrderListResponse, CreateOrderParams, CreateOrderResponse, Execution, @@ -51,6 +56,9 @@ GetOrderResponse, ManualOrderIndicator, MarketMetadata, + ModifyOrderListItem, + ModifyOrderListParams, + ModifyOrderListResponse, ModifyOrderParams, Order, OrderIntent, @@ -142,6 +150,14 @@ "GetMarketBookResponse", "GetMarketBBOResponse", # Orders + "CreateOrderListParams", + "CreateOrderListResponse", + "CancelOrderListItem", + "CancelOrderListParams", + "CancelOrderListResponse", + "ModifyOrderListItem", + "ModifyOrderListParams", + "ModifyOrderListResponse", "OrderType", "OrderSide", "OutcomeSide", diff --git a/polymarket_us/types/orders.py b/polymarket_us/types/orders.py index 978c337..33b8626 100644 --- a/polymarket_us/types/orders.py +++ b/polymarket_us/types/orders.py @@ -211,3 +211,51 @@ class ClosePositionResponse(TypedDict, total=False): id: str executions: list[Execution] + + +class CreateOrderListParams(TypedDict): + """A batch of up to 20 orders to create.""" + + orders: list[CreateOrderParams] + + +class CreateOrderListResponse(TypedDict): + """Exchange-assigned IDs; follow the order stream for execution outcomes.""" + + createdOrderIds: list[str] + + +class CancelOrderListItem(CancelOrderParams): + """An existing order to cancel in a batch.""" + + orderId: str + + +class CancelOrderListParams(TypedDict): + """A batch of up to 20 orders to cancel.""" + + orders: list[CancelOrderListItem] + + +class CancelOrderListResponse(TypedDict): + """IDs submitted for cancellation, not confirmation of per-order success.""" + + canceledOrderIds: list[str] + + +class ModifyOrderListItem(ModifyOrderParams): + """An existing order to modify; marketSlug is required by the API.""" + + orderId: str + + +class ModifyOrderListParams(TypedDict): + """A batch of up to 20 orders to modify.""" + + orders: list[ModifyOrderListItem] + + +class ModifyOrderListResponse(TypedDict): + """IDs submitted for modification, not confirmation of per-order success.""" + + modifiedOrderIds: list[str] diff --git a/pyproject.toml b/pyproject.toml index 9760c23..736ed97 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.2.0" description = "Polymarket US Python SDK" readme = "README.md" license = "MIT" diff --git a/tests/test_batch_orders.py b/tests/test_batch_orders.py new file mode 100644 index 0000000..47fb4ef --- /dev/null +++ b/tests/test_batch_orders.py @@ -0,0 +1,197 @@ +"""Batch order contracts through the real HTTP clients and signing path.""" + +import base64 +import json +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +import httpx +import pytest +from nacl.signing import SigningKey +from pytest_httpx import HTTPXMock + +from polymarket_us import ( + APIStatusError, + APITimeoutError, + AsyncPolymarketUS, + AuthenticationError, + BadRequestError, + InternalServerError, + PolymarketUS, + RateLimitError, +) +from polymarket_us.types import ( + CancelOrderListParams, + CreateOrderListParams, + ModifyOrderListParams, +) + +TEST_SECRET_KEY = "nWGxne/9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A=" +pytestmark = pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) + + +@dataclass +class BatchCase: + path: str + body: object + response: dict[str, list[str]] + sync_call: Callable[[PolymarketUS], object] + async_call: Callable[[AsyncPolymarketUS], Awaitable[object]] + + +def batch_cases(count: int = 2) -> list[BatchCase]: + create: CreateOrderListParams = { + "orders": [ + { + "marketSlug": f"market-{i}", + "intent": "ORDER_INTENT_BUY_LONG", + "type": "ORDER_TYPE_LIMIT", + "price": {"value": "0.1234", "currency": "USD"}, + "quantity": i + 1, + "tif": "TIME_IN_FORCE_GOOD_TILL_DATE", + "goodTillTime": "2027-01-01T00:00:00Z", + "participateDontInitiate": True, + "manualOrderIndicator": "MANUAL_ORDER_INDICATOR_AUTOMATIC", + "synchronousExecution": True, + "maxBlockTime": "1s", + "slippageTolerance": {"bips": 10}, + } + for i in range(count) + ] + } + cancel: CancelOrderListParams = { + "orders": [{"orderId": f"order-{i}", "marketSlug": f"market-{i}"} for i in range(count)] + } + modify: ModifyOrderListParams = { + "orders": [ + { + "orderId": f"order-{i}", + "marketSlug": f"market-{i}", + "price": {"value": "0.4321", "currency": "USD"}, + "quantity": i + 1, + "tif": "TIME_IN_FORCE_GOOD_TILL_DATE", + "goodTillTime": "2027-01-01T00:00:00Z", + "participateDontInitiate": False, + } + for i in range(count) + ] + } + return [ + BatchCase( + "/v1/orders/batched", + create, + {"createdOrderIds": ["created-b", "created-a"]}, + lambda client: client.orders.create_many(create), + lambda client: client.orders.create_many(create), + ), + BatchCase( + "/v1/orders/batched/cancel", + cancel, + {"canceledOrderIds": ["order-1", "order-0"]}, + lambda client: client.orders.cancel_many(cancel), + lambda client: client.orders.cancel_many(cancel), + ), + BatchCase( + "/v1/orders/batched/modify", + modify, + {"modifiedOrderIds": ["order-1", "order-0"]}, + lambda client: client.orders.modify_many(modify), + lambda client: client.orders.modify_many(modify), + ), + ] + + +async def invoke(case: BatchCase, asynchronous: bool, authenticated: bool = True) -> object: + key_id = "test-key" 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", max_retries=3 + ) as client: + return await case.async_call(client) + with PolymarketUS( + key_id=key_id, secret_key=secret_key, api_base_url="https://api.test", max_retries=3 + ) as sync_client: + return case.sync_call(sync_client) + + +@pytest.mark.parametrize("case", batch_cases(), ids=["create", "cancel", "modify"]) +async def test_batch_request_and_response( + case: BatchCase, asynchronous: bool, httpx_mock: HTTPXMock +) -> None: + httpx_mock.add_response(json=case.response) + + assert await invoke(case, asynchronous) == case.response + + requests = httpx_mock.get_requests() + assert len(requests) == 1 + request = requests[0] + assert request.method == "POST" + assert str(request.url) == f"https://api.test{case.path}" + assert json.loads(request.content) == case.body + assert request.headers["Content-Type"] == "application/json" + assert request.headers["X-PM-Access-Key"] == "test-key" + message = f"{request.headers['X-PM-Timestamp']}POST{case.path}".encode() + verify_key = SigningKey(base64.b64decode(TEST_SECRET_KEY)).verify_key + verify_key.verify(message, base64.b64decode(request.headers["X-PM-Signature"])) + + +@pytest.mark.parametrize("case", batch_cases(), ids=["create", "cancel", "modify"]) +@pytest.mark.parametrize( + ("status", "error_type"), + [(400, BadRequestError), (429, RateLimitError), (503, InternalServerError)], +) +async def test_batch_errors_are_preserved_without_retry( + case: BatchCase, + asynchronous: bool, + status: int, + error_type: type[APIStatusError], + httpx_mock: HTTPXMock, +) -> None: + payload = {"code": status, "message": "batch rejected"} + httpx_mock.add_response(status_code=status, json=payload, headers={"Retry-After": "0"}) + + with pytest.raises(error_type, match="batch rejected") as exc: + await invoke(case, asynchronous) + + assert exc.value.status_code == status + assert exc.value.body == payload + requests = httpx_mock.get_requests() + assert len(requests) == 1 + assert exc.value.request_id == requests[0].headers["poly-correlation-id"] + + +@pytest.mark.parametrize("case", batch_cases(), ids=["create", "cancel", "modify"]) +async def test_batch_timeout_is_not_retried( + case: BatchCase, asynchronous: bool, httpx_mock: HTTPXMock +) -> None: + httpx_mock.add_exception(httpx.ReadTimeout("response lost after submission")) + + with pytest.raises(APITimeoutError): + await invoke(case, asynchronous) + + assert len(httpx_mock.get_requests()) == 1 + + +@pytest.mark.parametrize("case", batch_cases(), ids=["create", "cancel", "modify"]) +async def test_batch_requires_credentials_before_sending( + case: BatchCase, asynchronous: bool, httpx_mock: HTTPXMock +) -> None: + with pytest.raises(AuthenticationError): + await invoke(case, asynchronous, authenticated=False) + + assert httpx_mock.get_requests() == [] + + +@pytest.mark.parametrize("case", [*batch_cases(0), *batch_cases(21)]) +async def test_batch_size_validation_is_left_to_server( + case: BatchCase, asynchronous: bool, httpx_mock: HTTPXMock +) -> None: + httpx_mock.add_response(status_code=400, json={"message": "invalid batch size"}) + + with pytest.raises(BadRequestError, match="invalid batch size"): + await invoke(case, asynchronous) + + requests = httpx_mock.get_requests() + assert len(requests) == 1 + assert json.loads(requests[0].content) == case.body diff --git a/tests/types/batch_orders.py b/tests/types/batch_orders.py new file mode 100644 index 0000000..9b44c28 --- /dev/null +++ b/tests/types/batch_orders.py @@ -0,0 +1,48 @@ +"""Consumer checks for batch requests, exported types, and sync/async responses.""" + +from polymarket_us import AsyncPolymarketUS, PolymarketUS +from polymarket_us.types import ( + CancelOrderListItem, + CancelOrderListParams, + CancelOrderListResponse, + CancelOrderParams, + CreateOrderListParams, + CreateOrderListResponse, + CreateOrderParams, + ModifyOrderListItem, + ModifyOrderListParams, + ModifyOrderListResponse, + ModifyOrderParams, +) + +create_order: CreateOrderParams = { + "marketSlug": "market-a", + "intent": "ORDER_INTENT_BUY_LONG", + "price": {"value": "0.50", "currency": "USD"}, + "quantity": 10, +} +cancel_order: CancelOrderParams = {"marketSlug": "market-a"} +modify_order: ModifyOrderParams = { + "marketSlug": "market-a", + "price": {"value": "0.55", "currency": "USD"}, + "quantity": 5, +} +cancel_item: CancelOrderListItem = {**cancel_order, "orderId": "order-a"} +modify_item: ModifyOrderListItem = {**modify_order, "orderId": "order-a"} +create: CreateOrderListParams = {"orders": [create_order]} +cancel: CancelOrderListParams = {"orders": [cancel_item]} +modify: ModifyOrderListParams = {"orders": [modify_item]} + + +def batches(client: PolymarketUS) -> tuple[list[str], list[str], list[str]]: + created: CreateOrderListResponse = client.orders.create_many(create) + canceled: CancelOrderListResponse = client.orders.cancel_many(cancel) + modified: ModifyOrderListResponse = client.orders.modify_many(modify) + return created["createdOrderIds"], canceled["canceledOrderIds"], modified["modifiedOrderIds"] + + +async def async_batches(client: AsyncPolymarketUS) -> tuple[list[str], list[str], list[str]]: + created: CreateOrderListResponse = await client.orders.create_many(create) + canceled: CancelOrderListResponse = await client.orders.cancel_many(cancel) + modified: ModifyOrderListResponse = await client.orders.modify_many(modify) + return created["createdOrderIds"], canceled["canceledOrderIds"], modified["modifiedOrderIds"] From e139be24d2fd224bd7874c0b930ad90fc3c55fab Mon Sep 17 00:00:00 2001 From: harley-poly Date: Tue, 6 Oct 2026 02:33:28 -0400 Subject: [PATCH 2/2] fix: accept fractional batch order quantities --- polymarket_us/types/orders.py | 4 ++-- pyproject.toml | 2 +- tests/test_batch_orders.py | 6 +++--- tests/types/batch_orders.py | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/polymarket_us/types/orders.py b/polymarket_us/types/orders.py index 33b8626..0a69bdf 100644 --- a/polymarket_us/types/orders.py +++ b/polymarket_us/types/orders.py @@ -119,7 +119,7 @@ class CreateOrderParams(TypedDict, total=False): intent: OrderIntent # Required type: OrderType price: Amount - quantity: int + quantity: int | float tif: TimeInForce participateDontInitiate: bool goodTillTime: str @@ -142,7 +142,7 @@ class ModifyOrderParams(TypedDict, total=False): marketSlug: str # Required price: Amount - quantity: int + quantity: int | float tif: TimeInForce participateDontInitiate: bool goodTillTime: str diff --git a/pyproject.toml b/pyproject.toml index 736ed97..cbdf8d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "polymarket-us" -version = "2.2.0" +version = "2.4.0" description = "Polymarket US Python SDK" readme = "README.md" license = "MIT" diff --git a/tests/test_batch_orders.py b/tests/test_batch_orders.py index 47fb4ef..d24e729 100644 --- a/tests/test_batch_orders.py +++ b/tests/test_batch_orders.py @@ -47,13 +47,13 @@ def batch_cases(count: int = 2) -> list[BatchCase]: "intent": "ORDER_INTENT_BUY_LONG", "type": "ORDER_TYPE_LIMIT", "price": {"value": "0.1234", "currency": "USD"}, - "quantity": i + 1, + "quantity": 1.25 if i == 0 else i + 1, "tif": "TIME_IN_FORCE_GOOD_TILL_DATE", "goodTillTime": "2027-01-01T00:00:00Z", "participateDontInitiate": True, "manualOrderIndicator": "MANUAL_ORDER_INDICATOR_AUTOMATIC", "synchronousExecution": True, - "maxBlockTime": "1s", + "maxBlockTime": "1", "slippageTolerance": {"bips": 10}, } for i in range(count) @@ -68,7 +68,7 @@ def batch_cases(count: int = 2) -> list[BatchCase]: "orderId": f"order-{i}", "marketSlug": f"market-{i}", "price": {"value": "0.4321", "currency": "USD"}, - "quantity": i + 1, + "quantity": 1.25 if i == 0 else i + 1, "tif": "TIME_IN_FORCE_GOOD_TILL_DATE", "goodTillTime": "2027-01-01T00:00:00Z", "participateDontInitiate": False, diff --git a/tests/types/batch_orders.py b/tests/types/batch_orders.py index 9b44c28..12ff2f5 100644 --- a/tests/types/batch_orders.py +++ b/tests/types/batch_orders.py @@ -19,19 +19,19 @@ "marketSlug": "market-a", "intent": "ORDER_INTENT_BUY_LONG", "price": {"value": "0.50", "currency": "USD"}, - "quantity": 10, + "quantity": 1.25, } cancel_order: CancelOrderParams = {"marketSlug": "market-a"} modify_order: ModifyOrderParams = { "marketSlug": "market-a", "price": {"value": "0.55", "currency": "USD"}, - "quantity": 5, + "quantity": 1.25, } cancel_item: CancelOrderListItem = {**cancel_order, "orderId": "order-a"} modify_item: ModifyOrderListItem = {**modify_order, "orderId": "order-a"} -create: CreateOrderListParams = {"orders": [create_order]} +create: CreateOrderListParams = {"orders": [create_order, {**create_order, "quantity": 10}]} cancel: CancelOrderListParams = {"orders": [cancel_item]} -modify: ModifyOrderListParams = {"orders": [modify_item]} +modify: ModifyOrderListParams = {"orders": [modify_item, {**modify_item, "quantity": 5}]} def batches(client: PolymarketUS) -> tuple[list[str], list[str], list[str]]: