diff --git a/src/polymarket_insider_tracker/detector/__init__.py b/src/polymarket_insider_tracker/detector/__init__.py index e82d348..cbb71f8 100644 --- a/src/polymarket_insider_tracker/detector/__init__.py +++ b/src/polymarket_insider_tracker/detector/__init__.py @@ -1,6 +1,12 @@ """Anomaly detection layer - Suspicious activity identification.""" from polymarket_insider_tracker.detector.fresh_wallet import FreshWalletDetector -from polymarket_insider_tracker.detector.models import FreshWalletSignal +from polymarket_insider_tracker.detector.models import FreshWalletSignal, SizeAnomalySignal +from polymarket_insider_tracker.detector.size_anomaly import SizeAnomalyDetector -__all__ = ["FreshWalletDetector", "FreshWalletSignal"] +__all__ = [ + "FreshWalletDetector", + "FreshWalletSignal", + "SizeAnomalyDetector", + "SizeAnomalySignal", +] diff --git a/src/polymarket_insider_tracker/detector/models.py b/src/polymarket_insider_tracker/detector/models.py index d958c2e..f1aa7e4 100644 --- a/src/polymarket_insider_tracker/detector/models.py +++ b/src/polymarket_insider_tracker/detector/models.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from datetime import UTC, datetime from decimal import Decimal -from polymarket_insider_tracker.ingestor.models import TradeEvent +from polymarket_insider_tracker.ingestor.models import MarketMetadata, TradeEvent from polymarket_insider_tracker.profiler.models import WalletProfile @@ -71,3 +71,75 @@ class FreshWalletSignal: "factors": self.factors, "timestamp": self.timestamp.isoformat(), } + + +@dataclass(frozen=True) +class SizeAnomalySignal: + """Signal emitted when a trade has unusually large position size. + + This signal is generated when a trade's size significantly impacts + the market volume or order book depth, indicating potential informed + trading activity. + + Attributes: + trade_event: The original trade event that triggered this signal. + market_metadata: Metadata about the market being traded. + volume_impact: Trade size as fraction of 24h volume (0.0 if unknown). + book_impact: Trade size as fraction of order book depth (0.0 if unknown). + is_niche_market: Whether the market is considered niche/low-volume. + confidence: Overall confidence score (0.0 to 1.0). + factors: Individual factor scores contributing to confidence. + timestamp: When this signal was generated. + """ + + trade_event: TradeEvent + market_metadata: MarketMetadata + volume_impact: float + book_impact: float + is_niche_market: bool + confidence: float + factors: dict[str, float] + timestamp: datetime = field(default_factory=lambda: datetime.now(UTC)) + + @property + def wallet_address(self) -> str: + """Return the wallet address from the trade event.""" + return self.trade_event.wallet_address + + @property + def market_id(self) -> str: + """Return the market ID from the trade event.""" + return self.trade_event.market_id + + @property + def trade_size_usdc(self) -> Decimal: + """Return the trade size in USDC (notional value).""" + return self.trade_event.notional_value + + @property + def is_high_confidence(self) -> bool: + """Return True if confidence exceeds 0.7.""" + return self.confidence >= 0.7 + + @property + def is_very_high_confidence(self) -> bool: + """Return True if confidence exceeds 0.85.""" + return self.confidence >= 0.85 + + def to_dict(self) -> dict[str, object]: + """Serialize to dictionary for Redis stream publishing.""" + return { + "wallet_address": self.wallet_address, + "market_id": self.market_id, + "trade_id": self.trade_event.trade_id, + "trade_size": str(self.trade_size_usdc), + "trade_side": self.trade_event.side, + "trade_price": str(self.trade_event.price), + "market_category": self.market_metadata.category, + "volume_impact": self.volume_impact, + "book_impact": self.book_impact, + "is_niche_market": self.is_niche_market, + "confidence": self.confidence, + "factors": self.factors, + "timestamp": self.timestamp.isoformat(), + } diff --git a/src/polymarket_insider_tracker/detector/size_anomaly.py b/src/polymarket_insider_tracker/detector/size_anomaly.py new file mode 100644 index 0000000..b348dee --- /dev/null +++ b/src/polymarket_insider_tracker/detector/size_anomaly.py @@ -0,0 +1,354 @@ +"""Position size anomaly detection algorithm. + +This module provides the SizeAnomalyDetector class that identifies trades +with unusually large position sizes relative to market liquidity. +""" + +import logging +from decimal import Decimal + +from polymarket_insider_tracker.detector.models import SizeAnomalySignal +from polymarket_insider_tracker.ingestor.metadata_sync import MarketMetadataSync +from polymarket_insider_tracker.ingestor.models import MarketMetadata, TradeEvent + +logger = logging.getLogger(__name__) + +# Default configuration +DEFAULT_VOLUME_THRESHOLD = 0.02 # 2% of daily volume +DEFAULT_BOOK_THRESHOLD = 0.05 # 5% of order book depth +DEFAULT_NICHE_VOLUME_THRESHOLD = Decimal("50000") # $50k daily volume + +# Niche market categories - markets in these categories with low specificity +# are more likely to have insider information value +NICHE_PRONE_CATEGORIES = frozenset({"science", "tech", "finance", "other"}) + + +class SizeAnomalyDetector: + """Detector for unusually large trade sizes. + + This detector analyzes trade events for size anomalies by comparing + the trade size against market liquidity metrics: + - Volume impact: trade size / 24h volume + - Book impact: trade size / order book depth + + When volume data is unavailable, the detector uses category-based + heuristics to identify niche markets where large trades are more + significant. + + Confidence scoring: + - Volume impact > threshold: base score from impact ratio + - Book impact > threshold: additional score from impact ratio + - Niche market multiplier: 1.5x for low-volume markets + + Example: + ```python + sync = MarketMetadataSync(redis, clob_client) + detector = SizeAnomalyDetector(sync) + + # Analyze a trade + signal = await detector.analyze(trade_event) + if signal is not None: + print(f"Size anomaly detected! Confidence: {signal.confidence}") + ``` + """ + + def __init__( + self, + metadata_sync: MarketMetadataSync, + *, + volume_threshold: float = DEFAULT_VOLUME_THRESHOLD, + book_threshold: float = DEFAULT_BOOK_THRESHOLD, + niche_volume_threshold: Decimal = DEFAULT_NICHE_VOLUME_THRESHOLD, + ) -> None: + """Initialize the size anomaly detector. + + Args: + metadata_sync: MarketMetadataSync for fetching market metadata. + volume_threshold: Threshold for volume impact (default 0.02 = 2%). + book_threshold: Threshold for book impact (default 0.05 = 5%). + niche_volume_threshold: Volume below which market is niche ($50k). + """ + self._metadata_sync = metadata_sync + self._volume_threshold = volume_threshold + self._book_threshold = book_threshold + self._niche_volume_threshold = niche_volume_threshold + + async def analyze( + self, + trade: TradeEvent, + *, + daily_volume: Decimal | None = None, + book_depth: Decimal | None = None, + ) -> SizeAnomalySignal | None: + """Analyze a trade event for size anomalies. + + This method: + 1. Fetches market metadata + 2. Calculates volume and book impact (if data available) + 3. Determines if market is niche + 4. Calculates confidence score + + Args: + trade: TradeEvent to analyze. + daily_volume: Optional 24h volume in USDC. If provided, enables + volume impact calculation. + book_depth: Optional order book depth in USDC. If provided, + enables book impact calculation. + + Returns: + SizeAnomalySignal if the trade triggers anomaly detection, + None otherwise. + """ + # Get market metadata + try: + metadata = await self._metadata_sync.get_market(trade.market_id) + if metadata is None: + logger.warning( + "No metadata found for market %s, creating minimal metadata", + trade.market_id, + ) + metadata = self._create_minimal_metadata(trade) + except Exception as e: + logger.warning( + "Failed to get metadata for market %s: %s", + trade.market_id, + e, + ) + metadata = self._create_minimal_metadata(trade) + + trade_size = trade.notional_value + + # Calculate impacts + volume_impact = self._calculate_volume_impact(trade_size, daily_volume) + book_impact = self._calculate_book_impact(trade_size, book_depth) + + # Determine if niche market + is_niche = self._is_niche_market(metadata, daily_volume) + + # Check if any threshold exceeded + exceeds_volume = volume_impact > self._volume_threshold + exceeds_book = book_impact > self._book_threshold + + if not exceeds_volume and not exceeds_book and not is_niche: + logger.debug( + "Trade %s does not exceed thresholds: volume=%.4f, book=%.4f", + trade.trade_id, + volume_impact, + book_impact, + ) + return None + + # Calculate confidence score + confidence, factors = self.calculate_confidence( + volume_impact=volume_impact, + book_impact=book_impact, + is_niche=is_niche, + ) + + # Only emit signal if confidence is meaningful + if confidence < 0.1: + return None + + logger.info( + "Size anomaly signal: market=%s, size=%s, volume_impact=%.4f, " + "book_impact=%.4f, niche=%s, confidence=%.2f", + trade.market_id[:10] + "...", + trade_size, + volume_impact, + book_impact, + is_niche, + confidence, + ) + + return SizeAnomalySignal( + trade_event=trade, + market_metadata=metadata, + volume_impact=volume_impact, + book_impact=book_impact, + is_niche_market=is_niche, + confidence=confidence, + factors=factors, + ) + + def _create_minimal_metadata(self, trade: TradeEvent) -> MarketMetadata: + """Create minimal metadata from trade event.""" + from polymarket_insider_tracker.ingestor.models import Token + + return MarketMetadata( + condition_id=trade.market_id, + question=trade.event_title or "Unknown Market", + description="", + tokens=( + Token( + token_id=trade.asset_id, + outcome=trade.outcome, + price=trade.price, + ), + ), + category="other", + ) + + def _calculate_volume_impact( + self, + trade_size: Decimal, + daily_volume: Decimal | None, + ) -> float: + """Calculate trade size as fraction of daily volume. + + Args: + trade_size: Trade notional value in USDC. + daily_volume: 24h trading volume in USDC. + + Returns: + Volume impact ratio, or 0.0 if volume unknown. + """ + if daily_volume is None or daily_volume <= 0: + return 0.0 + return float(trade_size / daily_volume) + + def _calculate_book_impact( + self, + trade_size: Decimal, + book_depth: Decimal | None, + ) -> float: + """Calculate trade size as fraction of order book depth. + + Args: + trade_size: Trade notional value in USDC. + book_depth: Visible order book depth in USDC. + + Returns: + Book impact ratio, or 0.0 if depth unknown. + """ + if book_depth is None or book_depth <= 0: + return 0.0 + return float(trade_size / book_depth) + + def _is_niche_market( + self, + metadata: MarketMetadata, + daily_volume: Decimal | None, + ) -> bool: + """Determine if market is considered niche. + + A market is niche if: + - Volume is below threshold ($50k), OR + - Category is prone to insider info AND volume is unknown + + Args: + metadata: Market metadata with category. + daily_volume: Optional 24h volume. + + Returns: + True if market is considered niche. + """ + # If volume known and below threshold, it's niche + if daily_volume is not None and daily_volume < self._niche_volume_threshold: + return True + + # If volume unknown, use category heuristics + return daily_volume is None and metadata.category in NICHE_PRONE_CATEGORIES + + def calculate_confidence( + self, + *, + volume_impact: float, + book_impact: float, + is_niche: bool, + ) -> tuple[float, dict[str, float]]: + """Calculate confidence score based on impact metrics. + + Confidence scoring: + - Volume impact: min(impact/threshold, 3) / 3 * 0.5 + - Book impact: min(impact/threshold, 3) / 3 * 0.3 + - Niche multiplier: 1.5x final score + + Final confidence clamped to [0.0, 1.0]. + + Args: + volume_impact: Trade size / daily volume ratio. + book_impact: Trade size / book depth ratio. + is_niche: Whether market is niche. + + Returns: + Tuple of (confidence_score, factors_dict). + """ + factors: dict[str, float] = {} + confidence = 0.0 + + # Volume impact component + if volume_impact > self._volume_threshold: + ratio = min(volume_impact / self._volume_threshold, 3.0) + volume_score = ratio / 3.0 * 0.5 + factors["volume_impact"] = volume_score + confidence += volume_score + + # Book impact component + if book_impact > self._book_threshold: + ratio = min(book_impact / self._book_threshold, 3.0) + book_score = ratio / 3.0 * 0.3 + factors["book_impact"] = book_score + confidence += book_score + + # Niche market multiplier + if is_niche and confidence > 0: + factors["niche_multiplier"] = 1.5 + confidence *= 1.5 + + # If niche but no other signals, give small base confidence + if is_niche and confidence == 0: + factors["niche_base"] = 0.2 + confidence = 0.2 + + # Clamp to valid range + confidence = max(0.0, min(1.0, confidence)) + + return confidence, factors + + async def analyze_batch( + self, + trades: list[TradeEvent], + *, + volume_data: dict[str, Decimal] | None = None, + book_data: dict[str, Decimal] | None = None, + ) -> list[SizeAnomalySignal]: + """Analyze multiple trades for size anomalies. + + Processes trades in parallel for efficiency. + + Args: + trades: List of trades to analyze. + volume_data: Optional dict mapping market_id to 24h volume. + book_data: Optional dict mapping market_id to book depth. + + Returns: + List of SizeAnomalySignal for trades with anomalies. + """ + import asyncio + + volume_data = volume_data or {} + book_data = book_data or {} + + tasks = [ + self.analyze( + trade, + daily_volume=volume_data.get(trade.market_id), + book_depth=book_data.get(trade.market_id), + ) + for trade in trades + ] + results = await asyncio.gather(*tasks, return_exceptions=True) + + signals: list[SizeAnomalySignal] = [] + for trade, result in zip(trades, results, strict=True): + if isinstance(result, BaseException): + logger.warning( + "Failed to analyze trade %s: %s", + trade.trade_id, + result, + ) + continue + if result is not None: + signals.append(result) + + return signals diff --git a/tests/detector/test_size_anomaly.py b/tests/detector/test_size_anomaly.py new file mode 100644 index 0000000..e617838 --- /dev/null +++ b/tests/detector/test_size_anomaly.py @@ -0,0 +1,829 @@ +"""Tests for position size anomaly detection.""" + +from datetime import UTC, datetime +from decimal import Decimal +from unittest.mock import AsyncMock + +import pytest + +from polymarket_insider_tracker.detector.models import SizeAnomalySignal +from polymarket_insider_tracker.detector.size_anomaly import ( + DEFAULT_BOOK_THRESHOLD, + DEFAULT_NICHE_VOLUME_THRESHOLD, + DEFAULT_VOLUME_THRESHOLD, + NICHE_PRONE_CATEGORIES, + SizeAnomalyDetector, +) +from polymarket_insider_tracker.ingestor.metadata_sync import MarketMetadataSync +from polymarket_insider_tracker.ingestor.models import MarketMetadata, Token, TradeEvent + +# ============================================================================ +# Fixtures +# ============================================================================ + + +@pytest.fixture +def mock_metadata_sync() -> AsyncMock: + """Create a mock MarketMetadataSync.""" + return AsyncMock(spec=MarketMetadataSync) + + +@pytest.fixture +def sample_token() -> Token: + """Create a sample token.""" + return Token( + token_id="token_123", + outcome="Yes", + price=Decimal("0.65"), + ) + + +@pytest.fixture +def sample_metadata(sample_token: Token) -> MarketMetadata: + """Create sample market metadata.""" + return MarketMetadata( + condition_id="market_abc123", + question="Will it rain tomorrow?", + description="Weather prediction market", + tokens=(sample_token,), + category="science", + ) + + +@pytest.fixture +def sample_trade() -> TradeEvent: + """Create a sample trade event.""" + return TradeEvent( + market_id="market_abc123", + trade_id="tx_001", + wallet_address="0x1234567890abcdef", + side="BUY", + outcome="Yes", + outcome_index=0, + price=Decimal("0.65"), + size=Decimal("10000"), # $6,500 notional + timestamp=datetime.now(UTC), + asset_id="token_123", + event_title="Weather Market", + ) + + +@pytest.fixture +def large_trade() -> TradeEvent: + """Create a large trade event.""" + return TradeEvent( + market_id="market_abc123", + trade_id="tx_002", + wallet_address="0xlargewallet", + side="BUY", + outcome="Yes", + outcome_index=0, + price=Decimal("0.50"), + size=Decimal("100000"), # $50,000 notional + timestamp=datetime.now(UTC), + asset_id="token_123", + event_title="Big Market", + ) + + +# ============================================================================ +# SizeAnomalySignal Tests +# ============================================================================ + + +class TestSizeAnomalySignal: + """Tests for the SizeAnomalySignal dataclass.""" + + def test_signal_creation( + self, sample_trade: TradeEvent, sample_metadata: MarketMetadata + ) -> None: + """Test basic signal creation.""" + signal = SizeAnomalySignal( + trade_event=sample_trade, + market_metadata=sample_metadata, + volume_impact=0.05, + book_impact=0.10, + is_niche_market=True, + confidence=0.75, + factors={"volume_impact": 0.4, "niche_multiplier": 1.5}, + ) + + assert signal.trade_event == sample_trade + assert signal.market_metadata == sample_metadata + assert signal.volume_impact == 0.05 + assert signal.book_impact == 0.10 + assert signal.is_niche_market is True + assert signal.confidence == 0.75 + assert "volume_impact" in signal.factors + + def test_wallet_address_property( + self, sample_trade: TradeEvent, sample_metadata: MarketMetadata + ) -> None: + """Test wallet_address property.""" + signal = SizeAnomalySignal( + trade_event=sample_trade, + market_metadata=sample_metadata, + volume_impact=0.05, + book_impact=0.10, + is_niche_market=False, + confidence=0.5, + factors={}, + ) + + assert signal.wallet_address == sample_trade.wallet_address + + def test_market_id_property( + self, sample_trade: TradeEvent, sample_metadata: MarketMetadata + ) -> None: + """Test market_id property.""" + signal = SizeAnomalySignal( + trade_event=sample_trade, + market_metadata=sample_metadata, + volume_impact=0.05, + book_impact=0.10, + is_niche_market=False, + confidence=0.5, + factors={}, + ) + + assert signal.market_id == sample_trade.market_id + + def test_trade_size_usdc_property( + self, sample_trade: TradeEvent, sample_metadata: MarketMetadata + ) -> None: + """Test trade_size_usdc property returns notional value.""" + signal = SizeAnomalySignal( + trade_event=sample_trade, + market_metadata=sample_metadata, + volume_impact=0.05, + book_impact=0.10, + is_niche_market=False, + confidence=0.5, + factors={}, + ) + + # notional = price * size = 0.65 * 10000 = 6500 + assert signal.trade_size_usdc == Decimal("6500.00") + + def test_is_high_confidence( + self, sample_trade: TradeEvent, sample_metadata: MarketMetadata + ) -> None: + """Test is_high_confidence threshold.""" + high_signal = SizeAnomalySignal( + trade_event=sample_trade, + market_metadata=sample_metadata, + volume_impact=0.05, + book_impact=0.10, + is_niche_market=False, + confidence=0.70, + factors={}, + ) + low_signal = SizeAnomalySignal( + trade_event=sample_trade, + market_metadata=sample_metadata, + volume_impact=0.05, + book_impact=0.10, + is_niche_market=False, + confidence=0.69, + factors={}, + ) + + assert high_signal.is_high_confidence is True + assert low_signal.is_high_confidence is False + + def test_is_very_high_confidence( + self, sample_trade: TradeEvent, sample_metadata: MarketMetadata + ) -> None: + """Test is_very_high_confidence threshold.""" + very_high = SizeAnomalySignal( + trade_event=sample_trade, + market_metadata=sample_metadata, + volume_impact=0.05, + book_impact=0.10, + is_niche_market=False, + confidence=0.85, + factors={}, + ) + high = SizeAnomalySignal( + trade_event=sample_trade, + market_metadata=sample_metadata, + volume_impact=0.05, + book_impact=0.10, + is_niche_market=False, + confidence=0.84, + factors={}, + ) + + assert very_high.is_very_high_confidence is True + assert high.is_very_high_confidence is False + + def test_to_dict_serialization( + self, sample_trade: TradeEvent, sample_metadata: MarketMetadata + ) -> None: + """Test to_dict produces valid serialization.""" + signal = SizeAnomalySignal( + trade_event=sample_trade, + market_metadata=sample_metadata, + volume_impact=0.05, + book_impact=0.10, + is_niche_market=True, + confidence=0.75, + factors={"volume_impact": 0.5}, + ) + + result = signal.to_dict() + + assert result["wallet_address"] == sample_trade.wallet_address + assert result["market_id"] == sample_trade.market_id + assert result["trade_id"] == sample_trade.trade_id + assert result["trade_size"] == "6500.00" + assert result["trade_side"] == "BUY" + assert result["market_category"] == "science" + assert result["volume_impact"] == 0.05 + assert result["book_impact"] == 0.10 + assert result["is_niche_market"] is True + assert result["confidence"] == 0.75 + assert result["factors"] == {"volume_impact": 0.5} + assert "timestamp" in result + + +# ============================================================================ +# SizeAnomalyDetector Initialization Tests +# ============================================================================ + + +class TestSizeAnomalyDetectorInit: + """Tests for SizeAnomalyDetector initialization.""" + + def test_default_initialization(self, mock_metadata_sync: AsyncMock) -> None: + """Test detector initializes with default values.""" + detector = SizeAnomalyDetector(mock_metadata_sync) + + assert detector._volume_threshold == DEFAULT_VOLUME_THRESHOLD + assert detector._book_threshold == DEFAULT_BOOK_THRESHOLD + assert detector._niche_volume_threshold == DEFAULT_NICHE_VOLUME_THRESHOLD + + def test_custom_thresholds(self, mock_metadata_sync: AsyncMock) -> None: + """Test detector with custom thresholds.""" + detector = SizeAnomalyDetector( + mock_metadata_sync, + volume_threshold=0.05, + book_threshold=0.10, + niche_volume_threshold=Decimal("100000"), + ) + + assert detector._volume_threshold == 0.05 + assert detector._book_threshold == 0.10 + assert detector._niche_volume_threshold == Decimal("100000") + + +# ============================================================================ +# Volume Impact Tests +# ============================================================================ + + +class TestVolumeImpactCalculation: + """Tests for volume impact calculation.""" + + def test_volume_impact_calculation(self, mock_metadata_sync: AsyncMock) -> None: + """Test correct volume impact calculation.""" + detector = SizeAnomalyDetector(mock_metadata_sync) + + # Trade size $1000, daily volume $50000 = 2% impact + impact = detector._calculate_volume_impact( + Decimal("1000"), Decimal("50000") + ) + assert impact == pytest.approx(0.02) + + def test_volume_impact_none_volume(self, mock_metadata_sync: AsyncMock) -> None: + """Test volume impact returns 0 when volume is None.""" + detector = SizeAnomalyDetector(mock_metadata_sync) + + impact = detector._calculate_volume_impact(Decimal("1000"), None) + assert impact == 0.0 + + def test_volume_impact_zero_volume(self, mock_metadata_sync: AsyncMock) -> None: + """Test volume impact returns 0 when volume is zero.""" + detector = SizeAnomalyDetector(mock_metadata_sync) + + impact = detector._calculate_volume_impact(Decimal("1000"), Decimal("0")) + assert impact == 0.0 + + def test_volume_impact_negative_volume( + self, mock_metadata_sync: AsyncMock + ) -> None: + """Test volume impact returns 0 when volume is negative.""" + detector = SizeAnomalyDetector(mock_metadata_sync) + + impact = detector._calculate_volume_impact(Decimal("1000"), Decimal("-1000")) + assert impact == 0.0 + + +# ============================================================================ +# Book Impact Tests +# ============================================================================ + + +class TestBookImpactCalculation: + """Tests for order book impact calculation.""" + + def test_book_impact_calculation(self, mock_metadata_sync: AsyncMock) -> None: + """Test correct book impact calculation.""" + detector = SizeAnomalyDetector(mock_metadata_sync) + + # Trade size $5000, book depth $50000 = 10% impact + impact = detector._calculate_book_impact(Decimal("5000"), Decimal("50000")) + assert impact == pytest.approx(0.10) + + def test_book_impact_none_depth(self, mock_metadata_sync: AsyncMock) -> None: + """Test book impact returns 0 when depth is None.""" + detector = SizeAnomalyDetector(mock_metadata_sync) + + impact = detector._calculate_book_impact(Decimal("5000"), None) + assert impact == 0.0 + + def test_book_impact_zero_depth(self, mock_metadata_sync: AsyncMock) -> None: + """Test book impact returns 0 when depth is zero.""" + detector = SizeAnomalyDetector(mock_metadata_sync) + + impact = detector._calculate_book_impact(Decimal("5000"), Decimal("0")) + assert impact == 0.0 + + +# ============================================================================ +# Niche Market Detection Tests +# ============================================================================ + + +class TestNicheMarketDetection: + """Tests for niche market detection.""" + + def test_niche_market_low_volume( + self, mock_metadata_sync: AsyncMock, sample_metadata: MarketMetadata + ) -> None: + """Test market is niche when volume below threshold.""" + detector = SizeAnomalyDetector(mock_metadata_sync) + + # Volume $40k < $50k threshold + is_niche = detector._is_niche_market(sample_metadata, Decimal("40000")) + assert is_niche is True + + def test_not_niche_high_volume( + self, mock_metadata_sync: AsyncMock, sample_metadata: MarketMetadata + ) -> None: + """Test market is not niche when volume above threshold.""" + detector = SizeAnomalyDetector(mock_metadata_sync) + + # Volume $100k > $50k threshold + is_niche = detector._is_niche_market(sample_metadata, Decimal("100000")) + assert is_niche is False + + def test_niche_market_unknown_volume_niche_category( + self, mock_metadata_sync: AsyncMock, sample_token: Token + ) -> None: + """Test market is niche when volume unknown and category is niche-prone.""" + detector = SizeAnomalyDetector(mock_metadata_sync) + + for category in NICHE_PRONE_CATEGORIES: + metadata = MarketMetadata( + condition_id="test", + question="Test", + description="", + tokens=(sample_token,), + category=category, + ) + is_niche = detector._is_niche_market(metadata, None) + assert is_niche is True, f"Category {category} should be niche" + + def test_not_niche_unknown_volume_mainstream_category( + self, mock_metadata_sync: AsyncMock, sample_token: Token + ) -> None: + """Test market is not niche when volume unknown but category is mainstream.""" + detector = SizeAnomalyDetector(mock_metadata_sync) + + mainstream_categories = ["politics", "sports", "crypto", "entertainment"] + for category in mainstream_categories: + metadata = MarketMetadata( + condition_id="test", + question="Test", + description="", + tokens=(sample_token,), + category=category, + ) + is_niche = detector._is_niche_market(metadata, None) + assert is_niche is False, f"Category {category} should not be niche" + + +# ============================================================================ +# Confidence Scoring Tests +# ============================================================================ + + +class TestConfidenceScoring: + """Tests for confidence score calculation.""" + + def test_confidence_volume_impact_only( + self, mock_metadata_sync: AsyncMock + ) -> None: + """Test confidence with only volume impact.""" + detector = SizeAnomalyDetector(mock_metadata_sync) + + # Volume impact 3x threshold = max score 0.5 + confidence, factors = detector.calculate_confidence( + volume_impact=0.06, # 3x the 0.02 threshold + book_impact=0.0, + is_niche=False, + ) + + assert confidence == pytest.approx(0.5) + assert "volume_impact" in factors + assert factors["volume_impact"] == pytest.approx(0.5) + + def test_confidence_book_impact_only(self, mock_metadata_sync: AsyncMock) -> None: + """Test confidence with only book impact.""" + detector = SizeAnomalyDetector(mock_metadata_sync) + + # Book impact 3x threshold = max score 0.3 + confidence, factors = detector.calculate_confidence( + volume_impact=0.0, + book_impact=0.15, # 3x the 0.05 threshold + is_niche=False, + ) + + assert confidence == pytest.approx(0.3) + assert "book_impact" in factors + assert factors["book_impact"] == pytest.approx(0.3) + + def test_confidence_combined_impacts(self, mock_metadata_sync: AsyncMock) -> None: + """Test confidence with both volume and book impact.""" + detector = SizeAnomalyDetector(mock_metadata_sync) + + # Both at 3x threshold = 0.5 + 0.3 = 0.8 + confidence, factors = detector.calculate_confidence( + volume_impact=0.06, + book_impact=0.15, + is_niche=False, + ) + + assert confidence == pytest.approx(0.8) + assert "volume_impact" in factors + assert "book_impact" in factors + + def test_confidence_niche_multiplier(self, mock_metadata_sync: AsyncMock) -> None: + """Test niche multiplier increases confidence.""" + detector = SizeAnomalyDetector(mock_metadata_sync) + + # Volume impact 2x threshold = 0.33, with 1.5x niche = 0.5 + confidence, factors = detector.calculate_confidence( + volume_impact=0.04, # 2x threshold + book_impact=0.0, + is_niche=True, + ) + + assert confidence == pytest.approx(0.5, rel=0.01) + assert "niche_multiplier" in factors + assert factors["niche_multiplier"] == 1.5 + + def test_confidence_niche_only_base(self, mock_metadata_sync: AsyncMock) -> None: + """Test niche market with no other signals gives base confidence.""" + detector = SizeAnomalyDetector(mock_metadata_sync) + + # No threshold exceeded, but is niche + confidence, factors = detector.calculate_confidence( + volume_impact=0.01, # Below 0.02 threshold + book_impact=0.01, # Below 0.05 threshold + is_niche=True, + ) + + assert confidence == 0.2 + assert "niche_base" in factors + assert factors["niche_base"] == 0.2 + + def test_confidence_clamped_to_max(self, mock_metadata_sync: AsyncMock) -> None: + """Test confidence is clamped to 1.0.""" + detector = SizeAnomalyDetector(mock_metadata_sync) + + # High impacts with niche multiplier would exceed 1.0 + confidence, factors = detector.calculate_confidence( + volume_impact=0.10, # 5x threshold (capped at 3x) + book_impact=0.20, # 4x threshold (capped at 3x) + is_niche=True, # 1.5x multiplier + ) + + assert confidence == 1.0 + + def test_confidence_zero_no_signals(self, mock_metadata_sync: AsyncMock) -> None: + """Test confidence is zero with no signals.""" + detector = SizeAnomalyDetector(mock_metadata_sync) + + confidence, factors = detector.calculate_confidence( + volume_impact=0.01, # Below threshold + book_impact=0.01, # Below threshold + is_niche=False, + ) + + assert confidence == 0.0 + assert len(factors) == 0 + + +# ============================================================================ +# Analyze Method Tests +# ============================================================================ + + +class TestAnalyzeMethod: + """Tests for the analyze method.""" + + @pytest.mark.asyncio + async def test_analyze_high_volume_impact( + self, + mock_metadata_sync: AsyncMock, + sample_trade: TradeEvent, + sample_metadata: MarketMetadata, + ) -> None: + """Test analyze detects high volume impact trade.""" + mock_metadata_sync.get_market.return_value = sample_metadata + detector = SizeAnomalyDetector(mock_metadata_sync) + + # Trade notional = 6500, volume = 65000, impact = 10% > 2% threshold + signal = await detector.analyze( + sample_trade, + daily_volume=Decimal("65000"), + ) + + assert signal is not None + assert signal.volume_impact == pytest.approx(0.10) + assert signal.confidence > 0.1 + + @pytest.mark.asyncio + async def test_analyze_high_book_impact( + self, + mock_metadata_sync: AsyncMock, + sample_trade: TradeEvent, + sample_metadata: MarketMetadata, + ) -> None: + """Test analyze detects high book impact trade.""" + mock_metadata_sync.get_market.return_value = sample_metadata + detector = SizeAnomalyDetector(mock_metadata_sync) + + # Trade notional = 6500, book depth = 32500, impact = 20% > 5% threshold + signal = await detector.analyze( + sample_trade, + book_depth=Decimal("32500"), + ) + + assert signal is not None + assert signal.book_impact == pytest.approx(0.20) + assert signal.confidence > 0.1 + + @pytest.mark.asyncio + async def test_analyze_niche_market( + self, + mock_metadata_sync: AsyncMock, + sample_trade: TradeEvent, + sample_metadata: MarketMetadata, + ) -> None: + """Test analyze detects niche market trade.""" + mock_metadata_sync.get_market.return_value = sample_metadata + detector = SizeAnomalyDetector(mock_metadata_sync) + + # Low volume market (science category with volume unknown) + signal = await detector.analyze(sample_trade) + + assert signal is not None + assert signal.is_niche_market is True + assert signal.confidence == 0.2 # niche_base + + @pytest.mark.asyncio + async def test_analyze_no_anomaly( + self, + mock_metadata_sync: AsyncMock, + sample_token: Token, + ) -> None: + """Test analyze returns None for normal trade.""" + # Politics category is not niche + metadata = MarketMetadata( + condition_id="market_politics", + question="Will Biden win?", + description="", + tokens=(sample_token,), + category="politics", + ) + mock_metadata_sync.get_market.return_value = metadata + + trade = TradeEvent( + market_id="market_politics", + trade_id="tx_normal", + wallet_address="0xnormal", + side="BUY", + outcome="Yes", + outcome_index=0, + price=Decimal("0.50"), + size=Decimal("100"), # Small trade = $50 notional + timestamp=datetime.now(UTC), + asset_id="token_pol", + ) + + detector = SizeAnomalyDetector(mock_metadata_sync) + + # High volume, large book depth = low impact + signal = await detector.analyze( + trade, + daily_volume=Decimal("1000000"), + book_depth=Decimal("500000"), + ) + + assert signal is None + + @pytest.mark.asyncio + async def test_analyze_creates_minimal_metadata_on_missing( + self, + mock_metadata_sync: AsyncMock, + sample_trade: TradeEvent, + ) -> None: + """Test analyze creates minimal metadata when market not found.""" + mock_metadata_sync.get_market.return_value = None + detector = SizeAnomalyDetector(mock_metadata_sync) + + # Should still work with minimal metadata (category="other" which is niche) + signal = await detector.analyze(sample_trade) + + assert signal is not None + assert signal.market_metadata.condition_id == sample_trade.market_id + assert signal.market_metadata.category == "other" + + @pytest.mark.asyncio + async def test_analyze_handles_metadata_exception( + self, + mock_metadata_sync: AsyncMock, + sample_trade: TradeEvent, + ) -> None: + """Test analyze handles exception when fetching metadata.""" + mock_metadata_sync.get_market.side_effect = Exception("Redis error") + detector = SizeAnomalyDetector(mock_metadata_sync) + + # Should still work with minimal metadata + signal = await detector.analyze(sample_trade) + + assert signal is not None + assert signal.market_metadata.category == "other" + + @pytest.mark.asyncio + async def test_analyze_low_confidence_filtered( + self, + mock_metadata_sync: AsyncMock, + sample_token: Token, + ) -> None: + """Test analyze returns None when confidence is below 0.1.""" + # Use a mainstream category with below-threshold impacts + metadata = MarketMetadata( + condition_id="market_sports", + question="Super Bowl winner?", + description="", + tokens=(sample_token,), + category="sports", + ) + mock_metadata_sync.get_market.return_value = metadata + + trade = TradeEvent( + market_id="market_sports", + trade_id="tx_small", + wallet_address="0xsmall", + side="BUY", + outcome="Yes", + outcome_index=0, + price=Decimal("0.50"), + size=Decimal("10"), # Tiny trade + timestamp=datetime.now(UTC), + asset_id="token_sports", + ) + + detector = SizeAnomalyDetector(mock_metadata_sync) + + # High volume but below threshold impacts + signal = await detector.analyze( + trade, + daily_volume=Decimal("10000000"), # $10M volume + book_depth=Decimal("1000000"), # $1M depth + ) + + assert signal is None + + +# ============================================================================ +# Batch Analysis Tests +# ============================================================================ + + +class TestBatchAnalysis: + """Tests for batch analysis.""" + + @pytest.mark.asyncio + async def test_analyze_batch_returns_signals( + self, + mock_metadata_sync: AsyncMock, + sample_metadata: MarketMetadata, + ) -> None: + """Test batch analysis returns signals for anomalous trades.""" + mock_metadata_sync.get_market.return_value = sample_metadata + + trades = [ + TradeEvent( + market_id="market_abc123", + trade_id=f"tx_{i}", + wallet_address=f"0xwallet{i}", + side="BUY", + outcome="Yes", + outcome_index=0, + price=Decimal("0.50"), + size=Decimal("10000"), # Large trade + timestamp=datetime.now(UTC), + asset_id="token_123", + ) + for i in range(3) + ] + + detector = SizeAnomalyDetector(mock_metadata_sync) + signals = await detector.analyze_batch(trades) + + # All trades are in niche category with unknown volume + assert len(signals) == 3 + + @pytest.mark.asyncio + async def test_analyze_batch_with_volume_data( + self, + mock_metadata_sync: AsyncMock, + sample_metadata: MarketMetadata, + ) -> None: + """Test batch analysis uses provided volume data.""" + mock_metadata_sync.get_market.return_value = sample_metadata + + trades = [ + TradeEvent( + market_id="market_abc123", + trade_id="tx_1", + wallet_address="0xwallet1", + side="BUY", + outcome="Yes", + outcome_index=0, + price=Decimal("0.50"), + size=Decimal("10000"), # $5000 notional + timestamp=datetime.now(UTC), + asset_id="token_123", + ) + ] + + detector = SizeAnomalyDetector(mock_metadata_sync) + + # $5000 trade / $50000 volume = 10% impact + signals = await detector.analyze_batch( + trades, + volume_data={"market_abc123": Decimal("50000")}, + ) + + assert len(signals) == 1 + assert signals[0].volume_impact == pytest.approx(0.10) + + @pytest.mark.asyncio + async def test_analyze_batch_handles_errors( + self, + mock_metadata_sync: AsyncMock, + ) -> None: + """Test batch analysis handles individual trade errors.""" + # First call succeeds, second fails + mock_metadata_sync.get_market.side_effect = [ + Exception("Error"), + None, + ] + + trades = [ + TradeEvent( + market_id=f"market_{i}", + trade_id=f"tx_{i}", + wallet_address=f"0xwallet{i}", + side="BUY", + outcome="Yes", + outcome_index=0, + price=Decimal("0.50"), + size=Decimal("10000"), + timestamp=datetime.now(UTC), + asset_id="token_123", + ) + for i in range(2) + ] + + detector = SizeAnomalyDetector(mock_metadata_sync) + signals = await detector.analyze_batch(trades) + + # Both should still produce signals (with minimal metadata fallback) + assert len(signals) == 2 + + @pytest.mark.asyncio + async def test_analyze_batch_empty_list( + self, mock_metadata_sync: AsyncMock + ) -> None: + """Test batch analysis with empty list.""" + detector = SizeAnomalyDetector(mock_metadata_sync) + signals = await detector.analyze_batch([]) + + assert signals == []