From 78243a6fb87c991c0912b694d01d6d9996b31ad2 Mon Sep 17 00:00:00 2001 From: Patrick Selamy Date: Sun, 4 Jan 2026 15:49:52 -0500 Subject: [PATCH] feat: implement fresh wallet detection algorithm (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add FreshWalletDetector class that identifies trades from fresh wallets and generates alert signals with confidence scores. Features: - FreshWalletSignal dataclass with trade/wallet data and confidence - Configurable thresholds for nonce, age, and minimum trade size - Confidence scoring based on wallet freshness and trade characteristics - Batch analysis support with parallel processing - Integration with WalletAnalyzer from profiler module Confidence scoring: - Base: 0.5 (fresh wallet detected) - +0.2 if nonce == 0 (brand new) - +0.1 if age < 2 hours (very young) - +0.1 if trade size > $10,000 (large trade) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../detector/__init__.py | 5 + .../detector/fresh_wallet.py | 238 +++++++ .../detector/models.py | 73 +++ tests/detector/test_fresh_wallet.py | 608 ++++++++++++++++++ 4 files changed, 924 insertions(+) create mode 100644 src/polymarket_insider_tracker/detector/fresh_wallet.py create mode 100644 src/polymarket_insider_tracker/detector/models.py create mode 100644 tests/detector/test_fresh_wallet.py diff --git a/src/polymarket_insider_tracker/detector/__init__.py b/src/polymarket_insider_tracker/detector/__init__.py index 22a52b1..e82d348 100644 --- a/src/polymarket_insider_tracker/detector/__init__.py +++ b/src/polymarket_insider_tracker/detector/__init__.py @@ -1 +1,6 @@ """Anomaly detection layer - Suspicious activity identification.""" + +from polymarket_insider_tracker.detector.fresh_wallet import FreshWalletDetector +from polymarket_insider_tracker.detector.models import FreshWalletSignal + +__all__ = ["FreshWalletDetector", "FreshWalletSignal"] diff --git a/src/polymarket_insider_tracker/detector/fresh_wallet.py b/src/polymarket_insider_tracker/detector/fresh_wallet.py new file mode 100644 index 0000000..5a11cc8 --- /dev/null +++ b/src/polymarket_insider_tracker/detector/fresh_wallet.py @@ -0,0 +1,238 @@ +"""Fresh wallet detection algorithm. + +This module provides the FreshWalletDetector class that identifies trades +from fresh wallets and generates alert signals with confidence scores. +""" + +import logging +from decimal import Decimal + +from polymarket_insider_tracker.detector.models import FreshWalletSignal +from polymarket_insider_tracker.ingestor.models import TradeEvent +from polymarket_insider_tracker.profiler.analyzer import WalletAnalyzer +from polymarket_insider_tracker.profiler.models import WalletProfile + +logger = logging.getLogger(__name__) + +# Default configuration +DEFAULT_MIN_TRADE_SIZE = Decimal("1000") # $1,000 minimum trade size +DEFAULT_MAX_NONCE = 5 # Max nonce to be considered fresh +DEFAULT_MAX_AGE_HOURS = 48.0 # Max age in hours to be considered fresh + +# Confidence scoring constants +BASE_CONFIDENCE = 0.5 +BRAND_NEW_BONUS = 0.2 # nonce == 0 +VERY_YOUNG_BONUS = 0.1 # age < 2 hours +LARGE_TRADE_BONUS = 0.1 # trade size > $10,000 +LARGE_TRADE_THRESHOLD = Decimal("10000") + + +class FreshWalletDetector: + """Detector for fresh wallet trading patterns. + + This detector analyzes trade events for fresh wallet signals. A trade + is flagged as suspicious if: + 1. The wallet meets freshness criteria (low nonce, recent activity) + 2. The trade size meets minimum threshold + + The detector produces confidence scores based on multiple factors: + - Wallet nonce (brand new = higher confidence) + - Wallet age (very young = higher confidence) + - Trade size (larger = higher confidence) + + Example: + ```python + analyzer = WalletAnalyzer(polygon_client, redis=redis) + detector = FreshWalletDetector(analyzer) + + # Analyze a single trade + signal = await detector.analyze(trade_event) + if signal is not None: + print(f"Fresh wallet detected! Confidence: {signal.confidence}") + ``` + """ + + def __init__( + self, + wallet_analyzer: WalletAnalyzer, + *, + min_trade_size: Decimal = DEFAULT_MIN_TRADE_SIZE, + max_nonce: int = DEFAULT_MAX_NONCE, + max_age_hours: float = DEFAULT_MAX_AGE_HOURS, + ) -> None: + """Initialize the fresh wallet detector. + + Args: + wallet_analyzer: WalletAnalyzer instance for wallet profiling. + min_trade_size: Minimum trade size to analyze (default $1,000). + max_nonce: Maximum nonce to consider wallet fresh (default 5). + max_age_hours: Maximum age in hours to consider fresh (default 48). + """ + self._analyzer = wallet_analyzer + self._min_trade_size = min_trade_size + self._max_nonce = max_nonce + self._max_age_hours = max_age_hours + + async def analyze(self, trade: TradeEvent) -> FreshWalletSignal | None: + """Analyze a trade event for fresh wallet signals. + + This method: + 1. Filters out trades below the minimum size threshold + 2. Analyzes the trader's wallet profile + 3. Determines if the wallet is fresh + 4. Calculates confidence score based on multiple factors + + Args: + trade: TradeEvent to analyze. + + Returns: + FreshWalletSignal if the trade is from a fresh wallet, + None otherwise. + """ + # Filter by minimum trade size + if trade.notional_value < self._min_trade_size: + logger.debug( + "Trade %s below minimum size: %s < %s", + trade.trade_id, + trade.notional_value, + self._min_trade_size, + ) + return None + + # Get wallet profile + try: + profile = await self._analyzer.analyze(trade.wallet_address) + except Exception as e: + logger.warning( + "Failed to analyze wallet %s for trade %s: %s", + trade.wallet_address, + trade.trade_id, + e, + ) + return None + + # Check if wallet is fresh + if not self._is_wallet_fresh(profile): + logger.debug( + "Wallet %s is not fresh (nonce=%d, age=%s)", + trade.wallet_address, + profile.nonce, + profile.age_hours, + ) + return None + + # Calculate confidence score + confidence, factors = self.calculate_confidence(profile, trade) + + logger.info( + "Fresh wallet signal: wallet=%s, market=%s, size=%s, confidence=%.2f", + trade.wallet_address[:10] + "...", + trade.market_id[:10] + "...", + trade.notional_value, + confidence, + ) + + return FreshWalletSignal( + trade_event=trade, + wallet_profile=profile, + confidence=confidence, + factors=factors, + ) + + def _is_wallet_fresh(self, profile: WalletProfile) -> bool: + """Check if wallet meets freshness criteria. + + A wallet is considered fresh if: + 1. Nonce is at or below max_nonce threshold + 2. Age is unknown OR within max_age_hours + + Args: + profile: Wallet profile to check. + + Returns: + True if wallet is fresh. + """ + # Must have few transactions + if profile.nonce > self._max_nonce: + return False + + # If age is known, must be recent + return not (profile.age_hours is not None and profile.age_hours > self._max_age_hours) + + def calculate_confidence( + self, + profile: WalletProfile, + trade: TradeEvent, + ) -> tuple[float, dict[str, float]]: + """Calculate confidence score based on multiple factors. + + Confidence scoring: + - Base: 0.5 (fresh wallet detected) + - +0.2 if nonce == 0 (brand new wallet) + - +0.1 if age < 2 hours (very young) + - +0.1 if trade size > $10,000 (large trade) + + Final confidence is clamped to [0.0, 1.0]. + + Args: + profile: Wallet profile with nonce and age data. + trade: Trade event with size data. + + Returns: + Tuple of (confidence_score, factors_dict). + """ + factors: dict[str, float] = {"base": BASE_CONFIDENCE} + confidence = BASE_CONFIDENCE + + # Brand new wallet bonus + if profile.nonce == 0: + factors["brand_new"] = BRAND_NEW_BONUS + confidence += BRAND_NEW_BONUS + + # Very young wallet bonus + if profile.age_hours is not None and profile.age_hours < 2.0: + factors["very_young"] = VERY_YOUNG_BONUS + confidence += VERY_YOUNG_BONUS + + # Large trade bonus + if trade.notional_value > LARGE_TRADE_THRESHOLD: + factors["large_trade"] = LARGE_TRADE_BONUS + confidence += LARGE_TRADE_BONUS + + # Clamp to valid range + confidence = max(0.0, min(1.0, confidence)) + + return confidence, factors + + async def analyze_batch( + self, + trades: list[TradeEvent], + ) -> list[FreshWalletSignal]: + """Analyze multiple trades for fresh wallet signals. + + Processes trades in parallel for efficiency. + + Args: + trades: List of trades to analyze. + + Returns: + List of FreshWalletSignal for trades from fresh wallets. + """ + import asyncio + + tasks = [self.analyze(trade) for trade in trades] + results = await asyncio.gather(*tasks, return_exceptions=True) + + signals: list[FreshWalletSignal] = [] + for trade, result in zip(trades, results, strict=True): + if isinstance(result, BaseException): + logger.warning( + "Failed to analyze trade %s: %s", + trade.trade_id, + result, + ) + continue + if result is not None: + signals.append(result) + + return signals diff --git a/src/polymarket_insider_tracker/detector/models.py b/src/polymarket_insider_tracker/detector/models.py new file mode 100644 index 0000000..d958c2e --- /dev/null +++ b/src/polymarket_insider_tracker/detector/models.py @@ -0,0 +1,73 @@ +"""Data models for the detector module.""" + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from decimal import Decimal + +from polymarket_insider_tracker.ingestor.models import TradeEvent +from polymarket_insider_tracker.profiler.models import WalletProfile + + +@dataclass(frozen=True) +class FreshWalletSignal: + """Signal emitted when a fresh wallet makes a suspicious trade. + + This signal combines trade event data with wallet profile analysis + to produce a confidence score indicating the likelihood of suspicious + activity. + + Attributes: + trade_event: The original trade event that triggered this signal. + wallet_profile: Analyzed profile of the trader's wallet. + 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 + wallet_profile: WalletProfile + 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), + "wallet_nonce": self.wallet_profile.nonce, + "wallet_age_hours": self.wallet_profile.age_hours, + "wallet_is_fresh": self.wallet_profile.is_fresh, + "confidence": self.confidence, + "factors": self.factors, + "timestamp": self.timestamp.isoformat(), + } diff --git a/tests/detector/test_fresh_wallet.py b/tests/detector/test_fresh_wallet.py new file mode 100644 index 0000000..68a6900 --- /dev/null +++ b/tests/detector/test_fresh_wallet.py @@ -0,0 +1,608 @@ +"""Tests for the fresh wallet detector.""" + +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from unittest.mock import AsyncMock + +import pytest + +from polymarket_insider_tracker.detector.fresh_wallet import ( + BASE_CONFIDENCE, + BRAND_NEW_BONUS, + DEFAULT_MAX_AGE_HOURS, + DEFAULT_MAX_NONCE, + DEFAULT_MIN_TRADE_SIZE, + LARGE_TRADE_BONUS, + VERY_YOUNG_BONUS, + FreshWalletDetector, +) +from polymarket_insider_tracker.detector.models import FreshWalletSignal +from polymarket_insider_tracker.ingestor.models import TradeEvent +from polymarket_insider_tracker.profiler.models import WalletProfile + + +# Test fixtures +@pytest.fixture +def mock_wallet_analyzer(): + """Create a mock WalletAnalyzer.""" + return AsyncMock() + + +@pytest.fixture +def detector(mock_wallet_analyzer): + """Create a FreshWalletDetector with mocked analyzer.""" + return FreshWalletDetector(mock_wallet_analyzer) + + +def create_trade_event( + *, + wallet_address: str = "0x1234567890123456789012345678901234567890", + market_id: str = "market123", + trade_id: str = "trade123", + price: Decimal = Decimal("0.5"), + size: Decimal = Decimal("2000"), + side: str = "BUY", +) -> TradeEvent: + """Create a TradeEvent for testing.""" + return TradeEvent( + market_id=market_id, + trade_id=trade_id, + wallet_address=wallet_address, + side=side, + outcome="Yes", + outcome_index=0, + price=price, + size=size, + timestamp=datetime.now(UTC), + asset_id="asset123", + ) + + +def create_wallet_profile( + *, + address: str = "0x1234567890123456789012345678901234567890", + nonce: int = 2, + age_hours: float | None = 24.0, + is_fresh: bool = True, +) -> WalletProfile: + """Create a WalletProfile for testing.""" + first_seen = None + if age_hours is not None: + first_seen = datetime.now(UTC) - timedelta(hours=age_hours) + + return WalletProfile( + address=address.lower(), + nonce=nonce, + first_seen=first_seen, + age_hours=age_hours, + is_fresh=is_fresh, + total_tx_count=nonce, + matic_balance=Decimal("1000000000000000000"), # 1 MATIC + usdc_balance=Decimal("1000000000"), # 1000 USDC + fresh_threshold=5, + ) + + +# === Test FreshWalletSignal Model === + + +class TestFreshWalletSignal: + """Tests for FreshWalletSignal dataclass.""" + + def test_wallet_address_property(self): + """Test wallet_address property returns trade wallet.""" + trade = create_trade_event(wallet_address="0xabc123") + profile = create_wallet_profile(address="0xabc123") + signal = FreshWalletSignal( + trade_event=trade, + wallet_profile=profile, + confidence=0.7, + factors={"base": 0.5}, + ) + assert signal.wallet_address == "0xabc123" + + def test_market_id_property(self): + """Test market_id property returns trade market.""" + trade = create_trade_event(market_id="market456") + profile = create_wallet_profile() + signal = FreshWalletSignal( + trade_event=trade, + wallet_profile=profile, + confidence=0.7, + factors={"base": 0.5}, + ) + assert signal.market_id == "market456" + + def test_trade_size_usdc_property(self): + """Test trade_size_usdc returns notional value.""" + trade = create_trade_event(price=Decimal("0.5"), size=Decimal("2000")) + profile = create_wallet_profile() + signal = FreshWalletSignal( + trade_event=trade, + wallet_profile=profile, + confidence=0.7, + factors={"base": 0.5}, + ) + assert signal.trade_size_usdc == Decimal("1000") + + def test_is_high_confidence(self): + """Test is_high_confidence threshold.""" + trade = create_trade_event() + profile = create_wallet_profile() + + # At threshold + signal = FreshWalletSignal( + trade_event=trade, + wallet_profile=profile, + confidence=0.7, + factors={}, + ) + assert signal.is_high_confidence is True + + # Below threshold + signal_low = FreshWalletSignal( + trade_event=trade, + wallet_profile=profile, + confidence=0.69, + factors={}, + ) + assert signal_low.is_high_confidence is False + + def test_is_very_high_confidence(self): + """Test is_very_high_confidence threshold.""" + trade = create_trade_event() + profile = create_wallet_profile() + + # At threshold + signal = FreshWalletSignal( + trade_event=trade, + wallet_profile=profile, + confidence=0.85, + factors={}, + ) + assert signal.is_very_high_confidence is True + + # Below threshold + signal_low = FreshWalletSignal( + trade_event=trade, + wallet_profile=profile, + confidence=0.84, + factors={}, + ) + assert signal_low.is_very_high_confidence is False + + def test_to_dict(self): + """Test serialization to dictionary.""" + trade = create_trade_event( + wallet_address="0xtest", + market_id="market1", + trade_id="tx1", + price=Decimal("0.6"), + size=Decimal("5000"), + side="BUY", + ) + profile = create_wallet_profile(address="0xtest", nonce=0, age_hours=1.0) + signal = FreshWalletSignal( + trade_event=trade, + wallet_profile=profile, + confidence=0.8, + factors={"base": 0.5, "brand_new": 0.2}, + ) + + result = signal.to_dict() + + assert result["wallet_address"] == "0xtest" + assert result["market_id"] == "market1" + assert result["trade_id"] == "tx1" + assert result["trade_size"] == "3000.0" + assert result["trade_side"] == "BUY" + assert result["trade_price"] == "0.6" + assert result["wallet_nonce"] == 0 + assert result["wallet_age_hours"] == 1.0 + assert result["wallet_is_fresh"] is True + assert result["confidence"] == 0.8 + assert result["factors"] == {"base": 0.5, "brand_new": 0.2} + assert "timestamp" in result + + +# === Test FreshWalletDetector Initialization === + + +class TestFreshWalletDetectorInit: + """Tests for FreshWalletDetector initialization.""" + + def test_default_config(self, mock_wallet_analyzer): + """Test detector initializes with default config.""" + detector = FreshWalletDetector(mock_wallet_analyzer) + assert detector._min_trade_size == DEFAULT_MIN_TRADE_SIZE + assert detector._max_nonce == DEFAULT_MAX_NONCE + assert detector._max_age_hours == DEFAULT_MAX_AGE_HOURS + + def test_custom_config(self, mock_wallet_analyzer): + """Test detector accepts custom config.""" + detector = FreshWalletDetector( + mock_wallet_analyzer, + min_trade_size=Decimal("5000"), + max_nonce=10, + max_age_hours=72.0, + ) + assert detector._min_trade_size == Decimal("5000") + assert detector._max_nonce == 10 + assert detector._max_age_hours == 72.0 + + +# === Test FreshWalletDetector.analyze === + + +class TestFreshWalletDetectorAnalyze: + """Tests for FreshWalletDetector.analyze method.""" + + async def test_filters_small_trades(self, detector, mock_wallet_analyzer): + """Test that trades below minimum size are filtered out.""" + trade = create_trade_event( + price=Decimal("0.1"), + size=Decimal("100"), # notional = $10 + ) + + result = await detector.analyze(trade) + + assert result is None + mock_wallet_analyzer.analyze.assert_not_called() + + async def test_detects_fresh_wallet(self, detector, mock_wallet_analyzer): + """Test detection of fresh wallet trade.""" + trade = create_trade_event( + price=Decimal("0.5"), + size=Decimal("2000"), # notional = $1000 + ) + profile = create_wallet_profile(nonce=2, age_hours=24.0, is_fresh=True) + mock_wallet_analyzer.analyze.return_value = profile + + result = await detector.analyze(trade) + + assert result is not None + assert isinstance(result, FreshWalletSignal) + assert result.trade_event == trade + assert result.wallet_profile == profile + assert result.confidence >= BASE_CONFIDENCE + + async def test_filters_non_fresh_wallet(self, detector, mock_wallet_analyzer): + """Test that non-fresh wallets are filtered out.""" + trade = create_trade_event( + price=Decimal("0.5"), + size=Decimal("4000"), # notional = $2000 + ) + profile = create_wallet_profile(nonce=10, age_hours=100.0, is_fresh=False) + mock_wallet_analyzer.analyze.return_value = profile + + result = await detector.analyze(trade) + + assert result is None + + async def test_handles_analyzer_error(self, detector, mock_wallet_analyzer): + """Test graceful handling of analyzer errors.""" + trade = create_trade_event() + mock_wallet_analyzer.analyze.side_effect = Exception("RPC error") + + result = await detector.analyze(trade) + + assert result is None + + async def test_wallet_at_nonce_threshold(self, detector, mock_wallet_analyzer): + """Test wallet exactly at max_nonce threshold.""" + trade = create_trade_event(size=Decimal("3000")) + profile = create_wallet_profile(nonce=5, age_hours=24.0) # At threshold + mock_wallet_analyzer.analyze.return_value = profile + + result = await detector.analyze(trade) + + assert result is not None + + async def test_wallet_above_nonce_threshold(self, detector, mock_wallet_analyzer): + """Test wallet above max_nonce threshold.""" + trade = create_trade_event(size=Decimal("3000")) + profile = create_wallet_profile(nonce=6, age_hours=24.0) # Above threshold + mock_wallet_analyzer.analyze.return_value = profile + + result = await detector.analyze(trade) + + assert result is None + + async def test_wallet_at_age_threshold(self, detector, mock_wallet_analyzer): + """Test wallet exactly at max_age_hours threshold.""" + trade = create_trade_event(size=Decimal("3000")) + profile = create_wallet_profile(nonce=2, age_hours=48.0) # At threshold + mock_wallet_analyzer.analyze.return_value = profile + + result = await detector.analyze(trade) + + assert result is not None + + async def test_wallet_above_age_threshold(self, detector, mock_wallet_analyzer): + """Test wallet above max_age_hours threshold.""" + trade = create_trade_event(size=Decimal("3000")) + profile = create_wallet_profile(nonce=2, age_hours=49.0) # Above threshold + mock_wallet_analyzer.analyze.return_value = profile + + result = await detector.analyze(trade) + + assert result is None + + async def test_wallet_with_unknown_age(self, detector, mock_wallet_analyzer): + """Test wallet with unknown age (None).""" + trade = create_trade_event(size=Decimal("3000")) + profile = create_wallet_profile(nonce=2, age_hours=None) + mock_wallet_analyzer.analyze.return_value = profile + + result = await detector.analyze(trade) + + # Should pass since age is unknown + assert result is not None + + +# === Test Confidence Scoring === + + +class TestConfidenceScoring: + """Tests for confidence score calculation.""" + + def test_base_confidence_only(self, detector): + """Test base confidence for fresh wallet.""" + profile = create_wallet_profile(nonce=3, age_hours=24.0) + trade = create_trade_event(size=Decimal("2000")) # $1000 notional + + confidence, factors = detector.calculate_confidence(profile, trade) + + assert confidence == BASE_CONFIDENCE + assert factors == {"base": BASE_CONFIDENCE} + + def test_brand_new_bonus(self, detector): + """Test bonus for brand new wallet (nonce=0).""" + profile = create_wallet_profile(nonce=0, age_hours=24.0) + trade = create_trade_event(size=Decimal("2000")) + + confidence, factors = detector.calculate_confidence(profile, trade) + + expected = BASE_CONFIDENCE + BRAND_NEW_BONUS + assert confidence == expected + assert factors["brand_new"] == BRAND_NEW_BONUS + + def test_very_young_bonus(self, detector): + """Test bonus for very young wallet (age < 2 hours).""" + profile = create_wallet_profile(nonce=3, age_hours=1.5) + trade = create_trade_event(size=Decimal("2000")) + + confidence, factors = detector.calculate_confidence(profile, trade) + + expected = BASE_CONFIDENCE + VERY_YOUNG_BONUS + assert confidence == expected + assert factors["very_young"] == VERY_YOUNG_BONUS + + def test_large_trade_bonus(self, detector): + """Test bonus for large trade (> $10,000).""" + profile = create_wallet_profile(nonce=3, age_hours=24.0) + trade = create_trade_event( + price=Decimal("0.5"), + size=Decimal("25000"), # $12,500 notional + ) + + confidence, factors = detector.calculate_confidence(profile, trade) + + expected = BASE_CONFIDENCE + LARGE_TRADE_BONUS + assert confidence == expected + assert factors["large_trade"] == LARGE_TRADE_BONUS + + def test_all_bonuses_combined(self, detector): + """Test all bonuses stacking.""" + profile = create_wallet_profile(nonce=0, age_hours=0.5) + trade = create_trade_event( + price=Decimal("0.5"), + size=Decimal("30000"), # $15,000 notional + ) + + confidence, factors = detector.calculate_confidence(profile, trade) + + expected = BASE_CONFIDENCE + BRAND_NEW_BONUS + VERY_YOUNG_BONUS + LARGE_TRADE_BONUS + assert confidence == expected + assert factors["brand_new"] == BRAND_NEW_BONUS + assert factors["very_young"] == VERY_YOUNG_BONUS + assert factors["large_trade"] == LARGE_TRADE_BONUS + + def test_confidence_clamped_to_max(self, detector): + """Test confidence is clamped to 1.0 max.""" + # Create scenario where total would exceed 1.0 + # BASE=0.5 + BRAND_NEW=0.2 + VERY_YOUNG=0.1 + LARGE_TRADE=0.1 = 0.9 + # This is less than 1.0, so let's verify clamping works + profile = create_wallet_profile(nonce=0, age_hours=0.5) + trade = create_trade_event(size=Decimal("30000")) + + confidence, _ = detector.calculate_confidence(profile, trade) + + assert confidence <= 1.0 + + def test_no_bonus_at_age_boundary(self, detector): + """Test no bonus when age exactly at 2 hours.""" + profile = create_wallet_profile(nonce=3, age_hours=2.0) + trade = create_trade_event(size=Decimal("2000")) + + confidence, factors = detector.calculate_confidence(profile, trade) + + assert "very_young" not in factors + assert confidence == BASE_CONFIDENCE + + def test_no_bonus_at_trade_size_boundary(self, detector): + """Test no bonus when trade exactly at $10,000.""" + profile = create_wallet_profile(nonce=3, age_hours=24.0) + trade = create_trade_event( + price=Decimal("0.5"), + size=Decimal("20000"), # $10,000 notional exactly + ) + + confidence, factors = detector.calculate_confidence(profile, trade) + + # Exactly at threshold should NOT get bonus + assert "large_trade" not in factors + assert confidence == BASE_CONFIDENCE + + def test_unknown_age_no_young_bonus(self, detector): + """Test no young bonus when age is None.""" + profile = create_wallet_profile(nonce=3, age_hours=None) + trade = create_trade_event(size=Decimal("2000")) + + confidence, factors = detector.calculate_confidence(profile, trade) + + assert "very_young" not in factors + assert confidence == BASE_CONFIDENCE + + +# === Test Batch Analysis === + + +class TestBatchAnalysis: + """Tests for batch trade analysis.""" + + async def test_analyze_batch_success(self, detector, mock_wallet_analyzer): + """Test analyzing multiple trades.""" + trades = [ + create_trade_event(trade_id="trade1", size=Decimal("3000")), + create_trade_event(trade_id="trade2", size=Decimal("4000")), + ] + profile = create_wallet_profile(nonce=2, age_hours=24.0) + mock_wallet_analyzer.analyze.return_value = profile + + results = await detector.analyze_batch(trades) + + assert len(results) == 2 + assert all(isinstance(r, FreshWalletSignal) for r in results) + + async def test_analyze_batch_filters_small(self, detector, mock_wallet_analyzer): + """Test batch analysis filters small trades.""" + trades = [ + create_trade_event(trade_id="trade1", size=Decimal("3000")), # Above threshold + create_trade_event(trade_id="trade2", size=Decimal("100")), # Below threshold + ] + profile = create_wallet_profile(nonce=2, age_hours=24.0) + mock_wallet_analyzer.analyze.return_value = profile + + results = await detector.analyze_batch(trades) + + assert len(results) == 1 + assert results[0].trade_event.trade_id == "trade1" + + async def test_analyze_batch_handles_errors(self, detector, mock_wallet_analyzer): + """Test batch analysis handles individual errors gracefully.""" + trades = [ + create_trade_event(trade_id="trade1", size=Decimal("3000")), + create_trade_event(trade_id="trade2", size=Decimal("4000")), + ] + + # First call succeeds, second fails + profile = create_wallet_profile(nonce=2, age_hours=24.0) + mock_wallet_analyzer.analyze.side_effect = [profile, Exception("Error")] + + results = await detector.analyze_batch(trades) + + assert len(results) == 1 + + async def test_analyze_batch_empty_list(self, detector): + """Test batch analysis with empty list.""" + results = await detector.analyze_batch([]) + + assert results == [] + + async def test_analyze_batch_all_filtered(self, detector, mock_wallet_analyzer): + """Test batch analysis when all trades are filtered.""" + trades = [ + create_trade_event(trade_id="trade1", size=Decimal("100")), + create_trade_event(trade_id="trade2", size=Decimal("50")), + ] + + results = await detector.analyze_batch(trades) + + assert results == [] + mock_wallet_analyzer.analyze.assert_not_called() + + +# === Integration-Style Tests === + + +class TestDetectorIntegration: + """Integration-style tests for complete detector flow.""" + + async def test_full_detection_flow(self, mock_wallet_analyzer): + """Test complete detection flow from trade to signal.""" + detector = FreshWalletDetector(mock_wallet_analyzer) + + # Create a suspicious trade + trade = create_trade_event( + wallet_address="0xfresh123", + market_id="suspicious_market", + price=Decimal("0.65"), + size=Decimal("20000"), # $13,000 notional + ) + + # Create a fresh wallet profile + profile = create_wallet_profile( + address="0xfresh123", + nonce=0, # Brand new + age_hours=0.5, # Very young + ) + mock_wallet_analyzer.analyze.return_value = profile + + # Analyze + signal = await detector.analyze(trade) + + # Verify signal + assert signal is not None + assert signal.wallet_address == "0xfresh123" + assert signal.market_id == "suspicious_market" + assert signal.is_high_confidence is True + assert "brand_new" in signal.factors + assert "very_young" in signal.factors + assert "large_trade" in signal.factors + + async def test_custom_thresholds(self, mock_wallet_analyzer): + """Test detection with custom thresholds.""" + detector = FreshWalletDetector( + mock_wallet_analyzer, + min_trade_size=Decimal("5000"), + max_nonce=3, + max_age_hours=24.0, + ) + + # Trade that would pass default but fails custom thresholds + trade = create_trade_event(size=Decimal("8000")) # $4000 notional + profile = create_wallet_profile(nonce=4, age_hours=30.0) + mock_wallet_analyzer.analyze.return_value = profile + + # Should fail min_trade_size check + result = await detector.analyze(trade) + assert result is None + + async def test_edge_case_exact_min_trade_size(self, detector, mock_wallet_analyzer): + """Test trade exactly at minimum size threshold.""" + trade = create_trade_event( + price=Decimal("0.5"), + size=Decimal("2000"), # $1000 notional exactly at default + ) + profile = create_wallet_profile(nonce=2) + mock_wallet_analyzer.analyze.return_value = profile + + result = await detector.analyze(trade) + + # At threshold should pass + assert result is not None + + async def test_edge_case_below_min_trade_size(self, detector, mock_wallet_analyzer): + """Test trade just below minimum size threshold.""" + trade = create_trade_event( + price=Decimal("0.5"), + size=Decimal("1999"), # $999.50 - just under $1000 + ) + profile = create_wallet_profile(nonce=2) + mock_wallet_analyzer.analyze.return_value = profile + + result = await detector.analyze(trade) + + # Below threshold should fail + assert result is None