diff --git a/nautilus_trader/adapters/polymarket/execution.py b/nautilus_trader/adapters/polymarket/execution.py index e72bc165a06d..84a818b3b6f1 100644 --- a/nautilus_trader/adapters/polymarket/execution.py +++ b/nautilus_trader/adapters/polymarket/execution.py @@ -28,6 +28,7 @@ from py_clob_client.client import PartialCreateOrderOptions from py_clob_client.client import TradeParams from py_clob_client.clob_types import AssetType +from py_clob_client.clob_types import PostOrdersArgs from py_clob_client.exceptions import PolyApiException from nautilus_trader.adapters.polymarket.common.cache import get_polymarket_trades_key @@ -77,6 +78,7 @@ from nautilus_trader.execution.messages import GeneratePositionStatusReports from nautilus_trader.execution.messages import QueryAccount from nautilus_trader.execution.messages import SubmitOrder +from nautilus_trader.execution.messages import SubmitOrderList from nautilus_trader.execution.reports import FillReport from nautilus_trader.execution.reports import OrderStatusReport from nautilus_trader.execution.reports import PositionStatusReport @@ -956,6 +958,92 @@ async def _cancel_all_orders(self, command: CancelAllOrders) -> None: finally: await self._retry_manager_pool.release(retry_manager) + async def _cancel_all_global(self) -> None: + """ + Cancel all orders for this API key using Polymarket's cancel_all endpoint. + + This cancels ALL orders across all markets and strategies. Use with caution as + it cannot be filtered by instrument or strategy. + + """ + self._log.info("Canceling ALL orders globally via Polymarket cancel_all endpoint") + + retry_manager = await self._retry_manager_pool.acquire() + try: + response: JSON | None = await retry_manager.run( + "cancel_all_global", + [], + asyncio.to_thread, + self._http_client.cancel_all, + ) + if not response or not retry_manager.result: + self._log.error(f"Failed to cancel all orders: {retry_manager.message}") + else: + canceled = response.get("canceled", []) + not_canceled = response.get("not_canceled", {}) + self._log.info( + f"Cancel all result: {len(canceled)} canceled, " + f"{len(not_canceled)} not canceled", + ) + for order_id, reason in not_canceled.items(): + self._log.warning(f"Order {order_id} not canceled: {reason}") + finally: + await self._retry_manager_pool.release(retry_manager) + + async def _cancel_market_orders( + self, + instrument_id: InstrumentId | None = None, + asset_id: str = "", + ) -> None: + """ + Cancel orders for a specific market using Polymarket's cancel_market_orders + endpoint. + + Parameters + ---------- + instrument_id : InstrumentId, optional + The instrument ID to derive the market (condition_id). + asset_id : str, optional + The specific asset ID (token_id) to cancel orders for. + + """ + from nautilus_trader.adapters.polymarket.common.symbol import get_polymarket_condition_id + from nautilus_trader.adapters.polymarket.common.symbol import get_polymarket_token_id + + market = "" + if instrument_id is not None: + market = get_polymarket_condition_id(instrument_id) + if not asset_id: + asset_id = get_polymarket_token_id(instrument_id) + + self._log.info( + f"Canceling orders for market={market or 'ALL'}, asset_id={asset_id or 'ALL'}", + ) + + retry_manager = await self._retry_manager_pool.acquire() + try: + response: JSON | None = await retry_manager.run( + "cancel_market_orders", + [instrument_id] if instrument_id else [], + asyncio.to_thread, + self._http_client.cancel_market_orders, + market, + asset_id, + ) + if not response or not retry_manager.result: + self._log.error(f"Failed to cancel market orders: {retry_manager.message}") + else: + canceled = response.get("canceled", []) + not_canceled = response.get("not_canceled", {}) + self._log.info( + f"Cancel market orders result: {len(canceled)} canceled, " + f"{len(not_canceled)} not canceled", + ) + for order_id, reason in not_canceled.items(): + self._log.warning(f"Order {order_id} not canceled: {reason}") + finally: + await self._retry_manager_pool.release(retry_manager) + async def _submit_order(self, command: SubmitOrder) -> None: await self._maintain_active_market(command.instrument_id) @@ -979,17 +1067,18 @@ async def _submit_order(self, command: SubmitOrder) -> None: ) return - if order.is_post_only: + # post_only orders only supported with GTC or GTD time_in_force + if order.is_post_only and order.time_in_force not in (TimeInForce.GTC, TimeInForce.GTD): self._log.error( f"Cannot submit order {order.client_order_id}: " - "Post-only orders not supported on Polymarket", + "Post-only orders require GTC or GTD time in force", LogColor.RED, ) self.generate_order_denied( strategy_id=order.strategy_id, instrument_id=order.instrument_id, client_order_id=order.client_order_id, - reason="POST_ONLY_NOT_SUPPORTED", + reason="POST_ONLY_REQUIRES_GTC_OR_GTD", ts_event=self._clock.timestamp_ns(), ) return @@ -1031,6 +1120,222 @@ async def _submit_order(self, command: SubmitOrder) -> None: ts_event=self._clock.timestamp_ns(), ) + def _validate_order_for_batch(self, order: Order) -> str | None: + """ + Validate an order for batch submission. + + Returns None if valid, or an error reason string if invalid. + + """ + if order.is_reduce_only: + return "REDUCE_ONLY_NOT_SUPPORTED" + + if order.is_post_only and order.time_in_force not in (TimeInForce.GTC, TimeInForce.GTD): + return "POST_ONLY_REQUIRES_GTC_OR_GTD" + + if order.time_in_force not in VALID_POLYMARKET_TIME_IN_FORCE: + return "UNSUPPORTED_TIME_IN_FORCE" + + if order.order_type != OrderType.LIMIT: + return "BATCH_ONLY_SUPPORTS_LIMIT_ORDERS" + + if order.is_quote_quantity: + return "UNSUPPORTED_QUOTE_QUANTITY" + + return None + + async def _submit_order_list(self, command: SubmitOrderList) -> None: + """ + Submit a batch of orders to Polymarket using the post_orders endpoint. + + Parameters + ---------- + command : SubmitOrderList + The command containing the list of orders to submit. + + """ + order_list = command.order_list + orders = order_list.orders + + if not orders: + self._log.warning("Order list is empty, nothing to submit") + return + + # Filter out closed orders + orders = [order for order in orders if not order.is_closed] + if not orders: + return + + # Validate all orders before processing + valid_orders = [] + for order in orders: + denial_reason = self._validate_order_for_batch(order) + if denial_reason: + self._log.error( + f"Cannot submit order {order.client_order_id}: {denial_reason}", + LogColor.RED, + ) + self.generate_order_denied( + strategy_id=order.strategy_id, + instrument_id=order.instrument_id, + client_order_id=order.client_order_id, + reason=denial_reason, + ts_event=self._clock.timestamp_ns(), + ) + continue + valid_orders.append(order) + + if not valid_orders: + self._log.warning("No valid orders to submit after validation") + return + + self._log.info(f"Submitting batch of {len(valid_orders)} orders to Polymarket") + + # Maintain active markets for all orders + for order in valid_orders: + await self._maintain_active_market(order.instrument_id) + + # Sign all orders + signed_orders_args = await self._sign_orders_for_batch(valid_orders) + + # Generate submitted events for all orders + now_ns = self._clock.timestamp_ns() + for order in valid_orders: + self.generate_order_submitted( + strategy_id=order.strategy_id, + instrument_id=order.instrument_id, + client_order_id=order.client_order_id, + ts_event=now_ns, + ) + + # Submit batch + await self._post_signed_orders_batch(valid_orders, signed_orders_args) + + async def _sign_orders_for_batch( + self, + orders: list[Order], + ) -> list[PostOrdersArgs]: + """ + Sign multiple orders for batch submission. + """ + signed_orders_args: list[PostOrdersArgs] = [] + signing_start = self._clock.timestamp() + + for order in orders: + instrument = self._cache.instrument(order.instrument_id) + + order_args = OrderArgs( + price=float(order.price), + token_id=get_polymarket_token_id(order.instrument_id), + size=float(order.quantity), + side=order_side_to_str(order.side), + expiration=int(nanos_to_secs(order.expire_time_ns)), + ) + + neg_risk = self._get_neg_risk_for_instrument(instrument) + options = PartialCreateOrderOptions(neg_risk=neg_risk) + + signed_order = await asyncio.to_thread( + self._http_client.create_order, + order_args, + options=options, + ) + + order_type = convert_tif_to_polymarket_order_type(order.time_in_force) + signed_orders_args.append( + PostOrdersArgs( + order=signed_order, + orderType=order_type, + postOnly=order.is_post_only, + ), + ) + + interval = self._clock.timestamp() - signing_start + self._log.info( + f"Signed {len(orders)} Polymarket orders in {interval:.3f}s", + LogColor.BLUE, + ) + + return signed_orders_args + + async def _post_signed_orders_batch( + self, + orders: list[Order], + signed_orders_args: list[PostOrdersArgs], + ) -> None: + """ + Post a batch of signed orders to Polymarket. + """ + retry_manager = await self._retry_manager_pool.acquire() + try: + client_order_ids = [order.client_order_id for order in orders] + response = await retry_manager.run( + "submit_orders_batch", + client_order_ids, + asyncio.to_thread, + self._http_client.post_orders, + signed_orders_args, + ) + + if not response: + self._reject_all_orders(orders, str(retry_manager.message)) + return + + self._process_batch_response(orders, response) + + except Exception as e: + self._log.error(f"Error submitting order batch: {e}") + self._reject_all_orders(orders, str(e)) + finally: + await self._retry_manager_pool.release(retry_manager) + + def _reject_all_orders(self, orders: list[Order], reason: str) -> None: + """ + Generate rejection events for all orders. + """ + for order in orders: + self.generate_order_rejected( + strategy_id=order.strategy_id, + instrument_id=order.instrument_id, + client_order_id=order.client_order_id, + reason=reason, + ts_event=self._clock.timestamp_ns(), + ) + + def _process_batch_response(self, orders: list[Order], response: list) -> None: + """ + Process the response from a batch order submission. + """ + for i, result in enumerate(response): + order = orders[i] + if result.get("success"): + venue_order_id = VenueOrderId(result["orderID"]) + self._cache.add_venue_order_id(order.client_order_id, venue_order_id) + + # Signal order event + event = self._ack_events_order.get(venue_order_id) + if event: + event.set() + + # Signal trade event + trade_event = self._ack_events_trade.get(venue_order_id) + if trade_event: + trade_event.set() + + self._log.debug( + f"Order {order.client_order_id} accepted, venue_order_id={venue_order_id}", + ) + else: + reason = result.get("errorMsg", "Unknown error") + self.generate_order_rejected( + strategy_id=order.strategy_id, + instrument_id=order.instrument_id, + client_order_id=order.client_order_id, + reason=reason, + ts_event=self._clock.timestamp_ns(), + ) + self._log.warning(f"Order {order.client_order_id} rejected: {reason}") + def _deny_market_order_quantity(self, order: Order, reason: str) -> None: self._log.error( f"Cannot submit market order {order.client_order_id}: {reason}", @@ -1142,9 +1447,14 @@ async def _submit_limit_order(self, command: SubmitOrder, instrument) -> None: ts_event=self._clock.timestamp_ns(), ) - await self._post_signed_order(order, signed_order) + await self._post_signed_order(order, signed_order, post_only=order.is_post_only) - async def _post_signed_order(self, order: Order, signed_order) -> None: + async def _post_signed_order( + self, + order: Order, + signed_order, + post_only: bool = False, + ) -> None: retry_manager = await self._retry_manager_pool.acquire() try: response: JSON | None = await retry_manager.run( @@ -1154,6 +1464,7 @@ async def _post_signed_order(self, order: Order, signed_order) -> None: self._http_client.post_order, signed_order, convert_tif_to_polymarket_order_type(order.time_in_force), + post_only, ) if not response or not response.get("success"): self.generate_order_rejected( diff --git a/tests/integration_tests/adapters/polymarket/test_execution.py b/tests/integration_tests/adapters/polymarket/test_execution.py index f2e9010e2370..a8135011faa4 100644 --- a/tests/integration_tests/adapters/polymarket/test_execution.py +++ b/tests/integration_tests/adapters/polymarket/test_execution.py @@ -42,6 +42,7 @@ from nautilus_trader.execution.engine import ExecutionEngine from nautilus_trader.execution.messages import GenerateFillReports from nautilus_trader.execution.messages import SubmitOrder +from nautilus_trader.execution.messages import SubmitOrderList from nautilus_trader.execution.reports import FillReport from nautilus_trader.model.currencies import USDC from nautilus_trader.model.currencies import USDC_POS @@ -53,6 +54,7 @@ from nautilus_trader.model.enums import TimeInForce from nautilus_trader.model.identifiers import AccountId from nautilus_trader.model.identifiers import ClientOrderId +from nautilus_trader.model.identifiers import OrderListId from nautilus_trader.model.identifiers import Symbol from nautilus_trader.model.identifiers import TradeId from nautilus_trader.model.identifiers import VenueOrderId @@ -61,6 +63,7 @@ from nautilus_trader.model.objects import Money from nautilus_trader.model.objects import Price from nautilus_trader.model.objects import Quantity +from nautilus_trader.model.orders.list import OrderList from nautilus_trader.portfolio.portfolio import Portfolio from nautilus_trader.risk.engine import RiskEngine from nautilus_trader.test_kit.providers import TestInstrumentProvider @@ -2467,3 +2470,731 @@ async def test_connect_connects_ws_client_with_no_cached_instruments(self, mocke # Assert mock_ws_client.connect.assert_awaited_once() + + +# ===================================================================================== +# Tests for batch order submission (_submit_order_list) +# ===================================================================================== + + +class TestPolymarketBatchOrderSubmission: + """ + Tests for batch order submission using post_orders endpoint. + """ + + @pytest.fixture(autouse=True) + def setup(self, request): + # Fixture Setup + self.loop = request.getfixturevalue("event_loop") + self.loop.set_debug(True) + + self.clock = LiveClock() + self.trader_id = TestIdStubs.trader_id() + self.venue = POLYMARKET_VENUE + self.account_id = AccountId(f"{self.venue.value}-001") + + self.msgbus = MessageBus( + trader_id=self.trader_id, + clock=self.clock, + ) + + self.cache = TestComponentStubs.cache() + + # Mock HTTP client + self.http_client = MagicMock(spec=ClobClient) + self.http_client.get_address.return_value = "0xa3D82Ed56F4c68d2328Fb8c29e568Ba2cAF7d7c8" + + # Mock the creds attribute + mock_creds = MagicMock() + mock_creds.api_key = "test_api_key" + self.http_client.creds = mock_creds + + # Mock instrument provider + self.provider = MagicMock(spec=PolymarketInstrumentProvider) + self.provider.initialize = AsyncMock() + + # Mock WebSocket auth + self.ws_auth = MagicMock(spec=PolymarketWebSocketAuth) + + self.portfolio = Portfolio( + msgbus=self.msgbus, + cache=self.cache, + clock=self.clock, + ) + + self.data_engine = DataEngine( + msgbus=self.msgbus, + cache=self.cache, + clock=self.clock, + ) + + self.exec_engine = ExecutionEngine( + msgbus=self.msgbus, + cache=self.cache, + clock=self.clock, + ) + + self.risk_engine = RiskEngine( + portfolio=self.portfolio, + msgbus=self.msgbus, + cache=self.cache, + clock=self.clock, + ) + + # Create execution client + config = PolymarketExecClientConfig() + self.exec_client = PolymarketExecutionClient( + loop=self.loop, + http_client=self.http_client, + msgbus=self.msgbus, + cache=self.cache, + clock=self.clock, + instrument_provider=self.provider, + ws_auth=self.ws_auth, + config=config, + name=None, + ) + + # Prevent actual WebSocket connections in tests + mock_ws_client = MagicMock() + mock_ws_client.is_disconnected.return_value = False + mock_ws_client.is_connected.return_value = True + mock_ws_client.has_subscriptions = True + mock_ws_client.subscriptions = [] + mock_ws_client.market_subscriptions.return_value = [] + mock_ws_client.connect = AsyncMock() + mock_ws_client.disconnect = AsyncMock() + mock_ws_client.subscribe = AsyncMock() + mock_ws_client.unsubscribe = AsyncMock() + mock_ws_client.add_subscription = MagicMock() + self.exec_client._ws_client = mock_ws_client + + self.exec_engine.register_client(self.exec_client) + + self.strategy = Strategy() + self.strategy.register( + trader_id=self.trader_id, + portfolio=self.portfolio, + msgbus=self.msgbus, + cache=self.cache, + clock=self.clock, + ) + + # Add test instrument to cache + self.cache.add_instrument(ELECTION_INSTRUMENT) + + @pytest.mark.asyncio + async def test_submit_order_list_success(self, mocker): + """ + Test successful batch order submission. + """ + # Arrange + mock_create_order = mocker.patch.object(self.http_client, "create_order") + mock_post_orders = mocker.patch.object(self.http_client, "post_orders") + + # Mock successful responses + mock_create_order.return_value = {"signed_order": "mock_signed"} + mock_post_orders.return_value = [ + {"success": True, "orderID": "batch_order_1"}, + {"success": True, "orderID": "batch_order_2"}, + ] + + # Create two limit orders + order1 = self.strategy.order_factory.limit( + instrument_id=ELECTION_INSTRUMENT.id, + order_side=OrderSide.BUY, + quantity=Quantity.from_str("10"), + price=Price.from_str("0.50"), + ) + order2 = self.strategy.order_factory.limit( + instrument_id=ELECTION_INSTRUMENT.id, + order_side=OrderSide.SELL, + quantity=Quantity.from_str("5"), + price=Price.from_str("0.60"), + ) + + self.cache.add_order(order1, None) + self.cache.add_order(order2, None) + + order_list = OrderList( + order_list_id=OrderListId("BATCH-001"), + orders=[order1, order2], + ) + + submit_order_list = SubmitOrderList( + trader_id=self.trader_id, + strategy_id=self.strategy.id, + order_list=order_list, + position_id=None, + command_id=UUID4(), + ts_init=0, + ) + + # Act + await self.exec_client._submit_order_list(submit_order_list) + + # Assert + assert mock_create_order.call_count == 2 + mock_post_orders.assert_called_once() + + # Check that venue order IDs were cached + venue_order_id_1 = VenueOrderId("batch_order_1") + venue_order_id_2 = VenueOrderId("batch_order_2") + assert self.cache.client_order_id(venue_order_id_1) == order1.client_order_id + assert self.cache.client_order_id(venue_order_id_2) == order2.client_order_id + + @pytest.mark.asyncio + async def test_submit_order_list_partial_failure(self, mocker): + """ + Test batch order submission with partial failure. + """ + # Arrange + mock_create_order = mocker.patch.object(self.http_client, "create_order") + mock_post_orders = mocker.patch.object(self.http_client, "post_orders") + mock_generate_rejected = mocker.patch.object( + self.exec_client, + "generate_order_rejected", + ) + + # Mock responses - first succeeds, second fails + mock_create_order.return_value = {"signed_order": "mock_signed"} + mock_post_orders.return_value = [ + {"success": True, "orderID": "batch_order_1"}, + {"success": False, "errorMsg": "Insufficient balance"}, + ] + + # Create two limit orders + order1 = self.strategy.order_factory.limit( + instrument_id=ELECTION_INSTRUMENT.id, + order_side=OrderSide.BUY, + quantity=Quantity.from_str("10"), + price=Price.from_str("0.50"), + ) + order2 = self.strategy.order_factory.limit( + instrument_id=ELECTION_INSTRUMENT.id, + order_side=OrderSide.BUY, + quantity=Quantity.from_str("5"), + price=Price.from_str("0.55"), + ) + + self.cache.add_order(order1, None) + self.cache.add_order(order2, None) + + order_list = OrderList( + order_list_id=OrderListId("BATCH-002"), + orders=[order1, order2], + ) + + submit_order_list = SubmitOrderList( + trader_id=self.trader_id, + strategy_id=self.strategy.id, + order_list=order_list, + position_id=None, + command_id=UUID4(), + ts_init=0, + ) + + # Act + await self.exec_client._submit_order_list(submit_order_list) + + # Assert + mock_post_orders.assert_called_once() + + # First order should succeed + venue_order_id_1 = VenueOrderId("batch_order_1") + assert self.cache.client_order_id(venue_order_id_1) == order1.client_order_id + + # Second order should be rejected + mock_generate_rejected.assert_called_once() + reject_call = mock_generate_rejected.call_args + assert reject_call.kwargs["client_order_id"] == order2.client_order_id + assert "Insufficient balance" in reject_call.kwargs["reason"] + + @pytest.mark.asyncio + async def test_submit_order_list_with_market_order_denied(self, mocker): + """ + Test that market orders in batch are denied. + """ + # Arrange + mock_generate_denied = mocker.patch.object( + self.exec_client, + "generate_order_denied", + ) + mock_post_orders = mocker.patch.object(self.http_client, "post_orders") + + # Create a market order (not supported in batch) + market_order = self.strategy.order_factory.market( + instrument_id=ELECTION_INSTRUMENT.id, + order_side=OrderSide.BUY, + quantity=Quantity.from_str("10"), + ) + self.cache.add_order(market_order, None) + + order_list = OrderList( + order_list_id=OrderListId("BATCH-003"), + orders=[market_order], + ) + + submit_order_list = SubmitOrderList( + trader_id=self.trader_id, + strategy_id=self.strategy.id, + order_list=order_list, + position_id=None, + command_id=UUID4(), + ts_init=0, + ) + + # Act + await self.exec_client._submit_order_list(submit_order_list) + + # Assert + mock_generate_denied.assert_called_once() + denied_call = mock_generate_denied.call_args + assert denied_call.kwargs["client_order_id"] == market_order.client_order_id + assert "BATCH_ONLY_SUPPORTS_LIMIT_ORDERS" in denied_call.kwargs["reason"] + + # post_orders should not be called since no valid orders + mock_post_orders.assert_not_called() + + @pytest.mark.asyncio + async def test_submit_order_list_empty_list(self, mocker): + """ + Test that empty order list is handled gracefully. + """ + # Arrange + mock_post_orders = mocker.patch.object(self.http_client, "post_orders") + + order_list = OrderList( + order_list_id=OrderListId("BATCH-004"), + orders=[], + ) + + submit_order_list = SubmitOrderList( + trader_id=self.trader_id, + strategy_id=self.strategy.id, + order_list=order_list, + position_id=None, + command_id=UUID4(), + ts_init=0, + ) + + # Act + await self.exec_client._submit_order_list(submit_order_list) + + # Assert - should return early without calling post_orders + mock_post_orders.assert_not_called() + + @pytest.mark.asyncio + async def test_submit_order_list_with_reduce_only_denied(self, mocker): + """ + Test that reduce_only orders in batch are denied. + """ + # Arrange + mock_generate_denied = mocker.patch.object( + self.exec_client, + "generate_order_denied", + ) + mock_post_orders = mocker.patch.object(self.http_client, "post_orders") + + # Create a reduce_only limit order + reduce_only_order = self.strategy.order_factory.limit( + instrument_id=ELECTION_INSTRUMENT.id, + order_side=OrderSide.SELL, + quantity=Quantity.from_str("10"), + price=Price.from_str("0.50"), + reduce_only=True, + ) + self.cache.add_order(reduce_only_order, None) + + order_list = OrderList( + order_list_id=OrderListId("BATCH-005"), + orders=[reduce_only_order], + ) + + submit_order_list = SubmitOrderList( + trader_id=self.trader_id, + strategy_id=self.strategy.id, + order_list=order_list, + position_id=None, + command_id=UUID4(), + ts_init=0, + ) + + # Act + await self.exec_client._submit_order_list(submit_order_list) + + # Assert + mock_generate_denied.assert_called_once() + denied_call = mock_generate_denied.call_args + assert "REDUCE_ONLY_NOT_SUPPORTED" in denied_call.kwargs["reason"] + mock_post_orders.assert_not_called() + + @pytest.mark.asyncio + async def test_submit_order_list_with_post_only_and_ioc_denied(self, mocker): + """ + Test that post_only orders with IOC time_in_force are denied. + + Post-only orders are only supported with GTC or GTD time in force. + + """ + # Arrange + mock_generate_denied = mocker.patch.object( + self.exec_client, + "generate_order_denied", + ) + mock_post_orders = mocker.patch.object(self.http_client, "post_orders") + + # Create a post_only limit order with IOC (not supported) + post_only_order = self.strategy.order_factory.limit( + instrument_id=ELECTION_INSTRUMENT.id, + order_side=OrderSide.BUY, + quantity=Quantity.from_str("10"), + price=Price.from_str("0.50"), + time_in_force=TimeInForce.IOC, + post_only=True, + ) + self.cache.add_order(post_only_order, None) + + order_list = OrderList( + order_list_id=OrderListId("BATCH-006"), + orders=[post_only_order], + ) + + submit_order_list = SubmitOrderList( + trader_id=self.trader_id, + strategy_id=self.strategy.id, + order_list=order_list, + position_id=None, + command_id=UUID4(), + ts_init=0, + ) + + # Act + await self.exec_client._submit_order_list(submit_order_list) + + # Assert + mock_generate_denied.assert_called_once() + denied_call = mock_generate_denied.call_args + assert "POST_ONLY_REQUIRES_GTC_OR_GTD" in denied_call.kwargs["reason"] + mock_post_orders.assert_not_called() + + +# ===================================================================================== +# Tests for cancel_all_global, cancel_market_orders, and post_only support +# ===================================================================================== + + +class TestPolymarketCancelAndPostOnly: + """ + Tests for cancel_all_global, cancel_market_orders, and post_only order support. + """ + + @pytest.fixture(autouse=True) + def setup(self, request): + # Fixture Setup + self.loop = request.getfixturevalue("event_loop") + self.loop.set_debug(True) + + self.clock = LiveClock() + self.trader_id = TestIdStubs.trader_id() + self.venue = POLYMARKET_VENUE + self.account_id = AccountId(f"{self.venue.value}-001") + + self.msgbus = MessageBus( + trader_id=self.trader_id, + clock=self.clock, + ) + + self.cache = TestComponentStubs.cache() + + # Mock HTTP client + self.http_client = MagicMock(spec=ClobClient) + self.http_client.get_address.return_value = "0xa3D82Ed56F4c68d2328Fb8c29e568Ba2cAF7d7c8" + + # Mock the creds attribute + mock_creds = MagicMock() + mock_creds.api_key = "test_api_key" + self.http_client.creds = mock_creds + + # Mock instrument provider + self.provider = MagicMock(spec=PolymarketInstrumentProvider) + self.provider.initialize = AsyncMock() + + # Mock WebSocket auth + self.ws_auth = MagicMock(spec=PolymarketWebSocketAuth) + + self.portfolio = Portfolio( + msgbus=self.msgbus, + cache=self.cache, + clock=self.clock, + ) + + self.data_engine = DataEngine( + msgbus=self.msgbus, + cache=self.cache, + clock=self.clock, + ) + + self.exec_engine = ExecutionEngine( + msgbus=self.msgbus, + cache=self.cache, + clock=self.clock, + ) + + self.risk_engine = RiskEngine( + portfolio=self.portfolio, + msgbus=self.msgbus, + cache=self.cache, + clock=self.clock, + ) + + # Create execution client + config = PolymarketExecClientConfig() + self.exec_client = PolymarketExecutionClient( + loop=self.loop, + http_client=self.http_client, + msgbus=self.msgbus, + cache=self.cache, + clock=self.clock, + instrument_provider=self.provider, + ws_auth=self.ws_auth, + config=config, + name=None, + ) + + # Prevent actual WebSocket connections in tests + mock_ws_client = MagicMock() + mock_ws_client.is_disconnected.return_value = False + mock_ws_client.is_connected.return_value = True + mock_ws_client.has_subscriptions = True + mock_ws_client.subscriptions = [] + mock_ws_client.market_subscriptions.return_value = [] + mock_ws_client.connect = AsyncMock() + mock_ws_client.disconnect = AsyncMock() + mock_ws_client.subscribe = AsyncMock() + mock_ws_client.unsubscribe = AsyncMock() + mock_ws_client.add_subscription = MagicMock() + self.exec_client._ws_client = mock_ws_client + + self.exec_engine.register_client(self.exec_client) + + self.strategy = Strategy() + self.strategy.register( + trader_id=self.trader_id, + portfolio=self.portfolio, + msgbus=self.msgbus, + cache=self.cache, + clock=self.clock, + ) + + # Add test instrument to cache + self.cache.add_instrument(ELECTION_INSTRUMENT) + + # ------------------------------------------------------------------------- + # Tests for cancel_all_global + # ------------------------------------------------------------------------- + + @pytest.mark.asyncio + async def test_cancel_all_global_success(self, mocker): + """ + Test successful global cancel all orders. + """ + # Arrange + mock_cancel_all = mocker.patch.object(self.http_client, "cancel_all") + mock_cancel_all.return_value = { + "canceled": ["order1", "order2", "order3"], + "not_canceled": {}, + } + + # Act + await self.exec_client._cancel_all_global() + + # Assert + mock_cancel_all.assert_called_once() + + @pytest.mark.asyncio + async def test_cancel_all_global_partial_failure(self, mocker): + """ + Test cancel all with some orders not canceled. + """ + # Arrange + mock_cancel_all = mocker.patch.object(self.http_client, "cancel_all") + mock_cancel_all.return_value = { + "canceled": ["order1"], + "not_canceled": {"order2": "Order already filled"}, + } + + # Act + await self.exec_client._cancel_all_global() + + # Assert + mock_cancel_all.assert_called_once() + + # ------------------------------------------------------------------------- + # Tests for cancel_market_orders + # ------------------------------------------------------------------------- + + @pytest.mark.asyncio + async def test_cancel_market_orders_success(self, mocker): + """ + Test cancel orders for a specific market. + """ + # Arrange + mock_cancel_market = mocker.patch.object( + self.http_client, + "cancel_market_orders", + ) + mock_cancel_market.return_value = { + "canceled": ["order1", "order2"], + "not_canceled": {}, + } + + # Act + await self.exec_client._cancel_market_orders( + instrument_id=ELECTION_INSTRUMENT.id, + ) + + # Assert + mock_cancel_market.assert_called_once() + + @pytest.mark.asyncio + async def test_cancel_market_orders_all_markets(self, mocker): + """ + Test cancel orders for all markets (no instrument_id). + """ + # Arrange + mock_cancel_market = mocker.patch.object( + self.http_client, + "cancel_market_orders", + ) + mock_cancel_market.return_value = { + "canceled": ["order1"], + "not_canceled": {}, + } + + # Act + await self.exec_client._cancel_market_orders() + + # Assert + mock_cancel_market.assert_called_once_with("", "") + + # ------------------------------------------------------------------------- + # Tests for post_only order support + # ------------------------------------------------------------------------- + + @pytest.mark.asyncio + async def test_submit_post_only_order_with_gtc_success(self, mocker): + """ + Test successful post_only order submission with GTC time in force. + """ + # Arrange + mock_create_order = mocker.patch.object(self.http_client, "create_order") + mock_post_order = mocker.patch.object(self.http_client, "post_order") + + mock_create_order.return_value = {"signed_order": "mock_signed"} + mock_post_order.return_value = {"success": True, "orderID": "post_only_order_1"} + + order = self.strategy.order_factory.limit( + instrument_id=ELECTION_INSTRUMENT.id, + order_side=OrderSide.BUY, + quantity=Quantity.from_str("10"), + price=Price.from_str("0.50"), + time_in_force=TimeInForce.GTC, + post_only=True, + ) + self.cache.add_order(order, None) + + submit_order = SubmitOrder( + trader_id=self.trader_id, + strategy_id=self.strategy.id, + position_id=None, + order=order, + command_id=UUID4(), + ts_init=0, + ) + + # Act + await self.exec_client._submit_order(submit_order) + + # Assert + mock_create_order.assert_called_once() + mock_post_order.assert_called_once() + # Verify post_only=True was passed + call_args = mock_post_order.call_args + assert call_args[0][2] is True # post_only parameter + + @pytest.mark.asyncio + async def test_submit_post_only_order_with_fok_denied(self, mocker): + """ + Test post_only order with FOK time in force is denied. + """ + # Arrange + mock_generate_denied = mocker.patch.object( + self.exec_client, + "generate_order_denied", + ) + + order = self.strategy.order_factory.limit( + instrument_id=ELECTION_INSTRUMENT.id, + order_side=OrderSide.BUY, + quantity=Quantity.from_str("10"), + price=Price.from_str("0.50"), + time_in_force=TimeInForce.FOK, + post_only=True, + ) + self.cache.add_order(order, None) + + submit_order = SubmitOrder( + trader_id=self.trader_id, + strategy_id=self.strategy.id, + position_id=None, + order=order, + command_id=UUID4(), + ts_init=0, + ) + + # Act + await self.exec_client._submit_order(submit_order) + + # Assert + mock_generate_denied.assert_called_once() + denied_call = mock_generate_denied.call_args + assert "POST_ONLY_REQUIRES_GTC_OR_GTD" in denied_call.kwargs["reason"] + + @pytest.mark.asyncio + async def test_submit_post_only_order_with_ioc_denied(self, mocker): + """ + Test post_only order with IOC time in force is denied. + """ + # Arrange + mock_generate_denied = mocker.patch.object( + self.exec_client, + "generate_order_denied", + ) + + order = self.strategy.order_factory.limit( + instrument_id=ELECTION_INSTRUMENT.id, + order_side=OrderSide.BUY, + quantity=Quantity.from_str("10"), + price=Price.from_str("0.50"), + time_in_force=TimeInForce.IOC, + post_only=True, + ) + self.cache.add_order(order, None) + + submit_order = SubmitOrder( + trader_id=self.trader_id, + strategy_id=self.strategy.id, + position_id=None, + order=order, + command_id=UUID4(), + ts_init=0, + ) + + # Act + await self.exec_client._submit_order(submit_order) + + # Assert + mock_generate_denied.assert_called_once() + denied_call = mock_generate_denied.call_args + assert "POST_ONLY_REQUIRES_GTC_OR_GTD" in denied_call.kwargs["reason"]