diff --git a/src/polymarket/_internal/actions/orders/market.py b/src/polymarket/_internal/actions/orders/market.py index 8328018..ed618a7 100644 --- a/src/polymarket/_internal/actions/orders/market.py +++ b/src/polymarket/_internal/actions/orders/market.py @@ -45,6 +45,8 @@ class PrepareMarketOrderParams: amount: Decimal | None = None shares: Decimal | None = None max_spend: Decimal | None = None + max_price: Decimal | None = None + min_price: Decimal | None = None builder_code: HexString | None = None @@ -55,6 +57,8 @@ def validate_market_order_params( amount: Decimal | int | float | str | None = None, shares: Decimal | int | float | str | None = None, max_spend: Decimal | int | float | str | None = None, + max_price: Decimal | int | float | str | None = None, + min_price: Decimal | int | float | str | None = None, order_type: MarketOrderType = "FAK", builder_code: str | None = None, ) -> PrepareMarketOrderParams: @@ -69,16 +73,22 @@ def validate_market_order_params( raise UserInputError("amount is required for BUY market orders.") if shares is not None: raise UserInputError("shares must not be set for BUY market orders.") + if min_price is not None: + raise UserInputError("min_price is only valid for SELL market orders.") validated_amount = coerce_positive_decimal("amount", amount) validated_max_spend = ( coerce_positive_decimal("max_spend", max_spend) if max_spend is not None else None ) + validated_max_price = ( + coerce_positive_decimal("max_price", max_price) if max_price is not None else None + ) return PrepareMarketOrderParams( token_id=validated_token, side=side, order_type=order_type, amount=validated_amount, max_spend=validated_max_spend, + max_price=validated_max_price, builder_code=validated_builder, ) if shares is None: @@ -87,11 +97,17 @@ def validate_market_order_params( raise UserInputError("amount must not be set for SELL market orders.") if max_spend is not None: raise UserInputError("max_spend is only valid for BUY market orders.") + if max_price is not None: + raise UserInputError("max_price is only valid for BUY market orders.") + validated_min_price = ( + coerce_positive_decimal("min_price", min_price) if min_price is not None else None + ) return PrepareMarketOrderParams( token_id=validated_token, side=side, order_type=order_type, shares=coerce_positive_decimal("shares", shares), + min_price=validated_min_price, builder_code=validated_builder, ) @@ -102,18 +118,15 @@ async def prepare_market_order_draft( tick_size = await fetch_tick_size(ctx, token_id=params.token_id) notional = params.amount if params.side == "BUY" else params.shares assert notional is not None - price = await resolve_estimated_market_price( - ctx, - token_id=params.token_id, - side=params.side, - notional=notional, - order_type=params.order_type, - tick_size=tick_size, - ) + price = await _resolve_market_order_price(ctx, params, notional=notional, tick_size=tick_size) neg_risk = await fetch_neg_risk(ctx, token_id=params.token_id) resolved_amount = await _resolve_buy_amount_for_fees(ctx, params, price=price) offered, requested = _compute_market_order_amounts( - amount=resolved_amount, price=price, side=params.side, tick_size=tick_size + amount=resolved_amount, + price=price, + protect_price=_has_protected_price(params), + side=params.side, + tick_size=tick_size, ) return OrderDraft( chain_id=ctx.environment.chain_id, @@ -136,18 +149,15 @@ def prepare_market_order_draft_sync( tick_size = fetch_tick_size_sync(ctx, token_id=params.token_id) notional = params.amount if params.side == "BUY" else params.shares assert notional is not None - price = resolve_estimated_market_price_sync( - ctx, - token_id=params.token_id, - side=params.side, - notional=notional, - order_type=params.order_type, - tick_size=tick_size, - ) + price = _resolve_market_order_price_sync(ctx, params, notional=notional, tick_size=tick_size) neg_risk = fetch_neg_risk_sync(ctx, token_id=params.token_id) resolved_amount = _resolve_buy_amount_for_fees_sync(ctx, params, price=price) offered, requested = _compute_market_order_amounts( - amount=resolved_amount, price=price, side=params.side, tick_size=tick_size + amount=resolved_amount, + price=price, + protect_price=_has_protected_price(params), + side=params.side, + tick_size=tick_size, ) return OrderDraft( chain_id=ctx.environment.chain_id, @@ -164,8 +174,76 @@ def prepare_market_order_draft_sync( ) +async def _resolve_market_order_price( + ctx: AsyncSecureClientContext, + params: PrepareMarketOrderParams, + *, + notional: Decimal, + tick_size: Decimal, +) -> Decimal: + if params.side == "BUY" and params.max_price is not None: + return _resolve_protected_market_price(params.max_price, tick_size, field="max_price") + if params.side == "SELL" and params.min_price is not None: + return _resolve_protected_market_price(params.min_price, tick_size, field="min_price") + return await resolve_estimated_market_price( + ctx, + token_id=params.token_id, + side=params.side, + notional=notional, + order_type=params.order_type, + tick_size=tick_size, + ) + + +def _resolve_market_order_price_sync( + ctx: SyncSecureClientContext, + params: PrepareMarketOrderParams, + *, + notional: Decimal, + tick_size: Decimal, +) -> Decimal: + if params.side == "BUY" and params.max_price is not None: + return _resolve_protected_market_price(params.max_price, tick_size, field="max_price") + if params.side == "SELL" and params.min_price is not None: + return _resolve_protected_market_price(params.min_price, tick_size, field="min_price") + return resolve_estimated_market_price_sync( + ctx, + token_id=params.token_id, + side=params.side, + notional=notional, + order_type=params.order_type, + tick_size=tick_size, + ) + + +def _resolve_protected_market_price(price: Decimal, tick_size: Decimal, *, field: str) -> Decimal: + config = resolve_rounding_config(tick_size) + if price < tick_size or price > Decimal(1) - tick_size: + raise UserInputError( + f"{field} must be between {tick_size} and {Decimal(1) - tick_size} " + f"for tick size {tick_size}." + ) + if decimal_places(price) > config.price: + raise UserInputError( + f"{field} must conform to tick size {tick_size} with at most " + f"{config.price} decimal places." + ) + return price + + +def _has_protected_price(params: PrepareMarketOrderParams) -> bool: + return (params.side == "BUY" and params.max_price is not None) or ( + params.side == "SELL" and params.min_price is not None + ) + + def _compute_market_order_amounts( - *, amount: Decimal, price: Decimal, side: OrderSide, tick_size: Decimal + *, + amount: Decimal, + price: Decimal, + side: OrderSide, + tick_size: Decimal, + protect_price: bool = False, ) -> tuple[int, int]: config = resolve_rounding_config(tick_size) raw_price = round_down(price, config.price) @@ -174,7 +252,11 @@ def _compute_market_order_amounts( if decimal_places(raw_taker) > config.amount: raw_taker = round_up(raw_taker, config.amount + 4) if decimal_places(raw_taker) > config.amount: - raw_taker = round_down(raw_taker, config.amount) + raw_taker = ( + round_up(raw_taker, config.amount) + if protect_price + else round_down(raw_taker, config.amount) + ) return parse_amount(raw_maker), parse_amount(raw_taker) diff --git a/src/polymarket/clients/async_secure.py b/src/polymarket/clients/async_secure.py index 45a5e72..7bcfe77 100644 --- a/src/polymarket/clients/async_secure.py +++ b/src/polymarket/clients/async_secure.py @@ -1789,6 +1789,7 @@ async def create_market_order( side: Literal["BUY"], amount: Decimal | int | float | str, max_spend: Decimal | int | float | str | None = None, + max_price: Decimal | int | float | str | None = None, order_type: MarketOrderType = "FAK", builder_code: str | None = None, ) -> SignedOrder: ... @@ -1799,6 +1800,7 @@ async def create_market_order( token_id: str, side: Literal["SELL"], shares: Decimal | int | float | str, + min_price: Decimal | int | float | str | None = None, order_type: MarketOrderType = "FAK", builder_code: str | None = None, ) -> SignedOrder: ... @@ -1810,14 +1812,16 @@ async def create_market_order( amount: Decimal | int | float | str | None = None, shares: Decimal | int | float | str | None = None, max_spend: Decimal | int | float | str | None = None, + max_price: Decimal | int | float | str | None = None, + min_price: Decimal | int | float | str | None = None, order_type: MarketOrderType = "FAK", builder_code: str | None = None, ) -> SignedOrder: """Create and sign a market order without posting it. BUY orders use ``amount`` as the spend amount and may include - ``max_spend``. SELL orders use ``shares`` as the number of shares to - sell. + ``max_spend`` and ``max_price``. SELL orders use ``shares`` as the + number of shares to sell and may include ``min_price``. """ return await self._prepare_and_sign_market_order( token_id=token_id, @@ -1825,6 +1829,8 @@ async def create_market_order( amount=amount, shares=shares, max_spend=max_spend, + max_price=max_price, + min_price=min_price, order_type=order_type, builder_code=builder_code, ) @@ -1837,6 +1843,8 @@ async def _prepare_and_sign_market_order( amount: Decimal | int | float | str | None = None, shares: Decimal | int | float | str | None = None, max_spend: Decimal | int | float | str | None = None, + max_price: Decimal | int | float | str | None = None, + min_price: Decimal | int | float | str | None = None, order_type: MarketOrderType = "FAK", builder_code: str | None = None, ) -> SignedOrder: @@ -1846,6 +1854,8 @@ async def _prepare_and_sign_market_order( amount=amount, shares=shares, max_spend=max_spend, + max_price=max_price, + min_price=min_price, order_type=order_type, builder_code=builder_code, ) @@ -1888,6 +1898,7 @@ async def place_market_order( side: Literal["BUY"], amount: Decimal | int | float | str, max_spend: Decimal | int | float | str | None = None, + max_price: Decimal | int | float | str | None = None, order_type: MarketOrderType = "FAK", builder_code: str | None = None, ) -> OrderResponse: ... @@ -1898,6 +1909,7 @@ async def place_market_order( token_id: str, side: Literal["SELL"], shares: Decimal | int | float | str, + min_price: Decimal | int | float | str | None = None, order_type: MarketOrderType = "FAK", builder_code: str | None = None, ) -> OrderResponse: ... @@ -1909,14 +1921,16 @@ async def place_market_order( amount: Decimal | int | float | str | None = None, shares: Decimal | int | float | str | None = None, max_spend: Decimal | int | float | str | None = None, + max_price: Decimal | int | float | str | None = None, + min_price: Decimal | int | float | str | None = None, order_type: MarketOrderType = "FAK", builder_code: str | None = None, ) -> OrderResponse: """Create, sign, and post a market order. BUY orders use ``amount`` as the spend amount and may include - ``max_spend``. SELL orders use ``shares`` as the number of shares to - sell. + ``max_spend`` and ``max_price``. SELL orders use ``shares`` as the + number of shares to sell and may include ``min_price``. """ signed = await self._prepare_and_sign_market_order( token_id=token_id, @@ -1924,6 +1938,8 @@ async def place_market_order( amount=amount, shares=shares, max_spend=max_spend, + max_price=max_price, + min_price=min_price, order_type=order_type, builder_code=builder_code, ) diff --git a/src/polymarket/clients/secure.py b/src/polymarket/clients/secure.py index 8847869..32c0808 100644 --- a/src/polymarket/clients/secure.py +++ b/src/polymarket/clients/secure.py @@ -1558,6 +1558,7 @@ def create_market_order( side: Literal["BUY"], amount: Decimal | int | float | str, max_spend: Decimal | int | float | str | None = None, + max_price: Decimal | int | float | str | None = None, order_type: MarketOrderType = "FAK", builder_code: str | None = None, ) -> SignedOrder: ... @@ -1568,6 +1569,7 @@ def create_market_order( token_id: str, side: Literal["SELL"], shares: Decimal | int | float | str, + min_price: Decimal | int | float | str | None = None, order_type: MarketOrderType = "FAK", builder_code: str | None = None, ) -> SignedOrder: ... @@ -1579,14 +1581,16 @@ def create_market_order( amount: Decimal | int | float | str | None = None, shares: Decimal | int | float | str | None = None, max_spend: Decimal | int | float | str | None = None, + max_price: Decimal | int | float | str | None = None, + min_price: Decimal | int | float | str | None = None, order_type: MarketOrderType = "FAK", builder_code: str | None = None, ) -> SignedOrder: """Create and sign a market order without posting it. BUY orders use ``amount`` as the spend amount and may include - ``max_spend``. SELL orders use ``shares`` as the number of shares to - sell. + ``max_spend`` and ``max_price``. SELL orders use ``shares`` as the + number of shares to sell and may include ``min_price``. Raises: UserInputError: If side-specific order parameters are invalid. @@ -1599,6 +1603,8 @@ def create_market_order( amount=amount, shares=shares, max_spend=max_spend, + max_price=max_price, + min_price=min_price, order_type=order_type, builder_code=builder_code, ) @@ -1645,6 +1651,7 @@ def place_market_order( side: Literal["BUY"], amount: Decimal | int | float | str, max_spend: Decimal | int | float | str | None = None, + max_price: Decimal | int | float | str | None = None, order_type: MarketOrderType = "FAK", builder_code: str | None = None, ) -> OrderResponse: ... @@ -1655,6 +1662,7 @@ def place_market_order( token_id: str, side: Literal["SELL"], shares: Decimal | int | float | str, + min_price: Decimal | int | float | str | None = None, order_type: MarketOrderType = "FAK", builder_code: str | None = None, ) -> OrderResponse: ... @@ -1666,14 +1674,16 @@ def place_market_order( amount: Decimal | int | float | str | None = None, shares: Decimal | int | float | str | None = None, max_spend: Decimal | int | float | str | None = None, + max_price: Decimal | int | float | str | None = None, + min_price: Decimal | int | float | str | None = None, order_type: MarketOrderType = "FAK", builder_code: str | None = None, ) -> OrderResponse: """Create, sign, and post a market order. BUY orders use ``amount`` as the spend amount and may include - ``max_spend``. SELL orders use ``shares`` as the number of shares to - sell. + ``max_spend`` and ``max_price``. SELL orders use ``shares`` as the + number of shares to sell and may include ``min_price``. Raises: UserInputError: If side-specific order parameters are invalid. @@ -1688,6 +1698,8 @@ def place_market_order( amount=amount, shares=shares, max_spend=max_spend, + max_price=max_price, + min_price=min_price, order_type=order_type, builder_code=builder_code, ) @@ -1780,6 +1792,8 @@ def _prepare_and_sign_market_order( amount: Decimal | int | float | str | None, shares: Decimal | int | float | str | None, max_spend: Decimal | int | float | str | None, + max_price: Decimal | int | float | str | None, + min_price: Decimal | int | float | str | None, order_type: MarketOrderType, builder_code: str | None, ) -> SignedOrder: @@ -1789,6 +1803,8 @@ def _prepare_and_sign_market_order( amount=amount, shares=shares, max_spend=max_spend, + max_price=max_price, + min_price=min_price, order_type=order_type, builder_code=builder_code, ) diff --git a/tests/unit/test_market_order_overloads.py b/tests/unit/test_market_order_overloads.py index 89e1b31..649acee 100644 --- a/tests/unit/test_market_order_overloads.py +++ b/tests/unit/test_market_order_overloads.py @@ -46,7 +46,9 @@ def test_create_market_order_buy_overload_accepts_amount_rejects_shares(self) -> params = inspect.signature(buy_overload).parameters assert "amount" in params assert "max_spend" in params + assert "max_price" in params assert "shares" not in params + assert "min_price" not in params def test_create_market_order_sell_overload_accepts_shares_rejects_amount(self) -> None: sell_overload = next( @@ -58,6 +60,8 @@ def test_create_market_order_sell_overload_accepts_shares_rejects_amount(self) - assert "shares" in params assert "amount" not in params assert "max_spend" not in params + assert "max_price" not in params + assert "min_price" in params def test_place_market_order_buy_overload_accepts_amount_rejects_shares(self) -> None: buy_overload = next( @@ -68,7 +72,9 @@ def test_place_market_order_buy_overload_accepts_amount_rejects_shares(self) -> params = inspect.signature(buy_overload).parameters assert "amount" in params assert "max_spend" in params + assert "max_price" in params assert "shares" not in params + assert "min_price" not in params def test_place_market_order_sell_overload_accepts_shares_rejects_amount(self) -> None: sell_overload = next( @@ -80,6 +86,8 @@ def test_place_market_order_sell_overload_accepts_shares_rejects_amount(self) -> assert "shares" in params assert "amount" not in params assert "max_spend" not in params + assert "max_price" not in params + assert "min_price" in params def test_buy_overload_amount_is_required(self) -> None: for method in (AsyncSecureClient.create_market_order, AsyncSecureClient.place_market_order): @@ -186,6 +194,36 @@ def test_sell_with_max_spend_raises(self) -> None: finally: asyncio.run(client.close()) + def test_buy_with_min_price_raises(self) -> None: + client = _make_client() + try: + with pytest.raises(UserInputError, match="min_price is only valid for SELL"): + asyncio.run( + cast(Any, client.create_market_order)( + token_id="1", + side="BUY", + amount=Decimal(1), + min_price=Decimal("0.50"), + ) + ) + finally: + asyncio.run(client.close()) + + def test_sell_with_max_price_raises(self) -> None: + client = _make_client() + try: + with pytest.raises(UserInputError, match="max_price is only valid for BUY"): + asyncio.run( + cast(Any, client.create_market_order)( + token_id="1", + side="SELL", + shares=Decimal(1), + max_price=Decimal("0.50"), + ) + ) + finally: + asyncio.run(client.close()) + def test_invalid_side_raises(self) -> None: client = _make_client() try: @@ -206,12 +244,24 @@ async def _example_buy(client: AsyncSecureClient) -> None: await client.create_market_order( token_id="1", side="BUY", amount=Decimal(2), max_spend=Decimal(3) ) + await client.create_market_order( + token_id="1", side="BUY", amount=Decimal(2), max_price=Decimal("0.55") + ) await client.place_market_order(token_id="1", side="BUY", amount=Decimal(2)) + await client.place_market_order( + token_id="1", side="BUY", amount=Decimal(2), max_price=Decimal("0.55") + ) @staticmethod async def _example_sell(client: AsyncSecureClient) -> None: await client.create_market_order(token_id="1", side="SELL", shares=Decimal(5)) + await client.create_market_order( + token_id="1", side="SELL", shares=Decimal(5), min_price=Decimal("0.45") + ) await client.place_market_order(token_id="1", side="SELL", shares=Decimal(5)) + await client.place_market_order( + token_id="1", side="SELL", shares=Decimal(5), min_price=Decimal("0.45") + ) def test_typed_examples_are_callable(self) -> None: assert inspect.iscoroutinefunction(self._example_buy) diff --git a/tests/unit/test_order_market.py b/tests/unit/test_order_market.py index c8c9b8c..78b58bc 100644 --- a/tests/unit/test_order_market.py +++ b/tests/unit/test_order_market.py @@ -48,6 +48,21 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.MockTransport(handler) +def _tracked_route_handler( + routes: dict[str, dict[str, Any]], captured: list[str] +) -> httpx.MockTransport: + def handler(request: httpx.Request) -> httpx.Response: + from urllib.parse import urlparse + + path = urlparse(str(request.url)).path + captured.append(path) + if path in routes: + return httpx.Response(200, json=routes[path], request=request) + return httpx.Response(404, json={"error": "not mocked"}, request=request) + + return httpx.MockTransport(handler) + + def _install_public_clob(client: AsyncSecureClient, handler: httpx.MockTransport) -> None: transport = AsyncTransport( base_url="https://clob.test", @@ -89,6 +104,20 @@ def test_validate_market_order_params_rejects_max_spend_on_sell() -> None: ) +def test_validate_market_order_params_rejects_min_price_on_buy() -> None: + with pytest.raises(UserInputError, match="min_price is only valid"): + validate_market_order_params( + token_id="8501497", side="BUY", amount=Decimal(10), min_price=Decimal("0.50") + ) + + +def test_validate_market_order_params_rejects_max_price_on_sell() -> None: + with pytest.raises(UserInputError, match="max_price is only valid"): + validate_market_order_params( + token_id="8501497", side="SELL", shares=Decimal(10), max_price=Decimal("0.50") + ) + + def test_validate_market_order_params_defaults_order_type_to_fak() -> None: params = validate_market_order_params(token_id="8501497", side="BUY", amount=Decimal(10)) assert params.order_type == "FAK" @@ -191,3 +220,61 @@ async def run() -> tuple[int, int]: offered, requested = asyncio.run(run()) assert offered == 4_000_000 # 4 shares assert requested == 2_000_000 # 4 * 0.5 = 2 USDC + + +def test_prepare_market_order_draft_buy_uses_max_price_without_book() -> None: + captured: list[str] = [] + routes = { + "/tick-size": {"minimum_tick_size": 0.01}, + "/neg-risk": {"neg_risk": False}, + } + + async def run() -> tuple[int, int]: + client = await _make_client() + try: + _install_public_clob(client, _tracked_route_handler(routes, captured)) + params = validate_market_order_params( + token_id="8501497", + side="BUY", + amount=Decimal("100"), + max_price=Decimal("0.55"), + order_type="FAK", + ) + draft = await prepare_market_order_draft(client._ctx, params) + return draft.offered_amount, draft.requested_amount + finally: + await client.close() + + offered, requested = asyncio.run(run()) + assert offered == 100_000_000 + assert requested == 181_818_200 + assert "/book" not in captured + + +def test_prepare_market_order_draft_sell_uses_min_price_without_book() -> None: + captured: list[str] = [] + routes = { + "/tick-size": {"minimum_tick_size": 0.01}, + "/neg-risk": {"neg_risk": False}, + } + + async def run() -> tuple[int, int]: + client = await _make_client() + try: + _install_public_clob(client, _tracked_route_handler(routes, captured)) + params = validate_market_order_params( + token_id="8501497", + side="SELL", + shares=Decimal("180"), + min_price=Decimal("0.54"), + order_type="FOK", + ) + draft = await prepare_market_order_draft(client._ctx, params) + return draft.offered_amount, draft.requested_amount + finally: + await client.close() + + offered, requested = asyncio.run(run()) + assert offered == 180_000_000 + assert requested == 97_200_000 + assert "/book" not in captured