feat: implement wallet age analyzer with fresh wallet detection (#9)

## 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 <noreply@anthropic.com>
This commit is contained in:
Patrick Selamy
2026-01-04 15:41:34 -05:00
co-authored by Claude Opus 4.5
parent 9c13e4ca07
commit b81ac7c29f
5 changed files with 917 additions and 3 deletions
@@ -1,5 +1,8 @@
"""Wallet profiler - Blockchain analysis for trader intelligence.""" """Wallet profiler - Blockchain analysis for trader intelligence."""
from polymarket_insider_tracker.profiler.analyzer import (
WalletAnalyzer,
)
from polymarket_insider_tracker.profiler.chain import ( from polymarket_insider_tracker.profiler.chain import (
PolygonClient, PolygonClient,
PolygonClientError, PolygonClientError,
@@ -9,9 +12,12 @@ from polymarket_insider_tracker.profiler.chain import (
from polymarket_insider_tracker.profiler.models import ( from polymarket_insider_tracker.profiler.models import (
Transaction, Transaction,
WalletInfo, WalletInfo,
WalletProfile,
) )
__all__ = [ __all__ = [
# Analyzer
"WalletAnalyzer",
# Polygon Client # Polygon Client
"PolygonClient", "PolygonClient",
"PolygonClientError", "PolygonClientError",
@@ -20,4 +26,5 @@ __all__ = [
# Models # Models
"Transaction", "Transaction",
"WalletInfo", "WalletInfo",
"WalletProfile",
] ]
@@ -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]
@@ -1,7 +1,7 @@
"""Data models for the profiler module.""" """Data models for the profiler module."""
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import datetime from datetime import UTC, datetime
from decimal import Decimal from decimal import Decimal
@@ -60,3 +60,74 @@ class WalletInfo:
return None return None
delta = datetime.now(tz=self.first_transaction.timestamp.tzinfo) - self.first_transaction.timestamp delta = datetime.now(tz=self.first_transaction.timestamp.tzinfo) - self.first_transaction.timestamp
return delta.total_seconds() / 86400 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
+395
View File
@@ -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
+159 -1
View File
@@ -5,7 +5,7 @@ from decimal import Decimal
import pytest import pytest
from polymarket_insider_tracker.profiler.models import Transaction, WalletInfo from polymarket_insider_tracker.profiler.models import Transaction, WalletInfo, WalletProfile
class TestTransaction: class TestTransaction:
@@ -267,3 +267,161 @@ class TestTransactionEquality:
tx_set = {tx} tx_set = {tx}
assert tx in tx_set 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