From bb5fc086aecb99f18a12898604cb9112e3fc892f Mon Sep 17 00:00:00 2001 From: Patrick Selamy Date: Sun, 4 Jan 2026 14:40:43 -0500 Subject: [PATCH] feat: implement Polymarket CLOB client wrapper with rate limiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../ingestor/__init__.py | 22 ++ .../ingestor/clob_client.py | 340 +++++++++++++++++ .../ingestor/models.py | 140 +++++++ tests/ingestor/test_clob_client.py | 356 ++++++++++++++++++ tests/ingestor/test_models.py | 269 +++++++++++++ 5 files changed, 1127 insertions(+) create mode 100644 src/polymarket_insider_tracker/ingestor/clob_client.py create mode 100644 src/polymarket_insider_tracker/ingestor/models.py create mode 100644 tests/ingestor/test_clob_client.py create mode 100644 tests/ingestor/test_models.py diff --git a/src/polymarket_insider_tracker/ingestor/__init__.py b/src/polymarket_insider_tracker/ingestor/__init__.py index 67a7f7a..1b96ed9 100644 --- a/src/polymarket_insider_tracker/ingestor/__init__.py +++ b/src/polymarket_insider_tracker/ingestor/__init__.py @@ -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", +] diff --git a/src/polymarket_insider_tracker/ingestor/clob_client.py b/src/polymarket_insider_tracker/ingestor/clob_client.py new file mode 100644 index 0000000..4196658 --- /dev/null +++ b/src/polymarket_insider_tracker/ingestor/clob_client.py @@ -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 diff --git a/src/polymarket_insider_tracker/ingestor/models.py b/src/polymarket_insider_tracker/ingestor/models.py new file mode 100644 index 0000000..89826eb --- /dev/null +++ b/src/polymarket_insider_tracker/ingestor/models.py @@ -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 diff --git a/tests/ingestor/test_clob_client.py b/tests/ingestor/test_clob_client.py new file mode 100644 index 0000000..8e60334 --- /dev/null +++ b/tests/ingestor/test_clob_client.py @@ -0,0 +1,356 @@ +"""Tests for ClobClient wrapper.""" + +import time +from unittest.mock import MagicMock, patch + +import pytest + +from polymarket_insider_tracker.ingestor.clob_client import ( + ClobClient, + ClobClientError, + RateLimiter, + RetryError, + with_retry, +) +from polymarket_insider_tracker.ingestor.models import Market, Orderbook + + +class TestRateLimiter: + """Tests for RateLimiter.""" + + def test_acquire_sync_no_wait_first_call(self) -> None: + """First call should not wait.""" + limiter = RateLimiter(max_requests_per_second=10) + start = time.monotonic() + limiter.acquire_sync() + elapsed = time.monotonic() - start + + # Should be nearly instant + assert elapsed < 0.05 + + def test_acquire_sync_enforces_rate(self) -> None: + """Subsequent calls should be rate limited.""" + limiter = RateLimiter(max_requests_per_second=10) # 100ms between calls + + # First call + limiter.acquire_sync() + + # Second call should wait + start = time.monotonic() + limiter.acquire_sync() + elapsed = time.monotonic() - start + + # Should wait at least 90ms (allowing some tolerance) + assert elapsed >= 0.08 + + +class TestWithRetry: + """Tests for retry decorator.""" + + def test_success_first_try(self) -> None: + """Function succeeds on first try.""" + call_count = 0 + + @with_retry(max_retries=3) + def succeed() -> str: + nonlocal call_count + call_count += 1 + return "success" + + result = succeed() + + assert result == "success" + assert call_count == 1 + + def test_success_after_retries(self) -> None: + """Function succeeds after some retries.""" + call_count = 0 + + @with_retry(max_retries=3, base_delay=0.01) + def succeed_eventually() -> str: + nonlocal call_count + call_count += 1 + if call_count < 3: + raise ValueError("Not yet") + return "success" + + result = succeed_eventually() + + assert result == "success" + assert call_count == 3 + + def test_exhausted_retries(self) -> None: + """Raises RetryError after exhausting retries.""" + + @with_retry(max_retries=2, base_delay=0.01) + def always_fails() -> str: + raise ValueError("Always fails") + + with pytest.raises(RetryError) as exc_info: + always_fails() + + assert "3 attempts failed" in str(exc_info.value) + assert isinstance(exc_info.value.last_exception, ValueError) + + def test_specific_exception_types(self) -> None: + """Only retries on specified exception types.""" + call_count = 0 + + @with_retry(max_retries=3, base_delay=0.01, retry_on=(ValueError,)) + def raise_type_error() -> str: + nonlocal call_count + call_count += 1 + raise TypeError("Not retried") + + with pytest.raises(TypeError): + raise_type_error() + + # Should only be called once since TypeError is not in retry_on + assert call_count == 1 + + +class TestClobClient: + """Tests for ClobClient wrapper.""" + + @pytest.fixture + def mock_base_client(self) -> MagicMock: + """Create a mock base CLOB client.""" + with patch( + "polymarket_insider_tracker.ingestor.clob_client.BaseClobClient" + ) as mock: + yield mock.return_value + + def test_init_defaults(self, mock_base_client: MagicMock) -> None: + """Test client initialization with defaults.""" + client = ClobClient() + + assert client._host == "https://clob.polymarket.com" + assert client._max_retries == 3 + + def test_init_with_env_api_key(self, mock_base_client: MagicMock) -> None: + """Test client reads API key from environment.""" + with patch.dict("os.environ", {"POLYMARKET_API_KEY": "test-key"}): + client = ClobClient() + assert client._api_key == "test-key" + + def test_init_with_explicit_api_key(self, mock_base_client: MagicMock) -> None: + """Test client uses explicitly provided API key.""" + client = ClobClient(api_key="explicit-key") + assert client._api_key == "explicit-key" + + def test_health_check_success(self, mock_base_client: MagicMock) -> None: + """Test health check returns True when API responds OK.""" + mock_base_client.get_ok.return_value = "OK" + + client = ClobClient() + result = client.health_check() + + assert result is True + mock_base_client.get_ok.assert_called_once() + + def test_health_check_failure(self, mock_base_client: MagicMock) -> None: + """Test health check returns False on error.""" + mock_base_client.get_ok.side_effect = Exception("Connection failed") + + client = ClobClient() + result = client.health_check() + + assert result is False + + def test_get_server_time(self, mock_base_client: MagicMock) -> None: + """Test getting server time.""" + mock_base_client.get_server_time.return_value = 1704067200000 + + client = ClobClient() + result = client.get_server_time() + + assert result == 1704067200000 + + def test_get_markets(self, mock_base_client: MagicMock) -> None: + """Test fetching markets.""" + mock_base_client.get_simplified_markets.return_value = { + "data": [ + { + "condition_id": "0x123", + "question": "Test market?", + "tokens": [], + "closed": False, + }, + ], + "next_cursor": "LTE=", + } + + client = ClobClient() + markets = client.get_markets() + + assert len(markets) == 1 + assert isinstance(markets[0], Market) + assert markets[0].condition_id == "0x123" + + def test_get_markets_filters_closed(self, mock_base_client: MagicMock) -> None: + """Test that closed markets are filtered when active_only=True.""" + mock_base_client.get_simplified_markets.return_value = { + "data": [ + {"condition_id": "0x1", "closed": False}, + {"condition_id": "0x2", "closed": True}, + ], + "next_cursor": "LTE=", + } + + client = ClobClient() + markets = client.get_markets(active_only=True) + + assert len(markets) == 1 + assert markets[0].condition_id == "0x1" + + def test_get_markets_includes_closed(self, mock_base_client: MagicMock) -> None: + """Test that closed markets are included when active_only=False.""" + mock_base_client.get_simplified_markets.return_value = { + "data": [ + {"condition_id": "0x1", "closed": False}, + {"condition_id": "0x2", "closed": True}, + ], + "next_cursor": "LTE=", + } + + client = ClobClient() + markets = client.get_markets(active_only=False) + + assert len(markets) == 2 + + def test_get_markets_pagination(self, mock_base_client: MagicMock) -> None: + """Test that pagination is handled correctly.""" + mock_base_client.get_simplified_markets.side_effect = [ + { + "data": [{"condition_id": "0x1"}], + "next_cursor": "cursor2", + }, + { + "data": [{"condition_id": "0x2"}], + "next_cursor": "LTE=", + }, + ] + + client = ClobClient() + markets = client.get_markets() + + assert len(markets) == 2 + assert mock_base_client.get_simplified_markets.call_count == 2 + + def test_get_market(self, mock_base_client: MagicMock) -> None: + """Test fetching a single market.""" + mock_base_client.get_market.return_value = { + "condition_id": "0xabc", + "question": "Will it happen?", + "tokens": [ + {"token_id": "t1", "outcome": "Yes"}, + {"token_id": "t2", "outcome": "No"}, + ], + } + + client = ClobClient() + market = client.get_market("0xabc") + + assert isinstance(market, Market) + assert market.condition_id == "0xabc" + assert len(market.tokens) == 2 + + def test_get_market_not_found(self, mock_base_client: MagicMock) -> None: + """Test error handling when market not found.""" + mock_base_client.get_market.side_effect = Exception("Not found") + + client = ClobClient() + + with pytest.raises(ClobClientError) as exc_info: + client.get_market("0xnotfound") + + assert "0xnotfound" in str(exc_info.value) + + def test_get_orderbook(self, mock_base_client: MagicMock) -> None: + """Test fetching an orderbook.""" + mock_bid = MagicMock() + mock_bid.price = "0.50" + mock_bid.size = "100" + + mock_ask = MagicMock() + mock_ask.price = "0.52" + mock_ask.size = "150" + + mock_orderbook = MagicMock() + mock_orderbook.market = "0xmarket" + mock_orderbook.asset_id = "token123" + mock_orderbook.tick_size = "0.01" + mock_orderbook.bids = [mock_bid] + mock_orderbook.asks = [mock_ask] + + mock_base_client.get_order_book.return_value = mock_orderbook + + client = ClobClient() + orderbook = client.get_orderbook("token123") + + assert isinstance(orderbook, Orderbook) + assert orderbook.asset_id == "token123" + assert len(orderbook.bids) == 1 + assert len(orderbook.asks) == 1 + + def test_get_orderbooks(self, mock_base_client: MagicMock) -> None: + """Test fetching multiple orderbooks.""" + mock_ob1 = MagicMock() + mock_ob1.market = "m1" + mock_ob1.asset_id = "t1" + mock_ob1.tick_size = "0.01" + mock_ob1.bids = [] + mock_ob1.asks = [] + + mock_ob2 = MagicMock() + mock_ob2.market = "m2" + mock_ob2.asset_id = "t2" + mock_ob2.tick_size = "0.01" + mock_ob2.bids = [] + mock_ob2.asks = [] + + mock_base_client.get_order_books.return_value = [mock_ob1, mock_ob2] + + client = ClobClient() + orderbooks = client.get_orderbooks(["t1", "t2"]) + + assert len(orderbooks) == 2 + assert all(isinstance(ob, Orderbook) for ob in orderbooks) + + def test_get_midpoint(self, mock_base_client: MagicMock) -> None: + """Test fetching midpoint price.""" + mock_base_client.get_midpoint.return_value = {"mid": "0.55"} + + client = ClobClient() + result = client.get_midpoint("token123") + + assert result == "0.55" + + def test_get_midpoint_error(self, mock_base_client: MagicMock) -> None: + """Test midpoint returns None on error.""" + mock_base_client.get_midpoint.side_effect = Exception("API error") + + client = ClobClient() + result = client.get_midpoint("token123") + + assert result is None + + def test_get_price_buy(self, mock_base_client: MagicMock) -> None: + """Test fetching buy price.""" + mock_base_client.get_price.return_value = {"price": "0.53"} + + client = ClobClient() + result = client.get_price("token123", side="BUY") + + assert result == "0.53" + mock_base_client.get_price.assert_called_with("token123", side="BUY") + + def test_get_price_sell(self, mock_base_client: MagicMock) -> None: + """Test fetching sell price.""" + mock_base_client.get_price.return_value = {"price": "0.51"} + + client = ClobClient() + result = client.get_price("token123", side="SELL") + + assert result == "0.51" + mock_base_client.get_price.assert_called_with("token123", side="SELL") diff --git a/tests/ingestor/test_models.py b/tests/ingestor/test_models.py new file mode 100644 index 0000000..0219458 --- /dev/null +++ b/tests/ingestor/test_models.py @@ -0,0 +1,269 @@ +"""Tests for ingestor data models.""" + +from datetime import datetime, timezone +from decimal import Decimal +from unittest.mock import MagicMock + +import pytest + +from polymarket_insider_tracker.ingestor.models import ( + Market, + Orderbook, + OrderbookLevel, + Token, +) + + +class TestToken: + """Tests for Token model.""" + + def test_from_dict_with_price(self) -> None: + """Test creating Token from dict with price.""" + data = { + "token_id": "123abc", + "outcome": "Yes", + "price": "0.65", + } + token = Token.from_dict(data) + + assert token.token_id == "123abc" + assert token.outcome == "Yes" + assert token.price == Decimal("0.65") + + def test_from_dict_without_price(self) -> None: + """Test creating Token from dict without price.""" + data = { + "token_id": "456def", + "outcome": "No", + } + token = Token.from_dict(data) + + assert token.token_id == "456def" + assert token.outcome == "No" + assert token.price is None + + def test_frozen(self) -> None: + """Test that Token is immutable.""" + token = Token(token_id="123", outcome="Yes", price=Decimal("0.5")) + with pytest.raises(AttributeError): + token.token_id = "456" # type: ignore[misc] + + +class TestMarket: + """Tests for Market model.""" + + def test_from_dict_full(self) -> None: + """Test creating Market from complete dict.""" + data = { + "condition_id": "0xabc123", + "question": "Will it rain tomorrow?", + "description": "Market for weather prediction", + "tokens": [ + {"token_id": "t1", "outcome": "Yes", "price": "0.7"}, + {"token_id": "t2", "outcome": "No", "price": "0.3"}, + ], + "end_date_iso": "2024-12-31T23:59:59Z", + "active": True, + "closed": False, + } + market = Market.from_dict(data) + + assert market.condition_id == "0xabc123" + assert market.question == "Will it rain tomorrow?" + assert market.description == "Market for weather prediction" + assert len(market.tokens) == 2 + assert market.tokens[0].outcome == "Yes" + assert market.tokens[1].outcome == "No" + assert market.end_date == datetime(2024, 12, 31, 23, 59, 59, tzinfo=timezone.utc) + assert market.active is True + assert market.closed is False + + def test_from_dict_minimal(self) -> None: + """Test creating Market from minimal dict.""" + data = { + "condition_id": "0xdef456", + } + market = Market.from_dict(data) + + assert market.condition_id == "0xdef456" + assert market.question == "" + assert market.description == "" + assert market.tokens == () + assert market.end_date is None + assert market.active is True + assert market.closed is False + + def test_from_dict_invalid_date(self) -> None: + """Test that invalid date is handled gracefully.""" + data = { + "condition_id": "0x123", + "end_date_iso": "not-a-valid-date", + } + market = Market.from_dict(data) + + assert market.end_date is None + + def test_frozen(self) -> None: + """Test that Market is immutable.""" + market = Market( + condition_id="0x123", + question="Test?", + description="", + tokens=(), + ) + with pytest.raises(AttributeError): + market.condition_id = "0x456" # type: ignore[misc] + + +class TestOrderbookLevel: + """Tests for OrderbookLevel model.""" + + def test_from_dict(self) -> None: + """Test creating OrderbookLevel from dict.""" + data = {"price": "0.55", "size": "100.5"} + level = OrderbookLevel.from_dict(data) + + assert level.price == Decimal("0.55") + assert level.size == Decimal("100.5") + + def test_frozen(self) -> None: + """Test that OrderbookLevel is immutable.""" + level = OrderbookLevel(price=Decimal("0.5"), size=Decimal("10")) + with pytest.raises(AttributeError): + level.price = Decimal("0.6") # type: ignore[misc] + + +class TestOrderbook: + """Tests for Orderbook model.""" + + def test_from_clob_orderbook(self) -> None: + """Test creating Orderbook from py-clob-client response.""" + # Create mock bid/ask objects + mock_bid = MagicMock() + mock_bid.price = "0.50" + mock_bid.size = "100" + + mock_ask = MagicMock() + mock_ask.price = "0.52" + mock_ask.size = "150" + + mock_orderbook = MagicMock() + mock_orderbook.market = "0xmarket123" + mock_orderbook.asset_id = "token123" + mock_orderbook.tick_size = "0.01" + mock_orderbook.bids = [mock_bid] + mock_orderbook.asks = [mock_ask] + + orderbook = Orderbook.from_clob_orderbook(mock_orderbook) + + assert orderbook.market == "0xmarket123" + assert orderbook.asset_id == "token123" + assert orderbook.tick_size == Decimal("0.01") + assert len(orderbook.bids) == 1 + assert len(orderbook.asks) == 1 + assert orderbook.bids[0].price == Decimal("0.50") + assert orderbook.asks[0].price == Decimal("0.52") + + def test_from_clob_orderbook_empty(self) -> None: + """Test creating Orderbook with empty bids/asks.""" + mock_orderbook = MagicMock() + mock_orderbook.market = "0xmarket" + mock_orderbook.asset_id = "token" + mock_orderbook.tick_size = "0.01" + mock_orderbook.bids = None + mock_orderbook.asks = [] + + orderbook = Orderbook.from_clob_orderbook(mock_orderbook) + + assert orderbook.bids == () + assert orderbook.asks == () + + def test_best_bid(self) -> None: + """Test best_bid property.""" + orderbook = Orderbook( + market="0x", + asset_id="t", + bids=( + OrderbookLevel(Decimal("0.50"), Decimal("100")), + OrderbookLevel(Decimal("0.49"), Decimal("50")), + ), + asks=(), + tick_size=Decimal("0.01"), + ) + + assert orderbook.best_bid == Decimal("0.50") + + def test_best_bid_empty(self) -> None: + """Test best_bid with no bids.""" + orderbook = Orderbook( + market="0x", + asset_id="t", + bids=(), + asks=(), + tick_size=Decimal("0.01"), + ) + + assert orderbook.best_bid is None + + def test_best_ask(self) -> None: + """Test best_ask property.""" + orderbook = Orderbook( + market="0x", + asset_id="t", + bids=(), + asks=( + OrderbookLevel(Decimal("0.52"), Decimal("100")), + OrderbookLevel(Decimal("0.53"), Decimal("50")), + ), + tick_size=Decimal("0.01"), + ) + + assert orderbook.best_ask == Decimal("0.52") + + def test_spread(self) -> None: + """Test spread calculation.""" + orderbook = Orderbook( + market="0x", + asset_id="t", + bids=(OrderbookLevel(Decimal("0.50"), Decimal("100")),), + asks=(OrderbookLevel(Decimal("0.52"), Decimal("100")),), + tick_size=Decimal("0.01"), + ) + + assert orderbook.spread == Decimal("0.02") + + def test_spread_missing_data(self) -> None: + """Test spread with missing bid or ask.""" + orderbook = Orderbook( + market="0x", + asset_id="t", + bids=(OrderbookLevel(Decimal("0.50"), Decimal("100")),), + asks=(), + tick_size=Decimal("0.01"), + ) + + assert orderbook.spread is None + + def test_midpoint(self) -> None: + """Test midpoint calculation.""" + orderbook = Orderbook( + market="0x", + asset_id="t", + bids=(OrderbookLevel(Decimal("0.50"), Decimal("100")),), + asks=(OrderbookLevel(Decimal("0.52"), Decimal("100")),), + tick_size=Decimal("0.01"), + ) + + assert orderbook.midpoint == Decimal("0.51") + + def test_midpoint_missing_data(self) -> None: + """Test midpoint with missing data.""" + orderbook = Orderbook( + market="0x", + asset_id="t", + bids=(), + asks=(OrderbookLevel(Decimal("0.52"), Decimal("100")),), + tick_size=Decimal("0.01"), + ) + + assert orderbook.midpoint is None