feat: implement Polymarket CLOB client wrapper with rate limiting

- Add ClobClient class wrapping py-clob-client library
- Implement rate limiting (10 requests/second) with token bucket
- Add retry logic with exponential backoff (3 retries)
- Load API key from POLYMARKET_API_KEY environment variable
- Create Market, Orderbook, Token dataclass models
- Add comprehensive unit tests with mocked responses

Acceptance Criteria:
- [x] ClobClient class that wraps py-clob-client
- [x] Loads POLYMARKET_API_KEY from environment
- [x] Implements get_markets() returning all active markets
- [x] Implements get_market(market_id) returning market details
- [x] Implements get_orderbook(market_id) returning current book
- [x] Rate limiting: max 10 requests/second with automatic throttling
- [x] Retry logic: 3 retries with exponential backoff on errors
- [x] Unit tests with mocked responses
- [x] Type hints for all public methods

Closes #2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Selamy
2026-01-04 14:40:43 -05:00
co-authored by Claude Opus 4.5
parent bc05af2fd7
commit bb5fc086ae
5 changed files with 1127 additions and 0 deletions
@@ -1 +1,23 @@
"""Data ingestion layer - Real-time Polymarket trade streaming."""
from polymarket_insider_tracker.ingestor.clob_client import (
ClobClient,
ClobClientError,
RetryError,
)
from polymarket_insider_tracker.ingestor.models import (
Market,
Orderbook,
OrderbookLevel,
Token,
)
__all__ = [
"ClobClient",
"ClobClientError",
"RetryError",
"Market",
"Orderbook",
"OrderbookLevel",
"Token",
]
@@ -0,0 +1,340 @@
"""Wrapper around py-clob-client with rate limiting and retry logic."""
import asyncio
import logging
import os
import time
from collections.abc import Callable
from functools import wraps
from typing import Any, ParamSpec, TypeVar
from py_clob_client.client import ClobClient as BaseClobClient
from py_clob_client.clob_types import BookParams
from polymarket_insider_tracker.ingestor.models import Market, Orderbook
logger = logging.getLogger(__name__)
P = ParamSpec("P")
T = TypeVar("T")
# Constants
DEFAULT_HOST = "https://clob.polymarket.com"
MAX_REQUESTS_PER_SECOND = 10
MIN_REQUEST_INTERVAL = 1.0 / MAX_REQUESTS_PER_SECOND # 0.1 seconds
DEFAULT_MAX_RETRIES = 3
DEFAULT_RETRY_BASE_DELAY = 1.0
RETRY_STATUS_CODES = (429, 500, 502, 503, 504)
class RateLimiter:
"""Token bucket rate limiter for API requests."""
def __init__(self, max_requests_per_second: float = MAX_REQUESTS_PER_SECOND) -> None:
"""Initialize the rate limiter.
Args:
max_requests_per_second: Maximum requests allowed per second.
"""
self._min_interval = 1.0 / max_requests_per_second
self._last_request_time: float = 0.0
self._lock = asyncio.Lock()
async def acquire(self) -> None:
"""Wait until a request slot is available."""
async with self._lock:
now = time.monotonic()
elapsed = now - self._last_request_time
if elapsed < self._min_interval:
wait_time = self._min_interval - elapsed
await asyncio.sleep(wait_time)
self._last_request_time = time.monotonic()
def acquire_sync(self) -> None:
"""Synchronous version of acquire for sync operations."""
now = time.monotonic()
elapsed = now - self._last_request_time
if elapsed < self._min_interval:
wait_time = self._min_interval - elapsed
time.sleep(wait_time)
self._last_request_time = time.monotonic()
class RetryError(Exception):
"""Raised when all retry attempts are exhausted."""
def __init__(self, message: str, last_exception: Exception | None = None) -> None:
super().__init__(message)
self.last_exception = last_exception
def with_retry(
max_retries: int = DEFAULT_MAX_RETRIES,
base_delay: float = DEFAULT_RETRY_BASE_DELAY,
retry_on: tuple[type[Exception], ...] = (Exception,),
) -> Callable[[Callable[P, T]], Callable[P, T]]:
"""Decorator for adding retry logic with exponential backoff.
Args:
max_retries: Maximum number of retry attempts.
base_delay: Base delay in seconds (doubles with each retry).
retry_on: Tuple of exception types to retry on.
Returns:
Decorated function with retry logic.
"""
def decorator(func: Callable[P, T]) -> Callable[P, T]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
last_exception: Exception | None = None
for attempt in range(max_retries + 1):
try:
return func(*args, **kwargs)
except retry_on as e:
last_exception = e
if attempt == max_retries:
break
delay = base_delay * (2**attempt)
logger.warning(
"Attempt %d/%d failed: %s. Retrying in %.1f seconds...",
attempt + 1,
max_retries + 1,
str(e),
delay,
)
time.sleep(delay)
raise RetryError(
f"All {max_retries + 1} attempts failed for {func.__name__}",
last_exception=last_exception,
)
return wrapper
return decorator
class ClobClientError(Exception):
"""Base exception for ClobClient errors."""
class ClobClient:
"""Wrapper around py-clob-client with rate limiting and retry logic.
This client provides a clean interface for querying Polymarket CLOB data
with built-in rate limiting (10 requests/second) and automatic retry
with exponential backoff on transient errors.
Example:
>>> client = ClobClient() # Uses POLYMARKET_API_KEY env var
>>> markets = client.get_markets()
>>> orderbook = client.get_orderbook("token_id_here")
"""
def __init__(
self,
api_key: str | None = None,
host: str = DEFAULT_HOST,
max_retries: int = DEFAULT_MAX_RETRIES,
requests_per_second: float = MAX_REQUESTS_PER_SECOND,
) -> None:
"""Initialize the CLOB client.
Args:
api_key: Polymarket API key. If not provided, reads from
POLYMARKET_API_KEY environment variable.
host: CLOB API endpoint URL.
max_retries: Maximum retry attempts for failed requests.
requests_per_second: Rate limit for API requests.
"""
self._api_key = api_key or os.environ.get("POLYMARKET_API_KEY")
self._host = host
self._max_retries = max_retries
self._rate_limiter = RateLimiter(requests_per_second)
# Initialize the underlying client (read-only, no auth needed for queries)
self._client = BaseClobClient(host)
logger.info(
"Initialized ClobClient with host=%s, rate_limit=%.1f req/s",
host,
requests_per_second,
)
def _with_rate_limit(self, func: Callable[P, T]) -> Callable[P, T]:
"""Wrap a function with rate limiting."""
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
self._rate_limiter.acquire_sync()
return func(*args, **kwargs)
return wrapper
@with_retry()
def get_markets(self, active_only: bool = True) -> list[Market]:
"""Fetch all markets from the CLOB.
Args:
active_only: If True, only return active (non-closed) markets.
Returns:
List of Market objects.
"""
self._rate_limiter.acquire_sync()
all_markets: list[Market] = []
cursor: str | None = None
while True:
if cursor:
response = self._client.get_simplified_markets(cursor)
else:
response = self._client.get_simplified_markets()
data = response.get("data", [])
for market_data in data:
market = Market.from_dict(market_data)
if active_only and market.closed:
continue
all_markets.append(market)
next_cursor = response.get("next_cursor")
if not next_cursor or next_cursor == "LTE=":
break
cursor = next_cursor
# Rate limit between pagination requests
self._rate_limiter.acquire_sync()
logger.debug("Fetched %d markets", len(all_markets))
return all_markets
@with_retry()
def get_market(self, condition_id: str) -> Market:
"""Fetch a specific market by its condition ID.
Args:
condition_id: The market's condition ID.
Returns:
Market object.
Raises:
ClobClientError: If the market is not found.
"""
self._rate_limiter.acquire_sync()
try:
response = self._client.get_market(condition_id)
return Market.from_dict(response)
except Exception as e:
raise ClobClientError(f"Failed to fetch market {condition_id}: {e}") from e
@with_retry()
def get_orderbook(self, token_id: str) -> Orderbook:
"""Fetch the orderbook for a specific token.
Args:
token_id: The token ID to fetch the orderbook for.
Returns:
Orderbook object with bids, asks, and spread information.
"""
self._rate_limiter.acquire_sync()
try:
orderbook = self._client.get_order_book(token_id)
return Orderbook.from_clob_orderbook(orderbook)
except Exception as e:
raise ClobClientError(f"Failed to fetch orderbook for {token_id}: {e}") from e
@with_retry()
def get_orderbooks(self, token_ids: list[str]) -> list[Orderbook]:
"""Fetch orderbooks for multiple tokens in a single request.
Args:
token_ids: List of token IDs to fetch orderbooks for.
Returns:
List of Orderbook objects.
"""
self._rate_limiter.acquire_sync()
params = [BookParams(token_id=tid) for tid in token_ids]
try:
orderbooks = self._client.get_order_books(params)
return [Orderbook.from_clob_orderbook(ob) for ob in orderbooks]
except Exception as e:
raise ClobClientError(f"Failed to fetch orderbooks: {e}") from e
@with_retry()
def get_midpoint(self, token_id: str) -> str | None:
"""Fetch the midpoint price for a token.
Args:
token_id: The token ID.
Returns:
Midpoint price as a string, or None if unavailable.
"""
self._rate_limiter.acquire_sync()
try:
response = self._client.get_midpoint(token_id)
return response.get("mid")
except Exception as e:
logger.warning("Failed to get midpoint for %s: %s", token_id, e)
return None
@with_retry()
def get_price(self, token_id: str, side: str = "BUY") -> str | None:
"""Fetch the best price for a token on a given side.
Args:
token_id: The token ID.
side: Either "BUY" or "SELL".
Returns:
Best price as a string, or None if unavailable.
"""
self._rate_limiter.acquire_sync()
try:
response = self._client.get_price(token_id, side=side)
return response.get("price")
except Exception as e:
logger.warning("Failed to get %s price for %s: %s", side, token_id, e)
return None
def health_check(self) -> bool:
"""Check if the CLOB API is reachable.
Returns:
True if the API responds with "OK", False otherwise.
"""
try:
self._rate_limiter.acquire_sync()
result = self._client.get_ok()
return result == "OK"
except Exception as e:
logger.error("Health check failed: %s", e)
return False
def get_server_time(self) -> int | None:
"""Get the server timestamp.
Returns:
Server timestamp in milliseconds, or None on error.
"""
try:
self._rate_limiter.acquire_sync()
return self._client.get_server_time()
except Exception as e:
logger.error("Failed to get server time: %s", e)
return None
@@ -0,0 +1,140 @@
"""Data models for the ingestor module."""
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal
from typing import Any
@dataclass(frozen=True)
class Token:
"""Represents a token in a Polymarket market."""
token_id: str
outcome: str
price: Decimal | None = None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Token":
"""Create a Token from a dictionary."""
price = data.get("price")
return cls(
token_id=str(data["token_id"]),
outcome=str(data["outcome"]),
price=Decimal(str(price)) if price is not None else None,
)
@dataclass(frozen=True)
class Market:
"""Represents a Polymarket prediction market."""
condition_id: str
question: str
description: str
tokens: tuple[Token, ...]
end_date: datetime | None = None
active: bool = True
closed: bool = False
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Market":
"""Create a Market from a dictionary response."""
tokens_data = data.get("tokens", [])
tokens = tuple(Token.from_dict(t) for t in tokens_data)
end_date = None
end_date_iso = data.get("end_date_iso")
if end_date_iso:
try:
end_date = datetime.fromisoformat(end_date_iso.replace("Z", "+00:00"))
except (ValueError, AttributeError):
pass
return cls(
condition_id=str(data["condition_id"]),
question=str(data.get("question", "")),
description=str(data.get("description", "")),
tokens=tokens,
end_date=end_date,
active=bool(data.get("active", True)),
closed=bool(data.get("closed", False)),
)
@dataclass(frozen=True)
class OrderbookLevel:
"""Represents a single price level in an orderbook."""
price: Decimal
size: Decimal
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "OrderbookLevel":
"""Create an OrderbookLevel from a dictionary."""
return cls(
price=Decimal(str(data["price"])),
size=Decimal(str(data["size"])),
)
@dataclass(frozen=True)
class Orderbook:
"""Represents an orderbook for a Polymarket token."""
market: str
asset_id: str
bids: tuple[OrderbookLevel, ...]
asks: tuple[OrderbookLevel, ...]
tick_size: Decimal
timestamp: datetime = field(default_factory=datetime.utcnow)
@classmethod
def from_clob_orderbook(cls, orderbook: Any) -> "Orderbook":
"""Create an Orderbook from a py-clob-client orderbook object."""
bids = tuple(
OrderbookLevel(
price=Decimal(str(bid.price)),
size=Decimal(str(bid.size)),
)
for bid in (orderbook.bids or [])
)
asks = tuple(
OrderbookLevel(
price=Decimal(str(ask.price)),
size=Decimal(str(ask.size)),
)
for ask in (orderbook.asks or [])
)
return cls(
market=str(orderbook.market),
asset_id=str(orderbook.asset_id),
bids=bids,
asks=asks,
tick_size=Decimal(str(orderbook.tick_size)),
)
@property
def best_bid(self) -> Decimal | None:
"""Return the best bid price, or None if no bids."""
return self.bids[0].price if self.bids else None
@property
def best_ask(self) -> Decimal | None:
"""Return the best ask price, or None if no asks."""
return self.asks[0].price if self.asks else None
@property
def spread(self) -> Decimal | None:
"""Return the bid-ask spread, or None if missing data."""
if self.best_bid is not None and self.best_ask is not None:
return self.best_ask - self.best_bid
return None
@property
def midpoint(self) -> Decimal | None:
"""Return the midpoint price, or None if missing data."""
if self.best_bid is not None and self.best_ask is not None:
return (self.best_bid + self.best_ask) / 2
return None