Skip to content
This repository was archived by the owner on May 25, 2026. It is now read-only.
Open
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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions py_clob_client/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .client import ClobClient
from .exceptions import TickSizeRejectedError
from .clob_types import (
ApiCreds,
OrderArgs,
Expand Down Expand Up @@ -38,6 +39,8 @@
__all__ = [
# Main client
"ClobClient",
# Exceptions
"TickSizeRejectedError",
# Core types
"ApiCreds",
"OrderArgs",
Expand Down
134 changes: 100 additions & 34 deletions py_clob_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
):
Expand Down Expand Up @@ -438,6 +449,38 @@ 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
Comment thread
cursor[bot] marked this conversation as resolved.
msg = ClobClient._error_message_to_string(error_msg)
return any(
kw in msg for kw in ("tick", "precision", "minimum_tick")
)
Comment thread
kingo233 marked this conversation as resolved.

@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]
Expand All @@ -458,9 +501,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(
Expand Down Expand Up @@ -498,10 +544,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):
Expand Down Expand Up @@ -545,10 +592,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:
Expand Down Expand Up @@ -604,21 +652,30 @@ 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,
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(
self._flatten_error_message(e.error_msg), api_exception=e
)
# send exact serialized bytes
return post(
"{}{}".format(self.host, POST_ORDERS),
headers=headers,
data=request_args.serialized_body,
)
err.__cause__ = e
Comment thread
kingo233 marked this conversation as resolved.
raise err
raise

def post_order(self, order, orderType: OrderType = OrderType.GTC, post_only: bool = False):
"""
Expand All @@ -636,20 +693,29 @@ 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,
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(
self._flatten_error_message(e.error_msg), api_exception=e
)
return post(
"{}{}".format(self.host, POST_ORDER),
headers=headers,
data=request_args.serialized_body,
)
err.__cause__ = e
raise err
raise

def create_and_post_order(
self, order_args: OrderArgs, options: PartialCreateOrderOptions = None
Expand Down
26 changes: 26 additions & 0 deletions py_clob_client/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,29 @@ def __repr__(self):

def __str__(self):
return self.__repr__()


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
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__(error_msg=self.msg)
if api_exception is not None:
self.status_code = api_exception.status_code

def __str__(self):
return self.msg

def __repr__(self):
return f"TickSizeRejectedError({self.msg!r})"
Comment thread
cursor[bot] marked this conversation as resolved.
55 changes: 55 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -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), "")