From 3ab67c9c8a6a486c534f56dba83a7f92f7162412 Mon Sep 17 00:00:00 2001 From: qiumiao Date: Mon, 23 Feb 2026 15:53:35 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20tick=20size=20cache=20stale=20?= =?UTF-8?q?=E2=80=94=20force=20refresh=20on=20order=20create,=20better=20e?= =?UTF-8?q?rror=20on=20reject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 18 ++++++ py_clob_client/__init__.py | 3 + py_clob_client/client.py | 110 +++++++++++++++++++++++------------ py_clob_client/exceptions.py | 24 ++++++++ 4 files changed, 119 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 93e4240e..4b005f01 100644 --- a/README.md +++ b/README.md @@ -266,8 +266,26 @@ See [this Python example](https://gist.github.com/poly-rodr/44313920481de58d5a3f **Pro tip**: You only need to set these once per wallet. After that, you can trade freely. +## Tick size and order rejection + +The client **caches** each market’s minimum tick size (from `get_tick_size`) for a short time to reduce API calls. When you **create or sign orders** (`create_order`, `create_market_order`), the client always fetches the **current** tick size from the CLOB, so signed orders use the correct value even if the cache is stale. + +- **Using `get_tick_size` elsewhere** (e.g. for display or validation): if the market’s tick size may have changed on the CLOB, either call `get_tick_size(token_id, force_refresh=True)` or clear the cache first: `client.clear_tick_size_cache(token_id)` (or `client.clear_tick_size_cache()` for all tokens). +- **Order rejected by the API**: if the server rejects an order and the error is related to tick size or price precision, the client may raise `TickSizeRejectedError` with a message suggesting you clear the tick size cache and retry: + ```python + from py_clob_client import ClobClient, TickSizeRejectedError + + try: + resp = client.post_order(signed, OrderType.GTC) + except TickSizeRejectedError as e: + # Market tick size may have changed; clear cache and retry + client.clear_tick_size_cache() # or client.clear_tick_size_cache(token_id) + # Re-create and post the order + ``` + ## Notes - To discover token IDs, use the Markets API Explorer: [Get Markets](https://docs.polymarket.com/developers/gamma-markets-api/get-markets). - Prices are in dollars from 0.00 to 1.00. Shares are whole or fractional units of the outcome token. +- If an order is rejected due to tick size or price precision, see [Tick size and order rejection](#tick-size-and-order-rejection) above. See [/example](/examples) for more. \ No newline at end of file diff --git a/py_clob_client/__init__.py b/py_clob_client/__init__.py index ecde9216..68fb0903 100644 --- a/py_clob_client/__init__.py +++ b/py_clob_client/__init__.py @@ -1,4 +1,5 @@ from .client import ClobClient +from .exceptions import TickSizeRejectedError from .clob_types import ( ApiCreds, OrderArgs, @@ -38,6 +39,8 @@ __all__ = [ # Main client "ClobClient", + # Exceptions + "TickSizeRejectedError", # Core types "ApiCreds", "OrderArgs", diff --git a/py_clob_client/client.py b/py_clob_client/client.py index e6be3c56..bd107d91 100644 --- a/py_clob_client/client.py +++ b/py_clob_client/client.py @@ -82,7 +82,7 @@ MarketOrderArgs, PostOrdersArgs, ) -from .exceptions import PolyException +from .exceptions import PolyException, PolyApiException, TickSizeRejectedError from .http_helpers.helpers import ( add_query_trade_params, add_query_open_orders_params, @@ -399,11 +399,22 @@ def get_spreads(self, params: list[BookParams]): body = [{"token_id": param.token_id} for param in params] return post("{}{}".format(self.host, GET_SPREADS), data=body) - def get_tick_size(self, token_id: str) -> TickSize: + def get_tick_size(self, token_id: str, force_refresh: bool = False) -> TickSize: + """ + Returns the minimum tick size for the given token (market). + + Results are cached for tick_size_ttl seconds. If the order book's tick + size has changed on the CLOB, you may get a stale value until the cache + expires or you force a refresh. When signing orders, the client + automatically uses a fresh tick size; if you use get_tick_size elsewhere + and the market may have changed, pass force_refresh=True or call + clear_tick_size_cache(token_id) first. + """ cached_at = self.__tick_size_timestamps.get(token_id) if ( - token_id in self.__tick_sizes + not force_refresh + and token_id in self.__tick_sizes and cached_at is not None and (time.monotonic() - cached_at) < self.__tick_size_ttl ): @@ -438,6 +449,14 @@ def _update_tick_size_from_order_book(self, book: OrderBookSummary): self.__tick_sizes[book.asset_id] = str(book.tick_size) self.__tick_size_timestamps[book.asset_id] = time.monotonic() + @staticmethod + def _is_tick_size_related_error(error_msg) -> bool: + """True if the API error message is likely due to tick size / price precision.""" + if error_msg is None: + return False + s = json.dumps(error_msg).lower() if isinstance(error_msg, dict) else str(error_msg).lower() + return "tick" in s or "precision" in s or "minimum_tick" in s + def get_neg_risk(self, token_id: str) -> bool: if token_id in self.__neg_risk: return self.__neg_risk[token_id] @@ -458,9 +477,12 @@ def get_fee_rate_bps(self, token_id: str) -> int: return fee_rate def __resolve_tick_size( - self, token_id: str, tick_size: TickSize = None + self, + token_id: str, + tick_size: TickSize = None, + force_refresh: bool = False, ) -> TickSize: - min_tick_size = self.get_tick_size(token_id) + min_tick_size = self.get_tick_size(token_id, force_refresh=force_refresh) if tick_size is not None: if is_tick_size_smaller(tick_size, min_tick_size): raise Exception( @@ -498,10 +520,11 @@ def create_order( """ self.assert_level_1_auth() - # add resolve_order_options, or similar + # Resolve tick size from CLOB (force refresh to avoid stale cache when signing) tick_size = self.__resolve_tick_size( order_args.token_id, options.tick_size if options else None, + force_refresh=True, ) if not price_valid(order_args.price, tick_size): @@ -545,10 +568,11 @@ def create_market_order( """ self.assert_level_1_auth() - # add resolve_order_options, or similar + # Resolve tick size from CLOB (force refresh to avoid stale cache when signing) tick_size = self.__resolve_tick_size( order_args.token_id, options.tick_size if options else None, + force_refresh=True, ) if order_args.price is None or order_args.price <= 0: @@ -604,21 +628,28 @@ def post_orders(self, args: list[PostOrdersArgs]): serialized_body=json.dumps(body, separators=(",", ":"), ensure_ascii=False), ) headers = create_level_2_headers(self.signer, self.creds, request_args) - # Builder flow - if self.can_builder_auth(): - builder_headers = self._generate_builder_headers(request_args, headers) - if builder_headers is not None: - return post( - "{}{}".format(self.host, POST_ORDERS), - headers=builder_headers, - data=request_args.serialized_body, - ) - # send exact serialized bytes - return post( - "{}{}".format(self.host, POST_ORDERS), - headers=headers, - data=request_args.serialized_body, - ) + try: + # Builder flow + if self.can_builder_auth(): + builder_headers = self._generate_builder_headers(request_args, headers) + if builder_headers is not None: + return post( + "{}{}".format(self.host, POST_ORDERS), + headers=builder_headers, + data=request_args.serialized_body, + ) + # send exact serialized bytes + return post( + "{}{}".format(self.host, POST_ORDERS), + headers=headers, + data=request_args.serialized_body, + ) + except PolyApiException as e: + if self._is_tick_size_related_error(e.error_msg): + err = TickSizeRejectedError(str(e.error_msg), api_exception=e) + err.__cause__ = e + raise err + raise def post_order(self, order, orderType: OrderType = OrderType.GTC, post_only: bool = False): """ @@ -636,20 +667,27 @@ def post_order(self, order, orderType: OrderType = OrderType.GTC, post_only: boo serialized_body=json.dumps(body, separators=(",", ":"), ensure_ascii=False), ) headers = create_level_2_headers(self.signer, self.creds, request_args) - # Builder flow - if self.can_builder_auth(): - builder_headers = self._generate_builder_headers(request_args, headers) - if builder_headers is not None: - return post( - "{}{}".format(self.host, POST_ORDER), - headers=builder_headers, - data=request_args.serialized_body, - ) - return post( - "{}{}".format(self.host, POST_ORDER), - headers=headers, - data=request_args.serialized_body, - ) + try: + # Builder flow + if self.can_builder_auth(): + builder_headers = self._generate_builder_headers(request_args, headers) + if builder_headers is not None: + return post( + "{}{}".format(self.host, POST_ORDER), + headers=builder_headers, + data=request_args.serialized_body, + ) + return post( + "{}{}".format(self.host, POST_ORDER), + headers=headers, + data=request_args.serialized_body, + ) + except PolyApiException as e: + if self._is_tick_size_related_error(e.error_msg): + err = TickSizeRejectedError(str(e.error_msg), api_exception=e) + err.__cause__ = e + raise err + raise def create_and_post_order( self, order_args: OrderArgs, options: PartialCreateOrderOptions = None diff --git a/py_clob_client/exceptions.py b/py_clob_client/exceptions.py index 3bf8dd57..12ba69ce 100644 --- a/py_clob_client/exceptions.py +++ b/py_clob_client/exceptions.py @@ -30,3 +30,27 @@ def __repr__(self): def __str__(self): return self.__repr__() + + +class TickSizeRejectedError(PolyException): + """ + Raised when an order is rejected and the error is likely due to tick size / + price precision (e.g. the market's tick size changed on the CLOB). Clear the + tick size cache and retry: client.clear_tick_size_cache() or + client.clear_tick_size_cache(token_id), then create and post the order again. + """ + + def __init__(self, msg, api_exception=None): + self.api_exception = api_exception + hint = ( + "Clear tick size cache: client.clear_tick_size_cache() or " + "client.clear_tick_size_cache(token_id), then create and post the order again." + ) + self.msg = f"{msg}. {hint}" + super().__init__(self.msg) + + def __str__(self): + return self.msg + + def __repr__(self): + return f"TickSizeRejectedError({self.msg!r})" From 368f2c206d7af28b75464ab7af902aeb33085b8e Mon Sep 17 00:00:00 2001 From: qiumiao Date: Mon, 23 Feb 2026 16:19:11 +0800 Subject: [PATCH 2/5] fix: make TickSizeRejectedError inherit from PolyApiException So that except PolyApiException continues to catch all API errors including tick-size rejections; preserves status_code from wrapped api_exception. Co-authored-by: Cursor --- py_clob_client/exceptions.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/py_clob_client/exceptions.py b/py_clob_client/exceptions.py index 12ba69ce..a4e9b768 100644 --- a/py_clob_client/exceptions.py +++ b/py_clob_client/exceptions.py @@ -32,7 +32,7 @@ def __str__(self): return self.__repr__() -class TickSizeRejectedError(PolyException): +class TickSizeRejectedError(PolyApiException): """ Raised when an order is rejected and the error is likely due to tick size / price precision (e.g. the market's tick size changed on the CLOB). Clear the @@ -47,7 +47,9 @@ def __init__(self, msg, api_exception=None): "client.clear_tick_size_cache(token_id), then create and post the order again." ) self.msg = f"{msg}. {hint}" - super().__init__(self.msg) + super().__init__(error_msg=self.msg) + if api_exception is not None: + self.status_code = api_exception.status_code def __str__(self): return self.msg From dff0ba87467bec53d5d4f2126be64e5f7baf595f Mon Sep 17 00:00:00 2001 From: qiumiao Date: Mon, 23 Feb 2026 16:31:27 +0800 Subject: [PATCH 3/5] fix: remove dead 'minimum_tick' check in _is_tick_size_related_error --- py_clob_client/client.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/py_clob_client/client.py b/py_clob_client/client.py index bd107d91..821d99f6 100644 --- a/py_clob_client/client.py +++ b/py_clob_client/client.py @@ -454,8 +454,6 @@ def _is_tick_size_related_error(error_msg) -> bool: """True if the API error message is likely due to tick size / price precision.""" if error_msg is None: return False - s = json.dumps(error_msg).lower() if isinstance(error_msg, dict) else str(error_msg).lower() - return "tick" in s or "precision" in s or "minimum_tick" in s def get_neg_risk(self, token_id: str) -> bool: if token_id in self.__neg_risk: From 40a4d7fc6949b5169156fcd614f974acb8662991 Mon Sep 17 00:00:00 2001 From: qiumiao Date: Mon, 23 Feb 2026 16:38:36 +0800 Subject: [PATCH 4/5] fix: add keyword detection to _is_tick_size_related_error --- py_clob_client/client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/py_clob_client/client.py b/py_clob_client/client.py index 821d99f6..87c3676b 100644 --- a/py_clob_client/client.py +++ b/py_clob_client/client.py @@ -454,6 +454,10 @@ def _is_tick_size_related_error(error_msg) -> bool: """True if the API error message is likely due to tick size / price precision.""" if error_msg is None: return False + msg = str(error_msg).lower() + return any( + kw in msg for kw in ("tick", "precision", "minimum_tick") + ) def get_neg_risk(self, token_id: str) -> bool: if token_id in self.__neg_risk: From b077da848398238c961e3588aedbb9de73a1c185 Mon Sep 17 00:00:00 2001 From: qiumiao Date: Mon, 23 Feb 2026 17:09:45 +0800 Subject: [PATCH 5/5] fix: handle dict/nested error_msg in tick size detection - Add _flatten_error_message() to recursively extract string from dict/list API error payloads (e.g. {"error": {"message": "..."}}) - Use flattened text in _is_tick_size_related_error() so keyword search works for nested structures; use flattened message for TickSizeRejectedError - Add tests for string, dict, nested dict, and list error payloads Co-authored-by: Cursor --- py_clob_client/client.py | 32 ++++++++++++++++++++--- tests/test_client.py | 55 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 tests/test_client.py diff --git a/py_clob_client/client.py b/py_clob_client/client.py index 87c3676b..c2a1d296 100644 --- a/py_clob_client/client.py +++ b/py_clob_client/client.py @@ -454,11 +454,33 @@ def _is_tick_size_related_error(error_msg) -> bool: """True if the API error message is likely due to tick size / price precision.""" if error_msg is None: return False - msg = str(error_msg).lower() + msg = ClobClient._error_message_to_string(error_msg) return any( kw in msg for kw in ("tick", "precision", "minimum_tick") ) + @staticmethod + def _flatten_error_message(value) -> str: + """Flatten dict/list error payloads into a single string (preserves casing).""" + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, dict): + return " ".join( + ClobClient._flatten_error_message(v) for v in value.values() + ) + if isinstance(value, (list, tuple)): + return " ".join( + ClobClient._flatten_error_message(v) for v in value + ) + return str(value) + + @staticmethod + def _error_message_to_string(value) -> str: + """Flatten dict/list error payloads into a single lowercase string for keyword search.""" + return ClobClient._flatten_error_message(value).lower() + def get_neg_risk(self, token_id: str) -> bool: if token_id in self.__neg_risk: return self.__neg_risk[token_id] @@ -648,7 +670,9 @@ def post_orders(self, args: list[PostOrdersArgs]): ) except PolyApiException as e: if self._is_tick_size_related_error(e.error_msg): - err = TickSizeRejectedError(str(e.error_msg), api_exception=e) + err = TickSizeRejectedError( + self._flatten_error_message(e.error_msg), api_exception=e + ) err.__cause__ = e raise err raise @@ -686,7 +710,9 @@ def post_order(self, order, orderType: OrderType = OrderType.GTC, post_only: boo ) except PolyApiException as e: if self._is_tick_size_related_error(e.error_msg): - err = TickSizeRejectedError(str(e.error_msg), api_exception=e) + err = TickSizeRejectedError( + self._flatten_error_message(e.error_msg), api_exception=e + ) err.__cause__ = e raise err raise diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 00000000..e56fe39d --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,55 @@ +"""Tests for ClobClient error message handling (tick size detection).""" +from unittest import TestCase + +from py_clob_client.client import ClobClient + + +class TestClientTickSizeErrorDetection(TestCase): + """Test _is_tick_size_related_error and _flatten_error_message with dict/string payloads.""" + + def test_is_tick_size_related_error_string_plain(self): + self.assertTrue(ClobClient._is_tick_size_related_error("invalid tick size")) + self.assertTrue(ClobClient._is_tick_size_related_error("Price precision error")) + self.assertTrue(ClobClient._is_tick_size_related_error("minimum_tick violated")) + self.assertFalse(ClobClient._is_tick_size_related_error("insufficient balance")) + self.assertFalse(ClobClient._is_tick_size_related_error("")) + + def test_is_tick_size_related_error_string_none(self): + self.assertFalse(ClobClient._is_tick_size_related_error(None)) + + def test_is_tick_size_related_error_dict_nested_message(self): + # API returns resp.json() e.g. {"error": {"message": "invalid tick size"}} + payload = {"error": {"message": "invalid tick size"}} + self.assertTrue(ClobClient._is_tick_size_related_error(payload)) + + def test_is_tick_size_related_error_dict_precision_deep(self): + payload = {"detail": {"reason": "Price precision does not match"}} + self.assertTrue(ClobClient._is_tick_size_related_error(payload)) + + def test_is_tick_size_related_error_dict_minimum_tick(self): + payload = {"message": "Order violates minimum_tick"} + self.assertTrue(ClobClient._is_tick_size_related_error(payload)) + + def test_is_tick_size_related_error_dict_unrelated(self): + payload = {"error": {"message": "insufficient balance"}} + self.assertFalse(ClobClient._is_tick_size_related_error(payload)) + + def test_flatten_error_message_string(self): + self.assertEqual(ClobClient._flatten_error_message("hello"), "hello") + + def test_flatten_error_message_dict_nested(self): + payload = {"error": {"message": "invalid tick size"}} + self.assertEqual( + ClobClient._flatten_error_message(payload), "invalid tick size" + ) + + def test_flatten_error_message_dict_multiple_keys(self): + payload = {"msg": "precision", "code": 400} + self.assertIn("precision", ClobClient._flatten_error_message(payload)) + + def test_flatten_error_message_list(self): + payload = ["first", "minimum_tick error"] + self.assertIn("minimum_tick", ClobClient._flatten_error_message(payload)) + + def test_flatten_error_message_none(self): + self.assertEqual(ClobClient._flatten_error_message(None), "")