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:
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user