Files
polymarket-insider-tracker/src/polymarket_insider_tracker/profiler/analyzer.py
T
Patrick SelamyandClaude Opus 4.5 1f4f1fa557 fix: resolve linting and formatting issues for CI
- Use contextlib.suppress instead of try/except/pass (SIM105)
- Prefix unused fixture arguments with underscore (ARG002)
- Replace asyncio.TimeoutError with TimeoutError (UP041)
- Apply ruff formatting to all files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:09:14 -05:00

286 lines
9.5 KiB
Python

"""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]