diff --git a/src/polymarket/_internal/actions/perps/trading.py b/src/polymarket/_internal/actions/perps/trading.py index ab81330..66641a8 100644 --- a/src/polymarket/_internal/actions/perps/trading.py +++ b/src/polymarket/_internal/actions/perps/trading.py @@ -31,7 +31,7 @@ def to_raw_order(request: PerpsOrderRequest) -> RawPerpsOrder: to_decimal_string("quantity", request.quantity), request.time_in_force, request.post_only, - None, + True if request.reduce_only else None, request.client_order_id, None, ] diff --git a/src/polymarket/_internal/perps_session.py b/src/polymarket/_internal/perps_session.py index 5680fa2..c021b8d 100644 --- a/src/polymarket/_internal/perps_session.py +++ b/src/polymarket/_internal/perps_session.py @@ -238,6 +238,7 @@ async def place_order( time_in_force: Literal["gtc"], price: DecimalInput, post_only: bool = False, + reduce_only: bool = False, client_order_id: str | None = None, take_profit: PerpsTpSlTrigger | None = None, stop_loss: PerpsTpSlTrigger | None = None, @@ -252,6 +253,7 @@ async def place_order( quantity: DecimalInput, time_in_force: Literal["ioc", "fok"], price: DecimalInput | None = None, + reduce_only: bool = False, client_order_id: str | None = None, take_profit: PerpsTpSlTrigger | None = None, stop_loss: PerpsTpSlTrigger | None = None, @@ -266,6 +268,7 @@ async def place_order( time_in_force: PerpsTimeInForce, price: DecimalInput | None = None, post_only: bool = False, + reduce_only: bool = False, client_order_id: str | None = None, take_profit: PerpsTpSlTrigger | None = None, stop_loss: PerpsTpSlTrigger | None = None, @@ -274,11 +277,11 @@ async def place_order( """Place one order and resolve with its first orders update. ``gtc`` orders require ``price`` and may set ``post_only``; ``ioc`` and - ``fok`` orders may omit ``price`` for market-style execution. Pass - ``take_profit`` and/or ``stop_loss`` to place reduce-only trigger - orders together with the entry order. ``expires_at`` is an optional - command expiration timestamp, accepted as ``datetime`` or epoch - milliseconds. + ``fok`` orders may omit ``price`` for market-style execution. Set + ``reduce_only`` to prevent the order from increasing exposure. Pass + ``take_profit`` and/or ``stop_loss`` to place reduce-only trigger orders + together with the entry order. ``expires_at`` is an optional command + expiration timestamp, accepted as ``datetime`` or epoch milliseconds. """ if time_in_force == "gtc": request = PerpsOrderRequest( @@ -288,6 +291,7 @@ async def place_order( time_in_force=time_in_force, price=cast(DecimalInput, price), post_only=post_only, + reduce_only=reduce_only, client_order_id=client_order_id, ) else: @@ -299,6 +303,7 @@ async def place_order( quantity=quantity, time_in_force=time_in_force, price=price, + reduce_only=reduce_only, client_order_id=client_order_id, ) if take_profit is None and stop_loss is None: diff --git a/src/polymarket/models/perps/orders.py b/src/polymarket/models/perps/orders.py index 7664a81..11ffd5f 100644 --- a/src/polymarket/models/perps/orders.py +++ b/src/polymarket/models/perps/orders.py @@ -55,6 +55,7 @@ class PerpsOrder(BaseModel): quantity: _Decimal = Field(validation_alias=AliasChoices("quantity", "qty")) time_in_force: PerpsTimeInForce = Field(validation_alias="tif") post_only: bool = Field(validation_alias=AliasChoices("post_only", "po")) + reduce_only: bool = Field(validation_alias="ro") status: PerpsOrderStatus resting_quantity: _Decimal = Field(validation_alias=AliasChoices("resting_quantity", "rest")) filled_quantity: _Decimal = Field(validation_alias=AliasChoices("filled_quantity", "fill")) diff --git a/src/polymarket/models/perps/requests.py b/src/polymarket/models/perps/requests.py index 609d768..1e6de1a 100644 --- a/src/polymarket/models/perps/requests.py +++ b/src/polymarket/models/perps/requests.py @@ -41,7 +41,7 @@ class PerpsOrderRequest: ``gtc`` orders require a ``price`` and may set ``post_only``. ``ioc`` and ``fok`` orders may omit ``price`` for market-style execution and cannot be - post-only. + post-only. Set ``reduce_only`` to prevent the order from increasing exposure. """ instrument_id: int @@ -56,6 +56,8 @@ class PerpsOrderRequest: """Limit price. Required for ``gtc``; optional for ``ioc``/``fok``.""" post_only: bool = False """Whether the order must rest instead of taking liquidity.""" + reduce_only: bool = False + """Whether the order may only reduce or close an existing position.""" client_order_id: str | None = None """Optional caller-supplied idempotency identifier.""" @@ -69,6 +71,7 @@ def __init__( time_in_force: Literal["gtc"], price: DecimalInput, post_only: bool = False, + reduce_only: bool = False, client_order_id: str | None = None, ) -> None: ... @@ -81,6 +84,7 @@ def __init__( quantity: DecimalInput, time_in_force: Literal["ioc", "fok"], price: DecimalInput | None = None, + reduce_only: bool = False, client_order_id: str | None = None, ) -> None: ... @@ -93,6 +97,7 @@ def __init__( time_in_force: PerpsTimeInForce, price: DecimalInput | None = None, post_only: bool = False, + reduce_only: bool = False, client_order_id: str | None = None, ) -> None: object.__setattr__(self, "instrument_id", instrument_id) @@ -101,6 +106,7 @@ def __init__( object.__setattr__(self, "time_in_force", time_in_force) object.__setattr__(self, "price", price) object.__setattr__(self, "post_only", post_only) + object.__setattr__(self, "reduce_only", reduce_only) object.__setattr__(self, "client_order_id", client_order_id) self.__post_init__() @@ -117,6 +123,8 @@ def __post_init__(self) -> None: ) if not isinstance(self.post_only, bool): # pyright: ignore[reportUnnecessaryIsInstance] raise UserInputError("post_only must be a bool") + if not isinstance(self.reduce_only, bool): # pyright: ignore[reportUnnecessaryIsInstance] + raise UserInputError("reduce_only must be a bool") to_decimal_string("quantity", self.quantity) if self.time_in_force == "gtc": if self.price is None: diff --git a/tests/unit/test_perps_events.py b/tests/unit/test_perps_events.py index 973f754..43becdc 100644 --- a/tests/unit/test_perps_events.py +++ b/tests/unit/test_perps_events.py @@ -15,6 +15,7 @@ parse_perps_market_events, parse_perps_session_event, ) +from polymarket.models.perps.orders import PerpsOrder def _order_update(order_id: int = 5) -> dict[str, object]: @@ -26,6 +27,7 @@ def _order_update(order_id: int = 5) -> dict[str, object]: "qty": "10", "tif": "gtc", "po": False, + "ro": True, "status": "open", "rest": "10", "fill": "0", @@ -108,10 +110,33 @@ def test_session_order_event_normalizes_compact_order() -> None: ) assert isinstance(event, PerpsOrderEvent) assert event.payload.id == 5 + assert event.payload.reduce_only is True assert event.payload.side == "BUY" assert event.payload.status == "open" +def test_order_model_normalizes_reduce_only_response_field() -> None: + order = PerpsOrder.parse_response( + { + "order_id": 5, + "instrument_id": 1, + "buy": True, + "price": "0.5", + "quantity": "10", + "tif": "gtc", + "post_only": False, + "ro": True, + "status": "open", + "resting_quantity": "10", + "filled_quantity": "0", + "created_timestamp": 1751500000000, + "updated_timestamp": 1751500000001, + } + ) + + assert order.reduce_only is True + + def test_session_tpsl_event_parses_lifecycle_update() -> None: event = parse_perps_session_event( { diff --git a/tests/unit/test_perps_session.py b/tests/unit/test_perps_session.py index 8a014c8..05eb2c2 100644 --- a/tests/unit/test_perps_session.py +++ b/tests/unit/test_perps_session.py @@ -57,8 +57,10 @@ async def _handshake(ws: ServerConnection) -> list[dict[str, Any]]: return frames -def _order_update(order_id: int, *, sequence: int = 1) -> dict[str, Any]: - return { +def _order_update( + order_id: int, *, sequence: int = 1, reduce_only: bool | None = None +) -> dict[str, Any]: + update: dict[str, Any] = { "ch": "orders", "ts": 1751500000000, "sq": sequence, @@ -70,6 +72,7 @@ def _order_update(order_id: int, *, sequence: int = 1) -> dict[str, Any]: "qty": "10", "tif": "gtc", "po": False, + "ro": reduce_only or False, "status": "open", "rest": "10", "fill": "0", @@ -77,6 +80,7 @@ def _order_update(order_id: int, *, sequence: int = 1) -> dict[str, Any]: "uts": 1751500000001, }, } + return update @asynccontextmanager @@ -161,7 +165,7 @@ async def handler(ws: ServerConnection) -> None: continue commands.append(message) await ws.send(json.dumps({"id": message["id"], "data": [{"status": "ok", "oid": 77}]})) - await ws.send(json.dumps(_order_update(77))) + await ws.send(json.dumps(_order_update(77, reduce_only=True))) async def run() -> None: async with ws_server(handler) as url, _open_session(url) as session: @@ -170,9 +174,11 @@ async def run() -> None: side="BUY", price="0.5", quantity="10", + reduce_only=True, time_in_force="gtc", ) assert placement.order.id == 77 + assert placement.order.reduce_only is True assert placement.order.status == "open" assert placement.tp_sl is None @@ -180,7 +186,15 @@ async def run() -> None: command = commands[0] assert command["op"]["type"] == "createOrders" assert command["op"]["args"] == [ - {"iid": 1, "buy": True, "po": False, "qty": "10", "tif": "gtc", "p": "0.5"} + { + "iid": 1, + "buy": True, + "po": False, + "qty": "10", + "ro": True, + "tif": "gtc", + "p": "0.5", + } ] assert isinstance(command["salt"], int) assert isinstance(command["ts"], int) diff --git a/tests/unit/test_perps_trading_commands.py b/tests/unit/test_perps_trading_commands.py index 5b9b5ef..8087a2a 100644 --- a/tests/unit/test_perps_trading_commands.py +++ b/tests/unit/test_perps_trading_commands.py @@ -50,6 +50,21 @@ def test_market_style_order_omits_price_from_body() -> None: assert body["args"] == [{"iid": 2, "buy": False, "po": False, "qty": "1.5", "tif": "ioc"}] +def test_reduce_only_order_sets_body_flag() -> None: + request = PerpsOrderRequest( + instrument_id=2, + side="SELL", + price="99", + quantity="1.5", + reduce_only=True, + time_in_force="ioc", + ) + body = to_command_body_op(create_orders_op([to_raw_order(request)])) + assert body["args"] == [ + {"iid": 2, "buy": False, "po": False, "qty": "1.5", "ro": True, "tif": "ioc", "p": "99"} + ] + + def test_client_order_id_round_trips_into_body() -> None: request = PerpsOrderRequest( instrument_id=3, @@ -148,6 +163,18 @@ def test_post_only_rejected_for_ioc_and_fok() -> None: ) +def test_reduce_only_must_be_bool() -> None: + with pytest.raises(UserInputError, match="reduce_only"): + PerpsOrderRequest( + instrument_id=1, + side="BUY", + price="1", + quantity="1", + reduce_only=cast(Any, "yes"), + time_in_force="gtc", + ) + + def test_invalid_client_order_id_rejected() -> None: with pytest.raises(UserInputError, match="client_order_id"): PerpsOrderRequest( diff --git a/tests/unit/test_perps_typing.py b/tests/unit/test_perps_typing.py index f33b52d..eb8b3d0 100644 --- a/tests/unit/test_perps_typing.py +++ b/tests/unit/test_perps_typing.py @@ -28,6 +28,7 @@ PerpsOrderRequest( instrument_id=1, quantity="1", + reduce_only=True, side="BUY", time_in_force="ioc", ), @@ -40,6 +41,7 @@ async def _check_session_typing(session: PerpsSession) -> None: instrument_id=1, price="100", quantity="1", + reduce_only=True, side="BUY", time_in_force="gtc", ),