From 374a4f7e13a7dcfea8bed1654cdb84391117fbbb Mon Sep 17 00:00:00 2001 From: skyc1e Date: Wed, 1 Apr 2026 22:18:02 +0200 Subject: [PATCH] feat: cancel and cancel_orders return full order objects with sizeMatched After a successful cancellation, fetch the complete order via get_order so callers can inspect size_matched without waiting for a fill event. Falls back to the raw API response if get_order fails. Closes #316 --- py_clob_client/client.py | 37 ++++++++-- tests/test_cancel.py | 146 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 tests/test_cancel.py diff --git a/py_clob_client/client.py b/py_clob_client/client.py index e6be3c56..6d94c34a 100644 --- a/py_clob_client/client.py +++ b/py_clob_client/client.py @@ -662,7 +662,12 @@ def create_and_post_order( def cancel(self, order_id): """ - Cancels an order + Cancels an order and returns the full order object (including sizeMatched). + + If the order was successfully canceled, fetches and returns the order details + so callers can inspect ``sizeMatched`` without waiting for a fill event. + If the cancellation failed, returns the raw API response unchanged. + Level 2 Auth required """ self.assert_level_2_auth() @@ -675,15 +680,28 @@ def cancel(self, order_id): serialized_body=json.dumps(body, separators=(",", ":"), ensure_ascii=False), ) headers = create_level_2_headers(self.signer, self.creds, request_args) - return delete( + response = delete( "{}{}".format(self.host, CANCEL), headers=headers, data=request_args.serialized_body, ) + canceled = response.get("canceled", []) + if order_id in canceled: + try: + return self.get_order(order_id) + except Exception: + return response + return response def cancel_orders(self, order_ids): """ - Cancels orders + Cancels a list of orders and returns full order objects (including sizeMatched) + for each successfully canceled order. + + Returns a dict with: + - ``canceled``: list of full order objects for successfully canceled orders + - ``not_canceled``: dict mapping order IDs to error reasons (unchanged from API) + Level 2 Auth required """ self.assert_level_2_auth() @@ -696,9 +714,20 @@ def cancel_orders(self, order_ids): serialized_body=serialized, ) headers = create_level_2_headers(self.signer, self.creds, request_args) - return delete( + response = delete( "{}{}".format(self.host, CANCEL_ORDERS), headers=headers, data=serialized ) + canceled_ids = response.get("canceled", []) + canceled_orders = [] + for oid in canceled_ids: + try: + canceled_orders.append(self.get_order(oid)) + except Exception: + canceled_orders.append({"id": oid}) + return { + "canceled": canceled_orders, + "not_canceled": response.get("not_canceled", {}), + } def cancel_all(self): """ diff --git a/tests/test_cancel.py b/tests/test_cancel.py new file mode 100644 index 00000000..d89566b5 --- /dev/null +++ b/tests/test_cancel.py @@ -0,0 +1,146 @@ +from unittest import TestCase +from unittest.mock import MagicMock, patch + + +ORDER_ID = "0xabc123" +FULL_ORDER = { + "id": ORDER_ID, + "status": "CANCELED", + "side": "BUY", + "original_size": "100000000", + "size_matched": "30000000", + "price": "0.5", + "asset_id": "52114319501245915516055106046884209969926127482827954674443846427813813222426", +} + + +def _make_client(): + """Return a ClobClient with auth mocked out.""" + from py_clob_client.client import ClobClient + + client = ClobClient.__new__(ClobClient) + client.host = "https://clob.polymarket.com" + client.signer = MagicMock() + client.creds = MagicMock() + client.assert_level_2_auth = MagicMock() + return client + + +class TestCancel(TestCase): + @patch("py_clob_client.client.create_level_2_headers", return_value={}) + @patch("py_clob_client.client.delete") + def test_cancel_returns_full_order_on_success(self, mock_delete, _mock_headers): + mock_delete.return_value = {"canceled": [ORDER_ID], "not_canceled": {}} + client = _make_client() + client.get_order = MagicMock(return_value=FULL_ORDER) + + result = client.cancel(ORDER_ID) + + client.get_order.assert_called_once_with(ORDER_ID) + self.assertEqual(result, FULL_ORDER) + self.assertIn("size_matched", result) + + @patch("py_clob_client.client.create_level_2_headers", return_value={}) + @patch("py_clob_client.client.delete") + def test_cancel_returns_raw_response_when_not_canceled(self, mock_delete, _mock_headers): + raw = {"canceled": [], "not_canceled": {ORDER_ID: "Order not found"}} + mock_delete.return_value = raw + client = _make_client() + client.get_order = MagicMock() + + result = client.cancel(ORDER_ID) + + client.get_order.assert_not_called() + self.assertEqual(result, raw) + + @patch("py_clob_client.client.create_level_2_headers", return_value={}) + @patch("py_clob_client.client.delete") + def test_cancel_returns_raw_response_when_get_order_fails(self, mock_delete, _mock_headers): + raw = {"canceled": [ORDER_ID], "not_canceled": {}} + mock_delete.return_value = raw + client = _make_client() + client.get_order = MagicMock(side_effect=Exception("network error")) + + result = client.cancel(ORDER_ID) + + self.assertEqual(result, raw) + + +class TestCancelOrders(TestCase): + ORDER_ID_2 = "0xdef456" + + @patch("py_clob_client.client.create_level_2_headers", return_value={}) + @patch("py_clob_client.client.delete") + def test_cancel_orders_returns_full_order_objects(self, mock_delete, _mock_headers): + mock_delete.return_value = { + "canceled": [ORDER_ID], + "not_canceled": {}, + } + client = _make_client() + client.get_order = MagicMock(return_value=FULL_ORDER) + + result = client.cancel_orders([ORDER_ID]) + + self.assertIn("canceled", result) + self.assertIn("not_canceled", result) + self.assertEqual(len(result["canceled"]), 1) + self.assertEqual(result["canceled"][0], FULL_ORDER) + self.assertIn("size_matched", result["canceled"][0]) + + @patch("py_clob_client.client.create_level_2_headers", return_value={}) + @patch("py_clob_client.client.delete") + def test_cancel_orders_preserves_not_canceled(self, mock_delete, _mock_headers): + mock_delete.return_value = { + "canceled": [ORDER_ID], + "not_canceled": {self.ORDER_ID_2: "Order already matched"}, + } + client = _make_client() + client.get_order = MagicMock(return_value=FULL_ORDER) + + result = client.cancel_orders([ORDER_ID, self.ORDER_ID_2]) + + self.assertEqual(result["not_canceled"], {self.ORDER_ID_2: "Order already matched"}) + + @patch("py_clob_client.client.create_level_2_headers", return_value={}) + @patch("py_clob_client.client.delete") + def test_cancel_orders_falls_back_to_id_dict_when_get_order_fails(self, mock_delete, _mock_headers): + mock_delete.return_value = {"canceled": [ORDER_ID], "not_canceled": {}} + client = _make_client() + client.get_order = MagicMock(side_effect=Exception("network error")) + + result = client.cancel_orders([ORDER_ID]) + + self.assertEqual(result["canceled"], [{"id": ORDER_ID}]) + + @patch("py_clob_client.client.create_level_2_headers", return_value={}) + @patch("py_clob_client.client.delete") + def test_cancel_orders_handles_empty_response(self, mock_delete, _mock_headers): + mock_delete.return_value = {"canceled": [], "not_canceled": {}} + client = _make_client() + client.get_order = MagicMock() + + result = client.cancel_orders([]) + + client.get_order.assert_not_called() + self.assertEqual(result["canceled"], []) + self.assertEqual(result["not_canceled"], {}) + + @patch("py_clob_client.client.create_level_2_headers", return_value={}) + @patch("py_clob_client.client.delete") + def test_cancel_orders_continues_after_single_get_order_failure(self, mock_delete, _mock_headers): + ORDER_ID_3 = "0xghi789" + FULL_ORDER_3 = {**FULL_ORDER, "id": ORDER_ID_3} + mock_delete.return_value = { + "canceled": [ORDER_ID, ORDER_ID_3], + "not_canceled": {}, + } + client = _make_client() + client.get_order = MagicMock( + side_effect=[Exception("transient error"), FULL_ORDER_3] + ) + + result = client.cancel_orders([ORDER_ID, ORDER_ID_3]) + + self.assertEqual(len(result["canceled"]), 2) + self.assertEqual(result["canceled"][0], {"id": ORDER_ID}) + self.assertEqual(result["canceled"][1], FULL_ORDER_3)