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:
co-authored by
Claude Opus 4.5
parent
9c13e4ca07
commit
b81ac7c29f
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user