Files
polymarket-insider-tracker/tests/detector/test_fresh_wallet.py
T

609 lines
21 KiB
Python
Raw Normal View History

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