feat(detector): add position size anomaly detection (#15)

Implement SizeAnomalyDetector for identifying trades with unusually
large position sizes relative to market liquidity.

Features:
- Volume impact analysis (trade size / 24h volume)
- Order book impact analysis (trade size / book depth)
- Niche market detection using category heuristics
- Confidence scoring with configurable thresholds
- Batch analysis for processing multiple trades

The detector gracefully handles missing volume/book data by falling
back to category-based heuristics for identifying niche markets
where large trades are more significant.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Selamy
2026-01-04 15:58:27 -05:00
co-authored by Claude Opus 4.5
parent 89bf80e3d8
commit a0bec521b6
4 changed files with 1264 additions and 3 deletions
@@ -1,6 +1,12 @@
"""Anomaly detection layer - Suspicious activity identification.""" """Anomaly detection layer - Suspicious activity identification."""
from polymarket_insider_tracker.detector.fresh_wallet import FreshWalletDetector 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",
]
@@ -4,7 +4,7 @@ from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
from decimal import Decimal 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 from polymarket_insider_tracker.profiler.models import WalletProfile
@@ -71,3 +71,75 @@ class FreshWalletSignal:
"factors": self.factors, "factors": self.factors,
"timestamp": self.timestamp.isoformat(), "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(),
}
@@ -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
+829
View File
@@ -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 == []