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
+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
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