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
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
+356
View File
@@ -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")
+269
View File
@@ -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