Files
polymarket-insider-tracker/tests/detector/test_fresh_wallet.py
T
Patrick Selamy 78243a6fb8 feat: implement fresh wallet detection algorithm (#14)
Add FreshWalletDetector class that identifies trades from fresh wallets
and generates alert signals with confidence scores.

Features:
- FreshWalletSignal dataclass with trade/wallet data and confidence
- Configurable thresholds for nonce, age, and minimum trade size
- Confidence scoring based on wallet freshness and trade characteristics
- Batch analysis support with parallel processing
- Integration with WalletAnalyzer from profiler module

Confidence scoring:
- Base: 0.5 (fresh wallet detected)
- +0.2 if nonce == 0 (brand new)
- +0.1 if age < 2 hours (very young)
- +0.1 if trade size > $10,000 (large trade)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 15:49:52 -05:00

609 lines
21 KiB
Python

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