Replace HTTP polling with RTDS WebSocket for real-time trade monitoring

- Add rtds_client.py: persistent WebSocket connection to wss://ws-live-data.polymarket.com
  with auto-reconnect, heartbeat, and trade message parsing
- Rewrite trade_monitor.py: single WebSocket receives ALL trades in real-time,
  replacing per-market HTTP polling loops (zero missed trades, sub-second latency)
- Remove QPS rate limiter and per-market polling infrastructure (no longer needed)
- Update default LLM model to gemini-3.1-pro-preview
- Add websockets and python-socks dependencies

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
SII-leiyu
2026-05-06 12:06:45 +08:00
co-authored by Claude Opus 4.6
parent 265c8fb5a1
commit af5bbd0bce
6 changed files with 503 additions and 517 deletions
+2
View File
@@ -11,6 +11,8 @@ requires-python = ">=3.10"
license = {text = "MIT"} license = {text = "MIT"}
dependencies = [ dependencies = [
"httpx>=0.27.0", "httpx>=0.27.0",
"websockets>=13.0",
"python-socks[asyncio]>=2.0.0",
"pydantic>=2.0.0", "pydantic>=2.0.0",
"pydantic-settings>=2.0.0", "pydantic-settings>=2.0.0",
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
+2 -2
View File
@@ -13,7 +13,7 @@ class Settings(BaseSettings):
# LLM API (OpenAI-compatible proxy) # LLM API (OpenAI-compatible proxy)
gemini_api_key: str = Field(default="", alias="GEMINI_API_KEY") gemini_api_key: str = Field(default="", alias="GEMINI_API_KEY")
llm_base_url: str = Field(default="http://apicz.boyuerichdata.com/v1/", alias="LLM_BASE_URL") llm_base_url: str = Field(default="https://generativelanguage.googleapis.com/v1beta/openai/", alias="LLM_BASE_URL")
# Twitter API (for social sentiment search) # Twitter API (for social sentiment search)
twitter_api_key: str = Field(default="", alias="TWITTER_API_KEY") twitter_api_key: str = Field(default="", alias="TWITTER_API_KEY")
@@ -68,7 +68,7 @@ class Settings(BaseSettings):
tier3_poll_interval: int = Field(default=300, alias="TIER3_POLL_INTERVAL") tier3_poll_interval: int = Field(default=300, alias="TIER3_POLL_INTERVAL")
# LLM Settings # LLM Settings
llm_model: str = Field(default="gemini-3-flash-preview", alias="LLM_MODEL") llm_model: str = Field(default="gemini-3.1-pro-preview", alias="LLM_MODEL")
llm_temperature: float = Field(default=0.0, alias="LLM_TEMPERATURE") llm_temperature: float = Field(default=0.0, alias="LLM_TEMPERATURE")
+7 -8
View File
@@ -1,13 +1,13 @@
""" """
Polymarket Whale Watcher - Main Entry Point Polymarket Whale Watcher - Main Entry Point
This bot monitors trending Polymarket markets for large (whale) trades This bot monitors Polymarket markets for large (whale) trades via
and generates AI-powered analysis reports to assist user decision-making. real-time WebSocket (RTDS) and generates AI-powered analysis reports.
Flow: Flow:
1. Fetch trending markets (by 24hr volume, excluding sports) 1. Fetch market list (for enrichment metadata)
2. Monitor these markets for trades 2. RTDS WebSocket receives ALL trades in real-time (zero missed trades)
3. Detect anomalous trades ($1,000+, price 0.2-0.8) 3. Filter for whale trades (size, price range, conviction)
4. Generate analysis reports using LLM 4. Generate analysis reports using LLM
5. Output reports for user review (no automatic trading) 5. Output reports for user review (no automatic trading)
""" """
@@ -312,15 +312,14 @@ class WhaleWatcher:
# Log startup # Log startup
logger.monitoring_started( logger.monitoring_started(
market_count=len(self.trade_monitor._monitored_markets), market_count=len(self.trade_monitor._monitored_markets),
interval=self.settings.fetch_interval_seconds,
min_trade_size=self.settings.min_trade_size_usd, min_trade_size=self.settings.min_trade_size_usd,
min_price=self.settings.min_price, min_price=self.settings.min_price,
max_price=self.settings.max_price, max_price=self.settings.max_price,
) )
# Start monitoring tasks: # Start monitoring tasks:
# 1. Trade monitor - watches top markets for whale trades # 1. Trade monitor - RTDS WebSocket real-time trade stream
# 2. Market refresh - refreshes the market list periodically # 2. Market refresh - refreshes market metadata periodically
# 3. Daily briefing - generates daily summary at midnight # 3. Daily briefing - generates daily summary at midnight
# 4. Resolution check - checks if markets with signals have resolved # 4. Resolution check - checks if markets with signals have resolved
# NOTE: Price volatility monitor is temporarily disabled # NOTE: Price volatility monitor is temporarily disabled
+229
View File
@@ -0,0 +1,229 @@
"""
RTDS (Real-Time Data Socket) client for Polymarket.
Connects to wss://ws-live-data.polymarket.com and subscribes to
activity/trades for real-time trade data across ALL markets.
Replaces the per-market HTTP polling approach with a single persistent
WebSocket connection — zero missed trades, sub-second latency.
"""
import asyncio
import json
import logging
import time
from typing import Awaitable, Callable, Optional
import websockets
from websockets.asyncio.client import ClientConnection
from src.models.trade import TradeActivity
logger = logging.getLogger(__name__)
RTDS_URI = "wss://ws-live-data.polymarket.com"
HEARTBEAT_INTERVAL = 5 # seconds
RECONNECT_DELAYS = [1, 2, 5, 10, 30, 60] # backoff schedule
class RTDSClient:
"""
Persistent WebSocket client for Polymarket RTDS trade stream.
Features:
- Auto-reconnect with exponential backoff
- Heartbeat (PING every 5s)
- Parses raw messages into TradeActivity objects
- Fires an async callback for each trade
"""
def __init__(
self,
on_trade: Optional[Callable[[TradeActivity], Awaitable[None]]] = None,
):
self._on_trade = on_trade
self._running = False
self._ws: Optional[ClientConnection] = None
self._trade_count = 0
self._connect_count = 0
# ================================================================
# Message parsing
# ================================================================
@staticmethod
def _parse_trade(payload: dict) -> Optional[TradeActivity]:
"""Convert an RTDS trade payload into a TradeActivity."""
try:
side = (payload.get("side") or "").upper()
size = float(payload.get("size", 0) or 0)
price = float(payload.get("price", 0) or 0)
usdc_size = size * price
outcome = payload.get("outcome", "Yes")
outcome_index = int(payload.get("outcomeIndex", 0 if outcome == "Yes" else 1))
ts = int(payload.get("timestamp", 0) or 0)
if ts == 0:
ts = int(time.time())
return TradeActivity(
transaction_hash=payload.get("transactionHash", ""),
timestamp=ts,
condition_id=payload.get("conditionId", ""),
asset=payload.get("asset", ""),
side=side,
size=size,
usdc_size=usdc_size,
price=price,
outcome=outcome,
outcome_index=outcome_index,
title=payload.get("title", ""),
slug=payload.get("slug"),
event_slug=payload.get("eventSlug"),
proxy_wallet=payload.get("proxyWallet"),
name=payload.get("name") or payload.get("pseudonym"),
)
except Exception as e:
logger.debug(f"Failed to parse RTDS trade: {e}")
return None
# ================================================================
# Connection lifecycle
# ================================================================
async def _heartbeat(self, ws: ClientConnection) -> None:
"""Send PING every HEARTBEAT_INTERVAL seconds."""
try:
while True:
await asyncio.sleep(HEARTBEAT_INTERVAL)
await ws.send("PING")
except (asyncio.CancelledError, websockets.ConnectionClosed):
pass
async def _subscribe(self, ws: ClientConnection) -> None:
"""Subscribe to the activity/trades stream."""
msg = {
"action": "subscribe",
"subscriptions": [
{"topic": "activity", "type": "trades", "filters": ""}
],
}
await ws.send(json.dumps(msg))
logger.info("Subscribed to RTDS activity/trades")
async def _consume(self, ws: ClientConnection) -> None:
"""Read messages from the WebSocket and dispatch trades."""
async for raw in ws:
if not self._running:
break
if raw == "PONG" or not raw.strip():
continue
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
if msg.get("topic") != "activity" or msg.get("type") != "trades":
continue
payload = msg.get("payload")
if not payload:
continue
activity = self._parse_trade(payload)
if not activity:
continue
self._trade_count += 1
if self._on_trade:
try:
await self._on_trade(activity)
except Exception as e:
logger.error(f"Error in trade callback: {e}")
async def _connect_and_run(self) -> None:
"""Single connection attempt: connect → subscribe → consume."""
self._connect_count += 1
logger.info(
f"Connecting to RTDS ({self._connect_count})... "
f"(total trades so far: {self._trade_count})"
)
async with websockets.connect(RTDS_URI, ping_interval=None) as ws:
self._ws = ws
logger.info("RTDS connected")
await self._subscribe(ws)
hb_task = asyncio.create_task(self._heartbeat(ws))
try:
await self._consume(ws)
finally:
hb_task.cancel()
self._ws = None
# ================================================================
# Public API
# ================================================================
async def run(self) -> None:
"""
Start the RTDS client with auto-reconnect.
Runs forever until stop() is called.
"""
self._running = True
consecutive_failures = 0
while self._running:
try:
await self._connect_and_run()
# Clean disconnect (stop() called) — exit
if not self._running:
break
# Unexpected clean close — reconnect immediately
consecutive_failures = 0
except (
websockets.ConnectionClosed,
websockets.InvalidURI,
websockets.InvalidHandshake,
OSError,
ConnectionError,
) as e:
if not self._running:
break
delay_idx = min(consecutive_failures, len(RECONNECT_DELAYS) - 1)
delay = RECONNECT_DELAYS[delay_idx]
consecutive_failures += 1
logger.warning(
f"RTDS disconnected: {type(e).__name__}: {e}. "
f"Reconnecting in {delay}s (attempt {consecutive_failures})"
)
await asyncio.sleep(delay)
except Exception as e:
if not self._running:
break
logger.error(f"Unexpected RTDS error: {e}. Reconnecting in 10s")
await asyncio.sleep(10)
logger.info(f"RTDS client stopped (total trades received: {self._trade_count})")
def stop(self) -> None:
"""Stop the RTDS client."""
self._running = False
if self._ws:
asyncio.ensure_future(self._ws.close())
logger.info("RTDS client stopping...")
@property
def trade_count(self) -> int:
"""Total number of trades received since start."""
return self._trade_count
@property
def is_connected(self) -> bool:
"""Whether the WebSocket is currently connected."""
return self._ws is not None and self._ws.state.name == "OPEN"
+259 -503
View File
@@ -1,18 +1,22 @@
""" """
Trade monitoring service - per-market parallel architecture. Trade monitoring service — RTDS WebSocket architecture.
Each market runs its own independent async task that: Single WebSocket connection receives ALL trades in real-time from
1. Polls the official Polymarket data-api for new trades Polymarket RTDS (wss://ws-live-data.polymarket.com).
2. Detects whale trades
3. Fetches trader ranking + history in parallel
4. Fires the whale callback (LLM report generation) without blocking other markets
Modeled after paper_trading/paper_trading.py's _market_loop pattern. For each incoming trade:
1. Record for cluster detection (anomaly detector)
2. Dedup by transaction hash
3. Filter: whale pre-filter (price range, size, conviction)
4. Enrich: trader ranking + history → anomaly score
5. If score passes threshold → full enrichment + LLM callback
Replaces the previous per-market HTTP polling architecture.
""" """
import asyncio import asyncio
import json import json
import logging import logging
import random import math
import time as _time import time as _time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -27,24 +31,22 @@ from src.models.trade import (
EventPosition, MarketTopTrader, EventPosition, MarketTopTrader,
) )
from src.services.anomaly_detector import AnomalyDetector from src.services.anomaly_detector import AnomalyDetector
from src.services.rtds_client import RTDSClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Gamma API for fetching latest market prices # Gamma API for fetching latest market prices
GAMMA_API_URL = "https://gamma-api.polymarket.com/markets" GAMMA_API_URL = "https://gamma-api.polymarket.com/markets"
# Official Polymarket data-api for trade data
# URL and key loaded from settings (.env)
# File to persist processed transaction hashes # File to persist processed transaction hashes
PROCESSED_TXNS_FILE = Path(__file__).parent.parent.parent / "data" / "processed_transactions.json" PROCESSED_TXNS_FILE = Path(__file__).parent.parent.parent / "data" / "processed_transactions.json"
class TradeMonitor: class TradeMonitor:
""" """
Monitors Polymarket markets for large trades. Monitors Polymarket markets for large trades via RTDS WebSocket.
Architecture: one asyncio.Task per market, fully parallel. Architecture: single WebSocket connection → filter → enrich → callback.
""" """
def __init__( def __init__(
@@ -53,10 +55,13 @@ class TradeMonitor:
): ):
self.settings = get_settings() self.settings = get_settings()
# Official Polymarket data-api # RTDS WebSocket client (created in run())
self._rtds: Optional[RTDSClient] = None
# HTTP client for enrichment API calls (trader ranking, history, etc.)
self.data_api_url = "https://data-api.polymarket.com" self.data_api_url = "https://data-api.polymarket.com"
self.trades_endpoint = f"{self.data_api_url}/trades"
self.leaderboard_endpoint = f"{self.data_api_url}/v1/leaderboard" self.leaderboard_endpoint = f"{self.data_api_url}/v1/leaderboard"
self.trades_endpoint = f"{self.data_api_url}/trades"
self._client = httpx.AsyncClient( self._client = httpx.AsyncClient(
timeout=httpx.Timeout(30.0, pool=120.0), timeout=httpx.Timeout(30.0, pool=120.0),
limits=httpx.Limits( limits=httpx.Limits(
@@ -66,20 +71,14 @@ class TradeMonitor:
), ),
) )
# Per-market last-fetch timestamps for incremental polling
self._market_last_ts: Dict[str, int] = {}
# Rate limiter: Lock + Semaphore created lazily in run() to avoid "attached to different loop" error
self._api_lock: Optional[asyncio.Lock] = None
self._api_sem: Optional[asyncio.Semaphore] = None # concurrency limiter
self._api_last_request: float = 0.0
self._api_global_interval: float = 0.2 # min 0.2s between requests = 5 QPS
# Cache for trader rankings to avoid repeated API calls # Cache for trader rankings to avoid repeated API calls
self._trader_ranking_cache: Dict[str, TraderRanking] = {} self._trader_ranking_cache: Dict[str, TraderRanking] = {}
# Markets being monitored: market_id -> Market # Markets being monitored: condition_id -> Market
# Used for enrichment (market question, description, etc.)
self._monitored_markets: Dict[str, Market] = {} self._monitored_markets: Dict[str, Market] = {}
# condition_id -> market_id mapping
self._condition_to_market_id: Dict[str, str] = {}
# Track processed transactions to avoid duplicates # Track processed transactions to avoid duplicates
self._processed_txns: Set[str] = set() self._processed_txns: Set[str] = set()
@@ -91,12 +90,12 @@ class TradeMonitor:
# Callback for whale detection # Callback for whale detection
self._on_whale_detected = on_whale_detected self._on_whale_detected = on_whale_detected
# Control flag and per-market tasks # Control flag
self._running = False self._running = False
self._market_tasks: Dict[str, asyncio.Task] = {}
# Flag to track if initial scan is complete (ignore historical trades) # Flag to suppress alerts during initial warmup
self._initial_scan_complete = False self._warmup_complete = False
self._warmup_seconds = 10 # seconds to collect baseline before alerting
# ================================================================ # ================================================================
# Persistence # Persistence
@@ -147,194 +146,216 @@ class TradeMonitor:
def set_monitored_markets(self, markets: List[TrendingMarket]): def set_monitored_markets(self, markets: List[TrendingMarket]):
"""Update the list of markets to monitor.""" """Update the list of markets to monitor."""
self._monitored_markets = {} self._monitored_markets = {}
self._condition_to_market_id = {}
for tm in markets: for tm in markets:
if tm.market.id: m = tm.market
self._monitored_markets[tm.market.id] = tm.market if m.id and m.condition_id:
self._monitored_markets[m.condition_id] = m
self._condition_to_market_id[m.condition_id] = m.id
logger.info(f"Now monitoring {len(self._monitored_markets)} markets") logger.info(f"Now monitoring {len(self._monitored_markets)} markets")
def set_tiered_markets(self, tiers: dict[str, list]) -> None: def set_tiered_markets(self, tiers: dict[str, list]) -> None:
""" """Set markets from tiered scan (same interface as before)."""
Set markets with per-tier poll intervals.
Stores poll_interval per market_id in _market_poll_intervals dict.
"""
self._monitored_markets = {} self._monitored_markets = {}
self._market_poll_intervals: dict[str, int] = {} self._condition_to_market_id = {}
tier_intervals = {
"tier1": self.settings.tier1_poll_interval,
"tier2": self.settings.tier2_poll_interval,
"tier3": self.settings.tier3_poll_interval,
}
for tier_name, markets in tiers.items(): for tier_name, markets in tiers.items():
interval = tier_intervals.get(tier_name, self.settings.fetch_interval_seconds)
for tm in markets: for tm in markets:
if tm.market.id: m = tm.market
self._monitored_markets[tm.market.id] = tm.market if m.id and m.condition_id:
self._market_poll_intervals[tm.market.id] = interval self._monitored_markets[m.condition_id] = m
self._condition_to_market_id[m.condition_id] = m.id
total = len(self._monitored_markets)
tier_counts = {k: len(v) for k, v in tiers.items()} tier_counts = {k: len(v) for k, v in tiers.items()}
logger.info( logger.info(f"Tiered monitoring: {tier_counts}, total={total}")
f"Tiered monitoring: {tier_counts} "
f"(intervals: {tier_intervals}s), total={len(self._monitored_markets)}"
)
# ================================================================ # ================================================================
# Trade fetching # RTDS trade handler (core of the new architecture)
# ================================================================ # ================================================================
_MAX_RETRIES = 4 async def _on_rtds_trade(self, activity: TradeActivity) -> None:
_RETRY_BACKOFF = [2, 5, 10, 20] # seconds between retries (with jitter)
# ================================================================
# Official Polymarket data-api: fetch trades
# ================================================================
async def fetch_market_trades(self, market_id: str) -> List[TradeActivity]:
""" """
Fetch recent trades using the official Polymarket data-api /trades endpoint. Called for every trade received from RTDS WebSocket.
The official API returns trades with fields: This replaces the per-market polling loop.
- id, taker_order_id, market, asset, side, size, price, status """
- match_time, transaction_hash, outcome, bucket_index, owner, type condition_id = activity.condition_id
# Look up market info (enrichment data)
market = self._monitored_markets.get(condition_id)
market_id = self._condition_to_market_id.get(condition_id, "")
# Record every trade for cluster detection (even unmonitored markets)
if market_id:
self._anomaly_detector.record_trade(activity, market_id)
# Dedup by transaction hash + outcome (same tx can have multiple fills)
dedup_key = f"{activity.transaction_hash}_{activity.outcome}_{activity.size}"
if dedup_key in self._processed_txns:
return
self._processed_txns.add(dedup_key)
# Skip unmonitored markets
if not market:
return
# Skip during warmup period (avoid alerting on historical trades)
if not self._warmup_complete:
return
# Only track BUY trades (new positions)
if activity.side != "BUY":
return
# Whale pre-filter
if not self._is_whale_trade(activity, market=market):
return
# Handle whale (enrich + score + callback)
asyncio.create_task(self._handle_whale(activity, market_id, market))
# ================================================================
# Whale detection (unchanged from original)
# ================================================================
def _is_whale_trade(self, activity: TradeActivity, market: Optional[Market] = None) -> bool:
"""
Multi-layer pre-filter mirroring options flow SignalFilter._check_signal.
Filter chain (early rejection):
1. Price range — like moneyness filter (OTM/ITM range)
2. Direction — BUY only (like enabled direction_filters)
3. Resolution window — like DTE filter (3-60 days sweet spot)
4. Size — like premium filter ($250K+ minimum)
5. Dynamic size — like dynamic_premium (base × √(vol / baseline))
6. Signal strength — like ask_ratio filter (conviction check)
"""
# --- 1. Price range ---
if not (self.settings.min_price <= activity.price <= self.settings.max_price):
return False
# --- 2. Direction: BUY only ---
# Already enforced upstream
# --- 3. Resolution window ---
if market and market.end_date:
try:
end_dt = datetime.fromisoformat(market.end_date.replace("Z", "+00:00"))
now_dt = datetime.utcnow().replace(tzinfo=end_dt.tzinfo) if end_dt.tzinfo else datetime.utcnow()
hours_to_resolution = max(0, (end_dt - now_dt).total_seconds() / 3600)
if hours_to_resolution < 3:
return False
if hours_to_resolution > 180 * 24:
return False
except (ValueError, TypeError):
pass
# --- 4. Size ---
if activity.usdc_size < 3_000:
return False
# --- 5. Dynamic size ---
base_size = 5_000.0
baseline_volume = 1_000_000.0
if market and market.volume > 0:
threshold = base_size * math.sqrt(market.volume / baseline_volume)
threshold = max(3_000.0, min(threshold, 50_000.0))
else:
threshold = base_size
if activity.usdc_size < threshold:
return False
# --- 6. Signal strength ---
if market and market.outcome_prices:
if activity.outcome == "Yes":
market_mid = market.outcome_prices[0]
elif len(market.outcome_prices) > 1:
market_mid = market.outcome_prices[1]
else:
market_mid = 1.0 - market.outcome_prices[0]
if activity.price < market_mid + 0.01:
return False
return True
async def _handle_whale(self, activity: TradeActivity, market_id: str, market: Market):
"""
Handle a single whale trade:
1. Fetch trader info (ranking + history) for anomaly scoring
2. Compute multi-dimensional anomaly score as pre-filter
3. If score passes threshold, fetch full enrichment data and fire LLM callback
""" """
try: try:
market = self._monitored_markets.get(market_id) # Phase 1: Quick fetch — ranking + history for anomaly scoring
if not market: trader_ranking, trader_history = await asyncio.gather(
return [] self.fetch_trader_ranking(activity.proxy_wallet),
self.fetch_trader_history(activity.proxy_wallet),
# The official /trades endpoint uses condition_id as the "market" param
condition_id = market.condition_id
if not condition_id:
return []
last_ts = self._market_last_ts.get(market_id)
params: Dict[str, object] = {
"market": condition_id,
"limit": 50,
}
sem = self._api_sem or asyncio.Semaphore(20)
last_err: Optional[Exception] = None
async with sem:
for attempt in range(self._MAX_RETRIES):
try:
async with self._api_lock:
now = _time.monotonic()
wait = self._api_global_interval - (now - self._api_last_request)
if wait > 0:
await asyncio.sleep(wait)
self._api_last_request = _time.monotonic()
response = await self._client.get(
f"{self.data_api_url}/trades", params=params,
)
response.raise_for_status()
break
except httpx.HTTPStatusError as e:
if e.response.status_code in (502, 503, 504) and attempt < self._MAX_RETRIES - 1:
delay = self._RETRY_BACKOFF[attempt]
logger.debug(
f"Official API {e.response.status_code} for {market_id} "
f"(attempt {attempt + 1}/{self._MAX_RETRIES}), "
f"retrying in {delay}s"
)
await asyncio.sleep(delay)
continue
raise
except httpx.HTTPError as e:
last_err = e
if attempt < self._MAX_RETRIES - 1:
delay = self._RETRY_BACKOFF[attempt] + random.uniform(0, 2)
logger.debug(
f"Official API retry for {market_id} "
f"(attempt {attempt + 1}/{self._MAX_RETRIES}): "
f"{type(e).__name__}, retrying in {delay:.1f}s"
)
await asyncio.sleep(delay)
else:
logger.warning(
f"Official API connection error for {market_id} "
f"(attempt {attempt + 1}/{self._MAX_RETRIES}, giving up): "
f"{type(e).__name__}: {e}"
)
return []
else:
return []
data = response.json()
if not data:
return []
activities = []
max_ts = last_ts or 0
for item in data:
try:
side = item.get("side", "").upper()
# Only track BUY trades (new positions)
if side != "BUY":
continue
size = float(item.get("size", 0) or 0)
price = float(item.get("price", 0) or 0)
usdc_size = size * price # Official API: USDC value = tokens * price
outcome = item.get("outcome", "Yes")
outcome_index = int(item.get("outcomeIndex", 0 if outcome == "Yes" else 1))
# Timestamp is epoch seconds in the official API
ts = int(item.get("timestamp", 0) or 0)
if ts == 0:
ts = int(_time.time())
if ts > max_ts:
max_ts = ts
tx_hash = item.get("transactionHash", "")
activity = TradeActivity(
transaction_hash=tx_hash,
timestamp=ts,
condition_id=item.get("conditionId", condition_id),
asset=item.get("asset", ""),
side="BUY",
size=size,
usdc_size=usdc_size,
price=price,
outcome=outcome,
outcome_index=outcome_index,
title=item.get("title", ""),
slug=item.get("slug"),
event_slug=item.get("eventSlug"),
proxy_wallet=item.get("proxyWallet"),
name=item.get("name") or item.get("pseudonym"),
)
activities.append(activity)
except Exception as e:
logger.debug(f"Failed to parse official API trade: {e}")
continue
if max_ts > 0:
self._market_last_ts[market_id] = max_ts
return activities
except httpx.HTTPStatusError as e:
logger.warning(
f"Official trades API HTTP {e.response.status_code} for {market_id}: "
f"{e.response.text[:200]}"
) )
return []
# Phase 2: Anomaly scoring
should_analyze, score, breakdown = self._anomaly_detector.should_analyze(
activity, market=market, trader_history=trader_history,
market_id=market_id,
)
rank_str = f"(Rank #{trader_ranking.rank})" if trader_ranking and trader_ranking.rank else "(Unranked)"
breakdown_short = " | ".join(f"{k}={v:.2f}" for k, v in breakdown.items())
if not should_analyze:
logger.info(
f"⚪ Whale below threshold: ${activity.usdc_size:,.2f} "
f"BUY {activity.outcome} @ {activity.price:.4f} {rank_str} "
f"score={score:.2f} [{breakdown_short}] — skipped LLM"
)
return
logger.info(
f"🐋 Whale trade detected! ${activity.usdc_size:,.2f} "
f"BUY {activity.outcome} @ {activity.price:.4f} {rank_str} "
f"score={score:.2f} [{breakdown_short}] on '{market.question[:50]}...'"
)
# Phase 3: Full enrichment
event_positions, (top_buyers, top_sellers) = await asyncio.gather(
self.fetch_whale_event_positions(
activity.proxy_wallet,
activity.event_slug,
market.condition_id or "",
),
self.fetch_market_top_traders(
market_id, condition_id=market.condition_id or "",
outcome_prices=market.outcome_prices,
),
)
whale_trade = WhaleTrade(
id=f"{market_id}_{activity.transaction_hash}",
trade=activity,
market_id=market_id,
market_question=market.question,
market_description=market.description,
market_outcomes=market.outcomes,
market_outcome_prices=market.outcome_prices,
trader_ranking=trader_ranking,
trader_history=trader_history,
whale_event_positions=event_positions,
market_top_buyers=top_buyers,
market_top_sellers=top_sellers,
)
# Fire callback (LLM report generation)
if self._on_whale_detected:
await self._on_whale_detected(whale_trade)
except Exception as e: except Exception as e:
logger.warning(f"Error fetching official trades for {market_id}: {type(e).__name__}: {e}") logger.error(f"Error handling whale trade in {market_id}: {e}")
return []
# ================================================================ # ================================================================
# Official API: trader info (ranking + history) # Enrichment API calls (unchanged — still uses HTTP)
# ================================================================ # ================================================================
async def fetch_trader_ranking(self, wallet_address: str) -> Optional[TraderRanking]: async def fetch_trader_ranking(self, wallet_address: str) -> Optional[TraderRanking]:
@@ -444,23 +465,13 @@ class TradeMonitor:
logger.debug(f"Error fetching history for {wallet_address}: {e}") logger.debug(f"Error fetching history for {wallet_address}: {e}")
return None return None
# ================================================================
# Event positions & market top traders
# ================================================================
async def fetch_whale_event_positions( async def fetch_whale_event_positions(
self, self,
wallet_address: str, wallet_address: str,
event_slug: str, event_slug: str,
current_condition_id: str, current_condition_id: str,
) -> List[EventPosition]: ) -> List[EventPosition]:
""" """Fetch the whale's positions across all markets in the same event."""
Fetch the whale's current positions across all markets in the same event.
Uses the Polymarket data API positions endpoint directly:
GET https://data-api.polymarket.com/positions?user=<wallet>
Then filters by event_slug to find related holdings.
"""
if not wallet_address or not event_slug: if not wallet_address or not event_slug:
return [] return []
@@ -474,7 +485,6 @@ class TradeMonitor:
if not all_positions: if not all_positions:
return [] return []
# Filter positions belonging to the same event, excluding current market
result = [] result = []
for pos in all_positions: for pos in all_positions:
pos_event_slug = pos.get("eventSlug", "") pos_event_slug = pos.get("eventSlug", "")
@@ -487,7 +497,7 @@ class TradeMonitor:
size = float(pos.get("size", 0) or 0) size = float(pos.get("size", 0) or 0)
if size == 0: if size == 0:
continue # skip empty positions continue
outcome = pos.get("outcome", "Yes") outcome = pos.get("outcome", "Yes")
avg_price = float(pos.get("avgPrice", 0) or 0) avg_price = float(pos.get("avgPrice", 0) or 0)
@@ -497,7 +507,6 @@ class TradeMonitor:
cash_pnl = float(pos.get("cashPnl", 0) or 0) cash_pnl = float(pos.get("cashPnl", 0) or 0)
title = pos.get("title", "") title = pos.get("title", "")
# Build human-readable summary
if outcome == "Yes": if outcome == "Yes":
side_summary = f"Holding Yes {size:,.0f} tokens @ avg {avg_price:.2%}, current {cur_price:.2%}" side_summary = f"Holding Yes {size:,.0f} tokens @ avg {avg_price:.2%}, current {cur_price:.2%}"
else: else:
@@ -516,7 +525,6 @@ class TradeMonitor:
side_summary=side_summary, side_summary=side_summary,
)) ))
# Sort by position value descending
result.sort(key=lambda x: x.current_value, reverse=True) result.sort(key=lambda x: x.current_value, reverse=True)
logger.debug( logger.debug(
f"Found {len(result)} event positions for {wallet_address} " f"Found {len(result)} event positions for {wallet_address} "
@@ -532,21 +540,10 @@ class TradeMonitor:
self, market_id: str, condition_id: str = "", self, market_id: str, condition_id: str = "",
outcome_prices: Optional[List[float]] = None, top_n: int = 5, outcome_prices: Optional[List[float]] = None, top_n: int = 5,
) -> tuple[List[MarketTopTrader], List[MarketTopTrader]]: ) -> tuple[List[MarketTopTrader], List[MarketTopTrader]]:
""" """Fetch top holders (bulls and bears) for a market."""
Fetch top holders (bulls and bears) for a market.
Uses the official Polymarket data-api /holders endpoint which returns
the top position holders for each outcome token, sorted by amount.
Returns:
(top_buyers, top_sellers) — each up to top_n entries.
top_buyers = top Yes token holders (bullish).
top_sellers = top No token holders (bearish).
"""
if not condition_id: if not condition_id:
return [], [] return [], []
# outcome_prices: [yes_price, no_price]
yes_price = outcome_prices[0] if outcome_prices and len(outcome_prices) > 0 else 0.5 yes_price = outcome_prices[0] if outcome_prices and len(outcome_prices) > 0 else 0.5
no_price = outcome_prices[1] if outcome_prices and len(outcome_prices) > 1 else 0.5 no_price = outcome_prices[1] if outcome_prices and len(outcome_prices) > 1 else 0.5
@@ -568,7 +565,6 @@ class TradeMonitor:
if not holders: if not holders:
continue continue
# outcomeIndex: 0 = Yes (bulls), 1 = No (bears)
outcome_index = holders[0].get("outcomeIndex", 0) outcome_index = holders[0].get("outcomeIndex", 0)
token_price = yes_price if outcome_index == 0 else no_price token_price = yes_price if outcome_index == 0 else no_price
@@ -576,7 +572,6 @@ class TradeMonitor:
wallet = h.get("proxyWallet", "") wallet = h.get("proxyWallet", "")
name = h.get("name") or h.get("pseudonym") or None name = h.get("name") or h.get("pseudonym") or None
amount = float(h.get("amount", 0) or 0) amount = float(h.get("amount", 0) or 0)
# Convert token amount to USD value
usd_value = amount * token_price usd_value = amount * token_price
trader = MarketTopTrader( trader = MarketTopTrader(
@@ -591,7 +586,7 @@ class TradeMonitor:
else: else:
top_sellers.append(trader) top_sellers.append(trader)
# Fetch rankings for top traders in parallel # Fetch rankings in parallel
ranking_tasks = [] ranking_tasks = []
trader_refs = [] trader_refs = []
for t in top_buyers + top_sellers: for t in top_buyers + top_sellers:
@@ -617,309 +612,70 @@ class TradeMonitor:
logger.warning(f"Error fetching top holders for {market_id}: {e}") logger.warning(f"Error fetching top holders for {market_id}: {e}")
return [], [] return [], []
# ================================================================
# Whale detection
# ================================================================
def _is_whale_trade(self, activity: TradeActivity, market: Optional[Market] = None) -> bool:
"""
Multi-layer pre-filter mirroring options flow SignalFilter._check_signal.
Filter chain (early rejection, same order as options flow):
1. Price range — like moneyness filter (OTM/ITM range)
2. Direction — BUY only (like enabled direction_filters)
3. Resolution window — like DTE filter (3-60 days sweet spot)
4. Size — like premium filter ($250K+ minimum)
5. Dynamic size — like dynamic_premium (base × √(vol / baseline))
6. Signal strength — like ask_ratio filter (conviction check)
"""
import math
from datetime import datetime as _dt
# --- 1. Price range (like moneyness: OTM 0-20%) ---
# Price 0.2-0.8 = uncertain outcome = tradeable
# Price < 0.2 or > 0.8 = near-consensus = no edge
if not (self.settings.min_price <= activity.price <= self.settings.max_price):
return False
# --- 2. Direction: BUY only (like direction_filters.enabled) ---
# Already enforced upstream (only BUY trades reach here)
# --- 3. Resolution window (like DTE min=3, max=60) ---
# Markets resolving < 6 hours = price already settled (like DTE < 3)
# Markets resolving > 90 days = too far out, edge diluted (like DTE > 60)
if market and market.end_date:
try:
end_dt = _dt.fromisoformat(market.end_date.replace("Z", "+00:00"))
now_dt = _dt.utcnow().replace(tzinfo=end_dt.tzinfo) if end_dt.tzinfo else _dt.utcnow()
hours_to_resolution = max(0, (end_dt - now_dt).total_seconds() / 3600)
if hours_to_resolution < 3:
return False # too close, like DTE < 3
if hours_to_resolution > 180 * 24:
return False # too far, like DTE > 60
except (ValueError, TypeError):
pass # unknown end date, don't reject
# --- 4. Size (like premium min=$250K) ---
# Base minimum: $5,000 (Polymarket scale vs options $250K)
if activity.usdc_size < 3_000:
return False
# --- 5. Dynamic size (like dynamic_premium = base × √(mcap / baseline)) ---
# Larger markets require proportionally larger trades to be meaningful
base_size = 5_000.0
baseline_volume = 1_000_000.0
if market and market.volume > 0:
threshold = base_size * math.sqrt(market.volume / baseline_volume)
threshold = max(3_000.0, min(threshold, 50_000.0)) # floor $3K, cap $50K
else:
threshold = base_size
if activity.usdc_size < threshold:
return False
# --- 6. Signal strength (like ask_ratio > 70%) ---
# In Polymarket: buyer paying above market mid = conviction
# Reject trades at or below market mid (no conviction, possibly hedging)
if market and market.outcome_prices:
if activity.outcome == "Yes":
market_mid = market.outcome_prices[0]
elif len(market.outcome_prices) > 1:
market_mid = market.outcome_prices[1]
else:
market_mid = 1.0 - market.outcome_prices[0]
# Must pay above market mid (no discount buys = no conviction)
if activity.price < market_mid + 0.01:
return False
return True
async def _handle_whale(self, activity: TradeActivity, market_id: str, market: Market):
"""
Handle a single whale trade:
1. Fetch trader info (ranking + history) for anomaly scoring
2. Compute multi-dimensional anomaly score as pre-filter
3. If score passes threshold, fetch full enrichment data and fire LLM callback
"""
try:
# Phase 1: Quick fetch — only ranking + history (needed for anomaly scoring)
trader_ranking, trader_history = await asyncio.gather(
self.fetch_trader_ranking(activity.proxy_wallet),
self.fetch_trader_history(activity.proxy_wallet),
)
# Phase 2: Multi-dimensional anomaly scoring (pre-filter before LLM)
should_analyze, score, breakdown = self._anomaly_detector.should_analyze(
activity, market=market, trader_history=trader_history,
market_id=market_id,
)
rank_str = f"(Rank #{trader_ranking.rank})" if trader_ranking and trader_ranking.rank else "(Unranked)"
breakdown_short = " | ".join(f"{k}={v:.2f}" for k, v in breakdown.items())
if not should_analyze:
logger.info(
f"⚪ Whale below threshold: ${activity.usdc_size:,.2f} "
f"BUY {activity.outcome} @ {activity.price:.4f} {rank_str} "
f"score={score:.2f} [{breakdown_short}] — skipped LLM"
)
return
logger.info(
f"🐋 Whale trade detected! ${activity.usdc_size:,.2f} "
f"BUY {activity.outcome} @ {activity.price:.4f} {rank_str} "
f"score={score:.2f} [{breakdown_short}] on '{market.question[:50]}...'"
)
# Phase 3: Full enrichment (only for trades that pass pre-filter)
event_positions, (top_buyers, top_sellers) = await asyncio.gather(
self.fetch_whale_event_positions(
activity.proxy_wallet,
activity.event_slug,
market.condition_id or "",
),
self.fetch_market_top_traders(
market_id, condition_id=market.condition_id or "",
outcome_prices=market.outcome_prices,
),
)
whale_trade = WhaleTrade(
id=f"{market_id}_{activity.transaction_hash}",
trade=activity,
market_id=market_id,
market_question=market.question,
market_description=market.description,
market_outcomes=market.outcomes,
market_outcome_prices=market.outcome_prices,
trader_ranking=trader_ranking,
trader_history=trader_history,
whale_event_positions=event_positions,
market_top_buyers=top_buyers,
market_top_sellers=top_sellers,
)
# Fire callback (LLM report generation)
if self._on_whale_detected:
await self._on_whale_detected(whale_trade)
except Exception as e:
logger.error(f"Error handling whale trade in {market_id}: {e}")
# ================================================================
# Per-market independent loop
# ================================================================
async def _market_loop(self, market_id: str, initial_delay: float):
"""
Independent polling loop for a single market.
Each market runs this as its own asyncio.Task:
1. Wait initial_delay (stagger startup to avoid request storm)
2. First poll: record existing transactions (no alerts)
3. Subsequent polls: detect whales, handle in parallel
"""
if initial_delay > 0:
await asyncio.sleep(initial_delay)
market = self._monitored_markets.get(market_id)
if not market:
return
# Per-market interval (from tiered monitoring) or global default
poll_intervals = getattr(self, '_market_poll_intervals', {})
poll_interval = poll_intervals.get(market_id, self.settings.fetch_interval_seconds)
# If we already have a last_ts for this market, it means the loop was
# restarted (e.g. after a market list refresh) — skip the silent
# first-poll window to avoid missing trades.
is_first_poll = market_id not in self._market_last_ts
while self._running:
try:
# Check if market was removed during refresh
market = self._monitored_markets.get(market_id)
if not market:
logger.debug(f"Market {market_id} no longer monitored, stopping loop")
break
activities = await self.fetch_market_trades(market_id)
# Collect whale handling tasks for this poll cycle
whale_tasks = []
for activity in activities:
# Record every trade for cluster detection
self._anomaly_detector.record_trade(activity, market_id)
if activity.transaction_hash in self._processed_txns:
continue
self._processed_txns.add(activity.transaction_hash)
# First poll: only record, don't alert
if is_first_poll:
continue
if self._is_whale_trade(activity, market=market):
# Launch whale handling as a parallel task
whale_tasks.append(
asyncio.create_task(
self._handle_whale(activity, market_id, market)
)
)
# Wait for all whale handlers in this cycle to complete
if whale_tasks:
await asyncio.gather(*whale_tasks, return_exceptions=True)
is_first_poll = False
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in market loop {market_id}: {e}")
await asyncio.sleep(poll_interval)
# ================================================================ # ================================================================
# Main run loop # Main run loop
# ================================================================ # ================================================================
async def run(self): async def run(self):
""" """
Start the parallel monitoring loop. Start the RTDS-based monitoring loop.
Architecture (modeled after paper_trading._poll_trades): Architecture:
- Each market gets its own asyncio.Task (_market_loop) - Single RTDS WebSocket receives ALL trades in real-time
- Startup is staggered to avoid request storms - _on_rtds_trade filters and handles each trade
- Main loop handles: task lifecycle, persistence, new market spawning - Periodic persistence of processed transactions
""" """
self._running = True self._running = True
poll_interval = self.settings.fetch_interval_seconds
# Create lock/semaphore inside event loop (avoids "attached to different loop" error) # Create RTDS client with our trade handler
self._api_lock = asyncio.Lock() self._rtds = RTDSClient(on_trade=self._on_rtds_trade)
self._api_sem = asyncio.Semaphore(10) # max 10 concurrent API requests
logger.info( logger.info(
f"Starting parallel trade monitor " f"Starting RTDS trade monitor "
f"({len(self._monitored_markets)} markets, interval: {poll_interval}s)" f"({len(self._monitored_markets)} monitored markets)"
) )
try: # Start warmup timer — suppress alerts for first N seconds
# Spawn per-market tasks with staggered start # to avoid firing on trades already in the RTDS pipeline
market_ids = list(self._monitored_markets.keys()) async def warmup_timer():
n_markets = len(market_ids) await asyncio.sleep(self._warmup_seconds)
stagger_window = max(poll_interval, n_markets * 1.0) # ~1s per market self._warmup_complete = True
logger.info(
f"Warmup complete ({self._warmup_seconds}s). "
f"Now alerting on new whale trades."
)
for i, market_id in enumerate(market_ids): warmup_task = asyncio.create_task(warmup_timer())
delay = (i / max(n_markets, 1)) * stagger_window
task = asyncio.create_task(self._market_loop(market_id, initial_delay=delay))
self._market_tasks[market_id] = task
logger.info(f"Spawned {len(self._market_tasks)} parallel market tasks")
# Main supervisory loop
save_interval = 60 # save processed txns every 60 seconds
last_save = asyncio.get_event_loop().time()
# Periodic persistence task
async def persistence_loop():
while self._running: while self._running:
now = asyncio.get_event_loop().time() await asyncio.sleep(60)
self._save_processed_txns()
# Trim processed txns set to prevent unbounded growth
if len(self._processed_txns) > 100_000:
# Keep only the most recent 50K (approximate — set is unordered,
# but old hashes won't repeat so trimming is safe)
excess = len(self._processed_txns) - 50_000
for _ in range(excess):
self._processed_txns.pop()
logger.info(f"Trimmed processed txns to {len(self._processed_txns)}")
# Spawn tasks for newly added markets (from set_monitored_markets) persistence_task = asyncio.create_task(persistence_loop())
for market_id in self._monitored_markets:
if market_id not in self._market_tasks or self._market_tasks[market_id].done():
task = asyncio.create_task(
self._market_loop(market_id, initial_delay=0)
)
self._market_tasks[market_id] = task
logger.info(f"Spawned new task for market {market_id}")
# Clean up tasks for removed markets
removed = [mid for mid in self._market_tasks if mid not in self._monitored_markets]
for mid in removed:
self._market_tasks[mid].cancel()
del self._market_tasks[mid]
# Periodic persistence
if now - last_save >= save_interval:
self._save_processed_txns()
last_save = now
await asyncio.sleep(5.0)
try:
# Run RTDS client (blocks until stop)
await self._rtds.run()
finally: finally:
# Cancel all market tasks warmup_task.cancel()
for task in self._market_tasks.values(): persistence_task.cancel()
task.cancel()
await asyncio.gather(*self._market_tasks.values(), return_exceptions=True)
self._market_tasks.clear()
self._save_processed_txns() self._save_processed_txns()
def stop(self): def stop(self):
"""Stop the monitoring loop.""" """Stop the monitoring loop."""
self._running = False self._running = False
if self._rtds:
self._rtds.stop()
logger.info("Trade monitor stopping...") logger.info("Trade monitor stopping...")
def clear_processed_transactions(self): def clear_processed_transactions(self):
+4 -4
View File
@@ -94,14 +94,14 @@ class WhaleWatcherLogger:
f"[bold magenta]{'='*60}[/bold magenta]\n" f"[bold magenta]{'='*60}[/bold magenta]\n"
) )
def monitoring_started(self, market_count: int, interval: int, min_trade_size: float = 1000, min_price: float = 0.2, max_price: float = 0.8) -> None: def monitoring_started(self, market_count: int, interval: int = 0, min_trade_size: float = 1000, min_price: float = 0.2, max_price: float = 0.8) -> None:
"""Log monitoring start.""" """Log monitoring start."""
self.console.print( self.console.print(
f"\n[bold green]{'='*60}[/bold green]\n" f"\n[bold green]{'='*60}[/bold green]\n"
f"[bold green]🚀 WHALE WATCHER STARTED[/bold green]\n" f"[bold green]🚀 WHALE WATCHER STARTED (RTDS WebSocket)[/bold green]\n"
f"[bold green]{'='*60}[/bold green]\n" f"[bold green]{'='*60}[/bold green]\n"
f"[green]Monitoring:[/green] {market_count} markets\n" f"[green]Monitored Markets:[/green] {market_count}\n"
f"[green]Interval:[/green] {interval} seconds\n" f"[green]Mode:[/green] Real-time WebSocket (zero missed trades)\n"
f"[green]Min Trade Size:[/green] ${min_trade_size:,.0f} USD\n" f"[green]Min Trade Size:[/green] ${min_trade_size:,.0f} USD\n"
f"[green]Price Range:[/green] {min_price} - {max_price}\n" f"[green]Price Range:[/green] {min_price} - {max_price}\n"
f"[bold green]{'='*60}[/bold green]\n" f"[bold green]{'='*60}[/bold green]\n"