feat: implement WebSocket trade stream handler with reconnection
- Add TradeEvent dataclass for trade data from WebSocket feed - Implement TradeStreamHandler with async WebSocket streaming - Add automatic reconnection with exponential backoff (1s-30s) - Support event/market filtering for targeted subscriptions - Include connection state management and statistics tracking - Add websockets>=12.0 dependency Acceptance Criteria: - [x] TradeStreamHandler class using websockets library - [x] Connects to Polymarket WSS endpoint - [x] Subscribes to market trade channel on connection - [x] Parses trade messages into TradeEvent dataclass - [x] Implements heartbeat/ping-pong for connection health - [x] Auto-reconnects on disconnect with exponential backoff - [x] Emits events via callback pattern - [x] Logs connection state changes Closes #3 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
1299cfe0b7
commit
85010a3ea5
@@ -10,14 +10,29 @@ from polymarket_insider_tracker.ingestor.models import (
|
||||
Orderbook,
|
||||
OrderbookLevel,
|
||||
Token,
|
||||
TradeEvent,
|
||||
)
|
||||
from polymarket_insider_tracker.ingestor.websocket import (
|
||||
ConnectionState,
|
||||
StreamStats,
|
||||
TradeStreamError,
|
||||
TradeStreamHandler,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# CLOB Client
|
||||
"ClobClient",
|
||||
"ClobClientError",
|
||||
"RetryError",
|
||||
# Models
|
||||
"Market",
|
||||
"Orderbook",
|
||||
"OrderbookLevel",
|
||||
"Token",
|
||||
"TradeEvent",
|
||||
# WebSocket
|
||||
"ConnectionState",
|
||||
"StreamStats",
|
||||
"TradeStreamError",
|
||||
"TradeStreamHandler",
|
||||
]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Data models for the ingestor module."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -138,3 +138,91 @@ class Orderbook:
|
||||
if self.best_bid is not None and self.best_ask is not None:
|
||||
return (self.best_bid + self.best_ask) / 2
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TradeEvent:
|
||||
"""Represents a trade event from the Polymarket WebSocket feed.
|
||||
|
||||
This captures all the information about a single trade execution,
|
||||
including the market, wallet, trade details, and metadata.
|
||||
"""
|
||||
|
||||
# Core trade identifiers
|
||||
market_id: str # conditionId - the market/CTF condition ID
|
||||
trade_id: str # transactionHash - unique trade identifier
|
||||
wallet_address: str # proxyWallet - trader's wallet address
|
||||
|
||||
# Trade details
|
||||
side: Literal["BUY", "SELL"]
|
||||
outcome: str # Human-readable outcome (e.g., "Yes", "No")
|
||||
outcome_index: int # Index of the outcome (0 or 1)
|
||||
price: Decimal
|
||||
size: Decimal # Number of shares traded
|
||||
timestamp: datetime
|
||||
|
||||
# Asset information
|
||||
asset_id: str # ERC1155 token ID
|
||||
|
||||
# Market metadata
|
||||
market_slug: str = ""
|
||||
event_slug: str = ""
|
||||
event_title: str = ""
|
||||
|
||||
# Trader metadata (optional - may not be available for all trades)
|
||||
trader_name: str = ""
|
||||
trader_pseudonym: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_websocket_message(cls, data: dict[str, Any]) -> "TradeEvent":
|
||||
"""Create a TradeEvent from a WebSocket activity/trade message.
|
||||
|
||||
Args:
|
||||
data: The payload from a WebSocket trade message.
|
||||
|
||||
Returns:
|
||||
TradeEvent instance.
|
||||
"""
|
||||
# Parse timestamp - it's a Unix timestamp in seconds
|
||||
raw_timestamp = data.get("timestamp", 0)
|
||||
if isinstance(raw_timestamp, int):
|
||||
timestamp = datetime.fromtimestamp(raw_timestamp, tz=timezone.utc)
|
||||
else:
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
|
||||
# Parse side - normalize to uppercase
|
||||
side_raw = str(data.get("side", "BUY")).upper()
|
||||
side: Literal["BUY", "SELL"] = "BUY" if side_raw == "BUY" else "SELL"
|
||||
|
||||
return cls(
|
||||
market_id=str(data.get("conditionId", "")),
|
||||
trade_id=str(data.get("transactionHash", "")),
|
||||
wallet_address=str(data.get("proxyWallet", "")),
|
||||
side=side,
|
||||
outcome=str(data.get("outcome", "")),
|
||||
outcome_index=int(data.get("outcomeIndex", 0)),
|
||||
price=Decimal(str(data.get("price", 0))),
|
||||
size=Decimal(str(data.get("size", 0))),
|
||||
timestamp=timestamp,
|
||||
asset_id=str(data.get("asset", "")),
|
||||
market_slug=str(data.get("slug", "")),
|
||||
event_slug=str(data.get("eventSlug", "")),
|
||||
event_title=str(data.get("title", "")),
|
||||
trader_name=str(data.get("name", "")),
|
||||
trader_pseudonym=str(data.get("pseudonym", "")),
|
||||
)
|
||||
|
||||
@property
|
||||
def is_buy(self) -> bool:
|
||||
"""Return True if this is a buy trade."""
|
||||
return self.side == "BUY"
|
||||
|
||||
@property
|
||||
def is_sell(self) -> bool:
|
||||
"""Return True if this is a sell trade."""
|
||||
return self.side == "SELL"
|
||||
|
||||
@property
|
||||
def notional_value(self) -> Decimal:
|
||||
"""Return the notional value of the trade (price * size)."""
|
||||
return self.price * self.size
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
"""WebSocket client for streaming Polymarket trade events."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import websockets
|
||||
from websockets.asyncio.client import ClientConnection
|
||||
|
||||
from polymarket_insider_tracker.ingestor.models import TradeEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Constants
|
||||
DEFAULT_WS_HOST = "wss://ws-live-data.polymarket.com"
|
||||
DEFAULT_PING_INTERVAL = 30 # seconds
|
||||
DEFAULT_MAX_RECONNECT_DELAY = 30 # seconds
|
||||
DEFAULT_INITIAL_RECONNECT_DELAY = 1 # seconds
|
||||
|
||||
|
||||
class ConnectionState(Enum):
|
||||
"""WebSocket connection states."""
|
||||
|
||||
DISCONNECTED = "disconnected"
|
||||
CONNECTING = "connecting"
|
||||
CONNECTED = "connected"
|
||||
RECONNECTING = "reconnecting"
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamStats:
|
||||
"""Statistics about the trade stream."""
|
||||
|
||||
trades_received: int = 0
|
||||
reconnect_count: int = 0
|
||||
last_trade_time: float | None = None
|
||||
connected_since: float | None = None
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
class TradeStreamError(Exception):
|
||||
"""Base exception for trade stream errors."""
|
||||
|
||||
|
||||
class ConnectionError(TradeStreamError):
|
||||
"""Raised when connection to WebSocket fails."""
|
||||
|
||||
|
||||
TradeCallback = Callable[[TradeEvent], Awaitable[None]]
|
||||
StateCallback = Callable[[ConnectionState], Awaitable[None]]
|
||||
|
||||
|
||||
class TradeStreamHandler:
|
||||
"""WebSocket client for streaming Polymarket trade events.
|
||||
|
||||
This handler maintains a persistent connection to Polymarket's real-time
|
||||
trade feed, automatically reconnecting on disconnection with exponential
|
||||
backoff.
|
||||
|
||||
Example:
|
||||
>>> async def on_trade(trade: TradeEvent):
|
||||
... print(f"Trade: {trade.side} {trade.size} @ {trade.price}")
|
||||
...
|
||||
>>> handler = TradeStreamHandler(on_trade=on_trade)
|
||||
>>> await handler.start() # Blocks until stop() is called
|
||||
|
||||
Attributes:
|
||||
state: Current connection state.
|
||||
stats: Statistics about the stream (trades received, reconnects, etc.).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_trade: TradeCallback,
|
||||
*,
|
||||
host: str = DEFAULT_WS_HOST,
|
||||
on_state_change: StateCallback | None = None,
|
||||
ping_interval: int = DEFAULT_PING_INTERVAL,
|
||||
max_reconnect_delay: int = DEFAULT_MAX_RECONNECT_DELAY,
|
||||
initial_reconnect_delay: int = DEFAULT_INITIAL_RECONNECT_DELAY,
|
||||
event_filter: str | None = None,
|
||||
market_filter: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the trade stream handler.
|
||||
|
||||
Args:
|
||||
on_trade: Async callback invoked for each trade event.
|
||||
host: WebSocket endpoint URL.
|
||||
on_state_change: Optional callback for connection state changes.
|
||||
ping_interval: Seconds between heartbeat pings.
|
||||
max_reconnect_delay: Maximum delay between reconnection attempts.
|
||||
initial_reconnect_delay: Initial delay for reconnection backoff.
|
||||
event_filter: Optional event slug to filter trades by event.
|
||||
market_filter: Optional market slug to filter trades by market.
|
||||
"""
|
||||
self._on_trade = on_trade
|
||||
self._on_state_change = on_state_change
|
||||
self._host = host
|
||||
self._ping_interval = ping_interval
|
||||
self._max_reconnect_delay = max_reconnect_delay
|
||||
self._initial_reconnect_delay = initial_reconnect_delay
|
||||
self._event_filter = event_filter
|
||||
self._market_filter = market_filter
|
||||
|
||||
self._state = ConnectionState.DISCONNECTED
|
||||
self._stats = StreamStats()
|
||||
self._ws: ClientConnection | None = None
|
||||
self._running = False
|
||||
self._stop_event: asyncio.Event | None = None
|
||||
|
||||
@property
|
||||
def state(self) -> ConnectionState:
|
||||
"""Current connection state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def stats(self) -> StreamStats:
|
||||
"""Stream statistics."""
|
||||
return self._stats
|
||||
|
||||
async def _set_state(self, new_state: ConnectionState) -> None:
|
||||
"""Update state and notify callback."""
|
||||
if self._state != new_state:
|
||||
old_state = self._state
|
||||
self._state = new_state
|
||||
logger.info("Connection state: %s -> %s", old_state.value, new_state.value)
|
||||
|
||||
if self._on_state_change:
|
||||
try:
|
||||
await self._on_state_change(new_state)
|
||||
except Exception as e:
|
||||
logger.error("Error in state change callback: %s", e)
|
||||
|
||||
def _build_subscription_message(self) -> dict[str, Any]:
|
||||
"""Build the WebSocket subscription message."""
|
||||
subscription: dict[str, Any] = {
|
||||
"topic": "activity",
|
||||
"type": "trades",
|
||||
}
|
||||
|
||||
# Add filters if specified
|
||||
if self._event_filter:
|
||||
subscription["filters"] = json.dumps({"event_slug": self._event_filter})
|
||||
elif self._market_filter:
|
||||
subscription["filters"] = json.dumps({"market_slug": self._market_filter})
|
||||
|
||||
return {"subscriptions": [subscription]}
|
||||
|
||||
async def _connect(self) -> ClientConnection:
|
||||
"""Establish WebSocket connection."""
|
||||
await self._set_state(ConnectionState.CONNECTING)
|
||||
|
||||
try:
|
||||
ws = await websockets.connect(
|
||||
self._host,
|
||||
ping_interval=self._ping_interval,
|
||||
ping_timeout=self._ping_interval * 2,
|
||||
)
|
||||
|
||||
# Send subscription message
|
||||
subscribe_msg = self._build_subscription_message()
|
||||
await ws.send(json.dumps(subscribe_msg))
|
||||
|
||||
logger.info("Connected to %s and subscribed to trades", self._host)
|
||||
await self._set_state(ConnectionState.CONNECTED)
|
||||
self._stats.connected_since = time.time()
|
||||
|
||||
return ws
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to connect: %s", e)
|
||||
self._stats.last_error = str(e)
|
||||
raise ConnectionError(f"Failed to connect to {self._host}: {e}") from e
|
||||
|
||||
async def _handle_message(self, message: str) -> None:
|
||||
"""Parse and process an incoming WebSocket message."""
|
||||
try:
|
||||
data = json.loads(message)
|
||||
|
||||
# Check if this is a trade message
|
||||
topic = data.get("topic")
|
||||
msg_type = data.get("type")
|
||||
|
||||
if topic == "activity" and msg_type == "trades":
|
||||
payload = data.get("payload", {})
|
||||
trade = TradeEvent.from_websocket_message(payload)
|
||||
|
||||
self._stats.trades_received += 1
|
||||
self._stats.last_trade_time = time.time()
|
||||
|
||||
logger.debug(
|
||||
"Trade: %s %s @ %s on %s",
|
||||
trade.side,
|
||||
trade.size,
|
||||
trade.price,
|
||||
trade.market_slug,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._on_trade(trade)
|
||||
except Exception as e:
|
||||
logger.error("Error in trade callback: %s", e)
|
||||
|
||||
else:
|
||||
# Log other message types for debugging
|
||||
logger.debug("Received message: topic=%s type=%s", topic, msg_type)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("Invalid JSON message: %s", e)
|
||||
except Exception as e:
|
||||
logger.error("Error processing message: %s", e)
|
||||
|
||||
async def _listen(self, ws: ClientConnection) -> None:
|
||||
"""Listen for messages on the WebSocket."""
|
||||
try:
|
||||
async for message in ws:
|
||||
if not self._running:
|
||||
break
|
||||
|
||||
if isinstance(message, str):
|
||||
await self._handle_message(message)
|
||||
else:
|
||||
logger.debug("Received binary message (%d bytes)", len(message))
|
||||
|
||||
except websockets.ConnectionClosed as e:
|
||||
logger.warning("Connection closed: %s", e)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error in message loop: %s", e)
|
||||
raise
|
||||
|
||||
async def _reconnect_loop(self) -> None:
|
||||
"""Handle reconnection with exponential backoff."""
|
||||
delay = self._initial_reconnect_delay
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
await self._set_state(ConnectionState.RECONNECTING)
|
||||
|
||||
logger.info("Reconnecting in %.1f seconds...", delay)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
if not self._running:
|
||||
break
|
||||
|
||||
self._ws = await self._connect()
|
||||
self._stats.reconnect_count += 1
|
||||
delay = self._initial_reconnect_delay # Reset delay on success
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Reconnection failed: %s", e)
|
||||
self._stats.last_error = str(e)
|
||||
|
||||
# Exponential backoff with jitter
|
||||
delay = min(delay * 2, self._max_reconnect_delay)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Connect and begin streaming trades.
|
||||
|
||||
This method blocks until stop() is called. It automatically
|
||||
handles reconnection on disconnection.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If initial connection fails.
|
||||
"""
|
||||
if self._running:
|
||||
logger.warning("Handler already running")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
try:
|
||||
# Initial connection
|
||||
self._ws = await self._connect()
|
||||
|
||||
# Main loop
|
||||
while self._running:
|
||||
try:
|
||||
await self._listen(self._ws)
|
||||
except (websockets.ConnectionClosed, Exception) as e:
|
||||
if not self._running:
|
||||
break
|
||||
|
||||
logger.warning("Connection lost: %s", e)
|
||||
await self._set_state(ConnectionState.DISCONNECTED)
|
||||
|
||||
# Attempt reconnection
|
||||
await self._reconnect_loop()
|
||||
|
||||
if not self._running or self._ws is None:
|
||||
break
|
||||
|
||||
finally:
|
||||
await self._cleanup()
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Gracefully disconnect from the WebSocket.
|
||||
|
||||
This signals the handler to stop and cleanly close the connection.
|
||||
"""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
logger.info("Stopping trade stream handler...")
|
||||
self._running = False
|
||||
|
||||
if self._stop_event:
|
||||
self._stop_event.set()
|
||||
|
||||
await self._cleanup()
|
||||
|
||||
async def _cleanup(self) -> None:
|
||||
"""Clean up resources."""
|
||||
if self._ws:
|
||||
try:
|
||||
await self._ws.close()
|
||||
except Exception as e:
|
||||
logger.debug("Error closing WebSocket: %s", e)
|
||||
finally:
|
||||
self._ws = None
|
||||
|
||||
await self._set_state(ConnectionState.DISCONNECTED)
|
||||
logger.info("Trade stream handler stopped")
|
||||
|
||||
async def __aenter__(self) -> "TradeStreamHandler":
|
||||
"""Async context manager entry."""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: Any) -> None:
|
||||
"""Async context manager exit."""
|
||||
await self.stop()
|
||||
Reference in New Issue
Block a user