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:
co-authored by
Claude Opus 4.5
parent
89bf80e3d8
commit
a0bec521b6
@@ -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
|
||||||
@@ -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 == []
|
||||||
Reference in New Issue
Block a user