Merge pull request #36 from pselamy/fix/14

feat: implement fresh wallet detection algorithm (#14)
This commit is contained in:
Patrick Selamy
2026-01-04 15:51:03 -05:00
committed by GitHub
4 changed files with 924 additions and 0 deletions
@@ -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(),
}
+608
View File
@@ -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