From 4981277eef8b792a131923990d4b40218087aa79 Mon Sep 17 00:00:00 2001 From: Patrick Selamy Date: Sun, 4 Jan 2026 16:06:47 -0500 Subject: [PATCH] feat(detector): add composite risk scorer for signal aggregation (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement RiskScorer that combines signals from multiple detectors into a unified risk assessment with weighted scoring and deduplication. Features: - SignalBundle for collecting signals for a single trade - RiskAssessment dataclass with complete scoring metadata - Configurable weights for each signal type - Multi-signal bonus (1.2x for 2 signals, 1.3x for 3+) - Redis-based deduplication (1 hour window by default) - Alert threshold configuration (default: 0.6) - Batch assessment for processing multiple trades - A/B testing support via dynamic weight updates Default weights: - fresh_wallet: 0.40 - size_anomaly: 0.35 - niche_market: 0.25 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../detector/__init__.py | 10 +- .../detector/models.py | 85 +++ .../detector/scorer.py | 309 ++++++++ tests/detector/test_scorer.py | 690 ++++++++++++++++++ 4 files changed, 1093 insertions(+), 1 deletion(-) create mode 100644 src/polymarket_insider_tracker/detector/scorer.py create mode 100644 tests/detector/test_scorer.py diff --git a/src/polymarket_insider_tracker/detector/__init__.py b/src/polymarket_insider_tracker/detector/__init__.py index cbb71f8..97f29f9 100644 --- a/src/polymarket_insider_tracker/detector/__init__.py +++ b/src/polymarket_insider_tracker/detector/__init__.py @@ -1,12 +1,20 @@ """Anomaly detection layer - Suspicious activity identification.""" from polymarket_insider_tracker.detector.fresh_wallet import FreshWalletDetector -from polymarket_insider_tracker.detector.models import FreshWalletSignal, SizeAnomalySignal +from polymarket_insider_tracker.detector.models import ( + FreshWalletSignal, + RiskAssessment, + SizeAnomalySignal, +) +from polymarket_insider_tracker.detector.scorer import RiskScorer, SignalBundle from polymarket_insider_tracker.detector.size_anomaly import SizeAnomalyDetector __all__ = [ "FreshWalletDetector", "FreshWalletSignal", + "RiskAssessment", + "RiskScorer", + "SignalBundle", "SizeAnomalyDetector", "SizeAnomalySignal", ] diff --git a/src/polymarket_insider_tracker/detector/models.py b/src/polymarket_insider_tracker/detector/models.py index f1aa7e4..b7ecc60 100644 --- a/src/polymarket_insider_tracker/detector/models.py +++ b/src/polymarket_insider_tracker/detector/models.py @@ -1,5 +1,8 @@ """Data models for the detector module.""" +from __future__ import annotations + +import uuid from dataclasses import dataclass, field from datetime import UTC, datetime from decimal import Decimal @@ -143,3 +146,85 @@ class SizeAnomalySignal: "factors": self.factors, "timestamp": self.timestamp.isoformat(), } + + +@dataclass(frozen=True) +class RiskAssessment: + """Combined risk assessment aggregating all signal types. + + This represents the final scoring output that determines whether + a trade should trigger an alert, combining signals from multiple + detectors with configurable weights. + + Attributes: + trade_event: The original trade event being assessed. + wallet_address: The trader's wallet address. + market_id: The market condition ID. + fresh_wallet_signal: Signal from fresh wallet detector, if triggered. + size_anomaly_signal: Signal from size anomaly detector, if triggered. + signals_triggered: Count of how many signal types fired. + weighted_score: Final weighted combination of all signals (0.0 to 1.0). + should_alert: Whether this assessment meets alert threshold. + assessment_id: Unique identifier for this assessment. + timestamp: When this assessment was generated. + """ + + trade_event: TradeEvent + wallet_address: str + market_id: str + + # Individual signals (None if not triggered) + fresh_wallet_signal: FreshWalletSignal | None + size_anomaly_signal: SizeAnomalySignal | None + + # Combined scoring + signals_triggered: int + weighted_score: float + should_alert: bool + + # Metadata + assessment_id: str = field(default_factory=lambda: str(uuid.uuid4())) + timestamp: datetime = field(default_factory=lambda: datetime.now(UTC)) + + @property + def is_high_risk(self) -> bool: + """Return True if weighted score exceeds 0.7.""" + return self.weighted_score >= 0.7 + + @property + def is_very_high_risk(self) -> bool: + """Return True if weighted score exceeds 0.85.""" + return self.weighted_score >= 0.85 + + @property + def trade_size_usdc(self) -> Decimal: + """Return the trade size in USDC (notional value).""" + return self.trade_event.notional_value + + def to_dict(self) -> dict[str, object]: + """Serialize to dictionary for Redis stream publishing.""" + return { + "assessment_id": self.assessment_id, + "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), + "signals_triggered": self.signals_triggered, + "weighted_score": self.weighted_score, + "should_alert": self.should_alert, + "has_fresh_wallet_signal": self.fresh_wallet_signal is not None, + "has_size_anomaly_signal": self.size_anomaly_signal is not None, + "fresh_wallet_confidence": ( + self.fresh_wallet_signal.confidence + if self.fresh_wallet_signal + else None + ), + "size_anomaly_confidence": ( + self.size_anomaly_signal.confidence + if self.size_anomaly_signal + else None + ), + "timestamp": self.timestamp.isoformat(), + } diff --git a/src/polymarket_insider_tracker/detector/scorer.py b/src/polymarket_insider_tracker/detector/scorer.py new file mode 100644 index 0000000..b4a481d --- /dev/null +++ b/src/polymarket_insider_tracker/detector/scorer.py @@ -0,0 +1,309 @@ +"""Composite risk scorer combining all detector signals. + +This module provides the RiskScorer class that aggregates signals from +multiple detectors into a unified risk assessment with weighted scoring. +""" + +import logging +from dataclasses import dataclass +from datetime import UTC, datetime + +from redis.asyncio import Redis + +from polymarket_insider_tracker.detector.models import ( + FreshWalletSignal, + RiskAssessment, + SizeAnomalySignal, +) +from polymarket_insider_tracker.ingestor.models import TradeEvent + +logger = logging.getLogger(__name__) + +# Default configuration +DEFAULT_ALERT_THRESHOLD = 0.6 +DEFAULT_DEDUP_WINDOW_SECONDS = 3600 # 1 hour +DEFAULT_REDIS_KEY_PREFIX = "polymarket:dedup:" + +# Default weights for each signal type +DEFAULT_WEIGHTS = { + "fresh_wallet": 0.40, + "size_anomaly": 0.35, + "niche_market": 0.25, +} + +# Multi-signal bonuses +MULTI_SIGNAL_BONUS_2 = 1.2 # 20% bonus for 2 signals +MULTI_SIGNAL_BONUS_3 = 1.3 # 30% bonus for 3+ signals + + +@dataclass +class SignalBundle: + """Bundle of signals for a single trade. + + Collects all available signals for a trade event to pass to the scorer. + """ + + trade_event: TradeEvent + fresh_wallet_signal: FreshWalletSignal | None = None + size_anomaly_signal: SizeAnomalySignal | None = None + + @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 + + +class RiskScorer: + """Composite risk scorer combining signals into unified assessments. + + This scorer: + - Aggregates signals from multiple detectors for the same trade + - Applies configurable weights based on signal type + - Calculates multi-signal bonuses for correlated signals + - Enforces deduplication to prevent alert spam + - Produces RiskAssessment objects for downstream alerting + + Scoring Formula: + weighted_score = sum(signal.confidence * weight[type] for signal in signals) + + # Multi-signal bonus + if signals >= 2: weighted_score *= 1.2 + if signals >= 3: weighted_score *= 1.3 + + # Cap at 1.0 + final_score = min(weighted_score, 1.0) + + should_alert = final_score >= alert_threshold AND not deduplicated + + Example: + ```python + redis = Redis.from_url("redis://localhost:6379") + scorer = RiskScorer(redis) + + bundle = SignalBundle( + trade_event=trade, + fresh_wallet_signal=fresh_signal, + size_anomaly_signal=size_signal, + ) + + assessment = await scorer.assess(bundle) + if assessment.should_alert: + await send_alert(assessment) + ``` + """ + + def __init__( + self, + redis: Redis, + *, + weights: dict[str, float] | None = None, + alert_threshold: float = DEFAULT_ALERT_THRESHOLD, + dedup_window_seconds: int = DEFAULT_DEDUP_WINDOW_SECONDS, + key_prefix: str = DEFAULT_REDIS_KEY_PREFIX, + ) -> None: + """Initialize the risk scorer. + + Args: + redis: Redis async client for deduplication. + weights: Custom weights for signal types. Defaults to DEFAULT_WEIGHTS. + alert_threshold: Minimum score to trigger alert (default 0.6). + dedup_window_seconds: Window for deduplication (default 3600 = 1 hour). + key_prefix: Redis key prefix for dedup keys. + """ + self._redis = redis + self._weights = weights or DEFAULT_WEIGHTS.copy() + self._alert_threshold = alert_threshold + self._dedup_window = dedup_window_seconds + self._key_prefix = key_prefix + + async def assess(self, bundle: SignalBundle) -> RiskAssessment: + """Assess a trade's risk based on all available signals. + + This method: + 1. Counts triggered signals + 2. Calculates weighted score with bonuses + 3. Checks deduplication + 4. Creates RiskAssessment + + Args: + bundle: SignalBundle containing trade and all signals. + + Returns: + RiskAssessment with final scoring and alert decision. + """ + # Calculate weighted score + weighted_score, signals_triggered = self.calculate_weighted_score(bundle) + + # Determine if should alert (before dedup check) + meets_threshold = weighted_score >= self._alert_threshold + + # Check deduplication + is_duplicate = False + if meets_threshold: + is_duplicate = await self._check_and_set_dedup( + bundle.wallet_address, + bundle.market_id, + ) + + should_alert = meets_threshold and not is_duplicate + + # Log assessment + if should_alert: + logger.info( + "Risk assessment triggered alert: wallet=%s, market=%s, " + "score=%.2f, signals=%d", + bundle.wallet_address[:10] + "...", + bundle.market_id[:10] + "...", + weighted_score, + signals_triggered, + ) + elif is_duplicate: + logger.debug( + "Risk assessment deduplicated: wallet=%s, market=%s", + bundle.wallet_address[:10] + "...", + bundle.market_id[:10] + "...", + ) + + return RiskAssessment( + trade_event=bundle.trade_event, + wallet_address=bundle.wallet_address, + market_id=bundle.market_id, + fresh_wallet_signal=bundle.fresh_wallet_signal, + size_anomaly_signal=bundle.size_anomaly_signal, + signals_triggered=signals_triggered, + weighted_score=weighted_score, + should_alert=should_alert, + ) + + def calculate_weighted_score( + self, bundle: SignalBundle + ) -> tuple[float, int]: + """Calculate weighted score from all signals. + + Applies per-signal weights and multi-signal bonuses. + + Args: + bundle: SignalBundle with all available signals. + + Returns: + Tuple of (weighted_score, signals_triggered_count). + """ + score = 0.0 + signals_triggered = 0 + + # Fresh wallet signal + if bundle.fresh_wallet_signal is not None: + weight = self._weights.get("fresh_wallet", 0.0) + score += bundle.fresh_wallet_signal.confidence * weight + signals_triggered += 1 + + # Size anomaly signal + if bundle.size_anomaly_signal is not None: + weight = self._weights.get("size_anomaly", 0.0) + score += bundle.size_anomaly_signal.confidence * weight + signals_triggered += 1 + + # Additional niche market weight + if bundle.size_anomaly_signal.is_niche_market: + niche_weight = self._weights.get("niche_market", 0.0) + score += bundle.size_anomaly_signal.confidence * niche_weight + + # Apply multi-signal bonus + if signals_triggered >= 3: + score *= MULTI_SIGNAL_BONUS_3 + elif signals_triggered >= 2: + score *= MULTI_SIGNAL_BONUS_2 + + # Cap at 1.0 + score = min(score, 1.0) + + return score, signals_triggered + + async def _check_and_set_dedup( + self, + wallet_address: str, + market_id: str, + ) -> bool: + """Check if this wallet/market combo was recently alerted. + + If not a duplicate, sets the dedup key with TTL. + + Args: + wallet_address: The trader's wallet address. + market_id: The market condition ID. + + Returns: + True if this is a duplicate (already alerted), False otherwise. + """ + key = f"{self._key_prefix}{wallet_address}:{market_id}" + + # Try to set with NX (only if not exists) + was_set = await self._redis.set( + key, + datetime.now(UTC).isoformat(), + nx=True, + ex=self._dedup_window, + ) + + # If was_set is None/False, key already existed = duplicate + return not was_set + + async def clear_dedup( + self, + wallet_address: str, + market_id: str, + ) -> bool: + """Clear dedup key for a wallet/market combo. + + Useful for testing or manual override. + + Args: + wallet_address: The trader's wallet address. + market_id: The market condition ID. + + Returns: + True if key was deleted, False if it didn't exist. + """ + key = f"{self._key_prefix}{wallet_address}:{market_id}" + deleted = await self._redis.delete(key) + return deleted > 0 + + async def assess_batch( + self, bundles: list[SignalBundle] + ) -> list[RiskAssessment]: + """Assess multiple trade bundles. + + Args: + bundles: List of SignalBundles to assess. + + Returns: + List of RiskAssessments. + """ + import asyncio + + tasks = [self.assess(bundle) for bundle in bundles] + return await asyncio.gather(*tasks) + + def get_weights(self) -> dict[str, float]: + """Get current signal weights. + + Returns: + Copy of the weights dictionary. + """ + return self._weights.copy() + + def set_weights(self, weights: dict[str, float]) -> None: + """Update signal weights. + + Useful for A/B testing different weight configurations. + + Args: + weights: New weights dictionary. + """ + self._weights = weights.copy() + logger.info("Updated risk scorer weights: %s", self._weights) diff --git a/tests/detector/test_scorer.py b/tests/detector/test_scorer.py new file mode 100644 index 0000000..293e607 --- /dev/null +++ b/tests/detector/test_scorer.py @@ -0,0 +1,690 @@ +"""Tests for composite risk scorer.""" + +from datetime import UTC, datetime +from decimal import Decimal +from unittest.mock import AsyncMock + +import pytest + +from polymarket_insider_tracker.detector.models import ( + FreshWalletSignal, + RiskAssessment, + SizeAnomalySignal, +) +from polymarket_insider_tracker.detector.scorer import ( + DEFAULT_ALERT_THRESHOLD, + DEFAULT_WEIGHTS, + MULTI_SIGNAL_BONUS_2, + RiskScorer, + SignalBundle, +) +from polymarket_insider_tracker.ingestor.models import MarketMetadata, Token, TradeEvent +from polymarket_insider_tracker.profiler.models import WalletProfile + +# ============================================================================ +# Fixtures +# ============================================================================ + + +@pytest.fixture +def mock_redis() -> AsyncMock: + """Create a mock Redis client.""" + mock = AsyncMock() + # Default: key doesn't exist (not a duplicate) + mock.set.return_value = True + mock.delete.return_value = 1 + return mock + + +@pytest.fixture +def sample_trade() -> TradeEvent: + """Create a sample trade event.""" + return TradeEvent( + market_id="market_abc123", + trade_id="tx_001", + wallet_address="0x1234567890abcdef1234567890abcdef12345678", + side="BUY", + outcome="Yes", + outcome_index=0, + price=Decimal("0.65"), + size=Decimal("10000"), + timestamp=datetime.now(UTC), + asset_id="token_123", + event_title="Test Market", + ) + + +@pytest.fixture +def sample_wallet_profile() -> WalletProfile: + """Create a sample wallet profile.""" + return WalletProfile( + address="0x1234567890abcdef1234567890abcdef12345678", + nonce=2, + first_seen=datetime.now(UTC), + age_hours=1.0, + is_fresh=True, + total_tx_count=2, + matic_balance=Decimal("1000000000000000000"), # 1 MATIC + usdc_balance=Decimal("1000000"), # 1 USDC + ) + + +@pytest.fixture +def sample_metadata() -> MarketMetadata: + """Create sample market metadata.""" + return MarketMetadata( + condition_id="market_abc123", + question="Will it rain tomorrow?", + description="Weather prediction market", + tokens=( + Token(token_id="token_123", outcome="Yes", price=Decimal("0.65")), + ), + category="science", + ) + + +@pytest.fixture +def fresh_wallet_signal( + sample_trade: TradeEvent, sample_wallet_profile: WalletProfile +) -> FreshWalletSignal: + """Create a sample fresh wallet signal.""" + return FreshWalletSignal( + trade_event=sample_trade, + wallet_profile=sample_wallet_profile, + confidence=0.8, + factors={"base": 0.5, "brand_new_bonus": 0.2, "large_trade_bonus": 0.1}, + ) + + +@pytest.fixture +def size_anomaly_signal( + sample_trade: TradeEvent, sample_metadata: MarketMetadata +) -> SizeAnomalySignal: + """Create a sample size anomaly signal.""" + return SizeAnomalySignal( + trade_event=sample_trade, + market_metadata=sample_metadata, + volume_impact=0.10, + book_impact=0.15, + is_niche_market=True, + confidence=0.7, + factors={"volume_impact": 0.4, "book_impact": 0.3}, + ) + + +# ============================================================================ +# SignalBundle Tests +# ============================================================================ + + +class TestSignalBundle: + """Tests for the SignalBundle dataclass.""" + + def test_bundle_with_no_signals(self, sample_trade: TradeEvent) -> None: + """Test bundle with only trade, no signals.""" + bundle = SignalBundle(trade_event=sample_trade) + + assert bundle.trade_event == sample_trade + assert bundle.fresh_wallet_signal is None + assert bundle.size_anomaly_signal is None + assert bundle.wallet_address == sample_trade.wallet_address + assert bundle.market_id == sample_trade.market_id + + def test_bundle_with_fresh_wallet_signal( + self, + sample_trade: TradeEvent, + fresh_wallet_signal: FreshWalletSignal, + ) -> None: + """Test bundle with fresh wallet signal.""" + bundle = SignalBundle( + trade_event=sample_trade, + fresh_wallet_signal=fresh_wallet_signal, + ) + + assert bundle.fresh_wallet_signal == fresh_wallet_signal + assert bundle.size_anomaly_signal is None + + def test_bundle_with_all_signals( + self, + sample_trade: TradeEvent, + fresh_wallet_signal: FreshWalletSignal, + size_anomaly_signal: SizeAnomalySignal, + ) -> None: + """Test bundle with all signal types.""" + bundle = SignalBundle( + trade_event=sample_trade, + fresh_wallet_signal=fresh_wallet_signal, + size_anomaly_signal=size_anomaly_signal, + ) + + assert bundle.fresh_wallet_signal == fresh_wallet_signal + assert bundle.size_anomaly_signal == size_anomaly_signal + + +# ============================================================================ +# RiskAssessment Tests +# ============================================================================ + + +class TestRiskAssessment: + """Tests for the RiskAssessment dataclass.""" + + def test_assessment_creation(self, sample_trade: TradeEvent) -> None: + """Test basic assessment creation.""" + assessment = RiskAssessment( + trade_event=sample_trade, + wallet_address=sample_trade.wallet_address, + market_id=sample_trade.market_id, + fresh_wallet_signal=None, + size_anomaly_signal=None, + signals_triggered=0, + weighted_score=0.0, + should_alert=False, + ) + + assert assessment.trade_event == sample_trade + assert assessment.signals_triggered == 0 + assert assessment.weighted_score == 0.0 + assert assessment.should_alert is False + assert assessment.assessment_id is not None + assert assessment.timestamp is not None + + def test_is_high_risk(self, sample_trade: TradeEvent) -> None: + """Test is_high_risk property.""" + high_risk = RiskAssessment( + trade_event=sample_trade, + wallet_address=sample_trade.wallet_address, + market_id=sample_trade.market_id, + fresh_wallet_signal=None, + size_anomaly_signal=None, + signals_triggered=1, + weighted_score=0.70, + should_alert=True, + ) + low_risk = RiskAssessment( + trade_event=sample_trade, + wallet_address=sample_trade.wallet_address, + market_id=sample_trade.market_id, + fresh_wallet_signal=None, + size_anomaly_signal=None, + signals_triggered=1, + weighted_score=0.69, + should_alert=True, + ) + + assert high_risk.is_high_risk is True + assert low_risk.is_high_risk is False + + def test_is_very_high_risk(self, sample_trade: TradeEvent) -> None: + """Test is_very_high_risk property.""" + very_high = RiskAssessment( + trade_event=sample_trade, + wallet_address=sample_trade.wallet_address, + market_id=sample_trade.market_id, + fresh_wallet_signal=None, + size_anomaly_signal=None, + signals_triggered=2, + weighted_score=0.85, + should_alert=True, + ) + high = RiskAssessment( + trade_event=sample_trade, + wallet_address=sample_trade.wallet_address, + market_id=sample_trade.market_id, + fresh_wallet_signal=None, + size_anomaly_signal=None, + signals_triggered=2, + weighted_score=0.84, + should_alert=True, + ) + + assert very_high.is_very_high_risk is True + assert high.is_very_high_risk is False + + def test_to_dict( + self, + sample_trade: TradeEvent, + fresh_wallet_signal: FreshWalletSignal, + ) -> None: + """Test to_dict serialization.""" + assessment = RiskAssessment( + trade_event=sample_trade, + wallet_address=sample_trade.wallet_address, + market_id=sample_trade.market_id, + fresh_wallet_signal=fresh_wallet_signal, + size_anomaly_signal=None, + signals_triggered=1, + weighted_score=0.65, + should_alert=True, + ) + + result = assessment.to_dict() + + assert result["wallet_address"] == sample_trade.wallet_address + assert result["market_id"] == sample_trade.market_id + assert result["signals_triggered"] == 1 + assert result["weighted_score"] == 0.65 + assert result["should_alert"] is True + assert result["has_fresh_wallet_signal"] is True + assert result["has_size_anomaly_signal"] is False + assert result["fresh_wallet_confidence"] == 0.8 + assert result["size_anomaly_confidence"] is None + + +# ============================================================================ +# RiskScorer Initialization Tests +# ============================================================================ + + +class TestRiskScorerInit: + """Tests for RiskScorer initialization.""" + + def test_default_initialization(self, mock_redis: AsyncMock) -> None: + """Test scorer initializes with default values.""" + scorer = RiskScorer(mock_redis) + + assert scorer._alert_threshold == DEFAULT_ALERT_THRESHOLD + assert scorer._weights == DEFAULT_WEIGHTS + assert scorer._dedup_window == 3600 + + def test_custom_configuration(self, mock_redis: AsyncMock) -> None: + """Test scorer with custom configuration.""" + custom_weights = {"fresh_wallet": 0.5, "size_anomaly": 0.5} + scorer = RiskScorer( + mock_redis, + weights=custom_weights, + alert_threshold=0.7, + dedup_window_seconds=1800, + ) + + assert scorer._alert_threshold == 0.7 + assert scorer._weights == custom_weights + assert scorer._dedup_window == 1800 + + +# ============================================================================ +# Weighted Score Calculation Tests +# ============================================================================ + + +class TestWeightedScoreCalculation: + """Tests for weighted score calculation.""" + + def test_no_signals_zero_score( + self, mock_redis: AsyncMock, sample_trade: TradeEvent + ) -> None: + """Test score is zero when no signals present.""" + scorer = RiskScorer(mock_redis) + bundle = SignalBundle(trade_event=sample_trade) + + score, count = scorer.calculate_weighted_score(bundle) + + assert score == 0.0 + assert count == 0 + + def test_fresh_wallet_only( + self, + mock_redis: AsyncMock, + sample_trade: TradeEvent, + fresh_wallet_signal: FreshWalletSignal, + ) -> None: + """Test score with only fresh wallet signal.""" + scorer = RiskScorer(mock_redis) + bundle = SignalBundle( + trade_event=sample_trade, + fresh_wallet_signal=fresh_wallet_signal, + ) + + score, count = scorer.calculate_weighted_score(bundle) + + # 0.8 confidence * 0.4 weight = 0.32 + expected = 0.8 * DEFAULT_WEIGHTS["fresh_wallet"] + assert score == pytest.approx(expected) + assert count == 1 + + def test_size_anomaly_only( + self, + mock_redis: AsyncMock, + sample_trade: TradeEvent, + size_anomaly_signal: SizeAnomalySignal, + ) -> None: + """Test score with only size anomaly signal.""" + scorer = RiskScorer(mock_redis) + bundle = SignalBundle( + trade_event=sample_trade, + size_anomaly_signal=size_anomaly_signal, + ) + + score, count = scorer.calculate_weighted_score(bundle) + + # 0.7 confidence * 0.35 weight + 0.7 * 0.25 niche weight = 0.42 + expected = ( + 0.7 * DEFAULT_WEIGHTS["size_anomaly"] + + 0.7 * DEFAULT_WEIGHTS["niche_market"] + ) + assert score == pytest.approx(expected) + assert count == 1 + + def test_size_anomaly_non_niche( + self, + mock_redis: AsyncMock, + sample_trade: TradeEvent, + sample_metadata: MarketMetadata, + ) -> None: + """Test size anomaly without niche bonus.""" + signal = SizeAnomalySignal( + trade_event=sample_trade, + market_metadata=sample_metadata, + volume_impact=0.10, + book_impact=0.15, + is_niche_market=False, + confidence=0.7, + factors={}, + ) + scorer = RiskScorer(mock_redis) + bundle = SignalBundle( + trade_event=sample_trade, + size_anomaly_signal=signal, + ) + + score, count = scorer.calculate_weighted_score(bundle) + + # 0.7 * 0.35 = 0.245 (no niche bonus) + expected = 0.7 * DEFAULT_WEIGHTS["size_anomaly"] + assert score == pytest.approx(expected) + + def test_multi_signal_bonus_two_signals( + self, + mock_redis: AsyncMock, + sample_trade: TradeEvent, + fresh_wallet_signal: FreshWalletSignal, + size_anomaly_signal: SizeAnomalySignal, + ) -> None: + """Test 20% bonus for two signals.""" + scorer = RiskScorer(mock_redis) + bundle = SignalBundle( + trade_event=sample_trade, + fresh_wallet_signal=fresh_wallet_signal, + size_anomaly_signal=size_anomaly_signal, + ) + + score, count = scorer.calculate_weighted_score(bundle) + + # Calculate base score + base = ( + 0.8 * DEFAULT_WEIGHTS["fresh_wallet"] + + 0.7 * DEFAULT_WEIGHTS["size_anomaly"] + + 0.7 * DEFAULT_WEIGHTS["niche_market"] + ) + expected = base * MULTI_SIGNAL_BONUS_2 + assert score == pytest.approx(expected) + assert count == 2 + + def test_score_capped_at_one( + self, + mock_redis: AsyncMock, + sample_trade: TradeEvent, + sample_wallet_profile: WalletProfile, + sample_metadata: MarketMetadata, + ) -> None: + """Test score is capped at 1.0.""" + # Create high confidence signals + fresh_signal = FreshWalletSignal( + trade_event=sample_trade, + wallet_profile=sample_wallet_profile, + confidence=1.0, + factors={}, + ) + size_signal = SizeAnomalySignal( + trade_event=sample_trade, + market_metadata=sample_metadata, + volume_impact=0.10, + book_impact=0.15, + is_niche_market=True, + confidence=1.0, + factors={}, + ) + + scorer = RiskScorer(mock_redis) + bundle = SignalBundle( + trade_event=sample_trade, + fresh_wallet_signal=fresh_signal, + size_anomaly_signal=size_signal, + ) + + score, count = scorer.calculate_weighted_score(bundle) + + assert score == 1.0 # Capped + assert count == 2 + + +# ============================================================================ +# Assess Method Tests +# ============================================================================ + + +class TestAssessMethod: + """Tests for the assess method.""" + + @pytest.mark.asyncio + async def test_assess_triggers_alert( + self, + mock_redis: AsyncMock, + sample_trade: TradeEvent, + fresh_wallet_signal: FreshWalletSignal, + size_anomaly_signal: SizeAnomalySignal, + ) -> None: + """Test assess triggers alert for high-risk trades.""" + scorer = RiskScorer(mock_redis) + bundle = SignalBundle( + trade_event=sample_trade, + fresh_wallet_signal=fresh_wallet_signal, + size_anomaly_signal=size_anomaly_signal, + ) + + assessment = await scorer.assess(bundle) + + assert assessment.should_alert is True + assert assessment.signals_triggered == 2 + assert assessment.weighted_score >= DEFAULT_ALERT_THRESHOLD + + @pytest.mark.asyncio + async def test_assess_no_alert_below_threshold( + self, mock_redis: AsyncMock, sample_trade: TradeEvent + ) -> None: + """Test assess does not alert for low-risk trades.""" + scorer = RiskScorer(mock_redis) + bundle = SignalBundle(trade_event=sample_trade) + + assessment = await scorer.assess(bundle) + + assert assessment.should_alert is False + assert assessment.signals_triggered == 0 + assert assessment.weighted_score == 0.0 + + @pytest.mark.asyncio + async def test_assess_deduplication( + self, + mock_redis: AsyncMock, + sample_trade: TradeEvent, + fresh_wallet_signal: FreshWalletSignal, + size_anomaly_signal: SizeAnomalySignal, + ) -> None: + """Test assess deduplicates repeated alerts.""" + # First call: key doesn't exist (returns True) + # Second call: key exists (returns False/None) + mock_redis.set.side_effect = [True, False] + + scorer = RiskScorer(mock_redis) + bundle = SignalBundle( + trade_event=sample_trade, + fresh_wallet_signal=fresh_wallet_signal, + size_anomaly_signal=size_anomaly_signal, + ) + + # First assessment should alert + assessment1 = await scorer.assess(bundle) + # Second assessment should be deduplicated + assessment2 = await scorer.assess(bundle) + + assert assessment1.should_alert is True + assert assessment2.should_alert is False + + @pytest.mark.asyncio + async def test_assess_preserves_signals( + self, + mock_redis: AsyncMock, + sample_trade: TradeEvent, + fresh_wallet_signal: FreshWalletSignal, + ) -> None: + """Test assess preserves original signals in assessment.""" + scorer = RiskScorer(mock_redis) + bundle = SignalBundle( + trade_event=sample_trade, + fresh_wallet_signal=fresh_wallet_signal, + ) + + assessment = await scorer.assess(bundle) + + assert assessment.fresh_wallet_signal == fresh_wallet_signal + assert assessment.size_anomaly_signal is None + + +# ============================================================================ +# Deduplication Tests +# ============================================================================ + + +class TestDeduplication: + """Tests for deduplication functionality.""" + + @pytest.mark.asyncio + async def test_check_and_set_dedup_new_key( + self, mock_redis: AsyncMock + ) -> None: + """Test dedup returns False for new key.""" + mock_redis.set.return_value = True + + scorer = RiskScorer(mock_redis) + is_dup = await scorer._check_and_set_dedup("0xwallet", "market123") + + assert is_dup is False + mock_redis.set.assert_called_once() + + @pytest.mark.asyncio + async def test_check_and_set_dedup_existing_key( + self, mock_redis: AsyncMock + ) -> None: + """Test dedup returns True for existing key.""" + mock_redis.set.return_value = False # Key exists, NX failed + + scorer = RiskScorer(mock_redis) + is_dup = await scorer._check_and_set_dedup("0xwallet", "market123") + + assert is_dup is True + + @pytest.mark.asyncio + async def test_clear_dedup(self, mock_redis: AsyncMock) -> None: + """Test clearing dedup key.""" + mock_redis.delete.return_value = 1 + + scorer = RiskScorer(mock_redis) + cleared = await scorer.clear_dedup("0xwallet", "market123") + + assert cleared is True + mock_redis.delete.assert_called_once() + + +# ============================================================================ +# Batch Analysis Tests +# ============================================================================ + + +class TestBatchAnalysis: + """Tests for batch assessment.""" + + @pytest.mark.asyncio + async def test_assess_batch( + self, + mock_redis: AsyncMock, + sample_wallet_profile: WalletProfile, + ) -> None: + """Test batch assessment returns assessments for all bundles.""" + scorer = RiskScorer(mock_redis) + + bundles = [] + for i in range(3): + trade = 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", + ) + signal = FreshWalletSignal( + trade_event=trade, + wallet_profile=sample_wallet_profile, + confidence=0.8, + factors={}, + ) + bundles.append( + SignalBundle(trade_event=trade, fresh_wallet_signal=signal) + ) + + assessments = await scorer.assess_batch(bundles) + + assert len(assessments) == 3 + assert all(isinstance(a, RiskAssessment) for a in assessments) + + @pytest.mark.asyncio + async def test_assess_batch_empty(self, mock_redis: AsyncMock) -> None: + """Test batch assessment with empty list.""" + scorer = RiskScorer(mock_redis) + + assessments = await scorer.assess_batch([]) + + assert assessments == [] + + +# ============================================================================ +# Weight Management Tests +# ============================================================================ + + +class TestWeightManagement: + """Tests for weight get/set functionality.""" + + def test_get_weights(self, mock_redis: AsyncMock) -> None: + """Test getting weights returns a copy.""" + scorer = RiskScorer(mock_redis) + + weights = scorer.get_weights() + + assert weights == DEFAULT_WEIGHTS + # Verify it's a copy, not the original + weights["fresh_wallet"] = 999 + assert scorer._weights["fresh_wallet"] != 999 + + def test_set_weights(self, mock_redis: AsyncMock) -> None: + """Test setting new weights.""" + scorer = RiskScorer(mock_redis) + new_weights = {"fresh_wallet": 0.5, "size_anomaly": 0.5} + + scorer.set_weights(new_weights) + + assert scorer._weights == new_weights + + def test_set_weights_makes_copy(self, mock_redis: AsyncMock) -> None: + """Test set_weights makes a copy of the input.""" + scorer = RiskScorer(mock_redis) + new_weights = {"fresh_wallet": 0.5, "size_anomaly": 0.5} + + scorer.set_weights(new_weights) + new_weights["fresh_wallet"] = 999 + + assert scorer._weights["fresh_wallet"] == 0.5