From b81ac7c29fa23ec8dc9495dc6aa4ed978eeb3c5b Mon Sep 17 00:00:00 2001 From: Patrick Selamy Date: Sun, 4 Jan 2026 15:41:34 -0500 Subject: [PATCH] feat: implement wallet age analyzer with fresh wallet detection (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add WalletAnalyzer class for detecting fresh/suspicious wallets - Add WalletProfile dataclass with freshness scoring - Comprehensive test coverage (31 new tests, 82 total) ## Features - Fresh wallet detection based on nonce threshold (default <5) - Wallet age calculation from first transaction - USDC balance tracking on Polygon - Freshness score (0-1) combining nonce and age factors - Result caching with configurable TTL - Batch analysis support for multiple wallets ## Usage ```python analyzer = WalletAnalyzer(polygon_client, redis=redis) # Full analysis profile = await analyzer.analyze("0x...") print(f"Fresh: {profile.is_fresh}, Score: {profile.freshness_score}") # Quick check is_fresh = await analyzer.is_fresh("0x...") # Batch analysis fresh_wallets = await analyzer.get_fresh_wallets(addresses) ``` ## Test plan - [x] All 82 profiler tests pass - [x] Ruff lint passes - [x] Mypy type check passes Closes #9 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../profiler/__init__.py | 7 + .../profiler/analyzer.py | 283 +++++++++++++ .../profiler/models.py | 75 +++- tests/profiler/test_analyzer.py | 395 ++++++++++++++++++ tests/profiler/test_models.py | 160 ++++++- 5 files changed, 917 insertions(+), 3 deletions(-) create mode 100644 src/polymarket_insider_tracker/profiler/analyzer.py create mode 100644 tests/profiler/test_analyzer.py diff --git a/src/polymarket_insider_tracker/profiler/__init__.py b/src/polymarket_insider_tracker/profiler/__init__.py index 71b9a9d..38a099f 100644 --- a/src/polymarket_insider_tracker/profiler/__init__.py +++ b/src/polymarket_insider_tracker/profiler/__init__.py @@ -1,5 +1,8 @@ """Wallet profiler - Blockchain analysis for trader intelligence.""" +from polymarket_insider_tracker.profiler.analyzer import ( + WalletAnalyzer, +) from polymarket_insider_tracker.profiler.chain import ( PolygonClient, PolygonClientError, @@ -9,9 +12,12 @@ from polymarket_insider_tracker.profiler.chain import ( from polymarket_insider_tracker.profiler.models import ( Transaction, WalletInfo, + WalletProfile, ) __all__ = [ + # Analyzer + "WalletAnalyzer", # Polygon Client "PolygonClient", "PolygonClientError", @@ -20,4 +26,5 @@ __all__ = [ # Models "Transaction", "WalletInfo", + "WalletProfile", ] diff --git a/src/polymarket_insider_tracker/profiler/analyzer.py b/src/polymarket_insider_tracker/profiler/analyzer.py new file mode 100644 index 0000000..0651319 --- /dev/null +++ b/src/polymarket_insider_tracker/profiler/analyzer.py @@ -0,0 +1,283 @@ +"""Wallet analysis for fresh wallet detection. + +This module provides wallet analysis capabilities to identify potentially +suspicious wallets based on their on-chain activity patterns. +""" + +import json +import logging +from datetime import UTC, datetime +from decimal import Decimal + +from redis.asyncio import Redis + +from polymarket_insider_tracker.profiler.chain import PolygonClient +from polymarket_insider_tracker.profiler.models import WalletProfile + +logger = logging.getLogger(__name__) + +# USDC contract address on Polygon +USDC_POLYGON_ADDRESS = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" + +# Default configuration +DEFAULT_FRESH_THRESHOLD = 5 # Max nonce to be considered fresh +DEFAULT_PROFILE_CACHE_TTL = 300 # 5 minutes + + +class WalletAnalyzer: + """Analyzes wallets to detect fresh wallet patterns. + + This class provides wallet analysis functionality including: + - Fresh wallet detection based on transaction count + - Wallet age calculation from first transaction + - Balance queries for MATIC and USDC + - Caching of analysis results + + Example: + ```python + client = PolygonClient("https://polygon-rpc.com", redis=redis) + analyzer = WalletAnalyzer(client, redis=redis) + + # Full analysis + profile = await analyzer.analyze("0x...") + print(f"Fresh: {profile.is_fresh}, Score: {profile.freshness_score}") + + # Quick check + is_fresh = await analyzer.is_fresh("0x...") + ``` + """ + + def __init__( + self, + polygon_client: PolygonClient, + *, + redis: Redis | None = None, + fresh_threshold: int = DEFAULT_FRESH_THRESHOLD, + cache_ttl_seconds: int = DEFAULT_PROFILE_CACHE_TTL, + usdc_address: str = USDC_POLYGON_ADDRESS, + ) -> None: + """Initialize the wallet analyzer. + + Args: + polygon_client: PolygonClient for blockchain queries. + redis: Optional Redis client for caching profiles. + fresh_threshold: Maximum nonce to be considered fresh. + cache_ttl_seconds: How long to cache analysis results. + usdc_address: USDC token contract address on Polygon. + """ + self._client = polygon_client + self._redis = redis + self._fresh_threshold = fresh_threshold + self._cache_ttl = cache_ttl_seconds + self._usdc_address = usdc_address + self._cache_prefix = "wallet_profile:" + + def _cache_key(self, address: str) -> str: + """Generate cache key for wallet profile.""" + return f"{self._cache_prefix}{address.lower()}" + + async def _get_cached_profile(self, address: str) -> WalletProfile | None: + """Get cached profile if available.""" + if not self._redis: + return None + + try: + key = self._cache_key(address) + cached = await self._redis.get(key) + if cached is None: + return None + + data = json.loads(cached if isinstance(cached, str) else cached.decode()) + return WalletProfile( + address=data["address"], + nonce=data["nonce"], + first_seen=datetime.fromisoformat(data["first_seen"]) if data["first_seen"] else None, + age_hours=data["age_hours"], + is_fresh=data["is_fresh"], + total_tx_count=data["total_tx_count"], + matic_balance=Decimal(data["matic_balance"]), + usdc_balance=Decimal(data["usdc_balance"]), + analyzed_at=datetime.fromisoformat(data["analyzed_at"]), + fresh_threshold=data["fresh_threshold"], + ) + except Exception as e: + logger.warning("Failed to get cached profile for %s: %s", address, e) + return None + + async def _cache_profile(self, profile: WalletProfile) -> None: + """Cache a wallet profile.""" + if not self._redis: + return + + try: + key = self._cache_key(profile.address) + data = { + "address": profile.address, + "nonce": profile.nonce, + "first_seen": profile.first_seen.isoformat() if profile.first_seen else None, + "age_hours": profile.age_hours, + "is_fresh": profile.is_fresh, + "total_tx_count": profile.total_tx_count, + "matic_balance": str(profile.matic_balance), + "usdc_balance": str(profile.usdc_balance), + "analyzed_at": profile.analyzed_at.isoformat(), + "fresh_threshold": profile.fresh_threshold, + } + await self._redis.set(key, json.dumps(data), ex=self._cache_ttl) + except Exception as e: + logger.warning("Failed to cache profile for %s: %s", profile.address, e) + + async def analyze( + self, + address: str, + *, + force_refresh: bool = False, + ) -> WalletProfile: + """Analyze a wallet and return its profile. + + This method performs a comprehensive analysis of the wallet including: + - Transaction count (nonce) + - First transaction timestamp and wallet age + - MATIC and USDC balances + - Fresh wallet determination + + Args: + address: Wallet address to analyze. + force_refresh: If True, bypass cache and re-analyze. + + Returns: + WalletProfile with analysis results. + """ + address = address.lower() + + # Check cache unless force refresh + if not force_refresh: + cached = await self._get_cached_profile(address) + if cached is not None: + logger.debug("Using cached profile for %s", address) + return cached + + # Get wallet info from blockchain + wallet_info = await self._client.get_wallet_info(address) + + # Get USDC balance + try: + usdc_balance = await self._client.get_token_balance(address, self._usdc_address) + except Exception as e: + logger.warning("Failed to get USDC balance for %s: %s", address, e) + usdc_balance = Decimal(0) + + # Calculate age from first transaction + first_seen: datetime | None = None + age_hours: float | None = None + + if wallet_info.first_transaction is not None: + first_seen = wallet_info.first_transaction.timestamp + now = datetime.now(UTC) + delta = now - first_seen + age_hours = delta.total_seconds() / 3600 + + # Determine if fresh + is_fresh = self._is_wallet_fresh(wallet_info.transaction_count, age_hours) + + # Build profile + profile = WalletProfile( + address=address, + nonce=wallet_info.transaction_count, + first_seen=first_seen, + age_hours=age_hours, + is_fresh=is_fresh, + total_tx_count=wallet_info.transaction_count, + matic_balance=wallet_info.balance_wei, + usdc_balance=usdc_balance, + fresh_threshold=self._fresh_threshold, + ) + + # Cache the result + await self._cache_profile(profile) + + return profile + + def _is_wallet_fresh(self, nonce: int, age_hours: float | None) -> bool: + """Determine if wallet should be considered fresh. + + A wallet is fresh if: + - Transaction count (nonce) is below the threshold + - AND either age is unknown OR age is less than 48 hours + + Args: + nonce: Transaction count. + age_hours: Wallet age in hours, or None if unknown. + + Returns: + True if wallet is fresh. + """ + # Must have few transactions + if nonce >= self._fresh_threshold: + return False + + # If age is known, must be recent (within 48 hours) + return not (age_hours is not None and age_hours > 48) + + async def is_fresh(self, address: str) -> bool: + """Quick check if wallet is fresh. + + This is a convenience method that returns just the freshness status. + It uses cached data if available. + + Args: + address: Wallet address to check. + + Returns: + True if wallet is fresh. + """ + profile = await self.analyze(address) + return profile.is_fresh + + async def analyze_batch( + self, + addresses: list[str], + *, + force_refresh: bool = False, + ) -> dict[str, WalletProfile]: + """Analyze multiple wallets. + + Analyzes wallets in parallel for efficiency. + + Args: + addresses: List of wallet addresses to analyze. + force_refresh: If True, bypass cache for all wallets. + + Returns: + Dictionary mapping address (lowercase) to WalletProfile. + """ + import asyncio + + results: dict[str, WalletProfile] = {} + + # Analyze all in parallel + tasks = [self.analyze(addr, force_refresh=force_refresh) for addr in addresses] + profiles = await asyncio.gather(*tasks, return_exceptions=True) + + for addr, profile in zip(addresses, profiles, strict=True): + if isinstance(profile, BaseException): + logger.warning("Failed to analyze %s: %s", addr, profile) + continue + results[addr.lower()] = profile + + return results + + async def get_fresh_wallets( + self, + addresses: list[str], + ) -> list[str]: + """Filter addresses to only return fresh wallets. + + Args: + addresses: List of wallet addresses to check. + + Returns: + List of addresses that are fresh wallets. + """ + profiles = await self.analyze_batch(addresses) + return [addr for addr, profile in profiles.items() if profile.is_fresh] diff --git a/src/polymarket_insider_tracker/profiler/models.py b/src/polymarket_insider_tracker/profiler/models.py index f63a2a9..9d85891 100644 --- a/src/polymarket_insider_tracker/profiler/models.py +++ b/src/polymarket_insider_tracker/profiler/models.py @@ -1,7 +1,7 @@ """Data models for the profiler module.""" -from dataclasses import dataclass -from datetime import datetime +from dataclasses import dataclass, field +from datetime import UTC, datetime from decimal import Decimal @@ -60,3 +60,74 @@ class WalletInfo: return None delta = datetime.now(tz=self.first_transaction.timestamp.tzinfo) - self.first_transaction.timestamp return delta.total_seconds() / 86400 + + +@dataclass(frozen=True) +class WalletProfile: + """Complete wallet analysis profile. + + This is the result of analyzing a wallet's on-chain activity to determine + if it exhibits suspicious behavior patterns like fresh wallet trading. + + Attributes: + address: The wallet address (lowercase). + nonce: Transaction count (number of outgoing transactions). + first_seen: Timestamp of first transaction, if available. + age_hours: Wallet age in hours since first transaction. + is_fresh: True if wallet meets fresh wallet criteria. + total_tx_count: Total number of transactions (same as nonce for now). + matic_balance: MATIC balance in Wei. + usdc_balance: USDC balance in smallest unit (6 decimals). + analyzed_at: Timestamp when this profile was created. + fresh_threshold: The threshold used to determine freshness. + """ + + address: str + nonce: int + first_seen: datetime | None + age_hours: float | None + is_fresh: bool + total_tx_count: int + matic_balance: Decimal + usdc_balance: Decimal + analyzed_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + fresh_threshold: int = 5 + + @property + def age_days(self) -> float | None: + """Return wallet age in days.""" + if self.age_hours is None: + return None + return self.age_hours / 24.0 + + @property + def matic_balance_formatted(self) -> Decimal: + """Return MATIC balance in human-readable format (18 decimals).""" + return self.matic_balance / Decimal("1000000000000000000") + + @property + def usdc_balance_formatted(self) -> Decimal: + """Return USDC balance in human-readable format (6 decimals).""" + return self.usdc_balance / Decimal("1000000") + + @property + def is_brand_new(self) -> bool: + """Return True if wallet has never transacted (nonce = 0).""" + return self.nonce == 0 + + @property + def freshness_score(self) -> float: + """Return a 0-1 score where 1 is maximally fresh. + + Score is based on: + - Nonce (fewer = fresher) + - Age (younger = fresher) + """ + # Nonce component: 1.0 at 0, 0.0 at threshold or higher + nonce_score = max(0.0, 1.0 - (self.nonce / self.fresh_threshold)) + + # Age component: 1.0 at 0 hours, 0.0 at 48 hours or more + age_score = 1.0 if self.age_hours is None else max(0.0, 1.0 - self.age_hours / 48.0) + + # Weighted average: nonce is slightly more important + return 0.6 * nonce_score + 0.4 * age_score diff --git a/tests/profiler/test_analyzer.py b/tests/profiler/test_analyzer.py new file mode 100644 index 0000000..7afb5c8 --- /dev/null +++ b/tests/profiler/test_analyzer.py @@ -0,0 +1,395 @@ +"""Tests for the wallet analyzer.""" + +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from unittest.mock import AsyncMock + +import pytest + +from polymarket_insider_tracker.profiler.analyzer import ( + DEFAULT_FRESH_THRESHOLD, + USDC_POLYGON_ADDRESS, + WalletAnalyzer, +) +from polymarket_insider_tracker.profiler.models import Transaction, WalletInfo + +# Valid Ethereum addresses for testing +VALID_ADDRESS = "0x742d35Cc6634C0532925a3b844Bc9e7595f5eaE2" +VALID_ADDRESS_2 = "0x8ba1f109551bD432803012645Ac136ddd64DBA72" + + +class TestWalletAnalyzerInit: + """Tests for WalletAnalyzer initialization.""" + + def test_init_default(self) -> None: + """Test initialization with defaults.""" + client = AsyncMock() + analyzer = WalletAnalyzer(client) + + assert analyzer._client is client + assert analyzer._redis is None + assert analyzer._fresh_threshold == DEFAULT_FRESH_THRESHOLD + assert analyzer._usdc_address == USDC_POLYGON_ADDRESS + + def test_init_with_redis(self) -> None: + """Test initialization with Redis.""" + client = AsyncMock() + redis = AsyncMock() + analyzer = WalletAnalyzer(client, redis=redis) + + assert analyzer._redis is redis + + def test_init_custom_threshold(self) -> None: + """Test initialization with custom threshold.""" + client = AsyncMock() + analyzer = WalletAnalyzer(client, fresh_threshold=10) + + assert analyzer._fresh_threshold == 10 + + +class TestWalletAnalyzerAnalyze: + """Tests for the analyze method.""" + + @pytest.fixture + def mock_client(self) -> AsyncMock: + """Create a mock PolygonClient.""" + client = AsyncMock() + client.get_wallet_info = AsyncMock() + client.get_token_balance = AsyncMock(return_value=Decimal("1000000")) + return client + + @pytest.fixture + def mock_redis(self) -> AsyncMock: + """Create a mock Redis client.""" + redis = AsyncMock() + redis.get = AsyncMock(return_value=None) + redis.set = AsyncMock() + return redis + + @pytest.mark.asyncio + async def test_analyze_fresh_wallet(self, mock_client: AsyncMock) -> None: + """Test analyzing a fresh wallet.""" + first_tx = Transaction( + hash="0xabc", + block_number=1000, + timestamp=datetime.now(UTC) - timedelta(hours=12), + from_address="0xfaucet", + to_address=VALID_ADDRESS.lower(), + value=Decimal("1000000000000000000"), + gas_used=21000, + gas_price=Decimal("50000000000"), + ) + mock_client.get_wallet_info.return_value = WalletInfo( + address=VALID_ADDRESS.lower(), + transaction_count=3, + balance_wei=Decimal("5000000000000000000"), + first_transaction=first_tx, + ) + + analyzer = WalletAnalyzer(mock_client) + profile = await analyzer.analyze(VALID_ADDRESS) + + assert profile.address == VALID_ADDRESS.lower() + assert profile.nonce == 3 + assert profile.is_fresh is True + assert profile.first_seen is not None + assert profile.age_hours is not None + assert 11 < profile.age_hours < 13 + + @pytest.mark.asyncio + async def test_analyze_old_wallet(self, mock_client: AsyncMock) -> None: + """Test analyzing an old wallet with many transactions.""" + first_tx = Transaction( + hash="0xabc", + block_number=1000, + timestamp=datetime.now(UTC) - timedelta(days=365), + from_address="0xfaucet", + to_address=VALID_ADDRESS.lower(), + value=Decimal("1000000000000000000"), + gas_used=21000, + gas_price=Decimal("50000000000"), + ) + mock_client.get_wallet_info.return_value = WalletInfo( + address=VALID_ADDRESS.lower(), + transaction_count=500, + balance_wei=Decimal("100000000000000000000"), + first_transaction=first_tx, + ) + + analyzer = WalletAnalyzer(mock_client) + profile = await analyzer.analyze(VALID_ADDRESS) + + assert profile.nonce == 500 + assert profile.is_fresh is False + assert profile.age_hours is not None + assert profile.age_hours > 8000 # Over 365 days in hours + + @pytest.mark.asyncio + async def test_analyze_brand_new_wallet(self, mock_client: AsyncMock) -> None: + """Test analyzing a wallet with no transactions.""" + mock_client.get_wallet_info.return_value = WalletInfo( + address=VALID_ADDRESS.lower(), + transaction_count=0, + balance_wei=Decimal("1000000000000000000"), + first_transaction=None, + ) + + analyzer = WalletAnalyzer(mock_client) + profile = await analyzer.analyze(VALID_ADDRESS) + + assert profile.nonce == 0 + assert profile.is_fresh is True + assert profile.is_brand_new is True + assert profile.first_seen is None + assert profile.age_hours is None + + @pytest.mark.asyncio + async def test_analyze_uses_cache( + self, mock_client: AsyncMock, mock_redis: AsyncMock + ) -> None: + """Test that analyze uses cached data.""" + cached_data = { + "address": VALID_ADDRESS.lower(), + "nonce": 2, + "first_seen": datetime.now(UTC).isoformat(), + "age_hours": 6.0, + "is_fresh": True, + "total_tx_count": 2, + "matic_balance": "1000000000000000000", + "usdc_balance": "500000", + "analyzed_at": datetime.now(UTC).isoformat(), + "fresh_threshold": 5, + } + mock_redis.get = AsyncMock(return_value=str(cached_data).replace("'", '"').encode()) + + # Actually mock it properly with json + import json + mock_redis.get = AsyncMock(return_value=json.dumps(cached_data).encode()) + + analyzer = WalletAnalyzer(mock_client, redis=mock_redis) + profile = await analyzer.analyze(VALID_ADDRESS) + + assert profile.address == VALID_ADDRESS.lower() + assert profile.nonce == 2 + mock_client.get_wallet_info.assert_not_called() + + @pytest.mark.asyncio + async def test_analyze_force_refresh( + self, mock_client: AsyncMock, mock_redis: AsyncMock + ) -> None: + """Test that force_refresh bypasses cache.""" + import json + + cached_data = { + "address": VALID_ADDRESS.lower(), + "nonce": 1, + "first_seen": None, + "age_hours": None, + "is_fresh": True, + "total_tx_count": 1, + "matic_balance": "1000", + "usdc_balance": "0", + "analyzed_at": datetime.now(UTC).isoformat(), + "fresh_threshold": 5, + } + mock_redis.get = AsyncMock(return_value=json.dumps(cached_data).encode()) + + mock_client.get_wallet_info.return_value = WalletInfo( + address=VALID_ADDRESS.lower(), + transaction_count=10, + balance_wei=Decimal("5000000000000000000"), + first_transaction=None, + ) + + analyzer = WalletAnalyzer(mock_client, redis=mock_redis) + profile = await analyzer.analyze(VALID_ADDRESS, force_refresh=True) + + assert profile.nonce == 10 # From fresh query, not cache + mock_client.get_wallet_info.assert_called_once() + + @pytest.mark.asyncio + async def test_analyze_handles_usdc_error(self, mock_client: AsyncMock) -> None: + """Test that USDC balance error is handled gracefully.""" + mock_client.get_wallet_info.return_value = WalletInfo( + address=VALID_ADDRESS.lower(), + transaction_count=1, + balance_wei=Decimal("1000000000000000000"), + first_transaction=None, + ) + mock_client.get_token_balance.side_effect = Exception("Token query failed") + + analyzer = WalletAnalyzer(mock_client) + profile = await analyzer.analyze(VALID_ADDRESS) + + assert profile.usdc_balance == Decimal(0) + + +class TestWalletAnalyzerIsFresh: + """Tests for the is_fresh method.""" + + @pytest.fixture + def mock_client(self) -> AsyncMock: + """Create a mock PolygonClient.""" + client = AsyncMock() + client.get_wallet_info = AsyncMock() + client.get_token_balance = AsyncMock(return_value=Decimal("0")) + return client + + @pytest.mark.asyncio + async def test_is_fresh_true(self, mock_client: AsyncMock) -> None: + """Test is_fresh returns True for fresh wallet.""" + mock_client.get_wallet_info.return_value = WalletInfo( + address=VALID_ADDRESS.lower(), + transaction_count=2, + balance_wei=Decimal("1000000000000000000"), + first_transaction=None, + ) + + analyzer = WalletAnalyzer(mock_client) + result = await analyzer.is_fresh(VALID_ADDRESS) + + assert result is True + + @pytest.mark.asyncio + async def test_is_fresh_false(self, mock_client: AsyncMock) -> None: + """Test is_fresh returns False for old wallet.""" + mock_client.get_wallet_info.return_value = WalletInfo( + address=VALID_ADDRESS.lower(), + transaction_count=100, + balance_wei=Decimal("1000000000000000000"), + first_transaction=None, + ) + + analyzer = WalletAnalyzer(mock_client) + result = await analyzer.is_fresh(VALID_ADDRESS) + + assert result is False + + +class TestWalletAnalyzerFreshnessLogic: + """Tests for freshness determination logic.""" + + @pytest.fixture + def mock_client(self) -> AsyncMock: + """Create a mock PolygonClient.""" + return AsyncMock() + + def test_is_wallet_fresh_low_nonce_no_age(self, mock_client: AsyncMock) -> None: + """Test fresh wallet with low nonce and unknown age.""" + analyzer = WalletAnalyzer(mock_client, fresh_threshold=5) + result = analyzer._is_wallet_fresh(nonce=2, age_hours=None) + assert result is True + + def test_is_wallet_fresh_low_nonce_young_age(self, mock_client: AsyncMock) -> None: + """Test fresh wallet with low nonce and young age.""" + analyzer = WalletAnalyzer(mock_client, fresh_threshold=5) + result = analyzer._is_wallet_fresh(nonce=2, age_hours=12.0) + assert result is True + + def test_is_wallet_fresh_low_nonce_old_age(self, mock_client: AsyncMock) -> None: + """Test not fresh when nonce is low but age is old.""" + analyzer = WalletAnalyzer(mock_client, fresh_threshold=5) + result = analyzer._is_wallet_fresh(nonce=2, age_hours=100.0) + assert result is False + + def test_is_wallet_fresh_high_nonce(self, mock_client: AsyncMock) -> None: + """Test not fresh when nonce is high.""" + analyzer = WalletAnalyzer(mock_client, fresh_threshold=5) + result = analyzer._is_wallet_fresh(nonce=10, age_hours=12.0) + assert result is False + + def test_is_wallet_fresh_at_threshold(self, mock_client: AsyncMock) -> None: + """Test not fresh when nonce equals threshold.""" + analyzer = WalletAnalyzer(mock_client, fresh_threshold=5) + result = analyzer._is_wallet_fresh(nonce=5, age_hours=12.0) + assert result is False + + def test_is_wallet_fresh_at_age_boundary(self, mock_client: AsyncMock) -> None: + """Test at 48 hour boundary.""" + analyzer = WalletAnalyzer(mock_client, fresh_threshold=5) + + result_under = analyzer._is_wallet_fresh(nonce=2, age_hours=47.9) + assert result_under is True + + result_over = analyzer._is_wallet_fresh(nonce=2, age_hours=48.1) + assert result_over is False + + +class TestWalletAnalyzerBatch: + """Tests for batch analysis.""" + + @pytest.fixture + def mock_client(self) -> AsyncMock: + """Create a mock PolygonClient.""" + client = AsyncMock() + client.get_wallet_info = AsyncMock() + client.get_token_balance = AsyncMock(return_value=Decimal("0")) + return client + + @pytest.mark.asyncio + async def test_analyze_batch(self, mock_client: AsyncMock) -> None: + """Test batch analysis.""" + mock_client.get_wallet_info.side_effect = [ + WalletInfo( + address=VALID_ADDRESS.lower(), + transaction_count=2, + balance_wei=Decimal("1000"), + first_transaction=None, + ), + WalletInfo( + address=VALID_ADDRESS_2.lower(), + transaction_count=100, + balance_wei=Decimal("2000"), + first_transaction=None, + ), + ] + + analyzer = WalletAnalyzer(mock_client) + profiles = await analyzer.analyze_batch([VALID_ADDRESS, VALID_ADDRESS_2]) + + assert len(profiles) == 2 + assert profiles[VALID_ADDRESS.lower()].is_fresh is True + assert profiles[VALID_ADDRESS_2.lower()].is_fresh is False + + @pytest.mark.asyncio + async def test_analyze_batch_handles_errors(self, mock_client: AsyncMock) -> None: + """Test batch analysis handles individual failures.""" + mock_client.get_wallet_info.side_effect = [ + WalletInfo( + address=VALID_ADDRESS.lower(), + transaction_count=2, + balance_wei=Decimal("1000"), + first_transaction=None, + ), + Exception("RPC error"), + ] + + analyzer = WalletAnalyzer(mock_client) + profiles = await analyzer.analyze_batch([VALID_ADDRESS, VALID_ADDRESS_2]) + + assert len(profiles) == 1 + assert VALID_ADDRESS.lower() in profiles + + @pytest.mark.asyncio + async def test_get_fresh_wallets(self, mock_client: AsyncMock) -> None: + """Test filtering to only fresh wallets.""" + mock_client.get_wallet_info.side_effect = [ + WalletInfo( + address=VALID_ADDRESS.lower(), + transaction_count=2, + balance_wei=Decimal("1000"), + first_transaction=None, + ), + WalletInfo( + address=VALID_ADDRESS_2.lower(), + transaction_count=100, + balance_wei=Decimal("2000"), + first_transaction=None, + ), + ] + + analyzer = WalletAnalyzer(mock_client) + fresh = await analyzer.get_fresh_wallets([VALID_ADDRESS, VALID_ADDRESS_2]) + + assert len(fresh) == 1 + assert VALID_ADDRESS.lower() in fresh diff --git a/tests/profiler/test_models.py b/tests/profiler/test_models.py index 6e99476..fb8974c 100644 --- a/tests/profiler/test_models.py +++ b/tests/profiler/test_models.py @@ -5,7 +5,7 @@ from decimal import Decimal import pytest -from polymarket_insider_tracker.profiler.models import Transaction, WalletInfo +from polymarket_insider_tracker.profiler.models import Transaction, WalletInfo, WalletProfile class TestTransaction: @@ -267,3 +267,161 @@ class TestTransactionEquality: tx_set = {tx} assert tx in tx_set + + +class TestWalletProfile: + """Tests for the WalletProfile dataclass.""" + + @pytest.fixture + def fresh_profile(self) -> WalletProfile: + """Create a fresh wallet profile.""" + return WalletProfile( + address="0xfresh", + nonce=2, + first_seen=datetime.now(UTC) - timedelta(hours=6), + age_hours=6.0, + is_fresh=True, + total_tx_count=2, + matic_balance=Decimal("1000000000000000000"), # 1 MATIC + usdc_balance=Decimal("1000000"), # 1 USDC + fresh_threshold=5, + ) + + @pytest.fixture + def old_profile(self) -> WalletProfile: + """Create an old wallet profile.""" + return WalletProfile( + address="0xold", + nonce=500, + first_seen=datetime.now(UTC) - timedelta(days=365), + age_hours=365 * 24, + is_fresh=False, + total_tx_count=500, + matic_balance=Decimal("100000000000000000000"), # 100 MATIC + usdc_balance=Decimal("10000000000"), # 10000 USDC + fresh_threshold=5, + ) + + def test_profile_creation(self, fresh_profile: WalletProfile) -> None: + """Test creating a wallet profile.""" + assert fresh_profile.address == "0xfresh" + assert fresh_profile.nonce == 2 + assert fresh_profile.is_fresh is True + assert fresh_profile.age_hours == 6.0 + + def test_age_days(self, fresh_profile: WalletProfile) -> None: + """Test age_days property.""" + assert fresh_profile.age_days == 0.25 # 6 hours = 0.25 days + + def test_age_days_none(self) -> None: + """Test age_days when age_hours is None.""" + profile = WalletProfile( + address="0xnew", + nonce=0, + first_seen=None, + age_hours=None, + is_fresh=True, + total_tx_count=0, + matic_balance=Decimal("0"), + usdc_balance=Decimal("0"), + ) + assert profile.age_days is None + + def test_matic_balance_formatted(self, fresh_profile: WalletProfile) -> None: + """Test MATIC balance formatting.""" + assert fresh_profile.matic_balance_formatted == Decimal("1") + + def test_usdc_balance_formatted(self, fresh_profile: WalletProfile) -> None: + """Test USDC balance formatting.""" + assert fresh_profile.usdc_balance_formatted == Decimal("1") + + def test_is_brand_new(self) -> None: + """Test is_brand_new property.""" + brand_new = WalletProfile( + address="0xnew", + nonce=0, + first_seen=None, + age_hours=None, + is_fresh=True, + total_tx_count=0, + matic_balance=Decimal("0"), + usdc_balance=Decimal("0"), + ) + assert brand_new.is_brand_new is True + + not_brand_new = WalletProfile( + address="0xold", + nonce=1, + first_seen=datetime.now(UTC), + age_hours=1.0, + is_fresh=True, + total_tx_count=1, + matic_balance=Decimal("0"), + usdc_balance=Decimal("0"), + ) + assert not_brand_new.is_brand_new is False + + def test_freshness_score_brand_new(self) -> None: + """Test freshness score for brand new wallet.""" + profile = WalletProfile( + address="0xnew", + nonce=0, + first_seen=None, + age_hours=None, + is_fresh=True, + total_tx_count=0, + matic_balance=Decimal("0"), + usdc_balance=Decimal("0"), + fresh_threshold=5, + ) + # nonce_score = 1.0 (0/5 = 0, 1-0 = 1) + # age_score = 1.0 (None = assumed new) + # score = 0.6 * 1.0 + 0.4 * 1.0 = 1.0 + assert profile.freshness_score == 1.0 + + def test_freshness_score_old_wallet(self, old_profile: WalletProfile) -> None: + """Test freshness score for old wallet.""" + # nonce_score = max(0, 1 - 500/5) = 0 + # age_score = max(0, 1 - 8760/48) = 0 + # score = 0 + assert old_profile.freshness_score == 0.0 + + def test_freshness_score_moderate(self) -> None: + """Test freshness score for moderately fresh wallet.""" + profile = WalletProfile( + address="0xmoderate", + nonce=2, + first_seen=datetime.now(UTC) - timedelta(hours=24), + age_hours=24.0, + is_fresh=True, + total_tx_count=2, + matic_balance=Decimal("0"), + usdc_balance=Decimal("0"), + fresh_threshold=5, + ) + # nonce_score = 1 - 2/5 = 0.6 + # age_score = 1 - 24/48 = 0.5 + # score = 0.6 * 0.6 + 0.4 * 0.5 = 0.36 + 0.2 = 0.56 + assert profile.freshness_score == pytest.approx(0.56) + + def test_profile_frozen(self, fresh_profile: WalletProfile) -> None: + """Test that wallet profile is immutable.""" + with pytest.raises(AttributeError): + fresh_profile.nonce = 100 # type: ignore[misc] + + def test_analyzed_at_default(self) -> None: + """Test that analyzed_at has a default.""" + before = datetime.now(UTC) + profile = WalletProfile( + address="0x1", + nonce=0, + first_seen=None, + age_hours=None, + is_fresh=True, + total_tx_count=0, + matic_balance=Decimal("0"), + usdc_balance=Decimal("0"), + ) + after = datetime.now(UTC) + + assert before <= profile.analyzed_at <= after