Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/polymarket/_internal/actions/perps/trading.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]
Expand Down
15 changes: 10 additions & 5 deletions src/polymarket/_internal/perps_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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:
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/polymarket/models/perps/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment thread
cesarenaldi marked this conversation as resolved.
status: PerpsOrderStatus
resting_quantity: _Decimal = Field(validation_alias=AliasChoices("resting_quantity", "rest"))
filled_quantity: _Decimal = Field(validation_alias=AliasChoices("filled_quantity", "fill"))
Expand Down
10 changes: 9 additions & 1 deletion src/polymarket/models/perps/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""

Expand All @@ -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: ...

Expand All @@ -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: ...

Expand All @@ -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)
Expand All @@ -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__()

Expand All @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/test_perps_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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",
Expand Down Expand Up @@ -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(
{
Expand Down
22 changes: 18 additions & 4 deletions tests/unit/test_perps_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -70,13 +72,15 @@ 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",
"cts": 1751500000000,
"uts": 1751500000001,
},
}
return update


@asynccontextmanager
Expand Down Expand Up @@ -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:
Expand All @@ -170,17 +174,27 @@ 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

asyncio.run(asyncio.wait_for(run(), timeout=10.0))
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)
Expand Down
27 changes: 27 additions & 0 deletions tests/unit/test_perps_trading_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/test_perps_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
PerpsOrderRequest(
instrument_id=1,
quantity="1",
reduce_only=True,
side="BUY",
time_in_force="ioc",
),
Expand All @@ -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",
),
Expand Down
Loading