Merge pull request #36 from pselamy/fix/14
feat: implement fresh wallet detection algorithm (#14)
This commit is contained in:
@@ -1 +1,6 @@
|
||||
"""Anomaly detection layer - Suspicious activity identification."""
|
||||
|
||||
from polymarket_insider_tracker.detector.fresh_wallet import FreshWalletDetector
|
||||
from polymarket_insider_tracker.detector.models import FreshWalletSignal
|
||||
|
||||
__all__ = ["FreshWalletDetector", "FreshWalletSignal"]
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Fresh wallet detection algorithm.
|
||||
|
||||
This module provides the FreshWalletDetector class that identifies trades
|
||||
from fresh wallets and generates alert signals with confidence scores.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
from polymarket_insider_tracker.detector.models import FreshWalletSignal
|
||||
from polymarket_insider_tracker.ingestor.models import TradeEvent
|
||||
from polymarket_insider_tracker.profiler.analyzer import WalletAnalyzer
|
||||
from polymarket_insider_tracker.profiler.models import WalletProfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default configuration
|
||||
DEFAULT_MIN_TRADE_SIZE = Decimal("1000") # $1,000 minimum trade size
|
||||
DEFAULT_MAX_NONCE = 5 # Max nonce to be considered fresh
|
||||
DEFAULT_MAX_AGE_HOURS = 48.0 # Max age in hours to be considered fresh
|
||||
|
||||
# Confidence scoring constants
|
||||
BASE_CONFIDENCE = 0.5
|
||||
BRAND_NEW_BONUS = 0.2 # nonce == 0
|
||||
VERY_YOUNG_BONUS = 0.1 # age < 2 hours
|
||||
LARGE_TRADE_BONUS = 0.1 # trade size > $10,000
|
||||
LARGE_TRADE_THRESHOLD = Decimal("10000")
|
||||
|
||||
|
||||
class FreshWalletDetector:
|
||||
"""Detector for fresh wallet trading patterns.
|
||||
|
||||
This detector analyzes trade events for fresh wallet signals. A trade
|
||||
is flagged as suspicious if:
|
||||
1. The wallet meets freshness criteria (low nonce, recent activity)
|
||||
2. The trade size meets minimum threshold
|
||||
|
||||
The detector produces confidence scores based on multiple factors:
|
||||
- Wallet nonce (brand new = higher confidence)
|
||||
- Wallet age (very young = higher confidence)
|
||||
- Trade size (larger = higher confidence)
|
||||
|
||||
Example:
|
||||
```python
|
||||
analyzer = WalletAnalyzer(polygon_client, redis=redis)
|
||||
detector = FreshWalletDetector(analyzer)
|
||||
|
||||
# Analyze a single trade
|
||||
signal = await detector.analyze(trade_event)
|
||||
if signal is not None:
|
||||
print(f"Fresh wallet detected! Confidence: {signal.confidence}")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
wallet_analyzer: WalletAnalyzer,
|
||||
*,
|
||||
min_trade_size: Decimal = DEFAULT_MIN_TRADE_SIZE,
|
||||
max_nonce: int = DEFAULT_MAX_NONCE,
|
||||
max_age_hours: float = DEFAULT_MAX_AGE_HOURS,
|
||||
) -> None:
|
||||
"""Initialize the fresh wallet detector.
|
||||
|
||||
Args:
|
||||
wallet_analyzer: WalletAnalyzer instance for wallet profiling.
|
||||
min_trade_size: Minimum trade size to analyze (default $1,000).
|
||||
max_nonce: Maximum nonce to consider wallet fresh (default 5).
|
||||
max_age_hours: Maximum age in hours to consider fresh (default 48).
|
||||
"""
|
||||
self._analyzer = wallet_analyzer
|
||||
self._min_trade_size = min_trade_size
|
||||
self._max_nonce = max_nonce
|
||||
self._max_age_hours = max_age_hours
|
||||
|
||||
async def analyze(self, trade: TradeEvent) -> FreshWalletSignal | None:
|
||||
"""Analyze a trade event for fresh wallet signals.
|
||||
|
||||
This method:
|
||||
1. Filters out trades below the minimum size threshold
|
||||
2. Analyzes the trader's wallet profile
|
||||
3. Determines if the wallet is fresh
|
||||
4. Calculates confidence score based on multiple factors
|
||||
|
||||
Args:
|
||||
trade: TradeEvent to analyze.
|
||||
|
||||
Returns:
|
||||
FreshWalletSignal if the trade is from a fresh wallet,
|
||||
None otherwise.
|
||||
"""
|
||||
# Filter by minimum trade size
|
||||
if trade.notional_value < self._min_trade_size:
|
||||
logger.debug(
|
||||
"Trade %s below minimum size: %s < %s",
|
||||
trade.trade_id,
|
||||
trade.notional_value,
|
||||
self._min_trade_size,
|
||||
)
|
||||
return None
|
||||
|
||||
# Get wallet profile
|
||||
try:
|
||||
profile = await self._analyzer.analyze(trade.wallet_address)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to analyze wallet %s for trade %s: %s",
|
||||
trade.wallet_address,
|
||||
trade.trade_id,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
# Check if wallet is fresh
|
||||
if not self._is_wallet_fresh(profile):
|
||||
logger.debug(
|
||||
"Wallet %s is not fresh (nonce=%d, age=%s)",
|
||||
trade.wallet_address,
|
||||
profile.nonce,
|
||||
profile.age_hours,
|
||||
)
|
||||
return None
|
||||
|
||||
# Calculate confidence score
|
||||
confidence, factors = self.calculate_confidence(profile, trade)
|
||||
|
||||
logger.info(
|
||||
"Fresh wallet signal: wallet=%s, market=%s, size=%s, confidence=%.2f",
|
||||
trade.wallet_address[:10] + "...",
|
||||
trade.market_id[:10] + "...",
|
||||
trade.notional_value,
|
||||
confidence,
|
||||
)
|
||||
|
||||
return FreshWalletSignal(
|
||||
trade_event=trade,
|
||||
wallet_profile=profile,
|
||||
confidence=confidence,
|
||||
factors=factors,
|
||||
)
|
||||
|
||||
def _is_wallet_fresh(self, profile: WalletProfile) -> bool:
|
||||
"""Check if wallet meets freshness criteria.
|
||||
|
||||
A wallet is considered fresh if:
|
||||
1. Nonce is at or below max_nonce threshold
|
||||
2. Age is unknown OR within max_age_hours
|
||||
|
||||
Args:
|
||||
profile: Wallet profile to check.
|
||||
|
||||
Returns:
|
||||
True if wallet is fresh.
|
||||
"""
|
||||
# Must have few transactions
|
||||
if profile.nonce > self._max_nonce:
|
||||
return False
|
||||
|
||||
# If age is known, must be recent
|
||||
return not (profile.age_hours is not None and profile.age_hours > self._max_age_hours)
|
||||
|
||||
def calculate_confidence(
|
||||
self,
|
||||
profile: WalletProfile,
|
||||
trade: TradeEvent,
|
||||
) -> tuple[float, dict[str, float]]:
|
||||
"""Calculate confidence score based on multiple factors.
|
||||
|
||||
Confidence scoring:
|
||||
- Base: 0.5 (fresh wallet detected)
|
||||
- +0.2 if nonce == 0 (brand new wallet)
|
||||
- +0.1 if age < 2 hours (very young)
|
||||
- +0.1 if trade size > $10,000 (large trade)
|
||||
|
||||
Final confidence is clamped to [0.0, 1.0].
|
||||
|
||||
Args:
|
||||
profile: Wallet profile with nonce and age data.
|
||||
trade: Trade event with size data.
|
||||
|
||||
Returns:
|
||||
Tuple of (confidence_score, factors_dict).
|
||||
"""
|
||||
factors: dict[str, float] = {"base": BASE_CONFIDENCE}
|
||||
confidence = BASE_CONFIDENCE
|
||||
|
||||
# Brand new wallet bonus
|
||||
if profile.nonce == 0:
|
||||
factors["brand_new"] = BRAND_NEW_BONUS
|
||||
confidence += BRAND_NEW_BONUS
|
||||
|
||||
# Very young wallet bonus
|
||||
if profile.age_hours is not None and profile.age_hours < 2.0:
|
||||
factors["very_young"] = VERY_YOUNG_BONUS
|
||||
confidence += VERY_YOUNG_BONUS
|
||||
|
||||
# Large trade bonus
|
||||
if trade.notional_value > LARGE_TRADE_THRESHOLD:
|
||||
factors["large_trade"] = LARGE_TRADE_BONUS
|
||||
confidence += LARGE_TRADE_BONUS
|
||||
|
||||
# Clamp to valid range
|
||||
confidence = max(0.0, min(1.0, confidence))
|
||||
|
||||
return confidence, factors
|
||||
|
||||
async def analyze_batch(
|
||||
self,
|
||||
trades: list[TradeEvent],
|
||||
) -> list[FreshWalletSignal]:
|
||||
"""Analyze multiple trades for fresh wallet signals.
|
||||
|
||||
Processes trades in parallel for efficiency.
|
||||
|
||||
Args:
|
||||
trades: List of trades to analyze.
|
||||
|
||||
Returns:
|
||||
List of FreshWalletSignal for trades from fresh wallets.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
tasks = [self.analyze(trade) for trade in trades]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
signals: list[FreshWalletSignal] = []
|
||||
for trade, result in zip(trades, results, strict=True):
|
||||
if isinstance(result, BaseException):
|
||||
logger.warning(
|
||||
"Failed to analyze trade %s: %s",
|
||||
trade.trade_id,
|
||||
result,
|
||||
)
|
||||
continue
|
||||
if result is not None:
|
||||
signals.append(result)
|
||||
|
||||
return signals
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Data models for the detector module."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from polymarket_insider_tracker.ingestor.models import TradeEvent
|
||||
from polymarket_insider_tracker.profiler.models import WalletProfile
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FreshWalletSignal:
|
||||
"""Signal emitted when a fresh wallet makes a suspicious trade.
|
||||
|
||||
This signal combines trade event data with wallet profile analysis
|
||||
to produce a confidence score indicating the likelihood of suspicious
|
||||
activity.
|
||||
|
||||
Attributes:
|
||||
trade_event: The original trade event that triggered this signal.
|
||||
wallet_profile: Analyzed profile of the trader's wallet.
|
||||
confidence: Overall confidence score (0.0 to 1.0).
|
||||
factors: Individual factor scores contributing to confidence.
|
||||
timestamp: When this signal was generated.
|
||||
"""
|
||||
|
||||
trade_event: TradeEvent
|
||||
wallet_profile: WalletProfile
|
||||
confidence: float
|
||||
factors: dict[str, float]
|
||||
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
@property
|
||||
def wallet_address(self) -> str:
|
||||
"""Return the wallet address from the trade event."""
|
||||
return self.trade_event.wallet_address
|
||||
|
||||
@property
|
||||
def market_id(self) -> str:
|
||||
"""Return the market ID from the trade event."""
|
||||
return self.trade_event.market_id
|
||||
|
||||
@property
|
||||
def trade_size_usdc(self) -> Decimal:
|
||||
"""Return the trade size in USDC (notional value)."""
|
||||
return self.trade_event.notional_value
|
||||
|
||||
@property
|
||||
def is_high_confidence(self) -> bool:
|
||||
"""Return True if confidence exceeds 0.7."""
|
||||
return self.confidence >= 0.7
|
||||
|
||||
@property
|
||||
def is_very_high_confidence(self) -> bool:
|
||||
"""Return True if confidence exceeds 0.85."""
|
||||
return self.confidence >= 0.85
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
"""Serialize to dictionary for Redis stream publishing."""
|
||||
return {
|
||||
"wallet_address": self.wallet_address,
|
||||
"market_id": self.market_id,
|
||||
"trade_id": self.trade_event.trade_id,
|
||||
"trade_size": str(self.trade_size_usdc),
|
||||
"trade_side": self.trade_event.side,
|
||||
"trade_price": str(self.trade_event.price),
|
||||
"wallet_nonce": self.wallet_profile.nonce,
|
||||
"wallet_age_hours": self.wallet_profile.age_hours,
|
||||
"wallet_is_fresh": self.wallet_profile.is_fresh,
|
||||
"confidence": self.confidence,
|
||||
"factors": self.factors,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
"""Tests for the fresh wallet detector."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from polymarket_insider_tracker.detector.fresh_wallet import (
|
||||
BASE_CONFIDENCE,
|
||||
BRAND_NEW_BONUS,
|
||||
DEFAULT_MAX_AGE_HOURS,
|
||||
DEFAULT_MAX_NONCE,
|
||||
DEFAULT_MIN_TRADE_SIZE,
|
||||
LARGE_TRADE_BONUS,
|
||||
VERY_YOUNG_BONUS,
|
||||
FreshWalletDetector,
|
||||
)
|
||||
from polymarket_insider_tracker.detector.models import FreshWalletSignal
|
||||
from polymarket_insider_tracker.ingestor.models import TradeEvent
|
||||
from polymarket_insider_tracker.profiler.models import WalletProfile
|
||||
|
||||
|
||||
# Test fixtures
|
||||
@pytest.fixture
|
||||
def mock_wallet_analyzer():
|
||||
"""Create a mock WalletAnalyzer."""
|
||||
return AsyncMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def detector(mock_wallet_analyzer):
|
||||
"""Create a FreshWalletDetector with mocked analyzer."""
|
||||
return FreshWalletDetector(mock_wallet_analyzer)
|
||||
|
||||
|
||||
def create_trade_event(
|
||||
*,
|
||||
wallet_address: str = "0x1234567890123456789012345678901234567890",
|
||||
market_id: str = "market123",
|
||||
trade_id: str = "trade123",
|
||||
price: Decimal = Decimal("0.5"),
|
||||
size: Decimal = Decimal("2000"),
|
||||
side: str = "BUY",
|
||||
) -> TradeEvent:
|
||||
"""Create a TradeEvent for testing."""
|
||||
return TradeEvent(
|
||||
market_id=market_id,
|
||||
trade_id=trade_id,
|
||||
wallet_address=wallet_address,
|
||||
side=side,
|
||||
outcome="Yes",
|
||||
outcome_index=0,
|
||||
price=price,
|
||||
size=size,
|
||||
timestamp=datetime.now(UTC),
|
||||
asset_id="asset123",
|
||||
)
|
||||
|
||||
|
||||
def create_wallet_profile(
|
||||
*,
|
||||
address: str = "0x1234567890123456789012345678901234567890",
|
||||
nonce: int = 2,
|
||||
age_hours: float | None = 24.0,
|
||||
is_fresh: bool = True,
|
||||
) -> WalletProfile:
|
||||
"""Create a WalletProfile for testing."""
|
||||
first_seen = None
|
||||
if age_hours is not None:
|
||||
first_seen = datetime.now(UTC) - timedelta(hours=age_hours)
|
||||
|
||||
return WalletProfile(
|
||||
address=address.lower(),
|
||||
nonce=nonce,
|
||||
first_seen=first_seen,
|
||||
age_hours=age_hours,
|
||||
is_fresh=is_fresh,
|
||||
total_tx_count=nonce,
|
||||
matic_balance=Decimal("1000000000000000000"), # 1 MATIC
|
||||
usdc_balance=Decimal("1000000000"), # 1000 USDC
|
||||
fresh_threshold=5,
|
||||
)
|
||||
|
||||
|
||||
# === Test FreshWalletSignal Model ===
|
||||
|
||||
|
||||
class TestFreshWalletSignal:
|
||||
"""Tests for FreshWalletSignal dataclass."""
|
||||
|
||||
def test_wallet_address_property(self):
|
||||
"""Test wallet_address property returns trade wallet."""
|
||||
trade = create_trade_event(wallet_address="0xabc123")
|
||||
profile = create_wallet_profile(address="0xabc123")
|
||||
signal = FreshWalletSignal(
|
||||
trade_event=trade,
|
||||
wallet_profile=profile,
|
||||
confidence=0.7,
|
||||
factors={"base": 0.5},
|
||||
)
|
||||
assert signal.wallet_address == "0xabc123"
|
||||
|
||||
def test_market_id_property(self):
|
||||
"""Test market_id property returns trade market."""
|
||||
trade = create_trade_event(market_id="market456")
|
||||
profile = create_wallet_profile()
|
||||
signal = FreshWalletSignal(
|
||||
trade_event=trade,
|
||||
wallet_profile=profile,
|
||||
confidence=0.7,
|
||||
factors={"base": 0.5},
|
||||
)
|
||||
assert signal.market_id == "market456"
|
||||
|
||||
def test_trade_size_usdc_property(self):
|
||||
"""Test trade_size_usdc returns notional value."""
|
||||
trade = create_trade_event(price=Decimal("0.5"), size=Decimal("2000"))
|
||||
profile = create_wallet_profile()
|
||||
signal = FreshWalletSignal(
|
||||
trade_event=trade,
|
||||
wallet_profile=profile,
|
||||
confidence=0.7,
|
||||
factors={"base": 0.5},
|
||||
)
|
||||
assert signal.trade_size_usdc == Decimal("1000")
|
||||
|
||||
def test_is_high_confidence(self):
|
||||
"""Test is_high_confidence threshold."""
|
||||
trade = create_trade_event()
|
||||
profile = create_wallet_profile()
|
||||
|
||||
# At threshold
|
||||
signal = FreshWalletSignal(
|
||||
trade_event=trade,
|
||||
wallet_profile=profile,
|
||||
confidence=0.7,
|
||||
factors={},
|
||||
)
|
||||
assert signal.is_high_confidence is True
|
||||
|
||||
# Below threshold
|
||||
signal_low = FreshWalletSignal(
|
||||
trade_event=trade,
|
||||
wallet_profile=profile,
|
||||
confidence=0.69,
|
||||
factors={},
|
||||
)
|
||||
assert signal_low.is_high_confidence is False
|
||||
|
||||
def test_is_very_high_confidence(self):
|
||||
"""Test is_very_high_confidence threshold."""
|
||||
trade = create_trade_event()
|
||||
profile = create_wallet_profile()
|
||||
|
||||
# At threshold
|
||||
signal = FreshWalletSignal(
|
||||
trade_event=trade,
|
||||
wallet_profile=profile,
|
||||
confidence=0.85,
|
||||
factors={},
|
||||
)
|
||||
assert signal.is_very_high_confidence is True
|
||||
|
||||
# Below threshold
|
||||
signal_low = FreshWalletSignal(
|
||||
trade_event=trade,
|
||||
wallet_profile=profile,
|
||||
confidence=0.84,
|
||||
factors={},
|
||||
)
|
||||
assert signal_low.is_very_high_confidence is False
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test serialization to dictionary."""
|
||||
trade = create_trade_event(
|
||||
wallet_address="0xtest",
|
||||
market_id="market1",
|
||||
trade_id="tx1",
|
||||
price=Decimal("0.6"),
|
||||
size=Decimal("5000"),
|
||||
side="BUY",
|
||||
)
|
||||
profile = create_wallet_profile(address="0xtest", nonce=0, age_hours=1.0)
|
||||
signal = FreshWalletSignal(
|
||||
trade_event=trade,
|
||||
wallet_profile=profile,
|
||||
confidence=0.8,
|
||||
factors={"base": 0.5, "brand_new": 0.2},
|
||||
)
|
||||
|
||||
result = signal.to_dict()
|
||||
|
||||
assert result["wallet_address"] == "0xtest"
|
||||
assert result["market_id"] == "market1"
|
||||
assert result["trade_id"] == "tx1"
|
||||
assert result["trade_size"] == "3000.0"
|
||||
assert result["trade_side"] == "BUY"
|
||||
assert result["trade_price"] == "0.6"
|
||||
assert result["wallet_nonce"] == 0
|
||||
assert result["wallet_age_hours"] == 1.0
|
||||
assert result["wallet_is_fresh"] is True
|
||||
assert result["confidence"] == 0.8
|
||||
assert result["factors"] == {"base": 0.5, "brand_new": 0.2}
|
||||
assert "timestamp" in result
|
||||
|
||||
|
||||
# === Test FreshWalletDetector Initialization ===
|
||||
|
||||
|
||||
class TestFreshWalletDetectorInit:
|
||||
"""Tests for FreshWalletDetector initialization."""
|
||||
|
||||
def test_default_config(self, mock_wallet_analyzer):
|
||||
"""Test detector initializes with default config."""
|
||||
detector = FreshWalletDetector(mock_wallet_analyzer)
|
||||
assert detector._min_trade_size == DEFAULT_MIN_TRADE_SIZE
|
||||
assert detector._max_nonce == DEFAULT_MAX_NONCE
|
||||
assert detector._max_age_hours == DEFAULT_MAX_AGE_HOURS
|
||||
|
||||
def test_custom_config(self, mock_wallet_analyzer):
|
||||
"""Test detector accepts custom config."""
|
||||
detector = FreshWalletDetector(
|
||||
mock_wallet_analyzer,
|
||||
min_trade_size=Decimal("5000"),
|
||||
max_nonce=10,
|
||||
max_age_hours=72.0,
|
||||
)
|
||||
assert detector._min_trade_size == Decimal("5000")
|
||||
assert detector._max_nonce == 10
|
||||
assert detector._max_age_hours == 72.0
|
||||
|
||||
|
||||
# === Test FreshWalletDetector.analyze ===
|
||||
|
||||
|
||||
class TestFreshWalletDetectorAnalyze:
|
||||
"""Tests for FreshWalletDetector.analyze method."""
|
||||
|
||||
async def test_filters_small_trades(self, detector, mock_wallet_analyzer):
|
||||
"""Test that trades below minimum size are filtered out."""
|
||||
trade = create_trade_event(
|
||||
price=Decimal("0.1"),
|
||||
size=Decimal("100"), # notional = $10
|
||||
)
|
||||
|
||||
result = await detector.analyze(trade)
|
||||
|
||||
assert result is None
|
||||
mock_wallet_analyzer.analyze.assert_not_called()
|
||||
|
||||
async def test_detects_fresh_wallet(self, detector, mock_wallet_analyzer):
|
||||
"""Test detection of fresh wallet trade."""
|
||||
trade = create_trade_event(
|
||||
price=Decimal("0.5"),
|
||||
size=Decimal("2000"), # notional = $1000
|
||||
)
|
||||
profile = create_wallet_profile(nonce=2, age_hours=24.0, is_fresh=True)
|
||||
mock_wallet_analyzer.analyze.return_value = profile
|
||||
|
||||
result = await detector.analyze(trade)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, FreshWalletSignal)
|
||||
assert result.trade_event == trade
|
||||
assert result.wallet_profile == profile
|
||||
assert result.confidence >= BASE_CONFIDENCE
|
||||
|
||||
async def test_filters_non_fresh_wallet(self, detector, mock_wallet_analyzer):
|
||||
"""Test that non-fresh wallets are filtered out."""
|
||||
trade = create_trade_event(
|
||||
price=Decimal("0.5"),
|
||||
size=Decimal("4000"), # notional = $2000
|
||||
)
|
||||
profile = create_wallet_profile(nonce=10, age_hours=100.0, is_fresh=False)
|
||||
mock_wallet_analyzer.analyze.return_value = profile
|
||||
|
||||
result = await detector.analyze(trade)
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_handles_analyzer_error(self, detector, mock_wallet_analyzer):
|
||||
"""Test graceful handling of analyzer errors."""
|
||||
trade = create_trade_event()
|
||||
mock_wallet_analyzer.analyze.side_effect = Exception("RPC error")
|
||||
|
||||
result = await detector.analyze(trade)
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_wallet_at_nonce_threshold(self, detector, mock_wallet_analyzer):
|
||||
"""Test wallet exactly at max_nonce threshold."""
|
||||
trade = create_trade_event(size=Decimal("3000"))
|
||||
profile = create_wallet_profile(nonce=5, age_hours=24.0) # At threshold
|
||||
mock_wallet_analyzer.analyze.return_value = profile
|
||||
|
||||
result = await detector.analyze(trade)
|
||||
|
||||
assert result is not None
|
||||
|
||||
async def test_wallet_above_nonce_threshold(self, detector, mock_wallet_analyzer):
|
||||
"""Test wallet above max_nonce threshold."""
|
||||
trade = create_trade_event(size=Decimal("3000"))
|
||||
profile = create_wallet_profile(nonce=6, age_hours=24.0) # Above threshold
|
||||
mock_wallet_analyzer.analyze.return_value = profile
|
||||
|
||||
result = await detector.analyze(trade)
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_wallet_at_age_threshold(self, detector, mock_wallet_analyzer):
|
||||
"""Test wallet exactly at max_age_hours threshold."""
|
||||
trade = create_trade_event(size=Decimal("3000"))
|
||||
profile = create_wallet_profile(nonce=2, age_hours=48.0) # At threshold
|
||||
mock_wallet_analyzer.analyze.return_value = profile
|
||||
|
||||
result = await detector.analyze(trade)
|
||||
|
||||
assert result is not None
|
||||
|
||||
async def test_wallet_above_age_threshold(self, detector, mock_wallet_analyzer):
|
||||
"""Test wallet above max_age_hours threshold."""
|
||||
trade = create_trade_event(size=Decimal("3000"))
|
||||
profile = create_wallet_profile(nonce=2, age_hours=49.0) # Above threshold
|
||||
mock_wallet_analyzer.analyze.return_value = profile
|
||||
|
||||
result = await detector.analyze(trade)
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_wallet_with_unknown_age(self, detector, mock_wallet_analyzer):
|
||||
"""Test wallet with unknown age (None)."""
|
||||
trade = create_trade_event(size=Decimal("3000"))
|
||||
profile = create_wallet_profile(nonce=2, age_hours=None)
|
||||
mock_wallet_analyzer.analyze.return_value = profile
|
||||
|
||||
result = await detector.analyze(trade)
|
||||
|
||||
# Should pass since age is unknown
|
||||
assert result is not None
|
||||
|
||||
|
||||
# === Test Confidence Scoring ===
|
||||
|
||||
|
||||
class TestConfidenceScoring:
|
||||
"""Tests for confidence score calculation."""
|
||||
|
||||
def test_base_confidence_only(self, detector):
|
||||
"""Test base confidence for fresh wallet."""
|
||||
profile = create_wallet_profile(nonce=3, age_hours=24.0)
|
||||
trade = create_trade_event(size=Decimal("2000")) # $1000 notional
|
||||
|
||||
confidence, factors = detector.calculate_confidence(profile, trade)
|
||||
|
||||
assert confidence == BASE_CONFIDENCE
|
||||
assert factors == {"base": BASE_CONFIDENCE}
|
||||
|
||||
def test_brand_new_bonus(self, detector):
|
||||
"""Test bonus for brand new wallet (nonce=0)."""
|
||||
profile = create_wallet_profile(nonce=0, age_hours=24.0)
|
||||
trade = create_trade_event(size=Decimal("2000"))
|
||||
|
||||
confidence, factors = detector.calculate_confidence(profile, trade)
|
||||
|
||||
expected = BASE_CONFIDENCE + BRAND_NEW_BONUS
|
||||
assert confidence == expected
|
||||
assert factors["brand_new"] == BRAND_NEW_BONUS
|
||||
|
||||
def test_very_young_bonus(self, detector):
|
||||
"""Test bonus for very young wallet (age < 2 hours)."""
|
||||
profile = create_wallet_profile(nonce=3, age_hours=1.5)
|
||||
trade = create_trade_event(size=Decimal("2000"))
|
||||
|
||||
confidence, factors = detector.calculate_confidence(profile, trade)
|
||||
|
||||
expected = BASE_CONFIDENCE + VERY_YOUNG_BONUS
|
||||
assert confidence == expected
|
||||
assert factors["very_young"] == VERY_YOUNG_BONUS
|
||||
|
||||
def test_large_trade_bonus(self, detector):
|
||||
"""Test bonus for large trade (> $10,000)."""
|
||||
profile = create_wallet_profile(nonce=3, age_hours=24.0)
|
||||
trade = create_trade_event(
|
||||
price=Decimal("0.5"),
|
||||
size=Decimal("25000"), # $12,500 notional
|
||||
)
|
||||
|
||||
confidence, factors = detector.calculate_confidence(profile, trade)
|
||||
|
||||
expected = BASE_CONFIDENCE + LARGE_TRADE_BONUS
|
||||
assert confidence == expected
|
||||
assert factors["large_trade"] == LARGE_TRADE_BONUS
|
||||
|
||||
def test_all_bonuses_combined(self, detector):
|
||||
"""Test all bonuses stacking."""
|
||||
profile = create_wallet_profile(nonce=0, age_hours=0.5)
|
||||
trade = create_trade_event(
|
||||
price=Decimal("0.5"),
|
||||
size=Decimal("30000"), # $15,000 notional
|
||||
)
|
||||
|
||||
confidence, factors = detector.calculate_confidence(profile, trade)
|
||||
|
||||
expected = BASE_CONFIDENCE + BRAND_NEW_BONUS + VERY_YOUNG_BONUS + LARGE_TRADE_BONUS
|
||||
assert confidence == expected
|
||||
assert factors["brand_new"] == BRAND_NEW_BONUS
|
||||
assert factors["very_young"] == VERY_YOUNG_BONUS
|
||||
assert factors["large_trade"] == LARGE_TRADE_BONUS
|
||||
|
||||
def test_confidence_clamped_to_max(self, detector):
|
||||
"""Test confidence is clamped to 1.0 max."""
|
||||
# Create scenario where total would exceed 1.0
|
||||
# BASE=0.5 + BRAND_NEW=0.2 + VERY_YOUNG=0.1 + LARGE_TRADE=0.1 = 0.9
|
||||
# This is less than 1.0, so let's verify clamping works
|
||||
profile = create_wallet_profile(nonce=0, age_hours=0.5)
|
||||
trade = create_trade_event(size=Decimal("30000"))
|
||||
|
||||
confidence, _ = detector.calculate_confidence(profile, trade)
|
||||
|
||||
assert confidence <= 1.0
|
||||
|
||||
def test_no_bonus_at_age_boundary(self, detector):
|
||||
"""Test no bonus when age exactly at 2 hours."""
|
||||
profile = create_wallet_profile(nonce=3, age_hours=2.0)
|
||||
trade = create_trade_event(size=Decimal("2000"))
|
||||
|
||||
confidence, factors = detector.calculate_confidence(profile, trade)
|
||||
|
||||
assert "very_young" not in factors
|
||||
assert confidence == BASE_CONFIDENCE
|
||||
|
||||
def test_no_bonus_at_trade_size_boundary(self, detector):
|
||||
"""Test no bonus when trade exactly at $10,000."""
|
||||
profile = create_wallet_profile(nonce=3, age_hours=24.0)
|
||||
trade = create_trade_event(
|
||||
price=Decimal("0.5"),
|
||||
size=Decimal("20000"), # $10,000 notional exactly
|
||||
)
|
||||
|
||||
confidence, factors = detector.calculate_confidence(profile, trade)
|
||||
|
||||
# Exactly at threshold should NOT get bonus
|
||||
assert "large_trade" not in factors
|
||||
assert confidence == BASE_CONFIDENCE
|
||||
|
||||
def test_unknown_age_no_young_bonus(self, detector):
|
||||
"""Test no young bonus when age is None."""
|
||||
profile = create_wallet_profile(nonce=3, age_hours=None)
|
||||
trade = create_trade_event(size=Decimal("2000"))
|
||||
|
||||
confidence, factors = detector.calculate_confidence(profile, trade)
|
||||
|
||||
assert "very_young" not in factors
|
||||
assert confidence == BASE_CONFIDENCE
|
||||
|
||||
|
||||
# === Test Batch Analysis ===
|
||||
|
||||
|
||||
class TestBatchAnalysis:
|
||||
"""Tests for batch trade analysis."""
|
||||
|
||||
async def test_analyze_batch_success(self, detector, mock_wallet_analyzer):
|
||||
"""Test analyzing multiple trades."""
|
||||
trades = [
|
||||
create_trade_event(trade_id="trade1", size=Decimal("3000")),
|
||||
create_trade_event(trade_id="trade2", size=Decimal("4000")),
|
||||
]
|
||||
profile = create_wallet_profile(nonce=2, age_hours=24.0)
|
||||
mock_wallet_analyzer.analyze.return_value = profile
|
||||
|
||||
results = await detector.analyze_batch(trades)
|
||||
|
||||
assert len(results) == 2
|
||||
assert all(isinstance(r, FreshWalletSignal) for r in results)
|
||||
|
||||
async def test_analyze_batch_filters_small(self, detector, mock_wallet_analyzer):
|
||||
"""Test batch analysis filters small trades."""
|
||||
trades = [
|
||||
create_trade_event(trade_id="trade1", size=Decimal("3000")), # Above threshold
|
||||
create_trade_event(trade_id="trade2", size=Decimal("100")), # Below threshold
|
||||
]
|
||||
profile = create_wallet_profile(nonce=2, age_hours=24.0)
|
||||
mock_wallet_analyzer.analyze.return_value = profile
|
||||
|
||||
results = await detector.analyze_batch(trades)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].trade_event.trade_id == "trade1"
|
||||
|
||||
async def test_analyze_batch_handles_errors(self, detector, mock_wallet_analyzer):
|
||||
"""Test batch analysis handles individual errors gracefully."""
|
||||
trades = [
|
||||
create_trade_event(trade_id="trade1", size=Decimal("3000")),
|
||||
create_trade_event(trade_id="trade2", size=Decimal("4000")),
|
||||
]
|
||||
|
||||
# First call succeeds, second fails
|
||||
profile = create_wallet_profile(nonce=2, age_hours=24.0)
|
||||
mock_wallet_analyzer.analyze.side_effect = [profile, Exception("Error")]
|
||||
|
||||
results = await detector.analyze_batch(trades)
|
||||
|
||||
assert len(results) == 1
|
||||
|
||||
async def test_analyze_batch_empty_list(self, detector):
|
||||
"""Test batch analysis with empty list."""
|
||||
results = await detector.analyze_batch([])
|
||||
|
||||
assert results == []
|
||||
|
||||
async def test_analyze_batch_all_filtered(self, detector, mock_wallet_analyzer):
|
||||
"""Test batch analysis when all trades are filtered."""
|
||||
trades = [
|
||||
create_trade_event(trade_id="trade1", size=Decimal("100")),
|
||||
create_trade_event(trade_id="trade2", size=Decimal("50")),
|
||||
]
|
||||
|
||||
results = await detector.analyze_batch(trades)
|
||||
|
||||
assert results == []
|
||||
mock_wallet_analyzer.analyze.assert_not_called()
|
||||
|
||||
|
||||
# === Integration-Style Tests ===
|
||||
|
||||
|
||||
class TestDetectorIntegration:
|
||||
"""Integration-style tests for complete detector flow."""
|
||||
|
||||
async def test_full_detection_flow(self, mock_wallet_analyzer):
|
||||
"""Test complete detection flow from trade to signal."""
|
||||
detector = FreshWalletDetector(mock_wallet_analyzer)
|
||||
|
||||
# Create a suspicious trade
|
||||
trade = create_trade_event(
|
||||
wallet_address="0xfresh123",
|
||||
market_id="suspicious_market",
|
||||
price=Decimal("0.65"),
|
||||
size=Decimal("20000"), # $13,000 notional
|
||||
)
|
||||
|
||||
# Create a fresh wallet profile
|
||||
profile = create_wallet_profile(
|
||||
address="0xfresh123",
|
||||
nonce=0, # Brand new
|
||||
age_hours=0.5, # Very young
|
||||
)
|
||||
mock_wallet_analyzer.analyze.return_value = profile
|
||||
|
||||
# Analyze
|
||||
signal = await detector.analyze(trade)
|
||||
|
||||
# Verify signal
|
||||
assert signal is not None
|
||||
assert signal.wallet_address == "0xfresh123"
|
||||
assert signal.market_id == "suspicious_market"
|
||||
assert signal.is_high_confidence is True
|
||||
assert "brand_new" in signal.factors
|
||||
assert "very_young" in signal.factors
|
||||
assert "large_trade" in signal.factors
|
||||
|
||||
async def test_custom_thresholds(self, mock_wallet_analyzer):
|
||||
"""Test detection with custom thresholds."""
|
||||
detector = FreshWalletDetector(
|
||||
mock_wallet_analyzer,
|
||||
min_trade_size=Decimal("5000"),
|
||||
max_nonce=3,
|
||||
max_age_hours=24.0,
|
||||
)
|
||||
|
||||
# Trade that would pass default but fails custom thresholds
|
||||
trade = create_trade_event(size=Decimal("8000")) # $4000 notional
|
||||
profile = create_wallet_profile(nonce=4, age_hours=30.0)
|
||||
mock_wallet_analyzer.analyze.return_value = profile
|
||||
|
||||
# Should fail min_trade_size check
|
||||
result = await detector.analyze(trade)
|
||||
assert result is None
|
||||
|
||||
async def test_edge_case_exact_min_trade_size(self, detector, mock_wallet_analyzer):
|
||||
"""Test trade exactly at minimum size threshold."""
|
||||
trade = create_trade_event(
|
||||
price=Decimal("0.5"),
|
||||
size=Decimal("2000"), # $1000 notional exactly at default
|
||||
)
|
||||
profile = create_wallet_profile(nonce=2)
|
||||
mock_wallet_analyzer.analyze.return_value = profile
|
||||
|
||||
result = await detector.analyze(trade)
|
||||
|
||||
# At threshold should pass
|
||||
assert result is not None
|
||||
|
||||
async def test_edge_case_below_min_trade_size(self, detector, mock_wallet_analyzer):
|
||||
"""Test trade just below minimum size threshold."""
|
||||
trade = create_trade_event(
|
||||
price=Decimal("0.5"),
|
||||
size=Decimal("1999"), # $999.50 - just under $1000
|
||||
)
|
||||
profile = create_wallet_profile(nonce=2)
|
||||
mock_wallet_analyzer.analyze.return_value = profile
|
||||
|
||||
result = await detector.analyze(trade)
|
||||
|
||||
# Below threshold should fail
|
||||
assert result is None
|
||||
Reference in New Issue
Block a user